《Real-Time Rendering》第五章 着色基础

开篇

  当你渲染三维物体的图像时,模型应该不只有恰当的几何形状,它们也应该有期望的视觉外表。取决于应用场景,它可以是写实的,也可以是风格化的。下图为一个相关的例子
img

  这一章将会讨论写实化渲染和风格化渲染的一些共通的方面。第十五章专门介绍了风格化渲染,是这本书很重要的一个部分。第九章到第十四章则专注于介绍一些用于写实化渲染的一些基于物理的方法。

着色模型(Shading Model)

  决定被渲染物体的外表的第一步是选择一个着色模型Shading Model),它能描述物体的颜色是如何根据表面朝向、观察方向、光照条件变化的。
  例如,我们将使用Gooch着色模型的一个变种。它是非写实化渲染的一种形式。Gooch着色模型是被用来在技术展示的时候增加细节辨识度的。
  Gooch着色的基本想法是比较表面法线和光源位置。如果法线指向光源,那么就使用更暖的色调,否则使用更冷的色调。在这之间的角度会插值这些色调,这基于用户提供的表面颜色。在这个例子中,我们添加一个风格化的“高光”效果给模型,让表面有闪亮的外表。下图展示了使用这个着色模型的一些例子
img

  着色模型通常有些属性被用来控制外表变化,设置这些属性的值是决定物体外表的下一步。我们的样例模型只有一个属性,即表面颜色,如上图中的几个球体所示。
  和大多数着色模型相似,这个例子受表面朝向与视图和光照方向之间的相对关系的影响。对于着色的目的来说,这些方向通常都是归一化(单位长度的)向量,下图是一个相关的例子。
img

  现在我们已经为我们的着色模型定义了所有的输入,接下来可以看下模型本身的数学定义

\[\mathbf{c}_\mathrm{shaded} = s \mathbf{c}_\mathrm{highlight} + (1-s)(t\mathbf{c}_\mathrm{warm}+(1-t)\mathbf{c}_\mathrm{cool}) \]

在这个方程中,我们使用了如下的中间计算结果

\[\begin{align*} \mathbf{c}_\mathrm{cool} &= (0,0,0.55) + 0.25 \mathbf{c}_\mathrm{surface}\\ \mathbf{c}_\mathrm{warm} &= (0.3,0.3,0)+ 0.25 \mathbf{c}_\mathrm{surface}\\ \mathbf{c}_\mathrm{highlight} &= (1,1,1)\\ t &= \frac{(\mathbf{n} \cdot \mathbf{l}) + 1}{2}\\ \mathbf{r} &= 2(\mathbf{n} \cdot \mathbf{l})\mathbf{n}-\mathbf{l}\\ s &= \mathrm{clamp}((100(\mathbf{r} \cdot \mathbf{v})-97),0,1) \end{align*} \]

在这个数学定义中有许多数学表达式通常也能在其它的着色模型中被找到。

光源(Light Sources)

  光源在我们这个着色模型中的影响是非常简单的,它提供了着色的主要方向。当然了,光源在现实世界中是非常复杂的,它可以有尺寸、形状、颜色、强度。间接光照进一步增加了更多的变数。正如在第九章看到的那样,基于物理的写实化着色模型需要把这些参数一并纳入考量范围中。
  相反地,风格化着色模型可能用多种方法来使用光照,具体取决于应用场景和视觉风格。一些高度风格化的模型可能都不会有光照这一概念,或者(像我们的Gooch着色例子那样)只可能提供一些简单的方向性。
  光照复杂性的下一步是让着色模型为光照的存在与否进行二元的相应。一个使用这样的模型进行着色的表面通常有一个被点亮的外表和另一个不同的未被光源影响的外表。对于这两种情况我们需要一些标准来进行区分,这些标准为:与光源之间的距离、阴影、表面是否面向光源或这些因素的结合。
  从光源是否存在的二元情况到光源强度的连续变化只需要变化很小的一步,可以通过在这两种情况之间进行简单的插值来的得到,那么这意味着强度是有界的,可能是\(0\)\(1\),或者是个无界限的量以另外一些方式影响着色。实现后者的一个通常的方法是把着色模型分成点亮和未点亮的两部分,并使用光照强度\(k_\mathrm{light}\)线性缩放被点亮的部分

\[\mathbf{c}_\mathrm{shaded} = f_\mathrm{unlit}(\mathbf{n},\mathbf{v}) + k_\mathrm{light} f_\mathrm{lit}(\mathbf{l},\mathbf{n},\mathbf{v}) \]

这很容易扩展到RGB光照颜色\(\mathbf{c}_\mathrm{light}\)

\[\mathbf{c}_\mathrm{shaded} = f_\mathrm{unlit}(\mathbf{n},\mathbf{v}) + \mathbf{c}_\mathrm{light} f_\mathrm{lit}(\mathbf{l},\mathbf{n},\mathbf{v}) \]

以及到多个光源

\[\mathbf{c}_\mathrm{shaded} = f_\mathrm{unlit}(\mathbf{n},\mathbf{v}) + \sum_{i=1}^n \mathbf{c}_{\mathrm{light}_i} f_\mathrm{lit}(\mathbf{l}_i,\mathbf{n},\mathbf{v}) \tag{1} \]

取决于期望的视觉风格和应用场景,它可以有不同的一些形式。例如,\(f_\mathrm{unlit}\)可以为\((0,0,0)\),这样将会让任何不被光源影响的表面被着上纯黑色。另外,未点亮的部分还可以表示风格化外表的一些形式,和Gooch模型的用于背对光源的冷调色类似。通常来说,着色模型的这个部分表示不直接来自被放置光源的光照的一些形式,比如来自天空的光或者从周围物体反射过来的光。这些以及其它类型的光照会在第十章和第十一章中被讨论。
  作用于表面的光可以被看作是一些光线,有着强度的光击中表面就对应着用于表面着色的光强度,下图是一个相关的例子
img

由上图我们可以看到当“总光强”不变的*行光照射表面时,随着入射角的增大,*行光会照射到更多的区域,因此表面上各点接收到的光强度会变低。通过数学计算我们可以知道接收到的光强度实际上和\(\cos\theta\)成正比,在实际实现时我们可以通过\(\mathbf{n} \cdot \mathbf{l}\)来计算余弦值。当余弦值为正时意味着光线来自表面之上,负值则意味着光线来自表面之下。因此,在乘以余弦值用于着色之前应该把它钳制到\(0\),所以之前提到的着色公式就变为了

\[\mathbf{c}_\mathrm{shaded} = f_\mathrm{unlit}(\mathbf{n},\mathbf{v}) + \sum_{i=1}^{n} \mathrm{max}(\mathbf{l}_i \cdot \mathbf{n},0) \mathbf{c}_{\mathrm{light}_i} f_\mathrm{lit}(\mathbf{l}_i,\mathbf{n},\mathbf{v}) \tag{2} \]

  支持多个光源的着色模型通常会使用式\((1)\)和式\((2)\)的结构。式\((1)\)更通用一些,式\((2)\)对于基于物理的模型来说是硬性要求,对于风格化的模型来说其实也是有好处的,因为它能确保了光照的总体一致性,特别是对于那些背离光源的表面或者是被遮挡的那些表面。然而,有些模型并不适合式\((2)\)的结构,因此还是得使用式\((1)\)
  \(f_\mathrm{lit}()\)函数的极简实现也许是让它为一个常量颜色,即

\[f_\mathrm{lit}()=\mathbf{c}_\mathrm{surface} \]

因此就有如下的着色模型

\[\mathbf{c}_\mathrm{shaded} = f_\mathrm{unlit}(\mathbf{n},\mathbf{v}) + \sum_{i=1}^{n} \mathrm{max}(\mathbf{l}_i \cdot \mathbf{n},0) \mathbf{c}_{\mathrm{light}_i} \mathbf{c}_\mathrm{surface} \]

这个模型中的被点亮的部分其实对应着朗伯Lambertian)着色模型,它由朗伯在\(1760\)年发表。这个模型为理想漫反射表面工作,这种表面是完全粗糙的,会向四周均匀反射光线。我们在这里展示的是它的简化表达,在第九章我们会详细地了解它。朗伯模型可以被用于简单的着色,它是许多着色模型的基石。
  从之前的公式可以看到光源和着色模型的交互主要是通过\(\mathbf{l}\)\(\mathbf{c}_\mathrm{light}\)这两个参数实现的。光源是有非常多的种类的,在场景中这两个参数会因为光源种类不同而有变化。
  我们接下来讨论一些常被使用的光源的类型,它们都有一个共通点,即给定一个表面位置,每个光源只从一个方向\(\mathbf{l}\)点亮表面。用另一句话来说,从被着色的表面位置看到的光源是一个无穷小的点。这对于现实世界的光源来说不是完全正确的,但是绝大多数光源的尺寸相对于它们与被点亮的表面之间的距离来说是很小的,因此可以做这样一个*似。在往后的部分,我们将讨论从多个方向点亮表面某个位置的光源,比如区域光源这种。

定向光(Directional Lights)

  定向光是光源最简单的一种,它没有位置,\(\mathbf{l}\)\(\mathbf{c}_\mathrm{light}\)在场景中会保持不变,除了\(\mathbf{c}_\mathrm{light}\)因为表面被遮挡而衰减这种情况。它可以被扩展,比如让\(\mathbf{c}_\mathrm{light}\)在场景中变化而\(\mathbf{l}\)保持不变。

点式光(Punctual Lights)

  一个点式光Punctual Light)通常有一个位置,和定向光不同。这种光没有维度,没有形状或是尺寸,和现实世界中的光源不同。它的英文名里的“punctual”来自拉丁语的punctus,意思是“点”。我们使用“点光源”来指定一种特定类型的发光体,即那种向所有方向均匀辐射的发光体。因此,点光源和聚光灯是两种不同类型的点式光。光照方向\(\mathbf{l}\)取决于当前着色的位置\(\mathbf{p}_0\)和点式光的位置\(\mathbf{p}_\mathrm{light}\),计算公式为

\[\mathbf{l} = \frac{\mathbf{p}_\mathrm{light}-\mathbf{p}_0}{||\mathbf{p}_\mathrm{light}-\mathbf{p}_0||} \]

在实际计算时,我们通常需要一些中间结果用于光照计算,就比如距离信息用于衰减的计算,因此在获得光照方向\(\mathbf{l}\)时通常可以这么做

\[\begin{align*} \mathbf{d} &= \mathbf{p}_\mathrm{light} - \mathbf{p}_\mathrm{0}\\ r &= \sqrt{\mathbf{d} \cdot \mathbf{d}}\\ \mathbf{l} &= \frac{\mathbf{d}}{r} \end{align*} \]

