ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【Unity知识速递】第3期 Unity Shader 编写与性能优化深度解析——从 Shader 基础到移动端性能调优实战

【Unity知识速递】第3期 Unity Shader 编写与性能优化深度解析——从 Shader 基础到移动端性能调优实战

Unity Shader 编写与性能优化深度解析——从 Shader 基础到移动端性能调优实战

栏目:Unity知识速递 | 期号:NO.003 | 日期:2026-10-23

📚 知识要点

一、Unity Shader 体系全景——ShaderLab、CG/HLSL 与 SRP Shader 的三层架构

Unity 的 Shader 体系由三层构成:ShaderLab 声明层 → CG/HLSL 代码层 → 渲染管线适配层。ShaderLab 是 Unity 自研的 Shader 声明语言,用于定义 Shader 的组织结构(Properties、SubShader、Pass、Fallback 等);CG/HLSL 是实际的着色器代码,运行在 GPU 上;SRP Shader 则是 URP/HDRP 管线对 Shader 的扩展封装。

ShaderLab 的核心结构包括:① Properties(属性)——定义 Shader 在 Inspector 中暴露的参数(颜色、纹理、数值等),方便美术和策划调整;② SubShader(子着色器)——一个 Shader 可以有多个 SubShader,Unity 会按顺序选择第一个能在当前硬件上运行的 SubShader,用于适配不同等级的设备;③ Pass(通道)——每个 Pass 对应一次 Draw Call 的渲染过程,包含顶点着色器和片元着色器等可编程阶段,以及固定功能状态(混合模式、剔除模式、深度测试等);④ Fallback(回退)——如果所有 SubShader 都不支持,回退到指定的内置 Shader,保证最基本的渲染效果。

Shader 语言的选择:Unity 支持 CG 和 HLSL 两种 Shader 语言,两者语法几乎一致(CG 是 NVIDIA 的标准,HLSL 是微软的标准,Unity 做了统一封装)。在 SRP 时代,HLSL 成为推荐选择——URP/HDRP 的 Shader 全部基于 HLSL 编写,且 HLSL 与 DirectX/Vulkan 等现代图形 API 的兼容性更好。GLSL 则主要用于 OpenGL ES 平台,但 Unity 会自动将 HLSL/CG 编译为目标平台的 Shader 代码,开发者通常不需要关心底层语言差异。

SRP Shader 与 Built-in Shader 的区别:Built-in 管线的 Shader 使用 CGPROGRAM/ENDCG 块包裹 Shader 代码,依赖 Unity 内置的大量宏和函数(如 UnityObjectToClipPos、UNITY_LIGHTMODEL_AMBIENT 等);SRP Shader 则使用 HLSLPROGRAM/ENDHLSL 块,依赖 Core.hlsl 等核心头文件,内置函数和宏的命名空间更加清晰(如 TransformObjectToHClip 替代 UnityObjectToClipPos)。迁移 Shader 时,除了语法调整,还需要注意光照模型、阴影、雾效等内置机制的差异。

二、Shader 执行流程深度解析——从顶点到片元的全链路

Shader 的执行遵循渲染管线(Render Pipeline) 的固定流程,可编程阶段主要包括顶点着色器(Vertex Shader) 和片元着色器(Fragment Shader / Pixel Shader),此外还有可选的曲面细分(Tessellation)、几何着色器(Geometry Shader)和计算着色器(Compute Shader)等高级阶段。

顶点着色器(Vertex Shader) 是 Shader 执行的第一个可编程阶段,输入是每个顶点的属性(位置、法线、切线、UV、顶点色等),输出是经过变换后的顶点位置(裁剪空间坐标)以及传递给片元着色器的插值数据(varyings)。顶点着色器的核心工作是坐标变换——将顶点从模型空间(Object Space)依次变换到世界空间(World Space)、观察空间(View Space)、裁剪空间(Clip Space),最终由 GPU 的固定功能阶段完成透视除法和视口变换,得到屏幕坐标。顶点着色器的执行次数与模型的顶点数成正比,优化顶点着色器可以减少顶点处理阶段的 GPU 开销。

