【PBRT】《基于物理的渲染:从理论到实践》梳理II


一如既往,本文是笔者阅读《基于物理的渲染:从理论到实践》的总结,文章不会面面俱到地描述书中所有的东西,只会把笔者认为重要的东西整理出来。既是一种复习,也希望能对读者有帮助。整理范围:第2章+第3章的前5节。

由于作者水平所限,文中如果有错误或者没有解释到位的地方,还请读者不吝指正。

交点类(Interaction)

这是第2.10节的内容,也是第2章的最后一节。之所以认为这个重要,有两个原因:1、PBR意味着对光线和表面的交点进行着色。2、之前的内容都是向量、法线、光线等等非常简单而基础的东西,不值得拿出来再写一遍。

pbrt中的交点分为两种,一个是表面交点(SurfaceInteraction),这个应该是我们所熟悉的交点。另一个是介质交点(MediumInteraction),这个东西目前还没有讲到,猜测应该是和体积渲染有关,计算光在介质中反射或者折射的信息。于是,两种交点就提取出了共有的基类:Interaction。

Interaction

Interaction类中的成员有:

Point3f p;
Float time;
Vector3f pError;
Vector3f wo;
Normal3f n;
MediumInterface mediumInterface;

一个个来解释:

  • Point3f p:这是交点的位置信息。这非常容易理解,交点类都没有交点的信息算什么!
  • Float time:时间。不知道是什么时间,也许是到这个点的时间,也许是计算得到这个点所用的时间。
  • Vector3f pError:我们计算交点信息的时候用的是浮点数,浮点数不可能表示数学意义上的每一个数,所以计算的时候会产生误差。这个pError保存的就是一个保守的误差范围(就是误差绝对不会超过的范围)。
  • Vector3f wo:这是光线方向的相反方向。或者说是光线反射进入摄像机的方向。我们计算交点是假设有一条光线从摄像机的位置发出,然后和表面相交。这个wo就保存了这条光线的反方向,也就是前一篇文章中的\omega_o
  • Normal3f n:交点的法线。
  • MediumInterface mediumInterface:介质接口。是交点所处的环境,或者说是光在什么介质中穿行。目前为止还没用到。

SurfaceInteraction

SurfaceInteraction类中包含了交点的几何信息,这个类保存了许多跟点有关的计算需要用到的几何信息,这样在计算的时候就不用去管这个点在哪个物体上,所以你会看到:这个类居然连TM的Shape、BSDF、BSSRDF指针都有啊!

好了,不吐槽了,正经的。

SurfaceInteraction类的成员包括:

Point2f uv;
Vector3f dpdu, dpdv;
Normal3f dndu, dndv;
const Shape *shape = nullptr;

上面的这些代码定义的是关于几何表面的信息,其中,Shape是几何表面,这个在第3章中有介绍,都是曲面和曲线(所以我才去啃了一个礼拜的《曲线和曲面的微分几何》!)。要理解这些变量,需要理解曲面是怎么表示的。参考曲面的定义(不了解的读者可以参考我的前一篇文章。),曲面是由uv两个参数映射成的三维空间中的点。这里的uv变量就是这两个参数的值,而dpdu和dpdv则是曲面关于uv的偏导数。dndu和dndv则是曲面的法线关于uv的偏导数。这些数据意味着当前点附近的点的位置的变化情况以及法线的变化情况,在计算中非常有用。

struct {
    Normal3f n;
    Vector3f dpdu, dpdv;
    Normal3f dndu, dndv;
} shading;

着色几何信息,说实话我到现在还不知道有啥用。讲了一大堆怎么设置,怎么计算,结果有啥用没说,很蛋疼的感觉。

剩余的成员:

const Primitive *primitive = nullptr;
BSDF *bsdf = nullptr;
BSSRDF *bssrdf = nullptr;
mutable Vector3f dpdx, dpdy;
mutable Float dudx = 0, dvdx = 0, dudy = 0, dvdy = 0;

// Added after book publication. Shapes can optionally provide a face
// index with an intersection point for use in Ptex texture lookups.
// If Ptex isn't being used, then this value is ignored.
int faceIndex = 0;

