【计算机图形学】GAMES101学习笔记(3):着色

​本篇内容对应GAMES101课程的Lecture 7-9、Lecture 10的开头以及Lecture 12的后半部分,对应[1]的Chapter 10-11。

本文主要参考资料:

[1] S. Marschner, P. Shirley, et al., Fundamentals of Computer Graphics, 4th ed. Boca Raton, FL, USA: CRC Press, 2016.
[2] T. Akenine-Möller, et al., Real-time Rendering, 4th ed. Boca Raton, FL, USA: CRC Press, 2018.
[3] 【重心坐标插值、透视矫正插值】原理以及用法见解(GAMES101深度测试部分讨论)
[4] 图像缩放之双三次插值法
[5] EWA滤波


由于作者已经开学(苦逼大学生),空闲时间大大减少,因此接下来的文章(只要是学期中写的)均较为精简,并且更新频率很低,还望见谅。

(有些略去的内容或延伸阅读会给出所在文章的链接)

这篇文章主要关于着色(shading)部分。所谓“着色”,狭义上讲,指确定光线打在一个物体上呈现出的效果([2]即如此定义);广义上讲,指将一种材质应用到一个物体上(GAMES101课程即如此定义)。到目前为止,我们已经能够生成准确的几何形体,但是这还不够,因为当光源确定时,从不同位置看物体、物体之间的位置关系都能决定物体呈现出来是明是暗,得确定光照对物体的影响才能使图像更真实;有时候,我们可以让物体呈现出不同的纹理样式,使得物体能够有更生动多样的外观。这些都属于着色所研究的内容,也是本文的两大部分。

Blinn-Phong反射模型

我们使用着色模型(shading model)来给物体着色。着色模型,简单来说,就是给物体着色的方法,也就是物体表面的每一个点在给定的相机视角下所应呈现的颜色的计算方法(注意我们现在还是在光栅化的框架下讨论的)。

注意到我们能看到一个物体,一定经过了如下过程:光从光源发出,打到物体上,再经反射,打到人眼中。于是,研究光在特定材质表面上的反射是尤为重要的。

基于这个观点,可以把经反射后的光分为三种:

(1)高光(spectacular highlight),源于镜面反射,其光强用 L s L_s Ls表示;

(2)漫反射(diffuse reflection)光,其光强用 L d L_d Ld表示;

(3)环境光照(ambient lighting),其实也是某种漫反射,其光强用 L a L_a La表示。在密闭空间内,光打到墙面经漫反射后的光被称为环境光照,它使室内物体的背光面也不完全呈现黑色。

最终进入人眼的光强 L L L就是以上三类光的光强的简单相加。于是,我们其实已经可以写出 L L L的简易表达式(着色模型): L = L a + L d + L s . L=L_a+L_d+L_s. L=La+Ld+Ls.

这个着色模型被称为Blinn-Phong反射模型,我们很快就会依次研究这三项。

说明一下,这里的光强表示的是颜色。使用RGB表示的话,光强就是一个三维向量,其分量在0~255之间。着色点的“亮度”反映在RGB值中,例如, ( 100 , 0 , 0 ) (100,0,0) (100,0,0) ( 200 , 0 , 0 ) (200,0,0) (200,0,0)都表示某种红色,但后者显得比前者亮;再比如, ( x , x , x ) (x,x,x) (x,x,x) x x x在0~255之间)都表示灰色, x x x越大,越接近白色,反之越接近黑色。

光强的这种表示解释了为什么计算机显示出的大部分高光是白色:在实际计算中,如果某个分量超过了255,则为255;小于0,则为0。于是,当光线过于强时,它的三个分量都超过了255,从而为255。因此,无论光线“本应”为何色,都显示为白色。

在实际应用中,我们使用[0,1]内的三个实数表示一个点的颜色(归一化RGB值),最后要显示时再将每个分量乘以255,其原因在于这样便于颜色的调节:比如我们期望一个灰色 ( 128 , 128 , 128 ) (128,128,128) (128,128,128)作用于一个红色 ( 255 , 0 , 0 ) (255,0,0) (255,0,0)后它能变暗,于是我们把两个RGB值的对应分量相乘,得到 ( 32640 , 32640 , 32640 ) (32640,32640,32640) (32640,32640,32640),显示为白色,肯定不符合要求。但如果是 ( 0.5 , 0.5 , 0.5 ) (0.5,0.5,0.5) (0.5,0.5,0.5)作用于 ( 1 , 0 , 0 ) (1,0,0) (1,0,0)后再乘255,结果就是 ( 128 , 0 , 0 ) (128,0,0) (128,0,0),符合要求。

在这里插入图片描述

着色有一个特点:它是局部的,即只考虑物体表面上的一个点[称为着色点(shading point)]对光线的作用,而忽略物体其他部分以及其他物体对光的影响。也就是说,我们根本不考虑光线被着色点局部以外的区域遮挡的情况,而是假设光源-着色点-人眼三点两线之间空无一物。因此,着色过程不会生成任何阴影(shadow)。

着色模型可以被描述成这样一个函数,它的输入是:

(1)观测方向 v \boldsymbol{v} v

(2)表面法向量 n \boldsymbol{n} n

(3)光线入射方向 l \boldsymbol{l} l

(4)表面参数(包括颜色、反光度等)。

L L L L a L_a La L d L_d Ld L s L_s Ls都是这样的函数。

