☰
Unity半透明剪影效果:双Pass加Stencil实现穿墙透视
2026/10/15 4:42:28 网站建设 项目流程

1. 半透明剪影效果的核心需求与场景拆解

半透明剪影效果在Unity项目里出现的频率比很多人想象的要高。我第一次接触这个需求是在一个战术类原型里,当时需要让墙后的敌人以半透明轮廓的形式暴露位置,但又不能完全穿墙可见,否则就破坏了掩体机制。后来陆续在角色选择界面、技能指示器、建筑透视、X光模式等场景里反复用到类似方案,才意识到这其实是一个通用性很强的渲染技巧。

所谓半透明剪影,核心诉求可以拆成三层:第一层是轮廓可见,角色或物体被遮挡时仍然能看到一个形状;第二层是半透明,这个形状不能是实心的,要能透出背后的场景信息,否则视觉上会像贴了一块色块;第三层是剪影化,通常只保留单色或渐变色的轮廓,不显示模型本身的贴图和光照细节,这样既降低了视觉噪音,又强化了“透视感知”的功能性。

适合参考这套方案的人包括:做战术射击、潜行、RTS类项目的开发者;需要实现角色透视、建筑剖切、X光效果的美术和技术美术;以及任何想在Unity里折腾自定义渲染管线的同学。不管你是用内置管线、URP还是HDRP,核心思路是相通的,区别主要在Shader的写法细节和渲染队列的配置上。

我下面会从方案选型开始,把每一步的“为什么”讲清楚,然后给出可以直接抄的Shader代码和C#配置,最后把我踩过的坑整理成排查表。内容偏实操,假设你已经会基本的Unity操作和Shader语法,如果完全没接触过Shader,建议先补一下顶点片元着色器的基础。

2. 方案选型:为什么我最终选了双Pass加Stencil这条路

2.1 几种常见思路的对比

实现半透明剪影,市面上能想到的路子大概有这么几条:

方案原理优点致命缺点
单Pass透明渲染直接把材质改成Transparent队列,降低Alpha实现最简单被遮挡时完全看不见,无法穿墙
深度测试关闭ZTest Always,强制绘制能穿墙会盖住所有物体,没有遮挡关系,且自身重叠部分乱序
后处理描边用深度图或法线图做边缘检测轮廓干净只能画边,做不出半透明填充,且依赖后处理栈
双Pass + Stencil一个Pass写模板,一个Pass读模板画剪影遮挡关系精确,可控性强需要理解Stencil,配置稍复杂

我最早用的是“深度测试关闭”这个偷懒方案,结果在场景里一跑就发现问题:角色站在箱子前面时,剪影会盖在箱子上面,看起来角色像浮在箱子前面一样,完全丧失了透视的语义。后来又试了后处理描边,轮廓是有了,但填充部分做不出半透明效果,而且后处理对移动端不太友好。

最终我锁定的是双Pass加Stencil的方案。它的逻辑很直观:第一个Pass正常渲染物体,但把物体的“占位信息”写进模板缓冲;第二个Pass在物体被遮挡的区域,读取模板缓冲,画一个半透明的剪影。这样遮挡关系由深度测试和模板测试共同保证,既不会穿到前面的物体上,又能在被遮挡时显示轮廓。

2.2 Stencil的工作原理,用生活化类比讲一遍

Stencil缓冲可以理解成每个像素上贴的一张“便利贴”。渲染的时候,GPU除了处理颜色和深度,还可以往这张便利贴上写数字,或者读这张便利贴上的数字来决定要不要画这个像素。

在我们的方案里,第一个Pass做的事情是:正常画物体,同时在物体覆盖的像素上写一个标记值,比如“1”。第二个Pass做的事情是:对于每个像素,先看看深度测试——如果这个像素被前面的物体挡住了(深度测试失败),那就去读便利贴,如果便利贴上是“1”,说明这里原本有个物体被挡住了,那我就画剪影;如果便利贴上是空的,说明这里本来就没东西,那就不画。

