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

调试与排错

渲染 bug 的难点在于“看得见、摸不着”:画面错了,却不知道错在哪个 Pass、哪次 Draw、哪一行 Shader。本章先建立一套从帧到像素逐级缩小范围的调试方法,介绍四件趁手的工具,再给出一份按“症状 → 原因 → 解决”整理的常见问题手册,并亲手复现几种最典型的错误画面。

本章你会学到
从帧、Pass、Draw Call 到像素的二分定位法
Frame Debugger 的事件树、渲染状态与合批信息
在编辑器和真机上用 RenderDoc 抓帧、查像素历史
用 Xcode 和 FrameCapture API 抓取 Metal 帧
Rendering Debugger 的材质、光照、渲染与像素校验模式
“把中间值画成颜色”的调试 Shader 写法
20 个常见问题的症状、原因与解决办法
UV 颠倒、Gamma 错误、法线贴图错误、数值异常的现场还原

30.1 调试的思路:逐级缩小范围

面对一个错误的画面,最低效的做法是凭感觉改 Shader、改设置、再看效果。渲染是一条有明确顺序的流水线,错误一定发生在某个具体的环节,调试的本质是二分查找:

  1. 帧级:问题是每帧都有,还是特定条件下(特定相机角度、特定画质档位、特定设备)才有?能稳定复现是一切的前提。尽量把问题缩小到一个最小场景。
  2. Pass 级:用 Frame Debugger 或 RenderDoc 一步步看渲染目标的变化,找到“画面第一次变错”的那个 Pass。是阴影图本身就错,还是阴影图正确但采样错?是不透明阶段就错,还是后处理之后才错?
  3. Draw Call 级:在出错的 Pass 里找到出错的那次绘制,检查它用的 Shader、Pass、关键字、材质参数、渲染状态(混合、深度测试、剔除)和输入纹理。
  4. 像素级:在出错的那次绘制里挑一个错误的像素,看它的像素历史(哪些绘制写过它),或者把 Shader 的中间值输出成颜色,找出第一个不对的中间量。

每一级都有对应的工具:

工具适用范围擅长
Frame DebuggerUnity 编辑器;可连接 Development Build按 URP 的 Pass 结构浏览事件、查看关键字和材质参数、合批中断原因;零配置
RenderDocWindows/Linux 编辑器与 Player,Android(Vulkan/GLES)完整的 GPU 状态、任意纹理/缓冲查看、像素历史、Shader 单步调试、网格查看
Xcode GPU Frame CapturemacOS 编辑器/Player、iOSMetal 平台的帧捕获、Shader 调试、性能计数器与依赖图
Rendering Debugger编辑器与 Development Build按材质属性、光照分量、Overdraw、像素值范围等“视图模式”整体观察画面
调试 Shader任何地方把任意中间量直接画出来,最快、最灵活

30.2 Frame Debugger 深度用法

打开 Window › Analysis › Frame Debugger,点击 Enable,Game 视图会冻结在当前帧,左侧是这一帧的事件树。URP 14 的事件树大致按 Pass 组织:主光阴影(MainLightShadow)、附加光阴影、深度/法线预通道、DrawOpaqueObjects、DrawSkybox、CopyColor/CopyDepth、DrawTransparentObjects、后处理(PostProcessing 相关事件)、最终 Blit。你在 Renderer Feature 里用 new ProfilingScope(cmd, profilingSampler) 包起来的命令,会以 ProfilingSampler 的名字作为一个可展开的节点出现——这也是为什么本书所有 Feature 都给 Pass 起了名字:没有名字的命令会散落在父节点下,很难找。

右侧面板里最有用的信息

  • RenderTarget:这次绘制写入的目标。下拉框可以切换查看颜色的不同附件或深度;Levels 滑条可以把 HDR 颜色、深度这类超出 0~1 或分布很窄的数据拉伸到可见范围——看深度图时几乎总要调它。
  • Shader 与 Pass:使用的 Shader、SubShader 和 Pass 名(LightMode)。“物体画出来了但不对”时,先确认它走的是不是你以为的那个 Pass。
  • Keywords:这次绘制启用的关键字。阴影不显示时检查有没有 _MAIN_LIGHT_SHADOWS*;Forward+ 下附加光不对时检查 _FORWARD_PLUS;SSAO 不生效时检查 _SCREEN_SPACE_OCCLUSION。
  • Properties:所有 Shader 属性的实际值,包括全局变量和纹理。可以直接看到 _MainLightShadowmapTexture 绑定的是哪张图、_CameraDepthTexture 是否为空、自定义参数是否传进去了。
  • 渲染状态:Blend、ZTest、ZWrite、Cull、Stencil 的实际值。透明物体“不透明了”、模板效果失效,多半能在这里找到原因。
  • 合批信息:SRP Batch 事件里画了几个物体,以及为什么没能和上一批合并(第 29 章列出了常见的原因字符串)。

连接真机

Frame Debugger 也能调试真机:构建时勾选 Development Build,运行后在 Frame Debugger 窗口左上角的目标下拉框里选择设备上的 Player(与 Profiler 连接方式相同)。很多“只在真机上出错”的问题——关键字被剥离、精度、某个 RT 格式不支持——用它就能直接看到真机这一帧的关键字和纹理。

局限Frame Debugger 是 Unity 层面的记录,不是 GPU 层面的捕获:它显示的是 Unity 发出的命令和参数,看不到驱动实际执行的状态,也不能单步调试 Shader、看像素历史。它回放某个事件时会重新渲染到该事件为止,因此依赖“上一帧”数据的效果(TAA 历史、运动矢量)在回放中可能显示异常。这些情况需要 RenderDoc 或 Xcode。

30.3 RenderDoc 抓帧

RenderDoc 是开源的 GPU 帧调试器,支持 D3D11/12、Vulkan、OpenGL/GLES,不支持 Metal。Unity 编辑器对它有内置集成:

  1. 安装 RenderDoc。然后在 Unity 编辑器的 Game 视图或 Scene 视图的标签页上点右键,选择 Load RenderDoc;也可以用命令行参数 -load-renderdoc 启动编辑器。RenderDoc 必须在图形设备创建之前注入,所以加载后编辑器会重新初始化图形设备。
  2. 加载成功后,Game 视图和 Scene 视图的工具栏上会出现一个 RenderDoc 图标,点击它就捕获下一帧,并自动在 RenderDoc 中打开。
  3. 在 RenderDoc 的 Event Browser 中浏览所有 API 调用。Unity 会插入调试标记,URP 的 ProfilingScope 名字(以及你自己的 Feature 名)都会显示为可折叠的分组。
  4. Texture Viewer:查看任意时刻的任意纹理。对一个像素右键选 Pixel History,能看到这一帧所有写过这个像素的绘制、每一次的 Shader 输出、深度测试是否通过、混合前后的值——“这个像素为什么是黑的”这类问题,用像素历史几乎都能直接得到答案。
  5. Pipeline State:完整的管线状态,包括顶点输入布局、绑定的常量缓冲内容、所有采样器状态。Mesh Viewer 能看顶点着色器输入和输出,排查顶点动画、蒙皮、法线问题。
  6. Shader 调试:在像素历史或 Texture Viewer 中对某个像素选择 Debug,可以单步执行片元着色器、查看每个变量。要看到源码级的变量名,需要在 Shader 中加 #pragma enable_d3d11_debug_symbols,让编译器保留调试信息并关闭优化(发布前一定要删掉)。

Android 真机同样可以用 RenderDoc 捕获(Vulkan 或 OpenGL ES),通过 RenderDoc 的 Remote Context 连接设备并启动应用;高通、Arm 等厂商的工具(Snapdragon Profiler、Arm Mobile Studio、Android GPU Inspector)则更侧重性能计数器。

30.4 Xcode GPU Frame Capture

在 macOS 编辑器、Mac Player 和 iOS 上,图形 API 是 Metal,对应的工具是 Xcode 的 GPU Frame Capture。

iOS 与 Mac Player

  1. Unity 构建 iOS 时生成 Xcode 工程。在 Xcode 中打开 Scheme 编辑(Product › Scheme › Edit Scheme),在 Run › Options 中确认 GPU Frame Capture 为 Automatically 或 Metal。
  2. 从 Xcode 运行应用。调试栏上会出现相机形状的 Capture GPU Workload 按钮,点击即可捕获,Xcode 会显示这一帧的所有命令编码器、绘制调用和资源。
  3. 在捕获结果里可以查看每次绘制的绑定资源和附件、调试 Shader(选中像素后单步)、查看性能计数器以及 Pass 之间的依赖图。Unity 的调试标记同样会作为分组出现。

用代码触发捕获

Unity 提供了 UnityEngine.Apple.FrameCapture API,可以在需要的时刻精确地捕获某一帧,例如按下快捷键时,或者检测到异常数值时:

Assets/Cookbook/Ch30/GpuFrameCaptureHotkey.cs(节选:capture)
        public void Capture()
        {
#if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX || UNITY_IOS
            if (FrameCapture.IsDestinationSupported(FrameCaptureDestination.DevTools))
            {
                FrameCapture.CaptureNextFrameToXcode();          // 由 Xcode 启动时:直接在 Xcode 里打开
                Debug.Log("[GpuCapture] next frame -> Xcode");
            }
            else if (FrameCapture.IsDestinationSupported(FrameCaptureDestination.GPUTraceDocument))
            {
                string path = Path.Combine(Application.persistentDataPath,
                    $"frame_{System.DateTime.Now:yyyyMMdd_HHmmss}.gputrace");
                FrameCapture.CaptureNextFrameToFile(path);       // 否则写成 .gputrace 文件
                Debug.Log("[GpuCapture] next frame -> " + path);
            }
            else
            {
                Debug.LogWarning("[GpuCapture] Metal frame capture is not enabled for this process.");
            }
#else
            Debug.LogWarning("[GpuCapture] Metal frame capture is only available on Apple platforms. Use RenderDoc / PIX / AGI instead.");
#endif
        }

FrameCaptureDestination.DevTools 表示把捕获交给正在附加的开发工具(从 Xcode 启动时);GPUTraceDocument 表示写成 .gputrace 文件,之后用 Xcode 打开。写文件需要进程允许 Metal 捕获(例如设置环境变量 MTL_CAPTURE_ENABLED=1 启动,或在 Info.plist 里开启 Metal 捕获),不允许时 IsDestinationSupported 会返回 false。macOS 上的 Unity 编辑器本身也是 Metal 应用,用同样的方式启动编辑器,就能在编辑器里调用这个 API 捕获 Game 视图的一帧。

提示在 Metal 上,#pragma enable_d3d11_debug_symbols 同样会让 Unity 生成带调试信息的 Shader,Xcode 里就能看到更接近源码的变量。名字里的 d3d11 是历史遗留。

30.5 URP Rendering Debugger

Rendering Debugger(Window › Analysis › Rendering Debugger)是 URP 自带的“视图模式”工具。在 Development Build 的 Player 中按 Left Ctrl+Backspace 也能打开它的运行时界面(触控设备和手柄也有对应的手势和按键)。它有四个页面,下表按 URP 14 源码中的选项整理:

页面选项用途
Frequently Used常用选项的汇总不用翻页就能切换最常用的模式
MaterialMaterial Override只显示某个材质属性:Albedo、Specular、Alpha、Smoothness、Ambient Occlusion、Emission、Normal(World/Tangent Space)、Lighting Complexity、Metallic、Sprite Mask
Vertex Attribute显示顶点数据:Texcoord0–3、Color、Tangent、Normal
Material ValidationAlbedo 模式按亮度范围检查反照率是否在合理区间(内置了多种真实材质的预设,如 Fresh Snow、Dry Sand、Worn Asphalt 等);Metallic 模式检查金属度设置
LightingLighting Debug ModeShadow Cascades(级联着色)、Lighting Without/With Normal Maps(只看光照)、Reflections、Reflections With Smoothness
Lighting Features逐项开关 Global Illumination、Main Light、Additional Lights、Vertex Lighting、Emission、Ambient Occlusion,用来判断“哪一项光照贡献出了问题”
HDR Debug ModeHDR 输出相关的检查(Gamut View、Gamut Clip、Values Above Paper White)
RenderingRendering DebugMap Overlays(叠加显示深度图、主光/附加光阴影图)、Overdraw Mode(Opaque/Transparent/All)、额外的线框模式、后处理强制开关、MSAA 与 HDR 开关、TAA 调试模式
Pixel ValidationHighlight NaN, Inf and Negative Values:把异常像素涂成醒目颜色;Highlight Values Outside Range:按通道检查超出指定范围的值
Display Stats帧时间等统计信息
自定义 Shader 为什么在 Material 模式下没反应?Rendering Debugger 的材质与光照模式依赖 Shader 中的 DEBUG_DISPLAY 关键字变体:URP 的 Lit、SimpleLit 等在这个变体里改走调试代码路径(例如 UniversalFragmentPBR 内部会检查调试设置)。完全手写光照、不调用 URP 光照函数的自定义 Shader 不会响应这些模式。另外,URP Global Settings 的 Strip Debug Variants 默认开启,发布构建里没有这些变体,运行时 Rendering Debugger 会提示调试变体缺失。

30.6 Shader 调试技巧

工具再强,最快的调试手段仍然是“把你怀疑的那个值画出来”。约定好可视化方式,看一眼就知道对不对:

数据类型可视化方式正确时应该看到
单位向量(法线、光照方向)n * 0.5 + 0.5世界法线:朝上的面偏绿,朝 +X 的面偏红,朝 +Z 偏蓝;平面颜色均匀
UVfloat4(frac(uv), 0, 1)左下黑、右下红、左上绿、右上黄的平滑渐变;有接缝说明 UV 断开
0~1 标量(N·L、阴影、AO)灰度阴影衰减在阴影内为 0,外为 1
大范围标量(深度、距离)frac(x) 画等值线,或除以最大值等值线连续,随距离均匀分布
世界坐标frac(positionWS) 或世界网格网格贴在表面上,不随相机移动
可能异常的值超范围上色:x < 0 红、x > 1 蓝、NaN 品红没有异常颜色

案例 30.1可切换输出的调试视图 Shader入门单独打开 ↗

目标:写一个带法线贴图、能接收阴影的 Shader,用一个枚举属性切换输出最终光照、世界法线、UV、N·L、阴影衰减、切线空间法线和世界网格。

Assets/Cookbook/Ch30/DebugView.shader(节选:debug)
half4 frag(Varyings i) : SV_Target
{
    half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale);
    half3 bitangentWS = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    half3 normalWS = normalize(TransformTangentToWorld(normalTS, half3x3(i.tangentWS.xyz, bitangentWS, i.normalWS)));

    Light light = GetMainLight(TransformWorldToShadowCoord(i.positionWS));
    half ndl = saturate(dot(normalWS, light.direction));
    int view = (int)_DebugView;

    // 约定:方向向量 [-1,1] 映射到 [0,1] 再显示;标量直接显示为灰度
    if (view == 1) return half4(normalWS * 0.5 + 0.5, 1);
    if (view == 2) return half4(frac(i.uv), 0, 1);
    if (view == 3) return half4(ndl.xxx, 1);
    if (view == 4) return half4(light.shadowAttenuation.xxx, 1);
    if (view == 5) return half4(normalTS * 0.5 + 0.5, 1);
    if (view == 6)
    {
        float3 g = abs(frac(i.positionWS * 2 + 0.5) - 0.5) / fwidth(i.positionWS * 2);
        half line01 = 1 - saturate(min(g.x, min(g.y, g.z)));
        return half4(lerp(half3(0.15, 0.15, 0.18), half3(1, 0.6, 0.2), line01), 1);
    }

    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb;
    half3 color = albedo * (light.color * ndl * light.shadowAttenuation + SampleSH(normalWS));
    return half4(color, 1);
}
void main()
{
    vec3 normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale);
    vec3 bitangentWS = tangentWS.w * cross(normalWS, tangentWS.xyz);
    vec3 nWS = normalize(TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangentWS, normalWS)));

    Light light = GetMainLight(TransformWorldToShadowCoord(positionWS));
    float ndl = saturate(dot(nWS, light.direction));
    int view = int(_DebugView);

    // 约定:方向向量 [-1,1] 映射到 [0,1] 再显示;标量直接显示为灰度
    if (view == 1) { fragColor = vec4(nWS * 0.5 + 0.5, 1.0); return; }
    if (view == 2) { fragColor = vec4(fract(uv), 0.0, 1.0); return; }
    if (view == 3) { fragColor = vec4(vec3(ndl), 1.0); return; }
    if (view == 4) { fragColor = vec4(vec3(light.shadowAttenuation), 1.0); return; }
    if (view == 5) { fragColor = vec4(normalTS * 0.5 + 0.5, 1.0); return; }
    if (view == 6)
    {
        vec3 g = abs(fract(positionWS * 2.0 + 0.5) - 0.5) / fwidth(positionWS * 2.0);
        float line01 = 1.0 - saturate(min(g.x, min(g.y, g.z)));
        fragColor = vec4(mix(vec3(0.15, 0.15, 0.18), vec3(1, 0.6, 0.2), line01), 1.0);
        return;
    }

    vec3 albedo = texture(_BaseMap, uv).rgb;
    vec3 color = albedo * (light.color * ndl * light.shadowAttenuation + SampleSH(nWS));
    fragColor = vec4(color, 1.0);
}

调试模式用一个普通的 float 属性加 if 分支,而不是关键字:调试 Shader 不在乎这点分支开销,却能避免变体数量翻倍,切换时也不需要重新编译。[Enum(Final,0, NormalWS,1, …)] 让 Inspector 显示成下拉菜单。投影阴影借用了 Lit 的 ShadowCaster Pass(UsePass),这会让本 Shader 不兼容 SRP Batcher(两个 Pass 的 UnityPerMaterial 不一致),对调试 Shader 无所谓。