片元着色器(Fragment Shader) 是 Shader 执行的第二个可编程阶段,也是绝大多数 Shader 性能开销的来源。输入是经过光栅化阶段插值后的 varying 数据(每个像素一个),输出是该像素的最终颜色(以及可选的深度值)。片元着色器负责计算像素的颜色——包括纹理采样、光照计算、法线贴图、PBR 材质计算等。片元着色器的执行次数与被渲染的像素数量成正比,而非顶点数量——一个高多边形模型如果只占屏幕上很小的区域,片元着色器的执行次数可能远少于顶点数;反之,一个覆盖全屏的简单四边形,片元着色器要执行几百万次。这就是为什么填充率瓶颈(Fill Rate Bound) 是移动端最常见的 GPU 性能问题。

插值(Interpolation) 是连接顶点着色器和片元着色器的关键机制。顶点着色器输出的 varying 数据(如 UV、法线、顶点色等)会在三角形内部进行线性插值——每个片元得到的是三个顶点数据按面积权重插值后的结果。插值的精度和方式对性能和效果都有影响:nointerpolation 修饰符可以禁用插值(整个三角形使用第一个顶点的值),noperspective 可以关闭透视校正插值(用于 2D UI 等不需要透视的场景),centroid 可以在多重采样抗锯齿(MSAA)时改善边缘采样质量。合理使用插值修饰符可以在不影响效果的前提下减少 GPU 开销。

三、Shader 性能优化核心策略——计算、纹理与分支的三重优化

Shader 性能优化的核心原则是:片元着色器优先优化(因为执行次数最多)、减少纹理采样(带宽开销大)、避免动态分支(破坏 GPU并行性)、控制精度(移动端低精度更快)。

纹理采样优化是 Shader 性能优化的重中之重。纹理采样是显存带宽消耗的主要来源,每次纹理采样都需要从显存读取数据,带宽不足时会成为性能瓶颈。优化方法包括:① 减少采样次数——能用一次采样解决的就不要采两次,例如将高光和漫反射的信息打包到同一张纹理的不同通道;② 使用 Mipmap——Mipmap 不仅能改善纹理闪烁,更重要的是减少远距离采样的带宽开销(采样更低分辨率的 Mip 层级);③ 纹理压缩——ASTC、ETC2 等压缩格式能大幅减少纹理的显存占用和带宽消耗,移动端必须开启;④ 降低纹理分辨率——够用就好,不要盲目上 2K/4K 纹理;⑤ 纹理 Atlasing(图集)——将多张小纹理合并成一张大纹理,减少 Draw Call 的同时也减少纹理切换开销。

计算优化的关键是将计算从片元着色器移到顶点着色器(如果变化率低),以及用数学公式替代复杂计算。具体方法:① 预计算与 LUT(查找表)——将复杂的函数计算结果预存在纹理中,运行时直接采样查找,例如用渐变纹理(Ramp Texture)替代复杂的光照计算;② 近似公式替代精确计算——很多情况下近似值足够用,例如用 fast_sqrt 替代精确平方根、用球面谐波(SH)替代实时光照;③ 减少数学运算——能用乘法不用除法,能用加法不用乘法,能用位运算不用算术运算;④ 向量化(Vectorization)——充分利用 GPU 的 SIMD 特性,用向量运算替代标量循环;⑤ 将常量计算移出循环——在 Shader 中,循环内的常量表达式编译器可能不会自动提取,需要手动优化。

