着色器优化
着色器性能直接决定渲染帧率,优化着色器是图形编程的核心技能。优化的目标是减少每个着色器实例的指令数和内存访问,利用 GPU 的并行特性最大化吞吐量。GPU 的执行模型不同于 CPU,数百个线程同时执行相同指令,任何线程的延迟都会阻塞整个 warp,因此优化必须关注最坏情况而非平均情况。
GPU 的性能瓶颈可分为三类:计算受限(Compute Bound)、内存带宽受限(Memory Bandwidth Bound)、纹理带宽受限(Texture Bandwidth Bound)。计算受限意味着着色器的数学运算量太大,GPU 的 ALU 单元饱和,需要减少指令数。内存带宽受限意味着着色器访问全局内存太频繁,显存带宽成为瓶颈,需要减少内存访问。纹理带宽受限意味着着色器采样纹理太频繁,纹理缓存未命中,需要优化纹理访问模式。确定瓶颈类型是优化的第一步,不同瓶颈需要不同的优化策略。
指令级优化
指令级优化减少着色器的指令数量,提升 ALU 利用率。分支是优化的重点,GPU 使用 SIMD/SIMT 执行模型,warp 内的 32 个线程(NVIDIA)或 64 个线程(AMD)必须执行相同指令。如果 warp 内的线程走不同分支(如 if-else),则需要串行执行各分支,性能下降。解决方法包括:使用 mix() 或 select() 代替简单的 if-else,编译器将其编译为条件移动(无分支);确保分支在 warp 内一致(如基于像素坐标的分支会导致发散,基于常量的分支不会发散)。
// 不推荐:分支导致 warp 发散
if (value > 0.5) {
result = color1;
} else {
result = color2;
}
// 推荐:使用 mix 无分支
result = mix(color2, color1, step(0.5, value));
// 或使用条件运算符
result = (value > 0.5) ? color1 : color2;除法和开方是昂贵的操作,应尽量避免。除法可用乘倒数代替,x / y 变为 x * (1.0 / y),如果 y 是常量或重复使用,可预计算倒数。pow(x, y) 对于整数指数可展开为乘法,x * x * x 比 pow(x, 3.0) 快。sqrt(x) 如果后续用于归一化,可用 rsqrt(x) 乘法代替,x * rsqrt(x) 比 x / sqrt(x) 快。
三角函数是 GPU 上的查表或多项式近似,开销较大。如果角度是常量或有限几个值,可预计算结果。如果参数范围有限,可用泰勒展开或多项式近似。如果精度要求不高,可用低精度实现(如 sin(x) 改为 sin(lowp x),移动 GPU 支持 lowp/mediump/highp 精度修饰符)。
// 不推荐:昂贵的 pow
float specular = pow(max(dot(N, H), 0.0), shininess);
// 推荐:shininess 是整数时展开
float spec = max(dot(N, H), 0.0);
float specular = spec;
for (int i = 1; i < int(shininess); ++i) {
specular *= spec;
}内置函数通常比手写实现更快。mix()、smoothstep()、step()、sign() 等函数在 GPU 上有专用指令,手写 if-else 反而更慢。dot()、cross()、reflect()、refract() 等几何函数也高度优化。向量操作(加法、乘法)是单指令多数据(SIMD),比逐元素循环快得多。
内存访问优化
内存访问优化减少全局内存访问次数,提升缓存命中率。全局内存(显存)的延迟很高(~300 时钟周期),带宽有限(~500 GB/s),应尽量减少访问。寄存器是最快的存储(~200 TB/s),但数量有限(每个线程约 255 个 32-bit 寄存器),超出限制会触发溢出(spilling),性能急剧下降。共享内存在线程组内共享(~20 TB/s),适合缓存频繁访问的数据,但需要显式管理。
合并访问(Coalescing)是内存优化的关键。warp 内的 32 个线程应访问连续的 32 个元素,这样硬件可将这 32 次访问合并为 1 次事务。如果线程访问跨步较大的地址(如每 128 个元素访问一次),会触发多次事务,降低带宽利用率。对于纹理采样,合并访问自动满足(纹理坐标连续即可)。对于存储缓冲区,需要确保访问模式是连续的(线程 i 访问元素 i,而非元素 i*stride)。
// 不推荐:跨步访问导致多次事务
for (int i = 0; i < n; i += 128) {
sum += data[i];
}
// 推荐:连续访问合并事务
for (int i = 0; i < n; ++i) {
sum += data[i];
}常量缓冲区用于只读数据(变换矩阵、材质参数),GPU 有专门的缓存路径。存储缓冲区用于读写数据,但访问不如常量缓冲区高效。如果数据是只读的,优先使用常量缓冲区或纹理(纹理缓存对空间局部性友好)。如果数据需要随机写入,使用存储缓冲区,并注意原子操作的开销。
纹理缓存对空间局部性友好。采样附近像素时,缓存命中率很高。如果纹理访问模式是随机的(如噪声函数、程序化纹理),可考虑使用纹理数组或 3D 纹理,增加空间局部性。Mipmap 可减少纹理带宽(低分辨率层使用更少内存),但需要额外的纹理采样指令。textureLod() 可显式指定 mipmap 层级,避免硬件计算导数的开销。
着色器阶段优化
顶点着色器的优化目标是减少顶点处理时间。GPU 有顶点缓存(Post Transform Cache),已变换的顶点可复用(索引绘制时)。顶点着色器应尽量简单,复杂计算移到片段着色器(片段数量可能少于顶点数量,或可通过提前深度测试减少)。使用量化格式(如 int16 代替 float 表示位置)可减少内存带宽,但需要在着色器中解码。
几何着色器的开销较大,应谨慎使用。几何着色器会禁用顶点缓存,增加内存 bandwidth。如果几何着色器的输出数量固定且较小,考虑用实例化代替(多次绘制调用的开销可能更小)。曲面细分着色器比几何着色器更高效,但需要硬件支持。对于简单的几何扩展(如法线可视化),用几何着色器;对于复杂的细分,用曲面细分着色器。
片段着色器是优化重点,因为像素数量远大于顶点数量。早期深度测试(Early-Z)是关键优化,在片段着色器前执行深度测试,剔除被遮挡的片段。确保着色器不修改深度(discard 或 gl_FragDepth),驱动会自动启用 Early-Z。对于 Alpha 测试,使用 clip() 或 discard 会禁用 Early-Z,可考虑用 Alpha-to-Coverage(MSAA 模式)或预乘 Alpha 替代。
// 不推荐:discard 禁用 Early-Z
if (alpha < threshold) {
discard;
}
// 推荐:Alpha-to-Coverage 保留 Early-Z
// 启用 MSAA 和 sample-alpha-to-coverage,着色器输出 alpha
color = vec4(rgb, alpha); // alpha < threshold 的样本会被覆盖延迟渲染(Deferred Shading)减少光照计算。前向渲染(Forward Rendering)对每个光源渲染场景,光源数量增加时开销线性增长。延迟渲染先渲染几何信息(位置、法线、颜色)到 G-Buffer,然后对每个像素计算光照,光源数量对性能影响较小。延迟渲染的缺点是:不支持 MSAA(需特殊技术)、带宽开销大(G-Buffer 占用大量显存)、不支持透明物体(透明物体仍需前向渲染)。
纹理优化
纹理优化减少纹理采样次数和带宽。Mipmap 是必备技术,不仅减少带宽,还避免摩尔纹(纹理采样频率不足时的 aliasing)。生成 mipmap 会增加内存占用(约 33%),但收益远大于成本。纹理压缩(如 ASTC、BC7)可减少内存占用和带宽,压缩格式在 GPU 上硬件解压,性能优于未压缩纹理。纹理压缩是有损的,但视觉质量通常可接受。
纹理数组比多个 2D 纹理更高效。纹理数组的采样与 2D 纹理开销相同,但减少了资源绑定切换。纹理立方体(Cubemap)用于环境映射,采样开销略高于 2D 纹理,但比多次采样 2D 纹理(6 次)快得多。3D 纹理用于体积数据(体素、噪声),采样开销与 2D 纹理相同,但内存占用更大。
纹理采样模式影响性能。线性滤波(Linear)比最近邻(Nearest)慢,但质量更好。Mipmap 线性滤波(LinearMipmapLinear)比单一 mipmap 层慢,但避免摩尔纹。各向异性滤波(Anisotropic)改善倾斜视角的采样质量,但增加带宽。根据需求选择合适的模式,2D 游戏可用各向异性 16x,移动游戏可降至 4x 或禁用。
// 纹理采样优化
uniform sampler2D tex;
uniform sampler2D texArray[4]; // 纹理数组
// 不推荐:多次采样
vec4 color = texture(tex, uv);
color += texture(tex, uv + vec2(1.0, 0.0)); // 多次采样
// 推荐:纹理数组的单次采样
float layer = computeLayer(uv);
vec4 color = texture(texArray, vec3(uv, layer)); // 单次采样计算着色器优化
计算着色器的优化关注内存访问和同步。共享内存(shared 或 groupshared)是线程组内的高速缓存,应尽量利用。将全局内存数据加载到共享内存,然后在线程组内计算,减少全局内存访问。共享内存的大小有限(通常 32-64 KB),需要合理分配。共享内存的 bank conflict 会降低性能,应确保线程访问不同的 bank(访问步长不是 bank 大小的倍数)。
同步原语(barrier()、groupMemoryBarrier()) 确保共享内存的正确性,但开销较大。应尽量减少同步次数,将可并行执行的代码合并。原子操作(atomicAdd、atomicMin)用于线程间通信,但串行化执行,应尽量减少使用。如果可能,用线程安全的算法(如前缀和、归约)代替原子操作。
工作组大小影响性能。工作组太小(如 32 线程)无法充分利用 GPU,工作组太大(如 1024 线程)可能导致寄存器溢出和 shared memory 不足。最佳工作组大小取决于硬件(NVIDIA 建议 32 的倍数,AMD 建议 64 的倍数),应通过实验确定。[numthreads(x, y, z)] 应匹配数据访问模式,如 2D 纹理处理可用 [numthreads(16, 16, 1)](256 线程)。
// 计算着色器优化示例
groupshared float sharedData[256];
[numthreads(256, 1, 1)]
void CSMain(uint3 DTid : SV_DispatchThreadID, uint GI : SV_GroupIndex) {
// 加载到共享内存
sharedData[GI] = globalData[DTid.x];
GroupMemoryBarrierWithGroupSync();
// 在共享内存中计算(减少全局内存访问)
float sum = 0.0;
for (uint i = 0; i < 256; ++i) {
sum += sharedData[i];
}
// 写回全局内存
globalData[DTid.x] = sum;
}性能分析工具
性能分析工具是优化的基础,没有测量就没有优化。RenderDoc 是跨平台图形调试器,可捕获帧并分析每个 draw call 的着色器执行时间、资源使用、API 调用。Nsight(NVIDIA)和 Radeon GPU Profiler(AMD)是厂商工具,提供更深入的分析(GPU 时钟、流水线状态、寄存器使用)。Intel 的 Graphics Performance Analyzers 支持 Intel GPU。
GPU 时间戳可精确测量着色器执行时间。Vulkan 的 VK_EXT_calibrated_timestamps、DirectX 11 的 ID3D11Query、OpenGL 的 GL_ARB_timer_query 可查询 GPU 时间戳。WebGPU 的 timestamp-query 可用于浏览器。时间戳分析应针对热点(hotspot),优化的 80/20 法则:20% 的代码占用 80% 的时间,优先优化热点着色器。
着色器编译器输出可提供优化提示。glslangValidator -V 可输出 SPIR-V,spirv-opt 可优化并报告优化项。NVIDIA 的 nvcc(CUDA 编译器)和 ptxas(PTX 汇编器)可输出寄存器使用、shared memory 使用、occupancy(SM 占用率)。occupancy 是优化的关键指标,低 occupancy 意味着 GPU 资源未充分利用,可通过减少寄存器使用、减少 shared memory 使用、增加工作组大小提升。
优化应基于实际测量而非猜测。过早优化是万恶之源,先确保功能正确,再测量性能,最后优化瓶颈。优化后需要重新测量,确保优化有效且没有引入 bug。性能分析是迭代过程,可能需要多次测量-优化-验证的循环。