1.3 pbrt:系统概述(System Overview

pbrt 是使用标准面向对象技术构建的:对于每一个基本类型,系统指定了实现该类型必须满足的接口。例如, pbrt 要求在场景中表示几何图形的具体形状类型,需要实现一组方法,比如返回形状边界框(bounding box)的方法,和测试与给定光线是否相交的方法。反过来,系统中的大部分功能可以完全基于这些接口来实现;例如,检查光源与被着色点之间是否存在遮挡物的代码,只需调用形状的相交方法,而不必考虑场景中存在的具体形状类型。

表 1.1:主要接口类型。 pbrt 的大部分是基于 14 种关键基础类型实现的,如下所示。可以轻松地将每种实现添加到系统中以扩展其功能。

基础类型源文件章节
Spectrumbase/spectrum.h, util/spectrum.h, util/spectrum.cpp4.5
Camerabase/camera.h, cameras.h, cameras.cpp5.1
Shapebase/shape.h, shapes.h, shapes.cpp6.1
Primitivecpu/primitive.h, cpu/primitive.cpp,cpu/accelerators.h, cpu/accelerators.cpp7.1
Samplerbase/sampler.h, samplers.h, samplers.cpp8.3
Filterbase/filter.h, filters.h, filters.cpp8.8.1
BxDFbase/bxdf.h, bxdfs.h, bxdfs.cpp9.1.2
Materialbase/material.h, materials.h, materials.cpp10.5
FloatTexture
SpectrumTexturebase/texture.h, textures.h, textures.cpp10.3
Mediumbase/medium.h, media.h, media.cpp11.4
Lightbase/light.h, lights.h, lights.cpp12.1
LightSamplerbase/lightsampler.h, lightsamplers.h, lightsamplers.cpp12.6
Integratorcpu/integrators.h, cpu/integrators.cpp1.3.3

这些关键基础类型总共有 14 种,汇总在表 1.1 中。将这些类型中的一个新实现添加到系统中是简单直接的;该实现必须提供所需的方法,必须编译并链接到可执行文件中,并且场景对象创建例程(“routine”(例程)通常指的是一段可以被重复调用的代码)必须修改,以便在解析场景描述文件时根据需要创建对象的实例。C.4 节更详细地讨论了如何扩展系统功能。

在 C++中,传统做法是使用定义纯虚函数的抽象基类为这些类型指定接口,并让实现类从这些基类继承并实现所需的虚函数。然后,编译器会负责生成代码,根据基类类型的指针调用适当的方法。这种方法在 pbrt 的前三个版本中使用,但由于在本版本中增加对图形处理单元(GPU)渲染的支持,促使采用一种更具可移植性的 基于标签调度(tag-based dispatch) 的方法,其中每个特定类型的实现被分配一个唯一的整数,以确定其在运行时的类型。(有关此主题的更多信息,请参见第 1.5.7 节。)在 pbrt 中以这种方式实现的多态(polymorphic)类型都在 base/ 目录中的头文件中定义。

此版本的 pbrt 能够在支持 C++17 并提供光线相交测试 API 的 GPU 上运行。我们精心设计了系统,使得几乎所有的 pbrt 实现都可以在 CPU 和 GPU 上运行,正如第 2 章到第 12 章中所展示的。因此,在接下来的大部分内容中,我们将很少提及 CPU 与 GPU 的区别。

pbrt 中,CPU 和 GPU 渲染路径之间的主要区别在于它们的数据流、如何实现高效化并行以及各部分如何连接起来。本章后面描述的基本渲染算法以及第 13 章和第 14 章中描述的光传输算法仅在 CPU 上可用。GPU 渲染管线在第 15 章中讨论,尽管它也可以在 CPU 上运行(但效率不如针对 CPU 的光传输算法)。

虽然 pbrt 在当前实现中可以很好地渲染许多场景,但它经常被学生、研究人员和开发者扩展。本节中有许多来自这些努力的成果展示。图 1.131.141.15 都是由一门渲染课程的学生创作的,最终的课堂项目是通过新功能扩展 pbrt ,以渲染之前无法渲染的图像。这些图像是该课程中最优秀的作品之一。

图 1.13: Guillaume Poncin 和 Pramod Sharma 以多种方式扩展了 pbrt ,实现了多种复杂的渲染算法,以制作这幅斯坦福大学的 CS348b 渲染比赛中的获奖图像。树木采用 L系统(L-systems)进行程序建模,发光图像处理滤镜增强了树上灯光的真实感,雪采用元球(metaballs)进行程序建模,次表面散射算法通过考虑光在雪下传播一段距离后再离开的效果,使雪呈现出真实的外观。

图 1.14: 阿贝·戴维斯、戴维·雅各布斯和郑敏·白渲染了这幅惊人的冰洞图像,赢得了 2009 年斯坦福大学 CS348b 渲染比赛的大奖。他们首先实现了冰川化(glaciation)的物理过程模拟,这一过程是指雪在多年间落下、融化并重新冻结,形成分层的冰层。然后,他们模拟了由于融水径流导致的冰的侵蚀,随后生成了冰的几何模型。体积内的光散射通过体积光子映射进行模拟;冰的蓝色完全是由于对冰体积中光的波长依赖吸收的建模。

图 1.15: 陈林孟、霍伯特·张和志仁·朱渲染了这张看起来美味的棉花糖在茶杯中的图像,以赢得 2018 年斯坦福 CS348b 渲染比赛的大奖。他们使用多层曲线建模棉花糖,然后在中心填充参与介质,以高效地模拟其内部的散射。

图 1.16: 马丁·卢比奇使用 Blender 模拟了这个奥地利皇室皇冠的场景;最初使用 LuxRender 渲染,该软件最初是 pbrt-v1 代码库的一个分支。皇冠由大约 350 万个三角形组成,受到六个区域光源的照明,这些光源的发射光谱基于来自真实光源的测量数据。最初在四核 CPU 上以每像素 1280 个样本的设置渲染,计算耗时 73 小时。在现代 GPU 上, pbrt 以相同的采样率可以在 184 秒内渲染此场景。

1.3.1 执行阶段(Phases of Execution)

pbrt 可以概念上分为三个执行阶段。在第一阶段,它解析用户提供的场景描述文件。场景描述是一个文本文件,指定构成场景的几何形状、它们的材质属性、照亮它们的光源、虚拟相机在场景中的位置,以及系统中使用的所有单个算法的参数。场景文件格式在 pbrt 网站 pbrt.org 上有详细文档。

解析阶段的结果是一个 BasicScene 类的实例,它存储场景规范,但尚未以适合渲染的形式存储。在执行的第二阶段, pbrt 创建与场景对应的特定对象;例如,如果指定了透视投影,则在此阶段会创建一个与指定视图参数对应的 PerspectiveCamera 对象。之前版本的 pbrt 将这两个阶段混合在一起,但在这个版本中我们将它们分开,因为 CPU 和 GPU 的渲染路径在内存中表示场景的某些方式不同。

在第三阶段,执行主渲染循环。这个阶段是 pbrt 通常花费大部分运行时间的地方,本书的大部分内容都专注于在这个阶段执行的代码。为了协调渲染, pbrt 实现了一个 积分器(integrator),之所以这样命名是因为它的主要任务是评估(evaluate)方程 (1.1) 中的积分。

1.3.2 pbrt 的 main函数(pbrt’s main() Function)

pbrt 可执行文件的 main() 函数在 pbrt 源码的 cmd/pbrt.cpp 中定义,位于 pbrt 分支的 src/pbrt 的目录中。它只有大约一百五十行代码,其中大部分用于处理命令行参数和相关的簿记。

/** 程序入口 */
int main(int argc, char *argv[]) {
    /** 将命令行参数转换为字符串向量 */
       std::vector<std::string> args = GetCommandLineArguments(argv);
 
    /** 为要解析的命令行申明变量 */
       PBRTOptions options;
       std::vector<std::string> filenames;
 
    /** 处理命令行参数 */
       for (auto iter = args.begin(); iter != args.end(); ++iter) {
           if ((*iter)[0] != '-') {
               filenames.push_back(*iter);
               continue;
           }
         
           auto onError = [](const std::string &err) {
               usage(err);
               exit(1);
           };
         
           std::string cropWindow, pixelBounds, pixel, pixelMaterial;
           if (ParseArg(&iter, args.end(), "cropwindow", &cropWindow, onError)) {
               std::vector<Float> c = SplitStringToFloats(cropWindow, ',');
               if (c.size() != 4) {
                   usage("Didn't find four values after --cropwindow");
                   return 1;
               }
               options.cropWindow = Bounds2f(Point2f(c[0], c[2]), Point2f(c[1], c[3]));
           } else if (ParseArg(&iter, args.end(), "pixel", &pixel, onError)) {
               std::vector<int> p = SplitStringToInts(pixel, ',');
               if (p.size() != 2) {
                   usage("Didn't find two values after --pixel");
                   return 1;
               }
               options.pixelBounds =
                   Bounds2i(Point2i(p[0], p[1]), Point2i(p[0] + 1, p[1] + 1));
           } else if (ParseArg(&iter, args.end(), "pixelbounds", &pixelBounds, onError)) {
               std::vector<int> p = SplitStringToInts(pixelBounds, ',');
               if (p.size() != 4) {
                   usage("Didn't find four integer values after --pixelbounds");
                   return 1;
               }
               options.pixelBounds = Bounds2i(Point2i(p[0], p[2]), Point2i(p[1], p[3]));
           } else if (ParseArg(&iter, args.end(), "pixelmaterial", &pixelMaterial, onError)) {
               std::vector<int> p = SplitStringToInts(pixelMaterial, ',');
               if (p.size() != 2) {
                   usage("Didn't find two values after --pixelmaterial");
                   return 1;
               }
               options.pixelMaterial = Point2i(p[0], p[1]);
           } else if (
       #ifdef PBRT_BUILD_GPU_RENDERER
               ParseArg(&iter, args.end(), "gpu", &options.useGPU, onError) ||
               ParseArg(&iter, args.end(), "gpu-device", &options.gpuDevice, onError) ||
       #endif
               ParseArg(&iter, args.end(), "debugstart", &options.debugStart, onError) ||
               ParseArg(&iter, args.end(), "disable-pixel-jitter", &options.disablePixelJitter,
                       onError) ||
               ParseArg(&iter, args.end(), "disable-texture-filtering",
                       &options.disableTextureFiltering, onError) ||
               ParseArg(&iter, args.end(), "disable-wavelength-jitter", &options.disableWavelengthJitter,
                       onError) ||
               ParseArg(&iter, args.end(), "displacement-edge-scale",
                       &options.displacementEdgeScale, onError) ||
               ParseArg(&iter, args.end(), "display-server", &options.displayServer, onError) ||
               ParseArg(&iter, args.end(), "force-diffuse", &options.forceDiffuse, onError) ||
               ParseArg(&iter, args.end(), "format", &format, onError) ||
               ParseArg(&iter, args.end(), "log-level", &logLevel, onError) ||
               ParseArg(&iter, args.end(), "log-utilization", &options.logUtilization, onError) ||
               ParseArg(&iter, args.end(), "log-file", &options.logFile, onError) ||
               ParseArg(&iter, args.end(), "mse-reference-image", &options.mseReferenceImage, onError) ||
               ParseArg(&iter, args.end(), "mse-reference-out", &options.mseReferenceOutput, onError) ||
               ParseArg(&iter, args.end(), "nthreads", &options.nThreads, onError) ||
               ParseArg(&iter, args.end(), "outfile", &options.imageFile, onError) ||
               ParseArg(&iter, args.end(), "pixelstats", &options.recordPixelStatistics, onError) ||
               ParseArg(&iter, args.end(), "quick", &options.quickRender, onError) ||
               ParseArg(&iter, args.end(), "quiet", &options.quiet, onError) ||
               ParseArg(&iter, args.end(), "render-coord-sys", &renderCoordSys, onError) ||
               ParseArg(&iter, args.end(), "seed", &options.seed, onError) ||
               ParseArg(&iter, args.end(), "spp", &options.pixelSamples, onError) ||
               ParseArg(&iter, args.end(), "stats", &options.printStatistics, onError) ||
               ParseArg(&iter, args.end(), "toply", &toPly, onError) ||
               ParseArg(&iter, args.end(), "wavefront", &options.wavefront, onError) ||
               ParseArg(&iter, args.end(), "write-partial-images", &options.writePartialImages,
                       onError) ||
               ParseArg(&iter, args.end(), "upgrade", &options.upgrade, onError)) {
               // success
           } else if (*iter == "--help" || *iter == "-help" || *iter == "-h") {
               usage();
               return 0;
           } else {
               usage(StringPrintf("argument \"%s\" unknown", *iter));
               return 1;
           }
       }

    /** 初始化 pbrt */
       InitPBRT(options);

    /** 解析提供的场景描述文件 */
       BasicScene scene;
       BasicSceneBuilder builder(&scene);
       ParseFiles(&builder, filenames);

    /** 渲染场景 */
       if (Options->useGPU || Options->wavefront)
           RenderWavefront(scene);
       else
           RenderCPU(scene);

    /** 渲染场景后的清理 */
       CleanupPBRT();

}

相较于直接操作提供给 main() 函数的 argv 值, pbrt 将提供的参数转换为 std::string 的vector。这样做不仅是为了 string 类的更好的便利性,还支持非 ASCII 字符集。B.3.2 节提供了有关字符编码及其在 pbrt 中处理的更多信息。

/** 将命令行参数转换为字符串向量 */
std::vector<std::string> args = GetCommandLineArguments(argv);

我们将在书中仅包含一些 main 函数片段的定义。某些片段,例如处理用户提供的命令行参数的片段,既简单又长,不值得增加几页书的长度。然而,我们将包含声明存储选项值变量的片段。

/** 为要解析的命令行的申明变量 */
PBRTOptions options;
std::vector<std::string> filenames;

GetCommandLineArguments() 函数和 PBRTOptions 类型出现在页面边缘的 迷你索引(mini-index) 中,并附有它们定义所在页面的页码。迷你索引指向几乎所有在每页中使用或提及的函数、类、方法和成员变量的定义。(为了简洁起见,我们将从迷你索引中省略非常广泛使用的类,如 Ray ,以及在前几页刚刚介绍的类型或方法。)

PBRTOptions 类存储各种渲染选项,这些选项通常更适合在命令行中指定,而不是在场景描述文件中指定。例如, pbrt 在渲染过程中应该以何种详细程度报告其进度。它被传递给 InitPBRT() 函数,该函数汇总在进行其他工作之前必须执行的各种系统级初始化任务。例如,它初始化日志系统并启动一组用于 pbrt 并行化的线程。

/** 初始化 pbrt */
InitPBRT(options);

在参数被解析和验证后, ParseFiles() 函数接管处理前面描述的三个执行阶段中的第一个。借助于两个类 BasicSceneBuilderBasicScene ,它们分别在 C.2C.3 节中描述,它循环遍历提供的文件名,逐个解析每个文件。如果 pbrt 在没有提供文件名的情况下运行,它会从标准输入中查找场景描述。本书中不会描述场景描述文件的标记化和解析机制,但解析器的实现可以在 src/pbrt 目录中的 parser.hparser.cpp 文件中找到。

/** 解析提供的场景描述文件 */
BasicScene scene;
BasicSceneBuilder builder(&scene);
ParseFiles(&builder, filenames);

在场景描述被解析后,将调用两个函数中的一个来渲染场景。 RenderWavefront() 同时支持 CPU 和 GPU 渲染路径,能够并行处理大约一百万个图像采样(image samples)。它是第 15 章的主题。 RenderCPU() 使用 Integrator 的实现来渲染场景,且仅支持在 CPU 上运行。它的并行性远低于 RenderWavefront() ,仅并行渲染与 CPU 线程数量相同的图像采样。

这两个函数首先都将 BasicScene 转换为适合高效渲染的形式,然后将控制权传递给特定处理器的积分器(processor-specific integrator)。(有关此过程的更多信息,请参见 C.3 节。)我们暂时略过这一转换的细节,以便专注于 RenderCPU() 中的主要渲染循环,这要有趣得多。为此,我们将高效的场景表示视为已获取。

/** 渲染场景 */
if (Options->useGPU || Options->wavefront)
    RenderWavefront(scene);
else
    RenderCPU(scene);

在图像渲染完成后, CleanupPBRT() 负责优雅地关闭系统,包括例如终止由 InitPBRT() 启动的线程。

/** 渲染场景后的清理 */
CleanupPBRT();

1.3.3 积分器接口(Integrator Interface)

RenderCPU() 渲染路径中,由实现 Integrator 接口的类的实例负责渲染。由于 Integrator 实现仅在 CPU 上运行,我们将定义 Integrator 作为具有纯虚方法的标准基类。 Integrator 类和各种实现分别定义在文件 cpu/integrators.hcpu/integrators.cpp 中。

/** 积分器定义 */
class Integrator {
  public:
    /** 积分器公有方法 */
       virtual ~Integrator();
       
       static std::unique_ptr<Integrator> Create(const std::string &name,
                                                   const ParameterDictionary &parameters,
                                                   Camera camera, Sampler sampler,
                                                   Primitive aggregate,
                                                   std::vector<Light> lights,
                                                   const RGBColorSpace *colorSpace,
                                                   const FileLoc *loc);
       
       virtual std::string ToString() const = 0;
       virtual void Render() = 0;
       pstd::optional<ShapeIntersection> Intersect(const Ray &ray,
                                                   Float tMax = Infinity) const;
       bool IntersectP(const Ray &ray, Float tMax = Infinity) const;
       bool Unoccluded(const Interaction &p0, const Interaction &p1) const {
           return !IntersectP(p0.SpawnRayTo(p1), 1 - ShadowEpsilon);
       }
       SampledSpectrum Tr(const Interaction &p0, const Interaction &p1,
                           const SampledWavelengths &lambda) const;

    /** 积分器公有成员 */
       Primitive aggregate;
       std::vector<Light> lights;
       std::vector<Light> infiniteLights;

  protected:
    /** 积分器保护方法 */
       Integrator(Primitive aggregate, std::vector<Light> lights)
           : aggregate(aggregate), lights(lights) {
           // 积分器构造函数实现
           Bounds3f sceneBounds = aggregate ? aggregate.Bounds() : Bounds3f();
           for (auto &light : lights) {
               light.Preprocess(sceneBounds);
               if (light.Type() == LightType::Infinite)
                   infiniteLights.push_back(light);
           }
       }

};

Integrator 基类构造函数接受一个呈现场景中的所有几何对象的Primitive,以及一个包含场景中的所有光源的数组。

/** 积分器保护方法 */
Integrator(Primitive aggregate, std::vector<Light> lights)
    : aggregate(aggregate), lights(lights) {
        // 积分器构造函数实现
       Bounds3f sceneBounds = aggregate ? aggregate.Bounds() : Bounds3f();
       for (auto &light : lights) {
           light.Preprocess(sceneBounds);
           if (light.Type() == LightType::Infinite)
               infiniteLights.push_back(light);
       }
}

场景中的每个几何对象都由一个 Primitive 表示,该对象主要负责结合指定其几何形状的 Shape ,和描述其外观的 Material(例如,对象的颜色,或它的表面是哑光还是光泽的)。反过来,场景中的所有几何图元都被收集到一个存储在 Integrator::aggregate 成员变量中的单一聚合图元(single aggregate primitive)中。这个聚合是一个特殊类型的图元,它本身持有对许多其他图元的引用。聚合实现将场景中的所有图元存储在一个加速数据结构中,从而减少与距离给定光线较远的图元进行不必要的光线相交测试的次数。由于它实现了 Primitive 接口,因此对系统的其余部分来说,它与单个图元没有区别。

/** 积分器公共成员 */
Primitive aggregate;
std::vector<Light> lights;

场景中的每个光源由实现 Light 接口的对象表示,该接口允许光源指定其形状和发射的能量分布。有些光源需要知道整个场景的边界框,而在它们首次创建时这些信息是不可用的。因此, Integrator 构造函数调用它们的 Preprocess() 方法,提供这些边界。此时任何“无限”的光源也会存储在一个单独的数组中。这种光源将在第 12.5 节中介绍,为无限远的光源建模,例如,这是一个用于模拟地球表面接收到的天光(skylight)的合理的模型。有时仅循环遍历这些无限光源是有必要的,对于有成千上万光源的场景,循环遍历所有光源以找到这些无限光源是低效的。

// 积分器构造函数实现
Bounds3f sceneBounds = aggregate ? aggregate.Bounds() : Bounds3f();
for (auto &light : lights) {
    light.Preprocess(sceneBounds);
    if (light.Type() == LightType::Infinite)
        infiniteLights.push_back(light);
}
/** 积分器公有成员 */
std::vector<Light> infiniteLights;

Integrators 必须提供 Render() 方法的实现,该方法不接受其他参数。该方法在场景表示初始化完成后由 RenderCPU() 函数调用。积分器的任务是根据聚合图元和光源渲染场景。除此之外,具体的积分器使用其所需的其他类(例如,相机模型)来定义它要渲染的场景。此接口有意设计得非常通用,以允许广泛的实现——例如,可以实现一个 Integrator ,它仅在分布在场景中的稀疏点集上测量光,而不是生成常规的 2D 图像。

/** 积分器公有方法 */
virtual void Render() = 0;

Integrator 类提供了两个与光线-图元相交相关的方法供其子类使用。 Intersect() 接受一条光线和一个最大参数距离 tMax ,在场景中追踪给定的光线,如果在 tMax 之前沿光线有交点的话,返回一个被光线击中最近的图元对应的 ShapeIntersection 对象。( ShapeIntersection 结构在第 6.1.3 节中定义。)需要注意的是,此方法使用类型 pstd::optional 作为返回值,而不是来自 C++ 标准库的 std::optional ;我们在 pstd 命名空间中重新实现了标准库的部分内容,原因在第 1.5.5 节中讨论。

/** 积分器方法定义 */
pstd::optional<ShapeIntersection>
Integrator::Intersect(const Ray &ray, Float tMax) const {
    if (aggregate) return aggregate.Intersect(ray, tMax);
    else           return {};
}

请注意 Intersect() 函数签名中首字母大写的浮点类型 Float :几乎 pbrt 中所有的浮点值都被声明为 Float 。 (唯一的例外是少数情况下需要特定的 32 位 float 或 64 位 double (例如,当将二进制值保存到文件时)。) 根据 pbrt 的编译标志, Floatfloatdouble 的别名,尽管在实践中单精度 float 几乎总是足够的。 Float 的定义在 pbrt.h 头文件中,该文件被 pbrt 中的所有其他源文件包含。

/** Float 浮点类型定义 */
#ifdef PBRT_FLOAT_AS_DOUBLE
    using Float = double;
#else
    using Float = float;
#endif

Integrator::IntersectP()Intersect() 方法密切相关。它检查沿着光线是否存在交点,但仅返回一个布尔值,指示是否找到交点。(其名称中的“P”表示它是一个评估谓词(evaluates a predicate)的函数(在编程中,谓词(predicate)通常指的是一个返回布尔值(true或false)的函数。),使用了 Lisp 编程语言中的常见命名约定。)由于它不需要搜索最近的交点或返回关于交点的额外几何信息, IntersectP() 通常比 Integrator::Intersect() 更高效。此例程用于阴影光线。

/** 积分器方法定义 */
bool Integrator::IntersectP(const Ray &ray, Float tMax) const {
    if (aggregate) return aggregate.IntersectP(ray, tMax);
    else           return false;
}

1.3.4 图像块积分器和主渲染循环(ImageTileIntegrator and the Main Rendering Loop)

在实现一个基本的积分器以模拟光传输来渲染图像之前,我们将定义两个 Integrator 子类,这些子类提供该积分器以及许多后续积分器实现所需的额外通用功能。我们从 ImageTileIntegrator 开始,它继承自 Integrator 。下一节定义 RayIntegrator ,它继承自 ImageTileIntegrator

pbrt 的所有基于 CPU 的积分器都使用相机模型来定义视图参数并渲染图像,并通过将图像分割成块(tile)并让不同的处理器处理不同的块来实现渲染的并行化。因此, pbrt 包含一个 ImageTileIntegrator ,为这些任务提供通用功能。

/** 图像块积分器定义 */
class ImageTileIntegrator : public Integrator {
  public:
    /** 图像块积分器公有方法 */
       ImageTileIntegrator(Camera camera, Sampler sampler,
               Primitive aggregate, std::vector<Light> lights)
           : Integrator(aggregate, lights), camera(camera),
               samplerPrototype(sampler) {}
       void Render();
       virtual void EvaluatePixelSample(Point2i pPixel, int sampleIndex,
           Sampler sampler, ScratchBuffer &scratchBuffer) = 0;
       
  protected:
    /** 图像块积分器保护成员 */
       Camera camera;
       Sampler samplerPrototype;

};

除了聚合和光源, ImageTileIntegrator 构造函数接受一个 Camera,该参数指定视图和镜头参数,如位置、方向、焦距和视场。由相机存储的 Film 处理图像存储。 Camera 类是第 5 章的大部分内容, Film 在第 5.4 节中描述。 Film 负责将最终图像写入文件。

构造函数还接受一个 Sampler ;它的作用更为微妙,但其实现可以显著影响系统生成的图像质量。首先,采样器负责选择图像平面上的点,以确定最初追踪到场景中的光线。其次,它负责提供随机样本值,这些值被积分器用于估计光传输积分的值,方程(1.1)。例如,一些积分器需要选择光源上的随机点,以计算来自区域光源的照明。生成这些样本的良好分布是渲染过程中的一个重要部分,可以显著影响整体效率;这个主题是第 8 章的主要焦点。

/** 图像块积分器公有方法 */
ImageTileIntegrator(Camera camera, Sampler sampler,
        Primitive aggregate, std::vector<Light> lights)
    : Integrator(aggregate, lights), camera(camera),
      samplerPrototype(sampler) {}
/** 图像块积分器保护成员 */
Camera camera;
Sampler samplerPrototype;

对于 pbrt 的所有积分器,每个像素计算的最终颜色基于随机采样算法。如果每个像素的最终值是多个样本的平均值,则图像质量会提高。在样本数量较少时,采样误差(sampling error)表现为图像中的颗粒状高频噪声(grainy high-frequency noise),随着样本数量的增加,误差以可预测的速率下降。(此主题在第 2.1.4 节中有更深入的讨论。)因此, ImageTileIntegrator::Render() 将图像分成若干 波次(waves) 进行渲染,每个像素每次只处理少量样本。在前两波中,每个像素只取一个样本。在下一波中,每个像素取两个样本,每波的样本数量逐渐翻倍,直到达到一个限制。虽然最终图像的效果与图像是按波次渲染还是在一个像素中取完所有样本再转到下一个像素没有区别,但这种计算方式使得在渲染过程中可以看到最终图像的预览,其中所有像素都有一些样本,而不是只有少数像素有许多样本,其余像素没有样本。

因为 pbrt 是并行化以使用多个线程运行的,因此这种方法需要找到一个平衡。线程在获取新图像块的工作时会产生一定的开销,而一些线程在每波次结束时可能会变得空闲,因为它们没有更多的工作可做,而其他线程仍在处理它们被分配的图像块。这些考虑促使了限制翻倍方法的使用。

/** 图像块积分器方法定义 */
void ImageTileIntegrator::Render() {
    /** 声明用于分块渲染图像的公共变量 */
       ThreadLocal<ScratchBuffer> scratchBuffers(
           []() { return ScratchBuffer(); } );
       ThreadLocal<Sampler> samplers(
           [this]() { return samplerPrototype.Clone(); });
       Bounds2i pixelBounds = camera.GetFilm().PixelBounds();
       int spp = samplerPrototype.SamplesPerPixel();
       ProgressReporter progress(int64_t(spp) * pixelBounds.Area(), "Rendering",
                                   Options->quiet);
       int waveStart = 0, waveEnd = 1, nextWaveSize = 1;
       
    /** 按波次渲染图像 */
       while (waveStart < spp) {
           /** 并行渲染当前波次的图像块 */
           ParallelFor2D(pixelBounds, [&](Bounds2i tileBounds) {
               /** 渲染 tileBounds 提供的图像块 */
               ScratchBuffer &scratchBuffer = scratchBuffers.Get();
               Sampler &sampler = samplers.Get();
               for (Point2i pPixel : tileBounds) {
                   /** 渲染像素 pPixel 中的样本 */
                   for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) {
                       sampler.StartPixelSample(pPixel, sampleIndex);
                       EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer);
                       scratchBuffer.Reset();
                   }
               }
               progress.Update((waveEnd - waveStart) * tileBounds.Area());
           });
           
           /** 更新开始和结束波次 */
           waveStart = waveEnd;
           waveEnd = std::min(spp, waveEnd + nextWaveSize);
           nextWaveSize = std::min(2 * nextWaveSize, 64);
           
           /** 可选的将当前图像写入磁盘 */  
           if (waveStart == spp || Options->writePartialImages || referenceImage) {
               ImageMetadata metadata;
               metadata.renderTimeSeconds = progress.ElapsedSeconds();
               metadata.samplesPerPixel = waveStart;
               if (waveStart == spp || Options->writePartialImages) {
                   camera.InitMetadata(&metadata);
                   camera.GetFilm().WriteImage(metadata, 1.0f / waveStart);
               }
           }
       }
       
}

