游戏开发者指南- Qualcomm® Adreno ™ GPU(2)
2.2 着色器
2.2.1 一般的
使用内置函数
内置函数是 OpenGL ES 着色语言规范的重要组成部分,应始终优先于编写自定义实现来使用。这些函数通常针对特定着色器配置文件以及编译着色器的硬件功能进行优化。因此,它们通常比任何其他实现更快。
!笔记
gl_VertexID 和 gl_InstanceID 根据GL_KHR_vulkan_glsl扩展被删除,但 gl_InstanceIndex 可用。
!笔记
gl_Position、gl_PointSize、gl_ClipDistance、gl_CullDistance 在非片段阶段可用
有关 Vulkan 中 GLSL 内置项更改的详细信息,请参阅GL_KHR_vulkan_glsl扩展。
使用适当的数据类型
在代码中使用最合适的数据类型可以使编译器和驱动程序优化代码,包括着色器指令的配对。
使用 vec4 数据类型而不是 float 可能会阻止编译器执行优化。小错误可能会对性能产生重大影响。
另一个例子是以下代码应采用单个指令槽:
int4 ResultOfA(int4 a) {
return a + 1;
}
现在假设代码中引入了一个小错误。例如,使用浮点常量值 1.0,这不是适当的数据类型。
int4 ResultOfA(int4 a) {
return a + 1.0;
}
该代码现在可以消耗八个指令槽。将变量a转换为vec4,然后以浮点形式进行加法。最后,将结果转换回返回类型int4。
缩小型铸造
还建议减少执行的类型转换操作的数量。以下代码可能不是最优的:
uniform sampler2D ColorTexture;
in vec2 TexC;
vec3 light(in vec3 amb, in vec3 diff)
{
vec3 Color = texture(ColorTexture, TexC);
Color *= diff + amb;
return Color;
}
这里,对纹理函数的调用返回一个 vec4。有一种隐式类型转换为 vec3,这需要一个指令槽。按如下方式更改代码可能会将指令编号减少 1:
uniform sampler2D ColorTexture;
in vec2 TexC;
vec4 light(in vec4 amb, in vec4 diff)
{
vec4 Color = texture(Color, TexC);
Color *= diff + amb;
return Color;
}
打包标量常量
将标量常量打包到由四个通道组成的向量中可显着提高硬件读取效率。对于动画系统,这会增加可用于蒙皮的骨骼数量。
考虑以下代码:
float scale, bias;
vec4 a = Pos * scale + bias;
通过按如下方式更改代码,可能会少用一条指令,因为编译器可以将该行优化为更有效的指令(疯狂):
vec2 scaleNbias;
vec4 a = Pos * scaleNbias.x + scaleNbias.y;
保持合理的着色器长度
过长的着色器可能效率低下。如果需要在着色器中包含相对于纹理获取数量的许多指令槽,请考虑将算法分成几个部分。由算法的一个部分生成供另一部分稍后重用的值可以存储到纹理中,并随后通过纹理获取来检索。然而,这种方法在内存带宽方面可能会很昂贵。使用三线性、各向异性过滤、宽纹理格式、3D 和立方体贴图纹理、纹理投影、具有不同 Lod 梯度的纹理查找或跨像素四边形的梯度也可能会增加纹理采样时间并降低总体效益。
以有效的方式采样纹理
为了避免纹理停顿,请遵循以下规则:
-
避免随机访问 - 硬件在 2x2 片段块上运行,因此如果着色器访问单个块内的相邻纹理像素,它们会更高效。
-
避免 3D 纹理 - 由于需要执行复杂的过滤来计算结果值,因此从体积纹理中获取数据的成本很高。
-
限制从着色器采样的纹理数量 - 在单个着色器中使用四个采样器是可以接受的,但在单个着色器阶段访问更多纹理可能会导致性能瓶颈。
-
压缩所有纹理 - 这可以更好地使用内存,从而减少渲染管道中的纹理停顿数量。
-
考虑使用 mipmap – Mipmap 有助于合并纹理获取,并有助于提高性能,但会增加内存使用量
纹理过滤会影响纹理采样的速度。滤波器性能取决于架构/芯片,开发人员可能会发现在某些架构中使用双线性或最近滤波优于三线性或各向异性滤波。Mipmap 钳位可以降低使用三线性过滤的成本,因此在实际情况下平均成本可能会更低。添加各向异性过滤,各向异性程度成倍增加;这意味着,16x 各向异性查找可能比常规各向同性查找慢 16 倍。但是,由于各向异性过滤是自适应的,因此仅对需要各向异性过滤的片段进行此命中。总共可能只是几个碎片。现实世界案例的经验法则是,平均而言,各向异性过滤的成本不到各向同性过滤成本的两倍。
立方体贴图纹理和投影纹理查找不会产生任何额外成本,而基于 dFdx 和 dFdy 函数的着色器特定渐变则需要额外成本。这些特定于着色器的渐变无法跨查找存储。如果在同一采样器中使用相同的梯度再次进行纹理查找,则会再次产生成本。
飞行中的线程/动态分支
分支对于着色器的性能至关重要。每当分支遇到分歧时,或者线程的某些元素以一种方式分支而某些元素以另一种方式分支时,两个分支都将通过对不采用给定分支的元素使用 NULL 输出操作的谓词来采用。仅当数据以遵循这些条件的方式对齐时,这才是正确的,而片段着色器很少出现这种情况。分支分为三种类型,按 Adreno GPU 上性能从最佳到最差的顺序列出:
-
在编译时已知的常量上分支
-
在统一变量上分支
-
在着色器内部修改的变量上分支
根据常量进行分支可能会产生可接受的性能。
打包着色器插值器
着色器插值或变化需要 GPR(通用寄存器)来保存输入片段着色器的数据。因此,尽量减少它们的使用。
使用值统一的常量。将值打包在一起,因为所有变量都有四个组件,无论是否使用它们。将两个 vec2 纹理坐标放入单个 vec4 值是一种常见的做法,但其他策略采用更具创造性的打包和动态数据压缩。
笔记:
OpenGL ES 3.0 和 ES 3.1 引入了各种内部函数来执行打包操作。
最小化着色器 GPR 的使用
最大限度地减少探地雷达的使用可能是优化性能的重要手段。向编译器输入更简单的着色器有助于保证最佳结果。修改 GLSL 来保存甚至单个指令有时也可以保存 GPR。不展开循环也可以节省 GPR,但这取决于着色器编译器。始终对着色器进行分析,以确保选择的最终解决方案对于目标平台来说是最有效的。展开的循环往往会将纹理获取移向着色器顶部,导致需要更多 GPR 来同时保存多个纹理坐标和获取结果。
例如,如果展开下面所示的循环:
for (i = 0; i < 4; ++i) {
diffuse += ComputeDiffuseContribution(normal, light[i]);
}
代码片段将替换为:
diffuse += ComputeDiffuseContribution(normal, light[0]);
diffuse += ComputeDiffuseContribution(normal, light[1]);
diffuse += ComputeDiffuseContribution(normal, light[2]);
diffuse += ComputeDiffuseContribution(normal, light[3]);
最小化着色器指令数
编译器会优化特定的指令,但它不会自动高效。分析着色器以尽可能保存指令。即使保存一条指令也是值得的。
避免超级着色器
Uber-shaders 将多个着色器组合成一个使用静态分支的着色器。如果尝试减少状态更改和批量绘制调用,那么使用它们是有意义的。然而,这通常会增加 GPR 计数,从而影响性能。
避免对着色器常量进行数学计算
自着色器出现以来,几乎所有发布的游戏都使用指令对着色器常量执行不必要的数学运算。识别着色器中的这些指令并将这些计算移至 CPU。在后编译的微代码中识别着色器常量的数学可能会更容易。
避免丢弃片段着色器中的像素
一些开发人员认为,手动丢弃(也称为删除)片段着色器中的像素可以提高性能。规则并不那么简单,原因有二:
-
如果线程中的某些像素被终止而其他像素未被终止,则着色器仍会执行。
-
这取决于着色器编译器如何生成微代码。
理论上,如果一个线程中的所有像素都被杀死,GPU 将尽快停止处理该线程。实际上,丢弃操作可以禁用硬件优化。
如果着色器无法避免丢弃操作,请尝试渲染几何体,这在不透明绘制调用之后取决于它们。
避免修改片段着色器中的深度
与丢弃片段类似,修改片段着色器中的深度可以禁用硬件优化。
避免在顶点着色器中获取纹理
Adreno基于统一的着色器架构,这意味着顶点处理性能与片段处理性能相似。然而,为了获得最佳性能,重要的是要确保顶点着色器中的纹理获取是本地化的并且始终对压缩纹理数据进行操作。
分解绘制调用
如果着色器大量使用 GPR 和/或大量纹理缓存需求,则可以通过将绘制调用分解为多个通道来提高性能。结果是否会是积极的很难预测,因此使用两种方式进行现实世界的测量是最好的决定方法。理想情况下,两遍绘制调用会将其结果与简单的 alpha 混合相结合,由于图形内存 (GMEM),这对 Adreno GPU 来说并不繁重。
一些开发人员可能会考虑使用真正的延迟渲染算法,但该方法有许多缺点,例如,必须解析 GMEM 才能将前一个通道用作后续通道的输入。由于解析不是免费的,因此必须在算法的其他地方补偿性能成本。
笔记:
Vulkan:理想情况下,使用 Vulkan 的 RenderPass 将有助于最大限度地减少 GMEM 解析,因此重组渲染算法以使用尽可能多的子通道将是最佳方法。
尽可能使用中等精度
在 Adreno 上,16 位操作往往比 32 位操作更快、更节能。QTI 建议将默认精度设置为mediump,并仅提升那些需要更高精度的值。
然而,在某些情况下,highp 必须用于着色器中的某些变化,例如纹理坐标。这些情况可以使用条件语句和基于预处理器的宏定义来处理,如下所示:
precision mediump float;
#ifdef GL_FRAGMENT_PRECISION_HIGH
#define NEED_HIGHP highp
#else
#define NEED_HIGHP mediump
#endif
varying vec2 vSmallTexCoord;
varying NEED_HIGHP vec2 vLargeTexCoord;
与片段着色器计算相比,有利于顶点着色器计算
通常,顶点计数明显少于片段计数。通过将计算从片段着色器转移到顶点着色器,可以减少 GPU 工作负载。这有助于消除冗余计算。
测量、测试和验证结果
无论应用程序是顶点绑定、片段绑定还是纹理获取绑定,找到瓶颈对于优化都是必要的。在尝试使代码更快之前先测量性能。使用工具进行这些测量,例如 Snapdragon Profiler 甚至软件计时器。
不要仅凭直觉假设某些东西运行得更快。当代码被修改以获得更好的性能时,它可以禁用更有益的编译器/硬件优化。始终测量更改前后的时间,以评估为优化而执行的修改的影响。
优先选择统一缓冲区而不是着色器存储缓冲区
只要只读访问足以满足需求并且统一缓冲区提供的空间量足够,则始终优先使用它们而不是着色器存储缓冲区。它们在 Adreno 架构下可能会有更好的表现。如果统一缓冲区对象在 GLSL 中静态索引并且足够小,驱动程序或编译器可以将它们映射到用于默认统一块统一的相同硬件常量 RAM,则这是正确的。
出于性能原因,在 Adreno 硬件上也更喜欢使用统一缓冲区而不是推送常量。
在镶嵌过程中消除子像素三角形
曲面细分可提高细节级别,并允许其他游戏子系统在网格的低分辨率表示上运行,从而减少内存带宽和 CPU 周期。然而,高水平的曲面细分会生成子像素三角形,从而导致光栅器利用率不佳。利用距离、屏幕空间大小或其他自适应指标来计算避免亚像素三角形的镶嵌因子非常重要。
在镶嵌过程中进行背面剔除
硬件背面剔除发生在曲面细分阶段之后,这可能会浪费 GPU 资源来曲面细分背面图元。这些可以在曲面细分控制着色器阶段进行识别,并通过将其边缘曲面细分因子设置为 0 进行剔除。
笔记:
如果在曲面细分评估着色器阶段使用位移贴图,则在此计算中包括一个轻微的“模糊”因素,因为此技术可能会改变图元的可见性。
不需要时禁用曲面细分
如果网格的细分因子约为 1,请尽可能禁用细分控制着色器和细分评估着色器阶段。这消除了渲染过程中不必要的 GPU 阶段的使用。
使 UBO 尽可能小
适合 8k 常量内存的 UBO 比较大的 UBO 性能更好,因为每个波形都必须从主内存中获取。
2.2.2 OpenGL ES 特定
初始化期间编译和链接
着色器的编译和链接是一个耗时的过程。与 OpenGL ES 中的其他调用相比,它的成本较高。建议在初始化期间加载和编译着色器,然后在渲染阶段根据需要调用 glUseProgram 在着色器之间进行切换。
对于 OpenGL ES 2.0、ES 3.0 和 ES 3.1 上下文,建议使用 blob 二进制文件。编译和链接程序对象后,可以使用以下函数之一检索二进制表示形式或 blob:
-
glGetProgramBinaryOES – 如果使用带有可用 GL_OES_get_program_binary 扩展的 OpenGL ES 2.0 上下文
-
glGetProgramBinary – 如果使用 OpenGL ES 3.0 或 3.1 上下文(核心功能)
然后可以将该 blob 保存到持久存储中。下次启动应用程序时,无需重新编译和重新链接着色器。相反,从持久存储中读取 blob 并使用 glProgramBinaryOES 或 glProgramBinary 将其直接加载到程序对象中。这可以显着加快应用程序的启动时间。
警告:
许多 OpenGL ES 实现都有在链接程序对象时将构建时 GL 状态合并到程序对象中的习惯。如果该程序随后用于在不同 GL 状态配置的上下文中发出的绘制调用,则 OpenGL ES 实现必须透明地即时重建程序。此行为在 OpenGL ES 规范中是合法的。然而,它可能会给开发人员带来严重的问题。重建程序对象通常需要大量时间,这可能会导致严重的丢帧。应用程序未请求重建,因此延迟是意外的,并且其原因可能并不明显。
在 Adreno 平台上,这不是问题。Adreno 驱动程序从不重新编译着色器。可以安全地假设,除了特定请求之外,程序对象永远不会被重建。
尽早使帧缓冲区内容无效
应用程序应使用 glInvalidateFramebuffer 和 glInvalidateSubFramebuffer API 调用来通知驱动程序可以自由删除当前绘制帧缓冲区的内容(或其区域)。这对于平铺渲染模式很重要,因为驱动程序可以使用这些提示来减少硬件必须执行的工作量。
优化顶点缓冲区对象更新
如果在渲染帧时需要动态修改顶点缓冲区对象内容,请确保在发出使用修改后的 VBO 区域的任何绘制调用之前批处理所有 VBO 更新(glBufferSubData 调用)。如果使用多个 VBO,请首先对所有 VBO 进行批处理更新,然后发出所有绘制调用。
如果不遵循这些建议,可能会导致驱动程序维护整个 VBO 的多个副本,从而导致性能下降。