图 30-1同一组物体的四种调试视图。从左到右:最终光照;世界空间法线(叠加了法线贴图,砖缝的凹凸清晰可见);UV(球体的 U 方向绕一圈,立方体每个面独立 0~1,圆环纽结的 UV 沿管道拉伸);N·L(只看主光的余弦项,不含阴影与环境光)

其他几条实用技巧:

  • 看编译后的代码:在 Shader 的 Inspector 中点击 Compile and show code,可以选择平台,查看编译器生成的代码,确认某段代码是否被优化掉、某个关键字分支是否存在、实际用了多少指令和寄存器。
  • 用 Frame Debugger 确认参数:在怀疑 Shader 逻辑之前,先确认输入对了——纹理绑的是不是那张、参数是不是传进去了、关键字有没有开。很多“Shader bug”其实是 C# 没把参数设对。
  • 二分注释:Shader 编译报错位置不明确时(尤其是宏展开后的错误),把一半代码注释掉、直接 return 一个常量,逐步缩小范围。
  • 读错误信息里的文件和行号:错误经常报在 URP 的库文件里(例如第 28 章遇到的 Shadows.hlsl 的 LerpWhiteTo)。这通常不是库有 bug,而是你的 include 顺序或缺少某个 include,导致库代码依赖的东西没有定义。

30.7 常见问题手册

下表是 URP 项目中最常遇到的渲染问题。每一条都先列最常见的原因,排查时按顺序逐条排除。

