# Shading(着色)

image-20220923100511959

image-20220923100534976

image-20220923100602460

当我们把所有的三角形都画在了屏幕上之后,都变成了不同的像素之后,这些像素的颜色都应该是什么呢?这便是着色的内容。

# Shading:Definition(着色的定义)

image-20220923101013281

# A Simple Shading Model (Blinn-Phong Reflectance Model)

image-20220927160935474

我们先来看这几个茶杯,可以看出光源应该是在右上方,茶杯上用箭头指出的几个区域,分别是高光、漫反射和环境光。高光产生的原因是由于茶杯表面较为光滑所以光线朝着镜面反射方向附近反射。而对于墙面这种粗糙的表面就很容易产生漫反射。而环境光则是通过别的物体反射接受到的光照,属于间接光照。

image-20220927163231974

我们现在考虑的光照是在任意一个点上,这里我们认为,在一个极小的平面上,shading point 可以认为是一个平面。于是我们就可以定义法线方向、观察方向以及光照方向,并且我们把这个向量定义为单位向量。此外,我们还需要定义 shading point 的颜色和亮度。

image-20220927164157476

这里的 Shading is Local 指的是,这里考虑的着色点的情况只看这个点自己,顶多只看光照和观测的方向,而不考虑其他物体的存在,所以从上图我们可以看到,考虑了作色之后明暗变化没有问题,但是我们看不到阴影,阴影的处理我们后面再说。

# Diffuse Reflection(漫反射)

image-20220927164846740

当一根光线打到一个点时,这个光线会被均匀的反射到各个方向上去,这个就叫做漫反射。

image-20220927165232551

当物体 shading point 表面的朝向和光线有一定的夹角的时候,我们会发现得到的明暗是不一样的,也就是说物体表面的法线和光线方向的夹角决定了这个物体的表面有多么亮。一个更科学的理解是,我们考虑光是一种能量,那么我们考虑一个 shading point 的单位面积能收到多少能量。这里可以参考我们为什么会有四季之分,实际上就是光线和我们所处地球位置的表面它的夹角不同所造成的。这也就解释了为什么南半球是夏天的时候北半球是冬天。那么我们在考虑 shading point 它周围的单位面积所能接收到多少能量,那么它自然是和光线角度成一定关系的,这个角度能接受到多少能量我们有兰伯特余弦定律。这个定律表明物体表面接受到的能量和光照方向和物体表面的法线方向之间夹角的余弦成正比。

image-20220927171439323

我们假设光来自于一个点光源,这个点光源无时无刻都在向四面八方辐射出不同的能量。我们有一个很聪明的观测方法,就是我们认为在任何一个时刻,这个点光源往四面八方辐射出的能量一定都集中在一个球壳上。根据能量守恒定律,考虑它在真空中进行传播没有任何的能量损失,那么在离光源近的球壳的能量应该等于离光源远的球壳的能量,由于远近球壳的表面积并不相同,假设在距离光源为单位 1 的地方的光的强度为II,那么在远处距离光源 r 处球壳上一点的光的强度则为I/r2I/r^2。这个告诉了我们,光线在传播的过程中,如果我们考虑单位面积在任何一个位置上所能接收到的能量是和光线传播的距离是成平方反比的。

image-20220927173758674

于是,我们便可以得到 Diffuse 的表示方法了。假设,我们有一个点光源,这个点光源距离我们的 shading point 有一定的距离,这个距离表示为 r,那么我们便可以得到到达这个点的能量,我们又知道到达这个点能量会被物体表面接收,而且接受多少取决于我们的 Lambert's cosine law。于是明暗我们便可以算出来了。如果考虑 shading point 本身有颜色来说,那么就说明这个点会吸收一部分能量,然后它反射出去的就是它不吸收的能量。如果对于不同的点有不同的吸收率,那么我们得到的结果自然就会产生不同的颜色,特别是对不同波长所产生的颜色,所以这里有一个漫反射系数kdk_d。如果这个系数是 1,那么说明所有的能量都被反射出去了,那么这个点就是最亮的;如果这个系数是 0,那么说明所有的能量都被吸收了,那么这个点就是黑的。这里的kdk_d 表示的就是这个点它本身吸收了多少能量。如果我们把kdk_d 表示成一个向量,表示成一个 RGB 三通道的一个值,分别都是 0~1 之间,那么我们在这个 shading point 上就可以定义出一种颜色了。

