Unity深度纹理重构:从原理到实现激光雷达扫描特效

📅 2026/8/9 9:36:58
Unity深度纹理重构:从原理到实现激光雷达扫描特效
1. 项目概述从深度纹理到激光雷达特效的视觉魔法最近在做一个科幻驾驶模拟项目客户要求实现一个类似《赛博朋克2077》里那种车辆扫描环境的激光雷达特效。核心需求很明确屏幕上要能动态地、一圈圈地向外扩散出脉冲式的扫描波并且这个波要能“感知”到场景中的物体在碰到障碍物时产生精确的轮廓高亮反馈。这听起来像是后处理特效但单纯用Shader画几个圆环可不行它必须和场景的几何信息深度绑定。这就是“深度纹理重构”技术大显身手的地方。在Unity里我们通常拿到的只是一张存储了每个像素深度值Z-Buffer的纹理这个值范围在0到1之间代表从近裁剪面到远裁剪面的归一化距离。但我们需要的是三维空间中的具体位置世界坐标才能判断扫描波“撞”上了什么。所以整个项目的核心逻辑链条就是从屏幕空间的一个像素点出发利用它的深度值、当前摄像机的投影和视图矩阵通过一系列矩阵运算“反推”出这个像素在三维世界中的确切坐标。有了这个世界坐标我们就能计算它到扫描波中心的距离从而决定是否高亮、如何高亮最终模拟出激光雷达那种精准的空间感知效果。这个技术点不仅适用于游戏特效在AR应用的虚实遮挡、建筑可视化中的剖面分析、甚至是一些需要屏幕空间世界坐标进行特殊计算的模拟器中都是非常基础且强大的工具。无论你是想打造炫酷的科幻UI还是解决一些棘手的屏幕空间算法问题掌握深度纹理重构都是绕不开的一课。接下来我就带你一步步拆解这个效果从原理推导到Shader实现最后整合成一个完整的、可复用的激光雷达特效组件。2. 核心原理拆解深度值如何变回三维坐标要重构世界坐标我们得先搞清楚相机是怎么把三维世界“拍扁”成一张二维图片的。这个过程涉及两次关键的坐标变换。2.1 渲染管线中的坐标变换之旅一个模型顶点从本地坐标到最终屏幕像素主要经历以下变换模型变换 (Model Matrix): 顶点从模型本地坐标空间变换到世界坐标空间。这个矩阵决定了物体在场景中的位置、旋转和缩放。视图变换 (View Matrix): 将顶点从世界坐标空间变换到相机坐标空间或称观察空间。在这个空间里相机自身就是原点看向Z轴负方向。投影变换 (Projection Matrix): 这是将3D场景“投影”到2D成像平面的关键一步。对于透视投影它会产生近大远小的效果并将视锥体Frustum内的坐标变换到一个规则观察体通常是NDC标准化设备坐标中其x, y, z分量的范围都在[-1, 1]DirectX风格或[-1, 1]和[0, 1]OpenGL风格Unity采用之间。最终经过透视除法除以w分量我们得到了NDC坐标再经过视口变换就得到了屏幕像素坐标。而深度纹理中存储的深度值通常就是这个NDC坐标的z分量经过映射例如从[-1,1]映射到[0,1]。2.2 深度纹理重构的数学逆推我们的目标是从屏幕像素坐标(x, y)和其对应的深度值depth反推出世界坐标(X, Y, Z)。这个过程是渲染管线的逆过程。核心公式如下worldPos inv(V * P) * (screenToView * (ndcPos * depth))看起来有点复杂我们一步步拆解构建NDC坐标: 对于屏幕像素(x, y)其对应的NDC坐标ndcPos可以通过视口参数反算得到。在Shader中我们通常直接使用顶点着色器传递过来的屏幕空间UV0到1范围来构建float4 ndcPos float4(uv.x * 2 - 1, uv.y * 2 - 1, depth * 2 - 1, 1.0);。这里假设深度纹理格式存储的是线性深度或经过映射的Z值具体映射方式需与深度纹理的获取方式匹配。逆投影变换: 将NDC坐标乘以投影矩阵的逆矩阵inv(P)可以将其变换回观察空间相机空间。但更常见的做法是利用摄像机的近裁剪面距离_ProjectionParams.y和远裁剪面距离_ProjectionParams.z结合深度值直接线性或根据投影类型透视/正交重建观察空间的Z值然后利用屏幕射线法重建XY。这种方法更直观且性能更好。屏幕射线法更实用的方法: 在顶点着色器中我们计算从相机近裁剪面四个角发出的、指向远裁剪面的射线在观察空间中的方向。将这个方向向量传递到片元着色器并乘以根据深度值重建的观察空间线性深度即可得到观察空间坐标。最后再乘以相机到世界的变换矩阵即视图矩阵的逆矩阵inv(V)就得到了最终的世界坐标。在Unity中这个过程有高度封装的辅助函数。在片元着色器中我们可以这样写// 方法一使用Unity内置宏需要包含UnityCG.cginc #include “UnityCG.cginc” … float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float4 worldPos ComputeWorldSpacePosition(i.uv, depth, UNITY_MATRIX_I_VP); // UNITY_MATRIX_I_VP 是视图投影矩阵的逆矩阵Unity内置提供。 // 方法二屏幕射线法更易于理解和控制 struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 ray : TEXCOORD1; // 观察空间下的射线方向 }; v2f vert (appdata_base v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.texcoord; // 计算观察空间射线方向 float4 clipPos float4(v.texcoord.x * 2 - 1, v.texcoord.y * 2 - 1, -1, 1); // 近裁剪面NDC float4 camPos mul(unity_CameraInvProjection, clipPos); // 变换到观察空间 o.ray camPos.xyz; return o; } float4 frag (v2f i) : SV_Target { float depth Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv)); // 重建观察空间位置 float3 viewPos i.ray * depth * _ProjectionParams.z; // 注意i.ray是方向需要根据深度和远裁剪面缩放 // 更精确的做法是viewPos i.ray * LinearEyeDepth(depth); // 变换到世界空间 float3 worldPos mul(unity_CameraToWorld, float4(viewPos, 1.0)).xyz; // 后续使用worldPos进行计算… }注意Linear01Depth和LinearEyeDepth是两个关键函数。Linear01Depth将深度纹理的采样结果转换为[0, 1]的线性深度0为近裁剪面1为远裁剪面。LinearEyeDepth则直接转换为观察空间下的线性深度值单位通常与世界空间一致。根据你的深度纹理来源非线性Z-Buffer或线性深度纹理和需求正确选择和使用这两个函数是避免重构坐标错误的第一步。2.3 深度纹理的获取与精度陷阱在Unity中获取深度纹理主要有两种方式Camera Depth Texture: 在Camera组件上勾选“Depth Texture”。Unity会自动生成一张深度纹理_CameraDepthTexture。这种方式获取的是经过硬件深度测试后的深度缓冲Z-Buffer其值是非线性的尤其在透视投影下目的是为了在深度缓冲中提供更高的近处精度。因此在片元着色器中我们必须使用Linear01Depth或LinearEyeDepth进行解码。Render Texture with Depth: 自己创建带深度通道的Render Texture (RenderTextureFormat.Depth)。这种方式你可以控制渲染的流程和深度值的格式有时可以存储线性深度避免解码开销但需要自己管理渲染目标。精度问题是深度重构的老大难。在透视投影下由于非线性分布远处物体的深度值精度非常低。这意味着当你的扫描波传播到很远的地方时重构出的世界坐标可能会“抖动”导致特效边缘出现锯齿或闪烁。一个缓解方法是在可能的情况下尽量使用正交投影Orthographic Projection进行特效渲染或者将特效限制在距离相机较近的范围内。另外使用更高精度的深度纹理格式如RFloat或RHalf也能有所改善。3. 激光雷达特效的完整实现方案理解了原理我们就可以动手搭建激光雷达特效了。这个特效可以拆解为几个部分扫描波生成、基于深度的空间检测、轮廓高亮与融合。3.1 场景与后处理设置首先我们需要一个启用后处理的场景。创建一个新的Unity项目确保你的Unity版本支持可编程渲染管线URP或Built-in RP的Post Processing Stack。本文以Built-in RP为例使用传统的OnRenderImage方法。在主摄像机Main Camera上勾选“Depth Texture”选项。这是获取深度信息的基石。创建一个C#脚本命名为LidarEffect.cs将其挂载到摄像机上。这个脚本将负责驱动整个特效。创建一个Shader命名为LidarEffectShader用于实现核心的图像处理逻辑。LidarEffect.cs脚本的基本框架如下using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class LidarEffect : MonoBehaviour { public Shader lidarShader; // 拖入我们创建的Shader private Material _material; public Color waveColor Color.cyan; // 扫描波颜色 public float waveSpeed 5.0f; // 扫描波扩散速度 public float waveWidth 0.05f; // 扫描波宽度占半径的比例 public float waveInterval 2.0f; // 扫描波发射间隔 public Vector3 scanOrigin Vector3.zero; // 扫描中心的世界坐标 public float maxScanDistance 50.0f; // 最大扫描距离 private float _waveTimer 0f; private Listfloat _activeWaveRadii new Listfloat(); // 存储当前活跃的扫描波半径 void OnEnable() { GetComponentCamera().depthTextureMode | DepthTextureMode.Depth; } void Update() { // 计时定期发射新的扫描波 _waveTimer Time.deltaTime; if (_waveTimer waveInterval) { _waveTimer 0f; _activeWaveRadii.Add(0f); // 可以在这里触发音效等 } // 更新所有活跃扫描波的半径 for (int i _activeWaveRadii.Count - 1; i 0; i--) { _activeWaveRadii[i] waveSpeed * Time.deltaTime; // 如果波超出最大范围移除它 if (_activeWaveRadii[i] maxScanDistance) { _activeWaveRadii.RemoveAt(i); } } } // 后处理渲染 void OnRenderImage(RenderTexture src, RenderTexture dest) { if (_material null) { if (lidarShader null || !lidarShader.isSupported) { Graphics.Blit(src, dest); return; } _material new Material(lidarShader); _material.hideFlags HideFlags.HideAndDontSave; } // 将参数传递给Shader _material.SetColor(“_WaveColor”, waveColor); _material.SetFloat(“_WaveWidth”, waveWidth); _material.SetFloat(“_MaxScanDistance”, maxScanDistance); _material.SetVector(“_ScanOrigin”, scanOrigin); // 传递当前所有波的半径。这里简单传递第一个实际项目可能需要传递数组或纹理。 if (_activeWaveRadii.Count 0) { _material.SetFloat(“_CurrentWaveRadius”, _activeWaveRadii[0]); } else { _material.SetFloat(“_CurrentWaveRadius”, -1); // 无活跃波 } // 传递相机相关的矩阵用于深度重构 _material.SetMatrix(“_CamToWorldMatrix”, GetComponentCamera().cameraToWorldMatrix); _material.SetMatrix(“_CamInvProjectionMatrix”, GetComponentCamera().projectionMatrix.inverse); Graphics.Blit(src, dest, _material); } void OnDisable() { if (_material ! null) { DestroyImmediate(_material); } } }3.2 Shader核心算法距离检测与边缘高亮现在来到最关键的Shader部分。我们创建一个Unlit Shader在片元着色器中实现逻辑。属性与变量定义:Properties { _MainTex (“Base (RGB)”, 2D) “white” {} _WaveColor (“Wave Color”, Color) (0,1,1,1) _WaveWidth (“Wave Width”, Range(0.001, 0.1)) 0.02 _CurrentWaveRadius (“Current Wave Radius”, Float) 0 _MaxScanDistance (“Max Scan Distance”, Float) 50 _ScanOrigin (“Scan Origin”, Vector) (0,0,0,0) _EdgeIntensity (“Edge Intensity”, Float) 2.0 _FresnelPower (“Fresnel Power”, Float) 2.0 // 用于边缘衰减 }在CGPROGRAM中声明对应的变量并包含必要的头文件。深度重构与距离计算: 在片元着色器中我们使用屏幕射线法重构世界坐标。v2f vert (appdata_img v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.texcoord; // 计算观察空间射线方向方法如前文所述 float2 uvClip v.texcoord * 2.0 - 1.0; float4 clipPos float4(uvClip.x, uvClip.y, -1.0, 1.0); float4 camPos mul(_CamInvProjectionMatrix, clipPos); o.ray camPos.xyz; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 originalColor tex2D(_MainTex, i.uv); float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); // 如果深度为1代表天空盒或无限远跳过计算 if (depth 1.0) { return originalColor; } // 将深度转换为观察空间线性深度 float linearDepth LinearEyeDepth(depth); // 重建观察空间位置 float3 viewPos i.ray * linearDepth; // 转换到世界空间 float3 worldPos mul(_CamToWorldMatrix, float4(viewPos, 1.0)).xyz; // 计算该像素点到扫描中心的水平距离忽略Y轴模拟地面扫描 float3 toScanOrigin worldPos - _ScanOrigin; float horizontalDistance length(float3(toScanOrigin.x, 0, toScanOrigin.z)); // 核心判断是否在扫描波上 float waveEffect 0.0; if (_CurrentWaveRadius 0) { // 计算与当前波前缘的距离差 float distToWaveFront abs(horizontalDistance - _CurrentWaveRadius); // 如果距离差在波宽范围内则产生效果 if (distToWaveFront _WaveWidth * _CurrentWaveRadius) { // 使用平滑步进来柔化边缘 waveEffect 1.0 - smoothstep(0, _WaveWidth * _CurrentWaveRadius, distToWaveFront); // 添加基于法线或视角的衰减使效果更立体可选 // 这里简单使用一个基于深度差的边缘增强 } } // 边缘高亮处理当扫描波经过物体时增强物体靠近相机一侧的边缘 // 一种简单方法是采样深度纹理的相邻像素计算深度梯度 float2 texelSize 1.0 / _ScreenParams.xy; float depthUp LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv float2(0, texelSize.y))); float depthDown LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv - float2(0, texelSize.y))); float depthLeft LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv - float2(texelSize.x, 0))); float depthRight LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv float2(texelSize.x, 0))); float depthGradient abs(depthUp - depthDown) abs(depthLeft - depthRight); float edgeHighlight saturate(depthGradient * _EdgeIntensity); // 结合扫描波效果和边缘高亮 float3 finalEffect waveEffect * _WaveColor.rgb; finalEffect edgeHighlight * waveEffect * _WaveColor.rgb * 2.0; // 边缘处更亮 // 将特效叠加到原画面 fixed4 finalColor originalColor; finalColor.rgb finalEffect * waveEffect; // 用waveEffect作为遮罩确保只在波上显示 return finalColor; }这个Shader实现了基本的扫描波扩散和高亮。_WaveWidth * _CurrentWaveRadius使得波宽随着半径增大而线性增加模拟能量扩散的效果。深度梯度计算则用于找出物体的轮廓边缘。多波管理优化: 上面的例子只处理了一个扫描波。现实中激光雷达是连续脉冲。为了处理多个波我们需要将_activeWaveRadii列表传递给Shader。由于Shader数组传递有长度限制且不便动态更新一个更高效的做法是在C#端将当前所有波的半径和强度可随距离衰减编码到一张小的Render Texture例如1xN的纹理中。在Shader中采样这张纹理循环处理每一个波并累加它们的效果。或者使用一个时间变量和波的初始时间、速度在Shader中动态计算每个像素点相对于多个波的位置但这要求波的发射是严格周期性的。3.3 性能优化与视觉增强技巧性能方面降低采样率后处理是全屏操作代价高昂。可以考虑使用OnRenderImage的RenderTextureDescriptor来创建半分辨率或四分之一分辨率的临时RT进行处理最后再上采样回屏幕分辨率。对于激光雷达这种本身带有模糊感的效果分辨率降低带来的视觉损失很小。提前深度测试在Shader开头如果depth 1.0天空盒直接返回原图避免不必要的计算。简化计算比如如果扫描中心固定在相机位置可以省去一次世界坐标的减法。距离计算可以先用平方距离比较避免sqrt开方运算直到必要时。视觉增强菲涅尔效应让扫描波在掠射角与物体表面切线方向看起来更亮正对角度更暗。这可以通过计算视角方向与物体法线可从深度重建或使用法线纹理的点积来实现。动态波宽与透明度让波的宽度和透明度随着传播距离增加而发生变化模拟能量衰减。扫描线尾迹不是简单的圆环而是让波过后留下一段逐渐消失的“轨迹”这需要记录波的历史位置并在Shader中混合。与粒子系统结合在扫描波与物体相交的轮廓上动态生成粒子如小光点可以极大地增强冲击力和科技感。这需要在C#脚本中根据深度重构的结果进行物理射线检测或使用CommandBuffer在GPU端生成。4. 实战调试与常见问题排查即使按照步骤操作你也可能会遇到一些“坑”。这里记录几个我实战中踩过的问题和解决方法。4.1 坐标重构错误特效位置飘忽或错位这是最常见的问题现象是扫描波不围绕_ScanOrigin扩散或者随着相机移动而乱跑。检查深度来源首先确认摄像机已正确启用Depth Texture。在OnEnable中打印camera.depthTextureMode确认。验证矩阵传递在Shader中将重构出的worldPos直接作为颜色输出return float4(worldPos, 1.0);在Scene视图下移动相机观察Gizmos或帧调试器中的屏幕颜色。如果世界坐标正确颜色会随着相机移动而平滑变化。如果出现剧烈跳变或固定图案说明矩阵计算错误。确保从C#传递到Shader的_CamToWorldMatrix和_CamInvProjectionMatrix是正确的。注意在OnRenderImage中应使用camera.worldToCameraMatrix.inverse来获取当帧的相机到世界矩阵而不是camera.transform。区分线性深度与非线性深度务必使用LinearEyeDepth或Linear01Depth对SAMPLE_DEPTH_TEXTURE采样的结果进行解码。直接使用原始深度值进行运算是错误的。检查射线方向计算屏幕射线法中的i.ray应该在观察空间下指向远处。你可以在顶点着色器中将其作为颜色输出它应该是一个从屏幕中心向四周发散的模式。4.2 特效边缘闪烁或锯齿严重深度精度问题如前所述透视投影下远处精度低。尝试限制_MaxScanDistance不要让波传播到视锥体太远的地方。对于需要远距离扫描的场景考虑将特效渲染到另一个使用正交投影的相机再叠加到主画面。波宽计算问题_WaveWidth * _CurrentWaveRadius可能导致在波半径很小时波宽近乎为0导致效果闪烁。可以设置一个最小波宽float effectiveWidth max(_WaveWidth * _CurrentWaveRadius, _MinWaveWidth);。没有使用抗锯齿后处理Shader本身不处理几何边缘的锯齿。确保你的项目开启了MSAA或使用后处理的抗锯齿方案如FXAA、TAA。在Shader中对深度梯度或距离检测进行轻微的模糊采样周围多个像素取平均也能缓解锯齿。4.3 性能热点分析与优化使用Unity的Profiler特别是GPU Profiler需在Window Analysis Profiler中启用来定位性能瓶颈。过度绘制如果场景复杂深度纹理采样和全屏计算本身就是负担。确保你的Shader中没有不必要的循环或高开销的数学函数如sin,pow。带宽压力深度纹理和法线纹理的采样是带宽密集型操作。如果同时采样多次如为了计算梯度考虑使用tex2D的导数指令ddx,ddy来在硬件层面获取梯度但这可能在不同架构上效果有差异。更稳妥的做法是使用预计算的法线纹理G-Buffer的一部分如果项目使用了延迟渲染路径。脚本驱动开销Update中每帧更新大量波的半径以及OnRenderImage中频繁创建Material和设置属性都可能成为CPU端的开销。确保属性只在变化时设置使用MaterialPropertyBlock来批量设置属性或者将波的参数更新逻辑放在Compute Shader中执行。4.4 与其他渲染效果的兼容性激光雷达作为全屏后处理效果需要与其他效果如Bloom、Color Grading、体积雾正确融合。渲染顺序在Unity的Post Processing Stack中你需要通过[ExecuteAlways]和CameraEvent来控制你的LidarEffect脚本的执行顺序。通常深度重构类的效果需要在Bloom等光效之前执行以免高亮区域被过度泛光。HDR与颜色空间如果你的项目使用HDR和线性颜色空间确保Shader中的颜色混合是在线性空间下进行的。_WaveColor可能需要从sRGB转换颜色叠加使用finalColor.rgb originalColor.rgb finalEffect线性相加而不是finalColor.rgb lerp(originalColor.rgb, finalEffect, waveEffect)。与URP/HDRP集成在URP中你需要编写一个ScriptableRenderFeature和ScriptableRenderPass来插入到渲染管线中而不是使用OnRenderImage。深度纹理的获取方式也变为通过RendererUtils中的CopyDepthPass或直接配置RenderObjectsPass的深度纹理。原理相通但API完全不同。最后调试此类特效时善用Frame Debugger和RenderDoc这类图形调试工具至关重要。它们能让你看到每一帧的渲染状态、纹理内容和Shader中间结果是定位那些“看不见”的问题的终极武器。把这个激光雷达特效当作一个练手项目彻底搞懂深度纹理重构你就能解锁Unity中一大批高级屏幕空间特效的制作能力了。