性能优化
优化的第一步永远是测量,而不是改代码。本章先建立“瓶颈在哪”的判断方法,再逐一讲清合批、Shader 变体、精度、Overdraw、Alpha Test 与 TBDR、贴图和 URP Asset 设置,每个主题都配一个能直接放进项目的工具。
29.1 先定位,再优化:瓶颈的四种类型
一帧的时间由 CPU 和 GPU 共同决定,它们像流水线上的两道工序:CPU 准备并提交第 N 帧的绘制命令时,GPU 通常在执行第 N−1 帧。哪一边更慢,帧率就由哪一边决定,另一边优化得再好也没有用。所以第一件事是分清CPU 瓶颈还是 GPU 瓶颈,GPU 瓶颈再细分到顶点、片元还是带宽。
判断瓶颈的实验
工具层面,Unity Profiler 的 CPU Usage 模块能看到主线程与渲染线程各自的耗时;在主线程上看到大段的 Gfx.WaitForPresent/Gfx.WaitForGfxCommandsFromMainThread 一类等待标记,说明 CPU 在等 GPU 或渲染线程。在真机上,更可靠的是平台工具:Xcode 的 GPU Frame Capture 与 Metal System Trace、Android 的 Android GPU Inspector、Arm Mobile Studio(Mali)、Snapdragon Profiler(Adreno)等,它们能直接给出 GPU 的时间和各单元的负载。工具之外,下面这组“改一个变量看帧时间”的实验在任何平台都适用:
| 实验 | 如果帧时间明显下降 | 说明 |
|---|---|---|
| 把 Render Scale 降到 0.5 | 片元或带宽瓶颈 | 像素数变为 1/4,顶点数和 Draw Call 不变 |
| 关掉所有后处理 / 屏幕空间 Feature | 全屏 Pass 的片元或带宽 | 每个全屏 Pass 都至少读写一次整屏 |
| 把所有材质换成最简单的 Unlit | 片元 ALU(Shader 计算) | 像素数不变,计算量骤降 |
| 贴图全局降一级 Mip(Quality 里的 Texture Quality) | 贴图带宽 | 每次采样读取的数据变少 |
| 关掉阴影 / 减少级联 | 顶点(阴影图要把投影物体再画一遍) | 级联数 N 意味着投影物体最多画 N 次 |
| 不改画面,只把相机对准空地 | CPU 提交或顶点 | Draw Call 被视锥剔除,像素数几乎不变 |
| 在 CPU 上关闭渲染(只跑逻辑) | 如果仍然慢:不是渲染问题 | 脚本、物理、动画才是瓶颈 |
29.2 Draw Call、SRP Batcher 与各种合批
CPU 端的渲染开销,大头不在“发出一个 Draw Call”这个动作本身,而在每个 Draw Call 之前的状态设置:切换 Shader(SetPass)、绑定贴图、上传材质常量、上传物体变换矩阵。Unity 提供了四种机制来降低这部分开销,它们原理不同,适用场景也不同:
| 机制 | 原理 | 适用条件 | 代价/限制 |
|---|---|---|---|
| SRP Batcher | 材质常量常驻 GPU(每个材质一份 UnityPerMaterial 缓冲),物体常量(UnityPerDraw)批量上传;同一 Shader 变体的连续绘制省掉 SetPass 和常量上传 | Shader 兼容;可以是不同材质、不同网格 | Draw Call 数量不变,省的是每次之间的状态切换;不支持 MaterialPropertyBlock |
| GPU Instancing | 同一网格 + 同一材质的多个实例,一次 Draw Call 画完,逐实例数据放在数组里 | 同网格同材质;Shader 支持 instancing;材质勾选 Enable GPU Instancing | 网格不同就无法合并;实例很少时收益不大 |
| 静态合批 | 构建时把标记 Batching Static 的物体合并成大网格(共享材质) | 不会移动的物体 | 增加内存和包体(合并后的网格要额外存储) |
| 动态合批 | 每帧在 CPU 上把小网格变换到世界空间后合并 | 顶点很少的小网格(Unity 文档给出的上限是 300 顶点、900 个顶点属性) | CPU 每帧做顶点变换;URP Asset 中默认关闭 |
Unity 文档给出的优先级是:SRP Batcher 与静态合批优先,其次 GPU Instancing,最后动态合批。也就是说,一个物体如果能走 SRP Batcher,它就不会再被 GPU Instancing 合并;只有当它因为 MaterialPropertyBlock 等原因退出 SRP Batcher 时,Instancing 才有机会接手。
SRP Batcher 的兼容条件
一个 Shader 要兼容 SRP Batcher,需要满足两条:
- 所有材质属性(Properties 里声明、材质会设置的变量)都放在名为
UnityPerMaterial的 CBUFFER 里,并且这个 CBUFFER 在同一个 SubShader 的所有 Pass、所有变体里布局一致; - 所有内置的逐物体变量(
unity_ObjectToWorld等)放在UnityPerDraw里——URP 的UnityInput.hlsl已经替你做好了,只要 include URP 的 Core.hlsl 就满足。
贴图对象本身(TEXTURE2D/SAMPLER)不需要放进 CBUFFER,但贴图的 _ST、_TexelSize 如果是材质属性,就要放进去。
案例 29.1合批友好的 Shader 与反例入门单独打开 ↗
目标:写一个同时兼容 SRP Batcher、GPU Instancing 和 LOD Cross-Fade 的最小 Shader,再写一个反例,学会在编辑器里验证。
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); // SRP Batcher:每个材质一份常量缓冲,布局在所有变体里必须一致 CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; CBUFFER_END // GPU Instancing:允许用 MaterialPropertyBlock 逐实例覆盖颜色 UNITY_INSTANCING_BUFFER_START(PerInstance) UNITY_DEFINE_INSTANCED_PROP(half4, _InstanceColor) UNITY_INSTANCING_BUFFER_END(PerInstance)
反例只差一行——属性声明在 CBUFFER 之外:
逐实例颜色通过 MaterialPropertyBlock 设置:
如何验证:
- 在 Project 窗口选中 Shader,Inspector 里会显示 SRP Batcher: compatible 或 not compatible,不兼容时附带原因。上面的反例会提示类似 Material property is found in another cbuffer than "UnityPerMaterial";如果不同 Pass 的 UnityPerMaterial 内容不一致,原因是 UnityPerMaterial CBuffer inconsistent size inside a SubShader。
- 打开 ,Enable 后展开 DrawOpaqueObjects。兼容的物体会被归入名为 SRP Batch 的事件,选中后右侧会说明这一批为什么没能和上一批合并,例如 SRP: Node have different shaders.、SRP: Node use different shader keywords、SRP: Node is not compatible with SRP batcher、SRP: Node use multi-pass shader。
- 对非 SRP Batcher 的绘制(Instancing、静态/动态合批),Frame Debugger 同样会给出不能合批的原因,常见的有 Objects have different materials.、Objects have different MaterialPropertyBlock set.、Objects are affected by different light probes.、Objects are lightmapped. 等。
_NORMALMAP、一个没开)会让它们落到不同变体,Frame Debugger 里就会看到 Node use different shader keywords。整理材质、统一关键字,往往比改 Shader 更能提升合批效果。另外,URP 的不透明物体排序是“由近到远”优先的(支持隐藏面消除的 GPU 上会放宽,见 CameraData.defaultOpaqueSortFlags 的注释),排序也会打断同 Shader 的连续性。29.3 Shader 变体:爆炸、剥离与预热
每一行 multi_compile 都是在说“把下面这几个关键字的每一种取值都编译一份”。几行相乘,就是变体数量。以 URP 14 的 Lit.shader 为例,仅 UniversalForward Pass 里那 20 行 multi_compile(主光阴影 4 选 1、附加光 3 选 1、SH 求值 3 选 1、软阴影 5 选 1、DBuffer 4 选 1、雾 4 选 1,其余 14 行各 2 选 1),理论组合数就是:
这还没算 shader_feature_local 的材质关键字、include_with_pragmas 引入的关键字和 Instancing。实际项目里当然不会真编译这么多,靠的是三道闸门:
shader_feature只编译用到的:shader_feature声明的关键字,构建时只保留项目中材质实际启用过的组合;multi_compile则无条件全编。规则是:由材质决定的开关(是否有法线贴图、是否透明)用shader_feature;由运行时管线状态决定的开关(阴影、光照模式、雾)必须用multi_compile,否则运行时切到某个状态时变体不存在。- 阶段后缀:
multi_compile_fragment/_vertex只对一个着色阶段生效,另一个阶段不会因此翻倍。Lit 里大量关键字都是_fragment的。 - 剥离(Stripping):URP 在构建时根据 URP Asset 的设置剥掉用不到的变体(例如没开附加光阴影,就剥掉
_ADDITIONAL_LIGHT_SHADOWS)。URP Global Settings 里还有几个开关:Strip Debug Variants、Strip Unused Post Processing Variants、Strip Unused Variants、Strip Unused LOD Cross Fade Variants、Strip Screen Coord Override Variants,以及 Shader Variant Log Level(构建时打印剥离信息)。
此外还有 _local 后缀:multi_compile_local/shader_feature_local 声明的是 Shader 内部的局部关键字,不占用全局关键字数量,也不会被全局的 Shader.EnableKeyword 影响。材质级的开关一律应该用 local。
案例 29.2自定义变体剥离脚本进阶单独打开 ↗
目标:用 IPreprocessShaders 在构建时剥掉项目确定用不到的变体,并在构建结束时输出统计。
class CookbookVariantStripper : IPreprocessShaders, IPreprocessBuildWithReport, IPostprocessBuildWithReport { // ---- 项目约定(示例):根据你的项目改 ---- const bool k_ProjectUsesLightmaps = false; // 全实时光照,没有烘焙光照贴图 const bool k_ProjectUsesScreenSpaceShadows = false; // 没有加 Screen Space Shadows Feature const bool k_ProjectUsesVertexAdditionalLights = false; // 附加光一律逐像素 const string k_OnlyShadersWithPrefix = ""; // 只处理某些 Shader(空 = 全部) static readonly ShaderKeyword s_LightmapOn = new ShaderKeyword("LIGHTMAP_ON"); static readonly ShaderKeyword s_DynLightmapOn = new ShaderKeyword("DYNAMICLIGHTMAP_ON"); static readonly ShaderKeyword s_DirLightmap = new ShaderKeyword("DIRLIGHTMAP_COMBINED"); static readonly ShaderKeyword s_ShadowsShadowmask = new ShaderKeyword("SHADOWS_SHADOWMASK"); static readonly ShaderKeyword s_LightmapShadowMixing = new ShaderKeyword("LIGHTMAP_SHADOW_MIXING"); static readonly ShaderKeyword s_ScreenShadows = new ShaderKeyword("_MAIN_LIGHT_SHADOWS_SCREEN"); static readonly ShaderKeyword s_VertexLights = new ShaderKeyword("_ADDITIONAL_LIGHTS_VERTEX"); static readonly ShaderKeyword s_DebugDisplay = new ShaderKeyword("DEBUG_DISPLAY"); static int s_Before, s_After; static readonly Dictionary<string, int> s_RemovedPerShader = new Dictionary<string, int>(); // 数字越小越早执行。URP 自己的剥离器也实现了这个接口,顺序不影响结果(都是从同一个列表里删) public int callbackOrder => 10; bool ShouldStrip(ShaderSnippetData snippet, ShaderCompilerData data) { var keys = data.shaderKeywordSet; if (!k_ProjectUsesLightmaps && (keys.IsEnabled(s_LightmapOn) || keys.IsEnabled(s_DynLightmapOn) || keys.IsEnabled(s_DirLightmap) || keys.IsEnabled(s_ShadowsShadowmask) || keys.IsEnabled(s_LightmapShadowMixing))) return true; if (!k_ProjectUsesScreenSpaceShadows && keys.IsEnabled(s_ScreenShadows)) return true; if (!k_ProjectUsesVertexAdditionalLights && keys.IsEnabled(s_VertexLights)) return true; // 发布版不需要 Rendering Debugger 的调试变体 if (!EditorUserBuildSettings.development && keys.IsEnabled(s_DebugDisplay)) return true; // Meta Pass 给光照烘焙/实时 GI 提取反照率和自发光用;项目完全不用 GI 时才剥 if (!k_ProjectUsesLightmaps && snippet.passType == PassType.Meta) return true; return false; }
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data) { if (k_OnlyShadersWithPrefix.Length > 0 && !shader.name.StartsWith(k_OnlyShadersWithPrefix)) return; int before = data.Count; // 倒序删除,避免下标错位 for (int i = data.Count - 1; i >= 0; --i) if (ShouldStrip(snippet, data[i])) data.RemoveAt(i); s_Before += before; s_After += data.Count; int removed = before - data.Count; if (removed > 0) { s_RemovedPerShader.TryGetValue(shader.name, out int n); s_RemovedPerShader[shader.name] = n + removed; } }
工作方式:构建时,Unity 对每个 Shader 的每个 Pass、每个着色阶段(顶点、片元……)调用一次 OnProcessShader,data 里是这一阶段即将编译的全部变体,每个元素的 shaderKeywordSet 记录了它启用了哪些关键字。从列表里删掉的变体不会被编译,也不会进包。snippet 提供了 Pass 的类型(passType)、名字和着色阶段,可以按 Pass 剥离。
编写剥离规则的原则:
- 只剥确定用不到的。被剥掉的变体如果运行时被请求,Unity 会退回到“最接近”的已有变体,画面表现为光照、阴影莫名其妙地不对,而且只在真机上出现——这是最难查的一类 bug(第 30 章的问题手册会再提到)。
- 规则要和项目约定绑定。上面的示例把约定写成了常量,实际项目中更好的做法是做成一个 ScriptableObject 配置,或者从 URP Asset、场景设置里自动推断。
- 剥离本身就有坑:全局关键字用
new ShaderKeyword(name),局部关键字要用new ShaderKeyword(shader, name),否则IsEnabled永远返回 false,剥离不起作用。 - 每次修改规则后,对比构建日志里的变体数量,并在目标设备上把所有场景、所有画质档位都跑一遍。
案例 29.3用 ShaderVariantCollection 分帧预热入门单独打开 ↗
Shader 变体在运行时第一次被用到时,驱动才会把它编译成 GPU 指令(尤其在 OpenGL ES、Vulkan 这类驱动编译开销大的平台),表现为第一次放技能、第一次进入某个场景时卡一下。解决办法是在加载界面里提前“用一下”这些变体,称为预热(Warmup)。
- 在编辑器里把游戏完整跑一遍(尽量覆盖所有效果),然后在 底部的 Shader Loading 区域点击 Save to asset,得到一个 ShaderVariantCollection 资源,里面记录了这次运行中用到的变体。
- 把它交给下面的组件,在加载界面分帧预热。
IEnumerator Start() { foreach (var svc in collections) { if (svc == null) continue; // WarmUpProgressively 每次预热指定数量的变体,全部完成时返回 true while (!svc.WarmUpProgressively(variantsPerFrame)) yield return null; Debug.Log($"[ShaderWarmup] {svc.name}: {svc.warmedUpVariantCount}/{svc.variantCount} variants"); } onFinished?.Invoke(); }
WarmUpProgressively(n) 每次只预热 n 个变体,全部完成时返回 true,配合协程就能把预热摊到多帧,不至于让加载界面本身卡死。需要注意:在 Vulkan、Metal、D3D12 这类现代图形 API 上,“编译好的 GPU 程序”还和渲染状态(顶点格式、混合、渲染目标格式)有关,只用 WarmUp 不一定能完全消除首次卡顿;Unity 2022.3 提供了实验性的 UnityEngine.Experimental.Rendering.ShaderWarmup.WarmupShader,可以按指定的顶点布局预热,具体效果以实际版本和平台为准。
29.4 精度:half 与 float
移动 GPU 普遍原生支持 16 位浮点(half),其 ALU 吞吐、寄存器占用、插值器带宽都比 32 位 float 更省,这是 URP 代码里到处是 half 的原因。但 half 的表示能力很有限:1 位符号、5 位指数、10 位尾数,最大值约 65504,在 [2e, 2e+1) 区间内相邻两个可表示值相差 2e−10。换成具体的数:
| 数值范围 | half 的最小间隔 | 意味着 |
|---|---|---|
| [1, 2) | 1/1024 ≈ 0.001 | 颜色、法线、0~1 的系数都足够 |
| [64, 128) | 1/16 = 0.0625 | 用 half 存 100 米处的世界坐标,误差 6 厘米 |
| [256, 512) | 0.25 | 世界坐标、UV 已经完全不可用 |
| [1024, 2048) | 1 | — |
反过来,桌面 GPU 常常把 half 当 float 执行,所以“编辑器里看着没问题、真机上花了”是精度问题的典型特征。
案例 29.4远离原点的精度事故入门单独打开 ↗
目标:复现“用 half 保存世界坐标再算 UV”在远离原点时的问题。由于桌面平台可能把 half 当 float,这里按 half 的格式手动舍入来模拟:
// half:1 位符号 + 5 位指数 + 10 位尾数。数值落在 [2^e, 2^(e+1)) 时,相邻两个可表示值相差 2^(e-10) float2 ToHalfAndBack(float2 v) { float2 e = floor(log2(max(abs(v), 6.1e-5))); // 6.1e-5 ≈ half 最小正规数 float2 ulp = exp2(e - 10); return round(v / ulp) * ulp; } half4 frag(Varyings i) : SV_Target { float2 p = i.positionWS.xz; if (_EmulateHalf > 0.5) p = ToHalfAndBack(p); // 相当于 half2 p = positionWS.xz; float2 uv = p * _Tiling; half3 c = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).rgb; return half4(c, 1); }
// half:1 位符号 + 5 位指数 + 10 位尾数。数值落在 [2^e, 2^(e+1)) 时,相邻两个可表示值相差 2^(e-10) vec2 ToHalfAndBack(vec2 v) { vec2 e = floor(log2(max(abs(v), vec2(6.1e-5)))); // 6.1e-5 ≈ half 最小正规数 vec2 ulp = exp2(e - 10.0); return round(v / ulp) * ulp; } void main() { vec2 p = positionWS.xz; if (_EmulateHalf > 0.5) p = ToHalfAndBack(p); // 相当于 half2 p = positionWS.xz; vec2 uv = p * _Tiling; vec3 c = texture(_BaseMap, uv).rgb; fragColor = vec4(c, 1.0); }

精度选择的经验规则:
✓ 可以用 half
颜色、HDR 亮度(不超过几万)、单位向量(法线、光照方向、视线方向)、0~1 的系数(金属度、粗糙度、AO)、小范围内的 UV 偏移。✗ 必须用 float
位置(世界、观察、裁剪空间)、深度及其重建、大范围的 UV(世界空间 UV、大地形 UV)、时间(_Time.y 随运行时长增大)、需要做差的大数、矩阵运算的中间结果。URP/core 库里还有一个 real 类型:在支持原生 half 的平台上(定义了 PREFER_HALF 一类的条件)是 half,否则是 float,用于“精度要求不高、但希望在移动端省一点”的库函数。自己写 Shader 时,明确写 half 或 float 更清楚。
_Time.y 是从启动开始的秒数,运行几个小时后达到数千,任何用 half 存储它或 sin(_Time.y * 很大的系数) 的动画都会开始卡顿、跳帧。长时间运行的应用(展厅、挂机游戏)要用 frac 或在 C# 里自己维护一个循环的时间参数。29.5 Overdraw 与透明
Overdraw 指同一个像素在一帧里被着色了多次。不透明物体在有 Early-Z 的情况下,被挡住的片元大多会在着色前被剔除,代价有限;透明物体则完全不同:它们不写深度、从后往前逐层混合,每一层都要完整执行片元着色器,还要读回目标颜色做混合——既吃 ALU 又吃带宽。烟雾、火焰这类由大量重叠面片组成的粒子特效,以及全屏的 UI 面板叠加,是移动端片元瓶颈最常见的来源。
案例 29.5Overdraw 热度图 Renderer Feature进阶单独打开 ↗
目标:写一个 Feature,把场景里每个像素被绘制的次数画成热度图,直观地找出 Overdraw 热点。
思路:用 DrawingSettings.overrideMaterial 把所有可见物体用一个“计数材质”重新画一遍到一张浮点 RT 上。计数材质用加法混合(Blend One One),每个片元输出 1,最终每个像素的值就是它被画了几层。最后用一个全屏 Pass 把计数映射成颜色。
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { // ConfigureTarget/ConfigureClear 已经把 m_Count 设为目标并清成 0 context.ExecuteCommandBuffer(cmd); cmd.Clear(); if (m_Settings.opaque) Draw(context, ref renderingData, RenderQueueRange.opaque, SortingCriteria.CommonOpaque); if (m_Settings.transparent) Draw(context, ref renderingData, RenderQueueRange.transparent, SortingCriteria.CommonTransparent); Blitter.BlitCameraTexture(cmd, m_Count, m_CameraColor, m_Settings.heatmapMaterial, 0); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } void Draw(ScriptableRenderContext context, ref RenderingData renderingData, RenderQueueRange range, SortingCriteria sort) { var drawing = CreateDrawingSettings(s_Tags, ref renderingData, sort); drawing.overrideMaterial = m_Settings.countMaterial; // 所有物体都用计数材质画 drawing.overrideMaterialPassIndex = 0; var filtering = new FilteringSettings(range, m_Settings.layerMask); context.DrawRenderers(renderingData.cullResults, ref drawing, ref filtering); }
// 案例 29.5:Overdraw 可视化 Renderer Feature 的网页版(对应 Ch29/OverdrawFeature.cs)。 // 用 overrideMaterial 把可见物体全部重画一遍到一张 R16 浮点 RT(每层 +1),再用热度图画回相机颜色。 import { RenderPassEvent, featureMaterial, layerMask } from '../engine/features.js'; // 和 URP 自己画不透明/透明物体时用的 LightMode 列表一致 const TAGS = ['UniversalForward', 'UniversalForwardOnly', 'SRPDefaultUnlit', 'LightweightForward']; export default class OverdrawFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; const s = (data && data.settings) || {}; this.settings = { opaque: true, transparent: true, layerMask: -1, ...s }; } async init() { const s = this.settings; this.countMaterial = featureMaterial(this.scene, s.countMaterial); // Cookbook/Ch29/OverdrawCount this.heatmapMaterial = featureMaterial(this.scene, s.heatmapMaterial); // Cookbook/Ch29/OverdrawHeatmap } addPasses(frame, enqueue) { const s = this.settings; if (!this.countMaterial || !this.heatmapMaterial) return; enqueue(RenderPassEvent.AfterRenderingTransparents, (fr) => { const gl = fr.gl, E = this.engine; // OnCameraSetup:和相机同尺寸、不要 MSAA 和深度;计数可能超过 1,要浮点格式;点采样 const count = fr.getTemporaryRT('_OverdrawCount', fr.width, fr.height, { color: 'R16F', filter: 'Point' }); E.bindTarget(count); gl.colorMask(true, true, true, true); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); // ConfigureClear(ClearFlag.Color, Color.black) const mask = layerMask(s.layerMask); // Execute:所有物体都用计数材质画(overrideMaterial) if (s.opaque) fr.drawRenderers({ queue: 'opaque', lightModes: TAGS, layerMask: mask, overrideMaterial: this.countMaterial, overridePass: 0 }); if (s.transparent) fr.drawRenderers({ queue: 'transparent', lightModes: TAGS, layerMask: mask, overrideMaterial: this.countMaterial, overridePass: 0, sort: 'backToFront' }); // Blitter.BlitCameraTexture(cmd, m_Count, m_CameraColor, heatmapMaterial, 0) fr.blit(count.texture, 'camera', this.heatmapMaterial, 0); fr.bindCamera(); }, 'OverdrawView'); } }
half3 HeatRamp(float n) { static const float3 c[8] = { float3(0, 0, 0), float3(0.05, 0.1, 0.55), float3(0.0, 0.6, 0.8), float3(0.1, 0.75, 0.2), float3(0.95, 0.9, 0.1), float3(1.0, 0.5, 0.05), float3(0.9, 0.08, 0.05), float3(1, 1, 1) }; static const float k[8] = { 0, 1, 2, 3, 4, 6, 8, 12 }; n = min(n, k[7] * _MaxCount / 12); float3 col = c[7]; for (int i = 0; i < 7; i++) { float a = k[i] * _MaxCount / 12, b = k[i + 1] * _MaxCount / 12; if (n >= a && n < b) { col = lerp(c[i], c[i + 1], (n - a) / (b - a)); break; } } return col; }
vec3 HeatRamp(float n) { const vec3 c[8] = vec3[8]( vec3(0, 0, 0), vec3(0.05, 0.1, 0.55), vec3(0.0, 0.6, 0.8), vec3(0.1, 0.75, 0.2), vec3(0.95, 0.9, 0.1), vec3(1.0, 0.5, 0.05), vec3(0.9, 0.08, 0.05), vec3(1, 1, 1)); const float k[8] = float[8](0.0, 1.0, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0); n = min(n, k[7] * _MaxCount / 12.0); vec3 col = c[7]; for (int i = 0; i < 7; i++) { float a = k[i] * _MaxCount / 12.0, b = k[i + 1] * _MaxCount / 12.0; if (n >= a && n < b) { col = mix(c[i], c[i + 1], (n - a) / (b - a)); break; } } return col; }
几个设计点:
- ZTest Always 与 ZWrite Off:统计的是“光栅化产生了多少片元”,不考虑深度剔除,这是传统 Overdraw 视图的定义,能暴露出“被挡住但仍然在画”的浪费。如果想看“考虑 Early-Z 后实际着色的片元”,可以把材质的
_ZTest改为 LessEqual 并绑定相机深度——但那时得到的结果又依赖于绘制顺序,解读要小心。 - 计数纹理用 RHalf:8 位格式在加法混合下会饱和于 1。
- ShaderTagId 列表要与 URP 画物体时用的 LightMode 一致(UniversalForward、UniversalForwardOnly、SRPDefaultUnlit 等),否则有的物体不会被计入。
- 透明物体用
SortingCriteria.CommonTransparent排序,虽然加法混合与顺序无关,但保持与真实渲染一致是好习惯。

URP 14 的 Rendering Debugger 也内置了 Overdraw 视图( › Rendering › Rendering Debug › Overdraw Mode,可选 Opaque、Transparent、All,并可设置 Max Overdraw Count),日常排查直接用它即可;自写版本的价值在于能定制统计规则,并能在真机的 Development Build 里运行。
降低透明 Overdraw 的手段:粒子面片按贴图的不透明区域裁剪成多边形(Unity 的 Sprite 和粒子系统都支持紧凑网格),减少空白像素的光栅化;控制粒子数量和尺寸,大尺寸的半透明面片最昂贵;远处的粒子降低发射率;把多层叠加的效果烘焙进一张贴图;UI 中被全屏面板完全挡住的界面要真正关闭,而不是只是被盖住。
29.6 Alpha Test、Early-Z 与 TBDR
桌面 GPU 是“立即模式”渲染(IMR):三角形来一个画一个,直接读写显存中的颜色和深度缓冲。几乎所有移动 GPU 则是基于图块的延迟渲染(Tile-Based Deferred Rendering,TBDR)或其变体:先把整帧所有三角形按屏幕上的小块(Tile,如 16×16 或 32×32 像素)分组,再逐个 Tile 在片上高速缓存里完成深度测试、着色和混合,最后把结果一次性写回显存。这样,深度和颜色的反复读写都发生在片上,极大节省了带宽——而带宽恰恰是移动设备功耗和发热的大头。
很多 TBDR GPU 还会在着色之前,对整个 Tile 里的所有不透明三角形先做一遍完整的可见性判断,只为最终可见的片元执行片元着色器(Apple/PowerVR 称为 Hidden Surface Removal,Arm Mali 有 Forward Pixel Kill 等类似机制)。这让不透明物体的绘制顺序变得不那么重要——这也是 URP 在支持隐藏面消除的 GPU 上放宽不透明排序的原因。
Alpha Test 为什么昂贵:使用 clip()/discard 的片元,它“到底留不留下”要等片元着色器执行完才知道,深度也就要等那时才能写入。这会让 Early-Z 和隐藏面消除对它(以及它身后的物体)失效或退化:GPU 不能再在着色前断定谁挡住了谁。在 TBDR 上,这意味着本来只需着色一次的像素,可能要为每一层 Alpha Test 物体都执行一次片元着色器。
工程上的应对:
- 植被、铁丝网这类必须镂空的物体,网格尽量贴合不透明区域,减少被 clip 掉的面积。
- 先画不透明物体,再画 Alpha Test 物体(URP 的 AlphaTest 队列 2450 本就在 Geometry 之后),让 Alpha Test 物体至少能被前面已写入的深度剔除。
- Depth Priming(URP Renderer 上的 Depth Priming Mode:Disabled/Auto/Forced):先用一个深度预通道写好深度,正式绘制时用 Equal 深度测试,每个像素只着色一次。它对 Alpha Test 多的场景有帮助,但预通道本身要把场景多画一遍,在 TBDR GPU 上往往得不偿失,所以 URP 14 在 Android/iOS/tvOS 上把 Auto 视为“不推荐”而不启用(见
UniversalRenderer构造函数里的m_DepthPrimingRecommended)——是否 Forced 开启以实测为准。 - 能用 Alpha Blend 表现的半透明边缘,不一定比 Alpha Test 更贵也不一定更便宜:Blend 不写深度、需要排序,Test 破坏 Early-Z。两者的取舍取决于覆盖面积和层数,最终靠 GPU 工具测量。
SetRenderTarget 都可能引起一次整屏的 Store/Load。URP Asset 的 Store Actions Optimization、Renderer 上的 Native RenderPass 选项,就是为了减少不必要的 Load/Store。这也是第 28 章强调“合并全屏 Pass”的原因。29.7 贴图:压缩格式与 Mipmap
贴图带宽和内存往往比 Shader 计算更早成为瓶颈。三条基本原则:
- 永远使用 GPU 压缩格式。移动端首选 ASTC(块大小 4×4 到 12×12 可选,越大越省、质量越低),老设备回退 ETC2;桌面用 BC 系列(BC7 高质量颜色、BC5 法线、BC4 单通道、BC6H HDR)。未压缩的 RGBA32 每像素 4 字节,压缩后通常只有几分之一,而且 GPU 读取压缩块时带宽也同比减少。
- 3D 物体的贴图开启 Mipmap。远处物体在屏幕上很小,如果采样全分辨率贴图,相邻像素读取的纹素相隔很远,缓存命中率低、带宽浪费、还会闪烁(摩尔纹)。Mip 让 GPU 读取与屏幕尺寸匹配的那一级。UI、屏幕空间的 RT 等永远以 1:1 显示的贴图则关掉 Mipmap 省内存(Mip 链多占约 1/3 内存)。
- 分辨率按“屏幕上能看到的纹素密度”给,而不是越大越好。
案例 29.6Mip 级别可视化入门单独打开 ↗
目标:把 GPU 实际选择的 Mip 级别画成颜色,检查贴图分辨率和平铺是否合理。
float ComputeMipLevel(float2 uv, float2 texSize) { // 相邻像素之间 uv 变化了多少个纹素 float2 dx = ddx(uv * texSize); float2 dy = ddy(uv * texSize); float maxSq = max(dot(dx, dx), dot(dy, dy)); return 0.5 * log2(maxSq); // = log2(最大纹素跨度);<0 表示放大(一个纹素盖住多个像素) } half3 MipColor(float mip) { static const half3 c[7] = { half3(0.1, 0.35, 1), half3(0.15, 0.8, 0.3), half3(0.95, 0.9, 0.15), half3(1, 0.55, 0.1), half3(0.95, 0.15, 0.1), half3(0.8, 0.1, 0.7), half3(0.35, 0.1, 0.5) }; // c[0] 给“放大”区(mip<0),c[1] 起依次是 mip 0,1,2... float m = clamp(mip + 1, 0, 6); int i = (int)floor(m); return lerp(c[i], c[min(i + 1, 6)], frac(m)); }
float ComputeMipLevel(vec2 uv, vec2 texSize) { // 相邻像素之间 uv 变化了多少个纹素 vec2 dx = dFdx(uv * texSize); vec2 dy = dFdy(uv * texSize); float maxSq = max(dot(dx, dx), dot(dy, dy)); return 0.5 * log2(maxSq); // = log2(最大纹素跨度);<0 表示放大(一个纹素盖住多个像素) } vec3 MipColor(float mip) { const vec3 c[7] = vec3[7](vec3(0.1, 0.35, 1), vec3(0.15, 0.8, 0.3), vec3(0.95, 0.9, 0.15), vec3(1, 0.55, 0.1), vec3(0.95, 0.15, 0.1), vec3(0.8, 0.1, 0.7), vec3(0.35, 0.1, 0.5)); // c[0] 给“放大”区(mip<0),c[1] 起依次是 mip 0,1,2... float m = clamp(mip + 1.0, 0.0, 6.0); int i = int(floor(m)); return mix(c[i], c[min(i + 1, 6)], fract(m)); }
GPU 选 Mip 的依据就是屏幕空间导数:相邻像素之间 UV 跨过了多少个纹素。ddx(uv * texSize) 给出水平方向相邻像素的纹素差,取两个方向中较大的长度,log2 之后就是 Mip 级别(硬件的实际公式还涉及各向异性过滤,这里是各向同性的近似)。_BaseMap_TexelSize 是 Unity 自动为每张贴图属性设置的变量,(1/宽, 1/高, 宽, 高)。

Rendering Debugger 的 Material 页也提供了 Mipmap 相关的调试信息(URP 14 源码中有 DebugMipInfoMode 的 Level/Count/Ratio 模式),具体 UI 中可用的选项以实际版本为准。
29.8 URP Asset 与 Renderer 的移动端设置清单
下面按 URP 14 的 URP Asset 和 Universal Renderer Data 的设置项整理移动端的考虑。没有“万能最优值”,表中是方向性的建议,需要按画面要求和目标设备实测取舍:
| 设置项 | 移动端考虑 |
|---|---|
| HDR / HDR Precision | HDR 让颜色缓冲从 8 位变为浮点格式,Bloom 等效果才有意义,但带宽增加。默认的 32 Bits 精度(R11G11B10 一类无 Alpha 的打包格式)比 64 Bits(RGBA16F)省一半带宽,除非需要 Alpha 通道或更高精度,保持 32 Bits。 |
| Anti Aliasing (MSAA) | TBDR GPU 上 MSAA 的多重样本在片上解析,成本相对较低,2x/4x 常是性价比最高的抗锯齿;但它会让很多屏幕空间效果复杂化(深度要解析拷贝),也与 URP 14 的 TAA 互斥。 |
| Render Scale / Upscaling Filter | 高分辨率屏幕上把 Render Scale 降到 1 以下,是降低片元和带宽压力最直接的手段;Upscaling Filter 可选 Automatic、Bilinear、Nearest-Neighbor 和 FidelityFX Super Resolution 1.0,FSR 需要额外的全屏 Pass。 |
| Depth Texture / Opaque Texture | 按需开启。它们可能带来额外的拷贝 Pass;Opaque Texture 的 Downsampling 选 2x 甚至 4x 可减少开销。屏幕空间 Feature 应通过 ConfigureInput 按需申请,而不是全局常开。 |
| Main Light Shadows | 阴影图分辨率、级联数(每多一级,投影物体多画一遍)、阴影距离(越短,同样分辨率下越清晰)。很多移动项目用 1 级或 2 级级联配合较短的阴影距离。 |
| Soft Shadows / Quality | 软阴影的 PCF 多次采样是片元开销;URP 14 提供 Low/Medium/High 质量档位,移动端从 Low 开始试。 |
| Additional Lights | Per Vertex 比 Per Pixel 便宜得多;Per Object Limit 控制每个物体最多受几盏附加光影响(Forward 路径);附加光阴影代价很高,谨慎开启。 |
| Reflection Probes | Probe Blending 和 Box Projection 都有额外的采样和计算,非必要不开。 |
| SRP Batcher / Dynamic Batching | SRP Batcher 保持开启;Dynamic Batching 默认关闭,只有大量极小网格、且确认 CPU 是瓶颈时才考虑。 |
| LOD Cross Fade | 开启后 LOD 切换时两级同时绘制并抖动裁剪,短时间内 Draw Call 和片元翻倍,还引入 Alpha Test 式的 clip;可以只给关键物体使用。 |
| Store Actions Optimization | 控制渲染目标的 Load/Store 行为,Auto 会在合适时避免不必要的存储;对 TBDR 带宽有直接影响。 |
| Volume Update Mode | Every Frame 每帧计算 Volume 混合;如果 Volume 参数不变,改为 Via Scripting 并在需要时手动更新,可以省 CPU。 |
| Grading Mode / LUT Size | Low Dynamic Range 模式在低动态范围内做调色,开销更低;High Dynamic Range 模式在色调映射之前的 HDR 空间里调色,精度更高、代价也更大。LUT Size 越大越精确、越贵。 |
| Rendering Path(Renderer) | Forward 最简单、对 TBDR 友好;Forward+ 支持大量实时光源,但有额外的光源分簇开销;Deferred 的 G-Buffer 带宽在移动端需要配合 Native RenderPass 才划算。 |
| Depth Priming / Copy Depth Mode | 见 29.6 节;Copy Depth Mode 决定深度纹理在不透明之后还是透明之后拷贝,或强制使用预通道,影响屏幕空间效果能看到哪些物体。 |
| Intermediate Texture | Always 会始终使用中间纹理;Auto 只在需要时才用(例如有后处理、Renderer Feature 需要时),能省一次最终 Blit。 |
| Native RenderPass | 在支持的平台上把多个 Pass 合并为一个原生 Render Pass,用片上内存传递中间结果(尤其是延迟渲染的 G-Buffer),减少带宽。 |
建议为不同档位的设备准备多个 URP Asset(通过 Quality Settings 切换),而不是在一个 Asset 上妥协。
29.9 LOD、Cross-Fade 与遮挡剔除
LOD(Level of Detail)用 LOD Group 组件按物体在屏幕上的占比切换不同精度的网格,降低远处物体的顶点开销,同时减少远处细小三角形带来的“四边形过度着色”(GPU 以 2×2 像素为单位着色,细碎三角形的边缘会浪费大量片元)。最后一级可以是一个面片(Billboard)甚至直接剔除。
LOD Cross-Fade 让两级 LOD 在切换时交叉淡化,避免“跳变”。URP 14 的做法是抖动裁剪:Shader 声明 #pragma multi_compile_fragment _ LOD_FADE_CROSSFADE,在片元着色器中调用 LODCrossFade.hlsl 里的 LODFadeCrossFade(positionCS),它用一张抖动纹理(URP Asset 中 LOD Cross Fade Dithering Type 可选 Bayer 或 Blue Noise)和 unity_LODFade.x 比较后 clip。案例 29.1 的 BatcherFriendly.shader 已经包含了这部分。阴影 Pass 和深度 Pass 也要同样处理,否则淡化期间阴影会闪。
遮挡剔除(Occlusion Culling)在 CPU 端剔除被大型遮挡物完全挡住的物体,减少 Draw Call 和顶点处理。Unity 内置的是基于预计算的方案:在 中把静态物体标记为 Occluder Static/Occludee Static 并烘焙。它适合室内、城市街道这类遮挡多的场景;开阔场景收益很小,反而有查询开销。动态物体只能作为被遮挡者(Occludee),不能当遮挡物。
29.10 CPU 端的几个“隐形”开销
前面大部分内容针对 GPU。CPU 端除了 Draw Call 和合批,还有几类容易被忽视的开销:
- 运行时访问
renderer.material:这个属性在第一次访问时会复制一份材质实例给这个物体。每个物体一份独立材质,SRP Batcher 虽然仍能工作(同 Shader 变体即可),但材质数量暴涨、内存增加,而且每份材质的 UnityPerMaterial 缓冲都要单独维护;对静态合批、GPU Instancing 来说,不同材质则直接无法合并。只读参数用sharedMaterial,逐物体差异用 MaterialPropertyBlock(接受它会退出 SRP Batcher,走 Instancing)或者干脆做成不同的材质资源。 - 剔除:视锥剔除要遍历所有 Renderer 的包围盒。缩短相机远裁剪面、用
Camera.layerCullDistances给小物体所在的层设置更短的剔除距离,能同时减少剔除和绘制的数量。阴影投射物还有单独的一轮剔除,阴影距离越大,参与的物体越多。 - 多相机:每台相机都要完整走一遍剔除、排序和提交。相机栈中的 Overlay 相机、渲染到 RT 的辅助相机(小地图、反射)都是成倍的开销,能合并就合并,能降低更新频率就降低(例如小地图每隔几帧更新一次)。
- Volume 与后处理设置:Volume 混合在 Every Frame 模式下每帧计算;场景中 Local Volume 很多时,这部分也会出现在 Profiler 里(见 29.8 节表格)。
- 每帧修改材质参数:
Material.SetFloat本身不贵,但如果因此创建了材质实例或频繁切换关键字(EnableKeyword会改变材质的变体),就会打断合批。动画参数尽量走全局变量(Shader.SetGlobalFloat)或 MaterialPropertyBlock。
29.11 优化检查清单
把本章的内容整理成一份按顺序执行的清单。每一步都要先测量、改动后再测量,确认收益真实存在再保留改动:
| # | 检查项 | 做法 |
|---|---|---|
| 1 | 确认瓶颈类型 | Profiler 区分 CPU/GPU;29.1 节的实验区分顶点/片元/带宽;在目标设备上测量 |
| 2 | Draw Call 与 SetPass | Frame Debugger 查看 SRP Batch 的中断原因;统一材质关键字;检查是否有意外的材质实例 |
| 3 | 阴影 | 级联数、阴影距离、分辨率、软阴影质量;关闭不需要投影的物体的 Cast Shadows(小物件、远景) |
| 4 | 透明与粒子 | Overdraw 热度图定位热点;减少大面积半透明层数;粒子网格贴合不透明区域 |
| 5 | 全屏 Pass | 数一数一帧有多少次全屏读写(后处理、屏幕空间 Feature、拷贝);能降分辨率的降,能合并的合并 |
| 6 | Shader 复杂度 | 只在需要的地方使用复杂光照;half 精度;去掉无用的关键字分支;远处物体用简化 Shader(LOD 时切换材质) |
| 7 | 贴图 | 全部使用压缩格式;检查 Mip 可视化;分辨率与屏幕占比匹配;关闭不需要的 Read/Write |
| 8 | 变体与加载 | 构建日志里的变体数量;自定义剥离;变体预热消除首次卡顿 |
| 9 | URP Asset 分档 | 为高、中、低三档设备准备不同的 URP Asset,并在 Quality Settings 中绑定 |
| 10 | 回归 | 每次优化后在所有档位、所有关键场景截图对比,防止“优化”改变了画面 |
本章小结
- 先区分 CPU 与 GPU 瓶颈,再用“改一个变量看帧时间”的实验细分到顶点、片元、带宽;结论必须来自目标设备。
- SRP Batcher 省的是状态切换而不是 Draw Call 数量,兼容条件是材质属性全部在 UnityPerMaterial 中;Frame Debugger 会给出每次合批中断的原因。
- 变体数量是 multi_compile 各行取值数的乘积;材质开关用 shader_feature_local,管线状态用 multi_compile,单阶段关键字加 _fragment/_vertex 后缀;用 IPreprocessShaders 只剥确定用不到的变体。
- ShaderVariantCollection.WarmUpProgressively 可以把预热分摊到多帧。
- half 只适合颜色、方向和小范围系数;位置、深度、大 UV、时间必须用 float。
- 透明 Overdraw 是移动端片元和带宽的头号杀手,用热度图找热点;Alpha Test 会破坏 Early-Z 与隐藏面消除。
- 贴图用 GPU 压缩格式并合理使用 Mipmap;URP Asset 按设备档位准备多份。
练习
- 在你的项目里做 29.1 节表格中的前三个实验,记录帧时间变化,判断主要瓶颈。
- 给 OverdrawFeature 增加一个“只统计 Transparent 队列”的选项,并在热度图上叠加 30% 的原始画面,方便定位是哪个特效。
- 把 CookbookVariantStripper 的规则改为读取一个 ScriptableObject 配置,并在构建日志中输出每个 Shader 剥离前后的变体数。
- 修改 HalfPrecisionDemo:先在 C# 中把场景整体平移回原点附近(“浮动原点”,Floating Origin),验证问题消失。
- 为 MipLevelViz 增加“纹素密度”模式:显示每个屏幕像素对应多少纹素,并标出密度过高(浪费)和过低(模糊)的区域。