Unity URP全屏扫描线的三种思路

Source

扫描线是游戏中很经典的功能。

我们最终需要画出一圈从世界坐标向外扩散的光环,但当它被放进全屏幕后处理后,Shader 面对的已经不是一个有完整模型数据的物体,而是整张屏幕。片元阶段只有屏幕 UV 和深度,不能直接读取原物体的世界坐标。

因此,这个效果真正要解决的问题并不是如何画圆,而是:如何从屏幕 UV 和深度还原当前像素的世界坐标。

本文记录我在 Unity URP 中实现扫描线的三次尝试:

1. 在 C# 中计算视锥体四条角射线;

2. 使用逆 VP 矩阵直接重建世界坐标;

3. 在全屏三角形的顶点阶段自建视锥射线


最后还会把思路从“覆盖整张屏幕”转到“挂载在具体物体上”,让扫描从玩家、手持物或扫描建筑的位置开始传播。

目录

1. 环境与资源准备

2. 深度图

3. 第一次尝试:在 C# 中计算四角视锥射线

3.1 思路

3.2 在 C# 中建立四条射线

3.3 这条路线的注意点

4. 第二次尝试:使用逆 VP 矩阵直接重建

5. 第三次尝试:在顶点阶段自建视锥射线

6. 三种方案怎么选

7. 重建像素世界坐标

8. 扫描圆环

9. 从全屏扫描转向物体中心传播

10. 总结

参考资料​​​​​

1. 环境与资源准备

本文使用的环境如下:

  • Unity 2022.3.57f1;
  • Universal Render Pipeline 14.0.11;
  • 透视相机;
  • URP Depth Texture;
  • Full Screen Pass Renderer Feature;
  • 带 Collider 的场景物体,用于鼠标射线确定扫描中心。

在 URP Asset 或当前 Camera 使用的 Renderer 中开启深度纹理。

URP Data的Full Screen Pass Renderer Feature 添加对应扫描材质。

2. 深度图

采样深度纹理

float rawDepth = SampleSceneDepth(i.uv); 

剔除天空盒和远裁剪面

深度纹理中并不是每个像素都对应真实场景表面,还会有天空盒和相机背景的干扰。

如果直接拿这些值重建世界坐标,扫描线会被错误地画到天空盒上。URP 还可能使用 Reversed-Z,因此不能假设远端永远是 1。

因此进行剔除:

#if defined(UNITY_REVERSED_Z)
    if (rawDepth <= 0.00001) return 0;
#else
    if (rawDepth >= 0.99999) return 0;
#endif

效果:

由于场景深度需要在世界空间中参与计算,须将像素位置也变换到世界坐标,由此展开三次尝试。

3. 第一次尝试:在 C# 中计算四角视锥射线

3.1 思路

透视相机看到的区域是一个被近、远裁剪面截断的棱锥体,即视锥体(View Frustum)
比近裁剪面更靠近摄像机、比远裁剪面更远离摄像机、摄像机之外的物体不会被渲染。
Unity:了解视锥体

四条角射线传入顶点着色器后,由光栅化器插值得到每个像素对应的世界空间射线 worldRayWS

根据四条射线,可得到相机指向像素的射线方向,传入Frag阶段,可得到相机指向像素远裁剪面的长度;
则: 像素相对相机距离/相机指向像素远裁剪面的长度 = 像素线性深度/射线线性深度,此处线性深度沿相机前向轴方向,射线在相机前向方向上的投影长度被归一化为 1;
由此得到像素世界坐标 = 像素相对相机距离 + 相机世界坐标

则对于射线方案,世界坐标可以写成:

像素世界坐标 = 相机世界坐标 + 世界空间射线 × 像素线性深度

3.2 在 C# 中建立四条射线

下面的函数根据垂直 FOV 和相机宽高比得到单位深度对应的视锥尺寸,再使用相机的前、右、上方向组合四个角。