在渲染开始之前,需要一些额外的变量。首先,积分器的实现需要分配少量临时内存,以存储在计算每条光线的贡献过程中表面散射属性。大量的内存分配可能会轻易地压倒系统的常规内存分配例程(例如, new ),这些例程必须协调多线程维护复杂的数据结构以跟踪空闲内存。一个简单的实现可能会在内存分配器中花费相当大一部分的计算时间。

为了解决这个问题, pbrt 提供了一个 ScratchBuffer 类,该类管理一块小的预分配内存缓冲区。 ScratchBuffer 分配非常高效,只需增加偏移量。 ScratchBuffer 不允许独立释放分配;相反,所有分配必须一次性释放,但这样做只需重置该偏移量。

因为 ScratchBuffer 在多个线程同时使用时不安全,所以为每个线程使用 ThreadLocal 模板类创建一个单独的实例。它的构造函数接受一个返回其管理的对象类型的新实例的 lambda 函数:在这里,调用默认的 ScratchBuffer 构造函数就足够了。 ThreadLocal 然后处理了为每个线程维护对象的独立副本的细节,并按需分配这些副本。

/** 声明用于分块渲染图像的公共变量 */
ThreadLocal<ScratchBuffer> scratchBuffers(
    []() { return ScratchBuffer(); } );

