计算机图形学基础(5)——着色

上一篇 文章 我们介绍了光栅化所涉及的基本内容。通过光栅化,我们可以实现将 3D 空间模型的投影绘制到 2D 屏幕。然而,仅仅实现光栅化,还不足以让渲染结果具有真实感,如下图左部所示。我们希望能够模拟光线所带来的的明暗效果,如下图右部所示。

在计算机图形学中,着色(Shading)就是通过计算来决定三维模型表面每个像素的颜色和亮度的过程。本质而言,着色就是 对不同物体应用不同材质

着色模型

着色局部性

具体分析着色时,我们会分析光线照射到物体表面的每一个点,也称 着色点(Shading Point)。对于每个着色点,我们将其视为一个微平面(或称单位平面),由此我们可以构建法线。整体而言,着色的最终结果受以下几种输入影响,分别是:

  • 观测方向 \(v\)
  • 表面法向 \(n\)
  • 光线方向 \(l\)
  • 表面参数,如:颜色、材质。

注意:对于着色过程,我们只考虑光照对于物体表面的影响,而不考虑其他物体的阴影对本物体产生的影响。

布林-冯反射模型

关于光线与物体表面的作用,根据我们的认知,其实可以分为三种类型:

  • 漫反射(Diffuse)
  • 高光(Specular)
  • 环境光(Ambient)

在计算机图形学中,有一种广泛使用的光照和颜色计算模型——布林-冯反射模型(Blinn-Phong Relectance Model),其考虑了上述三种光照的叠加效果对物体表面颜色的影响。

下面,我们分别来介绍这三种光照类型。

漫反射

当光线照射到一个点时,光线会向各个方向发生反射,这种现象称为 漫反射。漫反射的反射光强主要受到光照角度、光照强度、漫反射系数等因素的影响。

光照角度

