GameTech/URP 渲染实战
CHAPTER 02第 2 章

URP 架构与一帧的流程

上一章讲的是 GPU 怎么画;这一章讲 URP 怎么“指挥” GPU 画:它由哪些资产配置、一帧里按什么顺序执行哪些 Pass、三种渲染路径有什么本质区别,以及为什么你的 Shader 必须按某种规矩来写。

本章你会学到
SRP 的思想;URP Asset、Renderer、Feature、Volume 的关系
一帧的 C# 调用链:从 Render() 到 Submit()
RenderPassEvent 的全部取值与执行分块
一帧的 Pass 顺序,以及每个 Pass 读写哪些纹理
Depth Texture / Opaque Texture 从哪来、何时可用
Forward、Forward+、Deferred 的原理与选择
LightMode 标签、SRP Batcher 原理与兼容条件
相机栈、Frame Debugger、URP Asset 各项设置

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 里,可读、可调试、必要时可以修改。

Built-in 管线 你的 Shader;少量插入点(CommandBuffer) C++ 引擎(黑盒) 剔除 + 固定的渲染流程(前向 Base/Add、延迟)+ 提交与合批 图形 API(D3D / Metal / Vulkan / GL) SRP(URP) 你的 Shader;Renderer Feature C# 渲染管线(URP 源码)决定 Pass、顺序、渲染目标 C++ 引擎:剔除、DrawRenderers、合批(通过 ScriptableRenderContext 调用) 图形 API
图 2-1Built-in 与 SRP 的分工。SRP 把“渲染流程”从引擎黑盒里搬到了 C# 层

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 把它们的关系画了出来。

Graphics / Quality选用哪个 URP Asset URP Asset UniversalRenderPipelineAsset HDR · MSAA · Render Scale主光/附加光 · 阴影Depth / Opaque Texture · SRP Batcher Renderer List:[0] [1] … 默认索引 UniversalRenderer Data 渲染路径Depth Texture ModeLayer、模板覆盖 Renderer Features Camera+ Universal AdditionalCamera Data Volume(Global / Local)→ Volume ProfileBloom、Tonemapping、… URP GlobalSettings全局:层名、剥离 选 Renderer 索引 相机按 Volume Mask / 位置混合 Volume,得到后处理参数 一句话:URP Asset 管“质量与开关”,Renderer 管“流程”,Feature 往流程里插 Pass,Volume 管“后处理参数”。
图 2-2URP 的资产关系。一个 URP Asset 可以挂多个 Renderer,相机自己选用哪一个
资产 / 组件负责什么典型设置
URP Asset
UniversalRenderPipelineAsset
整个管线的“质量档位”和全局开关。不同 Quality 级别可以指定不同的 URP Asset,这是做“高/中/低画质”的标准做法HDR、MSAA、Render Scale、主光与附加光模式、阴影距离/级联/分辨率、Depth/Opaque Texture、SRP Batcher
Universal Renderer Data
UniversalRendererData
一帧的“流程”:用什么渲染路径,加了哪些 Renderer FeatureRendering Path、Depth Priming、Depth Texture Mode、Opaque/Transparent Layer Mask、Renderer Features
Renderer Feature
ScriptableRendererFeature
往流程里插入自定义 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 里。

常见坑改了 URP Asset 却“没效果”?先检查 Project Settings › Quality 当前质量级别是否指定了另一个 Render Pipeline Asset——它会覆盖 Project Settings › Graphics 里的默认值。编辑器和真机的质量级别也可能不同。

2.3 从 Render() 到 Pass:一帧的调用链

每帧引擎调用 UniversalRenderPipeline.Render。顺着 URP 14 的源码走一遍(Runtime/UniversalRenderPipeline.cs、Runtime/ScriptableRenderer.cs、Runtime/UniversalRenderer.cs):