由于漫反射,能量会被均匀的反射到各个方向上去,那么就意味着我们不管从哪观察它,我们得到的结果都应该是一模一样的。也就是说漫反射和我们观察的方向完全没有关系,从上面的式子来看我们发现也是如此。

image-20220928100323841

# Specular Term(高光反射)

image-20220928101008247

现在,我么要把高光项给加进去。首先,我们想想什么是高光?在什么情况下能看到高光?比较光滑的物体它们的反射都有一个特性,它们反射的方向都比较接近镜面反射的方向,所以在镜面反射的方向上会有一个分布,当我们的观察方向和镜面反射的方向比较接近的时候我们就能看到高光。这种模型也被称为 Phong 模型。

image-20220928101536333

Blinn-Phong 模型发现,当我们的观察方向和镜面反射方向相近的时候,其实就说明了法线方向和半程向量很接近。半程向量的方向也就是 v 和 l 的角平分线的方向,是一个单位向量。我们为了衡量是否能够看到高光,只需要衡量半程向量和法线是不是足够的接近就可以了。注意,这个地方并没有考虑有多少能量被 shading point 吸收了。这里没有考虑是因为 Blinn-Phong 是经验模型,把这一点给简化掉了。仔细发现,我们会发现 max 函数有一个指数 p 在上面,我们根据下面这张图来分析。

image-20220928102723768

向量夹角的余弦确实能判断两个夹角是否足够接近,但是它的容忍度太高了。我们平时看到的高光都是集中在一个很小的区域里面的,也就是两个向量的方向只要离开了稍微远一点就不算是在高光点里了。于是我们在余弦函数上加上若干的指数以此来得到一个合理的效果。正常情况下,在 Blinn-Phong 模型里,我们会用 100~200 的一个指数。

image-20220928165011882

这张图我们看到了漫反射和高光一起形成的一个矩阵。我们会发现,当镜面反射系数ksk_s 在逐渐增大的时候,亮度就越来越大。而随着指数 p 增大,高光的区域变得越来越小。

# Ambient Term(环境光)

image-20220928170328395

最后一项就是环境光照。我们之前看到的茶杯,有些地方并不是被直接照亮的,但是它却不是完全是暗的,这是因为有很多光线它可以从任意方向打到任何一个点上,所以对茶杯背面的一些点它同样可以接受到来自环境的光。由于这个计算太过复杂,这里便做了一个大胆的假设,我们认为任何一个点接受的来自环境中的光永远都是相同的,而这个强度叫做IaI_a,任何一个点有它自己的颜色,用环境光系数kak_a 来表示,把这两项结合到一块,我们便得到了一个近似的环境光。环境光它和直接光照的方向、观察方向以及法线方向都没有关系,所以环境光是一个常数。环境光的作用就是保证没有一个地方是黑的,给物体提升了一个亮度。事实上,如果想要精确的计算环境光,我们要应用到全局光照的知识,我们在后面的章节中进行讲解。

image-20220928171539272

现在,我们把所有的项都加起来,便可以得到最右侧这张图的结果。这便是我们所说的 Blinn-Phong 反射模型。

# Shading Frequencies(着色频率)

image-20220928172048529

这三个球的模型完全一样,为什么着色了之后它们的结果各不相同呢?这便引出了我们对着色频率的探讨。从左到右的三个球,它们的着色分别应用到了面、顶点以及像素上。

image-20220928172440050

Flat Shading:每个三角形都是一个平面,求出它的法线(通过叉积),用这个法线求出整个面 Shading 的一个结果。三角形内部不会有任何着色的变化。

image-20220928172503330

Gouraud Shading:求出每个顶点的法线,根据每个顶点的法线做以此着色,再将三角形内部的颜色通过插值来算出来。结果比上一个好。

image-20220928172534073

Phong Shading:先求出来顶点各自的法线,然后在三角形内部插值出每个像素的法线方向,在对每个像素做一次着色,那么便可以得到一个相对上面比较好的一个结果。注意,这里需要区分一下概念,我们之前提高过 Blinn-Phong 反射模型,这是一种着色模型。而这里的 Phong Shading 指的是一种着色频率。

image-20220928173846500

