Note on "Ray Tracing The Next Week" - Part 1

Note on "Ray Tracing The Next Week" - Part 1

Ayano Kagurazaka Lv3

The code is compiled with cmake, please check out CMakeLists.txt

Remastering

After I finished the first part of the book, I decided to reorgnize the code and add some features.

Reorganization of file, classes, and namespaces

I removed all the namespaces, put spdlog dependencies into FetchContent, and Camera into a separate file:

Here’s the simplified directory structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.
├── CMakeLists.txt
├── README.md
├── include
│ └── Raytracing
│ ├── Camera.h
│ ├── GlobUtil.hpp
│ ├── GraphicObjects.h
│ ├── ImageUtil.h
│ ├── Material.h
│ └── MathUtil.h
└── src
├── Camera.cpp
├── GraphicObjects.cpp
├── ImageUtil.cpp
├── Material.cpp
├── MathUtil.cpp
└── main.cpp

CMakeLists.txt:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
cmake_minimum_required(VERSION 3.27)
project(Raytracing)
include(FetchContent)

set(CMAKE_CXX_STANDARD 23)

file(GLOB SRC_NORMAL "./src/*.cpp")
file(GLOB SRC_METAL "./src/metal/*.cpp")
set(INCLUDE_SELF "./include/Raytracing/")

FetchContent_Declare(
spdlog
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.x
)
FetchContent_MakeAvailable(spdlog)
list(APPEND INCLUDE_THIRDPARTIES ${spdlog_SOURCE_DIR}/include)
# metal, only for metal version
FetchContent_Declare(
metal
GIT_REPOSITORY https://github.com/bkaradzic/metal-cpp.git
GIT_TAG metal-cpp_macOS14.2_iOS17.2
)
FetchContent_MakeAvailable(metal)
set(METAL ${metal_SOURCE_DIR})


set(INCLUDE_NORMAL ${INCLUDE_SELF} ${INCLUDE_THIRDPARTIES})
set(INCLUDE_METAL ${INCLUDE_SELF} ${INCLUDE_THIRDPARTIES} ${METAL})


add_executable(RaytracingNormal ${SRC_NORMAL})
add_executable(RaytracingMetal ${SRC_METAL})

target_include_directories(RaytracingNormal PUBLIC ${INCLUDE_NORMAL})
target_include_directories(RaytracingMetal PUBLIC ${INCLUDE_METAL})
target_link_libraries(RaytracingMetal
"-framework Metal"
"-framework MetalKit"
"-framework AppKit"
"-framework Foundation"
"-framework QuartzCore"
)
set_target_properties(
RaytracingMetal
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin/metal/debug"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin/metal/release"
)
set_target_properties(
RaytracingNormal
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin/normal/debug"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin/normal/release"
)

icon-padding

Yeah there are metal dependencies. At some point I will do metal, but not now.

Camera.h and Camera.cpp are just extracted from the GraphicObjects.h and GraphicObjects.cpp. The only change is the removal of the namespace.

Better multithreading

Our current multithreading works well, but it has one critical flaw: when one core finished rendering, it stops working and waits for the other cores to finish. This is not efficient, and we can improve it by using a grid of image tiles. Each core will render a tile, and when it finishes, it will pick another tile to render. This way, all cores will be working until the rendering is done.

Distribution of work will be done with a message queue. Initially all the tiles will be pushed into the queue, and each core will continuously fetch a tile from the queue and render it. When the queue is empty, the core will stop working.

For message queue we will use my implementation “KawaiiMQ”, it can be grabbed from my GitHub repository. Here we will fetch it witl FetchContent

icon-padding

CMakeLists.txt

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

//...

FetchContent_Declare(
KawaiiMQ
GIT_REPOSITORY https://github.com/kagurazaka-ayano/KawaiiMq.git
GIT_TAG main
)
FetchContent_MakeAvailable(KawaiiMQ)
list(APPEND INCLUDE_SELF ${KawaiiMQ_SOURCE_DIR}/include)
link_libraries(KawaiiMQ)
//...

Now we define an image chunk struct. Since we will use it in the message queue, it must inherit from KawaiiMQ::MessageData. The struct will contain the start x and y coordinates, the width and height of the chunk, the index of the chunk, and the pixel data. The pixel data will be a 2D vector of Color objects.

icon-padding

ImageUtil.cpp

1
2
3
4
5
6
7
8
struct ImageChunk : public KawaiiMQ::MessageData {
int startx;
int starty;
int chunk_idx;
int width;
int height;
std::vector<std::vector<Color>> partial;
};

Then we will modify our RenderWorker function, define a partition function to divide tasks, some variables, and their getter function:

icon-padding

Camera.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Camera {
public:
//...

int getChunkDimension() const;

void setChunkDimension(int dimension);

private:

//...
void RenderWorker(const IHittable &world);

int partition();

int render_thread_count = std::thread::hardware_concurrency() == 0 ? 12 : std::thread::hardware_concurrency();
int chunk_dimension;
}

icon-padding

Camera.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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
void Camera::Render(const IHittable& world, const std::string& path, const std::string& name) {
auto now = std::chrono::system_clock::now();
int worker_cnt;
if(render_thread_count == 0) {
worker_cnt = std::thread::hardware_concurrency() == 0 ? 12 : std::thread::hardware_concurrency();
}
else {
worker_cnt = render_thread_count;
}
std::vector<std::vector<Color>> image;
image.resize(height);
for(auto& i : image) {
i.resize(width);
}
int part = partition();
auto m = KawaiiMQ::MessageQueueManager::Instance();
auto result_queue = KawaiiMQ::makeQueue("result");
auto result_topic = KawaiiMQ::Topic("renderResult");
m->relate(result_topic, result_queue);
auto th = std::vector<std::thread>();
spdlog::info("rendering started!");
spdlog::info("using {} threads to render {} blocks", worker_cnt, part);
auto begin = std::chrono::system_clock::now();
for(int i = 0; i < worker_cnt; i++) {
th.emplace_back(&Camera::RenderWorker, this, std::ref(world));
}
for(auto& i : th) {
i.join();
}
auto end = std::chrono::system_clock::now();
auto time_elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - begin);
spdlog::info("render completed! taken {}s", static_cast<double>(time_elapsed.count()) / 1000.0);
auto consumer = KawaiiMQ::Consumer("resultConsumer");
consumer.subscribe(result_topic);
while(!result_queue->empty()) {
auto chunk = KawaiiMQ::getMessage<ImageChunk>(consumer.fetchSingleTopic(result_topic)[0]);
for(int i = chunk.starty; i < chunk.starty + chunk.height; i++) {
for(int j = chunk.startx; j < chunk.startx + chunk.width; j++) {
image[i][j] = chunk.partial[i - chunk.starty][j - chunk.startx];
}
}
}
makePPM(width, height, image, path, name);

}

void Camera::RenderWorker(const IHittable &world) {
std::stringstream ss;
ss << std::this_thread::get_id();
auto m = KawaiiMQ::MessageQueueManager::Instance();
auto result_topic = KawaiiMQ::Topic("renderResult");
auto result_producer = KawaiiMQ::Producer("producer");
result_producer.subscribe(result_topic);
auto task_topic = KawaiiMQ::Topic("renderTask");
auto task_fetcher = KawaiiMQ::Consumer({task_topic});
auto task_queue = m->getAllRelatedQueue(task_topic)[0];
int chunk_rendered = 0;
spdlog::info("thread {} started", ss.str());
while (!task_queue->empty()) {
auto chunk = KawaiiMQ::getMessage<ImageChunk>(task_fetcher.fetchSingleTopic(task_topic)[0]);
spdlog::info("chunk {} (start from ({}, {}), dimension {} * {}) started by thread {}", chunk.chunk_idx,
chunk.startx, chunk.starty, chunk.width, chunk.height, ss.str());
for (int i = chunk.starty; i < chunk.starty + chunk.height; i++) {
auto hori = std::vector<Color>();
hori.reserve(chunk.width);
for (int j = chunk.startx; j < chunk.startx + chunk.width; j++) {
Color pixel_color = {0, 0, 0};
for (int k = 0; k < sample_count; ++k) {
auto ray = getRay(j, i);
pixel_color += rayColor(ray, world, render_depth);
}
pixel_color /= sample_count;
pixel_color = {gammaCorrect(pixel_color.x), gammaCorrect(pixel_color.y), gammaCorrect(pixel_color.z)};
hori.emplace_back(pixel_color);
}
chunk.partial.emplace_back(hori);
}
auto message = KawaiiMQ::makeMessage(chunk);
result_producer.publishMessage(result_topic, message);
}
}

