FEATURED · 精选文章

Unity深度纹理实战:激光雷达扫描特效实现与优化

发布时间 / 2026/8/7 20:19:27
来源 / 创域科博编辑部
栏目 / 资讯中心
Unity深度纹理实战:激光雷达扫描特效实现与优化 1. 项目概述从深度纹理到激光雷达扫描在Unity里做特效尤其是那种需要和环境深度交互的深度纹理Depth Texture绝对是个绕不开的核心技术。你可能在很多科幻电影或游戏里见过那种酷炫的激光雷达扫描效果一道绿色的光波从设备中心向外扩散扫过场景遇到障碍物就勾勒出精确的轮廓仿佛给世界做了一次“CT扫描”。这种效果不仅视觉冲击力强而且极具科技感和交互性。今天我们就来彻底拆解这个效果从深度纹理的原理讲起手把手带你用C#脚本和Shader代码实现一个完全可交互、可定制的激光雷达扫描特效。这个项目的核心就是利用Unity摄像机生成的深度纹理来获取场景中每个像素点到摄像机的距离信息。有了这个距离信息我们就能在Shader中精确判断扫描波的“波前”是否到达了某个物体表面从而决定是否渲染出高亮的轮廓。整个过程不依赖复杂的物理模拟而是基于屏幕后处理Post-processing和自定义着色器性能开销可控效果却非常惊艳。无论你是想为你的FPS游戏增加一个侦查技能还是为你的科幻场景营造氛围这个技术都能派上大用场。2. 核心原理深度拆解深度纹理是什么我们怎么用它2.1 深度纹理的本质与获取深度纹理简单说就是一张存储了场景深度信息的贴图。这里的“深度”在默认的透视摄像机下指的是从摄像机近裁剪面Near Clip Plane到物体表面的Z轴距离但这个值在纹理中被编码为一个0到1之间的非线性值。Unity为我们提供了便捷的访问方式。在C#脚本中通过设置摄像机的depthTextureMode属性我们就可以命令摄像机在渲染完场景后额外生成一张深度纹理。// 在摄像机所挂载的脚本中通常是在Start或OnEnable中设置 void Start() { Camera.main.depthTextureMode | DepthTextureMode.Depth; }这里我们使用了DepthTextureMode.Depth它告诉Unity生成一张只包含深度信息的纹理。除此之外还有DepthTextureMode.DepthNormals同时编码深度和法线信息和DepthTextureMode.MotionVectors运动矢量等模式。对于我们的激光雷达扫描只需要纯粹的深度信息所以选择Depth模式就够了。注意启用深度纹理会增加GPU的显存占用和带宽开销因为它意味着多渲染一次或一个Pass。在移动端或低端设备上需谨慎评估。另外透明物体Queue 2500默认不会写入深度纹理如果你的扫描需要作用于透明物体需要单独处理其着色器。2.2 从深度值到真实世界距离从深度纹理中采样得到的是一个0到1的编码值我们称之为rawDepth。这个值不是线性的尤其是在透视投影下越靠近远裁剪面深度值的精度越低。为了得到可用于计算的、线性的真实世界距离我们需要在Shader中进行解码。Unity在UnityCG.cginc中提供了两个极其重要的函数LinearEyeDepth和Linear01Depth。Linear01Depth(z)将采样的非线性深度值z转换为线性的、在0近裁剪面到1远裁剪面之间的值。LinearEyeDepth(z)将采样的非线性深度值z转换为线性的、以世界单位如米表示的、从摄像机到该点的绝对距离。对于激光雷达扫描我们需要知道扫描波“走了多远”这个距离应该是世界空间下的线性距离。因此LinearEyeDepth是我们的首选。其核心计算涉及投影矩阵的逆变换将屏幕空间的深度还原到视图空间View Space的Z值。// 在Fragment Shader中的典型用法 float sceneRawDepth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float sceneEyeDepth LinearEyeDepth(sceneRawDepth);这样sceneEyeDepth就是当前像素点对应的场景物体距离摄像机的实际距离单位米。2.3 激光雷达扫描波的模拟有了场景深度我们如何模拟一道扩散的扫描波呢核心思路是我们为扫描波定义一个随时间增长的“当前扫描距离”_ScanDistance。在Shader的每个像素上我们比较这个_ScanDistance和该像素点的sceneEyeDepth。如果sceneEyeDepth非常接近_ScanDistance在一个很小的阈值范围内我们就认为这个像素位于“波前”上应该被高亮显示。同时为了让波有一个宽度和衰减我们通常不会只判断相等而是判断sceneEyeDepth是否落在[_ScanDistance - _ScanWidth, _ScanDistance]这个区间内距离当前波前越近亮度越高。这个_ScanDistance变量将由C#脚本控制随着时间从0摄像机位置逐渐增加到最大扫描范围从而形成波向外扩散的动画。3. 完整实现方案C#控制器与Shader编写3.1 C#脚本扫描效果控制器C#脚本负责管理扫描的启停、控制扫描波的进度、传递参数给Shader并处理与游戏逻辑的交互比如点击哪里就从哪里开始扫描。using UnityEngine; [RequireComponent(typeof(Camera))] public class LidarScanController : MonoBehaviour { [Header(扫描参数)] public float scanSpeed 10.0f; // 扫描波传播速度米/秒 public float maxScanDistance 50.0f; // 最大扫描距离 public float scanWidth 0.5f; // 扫描波宽度 public Color scanColor Color.green; // 扫描波颜色 [Header(交互设置)] public bool scanOnClick true; // 是否点击触发 public KeyCode manualScanKey KeyCode.Space; // 手动触发按键 private Material _scanMaterial; // 后处理材质 private bool _isScanning false; private float _currentScanDistance 0f; private Vector3 _scanWorldOrigin; // 扫描的世界空间原点 // Shader中属性的ID用于高效传递参数 private static readonly int ScanDistanceID Shader.PropertyToID(_ScanDistance); private static readonly int ScanWidthID Shader.PropertyToID(_ScanWidth); private static readonly int ScanColorID Shader.PropertyToID(_ScanColor); private static readonly int ScanOriginID Shader.PropertyToID(_ScanWorldOrigin); void Start() { // 确保摄像机生成深度纹理 Camera cam GetComponentCamera(); cam.depthTextureMode | DepthTextureMode.Depth; // 创建后处理材质使用我们即将编写的Shader Shader scanShader Shader.Find(Custom/LidarScan); if (scanShader null) { Debug.LogError(找不到 Custom/LidarScan Shader。请确保已创建并编译。); enabled false; return; } _scanMaterial new Material(scanShader); _scanWorldOrigin transform.position; // 默认从摄像机位置开始扫描 } void Update() { // 处理输入触发 if (scanOnClick Input.GetMouseButtonDown(0)) { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, maxScanDistance)) { StartScan(hit.point); } } if (Input.GetKeyDown(manualScanKey)) { StartScan(transform.position); // 从摄像机位置开始 } // 更新扫描进程 if (_isScanning) { _currentScanDistance scanSpeed * Time.deltaTime; // 将参数传递给材质 _scanMaterial.SetFloat(ScanDistanceID, _currentScanDistance); _scanMaterial.SetFloat(ScanWidthID, scanWidth); _scanMaterial.SetColor(ScanColorID, scanColor); _scanMaterial.SetVector(ScanOriginID, _scanWorldOrigin); // 扫描结束判断 if (_currentScanDistance maxScanDistance) { StopScan(); } } } public void StartScan(Vector3 origin) { _isScanning true; _currentScanDistance 0f; _scanWorldOrigin origin; } public void StopScan() { _isScanning false; _currentScanDistance 0f; _scanMaterial.SetFloat(ScanDistanceID, 0); // 重置Shader参数 } // 关键在图像渲染完成后执行我们的自定义后处理 void OnRenderImage(RenderTexture source, RenderTexture destination) { if (_isScanning _scanMaterial ! null) { // 将原始画面和我们的扫描效果混合 Graphics.Blit(source, destination, _scanMaterial); } else { // 不扫描时直接复制原图 Graphics.Blit(source, destination); } } void OnDisable() { // 清理材质实例 if (_scanMaterial ! null) { DestroyImmediate(_scanMaterial); } } }脚本要点解析OnRenderImage方法这是Unity内置的后处理入口。当脚本挂载在摄像机上时每一帧渲染完成后都会调用此方法。我们在这里使用Graphics.Blit将源图像source用我们的_scanMaterial处理然后输出到目标destination通常是屏幕。属性ID缓存使用Shader.PropertyToID获取属性名称对应的整数ID然后在Update中使用SetFloat/SetColor等带ID参数的方法来传递数据。这比直接使用字符串属性名如_scanMaterial.SetFloat(“_ScanDistance”, value)效率更高是Unity Shader编程的最佳实践。交互逻辑提供了点击触发和按键触发两种方式。点击触发时通过射线检测获取点击点的世界坐标作为扫描原点这使得扫描可以从场景中的任意点开始交互性更强。3.2 Shader核心扫描算法实现接下来是重头戏——Shader。这是一个后处理效果的Unlit Shader我们将编写其片段着色器来实现核心的扫描逻辑。Shader Custom/LidarScan { Properties { _MainTex (Texture, 2D) white {} _ScanDistance (Scan Distance, Float) 0 _ScanWidth (Scan Width, Float) 1 _ScanColor (Scan Color, Color) (0, 1, 0, 1) // 默认绿色 _ScanOrigin (Scan World Origin, Vector) (0,0,0,0) } SubShader { Cull Off ZWrite Off ZTest Always // 后处理Shader标准设置 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 ray : TEXCOORD1; // 用于视图空间射线计算 }; sampler2D _MainTex; sampler2D _CameraDepthTexture; // Unity自动赋值的深度纹理 float4 _MainTex_ST; float _ScanDistance; float _ScanWidth; float4 _ScanColor; float3 _ScanOrigin; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); // 计算从摄像机到当前屏幕像素的视图空间射线方向 // 这是将深度值转换到世界空间的关键步骤之一 float3 viewVector mul(unity_CameraInvProjection, float4(v.uv * 2.0 - 1.0, 0.0, -1.0)).xyz; viewVector mul(unity_CameraToWorld, float4(viewVector, 0.0)).xyz; o.ray normalize(viewVector); return o; } fixed4 frag (v2f i) : SV_Target { // 1. 采样原始颜色和深度 fixed4 originalColor tex2D(_MainTex, i.uv); float rawDepth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float sceneDepth LinearEyeDepth(rawDepth); // 转换为线性眼空间深度单位米 // 2. 计算当前像素的世界坐标 // 方法摄像机位置 射线方向 * 深度值 float3 worldPos _WorldSpaceCameraPos i.ray * sceneDepth; // 3. 计算从扫描原点到当前世界坐标的距离 float distToOrigin distance(worldPos, _ScanOrigin); // 4. 核心扫描逻辑 float scanFactor 0.0; // 判断当前像素的世界距离是否落在扫描波范围内 if (distToOrigin _ScanDistance - _ScanWidth distToOrigin _ScanDistance) { // 计算一个平滑的强度因子 (0到1)越接近波前越亮 scanFactor 1.0 - smoothstep(_ScanDistance - _ScanWidth, _ScanDistance, distToOrigin); // 可以添加一个基于距离的衰减让波尾也有渐变 float falloff 1.0 - saturate((_ScanDistance - distToOrigin) / _ScanWidth); scanFactor * falloff; } // 5. 混合颜色 // 使用加法混合让扫描光叠加在原画面上 fixed4 scanColor _ScanColor * scanFactor; fixed4 finalColor originalColor scanColor; // 可选增强被扫描物体边缘的亮度菲涅尔近似 // 这里简单使用扫描因子作为边缘光强度 finalColor.rgb originalColor.rgb * scanFactor * 0.3; return finalColor; } ENDCG } } FallBack Off }Shader代码关键点解析Cull Off ZWrite Off ZTest Always这是后处理Shader的标配。ZTest Always确保这个Pass永远通过深度测试覆盖全屏。_CameraDepthTexture这是一个Unity内置的全局纹理变量。只要摄像机启用了深度纹理模式这个变量就会被自动赋值我们直接在Shader中声明并使用即可。视图空间射线计算vert函数中这是将2D屏幕UV和深度值还原为3D世界坐标的关键。我们通过摄像机的逆投影矩阵unity_CameraInvProjection和摄像机到世界的矩阵unity_CameraToWorld计算出从摄像机出发、穿过当前屏幕像素的一条射线方向。在片段着色器中用摄像机位置加上这条射线方向乘以深度值就得到了该像素对应的精确世界坐标。LinearEyeDepth如前所述它将采样的非线性深度转换为线性的、以米为单位的距离。平滑处理smoothstep使用smoothstep函数而不是简单的if判断可以让扫描波的边缘有一个平滑的过渡避免出现生硬的锯齿状边界视觉效果更加柔和自然。颜色混合这里使用了加法混合originalColor scanColor让扫描光叠加在原始画面上产生发光效果。你也可以尝试其他混合模式如屏幕混合Screen等以获得不同的视觉风格。4. 效果优化与高级功能扩展基础版本已经能跑起来了但一个工业级的特效还需要考虑性能、美观和灵活性。下面分享几个优化和扩展方向。4.1 性能优化技巧降低采样开销全屏后处理对每个像素都执行一次复杂的Shader计算。如果性能吃紧可以考虑将效果渲染到一张半分辨率或更低的RenderTexture上然后再上采样到屏幕。这能显著减少像素着色器的调用次数。// 在OnRenderImage中 RenderTexture halfResRT RenderTexture.GetTemporary(source.width / 2, source.height / 2, 0); Graphics.Blit(source, halfResRT, _scanMaterial); // 在半分辨率下处理 Graphics.Blit(halfResRT, destination); // 上采样到全屏 RenderTexture.ReleaseTemporary(halfResRT);控制扫描频率不需要每帧都更新扫描距离。如果扫描速度不快可以每2-3帧更新一次_ScanDistance通过插值来平滑动画减少CPU向GPU传递数据的频率。Shader LOD与变体剔除为Shader定义不同的LOD级别并为不需要的功能如点击原点扫描编写Shader变体Unity会在构建时自动剔除未使用的变体减少运行时状态切换和包体大小。4.2 视觉效果增强添加噪声扰动纯色的扫描波看起来可能有些“假”。我们可以采样一张噪声图对_ScanDistance或scanFactor进行轻微扰动让波前产生不规则的、破碎的效果更接近真实的能量扩散。float noise tex2D(_NoiseTex, i.uv * _NoiseScale _Time.y * _NoiseSpeed).r; float distortedScanDistance _ScanDistance noise * _NoiseStrength; // 然后用distortedScanDistance参与计算实现扫描“回波”真实的雷达有回波。我们可以模拟多个衰减的波同时存在。在C#脚本中维护一个波长的列表每个波有自己的距离和强度在Shader中遍历所有波并累加效果。基于法线的边缘增强目前的检测只基于深度距离。我们可以启用DepthTextureMode.DepthNormals在Shader中解码出法线信息。当扫描波遇到法线变化剧烈的地方即物体边缘时给予更高的亮度或不同的颜色这样轮廓勾勒会更精准、更有立体感。与场景物体交互让扫描波能“激活”场景中的特定物体。例如可以在Shader中读取物体的自定义数据如通过额外的RenderTexture传递ID当扫描波经过时向C#脚本发送消息触发物体上的灯光亮起、门打开等逻辑。4.3 应对不同渲染管线上述代码基于Unity内置渲染管线Built-in RP。如果你在使用通用渲染管线URP或高清渲染管线HDRP核心原理不变但API和实现方式有较大差异URP你需要创建一个ScriptableRendererFeature和对应的ScriptableRenderPass。深度纹理通过RenderingUtils.GetSceneDepthTexture获取。后处理效果通常通过BlitterAPI或自定义的CommandBuffer实现。HDRP通常使用自定义的FullScreenCustomPass。深度纹理可以通过RTHandle从HDCamera中获取。HDRP的着色器编写也更为复杂需要处理线性空间、HDR颜色等。迁移时最大的挑战在于如何正确获取和转换深度纹理以及如何将自定义的Pass插入到正确的渲染阶段。建议先查阅对应渲染管线的官方文档和示例。5. 实战调试与常见问题排查即使代码逻辑正确在实际运行时也可能遇到各种“坑”。这里记录几个我踩过的典型问题和解决方法。5.1 深度纹理采样结果为1全白或0全黑这是最常见的问题。现象扫描效果不出现或者整个屏幕都被扫描覆盖。排查检查摄像机设置确认脚本中已正确执行cam.depthTextureMode | DepthTextureMode.Depth;。可以在Scene视图的摄像机预览中查看是否有“Depth”标识。检查Shader中的深度转换确保使用了LinearEyeDepth或Linear01Depth对SAMPLE_DEPTH_TEXTURE的结果进行了解码。直接使用采样的rawDepth值是没有意义的。检查物体渲染队列确认你需要被扫描的物体渲染队列Queue小于2500即非透明物体。或者为透明物体编写一个能写入深度ZWrite On的Shader变体。检查平台支持在部分老旧的移动设备使用OpenGL ES 2.0或WebGL 1.0上深度纹理支持可能受限需要检查扩展是否存在。5.2 扫描波位置不准或抖动现象扫描波的世界坐标计算有偏差或者随着摄像机移动而抖动。排查验证世界坐标计算在Shader中将计算出的worldPos输出为颜色例如return float4(frac(worldPos), 1.0);在场景中移动摄像机观察颜色图案是否稳定。如果图案剧烈变化说明射线计算有误。统一坐标系确保_ScanOrigin扫描原点和worldPos像素世界坐标处于同一个坐标系都是世界空间。_WorldSpaceCameraPos是Unity提供的世界空间摄像机位置。精度问题在极远处深度精度会下降。如果扫描距离非常远如超过1000单位可能会发现波前闪烁或锯齿。可以考虑在远距离使用对数深度缓冲Logarithmic Depth Buffer但这需要修改摄像机和所有物体的Shader代价较大。更实用的方法是限制扫描范围或接受远距离的精度损失。5.3 后处理效果导致画面变暗或颜色异常现象启用扫描后整个游戏画面色调变了。排查颜色空间确保你的项目设置Player Settings中的颜色空间Color Space与Shader中的计算匹配。线性空间Linear和伽马空间Gamma下的颜色混合结果不同。Unity内置管线默认可能是伽马空间而URP/HDRP强制使用线性空间。在线性空间下需要进行伽马校正。HDR与Tonemapping如果你的项目启用了HDR和色调映射Tonemapping后处理效果可能在色调映射之前或之后执行结果天差地别。在URP/HDRP中你需要明确将Render Pass插入到色调映射Pass之前BeforeRenderingPostProcessing或之后AfterRenderingPostProcessing。5.4 在URP/HDRP中无法生效现象代码移植到URP后编译通过但屏幕上没效果。排查Renderer Feature是否添加在URP Asset配置的Renderer列表中找到你使用的Renderer检查是否添加了你创建的ScriptableRendererFeature。Render Pass执行条件在ScriptableRenderPass的Execute方法中检查renderingData.cameraData.cameraType确保它只在游戏摄像机CameraType.Game上执行而不是在场景预览摄像机或反射探头上执行。Shader兼容性URP使用简化的Shader库如Core.hlsl许多内置管线下的内置变量和函数如unity_CameraInvProjection需要替换为URP的等效写法如GetWorldSpaceNormalizeViewDir配合其他矩阵计算。务必参考URP的官方Shader示例。实现这个特效的过程本质上是对Unity渲染流程的一次深入实践。从摄像机设置、深度缓冲理解到Shader中的坐标空间变换、光线步进思想每一步都踩得很实。我最深的体会是图形学效果调试“可视化调试”是最强大的武器。当效果不对时不要干想大胆地把中间变量如深度值、计算出的世界坐标、距离差直接输出为颜色一眼就能看出问题出在哪个环节。比如把sceneDepth输出为灰度图你就能立刻看到整个场景的深度分布是否正确。另一个心得是性能优化要有数据支撑。不要过早优化先用最简单的方式实现效果再用Unity的Profiler特别是GPU Profiler和Frame Debugger定位真正的瓶颈。很多时候瓶颈可能不在你这个后处理效果上而是场景中其他不合理的Draw Call或Overdraw导致的。这个激光雷达扫描项目可以作为你深入Unity图形编程的一个绝佳跳板理解了它很多其他的屏幕空间效果如雾效、边缘光、景深的原理也就触类旁通了。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