大多数 Sampler 实现发现维护一些状态是有用的,例如当前像素的坐标。这意味着多个线程不能同时使用单个 Sampler ,因此 ThreadLocal 也用于 Sampler 管理。 Samplers 提供了一个 Clone() 方法,用于创建其采样器类型的新实例。在开始时提供给 ImageTileIntegrator 构造函数的采样器 samplerPrototype ,在这里提供这些副本。

/** 声明用于分块渲染图像的公共变量 */
ThreadLocal<Sampler> samplers(
    [this]() { return samplerPrototype.Clone(); });

提供给用户渲染工作完成多少以及还需要多长时间的指示是很有帮助的。这个任务由 ProgressReporter 类处理,它的第一个参数是工作项的总数。在这里,工作总量是每个像素采样的数量乘以总像素数。使用 64 位精度来计算这个值是很重要的,因为 32 位 int 可能不足以处理具有多个每像素样本的高分辨率图像。

/** 声明用于分块渲染图像的公共变量 */
Bounds2i pixelBounds = camera.GetFilm().PixelBounds();
int spp = samplerPrototype.SamplesPerPixel();
ProgressReporter progress(int64_t(spp) * pixelBounds.Area(), "Rendering",
                          Options->quiet);

接下来,当前波次中要采集的样本范围由 waveStartwaveEnd 给出; nextWaveSize 给出下一波中要采集的样本数量。