BSDF和BSSRDF不用说,梳理I中讲过,faceIndex是面片的索引。这世上绝大多数模型都不能用完美的数学公式来表示,通常的方法是采用三角形面片来近似,一个模型有许多三角形面片,而这个faceIndex指的就是面片的索引。具体有什么用,暂时还不知道。剩余的变量都没有说明,所以也不去纠结。

球面

球面的表示和交点计算算是最难的一部分,也不是说它本身有多难,而是它要很多微分几何的知识,比如说至少得知道第一基本形式和第二基本形式都是啥,有啥用。

废话不多说,一起来看。

球面的表示

球面的坐标方程是
x^2 + y^2 + z^2 = r^2
这个谁都知道,但是,这个没用!我们不仅要知道交点在哪,还要知道交点附近的点的变化趋势是什么样的。甚至,我们还需要知道如何将纹理贴到球表面上去,这些信息上面的方程给不了,所以,我们采用的是极坐标表示方式,因为它正好有两个参数(对应uv非常方便),而且计算偏导数也方便。

球的极坐标方程如下:
\begin{cases} x=r\sin\theta\cos\phi \\ y=r\sin\theta\sin\phi \\ z=r\cos\theta \end{cases}
其中\theta的取值范围是0\pi\phi的取值范围是02\pi。几何意义上,\theta是和z轴正方向的夹角,而\phi是和x轴正方向,沿着x\to{y}方向旋转的夹角,如下图所示(下图中没有标出\theta角,但是把按照上面描述的,把\theta比对图片带入计算就可以发现是这样的):


进一步,这只是球面的数学表示,要用类怎么表示呢?首先要想到,我们需要两个参数和,这两个参数是不是其中的和?显然不是,因为如果和是和的话,那么又是什么?所以,和不是参数方程中的任何一个变量,我们取得球面上的点,首先一条就是:这是一个确定的球!也就是说,、、都是固定值,只有和才是参数。

于是,球面关于uv参数方程是:
\begin{cases} \phi=u\phi_{max} \\ \theta=\theta_{min}+v(\theta_{max}-\theta_{min}) \end{cases}
代入到极坐标方程中就是
\begin{cases} x=r\sin(\theta_{min}+v(\theta_{max}-\theta_{min}))\cos(u\phi_{max}) \\ y=r\sin(\theta_{min}+v(\theta_{max}-\theta_{min}))\sin(u\phi_{max}) \\ z=r\cos(\theta_{min}+v(\theta_{max}-\theta_{min})) \end{cases}
虽然看上去很复杂,但这就是球关于uv的参数方程。其中,r,\theta_{min},\theta_{max},\phi_{max}都是固定值,\theta_{min}\theta_{max}\theta的取值范围,\phi_{max}\phi能取得最大值,也就是说\phi的取值范围是[0, \phi_{max}]

所以,Shpere类的成员就有:

const Float radius;
const Float zMin, zMax;
const Float thetaMin, thetaMax, phiMax;

其中的zMin和zMax是与thetaMin和thetaMax对应的z坐标最小值和最大值。Sphere的构造函数是这样初始化这些值的:

Sphere(const Transform *ObjectToWorld, const Transform *WorldToObject,
       bool reverseOrientation, Float radius, Float zMin, Float zMax,
       Float phiMax)
    : Shape(ObjectToWorld, WorldToObject, reverseOrientation),
      radius(radius),
      zMin(Clamp(std::min(zMin, zMax), -radius, radius)),
      zMax(Clamp(std::max(zMin, zMax), -radius, radius)),
      thetaMin(std::acos(Clamp(std::min(zMin, zMax) / radius, -1, 1))),
      thetaMax(std::acos(Clamp(std::max(zMin, zMax) / radius, -1, 1))),
      phiMax(Radians(Clamp(phiMax, 0, 360))) {}

但是这个初始化代码有个问题,那就是thetaMin、thetaMax和zMin、zMax的对应关系弄反了。从上面的式子我们知道,z=r\cos\theta,很明显,\theta在区间[0, \pi]是单调递减函数,所以,当\theta取最小值的时候,对应的z应该取最大值才对。

