资讯动态

Unity Spine与3D场景穿模根治:深度写入Shader与排序方案

发布时间:2026/9/15 19:03:52 来源:尧图企业网站定制
做2D与3D混合项目的人八成都在某个版本里被Spine穿模折磨过。我一个做横版卡牌的朋友场景是3D的角色全是Spine 2D动画结果角色走到柱子后面时人从柱子前面透出来走到墙角时半个身子直接嵌进墙里。弹幕里一堆人喊“穿模了穿模了”他自己也头疼得不行。后来我帮他梳理了一遍Unity渲染管线里深度缓冲和渲染队列的逻辑又写了个带深度写入的Spine专用Shader问题才算彻底解决。这篇东西我不会只丢一个Shader给你而是把“为什么穿模”“深度Pass为什么能解决”“哪几种场景下该用哪种方案”“阴影和半透明穿插怎么处理”全讲清楚。不管你是做二次元卡牌、横版动作、关卡演出还是XR原型只要遇到2D模型和3D场景互相穿透这篇都能给你一套能落地的处理思路。1. 穿模问题的根源Spine在3D场景里的渲染本质1.1 先认识Spine在Unity中的渲染链路很多人以为Spine是个“图片”只是能动而已。不是的。Spine的运行时组件SkeletonAnimation本质上是在CPU侧算好骨骼顶点然后把顶点数据提交给Unity的MeshRenderer——注意是MeshRenderer不是SpriteRenderer。Spine生成的是一个动态Mesh表面贴了图集Atlas材质默认是Spine自家的Shader变体。关键在于这套默认Shader的路数是纯2D渲染的思路渲染队列RenderQueue通常是Transparent深度写入ZWrite是关的深度测试ZTest用LEqual。这套配置在纯2D场景里没问题因为2D场景里所有东西都不写深度大家靠的是SortingOrder和相机距离来回排序。但在3D场景里就出事了——3D场景里的墙、柱子、地形这些不透明物体会正常写入深度缓冲而Spine因为ZWrite Off它自己不往深度缓冲里写东西却又参与深度测试。我打一个比方方便你理解3D物体就好比是一群人在黑板上写满了名字后来的看到黑板上有名字就让开。而Spine像是用隐形墨水写名字它能看到别人写的字可它自己写的字谁都看不见。结果就是——它可以被墙挡住但墙挡不住它后面的东西它自己也挡不住任何东西于是前后关系全靠运气。1.2 穿模到底是怎么发生的三种典型场景实际项目中我总结下来Spine和3D场景混用时的穿模大概有三种典型表现第一种角色站在遮挡物后面却被画在遮挡物前面。墙写了深度Spine没写深度。按正常深度测试Spine离相机远、深度值大墙离相机近、深度值小Spine会被墙挡住。但由于Spine不写自己的深度场景里其他不透明物体在绘制时完全感知不到Spine的存在所以某些情况下Spine会被错误地渲染在前面或者和墙发生半截穿插。这种情况尤其在移动端和某些延迟渲染路径下更容易出现。第二种角色站在前面却透出了后面的物。这是更隐蔽的一种。Spine自己的ZWrite是关的所以当它站在一堵墙前面时它并不会挡住墙后面的其他物体。比如角色站在墙前墙后刚好还有个NPC这个NPC就会透过墙显示出来视觉上像墙变成了半透明。原理和第一种一样都是因为Spine没有写入深度造成场景遮挡信息缺失。第三种多个Spine角色互相穿插。两个2D角色在3D场景里相遇它们彼此都不写深度排序只能靠SortingOrder。一旦SortingOrder没有随着世界坐标变化动态更新就会发生两个人“贴在一起”或者前后随机的情况。这个在镜头旋转时尤其明显。理解了这三种情况你已经明白要解决Spine在3D场景的穿模本质就是让Spine的网格在深度缓冲里“存在”同时不破坏它本身的2D渲染效果。下面两种方案就是围绕这个目标展开的。2. 方案一给Spine写一个会写深度的自定义Shader2.1 核心思路拆成“深度Pass 颜色Pass”两步常规的解决办法是给Spine写一个自定义Shader做两个Pass第一个Pass只写入深度不输出颜色。这样Spine的网格就相当于在深度缓冲里“刻了个模子”其他3D物体再渲染时就能正确判断前后遮挡关系了。第二个Pass才正常输出颜色并且把ZWrite关掉避免半透明混合的时候把背景颜色搅乱。为什么需要两个Pass而不是直接在一个Pass里既写颜色又写深度原因是Spine的材质一般带透明通道角色的大披风、头发边缘都有半透明区域。如果你用常规的不透明渲染流程去写深度那些半透明像素也会写入深度结果就是角色旁边明明看着是透明的地方也会变成一堵隐形的墙把后面的场景全部挡住。所以深度Pass里必须做透明度裁剪Alpha Clip只让不透明的像素参与深度写入。Shader的RenderQueue这里也有讲究。Spine默认是Transparent队列它排在所有不透明物体之后渲染。如果只是想解决“被墙遮挡”的问题这个顺序其实没问题不透明墙已经先渲染并写好深度了Spine做深度测试时自然会被挡住。但如果你把Shader的Queue改成Geometry让Spine去和不透明物体一起排序就会出问题——因为场景里可能有其他透明物体希望排在Spine后面这会导致整体渲染顺序凌乱。所以我的建议是保持Transparent队列只额外加一个深度写入Pass。2.2 Shader代码与逐行解释下面这份是我在项目里实际用过的简化版本基于Unity Built-in Render Pipeline写的Surface Shader风格但拆成了显式Pass。如果你的项目用的是URP思路一致照着改Pass名和Tag就行。Shader Spine/DepthWriter { Properties { _MainTex (Atlas Texture, 2D) white {} _Color (Tint, Color) (1,1,1,1) _Cutoff (Alpha Cutoff, Range(0,1)) 0.5 } SubShader { Tags { QueueTransparent RenderTypeTransparentCutout IgnoreProjectorTrue PreviewTypePlane } Cull Off ZWrite Off ZTest LEqual // Pass 1: 只写深度不输出颜色 Pass { Name DepthOnly ColorMask 0 ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_ST; fixed _Cutoff; 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 texColor tex2D(_MainTex, i.uv); clip(texColor.a - _Cutoff); return 0; } ENDCG } // Pass 2: 正常颜色输出不写深度 Pass { Name ColorPass Blend SrcAlpha OneMinusSrcAlpha ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; 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 texColor tex2D(_MainTex, i.uv); return texColor * _Color; } ENDCG } } Fallback Spine/Skeleton }代码不长但有几个细节必须说明。ColorMask 0这个指令意思是“不写入任何颜色通道”。深度Pass里必须写这一句否则片元着色器返回的颜色会覆盖到渲染目标上导致角色出现一块奇怪的纯色块。clip(texColor.a - _Cutoff)这是整套方案里最关键的一行。Spine图集里的像素有透明区域如果不做裁剪整个矩形的Mesh都会在深度缓冲里留下痕迹那些肉眼看不见的透明部分会变成“隐形的墙”把角色后面的场景挡得干干净净。截断值_Cutoff设成0.5比较稳妥因为Spine图集边缘一般会做anti-aliasingalpha值会从0平滑过渡到1取0.5作为阈值能比较准确地保留轮廓。Cull OffSpine的Mesh某些动画动作会翻转面片比如角色转身所以双面渲染是必须的。如果这个开关忘了关某些角度下角色就会整个消失。FallBack到Spine/Skeleton这是保底措施。如果某些平台上这个Shader编译失败至少能退回Spine的默认渲染方式不会黑屏。2.3 参数调优与使用注意事项我在项目里做了几十个角色后得出几个经验首先Spine网格的顶点数通常不多几千顶多上万一个额外的深度Pass不会造成太大的性能压力但在移动端上还是要注意如果场景里同时存在几十个Spine角色每个都要多一个PassDrawCall直接翻倍。我的处理办法是只给“会与3D场景发生遮挡关系的重要角色”开深度Shader路人NPC或者只在固定轨道上走的角色保持默认Shader就好。其次如果项目用的是URP上面这套代码要做一点调整。你把CGPROGRAM换成HLSLPROGRAM引用UnityInput.hlsl和UnityShaderVariables.hlsl然后在Pass里加上Tags { LightMode DepthOnly }或者干脆直接用URP里现成的DepthOnlyPass模板。URP部分我后面会单独说。最后是材质实例化问题。如果多个角色共用一个材质你调了_Cutoff会影响所有人。如果要按角色单独调整裁剪阈值记得用MaterialPropertyBlock不要直接改material否则会破坏Unity的合批。3. 方案二世界空间Y排序 动态SortingOrder3.1 横版游戏最常用的遮挡方案如果你的项目是横版视角相机不会大幅度旋转穿模问题的另一个高效解法是——Y轴排序。原理不复杂横版游戏里角色越靠下世界坐标Y越小说明离屏幕前方越近越应该被后渲染遮挡别人。角色越靠上说明在场景深处应该先渲染被别人遮挡。这种方案在纯2D时代就被大量使用经典做法是把物体的SortingOrder绑定到它的世界Y坐标上。到了2D和3D混用阶段这个方法依然有效尤其是当你不想给每个角色都换Shader时。但要注意用SortingOrder来排序角色和场景里的3D物体之间是没有遮挡关系的。3D物体是单独的一套深度渲染流程。也就是Y排序主要解决的是“Spine和Spine之间”以及“Spine和Sprite之间”的穿插问题。Spine和3D墙体的前后关系Y排序管不了还是得靠Shader或者拆分碰撞体来做遮挡。所以准确地说Y排序适合作为穿模问题的辅助方案不完全是替代方案。3.2 完整脚本示例下面这个C#脚本我封装过很多次了核心就是LateUpdate里把物体的世界Y坐标映射到SortingOrder上。注意是LateUpdate而不是Update目的是确保在所有动画更新完之后再调整排序避免角色状态还没算完就改了顺序导致一帧抖动。using UnityEngine; [DisallowMultipleComponent] public class SpineYSort : MonoBehaviour { [Header(向下为正方向Y越小越靠近屏幕前方)] [SerializeField] private float orderScale 100f; [SerializeField] private float yOffset 0f; private Renderer _renderer; private int _baseOrder; private void Awake() { _renderer GetComponentInChildrenRenderer(); if (_renderer null) { Debug.LogError(SpineYSort: 未找到Renderer组件, this); enabled false; return; } _baseOrder _renderer.sortingOrder; } private void LateUpdate() { if (_renderer null) return; float worldY transform.position.y yOffset; int sortOrder _baseOrder Mathf.RoundToInt(-worldY * orderScale); _renderer.sortingOrder sortOrder; } }这个脚本挂在Spine的根节点上就行。GetComponentInChildren会自动找到骨架网格的Renderer。orderScale是个经验参数默认100在大多数项目里够用如果角色分层不明显就调大比如300、500。yOffset是用来处理“角色脚底有阴影或特效希望它们和角色本身排序一致”的情况。3.3 和Shader方案怎么搭配实际项目里这两种方案完全互补。我的习惯用法是角色和普通地面、墙体之间用Shader方案的深度Pass来处理让角色能正确钻到墙角后面去。多个角色同时出现在场景里时用Y排序来控制角色彼此之间的前后关系。注意一个坑如果你同时用Y排序和Shader方案Shader里的ZWrite Pass会把角色的深度写进去但Y排序决定的是Transparent队列内部的绘制顺序。两者之间有时候会打架。举个例子两个角色A和BA的SortingOrder比B小A会先画但A和B的深度Pass都写入深度缓冲这会导致B在画的时候深度测试不过B可能直接被A遮掉了。看起来没问题但如果你希望A和B之间有半透明交叉的效果就会发现完全做不出来。解决办法是进行深度写入的角色之间SortingOrder的变化已经不关键了深度缓冲自然会处理前后遮挡只有那些不参与深度写入的角色才需要依赖Y排序。所以我通常会把项目里的角色分成两类第一类和3D场景有遮挡交互开启深度Shader。第二类纯2D演出用不会进3D结构内部保持默认Shader Y排序。这样处理下来既保证了画面正确又减少了Shader切换带来的性能消耗。4. 实战避坑阴影、UI遮挡、半透明穿插与性能4.1 阴影投射问题很多初学者换上深度Shader之后第一个喊怪的就是阴影。角色站得好好的地面上却多出一块巨型黑色投影或者角色明明被墙挡住了影子却还挂在墙面上。原因出在ShadowCaster Pass上。Spine的默认Shader通常会投射阴影但它的阴影投射用的是整个网格轮廓一个矩形面片直接拍在地面上。你看到的那个“巨型黑影”实际上是整个Spine网格的矩形轮廓不是角色身体的形状。尤其在角色有大幅度动画比如跳跃、挥剑时这个矩形阴影看起来非常呆。解决办法有两个方向。第一个是干脆关掉阴影投射在角色材质上把Cast Shadows设为Off。多数卡通风格项目本来也不依赖硬阴影用场景里的假阴影或者半透明Blob Shadow就够了。第二个是写一个自定义ShadowCaster Pass阴影只投在角色脚底的一个小区域近似模拟落脚点阴影。这个办法效果好但计算稍复杂适合对画面要求高的项目。顺带提一下热搜里那个“unity阴影问题”。在混用的项目里阴影问题多半不是光照参数不对而是阴影投射源不对。你先检查Spine的材质是否开了Cast Shadows再看ShadowCaster Pass是否符合你的预期。很多时候把Cast Shadows关掉画面反而干净了。4.2 World UI 与 Spine 的遮挡关系另一个高频问题是World Space UI。很多项目里角色头上顶着血条、名字、对话气泡这些都是World Space Canvas。当角色走到墙后面时血条应该被墙挡住但实际经常出现血条穿透墙面、清晰可见的情况。原因很简单World Space Canvas默认是不写深度、也不参与深度测试的。它的渲染队列在Transparent之上很多时候直接Overlay了。所以你会看到角色本身被墙挡住了飘在头顶的UI却依然浮现出来特别出戏。我建议给World Space UI单独配置一个相机层或者在Canvas上加上一个遮挡处理脚本。更轻量的做法是给Canvas的GraphicRaycaster目标材质设一个特殊的UI Shader让它在深度缓冲里做测试如果对应深度大于场景深度说明UI在墙后面就把UI透明度降为0或者直接裁剪掉。如果你用的是URP这个处理会简单很多——URP的UI Overlay有RenderFeature可以做自定义深度测试。但如果是内置管线项目里有大量World UI又不想给每个UI都写Shader最粗暴的方案是把Canvas拆成两个一个放在世界空间设置Render Mode为Screen Space Camera配一个优先渲染的Overlay相机另一个保持World Space但挂脚本根据相机与墙体的深度距离动态控制CanvasGroup的alpha。这个脚本不复杂但好用我经常用来处理“角色进洞后UI淡出”这类演出。4.3 Spine与粒子、玻璃等半透明物体的穿插这个坑比前面的都要深。Shader方案的深度写入Pass只能解决不透明物体的遮挡问题。粒子、玻璃、水、光效这些都是半透明物体它们本身ZWrite Off也不会给深度缓冲里写东西。所以即便Spine写了深度这些半透明物体也无法正确感知Spine的深度该穿还是穿。举例一个Spine角色站在瀑布后面瀑布的粒子特效是半透明的正常情况下角色应该被瀑布模糊遮挡。但因为粒子的渲染队列通常在Spine之后Transparent队列里越靠后越靠前画且不做深度测试粒子会直接盖在角色脸上形成“贴脸特效”。这类问题没有完美的通用解。我常用的一个折中手段是调整渲染队列把Spine的RenderQueue设为比粒子特效更靠前也就是数值更小让角色先画、粒子后画粒子会以半透明混合的方式盖在角色上方。这样至少视觉上不会出现硬边穿插看起来像是角色被粒子笼罩了。另外一个偏门但有效的方法是给角色单独加一个“半透明裁剪面”的代理网格放在角色前方。这个网格用深度写入Shader把角色“所在区域”的深度写进去粒子的深度测试就能匹配上。但实现成本比较高适合在关键演出镜头里用不推荐全场景常驻。4.4 性能优化经验和取舍建议最后说说性能因为混合项目最容易在优化上翻车。深度写入Pass带来的最大损失是顶点着色器的执行次数翻倍。Spine本身顶点量不大但在极端场景——比如战斗关卡里同时刷了30个Spine小怪、20个特效、若干UI的情况下Vertex处理压力会明显上升。我实测过中端Android机型上30个Spine加30个深度Pass帧率大约下降3到5帧如果是低端机影响会放大到10帧以上。针对这个问题我的取舍策略是只会和场景结构墙、柱子、门、山体发生遮挡交互的角色开深度Shader。纯横版视角下相机绕X轴旋转角度不超过20度的项目可以直接放弃Shader方案完全靠Y排序加手动遮挡体。战斗特效、脚印、攻击拖尾这类特效贴图一律不写深度。角色身上的附件武器、披风、帽子如果和角色本身穿插不严重也不要单独开深度写入否则插件的性能开销会成倍增长。如果你用的是URP还有个进阶优化把深度写入Pass改成Scriptable Render Feature的形式在渲染不透明物体之后、渲染半透明物体之前插入一个只渲染“深度标记角色”的Pass。这个Pass只跑顶点片元直接返回效率比每个角色都挂一个额外Pass高很多。有渲染管线定制能力的团队可以往这个方向走。5. 延伸思考遇到XR和数字孪生这类特殊场景怎么办写到最后我想留一小节聊一下延伸。最近很多项目已经不是普通的手机游戏了Pico VR、MR混合现实、数字孪生可视化都在大量用Unity。这些项目里2D元素和3D场景的穿插问题比传统游戏要严重得多因为相机是自由移动的视点可能绕到角色后面也可能从侧面贴近角色。在VR/MR场景里深度Shader的方案依然有效但要额外注意一点玩家的头部是实时移动的深度缓冲每帧都在变化深度Pass的写入必须和主相机保持完全同步。如果相机做过镜面反射或者平面反射Planar Reflection深度写入的时机更要小心否则反射画面里会出现错误遮挡。数字孪生项目里常见的做法是把UI界面和标注点到世界空间然后用射线检测判断相机和物体之间有没有阻碍物。这个判断和穿模问题的Shader解决思路其实是互补的。射线检测可以精确控制“什么时候隐藏、什么时候半透明”Shader解决的是“大面上不穿帮”两者结合体验最好。“unity 如何扩大按钮的点击范围”这类偏UI的问题在MR项目里也会和穿模扯上关系。我遇到过的情况是按钮被一个挡在相机和按钮之间的模型穿模了点击判定却仍然命中按钮用户觉得很迷惑。这个时候光调按钮点击区域没用你得先解决遮挡关系再考虑点击范围的扩大。从这个角度看穿模问题永远是混合渲染项目里优先级最高的基础问题。在真实项目里我做操作前一定会先去搞清楚相机的运动范围、场景里可能发生遮挡的结构体、以及角色是否会被大范围旋转镜头翻转而不是一上来就先改Shader。搞清楚了再动手你会发现两种穿模方案分别适合什么场景选起来一点都不纠结。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价