GameTech/URP 渲染实战
CHAPTER 29第 29 章

性能优化

优化的第一步永远是测量,而不是改代码。本章先建立“瓶颈在哪”的判断方法,再逐一讲清合批、Shader 变体、精度、Overdraw、Alpha Test 与 TBDR、贴图和 URP Asset 设置,每个主题都配一个能直接放进项目的工具。

本章你会学到
CPU、顶点、片元、带宽四类瓶颈的判断实验
SRP Batcher、GPU Instancing、静态/动态合批的原理与验证
变体是怎么爆炸的,以及用 IPreprocessShaders 剥离变体
用 ShaderVariantCollection 分帧预热,消除首次卡顿
half 与 float 的取舍,以及“远离原点”的精度事故
写一个 Overdraw 热度图 Renderer Feature
Alpha Test 为何在移动 GPU 上昂贵
URP Asset 与 Renderer 的移动端设置清单

29.1 先定位,再优化:瓶颈的四种类型

一帧的时间由 CPU 和 GPU 共同决定,它们像流水线上的两道工序:CPU 准备并提交第 N 帧的绘制命令时,GPU 通常在执行第 N−1 帧。哪一边更慢,帧率就由哪一边决定,另一边优化得再好也没有用。所以第一件事是分清CPU 瓶颈还是 GPU 瓶颈,GPU 瓶颈再细分到顶点、片元还是带宽。

CPU剔除 · 排序 · 提交 顶点阶段顶点数 · 蒙皮 · 阴影 片元阶段像素数 · Shader 复杂度 带宽贴图 · RT 读写 Draw Call、SetPass、脚本 高模、级联阴影×N Overdraw、全屏后处理 HDR、MSAA、多次全屏 移动端最常见的是片元与带宽,桌面/主机上 CPU 提交更常成为瓶颈——但永远以测量结果为准
图 29-1渲染瓶颈的四种类型及各自的常见诱因

判断瓶颈的实验

工具层面,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 上关闭渲染(只跑逻辑)如果仍然慢:不是渲染问题脚本、物理、动画才是瓶颈
注意编辑器里的性能数据只能作为参考:编辑器本身的开销、桌面 GPU 与移动 GPU 的架构差异,都会让结论走样。最终结论必须在目标设备上、用 Development Build 测量。本章不给任何“某设置能提升多少帧”的数字——它们完全取决于你的场景和设备。

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,需要满足两条:

  1. 所有材质属性(Properties 里声明、材质会设置的变量)都放在名为 UnityPerMaterial 的 CBUFFER 里,并且这个 CBUFFER 在同一个 SubShader 的所有 Pass、所有变体里布局一致;
  2. 所有内置的逐物体变量(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,再写一个反例,学会在编辑器里验证。

Assets/Cookbook/Ch29/BatcherFriendly.shader(节选:cbuffer)
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)
uniform sampler2D _BaseMap;
// SRP Batcher:每个材质一份常量缓冲,布局在所有变体里必须一致
uniform vec4 _BaseMap_ST;
uniform vec4 _BaseColor;

// GPU Instancing:允许用 MaterialPropertyBlock 逐实例覆盖颜色
uniform vec4 _InstanceColor;

反例只差一行——属性声明在 CBUFFER 之外:

Assets/Cookbook/Ch29/BatcherBreaker.shader(节选:bad)
half4 _BaseColor;     // ✗ 不在 UnityPerMaterial 里
uniform vec4 _BaseColor;     // ✗ 不在 UnityPerMaterial 里

逐实例颜色通过 MaterialPropertyBlock 设置:

Assets/Cookbook/Ch29/PerInstanceTint.cs(节选:apply)
void Apply()
{
    var r = GetComponent<Renderer>();
    if (r == null) return;
    s_Block ??= new MaterialPropertyBlock();
    r.GetPropertyBlock(s_Block);
    s_Block.SetColor(s_InstanceColor, tint);
    r.SetPropertyBlock(s_Block);
}

如何验证:

  1. 在 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。
  2. 打开 Window › Analysis › Frame Debugger,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。
  3. 对非 SRP Batcher 的绘制(Instancing、静态/动态合批),Frame Debugger 同样会给出不能合批的原因,常见的有 Objects have different materials.、Objects have different MaterialPropertyBlock set.、Objects are affected by different light probes.、Objects are lightmapped. 等。
提示“同一 Shader 变体”是 SRP Batcher 连续合批的关键。材质之间关键字不同(例如一个开了 _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),理论组合数就是:

4×3×3×5×4×4×214=47,185,920

