《Fundamentals of Computer Graphics》第五版 第四章 射线追踪

447 阅读6分钟

渲染(rendering)三维物体是计算机图形学的基本任务之一,即接收由一系列几何物体构成的 3D 场景,然后计算从特定视角下所看到的 2D 图像。这和数百年来建筑师、工程师所做事情一样——绘制图纸并传达给他人。

渲染是一个过程,接收一组物体(object),生成一个像素阵列。它需要考虑每个物体对每个像素的贡献,这里有两种主流的计算方式。物体序渲染(object-order rendering)就是依次考察每个物体,找出并更改一个物体影响的所有像素。图像序渲染(image-order rendering)就是依次考察每个像素,找出影响一个像素的所有物体并计算像素值。射线追踪(ray tracing)便是一种渲染 3D 场景的图像序算法。

如果输出的是矢量图,渲染不一定涉及像素。这里假定都是栅格图。

“ray tracing” 也可译为光线追踪

射线追踪常用于选点(picking)。

图像序和物体序可以生成完全相同的图像,但它们适用于不同的应用场景,并拥有非常不同的性能特征。一般来说,图像序渲染更易于实现,也更灵活,但通常需要花费更多时间。

射线追踪算法基础

射线追踪器(ray tracer)逐像素计算,对于每个像素,基本任务是找出该像素位置能够看到的物体。每个像素看向不同的方向,任何能被看到的物体一定和视射线(viewing ray)相交,视射线就是从像素发出的、沿着视向的射线。我们想要的就是与视射线相交的、离相机最近的物体。一旦找到了该物体,就可以使用交点、表面法向量以及其它信息来计算像素的颜色并为之着色(shading)。

ray-intersection.png

一个基础的射线追踪器由以下三个部分构成:

  1. 射线生成(ray generation):根据相机的几何,计算出每个像素的视射线的源点(origin)和方向。
  2. 射线求交(ray intersection):找出与视射线相交的、离相机最近的物体。
  3. 着色(shading):基于射线求交的结果计算出像素的颜色。

射线追踪程序的基本结构:

for each pixel do计算视射线找出射线命中的第一个物体以及交点处表面法向量 n根据交点、光源以及 n 计算像素值\begin{aligned} &\text{\textbf{for} each pixel \textbf{do}} \\ &\quad\begin{aligned} &\text{计算视射线} \\ &\text{找出射线命中的第一个物体以及交点处表面法向量 $\vec{n}$} \\ &\text{根据交点、光源以及 $\vec{n}$ 计算像素值} \end{aligned} \end{aligned}

透视(Perspective)

在有计算机之前几百年,就已经有画家研究如何用 2D 图像表示 3D 场景了。虽然有很多非常规的方式生成 2D 图像,但对于美术、摄影以及计算机图形学而言,标准方法是线性透视法(linear perspective),这种方法将 3D 物体投影到像平面(image plane)上,场景(scene)中的直线在图像中仍为直线。

最简单的投影方式是平行投影(parallel projection),此时所有的 3D 点都沿着同一个投影方向(projection direction)。所生成的平行视图(parallel view)依赖于投影方向和像平面。如果像平面和投影方向垂直,则称为正交投影(orthographic projection),否则称为斜投影(oblique projection)。平行投影常用于机械制图和建筑图纸,因为它保持了平行于像平面的平面物体的形状和大小。

orthographic-projection.png

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

perspective-projection.png

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

three-points-perspective.png

计算视射线

根据视点(或视向,view direction)和像平面可以生成视射线。射线(ray)的数学描述如下:

p(t)=o+td\vec{p}(t) = \vec{o} + t\vec{d}

其中,o\vec{o} 为射线的源点(origin),d\vec{d} 为射线的方向向量。易知,如果 0<t1<t20 < t_{1} < t_{2},那么 p(t1)\vec{p}(t_{1})p(t2)\vec{p}(t_{2}) 离源点更近;如果 t<0t < 0,那么 p(t)\vec{p}(t) 在反方向。