这三种具体有什么区别取决于具体的模型,当几何本身已经足够复杂的情况下,这时使用一些相对简单的着色模型得到的效果就已经挺好了。也就是说着色频率它取决于面、顶点或者像素它们本身出现的频率,当模型本身的面数已经足够高的时候,就不再需要去用复杂的逐像素着色,不过逐像素做的工作量也不一定比逐表面的要大,如果三角形的面数已经超过了像素数,这时候反而做 Flat Shading 的计算量要大了。所以这三种着色频率的选择要根据具体物体而定。

image-20220928225525366

现在,我们回到之前留下的问题。我们怎么样才能求出逐顶点的法线呢?先假设我们想表示出一个球,那么每个顶点的法线自然就是从球心向各顶点连出的那些向量。但我们平常并不会遇到这么好的事情。于是,人们发明了一种办法。任何一个顶点一定会和很多个三角形关联,那么我们就认为这个顶点的法线就是其相邻面的法线所求的平均。那么我们会想,是不是更大的三角形它会贡献的更多?是的,这里会做一个加权的平均,我们得到的效果会更好。

image-20220928230641729

那么我们又该如何定义逐像素的法线?假如我们已经知道三角形各顶点的法线是什么了,那么如何得到三角形内部平滑过渡的法线呢?这就要使用到所谓的重心坐标。这里记得求出来的法线要归一化。

# Graphics (Real-time Rendering) Pipeline(图形管线)

image-20220928231224594

图形管线说明了场景中的物体到最后生成的一张图所经历的一个过程,这个过程就叫做 Pipeline(管线)。从一开始输入是空间中一堆的点,经过顶点处理后会把这些点投影到屏幕上,然后这些点它们会形成三角形,之后再通过光栅化把它们离散成 Fragment,之后在对每一个 Fragment 进行着色,我们就知道了每个像素应该是个什么颜色,如果用了 MSAA,那么就是好多个 Fragment 会形成一个像素的颜色。最后我们就便得到整个屏幕是一个什么样的画面。这个过程就是我们处理三维场景到最后的一幅图的基本操作,这些操作都是已经在硬件里面写好了的,在 GPU 中进行。

image-20220928232459206

image-20220928232627193

image-20220928232656237

image-20220928232713179

这里 Sahding 会发现在 Vertex Processing 和 Fragment Processing 中都会发生,这是为什么呢?这里要考虑着色的频率,如果我们做的是 Gouraud Shading,那么着色就可以发生在顶点的处理上,如果做的是 Phong Shading,那么自然要等到所有的像素都产生了再在片元处理里面做。如果要做着色,重要的是选择是在顶点中着色还是在片元中着色,在现代的 GPU 的渲染管线中,它是允许某些部分是可编程的。这里的顶点处理和片元处理都是可编程的,这部分的代码我们称之为 Shader。

image-20220928232735981

这部分涉及到纹理映射,我们之后再说。

image-20220929094428302

在 Shader 里只需要考虑一个顶点或者一个片元如何处理即可,之后各个顶点和片元都会按照程序去执行。

image-20220929095515593

image-20220929095705040

image-20220929100030974

image-20220929100224205

随着 GPU 的发展,有越来越多不同类型的着色器产生。例如 Geometry Shader,可以动态的产生很多三角形。还有 Cumpute Shader,可以做任何形式的计算,不仅能做图形学内部的一些计算,还可以完成通用的 GPU 计算,即 General Purpose GPU(GPGPU)。

image-20220929100255409

GPU 核心的数量,即可以并行的线程的数量。GPU 的并行能力是非常惊人的,所以特别适合用于图形学的一些计算。很多的像素它们的着色方法都是一样的,所以它们执行的代码也基本相同,这也是为什么它们适合用于并行计算的原因。

# Texture Mapping(纹理映射)

image-20220929102653096

观察这个球,我们发现不同的位置有不同的颜色,它们其实共用了同一个模型,只不过在不同的位置上它们的漫反射系数法神了改变。我们希望定义在物体的不同位置定义一个不同的属性,这里就是我们引入纹理映射的一个思路。

image-20220929103102528

怎么定义物体上任意一个点它的基本属性呢?首先,我们知道这个点是定义在物体表面上的,我们还要考虑物体表面上的一个点应该是什么颜色,因为我们要做着色。其实,任何一个三维物体表面都是二维的。看到上面这个地球仪,我们发现物体的表面可以和一张图有一个一一对应的关系,这里我们就定义了纹理,纹理就是一张图。这张图我们可以使用其中的一块或者把它压缩拉伸,认为它是一张有弹性的一个图,然后再把它蒙在一个三维物体的表面,这个过程就叫做纹理映射。