/** 声明用于分块渲染图像的公共变量 */
int waveStart = 0, waveEnd = 1, nextWaveSize = 1;

手握这些变量,渲染继续进行,直到在所有像素中采集到所需数量的样本。

/** 按波次渲染图像 */
while (waveStart < spp) {
    /** 并行渲染当前波次的图像块 */
       ParallelFor2D(pixelBounds, [&](Bounds2i tileBounds) {
           /** 渲染 tileBounds 提供的图像块 */
           ScratchBuffer &scratchBuffer = scratchBuffers.Get();
           Sampler &sampler = samplers.Get();
           for (Point2i pPixel : tileBounds) {
               /** 渲染像素 pPixel 中的样本 */
               for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) {
                   sampler.StartPixelSample(pPixel, sampleIndex);
                   EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer);
                   scratchBuffer.Reset();
               }
           }
           progress.Update((waveEnd - waveStart) * tileBounds.Area());
       });
           
    /** 更新开始和结束波次 */
       waveStart = waveEnd;
       waveEnd = std::min(spp, waveEnd + nextWaveSize);
       nextWaveSize = std::min(2 * nextWaveSize, 64);
           
    /** 可选的将当前图像写入磁盘 */  
       if (waveStart == spp || Options->writePartialImages || referenceImage) {
           ImageMetadata metadata;
           metadata.renderTimeSeconds = progress.ElapsedSeconds();
           metadata.samplesPerPixel = waveStart;
           if (waveStart == spp || Options->writePartialImages) {
               camera.InitMetadata(&metadata);
               camera.GetFilm().WriteImage(metadata, 1.0f / waveStart);
           }
       }
}

