【PBRT】《基于物理的渲染:从理论到实践》精华提炼(I)

图片来自Physically Based Rendering: From Theory to Implementation

本文是笔者阅读《基于物理的渲染:从理论到实践》第一章的总结,整理成文章算是一种复习,同时也希望能对读者有帮助。

简介

作为开书第1章,这章讲述的东西可以说是非常杂,包括文学编程(Literate Programming),PBR简史,渲染器的组成,以及pbrt(本书所实现的渲染引擎)是如何实现的。

这一章的重点在渲染器的组成以及pbrt的实现,限于篇幅,本文也只会对这两部分的内容进行梳理。

其实,我觉得这本书的名字改成“如何实现一个基于物理的渲染引擎?”更合适:)

全书概览

本书除了第一章和最后一章外,剩余的章节可以分成四个部分。从这几个部分的简介来看,就是在介绍pbrt的引擎基础组件是什么,然后介绍PBR相关知识,最后pbrt是如何实现的。

  • 第一部分从第2章开始一直到第4章。这一部分定义了系统的几何基础组件,包括顶点,射线(光线),边界体,球、圆柱体、圆锥体(等形状),以及光线追踪的加速结构。

  • 第二部分从第5章开始到第7章结束。包括了光谱类、摄像机类、以及图片的采样方法。

  • 第三部分从第8章开始到第12章,介绍了光与材质的相关知识。这部分包括反射模型,材质和纹理,体积散射,对光源建模等等。

  • 最后一部分从第13章开始到第16章,重点介绍了蒙特卡洛方法以及使用蒙特卡洛方法实现光照传输。第13章是蒙特卡洛积分的理论基础,14、15、16章是应用蒙特卡洛积分近似计算光照传输方程,使用的方法是路径追踪、双向路径追踪、梅特波利斯光照传输和光子映射。

渲染器的组成

使用PBR的渲染大多属于照片写实渲染(photorealistic rendering),其终极目标是要渲染出的图像让人无法分辨真假( indistinguishable)。这就需要我们对物理世界中的光照进行建模,同时也要理解人类是如何感知到光的(换句话说就是对人类的感知建模)。

由于几乎所有的写实渲染系统都是基于光线追踪的,本书实现的渲染引擎也无法免俗。本书实现的渲染引擎名为pbrt,是一个基于光线追踪的PBR渲染引擎。光线追踪(ray tracing),简单地来说就是从最终渲染图片出发,像是在摄像机前放了一块比如说1280x720大小的屏幕,然后连接摄像机(将摄像机当成一个点)和屏幕上每一个像素,就像是从摄像机发射了一条光线那样,去计算这条光线跟场景中什么物体相交,计算那个交点的颜色并显示。

一个典型的渲染器包括:

摄像机(camera)

最简单的摄像机模型是针孔摄像机(pinhole camera),就是把摄像机当成一个简单的点。更复杂的摄像机就要把镜头的影响也考虑进去了,实现一个更加复杂的相机模型。

光线与物体如何相交(ray-object intersections)

我们可以将光线表示成参数形式:

其中,o表示光线的起点,d表示照射方向,t是参数。在计算光线与物体交点的时候,只需将此方程代入物体的方程中(物体必须要有方程),然后求出t值(t可能没有实数解)。如果t值存在,那么这个交点就算是求出来了。

更进一步地,对于简单的场景,我们可以遍历场景中的每一个物体,然后计算与每一条光线的交点是否存在,如果存在那么交点在哪。然而,这方法对复杂场景就不适用。原因非常简单,计算量级太大了。一个拥有几十万个面片的场景根本就无法遍历所有的物体来计算是否与光线相交,注意是每一根光线。所以,这一步还要场景分割作为辅助,减少需要遍历的物体数量。

光源(light source)

我们就用最简单的点光源来说明如何对光源进行建模。每个光源都有一个光强,我们用来表示,点光源会向所有的方向进行电磁辐射,也就是光会在所有方向上传播。于是,在以光源为中心的单位球面上,每个点的光强为。

如果光线与交点法向量的夹角为

【PBRT】《基于物理的渲染:从理论到实践》精华提炼(I)_第1张图片

,则在点P附近的一小块区域(记为dE)的光强为。这里的cos表示光强在角度上的衰减,而在距离上的衰减是。
上面只是做一个原理上的说明,在现实世界中,不存在只有一个点的光源。所以,PBR渲染模型中的光源是面光源。

可见性(visibility)

可见性主要是用来创造阴影效果,如果点P和光源之间有什么东西挡住了,那么点P就有阴影效果。这可以通过生成一条阴影线(shadow ray)来计算光源和点P之间有没有东西。

表面散射(surface scattering)

