GameTech/URP 渲染实战
CHAPTER 28第 28 章

屏幕空间技术

一张深度图加一张法线图,就能在后处理里“看见”整个场景的几何。本章把屏幕空间技术的公共骨架拆开讲透,再依次手写扫描波、高度雾、SSAO、体积光和 SSR,最后讲清 TAA 与运动矢量的来龙去脉。

本章你会学到
用逆 VP 矩阵和视锥射线两种方法从深度重建世界坐标
写一个通用的 URP 14 全屏效果 Feature(RTHandle + Blitter)
距离雾与高度雾的光学厚度推导和解析积分
URP 自带 SSAO 的参数含义,以及一个能接入 Lit 光照的简化 SSAO
在全屏 Pass 里采样主光阴影图,做光线步进体积光
屏幕空间反射的步进、细化、淡出,以及它的天然局限
TAA 的抖动、重投影、历史混合与 URP 14 的运动矢量
屏幕空间算法的性能开销都花在哪,怎么省

28.1 屏幕空间技术的共同骨架

所谓“屏幕空间技术”,指的是这样一类算法:它们不再一个物体一个物体地画,而是在不透明物体画完之后,拿着几张“屏幕大小的几何信息图”,对每个像素做一次计算。这几张图通常是:

  • 深度纹理(_CameraDepthTexture):每个像素离相机多远。有了它就能把像素还原回三维空间里的点,这是本章一切技术的起点。
  • 法线纹理(_CameraNormalsTexture):URP 14 里存的是世界空间法线,由 DepthNormals 预通道或延迟渲染的 G-Buffer 提供。SSAO、SSR 这类需要“表面朝向”的算法离不开它。
  • 不透明颜色(_CameraOpaqueTexture 或当前相机颜色的拷贝):SSR 要从里面取反射色,扭曲类效果要从里面取背景。
  • 运动矢量(_MotionVectorTexture):每个像素在上一帧的位置,TAA、运动模糊要用。

屏幕空间技术的优点很直接:开销只和像素数量有关,和场景复杂度无关。一万个物体的场景和十个物体的场景,做一次全屏 SSAO 的代价几乎一样。它的缺点同样直接:屏幕外、被遮挡的东西它一无所知。SSAO 在屏幕边缘变淡、SSR 反射不到屏幕外的物体、体积光被前景挡住的部分算不出来,都是这个根本限制的表现。理解了这一点,你就能提前预判每种屏幕空间效果会在哪里“露馅”。

深度/法线预通道 不透明物体 天空盒 _CameraDepthTexture _CameraNormalsTexture 相机颜色(拷贝) 全屏 Pass(每像素) 深度 → 世界坐标 → 算法(雾/AO/步进…) 写回相机颜色
图 28-1屏幕空间技术的数据流:先由预通道或 G-Buffer 得到深度/法线,再在全屏 Pass 中逐像素重建几何并计算效果

一个通用的全屏效果 Feature

第 25、26 章已经详细讲过 Renderer Feature 的生命周期和 RTHandle,本章只关心算法,所以先写一个“万能”的全屏 Feature:它负责把相机颜色拷贝到一张临时 RT,再用我们给的材质画回去。之后的世界坐标重建、扫描线、雾和 SSR 全都复用它,每个案例只需要写 Shader。

Assets/Cookbook/Ch28/ScreenEffectFeature.cs
// 第 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 值,再映射到深度缓冲的取值范围。它有三个让人头疼的特点:

  1. 非线性。透视投影下 zndc 与观察空间深度成倒数关系,近处精度高、远处精度低。直接把深度值当距离用,远处的东西几乎全挤在一起。
  2. 平台相关的方向。D3D11/12、Metal、Vulkan、主机平台使用 Reversed-Z(近处 1、远处 0,宏 UNITY_REVERSED_Z 为 1),OpenGL ES 则是近处 0、远处 1,且它的裁剪空间 z 范围是 [-1, 1]。
  3. 天空没有深度。天空盒不写深度,那里的值是清屏值(Reversed-Z 下为 0),任何“由深度算位置”的算法都要单独处理天空像素。