Render(context, cameras) SortCameras(按 depth) 每台 Base 相机:RenderCameraStack Base 与每台 Overlay:RenderSingleCamera ① context.Cull:视锥剔除,得到可见物体、可见灯光 ② InitializeRenderingData:选主光、阴影、每物体灯光数据 ③ AddRenderPasses:各 Renderer Feature 把 Pass 入队 ④ Setup:UniversalRenderer 把内置 Pass 入队、分配 RT ⑤ Execute:按 RenderPassEvent 稳定排序,分 4 块执行 ⑥ context.Submit():命令真正提交给渲染线程 URP 14 另有实验性的 Render Graph 路径(默认关闭), 开启后 ④⑤ 换成 RecordAndExecuteRenderGraph。
图 2-3URP 14 一帧的 C# 调用链(简化)

其中最值得注意的是 ③ 和 ④ 的先后: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 中的完整定义:

枚举值枚举值
BeforeRendering0BeforeRenderingOpaques250
BeforeRenderingShadows50AfterRenderingOpaques300
AfterRenderingShadows100BeforeRenderingSkybox350
BeforeRenderingPrePasses150AfterRenderingSkybox400
AfterRenderingPrePasses200BeforeRenderingTransparents450
BeforeRenderingGbuffer210AfterRenderingTransparents500
AfterRenderingGbuffer220BeforeRenderingPostProcessing550
BeforeRenderingDeferredLights230AfterRenderingPostProcessing600
AfterRenderingDeferredLights240AfterRendering1000

因为是整数,你可以写 RenderPassEvent.AfterRenderingOpaques + 1 这样的值做更细的排序,URP 自己也这么用:FinalBlit 是 AfterRendering + 1,屏幕空间 Overlay UI 是 AfterRendering + 2。

排好序的 Pass 并不是一口气执行的。ScriptableRenderer.Execute 按事件值把它们切成四个执行块(RenderPassBlock),块与块之间做一些全局设置:

050100150200210–2402503003504004505005506001000+ 内置:主光/附加光阴影内置:深度预渲染、LUT内置:GBuffer/延迟光照内置:不透明物体你的 Pass(After Opaques)内置:天空盒内置:CopyColor内置:透明物体内置:后处理内置:FinalBlit BeforeRendering 块 MainRenderingOpaque 块 MainRenderingTransparent 块 AfterRendering 块 BeforeRendering 块执行时相机矩阵尚未设置(适合画阴影图、LUT);随后 URP 才设置相机参数。
图 2-4RenderPassEvent 时间轴(刻度不按比例)与四个执行块。块边界:<150、[150,300)、[300,600)、≥600(ScriptableRenderer.cs 中 m_BlockEventLimits)

这四个块的划分解释了枚举注释里的一句话:“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(执行顺序)事件值条件写入 MainLightShadowCaster50主光开阴影_MainLightShadowmapTexture AdditionalLightsShadowCaster50附加光开阴影_AdditionalLightsShadowmap… DepthPrepass / DepthNormals150需要深度/法线且不能拷贝_CameraDepthTexture / Normals ColorGradingLut150相机开后处理_InternalGradingLut DrawOpaqueObjects250总是相机颜色 + 相机深度 CopyDepth(After Opaques)300需要深度纹理_CameraDepthTexture DrawSkybox350相机 Clear 为 Skybox相机颜色 CopyColor400需要 Opaque Texture_CameraOpaqueTexture DrawTransparentObjects450总是相机颜色 CopyDepth(After Transparents)500深度纹理 + 该模式_CameraDepthTexture PostProcess(Uber)550相机开后处理相机颜色(交换缓冲) FinalPostProcess600FXAA、非线性缩放等相机目标 FinalBlit1001后处理没直接输出时屏幕 / 相机的 RT 深蓝:每帧必有;虚线框:按需入队;橙色:后处理。DepthPrepass 与 CopyDepth 二选一。
图 2-5Forward / Forward+ 下一帧的内置 Pass 顺序(据 URP 14 UniversalRenderer.Setup 整理,省略了 XR、运动矢量、调试相关的 Pass)

几个值得展开的细节:

  • 阴影最先画,因为它们只依赖灯光,不依赖相机的颜色缓冲;画出来的阴影图在不透明和透明 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 章的内容,这里先当工具用,只看关键部分:

Assets/Cookbook/Ch02/FrameSnapshotFeature.cs(节选:feature)
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');
  }
}
Assets/Cookbook/Ch02/FrameSnapshotFeature.cs(节选:passes)
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 的要点,都是实测踩出来的:

  1. 相机颜色缓冲要在 Execute 里现取。后处理开启时 URP 用 A/B 两张缓冲交替,在 SetupRenderPasses 里缓存的句柄可能不是不透明 Pass 正在画的那一张。
  2. MSAA 下要显式解析。开了 MSAA,相机颜色缓冲是多重采样纹理,“解析(Resolve)”到普通纹理之前直接采样,读到的可能是上一次解析的旧内容。这里用 CommandBuffer.ResolveAntiAliasedSurface 解析到快照里。
  3. 想看“不透明之后”的真实背景,得先清屏。前面说过天空盒模式下 URP 只清深度,所以 Feature 在 BeforeRenderingOpaques 加了一个清成黑色的 Pass(clearColorBeforeOpaques),否则快照的背景里是上一帧的残影。
图 2-6同一帧的四个时刻。① AfterRenderingOpaques:只有不透明物体和阴影,背景为清屏色;② AfterRenderingSkybox:天空盒补上空白处;③ AfterRenderingTransparents:透明的球和方块叠上去;④ 最终画面:后处理(ACES 色调映射、Bloom、暗角)之后

对照四张图:发光球在①②③里是一团刺眼的黄色,到了④才变成带辉光的橙色——前三张是 HDR 颜色缓冲里“原始的”线性值(超过 1 的部分在显示时被截断),色调映射之后才回到可显示的范围,Bloom 也是在后处理阶段才加上的。透明物体在③才出现,所以①②阶段拷贝出的 Opaque Texture 里不会有它们。

2.6 深度纹理从哪来:Prepass 还是 CopyDepth

_CameraDepthTexture 是 URP 里最常被 Shader 读取的纹理之一:软粒子、水的深浅、贴花、SSAO、深度描边、雾……它有两种生成方式:

  • Depth Prepass:在画不透明物体之前,用每个物体 Shader 里的 DepthOnly Pass(需要法线时用 DepthNormals Pass)单独把场景深度画一遍到 _CameraDepthTexture。代价是不透明物体的顶点要多处理一遍、多一批 Draw Call。
  • CopyDepth:不透明 Pass 画的时候顺便写了相机深度缓冲,画完之后把它复制一份到 _CameraDepthTexture。只多一次全屏拷贝,通常更便宜。
有人需要深度纹理吗? URP Asset 勾选 / 相机覆盖 /Pass 调用 ConfigureInput(Depth) 否:不生成全局设为“远平面”纹理 是:能直接拷贝吗?CanCopyDepth(MSAA 时需平台支持深度解析;GLES 不行) 另外还要求法线?SSAO 等请求 Normal→ DepthNormals Prepass CopyDepthAfter Opaques / Transparents Depth Prepass用 DepthOnly Pass 先画一遍 能不能 / Force Prepass
图 2-7Forward / Forward+ 下深度纹理的生成方式(简化自 UniversalRenderer.Setup;Scene 视图、预览相机总是用 Prepass)

Universal Renderer 上的 Depth Texture Mode(CopyDepthMode)有三个选项:

选项拷贝时机适用
After OpaquesAfterRenderingOpaques(300)透明物体需要读深度(水、软粒子、力场护盾)时必须用它
After TransparentsAfterRenderingTransparents(500)只有后处理用深度时;把拷贝推迟到透明之后,对 TBDR 移动 GPU 更友好
Force Prepass不拷贝,强制 Depth Prepass需要深度纹理在不透明 Pass 之前就可用时
常见坑URP 14 的 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,这在移动端是一笔可见的带宽开销。

深入相机渲染到 RenderTexture 时还有一个容易忽略的细节:URP 14 的 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 章。

