URP 架构与一帧的流程
上一章讲的是 GPU 怎么画;这一章讲 URP 怎么“指挥” GPU 画:它由哪些资产配置、一帧里按什么顺序执行哪些 Pass、三种渲染路径有什么本质区别,以及为什么你的 Shader 必须按某种规矩来写。
2.1 SRP 是什么
在 Built-in(内置)渲染管线时代,“一帧怎么画”写死在引擎的 C++ 代码里:先画阴影、再画不透明物体、再画天空、再画透明物体……你能做的只是在固定的位置插入 CommandBuffer,或者用 OnRenderImage 做后处理。前向渲染的多光源靠“每盏灯一个 Pass”(ForwardBase + ForwardAdd)实现,想改这个机制几乎不可能。
可编程渲染管线(SRP,Scriptable Render Pipeline)把这件事反了过来:引擎底层仍然负责最脏最累的活——剔除、把绘制命令高效地提交给图形 API、合批——而“画什么、按什么顺序画、画到哪里”交给 C# 代码决定。URP(Universal Render Pipeline)和 HDRP 都是 Unity 官方用 SRP 写成的管线,源码就在工程的 Library/PackageCache 里,可读、可调试、必要时可以修改。
SRP 的入口极其简单:一个 RenderPipelineAsset(资产)负责创建一个 RenderPipeline(实例),引擎每帧调用实例的 Render(ScriptableRenderContext context, List<Camera> cameras)。ScriptableRenderContext 是一个“命令录制器”:你在 C# 里调用 context.Cull、context.DrawRenderers、context.ExecuteCommandBuffer,它们只是被录下来,直到 context.Submit() 才真正交给渲染线程执行。理解这一点很重要:URP 里很多“看起来立刻执行”的代码,其实都是在排队。
对 Shader 作者而言,SRP 带来的最大变化有三个:
- Shader 的 Pass 由 LightMode 标签认领。URP 不认识 Built-in 的
ForwardBase、ForwardAdd,你必须写UniversalForward等 URP 认识的标签(2.9 节)。 - 多光源在一个 Pass 里循环计算,不再“每盏灯一个 Pass”(第 6 章)。
- Shader 库换了:
UnityCG.cginc换成 URP 的Core.hlsl、Lighting.hlsl等,函数名几乎全部变了(第 3 章、附录 D)。
2.2 URP 的资产家族
刚接触 URP 时最让人困惑的,是它的设置分散在好几个资产里。图 2-2 把它们的关系画了出来。
| 资产 / 组件 | 负责什么 | 典型设置 |
|---|---|---|
URP AssetUniversalRenderPipelineAsset | 整个管线的“质量档位”和全局开关。不同 Quality 级别可以指定不同的 URP Asset,这是做“高/中/低画质”的标准做法 | HDR、MSAA、Render Scale、主光与附加光模式、阴影距离/级联/分辨率、Depth/Opaque Texture、SRP Batcher |
Universal Renderer DataUniversalRendererData | 一帧的“流程”:用什么渲染路径,加了哪些 Renderer Feature | Rendering Path、Depth Priming、Depth Texture Mode、Opaque/Transparent Layer Mask、Renderer Features |
Renderer FeatureScriptableRendererFeature | 往流程里插入自定义 Pass(第 25 章) | SSAO、Decal、Render Objects、Full Screen Pass、你自己写的 |
相机附加数据UniversalAdditionalCameraData | 每台相机自己的选择 | Render Type(Base/Overlay)、Renderer 索引、Stack、Post Processing、抗锯齿、Depth/Opaque Texture 覆盖 |
| Volume + Volume Profile | 后处理(以及部分渲染参数)的数值,可按空间区域混合 | Bloom、Tonemapping、Color Adjustments、Vignette……(第 26、27 章) |
一个实用的心法:“开不开某个功能”多半在 URP Asset,“怎么画”多半在 Renderer,“画多强”多半在 Volume。比如 SSAO:Renderer 里加 Screen Space Ambient Occlusion Feature 才有这个 Pass;而 Bloom 不需要 Feature,后处理 Pass 是 Renderer 自带的,开关在相机的 Post Processing 复选框,强度在 Volume 里。
2.3 从 Render() 到 Pass:一帧的调用链
每帧引擎调用 UniversalRenderPipeline.Render。顺着 URP 14 的源码走一遍(Runtime/UniversalRenderPipeline.cs、Runtime/ScriptableRenderer.cs、Runtime/UniversalRenderer.cs):
其中最值得注意的是 ③ 和 ④ 的先后:Renderer Feature 的 Pass 先入队,内置 Pass 后入队,然后 ⑤ 做一次稳定排序(相同 RenderPassEvent 的 Pass 保持入队顺序)。URP 源码在构造 Pass 时专门写了注释说明这一点:
// 摘自 URP 14 Runtime/UniversalRenderer.cs 构造函数 // Note: Since all custom render passes inject first and we have stable sort, // we inject the builtin passes in the before events. m_MainLightShadowCasterPass = new MainLightShadowCasterPass(RenderPassEvent.BeforeRenderingShadows); m_AdditionalLightsShadowCasterPass = new AdditionalLightsShadowCasterPass(RenderPassEvent.BeforeRenderingShadows);
翻译成人话:内置 Pass 都挂在 “Before×××” 事件上;你的 Pass 如果也挂在同一个 “Before×××” 上,会排在内置 Pass 之前;挂在 “After×××” 上,就排在内置 Pass 之后。例如内置的不透明绘制在 BeforeRenderingOpaques(250),你的 Pass 设成 BeforeRenderingOpaques 会在它之前执行,设成 AfterRenderingOpaques(300)则在它之后。
2.4 RenderPassEvent:Pass 的时间轴
RenderPassEvent 是一个整数枚举,数值越小越早执行。下表是 URP 14.0.12 Runtime/Passes/ScriptableRenderPass.cs 中的完整定义:
| 枚举 | 值 | 枚举 | 值 |
|---|---|---|---|
BeforeRendering | 0 | BeforeRenderingOpaques | 250 |
BeforeRenderingShadows | 50 | AfterRenderingOpaques | 300 |
AfterRenderingShadows | 100 | BeforeRenderingSkybox | 350 |
BeforeRenderingPrePasses | 150 | AfterRenderingSkybox | 400 |
AfterRenderingPrePasses | 200 | BeforeRenderingTransparents | 450 |
BeforeRenderingGbuffer | 210 | AfterRenderingTransparents | 500 |
AfterRenderingGbuffer | 220 | BeforeRenderingPostProcessing | 550 |
BeforeRenderingDeferredLights | 230 | AfterRenderingPostProcessing | 600 |
AfterRenderingDeferredLights | 240 | AfterRendering | 1000 |
因为是整数,你可以写 RenderPassEvent.AfterRenderingOpaques + 1 这样的值做更细的排序,URP 自己也这么用:FinalBlit 是 AfterRendering + 1,屏幕空间 Overlay UI 是 AfterRendering + 2。
排好序的 Pass 并不是一口气执行的。ScriptableRenderer.Execute 按事件值把它们切成四个执行块(RenderPassBlock),块与块之间做一些全局设置:
这四个块的划分解释了枚举注释里的一句话:“BeforeRenderingShadows… Camera matrices and stereo rendering are not setup this point.”——在 BeforeRenderingPrePasses(150)之前执行的 Pass 拿不到正确的相机矩阵,它们应该只做与相机无关的事(阴影图、颜色分级 LUT)。URP 自带的 RenderObjects Feature 就在 Create() 里强制把早于 BeforeRenderingPrePasses 的事件改成 BeforeRenderingPrePasses。
2.5 一帧的 Pass 顺序
下面以最常用的 Forward / Forward+ 路径为例,把 UniversalRenderer.Setup 里入队的内置 Pass 按执行顺序列出来。每个 Pass 只有满足条件才会入队——这是 URP 和 Built-in 的一大不同:没用到的功能就不花钱。
几个值得展开的细节:
- 阴影最先画,因为它们只依赖灯光,不依赖相机的颜色缓冲;画出来的阴影图在不透明和透明 Pass 里被采样(第 6、23 章)。
- 天空盒在不透明物体之后。被不透明物体挡住的像素在深度测试阶段就被丢弃,天空盒只画“空着”的地方,省下大量填充率。这也意味着:在天空盒模式下,URP 通常只清深度、不清颜色(
ScriptableRenderer.GetCameraClearFlag的注释:“CameraClearFlags.Skybox clears depth only”),颜色缓冲里背景区域残留的是上一帧的内容,反正马上会被天空盒覆盖。 - CopyColor 在天空盒之后、透明物体之前,所以
_CameraOpaqueTexture里有不透明物体和天空,但没有透明物体——这正是做折射(水、玻璃、热扭曲)需要的“背后的画面”。 - CopyDepth 的时机可配置,下一节专门讲。
- 后处理在两个颜色缓冲之间交替(URP 的 swap buffer 机制):读 A 写 B,然后交换,所以后处理之后“相机颜色缓冲”已经换了一张纹理。自定义 Pass 要在执行时现取
cameraColorTargetHandle,不能提前缓存——下面的案例就踩过这个坑。
案例 2.1帧快照:一格一格看一帧怎么画出来进阶单独打开 ↗
目标:写一个 Renderer Feature,在指定的 RenderPassEvent 时刻把相机颜色缓冲“拍”一张快照,等整帧画完再贴回屏幕。这样就能在 Game 视图里直接看到这一帧在不透明之后、天空盒之后、透明之后各是什么样,与 Frame Debugger 互相印证。
原理:两个 Pass。CapturePass 挂在 captureEvent 上,把当前相机颜色复制到一张 RT;RestorePass 挂在 AfterRenderingPostProcessing 上,把快照复制回相机颜色缓冲,随后 FinalBlit 把它送上屏幕。Renderer Feature 的完整写法是第 25 章的内容,这里先当工具用,只看关键部分:
public override void Create() { m_Clear = new ClearColorPass { renderPassEvent = RenderPassEvent.BeforeRenderingOpaques }; m_Capture = new CapturePass(); m_Restore = new RestorePass(); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { // 只处理游戏相机,Scene 视图、预览窗口保持原样 if (renderingData.cameraData.cameraType != CameraType.Game) return; m_Capture.renderPassEvent = captureEvent; // 贴回去的时机:后处理之后、FinalBlit 之前 m_Restore.renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing; if (clearColorBeforeOpaques) renderer.EnqueuePass(m_Clear); renderer.EnqueuePass(m_Capture); renderer.EnqueuePass(m_Restore); } public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData) { if (renderingData.cameraData.cameraType != CameraType.Game) return; // 快照的格式与相机颜色缓冲一致(HDR 时是浮点格式),但不需要深度、不需要 MSAA var desc = renderingData.cameraData.cameraTargetDescriptor; desc.depthBufferBits = 0; desc.msaaSamples = 1; RenderingUtils.ReAllocateIfNeeded(ref m_Snapshot, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_FrameSnapshot"); m_Capture.Setup(m_Snapshot); m_Restore.Setup(m_Snapshot); } protected override void Dispose(bool disposing) { m_Snapshot?.Release(); }
// 第 2 章 案例 2.1:帧快照 Renderer Feature(JS 版,对应 Ch02/FrameSnapshotFeature.cs) // 在 captureEvent 这个时间点,把相机颜色缓冲“拍”一张快照; // 在后处理之后,再把快照贴回相机颜色缓冲。于是屏幕上看到的,就是这一帧在 captureEvent 时刻的“半成品”。 import { RenderPassEvent } from '../engine/features.js'; export default class FrameSnapshotFeature { constructor(engine, data) { this.engine = engine; this.captureEvent = data.captureEvent ?? RenderPassEvent.AfterRenderingOpaques; // 在哪个时间点拍快照 this.clearColorBeforeOpaques = data.clearColorBeforeOpaques ?? true; // 画不透明物体之前把颜色清成黑色 } addPasses(frame, enqueue) { const E = this.engine, gl = E.gl; let snapshot = null; // ClearColorPass:此时相机颜色/深度缓冲已经绑定,只清颜色 if (this.clearColorBeforeOpaques) { enqueue(RenderPassEvent.BeforeRenderingOpaques, (fr) => { fr.bindCamera(); gl.colorMask(true, true, true, true); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); }, 'Cookbook Snapshot Clear'); } // CapturePass:快照格式与相机颜色缓冲一致(HDR 时是浮点),不要深度、不要 MSAA。 // cameraColorTexture() 会先把多重采样缓冲解析(resolve)出来,读到的才是此刻的画面 enqueue(this.captureEvent, (fr) => { const rt = fr.getTemporaryRT('_FrameSnapshot', fr.width, fr.height, { color: fr.colorFormat }); fr.blit(fr.cameraColorTexture(), rt, E.blitMat, 'Copy'); snapshot = rt.texture; fr.bindCamera(); }, 'Cookbook Snapshot Capture'); // RestorePass:后处理之后、FinalBlit 之前,把快照贴回“当前”的相机颜色(后处理开启时就是后处理的输出) enqueue(RenderPassEvent.AfterRenderingPostProcessing, (fr) => { if (!snapshot) return; if (fr.postDone) fr.postOutput = snapshot; else { fr.blit(snapshot, 'camera', E.blitMat, 'Copy'); fr.bindCamera(); } }, 'Cookbook Snapshot Restore'); } }
class ClearColorPass : ScriptableRenderPass { public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { // 没有调用 ConfigureTarget,执行到这里时 URP 已经绑定了相机颜色/深度缓冲 var cmd = CommandBufferPool.Get(); cmd.ClearRenderTarget(RTClearFlags.Color, Color.black, 1f, 0); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } class CapturePass : ScriptableRenderPass { RTHandle m_Destination; public CapturePass() { profilingSampler = new ProfilingSampler("Cookbook Snapshot Capture"); } public void Setup(RTHandle destination) { m_Destination = destination; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 告诉 URP 这个 Pass 的输出目标,URP 才知道之后的 Pass 需要重新绑定相机目标 ConfigureTarget(m_Destination); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { // 相机颜色缓冲在 Execute 里现取:后处理开启时 URP 用两个缓冲交替(swap buffer), // 在 SetupRenderPasses 里缓存的句柄不一定是此刻正在画的那一个 var source = renderingData.cameraData.renderer.cameraColorTargetHandle; var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { // 开了 MSAA 时,相机颜色缓冲是多重采样纹理,“解析”(resolve)之前直接采样它, // 读到的可能是上一次解析留下的旧内容。这里显式解析到快照里,读到的才是此刻的画面。 if (source.rt != null && source.rt.antiAliasing > 1) cmd.ResolveAntiAliasedSurface(source.rt, m_Destination.rt); else Blitter.BlitCameraTexture(cmd, source, m_Destination); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } class RestorePass : ScriptableRenderPass { RTHandle m_Snapshot; public RestorePass() { profilingSampler = new ProfilingSampler("Cookbook Snapshot Restore"); } public void Setup(RTHandle snapshot) { m_Snapshot = snapshot; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { // 后处理会在两个颜色缓冲之间来回切换(swap buffer),所以在 Execute 里现取“当前”的相机颜色缓冲 var target = renderingData.cameraData.renderer.cameraColorTargetHandle; var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) Blitter.BlitCameraTexture(cmd, m_Snapshot, target); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }
// 第 2 章 案例 2.1:帧快照 Renderer Feature(JS 版,对应 Ch02/FrameSnapshotFeature.cs) // 在 captureEvent 这个时间点,把相机颜色缓冲“拍”一张快照; // 在后处理之后,再把快照贴回相机颜色缓冲。于是屏幕上看到的,就是这一帧在 captureEvent 时刻的“半成品”。 import { RenderPassEvent } from '../engine/features.js'; export default class FrameSnapshotFeature { constructor(engine, data) { this.engine = engine; this.captureEvent = data.captureEvent ?? RenderPassEvent.AfterRenderingOpaques; // 在哪个时间点拍快照 this.clearColorBeforeOpaques = data.clearColorBeforeOpaques ?? true; // 画不透明物体之前把颜色清成黑色 } addPasses(frame, enqueue) { const E = this.engine, gl = E.gl; let snapshot = null; // ClearColorPass:此时相机颜色/深度缓冲已经绑定,只清颜色 if (this.clearColorBeforeOpaques) { enqueue(RenderPassEvent.BeforeRenderingOpaques, (fr) => { fr.bindCamera(); gl.colorMask(true, true, true, true); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); }, 'Cookbook Snapshot Clear'); } // CapturePass:快照格式与相机颜色缓冲一致(HDR 时是浮点),不要深度、不要 MSAA。 // cameraColorTexture() 会先把多重采样缓冲解析(resolve)出来,读到的才是此刻的画面 enqueue(this.captureEvent, (fr) => { const rt = fr.getTemporaryRT('_FrameSnapshot', fr.width, fr.height, { color: fr.colorFormat }); fr.blit(fr.cameraColorTexture(), rt, E.blitMat, 'Copy'); snapshot = rt.texture; fr.bindCamera(); }, 'Cookbook Snapshot Capture'); // RestorePass:后处理之后、FinalBlit 之前,把快照贴回“当前”的相机颜色(后处理开启时就是后处理的输出) enqueue(RenderPassEvent.AfterRenderingPostProcessing, (fr) => { if (!snapshot) return; if (fr.postDone) fr.postOutput = snapshot; else { fr.blit(snapshot, 'camera', E.blitMat, 'Copy'); fr.bindCamera(); } }, 'Cookbook Snapshot Restore'); } }
这段代码里藏着三个在 URP 14 中写 Pass 的要点,都是实测踩出来的:
- 相机颜色缓冲要在 Execute 里现取。后处理开启时 URP 用 A/B 两张缓冲交替,在
SetupRenderPasses里缓存的句柄可能不是不透明 Pass 正在画的那一张。 - MSAA 下要显式解析。开了 MSAA,相机颜色缓冲是多重采样纹理,“解析(Resolve)”到普通纹理之前直接采样,读到的可能是上一次解析的旧内容。这里用
CommandBuffer.ResolveAntiAliasedSurface解析到快照里。 - 想看“不透明之后”的真实背景,得先清屏。前面说过天空盒模式下 URP 只清深度,所以 Feature 在
BeforeRenderingOpaques加了一个清成黑色的 Pass(clearColorBeforeOpaques),否则快照的背景里是上一帧的残影。

对照四张图:发光球在①②③里是一团刺眼的黄色,到了④才变成带辉光的橙色——前三张是 HDR 颜色缓冲里“原始的”线性值(超过 1 的部分在显示时被截断),色调映射之后才回到可显示的范围,Bloom 也是在后处理阶段才加上的。透明物体在③才出现,所以①②阶段拷贝出的 Opaque Texture 里不会有它们。
2.6 深度纹理从哪来:Prepass 还是 CopyDepth
_CameraDepthTexture 是 URP 里最常被 Shader 读取的纹理之一:软粒子、水的深浅、贴花、SSAO、深度描边、雾……它有两种生成方式:
- Depth Prepass:在画不透明物体之前,用每个物体 Shader 里的
DepthOnlyPass(需要法线时用DepthNormalsPass)单独把场景深度画一遍到_CameraDepthTexture。代价是不透明物体的顶点要多处理一遍、多一批 Draw Call。 - CopyDepth:不透明 Pass 画的时候顺便写了相机深度缓冲,画完之后把它复制一份到
_CameraDepthTexture。只多一次全屏拷贝,通常更便宜。
Universal Renderer 上的 Depth Texture Mode(CopyDepthMode)有三个选项:
| 选项 | 拷贝时机 | 适用 |
|---|---|---|
| After Opaques | AfterRenderingOpaques(300) | 透明物体需要读深度(水、软粒子、力场护盾)时必须用它 |
| After Transparents | AfterRenderingTransparents(500) | 只有后处理用深度时;把拷贝推迟到透明之后,对 TBDR 移动 GPU 更友好 |
| Force Prepass | 不拷贝,强制 Depth Prepass | 需要深度纹理在不透明 Pass 之前就可用时 |
UniversalRendererData 中 m_CopyDepthMode 的默认值是 After Transparents;而为了不破坏老工程,源码在反序列化旧版本资产时会把它改回 After Opaques(见 OnAfterDeserialize)。于是会出现“老项目里水面好好的,新建项目里一样的 Shader 却读不到深度”的诡异情况:在 After Transparents 模式下,透明物体渲染时 _CameraDepthTexture 里还不是当前帧的深度。遇到透明 Shader 读深度异常,先看这个设置。还有两个与深度相关的选项。Depth Priming Mode(Disabled/Auto/Forced):先用 Prepass 写满深度,不透明 Pass 再以 ZTest Equal 的方式着色,彻底消除不透明物体的过度绘制,适合片元很贵、Overdraw 很高的桌面场景;URP 源码里对移动平台标注“不推荐”(m_DepthPrimingRecommended = false),因为 TBDR/HSR 已经替你做了类似的事。另一个是 MSAA:开了 MSAA 的深度缓冲是多重采样的,拷贝前要解析,平台不支持深度解析时(例如 GLES)只能退回 Prepass——这就是为什么“开 MSAA 后 Frame Debugger 里突然多了一个 DepthPrepass”。
如果最终谁都不需要深度纹理,URP 会把 _CameraDepthTexture 设成一张代表“远平面”的 1×1 纹理(Reversed-Z 平台是黑色),Shader 读到的永远是“无限远”。
2.7 Opaque Texture 与中间颜色缓冲
_CameraOpaqueTexture 由 CopyColor Pass 在天空盒之后生成,内容是“不透明物体 + 天空”。URP Asset 的 Opaque Downsampling 决定它的分辨率:None(原尺寸)、2x Bilinear(默认,半分辨率)、4x Box、4x Bilinear。降采样既省带宽,又自带一点模糊,做磨砂玻璃时反而是优点;需要清晰折射时设为 None。
要“复制颜色缓冲”,前提是相机不是直接往屏幕上画,而是先画到一张中间颜色缓冲(URP 里叫 _CameraColorAttachmentA/B)。URP 会在很多情况下自动启用中间缓冲:开了 HDR、MSAA 需要显式解析、Render Scale 不为 1、开了后处理、需要 Opaque Texture、相机渲染到 RT 且需要后处理……(UniversalRenderer.RequiresIntermediateColorTexture);Renderer 上的 Intermediate Texture 选项为 Always(默认)时,只要有 Renderer Feature 也会启用。中间缓冲意味着最后要多一次 FinalBlit,这在移动端是一笔可见的带宽开销。
CreateRenderTextureDescriptor 在 camera.targetTexture != null 时,中间缓冲的格式直接沿用目标 RT 的格式,而不是按 HDR 设置选择浮点格式。也就是说,渲染到一张 8 位 RGBA 的 RT,即使 URP Asset 勾了 HDR,中间缓冲也是 8 位的,HDR 形同虚设。想在 RT 上得到 HDR 结果(比如做截图、做小地图后再加 Bloom),目标 RT 本身要用浮点格式。本书的配图程序就因此专门为 HDR 配图提供了浮点目标(见案例 2.8 的配图说明)。案例 2.2Depth Texture 与 Opaque Texture 的开关入门单独打开 ↗
目标:写一个极简水面,同时用到深度纹理(深浅颜色、岸边白沫)和不透明纹理(折射),然后分别关掉 URP Asset 里的两个开关,看看会发生什么。
原理:水面自己的观察深度就是 positionNDC.w(第 1 章:clip.w 即观察深度);从深度纹理里读出同一像素“水底”的观察深度,两者相减就是视线穿过的水的厚度。厚度越大越接近深水色,厚度接近 0 的地方就是水面与物体相交处,叠一圈白沫。折射则是用扰动过的屏幕 UV 采样 Opaque Texture。完整的水在第 19 章。
half4 frag(Varyings input) : SV_Target { float2 screenUV = input.positionNDC.xy / input.positionNDC.w; // 1) 深度纹理:水底(不透明物体)离相机多远 float sceneEye = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams); float surfaceEye = input.positionNDC.w; // 透视投影下 clip.w 就是水面自己的观察深度 float thickness = max(sceneEye - surfaceEye, 0); // 视线穿过的水的厚度(米) // 2) 不透明纹理:水面背后的画面,加一点随世界坐标变化的扰动 float2 p = input.positionWS.xz * _WaveScale; float2 wave = float2(sin(p.x + p.y * 0.7), cos(p.y * 1.3 - p.x * 0.4)); float2 refrUV = screenUV + wave * _Distortion * saturate(thickness); half3 behind = SampleSceneColor(refrUV); // 越深越接近深水色;离岸(厚度接近 0)的地方叠一圈白沫 half t = saturate(thickness / _DepthFade); half3 col = lerp(behind * _ShallowColor.rgb, _DeepColor.rgb, t); half foam = 1.0 - saturate(thickness / _FoamWidth); col = lerp(col, half3(0.95, 0.98, 1.0), foam); return half4(col, 1); }
#ifdef FRAGMENT void main() { vec2 screenUV = vPositionNDC.xy / vPositionNDC.w; // 1) 深度纹理:水底(不透明物体)离相机多远 float sceneEye = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams); float surfaceEye = vPositionNDC.w; // 透视投影下 clip.w 就是水面自己的观察深度 float thickness = max(sceneEye - surfaceEye, 0.0); // 视线穿过的水的厚度(米) // 2) 不透明纹理:水面背后的画面,加一点随世界坐标变化的扰动 vec2 p = vPositionWS.xz * _WaveScale; vec2 wave = vec2(sin(p.x + p.y * 0.7), cos(p.y * 1.3 - p.x * 0.4)); vec2 refrUV = screenUV + wave * _Distortion * saturate(thickness); vec3 behind = SampleSceneColor(refrUV); // 越深越接近深水色;离岸(厚度接近 0)的地方叠一圈白沫 float t = saturate(thickness / _DepthFade); vec3 col = mix(behind * _ShallowColor.rgb, _DeepColor.rgb, t); float foam = 1.0 - saturate(thickness / _FoamWidth); col = mix(col, vec3(0.95, 0.98, 1.0), foam); fragColor = vec4(col, 1); } #endif

中图最能说明问题:深度纹理关闭后,URP 把它设成了“远平面”,Shader 算出的厚度永远很大,于是浅滩、白沫、水下方块的轮廓统统消失。右图里,深度效果都还在,但透过水面看到的不再是水底,而是一片灰色——关掉 Opaque Texture 后这一帧不会写入 _CameraOpaqueTexture,Shader 读到什么取决于之前谁设置过这个全局纹理,可能是上一帧、上一台相机留下的旧图,也可能是引擎的默认纹理。为了让配图结果确定,拍摄脚本显式放了一张灰色占位图。实际项目里这种“读到旧图”的 bug 更隐蔽:画面看起来似乎对,换个机位或分辨率就出错。
ConfigureInput(ScriptableRenderPassInput.Depth) 等方式声明需求,URP 会按需生成。2.8 三种渲染路径:Forward、Forward+、Deferred
Universal Renderer 的 Rendering Path 决定了“光照在哪里算、怎么找到影响某个像素的灯”。URP 14 提供三种(RenderingMode 枚举:Forward = 0、Deferred = 1、ForwardPlus = 2):
Forward:每物体挑灯
前向渲染下,CPU 在剔除阶段为每个物体挑出影响它的附加光,按重要性排序,最多取 URP Asset 中 Per Object Limit 盏(默认 4,上限由 UniversalRenderPipeline.maxPerObjectLights 决定:GLES2 为 4,其他平台为 8),把索引通过 unity_LightIndices 传给 Shader,Shader 里循环这几盏灯。问题在于这个上限是“每物体”的:一块很大的地面只算一个物体,被二十盏灯照着,也只能挑 4 盏,其余的灯直接被忽略。
Forward+:按屏幕格子挑灯
Forward+ 是 URP 14 的新路径,把“挑灯”从“每物体”改成了“每像素所在的格子”。从 ForwardLights.cs 的实现看:CPU 用 Job 把屏幕切成 Tile(从 8 像素宽起步,若格子总数超出缓冲上限就翻倍),沿深度方向切成 Z-Bin(透视相机按 log2 深度划分),把每盏灯和每个反射探针登记到它覆盖的格子与深度区间里,结果以位掩码的形式上传。Shader 里定义 _FORWARD_PLUS 时,光照循环(LIGHT_LOOP_BEGIN/END,第 6 章)先查出本像素所在的 Tile 与 Z-Bin,再只遍历相关的灯。好处是没有每物体上限,可见附加光的总数上限也提高了:桌面平台 256、移动平台 32、低端 GLES 16(universal-config 包 ShaderConfig.cs 中的 k_MaxVisibleLightCount*)。附加的方向光不进格子,单独循环。
Deferred:先存材质、后算光照
延迟渲染把“着色”拆成两步。GBuffer Pass 只把每个像素的材质信息写进几张纹理,不算光照;然后光照 Pass 对每盏灯只处理它覆盖到的像素(URP 用模板缓冲标记点光、聚光的体积)。光照成本因此与场景几何复杂度解耦——几何再复杂,每盏灯每个像素只算一次。URP 14 的 GBuffer 布局(ShaderLibrary/UnityGBuffer.hlsl)如下:
| RT | R | G | B | A | 格式(DeferredLights.GetGBufferFormat) |
|---|---|---|---|---|---|
| GBuffer0 | Albedo(漫反射色) | 材质标志 | R8G8B8A8,线性空间工程为 sRGB | ||
| GBuffer1 | 金属度 / 高光色 | AO(遮蔽) | R8G8B8A8_UNorm | ||
| GBuffer2 | 世界空间法线(编码) | 光滑度 | 取决于 Accurate G-buffer normals | ||
| GBuffer3 | GI + 自发光(即相机颜色缓冲) | — | 同相机颜色格式 | ||
| GBuffer4+ | 可选:深度(Native RenderPass 时)、Rendering Layers、Shadowmask | ||||
代价也很明显:多张全分辨率 RT 带来巨大带宽,不支持 MSAA(源码中 supportedRenderingFeatures.msaa = false),不支持 OpenGL 系 API(运行时会退回 Forward),透明物体和无法写 GBuffer 的材质(Unlit、自定义光照模型)仍然要走前向:它们用 UniversalForwardOnly 或 SRPDefaultUnlit 标签,在延迟光照之后由 “Render Opaques Forward Only” Pass 绘制。
案例 2.3同一场景:Forward vs Forward+ vs Deferred入门单独打开 ↗
目标:用 24 盏彩色点光源照亮一块 16 米 × 16 米的地面和 15 个小球,依次切换三种渲染路径,直观对比。URP Asset 的每物体附加光上限保持默认值 4。
[BookShot("ch02-paths", W = 640, H = 420, Panels = 3)] static void Paths(Shot s) { RenderingMode[] modes = { RenderingMode.Forward, RenderingMode.ForwardPlus, RenderingMode.Deferred }; s.Renderer.renderingMode = modes[s.Panel]; // 相当于在 Universal Renderer Data 上切换 Rendering Path s.Asset.maxAdditionalLightsCount = 4; // URP Asset 默认的“每物体附加光上限” ManyLightsScene(s, false); // 24 盏彩色点光 + 15 个球 + 一整块地面 }

左图的地面上只有 4 团光斑,其余 20 盏灯“消失”了;而每个小球都比较亮——每个球是独立的物体,各自挑了离自己最近的 4 盏灯。这就是 Forward 每物体上限的典型症状:大物体丢灯。中、右两图几乎一模一样:Forward+ 与 Deferred 在“能照亮多少灯”上没有区别,它们的差异在性能特征和功能限制上,而不是画面上。
案例 2.4Forward 的每物体光源上限入门单独打开 ↗
目标:仍用 Forward 路径,分别尝试“把上限提到 8”和“把地面拆成 12×12 块”,理解每物体上限的含义。

上限提到 8,地面多了 4 团光斑,但离“全部 24 盏”还差得远,而且 8 已经是硬上限。把地面拆成小块后,每一块只被附近少数几盏灯照到,4 盏的额度绰绰有余,画面就和 Forward+ 一样了。这正是 Forward 时代的经典做法:大面积的地形、地面要切块。代价是 Draw Call 和剔除开销增加——Forward+ 从根本上消除了这个两难。
渲染路径怎么选
| 考量 | Forward | Forward+ | Deferred |
|---|---|---|---|
| 每物体光源数 | ≤ 8(默认 4) | 不限(总可见数有上限) | 不限 |
| MSAA | 支持 | 支持 | 不支持 |
| 相机栈 Overlay 相机 | 可用 | 可用 | Overlay 相机不能用 Deferred 的 Renderer |
| 自定义光照模型(卡通等) | 自由 | 自由(需支持 _FORWARD_PLUS) | 需 UniversalForwardOnly,不享受延迟光照 |
| 带宽 | 低 | 低 | 高(多张全屏 GBuffer) |
| 适合 | 移动端、灯少、风格化 | 多数项目的默认选择 | 桌面/主机、海量动态灯、需要 GBuffer 做屏幕空间效果 |
一个现实的提醒:自己写的 Shader 要同时支持 Forward 和 Forward+,就必须像 URP 的 Lit 那样声明 #pragma multi_compile _ _FORWARD_PLUS 并使用 LIGHT_LOOP_BEGIN/END 宏,否则在 Forward+ 下附加光会算错或者完全不显示。第 6 章会写一个完整的多光源 Shader。
2.9 LightMode 标签与 Pass 的对应
一个 Shader 可以有多个 Pass,URP 怎么知道在哪一步用哪个 Pass?靠 Pass 的 Tags { "LightMode" = "..." }。每个 URP 内置 Pass 在调用 DrawRenderers 时,会声明自己认领哪些 LightMode(源码里的 ShaderTagId)。下表整理自 URP 14 源码:
| LightMode | 由谁绘制 | 用途 |
|---|---|---|
UniversalForward | DrawOpaqueObjects / DrawTransparentObjects(Forward、Forward+) | 前向着色的主 Pass |
SRPDefaultUnlit(或不写 LightMode) | 同上;Deferred 下由 Forward Only Pass 绘制 | 无光照 Pass、额外的描边 Pass 等 |
UniversalForwardOnly | Forward/Forward+ 的不透明/透明绘制;Deferred 下的 Forward Only Pass | 不能走延迟光照的材质(Unlit、毛发、卡通) |
UniversalGBuffer | GBufferPass(Deferred) | 写 GBuffer |
ShadowCaster | MainLight / AdditionalLights ShadowCaster | 写阴影图(第 6 章) |
DepthOnly | DepthOnlyPass(Depth Prepass) | 只写深度 |
DepthNormals / DepthNormalsOnly | DepthNormalOnlyPass | 写深度与法线(SSAO、贴花等需要) |
MotionVectors | MotionVectorRenderPass | 逐物体运动矢量(TAA、运动模糊) |
Meta | 烘焙系统(编辑器) | 光照贴图烘焙时提取反照率、自发光(第 8 章) |
Universal2D | 2D Renderer | 2D 光照 |
| 其他任意名字 | URP 不认领 | 只有 RenderObjects 等 Feature 点名时才画 |
注意 DrawObjectsPass 认领的是一个列表:SRPDefaultUnlit、UniversalForward、UniversalForwardOnly(见 DrawObjectsPass.cs 构造函数)。如果一个 Shader 里同时有 UniversalForward Pass 和没写 LightMode 的 Pass,两个都会被画——这可以用来做“多 Pass”效果(例如外描边),但也是“物体被画了两遍”这类 bug 的常见来源。URP 源码注释也特别警告:“TO declare a material with unamed pass and UniversalForward/UniversalForwardOnly pass is an ERROR, as the material will be rendered twice.”(这条注释写在 Deferred 的上下文里,但在 Forward 下同样会画两遍。)
另外,Built-in 的 Always、ForwardBase、PrepassBase、Vertex 等旧标签,在 URP 里会被当作“不支持的旧 Shader”:在编辑器和 Development Build 中用洋红色的错误材质画出来,正式包里则干脆不画(RenderingUtils.m_LegacyShaderPassNames 与带 [Conditional] 的 RenderObjectsWithError)。
案例 2.5同一个 Shader 的三个 Pass,谁会被画?进阶单独打开 ↗
目标:写一个有三个 Pass 的 Shader:UniversalForward 做正常着色、一个不写 LightMode 的 Pass 做外描边、一个 LightMode 为自定义名字 CookbookXRay 的 Pass 做“透过墙看到”的 X 光效果,然后验证 URP 会画哪些。
Pass { Name "Forward" Tags { "LightMode"="UniversalForward" } HLSLPROGRAM #pragma vertex VertDefault #pragma fragment Frag half4 Frag(Varyings input) : SV_Target { float3 n = normalize(input.normalWS); Light light = GetMainLight(); half3 diffuse = light.color * saturate(dot(n, light.direction)); half3 ambient = SampleSH(n); return half4(_BaseColor.rgb * (diffuse + ambient), 1); } ENDHLSL } Pass { Name "Outline" // 没有 LightMode 标签 → URP 视为 "SRPDefaultUnlit" Cull Front HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag Varyings Vert(Attributes input) { Varyings output; // 沿法线把顶点往外推,只画背面,就得到一圈“外壳”描边(第 14 章细讲) float3 positionWS = TransformObjectToWorld(input.positionOS.xyz); float3 normalWS = TransformObjectToWorldNormal(input.normalOS); output.positionCS = TransformWorldToHClip(positionWS + normalWS * _OutlineWidth); output.normalWS = normalWS; return output; } half4 Frag(Varyings input) : SV_Target { return _OutlineColor; } ENDHLSL } Pass { Name "XRay" Tags { "LightMode"="CookbookXRay" } // 只在“被挡住”的地方画:深度比已有深度更远才通过 ZTest Greater ZWrite Off Blend SrcAlpha OneMinusSrcAlpha HLSLPROGRAM #pragma vertex VertDefault #pragma fragment Frag half4 Frag(Varyings input) : SV_Target { return _XRayColor; } ENDHLSL }
Pass { Name "Forward" Tags { "LightMode"="UniversalForward" } GLSLPROGRAM #ifdef VERTEX void main() { VertDefault(); } #endif #ifdef FRAGMENT void main() { vec3 n = normalize(normalWS); Light light = GetMainLight(); vec3 diffuse = light.color * saturate(dot(n, light.direction)); vec3 ambient = SampleSH(n); fragColor = vec4(_BaseColor.rgb * (diffuse + ambient), 1); } #endif ENDGLSL } Pass { Name "Outline" // 没有 LightMode 标签 → URP 视为 "SRPDefaultUnlit" Cull Front GLSLPROGRAM #ifdef VERTEX void main() { // 沿法线把顶点往外推,只画背面,就得到一圈“外壳”描边(第 14 章细讲) vec3 positionWS = TransformObjectToWorld(positionOS.xyz); vec3 nWS = TransformObjectToWorldNormal(normalOS); gl_Position = TransformWorldToHClip(positionWS + nWS * _OutlineWidth); normalWS = nWS; } #endif #ifdef FRAGMENT void main() { fragColor = _OutlineColor; } #endif ENDGLSL } Pass { Name "XRay" Tags { "LightMode"="CookbookXRay" } // 只在“被挡住”的地方画:深度比已有深度更远才通过 ZTest Greater ZWrite Off Blend SrcAlpha OneMinusSrcAlpha GLSLPROGRAM #ifdef VERTEX void main() { VertDefault(); } #endif #ifdef FRAGMENT void main() { fragColor = _XRayColor; } #endif ENDGLSL }
拍摄脚本分三格:第一格用 Material.SetShaderPassEnabled("SRPDefaultUnlit", false) 关掉描边 Pass(按 LightMode 关闭);第二格打开它;第三格再给 Renderer 加一个 URP 自带的 RenderObjects Feature,让它在 AfterRenderingOpaques 时点名绘制 LightMode 为 CookbookXRay 的 Pass。对应的编辑器操作是:在 Universal Renderer Data 上 ,Event 选 AfterRenderingOpaques,Filters 里 Queue 选 Opaque、Layer Mask 选 Everything,LightMode Tags 填 CookbookXRay。

右图的 X 光 Pass 用了 ZTest Greater:只有当它比深度缓冲里已有的深度“更远”(被挡住)时才通过,于是只有墙后的部分被染成蓝色。这个效果第 16 章还会用模板测试做得更精细。这里的重点是理解:LightMode 是 Shader 与管线之间的“接头暗号”。URP 认识的暗号,它会自动在对应的时机画;不认识的,就需要你用 Feature 去点名。
2.10 SRP Batcher:为什么材质属性必须放进 CBUFFER
1.2 节说过,Draw Call 的开销主要在 CPU 准备和上传数据。传统流程里,每画一个物体,CPU 都要把这个材质的属性(颜色、贴图缩放……)和这个物体的数据(矩阵等)组装进常量缓冲区,上传给 GPU。场景里有一千个物体,就组装上传一千次,哪怕其中九百个物体用的是同一个材质、材质属性一帧都没变过。
SRP Batcher 的思路是“能不传就不传”。它把每次绘制需要的常量数据拆成两类,分别对待:
- 材质数据(UnityPerMaterial):颜色、贴图缩放、粗糙度这类属性,同一个材质的所有物体共享,而且大多数帧里根本不变。SRP Batcher 为每个材质在 GPU 上开辟一块常驻的缓冲区,只有当你修改了材质属性时才重新上传。
- 逐物体数据(UnityPerDraw):模型矩阵、光照探针系数、每物体灯光索引等,每个物体不同,而且物体一动就要变。SRP Batcher 把所有物体的这部分数据放进一个大缓冲区,一次性批量更新,绘制时只告诉 GPU“这次用第几段”。
在此基础上,只要连续的几次绘制使用同一个 Shader 变体(哪怕是不同的材质),就不需要重新绑定 Shader,它们被合成一个“SRP Batch”。注意它与传统合批的区别:Draw Call 的数量并没有减少,减少的是每次 Draw Call 前 CPU 要做的准备工作,所以它对“材质多、物体多”的场景尤其有效。
要做到这一点,Shader 必须让引擎能“看懂”数据的布局:所有材质属性放在同一个名为 UnityPerMaterial 的常量缓冲里,并且每个 Pass 中这个缓冲的内容和顺序完全一致;引擎内置的逐物体属性(unity_ObjectToWorld、光照探针系数等)放在 UnityPerDraw 里——这一部分 URP 的 UnityInput.hlsl 已经替你声明好了,只要 include URP 的 Core.hlsl 就行。
案例 2.6兼容与不兼容 SRP Batcher 的 Shader入门单独打开 ↗
目标:对比两个功能完全相同的 Shader,一个兼容 SRP Batcher,一个不兼容,并学会检查兼容性。
// 纹理对象本身不进 CBUFFER,但它的 _ST(缩放平移)是数值,要进 TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; CBUFFER_END
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); float4 _BaseMap_ST; // ✗ 不在 UnityPerMaterial 里 half4 _BaseColor; // ✗ 不在 UnityPerMaterial 里
两个 Shader 都能正常编译、正常显示,区别只在声明位置。选中 Shader 资产,Inspector 里会显示 “SRP Batcher: compatible” 或 “not compatible” 以及原因。本书在批处理模式下读取了同一个检查结果(Shader Inspector 背后的接口),得到的原因原文是:
SrpBatcherCompatible : OK SrpBatcherBroken : Material property is found in another cbuffer than "UnityPerMaterial" (_BaseColor) LightModeDemo : OK (三个 Pass 共用同一个 UnityPerMaterial)
常见的不兼容原因:
- 材质属性写在 CBUFFER 外(本例);或者不同 Pass 的
UnityPerMaterial内容不一致(例如某个 Pass 用UsePass引用了别的 Shader 的 Pass,而两者的 CBUFFER 布局不同)。 - 纹理的
_ST、_TexelSize等数值属性忘了放进 CBUFFER。注意纹理对象本身(TEXTURE2D)不放进 CBUFFER。 - 不是 Shader 的问题,而是用法的问题:对 Renderer 使用了
MaterialPropertyBlock,该物体就退出 SRP Batcher;粒子系统等非 MeshRenderer/SkinnedMeshRenderer 的渲染器也不参与。
验证是否真正生效:打开 Frame Debugger,选中某个绘制事件,右侧会显示它是否属于一个 SRP Batch,以及“为什么没有和上一个合批”的原因(例如 “Node use different shader keywords”——不同的关键字组合是不同的 Shader 变体,无法合进同一个 SRP Batch)。第 29 章会系统地讨论 SRP Batcher、GPU Instancing 与静态合批的取舍。
2.11 相机栈:Base 与 Overlay
URP 的相机分两种 Render Type:Base 相机独立渲染,会清屏;Overlay 相机不能单独渲染,必须被加进某个 Base 相机的 Stack 列表,它在 Base 相机的结果上继续画,不清颜色,可选是否清深度(Clear Depth)。典型用途:第一人称武器(避免穿墙)、3D UI、小地图叠加。
源码里的几条规则(UniversalRenderPipeline.RenderCameraStack、UniversalRenderer.SupportedCameraStackingTypes):
- Stack 里的相机必须使用同类型的 Renderer,而且该 Renderer 必须支持 Overlay:Deferred 的 Universal Renderer 只支持 Base。Base 相机可以用 Deferred,但 Overlay 相机要指定一个 Forward/Forward+ 的 Renderer,否则会被跳过并打印警告。
- Overlay 相机的清除只有“清不清深度”:
GetCameraClearFlag对 Overlay 返回clearDepth ? DepthStencil : None,颜色永远不清。 - 只有栈中最后一台活动的相机会解析到最终目标;中间的相机即使开了后处理,结果也留在中间缓冲里供后面的相机继续画。
- 每多一台相机,就多一轮剔除、阴影(若需要)和绘制。Overlay 相机的 Culling Mask 要尽量窄。
案例 2.7第一人称手持物:清不清深度入门单独打开 ↗
目标:用 Overlay 相机渲染一个“拿在手里”的物体,对比 Clear Depth 关闭和开启的效果。
s.Cam.cullingMask = ~(1 << overlayLayer); // Base 相机不画“手持物”层 if (s.Panel == 0) return; // 叠加相机:只画手持物这一层,挂到 Base 相机的 Stack 里 var go = new GameObject("Overlay Camera"); go.transform.SetPositionAndRotation(s.Cam.transform.position, s.Cam.transform.rotation); var cam = go.AddComponent<Camera>(); cam.fieldOfView = s.Cam.fieldOfView; cam.nearClipPlane = 0.05f; cam.aspect = 640f / 420f; cam.cullingMask = 1 << overlayLayer; var data = go.AddComponent<UniversalAdditionalCameraData>(); data.renderType = CameraRenderType.Overlay; // clearDepth 在 URP 14 里只有 getter,Inspector 上的 Clear Depth 勾选框对应序列化字段 m_ClearDepth var so = new SerializedObject(data); so.FindProperty("m_ClearDepth").boolValue = s.Panel == 2; // 面板 1 不清深度,面板 2 清深度 so.ApplyModifiedPropertiesWithoutUndo(); s.CamData.cameraStack.Add(cam); var item = s.Knot(s.Lit(new Color(1f, 0.6f, 0.15f), 1f, 0.8f), Vector3.zero, 0.3f); item.layer = overlayLayer; item.transform.SetParent(s.Cam.transform, false); item.transform.localPosition = new Vector3(0.36f, -0.2f, 1.25f); item.transform.localRotation = Quaternion.Euler(20, 40, 0);

中图里,Overlay 相机沿用了 Base 相机的深度缓冲,于是手持物和场景做了正常的深度测试,右下部分被离相机很近的柱子挡住——第一人称游戏里“枪管插进墙里”就是这个样子。勾上 Clear Depth 后,Overlay 相机开始渲染前把深度清空,手持物永远画在场景之上。代码里通过 SerializedObject 修改 m_ClearDepth,是因为 URP 14 的 UniversalAdditionalCameraData.clearDepth 只有 getter;在编辑器里直接勾选 Overlay 相机 Rendering 面板中的 Clear Depth 即可。
2.12 Frame Debugger:亲眼看一帧
本章讲的所有顺序,都可以用 Frame Debugger 验证。它是 URP 开发中最常用的调试工具:
- 打开 ,点 Enable。Game 视图会冻结在当前帧(编辑器在 Play 和非 Play 状态下都能用)。
- 左侧是本帧的事件树。URP 的事件按 ProfilingScope 分组,名字来自源码里的
URPProfileId与各 Pass 的profilingSampler,例如 MainLightShadow、DrawOpaqueObjects、CopyDepth、CopyColor、DrawTransparentObjects、UberPostProcess、FinalBlit 等(不同版本可能略有差异)。 - 拖动滑块或点选事件,Game 视图会显示“画到这一步为止”的渲染目标内容——正是案例 2.1 用代码做的事。右上角可以切换查看的 RT(颜色的各通道、深度)。
- 选中一个绘制事件,右侧面板显示所用 Shader、Pass 名、LightMode、关键字、渲染状态(Blend/ZTest/Cull 等)、所有绑定的纹理与数值属性。“我的 Shader 到底用了哪个变体”“这张纹理绑上没有”这类问题,在这里一眼可见。
- 查看 SRP Batch 的合批信息与打断原因(2.10 节)。
养成习惯:写完一个 Shader 或 Feature,先去 Frame Debugger 里找到它的事件,确认它在你预期的时间点、画到了预期的目标上。很多“效果不对”的问题,其实是“根本没画”或者“画早了/画晚了”。更底层的 GPU 调试(RenderDoc、Xcode GPU Frame Capture)放在第 30 章。
2.13 URP Asset 设置解读
最后,把 URP Asset 上与渲染效果和性能关系最大的设置过一遍(字段名为 UniversalRenderPipelineAsset 中的序列化字段,括号内为默认值):
| 设置 | 作用 | 建议与注意 |
|---|---|---|
| Depth Texture(关) | 生成 _CameraDepthTexture | 见 2.6 节;可按相机覆盖 |
| Opaque Texture(关)/ Opaque Downsampling(2x Bilinear) | 生成 _CameraOpaqueTexture | 见 2.7 节;移动端注意额外的拷贝开销 |
| HDR(开)/ HDR Precision(32 Bits) | 中间颜色缓冲使用浮点格式 | 32 Bits 通常是 R11G11B10,没有 Alpha,极少数情况下蓝色有轻微色带;64 Bits 为 RGBA16F,更准但更贵 |
| Anti Aliasing (MSAA)(关) | 多重采样抗锯齿 2×/4×/8× | Deferred 下无效;TBDR 移动端开 2×/4× 相对划算;会影响深度拷贝方式 |
| Render Scale(1) | 按比例缩放渲染分辨率,最后放大到屏幕 | 移动端降到 0.7–0.85 是常见的性能手段;配合 Upscaling Filter(Automatic / Bilinear / Nearest-Neighbor / FSR 1.0) |
| Main Light(Per Pixel)/ Cast Shadows | 主光模式与阴影 | 主光是最亮的方向光(或 RenderSettings.sun 指定) |
| Additional Lights(Per Pixel)/ Per Object Limit(4) | 附加光模式;Forward 下每物体上限 | 上限最大 8;Forward+ 下上限不起作用 |
| Shadow Max Distance(50)/ Cascade Count(1) | 阴影可见距离与级联数 | 距离直接决定阴影精度,够用就好(第 23 章) |
| Depth Bias / Normal Bias(1 / 1) | 阴影偏移 | 治“阴影痤疮”与“彼得潘”现象(第 6 章) |
| Soft Shadows(关) | PCF 软阴影 | 有额外采样开销 |
| SRP Batcher(开) | 见 2.10 节 | 基本不应关闭 |
| Dynamic Batching(关) | CPU 合并小网格 | SRP Batcher 时代通常不需要 |
| Store Actions(Auto) | 控制 RT 的 Load/Store 策略 | 与 TBDR 带宽相关(第 1 章 1.9 节) |
Universal Renderer Data 上的关键设置:Rendering Path(Forward / Forward+ / Deferred)、Depth Priming Mode、Depth Texture Mode(2.6 节)、Opaque / Transparent Layer Mask(这个 Renderer 画哪些层)、Default Stencil State(覆盖模板状态)、Intermediate Texture(Auto / Always)、Native RenderPass(在支持的平台上用原生 RenderPass 合并 Pass,减少移动端带宽)以及 Renderer Features 列表。
案例 2.8MSAA、Render Scale 与 HDR 的直观对比入门单独打开 ↗
目标:在同一场景里只改 URP Asset 的一项设置,看清楚每个开关到底改变了什么。

左图的细栏杆呈现明显的锯齿和断续;中图 MSAA 4× 下边缘平滑。右图 Render Scale 降到 0.5,渲染像素只有四分之一,再放大到屏幕:边缘整体变糊、细栏杆变粗变虚,MSAA 也救不回来——它只能平滑“边缘”,无法补回丢失的分辨率。Render Scale 是“用清晰度换性能”最直接的旋钮,片元开销大致与像素数成正比。

三个球的自发光强度都远大于 1。HDR 关闭时,颜色缓冲是 8 位的,写入时超过 1 的部分被直接截断:橙色 (4, 1.2, 0.16) 被截成 (1, 1, 0.16),于是变成了黄色——截断不仅丢亮度,还改变色相,之后再做色调映射也找不回来。HDR 开启时,颜色缓冲保留了真实的数值,色调映射把它们“温柔地”压回显示范围,橙色依然是橙色,金属纽结上的高光也足够亮,能触发 Bloom。这就是“色调映射必须配合 HDR”的原因,第 9、27 章会从原理上展开。
2.14 与 Built-in 管线的差异速览
从 Built-in 迁移过来的读者,可以用下表把旧概念对应到 URP 的新位置(第 25–27 章会逐项展开):
| Built-in 中的做法 | URP 14 中的对应 | 备注 |
|---|---|---|
Camera.depthTextureMode |= DepthTextureMode.Depth | URP Asset / 相机的 Depth Texture;Pass 中 ConfigureInput | 生成方式见 2.6 节 |
GrabPass { } | Opaque Texture(_CameraOpaqueTexture) | 只有不透明物体与天空;时机固定在天空盒之后 |
OnRenderImage + Graphics.Blit | Volume 后处理;自定义 Renderer Feature + Blitter | URP 不调用 OnRenderImage |
Camera.AddCommandBuffer(CameraEvent…) | Renderer Feature 的 Pass + RenderPassEvent | 插入点见 2.4 节 |
| ForwardBase + ForwardAdd 多 Pass 光照 | 单 Pass 内循环附加光(Forward / Forward+) | 第 6 章 |
| Shader Replacement(替换着色器渲染) | RenderObjects 的 Override Material/Shader;DrawRenderers + overrideMaterial | 第 25 章 |
| 相机 Clear Flags = Depth Only 叠加多台相机 | 相机栈 Base + Overlay | 2.11 节 |
| Rendering Path:Forward / Deferred / Legacy Vertex Lit | Forward / Forward+ / Deferred | 2.8 节 |
最常见的迁移陷阱是:旧项目的脚本里依赖 OnRenderImage、OnPreRender、OnPostRender 这类相机回调,在 URP 下它们不会被调用(URP 提供的是 RenderPipelineManager.beginCameraRendering / endCameraRendering 等事件),效果“静悄悄地消失”,没有任何报错。迁移时先全局搜索这几个函数名,是个省时间的好习惯。
本章小结
- SRP 把渲染流程交给 C#:URP 每帧执行 Cull → AddRenderPasses → Setup → Execute → Submit;Renderer Feature 的 Pass 先入队,内置 Pass 挂在 “Before×××” 事件上,稳定排序保证了插入顺序可预期。
- URP Asset 管质量与开关,Universal Renderer 管流程,Renderer Feature 插入 Pass,Volume 管后处理参数;Quality 级别可覆盖默认的 URP Asset。
- 一帧的典型顺序:阴影 → (深度预渲染) → 不透明 → (CopyDepth) → 天空盒 → CopyColor → 透明 → (CopyDepth) → 后处理 → FinalBlit。
- Depth Texture 由 Prepass 或 CopyDepth 生成;Depth Texture Mode 在 URP 14 新建 Renderer 中默认 After Transparents,透明物体要读深度须改为 After Opaques。
- Forward 有每物体光源上限(默认 4、最大 8);Forward+ 按屏幕 Tile 与深度 Z-Bin 分配灯光,没有每物体上限;Deferred 光照与几何解耦,但不支持 MSAA、带宽大、Overlay 相机不可用。
- LightMode 是 Shader 与管线的接头暗号;SRP Batcher 要求材质属性全部放在
UnityPerMaterialCBUFFER 中且各 Pass 一致。 - 相机栈中 Overlay 相机不清颜色、可选清深度;Frame Debugger 是验证一切顺序与状态的第一工具。
练习
- 在 FrameSnapshotFeature 中加一个时刻
AfterRenderingPrePasses,并在 URP Asset 中打开 SSAO(Renderer Feature)。观察快照内容,并在 Frame Debugger 中找到 DepthNormals Prepass,解释为什么它出现了。 - 把案例 2.2 的 Renderer 改为 Depth Texture Mode = After Transparents,在编辑器 Play 模式下观察水面。再把同一个 Shader 的 Queue 改成
Geometry+1(但保持透明混合),会怎样?为什么? - 修改案例 2.3 的场景:把灯光数增加到 300 盏,分别在 Forward+ 与 Deferred 下观察。哪一种开始“丢灯”?结合
MAX_VISIBLE_LIGHTS解释。 - 给 LightModeDemo 的描边 Pass 也加上
Tags { "LightMode" = "SRPDefaultUnlit" },效果是否变化?再把 Renderer 切换成 Deferred,描边 Pass 和 Forward Pass 分别由谁绘制? - 阅读
UniversalRenderer.Setup中requiresDepthPrepass的计算过程,列出至少五种会导致出现 Depth Prepass 的情况。