【RTR4笔记】 第五章 着色基础

光源、着色模型、反走样、透明度、伽马矫正

Posted Kongouuu's Blog on January 24, 2022

5.2光源

5.2.1 平行光

平行光为没有特定位置,固定角度,且不会衰减的光。因为定义上是从无限远的位置传过来的光,所以不考虑衰减。

5.2.2 精准光源

点光源

精准光源,包括点光源和聚光灯,都是拥有实际位置以及光强衰减的光源。

衰减的话有非常多的算法,拿一个比较基础的来看:

\[c_{light}(r)=c_{light_0}(\frac{r_0}{r})^2\\ r: current\ distance\\ r_0: reference\ distance\]

本章节提到的大部分的衰减都是类似这样的inverse squared(平方反比) 的方式去算衰减。

算衰减的时候,我们这样的算法会让衰减后的光没办法变成0,如果我们希望太远的物体不考虑这个光源,那很这样的衰减是不够用的。所以在上面的基础上我们可以引用一个窗口函数,主要用于把衰减的光源阶段在一个范围:

\[f_{win}(r)=(1-(\frac{r}{r_{max}})^4)^{+2}\\ r_{max}为我们截断光源的距离。所以光源的强度范围为:\\ c_{light}(r)=c_{light_0}(\frac{r_0}{r})^2f_{win}(r)\\\]

这样当我们的距离离最大距离还很远的时候,窗口函数会返回近乎1的值,然后越接近截断的距离,窗口值会越来越小,最终截断在rmax

在有的实现里,他们并不会特别在乎需不需要平方反比的衰减,会直接单独考虑一个截断函数的组合,让整个光强的计算变成这样:

\[f_{dist}(r) = (1-(\frac{r}{r_{max}})^2)^{+2}\\ c_{light}(r) = c_{light_0}f_{dist}(r)\]

聚光灯

聚光灯实际上就是一个特化了的点光源,拿现实生活来看就是类似手电筒这样的东西。他跟点光源最主要的差别就是,点光源是朝着360度无死角的放射光芒,而聚光灯只会朝着特定方向的特定范围发光。所以我们可以把聚光灯的光强写成这样:

\[c_{light}(r) = c_{light_0}f_{dist}(r)f_{dir}(l)\\ f_{dir}(l)会根据输入方向l来判断光的强度\]

一般来说我们会先想要设法让我们的光源只打在一个圆锥的范围上:

那么这时候我们可以写出一个很简单的阶段函数:

\[f_{dir}(l)\\ l是光源往面片的归一化向量\\ dir是归一化的光源朝向\\ \theta_{u}是我们希望有光的范围的角度\\ f_{dir}(l)=clamp(\frac{l\cdot dir-\cos(\theta_u)}{\left|{l\cdot dir-\cos(\theta_u)}\right|},0,1)\]

这样的函数保证了如果我们的目标在角度范围内,值为1,角度范围外值为0。

不过很多时候我们的聚光灯并不会有这样的硬边缘,我们会希望有一个内圈和外圈,并且在内圈到外圈的范围,光强是渐渐缩小到0的。这样会比较smooth,没有断层感。为了达到这个目的,我们需要再多准备一个内圈的角度范围。为了更贴切的形容,会用到下面三个角度:

  1. θs: 当前着色面片到光源位置于光源方向形成的夹角

  2. θp(penumbra angle): 可以给全额光强的内圈的夹角,

  3. θu(umbra angle): 限制最大范围的外圈夹角

那么我们可以写出下面的公式:

\[f_{dir}(l)=clamp((\frac{\cos\theta_s - \cos\theta_u}{\cos \theta_p - \cos \theta_u}),0,1)\\ if\ \theta_s=\theta_p,\ f_{dir}(l)=1\\ if\ \theta_s=\theta_u,\ f_{dir}(l)=0\\ if\ \theta_s<\theta_p,(\cos\theta_s - \cos\theta_u)>(\cos \theta_p - \cos \theta_u), clamps\ to\ 1\]

这样就能达到一个聚光灯的效果了。

5.2.3 其他光源

