着色
在 GAMES101 这门课中,认为着色是将一个材质应用到一个物体上。
Blinn-Phong 反射模型(Blinn-Phong Reflectance Model)
Blinn-Phong 反射模型是一个简单的着色模型,这个模型考虑了以下几个部分:漫反射(Diffuse reflection),高光(Specular highlights),环境光(Ambient lighting)。

模型定义

对于任意的着色点(Shading poing),我们定义:
观察方向(Viewer direction)\(\vec{v}\),是着色点到观察点的连线;
法线(Surface normal)\(\vec{n}\),是垂直于反射面的方向向量;
光照方向(Light direction)\(\vec{l}\),是着色点和光源的连线。
向量 \(\vec{v}\)、\(\vec{n}\)、\(\vec{l}\) 都是单位向量。同时我们还需要定义物体表面的参数,例如颜色,亮度。
着色是局部的操作,着色时,不考虑其他的物体遮挡,因此着色中没有阴影。
漫反射(Diffuse Reflection)
漫反射中光会向各个方向均匀反射,从如何方向观察到的表面颜色多是相同的。

Lambert’s 余弦定律(Lambert’s Cosine Law)表明了漫反射光的能量和入射角度之间的关系。光线反射的能量和光照方向(\(\vec{l}\))以及法线(\(\vec{n}\))的夹角余弦值(即 \(\cos \theta\))成正比。

由于 \(\vec{l}\) 和 \(\vec{n}\) 都是单位向量,所以可以得到: \[ \cos \theta = \vec{l} \cdot \vec{n} \] 对于一个点光源,在同一时刻的光的能量集中在一个球壳上,考虑到能量守恒定律,每个时刻球壳上的能量总和不变。随着球壳的变大,单位面积上的光能量会变小。设距离为 \(1\) 时的能量为 \(I\),则在距离为 \(r\) 的地方光的能量为: \[ \frac{I}{r^2} \] 经过上述分析,得出的漫反射公式为: \[ L_d = k_d \frac{I}{r^2} \max \left(0,\boldsymbol{n} \cdot \boldsymbol{l}\right) \]
上述公式中,\(k_d\) 表示漫反射系数,如果用 RGB 定义一个向量作为漫反射系数就可以代表这个反射点的颜色。\(\max \left(0,\boldsymbol{n} \cdot \boldsymbol{l}\right)\) 可以把反射光线在反方向的光线过滤掉,这些光线没有任何贡献。从公式可以看出,漫反射与观测方向没有任何关系,符合漫反射的定义。
下图表述了不同的 \(k_d\) 下的漫反射效果:

高光项(镜面反射)
镜面反射取决于观察方向,当观察方向与反射方向接近时我们就能看到高光,如下图所示:

在实际应用中一般用半程向量(Half vector)来刻画观察方向和反射方向的解决程度。如果观察方向和反射方向非常接近,则半程向量和法线方向非常接近,如下图所示:

半程向量用 \(\vec{h}\) 表示,\(\vec{h}\) 也是单位向量,显然: \[ \vec{h} = \frac{\vec{v} + \vec{l}} {\left\|\vec{v} + \vec{l}\right\|} \] 高光项计算公式为: \[ \begin{align*} L_s &= k_s \frac{I} {r^2} \max\left(0, \cos \alpha \right)^p \\ & = k_s \frac{I} {r^2} \max\left(0, \vec{n} \cdot \vec{h} \right) \end{align*} \]
公式中 \(k_s\) 为镜面反射系数。Blinn-Phong 反射模型中的高光项不考虑 Lambert’s 余弦定律,即忽略 \(\left(0,\boldsymbol{n} \cdot \boldsymbol{l}\right)\). 在公式中,有一个指数 \(p\),引入这个指数是为了将高光限制在一个小范围内,一般来说:\(p\in\left[100, 200\right]\)。不同的 \(p\) 对余弦函数的影响如下:

选取不同的 \(p\) 和 \(k_s\) 的得到的效果如下图所示:

环境光
在 Blinn-Phong 反射模型中认为环境光是一个常数,不依赖于光源方向、法线方向和观察方向。环境光计算公式如下: \[ L_a = k_a I_a \] 其中 \(I_a\) 为环境光反射系数。
Blinn-Phong 反射模型
综上所述,Blinn-Phong 反射模型如下: \[ \begin{align*} L &= L_a + L_d + L_S \\ &= k_aI_a + k_d \frac{I}{r^2} \max\left(0, \vec{n} \cdot \vec{l}\right) + k_s \frac{I}{r^2} \max\left(0, \vec{n} \cdot \vec{h}\right)^p \end{align*} \]

着色频率(Shading Frequencies)
根据不同的着色方式,有不同的着色频率,主要的着色频率分为三种——面着色、顶点着色和像素着色。主要的不同之处在于法线的选择方式不同。
- 面着色(Flat Shading,指的是计算每一个三角形平面的法线后对一个平面整体进行着色;
- 顶点着色(Gouraud Shading),指的是计算每一个三角形三个顶点的法线后进行着色,最后在三角形内部插值得到颜色。顶点法线的计算是通过顶点相邻面的法线的(加权)平均值求出;
- 像素着色(Phong Shading),指的是计算出每一个像素的法线进行着色。三角形内部点法线的计算需要依靠重心坐标进行插值计算。
不同的着色频率的效果如下所示:

当几何体相对复杂,构造精细的时候,三种着色效果产生的结果不相上下,如下图所示:

如何定义一个顶点的法线呢?有两种方法,一种是使用一个球形包围盒,求顶点法线就简化成了求球表面顶点的法线,如下如所示:

另外一种方法是求出顶点相邻的三角形的法线,然后做一个平均,再做一个归一化,即: \[ N_v = \frac{\sum_i N_i}{\left\| \sum_i N_i \right\|} \]

如何求出每个像素的法线呢?先求出三角形顶点的法线,在通过插值的方式求出每个像素的法线,如下图所示:

实时渲染管线(Real-time Rendering Pipeline)
管线是从模型到图片生成的过程。管线分为以下过程:

首先将顶点的三位向量作为输入,进行几何变换后划分三角形区域。通过光栅化获得一个个小的碎片(或者是一个个小像素)后进行着色,得到我们的输出。
我们可以定义顶点或者像素的着色方式来提供不同的着色要求,这被称为程序 Shader。硬件中会提供这样的编程方式定制不同的着色方式,以 OpenGL 为例,可以定义以下的着色函数:
1 | uniform sampler2D myTexture; |
这里我们定义了一个简单的漫反射着色器。同时,着色器会自动应用到每一个顶点或者是像素上,不需要我们使用显式的 for 循环进行遍历。
我们可以进入 Shadertoy 网站练习 Shader 编程,编程的结果会直接显示在网页中。
关于管线的更多内容可以参考文章:《细说图形学渲染管线》。
纹理映射(Texture Mapping)
三维物体表面展开
任何一个三维物体的表面都是二维的图形,可以将一个三维物体表面映射到一个二维的图像上。例如下图所示的地图和地球仪:

建立一个 \(\left(u, v\right)\) 坐标系,且 \(u, v \in \left[0, 1\right]\). 如下图所示:

在知道知道每一个三角形的顶点坐标和对应的纹理坐标的情况下,可以通过插值的方法求出三角形内部所有点的对应的纹理坐标,需要使用重心坐标计算。
在为墙面,地面加入纹理的时候可以使用边缘连接连续的纹理,称为 tiled。这样子就可以复用纹理拼接大表面。
重心坐标
通过插值,我们可以好的三角形内部平滑变化的值,例如纹理坐标、颜色、法线等等。要插值,就要使用重心坐标(Barycentric Coordinates)。
重心坐标是一种使用三角形三个顶点定义的坐标系统,坐标为 \(\left(\alpha, \beta, \gamma \right)\).

对于任意一点 \(\left(x, y\right)\),如果满足: \[ \left(x, y\right) = \alpha A + \beta B + \gamma C \\ \alpha + \beta + \gamma = 1 \] 则这个点在 \(\triangle ABC\) 所处的平面上。如果 \(\alpha\)、\(\beta\)、\(\gamma\) 全部大于 \(0\),则这个点在 \(\triangle ABC\) 内部。这说明三角形平面上任意一点都可以用三角形三个顶点坐标的线性组合表示。只需要计算出三个系数中的 2 个,就可以通过 \(\alpha + \beta + \gamma = 1\) 得出第 3 个系数的值。
我们还可以使用面积来计算重心坐标,假设点 \(A\)、\(B\)、\(C\) 对应的小三角形的面积分别为 \(A_A\)、\(A_B\)、\(A_C\),如下图所示:

则重心坐标为: \[ \begin{align*} \alpha &= \frac{A_a}{A_a + A_b + A_c} \\ \beta &= \frac{A_b}{A_a + A_b + A_c} \\ \gamma &= \frac{A_c}{A_a + A_b + A_c} \end{align*} \] 重心坐标为 \(\left(\frac{1}{3}, \frac{1}{3}, \frac{1}{3}\right)\) 的点是 \(\triangle ABC\) 的重心。
在已知要求的点的坐标的坐标的前提下,\(\triangle ABC\) 的重心坐标公式可以写成如下形式: \[ \begin{align*} \alpha & =\frac{-\left(x-x_B\right)\left(y_C-y_B\right)+\left(y-y_B\right)\left(x_C-x_B\right)}{-\left(x_A-x_B\right)\left(y_C-y_B\right)+\left(y_A-y_B\right)\left(x_C-x_B\right)} \\ \beta & =\frac{-\left(x-x_C\right)\left(y_A-y_C\right)+\left(y-y_C\right)\left(x_A-x_C\right)}{-\left(x_B-x_C\right)\left(y_A-y_C\right)+\left(y_B-y_C\right)\left(x_A-x_C\right)} \\ \gamma & =1-\alpha-\beta \end{align*} \] 如果三角形三个顶点对应了三个向量(颜色,法线或者纹理坐标),那么内部点对应的向量值是使用重心坐标进行的线性组合。
假设三个顶点对应的向量是 \(V_A\)、\(V_B\)、\(V_C\),那么三角形中任意一点的插值后向量是 \(V = \alpha V_a + \beta V_B + \gamma V_C\). 如下图所示是对颜色的插值:

注意,重心坐标在投影后不能保证结果不变,所以我们要在空间中使用三维坐标的重心公式进行插值。
纹理映射的问题
纹理映射主要分为两步,第一步是把像素点坐标映射到纹理坐标:\(\left(x, y\right) \rightarrow \left(u, v\right)\)。第二步根据纹理坐标得到对应的漫反射系数 \(\left(u, v\right) \rightarrow k_d\).
1 | for each rasterized screen sample(x, y): |
纹理上的像素叫做纹理元素或者纹素(Texel),如果纹理过大或者过小都会出现一些问题。
纹理太小
如果纹理本身太小,但是物体像素点比较多,那么就会产生非常多类似于马赛克的像素。这是由于多个相邻的像素会映射到同一个纹理坐标。我们可以采用双线性插值来解决这个问题。

双线性插值(Bilinear Interpolation)指的是对于任意一个纹理坐标,使用其临近的四个纹素值进行两次线性插值得到这个坐标对应的漫反射率。

在一维上的线性插值可以表示为: \[ \text{lerp}\left(x, v_0, v_1\right) = v_0 + x\left(v_1 - v_0\right) \] 双线性插值先在水平方向上做两次线性插值: \[ \begin{align*} u_0 & = \text{lerp}\left(s, u_{00}, u_{10}\right) \\ u_1 & = \text{lerp}\left(s, u_{01}, u_{11}\right) \\ \end{align*} \] 然后在垂直方向上做一次线性插值: \[ f\left(x, y\right) = \text{lerp}\left(t, u_0, u_1\right) \] 双立方插值(Bicubic Interpolation:使用相邻 16 个点进行计算,效果更好但是计算量相对来说更大。
纹理太大
当纹理过大的时候,近处的物体会产生锯齿,远处的物体会产生摩尔纹,也就是说结果会产生走样。主要原因是因为当物体离得越远,每一个像素所代表的纹素的数量会变多。这个时候再使用像素和纹素一一对应的方式是不可靠的。

我们的解决方法是避免采样。通过像素点直接得到对应纹素区域的平均值。
引入 MipMap 来解决范围查询的问题。Mipmap 是一个快速、近似并且只应用于正方形区域的范围查询方法。主要思想如下:我们从一张纹理生成一系列的纹理,每一个纹理的大小都是之前纹理大小的一半,最后得到的最小的纹理是一个 \(1 \times 1\) 的纹理,这就是一个图像金字塔。最终存储这些纹理额外的开销是原本纹理的 \(\frac{1}{3}\)。

我们需要计算每一个像素对应纹理的方形大小,并使用对应层的纹理。

对于一个点 \(u\left(0, 0\right)\) 和其相邻的两个点之间在纹理上的距离,可以用微分形式表示,那么这个像素所对应纹理方形的大小是: \[ L = \max\left(\sqrt{\left(\frac{\mathrm{d}u}{\mathrm{d}x}\right)^2 + \left(\frac{\mathrm{d}v}{\mathrm{d}x}\right)^2}, \sqrt{\left(\frac{\mathrm{d}u}{\mathrm{d}y}\right)^2 + \left(\frac{\mathrm{d}v}{\mathrm{d}y}\right)^2}\right) \] 那么对应的层数为: \[ D = \log_2 L \] 将 Mipmap 层数可视化的效果如下图所示:

可以看出,Mipmap 只能查出整数的层,为了保证能够查询到非整数的层,使用三线性插值法得到对应的结果。首先,对于任意一个非整数层数,我们在其上下两层使用双线性插值进行取值。接下来我们在两个层之间使用线性插值就可以得到最后的结果。

使用三线性插值法的到效果如下:

Mipmap 也存在局限性。由于所有的纹理都必须对应到一个正方形的区域,但是并不是所有的纹理都可以被一个正方形完美的包住(例如细长的长方形,或者是在对角线上的长方形)。最终远处的纹理会变得非常的模糊,丢失了许多细节。

为了解决这个问题,我们使用各向异性过滤(Anisotropic Filtering)解决。各向异性过滤指的是加入只在水平方向或者竖直方向上缩小的纹理,这样可以应对不同长方形纹理块区域。但是对于对角线上的纹理块依然不好解决,并且存储多余的纹理需要多使用原来纹理三倍的开销。

除此之外我们还可以使用 EWA 过滤得到更好的结果。可以把纹理拆分成不同的圆形块进行多次查询获得最终的结果。但是开销也会比较大。

纹理的应用
纹理除了是一个「贴图」之外,纹理还有各种各样的应用。在现代的 GPU 中,纹理是一块内存加上范围查询(滤波)的结果。除了上面简单的纹理应用之外,我们还可以使用纹理做以下事情。
环境贴图
环境贴图(Environment Map)指的是环境中四面八方的情况。可以使用纹理来描述环境光的样子。环境光纹理可以看作一个光滑镜面的球表面在环境中所记录的信息。

我们需要将球表面展开成一个平面,可以使用两种展开方式:
- 墨卡托投影法(Mercator Projection):通过将球面映射到一个平面上,我们可以使用墨卡托投影法。墨卡托投影法应用于目前地球仪的投影。它的特点是靠近南北极的地方会发生较大的畸变,这不是一个均匀地描述。

- 立方体映射(Cube Map):我们为光滑球定义一个包围盒,将球面投影到立方体的六个平面上,这样做就会得到 6 张纹理,并且畸变比较小。但是在计算纹素时需要计算球面上的点对应哪一张纹理,需要判断点和方向的位置关系。

凹凸贴图
凹凸贴图是指用纹理的方式得到物体表面凹凸不平的感觉,相比于直接通过做出物体凹凸不平的方式,这种方法更加的简单。对于任何一个点,我们只需要改变这个点的法线方向就可以表达出这个点高度的变化。因此这个贴图也被称作法线贴图(Bump mapping)。纹理上的点定义的是点高度的移动,通过纹理上信息我们可以求出新的法线方向。
在二维的情况下,我们假设原物体是一条直线,原始法线方向为 \(\left(0, 1\right)\)。对于任意一个点 \(p\),我们定义 \(p\) 点的导数是 \(\mathrm{d}p = c \cdot [h(p + 1) - h(p)]\)。常数 \(c\) 定义了凹凸贴图对于法线的影响。那么该点切线的方向是 \(\left(1, \mathrm{d} p\right)\)。法线的方向和切线的方向成 \(90\) 度角,法线的方向向量是 \(\left(\mathrm{d} p, -1\right)\) 正则化后的结果。

推广到三维情况,对于一个原始法线是 \(\left(0, 0, 1\right)\) 的平面,我们在 \(u\) 方向和 \(v\) 方向上各做一次求导,结果为: \[ \begin{align*} \frac{\mathrm{d}p}{\mathrm{d}u} & = c1 \cdot [h(u + 1) - h(u)] \\ \frac{\mathrm{d}p}{\mathrm{d}v} & = c2 \cdot [h(v + 1) - h(v)] \\ \end{align*} \] 法线方向是: \[ \left(-\frac{\mathrm{d}p}{\mathrm{d}u},-\frac{\mathrm{d}p}{\mathrm{d}v},1\right) \]
对于任意方向的原始法线,我们都可以先按照局部坐标系计算法线后通过变换变换到世界坐标系上。
除了凹凸贴图之外,还有另外一种贴图称作位移贴图。位移贴图会移动所有顶点位置。因此使用顶点贴图的时候模型三角形分的越细越好。凹凸贴图并没有实际改变物体的形状,所以在物体的边上依旧可以看到光滑的曲线。

贴图可以推广到三维空间,我们可以使用三维贴图,计算三维空间中任意一个点对应的纹理。
阴影贴图
贴图还可以直接加入一些阴影,直接计算好贴在纹理上,这样会使得阴影计算变得很快。纹理可以记录一些已经计算好阴影的信息。
阴影
Shadow Mapping 是一种使用光栅化生成阴影的算法,在计算阴影的时候我们不需要知道场景的几何信息。Shadow Mapping 的方法只适用于在点光源下计算硬阴影。
硬阴影指的是一个点是否在阴影内是确定的,它不是在阴影内就在阴影外;只有点光源才可以产生这种情况。软阴影指的是阴影是有过渡的,一个点可以接收到部分光线;当不忽略光源大小的时候,就会产生这种情况。可以接收到部分光线的区域一般称为半影。
一个点是否在阴影中取决于光源和摄像机是不是都可以看到这个点。如果都可以看到这个点,那么说明这个点不在阴影里。因此我们使用如下方式进行计算:
- 从光源位置看向场景,做出深度图;
- 从摄像机位置看向场景,对于每一个看到的点,计算到光源的距离,并且得到光源深度图上对应像素点的距离进行比较。如果距离一样,那么说明这个点不在阴影中,反之,这个点在阴影中。
这样的算法有两个问题:
- 距离是一个浮点数,不容易进行比较。需要引入一定的宽容度。这是数值精度的问题,不能从本质解决问题;
- 阴影的质量和光源深度图的分辨率有关。如果光源深度图太小,但是摄像机分辨率大就容易出现走样问题;
- 这个方法只适合硬阴影,不适合软阴影。