Assets/Cookbook/Ch02/SceneTextures.shader(节选:frag)
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
图 2-8左:两张纹理都开启;中:关闭 Depth Texture(厚度恒为“无穷大”,全是深水色,白沫消失);右:关闭 Opaque Texture(折射内容变成灰色占位图)

中图最能说明问题:深度纹理关闭后,URP 把它设成了“远平面”,Shader 算出的厚度永远很大,于是浅滩、白沫、水下方块的轮廓统统消失。右图里,深度效果都还在,但透过水面看到的不再是水底,而是一片灰色——关掉 Opaque Texture 后这一帧不会写入 _CameraOpaqueTexture,Shader 读到什么取决于之前谁设置过这个全局纹理,可能是上一帧、上一台相机留下的旧图,也可能是引擎的默认纹理。为了让配图结果确定,拍摄脚本显式放了一张灰色占位图。实际项目里这种“读到旧图”的 bug 更隐蔽:画面看起来似乎对,换个机位或分辨率就出错。

提示如果只是某台相机需要深度/不透明纹理(比如只有主相机看得到水),不必在 URP Asset 里全局打开:每台相机的 Rendering 面板里有 Depth Texture / Opaque Texture 的覆盖选项(Use Pipeline Settings / On / Off)。Renderer Feature 则应在 Pass 里调用 ConfigureInput(ScriptableRenderPassInput.Depth) 等方式声明需求,URP 会按需生成。

2.8 三种渲染路径:Forward、Forward+、Deferred

Universal Renderer 的 Rendering Path 决定了“光照在哪里算、怎么找到影响某个像素的灯”。URP 14 提供三种(RenderingMode 枚举:Forward = 0、Deferred = 1、ForwardPlus = 2):

ForwardForward+Deferred CPU:给每个物体挑灯最多 N 盏(默认 4,上限 8) 着色:循环这 N 盏灯unity_LightIndices 大物体被很多灯照到时会丢灯 最简单、兼容性最好 CPU 作业:屏幕切 Tile深度切 Z-Bin,灯光入格 着色:查本像素所在格只循环相关的灯 没有每物体上限反射探针也按格处理 URP 14 新增;多灯场景首选 GBuffer Pass:只写材质属性到 4+ 张 RT 光照 Pass:每盏灯只处理它覆盖的像素 光照成本与几何复杂度解耦不支持 MSAA;透明仍走前向 带宽大,移动端慎用 共同点:主光(最亮的方向光)总是单独计算;透明物体在三种路径下都用前向方式着色。 数字依据:maxPerObjectLights(GLES2 为 4,其余 8)、ForwardLights.cs 的 Tile/Z-Bin 构建、UnityGBuffer.hlsl。
图 2-9三种渲染路径的核心差异:如何把“灯”和“像素”对应起来

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)如下:

RTRGBA格式(DeferredLights.GetGBufferFormat)
GBuffer0Albedo(漫反射色)材质标志R8G8B8A8,线性空间工程为 sRGB
GBuffer1金属度 / 高光色AO(遮蔽)R8G8B8A8_UNorm
GBuffer2世界空间法线(编码)光滑度取决于 Accurate G-buffer normals
GBuffer3GI + 自发光(即相机颜色缓冲)—同相机颜色格式
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。

Assets/Cookbook/Ch02/Editor/Ch02Shots.cs(节选:paths)
[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 个球 + 一整块地面
}
图 2-10左:Forward(地面只被 4 盏灯照亮);中:Forward+;右:Deferred。后两者画面几乎一致

左图的地面上只有 4 团光斑,其余 20 盏灯“消失”了;而每个小球都比较亮——每个球是独立的物体,各自挑了离自己最近的 4 盏灯。这就是 Forward 每物体上限的典型症状:大物体丢灯。中、右两图几乎一模一样:Forward+ 与 Deferred 在“能照亮多少灯”上没有区别,它们的差异在性能特征和功能限制上,而不是画面上。

案例 2.4Forward 的每物体光源上限入门单独打开 ↗