光线与球面的交点

数学上,计算光线和球面的交点非常简单,将光线的方程
r(t)=o+t\mathbf{d}
代入球面方程中计算就好了(其中,o是光线的起始点,\mathbf{d}是光线的方向,这两个是固定值,t是参数变量)。但是,实际应用的过程不会这样简单。

  • 计算出的交点需要用到我们之前定义的SurfaceInterface类,我们需要填充很多信息,包括切向量,法向量以及偏导数等等。
  • 数学中我们可以认为光线是无限长的,但是实际应用过程中,我们没那么多资源去计算无限长的光线,所以光线类(pbrt中是Ray)就会有一个最大长度的限制,也就是t的最大值tMax。计算过程中,我们需要舍弃交点值的t大于这个tMax的情况。
  • 数学意义上的数是精确数,拥有无限的精度。计算机中用的是浮点数,不管是单精度还是双精度,都是一种近似数,不能精确地计算出交点的位置。在多次使用这种近似数计算之后,误差就会累积,甚至可能出现原本应该是交点,结果却不是,或者反过来,这些情况。所以,误差也需要考虑进去。
  • ……

有意思,从数学的抽象到落地成代码的过程从来都是最有意思的部分!

计算交点t值

第一步,先计算交点,用的方程不是极坐标参数方程,而是普通的笛卡尔坐标系中的球的隐式方程:
x^2 \quad+\quad y^2 \quad + \quad z^2 \quad = \quad r^2
将光线的方程代入其中可以得到:
(o_x + t\mathbf{d}_x)^2+(o_y+t\mathbf{d}_y)^2+(o_z+t\mathbf{d}_z)^2=r^2
整理得到:
(\mathbf{d}_x^2+\mathbf{d}_y^2+\mathbf{d}_z^2)t^2+[2(\mathbf{d}_x{o_x}+\mathbf{d}_y{o_y}+\mathbf{d}_z{o_z})]t+(o_x^2+o_y^2+o_z^2-r^2)=0
这是一元二次方程的标准形式(at^2+bt+c=0),利用求根判别式就可以知道到底有没有交点,有几个交点。

这个过程在代码中的实现是这样的:

EFloat a = dx * dx + dy * dy + dz * dz;
EFloat b = 2 * (dx * ox + dy * oy + dz * oz);
EFloat c = ox * ox + oy * oy + oz * oz - EFloat(radius) * EFloat(radius);

// Solve quadratic equation for _t_ values
EFloat t0, t1;
if (!Quadratic(a, b, c, &t0, &t1)) return false;

EFloat是pbrt中自定义的一个结构,与内置的float相比,最大的区别就是EFloat里包含了误差,可以理解成带误差的float。代码的实现和我们的数学推理基本一致,它采用了一个Quadratic函数来求解t的值,如果没有实数根,那么整个交点检测的函数就返回false。

t值的有效性(t是否超出[0, tMax]范围)

即便我们计算出t值了,这个t值也不一定是有效的,它必须在光线的“射程”范围内。因为计算出的t值用的是pbrt自定义的“带误差的float”结构,所以,实际检测的时候我们需要考虑:

  • 如果两个交点t0,t1都不在[0, tMax]的范围内,那么两个交点都舍弃,返回false,意味着没有交点。
  • 先检测t0,如果t0在[0, tMax]范围内,那么就认为光线和球面的交点是t0。如果t0检测失败,那么就检测t1,如果t1满足范围要求,就认为t1是光线和球面的交点。

写成代码就是:

// Check quadric shape _t0_ and _t1_ for nearest intersection
if (t0.UpperBound() > ray.tMax || t1.LowerBound() <= 0) return false;
EFloat tShapeHit = t0;
if (tShapeHit.LowerBound() <= 0) {
    tShapeHit = t1;
    if (tShapeHit.UpperBound() > ray.tMax) return false;
}

可以这样实现是因为在计算t0和t1的时候已经内含了排序,t0的值必定是小于t1的。

t值的有效性(是否超出了球面的\theta\phi范围)

