一个基于路径追踪的离线软渲染器,参考 Ray Tracing in One Weekend。
1 概述
本来想一个周末写完的,结果拖成了两周末加一下午。其实按有效工作时间也就一周末的事情,奈何无法集中精力。
基于路径追踪实现的光线追踪软渲染,单线程 CPU 处理硬编码的多材质几何球体(可以很方便做出其他几何形状,懒得写了)。
通过 cmake 管理各个源码文件。
本文只是看书实现时的手记,并没有全面反映 Ray Tracing in One Weekend 的内容,章节也没有对应区分,需要配合原书阅读。
2 图片
把渲染出的图片用 ASCII 字符写成 PPM 格式存放。注意字符编码的问题,Windows 10 可以用 PowerShell 命令来解决:
1 | ./slowpt.exe | Out-File ../image.ppm -Encoding ascii |
3 向量和颜色
整个项目用得最频繁的数据结构是三维向量。描述物体、相机和光线需要向量,描述 RGB 颜色也需要用到。因为不涉及几何变换,没有用四维的机会。
4 光线、简单相机、背景
4.1 光线
光线涉及的数据就是一个起点和一个方向,可以使用参数方程表示:$\mathrm{P}(t) = \mathrm{A} + t\mathrm{b}$。
关键是要获得光线在某个时间点的位置,主要用于求光线与表面的交点,实现为 ray::at(t)
。
4.2 相机和视口
采用与 OpenGL 类似的右手系和相机位置,视平面高 $2$ 单位,宽高比 $16:9$,距离相机 $1$ 个单位(这些单位长度后面都会做成可修改的)。
确定像素颜色的方式是:从视口的左上角开始,按行优先遍历所有像素。实现上,以像素为单位,使用视平面内某个点(代码里是左下角)和全长、全宽两个向量来表示整个视口,配合遍历的像素下标就可以按比例算出光线与视平面相交的位置,减去源点,就可以得到一条光线的方向。
4.3 渲染一个背景
我们可以硬编码一个天空背景作为光线不和几何体相交后的颜色,颜色渐变可以根据光线的空间位置使用线性插值实现。
5 渲染一个球(圆)
向量形式的隐式球方程,代入光线的参数方程就可以求出光线与球的交点。这里只需要知道是否相交,即一元二次方程是否有解。代码使用一套继承对象来定义一个球。
到此的着色方式:相交则返回红色,否则是背景色。注意此处允许光线的参数 $t$ 为负,后续会做改进。
6 球面法线和多个物体
球是最简单的几何体。我说的。
6.1 球面法线
法向量的设计:单位长度,取值在 $[-1, 1]^3$,映射到 $[0, 255]^3$ 显示在渲染结果里作为验证。
法向量与物体表面的特定点相关,所以此处需要求光线与物体的交点,而非仅仅“相交性”。
6.2 公式简化
球与直线(光线)相交的二次方程,约分,能消一点是一点。
6.3 多个物体和多种物体,抽象表示
使用一个记录结构 hit_record
来保存光线和每个物体的碰撞信息(相交点、点法线、相交时间)。
原先的函数 double ObjectBase::hit_object(ray)
改为 bool ObjectBase::hit(ray, double, double, hit_record&)
,接记录结构的一个引用,返回在时间区间内光线与物体是否发生碰撞。
6.4 前向面和后向面
(用于着色的)法向量不一定总是指向物体外部,它的方向往往取决于光线方向。那么就会遇到一个选择,即法向量与光线的方向:
- 如果法向量总取外向,那么需要一次点积才能判断光线照射的是物体内表面还是外表面
- 如果法向量总与光线反向,那就无法根据光线和法线方向区分物体的内外表面,需要把光线来向记录下来
本质上,这是个在几何阶段(总是反向)还是在着色阶段(总是向外)计算法线指向的问题。如果材质比较少,那就可以在着色的时候计算出用来着色的法向;如果几何形状更少,那就在几何阶段算出来,信息留到着色的时候用。
6.5 物体列表
另外封装一个列表对象,方便内存管理,未来有存储结构或者函数变动也比较容易改。
这里使用共享智能指针的向量 vector<shared_ptr<T>>
来记录和管理物体,这样可以多物体共用一个材质。
6.6 常量和工具函数
圆周率、无穷大等常量,角度弧度转换等常用函数,以及经常需要的头文件,都可以丢到一个头文件里。
7 抗锯齿
这边采用的是 SSAA。首先把相机抽象出来。简单相就是一个取景框和一个源点。
SSAA 对一个像素做多次着色采样,像素最终的颜色就是多次采样的颜色平均。而 MSAA 对一个像素做中心一次着色,但做多次几何采样,几何覆盖的频率作为像素颜色的权重。一般来说几何采样更快一些。目前用随机采样。
其实现在的光线反射/散射模型是永远一条光线,没有分裂,所以在一个像素内必须多次采样来获得比较准确的颜色。
注意像素颜色的计算从 r * 255.999
换成了 clamp(r, 0, 0.999) * 256
。这里可能是出于精度考虑,添加了一个函数 double clamp(double, double, double)
用来把颜色强度限制在一个区间内。实际上全部在区间内的颜色取平均之后应该仍然在区间内部。
8 材质
8.1 简单漫反射
漫反射是从非出射光线的方向也能看到部分入射光的现象,其物理基础是物体表面的微小面片有不同的朝向。
此处相关的数学知识是兰伯特余弦定理:单位面积的“光强” $L$ 与此处的光线入射方向和法向夹角的余弦成正比,即 $L{diffuse} = k\cos\theta$,如果把光线入射方向用从此处指向光源的一个单位向量 $\vec I$ 表示,可以变形为 $L{diffuse} = k\max(0, \vec n \cdot \vec I)$,取较大值是为了排除光线从后向面入射的情况。
代码上,往往使用随机的反射方向来实现,但问题是随机变量的分布可能并非物理正确的,甚至没有在数学上与我们的想法一致。
8.1.0 伽马校正
gamma 是一个比较巧合的事情。
早期的 CRT 显像设备,由于元件的电器特性,其接收电压和表现出的亮度大约是二次关系,因此渲染出的颜色需要做一个开方才能正常显示。
现在的显示设备基本没有这个特性,但仍然拥有内置的 gamma。保留 gamma 的原因不是硬件问题,而是人眼对亮度的感知特性。人眼对亮度的相对变化更敏感,例如 0.2 的亮度增加了 0.1 和 0.8 的亮度增加 0.1,前者会让人觉得“变化更大”。这个感知特性大约是一个 2 为底的对数函数。
基于这个特性,我们可以使用非线性编码来记录颜色,给高亮度分配更少的码值,低亮度分配更多的码值,在使低亮度表现更细腻的同时不影响高亮度的视觉效果。这样的编码当然最好取人眼感知曲线的反函数,这就是现在硬件内置的 gamma。
因此,我们交给显示器的亮度表现出来会更暗一些。为了达到和观感比较统一的效果,我们在进行从 $[0, 1]$ 到 $[0, 255]$ 的区间转换之前,先把数值开个 $\gamma$ 次根。这里 $\gamma$ 取 2。
8.1.1 使用球体随机实现 Lambertian
我们用以下模型来描述朗博特散射:
两个单位球,球心分别为 $\vec P + \vec N$ 和 $\vec P - \vec N$,我们设前者在接触表面的外侧。从两个球面中选择和入射光线同侧的,在球面内随机选择一个点 $\vec S$,以 $\vec S -\vec P$ 作为反射光的方向。
实现细节:
首先需要实现在三维球空间内随机一个点的算法。和球相关的随机化一般都很麻烦,这边暂时采用在单位立方体内不断随机,直到结果属于单位球的方法。
随后只需要在求光线颜色时限制一下递归层数即可。可以发现运行时间显著增加。
问题:
很黑,原因是光线参数在 $t = 0$ 时,如果继续弹射,会让后面反射的光线困在这个点(第一次记录的反射点就是和物体相交的点,后续的所有反射点都是交点,不会变化),在弹射次数到达限制后返回一个黑色。在求相交时间时加一个小量的下界限制即可解决。
(微表面模型是另一种建模方式,它把所有材质表面都视作多个镜面反射的小面片。)
另外注意,在没有添加环境光源的情况下,我们看到的渲染图应该是一片黑。此处的颜色实际上是灰度的。
8.1.2 暗色的粉刺
这是之前就遇到过的问题,物体表面有一些暗色的像素,原因就是十分接近 $0$ 的光线参数。
8.1.3 真正的 Lambertian 反射
回到我们之前提过的问题:球体相关的随机分布。
我们之前取反射的时候,在光线击中点外向面的单位圆里随机了一个点,然后把反射光朝那个点射出去。在圆上拉几条弦就能看出来,随机取点落在长弦上的概率更大。这样的结果是从侧向来的光对这个点颜色的贡献更低,尽管更符合实际,但毕竟不是纯的朗博特反射。
要得到更均匀的分布,可以采用在球面上随机取点,只需要把球体内随机出来的向量单位化即可。
渲染结果的阴影比在球体内取点更浅一些,整体的灰度也更浅了,应该是更多光线反射到了背景上。
8.1.4 使用半球随机实现 Lambertian
如果反射光线的取向在各个方向完全均匀分布,可以想象渲染结果的阴影和颜色会更浅。
我们通过在一个以反射点为圆心的半单位球中随机取点来实现均匀的分布。
8.2 金属材质
8.2.1 材质的抽象
如果要实现多种材质的话,比较容易管理的办法有以下几种:
- 所有类型的材质独立存在,分别管理
- 统一成一种材质,用多个参数控制
- 抽象出材质的基本属性,继承出其他材质
我们采用最后一种方法。
材质本身只需要完成散射,即根据入射的光线和反射点信息(hit_record
),来计算出散射的光线,以及颜色衰减,用一个纯虚函数实现。
同时,hit_record
也需要知道自己击中了什么材质,因此拥有一个指向材质的指针。这里不能实例化一个抽象类,直接声明一个即可。
这里有一个材质头文件和物体基类头文件互相包含的问题,正常写的话,在插入的时候会因为头文件展开的顺序不对发生找不到定义的情况。书上用了一个前置声明来解决,因为物体基类文件(里面的 hit_record
)只是要用材质的一个指针名称而已。实际上比较好的方法是把 hit_record
单独放到一个文件里面。
当光线击中物体表面时,hit_record
的材质指针就会指向那个物体表面的材质,然后交给求光线的函数 ray_color()
。于是我们也要修改一下各个物体的类。
此外,对于引入的材质“颜色”,是以衰减决定的,数学上就是设定一个 albedo 底色向量,乘上入射光的颜色,反射出的光线就会带上这个 albedo 的成分。
8.2.2 抽象后的 Lambertian 材质
漫反射对反射光线的处理,有两种理解方式:
- 总是反射,有一个常数 $R$ 的衰减
- 不会衰减,但是不反射 $1 - R$ 的光线
这里用总是反射 + 衰减。也可以用概率性反射 + 更小的衰减。
另外要注意的是,反射/散射光的方向可能是零,因为我们可能得到和法向正好相反的随机单位向量,这需要额外判断。相比于抛弃这个情况下的方向,更好的方法是重置成法向。
8.2.3 金属材质
金属材质和镜面材质有些类似,都是镜面反射,需要计算反射光线的方向。
已知入射光线的方向向量 $\vec v$ 和入射点的法向量 $\vec N$,用几何知识可以算出出射光线的方向是 $\vec v - 2(\vec v \cdot \vec N)\vec N$。
抽象出材质后,我们就可以把 ray_color
函数的流程写成更一般的形式:如果对应的材质有反射/散射,就返回一个衰减后的递归颜色,否则返回黑色;物体的初始化也要分为形状和材质两步。
现在我们可以渲染出 diffuse 和 metal 两种材质了。
8.2.4 金属磨砂面
可以用一个小的球体来随机化反射光线的方向,实现金属磨砂的效果。具体代码上,添加一个 $[0.0, 1.0]$ 的模糊系数 $f$,计算出反射方向 $\vec s$ 后随机一个单位向量 $\vec u$,以 $\vec s + f\vec u$,作为反射光的真实方向,并把后向反射的光线丢弃。不丢弃的话颜色会偏亮,尤其是阴影。
8.3 电介质材质
透明的材质就是电介质(?),可以造成反射、透射和折射。我们不把一条入射光线分解成多条,而是随机决定入射光线反射还是折射。这边会有一个能量分配的问题。
8.3.1 折射
和折射相关的物理规律是斯涅尔定律。Snell’s Law 认为入射角和折射角的正弦之比为常数,这个常数由折射面两侧介质的折射率之比。斯涅尔定律另外描述了折射光线的位置:与入射光线和法线共面,且与入射光线在法线不同侧。
则 $\theta’ = \frac{\eta}{\eta’}\sin\theta$。
如果我们有入射光方向、表面法向和折射率比值,就能求出折射光的方向。(可以尝试正交分解)
代码实现在折射率上有点取巧:空气的折射率是 $1.0$,玻璃是 $1.5$,所以如果发现是从物体外部向内折射(前向面),材质折射率就直接取倒数。
另外需要注意的是,斯涅尔定律导出的方程不一定有解,因为三角函数有取值限制。无法解出的光线会以全反射的形式存在。但是加上这个情况之后渲染结果没有太大区别,尽管确实有像素颜色变化。
8.3.2 施利克近似
除了光密到光疏时存在的全反射,从空气中斜着看向玻璃时也会发现类似镜面的现象。Schlick 对这个现象做了多项式级别的近似。这个情况对渲染结果有很大提升。
8.3.3 空心玻璃球(技巧)
可以通过负的半径来做一个法向量向内的球,放在一个正常球体的内部,就能做出一个空心玻璃球。
9 可移动的相机
之前的相机都是一个简单的框框,大小由结果图片的宽高比确定,根据像素确认从源点射出的光线应该射向取景框的哪里。现在我们实现一个比较符合 CG 的摄像机。
9.1 视场
一般用角度来指定要看到的范围。把固定采样数放在更小的 fov 上,画面显示的区域就更小,但原来的小物体会“更清晰”。
9.2 可改变位置和朝向的相机
相机要改变位置,那就不能依赖世界坐标系的固定点了。指定相机方位和姿态,经常用三个向量:位置、朝向和上方向。这样的相机会在位置处,看向 $-\vec w$,上方向为 $\vec v$。$\vec u$、$\vec w$ 和另外一个基底 $\vec u$ 组成相机空间的一组基。
不过比较直观的设置还是位置、看向和上方向,依靠这三个向量,可以算出 $\vec u$、$\vec v$ 和 $\vec w$。
10 焦散模糊
aka “景深”。
焦散模糊产生的原因与相机镜头的结构有关。针孔相机不会焦散,而使用透镜组的相机只能将一定距离(焦距)的光线完美聚焦到底片上,其他距离的物体就会变得模糊。
10.1 薄透镜近似
我们把一组透镜视作一个薄透镜模型,只关心射入和射出的光线取向。
我们可以用简单的方法模拟一条导致模糊的光线。说到底,焦散模糊就是感光点接收到了(因为不完美的聚焦而)不属于这个位置的光线。我们定一个焦散的半径,然后在这个圆内随机一个偏移向量,加上相机中心位置就得到了“本该”接收这条光线的感光点。这个流程可以在每次从相机获取光线时完成。如果要算的像素就在焦平面上,随机取起点的这些光线得到的结果都会是一样的,否则就会因为方向不同而得到不同的颜色。
11 总结
至此,一个简单的路径追踪渲染器就写好了。
我们可以继续深入的部分,以及根本没有实现的部分有:
- 光源,特化出一个光源类或者实现一个发光的材质都行
- 泛用的模型,一般用三角形网格或者四边形来组合模型的表面,这里只有球体
- 表面纹理,贴上更复杂的午图案,甚至用来实现表面法线和全局光照,加速渲染
- 固体纹理,3D 的纹理
- Volumes 和 Media,没看懂,应该是和体素有关系,
- 并行计算,并行多个小采样数的计算并取均值,或者并行渲染图像的不同部分
- 加速结构,八叉树、AABB、BVH(Bounding Volume Hierarchy)等
代码的总体结构:
- 基础数据结构
- 颜色、空间点需要的数据结构、运算函数和随机变量等功能
- 光线
- 圆周率等常量、角度弧度转换等功能函数
- 光线与几何体的相交信息,包括交点、交点材质、相交时间(用于实现遮挡)等
- 几何体
- 抽象的几何体基类
- 派生的球体
- 几何体列表,根据指定的光线,对所有几何体检查碰撞,并记录最先发生的碰撞信息
- 材质
- 如果发生了碰撞,对光线进行吸收或散射/反射,同时做衰减以表现颜色
- 相机
- 可定位,可设置焦散和焦平面,根据光路可逆来“发出”光线