这里我们这里把 v \boldsymbol{v} v l \boldsymbol{l} l的方向都定义为从着色点指向人眼或光源。注意 v \boldsymbol{v} v l \boldsymbol{l} l n \boldsymbol{n} n都是方向向量,因此都是单位向量。

在这里插入图片描述

先来考虑漫反射光强 L d L_d Ld。漫反射假设反射光被均匀分散到以着色点为中心的半球的各个方向,故观测方向 v \boldsymbol{v} v L d L_d Ld无影响。

在这里插入图片描述

现来研究 l \boldsymbol{l} l L d L_d Ld的影响,这是通过影响着色点接收到的光强实现的。显然,入射光与法向量的夹角 θ \theta θ不同,着色点应该呈现不同的亮度,因为着色点接收到的光强不同(严格来说,是着色点接收到的光的辐照度不同。本专栏之后在光线追踪的部分会讲到辐射度量学,那里有更深入的阐述),地球的四季就是这么产生的。其他条件一定时,着色点接收到的光强与 c o s θ = n ⋅ l \mathrm{cos}\theta = \boldsymbol{n}\cdot \boldsymbol{l} cosθ=nl呈正比。

此外,光源离着色点的距离也会影响 L d L_d Ld,这是通过影响抵达着色点处的光强实现的。光强在空间中的衰减遵循平方反比律:假设抵达以光源为球心、半径为1的球面的光强为 I I I,则抵达半径为 r r r的球面的光强为 I r 2 \dfrac{I}{r^2} r2I

于是,我们得到Lambertian(漫反射)着色模型 L d = k d I r 2 m a x ( 0 , n ⋅ l ) . L_d=k_d\frac{I}{r^2}\mathrm{max}(0,\boldsymbol{n}\cdot \boldsymbol{l}). Ld=kdr2Imax(0,nl).

其中, I r 2 m a x ( 0 , n ⋅ l ) \dfrac{I}{r^2}\mathrm{max}(0,\boldsymbol{n}\cdot \boldsymbol{l}) r2Imax(0,nl)是着色点接收到的来自光源的光强, m a x \mathrm{max} max函数的意义是保证来自着色点“背后”(此时 n ⋅ l < 0 \boldsymbol{n}\cdot \boldsymbol{l}<0 nl<0)的光线对着色无影响(因为光线被物体遮挡住了); k d k_d kd被称为漫反射系数,是一个三维向量,作用是调节反射光光强,使之呈现不同的颜色或亮度。在实际应用中, k d k_d kd就是着色点本身的颜色(归一化RGB值),例如红色的物体 k d = ( 1 , 0 , 0 ) k_d=(1,0,0) kd=(1,0,0)

在这里插入图片描述

再来考虑高光光强 L s L_s Ls L s L_s Ls显然与 v \boldsymbol{v} v有关系。回忆镜面反射的定律,设镜面反射的光线的方向向量为 r \boldsymbol{r} r,则 n \boldsymbol{n} n平分 l \boldsymbol{l} l r \boldsymbol{r} r。我们定义两个方向向量的半程向量(bisector)为它们的角平分线对应的方向向量,即 b i s e c t o r ( α , β ) : = α + β ∥ α + β ∥ . \mathrm{bisector}(\boldsymbol{\alpha},\boldsymbol{\beta}):=\dfrac{\boldsymbol{\alpha}+\boldsymbol{\beta}}{\left \| \boldsymbol{\alpha}+\boldsymbol{\beta} \right \|}. bisector(α,β):=α+βα+β.

于是, n = b i s e c t o r ( l , r ) \boldsymbol{n}=\mathrm{bisector}(\boldsymbol{l},\boldsymbol{r}) n=bisector(l,r)。当 v \boldsymbol{v} v r \boldsymbol{r} r越近,即二者夹角越小时,高光强度就越大。然而, r \boldsymbol{r} r有时并不好算,但我们发现两个方向向量的半程向量非常好算,于是我们改用 h = b i s e c t o r ( v , l ) \boldsymbol{h}=\mathrm{bisector}(\boldsymbol{v},\boldsymbol{l}) h=bisector(v,l) n \boldsymbol{n} n的夹角大小来判断高光的强度 L s L_s Ls,具体写出即 L s = k s I r 2 m a x ( 0 , n ⋅ h ) p . L_s=k_s\frac{I}{r^2}\mathrm{max}(0,\boldsymbol{n}\cdot \boldsymbol{h})^p. Ls=ksr2Imax(0,nh)p.

其中, k s k_s ks高光系数,一般是一个灰色RGB值 ( x , x , x ) (x,x,x) (x,x,x)。p用于控制高光的范围,p越大,高光范围越小,其数学原理是 0 ≤ m a x ( 0 , n ⋅ h ) ≤ 1 0\le \mathrm{max}(0,\boldsymbol{n}\cdot \boldsymbol{h})\le 1 0max(0,nh)1,而指数函数 f ( x ) = a x f(x)=a^x f(x)=ax a ∈ ( 0 , 1 ) a\in (0,1) a(0,1)时严格单调递减。实际情况中,p往往取几十至一百多,其原因在于 c o s θ = n ⋅ h \mathrm{cos}\theta=\boldsymbol{n}\cdot \boldsymbol{h} cosθ=nh关于 θ = ⟨ n , h ⟩ ∈ [ 0 , π 2 ] \theta=\left \langle \boldsymbol{n}, \boldsymbol{h} \right \rangle\in [0,\dfrac{\pi}{2}] θ=n,h[0,2π]下降速度较慢。