目标:仍用 Forward 路径,分别尝试“把上限提到 8”和“把地面拆成 12×12 块”,理解每物体上限的含义。

图 2-11Forward 路径。左:整块地面,上限 4;中:整块地面,上限 8;右:地面拆成 144 块,上限 4

上限提到 8,地面多了 4 团光斑,但离“全部 24 盏”还差得远,而且 8 已经是硬上限。把地面拆成小块后,每一块只被附近少数几盏灯照到,4 盏的额度绰绰有余,画面就和 Forward+ 一样了。这正是 Forward 时代的经典做法:大面积的地形、地面要切块。代价是 Draw Call 和剔除开销增加——Forward+ 从根本上消除了这个两难。

渲染路径怎么选

考量ForwardForward+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由谁绘制用途
UniversalForwardDrawOpaqueObjects / DrawTransparentObjects(Forward、Forward+)前向着色的主 Pass
SRPDefaultUnlit(或不写 LightMode)同上;Deferred 下由 Forward Only Pass 绘制无光照 Pass、额外的描边 Pass 等
UniversalForwardOnlyForward/Forward+ 的不透明/透明绘制;Deferred 下的 Forward Only Pass不能走延迟光照的材质(Unlit、毛发、卡通)
UniversalGBufferGBufferPass(Deferred)写 GBuffer
ShadowCasterMainLight / AdditionalLights ShadowCaster写阴影图(第 6 章)
DepthOnlyDepthOnlyPass(Depth Prepass)只写深度
DepthNormals / DepthNormalsOnlyDepthNormalOnlyPass写深度与法线(SSAO、贴花等需要)
MotionVectorsMotionVectorRenderPass逐物体运动矢量(TAA、运动模糊)
Meta烘焙系统(编辑器)光照贴图烘焙时提取反照率、自发光(第 8 章)
Universal2D2D Renderer2D 光照
其他任意名字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 会画哪些。

Assets/Cookbook/Ch02/LightModeDemo.shader(节选:passes)
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 上 Add Renderer Feature › Render Objects,Event 选 AfterRenderingOpaques,Filters 里 Queue 选 Opaque、Layer Mask 选 Everything,LightMode Tags 填 CookbookXRay。

图 2-12左:只有 UniversalForward;中:加上无 LightMode(SRPDefaultUnlit)的描边 Pass,URP 在同一次绘制中画了它;右:再加 RenderObjects Feature 点名 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 要做的准备工作,所以它对“材质多、物体多”的场景尤其有效。

传统:每次绘制都组装上传 Draw 1:设 Shader + 上传材质A + 上传物体1 Draw 2:设 Shader + 上传材质A + 上传物体2 Draw 3:设 Shader + 上传材质B + 上传物体3 材质没变也要重复上传 SRP Batcher GPU 常驻材质A 的 CBUFFER材质B 的 CBUFFER 大缓冲(逐物体)物体1 | 物体2 | 物体3UnityPerDraw,批量更新 SRP Batch:同一 Shader 变体,只设一次 Shader每个 Draw 只需“指一下”偏移 材质属性只在改变时上传 前提:Shader 的材质属性全在 UnityPerMaterial 里、引擎内置的逐物体属性全在 UnityPerDraw 里, 这样两类数据才能各自放进固定布局的缓冲区。Draw Call 数量不变,变少的是 CPU 的准备工作。
图 2-13SRP Batcher 原理:材质数据常驻 GPU,逐物体数据批量更新,同一 Shader 变体的连续绘制合成一个 SRP Batch

要做到这一点,Shader 必须让引擎能“看懂”数据的布局:所有材质属性放在同一个名为 UnityPerMaterial 的常量缓冲里,并且每个 Pass 中这个缓冲的内容和顺序完全一致;引擎内置的逐物体属性(unity_ObjectToWorld、光照探针系数等)放在 UnityPerDraw 里——这一部分 URP 的 UnityInput.hlsl 已经替你声明好了,只要 include URP 的 Core.hlsl 就行。

案例 2.6兼容与不兼容 SRP Batcher 的 Shader入门单独打开 ↗

