☰
Unity 6 URP中ScriptableRendererFeature实现全屏贴图叠加
2026/9/28 12:04:21 网站建设 项目流程

上周美术同事丢给我一张在Photoshop里画好的墨点贴图,说希望角色受击时屏幕边缘浮现墨色特效,而且要能在URP项目里跑起来。这种需求在老项目中其实很直接:OnRenderImage里Graphics.Blit一下就完事。但项目从Built-in管线迁到Unity 6000.0.3(也就是Unity 6)之后,情况变了——URP把渲染流程完全交给自己管理,要在管线里“插一脚”,正统入口就是ScriptableRendererFeature。

这篇文章我就拿Unity 6000.0.3 + URP 17.0.x环境,完整走一遍“通过ScriptableRendererFeature把一张自定义贴图渲染到画面里”的过程。不只有能直接复制的代码,还会把我踩过的API迁移、SRP Batcher、ZTest状态、多相机重复执行这些坑全部摊开讲。适合刚接触URP扩展机制、想搞全屏特效或贴花效果的开发者,也适合在从Built-in往URP迁移时被卡住的人。

1. 为什么偏偏要用ScriptableRendererFeature做这张贴图?

1.1 “自定义贴图效果”到底是哪种需求

先说清楚我们到底要做什么。很多美术或策划口中的“自定义贴图效果”,实际上指的是:在某一帧或某段时间内,把一张自己准备的纹理叠加到当前相机画面上。

这类需求在项目里特别常见:

  • 角色受血时屏幕边缘溅血贴图
  • 转场时的水墨晕染或黑幕贴图
  • 监视器画面里的扫描线、噪点贴纸
  • 雨天玻璃上的水滴贴图、冰雪覆盖效果
  • 屏幕角落的武器图标、准星光晕

这些效果的核心特征是一样的:一张贴图 + 一种混合方式 + 一个出现的时机。而我在这篇文章里做的就是“在URP管线的某个阶段拦截画面,把自定义贴图混合进去,再交还给管线继续渲染”。

1.2 为什么不能直接放UI或者用粒子

有人会问,这种效果用UI面板里的Image放一张贴图不就行了?很多时候还真不行。

UI图层跑在Canvas上,它不参与3D相机的渲染,不受场景深度、雾效、后处理的影响。你想要的是“墨色从屏幕边缘蔓延后逐渐遮住整个世界”,UI只能盖一个固定矩形,没有办法和相机画面做真正的像素级混合,也没有办法跟后处理链(比如Bloom、Color Grading)联动。

粒子方案也有局限:粒子本质是3D空间里的网格,要模拟“全屏流动的贴图”需要一直正对相机、处理相机裁剪和深度冲突,而且在URP里粒子对后处理的支持也不天然。

所以正确的做法是在渲染管线上动手。URP提供了一套扩展机制,叫ScriptableRendererFeature,它允许你往相机颜色缓冲写入、读取、混合。这看起来是一个“很底层”的玩法,但实际代码量并不大。

1.3 这个方案的项目与版本边界

这套写法适用于:

  • 项目渲染管线已切换为URP
  • Unity版本在6000.0.x(特别是6000.0.3)
  • 目标是全屏后处理类效果或自定义Pass注入

如果你还在Built-in管线,那没必要用这个方案,直接OnRenderImage反而更快。如果项目是HDRP,思路类似但API完全不同,HDRP更推荐Volumetric或Custom Pass Volume,不建议照搬URP这套。

另外强调一个版本问题:Unity 6的URP包版本已经是17.0.x,和早期URP 12、14、16存在不少API差异。很多你在网上搜到的老教程还在用CommandBuffer.Blit或GetTemporaryRT,这些写法在Unity 6下运行时会报错或产生大量警告,后面我会具体讲替换方案。

2. 动手之前,先把三个角色的分工搞清楚

2.1 ScriptableRendererFeature、ScriptableRenderPass和Shader材质

在URP里做自定义渲染效果,一共涉及三个角色,初学者最容易把它们的职责搞混。

第一个是ScriptableRendererFeature。你可以把它理解成“每帧向渲染流程报名的那张节目单”。URP在渲染每个相机时,会遍历所有Renderer Feature列表,问它们“这一帧你们要不要干活”。干活就调用AddRenderPasses,把需要执行的Pass塞进队列。