在这里插入图片描述

在这里插入图片描述

最后来看 L a L_a La。我们假设(尽管这是高度近似的)环境光强 I a I_a Ia是恒定的,则 L a = k a I a . L_a=k_aI_a. La=kaIa.

其中, k a k_a ka环境光照系数,同样是一个RGB值,只与着色点处的材质性质有关。

综上,我们得到Blinn-Phong反射模型完整的表达式: L = L a + L d + L s = k a I a + k d I r 2 m a x ( 0 , n ⋅ l ) + k s I r 2 m a x ( 0 , n ⋅ h ) p . \begin{align}L=L_a+L_d+L_s=k_aI_a+k_d\frac{I}{r^2}\mathrm{max}(0,\boldsymbol{n}\cdot \boldsymbol{l})+k_s\frac{I}{r^2}\mathrm{max}(0,\boldsymbol{n}\cdot \boldsymbol{h})^p.\end{align} L=La+Ld+Ls=kaIa+kdr2Imax(0,nl)+ksr2Imax(0,nh)p.

其中, h = b i s e c t o r ( v , l ) = v + l ∥ v + l ∥ . \boldsymbol{h}=\mathrm{bisector}(\boldsymbol{v},\boldsymbol{l})=\dfrac{\boldsymbol{v}+\boldsymbol{l}}{\left \| \boldsymbol{v}+\boldsymbol{l} \right \|}. h=bisector(v,l)=v+lv+l.

在这里插入图片描述

着色频率

着色频率(shading frequency)指的是应用着色模型计算颜色的频率。如果你听了这句话后一头雾水,那是正常的,只需了解三种常见的着色类型(对应于不同的着色频率)即可:

(1)平坦着色,着色频率是面:对每个三角形面仅作一次着色,整个三角形都显示为这个颜色。法向量为三角形所在平面的法线 n \boldsymbol{n} n,具体求法是:对于 △ A B C \bigtriangleup ABC ABC n = A B → × A C → ∥ A B → × A C → ∥ \boldsymbol{n}=\dfrac{\overrightarrow{AB} \times \overrightarrow{AC}}{\left \| \overrightarrow{AB} \times \overrightarrow{AC} \right \|} n= AB ×AC AB ×AC (当然要注意法向量的方向应该指向物体外部);

在这里插入图片描述

(2)Gouraud着色,着色频率是顶点:在每个三角形面的每个顶点着色,内部的颜色通过对三个顶点的颜色插值求得。插值算法见“重心坐标”一节。对于每个顶点,我们可以通过以下方法确定它的法向量 n \boldsymbol{n} n:设与该顶点相接的面的法向量为 n 1 ⋯ n k \boldsymbol{n_1} \cdots \boldsymbol{n_k} n1nk,面积是 S 1 ⋯ S k S_1 \cdots S_k S1Sk,则 n \boldsymbol{n} n可以由这些法向量直接求平均得到,也可以由其根据面积求加权平均得到: n = ∑ i = 1 k n i ∥ ∑ i = 1 k n i ∥  或  n = ∑ i = 1 k S i n i ∥ ∑ i = 1 k S i n i ∥ . \boldsymbol{n}=\frac{\sum_{i=1}^{k}\boldsymbol{n_i}}{\left \| \sum_{i=1}^{k}\boldsymbol{n_i} \right \|}\ 或\ \boldsymbol{n}=\frac{\sum_{i=1}^{k}S_i\boldsymbol{n_i}}{\left \| \sum_{i=1}^{k}S_i\boldsymbol{n_i} \right \|}. n= i=1kni i=1kni  n= i=1kSini i=1kSini.

在这里插入图片描述

(3)Phong着色,着色频率是像素:对每个像素都计算一次着色。利用深度缓存,设某个像素上看到的是位于某个三角形上的一个点,则在该点处计算着色,结果即为这个像素显示的颜色。三角形上该点的法向量通过对三个顶点的法向量插值求得。

在这里插入图片描述

三种着色类型的效果比较:

在这里插入图片描述

Phong着色的效果最好,但计算量最大,速度最慢。因此,当对图像质量要求不高(例如人眼里物体较远,显示出来的物体图像很小)时,可以使用平坦着色;要求一般时,可以使用Gouraud着色;要求高时,再考虑使用Phong着色。

重心坐标

现在来讲三角形的插值算法,这个算法的名字叫重心坐标(barycentric coordinates),它能够根据三角形每个顶点的值(数或向量)求出内部的值,并且使得整个三角形内该值平滑变化。

设位于平面中的 △ A B C \bigtriangleup ABC ABC顶点坐标已知,对于平面上任意一点 ( x , y ) (x,y) (x,y),存在唯一的三元组 ( α , β , γ ) ∈ R 3 (\alpha,\beta,\gamma)\in \mathbb{R}^3 (α,β,γ)R3,使得 ( x , y ) = α A + β B + γ C . (x,y)=\alpha A+\beta B+\gamma C. (x,y)=αA+βB+γC.

并且总有 α + β + γ = 1 \alpha+\beta+\gamma=1 α+β+γ=1 ( α , β , γ ) (\alpha,\beta,\gamma) (α,β,γ)被称为 ( x , y ) (x,y) (x,y)的重心坐标。

( x , y ) (x,y) (x,y) △ A B C \bigtriangleup ABC ABC的内部或边界上,当且仅当 α ≥ 0 \alpha \ge 0 α0 β ≥ 0 \beta \ge 0 β0 γ ≥ 0 \gamma \ge 0 γ0(即 α + β ≤ 1 \alpha+\beta \le 1 α+β1)。

