
1. 项目概述从屏幕扫描到空间交互在游戏或交互式应用中我们常常需要一种视觉手段来揭示场景中的隐藏信息或引导玩家注意力。传统的UI雷达图虽然直观但缺乏与三维游戏世界的深度融合感。而“雷达扫描”特效恰恰能将这种信息探查的体验从二维界面拉回到三维空间本身。它不仅仅是一个酷炫的视觉效果更是一种强大的交互语言——一道波阵面扫过地形起伏、建筑遮挡、角色动态所有空间信息都随着扫描线的推进而实时显现。这次我们要在Unity中实现的就是一个基于后处理Post-Processing和深度纹理Depth Texture的、完全可交互的雷达扫描特效。它的核心思路是将整个屏幕的渲染结果作为画布利用后处理Shader读取每个像素对应的世界空间深度信息然后根据一个自定义的“扫描中心”和“扫描半径”动态计算该像素是否处于“被扫描”状态并赋予其高亮、发光等视觉效果。为什么选择后处理深度纹理的方案因为它有几个不可替代的优势全场景覆盖后处理作用于最终渲染图像无需为场景中每个物体单独附加材质或脚本实现成本低兼容性极高。精确的空间感知深度纹理记录了每个像素到摄像机的距离通过简单的矩阵运算我们可以在Shader中精确还原出该像素对应的世界坐标。这使得扫描效果能严格遵循三维空间逻辑例如扫描波会因物体远近而产生正确的透视变形能真实地被墙壁等障碍物阻挡。强大的可定制性通过ShaderGraph可视化着色器编辑工具和配套的C#脚本我们可以轻松控制扫描的中心点可绑定到玩家角色、扫描速度、波宽、颜色、甚至扫描的启停与多波次管理实现高度动态和游戏逻辑驱动的特效。这个特效非常适合用于潜行游戏中的声呐探测、科幻游戏中的地形扫描、解密游戏中的线索高亮或是任何需要动态揭示场景信息的场合。接下来我将拆解整个实现流程从原理到代码让你不仅能复现更能透彻理解其背后的每一个技术选择。2. 核心原理与架构设计在动手写代码和连节点之前我们必须把核心的数学原理和系统架构想清楚。这个特效虽然最终呈现为一个屏幕效果但其计算完全基于三维世界空间。2.1 深度纹理从二维像素到三维坐标的钥匙深度纹理是后处理实现空间效果的核心。在Unity的URP通用渲染管线或内置管线开启深度纹理中摄像机在渲染完不透明物体后会生成一张深度纹理_CameraDepthTexture。这张纹理的每个像素值R通道并非颜色而是该像素对应的片元Fragment到摄像机的归一化深度值通常范围是0到10为近裁剪面1为远裁剪面。注意在URP中默认可能不生成深度纹理。我们需要确保在URP Asset设置中勾选了“Depth Texture”或者在摄像机组件上启用“Opaque Texture”它包含深度信息。有了深度值depth和当前像素的屏幕坐标uv我们如何得到世界坐标重建视图空间位置首先我们需要将屏幕UV和深度值转换回摄像机视角下的视图空间坐标。这通常通过一个内置函数或一个计算过程完成核心是使用摄像机的投影矩阵UNITY_MATRIX_P的逆矩阵进行反投影。转换到世界空间得到视图空间坐标后再乘以摄像机的世界-视图矩阵UNITY_MATRIX_I_V的逆矩阵或者直接使用unity_CameraToWorld矩阵即可得到该像素对应的世界坐标worldPos。在ShaderGraph中幸运的是这些复杂的矩阵运算已经被封装成了节点。我们可以直接使用Scene Depth节点获取深度然后用Depth节点提供的World Position输出即可轻松获得当前片元的世界坐标。这是整个效果的数据基石。2.2 扫描逻辑的数学定义假设我们在世界空间中有一个扫描中心点ScanCenter一个Vector3以及一个随时间增长的扫描半径ScanRadius一个Float。对于屏幕上任意一个像素点我们已知其世界坐标worldPos。那么该点到扫描中心的水平距离忽略Y轴高度使扫描波平行于地面传播可以这样计算float distanceToCenter length(worldPos.xz - ScanCenter.xz);接下来我们定义扫描波前。想象一个不断扩大的圆环它具有一个“波宽”ScanWidth。我们可以用smoothstep函数来创建一个平滑过渡的圆环区域// 计算当前像素相对于扫描波前的位置 float waveFront ScanRadius; float distanceToWave distanceToCenter - waveFront; // 使用smoothstep创建平滑的波环 // 当distanceToWave在 -ScanWidth/2 到 ScanWidth/2 之间时返回0到1的平滑插值 float scanFactor smoothstep(-ScanWidth*0.5, ScanWidth*0.5, -distanceToWave); // 另一种常见写法是使用abs(distanceToCenter - ScanRadius)来计算与波前的绝对距离再处理 float delta abs(distanceToCenter - ScanRadius); scanFactor 1 - smoothstep(0, ScanWidth, delta);scanFactor的值在0到1之间变化1代表像素正好处于扫描波的核心位置0代表完全不在扫描范围内。这个因子将用于混合原始颜色和扫描高亮色。2.3 系统架构C#与Shader的协同整个系统分为两层C#驱动层MonoBehaviour脚本负责游戏逻辑确定扫描中心如绑定到玩家Transform。控制扫描生命周期开始扫描、停止扫描、重置扫描。计算并随时间更新ScanRadius和可能其他的参数如波宽、颜色。通过Unity的材质属性块MaterialPropertyBlock或直接设置全局Shader属性Shader.SetGlobalVector将这些动态参数实时传递给后处理材质。Shader渲染层ShaderGraph / Shader作为后处理材质的核心。在片元着色器中执行上述深度解码、世界坐标重建、距离计算和颜色混合的全过程。接收来自C#脚本的全局属性ScanCenter,ScanRadius,ScanColor等并用于每帧的渲染计算。这种架构分离了逻辑和渲染使得特效的控制权完全掌握在游戏代码中非常灵活。例如你可以轻易实现“按下按键触发一次扫描”、“连续周期性扫描”或“扫描到敌人时改变波的颜色”等复杂逻辑。3. 使用ShaderGraph构建后处理着色器我们将首先在ShaderGraph中构建视觉部分。这里以URP为例因为ShaderGraph在URP中支持得最好。3.1 创建与基础设置创建ShaderGraph在Project窗口中右键 - Create - Shader - Universal Render Pipeline - Blank Shader Graph。命名为“RadarScanPostProcess”。图形设置打开该ShaderGraph。首先在Graph Settings中确保“Material”的“Surface Type”设置为“Transparent” “Blend Mode”设置为“Alpha”。因为后处理效果是叠加在原有画面之上的。关键节点引入Scene Color获取当前屏幕已渲染的颜色。这是我们的效果要叠加的基础。Scene Depth获取深度纹理。将其输出端口连接到Depth节点的“Depth”输入端口。Depth此节点是核心。将“Depth”输入连接后其“World Position”输出端口即为我们需要的世界空间坐标。注意这里选择“World Position”而非“View Position”。3.2 实现扫描波核心算法现在我们需要用节点网络实现第2.2节中的数学公式。定义属性Properties在Blackboard中创建以下属性它们将由C#脚本驱动_ScanCenter(Vector3)扫描中心世界坐标。_ScanRadius(Float)当前扫描半径。_ScanWidth(Float)扫描波的宽度。_ScanColor(Color)扫描波的颜色可带透明度。_ScanIntensity(Float)扫描效果的强度。计算水平距离从Depth节点拉出World Position。使用Split节点将其分解为X, Y, Z。使用Vector2组合节点将X和Z组合成一个Vector2即世界坐标的水平面投影。同样将_ScanCenter的X和Z组合成Vector2。将两个Vector2输入到Distance节点输出即为distanceToCenter。创建平滑波环获取_ScanRadius属性。使用Subtract节点计算distanceToCenter - _ScanRadius得到distanceToWave。为了创建一个以波前为中心、向内外平滑衰减的效果我们可以使用_ScanWidth来控制衰减区间。一种经典的实现是// 使用绝对值计算与波前的距离差 float delta abs(distanceToCenter - _ScanRadius); // 使用smoothstep创建0-1的平滑过渡当delta小于波宽时值大于0 float scanMask 1 - smoothstep(0, _ScanWidth, delta);在ShaderGraph中这可以通过Absolute,Subtract,Smoothstep节点组合实现。scanMask就是一个在扫描波环处为1向内外快速衰减到0的遮罩。颜色混合与输出将scanMask与_ScanColor相乘得到扫描波的颜色贡献。使用Lerp(线性插值) 节点以scanMask为插值因子在Scene Color(原始颜色) 和 (Scene Color 扫描颜色贡献) 之间进行混合。你也可以使用Add节点直接叠加但Lerp能提供更可控的混合。最后将混合结果连接到主图的Fragment输入通常标记为“Fragment”或“Base Color”。3.3 效果增强与优化基础的波环有了但可能看起来比较平淡。我们可以增加一些效果使其更出彩前沿辉光让扫描波的前沿更亮。可以再用一个更窄的smoothstep生成一个内环遮罩将其乘以一个更高的强度系数然后叠加到最终颜色上。衰减与拖尾让波环的后沿内侧也有一个渐弱的拖尾效果。这可以通过在计算scanMask时对波环内侧 (distanceToCenter _ScanRadius) 和外侧使用不同的衰减参数来实现。基于深度的边缘强化利用深度差使用DDX和DDY节点对深度求偏导来检测物体边缘在边缘处加强扫描效果能使物体轮廓在扫描时更加清晰。噪声扰动对_ScanRadius或计算距离时的坐标加入少量噪声使用Simple Noise节点可以让扫描波看起来不那么“完美”更具科技感或能量感。完成节点连接后保存ShaderGraph。然后基于此ShaderGraph创建一个新的材质命名为“Mat_RadarScan”。4. 集成后处理与C#驱动脚本着色器准备好了现在需要将它应用到摄像机并用C#脚本来驱动它。4.1 集成到Unity后处理栈在URP中集成自定义后处理效果推荐使用Renderer Feature方式它更灵活、更现代。创建Renderer Feature在Project中创建C#脚本命名为“RadarScanRendererFeature”。它需要继承ScriptableRendererFeature。在其内部创建一个继承自ScriptableRenderPass的类如RadarScanRenderPass。在RadarScanRenderPass的Execute方法中使用CommandBuffer.DrawProcedural或CommandBuffer.Blit来绘制一个覆盖全屏的四边形并使用我们创建的“Mat_RadarScan”材质。在RadarScanRendererFeature的Create方法中实例化这个Render Pass并在AddRenderPasses方法中将其加入到渲染器中。配置URP Renderer打开你的URP Renderer Asset通常是一个.asset文件。在它的Inspector面板中找到“Renderer Features”列表点击“”添加我们刚刚创建的RadarScanRendererFeature。将之前创建的“Mat_RadarScan”材质拖拽到Renderer Feature的暴露参数中。这样后处理效果就已经集成到渲染管线了。但此时材质还没有动态数据。4.2 编写C#驱动控制器创建一个名为“RadarScanController”的C#脚本将其挂载到场景中任意一个持久存在的GameObject上或玩家角色上。using UnityEngine; public class RadarScanController : MonoBehaviour { [Header(扫描核心参数)] public Transform scanCenter; // 扫描中心通常绑定玩家 public float scanSpeed 10.0f; // 半径扩张速度单位/秒 public float maxScanRadius 50.0f; // 最大扫描半径 public float scanWidth 5.0f; // 扫描波宽度 [Header(视觉效果参数)] public Color scanColor new Color(0, 1, 1, 0.7f); // 扫描波颜色 public float scanIntensity 1.0f; // 强度 [Header(运行时控制)] public bool isScanning false; // 是否正在扫描 public KeyCode triggerKey KeyCode.R; // 触发扫描的按键 // 私有变量 private float currentScanRadius 0f; private Material scanMaterial; // 后处理材质的引用 // Shader中属性的ID用于高效设置 private static readonly int ScanCenterID Shader.PropertyToID(_ScanCenter); private static readonly int ScanRadiusID Shader.PropertyToID(_ScanRadius); private static readonly int ScanWidthID Shader.PropertyToID(_ScanWidth); private static readonly int ScanColorID Shader.PropertyToID(_ScanColor); private static readonly int ScanIntensityID Shader.PropertyToID(_ScanIntensity); void Start() { // 确保有扫描中心默认为自身 if (scanCenter null) scanCenter this.transform; // 获取后处理材质 // 注意这里需要根据你集成后处理的方式获取材质实例。 // 如果是通过Renderer Feature通常需要在Feature脚本中暴露Material属性并在此处引用。 // 此处假设我们通过一个公开字段或资源加载方式获得了材质。 // scanMaterial ... (你的材质获取逻辑) // 例如如果材质是Renderer Feature的一个可配置项可以通过序列化字段传入。 // 为了示例我们这里使用一个临时创建的方式不推荐用于生产 // scanMaterial new Material(Shader.Find(Shader Graphs/RadarScanPostProcess)); // 更健壮的做法在Inspector中拖拽赋值或者通过Renderer Feature的实例获取。 // 这里先注释掉你需要根据实际项目结构连接材质。 // if (scanMaterial null) Debug.LogError(RadarScan Material not assigned!); } void Update() { // 按键触发扫描 if (Input.GetKeyDown(triggerKey)) { StartScan(); } // 更新扫描逻辑 if (isScanning) { currentScanRadius scanSpeed * Time.deltaTime; if (currentScanRadius maxScanRadius) { StopScan(); } UpdateShaderProperties(); } } /// summary /// 开始一次新的扫描 /// /summary public void StartScan() { if (isScanning) return; // 防止重复触发 isScanning true; currentScanRadius 0f; Debug.Log(Radar Scan Started.); } /// summary /// 停止当前扫描 /// /summary public void StopScan() { isScanning false; currentScanRadius 0f; UpdateShaderProperties(); // 重置参数隐藏效果 Debug.Log(Radar Scan Stopped.); } /// summary /// 更新Shader中的属性 /// /summary private void UpdateShaderProperties() { if (scanMaterial null) return; // 设置扫描中心世界坐标 scanMaterial.SetVector(ScanCenterID, scanCenter.position); // 设置当前扫描半径 scanMaterial.SetFloat(ScanRadiusID, currentScanRadius); // 设置其他视觉参数 scanMaterial.SetFloat(ScanWidthID, scanWidth); scanMaterial.SetColor(ScanColorID, scanColor); scanMaterial.SetFloat(ScanIntensityID, scanIntensity); } // 可选在编辑器模式下即使不运行也能预览效果 void OnValidate() { // 当在Inspector中修改参数时立即更新Shader仅编辑器有效 #if UNITY_EDITOR if (!Application.isPlaying) { UpdateShaderProperties(); } #endif } }关键点说明材质获取脚本中的scanMaterial需要指向实际使用的后处理材质实例。如果通过Renderer Feature实现最佳实践是在Renderer Feature的Inspector面板中公开一个Material字段然后将创建好的“Mat_RadarScan”材质拖进去再在RadarScanController脚本中通过某种方式如Singleton、资源查找或直接序列化引用获取到这个材质实例。避免在代码中动态new Material()这会导致材质不被项目管理且无法保存修改。属性ID使用Shader.PropertyToID获取属性名称对应的整数ID这是一个优化技巧比在每帧使用字符串如_ScanCenter查找属性要高效得多。全局属性替代方案如果后处理材质是全局唯一的也可以使用Shader.SetGlobalVector等全局属性设置方法。这样Shader中直接使用_ScanCenter全局变量即可无需通过材质实例。但这种方式在多个不同参数的后期效果共存时会有冲突。4.3 连接所有环节将“Mat_RadarScan”材质赋值给RadarScanRendererFeature的对应字段。将RadarScanRendererFeature添加到URP Renderer Asset中。在场景中创建空对象挂载RadarScanController脚本。将玩家角色的Transform拖拽到脚本的scanCenter字段。在RadarScanController脚本中正确获取或引用到“Mat_RadarScan”材质的实例可能需要稍作代码调整以适应你的项目架构。运行游戏按下设定的触发键默认为R你应该能看到以玩家为中心一道蓝色的波纹向外扩散的扫描效果。5. 高级功能扩展与优化实战基础功能跑通后我们可以让它更强大、更实用。5.1 多波次管理与对象交互一个高级的雷达系统可能支持连续扫描、多重回波甚至能标记扫描到的特定物体。实现多波次扫描 修改C#控制器将currentScanRadius从一个变量改为一个列表Listfloat每个元素代表一个活跃扫描波的当前半径。在Update中遍历列表更新每个半径当某个半径超过maxScanRadius时将其从列表中移除。在UpdateShaderProperties中需要将多个波的参数传递给Shader。这通常有两种方式Shader数组在Shader中定义数组属性如_ScanRadiusArray[10]C#端使用material.SetFloatArray传递。Shader中遍历所有波叠加它们的贡献。这种方式性能取决于数组大小和循环次数。多Pass渲染为每个扫描波使用一个独立的Render Pass或材质实例。这种方式更灵活波之间互不影响但Draw Call会增加。标记被扫描到的物体 在Shader中我们已经能计算出每个像素是否在扫描范围内。我们可以利用这个信息做更多事输出到自定义缓冲区在一个单独的渲染Pass中不进行颜色混合而是将scanMask等信息写入一张自定义的RenderTexture如_RadarMaskTexture。后续的游戏逻辑如另一个摄像机或Compute Shader可以读取这张纹理分析哪些屏幕区域对应世界空间物体被扫描到了。与物体ID结合如果项目使用了渲染物体ID到纹理的技术可以将扫描遮罩与物体ID图结合精确判断是哪个物体被扫描到从而触发游戏事件如敌人暴露、道具高亮。5.2 性能优化要点后处理效果每帧都会对全屏幕像素执行性能至关重要。精简Shader计算避免分支Shader中的if语句在某些GPU架构上性能较差。尽量使用step(),smoothstep(),lerp()等数学函数来替代条件判断。减少复杂函数length()函数内部有开方运算比较耗性能。在只需要比较距离大小时可以使用dot(distanceVector, distanceVector)计算距离的平方与半径的平方进行比较避免开方。利用内置变量_CameraDepthTexture的采样和视图/世界坐标重建是标准操作Unity有优化。但确保不要在不必要的地方重复采样深度纹理。控制渲染分辨率对于全屏后处理可以考虑在RenderTexture的创建中使用半分辨率width/2, height/2。因为扫描效果通常是低频变化波环移动相对平滑半分辨率下视觉损失不大但像素处理量减少到1/4性能提升显著。这可以在Render Pass的Configure方法中设置渲染目标时指定。基于距离的剔除在C#端如果ScanRadius很大但摄像机近处的物体才是关注重点可以在Shader中增加一个判断当像素的世界坐标与扫描中心的距离远大于某个阈值时直接返回原始颜色跳过后续计算。这需要额外的距离计算但对于远离扫描中心的像素能节省一些计算。5.3 美术效果深化扭曲效果让扫描波经过的地方背后的图像产生热浪般的扭曲。这可以通过在计算UV偏移时利用scanMask对屏幕UV进行扰动来实现。例如采样Scene Color时不是用原始的屏幕UV而是用uv distortionVector * scanMask其中distortionVector可以是一个基于噪声或正弦波的时间函数。深度衰减让扫描效果随着深度距离摄像机的远近而衰减。这可以让远处的扫描波看起来更淡增强空间感。只需在最终混合前将scanMask乘以一个基于worldPos与摄像机距离的衰减系数即可。地面投影增强如果扫描是平行于地面的可以特意强化与地面特定Y坐标范围相交部分的亮度或颜色模拟波与地面交互的感觉。6. 常见问题排查与调试技巧在实现过程中你可能会遇到一些典型问题。这里列出一些排查思路。问题1扫描效果完全不显示。检查深度纹理确保你的URP Asset或摄像机设置中启用了深度纹理Depth Texture或Opaque Texture。这是效果运行的前提。检查Renderer Feature确认Renderer Feature已正确添加到URP Renderer Asset中并且材质已赋值。可以在Frame Debugger中查看渲染过程确认你的Render Pass是否被执行。检查材质参数在Game视图运行时暂停游戏选中你的后处理材质实例查看其Inspector面板中的属性是否被C#脚本正确更新_ScanRadius是否在变化。如果参数没变检查C#脚本中材质引用是否正确以及UpdateShaderProperties是否被调用。检查Shader编译错误在ShaderGraph编辑器的左下角查看是否有编译错误。也可以查看Console窗口是否有Shader相关的错误提示。问题2扫描效果位置不对不跟随玩家。检查坐标空间确保在ShaderGraph中从Depth节点获取的是World Position而不是View Position或Object Position。检查C#传递的坐标在C#脚本中scanCenter.position传递的是世界坐标。确认你绑定的scanCenterTransform确实是玩家角色的Transform。使用调试输出可以在ShaderGraph中临时将World Position或计算出的distanceToCenter直接作为颜色输出例如除以一个大的数映射到0-1范围来可视化这些中间数据帮助定位计算错误发生在哪一步。问题3扫描波边缘锯齿严重Aliasing。增加波宽_ScanWidth参数太小会导致波环过于尖锐在像素层面产生锯齿。适当增加此值。使用抗锯齿确保项目的抗锯齿MSAA或后处理抗锯齿如SMAA、FXAA是开启的。后处理效果本身不会产生几何边缘但颜色的剧烈变化会暴露锯齿。Shader内抗锯齿在计算scanMask时使用更平滑的函数。smoothstep本身就有一定的抗锯齿作用。你也可以尝试在距离计算后加入一个非常小的模糊但这会增加开销。问题4移动设备上性能不佳。实施5.2节的优化特别是降低渲染分辨率这对移动端GPU帮助巨大。简化效果去掉辉光、扭曲等增强效果只保留核心的扫描波环计算。限制扫描频率不要让扫描波连续不断地发生。控制游戏逻辑降低扫描触发的频率。使用更简单的深度重建在移动端如果精度要求不高可以尝试使用线性深度而非透视正确的深度计算量会小一些。但可能会影响效果在透视下的正确性。调试利器Frame Debugger 和 RenderDocUnity Frame DebuggerWindow - Analysis - Frame Debugger。它可以逐帧、逐Draw Call地分解渲染过程。你可以找到你的后处理Render Pass查看其输入纹理深度纹理、场景颜色是否正确输出是否符合预期。RenderDoc一个强大的独立图形调试器。可以捕获一帧完整的渲染状态深入查看每一个纹理、缓冲区的数据是调试Shader逻辑的终极工具。你可以用它来检查深度纹理的具体数值、世界坐标重建是否正确等。实现这样一个可交互的雷达扫描特效是一个将图形学原理深度缓冲、空间变换、Shader编程、游戏逻辑架构和性能优化结合起来的综合练习。它看似是一个视觉效果但背后串联起了现代游戏渲染管线的多个重要环节。希望这份详细的指南不仅能帮你实现效果更能让你理解每一步背后的“为什么”从而具备自己设计和优化其他屏幕空间效果的能力。