公司动态
Unity透视效果XRay:双Pass渲染与深度测试实战解析
1. 项目概述透视效果XRay在Unity中的核心价值在游戏开发特别是动作、潜行或者解谜类项目中我们常常需要一种视觉提示来告诉玩家“虽然这个物体挡住了你的视线但后面有重要的东西”。比如当角色走到一堵墙后面时我们希望墙能变得半透明显露出墙后的宝箱、敌人或者关键路径。这种效果我们通常称之为“透视效果”或者“XRay效果”。实现这个效果听起来像是把模型材质调成半透明那么简单但实际操作起来你会发现一堆问题为什么我的XRay效果把整个场景都穿透了为什么XRay物体和正常物体叠加时颜色和深度看起来怪怪的为什么性能开销这么大这些问题的根源都指向了图形渲染的两个核心机制渲染队列Render Queue和深度测试Depth Test/ZTest。这次我们就来彻底拆解这个“透视效果XRay”从最基础的Shader编写开始一步步深入到渲染管线的底层逻辑。我会结合一个完整的Unity项目实例不仅告诉你代码怎么写更会解释每一行代码背后的“为什么”以及在实际项目中踩过的那些坑。无论你是刚接触Shader的新手还是想深入理解渲染机制的开发者这篇内容都能让你获得可以直接用到自己项目里的实战经验。2. 透视效果XRay的核心设计思路2.1 效果目标与常见误区我们的目标是让指定的物体如墙壁、岩石在被遮挡时以一种区别于其正常外观的方式如半透明、发光轮廓显示出来而当其不被遮挡时则正常渲染。新手最容易掉进的第一个坑就是直接修改物体材质启用透明度混合Blend SrcAlpha OneMinusSrcAlpha然后调低Alpha值。这样做看似实现了“透视”但会带来三个致命问题渲染顺序错乱半透明物体需要从后往前渲染而我们的墙壁原本是不透明的Opaque强行改为半透明会打乱引擎的渲染排序导致它可能错误地渲染在其他不透明物体之前或之后画面出现撕裂或深度错误。深度信息丢失标准半透明渲染通常会关闭深度写入ZWrite Off以防止半透明物体之间互相遮挡的问题。但这会导致我们的“XRay墙壁”无法为后续物体提供正确的深度遮挡墙后的所有东西都会画出来失去了“透视特定物体”的意义。视觉效果单一简单的半透明往往不够美观也无法清晰地区分“正常状态”和“透视状态”。因此一个健壮的XRay方案必须将物体的渲染拆分成至少两个部分正常的不透明渲染和特殊的透视效果渲染。并且我们需要精确控制这两部分在渲染管线中的执行顺序和深度处理逻辑。2.2 核心方案双Pass渲染与渲染队列控制基于以上分析最经典且实用的方案是在同一个Shader中使用两个独立的Pass渲染通道。第一个PassBase Pass负责物体正常状态下的渲染。它使用不透明的渲染队列如QueueGeometry进行标准的深度测试和深度写入输出物体原本的纹理和颜色。第二个PassXRay Pass专门负责透视状态下的渲染。它使用一个在透明物体之后渲染的队列如QueueTransparent1并采用特殊的混合模式和深度测试规则来绘制叠加在正常画面之上的透视效果如发光边框、扫描线、半透明体。这个方案的精髓在于对渲染队列的巧妙运用。Unity的渲染队列是一个整数数值小的先渲染。Geometry2000,AlphaTest2450,Transparent3000。我们将XRay Pass设为Transparent1即3001就能确保所有不透明物体包括物体自身的Base Pass先渲染完毕。然后渲染所有标准透明物体。最后才渲染我们的XRay效果。这样XRay效果就能稳定地叠加在所有常规物体之上不会被错误遮挡。接下来我们进入Shader的具体实现看看如何将这一思路转化为代码。3. Shader实现从框架到细节3.1 Shader基本框架与属性定义我们首先搭建Shader的基本结构并定义所需的材质属性。这些属性可以在Unity材质面板中调节。Shader Custom/XRayEffect { Properties { // 基础颜色纹理 _MainTex (Albedo (RGB), 2D) white {} _Color (Color, Color) (1,1,1,1) // 法线贴图 _BumpMap (Normal Map, 2D) bump {} _BumpScale (Normal Scale, Float) 1.0 // 高光与光滑度 _Specular (Specular, Color) (0.2, 0.2, 0.2, 1) _Glossiness (Smoothness, Range(0,1)) 0.5 // XRay效果专用属性 _XRayColor (XRay Color, Color) (0, 1, 1, 0.8) // 通常使用青色、绿色等醒目颜色 _XRayWidth (XRay Width, Range(0.1, 2.0)) 0.5 // 轮廓线或效果的宽度 _XRayIntensity (XRay Intensity, Range(0, 5)) 1.0 // 效果强度 _XRayTex (XRay Texture (Optional), 2D) white {} // 可选的扫描线等纹理 } SubShader { // 第一个Pass正常渲染 Pass { Tags { RenderTypeOpaque QueueGeometry } // ... 具体实现见下文 } // 第二个PassXRay效果渲染 Pass { Tags { QueueTransparent1 IgnoreProjectorTrue RenderTypeTransparent } Blend SrcAlpha OneMinusSrcAlpha // 标准Alpha混合 ZWrite Off // 关键关闭深度写入 ZTest Greater // 关键当深度值大于缓冲区值即被遮挡时才通过测试 // ... 具体实现见下文 } } FallBack Diffuse }关键点解析TagsQueueGeometry和QueueTransparent1是控制两个Pass渲染顺序的核心。IgnoreProjectorTrue让这个Pass不受投影器影响通常用于特效。ZWrite Off在XRay Pass中关闭深度写入。这意味着绘制XRay效果时不会修改深度缓冲区。这是为了防止XRay效果自身遮挡后续可能渲染的其他真正需要深度信息的物体。ZTest Greater这是实现“仅在遮挡时显示”的灵魂所在。深度测试默认是ZTest LEqual小于等于深度缓冲区值则通过即离相机更近或一样近。Greater意味着只有当这个片元**比深度缓冲区中已有的值更远即被遮挡**时才会被渲染。所以当物体不被遮挡时相机前无物体它的深度值小于等于缓冲区值Greater测试失败XRay Pass不渲染当它被遮挡时深度值大于缓冲区值测试通过XRay Pass渲染。3.2 顶点与片元着色器实现接下来我们填充两个Pass的着色器代码。为了清晰我们使用CGPROGRAM块并采用简单的光照模型。第一个Pass基础光照渲染这个Pass就是标准的正向基础渲染计算漫反射、高光等。// ---- 第一个PassBase Pass ---- Pass { Tags { LightModeForwardBase RenderTypeOpaque QueueGeometry} 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; fixed4 _Specular; half _Glossiness; 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; // 环境光 fixed3 ambient UNITY_LIGHTMODEL_AMBIENT.xyz * albedo.rgb; // 世界法线和视角方向 float3 worldNormal normalize(i.worldNormal); float3 worldViewDir normalize(UnityWorldSpaceViewDir(i.worldPos)); // 计算漫反射 (Lambert) float3 worldLightDir normalize(_WorldSpaceLightPos0.xyz); fixed diff max(0, dot(worldNormal, worldLightDir)); fixed3 diffuse _LightColor0.rgb * albedo.rgb * diff; // 计算高光 (Blinn-Phong) float3 halfDir normalize(worldLightDir worldViewDir); float nh max(0, dot(worldNormal, halfDir)); float spec pow(nh, _Glossiness * 128.0); fixed3 specular _LightColor0.rgb * _Specular.rgb * spec; // 最终颜色 fixed4 col fixed4(ambient diffuse specular, albedo.a); return col; } ENDCG }第二个PassXRay效果渲染这个Pass专注于产生透视视觉效果。这里实现一个常见的“边缘光”效果即物体被遮挡的部分发出特定颜色的光晕。// ---- 第二个PassXRay Pass ---- Pass { Tags { QueueTransparent1 IgnoreProjectorTrue RenderTypeTransparent } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off ZTest Greater 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 _XRayColor; float _XRayWidth; float _XRayIntensity; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 将法线转换到世界空间 o.worldNormal UnityObjectToWorldNormal(v.normal); // 计算世界空间下的视角方向 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.viewDir normalize(UnityWorldSpaceViewDir(worldPos)); return o; } fixed4 frag (v2f i) : SV_Target { // 核心计算边缘因子。法线与视角方向越接近垂直边缘越明显。 float edgeFactor 1.0 - abs(dot(normalize(i.worldNormal), normalize(i.viewDir))); // 通过平滑步进和宽度参数控制边缘范围 edgeFactor smoothstep(0, _XRayWidth, edgeFactor); // 应用强度并组合颜色 fixed4 finalColor _XRayColor; finalColor.a * edgeFactor * _XRayIntensity; // Alpha值由边缘因子和强度决定 return finalColor; } ENDCG }这个XRay Pass的工作原理顶点着色器计算裁剪空间位置、世界空间法线和视角方向。片元着色器计算“边缘因子”。dot(normal, viewDir)表示法线与视线方向的点积。当视线与表面平行即看到的是边缘时点积接近0当视线垂直表面时点积接近1或-1。1.0 - abs(...)将其反转使得边缘处值大中心处值小。smoothstep函数将边缘因子从[0, 1]的连续值根据_XRayWidth阈值平滑地映射为一个[0, 1]的遮罩。_XRayWidth越大发光边缘越宽。最终输出将计算出的边缘遮罩乘以强度_XRayIntensity赋给XRay颜色的Alpha通道。这样物体边缘尤其是被遮挡部分的轮廓就会以半透明的方式显示出来而面朝相机的部分则更透明。注意这里实现的是一种基于几何边缘的XRay效果。你完全可以在这个Pass里玩出更多花样比如替换成扫描线纹理动画、基于深度的渐变溶解等原理都是相通的利用ZTest Greater控制渲染时机用自定义的片元着色器计算视觉效果。4. 实战应用与场景整合4.1 材质配置与参数调节Shader写好后在Unity中创建一个材质球选择我们编写的Custom/XRayEffectShader。你会看到两组属性。Base部分按需贴图、调色这和普通材质无异。XRay部分这是调节效果的核心。_XRayColor选择透视效果的颜色。建议使用高饱和度的冷色如青色、亮蓝或暖色如橙色以与场景主色调区分。_XRayWidth控制发光轮廓的宽度。值太小效果不明显值太大会让整个物体都“发光”失去轮廓感。通常从0.3开始调节。_XRayIntensity控制整体亮度和透明度。强度太高可能会过于刺眼掩盖正常画面。调节技巧先将物体置于另一个物体之后确保其被遮挡。然后微调_XRayWidth和_XRayIntensity直到获得清晰且美观的轮廓发光效果。可以尝试在移动中观察确保效果在不同角度下都稳定。4.2 脚本控制动态开启与关闭我们通常不希望XRay效果一直显示。更常见的需求是当玩家靠近或触发某个条件时才启用透视。这需要通过C#脚本来动态控制。using UnityEngine; [RequireComponent(typeof(Renderer))] public class XRayController : MonoBehaviour { private Renderer targetRenderer; private Material[] originalMaterials; private Material xRayMaterial; // 我们创建的XRay材质 public Material xRayMaterialTemplate; // 在Inspector中拖入XRay材质 void Start() { targetRenderer GetComponentRenderer(); // 备份原始材质 originalMaterials targetRenderer.materials; // 实例化一个独立的XRay材质实例避免修改模板资产 if (xRayMaterialTemplate ! null) { xRayMaterial new Material(xRayMaterialTemplate); } } // 启用XRay效果将XRay材质附加到渲染器的材质列表末尾 public void EnableXRay() { if (xRayMaterial null || targetRenderer null) return; Material[] newMaterials new Material[originalMaterials.Length 1]; for (int i 0; i originalMaterials.Length; i) { newMaterials[i] originalMaterials[i]; } newMaterials[originalMaterials.Length] xRayMaterial; // XRay材质在最后渲染 targetRenderer.materials newMaterials; } // 禁用XRay效果恢复为原始材质 public void DisableXRay() { if (targetRenderer null) return; targetRenderer.materials originalMaterials; } // 示例根据玩家距离触发 void Update() { // 这里只是一个示例实际触发逻辑可能更复杂如射线检测、触发器事件等 GameObject player GameObject.FindGameObjectWithTag(Player); if (player ! null) { float distance Vector3.Distance(transform.position, player.transform.position); if (distance 10.0f) { EnableXRay(); } else { DisableXRay(); } } } void OnDestroy() { // 清理动态创建的材质实例防止内存泄漏 if (xRayMaterial ! null) { Destroy(xRayMaterial); } } }脚本工作流解析备份Start方法中备份物体原始的材质数组。实例化根据预设的XRay材质模板实例化一个独有的材质副本。这是关键步骤直接使用模板材质会导致场景中所有使用该材质的物体同时改变。启用EnableXRay方法创建一个新的材质数组包含所有原始材质并在末尾追加XRay材质。因为我们的Shader中XRay Pass的渲染队列是Transparent1放在材质数组末尾能确保它被正确渲染。禁用DisableXRay方法直接将渲染器的材质恢复为备份的原始数组。触发逻辑示例中使用了简单的距离检测。在实际项目中你可能会用到OnTriggerEnter/Exit、基于射线检测的视野判断或者更复杂的游戏逻辑事件来调用EnableXRay/DisableXRay。4.3 性能考量与优化技巧双Pass渲染意味着同一个物体被绘制了两次对性能有直接影响。在移动端或处理大量物体时需要谨慎。按需启用绝对不要在所有物体上常驻XRay效果。使用上述脚本严格按条件启用。简化XRay PassXRay Pass的着色器应尽可能简单。避免采样复杂的纹理、进行复杂的光照计算。上面的示例只进行了简单的向量运算是很好的范例。减少顶点数对于复杂模型可以考虑使用一个简化的LODLevel of Detail模型来渲染XRay效果。因为轮廓效果对模型细节不敏感用一个低模版本可以显著降低顶点处理开销。使用Shader LOD在SubShader中定义LOD值当相机距离过远时Unity可能会选择更简单的Shader变体或直接跳过某些Pass。批处理中断动态修改材质renderer.materials会打断Unity的动态批处理和GPU Instancing。对于需要频繁切换的物体这不是最优解。另一种高级方案是使用**材质属性块MaterialPropertyBlock**来动态修改Shader属性而不更换材质本身但这要求Shader和渲染逻辑有相应的设计。5. 常见问题与深度排查即使按照上述步骤操作你仍可能遇到一些诡异的问题。下面是我在实践中总结的“排坑指南”。5.1 效果不显示或显示不全问题XRay效果完全看不到或者只显示一部分。排查步骤检查渲染队列确认XRay Pass的Queue标签值大于所有可能遮挡它的物体的队列值。如果有一个物体的透明材质队列是Transparent2那么它就会遮挡你的XRay效果。确保你的XRay Pass队列足够靠后。检查深度测试ZTest确认是ZTest Greater。如果误设为LEqual效果会一直显示即使没被遮挡如果设为Always则无视深度永远显示。检查深度写入ZWriteXRay Pass必须是ZWrite Off。如果开启它可能会把自己写入深度缓冲区导致后续自身的片段被错误剔除或者影响其他透明物体。检查混合模式Blend确保混合模式正确。Blend SrcAlpha OneMinusSrcAlpha是最常用的半透明混合。如果Alpha值始终为0自然看不到效果。在片元着色器中输出一个固定颜色如return fixed4(1,0,0,1);来测试Shader本身是否执行。检查相机设置确认相机没有启用任何覆盖深度或颜色的后处理效果某些全屏特效可能会干扰。尝试新建一个纯净场景测试。5.2 效果闪烁Z-Fighting问题XRay效果与正常物体交界处出现闪烁的像素。原因当XRay Pass的片元与Base Pass的片元深度值极其接近时由于深度缓冲区的精度限制谁在前谁在后变得不确定导致帧间闪烁。解决方案偏移渲染Polygon Offset在XRay Pass中加入Offset -1, -1。这个指令会让XRay效果在深度上稍微向相机方向偏移一点从而与自身表面产生一个微小的深度间隔避免竞争。参数需要根据实际情况微调。增加Alpha阈值在XRay的片元着色器中对边缘因子设置一个最小值。例如edgeFactor max(0.1, edgeFactor);避免在深度非常接近的区域产生极度透明的像素这些像素是闪烁的元凶。5.3 性能突然下降问题开启XRay后帧率骤降。排查使用Frame Debugger或RenderDoc这些工具可以查看每一帧的绘制调用Draw Call。检查开启XRay后是否产生了大量额外的Draw Call。每个启用XRay的物体至少会增加1个Draw Call用于XRay Pass。检查Overdraw在Scene视图的渲染模式中选择“Overdraw”。XRay效果是叠加渲染的会带来过度绘制Overdraw。如果大片区域都是高亮的XRay效果意味着同一个像素被绘制了多次填充率压力大。优化方法是让XRay效果尽可能“细”如只保留轮廓减少覆盖的像素面积。简化Shader复杂度使用Unity的Shader Profiling工具分析XRay Pass的片元着色器指令数是否过高。5.4 与其他透明物体的叠加问题问题当XRay物体与其他标准透明物体如粒子特效、UI重叠时渲染顺序错乱。分析所有使用Transparent队列的物体其渲染顺序由它们到相机的距离决定从远到近。我们的XRay Pass队列是Transparent1理论上在所有Transparent物体之后渲染。但如果其他透明物体也设置了更高的队列值如Transparent2顺序就可能被打乱。解决规划好项目中透明效果的渲染队列。为XRay效果预留一个固定的、较高的队列区间如Transparent10到Transparent20并确保其他需要在其后渲染的透明效果使用更大的值。在团队中建立统一的渲染队列规范文档非常重要。6. 方案进阶与效果扩展基础轮廓光效果满足大部分需求但我们可以做得更炫酷。这里提供两个扩展方向。6.1 基于深度的渐变效果有时我们不仅想看到轮廓还想感知被遮挡物体的深度。可以通过采样深度纹理来实现。首先需要让相机生成深度纹理。在Unity中可以通过Camera.main.depthTextureMode | DepthTextureMode.Depth;实现或者在相机组件上勾选相关选项。然后在Shader的XRay Pass中// 在Properties中添加 _ScreenParams (Screen Params, Vector) (1,1,0,0) // Unity内置无需手动赋值 // 在CGPROGRAM中添加声明 sampler2D _CameraDepthTexture; // Unity内置的深度纹理 // 修改片元着色器 fixed4 frag (v2f i) : SV_Target { // 1. 计算屏幕空间UV float2 screenUV i.pos.xy / _ScreenParams.xy; #if UNITY_UV_STARTS_AT_TOP screenUV.y 1 - screenUV.y; // 处理平台差异 #endif // 2. 采样深度纹理获取当前像素在深度缓冲区中的值非线性0-1 float sceneDepth tex2D(_CameraDepthTexture, screenUV).r; // 3. 将深度值转换到线性空间近似 sceneDepth LinearEyeDepth(sceneDepth); // 4. 计算当前片元的线性深度 float fragmentDepth i.pos.w; // 在顶点着色器中需要将裁剪空间下的w分量即视角空间下的z值传递过来 // 或者更精确地从视角空间位置计算 // float fragmentDepth -i.viewSpacePos.z; // 如果传递了视角空间位置 // 5. 计算深度差并映射为颜色强度 float depthDifference fragmentDepth - sceneDepth; depthDifference saturate(depthDifference / _DepthFadeDistance); // _DepthFadeDistance是控制渐变距离的参数 // 6. 结合边缘因子 float edgeFactor 1.0 - abs(dot(normalize(i.worldNormal), normalize(i.viewDir))); edgeFactor smoothstep(0, _XRayWidth, edgeFactor); fixed4 finalColor _XRayColor; // 深度差越大被遮挡得越深颜色越深或越浅取决于你想要的效果 // 例如让被遮挡更深的部分更不透明 finalColor.a * edgeFactor * depthDifference * _XRayIntensity; return finalColor; }这种效果能让玩家直观感受到物体被“埋”得多深视觉信息更丰富。6.2 动态扫描线或噪声扰动为了营造科技感可以在XRay效果上叠加动态纹理。// 在Properties中添加 _XRayNoiseTex (XRay Noise Tex, 2D) white {} _NoiseSpeed (Noise Speed, Float) 1.0 // 在片元着色器中 fixed4 frag (v2f i) : SV_Target { // ... 计算基础的edgeFactor ... // 采样噪声纹理并让其随时间滚动 float2 noiseUV i.worldPos.xz * 0.1 _Time.y * _NoiseSpeed; // 使用世界坐标和时间的简单UV fixed noise tex2D(_XRayNoiseTex, noiseUV).r; // 将噪声融入效果例如扰动边缘宽度或Alpha值 edgeFactor * (0.8 0.4 * noise); // 让边缘宽度有些许波动 // 或者用噪声裁剪边缘产生破碎感 // if (noise 0.3) discard; fixed4 finalColor _XRayColor; finalColor.a * edgeFactor * _XRayIntensity; return finalColor; }通过结合时间_Time.y和噪声纹理可以轻松实现脉冲、扫描、失真等动态透视效果让视觉反馈更具活力。从渲染队列的宏观调度到深度测试的微观判断再到Shader代码的具体实现透视效果XRay是一个绝佳的学习案例它串联起了Unity渲染管线的多个关键知识点。理解并掌握它不仅能让你做出这个特定的效果更能让你在面对其他渲染顺序、混合、深度相关的问题时拥有清晰的排查思路和解决能力。记住图形编程很多时候就是在和深度缓冲区与颜色缓冲区打交道理清数据谁先谁后、谁写谁读问题就解决了一大半。在实际项目中多使用Frame Debugger等工具亲眼看看渲染指令的提交顺序你的理解会更加深刻。