调试与排错
渲染 bug 的难点在于“看得见、摸不着”:画面错了,却不知道错在哪个 Pass、哪次 Draw、哪一行 Shader。本章先建立一套从帧到像素逐级缩小范围的调试方法,介绍四件趁手的工具,再给出一份按“症状 → 原因 → 解决”整理的常见问题手册,并亲手复现几种最典型的错误画面。
30.1 调试的思路:逐级缩小范围
面对一个错误的画面,最低效的做法是凭感觉改 Shader、改设置、再看效果。渲染是一条有明确顺序的流水线,错误一定发生在某个具体的环节,调试的本质是二分查找:
- 帧级:问题是每帧都有,还是特定条件下(特定相机角度、特定画质档位、特定设备)才有?能稳定复现是一切的前提。尽量把问题缩小到一个最小场景。
- Pass 级:用 Frame Debugger 或 RenderDoc 一步步看渲染目标的变化,找到“画面第一次变错”的那个 Pass。是阴影图本身就错,还是阴影图正确但采样错?是不透明阶段就错,还是后处理之后才错?
- Draw Call 级:在出错的 Pass 里找到出错的那次绘制,检查它用的 Shader、Pass、关键字、材质参数、渲染状态(混合、深度测试、剔除)和输入纹理。
- 像素级:在出错的那次绘制里挑一个错误的像素,看它的像素历史(哪些绘制写过它),或者把 Shader 的中间值输出成颜色,找出第一个不对的中间量。
每一级都有对应的工具:
| 工具 | 适用范围 | 擅长 |
|---|---|---|
| Frame Debugger | Unity 编辑器;可连接 Development Build | 按 URP 的 Pass 结构浏览事件、查看关键字和材质参数、合批中断原因;零配置 |
| RenderDoc | Windows/Linux 编辑器与 Player,Android(Vulkan/GLES) | 完整的 GPU 状态、任意纹理/缓冲查看、像素历史、Shader 单步调试、网格查看 |
| Xcode GPU Frame Capture | macOS 编辑器/Player、iOS | Metal 平台的帧捕获、Shader 调试、性能计数器与依赖图 |
| Rendering Debugger | 编辑器与 Development Build | 按材质属性、光照分量、Overdraw、像素值范围等“视图模式”整体观察画面 |
| 调试 Shader | 任何地方 | 把任意中间量直接画出来,最快、最灵活 |
30.2 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 格式不支持——用它就能直接看到真机这一帧的关键字和纹理。
30.3 RenderDoc 抓帧
RenderDoc 是开源的 GPU 帧调试器,支持 D3D11/12、Vulkan、OpenGL/GLES,不支持 Metal。Unity 编辑器对它有内置集成:
- 安装 RenderDoc。然后在 Unity 编辑器的 Game 视图或 Scene 视图的标签页上点右键,选择 Load RenderDoc;也可以用命令行参数
-load-renderdoc启动编辑器。RenderDoc 必须在图形设备创建之前注入,所以加载后编辑器会重新初始化图形设备。 - 加载成功后,Game 视图和 Scene 视图的工具栏上会出现一个 RenderDoc 图标,点击它就捕获下一帧,并自动在 RenderDoc 中打开。
- 在 RenderDoc 的 Event Browser 中浏览所有 API 调用。Unity 会插入调试标记,URP 的 ProfilingScope 名字(以及你自己的 Feature 名)都会显示为可折叠的分组。
- Texture Viewer:查看任意时刻的任意纹理。对一个像素右键选 Pixel History,能看到这一帧所有写过这个像素的绘制、每一次的 Shader 输出、深度测试是否通过、混合前后的值——“这个像素为什么是黑的”这类问题,用像素历史几乎都能直接得到答案。
- Pipeline State:完整的管线状态,包括顶点输入布局、绑定的常量缓冲内容、所有采样器状态。Mesh Viewer 能看顶点着色器输入和输出,排查顶点动画、蒙皮、法线问题。
- 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
- Unity 构建 iOS 时生成 Xcode 工程。在 Xcode 中打开 Scheme 编辑(Product › Scheme › Edit Scheme),在 Run › Options 中确认 GPU Frame Capture 为 Automatically 或 Metal。
- 从 Xcode 运行应用。调试栏上会出现相机形状的 Capture GPU Workload 按钮,点击即可捕获,Xcode 会显示这一帧的所有命令编码器、绘制调用和资源。
- 在捕获结果里可以查看每次绘制的绑定资源和附件、调试 Shader(选中像素后单步)、查看性能计数器以及 Pass 之间的依赖图。Unity 的调试标记同样会作为分组出现。
用代码触发捕获
Unity 提供了 UnityEngine.Apple.FrameCapture API,可以在需要的时刻精确地捕获某一帧,例如按下快捷键时,或者检测到异常数值时:
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 视图的一帧。
#pragma enable_d3d11_debug_symbols 同样会让 Unity 生成带调试信息的 Shader,Xcode 里就能看到更接近源码的变量。名字里的 d3d11 是历史遗留。30.5 URP Rendering Debugger
Rendering Debugger()是 URP 自带的“视图模式”工具。在 Development Build 的 Player 中按 Left Ctrl+Backspace 也能打开它的运行时界面(触控设备和手柄也有对应的手势和按键)。它有四个页面,下表按 URP 14 源码中的选项整理:
| 页面 | 选项 | 用途 |
|---|---|---|
| Frequently Used | 常用选项的汇总 | 不用翻页就能切换最常用的模式 |
| Material | Material 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 Validation | Albedo 模式按亮度范围检查反照率是否在合理区间(内置了多种真实材质的预设,如 Fresh Snow、Dry Sand、Worn Asphalt 等);Metallic 模式检查金属度设置 | |
| Lighting | Lighting Debug Mode | Shadow 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 Mode | HDR 输出相关的检查(Gamut View、Gamut Clip、Values Above Paper White) | |
| Rendering | Rendering Debug | Map Overlays(叠加显示深度图、主光/附加光阴影图)、Overdraw Mode(Opaque/Transparent/All)、额外的线框模式、后处理强制开关、MSAA 与 HDR 开关、TAA 调试模式 |
| Pixel Validation | Highlight NaN, Inf and Negative Values:把异常像素涂成醒目颜色;Highlight Values Outside Range:按通道检查超出指定范围的值 | |
| Display Stats | 帧时间等统计信息 |
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 偏蓝;平面颜色均匀 |
| UV | float4(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、阴影衰减、切线空间法线和世界网格。
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 无所谓。

其他几条实用技巧:
- 看编译后的代码:在 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 材质用 转换;检查 "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_SCREEN | Frame 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 都已经把平台差异处理好了。自己再翻一次,就会得到颠倒的画面:
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); }