症状常见原因解决办法
材质显示为粉色(品红)① Shader 编译错误;② 使用了 Built-in 管线的 Shader(Standard、Legacy 等),在 URP 中没有可用的 SubShader;③ SubShader 的 Tags 或 Pass 的 LightMode 与当前渲染路径都不匹配;④ 目标平台不支持 Shader 需要的特性(#pragma target 太高)看 Console 与 Shader Inspector 的错误;Built-in 材质用 Window › Rendering › Render Pipeline Converter 转换;检查 "RenderPipeline"="UniversalPipeline" 与 LightMode
物体全黑① 法线错误(缩放为负、导入时法线丢失、法线方向反了);② Shader 只算了直接光,物体处在阴影里且没加环境光;③ 环境光为黑(Lighting 窗口的 Environment 设置、未生成环境光照);④ 计算中出现 NaN;⑤ 贴图采样到全黑(贴图没赋值、UV 全为 0)用调试视图分别输出法线、N·L、环境光(SampleSH)和贴图颜色,看哪一项为 0
Inspector 显示 SRP Batcher 不兼容材质属性不在 UnityPerMaterial 中;各 Pass 的 UnityPerMaterial 内容不一致;用了 UsePass 引用了布局不同的 Pass把所有材质属性放进同一个 CBUFFER,并在所有 Pass 中保持一致(常见做法是放进公共的 Input.hlsl 统一 include)
阴影不显示① 光源 Shadow Type 为 No Shadows;② URP Asset 关闭了主光阴影,或阴影距离太短;③ 物体的 Cast Shadows 关闭,或 Shader 没有 ShadowCaster Pass;④ 接收方 Shader 没有声明 _MAIN_LIGHT_SHADOWS 系列关键字,或 Renderer 的 Receive Shadows 关闭;⑤ 使用了 Screen Space Shadows,但接收方 Shader 未声明 _MAIN_LIGHT_SHADOWS_SCREENFrame Debugger 先看 MainLightShadow 阴影图里有没有该物体(投射端),再看接收方绘制的关键字和 _MainLightShadowmapTexture(接收端)
透明物体排序错乱、互相穿插透明物体按物体中心到相机的距离排序,无法处理相交、包含或大物体;ZWrite 关闭导致同一物体自身的前后面错乱调整 Sorting Priority / Render Queue;拆分网格;需要自身正确遮挡时先写一遍深度(第 16 章的双 Pass 方法);能用 Alpha Test 或抖动透明的改用它们
_CameraDepthTexture 为空或不对① Pass 没有 ConfigureInput(Depth),且 URP Asset/相机未开启 Depth Texture;② Pass 执行时深度尚未拷贝(Copy Depth Mode 为 After Transparents 时,透明阶段之前的 Pass 读不到本帧深度);③ Overlay 相机读到的是 Base 相机的深度;④ 透明物体本来就不写入深度纹理在 Feature 中声明 ConfigureInput;调整 renderPassEvent;Frame Debugger 查看该绘制的 _CameraDepthTexture 绑定
_CameraOpaqueTexture 是灰色/不更新URP Asset 未开启 Opaque Texture;在不透明阶段(CopyColor 之前)就去读它;它天然不包含透明物体开启 Opaque Texture;读取它的材质放在 Transparent 队列;需要包含透明物体的画面时自己在后面拷贝相机颜色
编辑器正常、真机效果不同① half 精度(29.4 节);② 变体被剥离,运行时退回到其他变体;③ 不同画质档位使用了不同的 URP Asset;④ OpenGL ES 的深度范围、UV 原点等平台差异处理不当;⑤ 贴图压缩格式导致的色带或法线精度下降;⑥ 每物体附加光数量上限、阴影级联数不同真机 Development Build + Frame Debugger 查看关键字与参数;检查各 Quality Level 绑定的 URP Asset;关闭自定义剥离复测
颜色发灰发白或过暗过艳Gamma/Linear 处理错误:颜色贴图的 sRGB 勾选被去掉、数据贴图被勾上了 sRGB、Shader 中多做或少做了一次 Gamma 转换(详见下文)颜色贴图勾 sRGB,Mask/法线/噪声等数据贴图不勾;线性空间项目中不要手动做 Gamma 编码
屏幕采样的画面上下颠倒手写屏幕 UV 时自己做了翻转(或少做了翻转);对 RT 的 y 方向与 UNITY_UV_STARTS_AT_TOP、_ProjectionParams.x 的关系理解有误用 URP 的 GetNormalizedScreenSpaceUV、Blit.hlsl 的 texcoord、ComputeNormalizedDeviceCoordinates,不要手动翻转
法线贴图光照方向不对、表面发紫发灰贴图没有设置为 Normal map;法线贴图来自 DirectX 约定(G 通道向下);切线缺失或镜像 UV 的 tangent.w 未处理Texture Type 设为 Normal map;DirectX 约定的贴图在导入时翻转绿色通道(Flip Green Channel);导入网格时计算切线
画面出现黑块、亮斑或闪烁的白点Shader 产生 NaN/Inf(sqrt/log 负数、normalize 零向量、除以接近 0 的数),经 Bloom、模糊等后处理扩散成大块Rendering Debugger 的 Pixel Validation 定位;修正 Shader(给分母下限、对 sqrt/pow 的参数做 max(0,…));相机的 Stop NaN 选项只能兜底
Renderer Feature 不生效相机使用的不是添加了 Feature 的那个 Renderer(URP Asset 的 Renderer List 与相机的 Renderer 下拉);Feature 被禁用;材质为空;renderPassEvent 太早(被后续 Pass 覆盖);Feature 里过滤掉了当前相机类型Frame Debugger 搜索 Pass 名;检查相机的 Renderer 索引;确认材质与事件
后处理不生效相机没勾 Post Processing;Volume 的 Layer 不在相机 Volume Mask 中;Override 的参数没有勾选;Local Volume 的碰撞体不包含相机;URP Asset 的后处理被关闭逐项检查;Frame Debugger 里确认后处理相关事件是否存在
Deferred 下自定义 Shader 物体不显示延迟渲染的不透明阶段只绘制 UniversalGBuffer 和“仅前向”的 LightMode(UniversalForwardOnly、SRPDefaultUnlit 等);只有 UniversalForward Pass 的不透明 Shader 在 Deferred 下不会被画为不透明的自定义 Shader 使用 "LightMode"="UniversalForwardOnly",或实现 GBuffer Pass
Forward+ 下附加光不生效用 for (i < GetAdditionalLightsCount()) 循环附加光:Forward+ 下该函数返回 0;没有声明 _FORWARD_PLUS 关键字使用 LIGHT_LOOP_BEGIN/LIGHT_LOOP_END 宏(第 6 章),并声明 #pragma multi_compile _ _FORWARD_PLUS
Z-Fighting、远处闪烁两个面几乎重合;近裁剪面过小、远裁剪面过大,深度精度不足拉开几何;增大近裁剪面;贴花类用 Offset 或专门的贴花方案
全屏 Shader 报 unable to unroll loop动态循环里使用了需要导数的纹理采样(SAMPLE_TEXTURE2D)循环内改用 SAMPLE_TEXTURE2D_LOD/SAMPLE_TEXTURE2D_X_LOD(第 28 章)
报错位置在 URP 库文件里include 顺序不对或缺少依赖(例如单独 include RealtimeLights.hlsl 时缺 CommonMaterial.hlsl);重复声明了库中已有的变量(SurfaceInput.hlsl 已声明 _BaseMap 等)先 include Core.hlsl,再按需 include 光照库;不要重复声明库里的纹理
首次触发某效果时卡顿运行时首次使用 Shader 变体,驱动即时编译ShaderVariantCollection 预热(第 29 章)

问题现场一:屏幕 UV 上下颠倒

“屏幕空间的 UV 在 D3D 和 OpenGL 上方向不同”是一条广为流传的经验,但在 URP 里,需要手动处理翻转的情况比你想的少得多。原因是 URP 在把场景渲染到中间纹理时,会使用翻转过的投影矩阵,使得渲染出的纹理在 D3D/Metal 的“UV 从顶部开始”约定下读取时恰好是正的;配套的 GetNormalizedScreenSpaceUV、ComputeNormalizedDeviceCoordinates、ComputeScreenPos(已不推荐使用)以及 Blit.hlsl 的 texcoord 都已经把平台差异处理好了。自己再翻一次,就会得到颠倒的画面:

Assets/Cookbook/Ch30/ScreenUVGlass.shader(节选:uv)
half4 frag(Varyings i) : SV_Target
{
    // ✓ 正确:URP 的函数内部已处理平台差异与动态分辨率
    float2 uv = GetNormalizedScreenSpaceUV(i.positionCS);
    // ✗ 错误:自作主张再翻一次
    if (_FlipBug > 0.5) uv.y = 1 - uv.y;
    return half4(SampleSceneColor(uv) * _Tint.rgb, 1);
}
void main()
{
    // ✓ 正确:用引擎提供的函数求屏幕 UV(WebGL 的 gl_FragCoord 原点在左下角,和纹理 UV 一致,不需要翻转)
    vec2 uv = GetNormalizedScreenSpaceUV(gl_FragCoord);
    // ✗ 错误:自作主张再翻一次
    if (_FlipBug > 0.5) uv.y = 1.0 - uv.y;
    fragColor = vec4(SampleSceneColor(uv) * _Tint.rgb, 1.0);
}
图 30-2一块读取 _CameraOpaqueTexture 的“玻璃”。左:使用 GetNormalizedScreenSpaceUV,玻璃后的物体位置正确;右:手动把 uv.y 再翻一次,玻璃里的地面跑到了上方、物体倒立

排查这类问题的原则:用 URP 提供的函数取屏幕 UV;如果确实要自己处理(例如在 C# 里手动设置矩阵或直接用 CommandBuffer.Blit 的旧路径),用 _ProjectionParams.x(投影是否翻转,-1 表示翻转)和 UNITY_UV_STARTS_AT_TOP 共同判断,并在 D3D/Metal 和 OpenGL ES/Vulkan 平台上各测试一次。

问题现场二:Gamma 与线性空间

线性空间(Linear Color Space)项目中,光照计算必须在线性值上进行。颜色贴图里存的是 sRGB 编码的值,所以要勾选 sRGB,GPU 采样时自动把它解码为线性;渲染结束时,管线在写入 sRGB 格式的目标时再自动编码回去。这条链上任何一环多做或少做一次转换,颜色就会出错:

图 30-3左:正确设置;中:两张颜色贴图(砖墙、刷漆金属)去掉了 sRGB 勾选,GPU 不再解码,贴图颜色变浅、发白,而纯色小球不受影响;右:自定义后处理里又手动做了一次 pow(1/2.2) 的 Gamma 编码,整个画面发灰发白、失去对比度
Assets/Cookbook/Ch30/ManualGamma.shader(节选:bug)
half4 Frag(Varyings input) : SV_Target
{
    half4 c = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, input.texcoord);
    c.rgb = pow(max(c.rgb, 0), 1.0 / 2.2);   // ✗ 多余:线性 → sRGB 的编码交给管线
    return c;
}
void main()
{
    // 点采样:GLSL 不能按调用选采样器,用 texelFetch
    vec4 c = texelFetch(_BlitTexture, ivec2(texcoord * _BlitTexture_TexelSize.zw), 0);
    c.rgb = pow(max(c.rgb, 0.0), vec3(1.0 / 2.2));   // ✗ 多余:线性 → sRGB 的编码交给管线
    fragColor = c;
}

记住两条规则:人眼看的颜色贴图(Albedo、Emission、UI)勾 sRGB;给 Shader 当数据用的贴图(法线、Mask、粗糙度、高度、噪声、LUT 之外的查找表)不勾。在线性空间项目里,Shader 中不要手动写 pow(c, 2.2) 或 pow(c, 1/2.2),除非你清楚地知道某个输入确实没有经过颜色空间转换(例如在 C# 里按 sRGB 取值、通过 Shader.SetGlobalVector 这类 Vector 接口传入的颜色,此时应在 C# 里先用 Color.linear 转换)。

问题现场三:法线贴图的导入设置

图 30-4同一张砖墙法线贴图的三种导入状态,光从上方偏右照来。左:Texture Type = Normal map,砖块上边缘亮、下边缘暗;中:当成普通颜色贴图(sRGB 解码后的 RGB),法线整体偏斜,砖墙整体变暗,球体边缘出现紫色杂色;右:绿色通道方向相反(DirectX 约定的贴图直接使用),砖块的上下明暗关系颠倒,看起来像是凹进去而不是凸出来

设置为 Normal map 时,Unity 会做三件事:关闭 sRGB 解码、按平台选择适合法线的压缩与编码方式(例如桌面平台常用的两通道编码,z 在 Shader 里由 UnpackNormal 系列函数重建)、并允许从灰度高度图生成法线。所以“没标记为法线贴图”带来的不只是颜色空间错误,还有通道布局与压缩格式的错误。Unity 使用 OpenGL 约定(绿色通道朝上,Y+),来自 DirectX 约定工具链的贴图要在导入设置里勾选 Flip Green Channel。

问题现场四:数值异常与“萤火虫”

片元着色器中的除法、sqrt、pow、log、normalize,在输入接近 0 或为负时会产生极大值、Inf 或 NaN。它们在画面上可能只是几个像素,但 Bloom 这类后处理会对周围像素加权求和:一个极大值会扩散成一团光斑,随相机移动闪烁——这就是常说的“萤火虫”(Fireflies);一个 NaN 则更糟,任何数与 NaN 运算的结果都是 NaN,它会顺着 Bloom 的降采样金字塔一路传播。

Assets/Cookbook/Ch30/FireflySource.shader(节选:firefly)
float4 frag(Varyings i) : SV_Target
{
    // 每个小格子一个随机数,模拟“偶尔为 0”的输入(例如某个几何项、某张贴图的通道)
    float2 cell = floor(i.uv * 160);
    float n = frac(sin(dot(cell, float2(12.9898, 78.233))) * 43758.5453);
    float denom = n < _BadRatio ? 1e-5 : n;       // 少数像素分母几乎为 0
    if (_Fix > 0.5) denom = max(denom, 0.05);      // 修复:给分母一个合理下限
    float boost = min(0.5 / denom, 1e5);           // 本意:0.5~几 的增益

    Light light = GetMainLight();
    float3 diffuse = _BaseColor.rgb * (saturate(dot(normalize(i.normalWS), light.direction)) * light.color + 0.3);
    return float4(diffuse * (0.85 + 0.15 * saturate(boost)) + boost * 0.002 * (boost > 50), 1);
}
void main()
{
    // 每个小格子一个随机数,模拟“偶尔为 0”的输入(例如某个几何项、某张贴图的通道)
    vec2 cell = floor(uv * 160.0);
    float n = fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453);
    float denom = n < _BadRatio ? 1e-5 : n;       // 少数像素分母几乎为 0
    if (_Fix > 0.5) denom = max(denom, 0.05);      // 修复:给分母一个合理下限
    float boost = min(0.5 / denom, 1e5);           // 本意:0.5~几 的增益

    Light light = GetMainLight();
    vec3 diffuse = _BaseColor.rgb * (saturate(dot(normalize(normalWS), light.direction)) * light.color + 0.3);
    fragColor = vec4(diffuse * (0.85 + 0.15 * saturate(boost)) + boost * 0.002 * (boost > 50.0 ? 1.0 : 0.0), 1.0);
}
图 30-5左:少数像素的分母接近 0,产生极大的 HDR 亮度,经 Bloom 放大成一团团光斑;右:给分母加上合理下限后,光斑消失

NaN 的演示用下面这个 Shader:少数像素的结果被设为 NaN。

Assets/Cookbook/Ch30/NaNSource.shader(节选:nan)
float4 frag(Varyings i) : SV_Target
{
    float2 cell = floor(i.uv * 160);
    float n = frac(sin(dot(cell, float2(12.9898, 78.233))) * 43758.5453);
    // 现实里的写法类似 float k = sqrt(n - _BadRatio); —— 少数像素参数为负,结果是 NaN。
    // sqrt(负数) 和写死的 NaN 常量都可能被编译器的快速数学优化“优化掉”,为了让演示可重复,
    // 这里用运行时传入的 NaN 位模式(指数全 1、尾数非 0)。
    float k = sqrt(n);
    if (n < _BadRatio) k = asfloat(_NaNBits);   // _NaNBits = 0x7FC00000,运行时由 C# 传入,编译器无法提前折叠
    Light light = GetMainLight();
    float3 color = _BaseColor.rgb * (saturate(dot(normalize(i.normalWS), light.direction)) * light.color + 0.3);
    return float4(color * (0.8 + 0.2 * k), 1);
}
void main()
{
    vec2 cell = floor(uv * 160.0);
    float n = fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453);
    // 现实里的写法类似 float k = sqrt(n - _BadRatio); —— 少数像素参数为负,结果是 NaN。
    // sqrt(负数) 和写死的 NaN 常量都可能被编译器的快速数学优化“优化掉”,为了让演示可重复,
    // 这里用运行时传入的 NaN 位模式(指数全 1、尾数非 0)。
    // 网页版:C# 的 Shader.SetGlobalInteger 不会导出,没设置时(为 0)就用 0x7FC00000,uniform 同样无法被编译器提前折叠
    uint bits = _NaNBits == 0u ? 0x7FC00000u : _NaNBits;
    float k = sqrt(n);
    if (n < _BadRatio) k = uintBitsToFloat(bits);   // HLSL 的 asfloat
    Light light = GetMainLight();
    vec3 color = _BaseColor.rgb * (saturate(dot(normalize(normalWS), light.direction)) * light.color + 0.3);
    fragColor = vec4(color * (0.8 + 0.2 * k), 1.0);
}
NaN 不好“造”第一版直接写 sqrt(n - 0.002),第二版写死 asfloat(0x7FC00000u),结果画面上都只是几个黑点,像素校验也查不到 NaN:编译器在快速数学优化下假设“不存在 NaN”,把相关运算化简掉了。最后改成由 C# 运行时传入 NaN 的位模式,编译器无法提前折叠,NaN 才真正写进了颜色缓冲。这也说明:同一段有问题的代码,在不同平台、不同编译器优化下,可能“恰好没事”,也可能直接黑屏,这正是 NaN 问题难以复现的原因。
图 30-6左:球面上约 0.2% 的像素是 NaN,开启 Bloom 后,NaN 经降采样金字塔传播到最低一级,再在上采样合成时污染了整个画面(这里表现为全屏发白,换一个平台或格式也可能是大片黑块或黑屏);中:关闭后处理、在后处理之前插入像素校验 Pass,NaN 像素被涂成品红;右:开启相机的 Stop NaN,NaN 被替换成黑色,Bloom 恢复正常,但那些像素本身仍是错误的黑点