ParallelFor2D() 函数遍历图像块,多个循环迭代并发运行;它是第 B.6 节中介绍的与并行相关的实用函数的一部分。C++ lambda 表达式提供了循环体。 ParallelFor2D() 自动选择图像块的大小,以平衡两个方面:一方面,我们希望图像块的数量显著多于系统中的处理器数量。某些块的处理时间可能会少于其他块,因此如果处理器与块之间存在 1:1 的映射,那么一些处理器在完成工作后将处于空闲状态,而其他处理器则继续处理其图像区域。(图 1.17 显示了渲染示例图像块所需时间的分布,说明了这一问题。)另一方面,块过多也会影响效率。线程在并行 for 循环中获取更多工作时,会有一个小的固定开销,块越多,这个开销付出的代价就越大。因此, ParallelFor2D() 选择的块大小考虑了待处理区域的范围和系统中的处理器数量。

/** 并行渲染当前波次的图像块 */
ParallelFor2D(pixelBounds, [&](Bounds2i tileBounds) {
    /** 渲染 tileBounds 提供的图像块 */
   ScratchBuffer &scratchBuffer = scratchBuffers.Get();
   Sampler &sampler = samplers.Get();
   for (Point2i pPixel : tileBounds) {
       /** 渲染像素 pPixel 中的样本 */
       for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) {
           sampler.StartPixelSample(pPixel, sampleIndex);
           EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer);
           scratchBuffer.Reset();
       }
   }
   progress.Update((waveEnd - waveStart) * tileBounds.Area());
});    

图 1.17:图 1.11 中场景每个瓦片渲染所花费时间的直方图。 横轴以秒为单位测量时间。注意执行时间的广泛变化,说明图像的不同部分需要的计算量有显著差异。

给定一个要渲染的瓦片,实现在开始时会为当前执行的线程获取 ScratchBufferSampler 。如前所述, ThreadLocal::Get() 方法负责为每个线程分配和返回它们的独立实例的细节。

有了这些,实现使用基于范围的 for 循环遍历图像块中的所有像素,该循环使用 Bounds2 类提供的迭代器,然后通知 ProgressReporter 已完成的工作量。

/** 渲染 tileBounds 提供的图像块 */
ScratchBuffer &scratchBuffer = scratchBuffers.Get();
Sampler &sampler = samplers.Get();
for (Point2i pPixel : tileBounds) {
    /** 渲染像素 pPixel 中的样本 */
    for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) {
        sampler.StartPixelSample(pPixel, sampleIndex);
        EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer);
        scratchBuffer.Reset();
    }
}
progress.Update((waveEnd - waveStart) * tileBounds.Area());

给定一个像素以进行一个或多个采样,通过 StartPixelSample() 通知线程的 Sampler 应该开始为当前像素生成样本,这使采样器能够根据当前正在处理的像素来设置任何内部状态。积分器的 EvaluatePixelSample() 方法负责确定具体样本的值,之后调用 ScratchBuffer::Reset() 来释放它在 [ScratchBuffer] 中分配的临时内存。

/** 渲染像素 pPixel 中的样本 */
for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) {
    sampler.StartPixelSample(pPixel, sampleIndex);
    EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer);
    scratchBuffer.Reset();
}

提供了纯虚方法 Integrator::Render() 的实现后, ImageTileIntegrator 现在要求其子类实现接下来的 EvaluatePixelSample() 方法。

/** 图像块积分器公有方法 */
virtual void EvaluatePixelSample(Point2i pPixel, int sampleIndex,
    Sampler sampler, ScratchBuffer &scratchBuffer) = 0;

在当前波次的并行 for 循环完成后,计算下一波要处理的样本索引范围。

/** 更新开始和结束波次 */
waveStart = waveEnd;
waveEnd = std::min(spp, waveEnd + nextWaveSize);
nextWaveSize = std::min(2 * nextWaveSize, 64);

如果用户提供了 –write-partial-images 命令行选项,则在处理下一波样本之前,正在进行的图像会写入磁盘。我们在这里不包括处理此操作的片段,可选的将当前图像写入磁盘

1.3.5 光线积分器实现(RayIntegrator Implementation)

正如 ImageTileIntegrator 集中处理与将图像分解为瓦片的积分器相关的功能, RayIntegrator 为从相机开始追踪光线路径的积分器提供通用功能。在第 13 章和第 14 章中实现的所有积分器都继承自 RayIntegrator

/** 光线积分器定义 */
class RayIntegrator : public ImageTileIntegrator {
  public:
    /** 光线积分器公有方法 */
       RayIntegrator(Camera camera, Sampler sampler, Primitive aggregate,
                       std::vector<Light> lights)
           : ImageTileIntegrator(camera, sampler, aggregate, lights) {}
       void EvaluatePixelSample(Point2i pPixel, int sampleIndex,
                               Sampler sampler, ScratchBuffer &scratchBuffer) final;
       virtual SampledSpectrum Li(
           RayDifferential ray, SampledWavelengths &lambda, Sampler sampler,
           ScratchBuffer &scratchBuffer, VisibleSurface *visibleSurface) const = 0;
       
};

它的构造函数只是将提供的对象传递给 ImageTileIntegrator 构造函数。

