我以前因为对真实感渲染的好奇去读 PBRT,书里依次介绍了 Shape、Camera、Sampler、BSDF、Light、Integrator……我一直看不明白,费了很大力气去读,才发觉这整本书写的都是蒙特卡洛光线追踪 Monte Carlo RayTracing,这和 smalllpt 本质上是一样的。去年偶然想到可以把 smallpt 一步步的改写成 pbrt,在这个过程中介绍蒙特卡洛光线追踪和 pbrt 的架构,用了几个月(基本)完成了:
可以把它当作是阅读 pbrt 的前菜,先熟悉相同结构下这个 smallpbrt 的工作流程,再去看 pbrt 应该会清晰一点。
文章用了别的标记语言来写,只能用 html 格式导出,这里贴一些片段:
smallpt[1] 是一个仅有 99 行的迷你光线追踪渲染器,在极小的体积下实现了图元、材质、光源、场景、相机、采样器、积分器等模块,可以说麻雀虽小五脏俱全。关于 smallpt 是如何实现这些功能的,网络上已经有些很好的介绍[2][3][4]。
pbrt[5] 既是一个多功能光线追踪渲染器的名字,也是对应书籍的简称[6],书中以文学编程 Literate Programming 的方式,介绍了这个读者如果能看懂就看得懂,看不懂就看不懂的渲染器。pbrt 具有一个相当优秀的架构,给很多渲染器带来了启发[7][8][9]。下文中提到的 pbrt 指渲染器,PBRT 指书籍。
smallpt 和 pbrt 虽然体量和功能差异巨大,但在笔者眼里却有着一样的核心功能:蒙特卡洛光线追踪(Monte Carlo Ray Tracing),这就像王垠说的:
很多人都不知道,有一天我用不到一百行 Scheme 代码就写出了一个「深度学习框架」,它其实是一个小的编程语言。虽然没有性能可言,没有 GPU 加速,功能也不完善,但它抓住了 PyTorch 等大型框架的本质——用这个语言写出来的函数能自动求导。这种洞察力才是最关键的东西,只要抓住了关键,细节都可以在需要的时候琢磨出来。几十行代码反复琢磨,往往能帮助你看透上百万行的项目里隐藏的秘密。
如何阅读别人的代码 https://www.yinwang.org/blog-cn/2020/02/05/how-to-read-code
通过阅读一个小规模的软件,来了解这一类型软件的架构模式是很常见的做法[10]。但 smallpt 为了控制体积实在太简略了一点,导致它的结构并没有那么清晰,想从 smallpt 里看出 pbrt 的架构并不容易,而且它那 99 行的代码里还涉及了大量的理论技术。
本文将基于 pbrt 的架构一步步改写 smallpt,通过每一步的实际代码去解释蒙特卡洛光线追踪和 pbrt 的大致架构,方便对光线追踪、全局光照感兴趣的同学了解学习。
……
这一步会把 smallpt 改写成 pbrt,在改写之前先来介绍下 pbrt。
pbrt 的主要功能是根据输入的描述文件创建出场景,并根据相机的参数渲染出对应图像,它的整个工作过程如下:
SamplerIntegraotr
:通过(蒙特卡洛方法)采样,来求解渲染方程(一个积分方程的)的积分器,也可以把它当做通常的渲染器 Renderer 看待。SamplerIntegraotr::Render()
: SamplerIntegraotr 的基类是抽象类 Integrator,它只有 Integraotr::Render()
这一个接口,pbrt 在创建完场景和积分器后,就会调用积分器开始渲染场景。每种积分器实现会用自己的方式去渲染场景。SamplerIntegraotr::Li()
: SamplerIntegraotr 的核心方法是 Li(ray, scene, sampler, depth)
,对应 smallpt 中的 Radiance(ray, sampler, dpeth)
(smallpt 的场景是全局的,不用传进来)。在 Li(ray, scene, sampler, depth) 中会同时采样 BRDF 和光源,关于要如何采样光源,会放到额外拓展的部分介绍。这就是 pbrt 的整体架构,也是 pbrt 的核心功能:求解渲染方程。(smallpt:我也是)
是不是对上了。
对于上面描述的这些概念,pbrt 都有对应的抽象类(接口类)来实现:
并且通过这组抽象类之间的交互(上面的箭头)来完成功能。每个抽象类都会有多种实现,比如对于材质 Material,就可以有哑光材质 MatteMaterial(通过完全漫反射 BRDF LambertenReflection 实现);镜面材质 MirrorMaterial(通过完全镜面反射 BRDF SpecularReflection 实现)。这些实现类基本放在 core/
之外的其它文件夹下。
除此之外 pbrt 还实现了大量别的功能:并行/内存分配/统计/调试/场景解析等一系列功能,于是它的代码行数就来到了几万行(没统计过,大概是这个量级)。
99 行 vs 几万行,这两个数量级要怎么跨过呢?
给 smallpt 加功能是不可能的,假如只保留 pbrt 的核心功能,去掉那些实现类和其它功能呢?
shape/
目录camera/
目录sampler/
目录至于场景中的空间加速结构 Aggregate,图像平面的滤波器 Filter,提供材质细节的纹理 Texture,实现体积渲染的介质 Medium 等,连抽象类都可以去掉。
到这里就只剩下了 core/
这个目录,把它的代码整合一下,再去掉并行等一系列没了也能跑的功能,给每个抽象类(Shape,Sampler,Camera,BSDF/BxDF,Light,Integrator……)加上最简单的实现,我们就得到了一千行左右的 smallpt_rewrite.cpp。(也可以管它叫 smallpbrt.cpp )
如果在这个基础上再做精简的话:
Radiance(...)
的实现都是 tirck 吧)在前几节,我们已经从 smallpt.cpp 反向实现了 smallpt_format.cpp 和 smallpt_comment.cpp ,接下来将把 300 行的 smallpt_comment.cpp 结构化为 1300 行的 smallpt_rewrite.cpp / smallpbrt.cpp ,这就有了本文的标题。
smallpt_rewrite.cpp 的提交记录比较干净,感兴趣的同学可以直接看每次提交都结构化了什么功能。
……
回想起以前刚学渲染的时候,既看不懂公式,也看不懂代码,只能看着那些漂亮的渲染图发愣,那种被挡在外面的感觉真是太痛苦了,因此想尽自己的一点努力给感兴趣的同学铺点基础,虽然还有很多细节没有提到,但笔者认为本文已经把蒙特卡洛光线追踪的基础框架搭起来了,剩下的知识都可以逐个去了解,希望这可以在图形渲染的路上助大家一臂之力。
祝收敛!