点光源(Point Lights)

  向所有方向均匀发射光线的被称为点光源Point Light)或全向光源Omni Light)。对于点光源来说\(\mathbf{c}_\mathrm{light}\)会随着距离\(r\)变化,也就是之前提到过的衰减。下图解释了为什么会有变暗
img

由上图可见,当“总光强”不变的光照射表面时,随着距离逐渐增加,光照射到的面积会增加,因此表面上各点接收到的光强度会变低。经过数学计算后,强度要和\(1/r^2\)成正比。假如\(r_0\)距离测得的强度为\(\mathbf{c}_{\mathrm{light}_0}\)那么任意距离的强度为

\[\mathbf{c}_\mathrm{r} = \mathbf{c}_{\mathrm{light}_0}(\frac{r_0}{r})^2 \]

  这个公式在实际使用时要小心,当\(r\)趋*\(0\)\(\mathbf{c}_\mathrm{light}\)会持续无界地增加。当\(r\)到达\(0\)时会遇到除以\(0\)的问题。为了解决这个问题,一个常用的修改是增加一个小值\(\epsilon\)到除数

\[\mathbf{c}_\mathrm{light}(r)=\mathbf{c}_{\mathrm{light}_0} \frac{r_0^2}{r^2+\epsilon} \]

小值\(\epsilon\)取决于实际的应用场景,虚幻游戏引擎让\(\epsilon\)\(1\)cm。
  另一个修改方法被CryEngine和寒霜游戏引擎使用,就是把\(r\)钳制到一个最小值\(r_\mathrm{min}\)

\[\mathbf{c}_\mathrm{light}(r) = \mathbf{c}_{\mathrm{light}_0}\left(\frac{r_0}{\mathrm{max}(r,r_\mathrm{min})} \right)^2 \]

  不像\(\epsilon\)\(r_\mathrm{min}\)有物理含义,它是发光物体的半径。小于\(r_\mathrm{min}\)\(r\)意味着穿透发光物体,位于发光物体里面,而这是不可能的。
  另一个问题则相反,它发生在相对远的距离和性能有关。尽管光强度随着距离衰减,但是因为*方反比不会到达\(0\)。对于高效的渲染来说,超出一定距离的光的强度应该到达\(0\)。那么衰减公式得被修改,理想地来说,修改应该尽量引入小的变化。为了避免在边界处的锐截止,导数和修改后的衰减函数应该在相同的距离同时到达\(0\)。一个方法是将*方反比公式乘以一个有着合适属性的窗函数Windowing Function)。下方这样一个函数同时被虚幻引擎和寒霜引擎使用

\[f_\mathrm{win}(r) = \left(\mathrm{max}\left( 1 - \left( \frac{r}{r_\mathrm{max}} \right)^2,0\right)\right)^2 \]

下图展示了使用窗函数函数的例子
img

  实际的应用场景会影响使用的方法。例如,当距离衰减函数以相对低的空间频率(在光照贴图或逐顶点)进行采样时,导数在\(r_\mathrm{max}\)处等于\(0\)是非常重要的。CryEngine没有使用光照贴图(Light Map)或顶点光照,因此进行了一个更加简单的调整,在\(0.8r_\mathrm{max}\)\(r_\mathrm{max}\)时会切换到线性衰减。
  对于一些应用场景来说,完全匹配*方反比曲线不是最优先的,有一些其它的函数也可以被使用。这因此让强度计算公式泛化到了

\[\mathbf{c}_\mathrm{light}(r) = \mathbf{c}_{\mathrm{light}_0} f_\mathrm{dist}(r) \]

其中,\(f_\mathrm{dist}(r)\)被称为距离衰减函数Distance Falloff Function)。在一些情况下,使用非*方反比衰减公式是因为有性能上的约束。例如,正当防卫2游戏需要的光照的计算开销是非常昂贵的,因此使用了一个能简单计算的衰减函数,而且还足够*滑避免了逐顶点光照的伪影。
  在其它的情况下,衰减函数的选择有创意方面的考量。例如,虚幻引擎同时被写实的和风格化的游戏使用,因此有两种光的衰减模式。一种为*方反比模式,另一种为指数衰减模式,它可以被调整来创造各种衰减曲线。古墓丽影(2013)的开发者使用了样条编辑工具来编辑衰减曲线,这很大地增加了曲线形状的控制能力。

聚光灯(Spotlights)

  不像点光源那样,所有现实世界的光源发出的光实际上也和方向有关,我们可以引入方向衰减函数来实现这一点,因此有如下公式

\[\mathbf{c}_\mathrm{light} = \mathbf{c}_{\mathrm{light}_0} f_\mathrm{dist}(r) f_\mathrm{dir}(\mathbf{l}) \]

  不同的\(f_\mathrm{dir}(\mathbf{l})\)选择会有不同的效果。其中一种重要的效果类型是聚光灯,它的\(f_\mathrm{dir}\)绕着聚光灯方向\(\mathbf{s}\)是旋转对称的,因此\(f_\mathrm{dir}\)也可以用\(\mathbf{s}\)与光照方向的相反方向\(-\mathbf{l}\)之间的角度\(\theta_s\)来表示。
  绝大多数聚光灯的表达式通常都会使用余弦\(\theta_s\),聚光灯通常有一个本影角度\(\theta_u\),当\(\theta_s \geq \theta_u\)\(f_\mathrm{dir}(\mathbf{l})=0\)。这个角度可以和最大衰减距离\(r_\mathrm{max}\)那样使用相似的方式进行剔除。聚光灯通常也可以有一个半影角度\(\theta_p\),它定义着一个在聚光灯内部有着最大光照强度的圆锥体,下图是有关上述角度的例子
img

  各种各样的方向衰减函数可以被用于聚光灯,它们基本上都很相似。例如,\(f_\mathrm{dir_F}(\mathbf{l})\)被用于寒霜游戏引擎,而\(f_\mathrm{dir_T}\)被用于three.js浏览器的图形库

\[\begin{align*} t &= \mathrm{clamp}\left(\frac{\cos\theta_s-\cos\theta_u}{\cos\theta_p-\cos\theta_u},0,1\right)\\ f_\mathrm{dir_F}(\mathbf{l}) &= t^2\\ f_\mathrm{dir_T}(\mathbf{l}) &= \mathrm{smoothstep}(t) = t^2(3-2t) \end{align*} \]

smoothstep函数是个三次多项式,它常被用于着色中的光滑插值中,在很多着色语言中是内建函数。
  下图展示了我们到目前为止讨论过的光源类型
img

其它的光源类型(Other Light Types)

  定向光和点式光主要以光的方向\(\mathbf{l}\)如何被计算为特征。不同类型的光也可以被定义,可以使用其它计算光线方向的方法。例如,除了先前提到的光源类型,古墓丽影也使用线段作为光源。对于被着色的像素来说,会取线段上距离着色点最*的点作为光源位置并计算光照方向\(\mathbf{l}\)
  只要着色器能得到\(\mathbf{l}\)\(\mathbf{c}_\mathrm{light}\)用于评估着色方程,那么就能使用任何方法来计算\(\mathbf{l}\)\(\mathbf{c}_\mathrm{light}\)
  之前讨论的光源类型都是抽象的。在现实中,光源都会有尺寸和形状,而且它们会从多个方向点亮表面某点。在渲染中,这种光被称为区域光源Area Light),它们在实时的应用场景中的运用频率在稳步上升。区域光源的渲染技术有两类,一类为模拟区域光源被部分遮蔽导致的阴影边缘的软化,另一类为模拟区域光源作用于表面着色的效果。第二类方法很适合光滑可以看出光源的形状和尺寸的表面。定向光和点式光都是通常被使用的光源,尽管不会像以前那样无处不在。有些光源面积的*似方法已经被开发出来了,这些方法开销不那么昂贵正在被越来越广泛地使用。GPU性能的不断增强也让过去很多不能使用的复杂技术得以被使用。

实现着色模型(Implementing Shading Models)

  我们最终都是要用代码来实现着色模型。在这个部分,我们将了解一些设计和编写具体实现的关键考量。我们也会了解一个简单的实现例子。

评估频率(Frequency of Evaluation)

  当设计一个着色的实现时,计算需要根据它们的评估频率Frequency Of Evaluation)被分开。首先,如果计算结果在整个绘制调用中总是常量,那么计算一般可以由应用程序(CPU)来完成。应用程序计算出的结果接着通过uniform类型的着色器输入被传递到图形API上。
  对于常量这种类别,最简单的情况就是着色方程中的常量子表达式,但是这可以应用于基于很少改变的参数的任何计算,就比如硬件配置或是安装设置。这种着色计算可能在着色器被编译时就被执行了,在这种情况下甚至不需要设置uniform类型的着色器输入。另外,计算有可能在一次离线的预计算通道中完成,或是在安装时间完成,又或是在应用程序被载入时完成。
  另一种情况是着色计算随着应用程序的运行进行缓慢的改变。在这种情况下进行逐帧更新是不必要的。例如,取决于虚拟游戏世界中一天中的时间的光照参数。如果计算的开销很昂贵,那么可能被分摊到几帧中进行计算。
  另一些情况包括每帧都要进行的计算,例如结合视图和透视矩阵。以评估频率进行uniform类型的着色器输入的分组对于应用程序效率的提升来说很有用,而且能最小化常量的更新次数从而提高GPU的性能。
  如果着色的计算结果在一个绘制调用中改变,那它就不能通过uniform类型的着色器输入来传递。反而,它应该在可编程着色器阶段被计算,如果有需要的话可以通过varying类型的着色器输入传递给其它的阶段,以下是着色器和评估频率之间的对应关系

  • 顶点着色器:评估每个镶嵌细分前的顶点

  • 外壳着色器:评估每个表面的曲面片

  • 域着色器:评估每个镶嵌细分后的顶点

  • 几何着色器:评估每个图元

  • 像素着色器:评估每个像素

