做2.5D或伪3D项目时,经常遇到一个让人挠头的情况:角色、道具这些用SpriteRenderer摆进场景的物体,在相机上挂好URP的Depth of Field后,不管怎么调焦点距离,Sprite就像贴纸一样钉在画面上,完全不受景深影响。我一开始以为是光圈参数没调对,反复改Focus Distance和Aperture,画面纹丝不动。用Frame Debugger一查才反应过来,_CameraDepthTexture(相机深度纹理)里根本没有Sprite的深度信息。没有深度,DOF连该像素离相机多远都不知道,自然算不出模糊量。
这个问题和DOF参数一点关系都没有,根子在Sprite的渲染方式上。URP默认的Sprite Shader跑在Transparent队列,既不走深度预通道(Depth Prepass),也不开启ZWrite,深度纹理里压根不会出现Sprite的身影。这篇文章我把实际踩坑后验证过的几种处理方式整理出来:不写代码的Renderer Feature方案、把Sprite搬进Opaque队列的Shader方案,以及围绕透明排序冲突的深入分析和2D Renderer下的特殊情况,给正在被这个问题卡住的朋友一个可落地的参考。
1. 问题根源:Sprite为什么缺席URP的深度纹理
1.1 DOF到底拿什么数据算模糊
先明确一件最基本的事:DOF需要的不是场景里的“最终画面”,而是一张记录了每个像素到相机距离信息的深度图。URP的景深效果(无论是Gaussian DOF还是Bokeh DOF)拿到深度图后,会根据每个像素的深度值与DOF参数里的Focus Distance做比较,差值越大,CoC(Circle of Confusion,弥散圆)越大,模糊就越强。
这张深度图来自_CameraDepthTexture。URP生成它的方式有两种:一种是Depth Prepass,在渲染不透明物体前先把不透明物体渲染到一张深度纹理里;另一种是在不透明渲染结束后从主深度缓冲拷贝一份出来。不管哪一种,Generator阶段都只会处理不透明(Opaque)队列里的物体。这是理解整个问题的关键:深度纹理和透明队列基本是绝缘的。
1.2 Sprite的默认身份是“透明物体”
打开Unity新建一个SpriteRenderer,它自带的材质是内置的Sprite Shader,队列标签是Transparent,渲染时走的是透明混合流程,并且ZWrite默认是Off。这意味着:
- 透明队列的物体不会被URP的DepthOnlyPass抓到,因为那个Pass只处理
Opaque或AlphaTest队列; - 就算在主渲染流程里,
ZWrite Off也意味着它永远不往深度缓冲里写数据; - 所以URP在生成
_CameraDepthTexture时,Sprite在深度图里对应的地方是一片“空洞”。DOF在此处采到的深度值,实际上是Sprite背后那个不透明物体的深度。
我在项目里验证过:场景里一个Sprite角色的背后是一面墙,DOF聚焦点放在角色身上,角色却始终“清晰”。其实DOF以为角色像素的深度是墙的深度,焦点没对准墙上,所以角色没被模糊,反而后面的墙被模糊了。这个表现非常误导人,排查方向很容易带偏。
1.3 不只是Sprite,所有透明物体都在这条船上
顺着这个逻辑再往外推一步:场景里所有用Transparent队列渲染的物体,粒子、半透明面片、带Alpha的公告牌,全都不会写深度。有些后处理效果,比如SSAO、运动模糊、DOF,对它们的效果天然是缺失或不正确的。这也是为什么不少URP项目里,粒子配合DOF时会出现“粒子永远锋利”的现象。
所以“让Sprite在DOF里生效”的本质,并不是去调后处理参数,而是要让Sprite的深度信息进入_CameraDepthTexture,让DOF在采样深度时有据可依。
2. 先上免代码方案:RenderObjects Feature配合Depth Only材质
如果你不想为了这个需求去写完整的RenderPipeline代码,URP自带的RenderObjects Feature基本可以解决90%的问题。这个Feature允许你在渲染流程的指定阶段,用指定的材质和过滤条件把一部分物体再渲染一遍。我们只要让Sprite在深度纹理生成之前用只写深度的材质被“扫”一遍,问题就解决了。
2.1 配置步骤
我以URP 12(Unity 2021.3)为例,操作路径如下:
- 给需要参与DOF的Sprite单独分一个Layer,比如叫“Character”。因为后面要用Layer来做过滤,千万不要全扔Default里。
- 在URP Asset的Renderer List里找到当前使用的ForwardRenderer(或者自定义Renderer),在Inspector底部的Renderer Features点击Add Renderer Feature,选择RenderObjects。
- 配置关键参数:
| 参数 | 值 | 说明 |
|---|---|---|
| Event | BeforeRenderingOpaques | 要在不透明物体和深度纹理生成前执行 |
| Queue | Opaque | 我们用来渲染深度的材质队列 |
| Layer Mask | Character | 只处理指定层的Sprite |
| Pass Names | DepthOnly | 确保走的是URP的深度Only Pass |
| Override Material | 深度Only材质 | 见下方Shader |
| Override Material Pass Index | 0 | 指定用材质的第0个Pass |
- 为这个Feature准备一个只写深度、不写颜色的材质,Shader如下:
Shader "Custom/DepthOnlyForSprite" { SubShader { Tags { "RenderType"="Opaque" "Queue"="Opaque" } Cull Off ZWrite On ColorMask 0 Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata_t { float4 vertex : POSITION; }; struct v2f { float4 positionCS : SV_POSITION; }; v2f vert(appdata_t v) { v2f o; o.positionCS = TransformObjectToHClip(v.vertex.xyz); return o; } half4 frag(v2f i) : SV_Target { return 0; } ENDHLSL } } }有两点值得强调:一是Cull Off必须开,Sprite通常都是单面片,关了背面剔除才能保证相机转到背后时深度依然正确;二是ColorMask 0让这个Pass不输出任何颜色,只借它的深度测试和深度写入能力。
2.2 为什么这个顺序能生效
RenderObjects的BeforeRenderingOpaques事件会在URP生成深度纹理、渲染不透明物体之前执行。在这一步把Sprite用ZWrite On的材质渲染一次,Sprite的深度就进入了主深度缓冲。URP如果采用“从主深度拷贝生成_CameraDepthTexture”的方式,Sprite的深度就会跟着进入深度纹理;即使采用Prepass方式,你只要保证深度纹理是在这个Event之后生成或拷贝,一样能拿得到。
这里有个容易忽视的点:不要把这个Feature的Event设成AfterRenderingTransparents,那时深度纹理已经生成完毕,你就算又把Sprite画了一遍,也改变不了已经定型的那张_CameraDepthTexture。
2.3 这个方案的牺牲在哪里
用RenderObjects把Sprite提前渲染一遍,相当于Sprite在“深度层面”变得不透明了。如果Sprite本身是半透明的(比如光晕、烟雾,或者半透明的玻璃碎片),这个方案会出问题:深度提前占了位,导致这个Sprite后面的不透明物体在深度测试时被它挡住,渲染顺序和透明混合也会乱掉。
所以这个方案最适合的场景是:Sprite本身需要被当作一个不透明体来对待,比如角色、怪物、地面上的道具。如果你的Sprite有大量镂空区域,材质里没有做AlphaTest,那些镂空的位置也会写成实心深度,DOF会在Sprite轮廓内部出现错误的模糊边缘。要处理镂空,可以把上面的Shader加一步clip(),在片段函数里根据主纹理Alpha丢弃像素。
// frag里加入 TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); half4 frag(v2f i) : SV_Target { float alpha = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord).a; clip(alpha - 0.5); return 0; }加了clip之后,镂空区域的像素不会写入深度,DOF在Sprite镂空的位置会采到背后场景的深度,表现就正常了。
2.4 需要在脚本侧动态开关吗
如果你的DOF需求是全局常驻的,这个Feature一直开着没问题但要注意它每帧会对Sprite列表做一次额外绘制。Sprite数量几百上千时,这个开销不算小。多数情况下我建议配合一个开关策略:在需要DOF的相机上启用对应的Renderer Feature,不需要时关掉,或者用脚本在运行时设置材质的关键字。URP的RendererFeature本身没有提供运行时Enable/Disable的公开按钮,但你可以通过给Feature加一个[System.Serializable]的bool开关,在AddRenderPasses里判断false就return,这样至少在渲染流程上能省掉那一次绘制调用。
3. 更稳的一招:把Sprite直接“搬进”Opaque队列
如果你不需要Sprite像烟雾那样半透明,还有一条更干净的路:直接改Sprite使用的Shader,让它走AlphaTest队列,不透明渲染,并且正常ZWrite。这样Sprite在URP的主渲染流程里就是一个“不透明物体”,会自然地出现在深度预通道或主深度缓冲中,DOF直接生效,完全不用加RendererFeature。
3.1 一个能用的Opaque Sprite Shader
下面是我在一个2.5D项目里用过的Shader,基本是URP Unlit Shader的Sprite改版:
Shader "Custom/OpaqueSpriteDepth" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} [PerRendererData] _Color ("Tint", Color) = (1,1,1,1) } SubShader { Tags { "Queue"="AlphaTest" "RenderType"="TransparentCutout" "RenderPipeline"="UniversalPipeline" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Cull Off ZWrite On Pass { HLSLPROGRAM #pragma vertex SpriteVert #pragma fragment SpriteFrag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) half4 _Color; CBUFFER_END TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); struct appdata_t { float4 vertex : POSITION; float2 texcoord : TEXCOORD0; }; struct v2f { float2 texcoord : TEXCOORD0; float4 positionCS : SV_POSITION; }; v2f SpriteVert(appdata_t v) { v2f o; o.positionCS = TransformObjectToHClip(v.vertex.xyz); o.texcoord = v.texcoord; return o; } half4 SpriteFrag(v2f i) : SV_Target { half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord) * _Color; clip(col.a - 0.5); return col; } ENDHLSL } } }在SpriteRenderer的Material上挂这个Shader,角色和道具的深度就能直接写入,不需要额外的RendererFeature。
3.2 这个方案的关键取舍
alpha接近0.5的像素在硬裁剪边缘会产生明显的锯齿。URP对AlphaTest队列的物体默认没有像内置渲染管线那样的CameraRenderSorting特性,所以边缘的锯齿只能靠贴图本身规避,或者改用后处理抗锯齿,MSAA在URP中配合这种硬裁剪效果也不理想。
另一个取舍是:半透明Sprite从此绝迹。一旦走AlphaTest队列,Shader里就不能再通过混合做半透明,材质里alpha值小于0.5的像素会被直接丢弃,大于等于0.5的像素是完全不透明的。想表现轻纱、烟雾、玻璃,这个Shader做不了。
3.3 什么时候优先选这个方案
如果你项目里的Sprite都是硬边物体,比如像素风、卡通风的角色和道具,这个方案性价比很高。它不需要渲染两遍,没有额外DrawCall,深度天然正确,DOF效果稳定。而且材质本身就是独立的,你可以在一个Material里开关ZWrite或调整ClipThreshold,控制起来非常灵活。
我在正式项目里有一版角色完全是用这个Shader做的。当时的要求是“过场动画里主角需要被DOF虚化,而UI和半透明特效不受影响”。主角用这个OpaqueSpriteDepth,特效继续用默认透明Sprite Shader,两套并行,效果非常干净。
4. ZWrite On和透明排序的冲突:为什么不能简单开一下
很多朋友第一反应是:既然Sprite默认不写深度,那我直接给默认Sprite Shader开ZWrite On总行了吧?我最初也是这么干的,结果很快被狠狠坑了一把。
4.1 透明混合队列的致命问题
Transparent队列的渲染方式和不透明队列完全不同。不透明队列先写深度,再按深度排序逐物体渲染;透明队列则是先渲染所有不透明物体,再关闭深度写入,对所有透明物体按距离从远到近排序,逐个开启Alpha混合并叠加颜色。
一旦你在透明队列里开ZWrite On,就破坏了这个逻辑。假设两个半透明Sprite交叉重叠,按排序规则应该是A在B前面,但因为A先写入了深度,B再做深度测试时直接失败被丢弃,B完全消失。Camera里看就是“写着写着角色少了一半”,而且这种消失没法用排序参数救回来,因为深度测试是逐像素的。
4.2 如果Sprite确实是不透明的,可以适当用
如果Sprite本质上是不透明的,只是用了Sprite默认的透明混合Shader,那给它开ZWrite On确实不会出现半透明穿插问题。坏处是它在渲染队列里还是透明队列的位置,也就是说不透明物体全部画完之后才轮到它,它不会参与不透明物体之间的深度遮挡。DOF需要的是深度纹理里的深度值,只要主深度缓冲在拷贝_CameraDepthTexture前包含Sprite深度,DOF就能用。而这要求Sprite在深度纹理生成那一刻之前写入主深度缓冲,或者主深度缓冲能在透明渲染结束后再拷贝一次。
问题在于:URP通常不会在透明队列之后再拷贝一次主深度到_CameraDepthTexture。所以就算你给Sprite开了ZWrite On,主深度缓冲里是有Sprite的深度,但_CameraDepthTexture还是根据不透明阶段的状态生成的,DOF依然看不到Sprite。这就是“改了没效果”的原因。除非你能确定当前URP版本、当前RenderPipelineAsset的深度纹理生成方式是晚于透明队列的,否则别赌这个行为。
4.3 什么时候“只开ZWrite On”能凑效
有一种场景确实能从直接给Sprite开ZWrite On里受益:如果项目用的URP版本里_CameraDepthTexture是DepthCopy模式并且拷贝时机发生在透明队列之后,这需要你通过Frame Debugger去验证。我自己的经验是,把Sprite的Shader队列从Transparent改成AlphaTest,它在不透明阶段就写进去了,什么版本差异都不怕。所以与其去踩“ZWrite On但队列没变”的坑,不如直接改变队列归属。
4.4 要不要用两个Pass来做
还有一种做法是同一个Shader里写两个Pass,第一个Pass走ZWrite On写深度,第二个Pass再走默认透明混合输出颜色。这样能兼顾深度和半透明排序。但有个很现实的问题:同一个物体被渲染两遍,它的Alpha混合结果会和深度Pass的写入互相干扰。例如两个半透明Sprite,深度Pass先画了前面那个,后面那个的深度测试失败,透明Pass也不绘制了,结果依然不对。所以双Pass只适合本身不透明但想做透明队列混合的Sprite,属于治标不治本。我试过之后还是回归了AlphaTest方案。
5. 2D Renderer下怎么办:Sprite深度与DOF的特殊处境
如果你项目用的是URP的2D Renderer,上面所有内容都需要打个折扣,因为2D Renderer的渲染管线结构和3D Renderer很不一样。
5.1 2D Renderer的深度和3D场景不是一个概念
2D Renderer的设计目标是把Sprite当作平面元素渲染,同一个相机下所有Sprite默认基本在同一个深度平面上。它的渲染流程里没有传统意义的“场景深度”,而是通过Sorting Layer、Order in Layer来决定谁盖谁。后期处理时的深度信息,2D Renderer里的_CameraDepthTexture表现并不稳定,而且也不会按Sprite的Z坐标去记录深度。换句话说,你想通过Z坐标区分Sprite距离,然后让DOF做出纵深感,在2D Renderer里没那么自然。
5.2 2D Renderer下想让DOF生效的做法
如果你仍希望DOF对2D Renderer里的Sprite产生影响,一个可行的思路是:使用正交或透视相机,把Sprite在Z轴方向拉开距离,然后确保Sprite Shader用的是带深度写入的“Depth Write”版本。但注意2D Renderer默认的Sprite-Lit-Default或Sprite-Unlit-Default Shader,它们的深度写入和相机深度纹理适配有差异。在你的项目里,需要手动去Renderer上开启Depth Texture(在URP Asset的Depth Texture选项里打勾),再用一个自定义深度Debug Shader验证Sprite是否进入了深度纹理。
一个很常见的坑是:2D Renderer的深度纹理即使能生成,也是基于Sprite的SortingOrder,而不是基于Z坐标。结果是两个Sprite在Z轴上一前一后,深度纹理里它们的深度值却可能相同或者取决于sortOrder。这会让DOF在一个“平面上”做模糊,只要焦点覆盖到该层,整层一起清晰或一起模糊,伪深度完全体现不出来。
5.3 2D项目要景深,更省心的妥协方案
说句实在话,2D项目要做出可信的景深,我试过各种办法后发现还是“分层模糊”更靠谱:先把场景按Sprite的Sorting Layer分成前景、中景、背景,用相机Orthographic Size或RenderTexture组合,把它们分别渲染到不同通道,再在后处理里做按层叠加模糊。这比纠结2D Renderer的深度纹理靠谱得多。如果你坚持用URP 2D Renderer的DOF,一定要先花时间确认自己项目的RenderPipelineAsset把Depth Texture开起来了,并且用Frame Debugger看清楚Sprite到底有没有进深度图,再决定继续投入还是换方案。
6. 验证方法、性能影响、常见坑汇总
6.1 先用Frame Debugger确认深度纹理里有没有Sprite
不管用哪种方案,验证第一步永远是打开Window > Analysis > Frame Debugger,选中相机渲染那一帧,找到_CameraDepthTexture或者CameraDepthTexture相关的DrawCall,查看RT预览。如果Sprite所在区域深度图上是平的、颜色的背景色,说明深度没写进去;如果有明显的轮廓深度变化,说明Sprite已经参与深度。
Frame Debugger里经常看不到具体哪个DrawCall对应Sprite的深度写入,这时候就要靠第二种验证法:写一个场景后处理Shader,把深度图可视化出来直接叠在屏幕上。
Shader "Hidden/DepthDebugger" { SubShader { Tags { "RenderType"="Opaque" } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); struct appdata_t { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 positionCS : SV_POSITION; }; v2f vert(appdata_t v) { v2f o; o.positionCS = TransformObjectToHClip(v.vertex.xyz); o.uv = v.uv; return o; } half4 frag(v2f i) : SV_Target { float rawDepth = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, i.uv).r; float linear01 = Linear01Depth(rawDepth, _ZBufferParams); return half4(linear01, linear01, linear01, 1); } ENDHLSL } } }把它挂到一个RawImage上或者用CommandBuffer Blit到屏幕上,画面越白的地方越远,越黑的地方越近。Sprite和背景如果深度一致,看起来就是融为一体的灰色色块,很容易识别。
6.2 性能注意:Depth Prepass的负载
让Sprite写入深度以后,从深度纹理的角度来看,Sprite就像变成了一个不透明几何体。Sprite数量大的时候,深度Prepass阶段多出来的绘制调用是实打实的。个人经验是几百个Sprite以内可以忽略,上千个Sprite就要考虑只给关键角色用OpaqueSpriteDepth,其他背景Sprite保持默认透明状态。
6.3 常见坑速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| DOF对Sprite完全无效 | Sprite在透明队列且ZWrite Off | 用RenderObjects或AlphaTest方案 |
| 开了ZWrite On但DOF没变化 | 深度纹理生成时机早于透明渲染 | 确认队列改为AlphaTest/Opaque |
| Sprite轮廓被DOF错误模糊 | 透明部分也写了深度 | 在深度Pass里加clip,丢弃透明像素 |
| 两个半透明Sprite其中一个消失 | 透明队列里ZWrite On导致深度测试失败 | 避免在透明队列里开ZWrite |
| 2D Renderer下DOF按层整体模糊 | 深度基于SortingOrder而非Z坐标 | 改分层模糊或接受现有行为 |
| 前景Sprite挡住了后面的不透明物体 | RenderObjects把Sprite深度写进了主深度缓冲 | 不要把这类Feature用于半透明物体 |
6.4 最后提醒一点:别忘关相机的Depth Texture开关
URP Asset和相机的Depth Texture设置是两回事,如果你用的是自定义Renderer又没在相机上勾选Depth Texture,前面做的所有深度写入都可能不生效。查问题的时候先确认摄像机组件上的Depth Texture和Render Pipeline Asset的Depth Texture勾选状态。我遇到过两次排查半天,最后发现是这个开关被关着,深度图压根没生成。
另外,如果后续要升级URP版本,注意Renderer Feature的API和Event枚举值可能会有变化,尤其老项目跨大版本升级时,RenderObjects Feature的配置可能会丢,重新确认一遍就能避免“升级后景深突然失效”的尴尬。