我们在定义球面的时候确定了\theta的最小值和最大值,这就确定了z坐标的最小值和最大值,因为z\theta之间有着非常明确的关系。同样,对\phi的范围我们也进行了限制,这就又给交点的计算带来了麻烦,我们必须确定这个交点在球面的范围之内。

// Compute sphere hit position and $\phi$
pHit = ray((Float)tShapeHit);

// Refine sphere intersection point
pHit *= radius / Distance(pHit, Point3f(0, 0, 0));
if (pHit.x == 0 && pHit.y == 0) pHit.x = 1e-5f * radius;
phi = std::atan2(pHit.y, pHit.x);
if (phi < 0) phi += 2 * Pi;

// Test sphere intersection against clipping parameters
if ((zMin > -radius && pHit.z < zMin) || (zMax < radius && pHit.z > zMax) ||
    phi > phiMax) {
    if (tShapeHit == t1) return false;
    if (t1.UpperBound() > ray.tMax) return false;
    tShapeHit = t1;
    // Compute sphere hit position and $\phi$
    pHit = ray((Float)tShapeHit);

    // Refine sphere intersection point
    pHit *= radius / Distance(pHit, Point3f(0, 0, 0));
    if (pHit.x == 0 && pHit.y == 0) pHit.x = 1e-5f * radius;
    phi = std::atan2(pHit.y, pHit.x);
    if (phi < 0) phi += 2 * Pi;
    if ((zMin > -radius && pHit.z < zMin) ||
        (zMax < radius && pHit.z > zMax) || phi > phiMax)
        return false;
}

忽略减少误差的操作(Refine spher intersection point的过程),我们来看怎么计算\phi值的。因为\frac{y}{x}=\frac{r\sin\theta\sin\phi}{r\sin\theta\cos\phi}=\tan\phi,所以\phi=\arctan\frac{y}{x},代码中也是这么计算的,并且为了确保\phi值在[0, 2\pi]之间,对计算出的小于0的数还加上了2\pi进行调整。

然后是判断交点的z值是否在[z_{min}, z_{max}]的范围内,\phi值是否在[0, \phi_{max}]范围内。如果不在,那么就换一个交点试试,如果另一个交点也不在,那就表示没有交点,交点检测失败了,返回false。

交点邻域的变化情况

交点邻域的变化情况主要指两个:
1、点在邻域内的变化情况。主要由点关于uv的偏导数(\frac{\partial{p}}{\partial{u}}\frac{\partial{p}}{\partial{v}})组成,暗示了在uv两个维度上,微小变化量会带来什么样的点位置改变。
2、法向量的变化情况。主要由法向量关于uv的偏导数(\frac{\partial{n}}{\partial{u}}\frac{\partial{n}}{\partial{v}})组成,暗示了在uv两个维度上,微小变化量会带来法向量的什么改变。

计算这些量的前提是,我们必须有u和v的值才行,从上面关于球面的uv方程中,我们很容易就能推导出:
\begin{cases} u=\frac{\phi}{\phi_{max}} \\ v=\frac{\theta-\theta_{min}}{\theta_{max}-\theta_{min}} \end{cases}
\theta可以通过交点的z值公式,利用反余弦函数求出,具体的代码是:

// Find parametric representation of sphere hit
Float u = phi / phiMax;
Float theta = std::acos(Clamp(pHit.z / radius, -1, 1));
Float v = (theta - thetaMin) / (thetaMax - thetaMin);

接着求p关于u和v的偏导数。
\begin{cases} \frac{\partial{x}}{\partial{u}}=-r\sin\theta\sin(u\phi_{max})\phi_{max}=-y\phi_{max}\\ \frac{\partial{y}}{\partial{u}}=r\sin\theta\cos(u\phi_{max})\phi_{max}=x\phi_{max}\\ \frac{\partial{z}}{\partial{u}}=0 \end{cases}
\begin{cases} \frac{\partial{x}}{\partial{v}}=r\cos(\theta_{min}+v(\theta_{max}-\theta_{min}))\cos\phi(\theta_{max}-\theta_{min})=z\cos\phi(\theta_{max}-\theta_{min})\\ \frac{\partial{x}}{\partial{v}}=r\cos(\theta_{min}+v(\theta_{max}-\theta_{min}))\sin\phi(\theta_{max}-\theta_{min})=z\sin\phi(\theta_{max}-\theta_{min})\\ \frac{\partial{z}}{\partial{v}}=-r\sin(\theta_{min}+v(\theta_{max}-\theta_{min}))(\theta_{max}-\theta_{min})=-r\sin\theta(\theta_{max}-\theta_{min}) \end{cases}
代码实现如下:

