参见http://www.opengpu.org/forum.php?mod=forumdisplay&fid=8
【题外话:这里只是记录我学习该书的体会,既不是直译,也不是意译,而是顺着作者的思路,把自己理解的东西再讲述出来,对于可能出现的误解会用原文说明。】
第一章介绍
关于渲染,有很多方式。大致有三类:
基于物理学的渲染(Physically based):着力于模拟现实。就是说,用物理学的原理搭建关于光和物质交互的模型,追求真实感是该类方法的首要任务。
非真实感的渲染(Nonphotorealistc)。这是为艺术的自由表达而作的渲染。
该书所描述的pbrt是基于光线追踪算法的物理学渲染系统。其它相关的书籍只是介绍原理,算法,或许还夹杂些少许源代码。该书则不同,因为它带了一个完全能工作的完备的渲染系统。(正是这个原因,有很多人用这个系统为蓝本作研究,甚至有LexRender这样相当高级的系统出现)。
1.1 文学编程(Literate Programming)
【该书开篇讲了文学编程,这是本书的组织方法,其理念和用法贯穿全书,故不得不学。文学编程是软件老泰Donald Knuth(老泰:老泰斗之缩写,他是谁就不用说了吧)的创造。该书作者Matt和Greg想必是他老人家的忠实信徒,也来一把“文学编程”.】
Knuth老泰写Tex系统的时候,阐述了一个简单而具革命性的思想:程序更应该写给人读的,而不仅仅是给计算机的,名之为文学编程。该书就被作者号称为一部长长的文学程序(Literate program)。文学程序是用一种元语言(metalanguage)写成,该元语言把一种文档格式化语言(document formatting language, 例如TeX, HTML)和一种编程语言(例如C++)混合使用。它提供两种功能:1)把文章跟源程序混在一起,使得对程序的描述跟实际的源代码一样重要,这样可鼓励仔细的设计和文档编写。2)跟提交给编译器的方式相比,它提供给程序读者全然不同的展现方式,这样使得程序的描述逻缉性很强。每段代码都加以名字,称为片断(fragment).每个片断可以用名字引用其它片断。
举例说明:有下面一段程序:
void InitGlobals(void) {
num_marbles = 25.7;
shoe_size = 13;
dialectric = true;
my_senator = REPUBLICAN;
}
如果没有上下文的话,它很是费解。你得搜索整个程序来查看每个变量的定义和它们的目的。这种结构对编译器没有任何问题,而对读者而言,读者更希望看到每个变量的初始化代码能在靠近声明和使用它的地方单独表达出来。(有点绕口,原文:a human reader would much rather see the initialization code for each variable presented separately, near the code that actually declares and uses the variable).
在文学程序中,可以这样写:
<Function Definitions>=
void InitGlobals() {
<Initialize Global Variables 3>
}
(“3”是书上的页码)
这就是一个片断, 名字是<Function Definitions>, 它定义了InitGlobals()函数,并引用在第3页的另一个片断<Initialize Global Variables〉。
当我们引入全局变量shoe_size时,我们可以写:
<Initialize Global Variables〉=
shoe_size = 13;
当我们再引入全局变量directric时,我们可以写:
<Initialize Global Variables〉 +=
dialectric = true;符号
+=(包括上面的“=”,原书是三条横线,因无法输入,用“=”代替)表示我们要对片断添加新的声明。
可以看出,我们可以把很复杂的函数化解成不同的逻辑部分,每一部分都很容易理解。(整部书都是按照这个步调有条不紊地,由简入繁地解释书中个个要点)。
1.3 pbrt: 系统概述 pbrt用的是插件式架构。pbrt执行文件包含了系统主要控制流程的核心代码,但并不包括象球体,聚光灯这样的具体元素的代码。核心渲染器是用抽象类写成的,这些抽象类定义了插件类型的接口。在系统运行时,用于场景渲染的子类模块被加载进来。这种组织方法很适合于系统的扩展:仅仅写个新插件就可以扩展出新的功能。当然, 我们无法预知开发者扩展系统的方式,有时修改核心渲染器还是很有必要的。 1.3.1 程序执行的各个阶段 pbrt分三个执行阶段: 1. 分析用户提供的场景描述文件。该文件是一个文本文件,说明了场景中所有几何形体及其材质, 光源, 相机,所有算法的参数。分析的结果是创建出一个Scene类的实例。 2. 渲染主循环 下一步是进入主要的渲染流程。这个阶段是最耗时的了,实现代码在Scene::Render(),我们将在1.3.3节介绍它。 3. 图像后处理并存盘,信息统计,内存释放等善后工作。 1.3.2 场景的表达
pbrt的main()函数非常简单:首先调用pbrtInit()来做系统初始化,然后分析所传入的每个场景文件,并对它们逐个进行渲染,最后调用pbrtCleanup()做最后的清理工作: <main program> = } 如果没有命令行变量传入pbrt, 则从标准输入中读取场景信息;否则,对每个命令行变量所指定的文件进行分析: < Processscene description>= } 场景文件的分析器是用lex和yacc写成,lex和yacc文件分别在core/pbrtlex.l 和 core/pbrtparse.y。经过对场景文件的分析,表示相机、光源、几何体素(geometric primitives)的对象就被创建出来,再加上管理渲染过程的其它对象,就组合成了Scene对象,这个对象是由RenderOptions::MakeScene()来创建的。Scene类的定义在文件core/scene.h和core/scene.cpp。注意其中的类定义中用了COREDLL宏定义,这用来表明这些类是核心渲染库所导出(export)的类,这在Windows平台上是必须的。 <SceneDeclarations>= 每个几何物体由一个Primitive对象来表示, Primitive包括两个对象:一个Shape对象(用来说明几何形状)和一个Material对象(用来说明材质)。所有的这些几何体素加在一起用一个单独的Primitive来表示,被称为Scene::aggregate。 它是一种特殊的Primitive,因为它只保持对其它许多Primitive的引用。 <SceneData> = 每一个光源由一个Light对象表示, 它说明光源的形状和光能量分布。Scene类把所有的光源放在一个C++标准库的Vector对象中。有些渲染程序让每个体素带有一个光源列表,这样可以允许一个光源只能照到有限的几个物体。这个方法不太适合pbrt的基于物理的渲染,所以我们只支持用于全场景的光源。 <SceneData> += Camera对象用来控制观察和镜头参数,比如相机位置、朝向、焦点、视野等。Camera类中有一个Film成员,它被用来存储图像。 <SceneData> +=
除了几何体素,pbrt还支持参与介质(participating media)或体积体素(volumetric primitives),pbrt通过VolumeRegion接口对这类体素提供支持。跟原始几何体一样, 所有的VolumeRegions被放在一个单一的成员中: <SceneData> += 积分器模拟光在场景中的传播并计算有多少光到达胶片版的图像采样位置。之所以称之为积分器,是因为它用数值方法对表面上和体积上的光传输方程求积分。表面积分器计算从几何表面上的反射光, 而体积积分器计算从体积体中散射出来的光。 <SceneData> += VolumeIntegrator * volumeIntegrator; 每个Scene还包括一个Sampler类的对象。采样器的功能很微妙, 因为它的实现极大地影响图像的质量。首先, 采样器负责选择图像平面上的点,用来生成被追踪的光线。其次, 它负责提供采样位置给积分器,用于光传输计算。比如,有些采样器随机地在面光源上选择点,来计算面光源的照明。 <SceneData> +=
1.3.3 渲染主循环 当我们创建并初始化好Scene对象后,就可以调用Scene::Render(),开始pbrt的第二阶段的执行: 渲染主循环。对图像平面上的每一个位置,用Camera和Sampler生成射入场景中的光线,然后用SurfaceIntegrator和VolumeIntegrator决定有多少光沿着光线路径到达图像平面。这个值被传给Film而被记录下来。 (图1.15) <SceneMethods> = } 在渲染开始之前,Render()构造一个Sample对象, 在主循环过程中,采样器将把采样结果放在里面。因为采样的数量和类型多半取决于积分器,所以Sample构造器要用到积分器的指针: <Allocate and Initialize sample> = 渲染开始之前的另一项工作是调用积分器的Preprocess()方法, 进行跟场景相关的初始化。比如说,16.5节的PhotonIntegrator会创建关于光照分布的数据结构。 <Allowintegrators to do pre-processing for the scene> = 光线追踪是很缓慢的过程, 特别是对有复杂光照和场景的情况更是如此。 ProgressReporter对象为用户提供一个直观的关于pbrt进程的反馈。
渲染主循环终于开场了: 在每次循环中, 我们调用Sampler::GetNextSample(),用下一个图像采样值初始化sample, 直到没有采样返回为止。在循环体中的片断(fragments)找到相对应的相机光线,并将它传给积分器,计算沿光线路径到达胶片平面上的光辐射亮度(radiance)。最后,把结果放到图像中, 释放内存资源, 更新进程报告(ProgressReporter)。 <Trace rays: The main loop> = } Camera类中的Camera::GenerateRay()生成给定图像采样位置的光线。它根据采样的内容初始化ray的每个成员。所生成的光线的方向向量是规格化的(单位长度为1)。 相机还把一个浮点数权值赋给光线。对于简单的相机模型而言,所有光线的权值是相同的。但对于更复杂的模型,相机可以生成更有贡献性的光线。比如, 对于真实相机,到达胶片平面边缘的光要少于中间的位置的光,即所谓的渐晕效应(vignetting)。 Camera::GenerateRay()返回这个权值,用于控制光线对图像的贡献值。 <Find camera rayfor sample> =
为了得到某些纹理函数的更佳效果(第11章), 有必要生成在图像平面x和y方向上相距一个象素远的额外光线。这些额外的光线可以用来计算纹理关于象素间距的变化,这是纹理反走样的关键一环。类Ray只记录光线的原点和方向, RayDifferential继承了Ray,并加上两条额外的Ray成员rx,ry来记录它的相邻光线。 <Generate ray differentials for camera ray> = 现在我们有了一条光线, 下一个任务是确定有多少光(单位是光辐射亮度Radiance)沿这这条光线到达图像平面,Scene:: Li()就是用来计算该值的。光辐射亮度值由Spectrum类来表示,这是pbrt对关于波长的能量分布的抽象—换句话说,就是颜色。
除了返回radiance值,Scene:: Li()还设置alpha值,即光线的透明度。如果光线碰到不透明的物体,则alpha值设为1;如果光线穿过象雾这样的半透明体,且没有碰上任何不透明体,则alpha在0和1之间。如果光线没有碰到任何东西, 则alpha为0。Alpha值可用于很多的后处理效果。比如, 把一个被渲染的物体合成到一幅照片上。 <Evaluate radiance along camera ray> =
得到光线的贡献值后,就可用Film::AddSample()更新图像了(见7.6,8.1,8.2节)。 <Add sample contribution to image> =
pbrt用BSDF类来描述表面上点的材质。在渲染过程中, 有必要为每个采样存储BSDF值。为了避免对系统内存申请函数的重复调用,我们用MemoryArena类管理BSDF内存池。一旦对一个采样的贡献值计算完毕,要通知BSDF类不再需要其相关的内存了。 <Free BSDF memory from computing image sample value> = 最后,调用ProgressReporter::Update(),让ProgressReporter知道完成了一条光线的追踪。 <Report renderingprogress> = 在主循环的最后, Scene::Render()释放Sample的内存, 让ProgressReporter报告任务完成, 并写盘: <clean up after rendering and store final image> = 1.3.4 场景的成员函数 除了Render()以外, Scene类还有其它几个很有用的函数。 Scene::Intersect用来测试光线是否和场景中的物体相交。如果相交, 则在Intersection结构中添入沿着光线的最近交点。 <Scene PublicMethods> = bool Intersect(const Ray &ray, Intersection *isect)const { } 另一个相似的函数是Scene::IntersectP(),它只判定是否有交点存在, 并不计算出所有的交点并返回最近一个, 故效率要快得多, 它被用在阴影光线(shadow rays)上。 <Scene PublicMethods> += } Scene::WorldBound()返回包含场景中所有几何体的包围盒,实际上它是Scene::aggregate的包围盒。 <Scene Data>+= BBox bound;
<SceneConstructor Implementation> =
<SceneMethods> += Scene:: Li()函数返回给定光线的辐射亮度。它首先调用SurfaceIntegrator:: Li()计算光线跟第一个相交的表面所产生的出射辐射亮度Lo。然后,调用VolumeIntegrator::Transmittance()计算光源T因参与介质而产生的光的消弱程度。最后,调用VolumeIntegrator:: Li()来计算因参与介质而使辐射亮度得到加强的那部分Lv。最终结果应该是TLo + Lv。 <SceneMethods> += Spectrum Scene :: Li(const RayDifferential &ray, const Sample *sample, float*alpha) const { Spectrum Lo = surfaceIntegrator->Li(this, ray, sample, alpha); } 另外, Scene::Transmittance()定义为对其中的体积积分器的调用: <Scene Methods>+= } 1.3.5一个Whitted风格的光线追踪积分器 第16和17章介绍了很多表面和体积积分器的实现, 它们所基于的算法的精确度不同. 这里介绍一个基于Whitted光线追踪算法的表面积分器. 这个积分器精确地计算从平滑表面(玻璃,镜子,水面等)发出的反射光和透射光,并不考虑间接照明效果.更复杂的积分器也是建筑在这个积分器的基本思想上的. <WhittedIntegrator Declarations> = Class WhittedInteger : public SurfaceIntegrator { Public: <WhittedIntegrator Public Methods> Private: <WhittedIntegrator Private Data> }; 积分器的核心部分是Integrator::Li(), 它返回沿着光线的光辐射亮度. 下图总结了在表面积分的过程中主要类之间的数据流程:
(图1.16) <WhittedIntegrator Mothod Definitions> = Spectrum WittedIntegrator::Li(const Scene *scene, const Raydifferential &ray, const Sample *sample, Float *alpha) const { Intersection isect; Spectrum L(0.); bool hitSomething; hitSomething = scene->Intersect(ray, &isect); if(!hitSomething) <Handle ray with nointersection> else { <Initialize alpha for ray hit> <Compute emitted and reflected light at ray intersection point> } return L; } 积分器首先要用Scene::Intersect()求交点. 如果没有找到交点, 我们把光线的alpha值设置为0.然而, 有些类型的光源没有几何信息但仍对那些没有交点的光线产生贡献值. 比如,天空会对地球表面产生蓝色光照作用, 而天空没有什么几何信息. 所以,我们仍需调用Light::Le()来支持这种情形(虽然大多数光源并不会对这条光线有贡献值). 第13.5节会介绍一种光源,它直接照到胶片平面上,这时,我们要把alpha值设置为1(不透明). <Handle ray with no intersection> = if (alpha) *alpha = 0; for ( u_int i = 0; i < scene->lights.size(); ++i) L += scene->lights[ i ]->Le(ray); if(alpha && !L.Black()) *alpha = 1.; return L; Whitted积分器沿着光线反射和折射的方向递归地求值,所以要记录递归深度,并当其到达预先设定的最大深度值时,停止递归过程, 以防止过程无限地进行下去(充满镜子的房间就会产生这种现象). <WhittedIntegrator Private Data> = int maxDepth; mutable int rayDepth; 如果我们找到了交点,首先要做的是将输出变量初始化为1: <Initialize alpha for ray hit> = if (alpha) *alpha = 1.; 现在我们到达了Whitted积分器的核心: 累加每个光源的贡献值并模拟全反射和折射: <Compute emitted and reflected light at rayintersection point> = <Evaluate BSDF at hit point> <Initialize common variables for Whitted integrator> <Compute emitted light if ray hit an area light source> <Add contribution of each light source> if (rayDepth++ < maxDepth) { <Trace rays for specular reflection and refraction> } --rayDepth; 在pbrt中,双向散射分布函数由BSDF类表示.pbrt有几种标准散射函数的实现, 包括Lambert反射,Torrance-Sparrow微表面模型(第9章).BSDF接口能用来对一个给定的表面上的点着色,但是表面上每个点的BSDF属性可能不尽相同. 比如木头和大理石, 即使木头被模型化为全漫反射,但其表面上每个点上的颜色仍取决于木头的纹理.这种着色参数的空间变化有Textures类表示, Textures既可以是过程型的,也可以存储在用图像里. 我们用Intersection::GetBSDF()来取得交点处的BSDF值: <Evaluate BSDF at hit point> = BSDF *bsdf = isect.GetBSDF(ray); 下面一段代码初始化交点位置p,表面法向量n, 从交点到光线原点的规则化向量ωo: <Initialize common variables for Whittedintegrator> = const Point &p = bsdf->dgShading.p; const Normal &n = bsdf->dgShading.nn; Vector wo = -ray.d; 如果交点所在的几何体是发光的(如面光源), 积分器用Intersection::Le()返回所发出光的辐射亮度: <Compute emiited light if ray hit an area lightsource> = L += isect.Le(wo); 对于每个光源,积分器调用里Light::Sample_L()来计算其对着色点的贡献值, 同时返回从点到光源的方向向量,存在变量wi中. 这个函数并不考虑光源被其他物体遮挡的情况,而是返回一个VisibilityTester对象,而这个对象可以探测出是否有物体当在光源和着色点之间. 正如前面将过的,是用阴影光线(shadow ray)的方法解决这个问题. 如果到达该点的光辐射亮度非零,则BSDF给出关于方向对(ωo, ωi)的贡献值, 积分器把光辐射亮度值Li乘以BSDF,cosine项,和光线与交点之间的透射比(Transmittance)T: <Add contribution of each light source> Vector wi; for(u_int i = 0; i < scene->lights.size(); ++i) { VisibilityTester visibility; Spectrum Li = scene->lights[ i ]->Sample_L(p, &wi, &visibility); if(Li.Black()) continue; Specturm f = bsdf->f(wo, wi); if(!f.Black() && visibility.Unoccluded(scene)) L += f * Li * AbsDot(wi, n) * visibility.Transmittance(scene); } 积分器还处理全反射的表面(镜子,玻璃等). 根据镜像原理,积分器很容易地求得反射光的方向,并递归地对之追踪. BSDF::Sample_f()对给定的散射模式和出射方向返回一个入射光的方向,这是蒙特卡罗光传输算法的基础之一(本书最后几章有详细介绍)。这里,我们只用它得到相对于全反射和折射的出射方向,并且用一个标志来指示BSDF::Sample_f()要忽略其它类型的反射。虽然BSDF::Sample_f()采样离开表面的随机方向(用于概率积分算法),其随机性要受BSDF的散射性质的限制。在全反射情况下,只有一个方向是可能的,所以就根本没有随机性了。 下面的片断有两个对BSDF::Sample_f()的调用,把wi初始化为选定的方向,并给出关于方向对(ωo, ωi)的BSDF值。如果BSDF值非零,积分器就用Scene::Li()来得到沿着ωi方向的入射辐射亮度,最后WhittedIntegrator::Li()再将被调用。为了计算反射积分的cosine项,积分器调用AbsDot(),由于向量wi和n都是正规化的,其返回的值正是cosine值。 还有用光线微分作纹理反走样的内容,见第11.1.3 节。 <Trace rays for specular reflection and refraction> =
到这里,第一章内容大致地过了一遍。还有剩两个短短的小节没有涉及: 1.4 如何读这本书 1.5 如何用本书的代码 这里就略掉不提了。 |