/** 光线积分器公有方法 */
RayIntegrator(Camera camera, Sampler sampler, Primitive aggregate,
              std::vector<Light> lights)
    : ImageTileIntegrator(camera, sampler, aggregate, lights) {}

RayIntegrator 实现了来自 ImageTileIntegrator 的纯虚方法 EvaluatePixelSample() 。在给定的像素处,它使用其 CameraSampler 生成一条射线进入场景,然后调用由子类提供的 Li() 方法,以确定沿该射线到达图像平面的光量。正如我们将在后面的章节中看到的,该方法返回的值的单位与射线起点的入射光谱辐射有关,通常在方程中用符号 \( L_\text{i} \) 表示——该方法的名称也因此而来。该值被传递给 Film ,记录射线对图像的贡献。

图 1.18 总结了该方法中使用的主要类及他们之间的数据流。

图 1.18: RayIntegrator::EvaluatePixelSample() 中执行计算的类之间的关系。 Sampler 为每个要采样的图像提供样本值。 Camera 将样本转换为来自胶片平面(film plane)的相应光线, Li() 方法计算到达胶片的该光线上的辐射亮度(radiance)。样本及其辐射亮度被传递给 Film ,后者将它们的贡献存储在图像中。

/** 光线积分器方法定义 */
void RayIntegrator::EvaluatePixelSample(Point2i pPixel, int sampleIndex,
        Sampler sampler, ScratchBuffer &scratchBuffer) {
    /** 采样光线的波长  */
       Float lu = sampler.Get1D();
              SampledWavelengths lambda = camera.GetFilm().SampleWavelengths(lu);
       
    /** 为当前样本初始化CameraSample */
       Filter filter = camera.GetFilm().GetFilter();
       CameraSample cameraSample = GetCameraSample(sampler, pPixel, filter);
       
    /** 为当前样本生成摄像机光线 */
       pstd::optional<CameraRayDifferential> cameraRay =
           camera.GenerateRayDifferential(cameraSample, lambda);
       
    /** 追踪有效的摄像机光线(cameraRay) */
       SampledSpectrum L(0.);
       VisibleSurface visibleSurface;
       if (cameraRay) {
           /** 根据图像采样率缩放摄像机光线微分 */
           Float rayDiffScale =
               std::max<Float>(.125f, 1 / std::sqrt((Float)sampler.SamplesPerPixel()));
           cameraRay->ray.ScaleDifferentials(rayDiffScale);
           /** 沿摄像机光线评估辐射亮度 */
           bool initializeVisibleSurface = camera.GetFilm().UsesVisibleSurface();
           L = cameraRay->weight *
               Li(cameraRay->ray, lambda, sampler, scratchBuffer,
                   initializeVisibleSurface ? &visibleSurface : nullptr);
           /** 如果返回意外的辐射亮度值,则发出警告 */
           if (L.HasNaNs()) {
               LOG_ERROR("Not-a-number radiance value returned for pixel (%d, "
                       "%d), sample %d. Setting to black.",
                       pPixel.x, pPixel.y, sampleIndex);
               L = SampledSpectrum(0.f);
           } else if (IsInf(L.y(lambda))) {
               LOG_ERROR("Infinite radiance value returned for pixel (%d, %d), "
                       "sample %d. Setting to black.",
                       pPixel.x, pPixel.y, sampleIndex);
               L = SampledSpectrum(0.f);
           }
       }
       
    /** 将摄像机光线的贡献值添加到图像中 */
       camera.GetFilm().AddSample(pPixel, L, lambda, &visibleSurface,
                               cameraSample.filterWeight);
       
}

每个光线在多个离散波长 \( \lambda \) (默认四个)上携带辐射。当计算每个像素的颜色时, pbrt 在不同的像素样本中选择不同的波长,以便最终结果更好地反映所有波长的正确结果。为了选择这些波长,首先由 Sampler 提供一个样本值 lu 。该值在范围 \( [0,1) \) 内均匀分布。然后, Film::SampleWavelengths() 方法将此样本映射到一组特定波长,把它的胶片传感器响应模型当作一个关于波长的函数。大多数 Sampler 实现确保如果在一个像素中采样多个样本,这些样本在总体上均匀分布在 \( [0,1] \) 上。相应的,它们确保采样的波长在有效波长范围内也均匀分布,从而提高图像质量。

/** 采样光线的波长  */
Float lu = sampler.Get1D();
SampledWavelengths lambda = camera.GetFilm().SampleWavelengths(lu);

CameraSample 结构记录了相机应生成光线的胶片位置。该位置受到采样器提供的样本位置和用于将给像素的多个样本值过滤为单个值的重建滤波器(reconstruction filter)的影响。 GetCameraSample() 处理这些计算。 CameraSample 还存储与光线相关的时间以及镜头位置样本,这些在渲染移动物体的场景和模拟非针孔光圈的相机模型时使用。

/** 为当前样本初始化CameraSample */
Filter filter = camera.GetFilm().GetFilter();
CameraSample cameraSample = GetCameraSample(sampler, pPixel, filter);

Camera 接口提供了两种生成光线的方法: GenerateRay() 返回给定图像采样位置的光线,以及 GenerateRayDifferential() 返回 光线微分(ray differential) ,包含相机在图像平面的 \( x \) 和 \( y \) 方向上与样本相距一个像素的位置生成的光线的信息。光线微分用于从第 10 章中定义的一些纹理函数中获得更好的结果,通过使得能够计算纹理随像素间距变化的速度,这是一种纹理抗锯齿的关键组成部分。

某些 CameraSample 值可能与给定相机的有效光线不一致。因此,用 pstd::optional 来包装相机返回的 CameraRayDifferential

/** 为当前样本生成摄像机光线 */
pstd::optional<CameraRayDifferential> cameraRay =
    camera.GenerateRayDifferential(cameraSample, lambda);

如果摄像机光线有效,它将在一些额外准备后传递给 RayIntegrator 子类的 Li() 方法实现。除了返回沿光线 \( \text{L} \) 的辐射亮度外,子类还负责初始化 VisibleSurface 类的一个实例,该实例记录光线在每个像素处与表面相交(如果有的话)的几何信息,以供 Film 实现使用,例如 GBufferFilm ,它在每个像素处存储的不仅仅是颜色的信息。

/** 追踪有效的摄像机光线(cameraRay) */
SampledSpectrum L(0.);
VisibleSurface visibleSurface;
if (cameraRay) {
    /** 根据图像采样率缩放摄像机光线微分 */
    /** 沿摄像机光线评估辐射亮度 */
    /** 如果返回意外的辐射亮度值,则发出警告 */
}

在将光线传递给 Li() 方法之前, ScaleDifferentials() 在每个像素进行多个采样时,会调整微分光线的比例,以考虑到胶片平面上样本之间的实际间距。。

/** 根据图像采样率缩放摄像机光线微分 */
Float rayDiffScale =
    std::max<Float>(.125f, 1 / std::sqrt((Float)sampler.SamplesPerPixel()));
cameraRay->ray.ScaleDifferentials(rayDiffScale);

对于不在每个像素处存储几何信息的 Film 实现,节省填充 VisibleSurface 类的工作是值得的。因此,只有在必要时,才会在调用 Li() 方法时传递指向该类的指针,否则传递空指针。积分器实现应仅在 VisibleSurface 非空时进行初始化。

CameraRayDifferential 还携带与光线相关的权重,用于缩放返回的辐射值。对于简单的相机模型,每条光线的权重相等,但更准确模拟透镜系统成像过程的相机模型可能会生成一些贡献大于其他光线的光线。这种相机模型可能会模拟在胶片平面的边缘到达的光线少于中心的效果,这种效果称为 渐晕(vignetting)

/** 沿摄像机光线评估辐射亮度 */
bool initializeVisibleSurface = camera.GetFilm().UsesVisibleSurface();
L = cameraRay->weight *
    Li(cameraRay->ray, lambda, sampler, scratchBuffer,
       initializeVisibleSurface ? &visibleSurface : nullptr);