上面对光源的介绍是一个很基础的光源抽象的理解,实际上我们完全可以做出更多不同的光源形容。比如说比起一个点,我们可以使用一个模型来当作光源。渲染其他物体的时候就选择我们光源模型上离自己最近的点去进行光照计算。同时也有很多其他的光源例如体积光,IBL的环境光等,后面会提到。

5.3 着色模型

5.3.1 展开频率

我们每次的光照渲染中,会有大量需要计算的数据,但每个数据需要计算的次数却大有不同。有的数据一辈子只用计算一次,有的数据一个Pass计算一次,而有的数据一个Draw Call计算一次。

一直到每个Draw Call计算一次的东西我们都可以提前用CPU算好再提交给GPU,不过如果是每个Draw Call内部需要计算多次的信息(依赖于顶点数据)的这种需要斟酌一下在哪里施行计算。

拿最简单的计算频率来说:

  1. 顶点着色器会为每个顶点运行一次
  2. 像素着色器会为每个顶点覆盖的像素计算

因此如果有的数据可以在顶点着色器里面准备好,那在顶点着色器里提前计算会比交给像素着色器效率要高。

5.3.3 材质系统

一般来说Shader是一个可编程但略微底层的存在,也就是说我们的美术可能是不会去修改Shader的,他们需要一个更简单操作的系统,也就是材质系统。从最简单的理解上,材质可以是单纯的形容Roughness和Metallic,并且共用同一个Shader。不过一般比较大的引擎/框架下,一般来说会需要大量的不同范围的材质,和对应的大量Shader

UE来说,就是预设了一大堆不同种类的材质,人能编辑的只是对应的Shader的输入罢了。材质本身也可以同时对应多个Shader。在使用大量Shader的情况下,我们希望能让整个Shader的使用更加模块化,或者说更加灵活。下面有一些会采取的手段:

  1. 代码重用: 很多我们在不同着色器中都会用到的代码,比如说PBRFGD项所用的公式等,可以放在统一的文件,然后通过#include使用
  2. 代码减法: 有的时候不同的材质的差距可能只是一小部分的代码差,所以我们可以写一个很完整的着色器代码,包含了多个材质的需要,然后通过Pre-defined Macro去决定编译时删去哪些不用的代码。简单来说就是一个副本编译多份有差异的着色器。
  3. 代码加法: 我们可以准备很多已经有写死输入和输出值的节点,例如UE4里面的很多函数。然后使用这些函数来搭建我们整个材质流程。
  4. 模板: 这是更正式的 代码加法 的形式,简单来说就是,Shader提供很大的自由度给固定的输入参数。 然后用户可以通过材质系统去自由的处理这些输入参数。

材质系统的一个重点就是在提供方便使用的同时具备比较好的效率。不过在现在的硬件中我们如果一批渲染在分支的地方有同一个计算结果,就不用计算分支的另一边,有了这个设置后我们对为材质写Shader上也可以更加灵活,虽然会有一些寄存器的开销。

5.4 抗锯齿

5.4.1 采样以及滤波原理

从根本上来看,锯齿,或者说走样的出现,就是因为采样的频率过低。

图形学里最经常提到的走样就是三角形的锯齿。也就是由于我们渲染目标的像素有限,没办法对场景进行完全的采样造成的结果。除此之外,如果我们使用渲染方程进行高光渲染的时候,采样的光源不够,也同时会出现光照上的走样。如果物体移动太快,帧数又不高,也会有运动的走样等。这些实际上都是 采样不够 造成的结果。

这个章节就是要处理几何走样,也就是屏幕上的锯齿。

5.4.2 基于屏幕的反走样

一个非常简单的走样造成的结果如下,由于像素不够,我们原本画出来的一个三角形最终在屏幕上可能会变成这个样子。

那么怎么解决呢?很直观的解决办法就是,提高采样的频率。从理论上来看,也只有这种办法能解决走样了。

5.4.2.1 基础反走样

SSAA

Super Sampling Anti-Aliasing 是最典型的一个提高频率来进行反走样的办法。它就是渲染一个N倍大的画面,再压缩成我们需要的输出大小。