还有一个容易忽略的因素:颜色缓冲格式决定了错误数值能不能“活下来”。8 位的 LDR 缓冲会把 NaN 和大于 1 的值截掉,只留下黑点或白点;URP 默认的 32 位 HDR 格式(R11G11B10)是无符号浮点,存不下负数;64 位(RGBA16F)则会把负数、Inf、NaN 原样保留,交给后处理去扩散。同一个 Bug 在不同设备、不同 HDR 设置下表现完全不同,“只在某些设置下出现黑屏/黑块”的问题,首先就要怀疑数值异常。

定位与修复:

  • Rendering Debugger › Rendering › Pixel Validation 选择 Highlight NaN, Inf and Negative Values,异常像素会被涂成醒目的颜色。也可以把下面这个全屏 Pass 放在后处理之前,作为项目自己的检查工具(图 30-6 中图就是它的效果;用法同第 28 章的全屏 Feature,本章代码中的 FullscreenDebugFeature):
Assets/Cookbook/Ch30/PixelValidator.shader(节选:validate)
float4 Frag(Varyings input) : SV_Target
{
    float4 c = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, input.texcoord);
    // 背景压暗、去色,让标记更显眼
    float3 bg = Luminance(saturate(c.rgb)) * 0.35;
    if (AnyIsNaN(c))              return float4(1, 0, 1, 1);   // 品红:NaN
    if (AnyIsInf(c))              return float4(0, 1, 1, 1);   // 青:Inf
    if (any(c.rgb < 0))           return float4(1, 1, 0, 1);   // 黄:负数
    if (any(c.rgb > _MaxValue))   return float4(1, 0.3, 0, 1); // 橙:异常大的 HDR 值
    return float4(bg, 1);
}
void main()
{
    vec4 c = texelFetch(_BlitTexture, ivec2(texcoord * _BlitTexture_TexelSize.zw), 0);
    // 背景压暗、去色,让标记更显眼
    vec3 bg = vec3(Luminance(saturate(c.rgb)) * 0.35);
    if (AnyIsNaN(c))                                   { fragColor = vec4(1, 0, 1, 1); return; }   // 品红:NaN
    if (AnyIsInf(c))                                   { fragColor = vec4(0, 1, 1, 1); return; }   // 青:Inf
    if (any(lessThan(c.rgb, vec3(0.0))))               { fragColor = vec4(1, 1, 0, 1); return; }   // 黄:负数
    if (any(greaterThan(c.rgb, vec3(_MaxValue))))      { fragColor = vec4(1, 0.3, 0, 1); return; } // 橙:异常大的 HDR 值
    fragColor = vec4(bg, 1.0);
}
  • core 库的 AnyIsNaN/AnyIsInf 用位运算判断((asuint(x) & 0x7FFFFFFF) > 0x7F800000),源码注释说明了原因:内置的 isnan 在某些编译器设置下不可靠。
  • 在源头修复:sqrt(max(x, 0))、pow(max(x, 0), n)、normalize 前检查长度、除法给分母加下限、SafeNormalize(core 库提供)。
  • 相机上的 Stop NaN 选项会在后处理之前加一个 Pass,把 NaN/Inf 替换成黑色,防止扩散(图 30-6 右)。它有额外开销,而且只是“止血”,像素本身仍然是错的,适合作为上线前的保险而不是修复手段。