int Camera::partition() {
auto manager = KawaiiMQ::MessageQueueManager::Instance();
auto queue = KawaiiMQ::makeQueue("renderTaskQueue");
auto topic = KawaiiMQ::Topic("renderTask");
manager->relate(topic, queue);
KawaiiMQ::Producer prod("chunkPusher");
prod.subscribe(topic);
int upperleft_x = 0;
int upperleft_y = 0;
int idx = 0;
while(upperleft_y < height) {
while(upperleft_x < width) {
ImageChunk chunk;
chunk.chunk_idx = idx;
++idx;
chunk.startx = upperleft_x;
chunk.starty = upperleft_y;
if(upperleft_x + chunk_dimension > width) {
chunk.width = width % chunk_dimension;
}
else {
chunk.width = chunk_dimension;
}
if(upperleft_y + chunk_dimension > height) {
chunk.height = height % chunk_dimension;
}
else {
chunk.height = chunk_dimension;
}
auto message = KawaiiMQ::makeMessage(chunk);
prod.broadcastMessage(message);
upperleft_x += chunk_dimension;
}
upperleft_x = 0;
upperleft_y += chunk_dimension;
}
return idx;
}

Now remastering has done, let’s go into the main topic

Motion blur

In real world camera, the shuttle opens for a certain amount of time, and during this time, the camera captures the light that comes through the lens. If the object is moving, the light that comes from the object will be captured at different positions, and the object will appear blurry. This is called motion blur.

We can simulate this process by capturing the light at different positions during the time the shuttle is open, since we are using multiple sampling, we can just sample the light at different positions and average them. This will give us a blurry image.

We can determine how a “photon” should be at some instant when the shutter is open, as long as we know where the object will be at that instant. To do that, we need to store when the ray hits the sphere, which we will be randomizing. To do this, we need to modify our Ray class:

icon-padding

MathUtil.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Ray {
private:
Point3 position;
Vec3 direction;
double tm;
public:
Ray(Vec3 pos, Vec3 dir, double time);
Ray(Vec3 pos, Vec3 dir);
Ray() = default;

Vec3 pos() const;
Vec3 dir() const;
double time() const;

Point3 at(double t) const;
};

icon-padding

MathUtil.cpp

1
2
3
4
5
6
7
8
Ray::Ray(Vec3 pos, Vec3 dir): position(std::move(pos)), direction(std::move(dir)), tm(0)
{

}

Ray::Ray(Vec3 pos, Vec3 dir, double time) : position(std::move(pos)), direction(std::move(dir)), tm(time) {

}

Since we know the light will be hitting the sensor from 0 to designated shutter time, we can create a mapping between time and position(we now assume the object is moving linearly). We will use a random number generator to generate the time, and we will do that in the Camera class where we cast the ray:

icon-padding

Camera.h

1
2
3
4
5
6
7
8
9
10
11
class Camera {
public:
// ...
double getShutterSpeed() const;

void setShutterSpeed(double shutterSpeed);
private:
// ...
double shutter_speed = 1;
// ...
}

icon-padding

Camera.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
double Camera::getShutterSpeed() const {
return shutter_speed;
}

void Camera::setShutterSpeed(double shutterSpeed) {
shutter_speed = shutterSpeed;
}

Ray Camera::getRay(int x, int y) {
auto pixel_vec = pixel_00 + pix_delta_x * x + pix_delta_y * y + randomDisplacement();
auto origin = dof_angle <= 0 ? position : dofDiskSample();
auto direction = pixel_vec - origin;
auto time = randomDouble();
return Ray(origin, direction, time);
}

Then we will modify our Sphere class to make a moving sphere by assigning a start direction and end direction to it, also a method that we can get the position of the sphere at a given time:

icon-padding

GraphicObjects.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

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

Sphere(double radius, const Point3& init_position, const Point3& final_position, std::shared_ptr<IMaterial> mat);

Vec3 getPosition(double time) const;

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

private:
Vec3 direction_vec;
bool is_moving = false;
double radius;
Vec3 position;
std::shared_ptr<IMaterial> material;
};

icon-padding

GraphicObjects.cpp

1
2
3
4
5
6
7
8
9
Sphere::Sphere(double radius, const Point3& init_position, const Point3& final_position, std::shared_ptr<IMaterial> mat) :
radius(radius), position(init_position), material(std::move(mat)) {
direction_vec = final_position - init_position;
is_moving = true;
}
Vec3 Sphere::getPosition(double time) const
{
return is_moving ? position + time * direction_vec : position;
}

Then apply the motion blur to the hit function:

icon-padding

GraphicObjects.cpp

1
2
3
4
5
6
7
8
9
10
11
12
bool Sphere::hit(const Ray &r, Interval interval, HitRecord& record) const {
auto sphere_center = getPosition(r.time());
std::shared_ptr<Vec3> oc = std::make_shared<Vec3>(r.pos() - sphere_center);

// ...

record.p = r.at(root);
auto out_normal = (record.p - sphere_center) / radius;
record.setFaceNormal(r, out_normal);
record.material = material;
return true;
}

We also have to modify the material code to take the time into account:

icon-padding