材质系统(Material System)

  渲染框架不只是实现了着色器。一般来说,有一个专用的系统会被需要用来处理各种各样的材质、着色模型、由应用程序使用的着色器。
  如之前的章节解释的那样,一个着色器是执行GPU的某个可编程着色阶段的程序。因此,它是一个低级图形API资源,美术师不能直接与其交互。相反地,一个材质Material)是美术师要面对的,它是表面的视觉外表的封装。材质有时也描述非视觉的一些方面,例如碰撞属性,我们在这里仅作了解,因为超出了这本书的范畴。
  虽然材质是通过着色器实现的,但是这不是简单的一对一的对应关系。在不同的渲染情况下,相同的材质可能使用不同的着色器。一个着色器也可以被多个材质共享。最常见的一种情况是参数化的材质。在它的最简形式中,材质的参数化需要两种类型的材质实体,即材质模板Material Template)和材质实例Material Instance)。每个材质模板描述了一类材质,而且有着一系列参数,可以被分配数值、颜色或纹理值,具体取决于参数类型。每个材质实例对应着一个材质模板和一系列特定的参数值。虚幻引擎等渲染框架则允许更加复杂的且层次化的从材质模板派生出的材质模板。
  参数可以在运行时被计算,接着通过uniform类型的输入传递给着色器程序。又或者是在编译时间被计算。一个常见类型的编译期参数是用于控制一个特定的材质特性的开启的布尔值,对于美术师来说这就是一个用户图形界面提供的一个复选框或者由材质系统进行程序化设置。
  尽管材质参数在很多情况下可能与着色模型的参数是一一对应的,在有些情况下则不会这样。一个材质可能把着色模型的参数例如表面颜色修改为常量。另外,一个着色模型的参数可能是通过取一系列材质参数的一系列操作计算出来的。
  材质系统中最重要之一的任务就是把不同的着色器函数划分到分开的元素中,然后控制这些元素的结合。这种组合在很多情形下都很有用,包括以下这些

  • 组合表面着色与几何处理,例如刚体变换、顶点混合、形态变形、镶嵌细分、实例化、裁剪。

  • 组合表面着色与一些合成操作,例如像素丢弃和混色。

  • 组合用于计算着色模型参数的操作与着色模型本身的计算。

  • 组合一些可以单独被选择的材质特性。

  • 组合着色模型和它的一些参数的计算与光源评估。

  如果图形API能提供着色代码的这种类型的模块化,那么将会很便捷。然而,不像CPU代码,GPU着色器是不允许编译后链接代码片段的。用于每个着色阶段的程序是作为一个单元被编译的。单独的一些着色阶段只能提供受限的模块化能力,这有点符合我们提到过的组合表面着色与几何处理。但是这并不是完全符合的,因为每个着色器也会执行其它的操作。在这些限制下,材质系统实现以上所有类型的组合必须在源代码级进行。而这主要涉及字符串操作,例如连接和替换,通常由C语言风格的预处理指令实现,就比如#include#if#define
  早期的渲染系统有着相对少的着色器变体,每个着色器都是被手动编写的。这有着一些优势。每个变体可以在了解最终的着色器程序的情况下被优化。然而,这个方法快速地变得不实际,因为变体的数量在增长。当考虑到所有不同的部分和选项时,着色器变体的数量会很庞大。这就是为什么模块化和可组合性至关重要。
  相反地,现代的CPU在处理动态分支这一方面很强,特别是当分支对于一个绘制调用的所有像素表现得一样的时候。在如今,很多的功能变体例如光源的数量都是在运行时被处理的。然而,给予一个着色器大量的功能变体则会带来完全不同的开销,会导致寄存器数量的增加,从而导致占用率(Occupancy)降低,因此降低了性能。第十八章有更详细的描述。因此,编译期的变体是仍然有价值的,它能避免包含那些从不会被执行的复杂逻辑。
  比如,让我们想象有个应用程序支持三种不同类型的光源。其中两种很简单分别是点光源和定向光。第三种是通用的聚光灯,需要大量的着色器代码来实现,被较少地使用,占用低于应用程序中所使用的光源的\(5\%\)。在过去,一个单独的着色器变体会为三种光源的每种可能的组合被编译,来避免动态分支。尽管在当下不需要这么做了,编译两个分开的变体也可能依然管用,一个用于通用的聚光灯数量等于或超过一个的时候,另一个用于没有通用的聚光灯的时候。后者会有更低的寄存器占用率,从而有更高的性能。
  现代的材质系统通常有运行时和编译期的着色器变体。尽管全部的负担不再由编译期承担,但是总的复杂度和变体的数量还在一直上升,因此有更多数量的着色器变体需要被编译。例如,在游戏Destiny: The Taken King的某些区域,就有超过\(9000\)个被编译的着色器变体在单独一帧中被使用。总的可能的变体的数量则会更大,比如Unity的渲染系统有着接*1000亿种可能的变体。当然了,只有实际被使用的变体会被编译,但是着色器编译系统需要被重新设计来处理庞大数量的可能变体。
  材质系统的设计者通常会实施一些不同的策略来实现这些设计目标。虽然这些策略有时被展现为互斥的系统架构,但是这些策略可以并通常在相同的系统中被组合。这些策略通常包含以下这些

  • 代码复用:在被共享的文件中实现函数,使用#include预处理指令来访问这些函数。

  • 减法:一个着色器通常被称为超级着色器Supershader),通常包含大量的功能,使用了很多编译期的预处理条件语句和动态分支语句,来移除那些不被使用的部分,并且在互斥的选项之间进行切换。

  • 加法:不同的小功能会被定义为节点,它有着输入和输出的连接端口,这些小功能通常能被组合到一起。这和代码复用很像但是更加的结构化。节点的组合可以通过文本或一个可视化的图形编辑器来进行。后者对于非工程师更友好一些,比如技术美术师可以用它来创建新的材质模板。一般来说,着色器只有一部分可以进行可视化图形编辑。例如虚幻引擎的图形编辑器只能影响着色模型的输入,详见下图

img

  • 基于模板的(template-based):可以定义一个接口,只要实现符合该接口,就可以将不同的实现插入其中。这比加法策略来说更加规范一些,通常被用于更大的功能块。一个常见的例子就是分离着色模型计算需要的参数和着色模型的计算本身。虚幻引擎有着不同的“材质域”,包括表面域用于计算着色模型的参数,还有光照函数域用于计算一个标量值,来为一个给定的光源调节\(\mathbf{c}_\mathrm{light}\)。一个相似的“表面着色器”结构同样也存在于Unity中。值得注意的是,延迟渲染技术会(在第二十章被讨论)强制使用一个相似的结构,让G缓冲作为接口。

  对于更加特定的例子来说,WebGL Insights书中的一些章节讨论了不同的引擎是如何控制它们的着色器管线的。除了组合之外,对于现代材质系统来说还有其它重要的设计考量,就比如使用最少重复的着色代码来支持多个*台。这就包括功能上的变体来适配不同*台之间的性能和能力上的差距、不同的着色语言以及不同的API。Destiny着色系统是一个有代表性的解决方案。它使用了一个专有的预处理层,能让着色器使用一个定制的着色语言来编写。这就允许了*台独立的材质的编写,它们会被自动翻译到不同的着色语言和实现。虚幻引擎和Unity引擎有着类似的系统。
  材质系统也需要确保良好的性能。除了专门的着色变体的编译之外,也有一些其它常用的材质系统能进行的优化。Destiny着色器系统和虚幻引擎会自动侦测在一次绘制调用中不变的计算(比如之前提到过的暖色和冷色的计算),并把这种计算移动到着色器之外。另一个例子是在Destiny中使用的作用域系统,这个系统被用来区分更新频率不同的常量,并在合适的时间进行更新来减少API方面的负担。
  正如我们看到的那样,实现一个着色方程关乎到决定什么部分可以被简化、不同的表达式的计算频率、用户要怎样修改和控制外表。渲染管线的最终输出是一个颜色和一个混合值。余下的部分如抗走样、透明度、图像显示则关乎到这些值是如何被结合和修改并被最终用于显示的。

走样和抗走样(Aliasing and Antialiasing)

  假如我们要渲染面积非常大的有着网格图案的地面,以接**视的角度进行观察。纹理坐标通过缩放顶点携带的世界坐标计算出来,如果不进行处理直接采样网格纹理中离纹理坐标最*的纹素(点采样)作为颜色输出,那么渲染出的图像会有极其异常的地方,下方为一张示例图。

img

从上图可以发现视线越接*水*异常就越明显。这种问题的发生其实很好理解,当视线越来越接*水*时,相邻像素之间的世界距离会越来越大,这就导致了计算出来的纹理坐标之间的跨度越来越大。在这种情况下,在纹理中的采样位置会变得相对随机,导致了蓝色和黑色像素的相对随机的分布,我们因而看到了上图中许多不像网格看起来很奇怪的地方。
  接下来让我们了解另一个例子,假如我们要采样正弦波,让采样后的样本点表示正弦波。当采样率很低时,我们会发现样本点组成的波形根本就不像原始的正弦波,如下图所示

img

  以上的两个例子均出现了“不像”的问题,我们把这种问题称为走样Aliasing),为了理解这个问题我们接下来必须了解下采样和滤波理论。

采样和滤波理论(Sampling and Filtering Theory)

  通过之前所说的例子,我们能知道走样会发生在低采样率的情况下,采样后的信号会不像原始信号。根据哈里·奈奎斯特得出的结论,一个带限(Band-limited)信号(频率范围有限的信号)如果要被恰当地采样,那么采样频率必须高于信号的最高频率的两倍。这通常被称为采样理论Sampling Theorem),采样率被称为奈奎斯特率Nyquist RateNyquist Limit)。接下来我们了解一个例子,假设有车轮以\(T\)(频率为\(1/T\))的周期在旋转,我们以不同的拍照频率进行拍照,下图是一张拍照后的示例图

img

第一行为较高采样率,从这一行我们可以看出车轮在进行顺时针方向的旋转。第二行为低采样率,我们可以看到车轮在逆时针旋转,这和车轮的实际旋转方向相悖。第三行为中等的采样率,拍照周期为\(T/2\)(频率为\(2/T\)),车轮每旋转半圈就拍一次照,此时我们还是不知道车轮是如何旋转的。第四行的拍照周期比第三行稍短(频率比第三行稍高),在这一行我们立刻就能知道车轮是如何旋转的。通过这个例子,我们就理解了奈奎斯特率。
  那么对于走样来说,一般有两种解决方法。第一种是简单地提高采样率,第二种是想办法滤除被采样信号中的高频信号,然后再进行点采样。第一种方法很直接且粗暴,但是在很多情况下我们不能这么做。比如,对于之前提到的网格地面的渲染,被渲染的纹理的分辨率一般是受限的,即使不受限强行提高渲染的分辨率会极大地增加GPU的负担。
  因此我们最好采用第二种办法,这则涉及到了滤波。为了滤除被采样信号中的高频信号,我们可以使用一个低通滤波器Low-pass Filter)。对于非常理想的情况,我们可以使用sinc滤波器进行滤波,这个滤波器的表达式如下

\[h_{LPF}(x) = 2 B_L \mathrm{sinc}(2 B_L x) = \frac{\sin(2 B_L \pi x)}{\pi x} \]