面向对象的射线类伪代码:

class RayVec3 o | 射线源点Vec3 d | 射线方向Vec3 evaluate(real t)return o+td\begin{aligned} &\text{\textbf{class} Ray} \\ &\quad\begin{aligned} &\text{Vec3 $\vec{o}$ | 射线源点} \\ &\text{Vec3 $\vec{d}$ | 射线方向} \\ &\text{Vec3 evaluate(real $t$)} \\ &\quad\text{\textbf{return} $\vec{o}+ t\vec{d}$} \end{aligned} \end{aligned}

其中,Vec3\text{Vec3} 是三维向量类。

camera-frame.png

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

up-vector.png

正交视图(orthographic view)

正交投影中所有射线方向均为 w-\vec{w},源点位于各个像素所处的位置。相机标架原点的位置用于确定射线源点所在平面,使得可以描述位于相机后方的物体。

视射线源点位于 {e;u,v}\{\vec{e}; \vec{u}, \vec{v}\} 定义的平面上。nx×nyn_{x} \times n_{y} 的栅格图中像素 (i,j)(i, j) 在平面标架 {e;u,v}\{\vec{e}; \vec{u}, \vec{v}\} 中的坐标为:

栅格图中的像素坐标按第三章 3.2 节约定。

{u=l+(rl)(i+0.5)/nxv=b+(tb)(j+0.5)/ny\left\{ \begin{aligned} u &= l + (r - l)(i + 0.5)/n_{x} \\ v &= b + (t - b)(j + 0.5)/n_{y} \end{aligned} \right.

其中,ttbbllrr 分别是栅格图上、下、左、右四条边在平面标架 {e;u,v}\{\vec{e}; \vec{u}, \vec{v}\} 中的坐标。通常,l<0<rl < 0 < rb<0<tb < 0 < t,即相机标架的原点位于栅格图内部。

生成正交视射线(orthographic viewing ray)的步骤:

计算像素 (i,j) 的坐标 (u,v)ray.oe+uu+vvray.dw\begin{aligned} &\text{计算像素 $(i, j)$ 的坐标 $(u, v)$} \\ &\text{ray.}\vec{o} \leftarrow \vec{e} + u\vec{u} + v\vec{v} \\ &\text{ray.}\vec{d} \leftarrow -\vec{w} \end{aligned}

用视向 d\vec{d} 代替上述最后一步中的 w-\vec{w},便可以得到斜平行投影的结果。

ray-generation.png

透视视图(perspective view)

透视投影中所有射线源点都位于视点,方向指向各个像素的位置。像平面处于视点 e\vec{e} 前方距离 dd 的位置,这一距离称为像面距(image plane distance),也称为焦距(focal length)。

生成透视视射线(perspective viewing ray)的步骤:

计算像素 (i,j) 的坐标 (u,v)ray.oeray.ddw+uu+vv\begin{aligned} &\text{计算像素 $(i, j)$ 的坐标 $(u, v)$} \\ &\text{ray.}\vec{o} \leftarrow \vec{e} \\ &\text{ray.}\vec{d} \leftarrow -d\vec{w} + u\vec{u} + v\vec{v} \end{aligned}

上式也适用于斜透视。

射线与物体交点(ray-object intersection)

更一般的射线物体交点问题是:找出射线和区间 [t0,t1][t_{0}, t_{1}] 上所有物体的第一个交点 tt。基础的射线物体交点问题只是 t0=0t_{0}=0t1=+t_{1}=+\infty 的特例。

射线与球面的交点

计算射线 p(t)=o+td\vec{p}(t)=\vec{o}+t\vec{d} 与曲面 f(p)=0f(\vec{p})=0 的交点等价于求解方程:

f(p(t))=0orf(o+td)=0f(\vec{p}(t)) = 0 \quad \text{or} \quad f(\vec{o}+t\vec{d}) = 0

球心 c\vec{c}、半径 RR 的球面方程为:

(pc)(pc)R2=0(\vec{p} - \vec{c})\cdot(\vec{p} - \vec{c}) - R^{2} = 0

代入射线参数方程并整理可得:

(dd)t2+2d(oc)t+(oc)(oc)R2=0(\vec{d}\cdot\vec{d})t^{2} + 2\vec{d}\cdot(\vec{o} - \vec{c})t + (\vec{o} - \vec{c})\cdot(\vec{o} - \vec{c}) - R^{2} = 0

解一元二次方程求出交点,根据所求 tt 以及范围 [t0,t1][t_{0}, t_{1}] 即可得到最终结果。计算求根公式之前,先计算判别式,可以知道可能的交点数目。如果判别式为负,则无交点;若为正,则有两个交点,一个进入球,一个离开球;若为零,则射线与球面相切。

球面上点 p\vec{p} 处单位法向量为 (pc)/R(\vec{p} - \vec{c})/R

射线与三角形的交点

计算射线与三角形交点的算法有很多,这里给出的是使用三角形重心坐标的形式。除了三角形的顶点(vertice)以外,这一算法无需额外的长期存储(long-term storage)。

计算射线与参数曲面的交点等价于求解如下方程:

o+td=p(u,v)\vec{o} + t\vec{d} = \vec{p}(u, v)

上式其实是三个标量方程所构成的方程组,包含了三个未知量 ttuuvv。当曲面是由 abc\triangle abc 确定的平面时,该方程具有如下形式:

o+td=a+β(ba)+γ(ca)\vec{o} + t\vec{d} = \vec{a} + \beta(\vec{b} - \vec{a}) + \gamma(\vec{c} - \vec{a})

上式是一个 3×33\times 3 线性方程组,求解该方程组可以得到交点。若无解,则说明射线与平面平行,或者三角形退化为直线或点。方程组有解时,tt 给出了交点在射线上的位置,uuvv 给出了交点在平面上的位置。根据重心坐标的知识可以知道,只有 β>0\beta>0γ>0\gamma>0β+γ<1\beta+\gamma<1 同时满足,交点才在 abc\triangle abc 内部。

求解 3×33\times 3 线性方程组的最快的、最经典的方法就是克拉默法则(Cramer's rule)。对于一般的 3×33\times 3 线性方程组:

[adgbehcfi][βγt]=[jkl]\begin{bmatrix} a & d & g \\ b & e & h \\ c & f & i \\ \end{bmatrix} \begin{bmatrix} \beta \\ \gamma \\ t \\ \end{bmatrix} = \begin{bmatrix} j \\ k \\ l \\ \end{bmatrix}

克拉默法则给出的结果为:

β=j(eihf)+k(gfdi)+l(dheg)Mγ=i(akjb)+h(jcal)+g(blkc)Mt=f(akjb)+e(jcal)+d(blkc)M\begin{aligned} \beta &= \frac{j(ei - hf) + k(gf - di) + l(dh - eg)}{M} \\ \gamma &= \frac{i(ak - jb) + h(jc - al) + g(bl - kc)}{M} \\ t &= -\frac{f(ak - jb) + e(jc - al) + d(bl - kc)}{M} \end{aligned}

其中,M=a(eihf)+b(gfdi)+c(dheg)M = a(ei - hf) + b(gf - di) + c(dh - eg)。利用上面重复出现的项可以减少运算次数,如 eihfei-hf

对于上面的算法,还可以通过某些条件来提前结束程序:

boolean raytri(Ray ray, Vec3 a, Vec3 b, Vec3 c,  interval [t0,t1])计算 tif (t<t0) or (t>t1thenreturn false计算 γif (γ<0) or (γ>1thenreturn false计算 βif (β<0) or (β>1γthenreturn falsereturn true\begin{aligned} &\text{boolean raytri(Ray ray, Vec3 $\vec{a}$, Vec3 $\vec{b}$, Vec3 $\vec{c}$,} \\ &\qquad\qquad\qquad\ \ \text{interval $[t_0, t_1]$)} \\ &\quad\begin{aligned} &\text{计算 $t$} \\ &\text{\textbf{if} ($t < t_0$) or ($t > t_1$) \textbf{then}} \\ &\quad\text{\textbf{return} false} \\ &\text{计算 $\gamma$} \\ &\text{\textbf{if} ($\gamma < 0$) or ($\gamma > 1$) \textbf{then}} \\ &\quad\text{\textbf{return} false} \\ &\text{计算 $\beta$} \\ &\text{\textbf{if} ($\beta < 0$) or ($\beta > 1 - \gamma$) \textbf{then}} \\ &\quad\text{\textbf{return} false} \\ &\text{\textbf{return} true} \end{aligned} \end{aligned}

射线求交在软件中的实现

实现射线追踪的一个好的想法是,应用面向对象的编程范式,设计一个名为 Surface\text{Surface} 的类,Triangle\text{Triangle}Sphere\text{Sphere} 均为其子类。任何需要计算射线交点的物体都是 Surface\text{Surface} 的子类,包括曲面组(group of surface)或其它更高效的几何数据结构。不管什么模型,射线追踪程序都将直接引用 Surface\text{Surface} 相关的接口。

Surface\text{Surface} 类中最关键的是计算与射线交点的方法:

class SurfaceHitRecord hit(Ray ray, real t0, real t1)\begin{aligned} &\text{\textbf{class} Surface} \\ &\quad\text{HitRecord hit(Ray ray, real $t_{0}$, real $t_{1}$)} \end{aligned}

其中,[t0,t1][t_{0}, t_{1}] 是射线交点的判定区间,HitRecord\text{HitRecord} 类包含了所有关于曲面交点的信息:

class HitRecordSurface s | 射线命中的曲面real t | 交点沿射线的坐标Vec3 n | 交点处表面法向量...\begin{aligned} &\text{\textbf{class} HitRecord} \\ &\quad\begin{aligned} &\text{Surface $s$ | 射线命中的曲面} \\ &\text{real $t$ | 交点沿射线的坐标} \\ &\text{Vec3 $\vec{n}$ | 交点处表面法向量} \\ &... \end{aligned} \end{aligned}

上面列举的是最低要求,其它数据也可以存储,比如切向量。有些编程语言会把 hitRecord\text{hitRecord} 的引用传入函数并填充,而不是作为返回值返回。没有交点可以用 t=t=\infty 来表示。

射线与一组物体的交点

大多场景都包含了多个物体,此时我们想要的是所有交点中离相机最近的那个。一个简单的想法是,把一组物体看作一种特殊类型的物体。下面是计算射线与一组物体交点的伪代码:

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, t0t1)if rec.t <  thenclosest-hit = rect1=rec.treturn closest-hit\begin{aligned} &\text{\textbf{class} Group \textbf{extends} Surface} \\ &\quad\begin{aligned} &\text{list-of-Surface surfaces | 曲面列表} \\ &\text{HitRecord hit(Ray ray, real $t_{0}$, real $t_{1}$)} \\ &\quad\begin{aligned} &\text{HitRecord closest-hit($\infty$) | 初始化为未命中} \\ &\text{\textbf{for} surf in surfaces \textbf{do}} \\ &\quad\begin{aligned} &\text{rec = surf.hit(ray, $t_{0}$, $t_{1}$)} \\ &\text{\textbf{if} rec.$t$ < $\infty$ \textbf{then}} \\ &\quad\begin{aligned} &\text{closest-hit = rec} \\ &t_{1} = \text{rec}.t \end{aligned} \end{aligned} \\ &\text{\textbf{return} closest-hit} \end{aligned} \end{aligned} \end{aligned}

着色(shading)

一旦知道了每个像素可以看到的曲面,就可以通过计算着色模型(shading model,有时也称为光照模型)来得到像素值。着色模型依赖于具体应用,可以很简单,也可以很复杂,它与渲染序(图像序、物体序)无关。第五章描述一种简单着色模型,下面以该模型为例介绍如何着色。

光源(light source)

通常需要有一组光源才能进行着色。对于当前的着色模型,我们需要三种类型的光源:点光源(point light)、平行光(directional light)、环境光(ambient light,提供固定光照以填充阴影)。更精致的模型可能需要其它类型的光源:面光源(area light,其实就是一个发光几何体)、背景光(environment light,用一个图像来表示远处光源发出的光,如天空)。

计算点光源或平行光的着色需要一些几何信息:

  • 着色点(shading point)x\vec{x}
  • 表面法向量 n\vec{n}
  • 光的方向 l\vec{l}
  • 视方向(viewing direction)v=d/d\vec{v}=-\vec{d}/\|\vec{d}\|,与视射线方向 d\vec{d} 相反

每个曲面都得能够计算出该曲面上任一点的法向量。

在射线追踪器中,视射线与曲面的交点确定之后,所需的四个向量也就完全确定了。

由于环境光来自四面八方,因此环境光的着色与 l\vec{l}v\vec{v} 均无关。对于这里所考虑的着色模型,环境光着色甚至也不依赖于 x\vec{x}n\vec{n}

如果场景中包含了多个光源,着色结果就是把各个光源的贡献加起来。

着色在软件中的实现

着色所需信息涉及两种对象:光源材质(material)。光源类是 Light\text{Light} 类的子类。材质用 Material\text{Material} 类描述。

如何在光源和材质之间拆分着色计算,取决于实际情况。这里所采取的拆分方式是,光源负责整体的光照计算,材质负责计算 BRDF 值。

class LightColor illuminate(Ray ray, HitRecord hrec)class MaterialColor evaluate(Vec3 l, Vec3 v, Vec3 n)\begin{aligned} &\text{\textbf{class} Light} \\ &\quad\text{Color illuminate(Ray ray, HitRecord hrec)} \\ &\text{\textbf{class} Material} \\ &\quad\text{Color evaluate(Vec3 $\vec{l}$, Vec3 $\vec{v}$, Vec3 $\vec{n}$)} \end{aligned}

其中,Color\text{Color} 类存储了 RGB 颜色分量,并支持相应的分量乘法(componentwise multiplication)。

点光源的伪代码如下:

class PointLight extends LightColor IVec3 pColor illuminate(Ray ray, HitRecord hrec)Vec3 x = ray.evaluate(hrec.t)real r=pxVec3 l=(px)/rVec3 n = hrec.normalColor E=max(0,nl)I/r2Color k=hrec.surface.material.evaluate(l,v,n)return kE\begin{aligned} &\text{\textbf{class} PointLight \textbf{extends} Light} \\ &\quad\begin{aligned} &\text{Color $I$} \\ &\text{Vec3 $\vec{p}$} \\ &\text{Color illuminate(Ray ray, HitRecord hrec)} \\ &\quad\begin{aligned} &\text{Vec3 $\vec{x}$ = ray.evaluate(hrec.$t$)} \\ &\text{real}\ r = \|\vec{p} - \vec{x}\| \\ &\text{Vec3}\ \vec{l} = (\vec{p} - \vec{x})/r \\ &\text{Vec3 $\vec{n}$ = hrec.normal} \\ &\text{Color}\ E = \text{max}(0, \vec{n}\cdot\vec{l})I/r^{2} \\ &\text{Color}\ k = \text{hrec.surface.material.evaluate}(\vec{l}, \vec{v}, \vec{n}) \\ &\textbf{return}\ kE \end{aligned} \end{aligned} \end{aligned}

环境光的伪代码如下:

class AmbientLight extends LightColor IaColor illuminate(Ray ray, HitRecord hrec)Color ka=hrec.surface.material.kareturn kaIa\begin{aligned} &\text{\textbf{class} AmbientLight \textbf{extends} Light} \\ &\quad\begin{aligned} &\text{Color}\ I_{a} \\ &\text{Color illuminate(Ray ray, HitRecord hrec)} \\ &\quad\begin{aligned} &\text{Color}\ k_{a} = \text{hrec.surface.material}.k_{a} \\ &\textbf{return}\ k_{a}I_{a} \end{aligned} \end{aligned} \end{aligned}

其中,kak_{a} 是材质的环境光反射系数(ambient coefficient)。

完整的着色伪程序如下:

function shade-ray(Ray ray, real t0, real t1)HitRecord rec = scene.hit(ray, t0t1)if rec.t< thenColor c=0for light in scene.lights doc=c+light.illuminate(ray, rec)return celsereturn background-color\begin{aligned} &\text{\textbf{function} shade-ray(Ray ray, real $t_{0}$, real $t_{1}$)} \\ &\quad\begin{aligned} &\text{HitRecord rec = scene.hit(ray, $t_{0}$, $t_{1}$)} \\ &\text{\textbf{if} rec.$t < \infty$ \textbf{then}} \\ &\quad\begin{aligned} &\text{Color $c = 0$} \\ &\text{\textbf{for} light \textbf{in} scene.lights \textbf{do}} \\ &\quad c = c + \text{light.illuminate(ray, rec)} \\ &\textbf{return}\ c \end{aligned} \\ &\textbf{else} \\ &\quad\textbf{return}\ \text{background-color} \end{aligned} \end{aligned}

着色只是让 3D 物体本身看起来更加真实,它并不处理物体间的空间关系。

阴影

从着色点 x\vec{x} 到光源的射线称为阴影射线(shadow ray)。通过检查阴影射线与场景中的物体是否相交,就可以知道着色点是否在阴影中。

如果阴影射线的检查区间为 [0,r][0, r],则可能会因为数值精度问题,检查到阴影射线与源点所在曲面相交。通常设置检查区间为 [ϵ,r][\epsilon, r] 可以避免这一问题,其中 ϵ\epsilon 是一个较小的正数。

shadow-ray.png

点光源的阴影检测可以通过在光照程序 PointLight.illuminate\text{PointLight.illuminate} 中添加如下分支逻辑来实现:

HitRecord srec = scene.hit(Ray(xl), ϵr)if srec.t< thenreturn 0 | 着色点在阴影中else计算光照\begin{aligned} &\text{HitRecord srec = scene.hit(Ray($\vec{x}$, $\vec{l}$), $\epsilon$, $r$)} \\ &\text{\textbf{if} srec.$t < \infty$ \textbf{then}} \\ &\quad\text{\textbf{return} 0 | 着色点在阴影中} \\ &\textbf{else} \\ &\quad 计算光照 \\ \end{aligned}

平行光的阴影检测只需将射线相交检测范围从 [ϵ,r][\epsilon, r] 改为 [ϵ,][\epsilon, \infty] 即可。每种光源的光照计算都需要一个单独的阴影射线。环境光着色计算不需要阴影检测。

阴影在表达相邻物体间关系时具有重要的视觉作用。

镜面反射(mirror reflection)

视射线的反射射线方向为:

r=d2(dn)n\vec{r} = \vec{d} - 2(\vec{d}\cdot\vec{n})\vec{n}

其中,d\vec{d} 是视射线的方向。真实世界中,光线经过物体表面反射后会损失一部分能量,损失多少与光的颜色以及物体材质有关。

reflection-ray.png

shade-ray\text{shade-ray} 函数中,在计算完所有光源的贡献之后,引入递归即可计入镜面反射的贡献:

c=c+kmshade-ray(Ray(x,r),ϵ,)c = c + k_{m}\text{shade-ray}(\text{Ray}(\vec{x}, \vec{r}), \epsilon, \infty)

其中,kmk_{m}镜面(specular)RGB 颜色,真实世界中可能依赖于光的入射角。通过限制最大递归深度(maximum recursion depth)可以避免无限递归的问题。生成反射射线之前先判断 kmk_{m} 是否为 00 可以让代码更高效。