Unity角色透视效果:基于ZTest与双Pass的Shader实现
1. 项目概述:从“穿墙”到“透视”的视觉魔法
在游戏开发中,我们常常需要实现一些特殊的视觉效果来增强玩法或叙事。比如,当角色走到一堵墙或一个大型障碍物后面时,玩家会瞬间失去对角色位置的掌控,这在强调策略或紧张追逐的关卡中是致命的体验。为了解决这个问题,“透视效果”应运而生。它能让玩家清晰地看到被遮挡的角色轮廓,仿佛拥有了“穿墙眼”。这个效果在MOBA、RTS、第三人称动作冒险游戏中非常常见。
今天要聊的,就是如何在Unity中,不依赖复杂的后处理或渲染管线修改,仅通过编写一个相对简单的Shader,利用ZTest和双Pass渲染技术,来实现一个稳定、高效且视觉效果不错的游戏角色透视效果。这个方案的核心思想是“欺骗”深度缓冲区,让被遮挡的角色部分以一种特殊的方式(如描边、半透明、发光)渲染出来,从而在视觉上“穿透”遮挡物。
网上有很多关于X-Ray、Outline的Shader教程,但大多要么效果单一,要么性能开销大,要么与场景光影结合不好。我们这次要实现的效果,重点在于“透视”而非“高亮”,它需要清晰地表达角色被遮挡部分的形状和朝向,同时不能过于突兀地破坏场景的整体渲染。使用ZTest进行深度比较,配合双Pass分别处理正常渲染和透视渲染,是一个在效果和性能上取得很好平衡的方案。无论你是Shader新手想了解深度测试的实战应用,还是想为你的游戏角色增加一个实用的功能,这篇内容都能给你一套可以直接“抄作业”的完整方案和背后的思考逻辑。
2. 核心原理与方案选型:为什么是ZTest+双Pass?
在深入代码之前,我们必须搞清楚我们要“骗”的是谁,以及怎么“骗”。现代图形渲染的核心机制之一是深度测试(ZTest/Depth Test)。GPU会为每个像素存储一个深度值(Z值),代表该像素到摄像机的距离。当一个新的片段(Fragment,可以理解为准备上色的像素)产生时,GPU会将其深度值与深度缓冲区中对应位置存储的深度值进行比较。只有通过深度测试的片段,其颜色才会被写入颜色缓冲区,并更新深度缓冲区。默认的比较函数是Less(深度值更小/离相机更近的通过)。
透视效果的本质矛盾在于:角色模型本身已经被墙壁(遮挡物)的像素在深度测试中“击败”了(因为墙壁离相机更近,深度值更小),所以角色的像素根本不会被渲染。我们要做的,就是让角色即使深度测试失败,也能以某种方式被画出来。
2.1 方案对比:从Stencil到多摄像机
实现类似效果有几种常见思路:
- 模板缓冲区(Stencil Buffer)方案:先渲染遮挡物并标记模板值,再渲染角色,只渲染在标记区域内的部分。这种方法控制精确,但需要管理模板状态,在多遮挡物、动态场景中设置稍显繁琐。
- 多摄像机渲染合成方案:用一个额外的摄像机只渲染角色到一张RenderTexture上,然后在主摄像机渲染的最后,将这张纹理以屏幕后处理的方式叠加到被遮挡的区域。效果灵活,但涉及RenderTexture分配、采样和混合,性能开销相对较大,且要处理两个摄像机间的参数同步。
- Shader内深度比较方案(本文方案):在角色自身的Shader中,通过调整深度测试规则和渲染遍数(Pass)来解决问题。这是最轻量、最内聚的方案,所有逻辑封装在一个材质球里,无需外部脚本或额外摄像机管理。
我们选择第三种方案,因为它足够简单、高效、且易于集成。一个材质,一个Shader,赋予角色模型,效果立竿见影。其核心武器就是ZTest指令和多个Pass。
2.2 ZTest指令:控制深度比较的开关
在Shader的Pass中,我们可以使用ZTest指令来改变深度测试的比较函数。除了默认的Less,常用的还有:
ZTest Greater: 当前片段深度值大于缓冲区深度值时通过(渲染更远的物体)。ZTest LEqual: 小于等于时通过(默认Less的宽松版)。ZTest Always: 总是通过,完全忽略深度测试。
要实现透视,思路就很清晰了:我们可以用两个Pass。第一个Pass按正常方式渲染角色(ZTest LEqual)。第二个Pass专门用于渲染“被遮挡的部分”,此时我们将深度测试设置为ZTest Greater,意思是“只渲染那些被其他东西挡住的部分(即角色深度比当前缓冲区深度值大的部分)”。对于这个第二遍渲染,我们再赋予它一个不同的颜色和样式(比如半透明的红色),透视轮廓就出来了。
2.3 双Pass渲染:顺序与混合的艺术
双Pass不是简单地把两个Pass堆叠在一起。渲染顺序和混合模式至关重要。
- 第一Pass(正常渲染):
ZTest LEqual,ZWrite On(写入深度)。这个Pass负责渲染角色所有未被遮挡的部分,并正常更新深度缓冲区,确保后续场景物体渲染正确。 - 第二Pass(透视渲染):
ZTest Greater,ZWrite Off(关闭深度写入)。这个Pass只在上一个Pass未能通过深度测试的像素(即被遮挡的像素)上执行。必须关闭深度写入,否则这个Pass渲染的“透视部分”会把自己的深度写进去,导致角色自身其他部分或后面其他物体无法正确渲染。同时,我们需要设置合适的混合模式(如Blend SrcAlpha OneMinusSrcAlpha)让透视部分与背景半透明融合。
这个顺序不能颠倒。如果先渲染透视Pass(Greater),那么当时深度缓冲区里可能是天空盒或远处物体,角色大部分都能通过测试,画出一大片透视色,然后正常Pass再画上去覆盖掉未遮挡部分,结果会出错。必须先正常渲染,确立“哪些部分可见”,再渲染“哪些部分不可见但需要特殊显示”。
注意:性能考量。双Pass意味着模型将被渲染两次,对于顶点数极高的模型会有额外的性能开销。但在实践中,角色模型的复杂度通常是可控的,且透视Pass通常使用更简单的片元着色计算,因此额外开销对于单个或少量角色来说微乎其微,远低于使用额外摄像机的方案。这是效果与性能的经典权衡。
3. Shader实战:逐行解析完整源码
理解了原理,我们来看代码。下面是一个完整的Unity Shader,实现了基础的角色透视效果。我们将使用Unity的ShaderLab语言和CG/HLSL片段来编写。
Shader "Custom/CharacterXRay" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _Color ("Main Color", Color) = (1,1,1,1) _XRayColor ("X-Ray Color", Color) = (1, 0, 0, 0.5) // 默认半透明红色 _XRayPower ("X-Ray Power", Range(0.1, 5)) = 1.0 // 用于边缘强度 } SubShader { // 渲染队列设为Geometry+1,确保在大多数不透明物体之后渲染 Tags { "RenderType"="Opaque" "Queue"="Geometry+1"} // --- Pass 1: 正常渲染 --- Pass { Name "FORWARD" Tags { "LightMode"="ForwardBase" } ZWrite On ZTest LEqual Cull Back // 背面剔除 CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" #include "Lighting.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; }; 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); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 albedo = tex2D(_MainTex, i.uv) * _Color; // 简单的漫反射光照 float3 worldNormal = normalize(i.worldNormal); float3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz); fixed diff = max(0, dot(worldNormal, worldLightDir)); fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb; fixed3 diffuse = _LightColor0.rgb * diff; fixed4 col = fixed4(albedo.rgb * (ambient + diffuse), albedo.a); return col; } ENDCG } // --- Pass 2: 透视渲染 --- Pass { Name "XRAY" // 注意:这个Pass不参与标准光照,纯色或自定义效果 Tags { "LightMode"="ForwardBase" } // 仍使用ForwardBase以获取一些光照变量,但可不计算 ZWrite Off // 关键:关闭深度写入 ZTest Greater // 关键:只渲染被遮挡的部分 Blend SrcAlpha OneMinusSrcAlpha // 标准Alpha混合 Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag_xray #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f_xray { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float3 viewDir : TEXCOORD1; }; fixed4 _XRayColor; float _XRayPower; v2f_xray vert (appdata v) { v2f_xray o; o.pos = UnityObjectToClipPos(v.vertex); float3 worldNormal = UnityObjectToWorldNormal(v.normal); o.worldNormal = worldNormal; float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.viewDir = normalize(_WorldSpaceCameraPos.xyz - worldPos); return o; } fixed4 frag_xray (v2f_xray i) : SV_Target { // 基于法线与视角夹角实现边缘光增强,使轮廓更明显 float rim = 1.0 - abs(dot(normalize(i.viewDir), normalize(i.worldNormal))); rim = pow(rim, _XRayPower); // 使用参数控制边缘强度 fixed4 col = _XRayColor; col.a *= rim; // 边缘处更不透明,中心更透明 return col; } ENDCG } } FallBack "Diffuse" }3.1 关键代码解析与参数说明
渲染队列 (
"Queue"="Geometry+1"): 这是整个SubShader的标签。将队列设置为Geometry+1,确保角色的渲染在所有标为Geometry(大多数不透明物体)的物体之后执行。这非常重要,因为我们的透视Pass(ZTest Greater)需要与已经渲染好的场景深度缓冲区进行比较。如果角色先渲染了,深度缓冲区里都是角色的深度,那么墙壁渲染时反而会成为“被遮挡”的部分。所以必须让遮挡物先画,角色后画。第一Pass (正常渲染):
ZWrite On,ZTest LEqual: 标准配置,写入深度,离相机近或等近的片段通过。- 包含了基本的漫反射光照计算,让角色在可见部分看起来是正常的。
第二Pass (透视渲染):
ZWrite Off:生命线。绝对不能在渲染透视轮廓时写入深度,否则会破坏整个场景的深度信息。ZTest Greater:核心逻辑。只渲染那些深度值比当前深度缓冲区值大的片段,即被前面物体挡住的片段。Blend SrcAlpha OneMinusSrcAlpha: 启用Alpha混合,让透视颜色与背景融合。- 片元着色器中的
rim计算:这是一个小技巧。通过计算视角方向与法线方向的点积,我们得到一个值,在模型边缘(法线与视线近乎垂直)接近1,在模型正面中心(法线与视线平行)接近0。再用pow函数和_XRayPower参数控制这个衰减的强度,最后应用到透明度上。这使得透视轮廓在边缘更亮、更实,在朝向玩家的中心部分更淡、更透明,视觉效果更有层次感,更像一个“发光轮廓”而非实心色块。
属性 (
Properties):_XRayColor: 控制透视轮廓的颜色和基础透明度。例如(1,0,0,0.5)是半透明红色。_XRayPower: 控制边缘衰减的强度。值越大,边缘光越锐利,只集中在非常薄的边缘;值越小(如0.5),衰减越平缓,透视效果会蔓延到模型更中心的区域。通常设置在1.0到2.0之间观感较好。
3.2 实操步骤:在Unity中应用此Shader
- 创建Shader文件:在Project视图中右键 -> Create -> Shader -> Standard Surface Shader(或Unlit Shader),然后清空文件,将上面的完整代码粘贴进去,保存为
CharacterXRay.shader。 - 创建材质球:右键刚才创建的Shader文件 -> Create -> Material,会自动生成一个使用该Shader的材质,命名为
Mat_CharacterXRay。 - 配置材质参数:选中
Mat_CharacterXRay,在Inspector面板你可以调整_MainTex(主纹理)、_Color(主色)、_XRayColor(透视色)和_XRayPower(边缘强度)。尝试将_XRayColor调成一个醒目的半透明颜色,如青色(0,1,1,0.6)。 - 赋予角色:将你的角色模型(GameObject)拖入场景。将
Mat_CharacterXRay材质拖拽到角色模型的Mesh Renderer组件的Materials槽位中,替换原有材质。 - 测试效果:将角色移动到一堵墙或一个立方体后面。你应该能看到,角色被遮挡的部分会显示出你设置的透视颜色,并且边缘更亮。
实操心得:关于渲染队列的坑。如果你发现透视效果时有时无,或者角色完全看不见,首先检查材质的渲染队列。确保角色的材质队列值(可通过材质Inspector查看)大于遮挡物的队列值。最稳妥的方法是,将遮挡物(如墙壁、地形)的材质设为
Geometry(默认),角色材质设为Geometry+1或AlphaTest(值为2450)。你也可以在Shader里明确写Tags { "Queue"="Geometry+2451" }来确保顺序。
4. 效果优化与高级扩展
基础效果实现了,但在实际项目中,我们可能需要对它进行打磨和扩展,以适应更复杂的需求和提升视觉品质。
4.1 性能优化:减少Overdraw
双Pass渲染最大的潜在性能问题是过度绘制(Overdraw)。透视Pass(ZTest Greater)虽然只画被挡住的像素,但如果角色被一个巨大的面(如地面、一堵大墙)完全挡住,透视Pass仍然会为角色所有在屏幕上的像素执行片元着色器计算。
优化策略1:基于距离的淡化当角色离摄像机很远时,透视效果的必要性降低,且像素面积小,可以逐渐减弱或关闭透视效果。
// 在透视Pass的顶点着色器中计算深度或距离 float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float distanceToCamera = length(_WorldSpaceCameraPos.xyz - worldPos); // 将距离信息传递给片元着色器 o.distance = distanceToCamera; // 在片元着色器中 float distanceFade = saturate(1.0 - (i.distance / _FadeDistance)); col.a *= distanceFade;可以添加一个_FadeDistance属性,控制开始淡出的距离。
优化策略2:Stencil Mask限定区域如果透视效果只期望出现在特定类型的遮挡物(如场景中的特殊墙壁)后面,可以使用模板缓冲区来精确控制。给这些特殊遮挡物Shader写入一个模板值(如Ref 1),然后在角色的透视Pass中设置Stencil { Ref 1 Comp Equal },这样透视效果就只会在这些特定遮挡物后面显示。这减少了不必要的渲染,但增加了场景物体Shader的复杂度。
4.2 视觉增强:更酷炫的透视样式
基础的纯色半透明轮廓看久了会单调。我们可以修改透视Pass的片元着色器,实现更丰富的效果。
1. 动态扫描线效果:模拟一种科技感的扫描透视。
fixed4 frag_xray (v2f_xray i) : SV_Target { float rim = 1.0 - abs(dot(normalize(i.viewDir), normalize(i.worldNormal))); rim = pow(rim, _XRayPower); // 添加基于世界空间Y坐标或时间的扫描线 float scanLine = sin(i.worldPos.y * _ScanFrequency + _Time.y * _ScanSpeed) * 0.5 + 0.5; fixed4 col = _XRayColor; col.rgb *= scanLine; // 颜色乘以扫描线强度 col.a *= rim * scanLine; // 透明度也受扫描线影响 return col; } // 在Properties中添加 _ScanFrequency 和 _ScanSpeed 参数2. 噪声扰动边缘:让轮廓边缘有一种能量扰动的感觉。
sampler2D _NoiseTex; float _NoiseStrength; ... fixed4 frag_xray (v2f_xray i) : SV_Target { float rim = 1.0 - abs(dot(normalize(i.viewDir), normalize(i.worldNormal))); // 对rim值进行噪声扰动 float2 noiseUV = i.worldPos.xz * _NoiseScale + _Time.y * _NoiseSpeed; float noise = tex2D(_NoiseTex, noiseUV).r; rim = rim * (1.0 - _NoiseStrength) + (rim * noise) * _NoiseStrength; rim = pow(rim, _XRayPower); ... }3. 深度渐变的透视:让透视效果的强度随着被遮挡的“深度差”而变化。这需要访问深度纹理,涉及更复杂的CameraDepthTexture采样,属于高级主题,但效果更物理。思路是在片元着色器中采样当前像素的场景深度,与角色的裁剪空间深度进行比较,差值越大,透视色越深或越浅。
4.3 多角色管理与遮挡处理
当场景中有多个可透视角色,且他们彼此可能互相遮挡时,情况会变得复杂。使用当前的Shader,角色A被墙挡住的部分会显示透视色,但如果角色B在角色A后面,角色B的透视色可能会错误地画在角色A的前面(因为角色A的深度写入是关闭的)。
解决方案:分层渲染或自定义深度排序一个更健壮的方案是引入“透视层”的概念。将所有需要透视效果的角色放在同一个特定的渲染层(如XRay)。然后,使用两个摄像机:
- 主摄像机:渲染所有不透明物体和正常状态的角色(排除
XRay层)。 - 透视摄像机:只渲染
XRay层,使用一个特殊的、只输出深度(或特定颜色)的Shader,将其渲染到一张RT。主摄像机在后期,根据这张RT的信息,在屏幕空间合成透视效果。
这个方案更重,但能完美解决角色间透视遮挡的排序问题,并且可以实现全局统一的透视后处理风格(如全屏扭曲、噪声等)。
注意事项:移动平台适配。在移动设备上,Alpha混合和多个Pass是性能敏感操作。如果性能吃紧,可以考虑以下简化:1) 透视Pass使用
Blend One One加法混合,并取消基于法线的rim计算,使用纯色。2) 如果支持,使用Early-Z或移动平台的TBDR架构特性,但需要仔细测试。3) 终极方案是,在低端机上直接关闭透视效果,或替换为一个简单的角色脚下箭头指示器。
5. 常见问题排查与调试技巧
即使有了代码,在实际集成时也可能遇到各种奇怪的问题。这里记录一些我踩过的坑和解决方法。
5.1 问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 完全看不到透视效果 | 1. 角色渲染队列比遮挡物早。 2. 透视Pass的 ZTest设置错误(不是Greater)。3. 透视颜色 _XRayColor的Alpha值为0。 | 1. 确保角色材质Queue> 遮挡物Queue(如Geometry+1)。2. 检查Shader中第二Pass的 ZTest Greater。3. 在材质面板调整 _XRayColor的A通道大于0。 |
| 透视效果穿透所有物体,角色永远有轮廓 | 透视Pass的ZTest被设为了Always或LEqual。 | 检查并确保第二Pass为ZTest Greater。 |
| 透视部分闪烁(Z-Fighting) | 角色与遮挡物表面距离太近,深度值相差极小,精度误差导致两Pass交替通过测试。 | 1. 让角色模型与遮挡物不要完全贴合,留微小距离。 2. 在透视Pass的顶点着色器中,将顶点沿法线方向轻微外扩( v.vertex.xyz += v.normal * 0.001;),使透视轮廓略微浮在模型表面之上。 |
| 透视颜色非常暗淡或不均匀 | _XRayPower值过大,导致rim计算后除了最边缘外其他部分都接近0。 | 减小_XRayPower值(如从3.0调到1.5),或增加_XRayColor的基础Alpha值。 |
| 角色正常部分也不见了 | 第一Pass(正常渲染)的ZWrite被意外关闭,或深度测试函数错误。 | 检查第一Pass的ZWrite On和ZTest LEqual。 |
| 在URP/HDRP中无效 | 内置管线Shader与URP/HDRP的Shader语法、渲染路径不兼容。 | URP中需使用SRPDefaultUnlit或UniversalForward等LightMode,并编写HLSL代码。需要将Shader移植到URP的Shader Graph或自定义HLSL Shader框架下。核心原理(双Pass,ZTest Greater)不变。 |
5.2 深度调试技巧
深度相关的问题最难直观排查。Unity提供了强大的帧调试器(Frame Debugger)来一探究竟。
- 打开Frame Debugger:Window -> Analysis -> Frame Debugger。
- 开始捕获:点击Enable,然后操作游戏让角色走到墙后。
- 逐步分析:在左侧事件列表中找到渲染你角色的DrawCall。你会看到两个Pass的事件(例如
Draw Mesh YourCharacter会出现两次)。 - 查看状态:点击第一个Pass(正常渲染),查看右侧的Render State。确认
Depth Test是LEqual,Depth Write是On。点击第二个Pass(透视渲染),确认Depth Test是Greater,Depth Write是Off。 - 查看输出:你甚至可以查看每一步之后的深度缓冲区或颜色缓冲区快照,直观地看到第一个Pass画了什么,第二个Pass又在哪些像素上进行了绘制。
5.3 与其它特效的兼容性
这个透视Shader可能会与角色身上的其他特效(如溶解、扭曲、全息投影)冲突,因为它们可能也修改了深度测试或写入状态。
通用兼容性原则:
- 深度写入是独占的:确保整个角色渲染过程中,只有一个Pass(通常是第一个,用于实体渲染)开启
ZWrite On。其他所有用于特效的Pass都应设为ZWrite Off。 - 深度测试是灵活的:根据特效需求调整
ZTest。例如,一个“被击中高亮”的全屏闪烁效果可能需要ZTest Always。 - 渲染顺序是关键:在SubShader内部,Pass的执行顺序就是书写顺序。通常顺序是:实体渲染 -> 透视/轮廓 -> 其他叠加特效。你可以通过
Tags { "Queue"="Transparent" }和Offset指令来进一步微调整个SubShader与其他物体的渲染顺序。
我个人在集成时的习惯是,为角色创建一个主Shader,它包含实体、透视等核心Pass。对于其他附加特效,尽量使用独立的材质球和Shader,并通过脚本控制它们的显隐,而不是把所有逻辑塞进一个庞大的、难以维护的Shader中。这样模块化更清晰,也便于调试。