在图形学中,兰伯特余弦定理(Lambert's cosine law)详细描述了光照角度对于表面接收光照照射量的影响。下图所示,列举了三种光照角度。

  • 情况一,入射角度为 \(90^{\circ}\),单位平面会接收全部光照。
  • 情况二,入射角度为 \(30^{\circ}\),单位平面只会接收到一半光照。
  • 情况三,入射角度为 \(90^{\circ}-\theta\),单位平面接收到的光照占全部光照的比例为 \(cos{\theta} = \hat{l} \cdot \hat{n}\)

基于兰伯特余弦定理,我们可以推导出一个函数表示单位平面接收的光照照射量占全部光照的比例,如下所示。由于 \(cos\theta\) 可能会负数,但这没有意义,所以我们使用 \(max(0, cos\theta)\) 来保证其值大于等于 0。

\[\begin{aligned} f(\theta) = max(0, cos\theta) = max(0, \hat{l} \cdot \hat{n}) \end{aligned}\]

光照强度

对于光照强度,我们考虑如下所示 3D 空间中的一个点光源。根据能量守恒定理,以光源为球心,任意距离为半径的球体,球面所覆盖的光线强度是相等的。

由此,我们可以推导光照强度与光源距离之间的关系。假设半径 \(r\)1 时,球面一个点的光照强度为 \(I\)。那么当半径为任意值 \(r\) 时,我们可以根据能量守恒定理得到:

\[\begin{aligned} 单位球面光照强度:& 4{\pi}I \\ 任意球面光照强度:& 4{\pi}r^2I_r \\ 根据能量守恒定理:& 4{\pi}r^2I_r = 4{\pi}I \\ 任意点的光照强度:& I_r = I/r^2 \end{aligned}\]

漫反射系数

不同的材质具有不同的漫反射系数,我们将漫反射系数定义为 \(k_d\)。如下所示,\(k_d\) 越大,反射的光线强度越大,看到的物体越亮。

漫反射公式

漫反射光线的计算公式其实就是由上述三部分组成,如下所示。

\[\begin{aligned} L_d = k_d(I/r^2)max(0, \hat{l} \cdot \hat{n}) \end{aligned}\]

高光

高光反射,当观测向量趋近于光线的反射向量时,我们可以看到镜面反射所产生的高光,如下图所示。

高光区域

那么如何判断高光区域呢?我们可以通过计算光照方向向量和观测方向向量之间的 半程向量(Half Vector)。然后再计算半程向量与平面法线之间的夹角,判断两者是否接近。

如下所示为半程向量的计算公式,有了半程向量之后,我们可以计算法向量与半程向量之间的夹角。

\[\begin{aligned} \hat{h} = bisector(\hat{v}, \hat{l}) = \frac{\hat{v} + \hat{l}}{|\hat{v} + \hat{l}|} \end{aligned}\]

高光突变

根据日常经验,我们可以发现当法向量与半程向量之间的夹角大于某个阈值之后,高光效应会发生突变。如果我们使用 \(cos\theta\) 来描述这种突变,显示是不合适的。在布林-冯模型中,我们对 \(cos^p\theta\) 来描述高光突变,其中 \(p\) 是一个经验值。下图所示,展示了不同 \(p\) 值随角度变化的曲线。

高光系数

类似于漫反射系数,对于高光,这里也有一个高光系数,使用 \(k_s\) 表示。下图所示为不同 \(k_s\) 和不同 \(p\) 的情况下,高光效果的对比。可以看出,高光系数越大,观测的效果越明亮。高光突变的 \(p\) 值越大,高光区域则越小。

高光公式

高光的计算公式其实也是由三部分组成:高光系数、光线强度、高光突变,具体公式如下所示。

\[\begin{aligned} L_s = k_s(I/r^2)max(0, \hat{n} \cdot \hat{h})^p \end{aligned}\]

环境光

在现实世界中,我们知道即使没有光源直接照射物体,物体也并不是完全是黑色的。对此,布林-冯着色模型也近似处理了这种情况,即环境光。

环境光公式

环境光的计算公式非常简单,由环境光系数和环境光强度组成,具体公式如下所示。

\[\begin{aligned} L_a = k_aI_a \end{aligned}\]

光线反射公式

布林-冯反射模型定义了一个光线反射公式,该公式由上述三种光照反射类型的计算公式组合,具体公式如下所示。

\[\begin{aligned} L = L_a + L_d + L_s = k_aI_a + k_d(I/r^2)max(0, \hat{n} \cdot \hat{l}) + k_s(I/r^2)max(0, \hat{n} \cdot \hat{h})^p \end{aligned}\]

着色频率

在布林-冯反射模型中,我们以着色点(单位平面)为单位介绍三种光照反射类型。那么在真实着色过程中,以什么为单位进行着色呢?考虑到着色性能的开销,实际上可以分为三种类型,分别是:

  • 平面着色(Flat Shading)
  • 顶点着色(Gouraud Shading)
  • 像素着色(冯-着色,Phong Shading)

平面着色

平面着色会对每一个平面做一次着色。相对而言,着色频率低,性能开销小,但是着色效果不够丝滑,会有明显的棱边效果。

在布林-冯反射模型中,着色点的法向量是计算着色的关键变量。对于平面着色而言,我们可以通过三角形的任意两条边所构成的向量,计算叉积,即可得到法向量。

顶点着色

顶点着色会对三角形的三个顶点进行着色。对于三角形内部的点,则基于三个顶点的颜色,使用速度更快的插值法进行计算。相比平面着色,着色频率略高,性能开销略大,但是着色效果会好一点,会有细微的棱边效果。

对于顶点着色,我们需要计算三个顶点各自的法向量。通常有两种选择:

  • 当平面属于一个规则几何体的局部表面时,可以通过规则几何体的整体出发,计算对应平面的法向量。
  • 其他情况时,可以基于周围平面的法向量,求解平均值,计算对应平面的法向量。

像素着色

像素着色,也称冯-着色,它会对每一个像素进行着色。这种方式着色频率很高,性能开销很大,但是着色效果非常丝滑。

对于像素着色,我们首先以上述方式计算三角形顶点的法向量,对于三角形内部的点,则通过 重心插值法(Barycentric Interpolation)来计算。关于重心插值法,我们稍后进行介绍。

实时渲染管线

实时渲染管线(Real-time Rendering),也称图形管线(Graphics Pipeline),其描述了 3D 场景转换成 2D 图像的完整流程,如下图所示。

实时渲染管线可以分为五个阶段,分别是:

  • 顶点处理(Vertex Processing)
  • 三角形处理(Triangle Processing)
  • 光栅化(Rasterization)
  • 片段处理(Fragment Processing)
  • 帧缓冲操作(Framebuffer Operations)

顶点处理

顶点处理的输入是 3D 空间中的顶点。为什么是顶点而不是 3D 模型?这是因为 3D 空间的所有模型都是以三角形为基本单元进行表示的,而三角形则可以通过顶点和连线来描述,3D 模型的本质就是大量顶点和连线的定义。

在顶点处理阶段,我们会对顶点进行观测变换,即 《计算机图形学基础(3)——观测变换》 中所介绍的 MVP 变换。最终输出经过观测变换的顶点。

三角形处理

在某些文章中,会将这个阶段定义成 图元处理(Primitive Processing),三角形处理只是其中的一个子集,它还会处理点和线。这里我们为了突出重点,将其称为三角形处理。

由于顶点处理阶段只对顶点进行变换,而 3D 模型还包括连线的定义,三角形处理阶段就是根据连线的定义,将顶点装配成三角形(也称图元)。

光栅化

当顶点处理和三角形处理完成之后,我们得到了经过观测变换后的三角形。此时三角形仍然处于 3D 空间中,不过我们可以通过正交投影快速获取它们在 2D 空间中的投影。

光栅化则是将连续的 2D 投影进行采样,转换成离散的 2D 投影,这是因为屏幕由一个离散的二维像素矩阵所构成。关于光栅化具体要做的事情以及可能遇到的问题,我们在 《计算机图形学(4)——光栅化》 中进行了详细的介绍。

在实际的 GPU 设计中,为了支持可编程、并行计算,实时渲染管线中的光栅化的主要任务是对连续的图形进行采样,使其离散化。

片段处理

片段处理,也称像素处理,它会对每个片段的颜色、纹理坐标、深度值等进行计算,期间会大量应用插值法进行计算。严格意义上说,片段处理也属于光栅化的一部分。

帧缓冲操作

帧缓冲操作包含了颜色混合、模板测试、深度测试、透明度检查等一系列操作,最终结果会保存在帧缓冲区,显示器会定时读取帧缓冲区,并将内容呈现在屏幕上。

关于着色

整体而言,实时渲染管线包含观测变换、光栅化、着色三大部分。

然而,着色其实在顶点处理和片段处理阶段都可以存在,这取决于着色频率。如果我们采用顶点着色,那么着色可以发生在顶点处理阶段;如果我们采用像素着色,那么着色可以发生在片段处理阶段。

在现代 GPU 中,实时渲染管线的部分阶段是支持可编程的,比如顶点处理阶段和片段处理阶段。在这些可编程阶段中,我们可以编写着色器(Shader)程序,从而生成自定义的着色结果。

着色器

在实时渲染领域,大部分从业者做的事情就是在写各种各样的着色器。如下所示,是 OpenGL 中的一个片段着色器程序,其采用 GLSL 着色语言编写。着色器程序最终由 GPU 调用,对于每个像素都会执行并生成着色结果。

1
2
3
4
5
6
7
8
9
10
11
uniform sampler2D myTexture;    // program parameter
uniform vec3 lightDir; // program parameter
varying vec2 uv; // per fragment value (interp. by rasterizer)
varying vec3 norm; // per fragment value (interp. by rasterizer)

void diffuseShader() {
vec3 kd;
kd = texture2d(myTexture, uv); // material color from texture
kd *= clamp(dot(–lightDir, norm), 0.0, 1.0); // Lambertian shading model
gl_FragColor = vec4(kd, 1.0); // output fragment color
}

纹理

在介绍着色模型中,我们提到着色点的材质会影响最终的着色结果,比如各种反射系数 \(k_d\)\(k_s\)\(k_a\) 等。除此之外,着色点的原始颜色、法线等属性也都会影响着色结果。

为了能够为着色点定义属性,提出了 纹理(Texture)的概念,使用纹理来记录每个着色点的各种属性。通常情况下,我们会把纹理等同于贴图(图片),这是因为大多数情况下会使用纹理来定义颜色。不过从严格意义上说,贴图只是纹理的一种而已。

纹理映射

纹理映射的本质就是将纹理定义的属性映射到 3D 模型的各个着色点。

如下图所示,我们定义了一个模型和一个纹理,中间的模型经过纹理映射后渲染得到了我们期望的效果。在建模时,我们会将模型分割成一个个三角形。与模型所绑定的纹理,我们也会将其分割成一个个三角形。两者之间的三角形会一一对应。

为了方便映射,我们会建立 纹理坐标系(Texture Coordinate),横坐标用 \(u\) 表示,纵坐标用 \(v\) 表示。\(u\)\(v\) 的值都在 [0, 1] 之间,这是一个约定俗成的规定。模型中的每个顶点都会设定一个纹理坐标,通过这种方式可以实现纹理映射。

重心坐标

虽然模型和纹理是绑定的,但是绑定是基于顶点实现的。因此在纹理映射中,对于模型三角形的顶点,我们可以直接使用绑定的纹理坐标找到纹理中对应坐标的属性。但是模型三角形内部的点该如何获取纹理属性呢?为了解决这个问题,提出了 重心坐标(Barycentric Coordinate)的概念。

以上图中的三角形为例,重心坐标定义了三角形内部任意一个点 \((x, y)\) 具有以下几个特性。

\[\begin{aligned} \begin{cases} (x, y) = {\alpha}A + {\beta}B + {\gamma}C \\ \alpha + \beta + \gamma = 1 \\ \alpha >= 0; \beta >= 0; \gamma >= 0; \end{cases} \end{aligned}\]

最终,我们可以计算得到三角形内任意一个点的重心坐标 \((\alpha, \beta, \gamma)\)。此时,我们可以使用重心坐标,结合顶点属性,计算得到该点的属性。这里的属性可以是位置、纹理坐标、颜色、法线、深度、材质等各种属性。

需要注意的是,在投影时三角形的形状会发生变化,所以在着色时应该基于三维空间的坐标计算重心坐标,然后再做插值。

纹理查询

上面我们介绍了使用重心坐标表示三角形中的任意点。那么具体该如何应用重心坐标来查找对应的纹理属性呢?如下所示,我们使用伪代码描述了这个查找过程。

1
2
3
4
5
for each rasterized screen sample(x, y) {
(u, v) = evaluate texture coordinate at (x, y)
texcolor = texture.sample(u, v)
set sample's color to texcolor
}

我们先说明一个前提:在光栅化阶段,即当三角形被转换为屏幕上的像素时,每个像素的纹理坐标会通过插值方式在三角形的顶点之间计算出来。此时,我们得到的是每个像素的屏幕坐标以及对应的纹理坐标。

上述伪代码所描述的流程是:

  • 遍历光栅化得到的屏幕采样点,比如一个三角形 \(ABC\) 的区域内的某个像素点 \((x, y)\)
  • 基于上述前提,有了像素的屏幕坐标 \((x, y)\),我们可以直接获取对应的纹理坐标。
  • 当得到像素点的纹理坐标后,我们就可以在纹理中查找对应的属性,伪代码中查找的是颜色属性。
  • 最后我们用纹理颜色来给像素着色。

本质上,这是一个纹理采样过程。一旦涉及采样,就可能会出现走样问题。下面,我们来分情况讨论。

纹理太小问题

对于纹理太小的情况,那么会出现多个像素映射到一个 纹素(Texel),即纹理中的一个点或像素。此时,就会出现锯齿问题。

为了解决锯齿问题,我们可以通过求均值的方式来解决。如下所示,为最近采样、双线性插值、双三次插值的对比结果。

双线性插值的原理非常简单,就是去临近的 4 个像素,通过三次插值计算得到一个颜色平均值。

双三次插值的原理与双线性插值类似,区别在于前者使用周围的 16 个像素求插值,后者使用周围的 4 个像素求插值。

纹理太大问题

对于纹理太大的情况,会出现摩尔纹、锯齿等情况。本质上是采样频率低于信号频率,我们在 计算机图形学基础(4)——光栅化 中介绍过两种解决思路,一种是超采样,一种是过滤高频信号。

这两种思路,在这种场景下都存在开销过大的问题。于是,在图形学中提出了范围查询的方法,即 Mipmap,从而避开了采样所带来的问题。

点查询 & 范围查询

本质上,采样就是点查询。当纹理太大时,屏幕上一个点对应到纹理上可能是一个很大的区域。然而,从这个区域中取一个点来代表整个区域的颜色,这显然是不合适的。对比而言,范围查询相当于提前计算出一个合适的值来代表这个区域。

Mipmap

Mipmap 正是范围查询的一种实现方案,它会为一张纹理生成多个不同层级的纹理,如下图所示。Mipmap 虽然生成了多个不同层级的纹理,但是整体的存储量只增加了不到 1/3。

既然 Mipmap 生成了多个不同层级的纹理,那么在纹理查询时,我们应该查询哪个层级的纹理呢?

如下图所示,对于屏幕上的一个像素点,考虑其相邻的两个点,获取它们的纹理坐标。根据纹理坐标计算相邻的距离,由此近似得到像素对应的矩形区域。我们获取矩形区域较大的边长 \(L\)。然后对 \(L\) 求对数,即可计算得出要查询的纹理的层级。

\[\begin{aligned} L = & max(\sqrt{(\frac{du}{dx})^2 + (\frac{du}{dx})^2}, \sqrt{(\frac{du}{dy})^2 + (\frac{du}{dy})^2}) \\ D = & log_2L \end{aligned}\]

各向异性过滤

事实上,Mipmap 也并不是万能的。在有些场景下,也会出现过度模糊的问题,如下所示。

根本原因是,Mipmap 的范围查询所覆盖的区域是正方形。如果屏幕像素点代表了纹理中 的一个长方形区域,那么范围查询就无法准确代表长方形区域内的值,因此会出现走样,如下图所示。

那么如何解决呢?方法是各项异性过滤(Anisotropic Filtering)。具体的技术是:除了生成针对正方形区域的范围查询的纹理外,还要生成其他形状(比如长方形)的范围查询的纹理。通过这种方式,纹理的存储量会增加 3 倍,不过能够降低着色走样的概率。

纹理应用

至此,我们基本了解了纹理及其工作原理,本质而言,纹理 = 内存存储 + 范围查询。上述内容我们主要介绍了通过纹理记录颜色,事实上纹理还能记录其他很多属性,比如:环境光、微几何、法向量、高度偏移等等。

下面,我们来介绍纹理的其他几种应用。

环境贴图

纹理应用最多的就是 环境贴图(Environment Map),这里又有非常多的类型。

立方体环境贴图(Cube Environment Map),它是将环境映射到一个立方体的六个面上,可以用于实现镜面反射和环境光照。

光照环境贴图(Light Environment Map),它在渲染过程中预先计算和存储环境光照信息,以提高实时渲染效率和质量的技术

除此之外,还有很多环境贴图,比如:球谐环境贴图、镜面反射环境贴图、辐射度环境贴图、天空盒环境贴图等等。

凹凸贴图

假如我们希望渲染一个表面凹凸不同的球状体,如果使用三角形来表示,那么需要大量三角形,而且结构非常复杂。对于这种情况,我们可以凹凸贴图(Bump Map),它可以定义点的相对高度,从而改变法线,进而影响着色结果,如下图所示。

位移贴图

凹凸贴图改变了着色时所使用的法向量,但并没有真正改变模型的形状。一种更现代化的 位移贴图(Displacement Mapping),则定义了顶点高度的偏移量,使得真真正改变了模型的形状,从而实现更加逼真的效果。下图所示,为凹凸贴图和位移贴图的对比效果。

总结

本文我们主要介绍了着色相关的内容。

首先,我们介绍了着色模型,具体介绍了经典的布林-冯反射模型,其由漫反射、高光、环境光三部分组成。

其次,我们介绍了几种着色频率,包括平面着色、顶点着色、像素着色,简单对比了它们之间的差异。

然后,我们简单介绍了实时渲染管线的 5 个阶段,包括顶点处理、三角形处理、光栅化、片段处理、帧缓冲操作等。

最后,我们详细介绍了着色中最重要的一部分——纹理。纹理查询是是如何通过重心坐标、纹理坐标查找对应的纹理属性。当然,纹理查询也属于采样,其中也会遇到走样的问题。于是,我们引入了线性插值、Mipmap、各向异性过滤等解决方案。除此之外,我们还介绍了纹理的几种应用,包括:环境贴图、凹凸贴图、位移贴图等。

参考

  1. 《GAMES 101》
  2. Shadertoy