Chapter 5 – Shading Basics 着色基础

5.3 实现着色模式

为了使用之前的着色与光照等式,我们必须在代码中对其进行实现。本小节中,我们会学习这些代码实现的关键部分。

5.3.1 频率

当我们在设计一个着色模式的代码实现时,需要对着色模式中的计算进行分类。首先决定计算结果在整个draw call是不是一个恒定量。如果是的话,这部分的计算可以由CPU负责,而GPU可以用于开销更大的计算。CPU的计算结果可以通过着色器的输入传输给图形API。

即使在这一类计算中,关于分析的频率还是有着很多不同。最简单的,例如,着色等式中的常量子表达式,硬件的设置,安装选项等等。这一类的着色计算应该在着色器编译时就完成,我们甚至不应该将其作为一个着色器输入。或者,这一类计算可以由离线的预计算的渲染通道负责,这样其就能在安装时或者应用打开时完成。

另一种情况是,着色计算的结果会在游戏运行时不断变化,但是其变化频率很慢,不需要每一帧对其更新。例如,游戏中随着游戏内时间改变的光照因子。如果其计算开销较大,那么我们可以在多帧内进行分摊(例如,每隔10帧才进行一次该类型的计算)。

其他类型的计算,可能每一帧都要执行一次,例如将camera矩阵与透视矩阵相结果;或者每一个模型都需要进行一次计算,例如基于模型所处的位置更新其光照参数;或者每一个draw call执行一次,更新每个模型的材质参数。对着色器的输入基于其频率进行分组往往能提高游戏的运行效率,也能通过减少constant buffer的更新来提高GPU的性能。

如果着色计算的结果在draw call中都会发生变化,那么我们也不能通过将其作为统一的着色器输入。相反,其必须由可编程的着色器来计算求出,如果需要的话,计算结果可能会作为着色器输入传输给其他渲染关系的阶段。理论上来说,着色计算可以由任何一个可编程的着色器阶段负责,而每一个阶段对应着不同的分析频率:

  • 顶点着色器——分析每一个未tessellate的顶点
  • Hull shader——分析每一个patch
  • Domain shader——分析每一个tessellate之后的顶点
  • 几何着色器——分析每一个图元
  • 像素着色器——分析每一个像素

实践中大多数着色计算都会基于每一个像素。虽然这意味着着色计算将由像素着色器执行,但是计算着色器的使用频率正不断增加;我们将在第二十章为大家展示一些关于计算着色器的例子。而其他的阶段则大多用于几何相关的操作,例如,位置转换和变形。为了让大家理解其中的原因,我们将比较基于顶点和基于像素的着色计算的差别。在几年前,可能我们还能在图形学的书籍中找到关于Gouraud着色模式和Phong着色模式的文章,但现在已经很少使用这两个术语了。

上图展示了基于像素和基于顶点的着色对于模型的影响。对于龙模型,由于我们使用了三角形数量非常多的网格模型,基于像素和基于顶点的差别并不大。但是在茶壶模型上,顶点着色就会导致模型的高光效果错误。而对于只由两个三角形组成的平面,顶点着色更是完全错误的。在这些错误是由着色等式所造成的,特别是着色等式中关于高光的计算,其结果在网格表面并没有线性变化。所以,这部分的计算明显是不适合顶点着色器的,因为顶点着色器的计算结果将会基于三角形进行插值,之后才会传输至像素着色器。

原则上,也可以只在像素着色器中计算着色模式中镜面反射高光(specular highlight)的部分,其他部分则由顶点着色器负责。这就能避免视觉效果上的错误,理论上还能减少着色计算的开销。但是,在实践中,这种“杂交”模式并不是最佳选择。因为,着色模型中线性变化的部分,其计算开销往往是最小的,但是将着色计算拆分却会造成其他开销,例如,重复的计算,额外的着色器输入等等,这样做反而是得不偿失。

正如我们之前提到的,大多数情况下,顶点着色器只负责非着色的操作,例如几何转换以及变形。其计算的几何数据将会输出,之后基于每个三角形进行线性插值,之后再作为着色器的输入传输至像素着色器。一般会包括,表面的位置,法线向量,如果需要法线映射的话,可能还需要切线向量。

