Shader 学习 21:自定义 Render Feature

为什么有些效果一定要用feature实现?

  • 当效果需要在“相机渲染流程的某个时机”拿到或改写整张屏幕的渲染结果时。

    例如:

    • 全屏后处理:模糊、调色、描边、像素化、镜头特效

    • 依赖相机深度/法线的效果:SSAO、基于深度的雾、屏幕空间描边

    • 需要渲染一张额外全局纹理:角色遮罩、扭曲贴图、分层结果

    • 必须控制执行顺序:在不透明物体后、透明物体前,或最终画面之后执行

但“全局数据”不一定都需要 Feature。比如某个全局 Shader 参数、全局贴图,只要脚本用Shader.SetGlobal...设置即可;材质在绘制时自然能读到它。


框架实现

需要打通Camera → Feature → Pass → Shader → GPU这条链路

ScriptableRendererFeature这个类的核心函数是:CreateAddRenderPasses负责feature自己的构造函数,以及将某些自定义pass加入到渲染队列中

  • Create:同步new一个Pass,并把feature的配置数据给它

  • AddRenderPasses:将Pass加入队列

既然要new pass与配置,就需要在该Feature中,创建两个SettingsScriptableRenderPass

  • Settings:这个配置内容会显示在 UniversalRenderData 的 Inspector 的对应 feature 上

  • ScriptableRenderPass:要有自己的构造函数,以及执行内容

    • Execute:写 CommandBuffer

using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; namespace MiniRenderer.Rendering { public class FullscreenTintRendererFeature : ScriptableRendererFeature { // 1. // 会显示在 Renderer Feature 的 Inspector 中,供你调参。 // 这里只是声明了一个 Serializable 的类,真正让这个Feature有该序列化数据,还需要下面new一个 [System.Serializable] public class Settings { public RenderPassEvent renderPassEvent = RenderPassEvent.AfterRenderingTransparents; public Color tintColor = Color.cyan; [Range(0f, 1f)] public float intensity = 0.2f; } [SerializeField] private Settings settings = new Settings(); // 2. // 这是该feature持有的一个pass // 这个类将来会录制“全屏滤镜”的 GPU 命令。 // 这个pass会做什么,都写在Execute中 private class TintPass : ScriptableRenderPass { private readonly Settings settings; public TintPass(Settings settings) { this.settings = settings; } public override void Execute( ScriptableRenderContext context, ref RenderingData renderingData) { // 在这里写 CommandBuffer。 } } // 3. // feature调度pass加到渲染队列中 private TintPass tintPass; public override void Create() { // Feature 负责注册;真正干活的是 Pass。 // 所以在feature的构造函数中,用我们的配置setting,new一个要干活的Pass tintPass = new TintPass(settings) // 存下来,不用每帧创建 { // 把 Inspector 选的渲染时机交给 URP。 renderPassEvent = settings.renderPassEvent }; } public override void AddRenderPasses( ScriptableRenderer renderer, ref RenderingData renderingData) { // 每次相机渲染时,把 Pass 加进 URP 队列。 renderer.EnqueuePass(tintPass); } } }

临时 Render Target 的必要性

必要性在于:读取相机颜色 A → 处理 → 立刻写回同一张 A的操作,如果GPU同时读写同一张纹理 不安全,结果可能不确定,所以正确的行为是要“乒乓”一次:

相机颜色 A → 临时纹理 B → 处理后的结果写回 A

// 1. 创建一张与来源画面兼容的临时纹理 B。 var temporary = AllocateCompatibleTarget(source); // 2. 第一次 Blit:把来源画面 A 复制到 B。 cmd.Blit(source, temporary); // 3. 第二次 Blit:读取 B,执行材质/Shader,并写入管线指定的输出目标。 cmd.Blit(temporary, destination, material); // 4. 释放 B。 Release(temporary);
  • commandBuffer.Blit(src, dest, mat, pass)

URP框架提供的封装写法

在 URP 的ScriptableRenderPass中,如果不需要手动管理临时 RT,可以调用它提供的Blit封装。传入效果材质后,URP 会在内部选择相机颜色的前后 Buffer,并在渲染完成后交换它们。

这行代码的内部逻辑仍是“读取当前颜色 Buffer → 写入另一颜色 Buffer → 交换 Buffer”。区别是临时 Buffer 的选择、写入目标和交换操作都由 URP 管理,因此不需要手动创建和释放临时 RT。

// 注意:这是 ScriptableRenderPass 的 Blit, // 不是 commandBuffer.Blit。 Blit(commandBuffer, ref renderingData, tintMaterial);