这里有个关键点:第二个Pass的深度测试要设置成“失败时才通过”,也就是ZTest Greater。正常渲染是“离相机近的通过”,我们这里反过来,专门处理被遮挡的像素。这个反转是整套方案的精髓,理解了这一点,后面所有配置都顺了。

2.3 渲染队列与批次顺序的考量

Stencil方案对渲染顺序有要求。第一个写模板的Pass必须在第二个读模板的Pass之前执行,否则读的时候便利贴上还是空的。Unity里控制这个靠的是Queue标签。

我一般把写模板的Pass放在Geometry队列(2000),把画剪影的Pass放在Transparent队列(3000)。这样不透明物体先渲染完,模板信息就绪,然后透明物体再渲染,剪影就能正确读到模板。如果你的项目里剪影物体本身也需要参与不透明渲染,那第一个Pass保持Geometry队列就行;如果剪影物体本身就是透明的,那要小心队列冲突,可能需要手动调整RenderQueue。

还有一个容易忽略的点:多个剪影物体之间的模板值不能互相干扰。比如两个敌人都被墙挡住,各自的剪影应该独立显示。这时候模板的读写要用“只写自己的区域,读的时候只认自己的值”,通常配合Stencil的Ref值和比较函数来实现。我后面给代码的时候会具体说。

3. Shader核心实现:从模板写入到剪影绘制的完整代码

3.1 第一个Pass:正常渲染并写入模板

第一个Pass的任务很纯粹:把物体正常画出来,同时往模板缓冲写一个标记。这里不需要任何特殊的光照计算,用最简单的Unlit就行,因为我们只关心它写模板的功能,颜色输出其实会被第二个Pass覆盖掉一部分。