需要注意的是,即使顶点着色器生成的法线向量是单位向量,但之后经过插值,像素着色器拿到的法线向量可能并不是单位向量。因此,在像素着色器中我们需要对法线向量再次进行标准化。但是,这并不意味着顶点着色器可以不去将法线向量进行标准化,如果顶点与顶点之间法线向量的长度差别较大,例如,由顶点融合造成的,这样就会影响之后基于三角形的插值操作(三角形中的fragment的大多数法线向量是偏向长度较大的法线向量的)。正因为上述原因,我们会在顶点着色器和像素着色器中对法线向量进行标准化。

不同于表面的法线向量,与具体位置相关的向量,例如,指向camera的view向量和指向精准光源的光线向量,一般不会进行插值。也就是说,我们将在像素着色器中使用插值后的表面的坐标点来计算这些向量。如果出于某些原因,不得不在顶点着色器中计算这些向量,经过插值,再从像素着色器读取这些向量的,那么我们也不应该在顶点着色器中对这些向量进行标准化。原因如下图所示。

上图以光线向量l为例,如果我们在顶点着色器中对其进行标准化,那么插值后,位于中间的点的光线向量便不再指向光源了。

之前我们提到,顶点着色器会将表面的几何体转换到“合适的坐标系”。camera与光源的位置会通过统一的着色器输入传入像素着色器,一般来说,CPU会将这些坐标点转换至“合适的坐标系”。这样,像素着色器就不需要再将这些值转换至同一坐标系了。但是哪一个坐标系是合适的呢?世界坐标系?camera的本地坐标系?还是正在绘制的模型的本地坐标系?而我们需要基于整个渲染系统进行考虑,例如,性能,便利性等等。例如,如果我们绘制的场景包含了大量的灯光,那么我们可能会选择世界坐标系,这样就不需要对光源的位置进行转换。否则的话,我们会更倾向选择camera坐标系,这样能够简化view向量的计算,而且能提高精确度。

大多数的着色器代码实现都会遵循上述原则,但也有例外。例如,有些游戏的画面是low polygon风格的,那么着色模式就会基于每个图元。而这一类的着色模式被称为flat shading。如下图所示。

5.3.2 实现

在本小节中,我们将展示一个着色模式的例子。正如我们之前提到的,例子中的着色模式类似于Gooch模式,但是其适用于多个光源。

在大多数渲染应用中,不同的材质属性,例如csurafce会存储在顶点数据中,或者更常见的做法是存储在贴图中。但是,为了让我们的例子尽可能的简洁,我们假设csurafce是一个常量。

这个着色模式将使用着色器的动态分支来遍历所有的光源。但是这个做法只适合简单的场景,如果场景中的光源较多且几何体较为复杂,那么我们就不能使用这种方式进行渲染。而我们将在第二十章中学习如何有效率地管理场景中大量的灯光。同时,为了便于理解,我们的着色模式只支持一种光源,点光源。

着色模式并不是独立的,其建立在一个庞大的渲染架构之上。而我们的例子以WebGL2的应用作为基础,但是其原理也适用于其他更为复杂的代码架构。

我们将会学习一些GLSL着色器代码与JavaScript WebGL应用中的例子。其并不是为了教会大家使用WebGL的API,只是为了展示基本的实现原理。而我们的学习顺序是“从内而外”,也就是说,起始于像素着色器,之后是顶点着色器,最后才是应用端调用API。

在开始着色器代码运行之前,着色器将包含着色器输入和输出的定义。正如我们在第三章中提到的,在使用GLSL时,着色器的输入被分为两种类型。一种是统一(uniform)输入,这些值由应用设置,且在一个draw call中将保持恒定不变。第二种则是改变(varying)输入,这一类变量能够在着色器中发生变化(例如,像素或者顶点)。这里我们将看到像素着色器的varying输入,其在GLSL中被标记为in,而输出则标记为out

// GLSL
in vec3 vPos;
in vec3 vNormal;
out vec4 outColor;

这个像素着色器只有一个输出,就是最后的颜色值。像素着色器的输入将会与顶点着色器的输出相匹配,而顶点着色器的输出将基于三角形进行插值,之后才会被传入像素着色器。这个像素着色器有两个varying输入:表面点的位置坐标和表面的法线向量,这两个坐标都位于应用的世界坐标系。统一输入的数量非常多,所以为了便于理解,我们值列举了两个与光源相关的定义:

// GLSL
struct Light
{
    vec4 position;
    vec4 color;
};

uniform LightUBlock
{
    Light uLights[MAXLIGHTS];
}

uniform uint uLightCount;

由于光源是点光源,每个光源的定义中只包含了光源的位置坐标和光源的颜色。这些值被定义为vec4而不是vec3,是为了保持GLSL std140的数据排列标准。虽然std140的排列标准会浪费一定的存储空间,但是其确保了CPU与GPU之间数据排列的一致性。结构体Light的数组被定义在一个命名的统一Block中,这也使GLSL的一个特性,为了更快的数据传输,其将一组统一变量绑定至一个buffer对象中。该数组的长度等于我们在应用中定义的在一个draw call允许的最大光源数。之后我们将看到,在着色器编译前,MAXLIGHTS被正确的值替代。统一整型数uLightCount则是draw call中实际使用的灯光数量。

接下来,我们将看到像素着色器中的代码:

// GLSL
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, hightlightColor, 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.rbg += NdL * uLights[i].color.rgb * lit(l, n, v);
    }
}

我们为着色模式的lit部分定义了一个函数,名为main()。总的来说,这就是通过GLSL来直接实现我们在本小节开头所展示的着色模式。需要注意的是,funlit()的值与cwarm都是通过统一变量进行传递。由于这些值在整个draw call中都是恒定不变的,CPU可以负责这一部分计算。

上述像素着色器使用了多个GLSL内置的函数。函数reflect()将会基于一个向量反射另一个向量,在代码中我们需要基于平面的法线向量反射光线向量。由于我们希望光线向量和反射后的向量都是远离平面的(在代码中,我们对于光线向量l的定义还是光线射出的方向而不是由像素点指向光源),我们需要为光线向量添加负号。函数clamp()有用三个参数。其中两个规定了范围,第三个则是clamp的对象。clamp的特殊情况是,范围0到1的clamp(其与HLSL中的函数saturate()相对应),这一类的clamp非常高效。这也使为什么即使我们知道计算结果不会超过1,但还是将范围定在了[0,1]。函数mix()也有三个参数,其中两个参数为插值的范围,而第三个参数则是插值的对象,在代码中,我们将对warm color和highlight color进行插值。在HLSL中,该函数被称为lerp()。最后,normalize()会将一个向量除以其自身的长度,使其长度等于1,也就是标准化。

现在再让我们看一下顶点着色器。这里我们不会再展示任何统一变量的定义,只有作为输入与输出的varying变量:

// GLSL
layout(location = 0) in vec4 position;
layout(location = 1) in vec4 normal;
out vec3 vPos;
out vec3 vNormal;

需要注意的是,正如我们之前提到的,顶点着色器的输出必须与像素着色器的varying输入相匹配。而顶点着色器的输入也展示了数据是如何在顶点数组中的排列。

// GLSL
void main()
{
    vec4 worldPosition = uModel * position;
    vPos = worldPosition.xyz;
    vNormal = (uModel * normal).xyz;
    gl_Position = viewProj * worldPosition;
}

上述代码都是一个顶点着色器的常见操作。着色器将表面的坐标点与法线向量转换至世界空间并将其传输至像素着色器。最后,表面上点会被转换至剪裁空间并传入gl_Position,这是一个系统定义的变量,光栅化时会使用该变量。而gl_Position也是顶点着色器需要的一个输出。

需要注意的是我们在顶点着色器中并没有对法线向量进行标准化,这是因为在原始的网格数据中它们的长度就是1,而且在这个应用中,我们也不会进行任何例如顶点融合或者非统一化的缩放。而模型的矩阵可能会有一个统一的缩放因子,但是这个缩放因子会按比例将所有的法线进行缩放,所以这也不会造成我们之前提到的法线插值错误的问题。

我们例子中的应用使用了WebGL API来进行渲染和着色器设置。每一个可编程的着色器阶段都是独立设置,之后都被绑定至一个对象。以下为像素着色器的设置:

// JavaScript
var fSource = document.getElementById("fragment").text.trim();