image-20220929103848768

根据我们刚才的思路,要想让三维模型上把一张图贴上去,我们就要把三维模型上的三角形顶点的位置给映射到纹理上三角形顶点的位置。怎么把空间中的三角形映射到纹理上,这里不作讨论。这里我们认为已经知道了这个映射关系。

image-20220929104606248

我们既然提到了纹理上的坐标,那么我们就应该在纹理上定义一个坐标系,这个坐标系上大家通常会用 UV 来表示纹理上的一个坐标点。UV 的范围对于一张纹理来说,不管纹理的长宽比是多少,都认为纹理的一个范围是 U 在 0~1 之间,V 也在 0~1 之间。

image-20220929105205787

image-20220929105322205

image-20220929105345377

纹理可以应用在各种不同的物体表面,上图中便将同一个纹理应用了多次,但是纹理和纹理之间应该是有条缝的,但是实际效果却看不出来。

image-20220929105411508

这说明纹理的设计也很重要,纹理在向上下作用重复的时候应该能无缝衔接,也就是说纹理的右边会自然而然的接上纹理的左边,上下同理。这种纹理也被称之为 tileable texture。这种纹理的设计也存在各种各样的算法。现在,我们知道了三角形三个顶点对应的纹理坐标 u 和 v,那我们如何知道三角形内部任何一个点所对应的 uv 坐标呢?这也引出了接下来我们要讲的重心坐标。

# Interpolation Across Triangles:Barycentric Coordinates(重心坐标)

image-20220929111340467

我们使用重心坐标的目的,主要是为了能在三角形内部进行插值。不过为什么要进行插值?在前面的学习中,我们发现很多的操作都是在三角形的顶点上完成或者计算的,然后在三角形的内部,我们希望能够得到一个平滑的过渡,所以我们需要插值。我们需要对哪些东西做插值呢?在前面遗留的问题中,我们发现我们想对纹理坐标、颜色以及物体的表面做插值。那么又该如何做插值?这就得说到重心坐标。

image-20220929111857844

重心坐标是定义在一个三角形上的,给你你个三角形你能定义一套重心坐标,换了一个三角形那就是另外一套。重心坐标告诉我们在三角形 ABC 所形成平面内的任何一个点 (x,y) 都可以表示成 A、B、C 三个点坐标的线性组合。只需要满足三个坐标前的系数和为 1 即可。于是我们便可以把(α,β,γ)(\alpha,\beta,\gamma) 作为一个坐标用来描述点(x,y)(x,y)。虽然重心坐标是三个数,但是由于条件的限制,我们用两个数就可以表示出来。这里有一个小细节,如果重心坐标的三个数都满足非负的条件,我们就知道这个点(x,y)(x,y) 在三角形内。

image-20220929113035507

image-20220929113132905

任何一个点的重心坐标实际上是可以通过面积比来求出来的。

image-20220929200452172

从这种点的定义方法,我们可以得到一个非常特殊的点,这个点就是三角形的重心,(α,β,γ)=(13,13,13)(\alpha,\beta,\gamma)=({1\over3},{1\over3},{1\over3})

image-20220929201139882

通过上面的公式,我们便可可以计算出一个三角形的重心坐标。

image-20220929201455245

重心坐标告诉我们的一件事情就是,如果我们要做插值,那么我们要做插值的属性同样也应该用重心坐标去把它线性的组合出来。假设三个顶点有各自的属性VAV_AVBV_BVCV_C,我们便可以通过任何一个三角形内部的点,它的重心坐标(α,β,γ)(\alpha,\beta,\gamma),然后把这些属性线性组合起来,然后得到任意一个的属性VV。这里的属性可以是任何的属性,例如位置、纹理坐标、颜色、发现、深度以及物体的材质...

重心坐标有一个问题,就是在投影下是不能保证重心坐标是不变的。一个三维空间中的三角形,它在投影之后,它的重心坐标可能是不一样的。这也就告诉了我们,如果我们想要插值三维空间中的属性,我们就应该取三维空间中的坐标,再来算它的重心坐标是多少,以此来进行插值,而不应该在投影之后的三角形里面做。之前做深度测试的时候,我们也应该在三维空间中做插值,否则会得到一个错误的结果。至于怎么将已经投影到屏幕上的物体投影回去,我们使用逆变换就好了。