目标:对比两个功能完全相同的 Shader,一个兼容 SRP Batcher,一个不兼容,并学会检查兼容性。

Assets/Cookbook/Ch02/SrpBatcherCompatible.shader(节选:cbuffer)
// 纹理对象本身不进 CBUFFER,但它的 _ST(缩放平移)是数值,要进
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    half4  _BaseColor;
CBUFFER_END
// 纹理对象本身不进 CBUFFER,但它的 _ST(缩放平移)是数值,要进
uniform sampler2D _BaseMap;
// CBUFFER_START(UnityPerMaterial)
uniform vec4 _BaseMap_ST;
uniform vec4 _BaseColor;
// CBUFFER_END
Assets/Cookbook/Ch02/SrpBatcherBroken.shader(节选:broken)
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
float4 _BaseMap_ST;     // ✗ 不在 UnityPerMaterial 里
half4  _BaseColor;      // ✗ 不在 UnityPerMaterial 里
uniform sampler2D _BaseMap;
uniform vec4 _BaseMap_ST;     // ✗ 不在 UnityPerMaterial 里
uniform vec4 _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、小地图叠加。

Base 相机清屏,画场景 Overlay 1(武器) Overlay 2(3D UI) 同一个颜色缓冲依次叠加深度按需清除 最后一台相机才解析到屏幕(resolveFinalTarget) Stack 的顺序就是绘制顺序
图 2-14相机栈:Overlay 相机共享 Base 相机的渲染目标,按 Stack 顺序叠加

源码里的几条规则(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 关闭和开启的效果。

Assets/Cookbook/Ch02/Editor/Ch02Shots.cs(节选:stack)
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);
图 2-15左:只有 Base 相机;中:加 Overlay 相机,不清深度(手持物插进了近处的柱子里);右:Overlay 相机清除深度(手持物总在最前面)

中图里,Overlay 相机沿用了 Base 相机的深度缓冲,于是手持物和场景做了正常的深度测试,右下部分被离相机很近的柱子挡住——第一人称游戏里“枪管插进墙里”就是这个样子。勾上 Clear Depth 后,Overlay 相机开始渲染前把深度清空,手持物永远画在场景之上。代码里通过 SerializedObject 修改 m_ClearDepth,是因为 URP 14 的 UniversalAdditionalCameraData.clearDepth 只有 getter;在编辑器里直接勾选 Overlay 相机 Rendering 面板中的 Clear Depth 即可。

提示手持物常用比场景更小的 FOV 来避免透视畸变。不同 FOV 的两台相机,深度值的含义不同,这时混用深度缓冲(不清深度)就失去了物理意义——再一次说明手持物的 Overlay 相机通常应该清深度。

2.12 Frame Debugger:亲眼看一帧

本章讲的所有顺序,都可以用 Frame Debugger 验证。它是 URP 开发中最常用的调试工具:

  1. 打开 Window › Analysis › Frame Debugger,点 Enable。Game 视图会冻结在当前帧(编辑器在 Play 和非 Play 状态下都能用)。
  2. 左侧是本帧的事件树。URP 的事件按 ProfilingScope 分组,名字来自源码里的 URPProfileId 与各 Pass 的 profilingSampler,例如 MainLightShadow、DrawOpaqueObjects、CopyDepth、CopyColor、DrawTransparentObjects、UberPostProcess、FinalBlit 等(不同版本可能略有差异)。
  3. 拖动滑块或点选事件,Game 视图会显示“画到这一步为止”的渲染目标内容——正是案例 2.1 用代码做的事。右上角可以切换查看的 RT(颜色的各通道、深度)。
  4. 选中一个绘制事件,右侧面板显示所用 Shader、Pass 名、LightMode、关键字、渲染状态(Blend/ZTest/Cull 等)、所有绑定的纹理与数值属性。“我的 Shader 到底用了哪个变体”“这张纹理绑上没有”这类问题,在这里一眼可见。
  5. 查看 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 节)