重心坐标的计算公式如下(推导过程见[3]): α = − ( x − x B ) ( y C − y B ) + ( y − y B ) ( x C − x B ) − ( x A − x B ) ( y C − y B ) + ( y A − y B ) ( x C − x B ) , β = − ( x − x C ) ( y A − y C ) + ( y − y C ) ( x A − x C ) − ( x B − x C ) ( y A − y C ) + ( y B − y C ) ( x A − x C ) , γ = 1 − α − β . \begin{align} \alpha & = \frac{-(x-x_B)(y_C-y_B)+(y-y_B)(x_C-x_B)}{-(x_A-x_B)(y_C-y_B)+(y_A-y_B)(x_C-x_B)}, \\ \beta & = \frac{-(x-x_C)(y_A-y_C)+(y-y_C)(x_A-x_C)}{-(x_B-x_C)(y_A-y_C)+(y_B-y_C)(x_A-x_C)}, \\ \gamma & = 1-\alpha-\beta. \end{align} αβγ=(xAxB)(yCyB)+(yAyB)(xCxB)(xxB)(yCyB)+(yyB)(xCxB),=(xBxC)(yAyC)+(yByC)(xAxC)(xxC)(yAyC)+(yyC)(xAxC),=1αβ.

假设在三个顶点处有数据值 V A V_A VA V B V_B VB V C V_C VC(可以是位置坐标、纹理坐标、颜色、法向量、深度、材质属性等),则三角形内任意一点的数据值 V = α V A + β V B + γ V C V=\alpha V_A+\beta V_B +\gamma V_C V=αVA+βVB+γVC

对于空间中的 △ A B C \bigtriangleup ABC ABC,若已知点 P P P △ A B C \bigtriangleup ABC ABC所在的平面上,则同样成立 P = α A + β B + γ C P=\alpha A+\beta B+\gamma C P=αA+βB+γC,且重心坐标 ( α , β , γ ) (\alpha,\beta,\gamma) (α,β,γ)同样满足上述公式。也就是说,我们可以完全忽略 P P P A A A B B B C C C的z坐标,而像在平面上一样只考虑x、y坐标,这等价于把 P P P △ A B C \bigtriangleup ABC ABC直接正交投影到xy平面上。

重心坐标在仿射变换(无论是二维还是三维)和正交投影下不变,但需要注意的是,透视投影不保持重心坐标。因此,若要求屏幕上一个三角形内的一个点的实际的重心坐标,我们不可以直接对屏幕上的三角形和点运用重心坐标公式,而是应该先求出该点的深度,再进行透视投影的逆变换(参见本专栏的第一篇文章),最后对相机空间中的原三角形和点运用式 ( 2 ) − ( 4 ) (2)-(4) (2)(4)。然而毫无疑问这样计算量非常大。有一个更简便的公式:假设我们已经按照前面的公式求出平面上 ( x , y ) (x,y) (x,y)的重心坐标 ( α , β , γ ) (\alpha,\beta,\gamma) (α,β,γ),则 ( x , y ) (x,y) (x,y)对应的点在相机空间中的z坐标 z o r i g z^{orig} zorig满足如下关系式: 1 z o r i g = 1 z A o r i g α + 1 z B o r i g β + 1 z C o r i g γ . \begin{align}\frac{1}{z^{orig}}=\frac{1}{z_A^{orig}}\alpha+\frac{1}{z_B^{orig}}\beta+\frac{1}{z_C^{orig}}\gamma.\end{align} zorig1=zAorig1α+zBorig1β+zCorig1γ.

其中, z A o r i g z_A^{orig} zAorig z B o r i g z_B^{orig} zBorig z C o r i g z_C^{orig} zCorig为原三角形三个顶点的z坐标(相机坐标),这是已知的。

对于任意数据值 V V V,其正确的插值需要用到 z o r i g z^{orig} zorig V = z o r i g ( α z A o r i g V A + β z B o r i g V B + γ z C o r i g V C ) . \begin{align}V=z^{orig}\Big( \frac{\alpha}{z_A^{orig}}V_A+\frac{\beta}{z_B^{orig}}V_B+\frac{\gamma}{z_C^{orig}}V_C \Big).\end{align} V=zorig(zAorigαVA+zBorigβVB+zCorigγVC).

也就是说,经过透视投影修正后,正确的重心坐标为 ( z o r i g z A o r i g α , z o r i g z B o r i g β , z o r i g z C o r i g γ ) (\dfrac{z^{orig}}{z_A^{orig}}\alpha,\dfrac{z^{orig}}{z_B^{orig}}\beta,\dfrac{z^{orig}}{z_C^{orig}}\gamma) (zAorigzorigα,zBorigzorigβ,zCorigzorigγ)

关于式 ( 5 ) (5) (5) ( 6 ) (6) (6)的推导,参见[3]。如果愿意读英文,也可以参考[2]的P998-1001,但那里的写法不甚直观。

图形管线

图形管线(graphics pipeline)(具体来说,这里我们讨论的是实时渲染管线)是指从构建三维场景到最终渲染出一张图呈现到屏幕上中间经历的全部操作过程,按先后顺序(下图从上到下),包括顶点处理、三角形处理、光栅化、片断(指每一个采样结果应用的屏幕区域,不做超采样时可认为是指像素)处理和帧缓存操作。