private Matrix4x4 BuildFrustumCornerRays()
{
    float halfHeight = Mathf.Tan(targetCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
    float halfWidth = halfHeight * targetCamera.aspect;
    Transform cameraTransform = targetCamera.transform;

    Vector3 forward = cameraTransform.forward;
    Vector3 right = cameraTransform.right * halfWidth;
    Vector3 up = cameraTransform.up * halfHeight;

    Vector3 bottomLeftRay = forward - right - up;
    Vector3 bottomRightRay = forward + right - up;
    Vector3 topRightRay = forward + right + up;
    Vector3 topLeftRay = forward - right + up;

    Matrix4x4 rays = Matrix4x4.identity;
    rays.SetRow(0, bottomLeftRay);
    rays.SetRow(1, bottomRightRay);
    rays.SetRow(2, topRightRay);
    rays.SetRow(3, topLeftRay);
    return rays;
}

这四条射线不能随意执行 Normalize()。后面相乘的是沿相机前向定义的线性深度,而不是沿单位射线测量的欧氏距离。保留当前 Z 标度,才能让画面中心与四角使用同一套深度尺度。

矩阵的行顺序也必须与 Shader 中的索引一致:

bool isRight = input.uv.x >= 0.5;
bool isTop = input.uv.y >= 0.5;
int index = isTop ? (isRight ? 2 : 3) : (isRight ? 1 : 0);

output.worldRayWS = _FrustumCornersRay[index].xyz;

3.3 这条路线的注意点

它的几何意义非常直观,但工程上需要同时维护两端:

  • C# 必须在相机 FOV、宽高比或旋转变化后重新提交射线;
  • 四条射线的矩阵顺序必须与 UV 方向一致;
  • C# 和 Shader 属性名必须完全一致;

同时,此写法只可适用透视相机,正交相机需重建每个像素对应的射线起点。

4. 第二次尝试:使用逆 VP 矩阵直接重建

URP 已经提供了当前相机的逆视图投影矩阵,我们也可以直接把屏幕 UV 和深度送回世界空间。

这一版使用全屏三角形,不再要求脚本提交 _FrustumCornersRay

struct Attributes
{
    uint vertexID : SV_VertexID;
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float2 uv : TEXCOORD0;
};

Varyings Vert(Attributes input)
{
    Varyings output;
    output.positionCS = GetFullScreenTriangleVertexPosition(input.vertexID);
    output.uv = GetFullScreenTriangleTexCoord(input.vertexID);
    return output;
}

片元阶段采样深度,调用 URP 的世界坐标重建函数:

float3 worldPosition = ComputeWorldSpacePosition(input.uv, rawDepth, UNITY_MATRIX_I_VP);

I_VP 表示View × Projection的逆矩阵。当前像素的屏幕 UV 与深度共同确定了一个裁剪空间位置,乘逆 VP 后即可回到世界空间。

相机矩阵由管线维护,C# 只需要更新扫描中心、半径和线宽:

scanMaterial.SetVector(ScanCenterId, scanCenter);
scanMaterial.SetFloat(ScanRadiusId, radius);
scanMaterial.SetFloat(ScanRangeId, scanRange);

逆 VP 方案代码逻辑清晰。但是它的矩阵重建发生在每个有效片元,在frag阶段进行矩阵运算是很昂贵的。

5. 第三次尝试:在顶点阶段自建视锥射线

第三种方案保留了第一版“相机世界坐标 + 世界空间射线 × 像素线性深度”的计算方式,但不再让 C# 构造四角矩阵,而是在全屏三角形顶点阶段使用当前相机矩阵生成射线。

Varyings Vert(uint vertexID : SV_VertexID)
{
    Varyings output;
    output.positionCS = GetFullScreenTriangleVertexPosition(vertexID);
    output.uv = GetFullScreenTriangleTexCoord(vertexID);

    float4 clipPosition = ComputeClipSpacePosition(output.uv, UNITY_NEAR_CLIP_VALUE);

    float4 viewPosition = mul(UNITY_MATRIX_I_P, clipPosition);

    float3 viewRay = viewPosition.xyz / max(-viewPosition.z, 0.000001);

    output.worldRayWS = mul((float3x3)UNITY_MATRIX_I_V, viewRay);
    return output;
}

这段代码分为四步。

第一步,通过 ComputeClipSpacePosition 把屏幕 UV 放到裁剪空间近裁剪面。使用 URP 函数的原因是让平台裁剪空间和 Y 方向差异由管线处理。

第二步,乘 UNITY_MATRIX_I_P,从裁剪空间回到视空间。

第三步,除以 -viewPosition.z。这一步不是把向量归一化到长度 1,而是把视空间 Z 标度固定下来,使它能够直接和 LinearEyeDepth 配合。

第四步,乘 UNITY_MATRIX_I_V 的 3×3 部分。我们处理的是方向,所以只需要相机的旋转,不需要逆视图矩阵中的平移。

片元阶段重新回到了最简单的形式:

float eyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams);
float3 worldPosition = _WorldSpaceCameraPos + input.worldRayWS * eyeDepth;

与第一版相比,它不需要脚本维护四角矩阵;与逆 VP 版本相比,主要矩阵运算位于全屏三角形的顶点阶段,片元阶段只保留一次深度采样和简单乘加。

这也是我最终选择它继续扩展单波、多波扫描的原因。

6. 三种方案怎么选

三种方案如何选择:

方案 重建路径 优点 适合场景
四条视锥角射线 C# 计算四角射线,Shader 插值 原理直观,便于学习和调试 验证视锥体重建
逆 VP 矩阵 UV、深度与 UNITY_MATRIX_I_VP 直接重建 代码简洁,接近官方实现 快速实现、维护优先
Shader 自建射线 逆投影构造射线,逆视图转到世界空间 计算阶段清晰,方便扩展 多波扫描、需要细分计算路径

7. 重建像素世界坐标

到片元阶段,射线已经被插值获得射线世界坐标,可以直接重建世界坐标:

float eyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams);
float3 worldPosition = _WorldSpaceCameraPos + eyeDepth * input.worldRayWS;