它能滤除信号中频率高于\(B_L\)的信号,而又不影响频率低于\(B_L\)的信号,因此是个理想的低通滤波器。有了滤波器后我们直接让它与被滤波的信号进行卷积Convolution),卷积后再进行点采样。关于这个卷积操作我们可以这么直观地理解一下,就是把被滤波的信号分解成不同频率的正弦波,然后去掉所有频率高于\(B_L\)的正弦波,最后对剩余的波进行合成得到被滤波后的信号。由于滤除了高频信号,滤波后的信号会显得*滑一些,如果信号表示的是图像,那么会让图像在某些地方稍显模糊。虽然和使用高分辨率纹理进行渲染之间还是有差距,但是相比于直接进行点采样改善了不少,下面这张示例图依次展示了使用高分辨率纹理进行渲染、直接使用点采样、滤波后再进行点采样的渲染图像。

img

重采样(Resampling)

   通过上述部分的内容,我们已经对走样、抗走样有了些初步的了解。在实践中我们遇到的很多问题会和重采样有关,它关乎到放大或缩小被采样的信号,即从表示一个信号的一些样本得到表示相同信号的另一些新样本。我们之前提到的网格地面的渲染就是这样一个例子,网格纹理上的一些纹素被采样,转换到了被渲染的纹理上的一些纹素上。接下来我们通过一次假想的实践来了解下重采样。
  假设有个带限的连续信号,我们要以\(f\)的采样率采样这个信号。那么得先使用sinc滤波器进行滤波,滤除原始信号中频率在\(f/2\)以上的信号。滤波后我们以\(f\)的采样率采样被滤波后的信号。那么现在已经有了一些样本,可以放大或缩小被采样的信号。
  不论是放大还是缩小,我们都得先从样本重建连续信号,对于我们这种情况来说,直接使用sinc滤波器进行重建即可,要注意重建使用的sinc滤波器的截止频率为\(f/2\)
  对于放大来说,使用的采样率会高于\(f\),因此不会出现走样,所以直接采样重建后的信号即可,下方是一张形象的示例图。

img

  对于缩小来说我们不能这么做,因为可能会发生走样。为了避免走样,我们应该对重建后的信号进行滤波。假设缩小使用的采样率为\(f/2\),那么得使用sinc滤波器来滤除重建信号中频率高于\(f/4\)的信号,接着再以\(f/2\)的采样率进行采样,下方为一张形象的示例图。

img

  在实时渲染中使用sinc滤波器是不切实际的,因为它的半径是无限的,而且它在某些地方会取负值,因此在许多应用场景下根本工作不了。我们接下来了解下实时渲染中使用的一些抗走样技术。

基于屏幕的抗走样(Screen-Based Antialiasing)

  如果没有被采样和滤波,三角形的边缘会有显眼的伪影。除此之外,阴影边缘、镜面高光还有其它的颜色快速改变的现象也会有类似的问题。在这个部分讨论的算法可以帮助改善这些情况下会遇到的问题。它们都是基于屏幕的,即它们只会操作来自管线的输出样本。在这些算法中没有最好的抗走样技术,每个算法都有着不同的优势,主要在质量、捕捉锐利的细节或其他现象的能力、移动中的外表、内存消耗、GPU需求、速度这些方面有区别。
  在我们之前提到的网格地面的渲染例子中,我们遇到的问题是采样率不够,而信号频率在增加(纹理坐标的跨度在增大)。只有一个样本在每个像素的网格单元的中央被采样出。通过为屏幕的每个网格单元使用多个样本,然后使用一些方法混合这些样本,我们可以得到一个更好的像素颜色,这其实起到了一个滤波的作用,下方为一张示例图。

img

  基于屏幕的抗走样方法的一般策略是使用某种采样方式进行采样,接着对样本加权求和来得到一个像素颜色\(\mathbf{p}\),公式大体如下

\[\mathbf{p}(x,y) = \sum_{i=1}^{n} w_i \mathbf{c}(i,x,y) \]

式中的\(n\)是采样的次数,\(\mathbf{c}(i,x,y)\)是样本颜色,\(w_i\)是每个样本的权重,在区间\([0,1]\)。采样位置会通过输入的样本索引\(i\)和像素位置\((x,y)\)计算出,对于每个像素来说可能有不同的样本分布。在实时渲染系统中,样本通常都是通过点采样得到的。因此函数\(\mathbf{c}\)可以被认为是两个函数。第一个函数得到采样位置,第二个函数进行点采样。具体的采样方案被选择后,渲染管线接着会被配置来计算在特定亚像素位置的样本。采样方案一般都基于每帧(或每个应用程序)的设置。
  抗走样中的另一个变量是权重\(w_i\)。样本权重的总和一般都为\(1\)。在很多用于实时渲染系统的抗走样方法中,每个样本都会使用相同的权重(\(w_i=\frac{1}{n}\))。抗走样的最简单形式就是取像素中心处的一个样本,对于这种情况来说,权重为\(1\),且采样位置总是为像素中心的位置。
  那些为每个像素计算超过一个样本的抗走样算法被称为超采样Supersampling)。其中,概念上最简单的是全场景抗走样Full-scene AntialiasingFSAA),它也被称为“超采样抗走样”(Supersampling Antialiasing,SSAA)。它会以更高的分辨率渲染场景的图像,然后滤波相邻的样本来生成最终的图像。比如,如果希望最终得到一张1280x1024的图像,那么可以先以2560x2048的分辨率渲染一张图像,接着在2x2的像素范围内求*均值,最后把*均值写入1280x1024的图像。由于提高了渲染的分辨率,这个方法的开销会很高,它的主要优势是非常简单。其它的更低质量的版本会进行1x2或2x1的超采样。一般来说,2的幂次的分辨率和方框滤波器会因为简单而被使用。NVIDIA的动态超级分辨率Dynamic Super Resolution)是一个更加复杂的超采样形式,场景会以更高的分辨率被渲染,在这之后使用了13个样本的高斯滤波器会被使用来生成最终的图像。
  一个和超采样有关的采样方法基于累积缓冲Accumulation Buffer)。这个方法使用了一个和期望的图像有着相同分辨率的缓冲,但是每通道有着更多的颜色比特位。为了获取一个场景的2x2采样,四张图象会被生成,每张图像的渲染都会将视图在屏幕的x方向或y方向上移动半个像素,这其实就是采样了每个网格单元格中的不同位置。它的开销在于要为每一帧最终看到的图像渲染多张图象,而且还要拷贝结果到屏幕上,对于实时渲染系统来说开销非常高。在某些性能不是特别重要的应用场景中,它能被用来生成高质量图像。在过去,累积缓冲在硬件中是分开进行存储的。它被OpenGL API直接支持,但是到了3.0版本被弃用。在现代GPU中,累积缓冲可以使用一个有着更高精度的颜色格式的缓冲在像素着色器中实现。
  当有物体的边缘、镜面高光、锐利的阴影这些现象时,额外的样本会被需要。对于阴影来说,可以使用更软的阴影来避免走样。而对于高光来说,可以使用更*滑的高光来避免。一些特别的物体类型,例如电缆可以通过增加尺寸来确保在每个位置都会覆盖至少一个像素。物体边缘的走样,在如今仍然还是个主要的采样问题。解析法也许是可以使用的,在渲染的时候可以检测物体的边缘,把它们的影响考虑在内,但是这些方法通常都更加昂贵,而且抗扰性一般都不如简单地取更多的样本。然而,GPU的一些特性例如保守光栅化和光栅顺序视图开启了更多的可能。
  超采样和累积缓冲通过生成更多的样本来达到抗走样,总体的收益是相对低的,而代价是相对高的,因为每个样本都得运行一次像素着色器。
  多重采样抗走样Multisampling AntialiasingMSAA)通过在样本之间共享为每个像素进行的表面着色的计算结果减轻了高计算开销。像素,比如有4个采样位置,每个采样位置都有对应的颜色和z深度,但是像素着色器只会为每个像素对应的每个物体片段评估一次。如果所有的MSAA位置样本都被片段覆盖,那么着色样本会在像素中心进行评估。如果片段覆盖更少的位置样本,那么着色样本的位置可以偏移到一个能更好地表示片段所覆盖位置的地方。这么做能避免着色采样在图元边界外。这个位置调整被称为质心采样Centroid Sampling)或质心插值Centroid Interpolation),如果这个特性被开启,那么一般都会由GPU自动完成。质心采样避免了偏离三角形的问题,但是会导致导数的计算返回错误的结果。
  相对于纯超采样方法来说MSAA更加快速,因为每个片段只被着色一次。它专注于以更高的采样率采样片段的像素覆盖率,并共享计算的着色结果。通过进一步解耦采样和覆盖率是有可能节省更多内存的,它也能让抗走样更快些,因为触碰了更少的内存。NVIDIA因此在2006年引入了覆盖率采样抗走样Coverage Sampling AntialiasingCSAA),AMD紧跟着发布了增强质量抗走样Enhanced Quality AntialiasingEQAA)。这些技术通过只以更高的采样率存储片段的覆盖率而工作。比如,EQAA的“2f4x”模式会存储两个颜色和两个深度,并在四个样本位置之间共享。颜色和深度不再为每个特定的位置存储而是被存储于表中,四个样本会分别指向在表中和它关联的颜色和深度值,如下图所示。

img

