Note on "Ray tracing in one week" - Part 5

Note on "Ray tracing in one week" - Part 5

Ayano Kagurazaka Lv3

Note on “Ray tracing in one week” - Part 5

In last part we did the diffusion of ray from the surface, but it’s far from realistic. In this part, we will add some more realistic features to the ray tracing.

Lambertian reflection

The Lambertian reflection is a model for diffuse reflection. It is more accurate than the previous model. The previous model was just scattering the ray in random direction, but the Lambertian reflection model scatters the ray in a direction that is proportional to the cosine of the angle between the incoming ray and the normal of the surface, which is reasonable because the reflection is more likely to be in the direction of the normal.

lambertian

In this graphic, we have CPCP the incident ray, PNPN, the normalized normal of the surface, and the pink lines showing the magnitude of probability of choosing this direction to reflect. We can see when the angle between the incident ray and the normal is small, the reflection is more likely to be in the direction of the normal.

The implementation is very simple. We pick the normalized normal vector of the surface and add a random unit vector to it (blue vectors in the graphic). And here is the code:

icon-padding

GraphicObjects.cpp

1
2
3
4
5
6
7
8
9
10
11
12
ImageUtil::Color Camera::rayColor(const MathUtil::Ray &ray, const IHittable &object, int depth) {
HitRecord record;
if(depth <= 0) return {0, 0, 0};
if (object.hit(ray, MathUtil::Interval(EPS, INF), record)) {
auto refl = std::make_shared<MathUtil::Vec3>(record.normal + MathUtil::Vec3::randomUnitVec3());
return 0.5 * rayColor(MathUtil::Ray(record.p, *refl), object, depth - 1);
}
else {
auto unit = ray.dir().unit();
return ImageUtil::Color(std::min(0.5 * unit.x + 1, 1.0), std::min(0.5 * unit.y + 1, 1.0), 1);
}
}

We apply this model to the ray that hits the surface: add a random unit vector to the normal of the surface. The result is a more realistic image:

icon-padding

original:

original

icon-padding

with lambertian reflection:

lambertian

We can see the second image has darker shadow at contact position, and the ball is reflecting more light than the first image.

Gamma correction

If we use a color picker to check the surface color of the two sphere, we will see that the color is not exactly what we expected. My check yields 100, 107, 110, which is approximately half way, but this should be more half way because the intensity decrease by one half each time it reflects, yet we have a bright environment.

This will occur because there is a space called gamma space, which the image viewer assume the image is in. However, recall our image generator, which the color intensity increases linearly as each color increases linearly. This is called linear space. The difference between these two space is the gamma correction, a mapping from linear space to gamma space. In this case we will use gamma = 1/2.2.

The code is simple, we just need to apply the gamma correction to the color before we write it to the image file:

icon-padding

ImageUtil.h

1
double gammaCorrect(double c);

icon-padding

ImageUtil.cpp

1
2
3
double gammaCorrect(double c) {
return std::pow(c, 1.0 / 2.2);
}

icon-padding

GraphicObjects.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
std::vector<std::vector<ImageUtil::Color>> Camera::RenderWorker(const IHittable &world, int start, int end) {
auto img = std::vector<std::vector<ImageUtil::Color>>();
std::stringstream ss;
ss << std::this_thread::get_id();
spdlog::info("thread {} (from {} to {}) started!", ss.str(), start, end - 1);
auto range = end - start;
auto begin = std::chrono::system_clock::now();
for (int i = start; i < end; ++i) {
auto v = std::vector<ImageUtil::Color>();
if (i % (range / 3) == 0) {
spdlog::info("line remaining for thread {}: {}", ss.str(), (range - (i - start) + 1));
}
for (int j = 0; j < getWidth(); ++j) {
ImageUtil::Color pixel_color = {0, 0, 0};
for (int k = 0; k < sample_count; ++k) {
auto ray_dir = getPixRayDir(j, i);
auto ray = MathUtil::Ray(getPosition(), ray_dir);
pixel_color += rayColor(ray, world, render_depth);
}
pixel_color /= sample_count;
pixel_color = {ImageUtil::gammaCorrect(pixel_color.x), ImageUtil::gammaCorrect(pixel_color.y), ImageUtil::gammaCorrect(pixel_color.z)};
v.emplace_back(pixel_color);
}
img.emplace_back(v);
}
auto finish = std::chrono::system_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(finish - begin);
spdlog::info("thread {} (from {} to {}) finished! time: {}s", ss.str(), start, end - 1, static_cast<double>(elapsed.count()) / 1000.0);
return img;
}