# Applying Textures

image-20220929205921179

屏幕上任何的一个采样点我们都已经知道了它插值出来的 uv(纹理坐标)在哪里,之后再在纹理上查询一下这个 uv 的值,我们就知道纹理对应的颜色,于是我们简单的认为这个颜色就是漫反射的系数kdk_d,其实就相当于我们把这张图给贴到物体上了,并且这个物体还有 Phong-Shading 带来的明暗变化、高光一系列的东西。

# Texture Magnification(纹理放大)(What if the texture is too small?)

image-20220929231848805

当纹理的分辨率过小,不足以匹配物体表面的分辨率的时候,纹理就会被拉大,于是我们看到的图自然就不清晰了。对于物体上任意的一个点,我们都可以找到纹理上的一个位置,这个位置如果不是整数,我们就把它四舍五入成整数,也就是说,在一定的范围内,我们查找的都会是同一个纹素。于是我们便可以看到第一张图处理的结果,一张图上很多的像素都被映射到同一个纹素上去了。这种效果的处理并不好,我们希望能得到右边两张图的效果。也就是说,当我们在查询纹理的时候,如果有一个非整数的坐标,那么我们该怎么得到它的值,这是我们要考虑的问题。

image-20220929233042419

我们现在看到一个高分辨屏幕上的一个像素中心映射到了纹理上非整数的位置上。我们现在想知道纹理在这个红点处它的值是多少。

image-20220929233133413

于是我们可以找它临近的四个点。

image-20220929233156768

我们可以找到四个点左下角的那个点,并且我们可以算出红色的这个点离左下角的水平距离和竖直距离,这个 s 和 t 大小是在 0~1 之间的,这里不考虑 uv 的范围是 0~1 之间的,这里假设每两个黑点之间的距离为 1。

image-20220929233416655

接下来定义的这个操作叫做线性插值。xx 是一个 0~1 之间的值,xx 为 0 的时候,函数值为v0v_0xx 为 1 的时候,函数值为v1v_1;而xx 为 0.5 的时候,函数值正好等于两个点的中间。

image-20220929233437793

我们先对上下两条边做线性插值,得到了两个值u0u_0u1u_1

image-20220929233514432

最终,我们再用得到的u0u_0u1u_1 对竖直方向做一次插值。但完成了这两轮的插值后,最终这个点的颜色就等于了周围四个点的颜色,并且在这个区域中的任意一个点,我们都能得到一个平滑的过渡。由于在水平方向和竖直方向共做了两轮的线性插值,所以我们把这种插值方法叫做双线性插值。

image-20220930153437979

中间的这副图就是双线性插值的结果,可以看到,效果还是挺不错的。要想得到更加高质量的效果,我们可以考虑使用 Bicubic 插值方法,这种方法取临近的 16 个点,同样做水平和竖直方向的插值,只不过每次用 4 个点做一次三次的插值,而不是线性的插值,这种方式的运算量会增大,但它确实有更好的结果。

# Texture Magnification (hard case) (What if the texture is too large?)

image-20220930154238901

纹理分辨率过小会出问题,我们在上面讨论过,但是令人困惑的是,纹理分辨率过大同样也会产生问题。这又是为什么呢?如果按照前面的思路,我们找到像素的中心,然后找这个点的纹理坐标,或者插值求出这个点上的纹理坐标是多少,然后我们把这个值再写回像素。但我们这么做得到的却是上面右边这张图的结果,我们不仅看到了远处的摩尔纹,并且还看到了近处的锯齿,也就是说我们遇到了和之前完全一样的问题,也就是发生了走样现象。

image-20220930155122435

这个地方的问题在于近处像素它所覆盖的纹理区域实际上相对较小,而在远处一个像素覆盖了很大一片区域。也就是说,屏幕上的这些像素他所覆盖的纹理区域的大小是各不相同的。如果这个像素覆盖的纹理区域很小,我们用这个像素的中心查询一下这个值,得到的结果大致也正确,可是,在远处那些接近地平线的那些像素点它们覆盖了很大的纹理区域,现在我们认为这个点的值就是这片区域的平均值,那么这个结果固然会产生问题,我们必须想出新的采样方法。

image-20220930155837203

回想我们之前是怎么解决反走样的?我们用到了 MSAA 或者说超采样,也就是说一个像素我们用更多的样本,在像素内不同的位置去感知这样一个变化的函数。上面右图我们就用到了 512 个采样点,用这些点把纹理上对应的位置算出来,我们确实得到了一个不错的结果。可想而知,这种方法也特别慢。