问题现场五:阴影不显示的完整排查流程

“阴影不显示”是问得最多的问题之一,因为它涉及投射端、阴影图、接收端三个环节,任何一环断了结果都一样。按下面的顺序排查,每一步都能明确排除一类原因:

  1. 灯光:主光(方向光)的 Shadow Type 不是 No Shadows;它确实是 URP 认定的主光(Lighting 窗口的 Sun Source,或场景中最亮的方向光)。
  2. URP Asset:Main Light 的 Cast Shadows 开启;Shadow Distance 大于物体到相机的距离。注意画质档位可能绑定了另一个 URP Asset。
  3. 阴影图里有没有它:Frame Debugger 选中 MainLightShadow 下的绘制,看 RenderTarget 里的阴影图是否出现了这个物体的轮廓(深度图需要调 Levels 才能看清)。没有的话,问题在投射端:物体的 Cast Shadows 是否开启;Shader 是否有 "LightMode"="ShadowCaster" 的 Pass;顶点动画是否在 ShadowCaster 里同步了。
  4. 接收端的关键字:选中接收阴影的那次绘制,Keywords 里应有 _MAIN_LIGHT_SHADOWS、_MAIN_LIGHT_SHADOWS_CASCADE 或 _MAIN_LIGHT_SHADOWS_SCREEN 之一。如果三个都没有,而别的物体有,说明这个 Shader 没有声明对应的 multi_compile,或者变体被剥离了。
  5. 接收端的计算:用调试视图输出 shadowAttenuation(案例 30.1 的 Shadow 模式)。全白说明采样没有生效,检查阴影坐标的计算(顶点里算还是片元里算,与关键字是否匹配)以及 Renderer 的 Receive Shadows。
  6. 阴影存在但看不清:阴影强度(Light 的 Strength)、Bias 太大导致阴影“漂”离物体(Peter Panning),或者环境光太强让阴影几乎不可见——用 Rendering Debugger 的 Lighting Features 只保留 Main Light 来确认。