Pass { Tags { "LightMode" = "Always" } ZWrite On ZTest LEqual ColorMask 0 // 不输出颜色,只写深度和模板 Stencil { Ref 1 Comp Always Pass Replace } CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); return o; } fixed4 frag(v2f i) : SV_Target { return fixed4(0, 0, 0, 0); } ENDCG }

这里有几个细节值得说。ColorMask 0表示这个Pass不往颜色缓冲写任何东西,只写深度和模板。这样做的好处是省带宽,而且不会和后面的剪影颜色打架。Stencil块里,Ref 1是我们要写的值,Comp Always表示不管当前模板是什么都通过,Pass Replace表示通过后把模板值替换成1。

注意:如果你的场景里已经有其他物体在用Stencil,Ref值要避开它们,否则会互相覆盖。我一般会用一个项目里统一的约定,比如剪影系统统一用1到8这一段。

3.2 第二个Pass:读取模板并绘制半透明剪影

第二个Pass是重头戏。它要在深度测试失败(被遮挡)且模板值为1的像素上,画一个半透明的颜色。

Pass { Tags { "LightMode" = "Always" "Queue" = "Transparent" } ZWrite Off ZTest Greater Blend SrcAlpha OneMinusSrcAlpha Stencil { Ref 1 Comp Equal Pass Keep } CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float3 viewDir : TEXCOORD1; }; fixed4 _SilhouetteColor; float _RimPower; float _Alpha; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.viewDir = WorldSpaceViewDir(v.vertex); return o; } fixed4 frag(v2f i) : SV_Target { float3 normal = normalize(i.worldNormal); float3 viewDir = normalize(i.viewDir); // 菲涅尔效果:边缘更亮,中间更透 float rim = 1.0 - saturate(dot(normal, viewDir)); rim = pow(rim, _RimPower); fixed4 col = _SilhouetteColor; col.a = _Alpha * (0.3 + 0.7 * rim); return col; } ENDCG }

这个Pass里,ZTest Greater是灵魂,它让深度测试在“被遮挡”时通过。Blend SrcAlpha OneMinusSrcAlpha是标准的透明混合。Stencil块里Comp Equal表示只有模板值等于1的像素才通过,Pass Keep表示不修改模板值。

菲涅尔部分是我后来加的。纯色剪影看起来有点平,加上边缘发光的效果后,轮廓感更强,视觉上也更像“透视”而不是“贴纸”。_RimPower控制边缘的锐利程度,值越大边缘越细;_Alpha控制整体透明度,我一般设在0.3到0.6之间,太透了看不清,太实了又挡住背景。

3.3 材质面板参数与默认值建议

在Shader的Properties块里,我一般暴露这几个参数:

Properties { _SilhouetteColor ("剪影颜色", Color) = (0.2, 0.8, 1.0, 1.0) _Alpha ("整体透明度", Range(0, 1)) = 0.45 _RimPower ("边缘锐度", Range(0.5, 8)) = 3.0 }

颜色我默认给一个偏青蓝的值,因为这种颜色在大多数场景里辨识度高,又不会像红色那样有强烈的警示感。透明度默认0.45,是我试下来在明亮和暗场景里都比较平衡的值。边缘锐度3.0是个中庸值,想要更柔和的轮廓可以降到1.5,想要更锐利的边可以升到5以上。

实操心得:如果你的项目有昼夜变化,剪影颜色最好跟着环境光走,或者至少准备两套预设。我在一个夜战场景里用默认的青蓝色,结果在暗处几乎看不见,后来改成偏暖的橙色才解决。

4. 工程配置与脚本控制:让剪影系统真正跑起来

4.1 材质与渲染队列的配置

Shader写好后,新建一个材质,把Shader拖上去。然后在材质的Inspector里,把Render Queue手动设成Transparent(3000)。虽然Shader里第二个Pass标了Queue,但Unity的材质面板有时会覆盖这个设置,手动确认一下更保险。

如果场景里有多个剪影物体,它们共用同一个材质就行,Stencil的Ref值是一样的,但因为每个物体的模板写入是独立的,读的时候也是各读各的像素区域,所以不会串。这一点我一开始担心过,实测下来是没问题的,模板缓冲是按像素存的,不同物体覆盖不同像素,互不干扰。

4.2 用C#脚本动态控制剪影开关

实际项目里,剪影效果通常不是一直开着的,而是根据游戏状态切换。比如只有进入“透视模式”时才显示,或者只有被特定技能标记的敌人才显示。这时候需要一个脚本来控制材质的参数。

using UnityEngine; public class SilhouetteController : MonoBehaviour { [SerializeField] private Color silhouetteColor = new Color(0.2f, 0.8f, 1.0f, 1.0f); [SerializeField] private float alpha = 0.45f; [SerializeField] private float rimPower = 3.0f; private Renderer[] renderers; private MaterialPropertyBlock propBlock; private static readonly int ColorID = Shader.PropertyToID("_SilhouetteColor"); private static readonly int AlphaID = Shader.PropertyToID("_Alpha"); private static readonly int RimPowerID = Shader.PropertyToID("_RimPower"); void Awake() { renderers = GetComponentsInChildren<Renderer>(); propBlock = new MaterialPropertyBlock(); } public void SetSilhouetteEnabled(bool enabled) { foreach (var r in renderers) { r.GetPropertyBlock(propBlock); propBlock.SetColor(ColorID, silhouetteColor); propBlock.SetFloat(AlphaID, enabled ? alpha : 0f); propBlock.SetFloat(RimPowerID, rimPower); r.SetPropertyBlock(propBlock); } } }

这里用MaterialPropertyBlock而不是直接改材质,是为了避免材质实例化。直接改renderer.material会为每个物体创建一个材质副本,场景里物体一多,Draw Call和内存都会上去。MaterialPropertyBlock是逐渲染器的属性覆盖,不破坏合批,是更规范的做法。

注意:MaterialPropertyBlock有个坑,它不会自动同步到所有Pass。如果你的Shader有多个Pass都用了同一个属性,要确认每个Pass都能正确读到。我实测下来,只要属性名一致,两个Pass都能读到,但如果你用了[PerRendererData]标签,行为会不一样,建议先小范围测试。

4.3 与相机和层级的配合

剪影效果对相机设置不敏感,但有一个点要注意:如果相机开了后处理,特别是景深或运动模糊,剪影可能会被模糊掉,看起来糊成一团。我的做法是把剪影物体单独放一个Layer,然后在后处理设置里排除这个Layer,或者调整后处理的深度阈值。

另外,如果场景用了多相机叠加(比如UI相机和主相机分开),要确保剪影物体只被主相机渲染,不然UI相机也会画一遍,可能出现重影。这个通过Culling Mask控制就行,属于常规操作。

5. 常见问题与排查技巧实录

5.1 剪影不显示或显示异常

这是最常见的问题,我整理了一个排查表:

现象可能原因排查方法
完全看不到剪影第二个Pass的Queue没设对检查材质Render Queue是否为3000以上
剪影盖住了前面的物体ZTest设成了Always或LEqual确认第二个Pass是ZTest Greater
剪影颜色不对模板值冲突检查场景里是否有其他Stencil物体用了Ref 1
剪影闪烁多个剪影物体渲染顺序不稳定给剪影物体设置相同的Queue和Sorting Order
剪影边缘有锯齿没有开MSAA或后处理抗锯齿在Quality设置里开启抗锯齿

我遇到最多的是“剪影盖住前面物体”,基本都是ZTest写错了。记住一个口诀:写模板用LEqual,画剪影用Greater。LEqual是正常渲染逻辑,Greater是专门抓被遮挡的像素。

5.2 性能开销与优化建议

双Pass方案比单Pass多了一次绘制,但第一个Pass只写深度和模板,不写颜色,开销其实很小。真正的大头在第二个Pass的透明混合,因为透明物体不能合批,每个剪影物体都是一次独立的Draw Call。

优化方向有几个:一是控制剪影物体的数量,不要给场景里所有敌人都挂剪影,只给需要透视的那几个挂;二是简化剪影模型的面数,剪影不需要高模,用低模甚至一个包围盒都行,反正只显示轮廓;三是如果剪影物体很多,可以考虑用GPU Instancing,但透明物体Instancing支持有限,要实测。

我在一个场景里同时开了20个剪影敌人,中端手机上帧率从60掉到了45左右。后来把剪影模型换成低模,并且限制了同时显示的数量(超过10个就只显示最近的),帧率就回来了。所以数量控制比Shader优化更有效。

5.3 与URP/HDRP的兼容性处理

上面给的代码是基于内置管线的CG写法。如果你用的是URP,需要把CGPROGRAM换成HLSLPROGRAM,引入URP的Shader库,并且Pass的Tags要改成URP的LightMode。Stencil部分URP是支持的,语法基本一致。

HDRP对Stencil的支持更严格,需要在Shader的Tags里声明"RenderPipeline" = "HDRP",并且模板操作要通过HDRP的API来做。我建议如果项目用HDRP,先查一下官方文档里Stencil的用法,不要直接套内置管线的代码,否则可能编译不过。

实操心得:跨管线移植时,最省事的做法是先用Shader Graph搭一个原型,确认Stencil逻辑跑得通,再转成手写Shader。Shader Graph里虽然有Stencil节点,但功能有限,复杂逻辑还是得手写。

6. 效果扩展:从基础剪影到进阶视觉表现

6.1 加入描边强化轮廓

基础剪影是纯填充,如果背景颜色和剪影颜色接近,轮廓会糊在一起。加一圈描边能显著提升辨识度。实现方式是在第二个Pass里,用顶点法线外扩的方式再画一层,颜色更深、透明度更高。

// 在第二个Pass之后再加一个Pass Pass { ZWrite Off ZTest Greater Blend SrcAlpha OneMinusSrcAlpha Cull Front // 只画背面,形成外扩轮廓 Stencil { Ref 1 Comp Equal Pass Keep } // 顶点着色器里把顶点沿法线外扩 // o.pos = UnityObjectToClipPos(v.vertex + v.normal * _OutlineWidth); }

外扩宽度_OutlineWidth一般设在0.01到0.03之间(世界空间单位),太宽了会像香肠,太窄了看不出效果。这个技巧在角色选择界面特别好用,能让剪影从背景里跳出来。

6.2 用渐变和纹理丰富剪影层次

纯色剪影看久了会腻,可以加一张渐变纹理或者噪声纹理,让剪影有流动感或能量感。比如技能指示器可以用扫描线纹理,让剪影从上到下扫过;透视模式可以用菲涅尔加噪声,做出“X光”的质感。

实现上就是在第二个Pass的frag里,用屏幕UV或模型UV采样一张纹理,然后和剪影颜色相乘。纹理的Wrap Mode设成Repeat,配合_Time.y做UV滚动,就能有动态效果。这个扩展不复杂,但视觉提升很明显,适合做技能特效或状态提示。

6.3 多颜色分层与优先级

如果场景里有多类剪影物体,比如友方是绿色、敌方是红色、中立是黄色,可以通过MaterialPropertyBlock给每个物体设不同的_SilhouetteColor。Stencil的Ref值可以保持一样,因为颜色是逐物体的属性,不影响模板逻辑。

但如果需要不同类别的剪影有不同的遮挡规则,比如友方剪影可以穿透所有墙,敌方剪影只能穿透特定墙,那就需要多组Stencil值配合多个材质。这种需求比较少见,实现起来也复杂,我一般建议用Layer加相机Culling的方式来做,比改Stencil更直观。

7. 我在实际项目里踩过的几个坑

第一个坑是模板值污染。有一次场景里有个UI特效也用了Stencil,Ref值恰好也是1,结果剪影在UI出现的时候会闪一下。排查了半天才发现是模板冲突。后来我养成了一个习惯:项目里所有Stencil用法都登记在一个表里,分配不同的Ref段,剪影系统固定用1到8,UI用9到16,互不重叠。

第二个坑是移动端的精度问题。在某些安卓机型上,Stencil缓冲的位深可能只有4位或8位,Ref值超过15就可能溢出。我一开始用了Ref 20,在编辑器里好好的,打包到手机上剪影就没了。后来把Ref值控制在0到15之间,问题解决。所以如果你的项目要上移动端,Ref值别设太大。

第三个坑是透明排序。剪影物体如果本身还有别的透明材质(比如角色身上的特效),两个透明Pass的排序可能会乱。我的做法是把剪影材质单独放一个Renderer,和角色本体分开,通过Sorting Order控制先后。这样虽然多了一个Renderer,但排序可控,比在一个Renderer里塞多个材质要稳。

第四个坑是Shader变体爆炸。如果剪影Shader里用了很多关键字(比如#pragma multi_compile),打包时变体会很多,包体变大,加载也慢。我后来把不必要的关键字都去掉了,只保留最核心的功能,变体数量从几十个降到了几个。这个在项目后期优化时特别重要,早期不注意,后期改起来很痛苦。

这套半透明剪影方案我从第一次实现到现在,前后迭代了大概四五个版本,从最初的单Pass偷懒版,到现在的双Pass加菲涅尔加描边,中间踩的坑基本都在这了。核心逻辑其实不复杂,就是Stencil加ZTest反转,但细节上的配置和兼容性处理,才是决定能不能在项目里稳定跑起来的关键。如果你正在做类似的需求,建议先把最小可运行版本跑通,再逐步加效果,不要一上来就堆功能,否则出了问题很难定位是哪一层的原因。

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

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

立即咨询