第二个是ScriptableRenderPass。它代表“真正的干活环节”。当URP执行到这个Pass时,会拿到CommandBuffer,往里写入渲染指令:设置RenderTarget、Blit拷贝、DrawProcedural等等。同一个Feature名下可以挂多个Pass,也可以动态决定这一帧要不要插队。

第三个是Shader和材质。Feature和Pass只是搭好了“渲染管线的舞台”,具体画面长什么样,全看Shader怎么写。我们要把自定义贴图和原画面混合,这一步是在Shader的fragment shader里完成的。材质负责把贴图、透明度、颜色等参数喂给Shader。

用一个容易理解的类比:Feature是导演,Pass是拍摄流程表,Shader就是摄影机和滤镜。导演决定要不要开机,流程表决定第几个场景拍,滤镜决定最终画面是什么风格。

2.2 关键的RenderPassEvent和渲染顺序

URP渲染流程不是铁板一块,它把渲染拆成了很多阶段,ScriptableRenderPass通过renderPassEvent属性声明自己要在哪一步执行。

常见的事件节点:

  • BeforeRendering:最早期,在几乎任何东西之前
  • AfterRenderingOpaques:不透明物体渲染完之后
  • BeforeRenderingPostProcessing:后处理开始之前
  • AfterRenderingPostProcessing:后处理全部结束之后
  • AfterRendering:最后兜底

这个顺序对你的效果位置影响极大。如果贴图希望和场景一起被后处理影响,比如被Bloom泛光、被颜色分级调整,那就放在BeforeRenderingPostProcessing。如果贴图要作为最终叠加层压在画面最顶上,不希望再被任何后处理改动,就放在AfterRenderingPostProcessing。

我这次示例用的是AfterRenderingPostProcessing,因为“屏幕墨迹”“溅血屏”“转场遮罩”这类效果通常就是最后盖上去的。

2.3 Unity 6000.0.3的URP 17版本注意点

Unity 6的URP包是17.0.x,相比旧版本,有几个不太一样的地方。

第一个是RTHandle成为主流。以前经常用RenderTexture.GetTemporary或者RenderTargetIdentifier来管理临时纹理,现在URP内部普遍使用RTHandle,推荐你也用RTHandle来管理中间缓存。

第二个是Blitter。老教程里常见的CommandBuffer.Blit在新版本里也可以跑,但Console会弹警告,而且在使用RenderGraph模式时会被明确禁止。新推荐的是Blitter.BlitCameraTexture,这是Core RP里提供的统一Blit接口,效率更高,也不再针对内置管线做兼容。

第三个是RenderGraph。Unity 6的URP支持两种执行模式:经典模式(Compatibility Mode)和RenderGraph模式。如果你的项目把渲染路径切到了RenderGraph,那么ScriptableRenderPass里传统的Execute方法根本不会被调用,需要换用RecordRenderGraph接口。这对很多老教程里的代码几乎是毁灭性的。实操建议是:如果你只是想快速实现贴图效果,把RenderGraph关掉,保持在经典模式;等效果稳定后再研究迁移。

2.4 先想清楚:你想渲染到哪一步

这个决策比写代码更重要。同样是“一张自定义贴图”,你可以:

  • 全屏覆盖:贴图直接铺满屏幕,配合透明通道做混合
  • 只在某块区域出现:通过UV坐标或屏幕空间坐标限制
  • 贴到3D物体表面:这里更像Decal贴花,需要额外重建世界空间坐标

我接下来先讲全屏覆盖,最后在扩展部分讲如何改成贴花。因为全屏覆盖覆盖了最核心的Feature、Pass、Shader流程,后面改任意方向都只是换Shader和注入逻辑,外围骨架不变。

3. 手把手实现:从零到一张贴图出现在画面上

3.1 项目准备与Renderer配置

先确认你的项目环境:

  • Unity版本选6000.0.3,可以查看Help -> About Unity确认
  • 项目创建时选Universal 3D模板,或者安装com.unity.render-pipelines.universal包
  • 检查URP Asset的配置:Project Settings -> Graphics -> Scriptable Render Pipeline Settings

然后找到你的Forward Renderer Data资产。它通常位于Assets/.../Settings/或者和UniversalRenderPipelineAsset放在一起。双击打开后,你会看到一个“Renderer Features”列表。我们一会儿要写的Feature就会挂在这里。

如果你的项目用的是多Renderer,记得选对你实际应用的那个Renderer。这一步很容易忽略,后面出现“为什么没生效”的困惑时,十有八九就是Feature挂错了Renderer。

