ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity Spine深度写入Shader:解决2D角色在3D场景中的穿模问题

Unity Spine深度写入Shader:解决2D角色在3D场景中的穿模问题 Unity项目里2D和3D混用最烦的就是2D角色插进3D场景里“穿模”。我最早遇到这个坑是在做一个人物展示场景Spine做的角色站在石柱后面回头说话镜头转过去的瞬间角色整个人直接盖在了石柱上面跟幽灵似的。当时第一反应是渲染层级问题调Sorting Order、调Renderer Order折腾了半天直到把Shader的深度写入打开问题才算真正落地。这篇文章就从Spine在3D场景中的穿模现象说起讲清楚背后的原理并直接给出一套可以抄作业的Shader方案顺便把半透明、阴影、性能这些坑也一起填上。适合正在做Unity 2D/3D混用项目、被Spine遮挡问题困扰的开发者参考。1. 穿模问题到底出在哪先搞懂渲染管线的“两张表”1.1 先复现一下你遇到的“穿模现象”很多人在项目里遇到的Spine穿模并不是只有一种表现。我归纳了一下大概有四种高频场景场景一Spine角色站在3D柱子、墙体后面角色没有被遮挡反而完整显示在物体前面像是角色从墙里穿过去。场景二Spine角色自己的左右手交叉摆动时理论上应该被身体挡住的那只手反而盖在了身体外侧看起来骨骼层级错乱。场景三场上站了多个Spine角色位置一前一后结果后面的角色把前面的角色盖住了像纸片人叠在一起。场景四镜头移动时Spine角色和3D物体接触的边缘产生闪烁、撕裂像两个模型在争夺同一个屏幕像素。如果你只遇到其中一种处理起来还算简单如果四种都有那说明问题不止是排序而是根子上的深度处理逻辑。1.2 深度缓冲GPU如何判定“谁挡住了谁”要理解穿模先得弄明白GPU是怎么判断遮挡关系的。渲染管线里有一个叫深度缓冲Depth Buffer的东西它保存的是每个屏幕像素对应的物体距离摄像机的远近。每个物体在绘制前GPU会做一次深度测试ZTest把当前要画的片元深度和缓冲里的深度做比较如果满足条件就绘制不满足就丢弃。除了测试还有个深度写入ZWrite操作决定当前片元的深度值要不要写进缓冲里。简单打个比方深度缓冲就像一个登记表ZTest是保安在问“你比里面那个人更近吗”ZWrite则是“你决定要不要在登记表上写下你的名字”。一个物体就算自己不写名字它依然会被登记表里已有的信息约束但它如果不写名字就没有办法约束后来的人。这个区别很关键很多“穿模”其实是“登记表上根本没有写我的名字”造成的。1.3 为什么Spine官方Shader默认不写深度Spine官方Shader默认的渲染状态是ZWrite Off、ZTest LEqual、Blend SrcAlpha OneMinusSrcAlpha渲染队列在Transparent3000。官方这样设计是合理的。Spine本质上是2D骨骼动画放在UI或2D场景里使用默认走的是半透明渲染逻辑。半透明物体有一个硬性要求必须从远到近按顺序绘制才能得到正确的混合效果。如果每个半透明物体都往深度缓冲里写深度后面的半透明物体就会被先画的半透明物体挡住混合效果直接乱套。所以Spine在纯2D场景里完全没问题问题出在把它硬塞进3D场景那一刻它带着“不写深度”的设定和3D不透明物体一起出现在屏幕空间里前后关系就只能靠Unity的渲染排序来猜了。1.4 “穿模”其实有三层我习惯把Spine在3D场景里的穿模分成三个层次来看第一层是Spine和普通3D不透明物体之间的遮挡。这个其实靠默认设置大部分能挡住因为3D不透明物体先渲染并写了深度Spine做ZTest的时候还是会按像素被正确遮挡。真正出问题的是Spine和3D物体相交时深度几乎相等LEqual测试直接通过Spine就会“骑”到3D物体表面上产生类似贴图穿透的观感。第二层是Spine自身的部件交叉。因为Spine是多个网格部件拼出来的默认不写深度导致先画的部件和后画的部件之间完全没有深度约束后画的永远覆盖先画的。这是自遮挡穿模最直接的来源。第三层是多个Spine角色之间的遮挡。大家都是Transparent队列都靠排序决定先后一旦排序没排对就会出现人物前后关系错误。所以要彻底解决核心思路不是调排序而是让Spine参与深度判定。2. 解决思路让Spine参与深度判定而不是靠渲染顺序2.1 方案A把Spine当成不透明物体处理AlphaTest ZWrite思路很简单把Spine的Shader改成ZWrite On放入不透明或AlphaTest渲染队列然后用clip()把Spine纹理中的透明区域裁剪掉。好处是彻底解决深度问题Spine和3D物体之间、Spine自身部件之间、多个Spine之间全部按像素深度判定前后关系。坏处是纹理边缘的透明渐变会被硬裁掉产生锯齿感。对于大多数偏卡通风、边缘轮廓清晰的角色这个方案在视觉上几乎无感Shader开销也很低。这个方案适合战斗场景里的角色、怪物、NPC只要不是追求极致渐变半透明的立绘基本上都能用。2.2 方案B双Pass分离“深度写入”和“颜色渲染”Spine的头发、裙摆、披风等部位经常有大面积的柔和半透明渐变。如果用方案A直接裁剪边缘会变得非常生硬角色瞬间像被剪刀剪过一样。双Pass就是为这种情况准备的第一个Pass只写深度不输出颜色ColorMask 0依然用clip()把透明度极低的区域裁掉让深度缓冲里留下一个“角色轮廓”的深度信息。第二个Pass正常输出颜色ZWrite OffZTest LEqual混合方式保持SrcAlpha OneMinusSrcAlpha让半透明渐变正常显示。核心逻辑是用一个裁剪过的轮廓去登记深度再用完整的半透明颜色去绘制。好处是半透明渐变保留深度关系也正确。代价是多跑了一个Pass顶点数和片元数翻倍性能开销比方案A高但大部分项目完全扛得住。这个方案适合过场动画、剧情演出、立绘特写等要求视觉效果的场合。2.3 方案C纯排序方案能用但有限不写Shader只通过调整Sorting Order、Renderer Order、Sorting Layer来手动排序。这个方案在多个Spine角色都是“站桩”状态下能凑合稍微有点动画交叉就露馅更别说和3D场景互动了。排序改变的是CPU侧的绘制顺序而深度问题靠的是GPU侧的逐像素判定两者本质不同。纯排序只能在绘制顺序层面决定“谁后画谁盖谁”没法做到像素级的前后遮挡。所以我的态度是排序方案可以作为辅助手段但别指望它包打天下。2.4 三种方案怎么选方案是否保留半透明渐变自遮挡效果性能开销适用场景AlphaTest ZWrite否边缘硬裁完整低跑动、战斗、大头兵双Pass是渐变保留完整中剧情、特写、立绘纯排序是不完整最低静态站桩、UI叠加实际开发中我一般默认用方案A等美术反馈“边缘太硬”再切到方案B。3. 实战手写一个Spine专用Shader并替换材质3.1 动手之前先确认三件事先确认项目的渲染管线。内置管线Built-in和URP的Shader写法差异很大别混用。再确认Spine纹理的透明模式。Spine导出时有Normal和Premultiplied Alpha两种格式Shdaer里的混合模式必须对应。判断方法很简单打开材质看Shader是Spine/Skeleton还是Spine/Skeleton (Premultiplied Alpha)。Premultiplied模式下纹理颜色已经乘过Alpha混合模式要改成Blend One OneMinusSrcAlpha。最后确认Spine版本。老版本用的是Spine/Skeleton新版Spine 4.x的Shader路径略有不同但核心渲染状态是一样的。3.2 内置渲染管线的AlphaTest版本这是一个可以直接拿来用的内置管线Shader。它把Spine当成不透明物体渲染用裁剪去掉透明区域。Shader Custom/Spine/DepthOnlyAlpha { Properties { _MainTex (Texture, 2D) white {} _Cutoff (Alpha Cutoff, Range(0, 1)) 0.5 } SubShader { Tags { QueueAlphaTest RenderTypeTransparentCutout IgnoreProjectorTrue } Pass { Cull Off ZWrite On ZTest LEqual Blend Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; fixed _Cutoff; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 color tex2D(_MainTex, i.uv); clip(color.a - _Cutoff); return color; } ENDCG } } Fallback Off }关键点有三个Queue放在AlphaTest2450确保在不透明物体之后、透明物体之前渲染避免透明队列里出现不必要的深度干扰。Cull Off必须加。Spine网格是单层四边形开启背面剔除后从背面看就会消失。clip()用来裁剪掉Alpha低于阈值的像素阈值建议从0.3到0.5之间试太高会把头发丝裁没太低会留下难看的灰边。如果你的Spine纹理是Premultiplied Alpha把Blend Off保持不动但颜色最后不要额外乘Alpha因为纹理里已经乘过了。3.3 内置渲染管线的双Pass半透明版本保留渐变效果同时解决遮挡问题。第一个Pass用ColorMask 0只写深度第二个Pass正常混合。Shader Custom/Spine/DepthWriteTransparent { Properties { _MainTex (Texture, 2D) white {} _Cutoff (Alpha Cutoff, Range(0, 1)) 0.3 } SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } // Pass 1: 只写深度 Pass { Cull Off ZWrite On ColorMask 0 Blend Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; fixed _Cutoff; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 color tex2D(_MainTex, i.uv); clip(color.a - _Cutoff); return 0; } ENDCG } // Pass 2: 正常渲染 Pass { Cull Off ZWrite Off ZTest LEqual Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2i i) : SV_Target { return tex2D(_MainTex, i.uv); } ENDCG } } Fallback Off }这里的Queue我依然放在Transparent因为第二个Pass需要参与透明混合半透明物体必须在Transparent队列里正确排序。可能会有疑问既然写深度的Pass用的是clip裁剪边缘依然是硬边那深度缓冲里的轮廓不还是硬的吗对但这不影响视觉。深度只管“哪里该被挡住”不管颜色。第二个Pass绘制时半透明渐变色会在硬深度轮廓的基础上正常显示视觉上看不出异样。3.4 URP下怎么改URP里直接用上面的CGPROGRAM会报错因为URP默认走HLSLPROGRAM并且需要声明SRP Batcher兼容的常量缓冲。核心渲染状态和思路不变只需要把代码骨架换成HLSL。Shader Custom/Spine/URP/DepthWriteAlpha { Properties { _MainTex (Texture, 2D) white {} _Cutoff (Alpha Cutoff, Range(0, 1)) 0.5 } SubShader { Tags { QueueAlphaTest RenderTypeTransparentCutout RenderPipelineUniversalPipeline IgnoreProjectorTrue } Pass { Cull Off ZWrite On ZTest LEqual Blend Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; float _Cutoff; CBUFFER_END struct appdata { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.positionHCS TransformObjectToHClip(v.positionOS); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } half4 frag (v2f i) : SV_Target { half4 color SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); clip(color.a - _Cutoff); return color; } ENDHLSL } } Fallback Off }URP里要注意TEXTURE2D和SAMPLER这套宏定义以及CBUFFER_START块。少了CBUFFERSRP Batcher就会失效同一个材质实例多几个Shader参数都会产生额外开销。3.5 在Spine模型上替换并验证实际操作流程是这样的选中场景里的Spine对象找到SkeletonAnimation组件下的Material引用。右键材质在Shader下拉框里选Custom/Spine/DepthOnlyAlpha或URP版本。调整_Cutoff值观察角色边缘。手动移动相机让Spine角色走到3D物体前后确认遮挡正确。让角色播放一个包含手臂交叉、身体扭转的动画确认自遮挡不再错乱。验证的时候有个小技巧把几个Spine角色叠在一起开启深度写入后正常情况下前后关系应该和纯3D模型一致。如果还有错乱就去检查是不是有别的Shader在干扰深度缓冲。4. 落地中绕不开的坑半透明边缘、双面、阴影和性能4.1 裁剪导致的边缘锯齿怎么缓解方案A用clip()裁剪后边缘出现锯齿是必然的尤其是头发丝和披风边缘。缓解办法有几个一是适当降低_Cutoff阈值不要硬卡在0.5放到0.2到0.3之间会让边缘柔和一些但也可能导致残留的半透明像素产生灰边。二是开启MSAA。在Quality Settings里打开4x MSAAAlphaTest的边缘会平滑很多。要注意的是URP里MSAA对Transparent队列无效但对AlphaTest队列有效这也算方案A的隐藏优势。三是在Shader里加一个简单的噪声抖动裁剪。用一张噪声图采样把噪声值加上后再和Alpha比较能模拟出类似抖动的半透明效果视觉上边缘更柔和但颗粒感偏重适合像素风项目。如果项目里角色特写镜头多我建议直接上方案B双Pass省得折腾边缘。4.2 背面剔除必须关掉Spine网格是单层四边形正面和背面共用一套顶点。默认Unity Shader的Cull Back会把背面裁掉导致角色从背面看缺一块。所有Spine相关Shader我都建议Cull Off。有个小坑开了Cull Off后如果有半透明的双Pass方案背面片元也会参与深度写入。但背面和正面的底层网格在空间上重合深度值趋近一致并不会产生明显问题。实测下来多了一层透明混合计算性能影响可忽略。4.3 阴影和Unity一些“奇怪”的现象很多人在开启Spine的深度写入后发现场景里投在角色身上的阴影变得很奇怪要么角色变黑要么阴影乱跳。原因在于Spine的Shader没有ShadowCasterPassUnity会使用Fallback的阴影Pass。如果Shader里写了Fallback OffUnity就不知道怎么给Spine生成阴影方向光阴影直接失效。如果之前Fallback到一个不合适的Shader阴影投射和接收都可能出错。处理方式有两种一是给Shader加一个ShadowCasterPass让Spine也能正确投射和接收阴影。这个方向适合想让2D角色真正融入3D明暗关系的项目。二是明确Spine不需要参与光影把Fallback Off写清楚同时确保场景中的光照不要对Spine产生阴影需求。如果还是觉得角色身上有奇怪的阴影块检查一下材质是不是意外开启了Receive Shadows。对于大多数2D在3D中展示的项目我建议先屏蔽Spine的阴影把视觉问题解决干净后再考虑光影融合。4.4 Draw Call与材质批处理的取舍开启深度写入Shader后如果每个Spine角色都用独立的材质实例Draw Call会肉眼可见地涨。Spine本身按网格拆部件一个角色几十个Draw Call很正常十几个角色同时上场就是几百个Draw Call。优化手段主要有两个一是所有Spine角色共享同一个Material。颜色或透明度差异可以通过MaterialPropertyBlock做逐实例覆盖避免复制材质。二是在URP下开启SRP Batcher并且Shader里正确使用CBUFFER_START(UnityPerMaterial)让材质参数打包。实测能减少一部分CPU提交开销。如果项目里Spine角色特别多建议把纹理合图Atlas也注意一下不同Atlas的Spine没法合批这是Spine引擎本身的设计。5. 常见问题排查速查表5.1 现象与处理对照表现象可能原因处理方法角色边缘大量锯齿_Cutoff阈值过高或没开MSAA调低阈值到0.2~0.3开启MSAA角色背面看不见Shader里没写Cull Off修改正面剔除为关闭角色身上出现灰边/黑边纹理带了半透明渐变被裁剪后残留换双Pass方案或调低裁剪阈值多个Spine角色遮挡仍然错乱部分角色还在用默认Shader所有Spine统一替换为深度写入Shader角色被3D物体遮挡时边缘闪烁深度值过于接近出现共面冲突Shader中加Offset -1, -1微调深度角色身上阴影乱跳缺少ShadowCasterPass或Fallback Off导致阴影无法投射是是否保留Spine阴影不保留就关闭Receive Shadows角色半透边缘完全消失用了AlphaTest把透明区域硬裁掉了考虑切双Pass保留渐变开启ZWrite后和UI重叠出现异常UI在不同渲染管线下的深度行为不同UI单独放一个相机Spine放场景相机5.2 三个实操经验第一个经验别一上来就写双Pass。我见过不少项目美术一喊“边缘有锯齿”开发直接上双Pass结果半透明叠加后角色在3D场景里显得像玻璃片。先试AlphaTest调整好裁剪阈值和MSAA80%的Spine角色都能用单Pass解决。第二个经验开启ZWrite后注意和UI的深度交互。如果项目里UI相机和3D相机没有分开Spine角色在UI后面时UI会被Spine的深度挡住因为UI在透明队列且通常不写深度。遇到这种问题优先把UI和3D场景分成两个相机渲染别指望Shader能统一处理所有队列。第三个经验双Pass方案在移动端上面临额外的带宽开销。第一Pass虽然不输出颜色但片元着色器要跑一遍纹理采样移动端的GPU纹理带宽本来就是瓶颈。如果游戏是移动端重场景先评估Spine角色数量和屏幕占比再决定是上双Pass还是干脆做AlphaTest。最后再分享一个我个人常用的判断标准战斗场景里的杂兵、野怪、NPC一律AlphaTest剧情过场里的主角、敌人Boss、有镜头特写的角色才上双Pass。Shader这个东西不是越复杂越好而是越匹配场景越好。
返回列表