// Compute sphere $\dpdu$ and $\dpdv$
Float zRadius = std::sqrt(pHit.x * pHit.x + pHit.y * pHit.y);
Float invZRadius = 1 / zRadius;
Float cosPhi = pHit.x * invZRadius;
Float sinPhi = pHit.y * invZRadius;
Vector3f dpdu(-phiMax * pHit.y, phiMax * pHit.x, 0);
Vector3f dpdv =
    (thetaMax - thetaMin) *
    Vector3f(pHit.z * cosPhi, pHit.z * sinPhi, -radius * std::sin(theta));

代码中,为了减少除法的消耗,将z的倒数保存起来,计算了\cos\phi\sin\phi,然后利用上面推导的公式,计算出想要的值。

接着就是最困难的部分了:如何计算法向量的偏导数?

我们用一个被称为温加顿方程(Weingarten equations)的东西来计算法向量的偏导数。温加顿方程(Weingarten equations)是用\frac{\partial{p}}{\partial{u}}\frac{\partial{p}}{\partial{v}}的线性组合来表示法向量的偏导数。那么,为什么能用线性组合来表示呢?这就要用到上一篇文章的知识了。假设你已经了解了曲线和曲面的相关知识,特别是第一基本形式和第二基本形式的相关知识。

在曲面中,法向量n意味着一个法向量场,表示的是表面的所有法向量的集合。而在代码中,它仅仅表示了一个向量,但是这没法计算变化了,所以,我们还是要放到整个曲面的意义上去。

法向量的计算方式是n=\frac{x_u\times{x_v}}{|x_u\times{x_v}|},也就是说,法向量与曲面在这点上的切平面垂直,并且它是单位向量。然后,对n\bullet{n}=1两边求导可得,n'\bullet{n}=0,就意味着n'\perp{n}。而n又垂直于切平面,所以n'必然在其平面内,于是,它就可以表示成x_u,u_v的线性组合。

接着,由第二基本形式可以得到edu^2+2fdudv+gdv^2,即
e=<x_{uu},n>=-<x_u,n_u>
f=<x_{uv},n>=-<x_u,n_v>=-<x_v,n_u>
g=<x_{vv},n>=-<x_v,n_v>
由于n_u,n_v也在切平面内,我们可以用x_u,x_v线性组合的方式来表示n_u,n_v
\begin{cases} n_u=\alpha_1x_u+\alpha_2x_v \\ n_v=\beta_1x_u+\beta_2x_v \end{cases}
n_un_v带入到e,f,g的表达式中得:
\begin{cases} -e=<x_u, \alpha_1x_u+\alpha_2x_v>=\alpha_1(x_u)^2+\alpha_2x_ux_v=\alpha_1E+\alpha_2F \\ -f=<x_u,\beta_1x_u+\beta_2x_v>=\beta_1(x_u)^2+\beta_2x_ux_v=\beta_1E+\beta_2F \\ -f=<x_v, \alpha_1x_u+\alpha_2x_v>=\alpha_1x_ux_v+\alpha_2(x_v)^2=\alpha_1F+\alpha_2G \\ -g=<x_v, \beta_1x_u+\beta_2x_v>=\beta_1x_ux_v+\beta_2(x_v)^2=\beta_1F+\beta_2G \end{cases}
这里的E,F,G都是第一基本形式的系数,具体是E=(x_u)^2,F=x_ux_v,G=(x_v)^2。将上述的方程组表示成矩阵就是
-\begin{bmatrix} e & f\\ f & g \end{bmatrix}=\begin{bmatrix} \alpha_1 & \alpha_2 \\ \beta_1 & \beta_2 \end{bmatrix} \begin{bmatrix} E & F \\ F & G \end{bmatrix}
于是
\begin{bmatrix} \alpha_1 & \alpha_2 \\ \beta_1 & \beta_2 \end{bmatrix}=- \begin{bmatrix} e & f\\ f & g \end{bmatrix} \begin{bmatrix} E & F \\ F & G \end{bmatrix}^{-1}
逆矩阵可以使用余子式来计算
\begin{bmatrix} E & F \\ F & G \end{bmatrix}^{-1}=\frac{1}{EG-F^2}\begin{bmatrix} G & -F \\ -F & E \end{bmatrix}
最后得到
\begin{bmatrix} \alpha_1 & \alpha_2 \\ \beta_1 & \beta_2 \end{bmatrix}=\frac{1}{EG-F^2}\begin{bmatrix} fF-eG & eF-fE \\ gF-fG & fF-gE \end{bmatrix}
最终,求\frac{\partial{n}}{\partial{u}}\frac{\partial{n}}{\partial{v}}的公式就是:
\frac{\partial{n}}{\partial{u}}=\frac{fF-eG}{EG-F^2}x_u+\frac{eF-fE}{EG-F^2}x_v
\frac{\partial{n}}{\partial{v}}=\frac{gF-fG}{EG-F^2}x_u+\frac{fF-gE}{EG-F^2}x_v
观察代码,我们发现,它的过程与我们推导出的结果完全一致:

// Compute sphere $\dndu$ and $\dndv$
Vector3f d2Pduu = -phiMax * phiMax * Vector3f(pHit.x, pHit.y, 0);
Vector3f d2Pduv =
    (thetaMax - thetaMin) * pHit.z * phiMax * Vector3f(-sinPhi, cosPhi, 0.);
Vector3f d2Pdvv = -(thetaMax - thetaMin) * (thetaMax - thetaMin) *
                  Vector3f(pHit.x, pHit.y, pHit.z);

// Compute coefficients for fundamental forms
Float E = Dot(dpdu, dpdu);
Float F = Dot(dpdu, dpdv);
Float G = Dot(dpdv, dpdv);
Vector3f N = Normalize(Cross(dpdu, dpdv));
Float e = Dot(N, d2Pduu);
Float f = Dot(N, d2Pduv);
Float g = Dot(N, d2Pdvv);

// Compute $\dndu$ and $\dndv$ from fundamental form coefficients
Float invEGF2 = 1 / (E * G - F * F);
Normal3f dndu = Normal3f((f * F - e * G) * invEGF2 * dpdu +
                         (e * F - f * E) * invEGF2 * dpdv);
Normal3f dndv = Normal3f((g * F - f * G) * invEGF2 * dpdu +
                         (f * F - g * E) * invEGF2 * dpdv);

当然,最后保存关于交点的信息以及集中时,参数t的值。

// Initialize _SurfaceInteraction_ from parametric information
*isect = (*ObjectToWorld)(SurfaceInteraction(pHit, pError, Point2f(u, v),
                                             -ray.d, dpdu, dpdv, dndu, dndv,
                                             ray.time, this));

// Update _tHit_ for quadric intersection
*tHit = (Float)tShapeHit;

直接把计算得到的信息都保存进SurfaceInteraction结构里,很直观,然后将这些信息都转换到世界空间中。

关于误差

误差的主要来源是浮点数计算时的舍入误差,每一次计算都会有误差,不管是加减还是乘除法。这点书中第三章的最后介绍得非常详细,之后会总结出来。

总结

虽然看上去并不多,但是花了很多时间在微分几何上,就为了理解温加顿方程。这一篇文章像是如何实现数学公式,事实上也是,图形和交点,怎么能没计算呢?想到后面还有茫茫多的计算,不禁打个冷战,想想是不是要再写篇文章压压惊?

参考资料

Physically Based Rendering
pbrt源码,版本3
Differiential Geometry of Curves and Surfaces

推荐阅读更多精彩内容