1.5 使用和理解代码(Using and Understanding the Code)
pbrt 源代码分发可从 pbrt.org 获取。该网站还包括额外的文档、使用 pbrt 渲染的图像、示例场景、勘误表以及错误报告系统的链接。我们鼓励您访问该网站并订阅 pbrt 邮件列表。
pbrt 是用 C++编写的,但我们努力限制使用语言的复杂特性来使其对非 C++专家更易理解。紧密围绕核心语言特性也有助于系统的可移植性。我们会在适用时利用 C++的扩展标准库,但在文本中不会讨论对标准库函数调用的语义。我们期望读者在必要时查阅标准库的文档。
我们将偶尔在书中省略 pbrt 的源代码的短部分。例如,当有多个几乎相同的代码需要处理时,我们将展示一个案例,并说明其余案例的代码已从文本中省略。默认的类构造函数通常不显示,文本中也不包括每个源文件开头的各种 #include 指令等细节。所有省略的代码可以在 pbrt 源代码分发中找到。
1.5.1 源代码组织(Source Code Organization)
用于构建 pbrt 的源代码位于 pbrt 发行版中的 src 目录下。在该目录中有 src/ext ,其中包含 pbrt 使用的各种第三方库的源代码,以及 src/pbrt ,其中包含 pbrt 的源代码。我们在书中不会讨论第三方库的实现。
src/pbrt 目录中的源文件主要由各种接口类型的实现组成。例如, shapes.h 和 shapes.cpp 实现了 Shape 接口, materials.h 和 materials.cpp 实现了材质,等等。该目录还包含解析 pbrt 的场景描述文件的源代码。
src/pbrt 中的 pbrt.h 头文件是系统中所有其他源文件包含的第一个文件。它包含了一些宏和广泛使用的前向声明,因此我们尽量保持其简短,并最小化它包含的其他头文件数量,以提高编译时间效率。
src/pbrt 目录还包含多个子目录。它们具有以下作用:
- base : 头文件定义了表 1.1 中列出的 12 种常见类型的接口( Primitive 和 Integrator 仅限 CPU 运行,因此在 cpu 目录中的文件中定义)。
- cmd : 包含为 pbrt 构建的可执行文件的 main() 函数的源文件。 (除了 pbrt 可执行文件之外,还包括 imgtool ,它执行各种图像处理操作,以及 pbrt_test ,它包含单元测试。)
- cpu : CPU 特定代码,包括 Integrator 的实现。
- gpu : GPU 特定代码,包括在 GPU 上分配内存和启动工作的函数。
- util : 底层工具程序代码,其中大部分与渲染无关。
- wavefront : WavefrontPathIntegrator的实现 ,该内容在第 15 章中介绍。该积分器可在 CPU 和 GPU 上运行。
1.5.2 命名约定(Naming Conventions)
函数和类通常使用驼峰命名法命名,每个单词的首字母大写且没有空格分隔。一个例外是某些容器类的方法,当它们具有相应的功能时,遵循 C++标准库的命名约定(例如, size() 和 begin() 和 end() 用于迭代器)。变量也使用驼峰命名法,但首字母小写,除了少数全局变量。
我们还尝试在命名中匹配数学符号:例如,我们使用变量 p 表示点 \( \text{p} \) ,使用 w 表示方向 \( \omega \) 。我们有时会在变量的末尾添加一个 p 来表示带撇号的符号: wp 表示 \( \omega' \) 。下划线用于表示方程中的下标:例如 theta_o 表示 \( \theta_\text{o} \) 。
然而,我们对下划线的使用并不完全一致。短变量名通常省略下划线——我们使用 wi 表示 \( \omega_\text{i} \) ,并且我们已经看到 Li 用于 \( L_\text{i} \) 。我们有时也使用下划线将一个单词与小写数学符号分开。例如,我们使用 Sample_f 表示一个对函数 \( f \) 进行采样的方法,而不是 Samplef ,这样会更难阅读,或者 SampleF ,这会模糊与函数 \( f \) 的联系(“函数 \( F \) 是在哪里定义的?”)。
1.5.3 指针还是引用?(Pointer or Reference?)
C++ 提供了两种不同的机制来将对象引用传递给函数或方法:指针和引用。如果函数参数不打算作为输出变量,可以使用任一方式来节省在栈上传递整个结构的开销。在 pbrt 中的约定是,当参数会被函数或方法完全改变时使用指针,当其内部状态会被改变但不会完全重新初始化时使用引用,而当参数根本不会被改变时使用 const 引用。这个规则的一个重要例外是,当我们希望能够传递 nullptr 来指示参数不可用或不应使用时,我们将始终使用指针。
1.5.4 抽象与效率(Abstraction versus Efficiency)
在设计软件系统的接口时,主要的关注点之一是合理权衡抽象与效率。例如,许多程序员严格地将所有类中的所有数据设为 private ,并提供方法来获取或修改数据项的值。对于简单类(例如, Vector3f ),我们认为这种方法无谓地隐藏了实现的一个基本属性——该类持有三个浮点坐标——我们可以合理地期望它们永远不会改变。当然,不使用隐藏信息并暴露所有类内部的细节会导致代码维护的噩梦,但我们认为在系统中明智地暴露基本设计决策没有任何问题。例如, Ray 用一个点、一个向量、一个时间和它所处的介质来表示的事实是一个不需要隐藏在抽象层背后的决策。当这些细节被暴露时,其他地方的代码更简洁且更易于理解。
在编写软件系统并进行这些权衡时,重要的一点是要考虑系统的预期最终规模。 pbrt 大约有 70,000 行代码,永远不会增长到一百万行代码;这一事实应体现在系统中使用的信息隐藏量上。设计接口以适应一个复杂度更高的系统将浪费程序员的时间(并可能导致运行时效率低下)。
1.5.5 pstd
我们在 pstd 命名空间中重新实现了 C++ 标准库的一个子集;这是为了能够在 CPU 和 GPU 上可互换地使用这些部分。为了阅读 pbrt 的源代码, pstd 中的任何内容都提供与 std 中相应实体相同的功能、类型和方法。因此,我们在此文本中将不记录 pstd 的使用。
1.5.6 分配器(Allocators)
几乎所有在 pbrt 中表示场景的对象的动态内存分配都是使用提供给对象创建方法的 Allocator 实例进行的。在 pbrt 中, Allocator 是 C++标准库的 pmr::polymorphic_allocator 类型的简写。它的定义在 pbrt.h 中,以便所有其他源文件都可以使用。
/** 定义 Allocator */
using Allocator = pstd::pmr::polymorphic_allocator<std::byte>;
std::pmr::polymorphic_allocator 实现提供了一些分配和释放对象的方法。下列三种方法在 pbrt 中被广泛使用:†(由于 pmr::polymorphic_allocator 是最近才添加到 C++ 中且未被广泛使用,于是我们破例再此列出标准库功能)
void *allocate_bytes(size_t nbytes, size_t alignment);
template <class T> T *allocate_object(size_t n = 1);
template <class T, class... Args> T *new_object(Args &&... args);
第一个, allocate_bytes() 分配指定字节数量的内存。接下来, allocate_object() 分配一个指定类型 T 的包含 n 个对象的数组,使用其默认构造函数初始化每个对象。最后一个方法, new_object() ,分配一个类型为 T 的单个对象,并使用提供的参数调用其构造函数。每种分配类型都有相应的释放方法: deallocate_bytes() , deallocate_object() 和 delete_object() 。
与 C++ 标准库中的数据结构使用分配器相关的一个棘手细节是,一旦容器的构造函数运行完毕,其分配器是固定的。因此,如果一个容器被赋值给另一个容器,目标容器的分配器不会改变,即使它存储的所有值都已更新。(即使在 C++ 的移动语义下也是如此。)因此,常见的情况是,在 pbrt 中的对象构造函数在成员初始化列表中传递分配器给它们存储的容器,即使它们尚未准备好设置存储在其中的值。
使用显式内存分配器而不是直接调用 new 和 delete 有几个优点。它不仅使跟踪已分配内存的总量变得简单,而且还使替换为许多小分配优化的分配器变得容易,这在第 7 章构建加速结构时非常有用。以这种方式使用分配器还使在使用 GPU 渲染时将场景对象存储在 GPU 可见的内存中变得简单。
1.5.7 动态调度(Dynamic Dispatch)
如第 1.3 节所述,虚函数通常不用于 pbrt 中的多态类型的动态调度(主要例外是 Integrator )。相反, TaggedPointer 类用于表示指向指定类型集合之一的指针;它包括运行时类型识别及随之而来的动态调度的机制。(其实现可以在附录 B.4.4 中找到。)使用它的原因有两个。
首先,在 C++中,继承自抽象基类的对象实例包含一个隐藏的虚函数表指针,用于解析虚函数调用。在大多数现代系统中,这个指针使用八个字节的内存。虽然八个字节看起来不算多,但我们发现,在使用之前版本的 pbrt 渲染复杂场景时,仅用于形状和图元的虚函数指针就会消耗大量内存。使用 TaggedPointer 类时,类型信息没有增量存储成本。
虚函数表的另一个问题是它们存储指向可执行代码的函数指针。当然,这正是它们应该做的,但这一特性意味着虚函数表可以有效地用于来自 CPU 或 GPU 的方法调用,但不能同时用于两者,因为不同处理器的可执行代码存储在不同的内存位置。当使用 GPU 进行渲染时,能够从两个处理器调用方法是很有用的。
对于所有仅调用多态对象方法的代码,使用 pbrt 的 TaggedPointer 代替虚函数没有任何区别,除了方法调用是使用 . 运算符进行的,就像用 C++ 引用一样。第 4.5.1 节介绍了 Spectrum ,这是书中出现的第一个基于 TaggedPointer 的类,关于 pbrt 的动态调度方案的实现有更多细节。
1.5.8 代码优化(Code Optimization)
我们试图通过使用精心选择的算法而不是局部微优化来使 pbrt 高效,以便系统更容易理解。然而,效率是渲染的一个不可或缺的部分,因此我们在整本书中都会讨论性能问题。
对于 CPU 和 GPU 而言,处理器性能的增长速度持续快于从主内存加载数据到处理器的速度。这意味着等待从内存中获取值可能成为一个主要的性能瓶颈。我们讨论的最重要的优化与最小化不必要的内存访问以及以有助于一致访问模式的方式组织算法和数据结构有关;关注这些问题可以比减少执行的总指令数更大程度地加快程序执行速度。
1.5.9 调试和日志记录(Debugging and Logging)
调试渲染器可能很具挑战性,特别是在结果大多数时候是正确的但并非总是正确的情况下。 pbrt 包含了许多便利工具来简化调试。
最重要的之一是一套单元测试。我们发现单元测试在 pbrt 的开发中是不可或缺的,因为它提供了被测试功能很可能是正确的保证。有了这种保证,可以减轻调试时诸如“我能确定这里使用的哈希表不是我错误的来源吗?”这样的问题背后的担忧。或者,失败的单元测试几乎总是比渲染器生成的不正确图像更容易调试;许多测试是在调试 pbrt 的过程中添加的。文件 code.cpp 的单元测试位于 code_tests.cpp 。所有单元测试都是通过调用 pbrt_test 可执行文件来执行的,特定的测试可以通过命令行选项进行选择。
在 pbrt 代码库中有许多断言,其中大多数未包含在书本文本中。这些断言检查在运行时绝不应该为真的条件,如果发现为真,则会发出错误并立即退出。(有关 pbrt 中使用的断言宏的定义,请参见第 B.3.6 节。)失败的断言提供了错误来源的初步线索;像单元测试一样,断言有助于集中调试,至少提供了一个起点。在 pbrt 中一些计算开销较大的断言仅在调试构建中启用;如果渲染器崩溃或以其他方式产生不正确的输出,尝试运行调试构建以查看这些额外的断言是否失败并提供线索是值得的。
我们还努力使在给定像素样本下执行 pbrt 具有确定性。调试渲染器的一大挑战是有些崩溃只在渲染计算几分钟或几小时后发生。通过确定性执行,可以在单个像素样本处重新启动渲染,以更快地返回到崩溃点。此外,在崩溃时, pbrt 将打印一条消息,例如“在 pixel (16, 27) sample 821 处渲染失败。使用 --debugstart 16,27,821
进行调试”。“debugstart”后打印的值取决于所使用的积分器,但足以在接近崩溃的地方重新启动其计算。
最后,在调试过程中,打印出存储在数据结构中的值通常是有用的。我们为几乎所有的 pbrt 类实现了 ToString() 方法。它返回 std::string ,以便在程序执行期间轻松打印其完整的对象状态。此外, pbrt 的自定义 Printf() 和 StringPrintf() 函数(第 B.3.3 节)在格式字符串中找到 %s 指定符时,会自动使用 ToString() 返回的对象字符串。
1.5.10 并行性和线程安全(Parallelism and Thread Safety)
在 pbrt (对于大多数光线追踪器来说都是如此),在渲染时绝大多数数据是只读的(例如,场景描述和纹理图像)。场景文件的解析和场景表示在内存中的创建主要是通过单线程执行完成的,因此在执行的这一阶段几乎没有同步问题。†在渲染过程中,多个线程对所有只读数据的并发读取在 CPU 和 GPU 上都没有问题;我们只需关注内存中数据被修改的情况。
作为一般规则,系统中的底层类和结构不是线程安全的。例如, Point3f 类存储三个 float 值以表示 3D 空间中的一个点,让多个线程同时调用修改它的方法是不安全的。(当然,多个线程可以同时将 Point3f 作为只读数据使用。)使 Point3f 线程安全的运行时开销将对性能产生重大影响,而回报却微乎其微。
对于像 Vector3f 、 Normal3f 、 SampledSpectrum 、 Transform 、 Quaternion 和 SurfaceInteraction 这样的类也是如此。这些类通常是在场景构建时创建的,然后作为只读数据使用,或者在渲染过程中在栈上分配,仅由单个线程使用。
实用类 ScratchBuffer (用于高性能临时内存分配)和 RNG (伪随机数生成)也不适合多个线程使用;这些类存储的状态在调用其方法时会被修改,而保护其状态修改的互斥开销相对于它们执行的计算量来说过于庞大。因此,在之前的 ImageTileIntegrator::Render() 方法中, pbrt 在栈上为每个线程分配这些类的实例。
除了两个例外,表 1.1 中列出的基本类型的实现是安全的,可以被多个线程同时使用。只需稍加注意,通常可以简单地实现这些基类的新实例,以便它们在其方法中不修改任何共享状态。
第一个例外是 Light Preprocess() 方法实现。这些方法在场景构建期间由系统调用,通常会修改其对象中的共享状态。因此,允许实现者假设只有一个线程会调用这些方法是有帮助的。(这与考虑到计算密集型的这些方法实现可能使用 ParallelFor() 来并行化其计算是一个独立的问题。)
第二个例外是 Sampler 类实现;它们的方法也不被期望是线程安全的。这是另一个例子,在这种情况下,这一要求会对性能和可扩展性产生过大的影响;许多线程同时尝试从单个 Sampler 获取样本会限制系统的整体性能。因此,如第 1.3.4 节所述,为每个渲染线程使用 Sampler::Clone() 创建一个唯一的 Sampler 。
pbrt 中的所有独立函数都是线程安全的(前提是多个线程不将指针传递给相同的数据)。
1.5.11 扩展系统(Extending the System)
我们编写本书和构建 pbrt 系统的目标之一是让开发者和研究人员更容易地实验新的(或旧的!)渲染创意。计算机图形学的一大乐趣是编写新的软件以生成新的图像;即使是对系统的小改动也能带来有趣的实验体验。本书中的练习建议对系统进行许多更改,从小调整到大型开放式研究项目。附录 C 的 C.4 节提供了有关添加表 1.1 中列出的接口新实现的机制的更多信息。
1.5.12 Bugs
尽管我们通过广泛的测试尽力使 pbrt 尽可能正确,但不可避免地仍然存在一些错误。
如果您认为在系统中发现了一个错误,请执行以下操作:
- 使用未修改的最新版本 pbrt 重现该错误。
- 检查在线讨论论坛和 pbrt.org 的缺陷跟踪系统。您的问题可能是一个已知的错误,或者可能是一个常被误解的功能。
- 尝试找到最简单的测试用例来演示这个错误。许多错误可以通过仅几行的场景描述文件来演示,使用简单场景进行调试比复杂场景要容易得多。
- 通过我们的在线缺陷跟踪系统提交详细的缺陷报告。确保您包含演示缺陷的场景文件,以及您认为 pbrt 在场景中表现不正确的详细描述。如果您能提供修复缺陷的补丁,那就更好了!
我们将定期更新 pbrt 源代码库,修复错误并进行小幅增强。(请注意,我们通常会让错误报告积累几个月再处理;不要将此视为我们不重视它们的迹象!)然而,我们不会对 pbrt 源代码进行重大更改,以确保它与本书中描述的系统保持一致。