var maxLights = 10;
fSource = fSource.replace(/MAXLIGHTS/g, maxLights.toString());

var fragmentShader = gl.craeteShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fSource);
gl.compileShader(fragmentShader);

我们可以看到上文提到了“fragment shader”。这是WebGL和OpenGL对于像素着色器的称呼。此外,在代码中我们还是用数值来替代MAXLIGHTS。大多数渲染框架会使用类似的预编译着色器处理。

除了上述内容,应用端还有更多的代码来设置统一变量,初始化顶点数组,清空back buffer,绘制等等,而具体的API用法我们可以参考更为专业的API书籍,例如,Introduction to 3D Game Programming With Direct X。而本章节意在告诉大家着色器是如何称为一个独立的处理器的,所以更为详细的API代码,不会在这里列出。

5.3.3 材质系统

一般来说,渲染框架不会只实现一个着色器,而是处理各种不同的材质,着色模式以及应用端需要的着色器。

在之前的几个章节,我们将着色器称作是GPU的一个可编程着色器阶段。所以这是一个底层图形学API资源,美术是不能直接对这个参数进行修改的。相反,材质(material)是美术需要设置的物体表面的外观参数。有时候,材质还会表示非外观的参数,例如碰撞属性。

虽然材质这一概念需要通过着色器来实现,但材质与着色器并不是一一对应的关系。在不同情况下,用一个材质可能应用于不同的着色器。一个着色器也可以由多个材质共享。最常见的例子就是参数化的材质。材质参数化需要两类材质实体:材质模板(material template)材质实例(material instance)。每一个材质模板都是一个材质类(class),其拥有一组参数,可以是数值,颜色或者贴图值等等。而每一个材质实例则对应一个材质模板,而且其中的参数都是一个特定值。一些渲染框架,例如虚幻引擎,其支持更复杂的材质层级结构,例如,一个材质模板可以继承自另一个材质模板。

参数可以在程序运行时决定,例如,通过统一输入传输至着色器程序;也可以在编译时决定,例如,在编译前就设定为一个值。对于后者来说,更为常见的做事是使用一个bool开关来控制材质特性的启用或禁用。而美术可以通过引擎编辑器的选项来控制材质系统。

虽然材质的参数可能与着色模式的参数一一对应,但通常不是这样。一个材质可能会直接将着色模式中的参数设为一个恒定值,例如,表面的颜色。但是,着色模式的参数也可能是多个材质参数计算后得出的结果,也可能是顶点插值的结果等等。

材质系统最重要的任务就是将不同的着色器函数分为独立的成员并将其进行组合。

  • 表面着色由几何处理组成,例如刚体转换,顶点融合,morphing,tessellation,instancing和剪裁。而这几个功能都互相独立:表面着色取决于材质,而几何处理取决于网格模型。
  • 表面着色由像素舍弃和像素融合所组成。这一部分主要与手机GPU相关,对手机GPU来说,大多数融合操作由像素着色器处理。

如果图形API能够提供模块化的着色器代码,那么将大大简化我们的工作。但是,不同于CPU代码,GPU着色器并不允许代码块的后编译链接。么一个着色器阶段的程序都被编译为一个单元。各个着色器阶段之间的分离可以提供有限的模块化。但是,由于每个着色器也会执行其他操作,我们还要处理其他类型的组合。所以,如果想要材质系统要实现这一类组合,我们只能通过源代码。这主要包括了string字符串的操作,例如组合和替代,通常由预处理指令来完成,例如#include#if#define

当我们在设计一个系统来处理着色器的变量时,第一个需要解决的问题就是代码的判断分支,是要通过着色器运行时的动态分支来实现,还是说通过编译时的预处理条件来实现。在早期的硬件中,动态分支几乎不被使用,或者说其效率过于低下,所以运行时的动态分支基本不会被采用。所有的判断分支在编译时处理,例如,所有可能使用的不同光源的数量。

相反,现代的GPU能够从容应对动态分支,尤其是当这个分支对于draw call中的所有像素都是同一个表现。现在,大多判断分支数操作都会在运行时进行处理,例如光源的数量等等。但是,如果我们在着色器中加入大量的判断分支,情况又会变得不同:寄存器数量的增加,相应的占用率就会减少,因此性能也会降低。我们将在第十八章再来详细讨论这个问题。所以,编译时的判断分支仍然是不错的选择,它能够避免哪些既复杂又永远不会被执行的逻辑。

