Renderer Feature 与 RTHandle
前面二十多章,我们写的都是“挂在材质上的 Shader”:物体被画的时候它才运行。这一章换一个角度——不改任何物体的材质,而是往 URP 每一帧的渲染流程里插入自己的步骤:全屏改色、只给某一层物体描边、把一组物体单独画到一张纹理里再合成回来。做这些事的入口就是 Renderer Feature。
25.1 Renderer Feature 在 URP 里的位置
先回忆第 2 章的结构:项目用的 URP Asset 里有一个 Renderer 列表,每个相机选其中一个 Renderer Data(通常是 Universal Renderer Data)。运行时,URP 根据 Renderer Data 创建一个 ScriptableRenderer 对象(具体是 UniversalRenderer),由它决定这一帧要执行哪些 Pass:阴影、深度预渲染、不透明物体、天空盒、透明物体、后处理、最终拷贝……每个 Pass 都是一个 ScriptableRenderPass 子类的实例。
Renderer Data 的 Inspector 最下面有一个 按钮,这里添加的每一项都是一个 ScriptableRendererFeature。它本身不画任何东西,它的职责是:在每个相机渲染前,决定要不要往 Renderer 的 Pass 队列里塞几个自己的 Pass。URP 自带的 SSAO、Decal、Screen Space Shadows、Render Objects、Full Screen Pass 都是这样实现的——它们和你写的 Feature 地位完全相同。
和 Built-in 管线对比一下更容易理解。在 Built-in 里,往渲染流程中插东西靠 Camera.AddCommandBuffer(CameraEvent.xxx, cmd),或者在脚本里写 OnRenderImage。这两种写法在 URP 中都不再生效:URP 不调用 OnRenderImage,相机上挂的 CommandBuffer 也不会被执行。所有“插入渲染步骤”的需求,在 URP 里统一走 Renderer Feature。代价是写起来比 OnRenderImage 啰嗦;好处是插入点精确、能声明依赖(需要深度图、法线图)、能被 Frame Debugger 和 Profiler 正确标记,而且可以只对某个 Renderer(比如只对主相机用的那个)生效。
| Built-in 管线 | URP 14 对应写法 | 说明 |
|---|---|---|
OnRenderImage(src, dst) | Feature + Pass,事件 BeforeRenderingPostProcessing | URP 不调用 OnRenderImage |
Camera.AddCommandBuffer(CameraEvent.AfterForwardOpaque, cmd) | renderPassEvent = AfterRenderingOpaques | 相机上的 CommandBuffer 在 URP 中不执行 |
Graphics.Blit / cmd.Blit,源纹理叫 _MainTex | Blitter.BlitCameraTexture,源纹理叫 _BlitTexture | 全屏三角形,支持 RTHandle 缩放与 XR |
cmd.GetTemporaryRT / ReleaseTemporaryRT | RenderingUtils.ReAllocateIfNeeded + RTHandle.Release | 持有句柄跨帧复用,Dispose 时释放 |
Camera.depthTextureMode |= DepthNormals | ConfigureInput(Depth | Normal) | 由 Pass 声明需求,URP 决定如何生成 |
替换着色器 Camera.RenderWithShader | DrawRenderers + overrideMaterial | 见 25.5 节 |
25.2 一帧之内的调用顺序
写 Feature 最容易出错的地方不是 Shader,而是“这个东西该在哪个回调里做”。URP 14 给 Feature 和 Pass 提供了一串虚函数,它们的调用时机差别很大。下面先给出源码层面的顺序(摘自 URP 14 Runtime/UniversalRenderPipeline.cs 的 RenderSingleCamera 与 Runtime/ScriptableRenderer.cs 的 Execute,删去了与本题无关的代码):
// UniversalRenderPipeline.RenderSingleCamera(节选) renderer.Clear(cameraData.renderType); renderer.OnPreCullRenderPasses(in cameraData); // → 每个 Feature.OnCameraPreCull var cullResults = context.Cull(ref cullingParameters); InitializeRenderingData(asset, ref cameraData, ref cullResults, cmd, out var renderingData); renderer.AddRenderPasses(ref renderingData); // → 每个 Feature.AddRenderPasses renderer.Setup(context, ref renderingData); // UniversalRenderer 决定中间纹理、深度/法线预渲染、入队内置 Pass renderer.Execute(context, ref renderingData); // ScriptableRenderer.Execute(节选) m_IsPipelineExecuting = true; SetupRenderPasses(in renderingData); // → 每个 Feature.SetupRenderPasses InternalStartRendering(context, ref renderingData); // → 每个 Pass.OnCameraSetup SortStable(m_ActiveRenderPassQueue); // 按 renderPassEvent 稳定排序 foreach (var pass in activeRenderPassQueue) pass.Configure(cmd, cameraData.cameraTargetDescriptor); // …按事件分块,依次 ExecuteRenderPass → 每个 Pass.Execute InternalFinishRendering(...); // → Pass.OnCameraCleanup,栈中最后一个相机再调 OnFinishCameraStackRendering
把它画成时序图,并标出 Feature 级别只调用一次的 Create / Dispose:
下表把每个回调“适合做什么、不能做什么”列清楚,后面所有案例都按这张表来写:
| 回调 | 调用频率 | 适合做 | 不要做 |
|---|---|---|---|
Feature.Create | OnEnable、Inspector 改值(OnValidate)、Renderer 被创建时 | 创建材质、new 出 Pass 对象 | 假设它只调一次;分配和屏幕尺寸相关的 RT |
Feature.AddRenderPasses | 每相机每帧 | 按相机类型/设置/Volume 决定是否入队;设置 renderPassEvent;调用 ConfigureInput | 读取 cameraColorTargetHandle(会报错并返回 null) |
Feature.SetupRenderPasses | 每相机每帧 | 把 cameraColorTargetHandle / cameraDepthTargetHandle 交给 Pass | 在这里才 ConfigureInput(本帧的 Setup 已经算完,晚了一帧) |
Pass.OnCameraSetup | 每相机每帧(Execute 开始时,对队列里所有 Pass) | ReAllocateIfNeeded 分配 RT;ConfigureTarget / ConfigureClear | 录制绘制命令 |
Pass.Configure | 每相机每帧(排序之后) | 旧写法的兼容入口,作用同上 | ——新代码用 OnCameraSetup 即可 |
Pass.Execute | 每相机每帧 | 录制命令:Blit、DrawRenderers、SetGlobalXxx | 分配/释放持久资源 |
Pass.OnCameraCleanup | 每相机每帧 | 释放“只活一帧”的临时资源(如 cmd.GetTemporaryRT 的旧写法) | 释放 RTHandle(下一帧还要复用) |
Feature.Dispose | Renderer 被销毁时 | 释放所有 RTHandle、销毁材质 | ——忘了写就会泄漏显存 |
Create() 被调用的次数比直觉多。ScriptableRendererFeature 的 OnEnable 和 OnValidate 都直接调用 Create(),而 ScriptableRenderer 的构造函数又会对每个 Feature 调一次 Create()(见 ScriptableRenderer.cs 构造函数里的 feature.Create())。在 Inspector 里拖一下滑条就会触发一次。所以 Create() 里必须先清理上一次创建的材质和 Pass,否则每改一次参数泄漏一份。本章所有 Feature 的 Create() 第一行都是先 Dispose 旧资源。RenderPassEvent:Pass 插在哪里
renderPassEvent 决定 Pass 在队列里的位置。它是一个整数枚举,内置值之间刻意留了间隔(源码注释写明“Spaced built-in events so we can add events in between them”),所以可以写 RenderPassEvent.AfterRenderingOpaques + 1 这样的值,表示“紧跟在不透明之后、但在同一事件的其他 Pass 之后”。URP 用稳定排序(SortStable),事件值相同的 Pass 保持入队顺序——也就是 Renderer Data 里 Feature 列表的先后。
| 事件 | 值 | 此时画面上有什么 / 典型用途 |
|---|---|---|
BeforeRendering | 0 | 相机矩阵尚未设置;生成与相机无关的输入(LUT、自定义阴影图) |
BeforeRenderingShadows / AfterRenderingShadows | 50 / 100 | 阴影图前后 |
BeforeRenderingPrePasses / AfterRenderingPrePasses | 150 / 200 | 深度预渲染(Depth/DepthNormals Prepass)前后 |
BeforeRenderingGbuffer … AfterRenderingDeferredLights | 210–240 | 只在延迟路径下有意义 |
BeforeRenderingOpaques / AfterRenderingOpaques | 250 / 300 | 不透明物体前后;“重画某层物体”常用 300 |
BeforeRenderingSkybox / AfterRenderingSkybox | 350 / 400 | 天空盒前后 |
BeforeRenderingTransparents / AfterRenderingTransparents | 450 / 500 | 透明物体前后;描边、覆盖类效果常用 500 |
BeforeRenderingPostProcessing | 550 | 场景已画完,仍是 HDR 线性颜色;自定义后处理首选 |
AfterRenderingPostProcessing | 600 | URP 后处理(含色调映射)之后,颜色已是 LDR;风格化滤镜、UI 前的最终处理 |
AfterRendering | 1000 | 所有渲染结束(含最终拷贝) |
案例 25.1最小可用的 Feature:全屏反色、去色与染色入门单独打开 ↗
目标:写一个可以直接放进项目的全屏颜色效果:可调的反色、去色和染色。通过这个案例把“Feature + Pass + 全屏 Shader”的标准骨架完整走一遍,后面所有案例都在它的基础上变化。
原理:全屏效果的本质是“读相机颜色 → 每个像素算一次 → 写回相机颜色”。GPU 上一张纹理不能在同一次绘制里既当输入又当输出(结果未定义,在部分平台上会直接出错),所以要借一张临时 RT 中转:
先看 Shader。全屏 Pass 不需要自己写顶点着色器:core 包的 Runtime/Utilities/Blit.hlsl 已经提供了 Vert,它生成一个覆盖全屏的三角形,并把输入纹理统一命名为 _BlitTexture(25.4 节详细拆解)。我们只写片元着色器:
// 案例 25.1:最小的全屏效果——反色 / 去色 / 染色。 // 顶点着色器直接用 core 包 Blit.hlsl 里的 Vert(全屏三角形),输入纹理名固定为 _BlitTexture。 Shader "Cookbook/Ch25/Tint" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } // 全屏 Pass 的标配:不测深度、不写深度、不剔除 ZTest Always ZWrite Off Cull Off Pass { Name "Tint" HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" // Blit.hlsl 提供:_BlitTexture、_BlitScaleBias、Attributes/Varyings、Vert()、sampler_LinearClamp 等 #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" // 这些参数由 C# 每帧写入材质,不放 Properties 块 float4 _TintColor; // 乘色 float _Invert; // 0..1 反色程度 float _Desaturate; // 0..1 去色程度 half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv); // 1) 去色:用 Rec.709 亮度权重(相机颜色缓冲是线性空间) half luma = dot(color.rgb, half3(0.2126, 0.7152, 0.0722)); color.rgb = lerp(color.rgb, luma.xxx, _Desaturate); // 2) 反色:在 sRGB(感知)空间里做 1-x 才像“底片”,在线性空间直接反会整体发白 half3 srgb = LinearToSRGB(saturate(color.rgb)); half3 inverted = SRGBToLinear(1.0 - srgb); color.rgb = lerp(color.rgb, inverted, _Invert); // 3) 染色 color.rgb *= _TintColor.rgb; return color; } ENDHLSL } } }
// 案例 25.1:最小的全屏效果——反色 / 去色 / 染色。 // 顶点着色器对应 core 包 Blit.hlsl 里的 Vert(全屏三角形),输入纹理名固定为 _BlitTexture。 Shader "Cookbook/Ch25/Tint" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } // 全屏 Pass 的标配:不测深度、不写深度、不剔除 ZTest Always ZWrite Off Cull Off Pass { Name "Tint" GLSLPROGRAM // _BlitTexture、_BlitScaleBias 由引擎(Core.glsl)提供,相当于 Blit.hlsl VARYING vec2 texcoord; #ifdef VERTEX // Blit.hlsl 的 Vert:用 gl_VertexID 生成一个盖住全屏的大三角形 void main() { vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); texcoord = p * _BlitScaleBias.xy + _BlitScaleBias.zw; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); } #endif #ifdef FRAGMENT // 这些参数由 C# 每帧写入材质,不放 Properties 块 uniform vec4 _TintColor; // 乘色 uniform float _Invert; // 0..1 反色程度 uniform float _Desaturate; // 0..1 去色程度 vec4 Frag() { vec2 uv = texcoord; vec4 color = texture(_BlitTexture, uv); // 1) 去色:用 Rec.709 亮度权重(相机颜色缓冲是线性空间) float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); color.rgb = mix(color.rgb, vec3(luma), _Desaturate); // 2) 反色:在 sRGB(感知)空间里做 1-x 才像“底片”,在线性空间直接反会整体发白 vec3 srgb = LinearToSRGB(saturate(color.rgb)); vec3 inverted = SRGBToLinear(1.0 - srgb); color.rgb = mix(color.rgb, inverted, _Invert); // 3) 染色 color.rgb *= _TintColor.rgb; return color; } void main() { fragColor = Frag(); } #endif ENDGLSL } } }
反色这一步值得多看一眼。相机颜色缓冲在线性空间里(项目是 Linear 色彩空间),如果直接 1 - color,中灰 0.18 会变成 0.82,显示出来是接近白色的浅灰——整张图发白、发灰,不像底片。正确做法是先转到感知均匀的 sRGB 空间再取反,再转回线性:LinearToSRGB / SRGBToLinear 来自 core 的 Color.hlsl,Blit.hlsl 已经 include 了它。另外,HDR 颜色可能大于 1,取反前要先 saturate,否则会得到负数。
然后是 Feature。注意它只做三件事:建材质与 Pass、决定是否入队、把相机目标交给 Pass:
public class TintFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public RenderPassEvent passEvent = RenderPassEvent.BeforeRenderingPostProcessing; public Color tint = Color.white; [Range(0, 1)] public float invert = 1f; [Range(0, 1)] public float desaturate = 0f; } public Settings settings = new Settings(); // 在 Inspector 里拖入 Cookbook/Ch25/Tint。直接引用能保证打包时 Shader 不被剔除; // 留空时退回 Shader.Find(只在编辑器或 Shader 已被别处引用时可靠)。 public Shader shader; Material m_Material; TintPass m_Pass; // Create 在 OnEnable、以及 Inspector 每次改值(OnValidate)时都会被调用, // 所以这里必须先清理上一次创建的东西,否则每改一次参数就泄漏一份材质和 RT。 public override void Create() { m_Pass?.Dispose(); CoreUtils.Destroy(m_Material); if (shader == null) shader = Shader.Find("Cookbook/Ch25/Tint"); m_Material = shader != null ? CoreUtils.CreateEngineMaterial(shader) : null; m_Pass = new TintPass(m_Material); } // 每个相机、每帧调用一次:决定这一帧要不要把 Pass 放进队列 public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (m_Material == null) return; var camType = renderingData.cameraData.cameraType; // 材质球预览、反射探针烘焙用的相机不需要全屏效果 if (camType == CameraType.Preview || camType == CameraType.Reflection) return; m_Pass.renderPassEvent = settings.passEvent; m_Pass.Setup(settings); renderer.EnqueuePass(m_Pass); } // 相机的颜色/深度目标在这里才已分配好;在 AddRenderPasses 里取会报错 public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData) { m_Pass.SetTarget(renderer.cameraColorTargetHandle, renderer.cameraDepthTargetHandle); } // Renderer 被销毁(切换管线资产、改 Renderer 列表、退出)时调用 protected override void Dispose(bool disposing) { m_Pass?.Dispose(); CoreUtils.Destroy(m_Material); } }
// 案例 25.1 / 25.2:TintFeature 的网页版(对应 Ch25/TintFeature.cs)。 // 结构和 C# 一样:Feature 负责建材质、按 settings.passEvent 入队;Pass 负责“相机颜色 →(材质)→ 临时 RT → 拷回相机颜色”。 import { Material } from '../engine/shader.js'; import { RenderPassEvent } from '../engine/features.js'; export default class TintFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; this.data = data || {}; // C# Settings 的默认值 this.settings = { passEvent: RenderPassEvent.BeforeRenderingPostProcessing, tint: [1, 1, 1, 1], invert: 1, desaturate: 0, ...(this.data.settings || {}) }; } async init() { // Create():shader 留空时退回 Shader.Find("Cookbook/Ch25/Tint") const name = (this.data.shader && this.data.shader.shader) || 'Cookbook/Ch25/Tint'; this.material = new Material(await this.engine.shaders.get(name)); } addPasses(frame, enqueue) { if (!this.material) return; const s = this.settings, m = this.material; // TintPass.Setup:这些值在一帧内不变,直接写材质 // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector m.setVector('_TintColor', s.tint); m.setFloat('_Invert', s.invert); m.setFloat('_Desaturate', s.desaturate); enqueue(s.passEvent, (fr) => { // OnCameraSetup:临时 RT 和相机颜色同尺寸、同格式,不要 MSAA、不要深度 const temp = fr.getTemporaryRT('_Ch25TintTemp', fr.width, fr.height); // 不能边读边写同一张纹理:先“相机颜色 → 临时 RT(经过材质)”,再拷回 fr.blit(fr.cameraColorTexture(), temp, m, 0); fr.blit(temp.texture, 'camera'); // 恢复相机的颜色 + 深度目标 fr.bindCamera(); }, 'Ch25 Tint'); } }
逐段说明:
- Shader 引用:
public Shader shader让使用者在 Inspector 里直接拖入 Shader。这不只是方便——Shader 被资源引用后,打包时才会被包含进去;只靠Shader.Find的话,如果没有任何材质用到它,打包后会找不到。教学代码保留了Shader.Find兜底,正式项目建议拖引用,或者把 Shader 加进 。 - 跳过不需要的相机:
CameraType.Preview是材质球、模型预览窗口的相机,CameraType.Reflection是反射探针烘焙用的相机。全屏改色效果对它们没有意义,还会让预览窗口里的材质球也被反色。Scene 视图相机(CameraType.SceneView)要不要处理看需求,这里保留,方便在 Scene 视图里直接看到效果。 - AddRenderPasses 与 SetupRenderPasses 的分工:在
AddRenderPasses被调用时,UniversalRenderer 还没决定这一帧要不要创建中间颜色纹理,cameraColorTargetHandle没有意义。源码里的保护是这样的(摘自 URP 14ScriptableRenderer.cs):
public RTHandle cameraColorTargetHandle { get { if (!m_IsPipelineExecuting) { Debug.LogError("You can only call cameraColorTarget inside the scope of a ScriptableRenderPass. ..."); return null; } return m_CameraColorTarget.handle; } }
m_IsPipelineExecuting 在 ScriptableRenderer.Execute 的第一行才被置为 true,紧接着就是 SetupRenderPasses。所以 URP 14 的规矩是:相机目标只能在 SetupRenderPasses(或之后的 Pass 回调)里取。很多从 URP 12 迁移过来的代码在 AddRenderPasses 里写 renderer.cameraColorTarget,升级到 14 后就会刷屏报错。
再看 Pass:
class TintPass : ScriptableRenderPass { static readonly int s_TintColor = Shader.PropertyToID("_TintColor"); static readonly int s_Invert = Shader.PropertyToID("_Invert"); static readonly int s_Desaturate = Shader.PropertyToID("_Desaturate"); readonly Material m_Material; RTHandle m_CameraColor, m_CameraDepth; // 借来的,不归我们释放 RTHandle m_Temp; // 自己分配的,Dispose 里释放 public TintPass(Material material) { m_Material = material; profilingSampler = new ProfilingSampler("Ch25 Tint"); // Frame Debugger 里显示的名字 } public void Setup(TintFeature.Settings s) { // 这些值在一帧内不变,直接写材质即可 m_Material.SetColor(s_TintColor, s.tint); m_Material.SetFloat(s_Invert, s.invert); m_Material.SetFloat(s_Desaturate, s.desaturate); } public void SetTarget(RTHandle color, RTHandle depth) { m_CameraColor = color; m_CameraDepth = depth; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 临时 RT 和相机颜色同尺寸、同格式(HDR 时是浮点格式),但不要 MSAA、不要深度 var desc = renderingData.cameraData.cameraTargetDescriptor; desc.msaaSamples = 1; desc.depthBufferBits = 0; RenderingUtils.ReAllocateIfNeeded(ref m_Temp, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_Ch25TintTemp"); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { // 不能边读边写同一张纹理:先“相机颜色 → 临时 RT(经过材质)”,再拷回 Blitter.BlitCameraTexture(cmd, m_CameraColor, m_Temp, m_Material, 0); Blitter.BlitCameraTexture(cmd, m_Temp, m_CameraColor); // BlitCameraTexture 只绑定了颜色。URP 以为相机的颜色+深度还绑着, // 后面的天空盒/透明物体会直接画,所以由我们把深度一起恢复回去。 CoreUtils.SetRenderTarget(cmd, m_CameraColor, m_CameraDepth); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() { m_Temp?.Release(); m_Temp = null; } }
// 案例 25.1 / 25.2:TintFeature 的网页版(对应 Ch25/TintFeature.cs)。 // 结构和 C# 一样:Feature 负责建材质、按 settings.passEvent 入队;Pass 负责“相机颜色 →(材质)→ 临时 RT → 拷回相机颜色”。 import { Material } from '../engine/shader.js'; import { RenderPassEvent } from '../engine/features.js'; export default class TintFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; this.data = data || {}; // C# Settings 的默认值 this.settings = { passEvent: RenderPassEvent.BeforeRenderingPostProcessing, tint: [1, 1, 1, 1], invert: 1, desaturate: 0, ...(this.data.settings || {}) }; } async init() { // Create():shader 留空时退回 Shader.Find("Cookbook/Ch25/Tint") const name = (this.data.shader && this.data.shader.shader) || 'Cookbook/Ch25/Tint'; this.material = new Material(await this.engine.shaders.get(name)); } addPasses(frame, enqueue) { if (!this.material) return; const s = this.settings, m = this.material; // TintPass.Setup:这些值在一帧内不变,直接写材质 // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector m.setVector('_TintColor', s.tint); m.setFloat('_Invert', s.invert); m.setFloat('_Desaturate', s.desaturate); enqueue(s.passEvent, (fr) => { // OnCameraSetup:临时 RT 和相机颜色同尺寸、同格式,不要 MSAA、不要深度 const temp = fr.getTemporaryRT('_Ch25TintTemp', fr.width, fr.height); // 不能边读边写同一张纹理:先“相机颜色 → 临时 RT(经过材质)”,再拷回 fr.blit(fr.cameraColorTexture(), temp, m, 0); fr.blit(temp.texture, 'camera'); // 恢复相机的颜色 + 深度目标 fr.bindCamera(); }, 'Ch25 Tint'); } }
- 描述符:临时 RT 直接从
cameraData.cameraTargetDescriptor复制,保证尺寸(已乘以 Render Scale)和格式(HDR 时是浮点格式)与相机颜色一致;再去掉 MSAA 和深度——全屏后处理既不需要多重采样也不需要深度缓冲,留着只会白白占用显存和带宽。 - ReAllocateIfNeeded:每帧都调用,但只有描述符变化(比如窗口大小改变)时才真正重新分配,详见 25.3 节。
- 命令缓冲:URP 14 里
RenderingData内部有一个commandBuffer字段,但它是internal的,只有 URP 自己的 Pass 能用。我们的 Pass 用CommandBufferPool.Get()取一个,录完交给context.ExecuteCommandBuffer,再还回池子。 - ProfilingScope:让这段命令在 Frame Debugger 和 Profiler 里显示为 “Ch25 Tint”,名字来自构造函数里设置的
profilingSampler。 - 最后一行的恢复:这是很多教程漏掉的一步,案例 25.2 会用实验说明为什么必须有它。
[BookShot("ch25-tint", W = 640, H = 400, Panels = 3)] static void Tint(Shot s) { Stage(s); if (s.Panel == 0) return; // 第 1 格:不加 Feature var f = s.Feature<TintFeature>(); if (s.Panel == 1) { f.settings.invert = 1; f.settings.desaturate = 0; } else { f.settings.invert = 0; f.settings.desaturate = 1; f.settings.tint = new Color(1.25f, 0.95f, 0.65f); } }

在编辑器里使用:
- 在 Project 窗口选中项目使用的 Renderer Data(新建 URP 项目里通常叫
xxx_Renderer)。 - Inspector 最下方 。
- 把
Cookbook/Ch25/Tint拖到 Shader 槽位,调整 Settings。Game 视图和 Scene 视图会立即生效。 - 打开 ,可以看到 “Ch25 Tint” 下面挂着两次 Draw Procedural(两次 Blit)。
ScriptableRenderPass 还提供了一个 Blit(CommandBuffer cmd, ref RenderingData data, Material material, int passIndex),它利用 UniversalRenderer 内部的双缓冲(_CameraColorAttachmentA/B):从当前颜色缓冲画到另一块,再交换前后台,省掉了“拷回”这一步。它确实更省一次全屏拷贝,但依赖内部的 SwapColorBuffer 行为,调试时也不如显式的临时 RT 直观。本书统一使用显式写法,读者理解原理后可以自行替换。25.3 RTHandle:URP 14 的渲染纹理句柄
URP 12 之前,Pass 里分配临时纹理的标准写法是 cmd.GetTemporaryRT(id, desc) 加 RenderTargetIdentifier;URP 13 起全面换成了 RTHandle,URP 14 中旧接口大多标了 [Obsolete](例如 ScriptableRenderPass.ConfigureTarget(RenderTargetIdentifier))。RTHandle 来自 core 包(Runtime/Textures/RTHandle.cs),是对 RenderTexture 的一层包装,多了两样东西:
- 两种尺寸模式:显式尺寸(给定宽高)和缩放尺寸(相对于一个“参考尺寸”的比例或函数)。缩放模式下,RTHandle 系统按所有相机里最大的分辨率分配一次,较小的相机只用其中左下角的一部分,从而避免窗口大小频繁变化时反复分配。URP 在每个相机渲染前调用
RTHandles.SetReferenceSize(width, height)更新参考尺寸。 - 与采样相关的缩放信息:因为缩放模式下纹理可能只用了一部分,采样时 UV 要乘一个比例。
rtHandleProperties.rtHandleScale就是这个比例,Blitter.BlitCameraTexture会自动把它作为_BlitScaleBias传给 Shader。
在 Renderer Feature 里我们几乎总是用 RenderingUtils.ReAllocateIfNeeded 来管理 RTHandle。它的核心逻辑(摘自 URP 14 Runtime/RenderingUtils.cs):
public static bool ReAllocateIfNeeded(ref RTHandle handle, in RenderTextureDescriptor descriptor, FilterMode filterMode = FilterMode.Point, TextureWrapMode wrapMode = TextureWrapMode.Repeat, bool isShadowMap = false, int anisoLevel = 1, float mipMapBias = 0, string name = "") { TextureDesc requestRTDesc = RTHandleResourcePool.CreateTextureDesc(descriptor, TextureSizeMode.Explicit, ...); if (RTHandleNeedsReAlloc(handle, requestRTDesc, false)) { if (handle != null && handle.rt != null) AddStaleResourceToPoolOrRelease(currentRTDesc, handle); // 旧的放回池子或释放 if (UniversalRenderPipeline.s_RTHandlePool.TryGetResource(requestRTDesc, out handle)) return true; // 池子里有现成的就复用 handle = RTHandles.Alloc(descriptor, filterMode, wrapMode, isShadowMap, anisoLevel, mipMapBias, name); return true; } return false; // 描述符没变:什么都不做 }
要点:
- 传
ref:第一次调用时handle为 null,会被分配;之后每帧调用,只要描述符(宽高、格式、MSAA、深度位数、过滤、寻址、名字)没变就直接返回 false,开销很小。所以放在 OnCameraSetup 里每帧调用是正确用法。 - 默认过滤是
Point、默认寻址是Repeat。全屏效果几乎都应该显式传FilterMode.Bilinear与TextureWrapMode.Clamp:模糊、缩放需要双线性;Repeat 会让屏幕边缘采样“绕”到另一边,产生一条奇怪的亮边。 name会显示在 Frame Debugger 和 Memory Profiler 里,起个能认出来的名字,排查显存问题时会感谢自己。- 释放:自己分配的 RTHandle 在
Dispose里调用handle.Release()(等价于RTHandles.Release(handle))。相机颜色、深度这些从 Renderer 借来的 RTHandle 绝不能释放。
多相机时的一个细节
同一个 Pass 对象会被多个相机复用(Game 视图相机、Scene 视图相机、相机栈里的每个相机)。如果它们分辨率不同,ReAllocateIfNeeded 会在相机之间来回重新分配——URP 内部的 RTHandle 资源池(s_RTHandlePool)正是为了缓解这种抖动:被替换掉的旧 RT 先进池子,下一个相机要同样规格时直接取回。即便如此,编辑器里同时开 Game 和 Scene 视图时,Feature 的临时 RT 数量翻倍是正常现象,不是泄漏。
25.4 Blitter 与 Blit.hlsl:全屏绘制的标准件
Built-in 时代全屏绘制靠 Graphics.Blit / cmd.Blit,它们会画一个全屏四边形,并把源纹理绑到 _MainTex。在 URP 14 里这两个函数仍能用,但官方推荐改用 core 包的 Blitter(Runtime/Utilities/Blitter.cs),原因有三:它和 RTHandle 的缩放模式配合(自动处理 rtHandleScale);它支持 XR 的纹理数组(TEXTURE2D_X);它画的是一个覆盖全屏的大三角形而不是两个三角形组成的四边形,避免对角线上的像素被重复着色。
Blit.hlsl 里的 Vert 很短,正好看清所有约定(摘自 core 14 Runtime/Utilities/Blit.hlsl,删去了 GLES 分支):
TEXTURE2D_X(_BlitTexture); uniform float4 _BlitScaleBias; struct Attributes { uint vertexID : SV_VertexID; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct Varyings { float4 positionCS : SV_POSITION; float2 texcoord : TEXCOORD0; UNITY_VERTEX_OUTPUT_STEREO }; Varyings Vert(Attributes input) { Varyings output; UNITY_SETUP_INSTANCE_ID(input); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); float4 pos = GetFullScreenTriangleVertexPosition(input.vertexID); float2 uv = GetFullScreenTriangleTexCoord(input.vertexID); output.positionCS = pos; output.texcoord = uv * _BlitScaleBias.xy + _BlitScaleBias.zw; return output; }
从中可以读出写全屏 Shader 的全部约定:
- 输入纹理名固定是
_BlitTexture,用TEXTURE2D_X声明、SAMPLE_TEXTURE2D_X采样。_X后缀在非 XR 平台上就是普通 2D 纹理,在单通道立体渲染(Single Pass Instanced)下会变成纹理数组,片元着色器开头的UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input)负责选对眼睛。不做 XR 也照着写,代价为零。 - UV 在
Varyings.texcoord里,已经乘过_BlitScaleBias。GetFullScreenTriangleTexCoord内部按UNITY_UV_STARTS_AT_TOP翻转了 V,所以在 D3D/Metal/Vulkan 和 OpenGL 上都不需要再手动翻转。 - 采样器用
GlobalSamplers.hlsl里预定义的sampler_LinearClamp、sampler_PointClamp等,不用为_BlitTexture再声明一个sampler_BlitTexture(URP 自己的Blit.shader声明了,但你的 Shader 里不必)。 - 纹素大小:URP 的 Bloom.shader 里直接声明
float4 _BlitTexture_TexelSize;并使用。Unity 在通过属性块绑定纹理时会同时填写名字_TexelSize,本书的模糊、描边 Shader 也沿用这个做法。
C# 侧常用的几个重载:
| 调用 | 做了什么 |
|---|---|
Blitter.BlitCameraTexture(cmd, src, dst, mipLevel, bilinear) | 设置目标为 dst,用内置 Blit 材质原样拷贝(bilinear 选择点/双线性 Pass) |
Blitter.BlitCameraTexture(cmd, src, dst, material, pass) | 设置目标为 dst,用你的材质的第 pass 个 Pass 画全屏三角形,src 绑到 _BlitTexture |
Blitter.BlitTexture(cmd, src, scaleBias, material, pass) | 不切换目标,在当前已绑定的目标上画;适合“叠加到相机颜色上”的混合类 Pass |
Blitter.BlitCameraTexture(cmd, src, dst, loadAction, storeAction, material, pass) | 同上,额外指定加载/存储动作(移动端 TBDR 优化用) |
Blitter 内部用一个静态的 MaterialPropertyBlock 传 _BlitTexture 和 _BlitScaleBias。属性块在录制时被拷贝,所以多次 Blit 之间互不影响;但你自己用 material.SetFloat 设置的参数不一样——材质属性在命令真正执行时才被读取。同一帧内对同一材质多次 SetFloat,所有绘制都会看到最后一次的值。第 26 章的多次迭代模糊会专门处理这个问题(用 cmd.SetGlobalFloat)。案例 25.2生命周期探针与 RenderPassEvent 实验入门单独打开 ↗
目标:亲眼看到 25.2 节的调用顺序;再用同一个“去色” Pass 放在三个不同事件上,观察插入点对结果的影响,顺便理解案例 25.1 最后那行“恢复渲染目标”的必要性。
探针 Feature 什么也不画,只在每个回调里打印一行:
public override void Create() { Log("Feature.Create"); m_Pass = new ProbePass(); } public override void OnCameraPreCull(ScriptableRenderer renderer, in CameraData cameraData) { s_Frame++; Log($"Feature.OnCameraPreCull camera={cameraData.camera.name}"); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { Log("Feature.AddRenderPasses"); m_Pass.renderPassEvent = passEvent; m_Pass.ConfigureInput(ScriptableRenderPassInput.Depth); // 必须在这里声明输入 renderer.EnqueuePass(m_Pass); } public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData) { // 此时相机目标已分配,可以安全读取 Log($"Feature.SetupRenderPasses color={renderer.cameraColorTargetHandle.name}"); } protected override void Dispose(bool disposing) => Log("Feature.Dispose"); class ProbePass : ScriptableRenderPass { public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) => Log(" Pass.OnCameraSetup"); public override void Configure(CommandBuffer cmd, RenderTextureDescriptor desc) => Log($" Pass.Configure {desc.width}x{desc.height} msaa={desc.msaaSamples}"); public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) => Log($" Pass.Execute event={renderPassEvent}"); public override void OnCameraCleanup(CommandBuffer cmd) => Log(" Pass.OnCameraCleanup"); public override void OnFinishCameraStackRendering(CommandBuffer cmd) => Log(" Pass.OnFinishCameraStackRendering"); }
// 案例 25.2:生命周期探针的网页版(对应 Ch25/LifecycleProbeFeature.cs)。 // 它什么也不画,只把回调顺序打到浏览器控制台(console.debug),用来观察 Feature / Pass 在一帧里被调用的顺序。 import { RenderPassEvent } from '../engine/features.js'; let s_Frame = 0; const log = (s) => console.debug(`[帧${s_Frame}] ${s}`); export default class LifecycleProbeFeature { constructor(engine, data) { this.passEvent = (data && data.passEvent) ?? RenderPassEvent.AfterRenderingOpaques; log('Feature.Create'); } addPasses(frame, enqueue) { s_Frame++; log(`Feature.OnCameraPreCull camera=${frame.camera.json.name || 'Camera'}`); log('Feature.AddRenderPasses'); frame.requireDepth = true; // ConfigureInput(ScriptableRenderPassInput.Depth) log('Feature.SetupRenderPasses color=_CameraColorAttachment'); enqueue(this.passEvent, (fr) => { log(' Pass.OnCameraSetup'); log(` Pass.Configure ${fr.width}x${fr.height} msaa=${fr.msaa}`); log(` Pass.Execute event=${this.passEvent}`); log(' Pass.OnCameraCleanup'); }, 'Ch25 Lifecycle Probe'); } }
下面是配图脚本渲染一张图(连续渲染 3 帧)时真实写出的日志,删去了重复的第 2 帧:
[帧0] Feature.Create [帧0] Feature.Create [帧1] Feature.OnCameraPreCull camera=Main Camera [帧1] Feature.AddRenderPasses [帧1] Feature.SetupRenderPasses color=_CameraColorAttachmentA [帧1] Pass.OnCameraSetup [帧1] Pass.Configure 640x400 msaa=1 [帧1] Pass.Execute event=AfterRenderingOpaques [帧1] Pass.OnCameraCleanup [帧1] Pass.OnFinishCameraStackRendering …… [帧3] Pass.OnFinishCameraStackRendering [帧3] Feature.Dispose
几个观察:
Create被调用了两次:一次来自ScriptableObject.CreateInstance触发的OnEnable,一次来自 Renderer 构造函数。这验证了“Create 不只调一次”。- 相机颜色目标的名字是
_CameraColorAttachmentA:UniversalRenderer 的颜色缓冲是 A/B 两块(RenderTargetBufferSystem),后处理会在两块之间切换,所以每帧都要重新取cameraColorTargetHandle,不能在第一帧缓存起来一直用。 OnCameraSetup在Configure之前;两者都在同一帧的Execute之前完成,此时所有 Pass 都已入队。- 单相机(无相机栈)时,
OnFinishCameraStackRendering每帧都会调用——单个相机本身就是“只有一个成员的相机栈”。
接下来是事件实验。场景里有不透明物体、天空和两个半透明物体(青色球、品红方块),把案例 25.1 的 Feature 设为 desaturate = 1,只改 passEvent:

左图能正确显示,依赖的正是 Pass 最后那一行 CoreUtils.SetRenderTarget(cmd, color, depth)。原因藏在 ScriptableRenderer.SetRenderPassAttachments 里:Renderer 会记住“当前绑定的是哪张颜色、哪张深度”(m_ActiveColorAttachments、m_ActiveDepthAttachment),下一个 Pass 要用的目标如果和记录的一样,就不再重新绑定。而 Blitter.BlitCameraTexture 内部用 CoreUtils.SetRenderTarget(cmd, destination) 只绑定了颜色——深度被解绑了,但 Renderer 不知道。如果不恢复,紧接着的天空盒 Pass 会在“没有深度缓冲”的状态下绘制,结果是天空盖住整个画面;透明物体也失去深度测试,会画到不透明物体前面。
✓ 推荐
自己切换过渲染目标的 Pass,结束前把目标恢复成相机颜色+深度;或者用ConfigureTarget 明确告诉 Renderer 本 Pass 画到哪里(案例 25.5、25.6 的做法),让 Renderer 自己负责后续的重新绑定。✗ 避免
在 Execute 里随意cmd.SetRenderTarget 后直接返回。效果放在 BeforeRenderingPostProcessing 时也许看不出问题(后处理会自己绑定目标),换一个事件就出错,而且这种错很难从现象倒推原因。案例 25.3不写 C#:Full Screen Pass Renderer Feature入门单独打开 ↗
目标:学会使用 URP 14 自带的 FullScreenPassRendererFeature,并为它手写两个兼容的 Shader:一个读屏幕颜色的“夜视仪”,一个读深度图的“距离雾”。
原理:这个 Feature 的源码只有两百多行(位于 Runtime/RendererFeatures 目录下的 FullScreenPassRendererFeature.cs),做的事和案例 25.1 几乎一样:如果勾选了 fetchColorBuffer,先把相机颜色拷一份到 _FullscreenPassColorCopy,然后用你的材质在相机颜色上画一个全屏三角形。关键的执行部分如下(摘自 URP 14 源码):
private static void ExecuteMainPass(CommandBuffer cmd, RTHandle sourceTexture, Material material, int passIndex) { s_SharedPropertyBlock.Clear(); if (sourceTexture != null) s_SharedPropertyBlock.SetTexture(ShaderPropertyId.blitTexture, sourceTexture); // _BlitTexture // We need to set the "_BlitScaleBias" uniform for user materials with shaders relying on core Blit.hlsl to work s_SharedPropertyBlock.SetVector(ShaderPropertyId.blitScaleBias, new Vector4(1, 1, 0, 0)); cmd.DrawProcedural(Matrix4x4.identity, material, passIndex, MeshTopology.Triangles, 3, 1, s_SharedPropertyBlock); }
源码注释说得很清楚:它把拷贝出来的颜色绑定为 _BlitTexture,并设置 _BlitScaleBias,专门为了让“基于 core Blit.hlsl 的用户 Shader”能直接工作。这正是我们手写 Shader 的方式——和案例 25.1 的 Shader 完全同构,Vert 直接用 Blit.hlsl 的。Shader Graph 的 Fullscreen 目标生成的也是同样的约定,所以手写版和 Shader Graph 版可以互换。
Inspector 上的几个设置:
| 字段 | 含义 |
|---|---|
| Injection Point | 只有三个选项:Before Rendering Transparents、Before Rendering Post Processing、After Rendering Post Processing(枚举值直接映射到对应的 RenderPassEvent) |
| Fetch Color Buffer | 是否先拷贝相机颜色作为 _BlitTexture。只做“叠加/混合”而不读屏幕时可以关掉,省一次全屏拷贝 |
| Requirements | 需要的输入:Depth、Normal、Color、Motion。它直接传给 ConfigureInput |
| Pass Material / Pass Index | 用哪个材质的哪个 Pass |
| Bind Depth Stencil Attachment | 绘制时是否同时绑定相机深度/模板,打开后材质里的 ZTest、Stencil 才有效 |
夜视仪的片元着色器:亮度放大并压缩、哈希噪点、扫描线、圆形镜筒暗角,全部是逐像素的独立计算:
half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half3 c = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv).rgb; // 亮度放大(模拟像增强管),再压一条曲线防止大片死白 half luma = dot(c, half3(0.2126, 0.7152, 0.0722)) * _Gain; luma = luma / (1.0 + luma); // 颗粒噪点:按像素坐标取哈希,_Time 让它在运行时闪动(截图时 _Time≈0) float2 pixel = uv * _ScreenParams.xy; half grain = Hash12(pixel + frac(_Time.y * 13.0) * 517.0) - 0.5; luma = saturate(luma + grain * 0.12); // 扫描线:每 3 像素一条暗线 half scan = 1.0 - _ScanlineStrength * step(2.0, fmod(pixel.y, 3.0)); // 圆形镜筒暗角(按宽高比校正成正圆) float2 d = (uv - 0.5) * float2(_ScreenParams.x / _ScreenParams.y, 1.0); half vignette = smoothstep(_VignetteRadius, _VignetteRadius - 0.25, length(d)); half3 result = _Tint.rgb * luma * scan * vignette; return half4(result, 1.0); }
vec4 Frag() { vec2 uv = texcoord; vec3 c = texture(_BlitTexture, uv).rgb; // 亮度放大(模拟像增强管),再压一条曲线防止大片死白 float luma = dot(c, vec3(0.2126, 0.7152, 0.0722)) * _Gain; luma = luma / (1.0 + luma); // 颗粒噪点:按像素坐标取哈希,_Time 让它在运行时闪动(截图时 _Time≈0) vec2 pixel = uv * _ScreenParams.xy; float grain = Hash12(pixel + fract(_Time.y * 13.0) * 517.0) - 0.5; luma = saturate(luma + grain * 0.12); // 扫描线:每 3 像素一条暗线(pixel.y 为正,mod 与 HLSL 的 fmod 结果相同) float scan = 1.0 - _ScanlineStrength * step(2.0, mod(pixel.y, 3.0)); // 圆形镜筒暗角(按宽高比校正成正圆) vec2 d = (uv - 0.5) * vec2(_ScreenParams.x / _ScreenParams.y, 1.0); float vignette = smoothstep(_VignetteRadius, _VignetteRadius - 0.25, length(d)); vec3 result = _Tint.rgb * luma * scan * vignette; return vec4(result, 1.0); }
距离雾需要深度图:include URP 的 DeclareDepthTexture.hlsl,用 SampleSceneDepth 读原始深度,再用 LinearEyeDepth(raw, _ZBufferParams) 转成观察空间的线性距离(单位:米)。_ZBufferParams 已经考虑了 Reversed-Z,所以这段代码在各平台上通用:
half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv); // 原始深度 → 观察空间线性深度(米)。Reversed-Z 的差异由 _ZBufferParams 处理 float rawDepth = SampleSceneDepth(uv); float eyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams); // 指数雾:f = 1 - e^(-density * 距离) float dist = max(eyeDepth - _FogStart, 0.0); half fog = 1.0 - exp(-_FogDensity * dist); color.rgb = lerp(color.rgb, _FogColor.rgb, fog); return color; }
vec4 Frag() { vec2 uv = texcoord; vec4 color = texture(_BlitTexture, uv); // 原始深度 → 观察空间线性深度(米)。Reversed-Z 的差异由 _ZBufferParams 处理 float rawDepth = SampleSceneDepth(uv); float eyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams); // 指数雾:f = 1 - e^(-density * 距离) float dist = max(eyeDepth - _FogStart, 0.0); float fog = 1.0 - exp(-_FogDensity * dist); color.rgb = mix(color.rgb, _FogColor.rgb, fog); return color; }
用代码配置这个 Feature(Inspector 里的操作完全对应):
[BookShot("ch25-fullscreen", W = 640, H = 400, Panels = 3)] static void FullScreen(Shot s) { Stage(s); // 加一排远处的柱子,让深度雾有层次 for (int i = 0; i < 6; i++) s.Cylinder(s.Lit(new Color(0.8f, 0.78f, 0.72f), 0, 0.3f), new Vector3(-5f + i * 2f, 1.5f, 4f + i * 2.5f), 1f) .transform.localScale = new Vector3(0.6f, 1.5f, 0.6f); if (s.Panel == 0) return; var f = s.Feature<FullScreenPassRendererFeature>(); if (s.Panel == 1) { f.passMaterial = s.Mat("Cookbook/Ch25/FullscreenNightVision"); f.injectionPoint = FullScreenPassRendererFeature.InjectionPoint.AfterRenderingPostProcessing; f.fetchColorBuffer = true; // 需要读屏幕颜色 f.requirements = ScriptableRenderPassInput.None; } else { f.passMaterial = s.Mat("Cookbook/Ch25/FullscreenDepthFog"); f.injectionPoint = FullScreenPassRendererFeature.InjectionPoint.BeforeRenderingPostProcessing; f.fetchColorBuffer = true; f.requirements = ScriptableRenderPassInput.Depth; // 需要 _CameraDepthTexture } }

_CameraDepthTexture 在这个时间点之前已经生成(必要时会插入 Depth Prepass 或 Copy Depth)。如果忘了勾,在 URP Asset 打开了 Depth Texture 的项目里效果看起来也“正常”,换到一个关闭了 Depth Texture 的平台配置就全错——这是典型的“在我电脑上没问题”。Full Screen Pass 的局限也很明显:只能单 Pass、只有三个插入点、不能分配自己的中间 RT、不能读 Volume。多 Pass 的模糊、Bloom,以及要接 Volume 的后处理,都需要像第 26 章那样自己写 Feature。
案例 25.4Render Objects:把某个 Layer 再画一遍入门单独打开 ↗
目标:用 URP 自带的 RenderObjects Feature,在不改物体材质的前提下,给某个 Layer 的物体叠加一层菲涅尔边缘光(例如“可交互物体高亮”)。
原理:RenderObjects 本质上就是一个可配置的 DrawRenderers 调用:在指定事件上,筛选指定 Layer、指定队列(Opaque/Transparent)、指定 LightMode 标签的物体,用原材质或替换材质再画一遍,还可以覆盖深度测试、模板测试,甚至临时修改相机的 FOV 和偏移。第 16 章用它做了“被遮挡部分显示轮廓色”(深度测试设为 Greater),这里换一个用法:同深度叠加。
替换材质用的 Shader:在不透明 Pass 写下的深度上再画一次,ZTest LEqual 让它恰好通过;Blend One One 加法混合,只增亮不遮盖:
// 案例 25.4:给 RenderObjects 当 Override Material 用的“叠加边缘光”。 // 物体已经在不透明 Pass 里画过一次,这里在同一深度上再画一遍,用加法混合叠上菲涅尔边缘光。 Shader "Cookbook/Ch25/RimOverlay" { Properties { [HDR] _RimColor ("Rim Color", Color) = (0.2, 0.8, 1.6, 1) _RimPower ("Rim Power", Range(0.5, 8)) = 2.5 _FillAlpha ("Fill", Range(0, 1)) = 0.12 } SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" } Pass { Name "RimOverlay" Tags { "LightMode"="SRPDefaultUnlit" } Blend One One // 加法:只增亮,不遮盖原来的颜色 ZWrite Off ZTest LEqual // 与自己在不透明 Pass 写下的深度相等,能通过 HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) float4 _RimColor; float _RimPower; float _FillAlpha; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionCS : SV_POSITION; float3 normalWS : TEXCOORD0; float3 positionWS : TEXCOORD1; }; Varyings vert(Attributes v) { Varyings o; VertexPositionInputs p = GetVertexPositionInputs(v.positionOS.xyz); o.positionCS = p.positionCS; o.positionWS = p.positionWS; o.normalWS = TransformObjectToWorldNormal(v.normalOS); return o; } half4 frag(Varyings i) : SV_Target { float3 n = normalize(i.normalWS); float3 v = GetWorldSpaceNormalizeViewDir(i.positionWS); half rim = pow(1.0 - saturate(dot(n, v)), _RimPower); return half4(_RimColor.rgb * (rim + _FillAlpha), 1.0); } ENDHLSL } } }
// 案例 25.4:给 RenderObjects 当 Override Material 用的“叠加边缘光”。 // 物体已经在不透明 Pass 里画过一次,这里在同一深度上再画一遍,用加法混合叠上菲涅尔边缘光。 Shader "Cookbook/Ch25/RimOverlay" { SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" } Pass { Name "RimOverlay" Tags { "LightMode"="SRPDefaultUnlit" } Blend One One // 加法:只增亮,不遮盖原来的颜色 ZWrite Off ZTest LEqual // 与自己在不透明 Pass 写下的深度相等,能通过 GLSLPROGRAM uniform vec4 _RimColor; uniform float _RimPower; uniform float _FillAlpha; VARYING vec3 normalWS; VARYING vec3 positionWS; #ifdef VERTEX void main() { VertexPositionInputs p = GetVertexPositionInputs(positionOS.xyz); gl_Position = p.positionCS; positionWS = p.positionWS; normalWS = TransformObjectToWorldNormal(normalOS); } #endif #ifdef FRAGMENT void main() { vec3 n = normalize(normalWS); vec3 v = GetWorldSpaceNormalizeViewDir(positionWS); float rim = pow(1.0 - saturate(dot(n, v)), _RimPower); fragColor = vec4(_RimColor.rgb * (rim + _FillAlpha), 1.0); } #endif ENDGLSL } } }
[BookShot("ch25-renderobjects", W = 640, H = 400, Panels = 2)] static void RenderObjectsShot(Shot s) { Stage(s, LayerA); // 纽结和红球放到第 8 层 if (s.Panel == 0) return; var f = s.Feature<RenderObjects>(); f.settings.Event = RenderPassEvent.AfterRenderingOpaques; f.settings.filterSettings.RenderQueueType = RenderQueueType.Opaque; f.settings.filterSettings.LayerMask = 1 << LayerA; f.settings.overrideMode = RenderObjects.RenderObjectsSettings.OverrideMaterialMode.Material; f.settings.overrideMaterial = s.Mat("Cookbook/Ch25/RimOverlay"); f.settings.overrideMaterialPassIndex = 0; // RenderObjects 只在 Create() 里把 settings 转成 Pass 参数。这里 Renderer 尚未创建, // 它创建时会调用每个 Feature 的 Create();若 Renderer 已在运行,改完 settings 必须手动再调一次。 f.Create(); }

几个容易踩的点:
- settings 只在 Create 里生效。
RenderObjects.Create()用 settings 构造RenderObjectsPass,之后AddRenderPasses只是把它入队。在 Inspector 里改值会触发OnValidate → Create,所以感觉不到;但运行时用代码改settings后,必须手动调用Create(),否则不生效。 - Filter 里的 LightMode(PassNames)留空时,默认匹配
SRPDefaultUnlit、UniversalForward、UniversalForwardOnly。替换材质时,这个过滤看的是物体原来的Shader 有没有这些 Pass,而真正绘制用的是替换材质的第overrideMaterialPassIndex个 Pass。 - 不想让物体被画两次:如果目标是“这一层只用 RenderObjects 画”(比如第一人称武器要用另一个 FOV 画),要在 Renderer Data 的 里把这一层去掉。
- 事件不能早于 BeforeRenderingPrePasses:源码在
Create里会把更早的事件强行改成 BeforeRenderingPrePasses,因为那之前相机矩阵还没设置。
25.5 DrawRenderers:自己写“重画某些物体”的 Pass
RenderObjects 能做的,都可以用 context.DrawRenderers 自己写,而且自由得多:可以画到自己的 RT 里、可以和别的 Pass 组合、可以在 Execute 里按条件切换。它需要三样东西:
- DrawingSettings:画哪些 Pass(一组
ShaderTagId,对应 Shader 里 Pass 的LightMode标签)、怎么排序(SortingCriteria,不透明一般用CommonOpaque或相机的defaultOpaqueSortFlags,透明用CommonTransparent),以及可选的替换材质overrideMaterial。用 Pass 自带的CreateDrawingSettings(tags, ref renderingData, sorting)创建,它会顺便填好每物体数据(光照探针、光照贴图等)的配置。 - FilteringSettings:画哪些物体——
RenderQueueRange(opaque = 0~2500,transparent = 2501~5000,all)、layerMask,还可以按renderingLayerMask过滤。 - RenderStateBlock(可选):覆盖渲染状态,例如深度测试、深度写入、模板、混合。它比在 Shader 里写死更灵活:同一个替换材质,可以在 C# 里决定用哪种深度测试。
还有两个前提:被画的物体必须在本相机的剔除结果(renderingData.cullResults)里——也就是相机的 Culling Mask 包含它所在的层;以及调用 context.DrawRenderers 之前,要先把命令缓冲里已录制的命令 ExecuteCommandBuffer 出去,保证顺序正确(URP 自己的 RenderObjectsPass 也是这么做的)。
案例 25.5只给某个 Layer 画一张遮罩进阶单独打开 ↗
目标:把第 8 层的物体画成纯白剪影,写进一张单通道遮罩 RT。支持两种模式:“透视”(被别的物体挡住的部分也写入)和“只写可见部分”。这张遮罩是案例 25.6 描边的输入,也可以用来做选中高亮、局部后处理等。
原理:遮罩 RT 和相机同尺寸;绘制时绑定遮罩做颜色、相机深度做深度。用 RenderStateBlock 在 C# 里切换深度测试:透视模式 ZTest Always,否则 ZTest LEqual——物体自己已经在不透明 Pass 里写过深度,等于自己时通过;被别的物体挡住的部分深度更大,不通过。替换材质的 Shader 只输出 1,并且 ZWrite Off,保证绝不修改相机深度:
// 案例 25.5:DrawRenderers 的 Override Material——把物体画成纯白剪影,写进遮罩 RT。 Shader "Cookbook/Ch25/LayerMaskSolid" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Pass { Name "Mask" ZWrite Off // 绝不改动相机深度;深度比较方式由 C# 的 RenderStateBlock 决定 Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; }; Varyings vert(Attributes v) { Varyings o; o.positionCS = TransformObjectToHClip(v.positionOS.xyz); return o; } half4 frag(Varyings i) : SV_Target { return 1.0; } ENDHLSL } } }
// 案例 25.5:DrawRenderers 的 Override Material——把物体画成纯白剪影,写进遮罩 RT。 Shader "Cookbook/Ch25/LayerMaskSolid" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Pass { Name "Mask" ZWrite Off // 绝不改动相机深度;深度比较方式由 C# 的 RenderStateBlock 决定 Cull Back GLSLPROGRAM #ifdef VERTEX void main() { // 对应 HLSL 的 TransformObjectToHClip。这里拆成“物体→世界→裁剪”两步,和 Lit 的顶点着色器 // (GetVertexPositionInputs)运算顺序一致,保证算出的深度逐位相同,LEqual 测试不会闪烁 gl_Position = TransformWorldToHClip(TransformObjectToWorld(positionOS.xyz)); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(1.0); } #endif ENDGLSL } } }
class MaskPass : ScriptableRenderPass { // 只画原材质里有这些 LightMode 的物体(URP 前向渲染用到的三个标签) static readonly List<ShaderTagId> s_ShaderTags = new List<ShaderTagId> { new ShaderTagId("UniversalForward"), new ShaderTagId("UniversalForwardOnly"), new ShaderTagId("SRPDefaultUnlit"), }; readonly Material m_Material; FilteringSettings m_Filtering; RenderStateBlock m_StateBlock; public RTHandle mask; // 遮罩 RT(自己分配) public RTHandle cameraDepth; // 借用相机深度做遮挡测试 public MaskPass(Material material) { m_Material = material; profilingSampler = new ProfilingSampler("Ch25 Layer Mask"); m_Filtering = new FilteringSettings(RenderQueueRange.all); } public void Setup(Settings s) { m_Filtering.layerMask = s.layerMask; // 用 RenderStateBlock 覆盖材质里的深度状态:透视模式 ZTest Always,否则 LEqual m_StateBlock = new RenderStateBlock(RenderStateMask.Depth) { depthState = new DepthState(false, s.seeThrough ? CompareFunction.Always : CompareFunction.LessEqual) }; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 遮罩与相机同尺寸、同 MSAA(要和相机深度一起绑定,两者采样数必须一致),单通道 8 位即可 var desc = renderingData.cameraData.cameraTargetDescriptor; desc.graphicsFormat = GraphicsFormat.R8_UNorm; desc.depthBufferBits = 0; RenderingUtils.ReAllocateIfNeeded(ref mask, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_Ch25LayerMask"); // 告诉 URP:这个 Pass 画到 mask 上,深度用相机深度;开始前只清颜色(深度绝不能清) ConfigureTarget(mask, cameraDepth); ConfigureClear(ClearFlag.Color, Color.clear); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { // 先把命令缓冲里已有的命令提交,再调用 context 级别的 DrawRenderers context.ExecuteCommandBuffer(cmd); cmd.Clear(); var drawing = CreateDrawingSettings(s_ShaderTags, ref renderingData, SortingCriteria.CommonOpaque); drawing.overrideMaterial = m_Material; // 所有物体都换成纯白材质 drawing.overrideMaterialPassIndex = 0; context.DrawRenderers(renderingData.cullResults, ref drawing, ref m_Filtering, ref m_StateBlock); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() { mask?.Release(); mask = null; } }
maskPass(fr) { const E = fr.engine, gl = fr.gl, s = this.settings; // 遮罩与相机同尺寸、同 MSAA(要和相机深度一起绑定,两者采样数必须一致),单通道 8 位即可 const rt = E.rt('ch25:_Ch25LayerMask', fr.width, fr.height, { color: 'R8', depth: null, samples: fr.msaa, renderbuffers: true }); // ConfigureTarget(mask, cameraDepth):把相机的深度缓冲挂到遮罩的 FBO 上(借用,不清除) gl.bindFramebuffer(gl.FRAMEBUFFER, rt.fbo); gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, fr.cameraTarget.depthRB); E.bindTarget(rt); // ConfigureClear(ClearFlag.Color, Color.clear):只清颜色,深度绝不能清 gl.colorMask(true, true, true, true); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); // RenderStateBlock 覆盖深度状态:透视模式 ZTest Always,否则 LEqual;不写深度 const pass = this.maskMaterial.shader.passes[0]; fr.drawRenderers({ queue: 'all', lightModes: SHADER_TAGS, layerMask: layerMask(s.layerMask), overrideMaterial: this.maskMaterial, overridePass: 0, state: { ...pass.state, zwrite: 'Off', ztest: s.seeThrough ? 'Always' : 'LEqual' }, }); E.glc.resolve(rt, { color: true }); this.mask = rt.texture; }
这里和案例 25.1 最大的不同是用了 ConfigureTarget 与 ConfigureClear,而不是在 Execute 里手动设置目标:
ConfigureTarget(mask, cameraDepth)告诉 Renderer:执行这个 Pass 之前,把颜色绑定为遮罩、深度绑定为相机深度。Renderer 记录下这次绑定,下一个 Pass 要用相机颜色时,它知道目标变了,会自动重新绑定——不需要我们手动恢复。ConfigureClear(ClearFlag.Color, Color.clear)只清颜色。千万不能写ClearFlag.All:那会把相机的深度缓冲清掉,后面的天空盒、透明物体全部出错。- 遮罩描述符的 MSAA 采样数沿用相机描述符。原因是颜色和深度一起绑定时,两者的采样数必须一致;相机开了 4x MSAA 时,相机深度也是 4x 的。采样这张多重采样遮罩时,Unity 会自动解析(resolve)。
- 格式用
GraphicsFormat.R8_UNorm:遮罩只需要一个通道 8 位,比相机颜色格式省 3/4 的带宽。

案例 25.6按遮罩给选中层描边进阶单独打开 ↗
目标:用案例 25.5 的遮罩,给第 8 层物体画出等宽的外描边,并可选半透明纯色填充。这是编辑器里“选中物体橙色描边”的同款做法,和第 14 章“背面法线外扩”描边相比,它不依赖模型的法线质量、线宽在屏幕上恒定,而且对任何 Shader 的物体都有效。
原理:对遮罩做一次膨胀(Dilation)——每个像素取周围半径 r 内遮罩的最大值;膨胀后的区域减去原区域,就是物体外侧宽度为 r 的一圈。然后用 Alpha 混合把这一圈(和可选的内部填充)叠到相机颜色上:
实际实现用两圈各 12 个方向近似圆形邻域(半径 r 和 r/2),双线性采样让边缘有一点抗锯齿:
half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half center = Mask(uv); // 膨胀(Dilation):在半径 = 线宽、线宽/2 的两圈圆周上各取 12 个样本,求最大值 float2 r = _BlitTexture_TexelSize.xy * _OutlineWidth; half dilated = center; [unroll] for (int k = 0; k < 12; k++) { float a = k * (TWO_PI / 12.0); float2 dir = float2(cos(a), sin(a)); dilated = max(dilated, Mask(uv + dir * r)); dilated = max(dilated, Mask(uv + dir * r * 0.5)); } // 膨胀后的区域减去原区域 = 物体外侧一圈 half outline = saturate(dilated - center); half3 rgb = lerp(_FillColor.rgb, _OutlineColor.rgb, outline); half alpha = outline * _OutlineColor.a + center * _FillColor.a; return half4(rgb, alpha); }
vec4 Frag() { vec2 uv = texcoord; float center = Mask(uv); // 膨胀(Dilation):在半径 = 线宽、线宽/2 的两圈圆周上各取 12 个样本,求最大值 vec2 r = _BlitTexture_TexelSize.xy * _OutlineWidth; float dilated = center; for (int k = 0; k < 12; k++) { float a = float(k) * (TWO_PI / 12.0); vec2 dir = vec2(cos(a), sin(a)); dilated = max(dilated, Mask(uv + dir * r)); dilated = max(dilated, Mask(uv + dir * r * 0.5)); } // 膨胀后的区域减去原区域 = 物体外侧一圈 float outline = saturate(dilated - center); vec3 rgb = mix(_FillColor.rgb, _OutlineColor.rgb, outline); float alpha = outline * _OutlineColor.a + center * _FillColor.a; return vec4(rgb, alpha); }
合成 Pass 用 ConfigureTarget(相机颜色, 相机深度) 声明目标,然后用 Blitter.BlitTexture(不切换目标的那个重载)把遮罩作为 _BlitTexture 画一个全屏三角形,靠 Shader 里的 Blend SrcAlpha OneMinusSrcAlpha 叠加上去。和案例 25.1 比,这里不需要临时 RT:我们不读相机颜色,只往上面混合,读写分离的问题根本不存在:
class CompositePass : ScriptableRenderPass { static readonly int s_OutlineColor = Shader.PropertyToID("_OutlineColor"); static readonly int s_FillColor = Shader.PropertyToID("_FillColor"); static readonly int s_OutlineWidth = Shader.PropertyToID("_OutlineWidth"); readonly Material m_Material; MaskPass m_MaskPass; RTHandle m_CameraColor, m_CameraDepth; int m_ShaderPass; public CompositePass(Material material) { m_Material = material; profilingSampler = new ProfilingSampler("Ch25 Outline Composite"); } public void Setup(Settings s, MaskPass maskPass) { m_MaskPass = maskPass; m_ShaderPass = s.mode == Mode.Outline ? 0 : 1; m_Material.SetColor(s_OutlineColor, s.outlineColor); m_Material.SetColor(s_FillColor, s.fillColor); m_Material.SetFloat(s_OutlineWidth, s.width); } public void SetTarget(RTHandle color, RTHandle depth) { m_CameraColor = color; m_CameraDepth = depth; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 画回相机颜色。显式 ConfigureTarget,URP 会在 MaskPass 之后重新绑定相机目标 ConfigureTarget(m_CameraColor, m_CameraDepth); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { // 不切换目标,直接在当前目标(相机颜色)上画一个全屏三角形,靠混合叠加上去 Blitter.BlitTexture(cmd, m_MaskPass.mask, new Vector4(1, 1, 0, 0), m_Material, m_ShaderPass); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }
// 案例 25.5 / 25.6:只给某个 Layer 的物体画遮罩,再根据遮罩画描边(对应 Ch25/LayerOutlineFeature.cs)。 // 两个 Pass:MaskPass 用 DrawRenderers 把选中层画进一张 R8 遮罩;CompositePass 读遮罩、膨胀、叠到屏幕上。 import { Material } from '../engine/shader.js'; import { RenderPassEvent, layerMask } from '../engine/features.js'; // 只画原材质里有这些 LightMode 的物体(URP 前向渲染用到的三个标签) const SHADER_TAGS = ['UniversalForward', 'UniversalForwardOnly', 'SRPDefaultUnlit']; export default class LayerOutlineFeature { constructor(engine, data, scene) { this.engine = engine; this.data = data || {}; this.settings = { layerMask: 1 << 8, seeThrough: true, outlineColor: [1, 0.55, 0.1, 1], fillColor: [1, 0.55, 0.1, 0], width: 4, mode: 0, passEvent: RenderPassEvent.AfterRenderingTransparents, ...(this.data.settings || {}), }; } async init() { const sh = (ref, def) => this.engine.shaders.get((ref && ref.shader) || def); this.maskMaterial = new Material(await sh(this.data.maskShader, 'Cookbook/Ch25/LayerMaskSolid')); this.outlineMaterial = new Material(await sh(this.data.outlineShader, 'Cookbook/Ch25/LayerOutline')); } addPasses(frame, enqueue) { const s = this.settings; // CompositePass.Setup // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector this.outlineMaterial.setVector('_OutlineColor', s.outlineColor); this.outlineMaterial.setVector('_FillColor', s.fillColor); this.outlineMaterial.setFloat('_OutlineWidth', s.width); const shaderPass = s.mode === 0 ? 0 : 1; // 两个 Pass 同一个事件:入队顺序就是执行顺序 enqueue(s.passEvent, (fr) => this.maskPass(fr), 'Ch25 Layer Mask'); enqueue(s.passEvent, (fr) => { // 画回相机颜色:在相机目标上画一个全屏三角形,靠混合叠加上去 fr.blit(this.mask, 'camera', this.outlineMaterial, shaderPass); }, 'Ch25 Outline Composite'); } maskPass(fr) { const E = fr.engine, gl = fr.gl, s = this.settings; // 遮罩与相机同尺寸、同 MSAA(要和相机深度一起绑定,两者采样数必须一致),单通道 8 位即可 const rt = E.rt('ch25:_Ch25LayerMask', fr.width, fr.height, { color: 'R8', depth: null, samples: fr.msaa, renderbuffers: true }); // ConfigureTarget(mask, cameraDepth):把相机的深度缓冲挂到遮罩的 FBO 上(借用,不清除) gl.bindFramebuffer(gl.FRAMEBUFFER, rt.fbo); gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, fr.cameraTarget.depthRB); E.bindTarget(rt); // ConfigureClear(ClearFlag.Color, Color.clear):只清颜色,深度绝不能清 gl.colorMask(true, true, true, true); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); // RenderStateBlock 覆盖深度状态:透视模式 ZTest Always,否则 LEqual;不写深度 const pass = this.maskMaterial.shader.passes[0]; fr.drawRenderers({ queue: 'all', lightModes: SHADER_TAGS, layerMask: layerMask(s.layerMask), overrideMaterial: this.maskMaterial, overridePass: 0, state: { ...pass.state, zwrite: 'Off', ztest: s.seeThrough ? 'Always' : 'LEqual' }, }); E.glc.resolve(rt, { color: true }); this.mask = rt.texture; } }
两个 Pass 都设在 AfterRenderingTransparents,入队顺序是先遮罩后合成,稳定排序保证它们按这个顺序执行。
用 Frame Debugger 验证时,会在 AfterRenderingTransparents 的位置看到 “Ch25 Layer Mask” 和 “Ch25 Outline Composite” 两个节点。点开前者,右侧的 RenderTarget 显示为 _Ch25LayerMask,绘制列表里只有第 8 层的两个物体,Shader 显示为替换材质 LayerMaskSolid;点开后者只有一次全屏的 Draw Procedural。如果遮罩节点里一个物体都没有,先检查相机的 Culling Mask 是否包含这一层(不在剔除结果里的物体 DrawRenderers 画不出来),再检查物体原来的 Shader 是否有 UniversalForward 等被过滤的 LightMode。
[BookShot("ch25-layer-outline", W = 640, H = 400, Panels = 3)] static void LayerOutlineShot(Shot s) { Stage(s, LayerA); s.Cube(s.Lit(new Color(0.55f, 0.5f, 0.45f), 0, 0.3f), new Vector3(-1.2f, 0.45f, -1.25f), 1f) .transform.localScale = new Vector3(0.9f, 0.9f, 0.08f); var f = s.Feature<LayerOutlineFeature>(); f.settings.layerMask = 1 << LayerA; f.settings.width = 4; switch (s.Panel) { case 0: f.settings.seeThrough = true; break; case 1: f.settings.seeThrough = false; break; default: f.settings.seeThrough = true; f.settings.outlineColor = new Color(0.2f, 0.9f, 1f, 1f); f.settings.fillColor = new Color(0.2f, 0.9f, 1f, 0.35f); // 纯色填充 + 描边 f.settings.width = 6; break; } }

调参与扩展:
- 开销:遮罩 Pass 只画选中的物体,很便宜;合成 Pass 每像素 25 次采样,在 1080p 上是一次不小的全屏操作。线宽很大时(超过十几像素),圆周采样会出现空隙,可以改用“先降采样再膨胀”或跳跃泛洪算法(Jump Flooding)求距离场,再按距离画线,线宽几乎不影响开销。
- 只在需要时入队:选中物体为空时,在
AddRenderPasses里直接 return,两个 Pass 都不执行。 - 多种颜色:遮罩改成 RGBA,替换材质根据
renderingLayerMask或 MaterialPropertyBlock 输出不同通道,就能一次画出“敌人红、队友绿”多组描边。
案例 25.7把一个 Layer 渲染到 RT 再合成:整体半透明的“幽灵”高阶单独打开 ↗
目标:让一个由多个部件拼成的角色整体半透明(隐身、灵魂、回放残影等),并且看不到它内部互相穿插的结构,同时被场景里的物体正确遮挡。
问题:最直接的做法是给每个部件用半透明材质。但半透明物体逐个混合,手臂和身体重叠的地方被叠加了两次,看起来更不透明;身体内部被手臂“插进去”的部分也能透过表面看到——这就是图 25-12 左边的样子。根本原因是:半透明是逐物体、逐表面的,而我们要的是“这个角色作为一张图”整体半透明。
原理:先把这个 Layer 的物体用它们原本的不透明材质,画到一张单独的离屏 RT 上(自带深度缓冲,所以内部穿插的面已经被深度测试正确剔除,RT 里只剩最外层表面);再把这张 RT 当成一张图,统一乘以透明度合成回屏幕。为了被场景正确遮挡,合成时比较“离屏深度”和“场景深度”:
LayerPass:颜色用 R16G16B16A16_SFloat(HDR 光照结果 + Alpha 作为覆盖率),深度用纯深度格式 D32_SFloat(之后要在 Shader 里当纹理采样)。ConfigureClear(ClearFlag.All, Color.clear) 把颜色清成全透明、深度清成最远——这次清的是我们自己的 RT,可以放心用 All。绘制时不设置替换材质,物体用自己的 Lit 材质正常打光。URP Lit 的不透明模式输出 Alpha 恒为 1(OutputAlpha 对非透明表面返回 1),所以 RT 的 Alpha 通道就是覆盖率:
class LayerPass : ScriptableRenderPass { static readonly List<ShaderTagId> s_ShaderTags = new List<ShaderTagId> { new ShaderTagId("UniversalForward"), new ShaderTagId("UniversalForwardOnly"), new ShaderTagId("SRPDefaultUnlit"), }; public LayerMask layerMask; public RTHandle color, depth; public LayerPass() { profilingSampler = new ProfilingSampler("Ch25 Ghost Layer"); } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { var desc = renderingData.cameraData.cameraTargetDescriptor; desc.msaaSamples = 1; // 颜色:带 Alpha 的半精度浮点(HDR 光照结果 + 覆盖率) var colorDesc = desc; colorDesc.graphicsFormat = GraphicsFormat.R16G16B16A16_SFloat; colorDesc.depthBufferBits = 0; RenderingUtils.ReAllocateIfNeeded(ref color, colorDesc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_Ch25GhostColor"); // 深度:纯深度格式的 RT,之后还要在合成 Shader 里当纹理采样 var depthDesc = desc; depthDesc.graphicsFormat = GraphicsFormat.None; depthDesc.depthStencilFormat = GraphicsFormat.D32_SFloat; depthDesc.depthBufferBits = 32; RenderingUtils.ReAllocateIfNeeded(ref depth, depthDesc, FilterMode.Point, TextureWrapMode.Clamp, name: "_Ch25GhostDepth"); ConfigureTarget(color, depth); ConfigureClear(ClearFlag.All, Color.clear); // 颜色清成全透明,深度清成最远 } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { context.ExecuteCommandBuffer(cmd); cmd.Clear(); // 不覆盖材质:用物体自己的 Lit 材质正常打光,只是画到了另一张 RT 上 var drawing = CreateDrawingSettings(s_ShaderTags, ref renderingData, renderingData.cameraData.defaultOpaqueSortFlags); var filtering = new FilteringSettings(RenderQueueRange.opaque, layerMask); context.DrawRenderers(renderingData.cullResults, ref drawing, ref filtering); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() { color?.Release(); color = null; depth?.Release(); depth = null; } }
layerPass(fr) { const gl = fr.gl; // 颜色:带 Alpha 的半精度浮点(HDR 光照结果 + 覆盖率);深度:之后还要在合成 Shader 里当纹理采样 const rt = fr.getTemporaryRT('_Ch25GhostColor', fr.width, fr.height, { color: 'RGBA16F', depth: 'DEPTH24' }); fr.engine.bindTarget(rt); // ConfigureClear(ClearFlag.All, Color.clear):颜色清成全透明,深度清成最远 gl.colorMask(true, true, true, true); gl.depthMask(true); gl.clearColor(0, 0, 0, 0); gl.clearDepth(1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // 不覆盖材质:用物体自己的 Lit 材质正常打光,只是画到了另一张 RT 上 fr.drawRenderers({ queue: 'opaque', lightModes: SHADER_TAGS, layerMask: layerMask(this.settings.layerMask) }); this.layer = rt; fr.bindCamera(); }
合成 Shader:深度比较统一换算成线性深度,避免和 Reversed-Z 纠缠;再用 Alpha 在邻域里的最小值找出剪影边缘,加一圈轮廓光:
half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 ghost = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv); // 1) 遮挡:幽灵层深度 与 场景深度 都换成线性深度再比较,与平台的 Reversed-Z 无关 float ghostEye = LinearEyeDepth(SAMPLE_TEXTURE2D_X(_GhostDepth, sampler_PointClamp, uv).r, _ZBufferParams); float sceneEye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams); half visible = step(ghostEye, sceneEye + 0.01); // 2) 轮廓光:覆盖率(alpha)在邻域里的最小值 < 1 说明靠近剪影边缘 float2 r = _BlitTexture_TexelSize.xy * _RimWidth; half minA = ghost.a; minA = min(minA, SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2( r.x, 0)).a); minA = min(minA, SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(-r.x, 0)).a); minA = min(minA, SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(0, r.y)).a); minA = min(minA, SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(0, -r.y)).a); half rim = ghost.a * (1.0 - minA); // 3) 整层统一透明度:内部的穿插面在离屏 RT 里已被深度测试剔除,合成时看不到 half3 rgb = ghost.rgb * _Tint.rgb + _RimColor.rgb * rim; half alpha = saturate(ghost.a * _Opacity + rim) * visible; return half4(rgb, alpha); }
vec4 Frag() { vec2 uv = texcoord; // sampler_PointClamp:按纹素整数坐标取,不做双线性 ivec2 pix = ivec2(uv * _BlitTexture_TexelSize.zw); vec4 ghost = texelFetch(_BlitTexture, pix, 0); // 1) 遮挡:幽灵层深度 与 场景深度 都换成线性深度再比较,与平台的 Reversed-Z 无关 float ghostEye = LinearEyeDepth(texelFetch(_GhostDepth, pix, 0).r, _ZBufferParams); float sceneEye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams); float visible = step(ghostEye, sceneEye + 0.01); // 2) 轮廓光:覆盖率(alpha)在邻域里的最小值 < 1 说明靠近剪影边缘 vec2 r = _BlitTexture_TexelSize.xy * _RimWidth; float minA = ghost.a; minA = min(minA, texture(_BlitTexture, uv + vec2( r.x, 0.0)).a); minA = min(minA, texture(_BlitTexture, uv + vec2(-r.x, 0.0)).a); minA = min(minA, texture(_BlitTexture, uv + vec2(0.0, r.y)).a); minA = min(minA, texture(_BlitTexture, uv + vec2(0.0, -r.y)).a); float rim = ghost.a * (1.0 - minA); // 3) 整层统一透明度:内部的穿插面在离屏 RT 里已被深度测试剔除,合成时看不到 vec3 rgb = ghost.rgb * _Tint.rgb + _RimColor.rgb * rim; float alpha = saturate(ghost.a * _Opacity + rim) * visible; return vec4(rgb, alpha); }
class CompositePass : ScriptableRenderPass { static readonly int s_GhostDepth = Shader.PropertyToID("_GhostDepth"); static readonly int s_Opacity = Shader.PropertyToID("_Opacity"); static readonly int s_Tint = Shader.PropertyToID("_Tint"); static readonly int s_RimColor = Shader.PropertyToID("_RimColor"); static readonly int s_RimWidth = Shader.PropertyToID("_RimWidth"); readonly Material m_Material; LayerPass m_Layer; RTHandle m_CameraColor, m_CameraDepth; public CompositePass(Material m) { m_Material = m; profilingSampler = new ProfilingSampler("Ch25 Ghost Composite"); } public void Setup(Settings s, LayerPass layer) { m_Layer = layer; m_Material.SetFloat(s_Opacity, s.opacity); m_Material.SetColor(s_Tint, s.tint); m_Material.SetColor(s_RimColor, s.rimColor); m_Material.SetFloat(s_RimWidth, s.rimWidth); } public void SetTarget(RTHandle color, RTHandle depth) { m_CameraColor = color; m_CameraDepth = depth; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) => ConfigureTarget(m_CameraColor, m_CameraDepth); public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { cmd.SetGlobalTexture(s_GhostDepth, m_Layer.depth); Blitter.BlitTexture(cmd, m_Layer.color, new Vector4(1, 1, 0, 0), m_Material, 0); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }
// 案例 25.7:把某个 Layer 单独渲染到离屏 RT(颜色+自己的深度),再整体半透明合成回屏幕(对应 Ch25/GhostLayerFeature.cs)。 // 用法:把该 Layer 从 Renderer 的 Opaque Layer Mask 里去掉(否则它会在主 Pass 里再画一次)。 import { Material } from '../engine/shader.js'; import { RenderPassEvent, layerMask } from '../engine/features.js'; const SHADER_TAGS = ['UniversalForward', 'UniversalForwardOnly', 'SRPDefaultUnlit']; export default class GhostLayerFeature { constructor(engine, data, scene) { this.engine = engine; this.data = data || {}; this.settings = { layerMask: 1 << 9, opacity: 0.45, tint: [0.75, 0.95, 1.1, 1], rimColor: [0.3, 1.2, 2.0, 1], rimWidth: 3, ...(this.data.settings || {}) }; } async init() { const name = (this.data.compositeShader && this.data.compositeShader.shader) || 'Cookbook/Ch25/GhostComposite'; this.material = new Material(await this.engine.shaders.get(name)); } addPasses(frame, enqueue) { const s = this.settings, m = this.material; // CompositePass.Setup m.setFloat('_Opacity', s.opacity); // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector m.setVector('_Tint', s.tint); m.setVector('_RimColor', s.rimColor); m.setFloat('_RimWidth', s.rimWidth); // 合成时要和场景深度比较 → 需要 _CameraDepthTexture frame.requireDepth = true; enqueue(RenderPassEvent.AfterRenderingOpaques, (fr) => this.layerPass(fr), 'Ch25 Ghost Layer'); enqueue(RenderPassEvent.AfterRenderingTransparents, (fr) => { fr.blit(this.layer.texture, 'camera', m, 0, { _GhostDepth: this.layer.depthTexture }); }, 'Ch25 Ghost Composite'); } layerPass(fr) { const gl = fr.gl; // 颜色:带 Alpha 的半精度浮点(HDR 光照结果 + 覆盖率);深度:之后还要在合成 Shader 里当纹理采样 const rt = fr.getTemporaryRT('_Ch25GhostColor', fr.width, fr.height, { color: 'RGBA16F', depth: 'DEPTH24' }); fr.engine.bindTarget(rt); // ConfigureClear(ClearFlag.All, Color.clear):颜色清成全透明,深度清成最远 gl.colorMask(true, true, true, true); gl.depthMask(true); gl.clearColor(0, 0, 0, 0); gl.clearDepth(1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // 不覆盖材质:用物体自己的 Lit 材质正常打光,只是画到了另一张 RT 上 fr.drawRenderers({ queue: 'opaque', lightModes: SHADER_TAGS, layerMask: layerMask(this.settings.layerMask) }); this.layer = rt; fr.bindCamera(); } }
[BookShot("ch25-ghost", W = 640, H = 440, Panels = 3)] static void Ghost(Shot s) { GhostStage(s, naive: s.Panel == 0); if (s.Panel == 0) return; // 第 1 格:逐物体半透明(对照组) // 幽灵层不参与主不透明 Pass,只由 Feature 单独绘制 s.Renderer.opaqueLayerMask = ~(1 << LayerB); var f = s.Feature<GhostLayerFeature>(); f.settings.layerMask = 1 << LayerB; if (s.Panel == 1) { f.settings.rimWidth = 0; f.settings.opacity = 0.45f; } else { f.settings.rimWidth = 3; f.settings.opacity = 0.3f; } }

要点与扩展:
- 一定要从 Opaque Layer Mask 里去掉这一层,否则它会先被主 Pass 不透明地画一次,合成就没有意义了。代码里是
s.Renderer.opaqueLayerMask = ~(1 << LayerB),编辑器里在 Renderer Data 的 Filtering 区域设置。 - 深度比较加了 1 厘米的偏移(
sceneEye + 0.01),防止幽灵贴着地面时因为精度问题闪烁。 - 阴影:这一层仍会投射阴影(阴影 Pass 不看 Opaque Layer Mask),如果不希望半透明的幽灵投出实心阴影,就像配图脚本那样把它的 Shadow Casting Mode 关掉,或在合成时按透明度处理阴影(需要更复杂的方案)。
- 降分辨率:离屏 RT 可以用半分辨率,合成时双线性放大,对于本来就半透明、边缘模糊的效果几乎看不出差别,开销降到 1/4。此时深度比较要注意低分辨率深度与全分辨率深度的不一致(可以取 2×2 邻域里最近的深度)。
- 同样的“分层离屏渲染 + 合成”结构还能做:只给角色做的局部后处理(比如角色单独做描边或色调)、画中画小地图、第一人称武器层等。
25.6 中间纹理、相机栈与性能
没有中间纹理时,相机颜色就是屏幕
本章所有“读相机颜色”的效果都隐含一个前提:相机先画到一张中间颜色纹理(_CameraColorAttachmentA/B),最后再由 FinalBlit 拷到屏幕。只有这样,cameraColorTargetHandle 才是一张可以被采样的 RT。Universal Renderer Data 上有一个 选项(源码字段 m_IntermediateTextureMode),默认值是 Always:只要 Renderer 上挂了任何 Feature,就一定创建中间纹理(见 UniversalRenderer.Setup 中 rendererFeatures.Count != 0 && m_IntermediateTextureMode == IntermediateTextureMode.Always 这一判断)。
移动端项目常把它改成 Auto 以省掉一次全屏拷贝。此时 URP 只在确有需要时(开了后处理、HDR、Render Scale 不为 1、MSAA 需要显式解析、需要 Opaque Texture、Scene 视图等,见 RequiresIntermediateColorTexture)才创建中间纹理,否则直接画到屏幕上。如果你的 Feature 要读相机颜色,而上述条件恰好都不满足,cameraColorTargetHandle 就会指向屏幕本身,Blit 读出来的是错误内容。解决办法是在 AddRenderPasses 里 ConfigureInput(ScriptableRenderPassInput.Color):它会让 URP 把“需要颜色纹理”计入判断,从而创建中间纹理——代价是同时会多一次拷贝到 _CameraOpaqueTexture 的 CopyColor Pass。用 Always 还是 Auto + ConfigureInput,要看项目里有多少 Feature 真的需要读颜色。
相机栈与 Overlay 相机
Feature 对使用该 Renderer 的每一个相机都会执行,包括相机栈里的 Overlay 相机。Overlay 相机和 Base 相机共用同一块颜色缓冲,所以一个挂在两者共用的 Renderer 上的全屏去色效果,会在 Base 相机结束时执行一次、在 Overlay 相机结束时再执行一次——如果效果不是幂等的(比如“亮度乘 1.2”),结果就会被叠加两次。常见的处理方式有三种:给 Overlay 相机单独配一个不挂该 Feature 的 Renderer;在 AddRenderPasses 里用 renderingData.cameraData.renderType 只处理 Base 相机;或者用 cameraData.resolveFinalTarget 只在栈中最后一个相机上执行(这正是 URP 自己的后处理只在最后一个相机上做色调映射等收尾工作的方式)。
开销估算
全屏 Pass 的成本主要是带宽而不是计算:一次 1920×1080、RGBA16F 的全屏读写,就要搬运约 2×1920×1080×8 字节 ≈ 33 MB 的数据。案例 25.1 的写法每帧做两次(拷出、拷回),在移动端上这是值得在意的开销。几个省带宽的方向:
- 能用混合叠加的效果(描边、覆盖层、暗角)不要“拷出再拷回”,像案例 25.6 那样直接在相机颜色上混合。
- 多个全屏颜色效果合并成一个 Pass(一个 Shader 里依次做),或者像第 26 章那样按 Volume 判断,未启用的效果根本不入队。
- 遮罩、中间结果用最小够用的格式(R8、R16F),能降分辨率就降。
- TBDR 移动 GPU 上,使用带 load/store action 的 Blit 重载,对不需要保留的目标用
DontCare,避免把 tile 内容无谓地写回或读入显存。
25.7 常见坑清单
| 现象 | 原因 | 解决 |
|---|---|---|
| 控制台刷 “You can only call cameraColorTarget inside the scope of a ScriptableRenderPass” | 在 AddRenderPasses 或 Create 里读了 cameraColorTargetHandle | 移到 SetupRenderPasses |
| 需要的深度/法线图第一帧是空的,或在某些平台上一直为空 | ConfigureInput 写在 SetupRenderPasses/OnCameraSetup 里,renderer.Setup 早已算完本帧需求 | 在 AddRenderPasses 里、入队前调用 |
| 每次在 Inspector 调参数,Memory Profiler 里材质/RT 数量就涨一点 | Create 重复创建资源却不释放 | Create 开头先释放旧资源;Dispose 里释放全部 |
| 加了 Feature 后天空盖住画面、透明物体跑到前面 | Pass 手动切换了渲染目标,没有恢复深度 | 用 ConfigureTarget,或结束时 CoreUtils.SetRenderTarget(cmd, color, depth) |
| 多次迭代的效果每次参数都一样 | 同一帧多次 material.SetFloat,执行时只读到最后一次 | 逐次变化的参数用 cmd.SetGlobalFloat 等记录进命令缓冲 |
| 材质球预览、反射探针也被加了效果 | 没有按 cameraType 过滤 | 跳过 Preview、Reflection(必要时也跳过 SceneView) |
| 编辑器里正常,打包后效果消失 | Shader 只靠 Shader.Find,打包时被剔除 | Feature 上直接引用 Shader,或加入 Always Included Shaders |
| 屏幕边缘出现一条另一侧的颜色 | 临时 RT 寻址模式是默认的 Repeat | ReAllocateIfNeeded 传 TextureWrapMode.Clamp |
| 开 MSAA 后报“颜色与深度采样数不一致” | 自己的颜色 RT 去掉了 MSAA,却和相机深度一起绑定 | 与相机深度一起绑定的 RT 沿用相机的 msaaSamples |
| VR 下只有一只眼睛有效果 | Shader 用了 TEXTURE2D 而非 TEXTURE2D_X,或漏了 UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX | 照 Blit.hlsl 的约定写 |
25.8 URP 17 Render Graph 带来了什么变化
本书锁定 URP 14,但读者迟早会升级到 Unity 6(URP 17)。在那里,本章的写法会遇到一次较大的变化:Render Graph 成为默认的执行路径。这里只讲概念上的差异,帮助你判断迁移工作量,不给出 17 的代码。
其实 URP 14 的源码里已经能看到它的影子:ScriptableRenderPass 有一个 internal virtual void RecordRenderGraph(RenderGraph renderGraph, ref RenderingData renderingData),注释写着 “TODO RENDERGRAPH”;UniversalRenderPipeline.Render 里根据一个内部开关 useRenderGraph 选择 RecordAndExecuteRenderGraph 或传统的 Setup + Execute。在 14 里这条路径是内部实验性质的,用户的 Feature 不会走它。
| 方面 | URP 14(本书) | URP 17 Render Graph(概念) |
|---|---|---|
| Pass 的核心方法 | OnCameraSetup + Execute,直接录制命令 | RecordRenderGraph:先“声明”这个 Pass 读哪些纹理、写哪些纹理,再提供一个渲染函数,由 Render Graph 统一编排执行 |
| 相机颜色/深度 | SetupRenderPasses 里取 cameraColorTargetHandle | 从帧数据(frame data)里取当前的颜色/深度纹理句柄,句柄只在本帧图中有效 |
| 临时纹理 | 自己 ReAllocateIfNeeded 持有 RTHandle,Dispose 里释放 | 在图中创建“瞬时”纹理,生命周期由 Render Graph 管理,可与其他 Pass 复用同一块显存 |
| 渲染目标 | ConfigureTarget 或手动 SetRenderTarget,需要自己保证恢复 | 声明写入的附件,由图负责绑定;不再存在“忘了恢复目标”的问题 |
| 输入依赖 | ConfigureInput | 仍有类似的输入声明,同时每个 Pass 显式声明读取的纹理,图据此自动排序、剔除无用 Pass |
| 移动端优化 | 手动 load/store action、可选 Native RenderPass | 图能自动合并相邻 Pass 为同一个原生 Render Pass(subpass),减少 TBDR 上的带宽 |
迁移时,本章的知识大多仍然有效:Feature 的职责划分、RenderPassEvent 的含义、Blit.hlsl 的 Shader 约定(_BlitTexture、Vert)、DrawRenderers 的三件套概念、Volume 的读取方式都没有变。需要重写的是 Pass 的 C# 部分:从“我在 Execute 里直接下命令”变成“我向图声明资源依赖并提供渲染回调”。Unity 6 还提供了关闭 Render Graph 的兼容模式,旧写法在兼容模式下可以继续工作,但官方将其定位为过渡方案。具体 API 名称与细节请以你使用的 URP 版本文档和源码为准。
本章小结
- Renderer Feature 挂在 Renderer Data 上,每帧通过
AddRenderPasses把自己的 Pass 放进队列;Pass 按renderPassEvent稳定排序后与内置 Pass 一起执行。 - 回调分工:
Create建材质和 Pass(会被多次调用)、AddRenderPasses决定入队并ConfigureInput、SetupRenderPasses取相机目标、OnCameraSetup分配 RT 和配置目标、Execute录制命令、Dispose释放一切。 - RTHandle 用
RenderingUtils.ReAllocateIfNeeded管理,每帧调用、按需重分配;全屏效果显式指定 Bilinear + Clamp;自己分配的才释放。 - 全屏 Shader 统一用 core
Blit.hlsl的Vert、_BlitTexture、TEXTURE2D_X;C# 侧用Blitter.BlitCameraTexture,读写同一张纹理时借助临时 RT。 - 手动切换渲染目标后要恢复颜色+深度,或用
ConfigureTarget让 Renderer 跟踪。 - Full Screen Pass 适合单 Pass 全屏效果;Render Objects 适合按层重画;更复杂的需求用
DrawRenderers+ 自己的 RT 实现遮罩、描边、离屏合成。 - URP 17 的 Render Graph 改变了 Pass 的 C# 写法(声明资源 + 渲染回调),但 Shader 约定和整体思路不变。
练习
- 给 TintFeature 增加一个
bool onlyGameCamera选项,勾选时 Scene 视图不显示效果。 - 把案例 25.1 改写为使用
ScriptableRenderPass.Blit(cmd, ref renderingData, material, 0)的双缓冲版本,用 Frame Debugger 对比两种写法的绘制次数。 - 修改 LayerOutlineFeature:遮罩 RT 用半分辨率,合成时观察线宽和锯齿的变化,并思考此时为什么不能再和相机深度一起绑定。
- 为幽灵层增加“受击时闪白”:在合成 Shader 里加一个
_Flash参数,由 C# 每帧写入。 - 用 RenderObjects 的 Camera 覆盖功能实现第一人称武器:武器所在层从 Opaque Layer Mask 去掉,再用一个 FOV 更小的 RenderObjects 画出来。观察武器插进墙里时会发生什么,并思考需要再加一个什么 Pass 来解决。