Unity深度纹理重构世界坐标实现动态激光雷达扫描效果
1. 项目概述:从深度纹理到动态激光雷达
最近在做一个科幻题材的项目,需要实现一个动态的激光雷达扫描效果,就像电影里飞船或探测器扫描星球表面那样,一道扇形的光幕扫过,实时勾勒出场景中物体的轮廓。这个需求听起来很酷,但Unity内置的渲染管线里并没有现成的“激光雷达”组件。最直接的思路可能是用Shader画一个扇形,然后让它旋转,但这只能得到一个平面的、无视场景几何体的“贴图”效果,毫无立体感和交互性。
真正的挑战在于,如何让这道“扫描光”知道它前方有什么?如何让它扫过物体时,能精确地“感知”到物体的表面,并据此动态生成扫描线?答案就藏在相机的深度纹理(Depth Texture)里。这个项目,本质上就是一次对深度纹理的深度挖掘和坐标转换之旅。我们不是简单地画一个特效,而是要利用每一帧的深度信息,在Shader中逆向推算出扫描线接触到的每一个像素在世界空间中的精确位置,从而实现扫描光与场景几何体的实时、精确交互。
这个技术点的应用远不止于激光雷达。理解了如何从深度纹理重构世界坐标,你就掌握了在屏幕后处理中实现诸如真实世界交互的溶解边缘、基于距离的雾效增强、动态遮挡高亮、甚至是一些高级的屏幕空间全局光照(SSGI)效果的基础。它打通了屏幕像素信息与三维世界之间的桥梁。
2. 核心原理:深度纹理与坐标系的穿梭
在动手写代码之前,我们必须把底层原理吃透。很多特效做出来感觉“不对味儿”,或者性能开销巨大,问题往往出在对原理的一知半解上。
2.1 深度纹理里到底存了什么?
Unity中,我们可以通过Camera.depthTextureMode |= DepthTextureMode.Depth;来让相机生成一张深度纹理。这张纹理的每个像素(R通道)存储的是一个深度值。但这个值不是我们直观理解的“距离相机的米数”。
在Unity的渲染管线中,深度纹理通常存储的是经过投影变换后的、归一化的设备坐标(NDC)的Z分量。对于透视相机,这个值是非线性的,它更靠近近裁剪面(Near Clip Plane)的精度更高,远裁剪面(Far Clip Plane)的精度更低。这个非线性关系由投影矩阵决定。
简单来说,深度纹理里的值是一个介于0到1之间的数,0代表近裁剪面,1代表远裁剪面。但它和实际距离(linearDepth)的换算关系是一个双曲线:linearDepth = 1.0 / (z * A + B),其中A和B是由投影矩阵参数推导出来的常数。好在Unity为我们提供了内置函数LinearEyeDepth和Linear01Depth,可以帮我们轻松完成这个转换。
注意:
LinearEyeDepth返回的是从相机到该像素的世界空间直线距离(单位通常与Unity世界空间单位一致,比如米)。这是我们重构世界坐标的关键第一步。
2.2 从屏幕UV到世界坐标的完整路径
重构世界坐标的核心思想是逆向工程渲染过程。渲染时,一个顶点从世界空间 -> 观察空间 -> 齐次裁剪空间 -> NDC -> 屏幕像素。我们现在要从屏幕像素反推回世界空间。
路径如下:
- 屏幕像素 -> NDC:已知屏幕像素坐标
(x, y)和深度值z(从深度纹理采样并线性化后得到linearDepth)。我们需要先将(x, y)转换为NDC坐标(ndcX, ndcY)。在Shader中,我们通常使用屏幕UV坐标(u, v),其中(0,0)是左下角,(1,1)是右上角。转换公式为:ndcX = u * 2 - 1,ndcY = v * 2 - 1。 - NDC -> 观察空间:观察空间是以相机为原点的坐标系。NDC坐标
(ndcX, ndcY, ndcZ)需要乘以相机的投影矩阵的逆矩阵(_InvProjectionMatrix),并除以w分量,才能转换到观察空间。这里有个技巧:我们可以构造一个观察空间的方向向量。在观察空间中,近裁剪面中心点的坐标是(0,0,-Near)。通过逆投影矩阵,我们可以将屏幕UV对应的射线方向计算出来。 - 观察空间 -> 世界空间:得到观察空间的位置或方向后,再乘以相机的世界-观察矩阵的逆矩阵(即观察-世界矩阵,_CameraToWorldMatrix),即可转换到世界空间。
Unity在Shader中提供了非常便捷的宏ComputeWorldSpacePosition或者我们可以手动组合这些矩阵。一个常见且高效的手动计算方法片段如下:
// 在Fragment Shader中 float2 uv = i.uv; // 屏幕UV float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, uv); float linearDepth = LinearEyeDepth(depth, _ZBufferParams); // 关键:得到观察空间下的Z值(正值) // 方法:重建观察空间位置 float4 clipPos = float4(uv * 2.0 - 1.0, 1.0, 1.0); // 齐次裁剪空间,z先设为1 float4 viewPos = mul(unity_CameraInvProjection, clipPos); // 逆投影变换到观察空间 viewPos.xyz /= viewPos.w; // 齐次除法 // 此时viewPos.xyz是一个位于近裁剪面上的点(因为z=1),方向是从相机指向该点 viewPos.xyz *= linearDepth; // 沿着视线方向延伸到实际的深度位置 // 观察空间 -> 世界空间 float3 worldPos = mul(unity_CameraToWorld, float4(viewPos.xyz, 1.0)).xyz;这段代码是整个过程的心脏。理解每一行在做什么,比复制粘贴更重要。
2.3 动态激光雷达的逻辑设计
有了任意屏幕像素对应的世界坐标,我们就能设计激光雷达的扫描逻辑了。
- 扫描平面定义:我们将激光雷达想象成一个无限薄的、垂直的“扫描扇面”。这个扇面由一个原点(雷达位置)、一个法线(扫描面的朝向)和一个向上向量定义。扫描时,扇面绕垂直轴(或指定轴)旋转。
- 距离与夹角判断:对于重构出的世界坐标点
worldPos,我们计算它到雷达原点的向量。然后计算这个向量与当前扫描扇面法线的点积。如果点积结果接近0(在一个很小的阈值内),说明该点恰好位于扫描面上。 - 动态生成扫描线:我们不仅检查“是否在面上”,还要结合扫描进度。例如,我们有一个从0到1的
_ScanProgress参数控制扫描角度。我们计算worldPos相对于雷达的极角(方位角)。如果这个极角落在当前_ScanProgress所覆盖的扇形角度范围内,则该点被视为“正在被扫描”。 - 效果渲染:将被扫描到的点,根据其到雷达的距离、高度等信息,映射到颜色和强度上。通常,距离越远信号越弱(颜色变淡/透明度增加),可以加入基于距离的衰减;也可以根据世界坐标的Y值(高度)来着色,模拟地形高度图。
3. 完整Shader实现与关键代码解析
理论清晰后,我们进入实战。这里我将实现一个基于Command Buffer或后处理的全屏激光雷达扫描Shader。我选择后处理方式,因为它兼容性好,易于集成。
3.1 Shader属性与变量定义
Shader "Hidden/Custom/DynamicLidarScan" { Properties { _MainTex ("Texture", 2D) = "white" {} _ScanColor ("扫描颜色", Color) = (0, 1, 0, 1) _ScanWidth ("扫描宽度(角度)", Range(1, 30)) = 10 _ScanProgress ("扫描进度", Range(0, 1)) = 0 _ScanSpeed ("扫描速度", Float) = 0.5 _MaxDistance ("最大扫描距离", Float) = 100 _FadePower ("衰减强度", Range(0.1, 5)) = 2 _NoiseTex ("噪声纹理", 2D) = "white" {} _NoiseStrength ("噪声强度", Range(0, 0.5)) = 0.1 _BaseIntensity ("基础强度", Range(0, 2)) = 1 } SubShader { Cull Off ZWrite Off ZTest Always 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; float4 _ScanColor; float _ScanWidth; float _ScanProgress; float _MaxDistance; float _FadePower; sampler2D _NoiseTex; float _NoiseStrength; float _BaseIntensity; float4 _NoiseTex_ST; // 雷达位置(世界空间),通常由脚本传递 float3 _LidarWorldPos; // 雷达初始前向向量(世界空间),用于计算旋转基准 float3 _LidarWorldForward; // 雷达初始向上向量(世界空间) float3 _LidarWorldUp;这里定义了核心参数。_ScanWidth控制扇形光束的角宽度,太窄像细线,太宽则失去“扫描”感,5-15度是比较好的范围。_FadePower控制距离衰减的曲线,值越大,远处衰减越快。噪声纹理用于给扫描线添加一些不规则扰动,使其看起来更像能量束而非完美几何体。
3.2 顶点着色器与视线方向计算
v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; // 优化:在顶点着色器计算从相机到远裁剪面四个角的射线方向(观察空间) // 这样在片段着色器可以节省一次矩阵乘法 float3 farClipRay = mul(unity_CameraInvProjection, float4(v.uv * 2 - 1, 1, 1)).xyz; farClipRay /= farClipRay.z; // 归一化到z=-1的平面(观察空间) o.ray = farClipRay; return o; }这个优化技巧很重要。我们在顶点着色器利用相机的逆投影矩阵,为每个顶点(对应屏幕四个角)计算出一条从相机出发、指向远裁剪面相应位置的方向向量(在观察空间)。在片段着色器,我们只需要对这个方向向量进行插值,然后乘以线性深度,就能得到观察空间位置,比每个像素都做一次完整的逆投影变换要高效。
3.3 片段着色器:核心扫描逻辑
这里是所有魔法发生的地方。
fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, i.uv); // 如果深度为1(远裁剪面或天空盒),跳过计算 if(depth >= 1.0) return col; // 1. 重构世界坐标(使用优化后的方法) float linearDepth = LinearEyeDepth(depth); float3 viewPos = i.ray * linearDepth; // 观察空间位置 float3 worldPos = mul(unity_CameraToWorld, float4(viewPos, 1.0)).xyz; // 2. 计算点到雷达的向量 float3 toPoint = worldPos - _LidarWorldPos; float distanceToLidar = length(toPoint); // 距离裁剪:超过最大距离不显示扫描效果 if(distanceToLidar > _MaxDistance) return col; // 3. 计算当前扫描扇面的法线方向 // 扫描进度 _ScanProgress 从0到1,对应旋转0到360度 float scanAngle = _ScanProgress * 2 * UNITY_PI; // 绕雷达的向上轴旋转初始前向向量,得到当前扫描方向 float3 scanDir = rotateVectorAroundAxis(_LidarWorldForward, _LidarWorldUp, scanAngle); // 扫描扇面的法线是扫描方向顺时针旋转90度(假设扫描面是垂直的) float3 scanPlaneNormal = rotateVectorAroundAxis(scanDir, _LidarWorldUp, UNITY_PI * 0.5); // 4. 判断点是否在扫描扇面上 // 计算点到扫描面的“有符号距离” float distToPlane = dot(toPoint, scanPlaneNormal); // 取绝对值,并判断是否在扫描宽度对应的容差范围内 float planeTolerance = distanceToLidar * tan(radians(_ScanWidth * 0.5)); float scanFactor = 1 - saturate(abs(distToPlane) / planeTolerance); // 5. 判断点是否在当前扫描进度角范围内 // 将toPoint投影到水平面(忽略Y轴差异),计算其与雷达前向的夹角 float3 toPointHorizontal = float3(toPoint.x, 0, toPoint.z); float3 forwardHorizontal = float3(_LidarWorldForward.x, 0, _LidarWorldForward.z); // 计算方位角,范围[-π, π] float pointAngle = atan2( dot(cross(forwardHorizontal, toPointHorizontal), _LidarWorldUp), dot(forwardHorizontal, toPointHorizontal) ); // 将角度归一化到[0, 2π] if(pointAngle < 0) pointAngle += 2 * UNITY_PI; // 计算当前扫描角度范围 float currentScanAngle = scanAngle; // 扫描中心线角度 float halfScanWidthRad = radians(_ScanWidth * 0.5); float angleStart = currentScanAngle - halfScanWidthRad; float angleEnd = currentScanAngle + halfScanWidthRad; // 处理角度环绕(0和2π交界处) float angleFactor = 0; if(angleEnd > 2 * UNITY_PI) { // 范围跨过了0度 if(pointAngle >= angleStart || pointAngle <= angleEnd - 2 * UNITY_PI) { angleFactor = 1; } } else if(angleStart < 0) { // 范围从负开始 if(pointAngle >= angleStart + 2 * UNITY_PI || pointAngle <= angleEnd) { angleFactor = 1; } } else { // 正常范围 if(pointAngle >= angleStart && pointAngle <= angleEnd) { angleFactor = 1; } } // 6. 合并因子,并应用距离衰减和噪声 float finalScanFactor = scanFactor * angleFactor; if(finalScanFactor > 0) { // 距离衰减:越远信号越弱 float distanceFade = 1 - saturate(distanceToLidar / _MaxDistance); distanceFade = pow(distanceFade, _FadePower); // 添加噪声扰动,使扫描线边缘不规则 float2 noiseUV = worldPos.xz * _NoiseTex_ST.xy + _NoiseTex_ST.zw + _Time.y * 0.1; float noise = tex2D(_NoiseTex, noiseUV).r * 2 - 1; // 范围[-1, 1] finalScanFactor += noise * _NoiseStrength; finalScanFactor = saturate(finalScanFactor); // 重新钳制到[0,1] // 计算最终颜色 float3 scanEffect = _ScanColor.rgb * finalScanFactor * distanceFade * _BaseIntensity; // 叠加到原画面(简单加法混合,也可用Screen等模式) col.rgb += scanEffect; } return col; } // 辅助函数:绕轴旋转向量 float3 rotateVectorAroundAxis(float3 v, float3 axis, float angle) { axis = normalize(axis); float cosA = cos(angle); float sinA = sin(angle); return v * cosA + cross(axis, v) * sinA + axis * dot(axis, v) * (1 - cosA); } ENDCG } } }关键点解析与避坑指南:
- 深度值判断:
if(depth >= 1.0)这一行至关重要。它排除了天空盒和超过远裁剪面的像素。如果不加这个判断,你会为整个天空盒计算世界坐标,结果是无意义的(通常是远裁剪面上的一个巨大坐标),会导致严重的性能浪费和视觉错误。 - 平面容差计算:
planeTolerance = distanceToLidar * tan(radians(_ScanWidth * 0.5))。这是根据当前点到雷达的距离和扫描半角宽度,动态计算出的“容差带”宽度。距离越远,同样的角度对应的平面距离就越宽,这符合透视规律。 - 角度处理:角度计算和范围判断是最大的坑点。
atan2函数返回的范围是[-π, π],而我们的扫描进度是[0, 2π],需要转换。更麻烦的是角度的循环性(0度就是360度)。代码中处理了扫描范围跨越0点的情况,这是实现无缝旋转扫描的关键。很多实现这里没处理好,会导致扫描到360度时效果突然跳变。 - 噪声应用:噪声的UV使用了世界坐标的XZ分量,这样噪声图案会“贴”在世界地面上,随着扫描移动,噪声图案是稳定的,而不是在屏幕空间闪烁。加上
_Time.y可以让噪声缓慢流动,模拟能量不稳定。 - 性能:在片段着色器中进行了深度判断、距离判断、角度计算、矩阵运算等,开销不小。务必在真机上测试性能。如果成为瓶颈,可以考虑:
- 降低后处理分辨率。
- 将扫描逻辑放到一个更低分辨率的Render Texture中处理,然后上采样混合。
- 使用
stencil或自定义渲染管线,只对可能被扫描到的物体区域(如一个大的扇形包围盒)执行这个Shader。
4. C#控制脚本与场景搭建
Shader写好了,还需要一个C#脚本来驱动它,控制扫描进度、传递雷达位置参数。
using UnityEngine; using UnityEngine.Rendering.PostProcessing; public class DynamicLidarController : MonoBehaviour { public PostProcessVolume postProcessVolume; // 关联后处理Volume private LidarScanEffect _lidarEffect; public Transform lidarEmitter; // 雷达发射器Transform public float scanSpeed = 180f; // 度/秒 [Range(0, 360)] public float currentScanAngle = 0f; // 当前角度,用于编辑器调试 public bool autoScan = true; void Start() { if(postProcessVolume == null) postProcessVolume = GetComponent<PostProcessVolume>(); // 从Volume中获取我们的自定义效果 postProcessVolume.profile.TryGetSettings(out _lidarEffect); if(_lidarEffect == null) { Debug.LogError("请在PostProcess Profile中添加LidarScanEffect。"); enabled = false; return; } if(lidarEmitter == null) lidarEmitter = this.transform; // 默认使用脚本所在物体 UpdateShaderProperties(); } void Update() { if(autoScan) { currentScanAngle += scanSpeed * Time.deltaTime; currentScanAngle %= 360f; // 保持在0-360度 } UpdateShaderProperties(); } void UpdateShaderProperties() { if(_lidarEffect == null || lidarEmitter == null) return; // 将角度[0,360]转换为进度[0,1] float scanProgress = currentScanAngle / 360f; // 更新Shader中的全局属性 Shader.SetGlobalVector("_LidarWorldPos", lidarEmitter.position); Shader.SetGlobalVector("_LidarWorldForward", lidarEmitter.forward); Shader.SetGlobalVector("_LidarWorldUp", lidarEmitter.up); // 如果效果是作为PostProcess Layer,可能需要通过MaterialPropertyBlock或直接设置Material // 这里假设_lidarEffect有对应的参数接口 _lidarEffect.scanProgress.value = scanProgress; // 其他参数如_ScanColor, _ScanWidth等也可以通过类似方式在Inspector中调整并传递 } // 提供一个方法,供其他脚本触发一次扫描或设置扫描角度 public void SetScanAngle(float angle) { currentScanAngle = angle % 360f; UpdateShaderProperties(); } void OnDrawGizmosSelected() { if(lidarEmitter == null) return; // 在Scene视图中绘制雷达扫描范围的Gizmo,便于调试 Gizmos.color = Color.green; Gizmos.DrawWireSphere(lidarEmitter.position, _lidarEffect != null ? _lidarEffect.maxDistance.value : 50f); float halfWidthRad = Mathf.Deg2Rad * _lidarEffect.scanWidth.value * 0.5f; Vector3 forward = lidarEmitter.forward; Vector3 up = lidarEmitter.up; Quaternion leftRot = Quaternion.AngleAxis(-_lidarEffect.scanWidth.value * 0.5f, up); Quaternion rightRot = Quaternion.AngleAxis(_lidarEffect.scanWidth.value * 0.5f, up); Vector3 leftEdge = leftRot * forward; Vector3 rightEdge = rightRot * forward; float maxDist = _lidarEffect.maxDistance.value; Gizmos.DrawLine(lidarEmitter.position, lidarEmitter.position + leftEdge * maxDist); Gizmos.DrawLine(lidarEmitter.position, lidarEmitter.position + rightEdge * maxDist); // 绘制当前扫描扇面 float currentAngle = currentScanAngle * Mathf.Deg2Rad; Quaternion currentRot = Quaternion.AngleAxis(currentScanAngle, up); Vector3 currentDir = currentRot * forward; Gizmos.color = Color.cyan; Gizmos.DrawLine(lidarEmitter.position, lidarEmitter.position + currentDir * maxDist); } }场景搭建步骤:
- 创建一个空物体作为“雷达站”,挂载
DynamicLidarController脚本。 - 在该物体下创建一个子物体(如一个圆柱体或自定义模型)作为“雷达发射器”,将其拖拽到脚本的
lidarEmitter字段。这样雷达的位置和旋转就可以独立控制。 - 在相机上添加
PostProcessVolume组件,并创建一个新的PostProcess Profile。 - 你需要创建一个自定义的
PostProcessEffectSettings和PostProcessEffectRenderer来包装我们的Shader。这是标准后处理效果的创建流程,网上教程很多。核心是将LidarScanEffect作为一个PostProcessEffectSettings,并在其Renderer中设置材质参数。 - 将创建好的效果添加到
PostProcess Profile中,并将Volume拖给控制脚本。 - 调整Volume的
Layer和相机的PostProcess Layer,确保后处理生效。
5. 性能优化与效果增强实战
一个基础版本完成后,我们总会追求更好。下面分享几个实战中总结的优化和增强技巧。
5.1 性能优化:降低片元着色器开销
全屏后处理对每个像素执行复杂的Shader计算,压力很大。我们可以采用“降采样渲染”策略。
思路:不直接在屏幕分辨率下进行激光雷达计算。而是先将深度和颜色缓冲区拷贝到一张较低分辨率(如一半或四分之一)的Render Texture中,在这张低分辨率纹理上执行我们昂贵的激光雷达计算。最后,将计算结果上采样(可以使用简单的双线性过滤,或一个边缘保持的上采样Shader)混合回全分辨率屏幕。
实现要点:
- 使用
CommandBuffer或新的RenderGraphAPI(URP/HDRP)来组织渲染流程。 - 创建两张低分辨率RT:一张用于存储深度/颜色,一张用于存储激光雷达的计算结果。
- 激光雷达Shader的输出是扫描强度图(一个单通道或RGB的掩码)。
- 最后用一个全屏Pass,采样低分辨率的扫描强度图和高分辨率的原图进行混合。上采样时,为了减少“块状”感,可以对强度图做一个轻微的高斯模糊。
这样做,像素着色器的调用次数可能减少到原来的1/4或1/16,性能提升立竿见影,而视觉效果损失在合理的模糊处理后几乎察觉不到。
5.2 效果增强:添加脉冲与回波细节
真实的激光雷达不是一道持续的光墙,而是快速发射的脉冲。我们可以模拟这个细节。
思路:在扫描因子finalScanFactor的计算中,引入时间变量,制造脉冲波沿着扫描方向前进的效果。
修改Shader:
// 在片段着色器中,计算角度因子后 // 假设一个脉冲周期时长 _PulseDuration, 脉冲宽度 _PulseWidth(角度制) float pulseSpeed = 360.0 / _PulseDuration; // 度/秒 float currentPulseAngle = fmod(_Time.y * pulseSpeed, 360.0); // 当前脉冲头角度 // 计算当前像素点角度与脉冲头角度的差值(考虑循环) float angleDiff = abs(pointAngle - radians(currentPulseAngle)); angleDiff = min(angleDiff, 2*UNITY_PI - angleDiff); // 取最短弧长 // 如果差值在脉冲宽度内,则增强该点的效果 float pulseFactor = 1 - saturate(angleDiff / radians(_PulseWidth)); pulseFactor = pow(pulseFactor, 2); // 让脉冲中心更亮 // 将脉冲因子乘入最终的扫描效果 finalScanFactor *= (1.0 + pulseFactor * _PulseIntensity);这样,扫描线上就会有一个明亮的光点周期性地快速扫过,科技感瞬间提升。
5.3 效果增强:深度剥离与体积感
目前的扫描线是贴在物体表面的。如何让它看起来更有“体积感”,像一道真实的光幕?
思路:利用“深度剥离”思想。我们不仅采样当前像素的深度,还在当前深度值的基础上,沿着视线方向向前后各偏移一小段距离,进行多次采样。如果这些采样点的世界坐标也满足扫描条件,我们就认为扫描光束“穿过”了这个空间点,并给予一定的亮度贡献。
实现(伪代码思路):
float depth = SAMPLE_DEPTH...; float linearDepth = LinearEyeDepth(depth); float stepSize = _VolumeStepSize; // 例如0.2米 int stepCount = _VolumeStepCount; // 例如3 float totalVolumeFactor = 0; for(int j = -stepCount; j <= stepCount; j++) { float offsetDepth = linearDepth + j * stepSize; // 注意:这里需要重新计算世界坐标,不能直接用viewPos偏移,因为视线方向不是z轴 float3 offsetViewPos = normalize(i.ray) * offsetDepth; // i.ray需要是单位化的方向 float3 offsetWorldPos = mul(unity_CameraToWorld, float4(offsetViewPos, 1)).xyz; // 用同样的逻辑判断 offsetWorldPos 是否在扫描范围内 float factor = CalculateScanFactor(offsetWorldPos); // 根据j(距离中心层的距离)进行衰减,例如高斯衰减 float weight = exp(-j*j / (2.0*stepCount*stepCount)); totalVolumeFactor += factor * weight; } totalVolumeFactor /= (2*stepCount+1); // 平均或其它混合方式 finalScanFactor = max(finalScanFactor, totalVolumeFactor * _VolumeIntensity);这个方法计算量会成倍增加(循环次数 * 原有计算),必须谨慎使用,可以作为一个可开关的高质量选项。
6. 常见问题排查与调试技巧
即使按照步骤操作,你也可能会遇到各种奇怪的问题。这里记录几个我踩过的坑和解决方法。
问题1:扫描效果在物体边缘闪烁或断裂。
- 原因:深度纹理的精度问题,特别是在物体边缘,可能会因为Z-Fighting或深度缓冲的插值导致深度值不稳定。
- 排查:在Shader中,将重构出的世界坐标的Y值直接输出为颜色(
return float4(worldPos.yyy * 0.1, 1)),观察是否在边缘有剧烈的颜色跳变。 - 解决:
- 增加
planeTolerance(扫描宽度)的容差,给一个小的缓冲。 - 对深度纹理采样使用
SAMPLE_DEPTH_TEXTURE_LOD并指定一个小的LOD偏移(如0.5),进行轻微的模糊,但这会损失细节。 - 更根本的方法是,如果物体是你自己控制的,确保其Shader的深度写入是稳定的,避免使用
AlphaTest或discard操作,它们会严重干扰深度。
- 增加
问题2:扫描效果在移动相机时剧烈抖动。
- 原因:很可能是在计算观察空间射线方向时没有正确处理。确保在顶点着色器中计算的
ray方向是单位向量,或者在片段着色器中使用前进行归一化。非单位向量乘以深度后,得到的位置会出错。 - 解决:在顶点着色器中,确保
farClipRay在除以farClipRay.z后,其长度是1(在观察空间中,远裁剪面四个角到相机的距离是1)。可以在片段着色器开始时加一句float3 viewRay = normalize(i.ray);确保安全。
问题3:效果在URP/HDRP中不工作。
- 原因:内置渲染管线、URP、HDRP获取深度纹理和矩阵的接口不同。
- 解决:
- 深度纹理:URP中需要在相机设置中开启“Depth Texture”,在Shader中使用
SAMPLER(_CameraDepthTexture)。HDRP类似,但可能使用_CameraDepthTexture或_CameraDepthValuesTexture。 - 矩阵:Unity内置的
unity_CameraInvProjection和unity_CameraToWorld在SRP中可能不存在或名称不同。你需要使用GetWorldSpaceNormalizeViewDir()等SRP库函数,或从_CameraViewProjectionMatrix等矩阵中自行推导。强烈建议查阅对应渲染管线的官方文档或Shader库文件。
- 深度纹理:URP中需要在相机设置中开启“Depth Texture”,在Shader中使用
问题4:性能开销太大,移动端卡顿。
- 解决:
- 首要:实施5.1的降采样方案。
- 降低精度:在片段着色器开头,如果
finalScanFactor经过初步计算(仅用粗略的包围盒判断)就小于一个极小的阈值(如0.01),直接return col;跳过后续复杂计算。 - 控制范围:通过
_MaxDistance严格限制计算范围。在脚本中根据雷达性能动态调整此参数。 - 简化计算:如果不需要非常精确的曲面扫描,可以用一个预先计算好的、表示扫描范围的距离场(Distance Field)贴图来近似判断,将大量计算转移到预处理或顶点阶段。
调试时,多用Frame Debugger和RenderDoc工具。将中间变量(如distToPlane,angleFactor,distanceFade)可视化为颜色输出,能帮你快速定位是哪个环节的计算出了逻辑错误。例如,将scanFactor输出为红色,angleFactor输出为绿色,你就能清晰地看到扫描的平面范围和角度范围是如何叠加的。