覆盖率的样本决定了每个片段对最终的像素颜色的贡献。如果存储的颜色数量超过了范围,那么一个存储的颜色会被去除,并且它的样本会被标记为未知。这些样本对最终的颜色没有贡献。对于大多数场景来说,只会有很少的像素包含三个或更多的可见的不透明的着色后差异很大的片段,因此这个方法在实践中表现得很好。然而,为了最高的质量,游戏地*线2使用了4xMSAA,尽管EQAA有着更好的性能优势。
  当所有的几何物体被渲染到了多样本缓冲上时,被称为解析Resolve)的操作将会被需要。这个过程会把样本的颜色*均到一起来决定像素的颜色。值得注意的是,使用多重采样应用于高动态范围颜色值时会出现问题。为了避免这种情况,一般会需要在解析前进行色调映射。然而它的开销会很昂贵,因此会需要色调映射的简单*似函数或是使用别的方法。
  在默认情况下,MSAA是使用方框滤波器进行解析的。在2007年,ATI引入了自定义滤波抗走样Custom Filter AntialiasingCFAA),这个技术同时使用了窄的和宽的略微延伸到其它像素格子内的帐篷滤波器,不过它后来被EQAA取代了。在现代的GPU中,像素着色器或计算着色器可以随意访问MSAA样本,并使用任何期望的重建滤波器,包括那些采样周围像素的样本的滤波器。更宽的滤波器可以避免走样,尽管会损失锐利的细节。Pettineo发现三次smoothstep滤波器和有着二或三个像素宽的B样条滤波器在总体上能获得最好的结果。但是这也有着性能开销,在着色器中模拟默认的方框滤波器进行解析已经有更大的性能开销,更宽的滤波器则会进一步增大性能开销。
  NVIDIA内建的TXAA使用了一个更好的重建滤波器,它有着比单个像素更大的范围。它和更新的MFAA(多帧抗走样,Multiframe Antialiasing)方法一样使用了时间抗走样Temporal AntialiasingTAA),这种抗走样技术会使用前几帧来改善输出的图像,因此能更好地对抗旋转的车轮这类与时间相关的走样,而且也能改善边缘的渲染质量。
  想象一下通过使用像素内的不同的位置渲染一系列图像,来达到“手动”实现一个采样模式。在像素内的偏移通过为投影矩阵附加一个小的*移得到。进行*均的图像越多,那么得到的结果就越好。这个概念被用于时间抗走样算法中。一个单独的图像可能是使用MSAA或者其他方法生成的,它会与之前的图像进行混合。一般来说会使用两到四帧。更早的图像可以有指数级的低权重,但是在观察者或场景不移动时会造成画面闪烁的效果,因此通常会对上一帧和当前帧使用相同的权重。通过使用不同的亚像素位置的样本,这些样本的加权后的和会比单独的一帧有更好的边缘的覆盖率估计。每一帧不需要额外的样本,因此这个方法很受青睐。甚至有了使用时间上的采样以低分辨率渲染多个图像再放大到显示分辨率的可能。此外,通过使用这个方法,光照或是其它需要多个样本来取得好结果的一些技术可以在每帧中使用更少的样本,因为输出的结果是通过混合一些帧做到的。
  虽然这个方法能在不需要额外的采样下为静态的场景提供很好的抗走样,但是被用于时间抗走样时会出现一些问题。如果帧之间的权重是不同的,那么静态场景中的物体会有闪烁。此外,快速移动的物体或是相机快速移动将会导致鬼影,正是因为前几帧的贡献。一个来避免鬼影的方法是只对低速移动的物体进行抗走样。另一个重要的方法是重投影Reprojection),它可以更好地关联之前帧和当前帧的物体。在这种方法中,物体生成运动向量存储在一个单独的“速度缓冲”中。当前像素位置减去运动向量就能知道物体表面在上一帧中在哪。那些在当前帧中不像是表面一部分的样本会背抛弃。因为没有额外的样本,而且也只是多了一些少的额外工作,这种时间抗走样在最*几年引发了很大的关注,它也被业界更多的人接受。有些是因为延迟渲染技术与MSAA和其它多重采样抗走样不兼容。有些取决于实际的应用场景和目标的一系列避免走样并改善质量的技术已经被开发出来了。Wihlidal就展示了EQAA、时间抗走样、不同的滤波技术是如何被用于棋盘采样模式并被结合起来,并且在像素着色器的调用次数下降的情况下维持质量的。Iglesias-Guitian等人总结了前人的工作并展示了他们的使用像素历史和预测来最小化滤波伪影的方法。

采样模式(Sampling Patterns)

img

  高效的采样模式是减少时间和其他方面上的走样的关键。Naiman揭示了人最容易被接*水*方向和接*竖直方向上的边缘打扰。接*45度的边是次打扰人的。旋转网格超采样Rotated Grid SupersamplingRGSS)使用了一个旋转后的方形采样模式来给予更多像素内的垂直分辨率和水*分辨率。
  RGSS模式是拉丁超立方采样Latin Hypercube)或N车采样N-rooks Sampling)的一种形式,在这种模式中\(n\)个样本会被置于\(n \times n\)的网格中,每行每列都会有一个样本,正如上图所示的2x2 RGSS那样。这种模式非常有利于捕捉接*水*和垂直的边,而对于规则的2x2采样模式来说,接*水*和接*垂直的边有可能覆盖奇数个样本,这会导致较差的效果。
  N车采样是好的采样模式的开始,但是它还不够好。比如,样本可以沿着某个对角线放置,这对于那些与选定的对角线*行的边来说有差的结果,详见下图。

img

从上图能看出,实际上就是另一个对角线方向上的采样率很低,所以才会有上述问题。为了进行更好地采样,我们应该避免把两个样本放一起。为了遵循这样一种模式,分层采样Stratified Sampling)技术如拉丁超立方采样会与抖动(jittering)、Halton序列、泊松圆盘采样等方法结合起来被使用。
  在实践中,GPU制造商通常会在硬件中固定实现上述的采样模式,来专门进行多重采样抗走样。下图展示了一些在实践中被使用的MSAA模式。

img

上图中,红色的为位置样本,绿色的是着色样本。从左到右依次为:2x、4x、6x(AMD)、8x采样。对于时间抗走样,采样模式可以按照编程者的想法来,采样位置在每帧中都可以不一样。比如Karis发现基础的Halton序列Halton Sequence)工作得比GPU提供的任何MSAA模式好。一个Halton序列会生成在空间中表现得随机但是有着低的差异度(discrepancy)的样本,也就是说它们在空间中分布得很好,而且没有一个是聚集的。
  虽然亚像素网格模式能更好地描述每个三角形是如何覆盖网格单元的,然而它不是理想的。一个场景可能由在屏幕上非常小的物体构成,也就是说没有采样率可以完美地捕捉它们。如果这些小物体或特性组成了一个图案,那么以常间隔采样将会莫尔条纹和其它干涉图案。在超采样中使用的网格模式就能导致这种走样。
  一个解决方案是使用随机采样Stochastic Sampling),它能给予一个更加随机化的模式。想象一下有一个隔着一定距离的有着细齿的梳子,使用常规的模式会带来严重的伪影,因为采样率完全不够。一个不那么有序的采样模式可以改善这种情况,但是随机化趋向于把重复的走样替代为噪音,不过对于人的视觉系统来说没有走样那么显眼。一个有着更少结构的采样模式会更好,但是还是会有那种从像素到像素重复的走样。一个解决方案是为每个像素使用不同的采样模式,或者是让采样位置随时间改变。在交错采样Interleaved Sampling)中,每个像素都有不同的采样模式,它在过去的几十年中偶尔被硬件支持。Molnar和Keller还有Heidrich发现了使用交错的随机采样可以最小化为每个像素使用相同的采样模式导致的走样。

形态学方法(Morphological Methods)

  走样通常由边缘导致,比如几何物体的边缘、锐利的阴影、明亮的高光。走样通常都有一个和它关联的结构这个认知可以被利用,来获得更好的抗走样结果。在2009年,Reshetov展示了一个相关的算法,这个算法被称为形态学抗走样Morphological AntialiasingMLAA)。“形态学”的意思是“和结构或形状相关”。早前已经有些成果在这个领域,可以追溯到Bloomenthal在1983年发表的一些成果。Reshetov的论文重新激发了使用多重采样的替代方法的研究,主要强调搜寻并重建边缘。
  这种类型的抗走样是以后处理的形式进行的。也就是说,渲染是以一般方式完成的,渲染的结果会被送入一个流程,这个流程最终会生成抗走样后的结果。自从2009年已经有很多相关的技术被开发。那些能利用额外的缓冲(法线或是深度等等)的技术可以提供更好的结果,就比如亚像素重建抗走样Subpixel Reconstruction AntialiasingSRAA),但是这些技术只能用于几何边缘。解析方法,例如几何缓冲抗走样Geometry Buffer AntialiasingGBAA)和边缘距离抗走样Distance-to-edge AntialiasingDEAA)等会让渲染器计算三角形的边在哪和像素中心到边的距离等一些额外的信息。
  最一般的方案仅会使用颜色缓冲,意味着也能改善阴影和高光等一些现象以及使用了各种后处理技术造成的边缘。比如,方向局部抗走样Directionally Localized AntialiasingDLAA)就基于对边的观察,对于几乎垂直的边可以进行水*模糊,而对于几乎水*的边可以进垂直模糊。
  更加复杂的边缘检测形式则尝试包含任意角度的边的像素,并决定边在像素中的覆盖率。在潜在的边周围的相邻像素也会被检查,用来找到原始的边最可能的位置。边缘对像素的影响接着能被用来混合相邻的像素颜色。下图是上述流程的一个概念图

img

注解:观察上图可以看到有两条可能的边被找到了,那条可能性最高的边被用来混合相邻颜色,混合结果最终被写入到了中间的像素。

  Iourcha等人通过检查MSAA的样本来计算一个更好的结果,改善了边缘查找。因此值得注意的是,边缘预测和混合相比于基于采样的算法的结果有着更高的精度。例如,一个使用每像素四样本的技术可以有五种物体边缘的混合级别,包括无样本被覆盖、一个样本被覆盖、两个样本被覆盖、三个样本被覆盖、四个样本被覆盖。边缘的估计位置变多了,因此能提供更好的结果。
  基于图像的抗走样算法可能也会出问题。首先,如果两个物体之间的颜色差异小于算法的阈值,那么边缘可能不会被检测到。此外,有着三个或更多不同表面重叠的像素很难进行解析。那些有着高对比度和高频率元素的表面会导致算法漏掉边缘。特别地来说,文字质量会受到形态学抗走样的影响。物体的边角也是个挑战,有些算法会让边角变得圆滑。曲线也有可能被“边缘都是笔直的”这一假设影响。单个像素的改变会对边的重建造成影响,从帧到帧之间会有显眼的伪影。一个用来改善这个问题的方法是使用MSAA覆盖掩码(MSAA coverage mask)来改善边缘的确定。
  形态学抗走样方法只会使用被提供的信息。比如,如果有物体的宽度比像素还细(就比如电缆或绳索),那么在屏幕上绘制时会有间隙,因为有些像素的中心没有被物体覆盖。使用更多的样本可以进行改善,而基于图像的抗走样就不行了。此外,执行时间会取决于实际看到的内容。比如,草场相对于天空将会花费更多的时间进行抗走样。
  总而言之,基于图像的抗走样对内存的要求和它执行的开销是不大的,因此在很多应用场景下被使用。只需要颜色信息的版本是与渲染管线解耦的,让它们能被轻易修改或关闭,此外它们甚至能以GPU驱动选项的形式进行暴露。两个最受欢迎的算法分别为快速*似抗走样Fast Approximate AntialiasingFXAA)和亚像素形态学抗走样Subpixel Morphological AntialiasingSMAA),这两个算法都提供了坚实且免费使用的能用于不同机器的源代码实现。它们都只使用颜色输出,而SMAA主要有能访问MSAA样本的优势。这两个算法本身都有一些可用的设置,通过在速度和质量间权衡进行选择。开销一般都在1至2毫秒每帧之间,这种开销通常是视频游戏所期望的。最后,这两个算法可以利用时间抗走样。Jimenez展示了一个改进的SMAA实现,比FXAA要快,而且描述了一个时间抗走样方案。

