看 PBR 这本书的笔记,以及实现 pbrt
的过程记录。
本科毕业之前应该能看完吧。
这篇对应书中的前言部分和第一章。
跟着这本书最后可以做出一个基于光线追踪的渲染器。但是现在打算先从 Shirley 的 Ray Tracing 系列入手。
0 三个目标
功能完备、人类可读、物理正确。
此外要做得模块化、可扩展。
1 文学编程
不太能理解这种代码组织形式。书上本身写得就很散,给人感觉像编辑器提供的大括号折叠一样,跳转和定位链接在浏览器里的表现还很迷,直接切屏的硬跳转经常让人搞不清楚自己在看哪段代码。
我在自己的项目里不打算采用所谓 Literature Programming。如果真有说的那么厉害,把它翻译成普通组织的代码应该也不麻烦。
2 真实感渲染和光线追踪
此处“真实感”约等于物理上的真实,因为感官真实还涉及感知学、心理学这些乱七八糟的东西。
几乎所有的真实感渲染都采用光追算法,而且都有以下几个内容:
- 相机:决定观察整个场景的位置和方式,以及光线如何被传感器接收。一般利用光路可逆的性质从相机射出“光线”。
- 光线-物体求交:计算光线与物体相交的时间、交点的法向、交点材质等等信息。
- 光源:确定光源位置、光源的能量和辐射分布(辐射度量学内容)。
- 可见性:在光追渲染的算法中比较好处理,取光线第一次相交即可。光栅化渲染做这个就比较复杂。
- 表面散射:描述物体表面如何反射或吸收光线。
- 间接光照:解决光线多次反射、折射等现象。
- 光线传播:光线在真空、空气、雾等介质中传播时发生的变化。
2.1 相机
主要负责生成光线,内部实现可以是针孔摄像机模型,也可以是透镜组模型。
2.2 光线-物体求交
主要关注光线与物体的第一次相交,提供要着色的位置。
如果全部(如果用加速结构的话就是部分)物体的隐式方程是 $F(x, y, z) = 0$,光线为 $r(t) = \vec o + t\vec d$ 光线-物体求交就是求方程 $F(r(t)_x, r(t)_y, r(t)_z) = 0$ 的最小正数根。
除了相交时间(位置),还要求出相交位置的材质、法线等等。
2.3 光线分布
涉及几何上和辐射上的光线分布。
常见的描述反射的模型是相交点附近的一个微分平面。理想的辐射度量会得到这个平面上的微分能量(differential irradiance):
2.4 可见性
光线追踪里处理阴影的方法是从相交点向光源射出一条阴影射线来检查这个光源能否照到这个点,以此决定是否要考虑这个光源的贡献。
2.5 表面散射
要知道一个点的颜色,光照方面我们需要知道有多少能量沿着追踪用的光线打过来。
双向反射分布函数 BRDF “描述”了给定位置 $\mathrm{p}$、入射角度 $\omega_i$ 和出射角度 $\omega_o$ 下,一个材质反射的能量。实际上它的单位是立体角倒数,乘上光线的 irradiance 就可以得到这条光线在此处射出的 radiance。
实际的 BRDF 以及一堆 BxxxDF 一般都是测量出来的,量纲也因为测量方法变得非常奇妙。
2.6 间接光照
这是光线追踪渲染尤其擅长处理的情况。
渲染方程:某处沿某方向射出的 radiance 等于物体本身的 radiance 加上附近整个球面上经过 BRDF(以下式子中的函数 $f$)和方向余弦调整之后的间接 radiance。
这个式子可以说是 PBR 的精髓,不仅很难理解也很难计算,需要做一些简化假设,或者用数值方法。
最原始的 Whitted 光追在间接光上只处理了光源方向和全反射/折射方向。我们可以随机取遍半球方向然后用 BRDF 赋权重,这样的结果非常真实,当然运行很慢。
如果递归处理光线,会得到一棵光线树,每根光线在父节点处按照特定的权值分裂。
2.7 光线在传播时的行为
考虑光线在介质中传播时的衰减或增强。这可能是因为光被散射出去/其他光被散射进来,也可能是因为介质本身吸收/放出光。一个经典的例子是丁达尔效应。
3 渲染器 pbrt
概览
几个基本的(抽象)类:
基类 | 路径 | 链接 |
---|---|---|
Shape | shapes/ | |
Aggregate | accelerators/ | |
Camera | cameras/ | |
Sampler | samplers/ | |
Filter | filters/ | |
Material | materials/ | |
Texture | textures/ | |
Medium | media/ | |
Light | lights/ | |
Integrator | integrators/ |
3.1 执行过程
渲染器的执行应该是基于命令行和输入文件的。输入一个 txt 文件描述物体、材质、光照等等(基于 lex
和 yacc
实现),处理得到一个场景实例和一个积分器实例,然后就开始渲染。渲染结果写到另一个文件里,系统继续执行输入文件中的剩余内容。
具体来说,系统先处理附加命令,然后初始化,生成场景并渲染,最后做清理和收尾。
3.2 场景表示
场景类负责存储几何图元(几何体 + 材质)和光源,处理光线相交的事情。光源存放在唯一的一个列表里,而几何图元以加速结构放在一个特殊实现的图元里(类似 Ray Tracing in One Weekend 的做法)。另外有一个放所有 BB 的变量,也在构造函数里初始化。光源可能需要预处理来优化效果或者性能。“相交”基于不同的需求(有解或求解)有多个实现,按需求返回相交记录或布尔值。
3.3 积分器接口和 SampleIntegrator
渲染就是做积分。
Integrator
提供了 Render()
函数,接收一个场景的引用,返回场景画面,或者场景光照某些方面的测度。
接下来讲两个东西:继承自 Integrator
的 SampleIntegrator
和实现它的 WhittedIntegrator
。SampleIntegrator
通过 Sampler
提供的流来做渲染。每个采样确定了目标图片上的一个点,然后积分器通过相关的信息做渲染。
SampleIntegrator
的成员变量是一个采样器和一个相机,成员函数有预处理、渲染以及光照相关的函数。渲染和求 radiance 的函数是必须实现的。
采样器对渲染质量的影响很大,它负责选择向成像面的哪一点投射光线,也为积分器求解渲染方程提供合适的采样点(从随机选择到特殊分布)。
相机记录观察者的位置、朝向、视场、光圈等信息,内部的胶卷成员则存储并输出成像图片。
积分器一般被 RenderOptions::MakeIntegrator
生成,后者被 pbrtWorldEnd()
调用,而这个函数又是在场景设置结束之后被调用的。
3.4 主要的渲染循环
Render()
函数在场景和积分器都设置好之后被调用。
SamplerIntegrator
实现的 Render()
对每个需要渲染的位置使用 Camera
和 Sampler
向场景投射一条光线,传给 Li()
计算 radiance,存放到 Film
里面。以下为关系图。
这里有一段代码实现,流程是先根据 tileSize 计算出用来并行的图块数量,然后对于每个图块都生成一个采样器,对每个图块范围内的像素计算 radiance。
确定图块数量依赖于整幅图像的尺寸和每个图块的开销。图块数量期望是“稍多于”处理器核数,理由是充分利用提前完成任务(比如分到了简单场景)的部分核。当然也要平衡并行数增大所带来的开销。pbrt 使用固定 16*16 大小的图块,这在渲染低分辨率图像时会比较慢,但毕竟都是低分辨率了。这里有一些取整和边界之类的细节要注意。
分解好的图块会以坐标的形式描述,实现并行执行的函数根据传入的匿名函数和图块的总数来并行渲染每个图块。这里是一个一维和二维之间的坐标转换,匿名函数拿到的是一个图块序号,在内部转换成对应图块的坐标。
在计算 radiance 的时候,Li()
会相当频繁地申请内存,通用的 malloc()
和 new
对于这些简单专用的内存效率不高,我们使用内存池来解决这个问题。为了避免并发访问等额外问题,我们对每个图块单独维护一个 MemoryArena
内存池。
每个图块的位置对采样也有一定影响,所以渲染图块使用的采样器也是图块独立的,这也避免了一些并行问题。Sampler
拥有一个 Clone(int)
函数,接收一个伪随机数种子,期望能使用这个种子生成更好的随机化效果。也可能根本不使用,对每个图块都采取一样的行为。采样器最终会被传给 Li()
函数。
图块边界被用来从胶卷上抠出来一个小胶卷块。胶卷块维护对应图块的像素值,把这些图块像素与其他的线程隔离。渲染结束之后,这个胶卷块会被写回胶卷,然后对图形进行并行更新。
图块包含的 256 个像素一般用普通的遍历去挨个渲染,采样器对每个像素生成一个或多个采样,用于生成光线。这部分的代码抽象程度比较高,有一点类似迭代器的操作。
采样器根据像素生成一个相机采样,传给相机来生成一个 ray differential,它包含了“邻近”光线的信息,在材质反走样上会比较有用。Camera
假设光线是间距一个像素的,所以生成的 ray differential 需要根据 spp 再调整一下(见 2.5 节最后)。生成 ray differential 的同时会得到一个光线权重,这个参数在使用透镜组的相机对象里比较重要。
这里有一个小细节:光线权重的变量类型是 Float
。pbrt 会根据不同的机器来采用 float
或者 double
。
接下来是确定光线的 radiance。纯虚函数 virtual Spectrum Li(const RayDifferential& ray, const Scene& scene, Sampler& sampler, MemoryArena& arena, int depth = 0) const = 0
接收 5 个参数:
ray
:要计算的入射 radiance 是沿此条光线的scene
:整个场景,用于查询几何和光源sampler
:用于通过蒙特卡洛积分求解光线传播方程的采样器arena
:内存池,里面的空间用于快速复用depth
:光线弹射次数,默认参数
Li()
返回一个 Spectrum
类型变量,记录入射 radiance。为了避免数值异常,还要做一些 nan、无穷大、负数和小量的检查。
之后就是把这个采样的结果加入到胶卷块里面,重置内存池,计算下一个采样。图块内所有的采样都结束之后,就可以把胶卷块整合到胶卷里。这里有用到一个标准库函数 std::move()
,它的作用是转移 unique_ptr
的所有权,实际上是把一个左值转换为右值(同时清空原来指向的对象)。
所有的图块都结束渲染之后,相机对象把自己的胶卷写到文件里去。
3.5 Whitted 光追积分器 WhittedIntegrator
第 14 和 15 章会介绍许多积分器,这里先看一个经典的 Whitted 光追。Whitted 的算法可以精确处理光滑物体的反射和透射,但不能处理其他种类的间接光照。
Whitted 积分器求 radiance 的方式,是沿反射和透射的光线不断递归,直到超过设定的最大深度。为此它除了从 SamplerIntegrator
继承来的相机和采样器,还需要一个成员变量 maxDepth
。数据流如下:
Li()
拿光线去场景里查询交互(一般是光线和物体相交),然后用和交互记录相应的 BSDF 结合采样器去计算场景的光照。具体来看,它首先去找相交,如果这条光线没有和物体相交,就只去查光线和场景光源的相交并累计 radiance;如果有相交,就用交点的信息计算 BSDF,(如果交点发光)加上光源贡献,再采样计算场景内其他光源的贡献(注意检查相交)。至此,直接光照的计算就结束了。如果还没有到达递归深度,就递归计算镜面物体反射(反射向量)和透射(斯涅尔定律)。
这里有些 ray differential 相关的、纯粹从数学模型翻译过来的代码。这些模型的结构还不太清楚,看到后面的时候再回来检查一下。
4 pbrt 的并行
多核并行是大势所趋。
这里提到了海姆达尔定律,一个非常简单而符合直觉的方程,可以用来计算部分性能提升对于整体的影响。CSAPP 上也提到过。
4.1 数据竞争和协调
pbrt 假设运行程序的处理器提供连续共享内存(coherent shared memory),连续内存的特点是所有线程都拥有读写权限,而且做出的改动对其他线程可见。理论上的线程通信一般只限于用简单的信号量等信息来控制同步,不涉及复杂消息的交换。而书这里提到的线程通信类似于 IPC 里面的共享内存通信,没有做那么明显的区分,但还是要注意同步问题。
解决同步问题的两个主要机制是“互斥”和“原子操作”。pbrt 使用标准库提供的互斥锁,用起来像下面这样:
1 | // shared variable |
原子操作是数据库里经常有的概念,表示一个不可分、只能有“完成”或“不完成”两种结果的操作。pbrt 使用的原子操作来自 C++11 的标准库:
1 | // special type overloading basic arithmetics |
上面两段代码的行为应该是一样的,都只会有一行打印,如果有两个线程并行的话,globalCounter
最后的值会是 -1。
第三种方法是 transactional memory,它允许把多个写入做成一个原子操作,在涉及到的内存没有被其他线程访问时一起提交,否则就一起回滚并等待再次尝试。这个方法避免了原子操作过分细致的粒度和互斥锁的高负载,但是硬件支持(在写这本书时,2018 年?)还不完善。
附录 A.6 会对并行编程做更详细的介绍。我可能等全书看完后或者实现到并行功能时再来更新。
4.2 惯例
并行和同步的最大隐患就是程序员。pbrt 读取场景文件和建立场景的阶段是单线程的,没有同步问题,而渲染阶段的大部分数据,都被设置为只读,数据的流向比较单一,也不用担心同步。需要注意的只有写入操作,尤其是在共享内存写入,一定要有合适的同步策略。
4.3 pbrt 对线程安全的要求
- 修改共享内存的函数必须做同步
- 基础和底层的类和结构,由于性能需要或单进程特性,一般不要求线程安全
- 随机数和内存池之类的功能类,由于性能需要,在每个进程都有独立的实例
其他表格里列出的抽象类的实现,都要求线程安全,除了以下两个例外:
采样积分器和光源的 Preprocess()
函数,由于需要实现一些数据结构,一般假定是单线程运行。
采样器也不要求线程安全,因为在多次重复执行的条件下,线程安全的需求会拉低性能,不如在每个线程里都实例化一个独立的采样器。
独立的函数,只要不接收相同的指针,都线程安全的。
5 如何看这本书
作者预设的阅读顺序是从前向后,先是向量、光线、光谱这些基础类型,以及基本的渲染循环,然后是各个模块更细节的实现。
后续的章节主要分为四个部分:
- 2-4 章介绍整个系统主要的几何功能
- 第 2 章:点、光线等底层类
- 第 3 章:
Shape
接口和光线求交 - 第 4 章:加速结构
- 5-7 章介绍图像格式处理
- 第 5 章:描述光的物理单元和光谱类
- 第 6 章:相机接口
- 第 7 章:采样器
- 8-12 章介绍材质、纹理以及光线交互
- 第 8 章:定义表面反射的基础类
- 第 9 章:材质
- 第 10 章:纹理
- 第 11 章:光线在参与介质中的行为
- 第 12 章:光源接口和若干种光源的实现
- 13-16 章介绍了蒙特卡洛积分以及基于它的一众积分器
- 17 章总结了若干系统设计的决定以及拓展系统的建议;附录包括一些功能函数和解析场景文件的细节。
5.1 练习
每章的最后是练习题,分为三种难度:
- 花费一两个小时的习题
- 花费 10 到 20 小时的课程任务
- 花费 40 小时以上的课程项目
6 Show Me the Code
为了保证移植性和可读性,多继承、运行时异常和过多的 C++11、C++14 特性都不会出现,C++ 外部标准库也尽可能少使用。短的重复性代码(比如 case
)偶尔会被省略。
我实现时会考虑引入一些 C++ 特性,顺带踩踩坑,因为不想硬背八股。
6.1 指针 vs 引用
传参时,如果参数会被函数完全改变,就使用指针,部分或内部改变则使用引用,完全不被改变则使用常引用。例外是:当要表示参数不可用或者不该被使用时,传递一个 nullptr
。
6.2 抽象 vs 效率
设计类的时候总要在暴露程度上做一些取舍。用 getter 和 setter 把所有成员都抽象出来会不必要地掩盖类的属性,暴露所有成员显然不利于代码维护。
做这些设计权衡的最终依据是系统的规模。pbrt 的核心部分只有不到 20k 行代码,而且不会暴涨到数百万行,添加新的系统功能也只需要实现各个抽象基类,所以不需要设计太多的抽象接口。
6.3 代码优化
相较于代码层面的优化,pbrt 在设计时更倾向于使用高效的算法。然而还是有一些最消耗性能的地方采用了不影响阅读的代码优化。主要的原则是:
- 在目前的 CPU 架构上最耗时的运算是除法、开方和三角函数,所以要通过数学变形减少这些运算。
- CPU 的处理速度比数据速率更快。附录 A.4 讨论了提高内存效率的编程原则,并且被运用在第 4 章和第 10 章的图像纹理映射部分。时刻注意编写内存友好的代码。
6.4 本书网站
官网,有涉及 pbrt 的所有资源和勘误、漏洞报告。
6.5 系统拓展
pbrt 的设计目标之一就是可扩展性。附录 A.4 和附录 B 介绍了添加新功能的方法。
6.6 漏洞
发现,复现,查询,讨论,提交,解决。
7 PBR 简史
哦,快乐的听故事时间。
上世纪七十年代的计算机图形学主要致力于解决可视性算法、几何表示等基础问题。当时的存储和算力都非常昂贵,图形学内容的复杂度也受到了限制,以至于任何精确模拟渲染物理的方案都不可行。
随着计算机的性能提高、价格降低,我们得以在渲染时使用对计算有更高要求的方案,这也让基于物理的方案变得切实可行。这样的进展可以用 Blinn’s law 描述:“科技飞速发展,但渲染时间不变”。
Jim Blinn 的描述指出了一个重要的约束:对于给定数量的渲染图像,只能用固定的时间去渲染每张图。计算量固定,用时固定,那么每张图片的最大计算量也是有限的。(这句不太明白,但理解上就是渲染时间不能毫无限制。)
Blinn’s law 也表现了这样的一个观察结果:人们想渲染的图像和能渲染的图像总有差距,艺术家和算法设计者们总能吃掉计算机发展带来的算力。
7.1 学术
图形学研究人员在二十世纪八十年代开始认真考虑基于物理的渲染。Whitted 的论文 (1980) 介绍了利用光线追踪处理全局光照的想法,为精确模拟场景中光线分布打开了一扇门。他渲染出的“前所未见”的图像,激起了学术圈对这种方法的热情。
Cook 和 Torrance 的反射模型 (1981, 1982) 是 PBR 方向另一个值得注意的早期进展,引入了微表面反射模型。在其他的贡献中,他们提出准确利用微表面反射建模可以精确地渲染金属表面,学术界在此之前都不能很好地解决这个问题。
不久之后,Goral et al. (1984) 将传热理论和渲染联系起来,提出了使用基于物理的光传播近似来表现全局漫反射效果,基础是有限元分析,认为场景中的表面都在互相交换能量。这个方法后来被以一个相关的物理单位命名为“辐射度”。Cohen 和 Greenberg (1985),以及 Nishita 和 Nakamae (1985) 在这个方向做了更多的推进。基于物理的方法再一次渲染出了前所未见的光效,让许多研究人员在这个领域追求进步。
辐射度方法严格基于物理单位和能量守恒,但随着时间推移,人们发现它没有引出切实可行的渲染算法:渐近的时间复杂度高达 $O(n^2)$,而且需要将几何模型沿着阴影边界重新细分才能得到好的结果,但提出足够健壮和高效的细分算法非常困难,所以辐射度的实际应用就被限制了。
在学界为辐射度着迷的年代,一小群研究人员致力于基于光线追踪和蒙特卡洛积分的 PBR。他们的工作一度受到许多怀疑,因为蒙特卡洛积分的不一致性造成的噪点似乎很难避免,而基于辐射度的方法,至少在简单场景下能做到又快又好的渲染。
1984 年,Cook,Porter 和 Carpenter 提出了分布式光线追踪,把 Whitted 的算法推广到从相机计算运动模糊和焦散模糊、从光亮表面计算模糊反射,以及从面光源计算光照 (1984),展示出光线追踪处理许多重要光效的能力。
两年之后,Kajiya (1986) 提出了路径追踪。他对渲染问题建立了一个严谨的方程(光传播积分等式)并且展示了利用蒙特卡洛积分求解的方法。这个方法需要大量的计算:渲染一张 $256\times 256$ 的两个球体图像需要一台 IBM 4341 计算机(发行售价280,000刀)工作 7 个小时 (1981)。Kajiya 和 von Herzen 共同提出了体渲染方程 (1984),这个方程严谨地描述了光在参与介质中的散射。
Cook et al. 和 Kajiya 的工作都渲染出了前所未见的图像,说明基于物理方法的价值不容小觑。后续的几年间,利用蒙特卡洛进行真实感图像合成的工作层出不穷。书上这里罗列了很多重要的文献和书籍。
PBR 关键的一步是 Veach 的工作 (1997),他发展了蒙特卡洛渲染的关键理论基础,提出多权重采样、双向路径追踪等新算法,以及效率极大提高的 Metropolis 光传播。以 Blinn’s law 为导向,我们相信效率方面重要的进展对这些方法的实际应用至关重要。
大约在二十世纪末,随着计算机性能和并行度的提高,一众研究人员开始追求实时的光线追踪。Wald et al. 提出了一个高度优化后的光线追踪器 (2001b),后续许多工作开始往这个方向卷。尽管有很多不是基于物理的,这些工作也推动了加速结构和几何组件的发展,也让更复杂场景的 PBR 变得可能。
7.2 工业
二十世纪八十年代,计算机性能越来越强,图形学也渐渐被应用到动画和电影制作方面,但是最初仍然只有对算力和内存要求不高的光栅化模型。光栅化方法在内存占用上优于光线追踪,因为它并不需要在内存里时刻维护整个场景。
同时,甚至有许多从业者认为物理正确的渲染没什么必要,因为图形学在那时主要被用来实现无法通过物理手段达到的效果,比如照在人身上比照在背景上更亮的光源。
到二十世纪末,首先在实践中采用基于物理方法的,是那些试图在实拍的场景中加入渲染效果的特效师。Blue Sky Studio 开发了一套基于物理的渲染管线,制作出的作品(例如 1998 年的 Bunny)依然是“前所未见”的,可惜相关的技术细节没有公开。
二十一世纪初,许多工作室使用 mental ray 系统来制作视觉效果。这是一套精妙复杂的全局光照渲染系统,但主要用于产品设计,因此没有影视行业关心的复杂场景和大量贴图处理。
随后是 2001 年 Marcos Fajardo 在 SIGGRAPH 上发表 Arnold 渲染器的早期版本。它基于蒙特卡洛方法,可以在几十分钟内渲染出包含复杂几何、材质和全局光照的图像。这个成果的效率还不能胜任当时的电影制作,但是展示出了许多创新点。
索尼在 Fajardo 的 Arnold 基础上开发出了一套产品化的 PBR 系统,致力于高效的运动模糊、可编程着色、复杂场景和场景几何延迟加载,以及纹理缓存等重要问题。
随后是皮克斯的 RenderMan 渲染器。它支持混合光栅化和全新的复杂场景全局光照算法,随后在 pbrt 的架构下被重构成基于物理的 ray tracer。
诸多基于蒙特卡洛方法的 PBR 之所以在生产领域取得成功,是因为它们提高了艺术家的生产效率。以下是一些关键因素:
- “傻瓜化”的算法通过调整 spp 就可以在渲染质量和时间之间做取舍,即适合快速预览中间结果,也适合精细制作最终成品,这相比于光栅化有很大进步。
- 在基于物理的模型下设计表面材质更加简单,而以前的反射模型由于不保证能量守恒,经常需要设置很不合理的参数,而且不能在其他光照下表现一致。
- 阴影,尤其是软阴影,在光线追踪下更加真实,而之前的模型还需要阴影贴图等等参数。光线弹跳和柔光效果也可以由算法保证,而非艺术家手调。