image-20220930160820917

首先,我们遇到的是一个走样的问题,也就是信号变化的过快,我们采样的速度已经跟不上它。在我们这个问题上则体现在,当我们的纹理特别大的时候,一个像素的里面就可能包含了很大区域的纹理,这块纹理它是一直在变化的。也就是说,在一个像素内它的频率很高,但是却只用了一个采样点去采样它,那肯定是不行的。回到我们之前说的信号的概念上来,一个像素内,它有着非常高频的信息,我们希望把像素内的值给重构出来,那么我么就需要一个更高频的采样方法,这也就说明了我们为什么需要更多的采样点在一个像素内去超采样。但是我们不想用这个方法,那该怎么办呢?采样既然会产生走样,那么我们不采样会怎么样?我们知道一个像素会在纹理上覆盖很大的一块区域,如果我们能立刻的就知道这块区域它的平均值是多少,那就很好了。这也引出了我们之后要说的 Mipmap 这个概念。

image-20220930160906327

这个问题其实是一个算法问题,这是个点查询问题和范围查询问题。以纹理为例,给你一个点它的值是多少,我们刚才说的双线性插值就是点查询(Point Query)。另外一个是,我们不做采样,给你一块区域,能立刻得到里面的平均值,这个就叫做范围查询(Range Query)。范围查询不一定是求范围内的平均值,也可能是求范围内的最大值或者最小值等等。

image-20220930162232167

# Mipmap - Allowing (fast, approx., square) range queries

💡注意:Mipmap 所做的范围查询是近似的、正方形的范围查询。

image-20220930162555359

Mipmap 实际上就是从一张图生成出一系列图。我们把原始的纹理叫做第 0 层纹理,我们可以生成更高层的纹理,使得第 i 层纹理总是第 i-1 层纹理分辨率缩小一半的结果,直到纹理最后只剩下一个纹素为止。我们可以提前计算这些纹理图,例如在渲染之前。

image-20220930164109674

我们生成了这么多张图引入了多大的存储量呢?实际上只有原来的43{4\over3} 倍,也就是只比原来的图多了131\over3 的存储量,这一点非常的好。

image-20220930170154014

image-20220930195143452

image-20220930195208387

我们要用 Mipmap 在一个正方形区域内近似的做范围查询,并且想立刻得到平均值的结果。任何一个像素可以映射到纹理上的一个区域。假设现在我们左下角这个红点的像素它所覆盖的纹理的面积,那么我们可以取它自己的中心和它邻居的中心分别都投影到纹理上去。之后我们就可以做一个近似,屏幕上左下角的红点到它上边和右边的距离都是一个像素的距离,并且我们可以求出这几个点在纹理上所形成的边有多长的距离,可以通过上面的公式求出这个距离 L(两个方向上的最大距离)。这个 L 所围成的正方形区域基本上就可以拿来近似这个像素点在纹理所形成的不规则的区域。当我们可以把任意一个像素覆盖的区域近似成一个正方形的时候,我们就可以通过之前预计算好的 Mipmap 来查询这个边长为 L 的区域的平均值是多少。假如这个 L 所形成的区域就是 1×1 的一个区域,那么我们就可以在最原始的 Mipmap 上找对应的纹素;如果这个区域是一个 4×4 的一个区域,那么这片区域一定会在第二层上变成一个像素,也就是说,在D=log2LD=log_2L 这一层上,这个像素一定会对应到一个纹素上去。

image-20220930201055318

这个时候我们已经可以清晰的看到不同物体所使用的各个层级的 Mipmap 在上图中用可视化的形式展现的出来。离我们近的使用了低层级的 Mipmap,而离我们远则使用了高层级的 Mipmap。但同时我们又发现了一个问题,这个变换不怎么连续。每一层之间的变化并没有平滑的过渡,假设我们想查 1.8 层又该怎么办呢?

image-20220930201658828

于是我们又想到了这个概念 -- 插值。假如我们要查询 1.8 层的结果,那么我们先找第 1 层,再找第 2 层,然后在这两层的内部分别做双线性插值,把对应在这两层上的查询结果先做出来,然后用两个的结果在层与层之间再做一次插值,也就是说一共做了三轮的插值,所以我们把它叫做三线性插值。这样我们就可以通过计算离散的 Mipmap 层,插值出中间任意的层级。