It becomes brighter, and the color picker yielded 169, 175, 177, which is closer to the desired color: above halfway between 0 and 255.

gamma

Materials

Material is how the surface of the object interacts with the light. For example, our current model is a diffuse material, which uses Lambertian reflection. There are other materials, such as metal, glass, and so on. We will implement a metal material in this part.

First, we will implement an interface for materials, a material will determine how much the ray is reflected, and how the ray is reflected. We will use the interface to implement the material:

icon-padding

Material.h

1
2
3
4
5
6
7
8
9
10
11
12
#ifndef ONEWEEKEND_IMATERIAL_H
#define ONEWEEKEND_IMATERIAL_H
#include "MathUtil.h"
#include "GraphicObjects.h"
class IMaterial {
public:
virtual ~IMaterial() = default;

virtual bool scatter(const MathUtil::Ray &r_in, const HitRecord &record, MathUtil::Vec3 &attenuation,
MathUtil::Ray &scattered) const = 0;
};
#endif // ONEWEEKEND_IMATERIAL_H

For each object, we wish to assign a unique material to it, and we will obtain the material from the object then store it inside the hit record:

icon-padding

GraphicObjects.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct HitRecord {
bool hit;
MathUtil::Point3 p;
double t;
MathUtil::Vec3 normal;
std::shared_ptr<IMaterial> material;
bool front_face;
void setFaceNormal(const MathUtil::Ray& r, const MathUtil::Vec3& normal_out);
};

// ...

class Sphere : public IHittable {
public:
Sphere(double radius, MathUtil::Vec3 position, std::shared_ptr<IMaterial> mat);

bool hit(const MathUtil::Ray &r, MathUtil::Interval interval, HitRecord& record) const override;

private:
double radius;
MathUtil::Vec3 position;
std::shared_ptr<IMaterial> material;
};

icon-padding

GraphicObjects.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Sphere::Sphere(double radius, MathUtil::Vec3 position, std::shared_ptr<IMaterial> mat) : radius(radius), position(std::move(position)), material(mat) {
}
bool Sphere::hit(const MathUtil::Ray &r, MathUtil::Interval interval, HitRecord& record) const {
std::shared_ptr<MathUtil::Vec3> oc = std::make_shared<MathUtil::Vec3>(r.pos() - position);
auto a = r.dir().lengthSq();
auto h = oc->dot(r.dir());
auto c = oc->lengthSq() - radius * radius;

auto discriminant = h * h - a * c;
if (discriminant < 0) return false;
auto discri_sqrt = std::sqrt(discriminant);

auto root = (-h - discri_sqrt) / a;
if (!interval.surround(root)) {
root = (-h + discri_sqrt) / a;
if (!interval.surround(root)){
return false;
}
}
record.t = root;
record.p = r.at(root);
auto out_normal = (record.p - position) / radius;
record.setFaceNormal(r, out_normal);
record.material = material;
return true;
}

Then we can rewrite our Lambertian reflection with material:

icon-padding

Materials.h

1
2
3
4
5
6
7
8
9
10
11
class Lambertian : public IMaterial {

Lambertian(const ImageUtil::Color &albedo);

bool scatter(const MathUtil::Ray &r_in, const HitRecord &record, MathUtil::Vec3 &attenuation,
MathUtil::Ray &scattered) const override;

private:
ImageUtil::Color albedo;

};

icon-padding

Materials.cpp

1
2
3
4
5
6
7
bool Lambertian::scatter(const MathUtil::Ray &r_in, const HitRecord &record, MathUtil::Vec3 &attenuation,
MathUtil::Ray &scattered) const {
auto ray_dir = record.normal + MathUtil::Vec3::randomUnitVec3();
scattered = MathUtil::Ray(record.p, ray_dir);
attenuation = albedo;
return true;
}

In this case, our Lambertian material will do the same thing. A material can either choose to absorb the ray, or to diffuse the ray, or do both. In our case, we will only diffuse the ray.

Notice that we calculated ray direction, by adding a random unit vector to the normal of the surface. However, if we are extremely unlucky, the ray direction will be in the opposite direction of the normal. In this case, the result is not good. We can fix this by checking whether the magnitude of the ray direction is very small, and if it is, we will just use the normal of the surface as the ray direction. This will make the result more realistic.

