第5章-着色基础-5.3-实现着色模型
5.3 实现着色模型
出于实用目的,这些着色和光照方程当然必须在代码中实现。在本节中,我们将讨论设计和编写此类实现的一些关键考虑因素。我们还将介绍一个简单的实现示例。
5.3.1 计算频率
在设计着色实现时,计算需要根据其计算频率进行划分。首先,确定给定计算的结果在整个绘制调用中是否始终不变。在这种情况下,计算可以由应用程序执行,通常在CPU上执行,尽管GPU计算着色器可用于特别昂贵的计算。结果通过标准着色器输入传递给图形API。
即使在这一类别中,也有广泛的可能计算频率,从“曾经一次”开始。这种情况最简单的就是是着色方程中的常量子表达式,但这可以应用于基于很少变化的因素(例如硬件配置和安装选项)的任何计算。在编译着色器时可能会解决此类着色计算,在这种情况下甚至不需要设置着色器的统一(uniform)输入。或者,可以在离线预计算阶段、安装时或加载应用程序时执行计算。
另一种情况是当应用程序运行时着色计算的结果发生变化,但速度太慢以至于不需要每帧更新它。例如,取决于虚拟游戏世界中一天中时间的照明因素。如果计算成本很高,则可能值得将其分摊到多个帧上。
其他情况包括每帧执行一次的计算,例如级联视图和透视矩阵;或每个模型一次,例如更新取决于位置的模型的照明参数;或者每次绘制调用一次,例如,更新模型中每种材质的参数。按计算频率对统一(uniform)着色器输入进行分组,有助于提高应用程序效率,并且还可以通过最小化持续更新来提高GPU性能 [1165]。
如果着色计算的结果在绘制调用中发生变化,则无法通过统一(uniform)着色器输入将其传递给着色器。相反,它必须由第3章中描述的可编程着色器阶段之一进行计算,并在需要时通过不同的着色器输入传递给其他阶段。理论上,可以在任何可编程阶段执行着色计算,每个阶段对应不同的计算频率:
- 顶点着色器——逐预细分顶点计算。
- 外壳着色器——逐表面片元计算。
- 域着色器——逐细分后顶点的计算。
- 几何着色器——逐图元计算。
- 像素着色器——逐像素计算。
在实践中,大多数着色计算都是逐像素执行的。虽然这些通常在像素着色器中实现,但计算着色器实现越来越普遍;第 20章将讨论几个例子。其他阶段主要用于几何运算,例如变换和变形。为了理解为什么会这样,我们将比较逐顶点和逐像素着色计算的结果。在较早的文本中,这些有时分别称为Gouraud着色[578]和Phong着色[1414],尽管这些术语在今天并不常用。此比较使用的着色模型与公式5.1中的模型有些相似,但经过修改以适用于多个光源。稍后将在我们详细介绍示例实现时给出完整的模型。
图5.9显示了在具有广泛顶点密度的模型上的逐像素和逐顶点着色的结果。对于龙来说,一个极其密集的网格,两者之间的差异很小。但是在茶壶上,顶点着色计算会导致可见的错误,例如角形高光,而在两个三角形平面上,顶点着色版本显然是不正确的。这些错误的原因是着色方程的某些部分,特别是高光部分,具有在网格表面上非线性变化的值。这使得它们不适合顶点着色器,其结果在被传递到像素着色器之前在三角形上线性插值。
图5.9. 公式5.19中示例着色模型的逐像素和逐顶点计算的比较,显示在三个不同顶点密度的模型上。左列显示逐像素计算的结果,中列显示逐顶点计算,右列显示每个模型的线框渲染以显示顶点密度。(来自计算机图形档案[1172]的中国龙网格,斯坦福3D扫描存储库的原始模型。)
原则上,可以在像素着色器中仅计算着色模型的镜面高光部分,并在顶点着色器中计算其余部分。这可能不会导致视觉伪影,理论上会节省一些计算量。在实践中,这种混合实现通常不是最优的。着色模型的线性变化部分往往是计算成本最低的,并且以这种方式拆分着色计算往往会增加足够的开销,例如重复计算和额外的变化输入,足以抵消任何好处。
正如我们前面提到的,在大多数实现中,顶点着色器负责非着色操作,例如几何变换和变形。生成的几何表面属性,转换为适当的坐标系,由顶点着色器写出,在三角形上线性插值,并作为不同的着色器输入传递到像素着色器。这些属性通常包括曲面的位置、曲面法线以及可选的曲面切线向量(如果需要进行法线映射)。
请注意,即使顶点着色器总是生成单位长度的表面法线,插值也可以改变它们的长度。请参见图5.10的左侧。出于这个原因,法线需要在像素着色器中重新归一化(缩放到长度 1)。但是,顶点着色器生成的法线长度仍然很重要。如果顶点之间的法线长度变化很大,例如,作为顶点混合的副作用,这将扭曲插值。这可以在图5.10的右侧看到。由于这两种影响,实现通常在插值之前和之后对插值向量进行归一化,即在顶点和像素着色器中。
图5.10. 在左边,我们看到跨表面的单位法线的线性插值导致长度小于1的插值向量。在右侧,我们看到长度明显不同的法线的线性插值导致插值方向偏向两条法线中较长的一条。
与表面法线不同,指向特定位置的向量(例如精确光源的视图向量和光向量)通常不会被插值。相反,插值的表面位置用于在像素着色器中计算这些向量。除了归一化(正如我们所见,在任何情况下都需要在像素着色器中执行)之外,这些向量中的每一个都是通过向量减法计算的,这很快。如果由于某种原因需要对这些向量进行插值,请不要事先对它们进行归一化。这将产生不正确的结果,如图5.11所示。
图5.11. 两个光向量之间的插值。在左侧,插值前对其进行归一化会导致插值后方向不正确。在右侧,对非归一化向量进行插值会产生正确的结果。
前面我们提到顶点着色器将表面几何转换为“适当的坐标系”。通过统一(uniform)变量传递给像素着色器的相机和灯光位置通常由应用程序转换到相同的坐标系中。这最大限度地减少了像素着色器所做的工作,以将所有着色模型向量带入相同的坐标空间。但是哪个坐标系是“合适的”?可能性包括全局世界空间以及相机的局部坐标系,或者更少见的是当前渲染模型的局部坐标系。通常基于系统考虑(例如性能、灵活性和简单性)对整个渲染系统进行选择。例如,如果渲染场景预计包含大量灯光,则可以选择世界空间以避免变换灯光位置。或者,相机空间可能是首选,以更好地优化与视图向量相关的像素着色器操作并可能提高精度(第16.6节)。
尽管大多数着色器实现,包括我们将要讨论的示例实现,都遵循上面描述的一般大纲,但当然也有例外。例如,出于风格化的原因,某些应用程序选择逐图元着色计算的面外观。这种风格通常被称为平面着色。图5.12显示了两个示例。
图5.12. 使用平面着色作为风格选择的两款游戏:《肯德基0号路》,上图和《癌症似龙》,底部。(上图由 Cardboard Computer提供,下图由Numinous Games提供。)
原则上,平面着色可以在几何着色器中执行,但最近的实现通常使用顶点着色器。这是通过将每个图元的属性与其第一个顶点相关联并禁用顶点值插值来完成的。禁用插值(可以分别为每个顶点值完成)导致来自第一个顶点的值传递给图元中的所有像素。
此实现将使用着色器的动态分支功能来循环所有光源。虽然这种简单的方法可以很好地适用于相当简单的场景,但它不能很好地扩展到具有许多光源的大型和几何复杂的场景。第20章将介绍有效处理大量灯光数量的渲染技术。此外,为了简单起见,我们将仅支持一种类型的光源:点光源。尽管实现非常简单,但它遵循了前面介绍的最佳实践。
着色模型不是孤立地实现的,而是在更大的渲染框架的上下文中实现的。这个例子是在一个简单的WebGL2应用程序中实现的,该应用程序是从Tarek Sherif[1623]的“Phong-shaded Cube”WebGL2示例修改而来的,但同样的原则也适用于更复杂的框架。
我们将讨论一些GLSL着色器代码示例和来自应用程序的JavaScript WebGL调用。目的不是教授WebGL API的细节,而是展示一般的实现原则。我们将以“由内而外”的顺序完成实现,从像素着色器开始,然后是顶点着色器,最后是应用程序端图形API调用。
在正确的着色器代码之前,着色器源代码包括着色器输入和输出的定义。如前面第3.3节所述,使用GLSL术语,着色器输入分为两类。一个是一组统一的(uniform)输入,其值由应用程序设置,并且在绘图调用中保持不变。第二种类型由可变的(varying)输入组成,其值可以在着色器调用(像素或顶点)之间改变。在这里,我们看到像素着色器的可变输入的定义,在GLSL中被标记,以及它的输出:
in vec3 vPos; in vec3 vNormal; out vec4 outColor;
此像素着色器有一个输出,即最终着色的颜色。像素着色器输入与顶点着色器输出相匹配,这些输出在输入到像素着色器之前在三角形上进行插值。这个像素着色器有两个不同的输入:表面位置和表面法线,都在应用程序的世界空间坐标系中。统一的(uniform)输入的数量要大得多,因此为简洁起见,我们将仅展示两个与光源相关的定义:
struct Light { vec4 position; vec4 color; }; uniform LightUBlock { Light uLights[MAXLIGHTS]; }; uniform uint uLightCount;
由于这些是点光源,因此每个光源的定义都包括位置和颜色。这些被定义为vec4而不是vec3以符合GLSL std140数据布局标准的限制。尽管在这种情况下,std140布局会导致一些空间浪费,但它简化了确保CPU和GPU之间数据布局一致的任务,这就是我们在本示例中使用它的原因。Light结构数组定义在一个命名的uniform块中,这是一个GLSL特性,用于将一组uniform变量绑定到一个缓冲区对象,以加快数据传输。数组长度被定义为等于应用程序在单个绘图调用中允许的最大灯光数量。正如我们稍后将看到的,应用程序在着色器编译之前将着色器源代码中的MAXLIGHTS字符串替换为正确的值(本例中为10)。uniform整数uLightCount是绘制调用中的实际活动灯光数。
接下来,我们来看看像素着色器代码:
vec3 lit(vec3 l, vec3 n, vec3 v) { vec3 r_l = reflect(-l, n); float s = clamp(100.0 * dot(r_l, v) - 97.0, 0.0, 1.0); vec3 highlightColor = vec3(2,2,2); return mix(uWarmColor , highlightColor , s); } void main() { vec3 n = normalize(vNormal); vec3 v = normalize(uEyePosition.xyz - vPos); outColor = vec4(uFUnlit , 1.0); for (uint i = 0u; i < uLightCount; i++) { vec3 l = normalize(uLights[i].position.xyz - vPos); float NdL = clamp(dot(n, l), 0.0, 1.0); outColor.rgb += NdL * uLights[i].color.rgb * lit(l,n,v); } }
我们有一个用于lit项的函数定义,由main()函数调用。总的来说,这是方程5.20和5.21的直接GLSL实现。请注意,funlit�unlit()和cwarmcwarm的值作为统一变量传入。由于这些值在整个绘制调用中是恒定的,因此应用程序可以计算这些值,从而节省一些 GPU 周期。
这个像素着色器使用了几个内置的GLSL函数。reflect()函数在由第二个向量定义的平面中反射一个向量,在这种情况下是光向量,在这种情况下是表面法线。由于我们希望光矢量和反射矢量都指向远离表面的方向,因此我们需要在将其传递给reflect()之前取反前者。clamp()函数具有三个输入。其中两个定义了第三个输入被钳位的范围。在大多数GPU上,钳制到0和1之间的范围(对应于HLSL的saturate()函数)的特殊情况很快,通常有效到无代价。这就是我们在这里使用它的原因,尽管我们只需要将值钳制为0,因为我们知道它不会超过1。函数mix()也有三个输入,并在其中两个之间进行线性插值。在这种情况下,暖色和高亮色基于第三个值,在0和1之间的混合参数。在HLSL中,此函数称为lerp(),用于“线性插值”。最后,normalize()将向量除以其长度,将其缩放为长度1。
现在让我们看看顶点着色器。我们不会展示它的任何统一(uniform)定义,因为我们已经看到了像素着色器的一些统一(uniform)定义示例,但是可变的(varying)输入和输出定义值得研究:
layout(location=0) in vec4 position; layout(location=1) in vec4 normal; out vec3 vPos; out vec3 vNormal;
请注意,如前所述,顶点着色器输出与像素着色器可变输入相匹配。输入包括指定数据在顶点数组中的布局方式的指令。接下来是顶点着色器代码:
void main() { vec4 worldPosition = uModel * position; vPos = worldPosition.xyz; vNormal = (uModel * normal).xyz; gl_Position = viewProj * worldPosition; }
这些是顶点着色器的常见操作。着色器将表面位置和法线转换为世界空间,并将它们传递给像素着色器以用于着色。最后,将表面位置转换为剪辑空间并传递到gl_Position,这是光栅化器使用的特殊系统定义变量。gl_Position变量是任何顶点着色器所需的输出。
请注意,法线向量在顶点着色器中未归一化。它们不需要归一化,因为它们在原始网格数据中的长度为1,并且此应用程序不执行任何可能不均匀地改变它们的长度的操作,例如顶点混合或非均匀缩放。模型矩阵可以有一个统一的比例因子,但这会按比例改变所有法线的长度,因此不会导致图5.10右侧所示的问题。
该应用程序使用WebGL API进行各种渲染和着色器设置。每个可编程着色器阶段都是单独设置的,然后它们都绑定到一个程序对象。这是像素着色器设置代码:
var fSource = document.getElementById("fragment").text.trim(); var maxLights = 10; fSource = fSource.replace(/MAXLIGHTS/g, maxLights.toString()); var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(fragmentShader , fSource); gl.compileShader(fragmentShader);
请注意“片元着色器”引用。该术语由WebGL(和它所基于的OpenGL)使用。正如本书前面提到的,虽然“像素着色器”在某些方面不太精确,但它是更常见的用法,我们将在本书中遵循。此代码也是用适当的数值替换MAXLIGHTS字符串的地方。大多数渲染框架执行类似的预编译着色器操作。
还有更多用于设置uniforms、初始化顶点数组、清除、绘图等的应用程序端代码,您可以在程序[1623]中查看这些代码,并由许多API指南进行解释。我们的目标是让我们了解着色器是如何被视为具有自己编程环境的独立处理器。因此,我们在这一点上结束我们的演练。
5.3.3 材质系统
渲染框架很少只实现单个着色器,如我们的简单示例。通常,需要一个专用系统来处理应用程序使用的各种材质、着色模型和着色器。
如前几章所述,着色器是用于GPU的可编程着色器阶段之一的程序。因此,它是一个低级的图形API资源,而不是艺术家可以直接与之交互的东西。相比之下,材质是面向艺术家的对表面视觉外观的封装。材质有时也会描述非视觉方面,例如碰撞属性,我们不会进一步讨论,因为它们超出了本书的范围。
虽然材质是通过着色器实现的,但这并不是简单的一对一对应。在不同的渲染情况下,相同的材质可能使用不同的着色器。一个着色器也可以被多种材质共享。最常见的情况是参数化材质。在最简单的形式中,材质参数化需要两种类型的材质实体:材料模板和材料实例。每个材质模板都描述了一类材质并具有一组参数,这些参数可以根据参数类型分配数值、颜色或纹理值。每个材质实例对应于一个材质模板加上其所有参数的一组特定值。一些渲染框架(例如Unreal Engine[1802])允许更复杂的分层结构,其中材质模板源自多个级别的其他模板。
参数可以在运行时通过将统一(uniform)输入传递给着色器程序来解析,或者在编译时通过在编译着色器之前替换值来解析。一种常见的编译时参数类型是一个布尔开关,用于控制给定材质特征的激活。这可以由艺术家通过材质用户界面中的复选框进行设置,也可以由材质系统以程序方式设置,这样的案例可以降低特征视觉效果可忽略不计的远处物体的着色器成本。
虽然材质参数可能与着色模型的参数一一对应,但情况并非总是如此。材质可以将给定的着色模型参数的值(例如表面颜色)固定为一个常数值。或者,可以计算着色模型参数作为采用多个材质参数的一系列复杂操作的结果,以及插值顶点或纹理值,作为输入。在某些情况下,表面位置、表面方向甚至时间等参数也可能会影响计算。基于表面位置和方向的着色在地形材质中尤其常见。例如,高度和表面法线可用于控制雪效果,在高海拔水平表面和接近水平表面上混合白色表面颜色。基于时间的着色在动画材质中很常见,例如闪烁的霓虹灯。
材质系统最重要的任务之一是将各种着色器功能划分为单独的元素并控制它们的组合方式。在许多情况下,这种类型的组合很有用,包括:
- 使用几何处理组合表面着色,例如刚性变换、顶点混合、变形、曲面细分、实例化和裁剪。这些功能位独立变化:表面着色取决于材质,几何处理取决于网格。因此,可以方便地分别编写它们并让材质系统根据需要组合它们。
- 使用合成操作(例如像素丢弃和混合)合成表面着色。这与移动GPU尤其相关,其中混合通常在像素着色器中执行。通常需要独立地用于表面着色的材质来选择这些操作。
- 将用于计算着色模型参数的操作与着色模型本身的计算组合起来。这允许创作着色模型实现一次,并结合各种不同的方法重新使用它来计算着色模型参数。
- 将可单独选择的材质特征相互组合、选择逻辑和着色器的其余部分。这使得分别编写每个功能的实现成为可能。
- 组合着色模型并计算其参数与光源计算:计算每个光源的着色点的clightclight和ll值。诸如延迟渲染(第20章讨论)之类的技术改变了这种组合的结构。在支持多种此类技术的渲染框架中,这增加了额外的复杂性。
如果图形API提供这种类型的着色器代码模块化作为核心功能,那将会很方便。遗憾的是,与CPU代码不同,GPU着色器不允许代码片段的编译后链接。每个着色器阶段的程序被编译为一个单元。着色器阶段之间的分离确实提供了一些有限的模块化,这在某种程度上符合我们列表中的第一项:组合表面着色(通常在像素着色器中执行)和几何处理(通常在其他着色器阶段中执行)。但拟合并不完美,因为每个着色器也执行其他操作,其他类型的合成仍然需要处理。鉴于这些限制,材质系统可以实现所有这些类型的合成的唯一方法是在源代码级别。这主要涉及字符串操作,例如连接和替换,通常通过C样式的预处理指令(例如#include、#if和#define)执行。
早期渲染系统的着色器变体数量相对较少,而且通常每个都是手动编写的。这有一些好处。例如,每个变体都可以在完全了解最终着色器程序的情况下进行优化。然而,随着变体数量的增加,这种方法很快变得不切实际。当考虑到所有不同的部分和选项时,可能的不同着色器变体的数量是巨大的。这就是模块化和可组合性如此重要的原因。
在设计处理着色器变体的系统时要解决的第一个问题是,不同选项之间的选择是在运行时通过动态分支执行,还是在编译时通过条件预处理执行。在较旧的硬件上,动态分支通常是不可能的或极其缓慢,因此运行时选择不是一个选项。然后在编译时处理所有变体,包括不同光类型计数的所有可能组合 [1193]。
相比之下,当前的GPU可以很好地处理动态分支,尤其是当分支对绘制调用中的所有像素表现相同时。今天,许多功能变化,例如灯光的数量,都是在运行时处理的。但是,向着色器添加大量功能变化会产生不同的成本:寄存器数量增加,占用率相应降低,从而降低性能。有关详细信息,请参阅第18.4.5节。因此,编译时变化仍然很有价值。它避免包含永远不会执行的复杂逻辑。
例如,让我们想象一个支持三种不同类型灯光的应用程序。两种光源类型很简单:点光源和定向光源。第三种类型是通用聚光灯,它支持列表照明模式和其他复杂功能,需要大量着色器代码来实现。然而,假设广义聚光灯相对很少使用,应用程序中只有不到5%的灯光属于这种类型。过去,会为三种灯光类型的每种可能的计数组合编译一个单独的着色器变体,以避免动态分支。虽然今天不需要这样做,但编译两个单独的变体可能仍然是有益的,一个用于广义聚光灯的计数等于或大于1的情况,一个用于此类灯光的计数正好是0。由于代码更简单,第二种变体(最常用)可能具有较低的寄存器占用率,因此性能更高。
现代材质系统同时使用运行时和编译时着色器变体。尽管不再仅在编译时处理全部负担,但总体复杂性和变体数量不断增加,因此仍然需要编译大量着色器变体。例如,在游戏《命运:被夺走的国王》的某些区域,一帧中使用了超过9000个已编译的着色器变体[1750]。可能的变体数量可能要大得多,例如,Unity渲染系统有接近1000亿种可能的变体的着色器。仅编译实际使用的变体,但必须重新设计着色器编译系统以处理大量可能的变体[1439]。
材质系统设计师采用不同的策略来解决这些设计目标。尽管这些有时表现为互斥的系统架构[342],但这些策略可以——而且通常是——组合在同一个系统中。这些策略包括以下内容:
- 代码重用——在共享文件中实现函数,使用#include预处理器指令从需要它们的任何着色器访问这些函数。
- 减法—一种着色器,通常称为übershader或supershader[1170,1784],它聚合了大量功能,使用编译时预处理器条件和动态分支的组合来删除未使用的部分并在互斥的替代方案之间切换。
- 加法——各种功能被定义为具有输入和输出连接器的节点,它们组合在一起。这类似于代码重用策略,但更加结构化。节点的组成可以通过文本[342]或可视图形编辑器来完成。后者旨在使非工程师(例如技术艺术家)更容易创作新的材料模板[1750,1802]。通常只有部分着色器可供可视化图形创作使用。例如,在虚幻引擎中,图形编辑器只能影响着色模型输入的计算[1802]。请参见图5.13。
- 基于模板——定义了一个接口,不同的实现可以插入到该接口中,只要它们符合该接口。这比加法策略更正式一些,通常用于更大的功能块。这种接口的一个常见示例是着色模型参数的计算与着色模型本身的计算之间的分离。虚幻引擎[1802]具有不同的“材质域”,包括用于计算着色模型参数的表面域和用于计算为给定光源调制clightclight的标量值的光函数域。Unity[1437]中也存在类似的“表面着色器”结构。请注意,延迟着色技术(在第20章中讨论)强制执行类似的结构,将G缓冲区用作接口。
图5.13. 虚幻引擎材质编辑器。注意节点图右侧的高节点。该节点的输入连接器对应于渲染引擎使用的各种着色输入,包括所有着色模型参数。(材质样本由Epic Games提供。)
对于更具体的示例,在(现已免费的)书籍《WebGL Insights》中[301] 的几章讨论了各种引擎如何控制其着色器管线。除了合成之外,现代材质系统还有其他几个重要的设计考虑因素,例如需要以最少的着色器代码重复来支持多个平台。这包括功能的变化,以解决平台、着色语言和API之间的性能和功能差异。Destiny着色器系统[1750]是此类问题的代表性解决方案。它使用专有的预处理器层,该层采用以自定义着色语言方言编写的着色器。这允许编写与平台无关的材质,并自动翻译成不同的着色语言和实现。Unreal Engine[1802]和Unity[1436]具有相似的系统。
材质系统也需要保证良好的性能。除了着色变体的专门编译之外,材质系统还可以执行一些其他常见的优化。Destiny 着色器系统和虚幻引擎自动检测在绘制调用中保持不变的计算(例如前面实现示例中的暖色和冷色计算)并将其移出着色器。另一个例子是Destiny中使用的范围系统,用于区分以不同频率更新的常量(例如,每帧一次、每个光源一次、每个对象一次),并在适当的时间更新每组常量以减少API开销。
正如我们所看到的,实现着色方程是决定哪些部分可以简化、计算各种表达式的频率以及用户如何修改和控制外观的问题。渲染管线的最终输出是颜色和混合值。关于抗锯齿、透明度和图像显示的其余部分详细说明了如何组合和修改这些值以进行显示。
分类: Real-Time Rendering 4th Edition