在这里插入图片描述

着色过程发生在顶点处理和片断处理中,例如如果是Gouraud着色,则发生在顶点处理中;如果是Phong着色,则是片断处理。在处理过程中,对于每个顶点或片断,将运行一段(通用的)程序,其决定了对于这个顶点或片断应如何着色。这段程序被称为着色器(shader)。

这里列几个与着色器有关的网站,其中第一个是GAMES101里推荐的,其他的是本人的一个朋友推荐的。因为时间关系本人目前都还没有研究,故质量可能参差不齐,仅供参考。

(1)http://shadertoy.com/view/ld3Gz2
(2)https://gpuopen.com
(3)https://www.adriancourreges.com/blog
(4)http://blog.hvidtfeldts.net/index.php/about
(5)https://mamoniem.com
(6)https://shader-playground.timjones.io
(7)https://microsoft.github.io/DirectX-Specs

纹理映射

我们可以将一张二维的图片贴到一个三维模型上,使之呈现不同的外观。这样的图片就叫做纹理图片(texture map/image),或纹理(texture)。纹理没有改变三维模型的形状

纹理有什么作用?试想,我们在实际的场景中往往需要展示很多具有复杂三维几何结构的物体,如人体皮肤的褶皱等。如何实现它?以人脸皮肤为例,我们当然可以精确地把每一个皱纹设计出来,但是这样往往增加了要处理的三角形数量,降低效率。如果我们能先设计出一张二维的人脸贴图(即纹理)出来,在上面“仿真”出具有三维结构的褶皱,使得其看起来具有立体感(尽管实际上并非立体的),然后贴到结构较为简单的人脸胚子(想想服装店石膏模特的脸)上,使之看起来像拥有了皱纹。这样虽然凑近看还是会露馅,但对于大多数情况,既还算真实,又运行得快。因此,纹理是降低成本、提高效率的重要工具。

纹理贴图所在的二维空间被称为纹理空间(texture space),从世界空间到纹理空间的映射被称为纹理映射(texture mapping)。纹理贴图上的每一个像素被称为纹素(texel),注意其与屏幕“像素”的区分。

在这里插入图片描述

纹理可以在一个三维物体上重复应用多次,例如一张砖头的纹理图可以反复应用于一面砖头墙上,每个砖头都贴一次。

在这里插入图片描述

纹理如何应用于三维物体上呢?考虑三维物体中的每一个三角形,其三个顶点通过纹理映射被映射到纹理上的一个坐标,这个坐标具体是什么由设计师决定。对于三角形中的任意一个点,通过计算重心坐标,我们可以获得其在纹理空间中的坐标(称为纹理坐标),从而知道它所对应的纹理颜色是什么。由此可见如果使用了纹理,着色过程一般发生在片断处理中,即对每个像素都进行一次着色。

但实现起来有一个细节问题:如何通过纹理坐标确定对应的纹理颜色呢?有一个很自然的办法:我们定义离一个纹理坐标最近的纹素为纹素中心离该坐标直线距离最短的纹素。那么,我们只需取用离该坐标最近的纹素的颜色即可。这个方法称为最近邻插值

回顾Bling-Phong反射模型公式 ( 1 ) (1) (1),在应用纹理的情况下,式中的漫反射系数 k d k_d kd取为该坐标对应的纹理颜色。

因为我们是在每个像素处都进行着色,所以以上过程在深度缓存代码中调用。如果当前三角形在这个像素处能被看到(深度最小),则对这个像素执行着色操作。伪代码:

for (each triangle T)
    for (each pixel (x, y) on screen)
        if (inside(x + 0.5, y + 0.5)) //如果像素中心在三角形内。inside函数的实现见上一篇文章
            calculate barycentric coordinates of (x + 0.5, y + 0.5) with respect to T
            //利用式(2)-(4)计算平面重心坐标
            calculate z such that (x + 0.5, y + 0.5, z) is on T //利用重心坐标和式(5)
            
            if (z < zbuffer[x, y]) //深度缓存
            zbuffer[x, y] = z //更新深度图中该像素处的深度
            calculate texture coordinates (u, v) //计算像素中心在纹理上对应的坐标,同样利用重心坐标
            kd = getcolor(u, v) //将该点的纹理颜色赋给漫反射系数
            calculate color using Bling-Phong reflectance model
            framebuffer[x, y] = color

现在我们已经解决了如何将纹理贴到模型上这个问题,但还有另外一些问题:很多时候,纹理的大小与实际模型的大小并不契合。纹理相对于模型可能过小(贴图纹素过少)或过大(贴图纹素过多)。当然,屏幕的分辨率和组成模型的三角形与相机镜头的位置关系(近大远小、正视斜视)也可能影响贴图效果,这种问题在光栅化的过程中产生。如果我们把这些问题总结一下,就会发现本质的问题是:当屏幕空间的坐标移动一个像素的距离时,对应纹理空间的纹理坐标并没有恰好移动一个纹素的距离。换一种说法,即是一个像素区域被映射到纹理上后覆盖了远大于/远小于一个纹素。为了叙述简便,下面以纹理过小与过大两个方面为基础进行讨论。

纹理过小

纹理过小会造成什么结果?