分支优化是 Shader 性能优化中最容易被忽视的部分。GPU 采用 SIMT(单指令多线程) 架构,同一波前(Warp/Wavefront)的所有线程执行相同的指令。如果同一个 Warp 内的线程走了不同的分支(分支发散,Branch Divergence),GPU 会先执行所有走 A 分支的线程(走 B 分支的线程空闲等待),再执行所有走 B 分支的线程(走 A 分支的线程空闲等待),总开销是两个分支的和。优化方法:① 用数学函数替代 if-else——lerp、step、smoothstep、min、max 等内置函数可以实现很多条件逻辑,且不会产生分支;② 减少分支嵌套层级——嵌套越深,发散的可能性越大,性能损失越严重;③ 将分支移到材质层级——如果分支条件是材质级别的(如是否启用法线贴图),用 Shader Keyword 或 Multi Compile 生成不同的 Shader 变体,在材质选择时就确定走哪条路径,而不是在运行时判断;④ Uniform 分支通常可优化——如果分支条件是 Uniform 变量(整个 Draw Call 内值相同),现代 GPU 编译器通常能优化掉分支,不会产生发散。

四、Shader Variant 管理——变体爆炸问题与工程化解决方案

Shader Variant(Shader 变体) 是 Unity Shader 系统中的重要概念。同一个 Shader 可以通过 #pragma multi_compile 或 #pragma shader_feature 定义多个编译选项,每个选项组合会生成一个独立的 Shader 变体(Variant)。例如,一个 Shader 有 3 个 multi_compile 关键字,每个关键字有 2 个选项,就会生成 2×2×2=8 个变体。变体机制让同一个 Shader 可以根据需要开启或关闭某些功能(如阴影、光照贴图、法线贴图等),避免在 Shader 中用动态分支判断,提升运行时性能。

变体爆炸(Variant Explosion) 是 Shader 管理中的经典难题。随着功能增加,multi_compile 关键字的数量会越来越多,变体数量呈指数级增长——一个复杂的 PBR Shader 很容易就有几千甚至上万个变体。这带来三个问题:① 构建时间长——每个变体都需要编译到所有目标平台,构建时间随变体数量线性增长;② 内存占用高——加载的 Shader 会占用内存,变体越多内存占用越大;③ Shader 编译卡顿——运行时遇到未预编译的变体,GPU 需要实时编译,导致明显的卡顿(Shader Compilation Stutter)。

变体管理的核心策略是只保留真正需要的变体。具体方法:① 用 shader_feature 替代 multi_compile——shader_feature 的变体只有在材质中实际使用了该关键字时才会被打进构建,而 multi_compile 的所有变体都会被打进构建。对于材质级别的选项(如是否启用法线贴图),应该用 shader_feature;② Shader Variant Collection(SVC)——创建一个 ShaderVariantCollection 资源,收集项目中实际使用的所有 Shader 变体,在构建时只包含这些变体,大幅减少构建出的变体数量。可以通过在 Editor 下运行游戏、切换场景、记录所有使用过的变体来自动收集 SVC;③ 预加载与预热——在 Loading 界面或场景加载时,提前调用 ShaderVariantCollection.WarmUp() 预编译所有需要的变体,避免游戏过程中的编译卡顿;④ 合理拆分 Shader——不要把所有功能都塞进一个 Shader,根据使用场景拆分成多个 Shader(如简单的环境用 Unlit Shader、角色用 PBR Shader、特效用 Particle Shader),每个 Shader 的变体数量都控制在合理范围内。

Unity 6.x 的变体管理改进:Unity 6.0+ 引入了 Shader Variant Prefiltering 功能,可以在构建时自动分析场景中实际使用的材质和关键字,过滤掉未使用的变体,不需要手动维护 SVC。此外,SRP Batcher 兼容性也很重要——确保所有自定义 Shader 都兼容 SRP Batcher(材质属性放在 CBUFFER_START(UnityPerMaterial) 块中),这样即使变体多,Draw Call 的 CPU 开销也很低。

💬 互动

Shader 是 Unity 开发者进阶路上绕不开的硬骨头——你在项目中写过最复杂的 Shader 是什么?是 PBR 材质、水体效果,还是后处理特效?踩过哪些印象深刻的性能坑?是移动端 Overdraw 太高导致帧率上不去,还是 Shader 变体爆炸让构建时间爆炸?或者你有什么私藏的 Shader 优化技巧?欢迎在评论区分享你的 Shader 编写经验和性能调优心得,一起交流 Unity 图形编程的工程化最佳实践!


标签:pragma

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进