逆VP只获取线性深度。

8. 扫描圆环

三种方案只是在用不同方式计算 worldPosition。拿到世界坐标以后,扫描圆环的判断相同。

脚本首先用鼠标向场景发射射线,把命中点传给 _ScanCenter。扫描开始后,半径随时间增加:

Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit))
{
    scanCenter = hit.point;
    currentRadius = 0f;
    isScanning = true;
}

if (isScanning)
{
    currentRadius += Time.deltaTime * scanSpeed;
}

在 Shader 中计算当前像素到扫描中心的距离:

float distanceFromCenter = distance(worldPosition, _ScanCenter.xyz);

一条半径为 _ScanRadius、宽度为 _ScanRange 的圆环,且不会渲染到远裁剪面:

  if (distanceFromCenter < _ScanRadius && 
      distanceFromCenter > (_ScanRadius - _ScanRange) &&
      linearDepth < _ProjectionParams.z) 
  {
      // 扫描线绘制 
  }

只有位于圆环内的像素才显示扫描色。再根据像素位于圆环内侧还是外侧,生成一条从 0 到 1 的渐变:

float scanAlpha = saturate(1 - (_ScanRadius - distanceFromCenter) / _ScanRange);

这里的距离全部位于世界空间,所以扫描线不会因为相机远近而改变实际宽度。

9. 从全屏扫描转向物体中心传播

全屏后处理适合让扫描覆盖整个相机画面,但它会固定执行一个全屏 Pass。如果扫描只需要出现在玩家周围、手雷爆点或一座扫描塔附近,则须把扫描 Shader 直接挂在物体的 Renderer 上。

这里使用 ScanCircle.shader。它不再通过 SV_VertexID 创建全屏三角形,而是接收普通模型顶点:

struct Attributes
{
    float4 positionOS : POSITION;
};

Varyings Vert(Attributes input)
{
    Varyings output;
    output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
    return output;
}

因此,Shader 只会运行在挂载网格覆盖到的像素上。脚本要求当前物体拥有 Renderer:

[RequireComponent(typeof(Renderer))]
public class ScanEffect : MonoBehaviour
{
    public Transform scanSource;

    private Renderer targetRenderer;
    private MaterialPropertyBlock propertyBlock;

    private void Awake()
    {
        targetRenderer = GetComponent<Renderer>();
        propertyBlock = new MaterialPropertyBlock();
    }
}

扫描中心优先读取指定发射源;如果没有指定,就以挂载脚本的物体自身为中心:

Vector3 centerPosition = scanSource != null ? scanSource.position : transform.position;

propertyBlock.SetVector(ScanCenterId, centerPosition);

使用 MaterialPropertyBlock 的好处是只修改当前 Renderer 的运行时参数,不需要为每一个扫描物体复制一份材质,也不会把动态参数写回共享材质资产。

此方法需要挂载扫描线材质物体大小包裹住扫描范围。

效果:

为扫描线添加遮罩:

float scanAlpha = saturate(1.0 - abs(_ScanRadius - distanceFromCenter) / _ScanRange); 

float2 wPosUV = worldPos.xz * _MaskScale;
wPosUV = wPosUV * _MaskTex_ST.xy + _MaskTex_ST.zw;

float mask = SAMPLE_TEXTURE2D(_MaskTex, sampler_MaskTex, wPosUV).r;

float finalAlpha = _MainColor.a * scanAlpha * mask;

return float4(_MainColor.rgb, finalAlpha);

效果:

10. 总结

这次扫描线实现经历了三个阶段。

第一阶段先在 C# 中计算四条视锥射线。它让我直观看到屏幕角点、射线插值和线性深度之间的关系,但两端维护较多。

第二阶段改用逆 VP 矩阵。代码更短,也更适合作为确认世界坐标重建是否正确的基线。

第三阶段把逆投影和逆视图计算移动到全屏三角形的顶点阶段,在 Shader 内自建视锥射线。片元阶段只保留“相机位置 + 射线 × 像素深度”,也为后续的单波与多波扫描提供了统一基础。

如果效果本身只围绕某个游戏物体传播,还可以放弃全屏 Pass,把 Shader 挂到带 Renderer 的网格上,并通过 MaterialPropertyBlock 使用 scanSource.positiontransform.position 作为扫描中心。

下一篇将继续使用自建视锥射线,拆解单波、多波的状态管理,以及如何通过 Alpha 混合和 Full Screen Pass 注入时机控制扫描是否覆盖透明物体。

希望对你有帮助,感谢观看!文中如有疏漏或错误,欢迎各位大佬评论区指正。

参考资料​​​​​

  1. (35 封私信 / 80 条消息) Unity Shader学习:扫描(Scan) - 知乎
  2.  (35 封私信 / 80 条消息) Unity实现地图扫描效果 - 知乎

  3. (35 封私信 / 80 条消息) 神奇的深度图:复杂的效果,不复杂的原理 - 知乎