扫描线是游戏中很经典的功能。
我们最终需要画出一圈从世界坐标向外扩散的光环,但当它被放进全屏幕后处理后,Shader 面对的已经不是一个有完整模型数据的物体,而是整张屏幕。片元阶段只有屏幕 UV 和深度,不能直接读取原物体的世界坐标。
因此,这个效果真正要解决的问题并不是如何画圆,而是:如何从屏幕 UV 和深度还原当前像素的世界坐标。
本文记录我在 Unity URP 中实现扫描线的三次尝试:
1. 在 C# 中计算视锥体四条角射线;
2. 使用逆 VP 矩阵直接重建世界坐标;
3. 在全屏三角形的顶点阶段自建视锥射线
最后还会把思路从“覆盖整张屏幕”转到“挂载在具体物体上”,让扫描从玩家、手持物或扫描建筑的位置开始传播。
目录
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.position 或 transform.position 作为扫描中心。
下一篇将继续使用自建视锥射线,拆解单波、多波的状态管理,以及如何通过 Alpha 混合和 Full Screen Pass 注入时机控制扫描是否覆盖透明物体。
希望对你有帮助,感谢观看!文中如有疏漏或错误,欢迎各位大佬评论区指正。
参考资料