image-20220930203644580

现在我们就可以看到在 Mipmap 不同的层级就有一个过渡了,自然也就能得到一个更好的渲染效果。

image-20220930203900608

image-20220930203915826

image-20220930203933047

我们花费了大量的时间来解释 Mipmap,但是 Mipmap 是否能完美解决我们的问题呢?我们假设 MSAA 得到的是正确的结果,但是 Mipmap 到了远处会把所有的细节全部给忽略,这种问题我们把它称之为 Overblur。Mipmap 会出现这种问题是因为它只能对一个方块区域内做范围查询。有一个办法可以部分解决 Mipmap 三线性插值所遇到的问题,这个方法就叫做各向异性过滤。

image-20220930204553137

image-20220930205740368

Mipmap 反应的实际上是上图中对角线上的那些图片,但是有一些图是在不同的长宽比上做的预计算这个是 Mipmap 没有的。也就是说各向异性过滤比 Mipmap 多了一些不均匀的水平和竖直方向上的压缩。这样做我们就能不被限制在一个正方形的区域上,而是在一个矩形的区域上做平均了。

image-20220930204723983

这也说明了屏幕上的一个像素它映射到纹理空间上并不一定是一个规则的区域,这个时候使用正方形区域来做平均就显得不太合理了。而使用各项异性过滤就能解决部分长条形区域的快速范围查询,相比于正方形区域结果自然就能好很多。但对于斜着的长条形区域,各向异性过滤也不能完美解决问题。

image-20220930204806834

还有一些另外的过滤方式,例如 EWA 过滤,EWA 过滤说的是一个不规则的形状都可以拆成很多不同的圆形去覆盖这个不规则的形状,多次查询得到最终的一个结果,但相对的也会需要付出更大的代价。

回到各向异性过滤,各项异性过滤本身生成了很多其它的图,要生成这些图,总共的开销是原本的 3 倍,而 Mipmap 的开销只是原本的131\over3,这就是各项异性过滤的代价,它还有另外一种名字,叫做 Ripmap。各向异性指的就是在各个方向上的表现各不相同。打游戏我们经常会看到多少 x 的各项异性过滤,2x 即每个方向上只压缩了一次,也就是上图左上角 2×2 的一块区域,如果是 4x 即每个方向上压缩了两次,也就是上图左上角 3×3 的一块区域。随着这个值的增大,最后这个结果会逐渐收敛到总存储的 3 倍,也就是说各向异性过滤的存储其实和开多少 x 关系不大,只要你显卡的显存足够就好,这里和计算力也基本没有什么关系。

# Applications of Textures(纹理的应用)

image-20220930213236908

Mipmap 的应用非常广泛,现在的硬件基本上都是支持 Mipmap 的。我们可以把纹理理解成一块内存以及包括可以对这片内存区域上进行点查询或者范围查询(或者说滤波)。从这个角度触发,那么纹理可以表示的东西就太多了。

image-20220930213255359

例如环境光映射(或者说环境贴图),也就是 Environment Map。假如我们站在一个房间里面往四面八方看,我们发现来自四面八方都有光,任何一个方向,不管它是直接光照还是反射来的光,如果把这个光给记录下来,这就是我们说的环境贴图。也就是说我们可以用这个纹理去描述我们的环境光长什么样,并且用它去渲染一个物体。环境贴图假设光源只记录方向信息,认为它们无限远,没有实际的深度意义。

image-20220930213307677

如果我们可以把环境光描述在一个纹理上,那么就可以用它来渲染一些东西。我们可以想象在房间里面放上一个非常光滑的金属球,这个金属球反射出来的东西,就是整个房间的环境光,也就是说,这给了我们一种存储环境光的说法,我们可以就把环境光存储在一个球上,并且像展开一个地球仪表面为世界地图一样把它给展开。这便是我们接下来要提到的 Spherical Environment Map。

image-20220930213334617

image-20220930213414171

如果把记录在球上的纹理展开,我们会发现纹理的顶部和底部会有很大程度上的扭曲,也就是说我们虽然做到了描述球上的每一个位置,但不是一个均匀的描述,这便是它存在的一个问题。

image-20220930213440689

