渲染(rendering)三维物体是计算机图形学的基本任务之一,即接收由一系列几何物体构成的 3D 场景,然后计算从特定视角下所看到的 2D 图像。这和数百年来建筑师、工程师所做事情一样——绘制图纸并传达给他人。
渲染是一个过程,接收一组物体(object),生成一个像素阵列。它需要考虑每个物体对每个像素的贡献,这里有两种主流的计算方式。物体序渲染(object-order rendering)就是依次考察每个物体,找出并更改一个物体影响的所有像素。图像序渲染(image-order rendering)就是依次考察每个像素,找出影响一个像素的所有物体并计算像素值。射线追踪(ray tracing)便是一种渲染 3D 场景的图像序算法。
如果输出的是矢量图,渲染不一定涉及像素。这里假定都是栅格图。
“ray tracing” 也可译为光线追踪。
射线追踪常用于选点(picking)。
图像序和物体序可以生成完全相同的图像,但它们适用于不同的应用场景,并拥有非常不同的性能特征。一般来说,图像序渲染更易于实现,也更灵活,但通常需要花费更多时间。
射线追踪算法基础
射线追踪器(ray tracer)逐像素计算,对于每个像素,基本任务是找出该像素位置能够看到的物体。每个像素看向不同的方向,任何能被看到的物体一定和视射线(viewing ray)相交,视射线就是从像素发出的、沿着视向的射线。我们想要的就是与视射线相交的、离相机最近的物体。一旦找到了该物体,就可以使用交点、表面法向量以及其它信息来计算像素的颜色并为之着色(shading)。

一个基础的射线追踪器由以下三个部分构成:
- 射线生成(ray generation):根据相机的几何,计算出每个像素的视射线的源点(origin)和方向。
- 射线求交(ray intersection):找出与视射线相交的、离相机最近的物体。
- 着色(shading):基于射线求交的结果计算出像素的颜色。
射线追踪程序的基本结构:
for each pixel do计算视射线找出射线命中的第一个物体以及交点处表面法向量 n根据交点、光源以及 n 计算像素值
透视(Perspective)
在有计算机之前几百年,就已经有画家研究如何用 2D 图像表示 3D 场景了。虽然有很多非常规的方式生成 2D 图像,但对于美术、摄影以及计算机图形学而言,标准方法是线性透视法(linear perspective),这种方法将 3D 物体投影到像平面(image plane)上,场景(scene)中的直线在图像中仍为直线。
最简单的投影方式是平行投影(parallel projection),此时所有的 3D 点都沿着同一个投影方向(projection direction)。所生成的平行视图(parallel view)依赖于投影方向和像平面。如果像平面和投影方向垂直,则称为正交投影(orthographic projection),否则称为斜投影(oblique projection)。平行投影常用于机械制图和建筑图纸,因为它保持了平行于像平面的平面物体的形状和大小。

平行投影的优点也正是它的局限性。日常生活中,物体成像总是近大远小的,照片中更是如此。这是因为人眼和相机接收的是通过某个特定视点(viewpoint)的光,而不是平行光。正如自文艺复兴以来画家所认识到的一样,计算机图形学中也可以使用透视投影(perspective projection)来生成更加自然的图像,即沿着一系列通过视点(viewpoint,也是投影中心)的直线进行投影。投影后的透视视图(perspective view)依赖于视点和像平面。在透视投影中,图像中心的投影方向与像平面垂直时称为非斜透视(non-oblique perspective),否则称为斜透视(oblique perspective)。

画家常用的三点透视法(three-points perspective)可用于手绘透视视图。所有手绘透视的规则都是透视投影数学规则的推论。

计算视射线
根据视点(或视向,view direction)和像平面可以生成视射线。射线(ray)的数学描述如下:
p(t)=o+td
其中,o 为射线的源点(origin),d 为射线的方向向量。易知,如果 0<t1<t2,那么 p(t1) 比 p(t2) 离源点更近;如果 t<0,那么 p(t) 在反方向。
面向对象的射线类伪代码:
class RayVec3 o | 射线源点Vec3 d | 射线方向Vec3 evaluate(real t)return o+td
其中,Vec3 是三维向量类。

生成视射线首先需要一个相机标架(camera frame)。相机标架 {e;u,v,w} 是一个标准正交坐标标架(orthonormal coordinate frame),它的原点 e 是视点,基向量 u 指向相机右方,v 指向相机上方,w 指向相机后方。相机标架通常根据视点 e、视向 −w 和一个向上的向量(up vector)来构造。