透明度、阿尔法、合成(Transparency, Alpha, and Compositing)

  半透明物体可以有很多种方式让光穿过它们。对于渲染算法来说,可以被大致分成基于光照的效果和基于视图的效果。基于光照的效果关乎到物体导致光被衰减或变向,从而导致场景中的其它物体被点亮并被不同地渲染。基于视图的效果则关乎到半透明物体本身是怎么被渲染的。
  在这个部分,我们将会与基于视图的透明度的最简形式打交道,在这之中,半透明物体将会作为在它背后物体的一个颜色衰减器。更加复杂的基于视图的和基于光照的效果,比如毛玻璃、光线的弯折(折射)、光因为透明物体厚度的衰减、反射率和透射率因为观察角度的改变这些将会在后续的章节中被讨论。
  一个用来制造透明度幻觉的方法被称为纱窗透明Screen-door Transparency)。它的想法是使用像素对齐的棋盘填充图案来渲染透明三角形。也就是说,三角形中每个其它的像素会被渲染,因而让背后的物体部分可见。 一般来说,屏幕上的像素会相隔很*,足够让棋盘图案本身不可见。这个方法的一个主要劣势是,在屏幕的一个区域通常只有一个透明物体能被确信地渲染出来。例如,如果一个透明的红物体和一个透明的绿物体被渲染在蓝物体之上,三个颜色中只有两个能出现在棋盘图案上。此外,50%的棋盘是受限的。其它更大的像素蒙板可以被使用来给予更大的百分比,但是这通常会制造一种肉眼可见的图案。
  也就是说,这个技术的一个优势是它的简单性。透明的物体可以在任何时候被渲染,也没有顺序要求,此外还没有特殊硬件的需求。它通过使所有物体在它们覆盖像素的地方不透明从而避免了透明度问题。相同的想法也在裁剪(Cutout)纹理的边缘的抗走样中被使用,但是是在亚像素的级别。使用了一个叫阿尔法到覆盖率Alpha To Coverage)的特性。
  Enderton等人引入了一个叫随机透明Stochastic Transparency)的技术,它结合了亚像素纱窗掩码与随机采样。一个合理的但是有噪声的图像可以通过点状图案来表示片段的阿尔法覆盖率。详见下图

img

在右下角的放大区域可以观察到噪声。每像素会需要大量的样本来让最终的结果看起来合理,因此会需要很多内存。它受到青睐是因为不需要混合,抗走样、透明度和其它现象所创造的被部分覆盖的像素都由这一个机制所实现。
  绝大多数透明度算法都会混合透明物体的颜色和在它背后物体的颜色。为此,就有了阿尔法混合这一概念。当一个物体被渲染到屏幕上时,一个RGB颜色和一个z缓冲深度值会和每个像素关联。另一个元素被称为阿尔法(\(\alpha\)),也能为物体覆盖的每个像素被定义。阿尔法是一个描述不透明程度的一个值,也是一个物体的片段对于一个给定像素的覆盖率。1.0的阿尔法意味着是完全不透明的,而0.0的阿尔法意味着是完全透明的。
  一个像素的阿尔法可以表示不透明度和覆盖率中的某一个或是同时表示两者,具体取决于实际情况。例如,肥皂的泡泡可能覆盖像素的75%,同时又可能是透明的让90%的光穿过它到达眼睛,因此不透明度为0.1。它的阿尔法可能是\(0.75 \times 0.1 = 0.075\)。然而,如果我们使用MSAA或者类似的抗走样方法时,覆盖率会由样本本身来考虑。75%的样本会被肥皂泡影响,这些样本将会使用0.1的不透明值作为阿尔法。

混合顺序(Blending Order)

  为了让物体表现得像透明一样,它会在已经存在的场景之上进行渲染,使用低于1.0的阿尔法值。每个被物体覆盖的像素将会收到来自像素着色器的一个结果RGB\(\alpha\)。混合这个片段值和原始的像素颜色通常是使用over运算得到的,如下所示

\[\mathbf{c}_o = \alpha_s \mathbf{c}_s + (1-\alpha_s) \mathbf{c}_d \]

其中,\(\mathbf{c}_s\)是透明物体的颜色(被称作源(source)),\(\alpha_s\)是物体的阿尔法,\(\mathbf{c}_d\)是混合前的像素颜色(被称为目标(destination)),\(\mathbf{c}_o\)是透明物体摆放在已经存在的场景的上方得到的结果颜色。如果送入的\(\alpha_s\)\(1.0\)那么公式就会简化为简单的颜色替代。
  over运算给予了被渲染的物体半透明的外表。使用它模拟现实世界中薄纱织物的效果。在织物后的物体会被部分遮蔽,因为织物的丝线是不透明的。在实践中,松散的织物所具有的阿尔法覆盖率会随角度变化。我们的观点是,阿尔法模拟了材质覆盖了像素多少。
  over运算相比于其他的透明效果来说是没有那么“真”的,观察染色的玻璃或塑料时特别能感受到这一点。在现实世界中,一个红色滤镜在一个蓝色物体面前时,蓝色物体会看起来暗一些,因为有些光被滤镜反射掉了,比如如下图所示那样

img

over被用于混色时,混色结果是部分的红色和部分的蓝色叠加到一起。或许让两个颜色相乘会更好一些,当然也可以考虑透明物体造成的反射带来的效果。
  在基础的混合阶段的一些运算中,over是最常被用于透明效果的。另一个被通常使用的运算是加法混合Additive Blending),在这之中像素值仅仅是求和计算的,如下所示

\[\mathbf{c}_o = \alpha \mathbf{c}_s + \mathbf{c}_d \]

这种混合模式对于发光效果来说非常好,例如闪电或火花这两个不会让背后的像素衰减而是让它们变亮的效果。然而,这种模式会让透明度看起来不正确,因为不透明的表面会看起来像没有被滤掉颜色一样。对于多层的半透明表面来说,比如烟雾或火焰,加法混合有着让颜色变饱和的现象。
  为了恰当地渲染透明物体,我们需要在不透明的物体被绘制后再绘制透明物体。也就是说,先关闭混合渲染所有的不透明物体,接着再使用Over运算渲染透明物体。理论上来说,我们可以让over保持开启,在渲染不透明物体时使用1.0的阿尔法。但是这么做会带来额外的开销,没有实际的收益。
  z缓冲的一个限制是每像素只对应一个物体。如果数个透明物体覆盖相同的像素,那么z缓冲将解决不了可见度的问题。当使用over运算时,透明表面必须以从远到*的顺序进行渲染。不这么做会带来不正确的感知线索。一个用来这么做的方法是以物体中心在视线上离观察者的距离进行排序。这种粗略的排序在一些情况下工作得很好,但是在一些情况下会有一些问题。首先,渲染顺序只是一个*似,被认为是更*的物体可能实际上比被认为更远的物体要远离视线。对于相互穿透的物体来说,为所有的视角进行逐网格的排序是不可能得到正确的绘制顺序的,除非把网格分解成独立的部件。下图是一个相关的例子

img

可以看到,左图以随机顺序渲染有很大的问题,而右图的深度剥离则提供了正确的外表,不过有额外通道的开销。甚至单独的一个有着凹形结构的网格也会导致排序的问题,比如当它在某些视角下在屏幕上与自己重叠时。
  尽管如此,因为它的简单性和速度上的优势,还有不需要额外的内存和特殊的GPU支持,为透明度执行粗略的排序仍然在被使用。当为透明物体进行颜色混合时,通常最好关闭z深度替代。这样能让透明物体至少以某些形式出现,而不会在相机的变化引起了排序顺序的改变时突然地出现或消失。另一个技巧也能帮助改善外表,比如绘制每个透明网格两次,第一次渲染背面,第二次渲染正面。
  over方程也可以被修改,让以从前往后的顺序进行的混合也能有相同的结果。这种混合模式被称为under运算,如下所示

\[\begin{align*} \mathbf{c}_o &= \alpha_d \mathbf{c}_d + (1-\alpha_d) \alpha_s \mathbf{c}_s \\ \mathbf{a}_o &= \alpha_s (1-\alpha_d) + \alpha_d = \alpha_s - \alpha_s \alpha_d + \alpha_d \end{align*} \]

要注意的是,under要求目标颜色也持有一个阿尔法值,而over不需要。此外,计算阿尔法的公式是顺序无关的,源和目标的阿尔法可以被交换,最终会有相同的阿尔法输出。
  阿尔法的公式来自把片段的阿尔法视为覆盖率。Porter和Duff提到,因为我们不知道任何片段的覆盖范围的形状,所以假设每个片段覆盖其它片段的比例为它的阿尔法。例如,如果\(\alpha_s=0.7\),那么像素会被分为两个区域,一个覆盖范围为0.7被源片段覆盖,另一个覆盖范围为0.3不被覆盖。再假设\(\alpha_d=0.6\),被源片段覆盖的比例。那么\(\mathbf{a}_o\)的计算公式就有如下的几何解释

img

顺序无关的透明度(Order-Independent Transparency)

  under公式会被用来把所有的透明物体绘制到一个单独的颜色缓冲上,这个颜色缓冲接着作为“上层”与场景的不透明视图的颜色缓冲使用over进行混合。另一个under运算的的用法是执行一次叫深度剥离Depth Peeling)的顺序无关的透明Order-independent TransparencyOIT)算法。顺序无关意味着应用程序不需要进行排序。它的想法是使用两个z缓冲进行多次通道。首先,一次渲染通道会让所有表面包括透明表面的z深度都处于第一个z缓冲中。在第二次通道中,所有透明的物体都会被渲染。如果一个物体的z深度能与第一个z缓冲中的数值匹配,那么我们就能知道这是最*的透明的物体,并把它的RGB\(\alpha\)写入到一个单独的颜色缓冲中。此外,我们也将这一层“剥离”,存储那些有着比第一个z深度高且最*的透明物体的深度。这个深度就是第二*的透明物体的距离。后续的通道接着这么剥离,并使用under来添加透明层。在一些通道后,我们让透明图像作为“上层”与不透明图像进行混合。下方为一张示例图

img

