学习「Ray Tracing in One Weekend」的笔记

前言

由于上个月只写了理论部分,这个月加上一些简单实现与优化。

主要希望简单过一遍 Ray Tracing in One Weekend 和后续系列(optional),另外还有著名的 smallpt: Global Illumination in 99 lines of C++

至于优化部分主要是「汇编与接口」课程的项目,因此以 SIMD 为主(项目 12 月底截止)

输出图像

PPM 格式确实非常好,可以做到人类可读(P3),最早应该是为了 ASCII 邮件发送设计的。也可以用二进制保存(P6),但都没有压缩。我们 CG 课程作业一开始也是用 PPM 的,后来载入纹理我也没改代码继续用 PPM。优点是不需要额外的库,十几行代码就能实现。缺点是文件大。

P6 与 P3 头部格式一样,但位图数据直接用二进制保存。

在 Windows 下可以找 Netpbm 的 Windows 版本,不过我用的是 GIMP。

代码

复用了 CG 作业的代码:

1
2
3
4
5
6
7
8
9
10
void ppmWrite(const char *filename, uint8_t *data, int w, int h)
{
FILE *fp;
fp = fopen(filename, "wb");

fprintf(fp, "P6\n%d %d\n255\n", w, h);
fwrite(data, w * h * 3, 1, fp);

fclose(fp);
}

主程序如下,对书中代码做了魔改:

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
int main()
{
const int image_width = 256;
const int image_height = 256;
uint8_t data[image_width * image_height * 3];

for (int i = 0; i < image_height; i++)
{
cerr << "\rScanlines remaining: " << image_height - i << ' ' << flush;
for (int j = 0; j < image_width; j++)
{
int base = (i * image_width + j) * 3;
float r = 1.0 * i / (image_height - 1);
float g = 1.0 * (image_width - j - 1) / (image_width - 1);
float b = 0.25;

data[base + 0] = (uint8_t)(255.999 * r);
data[base + 1] = (uint8_t)(255.999 * g);
data[base + 2] = (uint8_t)(255.999 * b);
}
}

ppmWrite("image.ppm", data, image_width, image_height);
cerr << "\nDone. " << endl;
return 0;
}

与书中结果一致。但显然输出非常快,进度条没有起到作用(

vec3 类

书中不使用齐次坐标,因此只需要 vec3 即可表示点和向量。vec3 实在是太简单了,直接交给 Copilot 生成:

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
struct vec3
{
float x, y, z;
vec3() : x(0), y(0), z(0) {}
vec3(float x, float y, float z) : x(x), y(y), z(z) {}

vec3 operator+(const vec3 &v) const { return vec3(x + v.x, y + v.y, z + v.z); }
vec3 operator-(const vec3 &v) const { return vec3(x - v.x, y - v.y, z - v.z); }
vec3 operator*(float f) const { return vec3(x * f, y * f, z * f); }
vec3 operator/(float f) const { return vec3(x / f, y / f, z / f); }
vec3 operator-() const { return vec3(-x, -y, -z); }

float dot(const vec3 &v) const { return x * v.x + y * v.y + z * v.z; }
vec3 cross(const vec3 &v) const { return vec3(y * v.z - z * v.y, z * v.x - x * v.z, x * v.y - y * v.x); }
float length() const { return sqrt(x * x + y * y + z * z); }
vec3 normalize() const { return *this / length(); }

vec3 &operator+=(const vec3 &v)
{
x += v.x;
y += v.y;
z += v.z;
return *this;
}

vec3 &operator-=(const vec3 &v)
{
x -= v.x;
y -= v.y;
z -= v.z;
return *this;
}

vec3 &operator*=(float f)
{
x *= f;
y *= f;
z *= f;
return *this;
}

vec3 &operator/=(float f)
{
x /= f;
y /= f;
z /= f;
return *this;
}
};

vec3 operator*(float f, const vec3 &v) { return v * f; }
float dot(const vec3 &v1, const vec3 &v2) { return v1.dot(v2); }
vec3 cross(const vec3 &v1, const vec3 &v2) { return v1.cross(v2); }
float length(const vec3 &v) { return v.length(); }
vec3 normalize(const vec3 &v) { return v.normalize(); }

ostream &operator<<(ostream &os, const vec3 &v)
{
os << v.x << ' ' << v.y << ' ' << v.z;
return os;
}

void write_color(uint8_t *pixel_start, const vec3 &color)
{
pixel_start[0] = (uint8_t)(255.999f * color.x);
pixel_start[1] = (uint8_t)(255.999f * color.y);
pixel_start[2] = (uint8_t)(255.999f * color.z);
}

看起来有很多用不上的,但反正都是自动生成的,就不删了。接下来改主程序,不再赘述。

光线,简单相机和背景

光线

光线的表示非常简单,正如前面理论部分所说,由原点和方向表示。

1
2
3
4
5
6
7
8
struct ray
{
vec3 orig;
vec3 dir;
ray() {}
ray(const vec3 &orig, const vec3 &dir) : orig(orig), dir(dir) {}
vec3 at(float t) const { return orig + t * dir; }
};

相机

接下来是书中给出的坐标系、相机定义,最好直接用,这样后面的代码就不用改了。不过其实有个小问题,下图的宽高比是 2:1,而代码中实际用的是 16:9,需要读者注意一下。

作者手绘的,是不是很不错?

背景

然后是用 ray 线性插值计算背景颜色的函数,可以说是强行用 ray 类了:

1
2
3
4
5
6
vec3 color(const ray &r)
{
vec3 unit_direction = normalize(r.dir);
float t = 0.5f * (unit_direction.y + 1.0f);
return (1.0f - t) * vec3(1.0f, 1.0f, 1.0f) + t * vec3(0.5f, 0.7f, 1.0f);
}

首先将 Y 范围从 [-1, 1] 映射到 [0, 1],然后用线性插值计算颜色,两端分别是白色和天蓝色。

接下来就是主程序,每个像素计算了 u, v 坐标(纹理?),然后用这个坐标构造了一条光线。常量就看书吧。

1
2
3
4
5
6
7
8
9
10
11
12
for (int i = 0; i < image_height; i++)
{
cerr << "\rScanlines remaining: " << image_height - i << ' ' << flush;
for (int j = 0; j < image_width; j++)
{
int base = (i * image_width + j) * 3;
float v = 1.0f * (image_height - i - 1) / (image_height - 1);
float u = 1.0f * j / (image_width - 1);
ray r(origin, lower_left_corner + u * horizontal + v * vertical - origin);
write_color(data + base, color(r));
}
}

插曲:精度之争

值得一提的是,你可能注意到我用的都是 float 而不是 double,因为在一般 CG 中,float 的精度已经足够了,而且速度更快。而且在一般的 GPU 上,单精度比双精度算力高很多,在 CPU SIMD 中也有 2 倍的吞吐量。不过,如果不用 GPU 或 SIMD,那么速度差别就不大了。

以下是使用 AIDA64 GPGPU Benchmark 测试的结果,反映的都是峰值性能。首先是我的笔记本:

这是计网实验室的台式机:

快去计网实验室玩游戏炼丹挖矿吧!

球体

回忆一下,在理论部分,我们定义光线方程为

$$\mathbf{r}(t) = \mathbf{o} + t \mathbf{d}$$

其中 $\mathbf{o}$ 是原点,$\mathbf{d}$ 是方向,$t$ 是参数。

定义球体方程为

$$(\mathbf{p} - \mathbf{c}) \cdot (\mathbf{p} - \mathbf{c}) = r^2$$