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 = GetComponent<Camera>(); 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,代价较大。更实用的方法是限制扫描范围,或接受远距离的精度损失。
- 验证世界坐标计算:在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示例。
- Renderer Feature是否添加:在URP Asset配置的Renderer列表中找到你使用的Renderer,检查是否添加了你创建的
实现这个特效的过程,本质上是对Unity渲染流程的一次深入实践。从摄像机设置、深度缓冲理解,到Shader中的坐标空间变换、光线步进思想,每一步都踩得很实。我最深的体会是,图形学效果调试,“可视化调试”是最强大的武器。当效果不对时,不要干想,大胆地把中间变量(如深度值、计算出的世界坐标、距离差)直接输出为颜色,一眼就能看出问题出在哪个环节。比如,把sceneDepth输出为灰度图,你就能立刻看到整个场景的深度分布是否正确。另一个心得是,性能优化要有数据支撑。不要过早优化,先用最简单的方式实现效果,再用Unity的Profiler(特别是GPU Profiler)和Frame Debugger定位真正的瓶颈。很多时候,瓶颈可能不在你这个后处理效果上,而是场景中其他不合理的Draw Call或Overdraw导致的。这个激光雷达扫描项目,可以作为你深入Unity图形编程的一个绝佳跳板,理解了它,很多其他的屏幕空间效果(如雾效、边缘光、景深)的原理也就触类旁通了。