在模型上移动一定的距离,其纹理坐标只会发生很小的变化(因为纹理必须“放大”才能适应模型的尺寸),因此造成的结果和将一张图像放大的结果类似——肉眼可见的分辨率不足。如果我们使用最近邻插值,那么在模型上相当大的一片区域内,离其纹理坐标最近的纹素都是同一个,因而将显示同一个颜色。因此,我们必须改进插值算法,使其即使在上述情况下颜色也能平滑变化。

常用的插值算法有双线性插值(bilinear interpolation)和双三次插值(bicubic interpolation)。

在这里插入图片描述

双线性插值的基本思想是取离纹理坐标最近的四个纹素,将它们的颜色进行某种加权平均后赋给该坐标。

在进一步介绍“双线性插值”之前,我们先介绍所谓的“线性插值”。这个插值是在一维空间上进行的,设要插值的数据是 V V V,坐标0处的数据为 V 0 V_0 V0,坐标1处的数据为 V 1 V_1 V1,则 ∀   x ∈ [ 0 , 1 ] \forall \, x \in [0,1] x[0,1] V x = l e r p ( x , V 0 , V 1 ) : = V 0 + x ( V 1 − V 0 ) . V_x=\mathrm{lerp}(x,V_0,V_1):=V_0+x(V_1-V_0). Vx=lerp(x,V0,V1):=V0+x(V1V0).

假设我们现在要对函数 f f f进行双线性插值,求出 f ( x , y ) f(x,y) f(x,y),其中 ( x , y ) ∈ R 2 (x,y)\in \mathbb{R}^2 (x,y)R2。先求出离 ( x , y ) (x,y) (x,y)最近的四个纹素,设它们中心的坐标为 ( x i j , y i j ) (x_{ij},y_{ij}) (xij,yij),函数值为 u i j u_{ij} uij i , j ∈ { 0 , 1 } i,j\in \{0,1\} i,j{0,1},如图:

在这里插入图片描述

s : = x − x 00 s:=x-x_{00} s:=xx00 t : = y − y 00 t:=y-y_{00} t:=yy00(使x、y坐标均平移至[0,1]内), u 0 : = l e r p ( s , u 00 , u 10 ) u_0:=\mathrm{lerp}(s,u_{00},u_{10}) u0:=lerp(s,u00,u10) u 1 : = l e r p ( s , u 01 , u 11 ) u_1:=\mathrm{lerp}(s,u_{01},u_{11}) u1:=lerp(s,u01,u11),则 f ( x , y ) = l e r p ( t , u 0 , u 1 ) . f(x,y)=\mathrm{lerp}(t,u_0,u_1). f(x,y)=lerp(t,u0,u1).

在这里插入图片描述

双三次插值的算法比较复杂,这里不对其进行讨论,感兴趣的读者可以参见[4]。这个算法的效果更好,但速度更慢。在实际情况下,要对三种插值算法进行取舍。

纹理过大

纹理过小会造成什么结果?

在模型上移动一定的距离,其纹理坐标会发生很大的变化(因为纹理必须“放大”才能适应模型的尺寸),因此造成的结果和将一张图像缩小的结果类似——许多信息丢失。从信号处理的角度来说,纹理过小会导致对纹理图片采样的频率远远小于图片信号变化的频率,因而会产生走样现象(参见本专栏的第二篇文章):

在这里插入图片描述

在上方的图片中,从下到上,根据近大远小的法则,每个像素覆盖的模型区域面积越来越大,对应地,覆盖的纹理空间的区域也越来越大,导致采样点越来越稀疏:

在这里插入图片描述

解决这个问题一个自然的方法就是进行反走样,可以运用MSAA(超采样)等算法。不过这个算法要花费数倍于原来的时间,我们来介绍一个更高效的算法——mipmap。这种算法允许快速地、近似地范围查询纹理上某片区域的平均颜色。

Mipmap的结构类似于金字塔。先上直观理解:

在这里插入图片描述

在这里插入图片描述

设原图的大小为 N × N N \times N N×N,在着色之前,预先归纳地生成以下图像金字塔:第0层为原图,第1层为 N 2 × N 2 \dfrac{N}{2} \times \dfrac{N}{2} 2N×2N大小的图片,每个像素为它对应的第0层的4个像素的颜色的平均值……第k+1层的长宽为第k层的一半( N 2 k + 1 × N 2 k + 1 \dfrac{N}{2^{k+1}} \times \dfrac{N}{2^{k+1}} 2k+1N×2k+1N),每个像素为对应第k层的4个像素的颜色均值(第0层 4 k + 1 4^{k+1} 4k+1个像素的颜色均值)。于是,生成了约 l o g 2 N \mathrm{log}_2N log2N张图片,然而只多占用了不到 1 3 \dfrac{1}{3} 31的存储空间。

我们的目标是,对于屏幕上的每一个像素,快速、且较准确(走样程度较低)地查询它所应该显示的颜色。因此,我们得先知道这个像素被映射到纹理空间上的哪个区域。我们有一个简便的近似方法:假设现在要对中心位于 ( x , y ) 00 (x,y)_{00} (x,y)00的像素进行查询,我们考虑位于其上和其右的两个像素,中心坐标分别为 ( x , y + 1 ) = : ( x , y ) 01 (x,y+1)=:(x,y)_{01} (x,y+1)=:(x,y)01 ( x + 1 , y ) = : ( x , y ) 10 (x+1,y)=:(x,y)_{10} (x+1,y)=:(x,y)10,获取三个像素中心位于纹理空间的坐标 ( u , v ) 00 (u,v)_{00} (u,v)00 ( u , v ) 01 (u,v)_{01} (u,v)01 ( u , v ) 10 (u,v)_{10} (u,v)10。然后令 L : = m a x ( ∥ ( u , v ) 01 − ( u , v ) 00 ∥ , ∥ ( u , v ) 10 − ( u , v ) 00 ∥ ) , L:=\mathrm{max}\big(\left \| (u,v)_{01}-(u,v)_{00} \right \|,\left \| (u,v)_{10}-(u,v)_{00} \right \| \big), L:=max((u,v)01(u,v)00,(u,v)10(u,v)00),