Material.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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Lambertian::Lambertian(Color albedo) : albedo(std::move(albedo)) {

}
bool Lambertian::scatter(const Ray &r_in, const HitRecord &record, Vec3 &attenuation,
Ray &scattered) const {
auto ray_dir = record.normal + Vec3::randomUnitVec3();
if (ray_dir.verySmall()) {
ray_dir = record.normal;
}
scattered = Ray(record.p, ray_dir, r_in.time());
attenuation = albedo;
return true;
}

Metal::Metal(const Color &albedo, double fuzz) : albedo(albedo), fuzz(fuzz) {}
bool Metal::scatter(const Ray &r_in, const HitRecord &record, Vec3 &attenuation,
Ray &scattered) const {
auto ray_dir = Vec3::reflect(r_in.dir().unit() + Vec3::randomUnitVec3() * fuzz, record.normal);
scattered = Ray(record.p, ray_dir, r_in.time());
attenuation = albedo;
return true;
}

Dielectric::Dielectric(double idx, const Color &albedo): ir(idx), albedo(albedo) {}

bool Dielectric::scatter(const Ray &r_in, const HitRecord &record, Vec3 &attenuation,
Ray &scattered) const {
attenuation = albedo;
double ref_ratio = record.front_face ? (1.0 / ir) : ir;
auto unit = r_in.dir().unit();

double cos = fmin((-unit).dot(record.normal), 1.0);
double sin = sqrt(1 - cos * cos);

bool can_refr = ref_ratio * sin < 1.0;
Vec3 dir;
if(can_refr && reflectance(cos, ref_ratio) < randomDouble())
dir = Vec3::refract(r_in.dir().unit(), record.normal, ref_ratio);
else
dir = Vec3::reflect(r_in.dir().unit(), record.normal);

scattered = Ray(record.p, dir, r_in.time());
return true;
}

Let’s modify our main function to see the result:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include "ImageUtil.h"
#include "GraphicObjects.h"
#include "Material.h"
#include "Camera.h"

int main() {

auto camera = Camera(1920, 16.0 / 9.0, 30, {0, 0, 0}, {-13, 2, 3}, 0.6);
auto world = HittableList();
auto ground_material = std::make_shared<Metal>(Metal(Color(0.69, 0.72, 0.85), 0.4));
auto left_ball_material = std::make_shared<Lambertian>(Lambertian(Color(0.357, 0.816, 0.98)));
auto center_ball_material = std::make_shared<Metal>(Metal(Color(0.965, 0.671, 0.729), 0.4));
auto right_ball_material = std::make_shared<Dielectric>(Dielectric(1.5, Color(0.8, 0.8, 0.8)));
world.add(std::make_shared<Sphere>(Sphere(1000, {0, -1000, -1.0}, ground_material)));
world.add(std::make_shared<Sphere>(Sphere(1, {0, 1, 0}, center_ball_material)));
world.add(std::make_shared<Sphere>(Sphere(1, {4, 1, 0}, right_ball_material)));
world.add(std::make_shared<Sphere>(Sphere(1, {-4, 1, 0}, left_ball_material)));
camera.setSampleCount(100);
camera.setShutterSpeed(1.0/24.0);
for(int i = -5; i < 5; ++i) {
for (int j = -5; j < 5; ++j) {
auto coord = Vec3((i + randomDouble(-1, 1)), 0.2, (j + randomDouble(-1, 1)));
auto displacement = Vec3{0, randomDouble(0, 5), 0};
auto material = static_cast<int>(3.0 * randomDouble());
if ((coord - Vec3{0, 1, 0}).length() > 0.9) {
Vec3 color;
std::shared_ptr<IMaterial> sphere_mat;
switch (material) {
case 0:
color = Vec3::random() * Vec3::random();
sphere_mat = std::make_shared<Lambertian>(color);
world.add(std::make_shared<Sphere>(0.2, coord, sphere_mat));
break;
case 1:
color = Vec3::random() * Vec3::random();
sphere_mat = std::make_shared<Metal>(color, randomDouble(0.2, 0.5));
world.add(std::make_shared<Sphere>(0.2, coord, coord + displacement, sphere_mat));
break;
case 2:
color = Vec3::random(0.7, 1);
sphere_mat = std::make_shared<Dielectric>(randomDouble(1, 2), color);
world.add(std::make_shared<Sphere>(0.2, coord, coord + displacement, sphere_mat));
break;
default:
break;
}
}
}
}
camera.setRenderDepth(50);
camera.setRenderThreadCount(12);
camera.setChunkDimension(64);
camera.Render(world, "out", "test.ppm");
return 0;
}

result:

blur

Comments