正交视图(orthographic view)
正交投影中所有射线方向均为 −w,源点位于各个像素所处的位置。相机标架原点的位置用于确定射线源点所在平面,使得可以描述位于相机后方的物体。
视射线源点位于 {e;u,v} 定义的平面上。nx×ny 的栅格图中像素 (i,j) 在平面标架 {e;u,v} 中的坐标为:
栅格图中的像素坐标按第三章 3.2 节约定。
{uv=l+(r−l)(i+0.5)/nx=b+(t−b)(j+0.5)/ny
其中,t、b、l、r 分别是栅格图上、下、左、右四条边在平面标架 {e;u,v} 中的坐标。通常,l<0<r,b<0<t,即相机标架的原点位于栅格图内部。
生成正交视射线(orthographic viewing ray)的步骤:
计算像素 (i,j) 的坐标 (u,v)ray.o←e+uu+vvray.d←−w
用视向 d 代替上述最后一步中的 −w,便可以得到斜平行投影的结果。

透视视图(perspective view)
透视投影中所有射线源点都位于视点,方向指向各个像素的位置。像平面处于视点 e 前方距离 d 的位置,这一距离称为像面距(image plane distance),也称为焦距(focal length)。
生成透视视射线(perspective viewing ray)的步骤:
计算像素 (i,j) 的坐标 (u,v)ray.o←eray.d←−dw+uu+vv
上式也适用于斜透视。
射线与物体交点(ray-object intersection)
更一般的射线物体交点问题是:找出射线和区间 [t0,t1] 上所有物体的第一个交点 t。基础的射线物体交点问题只是 t0=0、t1=+∞ 的特例。
射线与球面的交点
计算射线 p(t)=o+td 与曲面 f(p)=0 的交点等价于求解方程:
f(p(t))=0orf(o+td)=0
球心 c、半径 R 的球面方程为:
(p−c)⋅(p−c)−R2=0
代入射线参数方程并整理可得:
(d⋅d)t2+2d⋅(o−c)t+(o−c)⋅(o−c)−R2=0
解一元二次方程求出交点,根据所求 t 以及范围 [t0,t1] 即可得到最终结果。计算求根公式之前,先计算判别式,可以知道可能的交点数目。如果判别式为负,则无交点;若为正,则有两个交点,一个进入球,一个离开球;若为零,则射线与球面相切。
球面上点 p 处单位法向量为 (p−c)/R。
射线与三角形的交点
计算射线与三角形交点的算法有很多,这里给出的是使用三角形重心坐标的形式。除了三角形的顶点(vertice)以外,这一算法无需额外的长期存储(long-term storage)。
计算射线与参数曲面的交点等价于求解如下方程:
o+td=p(u,v)
上式其实是三个标量方程所构成的方程组,包含了三个未知量 t、u、v。当曲面是由 △abc 确定的平面时,该方程具有如下形式:
o+td=a+β(b−a)+γ(c−a)
上式是一个 3×3 线性方程组,求解该方程组可以得到交点。若无解,则说明射线与平面平行,或者三角形退化为直线或点。方程组有解时,t 给出了交点在射线上的位置,u、v 给出了交点在平面上的位置。根据重心坐标的知识可以知道,只有 β>0、γ>0、β+γ<1 同时满足,交点才在 △abc 内部。
求解 3×3 线性方程组的最快的、最经典的方法就是克拉默法则(Cramer's rule)。对于一般的 3×3 线性方程组:
abcdefghiβγt=jkl
克拉默法则给出的结果为:
βγt=Mj(ei−hf)+k(gf−di)+l(dh−eg)=Mi(ak−jb)+h(jc−al)+g(bl−kc)=−Mf(ak−jb)+e(jc−al)+d(bl−kc)
其中,M=a(ei−hf)+b(gf−di)+c(dh−eg)。利用上面重复出现的项可以减少运算次数,如 ei−hf。
对于上面的算法,还可以通过某些条件来提前结束程序:
boolean raytri(Ray ray, Vec3 a, Vec3 b, Vec3 c, interval [t0,t1])计算 tif (t<t0) or (t>t1) thenreturn false计算 γif (γ<0) or (γ>1) thenreturn false计算 βif (β<0) or (β>1−γ) thenreturn falsereturn true
射线求交在软件中的实现
实现射线追踪的一个好的想法是,应用面向对象的编程范式,设计一个名为 Surface 的类,Triangle、Sphere 均为其子类。任何需要计算射线交点的物体都是 Surface 的子类,包括曲面组(group of surface)或其它更高效的几何数据结构。不管什么模型,射线追踪程序都将直接引用 Surface 相关的接口。
Surface 类中最关键的是计算与射线交点的方法:
class SurfaceHitRecord hit(Ray ray, real t0, real t1)
其中,[t0,t1] 是射线交点的判定区间,HitRecord 类包含了所有关于曲面交点的信息:
class HitRecordSurface s | 射线命中的曲面real t | 交点沿射线的坐标Vec3 n | 交点处表面法向量...
上面列举的是最低要求,其它数据也可以存储,比如切向量。有些编程语言会把 hitRecord 的引用传入函数并填充,而不是作为返回值返回。没有交点可以用 t=∞ 来表示。
射线与一组物体的交点
大多场景都包含了多个物体,此时我们想要的是所有交点中离相机最近的那个。一个简单的想法是,把一组物体看作一种特殊类型的物体。下面是计算射线与一组物体交点的伪代码:
class Group extends Surfacelist-of-Surface surfaces | 曲面列表HitRecord hit(Ray ray, real t0, real t1)HitRecord closest-hit(∞) | 初始化为未命中for surf in surfaces dorec = surf.hit(ray, t0, t1)if rec.t < ∞ thenclosest-hit = rect1=rec.treturn closest-hit
着色(shading)
一旦知道了每个像素可以看到的曲面,就可以通过计算着色模型(shading model,有时也称为光照模型)来得到像素值。着色模型依赖于具体应用,可以很简单,也可以很复杂,它与渲染序(图像序、物体序)无关。第五章描述一种简单着色模型,下面以该模型为例介绍如何着色。
光源(light source)
通常需要有一组光源才能进行着色。对于当前的着色模型,我们需要三种类型的光源:点光源(point light)、平行光(directional light)、环境光(ambient light,提供固定光照以填充阴影)。更精致的模型可能需要其它类型的光源:面光源(area light,其实就是一个发光几何体)、背景光(environment light,用一个图像来表示远处光源发出的光,如天空)。
计算点光源或平行光的着色需要一些几何信息:
- 着色点(shading point)x
- 表面法向量 n
- 光的方向 l
- 视方向(viewing direction)v=−d/∥d∥,与视射线方向 d 相反
每个曲面都得能够计算出该曲面上任一点的法向量。
在射线追踪器中,视射线与曲面的交点确定之后,所需的四个向量也就完全确定了。
由于环境光来自四面八方,因此环境光的着色与 l、v 均无关。对于这里所考虑的着色模型,环境光着色甚至也不依赖于 x 和 n。
如果场景中包含了多个光源,着色结果就是把各个光源的贡献加起来。
着色在软件中的实现
着色所需信息涉及两种对象:光源和材质(material)。光源类是 Light 类的子类。材质用 Material 类描述。
如何在光源和材质之间拆分着色计算,取决于实际情况。这里所采取的拆分方式是,光源负责整体的光照计算,材质负责计算 BRDF 值。
class LightColor illuminate(Ray ray, HitRecord hrec)class MaterialColor evaluate(Vec3 l, Vec3 v, Vec3 n)
其中,Color 类存储了 RGB 颜色分量,并支持相应的分量乘法(componentwise multiplication)。
点光源的伪代码如下:
class PointLight extends LightColor IVec3 pColor illuminate(Ray ray, HitRecord hrec)Vec3 x = ray.evaluate(hrec.t)real r=∥p−x∥Vec3 l=(p−x)/rVec3 n = hrec.normalColor E=max(0,n⋅l)I/r2Color k=hrec.surface.material.evaluate(l,v,n)return kE
环境光的伪代码如下:
class AmbientLight extends LightColor IaColor illuminate(Ray ray, HitRecord hrec)Color ka=hrec.surface.material.kareturn kaIa
其中,ka 是材质的环境光反射系数(ambient coefficient)。
完整的着色伪程序如下:
function shade-ray(Ray ray, real t0, real t1)HitRecord rec = scene.hit(ray, t0, t1)if rec.t<∞ thenColor c=0for light in scene.lights doc=c+light.illuminate(ray, rec)return celsereturn background-color
着色只是让 3D 物体本身看起来更加真实,它并不处理物体间的空间关系。
阴影
从着色点 x 到光源的射线称为阴影射线(shadow ray)。通过检查阴影射线与场景中的物体是否相交,就可以知道着色点是否在阴影中。
如果阴影射线的检查区间为 [0,r],则可能会因为数值精度问题,检查到阴影射线与源点所在曲面相交。通常设置检查区间为 [ϵ,r] 可以避免这一问题,其中 ϵ 是一个较小的正数。

点光源的阴影检测可以通过在光照程序 PointLight.illuminate 中添加如下分支逻辑来实现:
HitRecord srec = scene.hit(Ray(x, l), ϵ, r)if srec.t<∞ thenreturn 0 | 着色点在阴影中else计算光照
平行光的阴影检测只需将射线相交检测范围从 [ϵ,r] 改为 [ϵ,∞] 即可。每种光源的光照计算都需要一个单独的阴影射线。环境光着色计算不需要阴影检测。
阴影在表达相邻物体间关系时具有重要的视觉作用。
镜面反射(mirror reflection)
视射线的反射射线方向为:
r=d−2(d⋅n)n
其中,d 是视射线的方向。真实世界中,光线经过物体表面反射后会损失一部分能量,损失多少与光的颜色以及物体材质有关。

shade-ray 函数中,在计算完所有光源的贡献之后,引入递归即可计入镜面反射的贡献:
c=c+kmshade-ray(Ray(x,r),ϵ,∞)
其中,km 是镜面(specular)RGB 颜色,真实世界中可能依赖于光的入射角。通过限制最大递归深度(maximum recursion depth)可以避免无限递归的问题。生成反射射线之前先判断 km 是否为 0 可以让代码更高效。