排查这类问题的原则:用 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 格式的目标时再自动编码回去。这条链上任何一环多做或少做一次转换,颜色就会出错:

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; }
记住两条规则:人眼看的颜色贴图(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 转换)。
问题现场三:法线贴图的导入设置

设置为 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 的降采样金字塔一路传播。
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); }

NaN 的演示用下面这个 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); }
sqrt(n - 0.002),第二版写死 asfloat(0x7FC00000u),结果画面上都只是几个黑点,像素校验也查不到 NaN:编译器在快速数学优化下假设“不存在 NaN”,把相关运算化简掉了。最后改成由 C# 运行时传入 NaN 的位模式,编译器无法提前折叠,NaN 才真正写进了颜色缓冲。这也说明:同一段有问题的代码,在不同平台、不同编译器优化下,可能“恰好没事”,也可能直接黑屏,这正是 NaN 问题难以复现的原因。
还有一个容易忽略的因素:颜色缓冲格式决定了错误数值能不能“活下来”。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):
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 右)。它有额外开销,而且只是“止血”,像素本身仍然是错的,适合作为上线前的保险而不是修复手段。
问题现场五:阴影不显示的完整排查流程
“阴影不显示”是问得最多的问题之一,因为它涉及投射端、阴影图、接收端三个环节,任何一环断了结果都一样。按下面的顺序排查,每一步都能明确排除一类原因:
- 灯光:主光(方向光)的 Shadow Type 不是 No Shadows;它确实是 URP 认定的主光(Lighting 窗口的 Sun Source,或场景中最亮的方向光)。
- URP Asset:Main Light 的 Cast Shadows 开启;Shadow Distance 大于物体到相机的距离。注意画质档位可能绑定了另一个 URP Asset。
- 阴影图里有没有它:Frame Debugger 选中 MainLightShadow 下的绘制,看 RenderTarget 里的阴影图是否出现了这个物体的轮廓(深度图需要调 Levels 才能看清)。没有的话,问题在投射端:物体的 Cast Shadows 是否开启;Shader 是否有
"LightMode"="ShadowCaster"的 Pass;顶点动画是否在 ShadowCaster 里同步了。 - 接收端的关键字:选中接收阴影的那次绘制,Keywords 里应有
_MAIN_LIGHT_SHADOWS、_MAIN_LIGHT_SHADOWS_CASCADE或_MAIN_LIGHT_SHADOWS_SCREEN之一。如果三个都没有,而别的物体有,说明这个 Shader 没有声明对应的multi_compile,或者变体被剥离了。 - 接收端的计算:用调试视图输出
shadowAttenuation(案例 30.1 的 Shadow 模式)。全白说明采样没有生效,检查阴影坐标的计算(顶点里算还是片元里算,与关键字是否匹配)以及 Renderer 的 Receive Shadows。 - 阴影存在但看不清:阴影强度(Light 的 Strength)、Bias 太大导致阴影“漂”离物体(Peter Panning),或者环境光太强让阴影几乎不可见——用 Rendering Debugger 的 Lighting Features 只保留 Main Light 来确认。
问题现场六:编辑器正常、真机异常的排查流程
这类问题最消耗时间,因为每次验证都要打包。先建立一个高效的循环,再逐项排除:
- 做一个 Development Build,连接 Frame Debugger(30.2 节)。对比编辑器和真机同一画面中出错物体那次绘制的 Shader、Pass、关键字和属性值。关键字不同,基本可以锁定是变体剥离或画质档位的问题。
- 检查 Quality Settings:真机实际使用的画质档位、该档位绑定的 URP Asset,以及其中的阴影、附加光、HDR、MSAA、Render Scale 设置。编辑器默认使用的档位不一定和真机一致。
- 暂时关闭项目自定义的变体剥离(第 29 章),重新打包。问题消失则说明剥离规则有误。
- 把可疑 Shader 中的 half 全部改成 float 打包测试。问题消失则是精度问题(29.4 节),再逐个变量改回 half 找出真正需要 float 的地方。
- 检查纹理导入在目标平台上的覆盖设置(Platform-specific overrides):压缩格式、最大尺寸、sRGB。法线贴图在某些压缩格式下精度明显下降。
- 如果只在某一类 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 只用来兜底;颜色缓冲格式会影响错误的表现。
练习
- 用 Frame Debugger 找出你的项目中 SSAO(或第 28 章的任一 Feature)对应的事件,确认它的输入纹理和关键字。
- 给 DebugView.shader 增加“观察空间深度等值线”模式(每 1 米一条线),并用它检查第 28 章的世界坐标重建是否与物体 Shader 的结果一致。
- 把 PixelValidator 改为同时显示原画面和异常标记(例如异常像素闪烁),并用 FullscreenDebugFeature 挂在 AfterRenderingPostProcessing,检查最终输出。
- 在 Windows 编辑器中用 RenderDoc 捕获一帧,对某个阴影边缘的像素查看 Pixel History,说明它被哪些绘制写入过。
- 故意把一张 Mask 贴图(金属度/光滑度)勾上 sRGB,观察材质的变化,并用 Rendering Debugger 的 Material Override › Smoothness 模式确认。