把这个非线性的值还原成世界坐标,有两条路。

方法一:逆 VP 矩阵

投影的正向过程是:世界坐标 PWS 乘上 VP 矩阵得到裁剪坐标,再除以 w 得到 NDC。反过来,只要知道某个像素的 NDC 坐标(屏幕 UV 映射到 [-1,1],再加上深度),乘上 VP 的逆矩阵,最后再做一次齐次除法,就回到了世界空间:

Pclip=(2u−1,2v−1,zdevice,1),H=(VP)−1Pclip,PWS=HxyzHw

为什么这里可以把 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],需要先把深度映射回去。完整的公共函数如下:

Assets/Cookbook/Ch28/ScreenSpaceCommon.hlsl(节选:rawdepth)
// 读“设备深度”,并统一成 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
}
Assets/Cookbook/Ch28/ScreenSpaceCommon.hlsl(节选:invvp)
// 方法一:逆 VP 矩阵。uv + 深度 → NDC → 乘逆矩阵 → 齐次除法
float3 ReconstructPositionWS(float2 uv, float deviceDepth)
{
    return ComputeWorldSpacePosition(uv, deviceDepth, UNITY_MATRIX_I_VP);
}
// 方法一:逆 VP 矩阵。uv + 深度 → NDC → 乘逆矩阵 → 齐次除法
vec3 ReconstructPositionWS(vec2 uv, float deviceDepth)
{
    return ComputeWorldSpacePosition(uv, deviceDepth, UNITY_MATRIX_I_VP);
}
屏幕 UV+ 设备深度 裁剪坐标(2uv−1, z, 1) 齐次坐标 H(VP)⁻¹ · P 世界坐标H.xyz / H.w GL: 深度先映射到 [-1,1] D3D/Metal: y 取反 每像素 1 次矩阵乘 + 1 次除法
图 28-2逆 VP 矩阵法:屏幕 UV 与深度组成裁剪坐标,乘逆矩阵后做齐次除法

方法二:视锥射线插值

逆矩阵法简单可靠,但每个像素都要做一次 4×4 矩阵乘法加一次除法。在 Built-in 管线时代流行另一种更省的做法:视锥射线(Frustum Ray / View Ray)。它基于一个几何事实——相机位置 C、像素对应的场景点 P、以及同一条视线与远裁剪面的交点 F,三点共线,并且按观察空间深度成比例:

P=C+(F−C)·zeyezfar=C+r→·Linear01Depth

关键在于:远裁剪面是一个平面,它上面的点 F 关于屏幕坐标是仿射(线性)的。所以只需要在顶点着色器里给全屏三角形的 3 个顶点算出射线 r = F − C,光栅化器线性插值后,每个像素拿到的就是它自己那条射线,误差为零。片元着色器里只剩一次乘加。

远裁剪面 近裁剪面 相机 C F P z_eye z_far r = F − C(顶点里算,插值)
图 28-3视锥射线法:P 在 C→F 的连线上,比例恰好是 zeye/zfar,即 Linear01Depth
Assets/Cookbook/Ch28/ScreenSpaceCommon.hlsl(节选:viewray)
// 方法二:视锥射线。顶点着色器里算“相机 → 远裁剪面”的向量,光栅化线性插值后,
// 片元里乘上 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可视化重建出的世界坐标入门单独打开 ↗

目标:用两种方法分别重建世界坐标并画出来,肉眼验证它们是对的——这是写任何屏幕空间效果前都应该先做的一步。

Assets/Cookbook/Ch28/ReconstructWorldPos.shader(节选:frag0)
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);
}
Assets/Cookbook/Ch28/ReconstructWorldPos.shader(节选:frag1)
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 换算成固定的像素宽度,远近粗细一致。

图 28-4左:原始画面;中:逆 VP 矩阵法重建的 frac(positionWS),地面色块与 1 米棋盘格完全对齐;右:视锥射线法重建并叠加 1 米世界网格,网格牢牢贴在球、圆柱和墙面上
坑第一版这里输出的是 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 得到的是一个竖直的圆柱面,扫过高墙时是一条竖直亮线,视觉上更像“贴地扩散”。两种都对,看设计需要。

Assets/Cookbook/Ch28/RadarScan.shader(节选:frag)
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 里同时保留两种驱动方式,用一个开关切换,是动画类效果的良好习惯。

图 28-5扫描波在三个时刻(_Progress = 0.12 / 0.26 / 0.42):亮边扫过物体时沿表面勾出轮廓,尾迹中的世界网格随地面透视正确收缩;右图波前已到达后墙,在墙面上形成竖直亮线

扩展方向:

  • 多个扫描中心:把中心和半径做成数组,循环取最大值。
  • 只高亮特定物体:效果本身不知道“哪个像素属于敌人”,可以先用 RenderObjects 把敌人画进一张遮罩 RT(第 25 章),在扫描 Shader 里采样遮罩,给敌人换一种颜色。
  • 透明物体:全屏效果在透明物体之前执行时,透明物体不会被扫描;放在之后执行时,透明物体后面的场景会被扫描、它自己却不会(深度里没有它)。这是所有基于深度的屏幕空间效果的共性。

案例 28.3距离雾与高度雾进阶单独打开 ↗

目标:写一个屏幕空间的雾:随距离变浓的距离雾、贴着地面堆积的高度雾,以及迎着太阳方向变亮变暖的“内散射”色调。

从透射率说起

光在均匀介质中穿过距离 d,被吸收和散射掉的比例由比尔-朗伯定律(Beer–Lambert Law)描述。设介质的消光系数为 σ(单位:1/米),透射率是

T=e−τ,τ=∫0dσ(t)dt

τ 叫光学厚度(Optical Depth)。雾的合成公式就是“场景颜色乘透射率,再加上雾色乘 (1 − 透射率)”:

Cfinal=Cscene·T+Cfog·(1−T)

这个框架的好处是:不同来源的雾可以把光学厚度相加,因为 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。光学厚度可以积出解析式:

τ=∫0Lae−b(cy+tvy−h0)dt=ae−b(cy−h0)⏟相机处密度·L·1−e−kk,k=bvyL

(1 − e−k)/k 这一项在视线水平(vy → 0,k → 0)时是 0/0 的形式,极限是 1;直接算会除零或者严重丢精度。代码里在 |k| 很小时改用泰勒展开 1 − k/2,这是写解析积分时必须处理的数值细节。

h₀(基准高度) 相机 向下看:穿过浓雾,τ 大 向上看:越走越稀,τ 小 σ(y)=a·e^(−b(y−h₀))
图 28-6高度雾:密度随高度指数衰减,同样长度的视线,向下看比向上看的光学厚度大得多
Assets/Cookbook/Ch28/HeightFog.shader(节选:heightfog)
// 高度雾的光学厚度:密度 σ(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;
}
Assets/Cookbook/Ch28/HeightFog.shader(节选:frag)
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),与谁在画无关。

图 28-7左:只有距离雾,远处柱子均匀地褪成雾色;中:只有高度雾,地面和低处的方块被吞没,远处高塔的上半截露出雾面;右:两者光学厚度相加,并按太阳方向混入暖色的内散射
坑:Shadows.hlsl 的隐藏依赖这个 Shader 里有一行看似多余的 #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 的可见度是法线半球上可见性函数的余弦加权积分:

A(p)=1π∫ΩV(p,ω)(n·ω)dω

其中 V 在方向 ω 一定距离内被遮挡时为 0,否则为 1;1/π 是余弦加权半球积分的归一化系数(∫Ω cosθ dω = π)。离线渲染会真的发射射线去求这个积分;屏幕空间 AO(SSAO)则用深度缓冲当作“场景的近似”:在半球里撒一些采样点,把它们投影回屏幕,看深度缓冲里那个位置的表面是不是在采样点的前面——如果是,说明这个采样点被埋在几何体里了,记一次遮挡。

n p ● 采样点在表面前方:未遮挡 ● 采样点在深度之后:遮挡 墙(深度缓冲里的表面)
图 28-8法线半球采样:采样点投影回屏幕后,若深度缓冲中的表面比它离相机更近,就认为它被几何体埋住了

这只是一个近似:深度缓冲是一张“只有正面、没有厚度”的高度场,它不知道墙有多厚,也不知道被前景挡住的后面是什么。所以 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 源码):

参数默认含义与调参建议
MethodBlue Noise采样点的随机化方式。Blue Noise 用 8 张 256² 蓝噪声贴图轮换,噪点更均匀;Interleaved Gradient 用解析噪声函数,不读贴图。两者都依赖后面的模糊来去噪。
Intensity3遮蔽强度系数。Shader 里与采样平均值相乘后再做 0.6 次幂(kContrast),所以不是线性的“变暗多少”。
Radius0.035采样半径(世界单位,米)。默认值非常小,只会在接缝处产生细线;场景尺度大时要加大,但半径越大越容易出现暗晕,也越依赖模糊。
Falloff Distance100超过这个观察深度的像素直接跳过 AO,并在此之前按 (1 − 深度/Falloff)² 淡出。开阔场景里把它调小能省不少开销。
Direct Lighting Strength0.25AO 对直接光的影响比例。物理上 AO 只该作用于间接光(直接光有阴影负责),给一点点直接光遮蔽是艺术上的折中。
SourceDepth NormalsDepth:只用深度,法线由相邻深度差重建(Normal Quality 控制重建采样数);Depth Normals:读 DepthNormals 预通道的法线,更准但多一个预通道。延迟渲染下强制为 Depth Normals(G-Buffer 里有法线)。
Downsample关半分辨率计算 AO。移动端几乎必开,AO 是低频信号,降分辨率损失很小。
After Opaque关关:AO 在不透明物体之前算好,交给 Lit 的光照只压暗间接光;开:不透明物体画完后直接把 AO 乘到颜色上,更省(不需要在光照里采样),但直接光也被压暗,看起来更“脏”。
Blur QualityHighHigh = 双边模糊(保边)、Medium = 高斯、Low = Kawase。
SamplesMedium每像素采样数: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-9URP 自带 SSAO。左:关闭;中:默认参数(Radius 0.035、Intensity 3),只在墙角接缝处出现细线;右:Radius 0.15、Intensity 2,接触阴影明显,但方块和圆环在墙面上留下了黑色暗晕——这正是深度缓冲“没有厚度”带来的典型瑕疵

案例 28.4简化版 SSAO,并接入 URP 光照高阶单独打开 ↗

目标:自己实现一个法线半球采样的 SSAO:随机旋转 + 双边模糊,并按 URP 的约定把结果交给 Lit,只压暗间接光。

算法步骤:

  1. 由深度重建当前像素的世界坐标 p,从法线纹理读世界空间法线 n。
  2. 用 Interleaved Gradient Noise 给每个像素一个随机角度,构造绕 n 随机旋转的切线空间。随机旋转把“规则采样图案的条带”变成高频噪声,后者可以被模糊掉,前者不行。
  3. 在半球里取 N 个方向(余弦分布,黄金角螺旋排列),长度向中心聚集——近处的遮挡对 AO 更重要。
  4. 每个采样点投影回屏幕,比较“采样点自己的观察深度”和“屏幕上那里的场景深度”。场景表面更近 → 采样点被埋住 → 记一次遮挡。
  5. 范围检查:如果场景深度和中心点相差远超半径(例如背景前的前景物体边缘),这次遮挡不算,避免暗晕。
  6. 结果做两遍可分离的双边模糊(横、竖),权重同时考虑空间距离和深度差,AO 不会跨越物体边缘渗出去。
Assets/Cookbook/Ch28/SimpleSSAO.shader(节选: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 取负。

Assets/Cookbook/Ch28/SimpleSSAO.shader(节选:blur)
// 双边模糊:高斯权重 × 深度相似度权重,跨越深度断层的样本被压低,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);
}
坑:循环里的纹理采样第一次编译时 Metal 报了 unable to unroll loop … use the [unroll(n)] attribute。原因是循环次数由材质参数决定(动态循环),而循环体里调用了 SampleSceneDepth——它用的是 SAMPLE_TEXTURE2D_X,需要隐式的屏幕空间导数来选 Mip,而导数在分支/动态循环里没有定义,编译器只好尝试把循环完全展开,展开失败就报错。解决办法是在循环里一律使用显式 LOD 的采样:本章公共文件里提供了 SampleSceneDepthLod,模糊里也改用 SAMPLE_TEXTURE2D_X_LOD。这条规则对所有“步进类”屏幕空间算法(SSR、体积光、视差)都适用。

C# 部分的关键是事件时机和交接方式:

Assets/Cookbook/Ch28/SimpleSSAOFeature.cs(节选:event)
// 进光照:必须在不透明物体之前,且要等深度/法线预通道画完(+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);
// 进光照:必须在不透明物体之前,且要等深度/法线预通道画完(+1 与 URP 自带 SSAO 一致)
const event = s.mode === Mode.IntoLighting ? RenderPassEvent.AfterRenderingPrePasses + 1 : RenderPassEvent.AfterRenderingOpaques;
Assets/Cookbook/Ch28/SimpleSSAOFeature.cs(节选:execute)
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 图。

图 28-10自写 SSAO。左:未模糊的原始 AO,随机旋转产生了均匀的高频噪声;中:双边模糊后,噪声消失而墙角、球底的边缘依然清晰;右:通过 _ScreenSpaceOcclusionTexture 接入 URP Lit,只压暗了间接光(Direct Lighting Strength = 0.4)

调参与性能提示:

  • 半径决定 AO 的“尺度”。室内道具 0.3–0.6 米比较合适;它和场景单位直接相关,不要照搬别人的数字。
  • Bias 太小会出现自遮挡的灰色噪点(尤其是倾斜的大平面),太大会让细小的接缝消失。
  • 采样数是开销的主要来源:每个采样点一次深度读取。全分辨率 16 个采样在移动端通常承受不起,可以 8 个采样 + 半分辨率 + 更强的模糊。如果项目已经有 TAA,还可以每帧换一组随机旋转,让时间累积来降噪。
  • 半分辨率时,合成阶段最好做深度感知上采样(取周围几个低分辨率样本中深度最接近的),否则物体边缘会出现一像素宽的 AO 漏光。

28.4 体积光:在全屏 Pass 里步进主光阴影

雾让远处变淡,但它是“均匀”的。当阳光穿过窗格、树叶或柱廊时,空气中被照亮和处在阴影里的部分明暗相间,形成一道道光束——体积光(Volumetric Light,也常叫 God Rays、光柱)。要算出它,必须知道视线上每一段空气是否被主光照到,而“是否被主光照到”正是阴影图回答的问题。

辐射传输的简化

沿视线从相机出发,对每一小段介质 dt:它被主光照亮的程度是可见性 V(t)(查阴影图);它把光散射到相机方向的比例由相位函数 p(θ) 决定(θ 是光线方向与视线的夹角);散射出的光走回相机途中还要乘上透射率 T(t)。把这些累加起来:

Lscatter=∫0Dσsp(θ)LsunV(t)e−σttdt

对方向光,θ 在整条视线上不变,相位函数可以提到积分外。我们用 Henyey-Greenstein 相位函数,参数 g ∈ (−1, 1) 控制散射的方向性,g > 0 表示以前向散射为主(迎着光看最亮,这正是空气中尘埃、水汽的特征):

pHG(θ)=14π1−g2(1+g2−2gcosθ)3/2

积分用光线步进(Ray Marching)离散求和:把视线切成 N 段,每段查一次阴影图。如果介质均匀,每段的透射率是常数 e−σΔt,每段贡献的散射量正比于 (1 − e−σΔt),这样即使步长很大,能量也是守恒的——比直接写 σΔt 更稳。

遮挡物(窗格) 主光方向 相机 每一步:世界坐标 → 阴影坐标 → 比较阴影图 ● 被照亮(V=1) ○ 在阴影中(V=0)
图 28-11体积光的光线步进:沿视线等距取点,逐点查询主光阴影图,被照亮的点贡献散射光

全屏 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 会落到“无阴影”变体,体积光变成均匀的雾——需要知道这一点。

Assets/Cookbook/Ch28/VolumetricLight.shader(节选:keywords)
// 主光阴影的关键字由 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"
// 主光阴影的关键字(_MAIN_LIGHT_SHADOWS 等)网页版始终开启,不用声明。
// 不需要 _MAIN_LIGHT_SHADOWS_SCREEN:屏幕空间阴影只有“表面”上的结果,体积里的点用不上。

软阴影关键字(_SHADOWS_SOFT 系列)这里也没有声明:步进本身就会把阴影边缘“平均”掉,再用软阴影的多次采样纯属浪费,硬件 2×2 比较采样(SAMPLE_TEXTURE2D_SHADOW)就足够了。

案例 28.5体积光:步进、抖动、模糊、合成高阶单独打开 ↗

Assets/Cookbook/Ch28/VolumetricLight.shader(节选:march)
// 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 是视线方向的总透射率。合成时场景颜色乘透射率,再加上散射光——体积光同时也是一层雾。
Assets/Cookbook/Ch28/VolumetricLight.shader(节选:composite)
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;
}
void main()
{
    vec4 color = SamplePointClamp(_BlitTexture, texcoord);
    vec2 vol = texture(_VolumetricResult, texcoord).rg;
    vec3 lightColor = GetMainLight().color * _Tint.rgb;
    // 场景被介质衰减(×透射率),再加上介质散射进视线的光
    color.rgb = color.rgb * vol.g + lightColor * vol.r;
    fragColor = color;
}
Assets/Cookbook/Ch28/VolumetricLightFeature.cs(节选:execute)
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:主光不投影或阴影被关掉时,这个效果没有意义,直接不入队。

图 28-12左:没有体积光,阳光从柱廊后方低角度照来,只在地面上留下条状阴影;中:只显示散射项(未模糊),可以看到抖动起点带来的细密噪点,以及柱子在空气中投下的一道道暗带;右:模糊后合成,光束从柱间射向镜头

性能与质量提示:

  • 开销 ≈ 像素数 × 步数 × 一次阴影采样。半分辨率 + 16~32 步是常见的起点;1/4 分辨率配合更强的模糊在移动端也常用。
  • 上采样时同样要注意深度边缘:低分辨率的散射值在前景物体边缘会“溢”到背景上,表现为物体轮廓外一圈亮边。可以用深度感知上采样或在模糊里加深度权重(参考案例 28.4 的双边模糊)。
  • 有 TAA 时,可以每帧换一组抖动偏移(把 IGN 的 frameCount 参数传入帧序号),让时间累积替代空间模糊,画面更干净。
  • 点光源、聚光灯的体积光原理相同,但要查附加光阴影图(AdditionalLightRealtimeShadow),且相位角每一步都在变;工程上更常见的做法是为它们放置体积网格或用 Froxel(视锥体素)方案,超出本章范围。

案例 28.6屏幕空间反射(SSR)原理与最简实现高阶单独打开 ↗

目标:让光滑地面映出场景里的物体。URP 14 没有内置 SSR,平面反射(第 19 章)又要多渲染一遍场景;SSR 只用已有的深度和颜色,就能得到动态的、跟随场景变化的反射。

原理:对每个“需要反射”的像素,由深度和法线算出反射方向 r = reflect(v, n),然后沿 r 在三维空间里一步步前进,每一步把光线上的点投影回屏幕,比较它和深度缓冲:光线点比深度缓冲里的表面更远,说明光线“钻到了表面后面”,交点就在这一步和上一步之间。在交点处取屏幕颜色,就是反射色。

光滑地面 物体 相机 视线 v 反射射线 r:等距步进 穿过表面 → 二分细化 → 取颜色 n
图 28-13屏幕空间反射:沿反射方向步进,每一步投影回屏幕与深度比较,穿过表面后用二分法逼近交点
Assets/Cookbook/Ch28/SimpleSSR.shader(节选:trace)
// 光线上一点 → (屏幕 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;
}
Assets/Cookbook/Ch28/SimpleSSR.shader(节选:frag)
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 近似随掠射角增强,正对地面看几乎没有反射,贴着地面看反射最强,与真实世界一致。
图 28-14左:普通的光滑深色地面,只有环境反射;右:加上 SSR,球、方块、圆柱和纽结在地面上留下倒影,倒影随离开物体的距离和屏幕边缘逐渐淡出

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 的四个步骤

  1. 亚像素抖动(Jitter):每一帧给投影矩阵加一个小于一个像素的偏移,让同一个像素在不同帧采样到像素内不同的位置。URP 14 用 Halton(2,3) 低差异序列生成偏移(TemporalAA.cs 的 CalculateJitter)。静止画面下,多帧平均就相当于超采样。
  2. 重投影(Reprojection):相机或物体在动时,当前像素在上一帧位于别处。需要知道每个像素“上一帧在哪”——这就是运动矢量(Motion Vector):当前帧屏幕坐标减去上一帧屏幕坐标。
  3. 历史混合:按运动矢量从上一帧的累积结果(History)里取值,与当前帧按比例混合,例如 C = lerp(History, Current, α),α 越小越平滑,越大越“新鲜”。URP 的 Frame Influence 参数就是这个比例。
  4. 历史校正:遮挡关系变化(物体移开露出背后)时,历史里的颜色是错的,直接混合会产生拖影(Ghosting)。常用做法是取当前帧该像素 3×3 邻域的颜色范围(或均值±方差),把历史颜色钳制(Clamp/Clip)进去。URP 的 Variance Clamp Scale 控制这个范围的宽窄:越窄越不拖影,但越容易闪烁。
① 每帧亚像素抖动(Halton 2,3) 上一帧位置 当前帧 ② 运动矢量:去哪找历史 History(钳制后) Current(3×3 范围) lerp(History, Current, α) ③④ 混合与校正
图 28-15TAA:抖动采样位置、用运动矢量找到上一帧对应位置、钳制历史颜色后与当前帧混合

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 会静默失效,排查时先对照这几条。
提示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 单独 include RealtimeLights.hlsl 时需补上 CommonMaterial.hlsl。
  • 步进类算法用随机起点把条带变成噪声,再用模糊或 TAA 去噪;循环里的采样必须用显式 LOD。
  • URP 14 的 TAA 要求无 MSAA、无相机栈、无动态分辨率且开启后处理;Lit 没有 MotionVectors Pass,顶点动画物体需要自写。

练习

  1. 把案例 28.1 的 Pass 2(两种方法的差值 ×1000)挂上去,分别在近处和远处观察误差分布,并解释为什么远处误差更大。
  2. 给扫描线增加“只在 y < 2 米的表面上显示网格”的限制,并让扫描中心跟随一个移动的物体(用 C# 每帧设置 _ScanCenter)。
  3. 把高度雾的雾色改成随视线高度渐变:贴地方向用较深的雾色,接近水平方向用天空色,体会雾与天空的融合。
  4. 在 SimpleSSAO 中加入“半分辨率 + 深度感知上采样”:合成时在 2×2 个低分辨率样本里选深度最接近的那个,比较边缘处的效果。
  5. 让体积光的抖动每帧变化,并实现一个最简单的时间累积(上一帧结果与当前帧 lerp 0.1,相机静止时),观察噪声如何随时间消失。
  6. 为 SimpleSSR 增加“命中失败时回退到反射探针”的逻辑(提示:在全屏 Pass 里用 GlossyEnvironmentReflection 采样默认反射探针)。