icon-padding

MathUtil.h

1
bool verySmall() const;

icon-padding

MathUtil.cpp

1
2
3
bool Vec3::verySmall() const {
return (std::abs(x) < EPS) && (std::abs(y) < EPS) && (std::abs(z) < EPS);
}

icon-padding

Materials.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13

class Lambertian : public IMaterial {
public:
Lambertian(const ImageUtil::Color &albedo);

bool scatter(const MathUtil::Ray &r_in, const HitRecord &record, MathUtil::Vec3 &attenuation,
MathUtil::Ray &scattered) const override;

private:
ImageUtil::Color albedo;

};

More materials

The next material we will implement is the metal material. In this case, the metal material will reflect the ray in a direction. We know there is a law called the law of reflection, which states that the angle of incidence is equal to the angle of reflection, illustrated in this graphic:

reflection

With this graph it’s not hard to see that the reflected ray direction vector is:

R=I2H\mathbf{R} = \mathbf{I} - 2 \cdot \mathbf{H}

we know H\mathbf{H} is normal to the surface, and it has same y conponent as I\mathbf{I}, so we can write H\mathbf{H} as:

H=I,NN\textbf{H} = \lang \textbf{I} , \textbf{N}\rang \cdot \textbf{N}

and now we get:

R=I2I,NN\mathbf{R} = \mathbf{I} - 2 \lang \textbf{I} , \textbf{N}\rang \cdot \textbf{N}

we can then implement our material this way:

icon-padding

MathUtil.h

1
2
3
4
5
6
7
8
9
class Vec3 {
//....

bool verySmall() const;

static Vec3 reflect(const Vec3 &v, const Vec3 &n);
//...
}

icon-padding

MathUtil.cpp

1
2
3
Vec3 Vec3::reflect(const Vec3 &v, const Vec3 &n) { 
return v - 2 * v.dot(n) * n;
}

icon-padding

Materials.h

1
2
3
4
5
6
7
8
9
class Metal : public IMaterial {
public:
Metal(const ImageUtil::Color &albedo);

bool scatter(const MathUtil::Ray &r_in, const HitRecord &record, MathUtil::Vec3 &attenuation,
MathUtil::Ray &scattered) const override;
private:
ImageUtil::Color albedo;
};

icon-padding

Materials.cpp

1
2
3
4
5
6
7
8
Metal::Metal(const ImageUtil::Color &albedo) : albedo(albedo) {}
bool Metal::scatter(const MathUtil::Ray &r_in, const HitRecord &record, MathUtil::Vec3 &attenuation,
MathUtil::Ray &scattered) const {
auto ray_dir = MathUtil::Vec3::reflect(r_in.dir().unit(), record.normal);
scattered = MathUtil::Ray(record.p, ray_dir);
attenuation = albedo;
return true;
}

Now we modify our rayColor function to use the material:

icon-padding

GraphicObjects.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ImageUtil::Color Camera::rayColor(const MathUtil::Ray &ray, const IHittable &object, int depth) {
HitRecord record;
if(depth <= 0) return {0, 0, 0};
if (object.hit(ray, MathUtil::Interval(EPS, INF), record)) {
MathUtil::Ray scattered;
ImageUtil::Color attenuation;

if (record.material->scatter(ray, record, attenuation, scattered)) {
return attenuation * rayColor(scattered, object, depth - 1);
}
return {0, 0, 0};
}
else {
auto unit = ray.dir().unit();
return ImageUtil::Color(std::min(0.5 * unit.x + 1, 1.0), std::min(0.5 * unit.y + 1, 1.0), 1);
}
}

Now let’s go to our main function, add some more sphere, and assign them different materials:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "ImageUtil.h"
#include "GraphicObjects.h"
#include "Material.h"