RayIntegrator 子类必须实现纯虚方法 Li() 。它返回在指定波长采样时,给定光线的起点处的入射辐射,。

/** 光线积分器公有方法 */
virtual SampledSpectrum Li(
    RayDifferential ray, SampledWavelengths &lambda, Sampler sampler,
    ScratchBuffer &scratchBuffer, VisibleSurface *visibleSurface) const = 0;

渲染过程中的出现的 bugs 的一个常见副作用是计算出不可能的辐射值。例如,除以零会导致辐射值等于 IEEE 浮点无穷大或“不是一个数字(not a number)”值。渲染器会查找这些可能性,并在遇到时打印错误消息。这里我们不包括执行此操作的片段,/** 如果返回意外的辐射亮度值,则发出警告 */。如果您对其细节感兴趣,请参见 cpu/integrators.cpp 中的实现。

在已知光线起始处的辐射亮度后,调用 Film::AddSample() 更新图像中相应的像素,给定样本的加权辐射亮度。样本值如何记录在胶片中的细节在第 5.4 节和第 8.8 节中解释。

/** 将摄像机光线的贡献值添加到图像中 */
camera.GetFilm().AddSample(pPixel, L, lambda, &visibleSurface,
                           cameraSample.filterWeight);

1.3.6 随机游走积分器(Random Walk Integrator)

尽管我们花了几页时间整理积分器基础设施的实现终于完成了 RayIntegrator ,但现在我们可以在一个比实现完整的 Integrator::Render() 方法更简单的上下文中转向实现光传输积分算法。本节中我们将描述的 RandomWalkIntegrator 继承自 RayIntegrator ,因此多线程的所有细节、从相机生成初始光线以及沿着该光线将辐射度添加到图像的过程都已处理好。积分器在一个更简单的上下文中操作:给定一条光线,其任务是计算到达其起点的辐射度。

请回忆在第 1.2.7 节中我们提到,在没有参与介质的情况下,光线通过自由空间时所携带的光是不变的。在实现这个积分器时,我们将忽略参与介质的可能性,这使我们能够迈出第一步:给定光线与场景中几何体的第一次交点,到达光线起点的辐射度等于从交点朝向光线起点发出的辐射度。该出射辐射(outgoing radiance)由光传输方程(1.1)给出,尽管以封闭形式评估它是不可能的。需要采用数值方法,而在 pbrt 中使用的方法基于蒙特卡罗积分(Monte Carlo integration),这使得可以通过对被积函数进行逐点计算来估计积分的值。第 2 章提供了蒙特卡罗积分的介绍,额外的蒙特卡罗技术将在书中使用到时进行介绍。

为了计算出射辐射, RandomWalkIntegrator 实现了一种基于增量构建 随机游走(random walk) 的简单蒙特卡洛方法,其在场景表面上连续随机选择一系列点,以构建从相机开始的光携带路径。这种方法有效地反向模拟了现实世界中的图像形成,从相机而不是从光源发出光线。在这方面向后推导在物理上仍然是有效的,因为 pbrt 所基于的光的物理模型是时间可逆的。

图 1.19:使用 RandomWalkIntegrator 渲染的 水彩(Watercolor) 场景的图像。 由于 RandomWalkIntegrator 无法完美处理镜面表面,桌子上的两个玻璃杯呈黑色。此外,即使使用每像素 8,192 个样本来渲染此图像,结果仍然布满高频噪声。(例如,请注意远处的墙壁和椅子的底部。) (场景由 Angelo Ferretti 提供。)(Scene courtesy of Angelo Ferretti)

尽管随机游走采样算法的实现总共只有二十多行代码,但它能够模拟复杂的光照和着色效果;图 1.19 显示了使用该算法渲染的图像。(不过,该图像的计算耗费了许多小时才能达到这样的质量。)在本节的其余部分,我们将略过一些积分器实现的数学细节,专注于对该方法的直观理解,后续章节将填补这些空白,并更严格地解释这一点以及更复杂的技术。

/** 随机游走积分器定义 */
class RandomWalkIntegrator : public RayIntegrator {
  public:
    /** 随机游走积分器 公有方法 */
       RandomWalkIntegrator(int maxDepth, Camera camera, Sampler sampler,
                       Primitive aggregate, std::vector<Light> lights)
           : RayIntegrator(camera, sampler, aggregate, lights), maxDepth(maxDepth) {}
       
       static std::unique_ptr<RandomWalkIntegrator> Create(
           const ParameterDictionary &parameters, Camera camera, Sampler sampler,
           Primitive aggregate, std::vector<Light> lights, const FileLoc *loc);
       
       std::string ToString() const;
       SampledSpectrum Li(RayDifferential ray, SampledWavelengths &lambda,
               Sampler sampler, ScratchBuffer &scratchBuffer,
               VisibleSurface *visibleSurface) const {
           return LiRandomWalk(ray, lambda, sampler, scratchBuffer, 0);
       }
       
  private:
    /** 随机游走积分器 私有方法 */
SampledSpectrum LiRandomWalk(RayDifferential ray,
        SampledWavelengths &lambda, Sampler sampler,
        ScratchBuffer &scratchBuffer, int depth) const {
    /** 让光线与场景相交并在没有交点时返回 */
       pstd::optional<ShapeIntersection> si = Intersect(ray);
       if (!si) {
           /** 返回从无限远光源发出的光 */
           SampledSpectrum Le(0.f);
           for (Light light : infiniteLights)
               Le += light.Le(ray, lambda);
           return Le;
       }
       SurfaceInteraction &isect = si->intr;
       
    /** 获取表面交点的发射辐射 */
       Vector3f wo = -ray.d;
       SampledSpectrum Le = isect.Le(wo, lambda);
       
    /** 如果达到最大递归深度则终止随机游走 */
       if (depth == maxDepth)
           return Le;
       
    /** 在随机游走交点计算 BSDF */
       BSDF bsdf = isect.GetBSDF(ray, lambda, camera, scratchBuffer, sampler);
       
    /** 为随机游走随机采样离开表面时的方向 */
       Point2f u = sampler.Get2D();
       Vector3f wp = SampleUniformSphere(u);
       
    /** 在采样方向评估表面的 BSDF */
       SampledSpectrum fcos = bsdf.f(wo, wp) * AbsDot(wp, isect.shading.n);
       if (!fcos)
           return Le;
       
    /** 递归追踪光线以估计表面的入射辐射亮度 */
       ray = isect.SpawnRay(wp);
       return Le  + fcos * LiRandomWalk(ray, lambda, sampler, scratchBuffer,
                                        depth + 1) / (1 / (4 * Pi));
       
}
    /** 随机游走积分器 私有成员 */
       int maxDepth;
};

这个积分器递归地评估随机游走。因此,它的 Li() 方法实现几乎只是通过调用 LiRandomWalk() 方法来启动递归。大多数传递给 Li() 的参数只是被传递下去,因此这个简单的积分器忽略 VisibleSurface ,取而代之添加一个额外的参数来跟踪递归的深度。

/** 随机游走积分器 公有方法 */
SampledSpectrum Li(RayDifferential ray, SampledWavelengths &lambda,
        Sampler sampler, ScratchBuffer &scratchBuffer,
        VisibleSurface *visibleSurface) const {
    return LiRandomWalk(ray, lambda, sampler, scratchBuffer, 0);
}
/** 随机游走积分器 私有方法 */
SampledSpectrum LiRandomWalk(RayDifferential ray,
        SampledWavelengths &lambda, Sampler sampler,
        ScratchBuffer &scratchBuffer, int depth) const {
    /** 让光线与场景相交并在没有交点时返回 */
    /** 获取表面交点的发射辐射 */
    /** 如果达到最大递归深度则终止随机游走 */
    /** 在随机游走交点计算 BSDF */
    /** 为随机游走随机采样离开表面时的方向 */
    /** 在采样方向评估表面的 BSDF */
    /** 递归追踪光线以估计表面的入射辐射亮度 */
}