问题现场六:编辑器正常、真机异常的排查流程

这类问题最消耗时间,因为每次验证都要打包。先建立一个高效的循环,再逐项排除:

  1. 做一个 Development Build,连接 Frame Debugger(30.2 节)。对比编辑器和真机同一画面中出错物体那次绘制的 Shader、Pass、关键字和属性值。关键字不同,基本可以锁定是变体剥离或画质档位的问题。
  2. 检查 Quality Settings:真机实际使用的画质档位、该档位绑定的 URP Asset,以及其中的阴影、附加光、HDR、MSAA、Render Scale 设置。编辑器默认使用的档位不一定和真机一致。
  3. 暂时关闭项目自定义的变体剥离(第 29 章),重新打包。问题消失则说明剥离规则有误。
  4. 把可疑 Shader 中的 half 全部改成 float 打包测试。问题消失则是精度问题(29.4 节),再逐个变量改回 half 找出真正需要 float 的地方。
  5. 检查纹理导入在目标平台上的覆盖设置(Platform-specific overrides):压缩格式、最大尺寸、sRGB。法线贴图在某些压缩格式下精度明显下降。
  6. 如果只在某一类 GPU 上出错,用 RenderDoc(Android Vulkan/GLES)或 Xcode(iOS)抓帧,查看驱动实际执行的状态;注意 OpenGL ES 的深度范围、半精度默认行为与 Metal/Vulkan 不同。

30.8 让项目“好调试”的几个习惯

调试的效率很大程度上取决于平时写代码的习惯。下面几条成本很低,却能在出问题时节省大量时间:

  • 给每个 Pass 起名字:Renderer Feature 中用 ProfilingSampler 和 ProfilingScope 包裹所有命令,名字会同时出现在 Frame Debugger、RenderDoc、Xcode 和 Profiler 里。临时 RT 也要给名字(RenderingUtils.ReAllocateIfNeeded 的 name 参数),在抓帧工具里一眼就能认出来。
  • 每个效果都留一个“只看中间结果”的开关:本书第 28 章的 SSAO 有 ShowAO 模式、体积光有 ScatterOnly 模式,就是为了排错时能单独检查中间纹理。这样的开关几乎不增加成本。
  • 动画参数可手动定格:依赖 _Time 的效果同时保留一个手动的进度参数,出问题时能把画面停在某一刻反复观察。
  • 项目内置调试视图:把案例 30.1 的调试模式做成一个全局开关(例如全局关键字或全局 float),在 Development Build 中通过菜单切换,测试人员截图时就能附上法线、UV、阴影等视图,问题报告的信息量会大很多。
  • 记录渲染相关的配置:问题报告里附上设备型号、图形 API、画质档位、URP Asset 名称和分辨率。很多“偶现”问题其实只和其中某一项有关。

本章小结

  • 调试是二分查找:帧 → Pass → Draw Call → 像素,每一级用对应的工具缩小范围。
  • Frame Debugger 零配置,能看事件树、关键字、属性值、渲染状态和合批原因,也能连接真机;它看不到 GPU 实际状态,也不能单步调试。
  • RenderDoc(D3D/Vulkan/GL)和 Xcode(Metal)提供像素历史、Shader 单步与完整管线状态;#pragma enable_d3d11_debug_symbols 让 Shader 保留调试信息。
  • Rendering Debugger 的材质与光照模式依赖 DEBUG_DISPLAY 变体,手写光照的自定义 Shader 不会响应。
  • 把中间值画成颜色是最快的 Shader 调试手段,约定好向量、标量、UV、异常值的可视化方式。
  • 屏幕 UV 交给 URP 的函数处理,不要手动翻转;颜色贴图勾 sRGB、数据贴图不勾,线性空间不手动做 Gamma;法线贴图必须标记为 Normal map。
  • NaN/Inf 和极大值会被后处理放大,从源头修复,Stop NaN 只用来兜底;颜色缓冲格式会影响错误的表现。

练习

  1. 用 Frame Debugger 找出你的项目中 SSAO(或第 28 章的任一 Feature)对应的事件,确认它的输入纹理和关键字。
  2. 给 DebugView.shader 增加“观察空间深度等值线”模式(每 1 米一条线),并用它检查第 28 章的世界坐标重建是否与物体 Shader 的结果一致。
  3. 把 PixelValidator 改为同时显示原画面和异常标记(例如异常像素闪烁),并用 FullscreenDebugFeature 挂在 AfterRenderingPostProcessing,检查最终输出。
  4. 在 Windows 编辑器中用 RenderDoc 捕获一帧,对某个阴影边缘的像素查看 Pixel History,说明它被哪些绘制写入过。
  5. 故意把一张 Mask 贴图(金属度/光滑度)勾上 sRGB,观察材质的变化,并用 Rendering Debugger 的 Material Override › Smoothness 模式确认。