3.2 写Feature脚本

Feature脚本的职责非常简单:声明一个Settings类,在Create里创建Pass,在AddRenderPasses里判断是否需要入队。

using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class CustomTextureFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public Material material; public RenderPassEvent renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing; public string passName = "CustomTexturePass"; } public Settings settings = new Settings(); private CustomTexturePass _pass; public override void Create() { _pass = new CustomTexturePass(settings.material, settings.renderPassEvent); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.material == null) { Debug.LogWarning("CustomTextureFeature: material is null, pass skipped."); return; } _pass.SetMaterial(settings.material); renderer.EnqueuePass(_pass); } protected override void OnDisable() { _pass?.Dispose(); } }

注意几个点:

  • Create方法只在运行时初始化时调用一次,不要把材质判断写在这里,因为编辑器下Inspector可能会改材质引用。
  • AddRenderPasses每帧都会执行,你可以在这里加逻辑控制“什么时候让效果生效”,比如用变量控制开关。
  • OnDisable里释放Pass持有的临时纹理资源,防止泄漏。

3.3 写Pass脚本

Pass才是核心。它做的事情是:在渲染流程走到你指定的事件节点时,把当前相机颜色画面复制到临时RT,再带着临时RT和材质Blit回相机目标。

using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class CustomTexturePass : ScriptableRenderPass, IDisposable { private Material _material; private RTHandle _tempRT; public CustomTexturePass(Material material, RenderPassEvent renderPassEvent) { _material = material; this.renderPassEvent = renderPassEvent; } public void SetMaterial(Material material) { _material = material; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { if (_material == null) return; var renderer = renderingData.cameraData.renderer; var colorTarget = renderer.cameraColorTargetHandle; RenderTextureDescriptor desc = renderingData.cameraData.cameraTargetDescriptor; desc.depthBufferBits = 0; desc.msaaSamples = 1; RenderingUtils.ReAllocateHandleIfNeeded(ref _tempRT, desc, name: "_CustomTextureTempRT"); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (_material == null) return; CommandBuffer cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, new ProfilingSampler("CustomTexturePass"))) { var renderer = renderingData.cameraData.renderer; var source = renderer.cameraColorTargetHandle; // 先把当前相机画面复制到临时RT,避免对相机目标的读写风险 Blitter.BlitCameraTexture(cmd, source, _tempRT); // 再带着临时RT作为源输入,走我们的材质shader写回相机目标 Blitter.BlitCameraTexture(cmd, _tempRT, source, _material, 0); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() { _tempRT?.Release(); } }

这里有个小设计必须说明:为什么不能直接Blitter.BlitCameraTexture(cmd, source, source, _material, 0)?

因为在很多图形API下,同一个RT既当输入又当输出时,行为是不可预测的,尤其移动端Vulkan或Metal上容易出现锯齿、错位甚至黑屏。稳妥做法是先复制一份,再读取临时RT写回原目标。多花一次Blit的成本,但换来的是跨平台稳定。

3.4 写Shader并创建材质

Shader就是要实现混合逻辑:读取上一帧画面和自定义贴图,按透明度混合。

这里直接用Blit.hlsl里自带的Vert顶点函数,不用自己写全屏三角形的顶点。Blit.hlsl会为我们提供_BlitTexture采样器,并把源纹理绑定进去。我们需要额外做的就是声明自定义贴图_OverlayTex。

Shader "Custom/CustomTextureBlit" { Properties { _OverlayTex ("Overlay Texture", 2D) = "black" {} _Intensity ("Intensity", Range(0, 1)) = 1.0 _Tint ("Tint", Color) = (1,1,1,1) } SubShader { Tags { "RenderPipeline" = "UniversalPipeline" } ZWrite Off ZTest Always Cull Off Pass { HLSLPROGRAM #pragma vertex Vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" TEXTURE2D(_OverlayTex); SAMPLER(sampler_OverlayTex); float _Intensity; float4 _Tint; half4 frag(Varyings input) : SV_Target { float2 uv = input.texcoord; half4 src = half4(SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_BlitTexture, uv).rgb, 1.0); half4 overlay = SAMPLE_TEXTURE2D(_OverlayTex, sampler_OverlayTex, uv); half3 mixed = lerp(src.rgb, overlay.rgb, overlay.a * _Intensity); return half4(mixed * _Tint.rgb, src.a); } ENDHLSL } } }

关于采样器:_BlitTexture来自Blit.hlsl,它代表你通过Blitter.BlitCameraTexture传入的源RT。因此不要在shader里再声明一份_BlitTexture,否则会和内置声明冲突,导致SRP Batcher或Shader编译报重复定义。

创建材质:

  • 在Project窗口右键 -> Create -> Material
  • Shader选择刚刚写的Custom/CustomTextureBlit
  • 把需要叠加的贴图拖到Overlay Texture槽位
  • Intensity控制混合强度,Tint控制染色

材质建好后,拖到Feature的Material字段。

3.5 挂载到Renderer并调试

找到Forward Renderer Data,点开Renderer Features列表右下角的“Add Renderer Feature”,选择CustomTextureFeature。

确保Material已赋值,然后运行游戏。你会在Game视图看到贴图叠加在原画面之上。

如果没效果,按顺序排查:

  1. Renderer是否针对当前相机脚本
  2. Feature是否在正确的Renderer下
  3. Material是否非空
  4. renderPassEvent是否被后处理之前的阶段把效果“洗掉”了
  5. 查看Frame Debugger,确认CustomTexturePass有没有出现在渲染时序中

Frame Debugger是URP排查这类问题的神器,Window -> Analysis -> Frame Debugger,在Game视图运行后可以逐级查看每个RenderPass的执行顺序。如果列表里没有我们的Pass,说明Feature压根没被触发;如果有但画面没变,问题多半在Shader混合逻辑或RT写入。

4. 实测中躲不开的几个大坑

4.1 CommandBuffer.Blit迁移问题

网上大量教程还在用cmd.Blit(src, dest, material)。在Unity 6的URP 17里,这个API不会直接阻止你运行,但会输出一条警告,提示使用Blitter替代。更麻烦的是,在RenderGraph模式下它会直接报错。

我的建议是不要绕过警告。你既然新项目用Unity 6,就从开始用Blitter.BlitCameraTexture,省得以后升级再改一遍。

Blitter.BlitCameraTexture的源和目标参数都是RTHandle。如果你的项目里需要临时纹理,不要再用RenderTexture.GetTemporary那一套,改用RTHandle加RenderingUtils.ReAllocateHandleIfNeeded。这个工具类会根据已有描述复用缓存,避免每帧申请新纹理导致的GC和GPU分配压力。

4.2 忽略ZTest/ZWrite导致效果被吞

这是一个特别隐蔽的坑。

如果Shader里没有写ZTest Always和ZWrite Off,URP在做全屏Blit时可能会沿用当前深度测试状态。结果就是贴图可能被场景里的深度挡掉,显示不完整,或者完全看不见。有些情况下你会看到贴图时有时无,尤其在有半透明物体或UI层参与的场景里。

全屏后处理Shader必须固定这套状态:

ZWrite Off ZTest Always Cull Off

如果你的效果不是全屏,而是想贴到某些物体上被深度遮挡,那就反过来:保留深度测试,ZTest LEqual。所以这个状态不是死规则,而是取决于效果意图。但默认的后处理叠加一定是ZTest Always。

4.3 多相机下Pass重复执行

URP里场景中可能有主相机、UI相机、反射探针、摄像机预览等多个相机。Feature默认对所有相机执行,也就是说你在主相机看到效果的同时,UI相机可能也叠了一遍贴图,导致UI被污染或效果浓度翻倍。

在AddRenderPasses里判断相机类型:

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (renderingData.cameraData.cameraType != CameraType.Game) return; // 可以再加一层相机tag判断 if (renderingData.cameraData.camera.name != "Main Camera") return; renderer.EnqueuePass(_pass); }

实测中建议用CameraType判断兜底,再用name或tag精确控制。否则你在Scene视图调效果时怎么都对,切到Game视图效果乱了,多半就是这个原因。

4.4 RenderGraph模式与经典Execute的冲突

Unity 6的URP支持RenderGraph模式,在URP Asset的Renderer部分可以看到RenderGraph开关。开启后,管线会换一套执行机制,传统ScriptableRenderPass的Execute不会被调用。

判断方式很简单:在AddRenderPasses里加一条Debug或其他输出,如果Feature调到但Pass内没执行,而你又开着RenderGraph,那就是它没跑。

如果想快速复现这篇文章的效果,把RenderGraph关掉,用经典模式。后续若想开启RenderGraph,需要重写Pass为RecordRenderGraph接口,这是另一个复杂的话题,建议单独研究,不要和这篇文章混做。

4.5 颜色空间和MSAA对Blit的影响

URP默认颜色空间是Linear。在Blit时,如果Shader里没有正确转换,直接采样线性纹理再输出,最终画面可能出现饱和度偏移。我们的示例是简单混合,不做额外转换,基本没大问题。但如果贴图是美术在sRGB空间画的,采样时可能需要_OverlayTex_HDR之类的标记辅助判断。

还有MSAA:主相机的颜色目标可能带有MSAA采样。如果临时RT不处理MSAA,Blit时会出现边缘锯齿或错位。所以在OnCameraSetup创建临时RT时,我把desc.msaaSamples强制设成1,这样复制出来的贴图就是解析后的单采样纹理,再做全屏混合也是安全的。

MSAA只影响场景几何的抗锯齿,不影响后处理叠加的最终输出,所以这个设置是合理的。

5. 从“一张贴图”进化到真正可用的效果

5.1 动态控制:让贴图随时间动起来

如果只是静态叠加,几行代码就够了。但真实项目里,贴图效果几乎都需要动态驱动。

可以在Shader里加时间变量,让UV随时间偏移,实现雨丝流动、雾气翻滚:

float2 uv = input.texcoord; uv.x += _Time.y * _ScrollSpeed; half4 overlay = SAMPLE_TEXTURE2D(_OverlayTex, sampler_OverlayTex, uv);

同时暴露_ScrollSpeed属性。这样贴图本身不用动,画面里的墨迹、雨丝就能自己“流动”。

透明度淡入淡出通常在C#侧控制,因为你需要根据游戏事件(受击、转场)来触发。简单做法是在Feature里声明一个Intensity字段,每帧用Mathf.Lerp往Material传:

float intensity = Mathf.Lerp(_currentIntensity, _targetIntensity, Time.deltaTime * _smoothTime); _material.SetFloat("_Intensity", intensity);

这段逻辑可以放在AddRenderPasses里,因为它在每帧渲染时执行,正好能驱动材质参数。

5.2 从全屏到贴花:让贴图出现在3D表面

全屏叠图层太粗暴,有些场景想要的是“贴图贴在地板上或墙上”,这时候思路要变。

核心改动在Shader:不再直接用input.texcoord作为贴图UV,而是用屏幕空间坐标重建世界坐标,算出一个贴花投影坐标。URP里可以通过Camera.relativeWorldToCamera矩阵或者_CameraDepthTexture做深度恢复。

这个方向适合做局部的脚印贴花、弹孔贴花、路面标记。实现难度比全屏高不少,但骨架还是我们写好的Feature和Pass,只需要替换RenderPass里注入的时机(通常选在AfterRenderingOpaques之前或之后),以及换成重构世界坐标的Shader。

5.3 性能与工程组织建议

第一,避免每帧创建新材质。Feature里的Settings.material就是你在Inspector里拖进去的那份,千万别为了改参数就new一个材质实例。临时修改材质属性没问题,但反复创建材质会导致材质球泄漏,也破坏SRP Batcher的合批。

第二,临时RT要复用。我们用的ReAllocateHandleIfNeeded就是这个目的。如果每帧都RTHandle.Alloc,你会发现内存占用巨高,GC也频繁。

第三,合并多个Pass。如果你同时要叠加多张贴图,例如“墨点+屏幕网格+暗角”,不要拆分三个Feature。可以在一个Feature里跑两遍Blit,或者在Shader里采样多张贴图。多一个全屏Pass就多两到三遍全屏三角形渲染,移动端帧率会很敏感。

第四,移动端要关注Overdraw。全屏后处理叠加本质是往屏幕每个像素写一次,这种效果在低端手机上比较昂贵。如果只是短暂出现(如受击0.5秒内),问题不大;如果常驻,就要考虑降低分辨率执行,或者用Shader的LOD区分高低端机。

最后一点,从项目工程角度说,我给这类效果做了统一的ScriptableObject配置。每个效果对应一份配置:贴图、混合方式、注入点、开关、持续时长。这样美术不需要碰代码,直接在资产里调参数就能预览效果。Feature只负责读配置执行,画面表现全部数据化。这也是我目前项目里比较稳定的落地方式。

如果你只是临时加一个效果,按文章里的四个步骤走完就够了;但如果是要长期维护的项目,建议统一整理成配置驱动的形式,后面的收益远比多写几十行代码高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询