第一步是找到光线与场景中形状的最近交点。如果没有找到交点,那么光线就已经离开场景。否则,作为 ShapeIntersection 结构一部分返回的 SurfaceInteraction 提供了关于交点局部几何属性的信息。

/** 让光线与场景相交并在没有交点时返回 */
pstd::optional<ShapeIntersection> si = Intersect(ray);
if (!si) {
    /** 返回从无限远光源发出的光 */
   SampledSpectrum Le(0.f);
   for (Light light : infiniteLights)
       Le += light.Le(ray, lambda);
   return Le;
}
SurfaceInteraction &isect = si->intr;

如果没有找到交点,辐射仍然可能沿着光线传播,因为存在一些没有几何形状的光源,例如 ImageInfiniteLightLight::Le() 方法允许这些光源为给定光线返回其辐射。

/** 返回从无限远光源发出的光 */
SampledSpectrum Le(0.f);
for (Light light : infiniteLights)
    Le += light.Le(ray, lambda);
return Le;

如果找到了有效的交点,我们必须在交点处评估光传输方程。第一个项,\( L_\text{e}(\text{p},\omega_\text{o}) \) ,即发射辐射,比较简单:发射是场景规格的一部分,发射辐射可以通过调用 SurfaceInteraction::Le() 方法获得,该方法接受关注的出射方向。在这里,我们关注的是沿光线方向发射回来的辐射。如果物体不是发射体,该方法将返回一个零值的光谱分布(zero-valued spectral distribution)。

/** 获取表面交点的发射辐射 */
Vector3f wo = -ray.d;
SampledSpectrum Le = isect.Le(wo, lambda);

评估光传输方程的第二项需要计算在交点 \( \text{p} \) 周围的方向球面上的积分。可以应用蒙特卡罗积分原理来表明,如果以相等的概率选择所有可能的方向 \( \omega' \) ,则积分的估值可以计算为 BSDF \( f \) (材质在 \( \text{p} \) 处的的光散射特性)、入射光照 \( L_\text{i} \) 以及一个余弦因子的加权乘积:

(1.2)

\[ \int_{\text{S}^2}^{} f(\text{p},\omega_\text{o},\omega_\text{i}) L_\text{i}(\text{p},\omega_\text{i}) |\cos{\theta_\text{i}}| \text{d}\omega_\text{i} \approx \frac{f(\text{p},\omega_\text{o},\omega') L_\text{i}(\text{p},\omega') |\cos{\theta'}|}{1/4\pi} \]

换句话说,给定一个随机方向 \( \omega' \) ,估计积分的值需要评估该方向下被积函数中的项,然后乘以一个因子 \( 4\pi \) 进行缩放。(这个因子在 A.5.2 节中推导,与单位球的表面积有关。)由于只考虑一个方向,因此与积分的真实值相比,蒙特卡洛估计几乎总是存在误差。然而,可以证明像这样的估计 在期望上(in expectation) 是正确的:非正式地说,它们在平均上给出了正确的结果。对多个独立估计取平均通常会减少这种误差——因此,采取每个像素多个样本的做法。

BSDF 和估计的余弦因子很容易评估,那么只剩下一个未知的 \( L_\text{i} \) ,即入射辐射。然而,请注意,我们发现自己又回到了最初调用 LiRandomWalk() 的地方:我们有一条光线,我们希望找到起点的入射辐射——这将通过对 LiRandomWalk() 的递归调用来提供。

在计算积分的估计值之前,我们必须考虑递归的终止条件。 RandomWalkIntegrator 在预定的最大深度 maxDepth 处停止。如果没有这个终止条件,算法可能永远不会终止(例如,想象一个镜子迷宫的场景)。这个成员变量在构造函数中根据可以在场景描述文件中设置的参数进行初始化。

/** 随机游走积分器 私有成员 */
int maxDepth;
/** 如果达到最大递归深度则终止随机游走 */
if (depth == maxDepth)
    return Le;

如果随机游走没有终止,则调用 SurfaceInteraction::GetBSDF() 方法找到交点处的 BSDF。它评估纹理函数以确定表面属性,然后初始化 BSDF 的表示。通常需要为构成 BSDF 表示的对象分配内存;因为这段内存只需要在处理当前光线时激活,所以提供 ScratchBuffer 供其进行分配。

/** 在随机游走交点计算 BSDF */
BSDF bsdf = isect.GetBSDF(ray, lambda, camera, scratchBuffer, sampler);

接下来,我们需要随机采样一个方向 \( \omega' \) 来计算方程(1.2)中的估值。 SampleUniformSphere() 函数返回单位球面上的均匀分布方向,输入由采样器提供的两个均匀值 \( [0, 1) \) 。

/** 为随机游走随机采样离开表面时的方向 */
Point2f u = sampler.Get2D();
Vector3f wp = SampleUniformSphere(u);

除了入射辐射外,蒙特卡洛估计的所有因素现在都可以轻松评估。 BSDF 类提供了一个 f() 方法,该方法评估一对指定方向的 BSDF,并且可以使用 AbsDot() 函数计算与表面法线夹角的余弦,该函数返回两个向量之间点积的绝对值。如果向量是归一化的(在这里都是),则该值等于它们之间夹角余弦的绝对值(第 3.3.2 节)。

BSDF 在提供的方向上可能为零值,因此 fcos 也可能为零——例如,当表面不透光而两个方向位于其相对两侧时,BSDF 为零。在这种情况下,没有理由继续随机游走,因为后续点对结果没有贡献。

/** 在采样方向评估表面的 BSDF */
SampledSpectrum fcos = bsdf.f(wo, wp) * AbsDot(wp, isect.shading.n);
if (!fcos)
    return Le;

剩余的任务是计算在采样方向 \( \omega' \) 上离开表面的新光线。这个任务由 SpawnRay() 方法处理,该方法返回在提供方向上离开交点的光线,确保光线与表面有足够的偏移,以避免因舍入误差而错误地重新相交。给定光线后,可以递归调用 LiRandomWalk() 来估计入射辐射,从而完成方程 (1.2) 的估计。

/** 递归追踪光线以估计表面的入射辐射亮度 */
ray = isect.SpawnRay(wp);
return Le  + fcos * LiRandomWalk(ray, lambda, sampler, scratchBuffer,
                                 depth + 1) / (1 / (4 * Pi));

这种简单的方法有许多缺点。例如,如果发射表面很小,大多数光线路径将找不到任何光照,但是需要追踪许多光线以形成准确的图像。在点光源的极限情况下,图像将是黑色,因为与这样的光源相交的概率为零。类似的问题也适用于在集中方向上散射光的 BSDF 模型。在完美镜子的极限情况下,它沿单一方向散射入射光, RandomWalkIntegrator 将永远无法随机采样该方向。

这些问题以及更多问题可以通过更复杂的蒙特卡罗积分技术来解决。在后续章节中,我们将介绍一系列改进,以获得更准确的结果。第 1315 章中定义的积分器是这些发展的巅峰。所有这些仍然基于 RandomWalkIntegrator 中使用的基本思想,但比它更高效和稳健。图 1.20RandomWalkIntegrator 与其中一个改进的积分器进行比较,并展示了可能的改进程度。

(a) RandomWalkIntegrator 随机游走积分器

(b) PathIntegrator 路径积分器

图 1.20:使用每像素 32 个样本渲染的 水彩(Watercolor) 场景。 (a) 使用 RandomWalkIntegrator 渲染。(b) 使用 PathIntegrator 渲染,采用相同的一般方法,但使用更复杂的蒙特卡洛技术。 PathIntegrator 在大致相同的工作量下提供了显著更好的图像,均方误差减少了 \( 54.5\times \) 。