这还没算 shader_feature_local 的材质关键字、include_with_pragmas 引入的关键字和 Instancing。实际项目里当然不会真编译这么多,靠的是三道闸门:

  1. shader_feature 只编译用到的:shader_feature 声明的关键字,构建时只保留项目中材质实际启用过的组合;multi_compile 则无条件全编。规则是:由材质决定的开关(是否有法线贴图、是否透明)用 shader_feature;由运行时管线状态决定的开关(阴影、光照模式、雾)必须用 multi_compile,否则运行时切到某个状态时变体不存在。
  2. 阶段后缀:multi_compile_fragment/_vertex 只对一个着色阶段生效,另一个阶段不会因此翻倍。Lit 里大量关键字都是 _fragment 的。
  3. 剥离(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 在构建时剥掉项目确定用不到的变体,并在构建结束时输出统计。

Assets/Cookbook/Ch29/Editor/CookbookVariantStripper.cs(节选:rules)
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;
    }
Assets/Cookbook/Ch29/Editor/CookbookVariantStripper.cs(节选:process)
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)。

  1. 在编辑器里把游戏完整跑一遍(尽量覆盖所有效果),然后在 Edit › Project Settings › Graphics 底部的 Shader Loading 区域点击 Save to asset,得到一个 ShaderVariantCollection 资源,里面记录了这次运行中用到的变体。
  2. 把它交给下面的组件,在加载界面分帧预热。
Assets/Cookbook/Ch29/ShaderWarmup.cs(节选:warmup)
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 的格式手动舍入来模拟:

Assets/Cookbook/Ch29/HalfPrecisionDemo.shader(节选:frag)
// 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);
}
图 29-2场景位于世界坐标 (300, 0, 300) 附近。左:float 计算世界空间 UV,网格贴图正常;右:模拟 half 存储,坐标只能取 0.25 米的整数倍,贴图被采样成一块块的色块,恰好落在网格线上的位置则整条变成白线

精度选择的经验规则:

✓ 可以用 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 把计数映射成颜色。

Assets/Cookbook/Ch29/OverdrawCount.shader(节选:state)
Blend One One          // 累加
ZWrite Off
ZTest [_ZTest]
Cull [_Cull]
Blend One One          // 累加
ZWrite Off
ZTest [_ZTest]
Cull [_Cull]
Assets/Cookbook/Ch29/OverdrawFeature.cs(节选:execute)
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');
  }
}
Assets/Cookbook/Ch29/OverdrawHeatmap.shader(节选:ramp)
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 排序,虽然加法混合与顺序无关,但保持与真实渲染一致是好习惯。
图 29-3左:正常画面,墙前有一团由 40 张半透明面片组成的烟;右:Overdraw 热度图(深蓝 1 层、青 2 层、绿 3 层、黄 4 层、橙 6 层、红 8 层、白 12 层以上),烟团中心超过 12 层,而这片区域在左图里看起来只是一团淡淡的烟