图中,左边的为第一层,可以被眼睛直接看到。中间的为第二层,为第二*的透明表面,在这种情况下是物体的背面。右边的为第三层,是第三*的透明表面。
  这个方法也有一些变体。比如,Thibieroz给予了一个从后往前工作的算法,它的优势在于能立刻混合透明值,也就是说不需要分开的阿尔法通道。深度剥离的一个问题是需要知道多少次通道足够捕获所有的透明层。一个硬件方案是提供一个像素绘制计数器,这样就能知道有多少像素在渲染时被写入。使用under的一个优势是,最重要的透明层也就是眼睛最先看到的那一层会先被渲染。每个透明表面总是会增加它覆盖的像素的阿尔法值。如果像素的阿尔法值接*1.0,那么混合的贡献会让像素几乎不透明,因此一些更远的物体将会有不可见的一个效果。使用从前往后的剥离则可以提前停止,避免这种情况。
  虽然深度剥离很有效,但是会很慢,因为每次只剥离一层。Bavoil和Myers提出了双深度剥离,这个方法使用了两个深度剥离层,最*的层和最远的层会被保留,在每次通道中两个深度剥离层会被剥离,因此渲染通道的数量为原来的一半。Liu等人探索了一个基于桶排序的方法,它能在一次通道中捕获至多32层。这个方法的一个缺点是需要可观的内存来保持所有层排序后的顺序。如果再加上MSAA或者类似的技术,那么开销将会急剧增加。
  以交互式帧率在每帧混合所有透明物体的问题并不是因为缺少可行的算法,而是需要高效地把算法应用于GPU。在1984年,Carpenter提出了A-Buffer,另一种多重采样的形式。在A-buffer中,每个被渲染的三角形会为它完全或部分覆盖的屏幕网格单元生成一个覆盖掩码Coverage Mask)。每个像素会存储一个有着所有相关片段的列表。不透明的片段会剔除那些在它背后的片段,和z缓冲相似。所有被存储的片段都是用于透明表面的。一旦所有的列表都得到了,一个最终的结果可以通过遍历片段并解析每个样本得到。
  在GPU上创建片段的链表这一想法是通过DirectX 11暴露的新功能实现的。使用的特性包括无序访问视图(UAV)和原子操作。通过MSAA进行的抗走样是通过开启访问覆盖掩码的能力,并在每个样本处评估像素着色器完成的。这个算法会光栅化每个透明表面,并把生成的片段插入到一个长的数组中。与颜色和深度一起,一个单独的指针结构会被生成,它会与为像素存储的前一个片段链接。另一个单独的通道接着被执行,在这个通道中会有一个填充屏幕的四边形被渲染,来让像素着色器评估每个像素。这个着色器会通过遍历链表读取每个像素处的透明片段。每个读取的片段会依次与之前的片段排序。这个有序列表接着被用来以从后往前的顺序进行混合,来得到最终的像素颜色。因为混合是由像素着色器执行的,所以对于每个像素来说可以有不同的混合模式。随着GPU和API的不断进化,原子操作的开销在变得越来越低。
  A-buffer的一个优势是只有被像素需要的片段会被分配,在GPU上的链表实现也是如此。这同时也可以是一个劣势,因为需求的总存储空间在每帧的渲染前是未知的。一个有着毛发、烟雾或是有着潜在的许多重叠的透明表面的物体的场景会导致大量的片段的生成。Andersson提到,对于复杂的游戏场景来说,至多有50个透明网格,至多有200个半透明粒子可以重叠。
  GPU通常会有一些提前分配的缓冲和数组,链表方法也不例外。用户需要自己决定多少内存是足够的,用完内存会导致显眼的伪影。Salvi和Vaidyanathan提到了一个解决这个问题的方法,它叫多层阿尔法混合Multi-layer Alpha Blending),这个方法使用了一个由Intel提供的名为像素同步的GPU特性。详见下图

img

其中,左上的图使用了传统的从后往前的阿尔法混合,因此有渲染错误。而右上的图使用了A-buffer,结果很完美但是交互性差。左下的图使用了多层阿尔法混合。右下是一张比较图,它是A-buffer和多层图像之间的差异图,为了提高可见度差异被乘以了4。像素同步提供了可编程的混合,有着比原子更低的开销。他们的方法重新构思了存储和混色,因此在内存用完时可以进行降级处理。进行粗略的排序对他们的方法有益。DirectX 11.3引入了光栅顺序视图,它是一种缓冲,在它的帮助下,A-buffer这种类型的透明度方法可以在任何支持这个特性的GPU上实现。移动式设备有着一个相似的技术,它被称为瓦片本地存储Tile Local Storage),也能被用来实现多层阿尔法混合。但是这种机制有着性能开销,因此这类算法的开销很昂贵。
  这种方法建立在k-buffer这一想法上,它由Bavoil等人引入。在这之中,最可见的几层会被保存并尽可能排序,更深的层则会被抛弃并尽可能合并。Maule等人使用了一个k-buffer,并对那些更远更深的层使用了加权*均Weighted Averaging)。加权和与加权*均透明度技术是顺序无关的,而且只需要一次通道,因此能运行在几乎所有的GPU上。有个问题是他们没有考虑到物体的顺序。因此,如果使用阿尔法来表示覆盖率,一个红纱巾在一个蓝围巾上方会给予紫罗兰色,而不是正确地看到有一点蓝色穿过的一个红围巾。此外,对于那些几乎不透明的物体来说会有差的一个效果,但是这种类型的算法被用于可视化是非常有用的,并且对那些高度透明的表面和粒子来说也很管用。下图为一个相关的例子

img

注解:物体的顺序随着不透明度增加而越来越重要。

  在加权和的透明度中,公式为

\[\mathbf{c}_o = \sum_{i=1}^{n} (\alpha_i \mathbf{c}_i) + \mathbf{c}_d(1-\sum_{i=1}^{n}\alpha_i) \]

其中,\(n\)是透明表面的数量,\(\mathbf{c}_i\)\(\alpha_i\)表示一些透明度值的集合,\(\mathbf{c}_d\)是场景中不透明的部分。在透明表面的渲染过程中,两个求和会不断累积并被分开存储,在最后一次透明度通道,这个公式会为每个像素进行评估。但是这个公式的问题是,如果第一个求和饱和了,那么会生成一个比\((1.0,1.0,1.0)\)还要大的颜色值。此外背景颜色能有一个“负的”效果,因为阿尔法的和可以超过\(1\)
  在这种情况下,加权*均公式通常是被使用的,因为它能避免这些问题

\[\begin{align*} \mathbf{c}_\mathrm{sum} &= \sum_{i=1}^n (\alpha_i \mathbf{c}_i), \,\, \alpha_\mathrm{sum} = \sum_{i=1}^{n} \alpha_i \\ \mathbf{c}_\mathrm{wavg} &= \frac{\mathbf{c}_\mathrm{sum}}{\alpha_\mathrm{sum}} , \,\, \alpha_\mathrm{avg} = \frac{\alpha_\mathrm{sum}}{n} \\ u &= (1-\alpha_\mathrm{avg})^{n} \\ \mathbf{c}_o &= (1-u)\mathbf{c}_\mathrm{avg} + u \mathbf{c}_d \end{align*} \]

加权*均的一个限制是,对于相同的阿尔法来说,它会均匀地混合所有表面而不在乎顺序。McGuire和Bavoil引入了加权混合顺序无关透明来给予一个更加信服的结果。在它们的公式中,表面的距离也会影响权重,*处的表面会有更大的影响。此外,与其*均阿尔法,\(u\)是通过把\((1-\alpha_i)\)乘到一起得到的,然后让\(1\)减去它得到真正的阿尔法覆盖率。使用这个方法能得到更加视觉信服的结果,如下图所示那样

img

  有个缺陷是在较大的环境中,相隔较*的物体会有几乎相同的权重,这会让渲染结果和使用加权*均的结果差不多。此外,相机到透明物体的距离如果改变,那么深度权重有可能让效果变化,但是这个变化是渐变的。
  McGuire和Mara扩展了这个方法,它们加入了一个合理的透射颜色效果。正如之前提到的那样,这个部分讨论的所有透明度方法都混合不同的颜色而不是对它们进行颜色滤波模拟像素覆盖率。为了得到一个颜色滤波的效果,不透明的场景会被像素着色器读取,透明表面会把它的颜色乘以它覆盖的像素的颜色,并把结果保存到第三个缓冲中。当解析透明缓冲时,这个缓冲会被用来替代不透明场景。这个方法能工作是因为不像覆盖率导致的透明度,有色透射是顺序无关的。
  还有其它的算法使用了一些在这里提到的技术中的元素。例如,Wyman用内存需求、插入和合并方法、是否使用阿尔法或几何覆盖率、如何处理被抛弃的片段这几个方面分类了前人的成果。 此外,还提出了两个新的方法。他的随机分层阿尔法混合方法使用了k-buffer、加权*均、随机透明度。他的另一个算法是Salvi和Vaidyanathan方法的一个变种,使用了覆盖掩码而不是阿尔法。
  现在,我们已经提到了很多不同类型的透明内容、渲染方法、GPU能力,对于渲染透明物体来说没有完美的方案。在这里,我们鼓励感兴趣的读者去阅读Wyman的论文和Maule等人对交互式透明度算法的调查。McGuire的研究结果则提供了一个更广的视野,贯穿了其它的相关现象,如体积光照(Volumetric Lighting)、颜色透射(Color Transmission)、折射,这些在后续会有更深入的讨论。

预乘阿尔法与合成(Premultiplied Alphas and Compositing)

  over运算也会被用来混合照片或合成物体的渲染图。这个过程被称作合成Compositing)。在这种情况下,每个像素处的阿尔法值会与每个像素处的RGB颜色值一起被存储。由阿尔法通道构成的图像有时被称为遮罩Matte)。它能展示物体形状的轮廓。这个RGB\(\alpha\)图像可以接着被用来与其它元素或是背景进行混合。
  一个使用合成的RGB\(\alpha\)数据的方法是预乘阿尔法Premultiplied Alphas),它也被称为关联阿尔法Associated Alphas)。也就是说,RGB值在被使用前会与阿尔法值相乘。这会让over运算更加地高效