移动端起步建议以下只是常见的出发点,最终要以真机性能分析为准:Forward 或 Forward+;HDR 视需要开启,开启时保持 32 Bits 精度;MSAA 2×–4×(TBDR 上相对便宜);Render Scale 0.8 左右;Depth/Opaque Texture 能关就关,必须用时考虑 Depth Texture Mode = After Transparents 与 Opaque Downsampling;阴影距离、级联数、分辨率都往小了调;不使用 Depth Priming。

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 的一项设置,看清楚每个开关到底改变了什么。

图 2-16细栏杆与斜边。左:MSAA 关闭;中:MSAA 4×;右:MSAA 4× 且 Render Scale = 0.5

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

图 2-17发光球 + Bloom + ACES 色调映射。左:HDR 关闭;右:HDR 开启

三个球的自发光强度都远大于 1。HDR 关闭时,颜色缓冲是 8 位的,写入时超过 1 的部分被直接截断:橙色 (4, 1.2, 0.16) 被截成 (1, 1, 0.16),于是变成了黄色——截断不仅丢亮度,还改变色相,之后再做色调映射也找不回来。HDR 开启时,颜色缓冲保留了真实的数值,色调映射把它们“温柔地”压回显示范围,橙色依然是橙色,金属纽结上的高光也足够亮,能触发 Bloom。这就是“色调映射必须配合 HDR”的原因,第 9、27 章会从原理上展开。

配图说明根据 2.7 节“深入”提到的 URP 14 规则,相机渲染到 RenderTexture 时中间缓冲沿用目标 RT 的格式。因此这两张图是分开拍的:左图渲染到 8 位 RT(同时关闭 URP Asset 的 HDR),右图渲染到浮点 RT(开启 HDR)再转存成图片。如果你自己做截图工具、离屏渲染或小地图,想保留 HDR 效果,目标 RT 也必须是浮点格式。

2.14 与 Built-in 管线的差异速览

从 Built-in 迁移过来的读者,可以用下表把旧概念对应到 URP 的新位置(第 25–27 章会逐项展开):

Built-in 中的做法URP 14 中的对应备注
Camera.depthTextureMode |= DepthTextureMode.DepthURP Asset / 相机的 Depth Texture;Pass 中 ConfigureInput生成方式见 2.6 节
GrabPass { }Opaque Texture(_CameraOpaqueTexture)只有不透明物体与天空;时机固定在天空盒之后
OnRenderImage + Graphics.BlitVolume 后处理;自定义 Renderer Feature + BlitterURP 不调用 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 + Overlay2.11 节
Rendering Path:Forward / Deferred / Legacy Vertex LitForward / Forward+ / Deferred2.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 要求材质属性全部放在 UnityPerMaterial CBUFFER 中且各 Pass 一致。
  • 相机栈中 Overlay 相机不清颜色、可选清深度;Frame Debugger 是验证一切顺序与状态的第一工具。

练习

  1. 在 FrameSnapshotFeature 中加一个时刻 AfterRenderingPrePasses,并在 URP Asset 中打开 SSAO(Renderer Feature)。观察快照内容,并在 Frame Debugger 中找到 DepthNormals Prepass,解释为什么它出现了。
  2. 把案例 2.2 的 Renderer 改为 Depth Texture Mode = After Transparents,在编辑器 Play 模式下观察水面。再把同一个 Shader 的 Queue 改成 Geometry+1(但保持透明混合),会怎样?为什么?
  3. 修改案例 2.3 的场景:把灯光数增加到 300 盏,分别在 Forward+ 与 Deferred 下观察。哪一种开始“丢灯”?结合 MAX_VISIBLE_LIGHTS 解释。
  4. 给 LightModeDemo 的描边 Pass 也加上 Tags { "LightMode" = "SRPDefaultUnlit" },效果是否变化?再把 Renderer 切换成 Deferred,描边 Pass 和 Forward Pass 分别由谁绘制?
  5. 阅读 UniversalRenderer.Setup 中 requiresDepthPrepass 的计算过程,列出至少五种会导致出现 Depth Prepass 的情况。