Unity 6 URP中ScriptableRendererFeature实现全屏贴图叠加
发布时间:2026/9/28 12:04:23来源:尧图网络
上周美术同事丢给我一张在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 这个方案的项目与版本边界这套写法适用于项目渲染管线已切换为URPUnity版本在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 - MaterialShader选择刚刚写的Custom/CustomTextureBlit把需要叠加的贴图拖到Overlay Texture槽位Intensity控制混合强度Tint控制染色材质建好后拖到Feature的Material字段。3.5 挂载到Renderer并调试找到Forward Renderer Data点开Renderer Features列表右下角的“Add Renderer Feature”选择CustomTextureFeature。确保Material已赋值然后运行游戏。你会在Game视图看到贴图叠加在原画面之上。如果没效果按顺序排查Renderer是否针对当前相机脚本Feature是否在正确的Renderer下Material是否非空renderPassEvent是否被后处理之前的阶段把效果“洗掉”了查看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 OffURP在做全屏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不处理MSAABlit时会出现边缘锯齿或错位。所以在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只负责读配置执行画面表现全部数据化。这也是我目前项目里比较稳定的落地方式。如果你只是临时加一个效果按文章里的四个步骤走完就够了但如果是要长期维护的项目建议统一整理成配置驱动的形式后面的收益远比多写几十行代码高。
网站建设高端定制企业官网