假设,我们的应用支持三种不同的光源。其中的两种是非常简单的:点光源和方向光。第三种是聚集光,其支持一些区域光的复杂特性,所以这一类的光源需要大量的着色器代码去实现。但是聚集光在应用中的使用却比较少,只有5%不到的光源是聚集光。过去,为了避免动态分支,我们可能会为每一种可能的灯光组合去编译一个单独的着色器。但是现在我们并不需要这样了。不过由于聚集光的使用率较低,我们仍然可以编译两种着色器,第一种的着色模式适用三种光源,而第二种只包含点光源和方向光。由于更为简单的代码构成,第二种着色器(其使用率也更高)有着更低的寄存器占有率,因此其性能也更高。

现代的材质系统会同时使用运行时以及预编译的判断分支。虽然并不是所有的分支都在编译时处理,但是随着游戏不断变得复杂,判断分支的复杂性和数量都在不断增加,所以还是有大量的着色器判断分支需要在编译时进行处理。例如,命运(Destiny)在有些场景中每一帧会有超过9000个预编译着色器分支。而可能出现的分支会更多,例如,Unity渲染系统拥有大约1000亿个可能分支。不过,只有真正被使用的分支才会被编译,但是我们仍然需要重新设计着色器的编译系统来处理如此大量的判断分支。

材质系统的设计者需要使用不同的策略来实现这些目标。而以下这些要点通常在渲染材质系统中都会被采用。

  • 代码重用——在共享的文件中实现函数,使用#include这一类预编译指令,以从其他着色器读取我们所需要的函数。
  • 做减法——一个着色器通常使用预编译和动态分支以去除不会被使用的部分
  • 做加法——不同的功能被定义为带有输入与输出的节点,而这些节点可以互相组合。这类似于上面提到的代码重用,但是结构性更强。节点的组合可通过本文编辑器或者图形编辑器实现。这样,技术美术或者非程序员也能够设置新的材质模板。一般来说,图形编辑器只能读取或者改变部分着色器。例如,虚幻引擎的图形编辑器只能影响着色模式的输入参数。
  • 以模板为基础——我们定义一个结构,不同的实现逻辑都能被加入这个接口,只要其标准与接口相符。例如,着色模式参数的计算方法与着色模式本身的计算有着不同的接口。虚幻引擎有着不同的材质节点,包括Surface节点,其用于计算着色模式的参数,而Light Function节点则用于计算标量,其控制光源的颜色。Unity中也存在类似的“表面着色器”结构体。需要注意的是,延迟着色技术(deferred shading techniques,我们将在第二十章学习)也需要类似的结构体,而G-buffer将作为接口。

关于更为详细的例子,WebGL Insights一书中有关于着色器管线方面的内容。除了上述提到的内容,现代的材质系统还需要考虑是否支持不同的平台,且代码的重复率不能太高。例如,各个函数中的分支不但靠率 性能还需要考虑对于不同平台,不同着色器语言,不同api的兼容。命运的着色器系统则是解决这一类问题的代表。其使用一个专有的预处理器层来将着色器写为一种特殊的着色器语言。这样我们就能“书写”适用于各个平台的着色器,之后再自动翻译为不同的着色器语言和实现。虚幻引擎和Unity都拥有类似的系统。

材质系统还要保证良好的性能。除了预编译的着色器怕那段分支,材质系统还是用其他的优化手段。命运和虚幻引擎会自动检测,如果一个计算在整个draw call中都是恒定的,那么会将其移出着色器。另一个例子是,命运所使用的分类系统,其更具constant buffer的更新频率对其进行分类(例如,每一帧更新,每一个光源更新,每一个物体更新),之后在合适的时机去更新这些constant buffer以减少API的开销。

正如我们看到的,实现一个着色运算是决定其中的哪些部分可以简单化,以何种频率去计算不同的表达式,和编辑器用户如何改变并控制物体表面的外观。而渲染管线最终的输出是一个颜色和融合值(alpha)。其他部分还有抗锯齿,透明度,图像显示细节。

留下评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据