则我们认为该像素区域 [ x 00 − 0.5 , x 00 + 0.5 ] × [ y 00 − 0.5 , y 00 + 0.5 ] [x_{00}-0.5,x_{00}+0.5]\times [y_{00}-0.5,y_{00}+0.5] [x000.5,x00+0.5]×[y000.5,y00+0.5]被映射到纹理空间中的区域 S : = [ u 00 − L 2 , u 00 + L 2 ] × [ v 00 − L 2 , v 00 + L 2 ] S:=[u_{00}-\dfrac{L}{2},u_{00}+\dfrac{L}{2}]\times [v_{00}-\dfrac{L}{2},v_{00}+\dfrac{L}{2}] S:=[u002L,u00+2L]×[v002L,v00+2L]

在这里插入图片描述

(上图中, d u = u 10 − u 00 \mathrm{d}u=u_{10}-u_{00} du=u10u00 d v = v 10 − v 00 \mathrm{d}v=v_{10}-v_{00} dv=v10v00 d x = y 10 − y 00 = 1 \mathrm{d}x=y_{10}-y_{00}=1 dx=y10y00=1

因此,我们在mipmap上要查询的区域就是 S S S,它的边长为 L L L,故在mipmap中的层数为 D 0 : = l o g 2 L D_0:=\mathrm{log}_2L D0:=log2L

读者此时肯定已经发现了至少两个问题:(1)我们在mipmap中只维护了非负整数层的数据,而 D 0 D_0 D0可能不是整数;(2)我们在mipmap中只维护了特定区域内的颜色的平均值(参考上方的瓢虫图),而 S S S可能不属于这些特定区域。如何解决?前面的双线性插值算法能给我们启示——这一次,我们使用三线性插值(trilinear interpolation)。

假设 D 0 D_0 D0位于第 D D D层和第 D + 1 D+1 D+1层之间,即 D = [ D 0 ] D=[D_0] D=[D0]。我们将第 D D D层的图片放大至原来的 2 D 2^D 2D倍,第 D + 1 D+1 D+1层的图片放大至原来的 2 D + 1 2^{D+1} 2D+1倍(使得它们的大小都变为纹理原本的大小 N × N N\times N N×N)。然后,分别在第 D D D层和第 D + 1 D+1 D+1层取离 ( u , v ) 00 (u,v)_{00} (u,v)00最近的四个点,分别做双线性插值,获得两个颜色,再在垂直方向(层数方向)做第三次线性插值,就能获得这个像素应该显示的颜色。

在这里插入图片描述

当然实际上你根本无需将纹理放大,而是对应地缩小点的坐标,得到 1 2 D ( u , v ) 00 \dfrac{1}{2^D}(u,v)_{00} 2D1(u,v)00 1 2 D + 1 ( u , v ) 00 \dfrac{1}{2^{D+1}}(u,v)_{00} 2D+11(u,v)00,二者是等价的。这种操作的另一个好处是你无需修改上方定义的 l e r p \mathrm{lerp} lerp函数即可正确实现线性插值。上图只是为了形象表示而已。

效果图:

在这里插入图片描述

可以看见远处的网格过模糊了——怎么还有问题?!没办法,冷静下来再分析一下:回忆mipmap只能对正方形区域进行查询,但把屏幕上的像素映射到纹理上时可能不是近似正方形,而可能会是正着或斜着的近似矩形等等。这时候mipmap查询效果就不好,因为像这样的矩形它的正方形“包围盒”可能很大,包围盒内的颜色均值和矩形内的颜色均值可能相去甚远。

在这里插入图片描述

对于四边近似地与坐标轴平行,但宽高差距较大的(近似)矩形,我们可以使用各向异性过滤(anisotropic filtering)算法。读者初次接触“各向异性”这个词时可能是在高中化学学习晶体时,其实这个词在这里依然可以顾名思义:mipmap中,我们维护的是正方形区域内的颜色均值,而各向异性过滤中,我们同时维护长方形区域内的颜色均值。怎么实现?我们可以对纹理在水平或竖直方向上压缩到原来的 1 2 N \dfrac{1}{2^N} 2N1,得到下面的图片。

在这里插入图片描述

其中,左上角是原图。原本的mipmap只生成了对角线上的图片。如果我们想要查询原图中一个宽度小于高度的矩形,就可以在上图的对角线下方查询一个正方形区域;如果要查询原图中一个宽度大于高度的矩形,就在上图的对角线上方查询一个正方形区域。在实际应用(特别是实时渲染)中,这些经过水平或竖直方向压缩的图片都是实时计算出来的,以节省显存。

那如果对应的区域是斜着的图形怎么办?此时各向异性过滤效果也不好。此时我们可以使用EWA滤波(EWA filtering, elliptically weighted average filtering)算法。事实上,这个算法被广泛认为是纹理滤波算法中最好的。若要详细了解该算法,参见[5]

纹理的其他应用

格局打开,我们完全可以将纹理的概念推广:谁说纹理只能是一张图片、每个纹素储存的是一个RGB值的?它完全可以是一个一般意义下的数据库,每个纹素储存某个数据,需要的时候通过某种映射确定纹理坐标,即可获取对应位置的数据值。

在现代GPU中,纹理=一块内存(存储数据)+对其进行范围查询功能(滤波)。纹理上存储的数据不仅仅可以是颜色,还可以是法向量、位移等等。下面我们就两个例子阐述纹理的更多应用。

凹凸贴图

如果我们想让一个物体表面显得凹凸不平,怎么做?

暴力但最正确的办法当然是对于每一个凹凸处,制作更多的三角形以表示其。但这样开销过大。事实上,我们不需要真正地做出凹凸不平的物体,而只需让它们看起来像是凹凸不平的。什么意思呢?我们知道,我们认为一个物体表面不光滑,是通过光在物体上的反射情况判断的,而这与入射点的法向量密切相关。因此,我们可以对法向量进行微小的扰动,而这可以通过对每个点施加相对邻近点微小的高度变化(实则没有真的发生变化)实现。这种存储相对位移的纹理被称为凹凸贴图(bump mapping)。注意因为这种变化是虚假的,只发生在着色过程中,所以它增加了表面细节的同时并没有引入更多的三角形。

在这里插入图片描述

上图是效果图。可以看出凹凸贴图的确是一种“幻象”:球体的轮廓和阴影仍是光滑,不过很多时候以假乱真已经够了。

位移贴图

位移贴图(displacement mapping)也是让物体表面变得凹凸不平的方法,与凹凸贴图相比,它真正地改变了物体表面点的位置,因而效果更好,代价当然是计算开销更大(注意这种方法只是在着色过程中临时对点的位置进行了改变,同样没有引入更多三角形)。

在这里插入图片描述

这两个例子我都不给出详细的公式,一方面没时间,第二是PPT上的公式是不完善的,最重要的是,这些映射的具体实现方法都在GAMES101的作业3中给出了,大家去查看源代码即可。你无需安装虚拟机来实现它们,只需下载作业3的源代码文件就可以了。下载地址:http://www.smartchair.org/f_/GAMES101-Spring2021/3cb39/F3/GAMES101_Homework3_S2021.zip

阴影处理

前面说了,着色过程(属于光栅化的一部分)是局部的,只考虑光线在物体表面一个点处的变化,因此不会生成任何阴影。但这不意味着光栅化无法生成任何阴影。事实上,对于光源是点光源的情况,我们有所谓的阴影映射(shadow mapping)算法来生成阴影。

阴影映射算法的核心思想是不在阴影里的点必须既可被相机看到,又可被光源“看到”,也就是说相机-物体之间无遮挡、且光源-物体之间无遮挡,因此,我们只需“看两次”、使用两个深度缓存(相关内容见本专栏的第二篇文章)即可。

具体来说,阴影映射分为三步:

(1)光源视角的渲染。把相机的位置“切换”到光源处,然后进行光栅化。但这次光栅化我们无需生成具体的场景图片(即帧缓存),而只需生成深度图,因为我们只关心一个点在光源视角下是否被遮挡,而不关心它从这个视角看过去是什么颜色;

在这里插入图片描述

(2)相机视角的渲染。这次就是正常的光栅化,但我们同样只关注深度图;

在这里插入图片描述

(3)对于深度图中的每一点,利用重心坐标计算出它对应的相机坐标(也就是相机看到的物体上点的坐标),然后将其变换到光源视角的坐标,之后再做透视投影,获取其在光源屏幕上的坐标(也就是第一步生成的深度图中的坐标和该点的深度)。比较该点的深度 z ′ z' z与深度图中存储的深度的大小 z z z关系,如果 z ′ = z z'=z z=z,则该点与光源之间无遮挡,该点可以被光源“看到”,因此不在阴影中;

在这里插入图片描述

如果 z ′ > z z'>z z>z,则该点被遮挡,位于阴影中。

在这里插入图片描述

现在假设我们要生成如下图片:

在这里插入图片描述

我们计算光源视角下的深度图:

在这里插入图片描述

在下方图片中,绿色部分代表 z ′ ≈ z z' \approx z zz(约等的原因马上会讲),不在阴影中;非绿色的部分代表 z ′ > z z'>z z>z,在阴影中:

在这里插入图片描述

可以看到图像非常“脏”,其一大原因是浮点数( z ′ z' z z z z)很难判断相等。一个解决方案是将相等的条件 z ′ = z z'=z z=z改为 z ′ < z + ε z'<z+\varepsilon z<z+ε,这一定程度上能改善效果,但不能完全解决问题。

最后,关于这个算法,有几点说明:

(1)这是一个图像空间的算法,也就是说,它同样只需考虑对于每一个点,光线在相机-点-光源三点两线之间的传播,而无需知道物体具体的几何结构;

(2)该算法会产生走样现象,需进行反走样处理;

(3)该算法只会产生硬阴影(即阴影边缘颜色是瞬变的,而非柔和渐变的),只能处理光源是点光源的情况;

(4)从前面的图片可以看出,阴影的质量取决于阴影映射的分辨率,并且由于浮点数难以判断相等等诸多困难,图像经常有很多噪点。

由此可见阴影映射虽能确实可以有效地生成阴影,但仍有很多局限。阴影生成的问题使用路径追踪算法可以彻底地解决,见“光线追踪”一节。

  • 7
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值