GameTech/URP 渲染实战

案例 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)

调参与性能提示: