屏幕空间技术
一张深度图加一张法线图,就能在后处理里“看见”整个场景的几何。本章把屏幕空间技术的公共骨架拆开讲透,再依次手写扫描波、高度雾、SSAO、体积光和 SSR,最后讲清 TAA 与运动矢量的来龙去脉。
28.1 屏幕空间技术的共同骨架
所谓“屏幕空间技术”,指的是这样一类算法:它们不再一个物体一个物体地画,而是在不透明物体画完之后,拿着几张“屏幕大小的几何信息图”,对每个像素做一次计算。这几张图通常是:
- 深度纹理(
_CameraDepthTexture):每个像素离相机多远。有了它就能把像素还原回三维空间里的点,这是本章一切技术的起点。 - 法线纹理(
_CameraNormalsTexture):URP 14 里存的是世界空间法线,由 DepthNormals 预通道或延迟渲染的 G-Buffer 提供。SSAO、SSR 这类需要“表面朝向”的算法离不开它。 - 不透明颜色(
_CameraOpaqueTexture或当前相机颜色的拷贝):SSR 要从里面取反射色,扭曲类效果要从里面取背景。 - 运动矢量(
_MotionVectorTexture):每个像素在上一帧的位置,TAA、运动模糊要用。
屏幕空间技术的优点很直接:开销只和像素数量有关,和场景复杂度无关。一万个物体的场景和十个物体的场景,做一次全屏 SSAO 的代价几乎一样。它的缺点同样直接:屏幕外、被遮挡的东西它一无所知。SSAO 在屏幕边缘变淡、SSR 反射不到屏幕外的物体、体积光被前景挡住的部分算不出来,都是这个根本限制的表现。理解了这一点,你就能提前预判每种屏幕空间效果会在哪里“露馅”。
一个通用的全屏效果 Feature
第 25、26 章已经详细讲过 Renderer Feature 的生命周期和 RTHandle,本章只关心算法,所以先写一个“万能”的全屏 Feature:它负责把相机颜色拷贝到一张临时 RT,再用我们给的材质画回去。之后的世界坐标重建、扫描线、雾和 SSR 全都复用它,每个案例只需要写 Shader。
// 第 28 章通用的“全屏效果” Renderer Feature(URP 14 的 RTHandle + Blitter 写法)。 // 做的事很简单:把相机颜色拷贝到一张临时 RT,再用指定材质画回相机颜色。 // 本章的世界坐标重建、扫描线、雾、SSR 都靠它跑,算法全部写在各自的 Shader 里。 using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; namespace Cookbook.Ch28 { public class ScreenEffectFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public Material material; public int passIndex = 0; // 屏幕空间效果大多放在不透明物体之后、后处理之前 public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; // 需要哪些屏幕纹理:Depth 会保证 _CameraDepthTexture 可用,Normal 会触发 DepthNormals 预通道 public ScriptableRenderPassInput requirements = ScriptableRenderPassInput.Depth; } public Settings settings = new Settings(); ScreenEffectPass m_Pass; public override void Create() { m_Pass = new ScreenEffectPass(name); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { var camType = renderingData.cameraData.cameraType; if (settings.material == null || camType == CameraType.Preview || camType == CameraType.Reflection) return; m_Pass.renderPassEvent = settings.renderPassEvent; m_Pass.ConfigureInput(settings.requirements); m_Pass.Setup(settings.material, settings.passIndex); renderer.EnqueuePass(m_Pass); } public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData) { // cameraColorTargetHandle 只有在这里(以及之后)才是本帧有效的句柄 m_Pass.SetTarget(renderer.cameraColorTargetHandle); } protected override void Dispose(bool disposing) { m_Pass?.Dispose(); } class ScreenEffectPass : ScriptableRenderPass { Material m_Material; int m_PassIndex; RTHandle m_CameraColor; RTHandle m_Copy; public ScreenEffectPass(string name) { profilingSampler = new ProfilingSampler(name); } public void Setup(Material material, int passIndex) { m_Material = material; m_PassIndex = passIndex; } public void SetTarget(RTHandle cameraColor) { m_CameraColor = cameraColor; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 临时 RT 与相机目标同尺寸、同格式,但不要 MSAA、不要深度 var desc = renderingData.cameraData.cameraTargetDescriptor; desc.msaaSamples = 1; desc.depthBufferBits = 0; RenderingUtils.ReAllocateIfNeeded(ref m_Copy, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_ScreenEffectCopy"); ConfigureTarget(m_CameraColor); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { // 不能边读边写同一张 RT:先拷贝,再以拷贝为 _BlitTexture 画回去 Blitter.BlitCameraTexture(cmd, m_CameraColor, m_Copy); Blitter.BlitCameraTexture(cmd, m_Copy, m_CameraColor, m_Material, m_PassIndex); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() { m_Copy?.Release(); } } } }
// 第 28 章通用的“全屏效果” Renderer Feature(对应 Ch28/ScreenEffectFeature.cs)。 // 把相机颜色拷贝到一张临时 RT,再用指定材质的指定 Pass 画回相机颜色。 // 本章的世界坐标重建、扫描线、雾、SSR 都靠它跑,算法全部写在各自的 Shader 里。 import { featureMaterial, RenderPassEvent } from '../engine/features.js'; import { PassInput, renderDepthNormals, ensureDepth } from './Cookbook.Ch28.ScreenSpaceUtil.js'; export default class ScreenEffectFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; this.settings = { material: null, passIndex: 0, renderPassEvent: RenderPassEvent.BeforeRenderingPostProcessing, requirements: PassInput.Depth, ...((data && data.settings) || {}) }; } async init() { this.material = featureMaterial(this.scene, this.settings.material); } addPasses(frame, enqueue) { const s = this.settings, m = this.material; if (!m) return; // ConfigureInput(Normal):DepthNormals 预渲染在不透明物体之前 if (s.requirements & PassInput.Normal) enqueue(RenderPassEvent.BeforeRenderingOpaques, (fr) => renderDepthNormals(fr), 'DepthNormalPrepass'); enqueue(s.renderPassEvent, (fr) => { if (!(s.requirements & PassInput.Normal) && (s.requirements & PassInput.Depth)) ensureDepth(fr); // 不能边读边写同一张 RT:先拷贝,再以拷贝为 _BlitTexture 画回去 const copy = fr.getTemporaryRT('_ScreenEffectCopy', fr.width, fr.height); fr.blit(fr.cameraColorTexture(), copy); fr.blit(copy.texture, 'camera', m, s.passIndex); fr.bindCamera(); }, 'ScreenEffect'); } }
有几处值得再强调一遍,它们是 URP 14 写法里最容易踩的坑:
ConfigureInput是“申请”屏幕纹理的唯一正确方式。传入ScriptableRenderPassInput.Depth,URP 会保证这个 Pass 执行时_CameraDepthTexture已经就绪(必要时自动加一个深度预通道或 CopyDepth);传入Normal会让前向渲染路径多跑一次 DepthNormals 预通道。不要指望在 URP Asset 上勾了 Depth Texture 就万事大吉——那只是默认值,相机上可以被覆盖。cameraColorTargetHandle只能在SetupRenderPasses里或之后取。在AddRenderPasses里访问它,URP 14 会直接报错,因为那时本帧的相机目标还没分配。- 不能边读边写同一张 RT。所以先
BlitCameraTexture(颜色 → 拷贝),再以拷贝为_BlitTexture画回颜色。Blitter.BlitCameraTexture会根据 RTHandle 的缩放自动算出_BlitScaleBias,所以 Shader 里用Blit.hlsl的Vert得到的texcoord永远是正确的屏幕 UV。 - 临时 RT 要关掉 MSAA、去掉深度缓冲:全屏 Pass 不需要它们,带着只会浪费内存。
28.2 从深度重建世界坐标
深度纹理里存的不是“距离”,而是设备深度(Device Depth):顶点经过投影矩阵、透视除法之后的 NDC z 值,再映射到深度缓冲的取值范围。它有三个让人头疼的特点:
- 非线性。透视投影下 zndc 与观察空间深度成倒数关系,近处精度高、远处精度低。直接把深度值当距离用,远处的东西几乎全挤在一起。
- 平台相关的方向。D3D11/12、Metal、Vulkan、主机平台使用 Reversed-Z(近处 1、远处 0,宏
UNITY_REVERSED_Z为 1),OpenGL ES 则是近处 0、远处 1,且它的裁剪空间 z 范围是 [-1, 1]。 - 天空没有深度。天空盒不写深度,那里的值是清屏值(Reversed-Z 下为 0),任何“由深度算位置”的算法都要单独处理天空像素。
把这个非线性的值还原成世界坐标,有两条路。
方法一:逆 VP 矩阵
投影的正向过程是:世界坐标 PWS 乘上 VP 矩阵得到裁剪坐标,再除以 w 得到 NDC。反过来,只要知道某个像素的 NDC 坐标(屏幕 UV 映射到 [-1,1],再加上深度),乘上 VP 的逆矩阵,最后再做一次齐次除法,就回到了世界空间:
为什么这里可以把 w 直接设成 1?因为透视除法前后的点在齐次空间里是同一条射线上的点,(x, y, z, 1) 与 (xw, yw, zw, w) 表示同一个位置;乘逆矩阵之后得到的 H 不是“真正的”齐次世界坐标,但它与真坐标只差一个比例因子,最后除以 Hw 就把这个比例消掉了。
URP 的 core 库已经把这件事写好了,摘自 core 14 ShaderLibrary/Common.hlsl:
float4 ComputeClipSpacePosition(float2 positionNDC, float deviceDepth) { float4 positionCS = float4(positionNDC * 2.0 - 1.0, deviceDepth, 1.0); #if UNITY_UV_STARTS_AT_TOP // 投影矩阵里已经烘焙了上下翻转,这里从 CS 回到 NDC 时要手动翻回来 positionCS.y = -positionCS.y; #endif return positionCS; } float3 ComputeWorldSpacePosition(float2 positionNDC, float deviceDepth, float4x4 invViewProjMatrix) { float4 positionCS = ComputeClipSpacePosition(positionNDC, deviceDepth); float4 hpositionWS = mul(invViewProjMatrix, positionCS); return hpositionWS.xyz / hpositionWS.w; }
注意它的参数名叫 positionNDC,但实际要传的是 [0,1] 的屏幕 UV——函数内部自己做 *2-1。UNITY_UV_STARTS_AT_TOP 那段处理的是 D3D/Metal 下“投影矩阵被上下翻转”的历史包袱;只要你传进来的是 Blit.hlsl 给出的 texcoord、矩阵用的是 UNITY_MATRIX_I_VP,平台差异就已经被抵消了。URP 自己的 ScreenSpaceShadows.shader 也正是这么写的,这是最可靠的参照。
唯一需要自己处理的是 OpenGL 系平台:深度纹理里存的是 [0,1],而它的裁剪空间 z 是 [-1,1],需要先把深度映射回去。完整的公共函数如下:
// 读“设备深度”,并统一成 ComputeWorldSpacePosition 需要的 NDC z float SampleDeviceDepth(float2 uv) { float d = SampleSceneDepth(uv); #if !UNITY_REVERSED_Z // OpenGL 系:深度纹理是 [0,1],而它的裁剪空间 z 是 [-1,1] d = lerp(UNITY_NEAR_CLIP_VALUE, 1, d); #endif return d; } // 循环里采样深度要用显式 LOD 版本:带隐式导数的 SAMPLE_TEXTURE2D 在动态循环里 // 会逼编译器展开整个循环(Metal/D3D 都会报 unable to unroll loop) float SampleSceneDepthLod(float2 uv) { return SAMPLE_TEXTURE2D_X_LOD(_CameraDepthTexture, sampler_CameraDepthTexture, UnityStereoTransformScreenSpaceTex(uv), 0).r; } // 天空(没有写深度的像素):Reversed-Z 下是 0,否则是 1 bool IsSkyDepth(float deviceDepth) { #if UNITY_REVERSED_Z return deviceDepth < 1e-6; #else return deviceDepth > 1 - 1e-6; #endif }
// 读“设备深度”,并统一成 ComputeWorldSpacePosition 需要的格式。 // HLSL 版在 OpenGL 系平台要把 [0,1] 换成 NDC 的 [-1,1];网页版的 ComputeWorldSpacePosition 自己做这一步,所以直接返回。 float SampleDeviceDepth(vec2 uv) { return SampleSceneDepth(uv); } // 循环里采样深度用显式 LOD 版本(HLSL 里为了避免循环被强制展开) float SampleSceneDepthLod(vec2 uv) { return textureLod(_CameraDepthTexture, uv, 0.0).r; } // 天空(没有写深度的像素):WebGL 深度不反转,天空是 1 bool IsSkyDepth(float deviceDepth) { #if UNITY_REVERSED_Z return deviceDepth < 1e-6; #else return deviceDepth > 1.0 - 1e-6; #endif }
// 方法一:逆 VP 矩阵。uv + 深度 → NDC → 乘逆矩阵 → 齐次除法 float3 ReconstructPositionWS(float2 uv, float deviceDepth) { return ComputeWorldSpacePosition(uv, deviceDepth, UNITY_MATRIX_I_VP); }
方法二:视锥射线插值
逆矩阵法简单可靠,但每个像素都要做一次 4×4 矩阵乘法加一次除法。在 Built-in 管线时代流行另一种更省的做法:视锥射线(Frustum Ray / View Ray)。它基于一个几何事实——相机位置 C、像素对应的场景点 P、以及同一条视线与远裁剪面的交点 F,三点共线,并且按观察空间深度成比例:
关键在于:远裁剪面是一个平面,它上面的点 F 关于屏幕坐标是仿射(线性)的。所以只需要在顶点着色器里给全屏三角形的 3 个顶点算出射线 r = F − C,光栅化器线性插值后,每个像素拿到的就是它自己那条射线,误差为零。片元着色器里只剩一次乘加。
// 方法二:视锥射线。顶点着色器里算“相机 → 远裁剪面”的向量,光栅化线性插值后, // 片元里乘上 0~1 的线性深度即可,每像素省掉一次 4x4 矩阵乘法和除法。 struct VaryingsRay { float4 positionCS : SV_POSITION; float2 texcoord : TEXCOORD0; float3 farRayWS : TEXCOORD1; // 相机位置 → 该像素对应的远裁剪面上的点 UNITY_VERTEX_OUTPUT_STEREO }; VaryingsRay VertRay(Attributes input) { VaryingsRay output; UNITY_SETUP_INSTANCE_ID(input); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); #if SHADER_API_GLES float4 pos = input.positionOS; float2 uv = input.uv; #else float4 pos = GetFullScreenTriangleVertexPosition(input.vertexID); float2 uv = GetFullScreenTriangleTexCoord(input.vertexID); #endif output.positionCS = pos; output.texcoord = uv * _BlitScaleBias.xy + _BlitScaleBias.zw; // 远平面上的点关于屏幕坐标是仿射(线性)的,所以只在 3 个顶点算、再插值,结果是精确的 float3 farWS = ComputeWorldSpacePosition(output.texcoord, UNITY_RAW_FAR_CLIP_VALUE, UNITY_MATRIX_I_VP); output.farRayWS = farWS - _WorldSpaceCameraPos; return output; } float3 ReconstructPositionWSFromRay(float3 farRayWS, float rawDepth) { // Linear01Depth = 观察空间深度 / 远裁剪面距离,正好是射线的缩放系数 return _WorldSpaceCameraPos + farRayWS * Linear01Depth(rawDepth, _ZBufferParams); }
// 方法二:视锥射线。顶点着色器里算“相机 → 远裁剪面”的向量,光栅化线性插值后, // 片元里乘上 0~1 的线性深度即可,每像素省掉一次 4x4 矩阵乘法和除法。 // (HLSL 的 VaryingsRay 结构体在 GLSL 里拆成两个 varying:texcoord 和 farRayWS) VARYING vec2 texcoord; #ifdef USE_VIEW_RAY VARYING vec3 farRayWS; // 相机位置 → 该像素对应的远裁剪面上的点 #endif #ifdef VERTEX void main() { vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); texcoord = p * _BlitScaleBias.xy + _BlitScaleBias.zw; #ifdef USE_VIEW_RAY // 远平面上的点关于屏幕坐标是仿射(线性)的,所以只在 3 个顶点算、再插值,结果是精确的 vec3 farWS = ComputeWorldSpacePosition(texcoord, UNITY_RAW_FAR_CLIP_VALUE, UNITY_MATRIX_I_VP); farRayWS = farWS - _WorldSpaceCameraPos; #endif } #endif vec3 ReconstructPositionWSFromRay(vec3 farRay, float rawDepth) { // Linear01Depth = 观察空间深度 / 远裁剪面距离,正好是射线的缩放系数 return _WorldSpaceCameraPos + farRay * Linear01Depth(rawDepth, _ZBufferParams); }
这里有个细节:顶点里用 ComputeWorldSpacePosition(uv, UNITY_RAW_FAR_CLIP_VALUE, UNITY_MATRIX_I_VP) 求远裁剪面上的点。UNITY_RAW_FAR_CLIP_VALUE 在 Reversed-Z 平台是 0、在 GL 平台是 1(见 core 的 API/Metal.hlsl、API/GLES3.hlsl),用它就不必自己判断平台。Linear01Depth(rawDepth, _ZBufferParams) 返回的是 zeye/zfar,正好是射线的缩放系数;如果你更喜欢“射线长度对应观察深度 1”的写法,就把射线除以 zfar,片元里改乘 LinearEyeDepth,两者等价。
| 对比项 | 逆 VP 矩阵 | 视锥射线 |
|---|---|---|
| 每像素开销 | 1 次 4×4 矩阵乘 + 1 次除法 | 1 次乘加(射线在顶点里算) |
| 正交相机 | 天然支持 | 不适用(正交投影下所有射线平行,需改写) |
| 任意 UV 处重建 | 可以(SSAO/SSR 在采样点处也要重建) | 只适合“当前像素”,别处要另算射线 |
| 精度 | 远处略差(矩阵乘法放大误差) | 与线性深度精度一致 |
| 推荐用途 | 默认选择;需要在多个 UV 处重建时 | 只在当前像素重建的雾、扫描线等 |
案例 28.1可视化重建出的世界坐标入门单独打开 ↗
目标:用两种方法分别重建世界坐标并画出来,肉眼验证它们是对的——这是写任何屏幕空间效果前都应该先做的一步。
half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 sceneColor = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv); float depth = SampleDeviceDepth(uv); if (IsSkyDepth(depth)) return sceneColor * 0.35; // 天空没有几何,压暗区分 float3 positionWS = ReconstructPositionWS(uv, depth); // +0.5:避开地面 y=0 这种整数坐标,frac 在 0/1 之间来回跳会产生噪点 return half4(frac(positionWS + 0.5), 1); }
vec4 Frag() { vec2 uv = texcoord; vec4 sceneColor = SamplePointClamp(_BlitTexture, uv); float depth = SampleDeviceDepth(uv); if (IsSkyDepth(depth)) return sceneColor * 0.35; // 天空没有几何,压暗区分 vec3 positionWS = ReconstructPositionWS(uv, depth); // +0.5:避开地面 y=0 这种整数坐标,frac 在 0/1 之间来回跳会产生噪点 return vec4(fract(positionWS + 0.5), 1.0); }
half4 Frag(VaryingsRay input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 sceneColor = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv); float rawDepth = SampleSceneDepth(uv); if (IsSkyDepth(rawDepth)) return sceneColor; float3 positionWS = ReconstructPositionWSFromRay(input.farRayWS, rawDepth); // 1 米网格线:用 fwidth 把线宽换算成像素,远近粗细一致 float3 dist = abs(frac(positionWS + 0.5) - 0.5); float3 width = fwidth(positionWS) * _GridWidth; float3 lines = 1 - smoothstep(width * 0.5, width * 1.5, dist); float grid = saturate(max(lines.x, max(lines.y, lines.z))); return lerp(sceneColor, _GridColor, grid * 0.85); }
vec4 Frag() { vec2 uv = texcoord; vec4 sceneColor = SamplePointClamp(_BlitTexture, uv); float rawDepth = SampleSceneDepth(uv); if (IsSkyDepth(rawDepth)) return sceneColor; vec3 positionWS = ReconstructPositionWSFromRay(farRayWS, rawDepth); // 1 米网格线:用 fwidth 把线宽换算成像素,远近粗细一致 vec3 dist = abs(fract(positionWS + 0.5) - 0.5); vec3 width = fwidth(positionWS) * _GridWidth; vec3 lines = 1.0 - smoothstep(width * 0.5, width * 1.5, dist); float grid = saturate(max(lines.x, max(lines.y, lines.z))); return mix(sceneColor, _GridColor, grid * 0.85); }
第一个 Pass 直接输出 frac(positionWS):每隔 1 米颜色循环一次,红绿蓝分别对应 x、y、z。如果重建正确,地面上会出现和棋盘格对齐的 1 米色块,立方体每个面的色块严格贴在面上,不会随相机移动而“游泳”。第二个 Pass 用视锥射线法,在原画面上叠加 1 米世界网格,网格线用 fwidth 换算成固定的像素宽度,远近粗细一致。

frac(positionWS),结果地面上出现了大片锯齿状的品红色块。原因是地面恰好在 y = 0,重建出的 y 在 ±0.0001 之间抖动,frac 在 0 和 0.9999 之间来回跳。这不是重建错误,而是“整数坐标处的 frac 不连续”,加 0.5 的偏移就好了。调试屏幕空间算法时,经常会遇到这类“看起来像 bug、其实是可视化方式问题”的现象,先想清楚数学再改代码。这个 Shader 还附带一个 Pass 2,输出两种方法结果之差乘以 1000。在实际工程里,把它挂上去看一眼几乎全黑,就能放心地在两种写法之间切换。
案例 28.2扫描线 / 雷达波入门单独打开 ↗
目标:从某点向外扩散的一圈能量波,掠过地面和物体,波前是一条亮边,后面拖着带世界网格的渐隐尾迹。游戏里的“侦测技能”“全息扫描”几乎都是这个结构。
原理:有了每个像素的世界坐标,扫描线就只是一个距离判断。设扫描中心为 O、当前半径为 R,像素的水平距离 d = |Pxz − Oxz|,令 b = R − d,那么:
- |b| 很小 → 位于波前,画亮边;
- 0 < b < 尾迹宽度 → 已被扫过、但还在尾迹里,按 (1 − b/宽度)² 渐隐;
- b < 0 → 波还没到,不处理。
为什么用水平距离而不是三维距离?三维距离得到的是一个膨胀的球壳,扫到高处物体时亮边会“缩小”;只用 xz 得到的是一个竖直的圆柱面,扫过高墙时是一条竖直亮线,视觉上更像“贴地扩散”。两种都对,看设计需要。
half4 Frag(VaryingsRay input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv); float rawDepth = SampleSceneDepth(uv); if (IsSkyDepth(rawDepth)) return color; float3 positionWS = ReconstructPositionWSFromRay(input.farRayWS, rawDepth); // 当前波前半径:运行时用 _Time 循环,拍摄/调试时用 _Progress 定格 float t = _UseTime > 0.5 ? frac(_Time.y * _Speed) : _Progress; float radius = t * _MaxRadius; // 水平距离:只看 xz,扫描波像贴着地面向外扩 float d = distance(positionWS.xz, _ScanCenter.xz); float behind = radius - d; // >0 表示已被波前扫过 // 波前亮边:|behind| 很小的一圈 float edge = 1 - smoothstep(0, _EdgeWidth, abs(behind)); // 尾迹:波前后方 _TrailWidth 米内从 1 渐隐到 0 float trail = (behind > 0) ? pow(saturate(1 - behind / _TrailWidth), 2) : 0; // 尾迹里的世界网格(xz 平面),fwidth 让线宽恒定为 ~1 像素 float2 g = abs(frac(positionWS.xz / _GridSize + 0.5) - 0.5) * _GridSize; float2 gw = fwidth(positionWS.xz); float grid = 1 - min(smoothstep(gw.x * 0.5, gw.x * 1.5, g.x), smoothstep(gw.y * 0.5, gw.y * 1.5, g.y)); // 越扩越淡,扫完一圈自然消失 float fade = 1 - smoothstep(0.7, 1.0, t); float intensity = (edge * 1.5 + trail * (0.25 + 0.75 * grid)) * fade; color.rgb += _ScanColor.rgb * intensity; return color; }
vec4 Frag() { vec2 uv = texcoord; vec4 color = SamplePointClamp(_BlitTexture, uv); float rawDepth = SampleSceneDepth(uv); vec3 positionWS = ReconstructPositionWSFromRay(farRayWS, rawDepth); // 当前波前半径:运行时用 _Time 循环,拍摄/调试时用 _Progress 定格 float t = _UseTime > 0.5 ? fract(_Time.y * _Speed) : _Progress; float radius = t * _MaxRadius; // 水平距离:只看 xz,扫描波像贴着地面向外扩 float d = distance(positionWS.xz, _ScanCenter.xz); float behind = radius - d; // >0 表示已被波前扫过 // 波前亮边:|behind| 很小的一圈 float edge = 1.0 - smoothstep(0.0, _EdgeWidth, abs(behind)); // 尾迹:波前后方 _TrailWidth 米内从 1 渐隐到 0 float trail = (behind > 0.0) ? pow(saturate(1.0 - behind / _TrailWidth), 2.0) : 0.0; // 尾迹里的世界网格(xz 平面),fwidth 让线宽恒定为 ~1 像素 vec2 g = abs(fract(positionWS.xz / _GridSize + 0.5) - 0.5) * _GridSize; vec2 gw = fwidth(positionWS.xz); float grid = 1.0 - min(smoothstep(gw.x * 0.5, gw.x * 1.5, g.x), smoothstep(gw.y * 0.5, gw.y * 1.5, g.y)); // 越扩越淡,扫完一圈自然消失 float fade = 1.0 - smoothstep(0.7, 1.0, t); float intensity = (edge * 1.5 + trail * (0.25 + 0.75 * grid)) * fade; // 天空没有几何:放在 fwidth 之后再返回(GLSL 的导数要求同一像素块里的控制流一致) if (IsSkyDepth(rawDepth)) return color; color.rgb += _ScanColor.rgb * intensity; return color; }
时间的处理值得一提:运行时用 frac(_Time.y * _Speed) 让波周期性扩散;但截图、调试或者由 C# 控制“技能释放进度”时,更需要一个可直接设置的 _Progress。Shader 里同时保留两种驱动方式,用一个开关切换,是动画类效果的良好习惯。

扩展方向:
- 多个扫描中心:把中心和半径做成数组,循环取最大值。
- 只高亮特定物体:效果本身不知道“哪个像素属于敌人”,可以先用 RenderObjects 把敌人画进一张遮罩 RT(第 25 章),在扫描 Shader 里采样遮罩,给敌人换一种颜色。
- 透明物体:全屏效果在透明物体之前执行时,透明物体不会被扫描;放在之后执行时,透明物体后面的场景会被扫描、它自己却不会(深度里没有它)。这是所有基于深度的屏幕空间效果的共性。
案例 28.3距离雾与高度雾进阶单独打开 ↗
目标:写一个屏幕空间的雾:随距离变浓的距离雾、贴着地面堆积的高度雾,以及迎着太阳方向变亮变暖的“内散射”色调。
从透射率说起
光在均匀介质中穿过距离 d,被吸收和散射掉的比例由比尔-朗伯定律(Beer–Lambert Law)描述。设介质的消光系数为 σ(单位:1/米),透射率是
τ 叫光学厚度(Optical Depth)。雾的合成公式就是“场景颜色乘透射率,再加上雾色乘 (1 − 透射率)”:
这个框架的好处是:不同来源的雾可以把光学厚度相加,因为 e−(τ₁+τ₂) = e−τ₁·e−τ₂,物理上就是光依次穿过两种介质。如果各自算出雾量再做两次 lerp,结果虽然也能看,但叠加顺序会影响结果,浓度也会不自然地叠加。
距离雾
密度处处相同(σ 为常数),τ = σ · max(0, d − dstart),就得到经典的指数雾。URP 自带的雾(Lighting 窗口里的 Fog,Shader 里的 ComputeFogFactor/MixFog)也是这一类,只不过它在每个物体的 Shader 里逐像素计算(URP 14 的 ShaderVariablesFunctions.hlsl 里 _FOG_FRAGMENT 被强制为 1),而我们这里在屏幕空间统一计算。两者的取舍是:
屏幕空间雾
不依赖每个 Shader 写对雾的代码;能做高度雾、体积感、太阳散射等复杂模型;一次全屏计算。但透明物体处理麻烦,天空也要单独考虑。逐物体雾(URP 内置)
每个 Shader 都要multi_compile_fog 并调用 MixFog,漏写的物体就“不进雾”;但透明物体天然正确,移动端也最便宜。高度雾的解析积分
真实的雾、霾在低处浓、高处淡,常用指数分布描述:σ(y) = a · e−b(y − h₀),a 是基准高度 h₀ 处的密度,b 决定随高度衰减的快慢。视线从相机 c 出发沿单位方向 v 走过长度 L,沿途高度是 cy + t vy。光学厚度可以积出解析式:
(1 − e−k)/k 这一项在视线水平(vy → 0,k → 0)时是 0/0 的形式,极限是 1;直接算会除零或者严重丢精度。代码里在 |k| 很小时改用泰勒展开 1 − k/2,这是写解析积分时必须处理的数值细节。
// 高度雾的光学厚度:密度 σ(y) = a·exp(-b·(y - h0)),沿射线 c + t·v (0≤t≤L) 积分 float HeightFogOpticalDepth(float3 camPos, float3 viewDir, float rayLength) { float a = _HeightDensity, b = _HeightFalloff; float densityAtCam = a * exp(-b * (camPos.y - _FogBaseHeight)); float k = b * viewDir.y * rayLength; // 射线跨过的“高度衰减量” // (1 - e^{-k}) / k 在 k→0 时趋于 1;用泰勒展开避免水平视线除零 float factor = abs(k) > 1e-3 ? (1 - exp(-k)) / k : 1 - 0.5 * k; return densityAtCam * rayLength * factor; }
// 高度雾的光学厚度:密度 σ(y) = a·exp(-b·(y - h0)),沿射线 c + t·v (0≤t≤L) 积分 float HeightFogOpticalDepth(vec3 camPos, vec3 viewDir, float rayLength) { float a = _HeightDensity, b = _HeightFalloff; float densityAtCam = a * exp(-b * (camPos.y - _FogBaseHeight)); float k = b * viewDir.y * rayLength; // 射线跨过的“高度衰减量” // (1 - e^{-k}) / k 在 k→0 时趋于 1;用泰勒展开避免水平视线除零 float factor = abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; return densityAtCam * rayLength * factor; }
half4 Frag(VaryingsRay input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv); float rawDepth = SampleSceneDepth(uv); float3 viewDir = normalize(input.farRayWS); float rayLength = IsSkyDepth(rawDepth) ? _SkyDistance : length(ReconstructPositionWSFromRay(input.farRayWS, rawDepth) - _WorldSpaceCameraPos); float tauDistance = _DistanceDensity * max(0, rayLength - _FogStart); float tauHeight = HeightFogOpticalDepth(_WorldSpaceCameraPos, viewDir, rayLength); float transmittance = exp(-(tauDistance + tauHeight)); // 朝太阳看时雾更亮更暖(前向散射的廉价近似) Light mainLight = GetMainLight(); float sunAmount = pow(saturate(dot(viewDir, mainLight.direction)), _SunScatterPower); half3 fogColor = lerp(_FogColor.rgb, _SunScatterColor.rgb * mainLight.color, sunAmount); color.rgb = color.rgb * transmittance + fogColor * (1 - transmittance); return color; }
vec4 Frag() { vec2 uv = texcoord; vec4 color = SamplePointClamp(_BlitTexture, uv); float rawDepth = SampleSceneDepth(uv); vec3 viewDir = normalize(farRayWS); float rayLength = IsSkyDepth(rawDepth) ? _SkyDistance : length(ReconstructPositionWSFromRay(farRayWS, rawDepth) - _WorldSpaceCameraPos); float tauDistance = _DistanceDensity * max(0.0, rayLength - _FogStart); float tauHeight = HeightFogOpticalDepth(_WorldSpaceCameraPos, viewDir, rayLength); float transmittance = exp(-(tauDistance + tauHeight)); // 朝太阳看时雾更亮更暖(前向散射的廉价近似) Light mainLight = GetMainLight(); float sunAmount = pow(saturate(dot(viewDir, mainLight.direction)), _SunScatterPower); vec3 fogColor = mix(_FogColor.rgb, _SunScatterColor.rgb * mainLight.color, sunAmount); color.rgb = color.rgb * transmittance + fogColor * (1.0 - transmittance); return color; }
天空像素没有深度,这里给它一个固定的“等效距离” _SkyDistance。这样水平方向的天空被雾吞没、头顶的天空依然清澈(因为向上看时高度雾的光学厚度很小),地平线自然地融进雾里。
雾色的“太阳内散射”是一个廉价近似:视线方向与太阳方向越接近,雾色越偏向太阳色。物理上这对应介质的前向散射(见 28.4 节的相位函数),这里用 pow(saturate(dot), n) 来控制光晕范围。注意 GetMainLight() 在全屏 Pass 里一样能用:主光的方向和颜色是 URP 每台相机设置一次的全局变量(_MainLightPosition、_MainLightColor),与谁在画无关。

#include "…/CommonMaterial.hlsl"。去掉它,编译会报 undeclared identifier 'LerpWhiteTo',错误位置在 URP 的 Shadows.hlsl。原因是 RealtimeLights.hlsl 包含了 Shadows.hlsl,而后者用到了 core 里 CommonMaterial.hlsl 的 LerpWhiteTo,却没有自己 include 它;普通物体 Shader 走 Lighting.hlsl 时恰好被别的文件带上了,全屏 Shader 只 include 了一部分就会暴露出来。URP 自己的 ScreenSpaceShadows.shader 开头有一句注释 “Keep compiler quiet about Shadows.hlsl”,说的就是这件事。28.3 SSAO:屏幕空间环境光遮蔽
环境光遮蔽(Ambient Occlusion,AO)描述的是“一个点周围有多少方向被附近的几何挡住了”。墙角、缝隙、物体与地面的接触处,能“看到”的天空少,接收到的环境光就少,应该更暗。严格地说,点 p 的可见度是法线半球上可见性函数的余弦加权积分:
其中 V 在方向 ω 一定距离内被遮挡时为 0,否则为 1;1/π 是余弦加权半球积分的归一化系数(∫Ω cosθ dω = π)。离线渲染会真的发射射线去求这个积分;屏幕空间 AO(SSAO)则用深度缓冲当作“场景的近似”:在半球里撒一些采样点,把它们投影回屏幕,看深度缓冲里那个位置的表面是不是在采样点的前面——如果是,说明这个采样点被埋在几何体里了,记一次遮挡。
这只是一个近似:深度缓冲是一张“只有正面、没有厚度”的高度场,它不知道墙有多厚,也不知道被前景挡住的后面是什么。所以 SSAO 会在细物体背后产生错误的暗晕(Halo),需要用“范围检查”压制;它在屏幕边缘缺少信息,AO 会变淡;它只能表达小范围的遮蔽,大尺度的遮挡还是要靠烘焙 AO 或光照探针。
URP 自带的 Screen Space Ambient Occlusion Feature
URP 14 自带一个 SSAO Renderer Feature(在 Renderer Data 上 Add Renderer Feature › Screen Space Ambient Occlusion)。它的类 ScreenSpaceAmbientOcclusion 是 internal 的,参数存在 ScreenSpaceAmbientOcclusionSettings 里。下表按 Inspector 的顺序解释每个参数(默认值取自 14.0.12 源码):
| 参数 | 默认 | 含义与调参建议 |
|---|---|---|
| Method | Blue Noise | 采样点的随机化方式。Blue Noise 用 8 张 256² 蓝噪声贴图轮换,噪点更均匀;Interleaved Gradient 用解析噪声函数,不读贴图。两者都依赖后面的模糊来去噪。 |
| Intensity | 3 | 遮蔽强度系数。Shader 里与采样平均值相乘后再做 0.6 次幂(kContrast),所以不是线性的“变暗多少”。 |
| Radius | 0.035 | 采样半径(世界单位,米)。默认值非常小,只会在接缝处产生细线;场景尺度大时要加大,但半径越大越容易出现暗晕,也越依赖模糊。 |
| Falloff Distance | 100 | 超过这个观察深度的像素直接跳过 AO,并在此之前按 (1 − 深度/Falloff)² 淡出。开阔场景里把它调小能省不少开销。 |
| Direct Lighting Strength | 0.25 | AO 对直接光的影响比例。物理上 AO 只该作用于间接光(直接光有阴影负责),给一点点直接光遮蔽是艺术上的折中。 |
| Source | Depth Normals | Depth:只用深度,法线由相邻深度差重建(Normal Quality 控制重建采样数);Depth Normals:读 DepthNormals 预通道的法线,更准但多一个预通道。延迟渲染下强制为 Depth Normals(G-Buffer 里有法线)。 |
| Downsample | 关 | 半分辨率计算 AO。移动端几乎必开,AO 是低频信号,降分辨率损失很小。 |
| After Opaque | 关 | 关:AO 在不透明物体之前算好,交给 Lit 的光照只压暗间接光;开:不透明物体画完后直接把 AO 乘到颜色上,更省(不需要在光照里采样),但直接光也被压暗,看起来更“脏”。 |
| Blur Quality | High | High = 双边模糊(保边)、Medium = 高斯、Low = Kawase。 |
| Samples | Medium | 每像素采样数:High 12、Medium 8、Low 4(见 SSAO.hlsl 的 SAMPLE_COUNT)。 |
URP 的 AO 估计器出自 McGuire 等人的 Alchemy AO(源码注释里直接给出了论文出处)。它不是“数遮挡点个数”,而是对每个采样点计算 max(0, v·n − β·z) / (v·v + ε),v 是从当前点指向重建出的采样表面点的向量:越靠近、越正对法线方向的遮挡物贡献越大。摘自 URP 14 ShaderLibrary/SSAO.hlsl:
// Relative postition of the sample point half3 v_s2 = half3(ReconstructViewPos(uv_s1_01, linearDepth_s) - vpos_o); // Estimate the obscurance value half dotVal = dot(v_s2, normal_o) - kBeta * halfLinearDepth_o; half a1 = max(dotVal, HALF_ZERO); half a2 = dot(v_s2, v_s2) + kEpsilon; ao += a1 * rcp(a2) * isInsideRadius;
AO 结果最终存进全局纹理 _ScreenSpaceOcclusionTexture,并打开关键字 _SCREEN_SPACE_OCCLUSION。Lit 在计算光照时通过 AmbientOcclusion.hlsl 读取它(摘自 URP 14):
AmbientOcclusionFactor GetScreenSpaceAmbientOcclusion(float2 normalizedScreenSpaceUV) { AmbientOcclusionFactor aoFactor; #if defined(_SCREEN_SPACE_OCCLUSION) && !defined(_SURFACE_TYPE_TRANSPARENT) float ssao = saturate(SampleAmbientOcclusion(normalizedScreenSpaceUV) + (1.0 - _AmbientOcclusionParam.x)); aoFactor.indirectAmbientOcclusion = ssao; aoFactor.directAmbientOcclusion = lerp(half(1.0), ssao, _AmbientOcclusionParam.w); #else ...
从这里能读出三条约定:纹理存的是可见度(1 = 不遮挡);_AmbientOcclusionParam.w 就是 Direct Lighting Strength;透明物体不吃 SSAO。下一节我们自己写的 SSAO 会严格遵守这些约定,从而无缝接入 Lit。

案例 28.4简化版 SSAO,并接入 URP 光照高阶单独打开 ↗
目标:自己实现一个法线半球采样的 SSAO:随机旋转 + 双边模糊,并按 URP 的约定把结果交给 Lit,只压暗间接光。
算法步骤:
- 由深度重建当前像素的世界坐标 p,从法线纹理读世界空间法线 n。
- 用 Interleaved Gradient Noise 给每个像素一个随机角度,构造绕 n 随机旋转的切线空间。随机旋转把“规则采样图案的条带”变成高频噪声,后者可以被模糊掉,前者不行。
- 在半球里取 N 个方向(余弦分布,黄金角螺旋排列),长度向中心聚集——近处的遮挡对 AO 更重要。
- 每个采样点投影回屏幕,比较“采样点自己的观察深度”和“屏幕上那里的场景深度”。场景表面更近 → 采样点被埋住 → 记一次遮挡。
- 范围检查:如果场景深度和中心点相差远超半径(例如背景前的前景物体边缘),这次遮挡不算,避免暗晕。
- 结果做两遍可分离的双边模糊(横、竖),权重同时考虑空间距离和深度差,AO 不会跨越物体边缘渗出去。
half4 FragAO(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; float depth = SampleDeviceDepth(uv); if (IsSkyDepth(depth)) return 1; float3 positionWS = ReconstructPositionWS(uv, depth); float3 normalWS = normalize(SampleSceneNormals(uv)); // URP 14 的法线纹理是世界空间 float centerEye = LinearEyeDepth(positionWS, UNITY_MATRIX_V); // 每像素一个随机旋转角,把规则的采样图案打散成高频噪声,后面再模糊掉 float noise = InterleavedGradientNoise(input.positionCS.xy, 0); float3 randomDir = float3(cos(noise * TWO_PI), sin(noise * TWO_PI), 0.37); float3 tangentWS = normalize(cross(normalWS, randomDir)); float3 bitangentWS = cross(normalWS, tangentWS); int count = (int)_SampleCount; float occlusion = 0; for (int i = 0; i < count; i++) { // 余弦分布的半球方向(黄金角螺旋),长度向中心聚集:近处样本更重要 float u = (i + 0.5) / count; float phi = i * 2.39996323; float cosTheta = sqrt(1 - u), sinTheta = sqrt(u); float3 dirTS = float3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); float lenScale = lerp(0.1, 1.0, frac(u * 7.31 + noise) * frac(u * 7.31 + noise)); float3 offsetWS = (tangentWS * dirTS.x + bitangentWS * dirTS.y + normalWS * dirTS.z) * (lenScale * _Radius); float3 sampleWS = positionWS + offsetWS; // 投影回屏幕,读那里的场景深度 float2 sampleUV = ComputeNormalizedDeviceCoordinates(sampleWS, UNITY_MATRIX_VP); float sampleEye = LinearEyeDepth(sampleWS, UNITY_MATRIX_V); float sceneEye = EyeDepthAt(sampleUV); // 场景表面在样本点前面 → 样本点被埋住 → 记一次遮挡 float occluded = sceneEye < sampleEye - _Bias ? 1 : 0; // 范围检查:深度差远大于半径的(背景前的前景边缘)不算,避免黑边光晕 float rangeCheck = smoothstep(0, 1, _Radius / max(abs(centerEye - sceneEye), 1e-4)); occlusion += occluded * rangeCheck; } float visibility = saturate(1 - occlusion / count * _Intensity); return half4(visibility, visibility, visibility, 1); }
vec4 FragAO() { vec2 uv = texcoord; float depth = SampleDeviceDepth(uv); if (IsSkyDepth(depth)) return vec4(1.0); vec3 positionWS = ReconstructPositionWS(uv, depth); vec3 normalWS = normalize(SampleSceneNormals(uv)); // URP 14 的法线纹理是世界空间 float centerEye = LinearEyeDepthWS(positionWS); // 每像素一个随机旋转角,把规则的采样图案打散成高频噪声,后面再模糊掉 float noise = InterleavedGradientNoise(gl_FragCoord.xy, 0); vec3 randomDir = vec3(cos(noise * TWO_PI), sin(noise * TWO_PI), 0.37); vec3 tangentWS = normalize(cross(normalWS, randomDir)); vec3 bitangentWS = cross(normalWS, tangentWS); int count = int(_SampleCount); float occlusion = 0.0; for (int i = 0; i < count; i++) { // 余弦分布的半球方向(黄金角螺旋),长度向中心聚集:近处样本更重要 float u = (float(i) + 0.5) / float(count); float phi = float(i) * 2.39996323; float cosTheta = sqrt(1.0 - u), sinTheta = sqrt(u); vec3 dirTS = vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); float lenScale = mix(0.1, 1.0, fract(u * 7.31 + noise) * fract(u * 7.31 + noise)); vec3 offsetWS = (tangentWS * dirTS.x + bitangentWS * dirTS.y + normalWS * dirTS.z) * (lenScale * _Radius); vec3 sampleWS = positionWS + offsetWS; // 投影回屏幕,读那里的场景深度 vec2 sampleUV = ComputeNormalizedDeviceCoordinates(sampleWS, UNITY_MATRIX_VP); float sampleEye = LinearEyeDepthWS(sampleWS); float sceneEye = EyeDepthAt(sampleUV); // 场景表面在样本点前面 → 样本点被埋住 → 记一次遮挡 float occluded = sceneEye < sampleEye - _Bias ? 1.0 : 0.0; // 范围检查:深度差远大于半径的(背景前的前景边缘)不算,避免黑边光晕 float rangeCheck = smoothstep(0.0, 1.0, _Radius / max(abs(centerEye - sceneEye), 1e-4)); occlusion += occluded * rangeCheck; } float visibility = saturate(1.0 - occlusion / float(count) * _Intensity); return vec4(visibility, visibility, visibility, 1.0); }
采样点的屏幕坐标用 ComputeNormalizedDeviceCoordinates(sampleWS, UNITY_MATRIX_VP) 计算——它内部处理了 D3D/Metal 下的 y 翻转,得到的 UV 与 Blit.hlsl 的 texcoord 约定一致。采样点自身的观察深度用 core 的 LinearEyeDepth(positionWS, UNITY_MATRIX_V) 重载,它就是观察空间 z 取负。
// 双边模糊:高斯权重 × 深度相似度权重,跨越深度断层的样本被压低,AO 不会渗到背景上 half4 BilateralBlur(float2 uv, float2 dir) { static const float w[4] = { 0.324, 0.232, 0.0855, 0.0205 }; float centerEye = EyeDepthAt(uv); half sum = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv).r * w[0]; float wsum = w[0]; for (int i = 1; i < 4; i++) { for (int s = -1; s <= 1; s += 2) { float2 suv = uv + dir * (i * s); float d = EyeDepthAt(suv); float wd = w[i] * exp(-abs(d - centerEye) / max(centerEye * 0.02, 1e-3)); sum += SAMPLE_TEXTURE2D_X_LOD(_BlitTexture, sampler_LinearClamp, suv, 0).r * wd; wsum += wd; } } half v = sum / wsum; return half4(v, v, v, 1); }
// 双边模糊:高斯权重 × 深度相似度权重,跨越深度断层的样本被压低,AO 不会渗到背景上 vec4 BilateralBlur(vec2 uv, vec2 dir) { const float w[4] = float[4](0.324, 0.232, 0.0855, 0.0205); float centerEye = EyeDepthAt(uv); float sum = texture(_BlitTexture, uv).r * w[0]; float wsum = w[0]; for (int i = 1; i < 4; i++) { for (int s = -1; s <= 1; s += 2) { vec2 suv = uv + dir * float(i * s); float d = EyeDepthAt(suv); float wd = w[i] * exp(-abs(d - centerEye) / max(centerEye * 0.02, 1e-3)); sum += textureLod(_BlitTexture, suv, 0.0).r * wd; wsum += wd; } } float v = sum / wsum; return vec4(v, v, v, 1.0); }
unable to unroll loop … use the [unroll(n)] attribute。原因是循环次数由材质参数决定(动态循环),而循环体里调用了 SampleSceneDepth——它用的是 SAMPLE_TEXTURE2D_X,需要隐式的屏幕空间导数来选 Mip,而导数在分支/动态循环里没有定义,编译器只好尝试把循环完全展开,展开失败就报错。解决办法是在循环里一律使用显式 LOD 的采样:本章公共文件里提供了 SampleSceneDepthLod,模糊里也改用 SAMPLE_TEXTURE2D_X_LOD。这条规则对所有“步进类”屏幕空间算法(SSR、体积光、视差)都适用。C# 部分的关键是事件时机和交接方式:
// 进光照:必须在不透明物体之前,且要等深度/法线预通道画完(+1 与 URP 自带 SSAO 一致) m_Pass.renderPassEvent = settings.mode == Mode.IntoLighting ? RenderPassEvent.AfterRenderingPrePasses + 1 : RenderPassEvent.AfterRenderingOpaques; // 要法线:Forward 路径下这会让 URP 执行 DepthNormals 预通道 m_Pass.ConfigureInput(ScriptableRenderPassInput.Depth | ScriptableRenderPassInput.Normal);
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var mat = m_Settings.material; var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { int w = m_AO.rt.width, h = m_AO.rt.height; mat.SetVector(s_TexelSize, new Vector4(1f / w, 1f / h, w, h)); // 1) AO:没有输入纹理,直接画全屏三角形到 m_AO CoreUtils.SetRenderTarget(cmd, m_AO); Blitter.BlitTexture(cmd, new Vector4(1, 1, 0, 0), mat, 0); // 2) 可分离双边模糊:AO → Temp(横)→ AO(竖) if (m_Settings.blur) { Blitter.BlitCameraTexture(cmd, m_AO, m_AOTemp, mat, 1); Blitter.BlitCameraTexture(cmd, m_AOTemp, m_AO, mat, 2); } if (m_Settings.mode == Mode.IntoLighting) { // 3a) 按 URP 的约定把结果交给光照:纹理 + 关键字 + 参数(w = 直接光受 AO 影响的比例) cmd.SetGlobalTexture(s_URPOcclusionTex, m_AO); cmd.SetGlobalVector(s_URPOcclusionParam, new Vector4(1, 0, 0, m_Settings.directLightingStrength)); CoreUtils.SetKeyword(cmd, k_URPKeyword, true); } else { // 3b) 直接乘到相机颜色上(或只显示 AO) cmd.SetGlobalTexture(s_Result, m_AO); Blitter.BlitCameraTexture(cmd, m_CameraColor, m_ColorCopy); Blitter.BlitCameraTexture(cmd, m_ColorCopy, m_CameraColor, mat, m_Settings.mode == Mode.ShowAO ? 4 : 3); } } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void OnCameraCleanup(CommandBuffer cmd) { // 关键字是全局状态,用完一定要关,否则会“漏”到下一台相机 if (m_Settings != null && m_Settings.mode == Mode.IntoLighting) CoreUtils.SetKeyword(cmd, k_URPKeyword, false); }
// 案例 28.4:简化版 SSAO 的 Renderer Feature(对应 Ch28/SimpleSSAOFeature.cs)。 // IntoLighting —— 像 URP 自带 SSAO 一样,在不透明物体之前算好 AO,作为 _ScreenSpaceOcclusionTexture 交给光照; // MultiplyAfterOpaque —— 不透明物体画完后直接把 AO 乘到颜色上; // ShowAO —— 只显示 AO(调试)。 import { featureMaterial, RenderPassEvent } from '../engine/features.js'; import { renderDepthNormals } from './Cookbook.Ch28.ScreenSpaceUtil.js'; const Mode = { IntoLighting: 0, MultiplyAfterOpaque: 1, ShowAO: 2 }; export default class SimpleSSAOFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; this.settings = { material: null, mode: Mode.IntoLighting, blur: true, halfResolution: false, directLightingStrength: 0.25, ...((data && data.settings) || {}) }; } async init() { this.material = featureMaterial(this.scene, this.settings.material); } addPasses(frame, enqueue) { const s = this.settings, mat = this.material; if (!mat) return; // 进光照:必须在不透明物体之前,且要等深度/法线预通道画完(+1 与 URP 自带 SSAO 一致) const event = s.mode === Mode.IntoLighting ? RenderPassEvent.AfterRenderingPrePasses + 1 : RenderPassEvent.AfterRenderingOpaques; // ConfigureInput(Depth | Normal):前向路径下 URP 会执行 DepthNormals 预通道 enqueue(RenderPassEvent.AfterRenderingPrePasses, (fr) => renderDepthNormals(fr), 'DepthNormalPrepass'); enqueue(event, (fr) => { const W = s.halfResolution ? Math.floor(fr.width / 2) : fr.width, H = s.halfResolution ? Math.floor(fr.height / 2) : fr.height; const ao = fr.getTemporaryRT('_SimpleSSAO', W, H, { color: 'RGBA8' }); const aoTemp = fr.getTemporaryRT('_SimpleSSAOTemp', W, H, { color: 'RGBA8' }); mat.setVector('_SSAOTexelSize', [1 / W, 1 / H, W, H]); // 1) AO:没有输入纹理,直接画全屏三角形 fr.blit(null, ao, mat, 0); // 2) 可分离双边模糊:AO → Temp(横)→ AO(竖) if (s.blur) { fr.blit(ao.texture, aoTemp, mat, 1); fr.blit(aoTemp.texture, ao, mat, 2); } if (s.mode === Mode.IntoLighting) { // 3a) 按 URP 的约定把结果交给光照:纹理 + 参数(w = 直接光受 AO 影响的比例) fr.setGlobal('_ScreenSpaceOcclusionTexture', ao.texture); fr.setGlobal('_AmbientOcclusionParam', [1, 0, 0, s.directLightingStrength]); } else { // 3b) 直接乘到相机颜色上(或只显示 AO) fr.setGlobal('_SSAOResult', ao.texture); const copy = fr.getTemporaryRT('_SimpleSSAOColor', fr.width, fr.height, { filter: 'Point' }); fr.blit(fr.cameraColorTexture(), copy); fr.blit(copy.texture, 'camera', mat, s.mode === Mode.ShowAO ? 4 : 3); } fr.bindCamera(); }, 'SimpleSSAO'); } }
AfterRenderingPrePasses + 1 照抄自 URP 自带 SSAO:注释里解释了开启 Depth Priming 时,深度纹理要等 PrePasses 之后的一次解析才可用,所以至少要晚一个刻度。AO 算完后,三件事缺一不可:纹理绑到 _ScreenSpaceOcclusionTexture、设置 _AmbientOcclusionParam、开启关键字 _SCREEN_SPACE_OCCLUSION;并且在 OnCameraCleanup 里关掉关键字,否则它会泄漏到下一台相机(例如 UI 相机、反射相机),让它们读到一张不属于自己的 AO 图。

调参与性能提示:
- 半径决定 AO 的“尺度”。室内道具 0.3–0.6 米比较合适;它和场景单位直接相关,不要照搬别人的数字。
- Bias 太小会出现自遮挡的灰色噪点(尤其是倾斜的大平面),太大会让细小的接缝消失。
- 采样数是开销的主要来源:每个采样点一次深度读取。全分辨率 16 个采样在移动端通常承受不起,可以 8 个采样 + 半分辨率 + 更强的模糊。如果项目已经有 TAA,还可以每帧换一组随机旋转,让时间累积来降噪。
- 半分辨率时,合成阶段最好做深度感知上采样(取周围几个低分辨率样本中深度最接近的),否则物体边缘会出现一像素宽的 AO 漏光。
28.4 体积光:在全屏 Pass 里步进主光阴影
雾让远处变淡,但它是“均匀”的。当阳光穿过窗格、树叶或柱廊时,空气中被照亮和处在阴影里的部分明暗相间,形成一道道光束——体积光(Volumetric Light,也常叫 God Rays、光柱)。要算出它,必须知道视线上每一段空气是否被主光照到,而“是否被主光照到”正是阴影图回答的问题。
辐射传输的简化
沿视线从相机出发,对每一小段介质 dt:它被主光照亮的程度是可见性 V(t)(查阴影图);它把光散射到相机方向的比例由相位函数 p(θ) 决定(θ 是光线方向与视线的夹角);散射出的光走回相机途中还要乘上透射率 T(t)。把这些累加起来:
对方向光,θ 在整条视线上不变,相位函数可以提到积分外。我们用 Henyey-Greenstein 相位函数,参数 g ∈ (−1, 1) 控制散射的方向性,g > 0 表示以前向散射为主(迎着光看最亮,这正是空气中尘埃、水汽的特征):
积分用光线步进(Ray Marching)离散求和:把视线切成 N 段,每段查一次阴影图。如果介质均匀,每段的透射率是常数 e−σΔt,每段贡献的散射量正比于 (1 − e−σΔt),这样即使步长很大,能量也是守恒的——比直接写 σΔt 更稳。
全屏 Pass 里怎么采样主光阴影?先读源码
物体 Shader 采样阴影已经很熟悉了:声明阴影关键字,TransformWorldToShadowCoord 得到阴影坐标,MainLightRealtimeShadow 查询。全屏 Pass 里能不能照搬?要回答这个问题,得确认三件事:阴影图纹理、阴影矩阵这些 uniform 在全屏 Pass 执行时是否还有效;阴影关键字是否开着;级联选择是否正常工作。翻 URP 14 的 Runtime/Passes/MainLightShadowCasterPass.cs,渲染完阴影图后有这样几行:
CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadows, renderingData.shadowData.mainLightShadowCascadesCount == 1); CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadowCascades, renderingData.shadowData.mainLightShadowCascadesCount > 1); ShadowUtils.SetSoftShadowQualityShaderKeywords(cmd, ref renderingData.shadowData); SetupMainLightShadowReceiverConstants(cmd, ref shadowLight, ref renderingData.shadowData);
关键字用 cmd 设置的是全局关键字,阴影接收常量(_MainLightWorldToShadow、级联包围球 _CascadeShadowSplitSpheres0..3、_MainLightShadowParams 等)也是全局 uniform,阴影图则以 _MainLightShadowmapTexture 绑定为全局纹理。它们在整台相机的渲染期间一直有效,直到下一台相机开始时 ScriptableRenderer.ClearRenderingState 把关键字关掉。所以结论是:在同一台相机、阴影 Pass 之后的任何全屏 Pass 里,只要声明了相同的关键字变体,就能直接用 URP 的阴影函数。URP 自己的屏幕空间阴影(ScreenSpaceShadows.shader)就是这么做的:全屏 Pass 里重建世界坐标,调用 TransformWorldToShadowCoord 和 MainLightRealtimeShadow。
再看 Shadows.hlsl 里的这两个函数(摘自 URP 14):
float4 TransformWorldToShadowCoord(float3 positionWS) { #if defined(_MAIN_LIGHT_SHADOWS_SCREEN) && !defined(_SURFACE_TYPE_TRANSPARENT) float4 shadowCoord = float4(ComputeNormalizedDeviceCoordinatesWithZ(positionWS, GetWorldToHClipMatrix()), 1.0); #else #ifdef _MAIN_LIGHT_SHADOWS_CASCADE half cascadeIndex = ComputeCascadeIndex(positionWS); #else half cascadeIndex = half(0.0); #endif float4 shadowCoord = float4(mul(_MainLightWorldToShadow[cascadeIndex], float4(positionWS, 1.0)).xyz, 0.0); #endif return shadowCoord; }
可以读出两点重要信息。第一,级联选择 ComputeCascadeIndex 只依赖世界坐标(判断它落在哪个级联包围球里),所以视线上的每个步进点都会自动选对级联;超出所有级联的点会落到一个“空操作矩阵”上,SampleShadowmap 末尾的 BEYOND_SHADOW_FAR 判断会让它返回 1(未遮挡)。第二,_MAIN_LIGHT_SHADOWS_SCREEN 变体走的是屏幕空间阴影纹理——那张纹理只记录了“屏幕上每个表面点”的阴影,视线中间空气里的点没有对应值。所以体积光 Shader 不声明这个关键字,只要 _MAIN_LIGHT_SHADOWS 和 _MAIN_LIGHT_SHADOWS_CASCADE。如果项目里加了 Screen Space Shadows Feature,URP 会关掉前两个关键字、只开 SCREEN,此时本 Shader 会落到“无阴影”变体,体积光变成均匀的雾——需要知道这一点。
// 主光阴影的关键字由 URP 在渲染阴影图后全局开启;全屏 Pass 只要声明同样的变体即可。 // 不需要 _MAIN_LIGHT_SHADOWS_SCREEN:屏幕空间阴影只有“表面”上的结果,体积里的点用不上。 #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE // Shadows.hlsl 用到 CommonMaterial.hlsl 里的 LerpWhiteTo,但自己没 include(Lit 走 Lighting.hlsl 时会间接带上) #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RealtimeLights.hlsl"
软阴影关键字(_SHADOWS_SOFT 系列)这里也没有声明:步进本身就会把阴影边缘“平均”掉,再用软阴影的多次采样纯属浪费,硬件 2×2 比较采样(SAMPLE_TEXTURE2D_SHADOW)就足够了。
案例 28.5体积光:步进、抖动、模糊、合成高阶单独打开 ↗
// Henyey-Greenstein 相位函数:g>0 时向前散射为主,迎着光看最亮 float PhaseHG(float cosTheta, float g) { float g2 = g * g; return INV_FOUR_PI * (1 - g2) / pow(max(1 + g2 - 2 * g * cosTheta, 1e-4), 1.5); } half4 FragMarch(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; float depth = SampleDeviceDepth(uv); float3 endWS = IsSkyDepth(depth) ? ComputeWorldSpacePosition(uv, UNITY_RAW_FAR_CLIP_VALUE, UNITY_MATRIX_I_VP) : ReconstructPositionWS(uv, depth); float3 rayWS = endWS - _WorldSpaceCameraPos; float rayLength = min(length(rayWS), _MaxDistance); float3 dir = rayWS / max(length(rayWS), 1e-4); Light mainLight = GetMainLight(); float phase = PhaseHG(dot(dir, mainLight.direction), _Anisotropy); int steps = (int)_Steps; float stepLength = rayLength / steps; // 抖动起点:把“条带”换成高频噪声,再交给模糊去掉 float jitter = _Jitter > 0.5 ? InterleavedGradientNoise(input.positionCS.xy, 0) : 0.5; float scatter = 0; float transmittance = 1; float stepT = exp(-_Density * stepLength); // 每步的透射率(均匀介质) for (int i = 0; i < steps; i++) { float t = (i + jitter) * stepLength; float3 p = _WorldSpaceCameraPos + dir * t; // 关键两行:世界坐标 → 阴影坐标(自动选级联)→ 比较阴影图 float4 shadowCoord = TransformWorldToShadowCoord(p); float visibility = MainLightRealtimeShadow(shadowCoord); // 这一小段介质散射到相机方向的光,再乘上它到相机之间的透射率 scatter += visibility * transmittance * (1 - stepT); transmittance *= stepT; } scatter *= phase * _Intensity * FOUR_PI; // ×4π 让 g=0 时的强度约为 1,便于调参 return half4(scatter, transmittance, 0, 1); }
// Henyey-Greenstein 相位函数:g>0 时向前散射为主,迎着光看最亮 float PhaseHG(float cosTheta, float g) { float g2 = g * g; return INV_FOUR_PI * (1.0 - g2) / pow(max(1.0 + g2 - 2.0 * g * cosTheta, 1e-4), 1.5); } vec4 FragMarch() { vec2 uv = texcoord; float depth = SampleDeviceDepth(uv); vec3 endWS = IsSkyDepth(depth) ? ComputeWorldSpacePosition(uv, UNITY_RAW_FAR_CLIP_VALUE, UNITY_MATRIX_I_VP) : ReconstructPositionWS(uv, depth); vec3 rayWS = endWS - _WorldSpaceCameraPos; float rayLength = min(length(rayWS), _MaxDistance); vec3 dir = rayWS / max(length(rayWS), 1e-4); Light mainLight = GetMainLight(); float phase = PhaseHG(dot(dir, mainLight.direction), _Anisotropy); int steps = int(_Steps); float stepLength = rayLength / float(steps); // 抖动起点:把“条带”换成高频噪声,再交给模糊去掉 float jitter = _Jitter > 0.5 ? InterleavedGradientNoise(gl_FragCoord.xy, 0) : 0.5; float scatter = 0.0; float transmittance = 1.0; float stepT = exp(-_Density * stepLength); // 每步的透射率(均匀介质) for (int i = 0; i < steps; i++) { float t = (float(i) + jitter) * stepLength; vec3 p = _WorldSpaceCameraPos + dir * t; // 关键两行:世界坐标 → 阴影坐标(自动选级联)→ 比较阴影图 vec4 shadowCoord = TransformWorldToShadowCoord(p); float visibility = MainLightRealtimeShadow(shadowCoord); // 这一小段介质散射到相机方向的光,再乘上它到相机之间的透射率 scatter += visibility * transmittance * (1.0 - stepT); transmittance *= stepT; } scatter *= phase * _Intensity * FOUR_PI; // ×4π 让 g=0 时的强度约为 1,便于调参 return vec4(scatter, transmittance, 0.0, 1.0); }
逐行看几个要点:
- 天空像素用远裁剪面上的点作为终点,再统一用
_MaxDistance截断。截断距离不要超过 URP Asset 的阴影距离,超出部分全部算作“被照亮”,只会得到一片均匀的亮雾。 - 抖动起点(Jitter):所有像素都从 t = 0 开始、每步固定 Δt,相邻像素的采样点落在同一组“深度切片”上,阴影边缘会出现一层层的条带(Banding)。给每个像素一个 [0,1) 的随机起始偏移(这里用 Interleaved Gradient Noise),条带就变成了高频噪声,交给后面的模糊即可。这是所有步进算法通用的技巧:用噪声换走条带,再用滤波(空间模糊或时间累积)去掉噪声。
- 结果乘了 4π,是为了让 g = 0 时相位函数归一到 1,方便美术调强度;这一步不影响物理关系,只是换了个单位。
- 输出两个通道:r 是散射光,g 是视线方向的总透射率。合成时场景颜色乘透射率,再加上散射光——体积光同时也是一层雾。
half4 Frag(Varyings input) : SV_Target { half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, input.texcoord); half2 vol = SAMPLE_TEXTURE2D_X(_VolumetricResult, sampler_LinearClamp, input.texcoord).rg; half3 lightColor = GetMainLight().color * _Tint.rgb; // 场景被介质衰减(×透射率),再加上介质散射进视线的光 color.rgb = color.rgb * vol.g + lightColor * vol.r; return color; }
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var mat = m_Settings.material; var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, profilingSampler)) { int w = m_Vol.rt.width, h = m_Vol.rt.height; mat.SetVector(s_TexelSize, new Vector4(1f / w, 1f / h, w, h)); CoreUtils.SetRenderTarget(cmd, m_Vol); Blitter.BlitTexture(cmd, new Vector4(1, 1, 0, 0), mat, 0); // 光线步进 if (m_Settings.blur) { Blitter.BlitCameraTexture(cmd, m_Vol, m_VolTemp, mat, 1); // 横向模糊 Blitter.BlitCameraTexture(cmd, m_VolTemp, m_Vol, mat, 2); // 纵向模糊 } cmd.SetGlobalTexture(s_Result, m_Vol); Blitter.BlitCameraTexture(cmd, m_CameraColor, m_Copy); Blitter.BlitCameraTexture(cmd, m_Copy, m_CameraColor, mat, m_Settings.output == Output.Composite ? 3 : 4); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); }
// 案例 28.5:体积光 Renderer Feature(对应 Ch28/VolumetricLightFeature.cs)—— 半分辨率光线步进 → 模糊 → 合成。 import { featureMaterial, RenderPassEvent } from '../engine/features.js'; import { ensureDepth } from './Cookbook.Ch28.ScreenSpaceUtil.js'; const Output = { Composite: 0, ScatterOnly: 1 }; export default class VolumetricLightFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; this.settings = { material: null, downsample: 2, blur: true, output: Output.Composite, renderPassEvent: RenderPassEvent.BeforeRenderingTransparents, ...((data && data.settings) || {}) }; } async init() { this.material = featureMaterial(this.scene, this.settings.material); } addPasses(frame, enqueue) { const s = this.settings, mat = this.material; if (!mat) return; // 没有主光阴影就没有“光束”,直接跳过 if (frame.scene.asset.m_MainLightShadowsSupported === false) return; enqueue(s.renderPassEvent, (fr) => { ensureDepth(fr); // ConfigureInput(Depth) const ds = Math.max(1, s.downsample | 0); const w = Math.max(1, Math.floor(fr.width / ds)), h = Math.max(1, Math.floor(fr.height / ds)); // 散射值可能 >1,用半精度浮点 const vol = fr.getTemporaryRT('_Volumetric', w, h, { color: 'RGBA16F' }); const volTemp = fr.getTemporaryRT('_VolumetricTemp', w, h, { color: 'RGBA16F' }); mat.setVector('_VolTexelSize', [1 / w, 1 / h, w, h]); fr.blit(null, vol, mat, 0); // 光线步进 if (s.blur) { fr.blit(vol.texture, volTemp, mat, 1); // 横向模糊 fr.blit(volTemp.texture, vol, mat, 2); // 纵向模糊 } fr.setGlobal('_VolumetricResult', vol.texture); const copy = fr.getTemporaryRT('_VolumetricColorCopy', fr.width, fr.height, { filter: 'Point' }); fr.blit(fr.cameraColorTexture(), copy); fr.blit(copy.texture, 'camera', mat, s.output === Output.Composite ? 3 : 4); fr.bindCamera(); }, 'VolumetricLight'); } }
Feature 在半分辨率(downsample = 2)的 ARGBHalf 纹理上步进,散射值可能超过 1,需要浮点格式。AddRenderPasses 里还检查了 renderingData.shadowData.supportsMainLightShadows:主光不投影或阴影被关掉时,这个效果没有意义,直接不入队。

性能与质量提示:
- 开销 ≈ 像素数 × 步数 × 一次阴影采样。半分辨率 + 16~32 步是常见的起点;1/4 分辨率配合更强的模糊在移动端也常用。
- 上采样时同样要注意深度边缘:低分辨率的散射值在前景物体边缘会“溢”到背景上,表现为物体轮廓外一圈亮边。可以用深度感知上采样或在模糊里加深度权重(参考案例 28.4 的双边模糊)。
- 有 TAA 时,可以每帧换一组抖动偏移(把 IGN 的 frameCount 参数传入帧序号),让时间累积替代空间模糊,画面更干净。
- 点光源、聚光灯的体积光原理相同,但要查附加光阴影图(
AdditionalLightRealtimeShadow),且相位角每一步都在变;工程上更常见的做法是为它们放置体积网格或用 Froxel(视锥体素)方案,超出本章范围。
案例 28.6屏幕空间反射(SSR)原理与最简实现高阶单独打开 ↗
目标:让光滑地面映出场景里的物体。URP 14 没有内置 SSR,平面反射(第 19 章)又要多渲染一遍场景;SSR 只用已有的深度和颜色,就能得到动态的、跟随场景变化的反射。
原理:对每个“需要反射”的像素,由深度和法线算出反射方向 r = reflect(v, n),然后沿 r 在三维空间里一步步前进,每一步把光线上的点投影回屏幕,比较它和深度缓冲:光线点比深度缓冲里的表面更远,说明光线“钻到了表面后面”,交点就在这一步和上一步之间。在交点处取屏幕颜色,就是反射色。
// 光线上一点 → (屏幕 uv, 光线点自己的观察深度, 该处场景的观察深度) float3 ProjectRay(float3 p, out float2 uv) { uv = ComputeNormalizedDeviceCoordinates(p, UNITY_MATRIX_VP); float rayEye = LinearEyeDepth(p, UNITY_MATRIX_V); float sceneEye = LinearEyeDepth(SampleSceneDepthLod(uv), _ZBufferParams); return float3(uv, rayEye - sceneEye); // z>0:光线跑到了场景表面后面 } bool TraceReflection(float3 originWS, float3 dirWS, float jitter, out float2 hitUV, out float hitFade) { hitUV = 0; hitFade = 0; float3 prev = originWS; int steps = (int)_MaxSteps; for (int i = 1; i <= steps; i++) { float3 p = originWS + dirWS * (_StepSize * (i + jitter)); float2 uv; float diff = ProjectRay(p, uv).z; if (any(uv < 0) || any(uv > 1)) return false; // 走出屏幕:屏幕里没有信息了 if (diff > 0 && diff < _Thickness) // 穿过了一个“有厚度”的表面 { // 二分细化:在 prev 与 p 之间逼近交点,减少台阶感 float3 a = prev, b = p; for (int k = 0; k < 5; k++) { float3 m = (a + b) * 0.5; float2 muv; if (ProjectRay(m, muv).z > 0) b = m; else a = m; } ProjectRay(b, hitUV); // 越靠屏幕边缘、越接近最大步数,越淡出,避免硬切 float2 edge = smoothstep(0, 0.08, hitUV) * smoothstep(1, 0.92, hitUV); hitFade = edge.x * edge.y * (1 - (float)i / steps); return true; } prev = p; } return false; }
// 光线上一点 → (屏幕 uv, 光线点自己的观察深度 - 该处场景的观察深度) vec3 ProjectRay(vec3 p, out vec2 uv) { vec4 cs = UNITY_MATRIX_VP * vec4(p, 1.0); // ComputeNormalizedDeviceCoordinates uv = cs.xy / cs.w * 0.5 + 0.5; float rayEye = -(UNITY_MATRIX_V * vec4(p, 1.0)).z; // LinearEyeDepth(p, UNITY_MATRIX_V) float sceneEye = LinearEyeDepth(SampleSceneDepthLod(uv), _ZBufferParams); return vec3(uv, rayEye - sceneEye); // z>0:光线跑到了场景表面后面 } bool TraceReflection(vec3 originWS, vec3 dirWS, float jitter, out vec2 hitUV, out float hitFade) { hitUV = vec2(0.0); hitFade = 0.0; vec3 prev = originWS; int steps = int(_MaxSteps); for (int i = 1; i <= steps; i++) { vec3 p = originWS + dirWS * (_StepSize * (float(i) + jitter)); vec2 uv; float diff = ProjectRay(p, uv).z; if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) return false; // 走出屏幕:屏幕里没有信息了 if (diff > 0.0 && diff < _Thickness) // 穿过了一个“有厚度”的表面 { // 二分细化:在 prev 与 p 之间逼近交点,减少台阶感 vec3 a = prev, b = p; for (int k = 0; k < 5; k++) { vec3 m = (a + b) * 0.5; vec2 muv; if (ProjectRay(m, muv).z > 0.0) b = m; else a = m; } ProjectRay(b, hitUV); // 越靠屏幕边缘、越接近最大步数,越淡出,避免硬切 vec2 edge = smoothstep(0.0, 0.08, hitUV) * smoothstep(1.0, 0.92, hitUV); hitFade = edge.x * edge.y * (1.0 - float(i) / float(steps)); return true; } prev = p; } return false; }
half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv); float depth = SampleDeviceDepth(uv); if (IsSkyDepth(depth)) return color; float3 normalWS = normalize(SampleSceneNormals(uv)); if (normalWS.y < _MinNormalY) return color; // 只让“地面”反射 float3 positionWS = ReconstructPositionWS(uv, depth); float3 viewDirWS = normalize(positionWS - _WorldSpaceCameraPos); float3 reflDirWS = reflect(viewDirWS, normalWS); float jitter = InterleavedGradientNoise(input.positionCS.xy, 0); float2 hitUV; float fade; if (!TraceReflection(positionWS + normalWS * 0.02, reflDirWS, jitter, hitUV, fade)) return color; half3 reflColor = SAMPLE_TEXTURE2D_X_LOD(_BlitTexture, sampler_LinearClamp, hitUV, 0).rgb; float NoV = saturate(dot(normalWS, -viewDirWS)); float fresnel = _F0 + (1 - _F0) * pow(1 - NoV, 5); // Schlick color.rgb = lerp(color.rgb, reflColor, saturate(fresnel * fade * _Intensity)); return color; }
vec4 Frag() { vec2 uv = texcoord; vec4 color = SamplePointClamp(_BlitTexture, uv); float depth = SampleDeviceDepth(uv); if (IsSkyDepth(depth)) return color; vec3 normalWS = normalize(SampleSceneNormals(uv)); if (normalWS.y < _MinNormalY) return color; // 只让“地面”反射 vec3 positionWS = ReconstructPositionWS(uv, depth); vec3 viewDirWS = normalize(positionWS - _WorldSpaceCameraPos); vec3 reflDirWS = reflect(viewDirWS, normalWS); float jitter = InterleavedGradientNoise(gl_FragCoord.xy, 0); vec2 hitUV; float fade; if (!TraceReflection(positionWS + normalWS * 0.02, reflDirWS, jitter, hitUV, fade)) return color; vec3 reflColor = textureLod(_BlitTexture, hitUV, 0.0).rgb; float NoV = saturate(dot(normalWS, -viewDirWS)); float fresnel = _F0 + (1.0 - _F0) * pow(1.0 - NoV, 5.0); // Schlick color.rgb = mix(color.rgb, reflColor, saturate(fresnel * fade * _Intensity)); return color; }
几个实现上的取舍:
- 厚度(Thickness):深度缓冲不知道物体有多厚,光线从一个细杆后面穿过时也会被判为“穿过表面”。所以只有当光线点在表面后方、且深度差小于一个厚度阈值时才算命中。阈值太小会漏掉命中(反射出现断裂),太大会让反射“粘”在物体背后。
- 二分细化:线性步进找到“穿过”的那一步后,在上一步和这一步之间二分 5 次,交点精度提高 32 倍,台阶状的锯齿大幅减轻。
- 淡出:命中点越靠近屏幕边缘越淡,步数越接近上限越淡。没有淡出时,反射会在屏幕边缘被生硬地切断——这是 SSR 最常见的穿帮。
- 菲涅尔:反射强度按 Schlick 近似随掠射角增强,正对地面看几乎没有反射,贴着地面看反射最强,与真实世界一致。

SSR 的天然局限与工程化方向
只能反射屏幕上看得见的东西:物体背面、屏幕外的物体、被前景挡住的部分都反射不出来;相机一转,反射内容会突然出现或消失。工程上通常把 SSR 当作反射探针之上的一层“增强”:命中失败时回退到反射探针,命中成功时淡入 SSR。
性能:世界空间等步长步进很浪费——远处一步可能只跨过不到一个像素,近处一步又跳过好几个像素。成熟的实现在屏幕空间按像素做 DDA 步进,并用 Hi-Z(深度金字塔,每级存下一级 2×2 区域的最近深度)跳过大片空白区域,配合半分辨率和时间累积,开销才能控制在可接受范围。粗糙表面的模糊反射还需要按粗糙度选择颜色金字塔的 Mip 或做重要性采样。
28.5 TAA 与运动矢量
本章的许多技术都有一个共同的“好朋友”:时间累积。SSAO 的噪声、体积光的抖动、SSR 的稀疏采样,如果能把多帧的结果合在一起,每帧就可以少采样。时间抗锯齿(Temporal Anti-Aliasing,TAA)是这一思路最成熟的应用,URP 14 已经内置(相机的 Anti-aliasing 选项里的 Temporal Anti-aliasing)。
TAA 的四个步骤
- 亚像素抖动(Jitter):每一帧给投影矩阵加一个小于一个像素的偏移,让同一个像素在不同帧采样到像素内不同的位置。URP 14 用 Halton(2,3) 低差异序列生成偏移(
TemporalAA.cs的CalculateJitter)。静止画面下,多帧平均就相当于超采样。 - 重投影(Reprojection):相机或物体在动时,当前像素在上一帧位于别处。需要知道每个像素“上一帧在哪”——这就是运动矢量(Motion Vector):当前帧屏幕坐标减去上一帧屏幕坐标。
- 历史混合:按运动矢量从上一帧的累积结果(History)里取值,与当前帧按比例混合,例如 C = lerp(History, Current, α),α 越小越平滑,越大越“新鲜”。URP 的 Frame Influence 参数就是这个比例。
- 历史校正:遮挡关系变化(物体移开露出背后)时,历史里的颜色是错的,直接混合会产生拖影(Ghosting)。常用做法是取当前帧该像素 3×3 邻域的颜色范围(或均值±方差),把历史颜色钳制(Clamp/Clip)进去。URP 的 Variance Clamp Scale 控制这个范围的宽窄:越窄越不拖影,但越容易闪烁。
URP 14 中的运动矢量
- 运动矢量由
MotionVectorRenderPass生成,结果是全局纹理_MotionVectorTexture。相机运动部分用全屏 Pass(CameraMotionVectors.shader)由深度和前后两帧的 VP 矩阵算出;运动的物体则用 LightMode 为MotionVectors的 Pass 绘制。 - URP 14 的 Lit.shader 没有 MotionVectors Pass,物体运动矢量由 Pass 内部的回退材质
ObjectMotionVectors.shader统一绘制——这意味着在顶点着色器里做了顶点动画的物体(第 17 章),回退材质并不知道这些偏移,它的运动矢量只反映 Transform 的变化,TAA 下会出现拖影。需要正确运动矢量的自定义 Shader,要自己写一个"LightMode" = "MotionVectors"的 Pass,在其中同时计算当前帧和上一帧(unity_MatrixPreviousM)的位置。 - 自定义 Feature 想读运动矢量,就
ConfigureInput(ScriptableRenderPassInput.Motion),URP 会确保运动矢量 Pass 在它之前执行。 - URP 14 中 TAA 有明确的启用条件(
CameraData.IsTemporalAAEnabled):相机不能开 MSAA、不能使用相机栈(Overlay 或带 Stack 的 Base 相机都不行)、不能开动态分辨率、必须开启后处理。不满足任一条件,TAA 会静默失效,排查时先对照这几条。
InterleavedGradientNoise(positionCS.xy, frameIndex) 的第二个参数传入帧序号,或者每帧旋转采样核。噪声如果每帧都一样,时间累积就没有东西可以平均。反过来,不开 TAA 时噪声必须每帧固定,否则画面会闪烁。28.6 屏幕空间效果的通用性能清单
| 手段 | 说明 |
|---|---|
| 降分辨率 | AO、体积光、SSR 都是低频或可容忍模糊的信号,半分辨率甚至 1/4 分辨率计算,再深度感知上采样。 |
| 减少采样,用滤波补 | 少量采样 + 随机化(抖动、随机旋转)+ 空间双边模糊或时间累积,总是比大量采样更划算。 |
| 早退 | 天空像素、超出距离(Falloff)的像素、不需要效果的像素(SSR 只处理光滑表面)尽早 return。注意 GPU 以线程组为单位执行,早退只有在一整片像素都满足条件时才真正省时间。 |
| 只申请需要的输入 | ConfigureInput(Normal) 会在前向路径下多一个 DepthNormals 预通道,相当于把场景多画一遍;只需要深度时就别申请法线,或者像 URP SSAO 的 Depth 模式那样从深度重建法线。 |
| 合并 Pass | 雾和扫描线这类同一时机、同样输入的效果,可以合进一个全屏 Pass,省一次全屏读写(移动端带宽很贵,见第 29 章)。 |
| 循环用显式 LOD 采样 | 避免编译器被迫展开循环;动态循环在现代 GPU 上很便宜,展开后的超长代码反而影响占用率。 |
本章小结
- 屏幕空间技术以深度、法线、颜色纹理为输入,开销与像素数相关、与场景复杂度无关,但看不到屏幕外和被遮挡的信息。
- 重建世界坐标首选
ComputeWorldSpacePosition(uv, depth, UNITY_MATRIX_I_VP);只在当前像素重建时可用视锥射线法省掉每像素的矩阵乘法。OpenGL 系平台要先把深度映射到 [-1,1],天空像素单独处理。 - 雾统一用光学厚度建模,各种雾的 τ 相加后求 T = e−τ;高度雾有解析积分,注意水平视线处的数值稳定。
- SSAO 结果是可见度纹理;按 URP 约定绑定
_ScreenSpaceOcclusionTexture、设置_AmbientOcclusionParam、开启_SCREEN_SPACE_OCCLUSION,Lit 就会只压暗间接光;用完要关关键字。 - 主光阴影的关键字和常量在整台相机期间全局有效,全屏 Pass 声明
_MAIN_LIGHT_SHADOWS/_MAIN_LIGHT_SHADOWS_CASCADE即可步进采样;全屏 Shader 单独 includeRealtimeLights.hlsl时需补上CommonMaterial.hlsl。 - 步进类算法用随机起点把条带变成噪声,再用模糊或 TAA 去噪;循环里的采样必须用显式 LOD。
- URP 14 的 TAA 要求无 MSAA、无相机栈、无动态分辨率且开启后处理;Lit 没有 MotionVectors Pass,顶点动画物体需要自写。
练习
- 把案例 28.1 的 Pass 2(两种方法的差值 ×1000)挂上去,分别在近处和远处观察误差分布,并解释为什么远处误差更大。
- 给扫描线增加“只在 y < 2 米的表面上显示网格”的限制,并让扫描中心跟随一个移动的物体(用 C# 每帧设置
_ScanCenter)。 - 把高度雾的雾色改成随视线高度渐变:贴地方向用较深的雾色,接近水平方向用天空色,体会雾与天空的融合。
- 在 SimpleSSAO 中加入“半分辨率 + 深度感知上采样”:合成时在 2×2 个低分辨率样本里选深度最接近的那个,比较边缘处的效果。
- 让体积光的抖动每帧变化,并实现一个最简单的时间累积(上一帧结果与当前帧 lerp 0.1,相机静止时),观察噪声如何随时间消失。
- 为 SimpleSSR 增加“命中失败时回退到反射探针”的逻辑(提示:在全屏 Pass 里用
GlossyEnvironmentReflection采样默认反射探针)。