GameTech/URP 渲染实战

案例 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 里不会有它们。