\[\mathbf{c}_o = \mathbf{c}_s^\mathbf{'}+(1-\alpha_s) \mathbf{c}_d \]

其中,\(\mathbf{c}_s^\mathbf{'}\)是预乘后的源通道,替代了\(\alpha_s \mathbf{c}_s\)。预乘阿尔法也让用over进行叠加混合有了可能,这样就不需要改变混合状态。要注意的是,预乘后的RGB\(\alpha\)值中的RGB这三个分量通常不会超过阿尔法值,否则会有一个特别亮的半透明值。
  渲染合成图像会与预乘阿尔法自然地吻合。一个抗走样的不透明物体的外表渲染在黑背景之上会默认提供预乘值。假如一个白\((1,1,1)\)三角形在它的边附*覆盖了一些像素\(40\%\)的区域。使用(极其精确)的抗走样,像素值可能被设置为\(0.4\),我们因此设置像素的颜色为\((0.4,0.4,0.4)\)。如果阿尔法值也被存储,那么也会是\(0.4\),因为这是被三角形覆盖的范围。RGB\(\alpha\)值就是\((0.4,0.4,0.4,0.4)\),它就是一个预乘值。
  图像还可以使用未乘阿尔法Unmultiplied Alpha)进行存储,它也被称为非关联阿尔法Unassociated Alpha)。它与预乘阿尔法相反,就是RGB值不与阿尔法值预先相乘,对于我们说的白三角形的例子来说,未乘颜色会是\((1,1,1,0.4)\)。它的优势在于能存储三角形的原始颜色,但是这个颜色在被显示前通常需要与阿尔法值相乘。因此在滤波和混合前最好使用预乘数据,因为线性插值对于未乘阿尔法来说不能正确工作。会有例如物体边缘处的黑边伪影这种伪影。预乘阿尔法也允许理论分析。
  对于图像处理的应用来说,一个非关联阿尔法可以被用来遮罩一个照片,而不影响底层图片的原始数据。此外,非关联阿尔法意味着颜色通道的整个精度范围都可以被使用。因此,在未乘RGB\(\alpha\)和用于计算机图形计算的线性空间之间进行转换时要注意。例如,没有浏览器会恰当地做这件事,它们几乎也不会这么做。支持阿尔法的图像格式包括PNG(仅非关联阿尔法)、OpenEXR(仅关联阿尔法)、TIFF(两种类型的阿尔法)。

显示编码(Display Encoding)

  当计算光照、纹理映射或其它操作的效果时,被使用的值会被假设是线性的。非正式地说,这意味着加法和乘法会以期望的方式工作。然而,为了避免各种视觉伪影,被显示的缓冲和纹理会使用非线性编码,我们因此在实践时必须考虑到这些。一个短且草率的回答是,让着色器输出的颜色在\([0,1]\)这个范围内,然后将颜色乘以\(1/2.2\)的幂,这被称为伽马校正Gamma Correction)。对于输入的纹理和颜色则做相反的事。在大多数情况下,你可以让GPU帮你完成这件事。这个部分将会以快速总结的方式解释怎么做到这件事以及为什么要做这件事。
  我们首先从阴极射线管Cathode-ray TubeCRT)开始。在早年的数字成像中,CRT显示是一般方式。这些设备展现了输入电压和显示辐亮度之间的一种幂定律。随着能量等级不断提高,发射的辐亮度不会线性增加而是以幂函数的方式增加的。假设能量为\(2\)。一个像素如果被设置为\(50\%\),那么发射光的量有可能为\({0.5}^2 =0.25\)。尽管LCD以及其它的显示技术和CRT不一样,但是它们在被制造的时候通常会有着转换电路,这会让它们模拟CRT的响应。
  这个幂函数几乎与人的视觉对光的敏感性相反。这就导致了一个巧合,编码大致与感知一致Perceptually Uniform)。也就是说,在可显示范围内均匀变化的编码值被感知后的颜色也是几乎均匀变化的。以阈值对比度Threshold Contrast)进行测量,在大范围内可以侦测到大约\(1\%\)的亮度差异。这种数值上几乎最优的分布可以最小化色带Banding)伪影,比如当颜色被存储于精度受限的显示缓冲中时。这通常也对纹理有用,因为纹理通常会使用相同的编码。
  显示传递函数Display Transfer Function)描述了显示缓冲中的数字值和从显示器发射的辐亮度等级之间的关系。正因为这样,它也被称为电光传递函数Electrical Optical Transfer FunctionEOTF)。显示传递函数是硬件的一部分,对于计算机监视器、电视、影视投影仪来说有不同的标准。在这个过程的另一端,也存在一个标准的传递函数,用于图像或视频捕获设备,它被称为光电传递函数Optical Electric Transfer FunctionOETF)。
  当编码线性颜色值用于显示时,我们的目标是消去显示传递函数的效果,以让我们计算出的值对应正确的一个辐亮度等级。例如,如果计算的值被翻倍了,那么输出的辐亮度也得翻倍。为了维持这个联系,我们应用显示传递函数的逆来消去它的非线性效果。这种取消显示响应曲线的过程也被称为伽马校正Gamma Correction),具体的原因很快就会明白。当解码纹理值时,我们需要应用显示传递函数来生成用于着色的线性值。下图展示了解码和编码在显示过程中的使用。

img

  用于个人计算机显示器的标准传递函数是通过一个叫sRGB的颜色空间规范定义的。绝大多数API可以控制GPU,让它被设置来自动地应用合适的sRGB转换,比如当值从纹理被读取又或是写入到颜色缓冲时。后面讨论的mipmap生成也会考虑到sRGB编码。如果要对纹理进行双线性插值,那么首先需要把它存储的值转换到线性值,接着再进行插值。对于阿尔法混合来说,如果要进行恰当的混合,那么需要先解码存储的值到线性值,使用新的线性值进行混合,接着再编码回去。
  当值被写入到帧缓冲用于显示时,在渲染的最后阶段应用转换是很重要的。如果在显示编码后应用了后处理,那么计算是对非线性值进行的,可以预见的会造成伪影。显示编码可以被认为是一种压缩的形式,它最好地保留了值的感知效果。一个好的思考编码和解码的方式是线性值需要被用来执行物理的计算,当我们想要显示结果或读取可显示图像时,我们需要使用合适的编码或解码变换。
  如果你真的需要手动应用sRGB,那么有些标准的转换公式或一些简化后的版本可以使用。在实际中,显示是由每通道的一些比特位控制的,比如消费级监视器每通道有8比特(\([0,255]\))。在这里我们使用\([0,1]\)来表示显示编码的等级,忽略掉这些比特位。线性值也会在\([0,1]\)。我们用\(x\)来表示线性值,用\(y\)来表示帧缓冲中被非线性编码的值。为了转换线性值到sRGB非线性编码的值,我们应用sRGB 显示传递函数的逆,公式如下所示

\[y = f_\mathrm{sRGB}^{-1}(x) = \begin{cases} 1.055x^{1/2.4} - 0.055 & \text{当} x \gt 0.0031308 \\ 12.92x & \text{当} x \leq 0.0031308 \end{cases} \]

式中的\(x\)表示线性RGB中的一个通道的值,这个公式会应用于每个通道。在手动应用转换函数时必须要注意,一定要搞清楚被转换的RGB是线性RGB还是sRGB。
  底下的两个变换表达式是简单的乘法,它被数字硬件所需要,来让变换完美可逆。其中,第一个表达式涉及到将值取幂,输入范围在\([0,1]\)。把偏移和缩放考虑进来,这个函数会很接*下方的简化公式

\[y = f_\mathrm{display}^{-1} (x) = x^{1/\gamma} \]

如果\(\gamma=2.2\),那么上方表示的变化可以被称为伽马校正。
  和计算值必须被编码用于显示一样,被摄像机拍摄的图像被用于计算前必须被转换到线性值。你在监视器或电视上看到的任何颜色都有一些显示编码的RGB值,可以通过屏幕截屏或取色器(Color Picker)来获得。这些值是PNG、JPEG、GIF这种格式的文件所存储的,它们可以被直接发送到帧缓冲用于显示。用另一句话来说,你在屏幕上看到的是显示编码的数据。在使用这些颜色用于着色计算前,必须把它们转换回线性值。我们需要的从显示编码到线性值的sRGB变换公式如下

\[x = f_\mathrm{sRGB}(y) = \begin{cases} \left(\frac{y+0.055}{1.055} \right)^{2.4} & \text{当} y \gt 0.04045 \\ \frac{y}{12.92} & \text{当} y \leq 0.04045 \end{cases} \]

式中的\(y\)是归一化的显示通道的值,也就是被存储于图像或帧缓冲中的那些,范围在\([0,1]\)。它的更简化的形式为

\[x = f_\mathrm{display}(y) = y^{\gamma} \]

有些时候你会在移动式应用和浏览器APP上看到还要简单的转换公式

\[\begin{align*} y &= f_\mathrm{simpl}^{-1} (x) = \sqrt{x} \\ x &= f_\mathrm{simpl} (y) = y^2 \end{align*} \]

尽管是个大致的*似,但是总比忽略这个问题要好。
  如果我们不注意伽马,更低的线性值在屏幕上会显得更暗。一个相关的错误是,如果没有伽马校正颜色的色调会偏移。假如\(\gamma = 2.2\),如果我们想要发射与像素值(计算值)成比的辐亮度,那么必须将像素值取幂\(1/2.2\)。如果不这么做,经过显示传递函数作用后,会可以预见的会有更低的辐亮度。
  忽略伽马校正还会带来另一个问题,在有些着色计算中会使用物理的线性的辐亮度值,如果使用了非线性的值,那么会有下图这种错误

img

上图展示了两个分别有着光照值0.6和0.4的聚光灯点亮一个*面。左图,没有伽马校正,聚光灯重叠区域的亮度异常的高。右图施加了伽马校正,点亮区域看起来更自然一些。
  忽略伽马校正也会影响抗走样的边缘的质量。假如如下图所示,有个三角形的边覆盖了4个屏幕网格单元

img

假设三角形的归一化辐亮度为\(1\)(白色),背景为\(0\)(黑色)。从左往右,网格单元被覆盖的区域大小分别为\(\frac{1}{8}\)\(\frac{3}{8}\)\(\frac{5}{8}\)\(\frac{7}{8}\)。如果我们使用的是方框滤波器,想用\(0.125\)\(0.375\)\(0.625\)\(0.875\)表示像素的归一化线性辐亮度。正确的方式是对线性值进行抗走样,接着对抗走样结果进行编码。不这么做会让像素的辐亮度偏低,导致被感知到的边缘像右图那样有形变。这种伪影被称为绳状伪影Roping),因为边缘看起来和扭曲的绳子差不多。下图展示了这个效果。

img

  sRGB标准在1996年被创建,对于绝大多数计算机监视器来说已经成为了标准。然而,显示技术是在不断进化的。有些更亮的可以表示更大范围颜色的监视器已经被制造出来了。在后续的部分,我们将会讨论颜色显示和亮度,以及用于高动态范围显示器的显示编码。

posted @ 2026-03-10 22:14  TiredInkRaven  阅读(21)  评论(0)    收藏  举报