int main() {
auto camera = Camera(1080, 16.0 / 9.0, 2.0, 1, MathUtil::Point3(0, 0, 1));
auto world = HittableList();
auto center_ball_material = std::make_shared<Lambertian>(Lambertian(ImageUtil::Color(0.96, 0.67, 0.73)));
auto right_ball_material = std::make_shared<Metal>(Metal(ImageUtil::Color(0.7, 0.3, 0.3)));
auto left_ball_material = std::make_shared<Metal>(Metal(ImageUtil::Color(0.8, 0.8, 0.8)));
auto ground_material = std::make_shared<Lambertian>(Lambertian(ImageUtil::Color(0.8, 0.6, 0.2)));
world.add(std::make_shared<Sphere>(Sphere(1000, {0, -1000.5, -1.0}, ground_material)));
world.add(std::make_shared<Sphere>(Sphere(0.5, {0, 0, -1}, center_ball_material)));
world.add(std::make_shared<Sphere>(Sphere(0.5, {-1, 0, -1.0}, left_ball_material)));
world.add(std::make_shared<Sphere>(Sphere(0.5, {1, 0, -1.0}, right_ball_material)));
camera.setSampleCount(100);
camera.setRenderDepth(400);
camera.setRenderThreadCount(12);
camera.Render(world, "out", "test.ppm");
return 0;
}

and the result is:

metal

We can see the metal sphere is reflecting the environment, and the ball is reflected by the metal sphere.

Fuzziness

Some metal doesn’t do full reflect, especially unpolished metal. Adding a fuzziness factor to the metal material will make the reflection less sharp. Since for metal, we need to reach fuzziness and reflection at the same time, we can do the following:

  1. Do the reflection as normal
  2. Add a random unit vector to the reflected ray direction, and multiply it by the fuzziness factor to manipulate the diversion of the reflected ray

fuzzy

icon-padding

In this case, the dashed pink vector is the original reflected ray direction, and vector RDRD is the fuzziness vector(with the randomness factor to be 1). The final reflected ray direction is the sum of the two vectors, or the blue vector.

We will add a fuzziness member variable to the Metal material, and modify the scatter function to use it:

icon-padding

Materials.h

1
2
3
4
5
6
7
8
9
10
11
class Metal : public IMaterial {
public:
Metal(const ImageUtil::Color &albedo, double fuzz);

bool scatter(const MathUtil::Ray &r_in, const HitRecord &record, MathUtil::Vec3 &attenuation,
MathUtil::Ray &scattered) const override;

private:
ImageUtil::Color albedo;
double fuzz;
};

icon-padding

Materials.cpp

1
2
3
4
5
6
7
8
Metal::Metal(const ImageUtil::Color &albedo, double fuzz) : albedo(albedo), fuzz(fuzz) {}
bool Metal::scatter(const MathUtil::Ray &r_in, const HitRecord &record, MathUtil::Vec3 &attenuation,
MathUtil::Ray &scattered) const {
auto ray_dir = MathUtil::Vec3::reflect(r_in.dir().unit() + MathUtil::Vec3::randomUnitVec3() * fuzz, record.normal);
scattered = MathUtil::Ray(record.p, ray_dir);
attenuation = albedo;
return true;
}

Now we can modify our main function to add a fuzzy metal sphere:

icon-padding

main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "ImageUtil.h"
#include "GraphicObjects.h"
#include "Material.h"

int main() {
auto camera = Camera(1920, 16.0 / 9.0, 2.0, 1, MathUtil::Point3(0, 0, 1));
auto world = HittableList();
auto center_ball_material = std::make_shared<Lambertian>(Lambertian(ImageUtil::Color(0.96, 0.67, 0.73)));
auto right_ball_material = std::make_shared<Metal>(Metal(ImageUtil::Color(0.7, 0.3, 0.3), 0.4));
auto left_ball_material = std::make_shared<Metal>(Metal(ImageUtil::Color(0.8, 0.8, 0.8), 0.4));
auto ground_material = std::make_shared<Metal>(Metal(ImageUtil::Color(0.8, 0.6, 0.2), 0.4));
world.add(std::make_shared<Sphere>(Sphere(1000, {0, -1000.5, -1.0}, ground_material)));
world.add(std::make_shared<Sphere>(Sphere(0.5, {0, 0, -1}, center_ball_material)));
world.add(std::make_shared<Sphere>(Sphere(0.5, {-1, 0, -1.0}, left_ball_material)));
world.add(std::make_shared<Sphere>(Sphere(0.5, {1, 0, -1.0}, right_ball_material)));
camera.setSampleCount(100);
camera.setRenderDepth(100);
camera.setRenderThreadCount(24);
camera.Render(world, "out", "test.ppm");
return 0;
}

And we can see the result:

fuzzy

Comments