Unity角色透视效果:基于ZTest与双Pass的Shader实现

📅 2026/8/4 5:53:38
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让透视部分与背景半透明融合。这个顺序不能颠倒。如果先渲染透视PassGreater那么当时深度缓冲区里可能是天空盒或远处物体角色大部分都能通过测试画出一大片透视色然后正常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 { // 渲染队列设为Geometry1确保在大多数不透明物体之后渲染 Tags { RenderTypeOpaque QueueGeometry1} // --- Pass 1: 正常渲染 --- Pass { Name FORWARD Tags { LightModeForwardBase } 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 { LightModeForwardBase } // 仍使用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 关键代码解析与参数说明渲染队列 (QueueGeometry1): 这是整个SubShader的标签。将队列设置为Geometry1确保角色的渲染在所有标为Geometry大多数不透明物体的物体之后执行。这非常重要因为我们的透视PassZTest 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默认角色材质设为Geometry1或AlphaTest值为2450。你也可以在Shader里明确写Tags { QueueGeometry2451 }来确保顺序。4. 效果优化与高级扩展基础效果实现了但在实际项目中我们可能需要对它进行打磨和扩展以适应更复杂的需求和提升视觉品质。4.1 性能优化减少Overdraw双Pass渲染最大的潜在性能问题是过度绘制Overdraw。透视PassZTest 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属性控制开始淡出的距离。优化策略2Stencil 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如Geometry1。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框架下。核心原理双PassZTest Greater不变。5.2 深度调试技巧深度相关的问题最难直观排查。Unity提供了强大的帧调试器Frame Debugger来一探究竟。打开Frame DebuggerWindow - Analysis - Frame Debugger。开始捕获点击Enable然后操作游戏让角色走到墙后。逐步分析在左侧事件列表中找到渲染你角色的DrawCall。你会看到两个Pass的事件例如Draw Mesh YourCharacter会出现两次。查看状态点击第一个Pass正常渲染查看右侧的Render State。确认Depth Test是LEqualDepth Write是On。点击第二个Pass透视渲染确认Depth Test是GreaterDepth Write是Off。查看输出你甚至可以查看每一步之后的深度缓冲区或颜色缓冲区快照直观地看到第一个Pass画了什么第二个Pass又在哪些像素上进行了绘制。5.3 与其它特效的兼容性这个透视Shader可能会与角色身上的其他特效如溶解、扭曲、全息投影冲突因为它们可能也修改了深度测试或写入状态。通用兼容性原则深度写入是独占的确保整个角色渲染过程中只有一个Pass通常是第一个用于实体渲染开启ZWrite On。其他所有用于特效的Pass都应设为ZWrite Off。深度测试是灵活的根据特效需求调整ZTest。例如一个“被击中高亮”的全屏闪烁效果可能需要ZTest Always。渲染顺序是关键在SubShader内部Pass的执行顺序就是书写顺序。通常顺序是实体渲染 - 透视/轮廓 - 其他叠加特效。你可以通过Tags { QueueTransparent }和Offset指令来进一步微调整个SubShader与其他物体的渲染顺序。我个人在集成时的习惯是为角色创建一个主Shader它包含实体、透视等核心Pass。对于其他附加特效尽量使用独立的材质球和Shader并通过脚本控制它们的显隐而不是把所有逻辑塞进一个庞大的、难以维护的Shader中。这样模块化更清晰也便于调试。