于是我们现在假设球的外面有一个包围盒,把球面上的点通过球心映射到正方体的表面上,于是我们便把环境光的信息存在了立方体的表面上,于是我们便可以得到六张图。立方体由于各个面都是均匀的,所以很少会有扭曲的现象发生。但现在我们要是想看某一个方向上的光照,例如给了两个角度θ\thetaϕ\phi,这个方向的光照原本可以在球上快速的求出来,现在还需要再判断一次它在正方形面上的对应关系。不过它们的本质都是一样的,都是为了表述不同方向上的光照信息。

💡注意:光照信息不一定指的是直接光照,我们看到的任何物体,因为我们能看到它,一定是因为有光线从它到达了我们的眼睛,它们反射过来的光同样也是来自于这个方向上的光照信息。

image-20220930213533883

image-20220930213557161

纹理还可以用于凹凸贴图。纹理从来没有规定只能描述物体表面的颜色,它可以定义任何不同位置任何不同的属性,例如这里可以定义在一个表面上任意一个点它的相对高度是多少,相对高度指的是在原先的基础的表面上沿着向上向下的方向各走多少。通过凹凸贴图,我们可以在不把几何形体变复杂的情况下,应用这个纹理定义任意一个点它的相对高度是多少。而相对高度一变,物体表面的法线就会发生变化,从而影响到最后着色的结果,而着色上的明暗对比就让人们认为物体表面是凹凸不平的了。但是实际上,我们并没有改变这个物体的几何。

image-20220930213612739

通过定义凹凸贴图,我们可以定义出一个复杂的纹理,但是我们并没有改变任何的几何信息,所以物体上的几何面片不会有任何影响,我们会把任何一个像素所对应的法线做一个扰动,这是通过定义不同位置的高度和它临近位置的一个高度差然后来重新计算它的法线。

image-20220930213629910

我们先把这个问题简单化,我们先不考虑二维的贴图三维的空间,而是考虑它现在就是一个变化的一维的函数,并且假设原本的点都在一个平面,那么扰动后的法线就是上面的式子,其中 c 是一个常数,用来表示凹凸贴图的影响程度。

image-20220930213647079

同理,我们也可以求出在二维贴图上的点如何影响物体表面的法线。这里有一点需要指出的就是说,实际上原始法线的方向可能是各种各样的,不一定就是 (0,0,1),所以这里定义了一个局部坐标系,我们就认为这个局部坐标系中对应的法线坐标就是 (0,0,1),并且它还有两个垂直的分量。在这个坐标系里面我们把这个法线的方向通过纹理映射的方法进行改变,然后再把这个计算出来的法线方向重新计算回世界坐标系里。

image-20220930213705387

还有一种贴图叫做位移贴图,不同于凹凸贴图。位移贴图会真正的把三角形上的不同的顶点做一个位置上的移动,而不是说通过位置的移动把它换算成法线的变化去做一个假的顶点的移动,而是真真意义上移动了顶点的位置。当然使用这种贴图,它会要求模型本身的三角形得足够细,细到顶点之间的间隔要比纹理定义的频率还要高才行,毕竟它改变是三角形顶点的位置。而使用 DirectX 提供的动态曲面细分,我们不需要一开始就提供很精细的模型,而是根据需要来做它的曲面细分。

image-20220930213724241

我们甚至还可以定义出三维的纹理,这里实际上定义空间中任意一个点的值,他们实际上并没有真正生成纹理的图,而是定义了一个在三维空间中的噪声函数,在空间中的任意一个点都会有一个解析式来算出这个噪声的值是多少,这个噪声可以进行一系列的处理来把它变成我们需要的样子,例如上图右侧大理石上的裂缝这种效果。

image-20220930213749114

纹理还可以用来存储之前已经计算好的一些信息,例如把环境光遮罩的信息写进纹理图,然后再把纹理图贴到模型上。也就是说,我们把着色的结果乘以我们计算好的环境光遮蔽的纹理,然后我们就可以得到最右边这张图的结果,这样子就节约了部分的开销。同时也说明了,纹理不仅可以存储颜色,还可以存储各种各样的信息,关键在于你如何再着色器里去解释它。

image-20220930213802834

三维的纹理我们还广泛应用在体积的渲染里。我们之前所说的各种光照模型只是在考虑一个表面,而实际上例如在医学里我们经常会扫描人体的某一块区域得到三维空间的信息,通过记录这些信息在把它拿去做渲染,这就叫做体积渲染。既然这块信息也存储在空间里,那么我们也把他当作是一个纹理,也就是三维的纹理。