URP 14 的 Rendering Debugger 也内置了 Overdraw 视图(Window › Analysis › Rendering Debugger › 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 在片上高速缓存里完成深度测试、着色和混合,最后把结果一次性写回显存。这样,深度和颜色的反复读写都发生在片上,极大节省了带宽——而带宽恰恰是移动设备功耗和发热的大头。

① 分块(Binning) 片上 Tile 缓存 深度测试 / 隐藏面消除 只对可见片元着色 混合(读写都在片上) ② 逐 Tile 渲染 系统显存每 Tile 写回一次 ③ Resolve / Store
图 29-4TBDR:先分块,再在片上完成整块的深度、着色与混合,最后一次性写回显存

很多 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 工具测量。
深入:TBDR 与 RT 切换在 TBDR 上,切换渲染目标意味着把当前 Tile 的内容写回显存,下次再用这个目标时可能又要读回来(Load)。URP 的每一个全屏 Pass、每一次 SetRenderTarget 都可能引起一次整屏的 Store/Load。URP Asset 的 Store Actions Optimization、Renderer 上的 Native RenderPass 选项,就是为了减少不必要的 Load/Store。这也是第 28 章强调“合并全屏 Pass”的原因。

29.7 贴图:压缩格式与 Mipmap

贴图带宽和内存往往比 Shader 计算更早成为瓶颈。三条基本原则:

  1. 永远使用 GPU 压缩格式。移动端首选 ASTC(块大小 4×4 到 12×12 可选,越大越省、质量越低),老设备回退 ETC2;桌面用 BC 系列(BC7 高质量颜色、BC5 法线、BC4 单通道、BC6H HDR)。未压缩的 RGBA32 每像素 4 字节,压缩后通常只有几分之一,而且 GPU 读取压缩块时带宽也同比减少。
  2. 3D 物体的贴图开启 Mipmap。远处物体在屏幕上很小,如果采样全分辨率贴图,相邻像素读取的纹素相隔很远,缓存命中率低、带宽浪费、还会闪烁(摩尔纹)。Mip 让 GPU 读取与屏幕尺寸匹配的那一级。UI、屏幕空间的 RT 等永远以 1:1 显示的贴图则关掉 Mipmap 省内存(Mip 链多占约 1/3 内存)。
  3. 分辨率按“屏幕上能看到的纹素密度”给,而不是越大越好。

案例 29.6Mip 级别可视化入门单独打开 ↗

目标:把 GPU 实际选择的 Mip 级别画成颜色,检查贴图分辨率和平铺是否合理。

Assets/Cookbook/Ch29/MipLevelViz.shader(节选: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/高, 宽, 高)。

图 29-5左:平铺的棋盘格地面与左侧墙面;右:Mip 级别可视化(绿 = Mip 0、黄 = 1、橙 = 2、红 = 3、品红 = 4、紫 = 5 及以上;蓝 = 放大)。地面随距离平滑升级,而左侧墙面面积不大却整面是 Mip 4 以上——它的贴图平铺次数太多,512 像素的贴图被压缩到很小的屏幕面积里,大部分分辨率被浪费

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 PrecisionHDR 让颜色缓冲从 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 LightsPer Vertex 比 Per Pixel 便宜得多;Per Object Limit 控制每个物体最多受几盏附加光影响(Forward 路径);附加光阴影代价很高,谨慎开启。
Reflection ProbesProbe Blending 和 Box Projection 都有额外的采样和计算,非必要不开。
SRP Batcher / Dynamic BatchingSRP Batcher 保持开启;Dynamic Batching 默认关闭,只有大量极小网格、且确认 CPU 是瓶颈时才考虑。
LOD Cross Fade开启后 LOD 切换时两级同时绘制并抖动裁剪,短时间内 Draw Call 和片元翻倍,还引入 Alpha Test 式的 clip;可以只给关键物体使用。
Store Actions Optimization控制渲染目标的 Load/Store 行为,Auto 会在合适时避免不必要的存储;对 TBDR 带宽有直接影响。
Volume Update ModeEvery Frame 每帧计算 Volume 混合;如果 Volume 参数不变,改为 Via Scripting 并在需要时手动更新,可以省 CPU。
Grading Mode / LUT SizeLow 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 TextureAlways 会始终使用中间纹理;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 内置的是基于预计算的方案:在 Window › Rendering › Occlusion Culling 中把静态物体标记为 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 节的实验区分顶点/片元/带宽;在目标设备上测量
2Draw Call 与 SetPassFrame Debugger 查看 SRP Batch 的中断原因;统一材质关键字;检查是否有意外的材质实例
3阴影级联数、阴影距离、分辨率、软阴影质量;关闭不需要投影的物体的 Cast Shadows(小物件、远景)
4透明与粒子Overdraw 热度图定位热点;减少大面积半透明层数;粒子网格贴合不透明区域
5全屏 Pass数一数一帧有多少次全屏读写(后处理、屏幕空间 Feature、拷贝);能降分辨率的降,能合并的合并
6Shader 复杂度只在需要的地方使用复杂光照;half 精度;去掉无用的关键字分支;远处物体用简化 Shader(LOD 时切换材质)
7贴图全部使用压缩格式;检查 Mip 可视化;分辨率与屏幕占比匹配;关闭不需要的 Read/Write
8变体与加载构建日志里的变体数量;自定义剥离;变体预热消除首次卡顿
9URP 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 按设备档位准备多份。

练习

  1. 在你的项目里做 29.1 节表格中的前三个实验,记录帧时间变化,判断主要瓶颈。
  2. 给 OverdrawFeature 增加一个“只统计 Transparent 队列”的选项,并在热度图上叠加 30% 的原始画面,方便定位是哪个特效。
  3. 把 CookbookVariantStripper 的规则改为读取一个 ScriptableObject 配置,并在构建日志中输出每个 Shader 剥离前后的变体数。
  4. 修改 HalfPrecisionDemo:先在 C# 中把场景整体平移回原点附近(“浮动原点”,Floating Origin),验证问题消失。
  5. 为 MipLevelViz 增加“纹素密度”模式:显示每个屏幕像素对应多少纹素,并标出密度过高(浪费)和过低(模糊)的区域。