比如说我们希望输出一个800*600的图片,那么SSAA就是让管线去渲染一个1600*1200的图片,然后把结果中的每四个像素合并成一个进行输出。这个方法很直观的是可行的,不过代价太高了。四倍的渲染大小意味着我们需要四倍的存储,以及四倍的光照计算,这样的开销很显然对于实时渲染来说太过奢侈。

从本质上来看,SSAA做的就是增加了几何以及颜色的采样率。

MSAA

Multi Sampling Anti-Aliasing 是对SSAA进行一定的效率改进后的结果。为了减少锯齿,MSAA同样也得增加几何采样率,但为了效率并没有提升颜色的采样率。相较于SSAA,我们没办法省去四倍的缓冲储存,因为这对于增加采样频率来说是必须的,所以只能在光照计算上进行加速。

用人话来说,MSAA就是没有进行N倍的光照计算的SSAASSAA就是光照计算变成N倍的MSAA,他们的其他开销都是一致的。

单说MSAA的流程,差不多是这样:

msaa_proc

部分细节:

  1. 在光栅化(设置三角形)的时候,会计算哪些采用点被三角形覆盖。同时也会为采样点进行插值计算深度。
  2. 在像素着色器里,只把计算结果传给被覆盖了的采样点。
  3. 在合并阶段会根据每个采样点自己的深度去决定是否把结果保留到颜色缓冲里。
  4. 在最后的最后会把每四个采样点的结果合并到最终的输出图像里。

TAA

Temporal AA, 就是使用往帧的信息来达到一个抗锯齿的效果。实践上来看就是去模拟MSAA的采样的轨迹,不过每一帧只使用一个采样点。并且在渲染的时候会把当前帧与最近的三个帧进行混合,结果就可以得出类似于使用了4倍缓冲的效果。

taa

它的优势在于使用的内存开销相对来说小很多,因为我们不再需要使用N倍的缓冲。实际上效果也不会太差,不过在处理运动的物体上可能会有一些失真。

5.4.2.2 采样规律

RGSS

一般我们会对接近横竖的锯齿比较排斥,其次是45度角的斜面总成的锯齿。为了提高我们对这样的斜面的分辨率,一般会使用 Rotated Grid Super Sampling 进行采样点的分配,实际效果如下:

使用这种配布我们可以更好的抓到不同的锯齿,并且同时保证了采样点和采样点之间的距离。

FLIPQUAD

当我们为RGSS进行了下面的操作,会发现实际上我们可以节省出很多采样点的空间:

实际上我们只需要准备接近一半的采样点就可以达到类似的效果,只需要让像素之间去共享定点就可以了。虽然硬件上可能并不是很适合用来进行MSAA,不过对于TAA来说这样的操作非常的好用,因为对应动态的画面来说,只需要往前读一帧的画面就可以做出四倍的AA效果。

5.4.2.3 形态学反走样 (Morphological Methods)

锯齿本质上就是我们对边界的采样不够造成的结果。形态学的反走样的思路就是对已经渲染好的图像,找到有锯齿的边缘,然后处理掉锯齿:

拿上图来说,我们拥有左手边的结果。然后根据这个结果可以判断出多半这里是两个物体的边界,并且边界实际上应该长得像中间的虚线一样。基于这样的猜测,去对这个像素进行操作,变成右图反走样后的结果。

这个类别的反走样跟前面不一样的地方是我们不需要对渲染管线进行什么操作,我们只需要等走样了的图像结果被输出,并且对单纯的颜色输入进行操作就可以把锯齿去掉。效率上来看是最高的。

最有名的两个是:

  1. FXAA: Fast Approximate Anti-Aliasing
  2. SMAA: Subpixel Morphological Anti-Aliasing

5.5 透明度、Alpha、和混合

5.5.1 混合顺序

为了让一个半透明的物体能够呈现出半透明的状态。我们一般会在最后进行半透明物体的渲染,让他们基于已经渲染好的颜色缓冲去进行绘制。一般来说我们会赋予物体一个Alpha值,然后通过这个值与颜色缓冲进行混合。一个最简单的例子就是使用Over的混合方式:

\[c_{output} = a_{s}c_{s} + (1-a_s)c_{buffer}\]

由于已经渲染好了所有不透明的物体,所以我们可以确定在不透明物体上的半透明物体是正确渲染的。不过假设半透明物体之间重叠了,我们没办法得出正确的结果,所以在半透明物体上我们还需要额外进行一次排序来决定哪个先渲染。

5.5.2 无序的透明渲染

我们也可以进行没有顺序的透明渲染物体渲染。这时候要用到另一个混合方式,也就是Under,相较于Over, 这个方式主要提倡的是先渲染靠近屏幕的物体。一般的使用中会用它把所有的半透明物体渲染到另一个缓冲,最后再和不透明物体混合:

\[c_{output} = a_{buffer}c_{buffer} +(1-a_{buffer})a_sc_s\\ a_{output}=a_s(1-a_{buffer})+a_{buffer}\]

5.5.2.1 Depth Peeling

同时这个混合方式也可以用来做无序的渲染。也就是说我们不需要在意物体之间的排序。使用它来进行无序的透明渲染流程如下:

  1. 用一个深度Pass算出所有(半透明+不透明)物体物体的深度。
  2. 用一个Pass通过深度把最上面一层的半透明物体渲染出来,并且剥离掉这个物体(Peel),让下一个深度Pass只考虑比这个深的像素。
  3. 重复上面两个步骤直到半透明物体被渲染完(或者达到手动设置的最大次数)
  4. 渲染不透明物体和刚刚计算好的半透明物体的混合。

通过这个方式我们不需要对半透明物体去一直排列,不过问题在我们需要跑大量的Pass才能得出想要的结果。并且很多时候也不知道最多应该跑几个Pass

5.5.2.2 A-Buffer

为了解决类似的问题,还有一个被推出的方法是使用A-BufferA-Buffer就是一个多层的缓冲区,为每一个像素建立一个链表,链表内的每个节点都有着颜色、深度、以及Alpha值。如果当前渲染的是一个不透明物体,那么链表内所有比它深的节点都会被剔除。

使用A-Buffer可以有效地储存出所有的不透明物体以及它们的贡献和深度,最后可以简单的进行统一的计算。这个的开销在于需要一次性的对所有的不透明物体所使用的像素进行大量的数据存储;不过速度上如果有GPU支持也是十分客观的。

5.5.2.3 Weighted Average

这个比较简单粗暴,把所有的不透明物体,在不论顺序的情况下算出颜色和Alpha的加权平均值,然后再和不透明物体一起渲染。

5.6 伽马矫正

从结果来看,伽马矫正就是在输出颜色的时候,进行下面的操作:

\[c_{output}=c^{(\frac{1}{2.2})}\]

在使用贴图的时候,进行下面的操作:

\[c_{texture}=c^{2.2}\]

这个很好理解,假设我们使用的素材在输出时都是进行了伽马矫正的计算,那么我们的贴图的色彩空间本来就会是非线性的。因此我们需要进行2.2次幂让贴图的色彩空间回到线性空间。

至于非贴图颜色输入,这完全是看自己对引擎是怎么设计的好吧,就是说希望用户输入的颜色为实际线性颜色还是视觉先行颜色。

5.6.1 理论

人眼对于线性空间的颜色的捕捉并不是很理想。线性的颜色空间在人眼中可能会呈现出非线性的画面。为了解决这个问题,大部分的显示器在现实中都会使用sRGB的颜色空间,其值相当于颜色的2.2次幂。

这样的颜色空间在人眼里会让颜色显得更加的线性,这自然是件好事。但当我们要进行图形渲染的时候,我们会想要在线性的颜色空间去操作,也就是说我们希望使用一个正常的颜色空间,而不是sRGB,来计算我们更加物理的渲染效果。

为了把线性的结果输出在屏幕上,我们需要进行(1/2.2)次幂的操作。

由于大部分的素材都是有类似的考量,所以我们实际上使用的贴图都是经过为了在显示器显得线性的处理所输出的结果,因此都是非线性的颜色空间。所以在贴图输入的地方我们一般也都需要把他恢复到线性空间。