前面介绍的都是光线直接到点P上的光强,但是我们在看点P的时候,看到的光是从点P上散射出来的光。所以,我们需要建立点P的散射模型,用来计算直接照射到点P上的光是如何被散射出去的。尤其是散射的方向是摄像机的方向!

这就引出了另外一个东西:材质。点P是如何散射光的完全取决于点P的材质,如何描述材质呢?我们使用一个函数来表示,这个函数称为双向反射分布函数(bidirectional reflectance distribution function ),简称BRDF。然后就是点P的BRDF值可以表示为,这个式子的意思是光线wi于点P处,对wo方向上的贡献量。

间接光照传输(indirect light transport)

间接光照传输是为了解决反射以及透射等等的问题,因为反射面需要看到其他地方的物体,透射面会看到后面的物体,所以无法直接计算其颜色。于是,间接光照传输方法就提出来了。其根本是,一个点到相机的总光量为其发光量和反射光量的和。列出的方程就是光照传输方程(light transport equation):

其中Lo表示点P在输出方向wo上的辐射量,Le表示点P在方向wo上的发光量,f(p, wo, wi)表示入射光wi在点P处对wo方向辐射量的贡献度,Li表示入射光wi在点P出的辐射量,cos表示光线角度上的衰减,加上绝对值是因为它是这个公式里包括的不仅仅是反射还包括折射的贡献度,所以光线夹角的范围就扩大到了整个360度的区间。

光线传播(ray propagation)

之前的光照计算严格意义上来说都是不对的,因为光在传输过程中没有因为空间中的小颗粒而衰减光强,也就是说上述的计算是真空中光的传播方式。光线传播主要是对一个空间进行建模,模拟空间中有烟雾或者灰尘等等之类的东西,会让光强衰减的情况,这种模型就被称为体积光照传输方程(volume light transport equation)。

PBRT的实现

设计抉择

指针还是引用?当参数完全会被改变的时候,就用指针;当参数部分会被改变的时候,就用引用;当参数不变的时候,就用const引用。

抽象还是高效?考虑系统的最终大小,pbrt的核心代码长度在2万行以下,而不是一百万行(UE4的代码量,深不可测)。

代码优化?大方向上,pbrt会选择好的算法而不是在代码细节上做小优化。但是,pbrt仍然有两个小优化,这两个小优化对代码的效率提升十分显著,分别是:
1、使用乘法代替除法,比如如果要除以v,那么会改成乘以1/v。
2、优化数据在内存中的布局,这样可以大大加快运行速度,毕竟从内存中获取数据的时间够CPU执行几百次指令了。

并行性
引擎必须要进行并行计算,这是大势所趋,没有什么转圜的余地。这就涉及到两个问题:(1)运用多线程技术,可以有多大的性能提升?(2)如何进行线程间的协调?

根据阿姆达尔定律:

其中,s是整个流程中,需要顺序执行的部分所占的比例,n表示线程数量。从公式中我们可以看出,整个流程的运行效率与流程的顺序部分所占比重有很大的关系,因为即便是n取无穷大,也只是把公式变成1/s而已,还是受到s的限制。因此,我们要尽量减少s值!

那么如何进行线程间的协调呢?两个方法:互斥体和原子操作。

使用互斥体(比如std::mutex)的方法是在需要协调的代码段的开始先获得互斥体,然后执行代码,最后释放互斥体,这样可以保证互斥体包围的代码在运行过程中,其所操作的数据不会被其他线程修改或者被其他线程访问。

使用原子操作(比如std::atomic),使用std::atomic这种类型定义的变量,对其进行++或者--操作可以保证在操作过程中不会对此变量进行访问或者修改。相当于是将++或者--操作包围在互斥体中了。

pbrt中的类大部分是线程安全的,但也有例外。这是因为有些类加入线程安全机制会导致严重的性能下降,得不偿失。这些未加入线程安全的类包括Point3f,Vector3f, Normal3f, Spectrum, Transform, Quaternion,SurfaceInteraction。

使用流程

pbrt的使用流程非常简单,我们来看main函数的代码


【PBRT】《基于物理的渲染:从理论到实践》精华提炼(I)_第2张图片

其中最重要的函数有三个:pbrtInit,pbrtParseFile,pbrtCleanup。这些函数的作用显而易见。值得注意的是,pbrt是一个离线渲染的引擎,pbrtParseFile函数不仅解析了场景文件,还进行了场景渲染,输出一张渲染后的图片。

渲染流程解析

pbrtParseFile函数如何解析场景我就不细说了,直接从完成场景的解析之后开始说起。

当pbrt完成了场景的解析,它会调用一个名为pbrtWorldEnd的函数,表示整个场景已经解析完毕。pbrtWorldEnd函数会进行一些场景解析的收尾工作,并且创建一个Integrator对象和Scene对象。Scene对象就是我们的场景,而Integrator是积分器,正是这个东西完成了对场景渲染的操作!我们来看代码:

std::unique_ptr integrator(renderOptions->MakeIntegrator());
std::unique_ptr scene(renderOptions->MakeScene());

// This is kind of ugly; we directly override the current profiler
// state to switch from parsing/scene construction related stuff to
// rendering stuff and then switch it back below. The underlying
// issue is that all the rest of the profiling system assumes
// hierarchical inheritance of profiling state; this is the only
// place where that isn't the case.
CHECK_EQ(CurrentProfilerState(), ProfToBits(Prof::SceneConstruction));
ProfilerState = ProfToBits(Prof::IntegratorRender);

if (scene && integrator) integrator->Render(*scene);

CHECK_EQ(CurrentProfilerState(), ProfToBits(Prof::IntegratorRender));
ProfilerState = ProfToBits(Prof::SceneConstruction);

可以看到,最关键的一行就是if (scene && integrator) integrator->Render(*scene);,渲染场景。pbrt这样设计,是想给使用这更大的方便,因为对积分方法非常多,不同的积分方法得到的效果不同。要想观察某个积分方法的话,只需要实现一个Integrator的具体类就可以了。我们先来看最简单的SamplerIntegrator积分器是如何渲染的:

void SamplerIntegrator::Render(const Scene &scene) {
    Preprocess(scene, *sampler);
    // Render image tiles in parallel

    // Compute number of tiles, _nTiles_, to use for parallel rendering
    Bounds2i sampleBounds = camera->film->GetSampleBounds();
    Vector2i sampleExtent = sampleBounds.Diagonal();
    const int tileSize = 16;
    Point2i nTiles((sampleExtent.x + tileSize - 1) / tileSize,
                   (sampleExtent.y + tileSize - 1) / tileSize);
    ProgressReporter reporter(nTiles.x * nTiles.y, "Rendering");
    {
        // 并行计算图像块
        ParallelFor2D([&](Point2i tile) {
             ....
            }
            LOG(INFO) << "Finished image tile " << tileBounds;

            // Merge image tile into _Film_
            camera->film->MergeFilmTile(std::move(filmTile));
            reporter.Update();
        }, nTiles);
        reporter.Done();
    }
    LOG(INFO) << "Rendering finished";

    // Save final image after rendering
    camera->film->WriteImage();
}

整个渲染分为三个主要部分:
(1)预处理——这个过程调用Preprocess就可以了。
(2)分块——根据需要分的块数(这里是16),确定每个块的大小。块数可以根据系统的核心数来确定。
(3)保存图像——这个过程直接调用相机的WriteImage即可。

并行计算部分的代码如下:

ParallelFor2D([&](Point2i tile) {
    // Render section of image corresponding to _tile_

    // Allocate _MemoryArena_ for tile
    MemoryArena arena;

    // Get sampler instance for tile
    int seed = tile.y * nTiles.x + tile.x;
    std::unique_ptr tileSampler = sampler->Clone(seed);

    // Compute sample bounds for tile
    int x0 = sampleBounds.pMin.x + tile.x * tileSize;
    int x1 = std::min(x0 + tileSize, sampleBounds.pMax.x);
    int y0 = sampleBounds.pMin.y + tile.y * tileSize;
    int y1 = std::min(y0 + tileSize, sampleBounds.pMax.y);
    Bounds2i tileBounds(Point2i(x0, y0), Point2i(x1, y1));
    LOG(INFO) << "Starting image tile " << tileBounds;

    // Get _FilmTile_ for tile
    std::unique_ptr filmTile =
        camera->film->GetFilmTile(tileBounds);

    // Loop over pixels in tile to render them
    for (Point2i pixel : tileBounds) {
        ...
    }
    LOG(INFO) << "Finished image tile " << tileBounds;

    // Merge image tile into _Film_
    camera->film->MergeFilmTile(std::move(filmTile));
    reporter.Update();
}, nTiles);

ParalleFor2D函数提供了并行计算的功能,它需要一个函数指针和块数作为参数。函数指针可以直接用lambda表达式,这是计算的方法。整个计算过程可以分为以下阶段:

  • 定义一个MemoryArena对象用于内存分配
  • 创建一个采样器实例
  • 计算采样的边界坐标
  • 获取FilmTile对象
  • 循环遍历每一个像素,然后渲染它们(关键点)
  • 保存渲染结果

遍历每个像素并渲染的代码如下:

for (Point2i pixel : tileBounds) {
        {
            ProfilePhase pp(Prof::StartPixel);
            tileSampler->StartPixel(pixel);
        }

        // Do this check after the StartPixel() call; this keeps
        // the usage of RNG values from (most) Samplers that use
        // RNGs consistent, which improves reproducability /
        // debugging.
        if (!InsideExclusive(pixel, pixelBounds))
            continue;

        do {
            // Initialize _CameraSample_ for current sample
            CameraSample cameraSample =
                tileSampler->GetCameraSample(pixel);

            // Generate camera ray for current sample
            RayDifferential ray;
            Float rayWeight =
                camera->GenerateRayDifferential(cameraSample, &ray);
            ray.ScaleDifferentials(
                1 / std::sqrt((Float)tileSampler->samplesPerPixel));
            ++nCameraRays;

            // Evaluate radiance along camera ray
            Spectrum L(0.f);
            if (rayWeight > 0) L = Li(ray, scene, *tileSampler, arena);

            // Issue warning if unexpected radiance value returned
            if (L.HasNaNs()) {
                LOG(ERROR) << StringPrintf(
                    "Not-a-number radiance value returned "
                    "for pixel (%d, %d), sample %d. Setting to black.",
                    pixel.x, pixel.y,
                    (int)tileSampler->CurrentSampleNumber());
                L = Spectrum(0.f);
            } else if (L.y() < -1e-5) {
                LOG(ERROR) << StringPrintf(
                    "Negative luminance value, %f, returned "
                    "for pixel (%d, %d), sample %d. Setting to black.",
                    L.y(), pixel.x, pixel.y,
                    (int)tileSampler->CurrentSampleNumber());
                L = Spectrum(0.f);
            } else if (std::isinf(L.y())) {
                  LOG(ERROR) << StringPrintf(
                    "Infinite luminance value returned "
                    "for pixel (%d, %d), sample %d. Setting to black.",
                    pixel.x, pixel.y,
                    (int)tileSampler->CurrentSampleNumber());
                L = Spectrum(0.f);
            }
            VLOG(1) << "Camera sample: " << cameraSample << " -> ray: " <<
                ray << " -> L = " << L;

            // Add camera ray's contribution to image
            filmTile->AddSample(cameraSample.pFilm, L, rayWeight);

            // Free _MemoryArena_ memory from computing image sample
            // value
            arena.Reset();
        } while (tileSampler->StartNextSample());
    }

我们最关心的是do-while循环中的东西,这是真正的渲染过程:

  • 初始化CameraSample
  • 生成光线
  • 计算光线的辐射亮度
  • 将光线的贡献值保存到图像中
  • 释放内存

值得注意的是,这里的Li函数不是光照传输方程中的Li,这里的Li函数对应的是光照传输方程中的Lo。SamplerIntegrator类中Li函数是一个纯虚函数,也就是说它只是一个接口,而上述的光照方程实现可以在具体类WhittedIntegrator中看到:

Spectrum WhittedIntegrator::Li(const RayDifferential &ray, const Scene &scene,
                               Sampler &sampler, MemoryArena &arena,
                               int depth) const {
    Spectrum L(0.);
    // Find closest ray intersection or return background radiance
    SurfaceInteraction isect;
    if (!scene.Intersect(ray, &isect)) {
        for (const auto &light : scene.lights) L += light->Le(ray);
        return L;
    }

    // Compute emitted and reflected light at ray intersection point

    // Initialize common variables for Whitted integrator
    const Normal3f &n = isect.shading.n;
    Vector3f wo = isect.wo;

    // Compute scattering functions for surface interaction
    isect.ComputeScatteringFunctions(ray, arena);
    if (!isect.bsdf)
        return Li(isect.SpawnRay(ray.d), scene, sampler, arena, depth);

    // Compute emitted light if ray hit an area light source
    L += isect.Le(wo);

    // Add contribution of each light source
    for (const auto &light : scene.lights) {
        Vector3f wi;
        Float pdf;
        VisibilityTester visibility;
        Spectrum Li =
            light->Sample_Li(isect, sampler.Get2D(), &wi, &pdf, &visibility);
        if (Li.IsBlack() || pdf == 0) continue;
        Spectrum f = isect.bsdf->f(wo, wi);
        if (!f.IsBlack() && visibility.Unoccluded(scene))
            L += f * Li * AbsDot(wi, n) / pdf;
    }
    if (depth + 1 < maxDepth) {
        // Trace rays for specular reflection and refraction
        L += SpecularReflect(ray, isect, scene, sampler, arena, depth);
        L += SpecularTransmit(ray, isect, scene, sampler, arena, depth);
    }
    return L;
}

其中L += isect.Le(wo);对应光照方程的Le,那个for循环就是后面的积分实现,整个Li函数就是Lo。

于是,理论和代码实现就统一起来了。

总结

总的而言,代码并不复杂,复杂的是原理,所以pbrt的核心代码没有那么多。要能理解算法的原理,需要很多数学知识,包括微积分、概率论、采样理论等等。总之,慢慢啃吧~

参考资料

Physically Based Rendering
pbrt源码,版本3

你可能感兴趣的:(【PBRT】《基于物理的渲染:从理论到实践》精华提炼(I))