《Real-Time Rendering 4th Edition》全文翻译 - 第5章 着色基础(中)5.3 ~ 5.4
这两节终于翻译完毕,不得不说原文篇幅是真的长,花了不少时间。
另外,以后引用的具体文章标题不会再列出来,一是为了节省时间,二是感觉列出来会过于冗余。所以如果想看具体引用文章标题的话,请在原书里手动搜索。
业余翻译,若有不周到之处,还请多多指教。
实时渲染(第四版)Real-Time Rendering (Fourth Edition)
第5章 着色基础 Chapter 5 Shading Basics
5.3 实现着色模型 Implementing Shading Models
为了发挥作用,这些着色和光照的方程当然必须在代码中实现。在本节中,我们将仔细介绍设计和编写此类实现的一些关键注意事项。此外,我们也会介绍一个简单的实现示例。
5.3.1 计算频率 Frequency of Evaluation
(注:frequency of evaluation 似乎是某个特别名词,暂未查到正式翻译,联系上下文后,发现译为 “计算频率” 更易理解。)
当设计一个着色实现时,需要根据他们的计算频率(frequency of evaluation)对计算进行划分。首先,要决定给出的计算结果是否总是在一个绘制调用(draw call)中保持恒定。在这种情况下,虽然 GPU 的计算着色器能够用于特别昂贵的计算,但是计算一般可由应用程序在 CPU 上执行。其计算的结果通过统一的着色器输入传到图形 API 中。
从“一次”开始,即使在这个类别中,也存在有大范围可能的计算频率。最简单的例子就是着色方程中一个常量子表达式,但是这可以应用到那些很少更改参数的例如硬件配置和安装选项的计算中。这种着色计算可能在着色器编译的时候就完成了,这种情况下甚至不需设置一个统一的着色器输入。或者,在安装阶段或当应用被加载时,计算就会被一个离线的预计算 Pass 所执行。
另一种情况是,着色计算的结果在应用程序执行时不断变化,但是这个变化很慢,以至于不需要在每一帧都进行更新。例如基于虚拟游戏世界时间的光照参数。如果计算的消耗是昂贵的,那么它应该被平摊到多帧。
其他情况下,包括每帧执行一次的计算,例如连接视图和透视矩阵;或每个模型执行一次的计算,例如根据位置更新模型的光照参数;或每次绘制调用(draw call)执行一次,例如,更新模型中每种材质的参数。通过计算频率,我们将统一的着色器输入分组,这样有助于提高应用程序的效率,并且还可以通过最小化常量更新(minimizing constant updates)来提高 GPU 的性能 [1165]。
如果着色计算的结果在一个绘制调用中不断变化,那么它就不能由一个统一的着色器输入传到着色器中。取而代之的是,它必须在第 3 章提到的可编程着色阶段之一中被计算,并且如果需要的话,会通过不同的着色器输入传到其他阶段。理论上,着色计算能在任何一个可编程阶段上执行,其中每个都对应着不同的计算频率:
顶点着色器(Vertex shader) —— 计算每个曲面细分前的顶点。
外壳着色器(Hull shader) —— 计算每个表面补丁。
域着色器(Domain shader) —— 计算每个曲面细分后的顶点。
几何着色器(Geometry shader)—— 计算每个图元。
像素着色器(Pixel shader)—— 计算每个像素。
图 5.9 对于来自公式 5.19 的案例着色模型的逐像素和逐顶点的计算结果的比较,展示了三个不同顶点密度的模型。左侧展示了逐像素计算的结果,中间展示了逐顶点计算的结果,以及右边呈现了每个模型的线框渲染以展示顶点的密度。(来自计算机图形学档案的中国龙网格模型 [1172],原模型来自斯坦福 3D 扫描存储库)
实际上,大部分着色计算是逐像素执行的。尽管这些通常是在像素着色器中实现的,但是计算着色器的实现正变得越来越普遍;相关的一些例子将在第 20 章中讨论。其他阶段主要用于几何操作,例如变换和变形。为了理解为什么是这种情况,我们会对比逐顶点和逐像素着色计算的结果。在旧版的文本里,它们有时会被称作 Gouraud 着色(Gouraud shading)[578] 和 Phong 着色(Phong Shading)[1414],尽管这些术语如今已不常使用。对比中使用的着色模型在某些方面与公式 5.1 中的较为相似,但是经过修改,它可以与多个光源一起使用。当我们详细讲解案例的实现时,会在之后给出完整的着色模型。
图 5.9 展示了不同顶点密度模型的逐像素和逐顶点着色的结果。对于龙,这个顶点密度极高的模型网格,逐顶点与逐像素之间的区别是很小的。但是对于茶壶,顶点着色计算导致了例如高光棱角分明的视觉错误,并且再两个三角面组成的平面上,顶点着色的版本很明显是不对的。导致这些错误的原因是着色方程,尤其是高光部分,在模型网格表面有着非线性变化的值。这使得它们不适合用于顶点着色器,其计算结果会在递交给像素着色器之前在三角面进行线性插值。
原则上来说,可以在像素着色器中仅计算着色模型的镜面高光部分(specular highlight),而在顶点着色器中计算其余部分。这可能不会导致视觉伪像(visual artifacts),并且理论上将节省一些计算。然而在实践中,这种混合实现通常不是最佳的。着色模型的线性变化部分往往在计算上花费最少,并且以这种方式拆分着色计算往往会增加相当多的开销,例如重复计算和额外的变化输入,从而导致弊大于利。
(注:visual artifacts 在此不是很好翻译,翻译为视觉人造物会莫名其妙,考虑到指的是之前提到的某种虚假感的视觉错误,且错误原因是与现实世界有差异,因此翻译为视觉伪像)
正如我们之前提到的,在大部分实现中,顶点着色器负责非着色操作,例如几何变换和变形。生成的几何表面属性,转换到合适的坐标系统中,并被顶点着色器写入,在三角面上进行线性插值,然后作为变化的着色器输入传入像素着色器。这些属性通常包括表面的位置,表面法线,以及可选的表面切线向量(如果需要法线贴图的话)。
需要注意的是,即使顶点着色器总是生成单位长度表面法线,插值也是能改变其长度的。见图 5.10 左侧。因此,法线需要在像素着色器中重新归一化(缩放至长度为 1)。然而,顶点着色器生成的法线的长度仍然很重要。如果法线长度在顶点间是明显不同的,例如,作为顶点混合的副作用,这就会使插值倾斜。此情况可见图 5.10 右侧。由于这两个副作用,具体的实现通常会在插值之前与之后,即在顶点着色器和像素着色器中,去归一化插值后的向量。
图 5.10 在左侧,我们看到跨越表面的单位法线的线性插值将导致插值后的向量长度小于1。在右侧,我们看到法线的线性插值有着明显不同的长度,这导致了插值后的方向朝着两个法线中较长的倾斜。
与表面法线不同,指向特殊位置的向量,例如视图向量(view vector)和精确光的光向量(light vector),通常是不进行插值的。取而代之的是,在像素着色器中插值后的表面位置将被用来计算这些向量。除了在任何情况下都需要在像素着色器中执行的归一化操作外,每个向量都会用向量减法运算,这是很快的。如果因为一些原因,需要对这些向量进行插值的话,不要事先对它们进行归一化。这会导致错误的结果,见图 5.11。
图 5.11 两个光向量间的插值。在左侧,在插值前将它们归一化将导致归一化后方向不正确。在右侧,对未归一化向量插值,得到了正确的结果。
之前我们有提到顶点着色器变换表面几何体到“合适的坐标系”。通过统一变量传递到像素着色器的相机与光源的位置,通常被应用程序变换到相同的坐标系。这样可以最大程度减少像素着色器将所有的着色模型向量带入相同的坐标空间的工作。但是究竟哪个坐标系是“合适”的呢?可能的答案包括全局世界空间以及相机的局部坐标系,或者更罕见的,是当前渲染模型的局部坐标系。这通常是基于系统性的考虑,例如性能,灵活性和简单性,为整个渲染系统做出选择。举个例子,如果渲染的场景预计包含大量的光源,那么就应该选择世界空间以避免光源位置的变换。或者,最好使用相机空间,这样可以更好地优化与视图向量相关的像素着色器操作,并且提高精确度。(第16.6节)
虽然大部分的着色器实现,包括我们将要讨论的案例实现,都遵循上述一般概述,但是总有例外。举个例子,一些应用程序出于美术风格的原因选择了基于逐图元着色计算的多面外观。这种风格被称为平面着色(flat shading)。如图 5.12 是两个平面着色的例子。
原则上,可以在几何着色器中执行平面着色(flat shading),但是近年来相关的实现通常是使用顶点着色器。这是通过将每个图元的属性与其第一个顶点相关联并禁用顶点值插值来完成的。禁用插值(可以为每个顶点值分别处理)将导致第一个顶点的值传递到图元中的所有像素。
图 5.12 风格使用平面着色的两个游戏:肯塔基 0 号路(Kentucky Route Zero,上图)与 癌症似龙(That Dragon, Cancer,下图)(上图由 Cardboard Computer 提供,下图由Numinous Games 提供)
5.3.2 实现案例 Implementation Example
我们现在会展示一个着色模型实现的案例。正如之前提到的,我们正在实现的着色模型与来自公式 5.1 的扩展的 Gooch 模型是相似的,但是我们经过了修改以让其能够支持多个光源。它可以被描述为
通过以下中间计算:
此公式适合公式 5.6 中的多光源结构,为方便起见,在此重复:
在此案例中,lit 和 unlit 项具体为
调整冷色的 unlit 贡献值,使得结果看起来更像原始方程。
在大部分通常的渲染应用程序中,材质球属性的变化值诸如 会被存储在顶点数据,或者,更普遍的做法是存在纹理中(第六章)。然而,为了让这个案例的实现保持简单。我们会假设在整个模型中 是一个常数。
此实现方案会使用着色器的动态分支功能去循环处理所有的光源。然而尽管这种直接的方法可以很好地处理还算简单的场景,但是它对于庞大、具有复杂几何体,且拥有许多光源的场景并不合适。有效处理大量光源的渲染技术将会在第 20 章详细介绍。并且,为了简单起见,我们只支持一种类型的光源:点光源。虽然这个实现方案是相当简单的,但是它遵循了我们之前提到的最佳实践。
(注:“最佳实践”应该是指前文中“然而在实践中,这种混合实现通常不是最佳的。着色模型的线性变化部分往往在计算上花费最少,并且以这种方式拆分着色计算往往会增加相当多的开销,例如重复计算和额外的变化输入,从而导致弊大于利。”)
着色模型并不是单独实现的,而是在更大的渲染框架环境(context)中实现。该案例在一个简单的 WebGL 2 应用程序中实现,修改自Tarek Sherif [1623] 的“Phong-shaded Cube”WebGL 2 案例,但是其他更复杂的框架也是运用相同的原则。
(注:context 在很多书籍中译为“上下文”,对于初学者会很费解,个人觉得译为“语境”、“环境”更好理解)
我们将讨论应用程序调用的 GLSL着色器代码和 JavaScript WebGL 的一些示例。这里的目的并不是讲述 WebGL API 的细节,而是要展示一般的实现原理。我们将以“由内到外”的顺序来讲解实现过程,首先是像素着色器,然后是顶点着色器,最后是应用程序侧的图形 API 调用。
着色器源文件应包含着色器输入与输出的定义,这样的着色器代码才是正确的。正如我们在第 3.3 节谈到的,使用 GLSL 术语,着色器输入会分为两类。其中之一就是统一输入集,它有着应用程序所设置的值,并且在一个绘制调用(draw call)中保持不变。第二种类型由变化的输入组成,它可以在着色器调用(像素或顶点)之间改变。以下是 GLSL 语言中像素着色器的各种输入及其输出的定义:
(注:最后一句直译太冗长拗口了,直接意译,原文 Here we see the defifinitions of the pixel shader’s varying inputs, which in GLSL are marked in, as well as its outputs: )
像素着色器有单独的输出,其内容为最终的着色颜色。像素着色器的输入与顶点着色器的输出相匹配,顶点着色器的输出在被输入到像素着色器之前会在整个三角面进行插值。像素着色器有两个不同的输入:表面位置与表面法线,且这两者都是在应用程序的世界空间坐标系内。当然,统一输入的数据数量还有很多,为了简洁,我们仅展示这两个的定义,且这两者都是与光源相关的:
哈莉奎茵: 请问你最后打开了吗
风生水虎: 就你这个有用 可惜就是 改了后 修改image上的color就没用了。
一_叶子: 这一章好多名词都没见过,看的云里雾里的
一_叶子: perspective division 翻译成透视除法会不会更好些
一_叶子: 翻译的很好,真的是太感谢博主了