文章

屏幕空间反射(SSR)完整实现

屏幕空间反射(SSR)完整实现

从基础 raymarch 到 HiZ 层次化追踪的完整演进路径。


第一章 屏幕空间反射(SSR)基础实现

1.1 什么是屏幕空间反射

​ ​ 屏幕空间反射(Screen Space Reflection,简称 SSR)是一种基于屏幕空间数据的实时反射技术。与传统反射探针(Reflection Probe)或平面反射(Planar Reflection)不同,SSR 直接利用当前帧已经渲染好的深度贴图颜色贴图来计算反射,不需要额外的渲染 Pass。

基本原理

​ ​ SSR 的核心思路可以拆成三步:

  1. 重建:对屏幕上每一个需要反射的像素,根据它的深度值重建出它在观察空间(View Space)中的三维位置和法线。
  2. 反射:根据观察方向和法线计算出反射光线方向。
  3. 步进:沿着反射光线在观察空间里一步步前进(Ray March),每前进一步就把当前位置投影回屏幕,检查它是否和场景几何体相交。相交了就把那个位置的颜色当作反射颜色采样出来。

​ ​ 因为所有信息都来自当前屏幕,SSR 有一个先天限制:屏幕外的东西反射不到。比如站在镜子前,你身后有个物体,但它在屏幕外,SSR 就无法反射它。

​ ​ 本章我们先实现一个能跑的最小版本,重点讲清楚每一步的原理和 Unity URP 里那些容易踩的坑。


1.2 搭建 URP Renderer Feature

​ ​ SSR 作为一个全屏后处理效果,在 URP 里通过 ScriptableRendererFeature 接入。整体结构是 Feature(外壳)+ Pass(执行)。

Feature:参数容器与 Pass 注册

​ ​ ScreenSpaceReflectionFeature.cs 负责把参数暴露给 Inspector,并创建、注册实际的渲染 Pass:

public class ScreenSpaceReflectionFeature : ScriptableRendererFeature
{
    [System.Serializable]
    public class SSRSettings
    {
        [Header("Ray March")]
        [Tooltip("每次步进在观察空间前进的距离")]
        [Range(0.01f, 1.0f)]
        public float stepSize = 0.1f;

        [Tooltip("最大步进次数")]
        [Range(1, 1024)]
        public int maxSteps = 32;

        [Tooltip("射线最大行进距离")]
        [Range(0.1f, 1024.0f)]
        public float maxDistance = 10.0f;

        [Tooltip("几何体厚度,用于命中检测的容差")]
        [Range(0.001f, 1.0f)]
        public float thickness = 0.1f;
    }

    [SerializeField] private SSRSettings settings = new SSRSettings();
    [SerializeField] private Shader ssrShader;

    private Material ssrMaterial;
    private ScreenSpaceReflectionPass ssrPass;

    public override void Create()
    {
        ssrPass = new ScreenSpaceReflectionPass(settings);
        // 在后处理之前执行,这样能拿到不透明 pass 的颜色和深度
        ssrPass.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (ssrShader == null) return;
        if (ssrMaterial == null)
            ssrMaterial = CoreUtils.CreateEngineMaterial(ssrShader);
        if (ssrMaterial == null) return;

        renderer.EnqueuePass(ssrPass);
    }
}

​ ​ 四个参数的含义:

参数 含义 调参建议
stepSize 每步在观察空间前进的距离(单位:米) 越小越精细但越慢,需要和 thickness 配合
maxSteps 最大步进次数 决定最远能反射多远,乘以 stepSize 即最远反射距离
maxDistance 射线最大行进距离(硬截断) 性能保险,防止射线无限前进
thickness 命中检测的厚度容差 太小会"跨过"薄物体导致漏检,太大会产生假反射

​ ​ stepSizethickness 的比例很关键。如果每步前进的距离(z 方向)大于 thickness,射线可能"一步跨过"命中窗口,永远检测不到相交。实践中 thickness 通常要大于等于单步在 z 方向的位移。

Pass:实际的渲染逻辑

​ ​ Pass 负责:申请临时 RT、把参数传给 Shader、执行 Blit。

internal class ScreenSpaceReflectionPass : ScriptableRenderPass
{
    private RTHandle sourceHandle;
    private RTHandle tempTarget;

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        var desc = renderingData.cameraData.cameraTargetDescriptor;
        desc.depthBufferBits = 0;              // 临时 RT 不需要深度
        desc.msaaSamples = 1;
        desc.graphicsFormat = GraphicsFormat.R16G16B16A16_SFloat;  // HDR 精度

        RenderingUtils.ReAllocateIfNeeded(ref tempTarget, desc,
            FilterMode.Point, TextureWrapMode.Clamp, name: "_SSRTempTarget");
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        if (material == null) return;

        CommandBuffer cmd = CommandBufferPool.Get("SSR Pass");
        using (new ProfilingScope(cmd, profilingSampler))
        {
            Camera cam = renderingData.cameraData.camera;
            // 把相机参数传给 Shader,用于重建观察空间位置
            Vector4 uvToView = GIUtility.ComputeUVToViewPos(cam);
            material.SetVector(UVToViewPosID, uvToView);

            material.SetFloat(StepSizeID, settings.stepSize);
            material.SetInt(MaxStepsID, settings.maxSteps);
            // ...

            // 两次 Blit:源 → 临时 RT(执行 SSR)→ 源
            Blitter.BlitCameraTexture(cmd, sourceHandle, tempTarget, material, 0);
            Blitter.BlitCameraTexture(cmd, tempTarget, sourceHandle);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }
}

​ ​ 注意几个细节:

  • 临时 RT 用 HDR 格式R16G16B16A16_SFloat),避免反射颜色被 LDR 截断。
  • 声明深度和颜色输入依赖:在 Setup 里调用 ConfigureInput(ScriptableRenderPassInput.Depth | ScriptableRenderPassInput.Color),URP 才能保证在 SSR 执行时深度图和不透明颜色图已就绪。
  • 两次 Blit:第一次带 Material 执行 SSR 着色器(源 → 临时 RT),第二次只是拷贝(临时 RT → 源)。不能原地 Blit,否则会有读写冲突。

1.3 重建观察空间位置

​ ​ 这是 SSR 的第一步,也是最容易出错的一步。

数学公式

​ ​ 给定屏幕 UV 和深度,重建观察空间位置:

viewPos.xy = (uv * 2 - 1) * tan(HalfFOV) * linearEyeDepth
viewPos.z  = linearEyeDepth

​ ​ 其中 linearEyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams),是把非线性的深度缓冲值还原成线性的"距相机距离"。

​ ​ 我们把 (uv * 2 - 1) * tan(HalfFOV) 这部分在 CPU 端预计算成一个 Vector4,避免 Shader 里重复算:

public static class GIUtility
{
    public static Vector4 ComputeUVToViewPos(Camera renderCamera)
    {
        float tanHalfFovY = Mathf.Tan(renderCamera.fieldOfView * Mathf.Deg2Rad * 0.5f);
        float tanHalfFovX = tanHalfFovY * renderCamera.aspect;

        return new Vector4(2 * tanHalfFovX, 2 * tanHalfFovY, -tanHalfFovX, -tanHalfFovY);
    }
}

​ ​ 这个 Vector4 的语义是 (2·tanX, 2·tanY, -tanX, -tanY),在 Shader 里这样用:

// _UVToViewPos = (2·tanX, 2·tanY, -tanX, -tanY)
float3 ComputeViewSpacePosition(float2 uv, float depth)
{
    float linearEyeDepth = LinearEyeDepth(depth, _ZBufferParams);
    return float3((uv * _UVToViewPos.xy + _UVToViewPos.zw) * linearEyeDepth, linearEyeDepth);
}

​ ​ 展开就是 ((uv·2·tanX - tanX)·depth, (uv·2·tanY - tanY)·depth, depth),等价于上面那个公式。

一个必须注意坑:UV 的 Y 方向

​ ​ 这里的 uv 来自 URP Blit 顶点的 texcoord。在 Windows/D3D 平台上,UNITY_UV_STARTS_AT_TOP 是定义的,这意味着 texcoord.y = 0 在屏幕顶部,texcoord.y = 1 在屏幕底部

​ ​ 但上面的重建公式假设 uv.y = 0 在底部(OpenGL 约定)。两者不一致,会导致重建出来的 viewPos.y 符号翻转——屏幕顶部的像素被当成下方,底部的被当成上方。

​ ​ 这会让后续所有依赖 y 方向的计算(法线、反射射线方向)全部反过来。修复方法是在重建前翻转 uv.y

float3 ComputeViewSpacePosition(float2 uv, float depth)
{
    float linearEyeDepth = LinearEyeDepth(depth, _ZBufferParams);
#if defined(UNITY_UV_STARTS_AT_TOP)
    // Blit 的 texcoord.y=0 在屏幕顶部,但重建公式假设底部原点,需要翻转
    uv.y = 1.0 - uv.y;
#endif
    return float3((uv * _UVToViewPos.xy + _UVToViewPos.zw) * linearEyeDepth, linearEyeDepth);
}

​ ​ 这一类"Y 方向"问题在 Unity 屏幕空间特效里非常常见。判断依据是 UNITY_UV_STARTS_AT_TOP 宏,在 D3D 系平台(Windows、Xbox)它为真,在 OpenGL 系平台为假。用宏包起来就能跨平台。


1.4 计算法线和反射方向

从位置导数算法线

​ ​ 有了 viewPos,可以用屏幕空间导数(ddx/ddy)算出法线:

float3 GetNormalFromPosition(float3 position)
{
    return normalize(cross(ddy(position), ddx(position)));
}

​ ​ ddx(p)p 沿屏幕 x 方向的变化率,ddy(p) 是沿屏幕 y 方向的变化率。这两个向量都贴着表面,叉乘出来就是法线。

​ ​ 关于 cross(ddy, ddx) 的顺序:在 D3D 约定下这会得到一个朝下的法线(对地板来说),而 cross(ddx, ddy) 才朝上。但因为我们只用法线做 reflect(),而 reflect(I, N) 对 N 的符号免疫(reflect(I, N) == reflect(I, -N)),所以这里顺序不影响结果。如果你之后要把法线用于光照或背面剔除,记得用 cross(ddx, ddy) 让法线朝外。

反射方向

float3 viewDir = normalize(viewPos);   // 从相机(原点)指向当前像素
float3 rayDir = reflect(viewDir, normal);

​ ​ viewDir 是入射方向(相机→表面),reflect(I, N) 给出反射后的射线方向。

​ ​ 接着做一个早期剔除:如果反射射线朝相机方向走(在正 Z 约定下 rayDir.z < 0),说明这个表面背对相机,反射不到任何场景几何体,直接跳过:

// 正 Z 约定:场景在 z>0,相机在原点。射线 z<0 表示朝相机走,没有可反射的东西
if (rayDir.z < 0.0) return originalColor;

1.5 Ray March:核心循环

​ ​ 现在沿着 rayDir 一步步前进,每步投影回屏幕检查是否命中。

float3 rayPos = viewPos;

[loop]
for (int i = 0; i < _SSRMaxSteps; i++)
{
    // 前进一步
    rayPos += rayDir * stepSize;

    // 把观察空间位置投影回屏幕 UV
    // 注意 -rayPos.z:UNITY_MATRIX_P 期望负 Z,而内部用正 Z 约定,需取反
    float4 clipPos = mul(UNITY_MATRIX_P, float4(rayPos.xy, -rayPos.z, 1.0));
    float2 rayUV = clipPos.xy / clipPos.w;
    rayUV = rayUV * 0.5 + 0.5;

    // 出屏了,放弃
    if (rayUV.x < 0.0 || rayUV.x > 1.0 || rayUV.y < 0.0 || rayUV.y > 1.0)
        break;

    // 采样这一步对应位置的深度,重建出场景在这里的观察空间位置
    float sceneDepth = SampleSceneDepth(rayUV);
    float3 sceneViewPos = ComputeViewSpacePosition(rayUV, sceneDepth);

    // 命中检测:射线已经走到几何体后面了(更远),且没有穿透太深
    float depthDiff = rayPos.z - sceneViewPos.z;
    if (depthDiff > 0.0 && abs(depthDiff) < _SSRThickness)
    {
        hitColor = SampleSceneColor(rayUV);
        hit = true;
        break;
    }
}

命中检测的原理

​ ​ depthDiff = rayPos.z - sceneViewPos.z 比较的是"射线当前深度"和"这一步屏幕位置上场景几何体的深度"。

  • depthDiff < 0:射线还在几何体前面(更靠近相机),继续走。
  • depthDiff ≈ 0:射线刚好打到几何体表面,命中。
  • depthDiff > 0:射线已经穿到几何体后面去了。如果穿透距离(abs(depthDiff))小于 thickness,认为这是命中;如果穿透太深,说明射线跨过了整个物体,算作未命中(miss)。

​ ​ 这就是"厚度测试"(thickness test)——把每个几何体当作有一定厚度的实体,射线钻进去不超过这个厚度就算打中表面。这是一种近似,能省去精确的射线-三角形相交计算。

投影矩阵的 Z 符号坑

​ ​ 循环里这一行看着平平无奇,实际上踩了一个大坑:

float4 clipPos = mul(UNITY_MATRIX_P, float4(rayPos.xy, -rayPos.z, 1.0));   // 注意 -rayPos.z

​ ​ Unity 的观察空间是右手系(相机前方 Z 为负),UNITY_MATRIX_P 按这个约定构建,使得 clip.w = -viewZ。但我们前面重建时用的是正 Z 约定(viewPos.z = +linearEyeDepth),如果直接把正 Z 喂给投影矩阵,clip.w 会变成负数,除以负 w 之后 NDC 的 x、y 同时翻转——这就是为什么最初的版本里反射出现在左右镜像的位置。修复就是在投影前手动取反 Z。

一个反直觉的点:rayUV 这里不需要再做 Y 翻转。理论上 D3D 下 NDC.y=+1 是屏幕顶部、而纹理 v=0 也在顶部,两者方向相反,似乎该翻转。但实测表明 rayUV = NDC·0.5+0.5 直接用就是对的。原因是 Unity 的 UNITY_MATRIX_P 经过了 GL.GetGPUProjectionMatrix 的平台适配,在 D3D 上输出的 clip.y 方向已经和屏幕纹理的采样方向一致。坐标约定的纯理论推导必须拿实际渲染结果验证——这一步如果不测,很容易写出"看起来严谨但其实多余"的翻转代码,反而把原本正确的反射搞没了。


1.6 合成

​ ​ 最后把反射颜色和原始颜色混合:

float4 originalColor = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);
if (hit)
{
    return float4(lerp(originalColor.rgb, hitColor, 0.5f), 1.0);
}
return originalColor;

​ ​ 基础版先用固定的 0.5 混合系数。后续章节会根据射线步数(越早命中越可信)、离屏幕边缘的距离(边缘外推不可靠)、表面粗糙度(粗糙表面反射应该模糊、暗淡)来调整这个混合权重,做出更自然的过渡。


1.7 完整 Shader 结构回顾

​ ​ 把上面所有部分拼起来,完整流程是:

对每个像素:
  1. 采样深度 → 跳过天空
  2. 重建观察空间位置 viewPos(注意 UV Y 翻转)
  3. 用 ddx/ddy 算法线
  4. reflect() 算反射方向,剔除朝向相机的射线
  5. Ray March 循环:
     a. 前进一步
     b. 投影回屏幕 UV(注意 Z 取反 + Y 翻转)
     c. 出屏 → break
     d. 采样深度,重建场景位置
     e. 厚度测试 → 命中则采样颜色,break
  6. 混合反射颜色和原始颜色

1.8 坐标约定速查表

​ ​ 这一章踩了两个坐标约定的坑,都和"方向"有关。整理成一张表,以后做任何屏幕空间特效都能用:

约定点 Unity 的实际行为 错误做法的后果
观察空间 Z 方向 右手系,相机前方 Z 为负;LinearEyeDepth 返回正值(距离) 重建时用正 Z,投影时不处理 →clip.w 为负 → NDC x/y 翻转 → 反射左右镜像
重建时的 UV Y D3D 下 texcoord.y=0 在顶部,公式假设底部 不翻转 →viewPos.y 符号反 → 反射射线 y 方向反 → 射线扎地下,完全没反射

​ ​ 一句话总结:在 Unity URP 里做屏幕空间特效,凡是涉及"UV 重建观察空间位置"的地方,要检查 UNITY_UV_STARTS_AT_TOP,该翻 Y 就翻 Y;凡是涉及"把观察空间位置喂给 UNITY_MATRIX_P"的地方,都要确认 Z 符号是负的(或者像我这里在正 Z 约定下投影前手动取反)。

关于投影后 NDC→UV 的转换:理论推导会认为这里也要翻 Y(因为 D3D 下 NDC.y 和纹理 v 方向似乎相反),但实测 rayUV = NDC·0.5+0.5 直接用就是对的——Unity 的 GPU 投影矩阵在平台适配时已经统一了方向。这种地方切忌只信理论不实测,多余的翻转会把正确的反射搞没。


本章小结

​ ​ 这一章我们搭起了一个能工作的 SSR 框架:URP Feature + Pass 的骨架、观察空间位置重建、基于导数的法线、Ray March 循环、厚度命中检测、简单的颜色混合。

​ ​ 但这个版本有不少明显的缺陷,留待后续章节解决:

  • 固定步长:每步前进相同距离,近处浪费、远处不够。下一章我们改成基于深度的自适应步长(Depth Buffer Ray Marching),用更少的步数达到更好的覆盖。
  • 锯齿和噪声:命中点离散,反射有明显的"条带"瑕疵。需要引入抖动(jitter)和模糊。
  • 粗糙度:所有表面都是完美镜面反射。要支持 GGX 重要采样,做出粗糙表面的模糊反射。
  • 屏幕外信息缺失:需要时间复用(Temporal Accumulation)来补全。
  • 半透明物体:当前深度图只有最前面一层,半透明物体后的反射丢失。

​ ​ 下一章我们从自适应步长开始,一步步把这个基础版本打磨成可以放进生产项目的样子。


第二章 屏幕空间 DDA 光线步进

2.1 为什么需要屏幕空间步进

​ ​ 第一章我们实现的 SSR 用的是 view space 固定步长 raymarch:每步在观察空间沿射线方向前进一个固定距离(stepSize),再把当前位置投影回屏幕做命中检测。这个方法简单,但有一个结构性缺陷——屏幕采样分布不均匀

​ ​ 考虑两种极端的射线方向:

  • 正对相机的射线(射线方向接近相机朝向):在 view space 走很远,投影到屏幕只移动很少几个像素。屏幕上大量像素被跳过,命中检测在这些像素上是空白的。
  • 掠射角的射线(射线方向几乎平行于表面):在 view space 走一小步,投影到屏幕可能跨过几十上百个像素。屏幕上采样极稀疏,容易一步跨过薄物体导致漏检。

​ ​ 根源在于:view space 的"均匀步进"和屏幕空间的"均匀采样"是两个不同的度量。透视投影把近处的物体放大、远处的物体缩小,所以相同的 view space 步长在屏幕上对应的像素距离是变化的。

​ ​ 屏幕空间 DDA(Digital Differential Analyzer)划线解决的就是这个问题:直接在屏幕 UV 空间均匀步进,每步大约走一个像素,无论射线方向如何都保证屏幕采样密度一致。


2.2 DDA 算法思路

​ ​ DDA 原本是用来在像素网格上画直线的算法。把它用到 SSR 里,思路是:

  1. 在 view space 算射线的起点和终点:起点就是当前像素的观察空间位置,终点是起点沿反射方向走 maxDistance 的位置。
  2. 把起点和终点都投影到屏幕 UV:得到 startUVendUV,这两点连起来就是射线在屏幕上的投影线段。
  3. 在屏幕 UV 空间用 DDA 均匀划线:根据这条线段的屏幕像素长度决定步数,每步沿 UV 前进一个像素左右的距离。
  4. 每步检查命中:用当前 UV 采样场景深度,和射线在该位置的深度做比较。

​ ​ 关键性质:因为 3D 直线在透视投影下仍然是屏幕上的直线,所以 startUVendUV 的连线就是射线在屏幕上的精确投影,沿这条线段步进就是在屏幕上沿射线走。


2.3 实现

第一步:算起点、终点,投影到屏幕

// 起点和终点(view space,正 Z 约定)
float3 rayStartVS = viewPos;
float3 rayEndVS   = viewPos + rayDir * _SSRMaxDistance;

// 投影到屏幕 UV(-Z 因为 UNITY_MATRIX_P 期望负 Z,第一章讲过的坑)
float4 startClip = mul(UNITY_MATRIX_P, float4(rayStartVS.xy, -rayStartVS.z, 1.0));
float2 startUV = (startClip.xy / startClip.w) * 0.5 + 0.5;

float4 endClip = mul(UNITY_MATRIX_P, float4(rayEndVS.xy, -rayEndVS.z, 1.0));
float2 endUV   = (endClip.xy / endClip.w) * 0.5 + 0.5;

​ ​ 这里沿用了第一章确立的坐标约定:内部用正 Z,投影时取反 Z 喂给 UNITY_MATRIX_P

第二步:DDA 步数

// UV 跨度按各轴对应的屏幕分辨率转成像素
float2 deltaUV      = endUV - startUV;
float2 deltaPixel   = deltaUV * _ScreenParams.xy;  // x 乘宽、y 乘高,各自对齐
float  maxPixelSpan = max(abs(deltaPixel.x), abs(deltaPixel.y));
int    numSteps     = (int)clamp(maxPixelSpan, 1.0, (float)_SSRMaxSteps);

​ ​ 这一步有个容易踩的坑:UV 跨度转像素时,X 轴和 Y 轴要分别乘对应的分辨率

​ ​ _ScreenParams.xy(width, height),比如 (1920, 1080)。如果直接用 _ScreenParams.x(宽度)统一乘两个轴的 UV 跨度,当主轴是 Y ​ ​ 时就会算错——UV 的 Y 跨度应该乘高度才对。正确做法是把 deltaUV 逐分量乘 (width, height),再取主轴的像素跨度。

​ ​ numSteps 取主轴像素跨度,意味着 DDA 沿主轴每步约走 1 像素。clamp[1, maxSteps] 防止退化(起点终点重合)和性能爆炸(屏幕跨度太大时封顶)。

第三步:每步增量

float  invZ0     = 1.0 / rayStartVS.z;
float  invZ1     = 1.0 / rayEndVS.z;
float2 stepUV    = deltaUV / (float)numSteps;
float  invZStep  = (invZ1 - invZ0) / (float)numSteps;

​ ​ 这里 stepUV 是屏幕空间的步进增量,invZStep1/z 的步进增量。为什么射线深度要插值 1/z 而不是直接插值 z——这是本章的核心,下一节单独讲。

第四步:步进循环

float2 currentUV   = startUV;
float  currentInvZ = invZ0;
float3 hitColor    = float3(0, 0, 0);
bool    hit        = false;

[loop]
for (int i = 0; i < numSteps; i++)
{
    currentUV += stepUV;
    currentInvZ += invZStep;

    // 超出图像边界则停止
    if (currentUV.x < 0.0 || currentUV.x > 1.0 ||
        currentUV.y < 0.0 || currentUV.y > 1.0)
        break;

    // 当前射线的透视正确深度
    float currentRayZ = 1.0 / currentInvZ;

    // 场景深度(正 Z)—— 直接用 LinearEyeDepth
    float sceneDepth = SampleSceneDepth(currentUV);
    float sceneZ = LinearEyeDepth(sceneDepth, _ZBufferParams);

    // 命中检测:射线已穿到几何体后方,且在厚度容差内
    float depthDiff = currentRayZ - sceneZ;
    if (depthDiff > 0.0 && abs(depthDiff) < _SSRThickness)
    {
        hitColor = SampleSceneColor(currentUV);
        hit = true;
        break;
    }
}

​ ​ 逻辑和第一章的命中检测一致:depthDiff > 0 表示射线已经走到几何体后面去了,abs(depthDiff) < thickness 表示穿透距离在容差内,算作命中表面。区别只在于射线深度的来源——这里用透视正确插值得到,而不是固定步长累加。

​ ​ 边界检查放在前进之后、命中检测之前:一旦 currentUV 越出 [0,1]break,保证不会在屏幕外采样。


2.4 核心难点:透视正确深度插值

​ ​ 这是从固定步长改成 DDA 时最容易栽跟头的地方。

错误做法:线性插值 z

​ ​ 第一直觉是:既然 rayStartVSrayEndVS 都知道,每步的射线位置直接线性插值就行:

// 错误!屏幕空间步进下这不对
float3 currentVS = lerp(rayStartVS, rayEndVS, i / numSteps);
float currentRayZ = currentVS.z;

​ ​ 在 view space 步进时这是对的(旧版本的固定步长就是这么做的),因为沿 view space 射线 z 确实线性变化。但在屏幕空间步进时这是错的——因为 currentUV 是屏幕空间线性插值,而 currentVS.z 是 view space 线性插值,两者在透视下不同步。

​ ​ 数值上感受一下差距(maxDistance = 1024numSteps ≈ 500):

步进位置 线性插值 z 透视正确 z(1/z 插值)
起点 5.0 5.0
第 1 步 6.84 5.01
第 2 步 8.68 5.02

​ ​ 近处线性插值每步 z 变化 ~1.84,而透视正确的每步变化只有 ~0.01。thickness 通常设 0.05 量级——线性插值一步就把整个命中窗口跨过去了,命中概率只有 ~2.7%,基本看不到反射。

正确做法:线性插值 1/z

​ ​ 透视投影有一个基本性质(光栅化做"透视正确插值"就靠它):

3D 直线投影到屏幕后仍是直线,且沿该屏幕直线,1/z 是线性变化的。

​ ​ 所以当我们在屏幕 UV 空间线性步进时,跟 z 相关的、也是线性变化的量是 1/z,不是 z 本身:

float invZ0 = 1.0 / rayStartVS.z;
float invZ1 = 1.0 / rayEndVS.z;

// 每步线性插值 1/z
float currentInvZ = lerp(invZ0, invZ1, i / numSteps);

// 还原出真正的射线深度
float currentRayZ = 1.0 / currentInvZ;

​ ​ 这样 currentRayZcurrentUV 在透视下精确对齐——它们对应的是同一个屏幕位置。近处每步 z 变化 ~0.01,和 thickness 同量级,命中检测正常工作。

一个判据:只要 raymarch 的"步进驱动"在屏幕空间(UV 均匀步进、DDA、Hi-Z 等),射线的深度追踪就必须用 1/z 插值。只有当步进驱动在 view space(沿射线本身均匀走)时,直接线性插值 z 才是对的。


2.5 为什么射线深度不能用 LinearEyeDepth

​ ​ 这是实现这一章时容易困惑的一个点。场景深度用了 LinearEyeDepth,射线深度却手动算 1/currentInvZ,为什么不统一?

因为两者面对的"原料"完全不同:

场景深度 射线深度
数据来源 深度缓冲里渲染时写入的硬件深度值 射线起点、终点算出来的虚拟位置
能否采样 能,SampleSceneDepth(uv) 不能,没有任何纹理存射线
转换方式 LinearEyeDepth(raw, _ZBufferParams) 把硬件非线性深度还原成线性距离 1/z 插值直接得到线性距离

​ ​ LinearEyeDepth 解决的问题是"硬件深度缓冲值 → 线性距离"——它的输入必须是采样来的范围在[0,1] 的非线性 raw depth。射线深度是凭空算出来的,根本没经过深度缓冲,自然没有 raw depth 可喂给 LinearEyeDepth

​ ​ 1/z 插值解决的是另一个完全不同的问题:"屏幕空间均匀步进下,怎么让射线的虚拟深度和当前 UV 对齐"。这是 DDA 这种屏幕空间步进方式特有的需求,LinearEyeDepth 帮不上忙。

​ ​ 两者最终得到的都是"正 Z 约定的距相机距离",单位一致,可以直接相减做命中检测——但得到这个值的路径不同。


2.6 和第一章版本的对比

第一章:view space 固定步长 第二章:屏幕空间 DDA
步进驱动 view space 沿射线走固定距离 屏幕 UV 均匀划线
步数 固定 maxSteps 由屏幕像素跨度决定,clampmaxSteps
射线深度跟踪 rayPos += rayDir * stepSize(z 线性) 1/z 线性插值后取倒数
屏幕采样均匀性 差(正对相机过密、掠射角过疏) 好(每步约一像素)
maxDistance 作用 需要手动 length 检查(第一章被注释掉了) 直接决定终点,自然生效
stepSize 参数 决定每步步长 不再使用
性能 步数固定,可预测 步数随屏幕跨度变化,需 clamp 封顶

​ ​ DDA 版本的主要代价:步数不固定(取决于射线在屏幕上的投影长度),需要 maxSteps 封顶防止极端情况。换来的是屏幕采样质量的显著提升,尤其是掠射角区域。


2.7 仍然存在的问题

​ ​ 这一章解决了"屏幕采样均匀性",但 SSR 还有几个明显的瑕疵留待后续章节:

  • 远处仍然跨过命中窗口:即使用了 1/z 插值,远处(z 大)每步的 z 变化仍然可能超过 thickness,导致漏检。下一章我们会引入二分法细化——粗步进发现"射线从几何体前方穿到后方"后,在那个区间做二分搜索精确定位命中点。
  • 锯齿和噪声:命中点离散,反射有"条带"瑕疵。需要 jitter(每帧给射线起点加随机扰动)+ 多帧累积。
  • 屏幕外信息缺失:射线一旦走出 [0,1] 边界就 break,屏幕外的反射完全丢失。需要时间复用从历史帧补全。
  • 硬边反射:命中直接采样颜色,没有考虑表面粗糙度。需要 GGX 重要采样做出模糊反射。

本章小结

​ ​ 这一章把 raymarch 从 view space 固定步长升级到屏幕空间 DDA:

  1. 在 view space 算射线起终点,投影到屏幕得到 UV 线段。
  2. 步数由屏幕像素跨度决定(X、Y 轴分别乘对应分辨率),保证每步约一像素。
  3. 核心要点:屏幕空间步进下,射线深度必须用 1/z 线性插值才能和 UV 对齐——直接线性插值 z 是透视不正确的,会导致命中窗口被一步跨过、完全看不到反射。

​ ​ 1/z 插值是所有屏幕空间 raymarch 技巧(DDA、Hi-Z tracing 等)的共同基础,理解了它,后续看任何 SSR 实现都会顺畅很多。下一章我们在 DDA 的基础上加二分法细化,把命中点从"厚度容差内"精确到"像素级"。


第三章 二分法精确命中

3.1 DDA 的远处漏检问题

​ ​ 第二章的 DDA raymarch 解决了"屏幕采样均匀性"的问题,但留下了一个尾巴:远处的命中检测仍然不可靠

​ ​ 问题出在 DDA 每步都要撞进厚度窗口才算命中:

// DDA 的命中条件:射线穿到后方,且穿透距离在容差内
if (depthDiff > 0.0 && abs(depthDiff) < thickness) { /* 命中 */ }

​ ​ (0, thickness) 这个命中窗口能否被"踩中",取决于每步射线深度的变化量。即使用了 1/z 透视正确插值,远处(z 大)每步的 z 变化仍然可能超过 thickness——因为 1/z 在 z 大的时候变化慢,取倒数还原成 z 后,相邻两步的 z 差反而被放大。

​ ​ 数值上感受:maxDistance = 1024numSteps ≈ 500thickness = 0.05,远处某段每步 z 变化可能达到 ~1.7,一步就从"前方"跳到"后方很深",整个厚度窗口被跨过去——命中概率只有 ~3%。

​ ​ 根本症结在于:DDA 把"发现射线穿越表面"和"确认命中"两个任务绑在了同一步里,都依赖厚度窗口。而厚度窗口在远处太窄,兜不住大步长。


3.2 二分法的思路:解耦发现与定位

​ ​ 二分法的核心思想是把这两个任务拆开

任务 DDA 的做法 二分法的做法
发现射线穿越表面 每步检查是否落在厚度窗口内 粗步进只看 depthDiff符号翻转(从前方变后方),不看厚度
精确定位命中点 (没有这一步,靠厚度窗口撞运气) 在穿越区间内二分搜索,把命中点收敛到像素级

​ ​ 符号翻转的检测非常宽松:只要射线从"前方(rayZ ≤ sceneZ)"变成"后方(rayZ > sceneZ)"就成立,和厚度无关。所以哪怕每步 z 变化很大,只要射线确实穿过了某个表面,符号一定会翻转——不会漏检

发现穿越之后,穿越点一定在"翻转前的最后一步"和"翻转后的第一步"之间。这是一个小区间,在里面做二分搜索,几次迭代就能把命中点收敛到极高精度。

​ ​ 关键洞察:粗步进用大步子快速扫描(每步一个像素,覆盖整条射线),一旦发现穿越立刻停下;二分用小步子在穿越区间内精修。两者分工明确,互不干扰。


3.3 实现

​ ​ 这一章我们把两种 raymarch 方法都封装成独立函数,便于阅读和切换。先定义统一的返回结构体:

struct SSRHit
{
    bool   hit;     // 是否命中
    float2 hitUV;   // 命中点的屏幕 UV
    float3 color;   // 命中点采样到的颜色
};

​ ​ 再加一个辅助函数,把"正 Z 约定的 VS 位置投影到屏幕 UV"这个重复操作(取反 Z 喂给 UNITY_MATRIX_P)封装起来:

float2 ProjectVStoUV(float3 vsPos)
{
    float4 clip = mul(UNITY_MATRIX_P, float4(vsPos.xy, -vsPos.z, 1.0));
    return (clip.xy / clip.w) * 0.5 + 0.5;
}

方法一:DDA 直接命中(封装第二章的实现)

​ ​ 把第二章的 DDA 逻辑原样搬进函数,返回 SSRHit

SSRHit SSRMarchDDA(float3 rayStartVS, float3 rayEndVS, int maxSteps, float thickness)
{
    SSRHit result = (SSRHit)0;
    // ... 投影、DDA 步数、1/z 插值(和第二章一致)...

    for (int i = 0; i < numSteps; i++)
    {
        // 前进、边界检查、采样深度(略,见第二章)...

        float depthDiff = currentRayZ - sceneZ;
        if (depthDiff > 0.0 && abs(depthDiff) < thickness)   // 每步都要撞进厚度窗口
        {
            result.hit = true;
            result.hitUV = currentUV;
            result.color = SampleSceneColor(currentUV);
            break;
        }
    }
    return result;
}

方法二:DDA 粗步进 + 二分细化

​ ​ 这是本章的重点。分两个阶段:

SSRHit SSRMarchBinary(float3 rayStartVS, float3 rayEndVS,
                      int maxSteps, float thickness, int binarySteps)
{
    SSRHit result = (SSRHit)0;
    // ... 投影、DDA 步数、1/z 插值参数(和 DDA 函数相同的前置部分)...

​ ​ Phase 1:粗步进找穿越点。

​ ​ 和 DDA 的步进方式完全一样(屏幕空间均匀步进、1/z 透视正确插值),但命中条件从"撞进厚度窗口"放宽为"符号翻转":

    // Phase 1: 粗步进,找射线第一次穿到几何体后方
    float2 prevUV      = startUV;
    float  prevInvZ    = invZ0;
    float2 currentUV   = startUV;
    float  currentInvZ = invZ0;
    bool   crossed     = false;

    [loop]
    for (int i = 0; i < numSteps; i++)
    {
        prevUV       = currentUV;       // 记住上一步(射线还在前方)
        prevInvZ     = currentInvZ;
        currentUV   += stepUV;
        currentInvZ += invZStep;

        if (currentUV 出屏) break;

        float currentRayZ = 1.0 / currentInvZ;
        float sceneZ = LinearEyeDepth(SampleSceneDepth(currentUV), _ZBufferParams);

        if (currentRayZ > sceneZ)       // 符号翻转:射线穿到后方了
        {
            crossed = true;
            break;
        }
    }

    if (!crossed) return result;        // 全程没碰到任何几何体

​ ​ 注意每步前进前先把 currentUV/InvZ 存进 prev。循环退出时,prev 是"射线在前方"的最后位置,current 是"射线在后方"的第一个位置——穿越点就在它们之间。

​ ​ Phase 2:二分细化。

​ ​ 在穿越区间 [prev, current] 内反复折半。用两个游标 lo(始终保持"射线在前方")和 hi(始终保持"射线在后方")夹住命中点:

    // Phase 2: 二分细化
    // lo: 射线在表面前方 (rayZ <= sceneZ) —— 跨越前的最后位置
    // hi: 射线在表面后方 (rayZ >  sceneZ) —— 跨越后的第一个位置
    float2 loUV   = prevUV;
    float  loInvZ = prevInvZ;
    float2 hiUV   = currentUV;
    float  hiInvZ = currentInvZ;

    [loop]
    for (int j = 0; j < binarySteps; j++)
    {
        float2 midUV     = (loUV + hiUV) * 0.5;
        float  midInvZ   = (loInvZ + hiInvZ) * 0.5;
        float  midRayZ   = 1.0 / midInvZ;
        float  midSceneZ = LinearEyeDepth(SampleSceneDepth(midUV), _ZBufferParams);

        if (midRayZ > midSceneZ)
        {
            // 中点在后方 → 命中点在 [lo, mid]
            hiUV = midUV;  hiInvZ = midInvZ;
        }
        else
        {
            // 中点在前方 → 命中点在 [mid, hi]
            loUV = midUV;  loInvZ = midInvZ;
        }
    }

​ ​ 每次迭代区间长度减半,lohi 始终从两侧夹住穿越点。二分的 1/z 仍然线性插值((loInvZ + hiInvZ) * 0.5),保持透视正确。

​ ​ 收敛后做厚度确认。

​ ​ 二分结束后 hi 是射线从后方逼近表面的位置。这里再做一次厚度测试——但目的和 DDA 不同:不是为了"撞进窗口"(二分已经精确定位了),而是排除射线跨过缝隙或厚墙的情况。如果收敛后穿透仍然很深(abs(depthDiff) ≥ thickness),说明射线穿过的不是薄表面,判为未命中:

    // 二分收敛,hi 是射线从后方逼近表面的位置;做最终厚度确认
    float finalRayZ   = 1.0 / hiInvZ;
    float finalSceneZ = LinearEyeDepth(SampleSceneDepth(hiUV), _ZBufferParams);
    float depthDiff   = finalRayZ - finalSceneZ;

    if (depthDiff > 0.0 && abs(depthDiff) < thickness)
    {
        result.hit   = true;
        result.hitUV = hiUV;
        result.color = SampleSceneColor(hiUV);
    }
    return result;
}

Fragment 里切换方法

​ ​ 有了两个封装好的函数,fragment 变得非常简洁,切换方法只需换一行:

float4 ScreenSpaceReflectionFrag(Varyings input) : SV_Target
{
    // ... 重建 viewPos、算法线、算 rayDir、剔除朝向相机的射线 ...

    float3 rayStartVS = viewPos;
    float3 rayEndVS   = viewPos + rayDir * _SSRMaxDistance;

    // 切换方法只需换这一行
    SSRHit hit = SSRMarchBinary(rayStartVS, rayEndVS, _SSRMaxSteps, _SSRThickness, 10);
    // SSRHit hit = SSRMarchDDA(rayStartVS, rayEndVS, _SSRMaxSteps, _SSRThickness);

    float4 originalColor = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);
    if (hit.hit)
        return float4(lerp(originalColor.rgb, hit.color, 0.5f), 1.0);
    return originalColor;
}

3.4 为什么二分 10 次就够了

​ ​ 二分每次把区间减半,N 次迭代后区间缩小到原来的 2^-N

​ ​ 粗步进发现穿越时,prevcurrent 在屏幕上相差约 1 像素(DDA 每步一个像素)。对这个 1 像素的区间做 10 次二分,区间缩小到 1 / 1024 像素——远小于亚像素精度。

​ ​ 从深度角度看,1 像素屏幕跨度对应的 view space 深度差,在合理场景下也就是零点几到几个单位。二分 10 次后深度精度达到 深度差 / 1024,量级在 0.001 以下,远小于常用的 thickness(0.05~0.5)。所以 10 次足够;再多只是浪费,不会有肉眼可见的差别。

实践中 8~12 次都常见。如果发现反射边缘有轻微抖动,可以加到 12;如果追求性能,8 次通常也够。


3.5 二分法解决了什么、没解决什么

解决了

  • 远处漏检:粗步进只看符号翻转,不依赖厚度窗口,远处只要射线真的穿过表面就能被发现。
  • 命中精度:二分把命中点收敛到亚像素级,反射位置更准、边缘更干净。

没解决(留给后续章节)

  • 屏幕外信息缺失:射线走出 [0,1] 边界就停,屏幕外的反射依然丢失。需要时间复用从历史帧补全。
  • 锯齿/条带瑕疵:每帧命中点离散,反射仍有高频噪声。需要 jitter(给射线起点加随机扰动)+ 多帧累积。
  • 硬边反射:命中直接采样颜色,没有粗糙度概念。需要 GGX 重要采样做模糊反射。
  • 半透明/多层几何:深度缓冲只有最前面一层,半透明物体后的反射、折射无法处理。
  • 掠射角边缘拉伸:射线在屏幕上近乎水平时,小步数覆盖的 view space 距离很大,厚度确认仍可能误判。Hi-Z tracing(基于深度金字塔的自适应步长)能进一步缓解。

3.6 三种方法的演进对比

第一章:VS 固定步长 第二章:屏幕空间 DDA 第三章:DDA + 二分
步进驱动 view space 固定距离 屏幕 UV 均匀 屏幕 UV 均匀(粗)+ 区间折半(精)
深度跟踪 rayPos += rayDir·step 1/z 线性插值 1/z 线性插值(两阶段都用)
命中判定 每步厚度测试 每步厚度测试 粗步进看符号翻转,二分后厚度确认
近处命中
远处命中 ✗(步长固定,可能跨过) △(每步深度变化大易跨过窗口) ✓(符号翻转兜底 + 二分精修)
屏幕采样均匀性 ✗(正对相机过密、掠射过疏)
性能开销 低(步数固定) 中(步数随屏幕跨度) 中偏高(粗步进 + 10 次二分)

​ ​ 从第一章到第三章,我们在不引入新数据结构的前提下,把命中质量从"近处勉强能用"提升到了"全场基本可用"。后续章节要解决的(时间复用、jitter、粗糙度)都需要新的数据或更复杂的框架,属于另一个层次的提升。


本章小结

​ ​ 这一章引入了二分法,把 raymarch 拆成"粗步进发现穿越 + 二分精确定位"两个阶段:

  1. 粗步进只检测 depthDiff 的符号翻转(射线从前方穿到后方),条件宽松、不会漏检。
  2. 二分在穿越区间内反复折半,用 lo(前方)和 hi(后方)夹住命中点,10 次迭代收敛到亚像素精度。
  3. 厚度测试降级为二分收敛后的最终确认,用来排除跨过缝隙/厚墙的误判。

​ ​ 同时把 DDA 和二分法都封装成独立函数(SSRMarchDDA / SSRMarchBinary),fragment 里切换方法只需改一行。这一章之后,SSR 的命中检测部分已经比较健壮了;下一章我们会转向反射质量——用 jitter + 时间累积消除锯齿,让反射从"能看见"变成"平滑自然"。


第四章 Jitter Dither 抖动采样

4.1 规律采样带来的"断带"

​ ​ 前面几章我们把 SSR 的命中检测从 view space 固定步长一路优化到屏幕空间 DDA + 二分法,命中精度已经很高。但实际跑起来会发现一个明显的视觉瑕疵——断带(banding):反射区域出现一条条水平的亮暗条纹,尤其在射线掠射角(地板延伸的远处)特别明显。

​ ​ 断带的根源是所有像素的粗步进起点完全对齐

​ ​ 考虑相邻的两个地板像素 A 和 B,它们的反射射线方向几乎相同,在屏幕上投射出的 DDA 路径也几乎平行。因为起点都对齐到各自的像素中心、步长都是 1 像素,两条路径上的采样点也是对齐的——A 在第 N 步采样的位置和 B 在第 N 步采样的位置,相对于各自起点有相同的偏移。

​ ​ 这意味着如果 A 的射线在第 N 步恰好跨过了某个物体的边缘(命中),旁边 B 的射线很可能在第 N 步也跨过边缘(命中);或者两者都恰好在边缘之间的缝隙里跨过去(一起 miss)。命中/未命中成片状、条状分布,反映到画面上就是断带。

​ ​ 本质上,这是一种采样规律性导致的走样——和不做抗锯齿时三角边缘的锯齿是同一类问题。


4.2 Jitter Dither 的思路

​ ​ 解决走样的经典思路是给采样位置加随机扰动,把规律的走样打散成均匀的噪声(噪声人眼更容易忽略,也更容易被模糊掉)。

​ ​ 在 SSR 里,这个扰动加在粗步进的起点上:每个像素根据自己的屏幕位置,查一张预定义的抖动表(dither table),得到一个 [0,1) 范围的偏移系数 jitter,然后给起点加上 jitter 个单步的偏移:

起点 += 单步增量 × jitter

​ ​ 这样相邻像素的起点偏移量不同,它们的采样路径相互错开,原本对齐的"集体命中/集体 miss"就被打散了。同样的步数能覆盖更多的相对位置,断带大幅减轻。

​ ​ Jitter dither 是一种"用噪声换走样"的策略。走样是结构化的(人眼敏感),噪声是均匀的(人眼不敏感,且容易模糊掉),所以视觉质量显著提升。


4.3 4×4 Bayer 抖动表

​ ​ 抖动表的选择有多种方案:白噪声、Halton 序列、Bayer 矩阵等。本文使用 4×4 Bayer 矩阵,它在 16 个像素的网格里均匀分布 16 个不同的偏移值,兼顾随机性和均匀性:

static const float _DitherTable[16] = {
    0.0,    0.5,    0.125,  0.625,
    0.75,   0.25,   0.875,  0.375,
    0.1875, 0.6875, 0.0625, 0.5625,
    0.9375, 0.4375, 0.8125, 0.3125
};

​ ​ 查表的方式:把像素的屏幕坐标对 4 取模,得到在 4×4 网格内的位置 (i, j),然后取第 i*4 + j 项。在 HLSL 里用位运算 & 3 替代 % 4(更快,且无负数边界问题):

float GetJitter(float2 uv)
{
    uint2 pix = uint2(uv * _ScreenParams.xy);
    return _DitherTable[(pix.x & 3) * 4 + (pix.y & 3)];
}

​ ​ 整张屏幕被划分成无数个 4×4 的像素块,每个块内的 16 个像素分别用 0.0、0.5、0.125、0.625… 这 16 个不同的偏移。块与块之间重复,但因为反射射线方向在变化,实际采样路径不会产生明显的块状走样。


4.4 把 jitter 加到粗步进起点

​ ​ 关键一步:jitter 必须同时作用在屏幕 UV 和 1/z 深度上,而且用同一个系数、各自的步进增量,才能保持透视对齐。

float2 currentUV   = startUV;
float  currentInvZ = invZ0;

#if defined(_JITTER_ON)
{
    float jit = GetJitter(startUV);
    currentUV   += stepUV   * jit;   // 屏幕位置偏移 jit 个单步
    currentInvZ += invZStep * jit;   // 1/z 同步偏移(透视正确)
}
#endif

​ ​ 为什么两者必须同步?回顾第二章的核心结论——屏幕空间步进下,1/z 才是线性变化的,必须插值 1/z 而不是 z。如果 jitter 只偏移 currentUV 不偏移 currentInvZ,那么偏移后的屏幕位置对应的射线深度就和 1/z 插值脱钩了,命中检测会失效。

​ ​ 用同一个 jit 系数乘各自的步进增量(stepUVinvZStep),相当于沿着射线"整体平移" jit 个步长的距离——UV 和深度一起平移,透视关系不变。

这个 jitter 加在粗步进(Phase 1)的起点。二分阶段(Phase 2)不需要再加,因为二分是在已经发现穿越的小区间内精修,起点已经由粗步进确定。


4.5 用 keyword 控制开关

​ ​ Jitter 带来的噪声在某些场景(强反射、近距离)可能不需要,或者你想对比开关效果。用 shader keyword 生成两个 variant:

// shader
#pragma multi_compile _ _JITTER_ON
// Feature
if (settings.jitterDither)
    material.EnableKeyword("_JITTER_ON");
else
    material.DisableKeyword("_JITTER_ON");

​ ​ multi_compile 会编译两份 shader:一份不带 jitter(_JITTER_ON 未定义,相关代码被 #if 剥离),一份带 jitter。运行时通过 EnableKeyword/DisableKeyword 切换。关掉时回到无抖动的纯 DDA+二分,断带会回来;打开时断带消失但会有轻微格子噪声。


4.6 效果与代价

收益

  • 断带大幅减轻:相邻像素采样路径错开,命中/未命中不再成片分布。
  • 等效超采样:同样的步数,覆盖的相对位置更多,反射质量提升——或者反过来,更少的步数就能达到原本的质量,性能可以换算。

代价

  • 4×4 格子噪声:因为抖动是 4×4 重复的,仔细看会有轻微的格子状纹理。
  • 需要后处理模糊配合:格子噪声必须靠后续的高斯模糊消除,否则比断带还难看。

​ ​  Jitter 和模糊是一对搭档:jitter 把结构化的断带打散成均匀噪声,模糊再把噪声抹平。单独用 jitter 不模糊(格子难看),单独模糊不 jitter(断带糊不掉),两者配合才能得到平滑干净的反射。下一章我们就讲高斯模糊怎么和 SSR 拼起来。


4.7 为什么不用时间随机

​ ​ 注意这里的 jitter 是空间上的(基于像素位置),不是时间上的(每帧变化)。每个像素的 jitter 值由它在屏幕上的固定位置决定,相机不动时每帧都一样。

​ ​ 为什么不直接用每帧变化的随机噪声(比如基于时间的白噪声)?因为单帧的白噪声会让反射剧烈闪烁(每帧每个像素的命中都不同),无法收敛。空间 dither 配合模糊,能在单帧内得到稳定、平滑的结果。

​ ​ 如果后续要做时间累积(TAA 式的多帧融合),那时才会用每帧变化的 jitter(通常是 Halton 序列),让时间维度的采样也错开,再靠历史帧加权消除噪声。那是更进阶的话题,基础版本先用空间 dither 就够了。


本章小结

​ ​ 这一章我们用 Jitter Dither 解决了屏幕空间 raymarch 的"断带"问题:

  1. 断带的根源是所有像素的粗步进起点对齐,采样规律导致走样。
  2. 给每个像素的起点加一个基于 4×4 Bayer 表的 [0,1) 偏移,错开相邻像素的采样路径。
  3. 偏移必须同时作用在 UV 和 1/z 上(透视对齐),加在粗步进起点。
  4. _JITTER_ON keyword 控制开关。
  5. Jitter 把断带打散成噪声,噪声交给下一章的高斯模糊消除。

​ ​ 到这里 SSR 的命中质量已经不错了,但画面还有两个问题:jitter 的格子噪声、反射边缘的硬切。下一章我们重构渲染管线,引入高斯模糊和基于 alpha 的混合,把反射打磨得平滑自然。


第五章 高斯模糊与反射合成

5.1 为什么 SSR 需要模糊

​ ​ 到上一章为止,SSR 的命中检测已经相当完善,但直接把命中颜色写回画面会有两个明显的视觉问题:

​ ​ 问题一:Jitter 的格子噪声。 上一章我们用 4×4 Bayer 表给射线起点加抖动,打散了断带,但代价是画面上会有轻微的格子状纹理。如果不处理,这种规则噪声比断带更刺眼。

​ ​ 问题二:反射边缘的硬切。 命中检测是二值的——要么命中(写反射色),要么没命中(写原图)。这导致反射区域的边缘是硬边,和周围没有反射的区域之间有一道清晰的分界线,非常不自然。真实世界里反射是渐变过渡的。

​ ​ 这两个问题都需要模糊来解决:模糊把格子噪声抹平,同时让反射边缘自然渐变。

​ ​ 但直接对整张画面做模糊是错的——我们只想模糊反射的部分,不模糊原图。这就引出了一个问题:怎么把"反射"和"原图"分开处理?


5.2 用 alpha 通道编码"反射强度"

​ ​ 我们的解决方案是把 SSR pass 的输出从"最终颜色"改成"反射结果 + 混合系数"

SSR pass 输出:
  RGB = 反射颜色(命中的话是采样到的场景色,没命中是黑色)
  A   = 混合系数(命中=0.5,没命中=0)

​ ​ alpha 通道在这里不再表示透明度,而是**"这个像素有多少反射"**:

  • A = 0.5:50% 反射 + 50% 原图(和第一章里 lerp(original, reflected, 0.5) 的效果一致)
  • A = 0:纯原图,完全没有反射
  • 模糊后 A 在边缘渐变:反射区域边缘从 0.5 平滑过渡到 0

​ ​ 这样设计的好处是把"反射强度"和"反射颜色"解耦。SSR pass 只管算"哪里有反射、反射什么颜色",alpha 编码"有多少反射";后续的模糊可以同时软化颜色和强度,合成时用 alpha 做加权。

​ ​ SSR pass 的 fragment 因此变得很简洁:

float4 FragSSR(Varyings input) : SV_Target
{
    // ... raymarch(DDA + 二分 + jitter)...

    if (hit.hit)
        return float4(hit.color, 0.5);   // 命中: 反射色 + 混合系数 0.5
    return float4(0, 0, 0, 0);           // 未命中: 无反射
}

5.3 渲染管线重构

​ ​ 有了"反射结果 + alpha"的输出,整个 SSR 的渲染管线从"单次 blit"重构为多 pass 流水线

   source(原图)
       │
       │  [Pass 0: SSR]
       │   raymarch → 输出 (反射色, alpha)
       ↓
      rtA
       │
       │  [Pass 1: BlurH]   水平模糊
       ↓
      rtB
       │
       │  [Pass 2: BlurV]   垂直模糊
       ↓
      rtA  (模糊后的 反射色 + alpha)
       │
       │  [Pass 3: Composite]
       │   lerp(原图, 模糊反射色, 模糊后alpha)
       ↓
      rtB  → 拷回 source

​ ​ 四个 pass 各司其职:

Pass 输入 输出 作用
0 SSR 深度图、不透明色 rtA:(反射色, alpha) raymarch 算反射
1 BlurH rtA rtB 水平方向高斯模糊
2 BlurV rtB rtA 垂直方向高斯模糊
3 Composite rtA + 原图 rtB → source 按 alpha 混合反射和原图

​ ​ 用两个临时 RT(rtA、rtB)乒乓交替,避免读写同一个目标。原图(source)在最后一步才被覆盖,前面全程只读。


5.4 分离卷积:两次 1D 代替一次 2D

​ ​ 高斯模糊的标准优化是分离卷积:把一个 N×N 的二维卷积拆成两次 N 的一维卷积(水平 + 垂直),复杂度从 O(N²) 降到 O(2N)。

​ ​ 我们用 5-tap(5 个采样点)的卷积核,权重来自二项式系数 [1,4,6,4,1] / 16

偏移 权重
中心 (±0) 0.375
±1 像素 0.25
±2 像素 0.0625

​ ​ 权重总和 = 0.375 + 2×0.25 + 2×0.0625 = 1.0,能量守恒。

​ ​ 水平模糊 pass:

float4 FragBlurH(Varyings input) : SV_Target
{
    float2 uv  = input.texcoord;
    float  off = _BlurSpread / _ScreenParams.x;   // 偏移量(UV 空间)

    float4 col = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv) * 0.375;
    col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(off, 0))     * 0.25;
    col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - float2(off, 0))     * 0.25;
    col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(2*off, 0))   * 0.0625;
    col += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - float2(2*off, 0))   * 0.0625;
    return col;
}

​ ​ 垂直模糊 pass 完全一样,只是偏移方向换成 Y 轴。

_BlurSpread 参数控制模糊半径(像素单位)。0 表示无模糊(所有采样点重合,输出=输入),越大越模糊。注意 RGB 和 alpha 是一起被模糊的——反射颜色被扩散的同时,混合系数 alpha 也被扩散,这正是我们想要的:反射边缘的 alpha 从 0.5 渐变到 0,边缘变软。


5.5 合成:按 alpha 混合

​ ​ 模糊之后,最后一步是把反射结果和原图按 alpha 混合。这里需要一个原图的输入——通过 _OriginalTexture 全局纹理传入:

TEXTURE2D_X(_OriginalTexture);

float4 FragComposite(Varyings input) : SV_Target
{
    float2 uv = input.texcoord;
    float4 reflected = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);       // 模糊后的反射
    float3 original  = SAMPLE_TEXTURE2D_X(_OriginalTexture, sampler_LinearClamp, uv).rgb; // 原图
    return float4(lerp(original, reflected.rgb, reflected.a), 1.0);
}

​ ​ reflected.a 是模糊后的混合系数。在反射区域中心,a ≈ 0.5(多个 0.5 的像素模糊后还是 0.5 左右),输出 50% 反射;在反射边缘,a 从 0.5 渐变到 0,反射平滑淡出;在无反射区域,a ≈ 0,输出纯原图。

​ ​ 为什么要单独的 Composite pass,而不是用硬件 alpha blending?因为硬件 blend 需要把模糊结果 blend 到 source 上,但 source 同时是渲染目标——读写同一个 RT 在 GPU 上是未定义行为(除非显式开启 feedback loop)。用单独的 Composite pass,读两个纹理(模糊结果 + 原图)、写第三个 RT,再拷回 source,干净安全。


5.6 C# 端的管线调度

​ ​ Feature 的 Execute 把四个 pass 串起来:

// 1. SSR: source → rtA
Blitter.BlitCameraTexture(cmd, sourceHandle, rtA, material, 0);
// 2. 水平模糊: rtA → rtB
Blitter.BlitCameraTexture(cmd, rtA, rtB, material, 1);
// 3. 垂直模糊: rtB → rtA
Blitter.BlitCameraTexture(cmd, rtB, rtA, material, 2);
// 4. 把原图绑给 Composite pass 采样
cmd.SetGlobalTexture(OriginalTextureID, sourceHandle);
// 5. 合成: rtA → rtB
Blitter.BlitCameraTexture(cmd, rtA, rtB, material, 3);
// 6. 拷回 source
Blitter.BlitCameraTexture(cmd, rtB, sourceHandle);

​ ​ 几个要点:

  • 两个 RTHandle 乒乓rtArtB 交替作为模糊的输入输出,避免分配更多 RT。
  • 原图通过 SetGlobalTexture 传给 Compositesource 在最后一步(第 6 步)才被写,前 5 步全是只读,所以第 4 步把它绑成 _OriginalTexture 是安全的。
  • RT 用 Bilinear 过滤:模糊需要双线性插值,Point 会产生块状瑕疵。
  • HDR 格式:RT 用 R16G16B16A16_SFloat,alpha 通道有足够精度存储混合系数。

5.7 模糊参数调优

​ ​ _BlurSpread(模糊半径)的效果:

blurSpread 效果
0 无模糊,反射边缘硬切,jitter 格子明显
1~2 轻度模糊,边缘微渐变,jitter 格子基本消失
3~5 中度模糊,反射柔和,适合镜面反射
6~8 强模糊,反射朦胧,开始丢失细节

​ ​ 实际使用中需要根据场景调整。结合上一章的 jitterDither 开关,典型组合是:

  • 镜面反射(光滑地板):jitterDither = trueblurSpread = 1.5~2(轻模糊,保细节)
  • 半光泽反射(打蜡地板):jitterDither = trueblurSpread = 3~5(中模糊)
  • 粗糙反射(磨砂金属):blurSpread = 6+,但这里 5-tap 单次模糊不够,需要多次迭代或更宽的核

严格来说,用单一模糊半径模拟粗糙度是不准确的——真实的粗糙反射应该按 GGX 分布做重要采样,反射方向本身就有发散。这里的高斯模糊只是个视觉近似,让反射"看起来"模糊。要做物理正确的粗糙反射,需要更复杂的框架,不在本章范围内。


5.8 模糊带来的边缘问题

​ ​ 模糊有一个副作用:反射会"溢出"到不该有反射的区域

​ ​ 考虑反射区域的边缘:内侧 alpha=0.5,外侧 alpha=0。模糊后,边缘外侧的 alpha 从 0 被内侧抬高到 0.1~0.2,于是原本不该有反射的地方出现了淡淡的反射色。这会让反射区域看起来比实际"胖"一圈。

​ ​ 缓解方法:

  • 缩小 SSR pass 输出的 alpha:比如命中时输出 0.5,模糊后会扩散成 0.5 附近的渐变;如果改成输出 0.8,扩散后边缘 alpha 更快降到 0。
  • 对 alpha 做阈值处理:合成时 alpha = step(0.1, alpha) * alpha,把低于 0.1 的 alpha 直接归零,硬切掉溢出。
  • 更精细的做法:模糊时对 alpha 做非线性处理(比如只模糊颜色,alpha 用形态学腐蚀收缩)。

​ ​ 基础版本先不做这些处理,接受轻微的边缘溢出。后续如果做粗糙度反射,这些边缘处理会和粗糙度模型一起设计。


本章小结

​ ​ 这一章我们把 SSR 的渲染管线从"单次 blit"重构为"反射分离 + 模糊 + 合成"的多 pass 流水线:

  1. SSR pass 输出改为 (反射色, alpha),alpha 编码"反射强度",解耦颜色和强度。
  2. 两次分离卷积模糊(水平 + 垂直,各 5-tap),同时软化反射颜色和边缘。
  3. Composite pass 按 alpha 混合反射和原图,边缘自然渐变。
  4. 两个临时 RT 乒乓,原图最后一步才覆盖,避免读写冲突。

​ ​ 配合上一章的 jitter dither,完整的效果链路是:jitter 把断带打散成噪声 → 模糊把噪声抹平 → alpha 让边缘自然渐变。三者配合,反射从"满是瑕疵"变成"基本平滑"。

​ ​ 到这里 SSR 的基础质量已经可以接受了。后续如果要继续提升,方向是:物理正确的粗糙反射(GGX 重要采样)、时间累积(TAA 式多帧融合,进一步消除噪声)、以及 Hi-Z tracing(基于深度金字塔的自适应步长,大幅提升性能)。


第六章 HiZ 层次化深度光线步进

6.1 为什么需要 HiZ

​ ​ 前面几章的 SSR raymarch——无论是 view space 固定步长、屏幕空间 DDA、还是二分法——都有一个共同的结构性瓶颈:步长固定或半固定

  • DDA 每步约 1 像素,空旷区域也要一格一格走
  • 二分法粗步进后做二分精化,但粗步进仍然是线性的

​ ​ 一条反射射线在屏幕上可能跨越几百甚至上千像素(地板延伸到远处的反射),固定步长意味着几百上千次迭代。这对实时渲染是沉重的负担。

​ ​ HiZ(Hierarchical Z-Buffer,层次化深度缓冲) 的核心思想是:根据射线当前所处区域的"空旷程度"动态调整步长。空旷区域用大步快速跳过,遇到障碍时切换到小步精确检测。这样一条射线通常只需要二三十步就能完成追踪,性能比 DDA/二分快 3-4 倍。

​ ​ HiZ 的关键洞察:深度图的 mipmap(每层是上一层 2×2 像素的"最近表面")构成一棵区域树。高 mip 覆盖大区域、信息保守;低 mip 覆盖小区域、信息精确。射线追踪时在高 mip 大步跳过空旷区域,遇到障碍降到低 mip 精细化——本质上是一棵空间加速结构的遍历。


6.2 HiZ 的原理

深度金字塔

​ ​ HiZ 是深度图的 mipmap 链,但和普通 mipmap 不同:

普通 mipmap HiZ mipmap
降采样方式 4 像素平均(颜色) 4 像素取最近表面
用途 远处纹理 LOD 射线遮挡检测

​ ​ "最近表面"在 Unity 的 reversed-z 约定下是 max(raw depth 越大 = 越近):

mip 0 = 原始深度图
mip 1 = mip 0 的每 2×2 块取 max
mip 2 = mip 1 的每 2×2 块取 max
...

​ ​ 高 mip 覆盖更大的屏幕区域,存的是该区域内最近的表面

动态步长的逻辑

​ ​ 射线追踪时维护一个 mipLevel,每步:

  1. 步进 stride = 2^mipLevel 像素
  2. 在当前 mipLevel 层采样 HiZ,得到该区域的最近表面深度 sceneZ
  3. 比较射线深度 rayZsceneZ
    • rayZ < sceneZ(射线在该区域前方):整个区域空旷 → 升 mip,下一步走更大 stride
    • rayZ > sceneZ(射线穿过了该区域最近表面):可能命中 → 降 mip,用更小 stride 在精细层重新检测
  4. 降到 mip 0 时若仍穿过,确认命中

​ ​ 这样射线在空旷区域快速穿越(高 mip 大步),在障碍附近精细检测(低 mip 小步),步数远少于线性 march。


6.3 HiZ Buffer 的生成

​ ​ HiZ 需要一个独立的 RendererFeature 来预生成深度金字塔。

独立 RT 数组方案

​ ​ 理论上 HiZ 应该是一个带 mipmap 的单纹理,通过 SampleLevel(uv, mip) 采样特定层。但在实践中,Unity URP 14 的 RTHandle 系统对 useMipMap 的支持极不稳定——无论用 RenderTextureDescriptor.mipCount 还是手动 RTHandles.Alloc(RenderTexture),mip 链要么不创建、要么 Blitter 无法正确渲染到带 mip 的 RT。

​ ​ 经过大量踩坑后,采用独立 RT 数组方案:每一层是一个独立的 RTHandle(不带 mip),尺寸递减。shader 用 switch 按 mipLevel 选纹理采样。

internal class HiZPass : ScriptableRenderPass
{
    private const int MaxMipCount = 8;
    private RTHandle[] hiZTextures = new RTHandle[MaxMipCount];

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        int w = ..., h = ...;
        for (int i = 0; i < runtimeMipCount; i++)
        {
            int mipW = Mathf.Max(w >> i, 1);
            int mipH = Mathf.Max(h >> i, 1);
            var desc = new RenderTextureDescriptor(mipW, mipH, GraphicsFormat.R32_SFloat, 0);
            RenderingUtils.ReAllocateIfNeeded(ref hiZTextures[i], desc, FilterMode.Point, ...);
        }
    }
}

​ ​ 每层是普通的 RTHandle(descriptor 路径),Blitter 能正确处理。

生成流程

public override void Execute(...)
{
    // 1. CopyDepth → hiZTextures[0](采样 _CameraDepthTexture)
    Blitter.BlitCameraTexture(cmd, colorTarget, hiZTextures[0], material, 0);

    // 2. 降采样链:hiZTextures[i-1] → hiZTextures[i]
    for (int i = 1; i < runtimeMipCount; i++)
    {
        cmd.SetGlobalVector(HiZSrcSizeID, new Vector4(
            hiZTextures[i-1].referenceSize.x, hiZTextures[i-1].referenceSize.y, 0, 0));
        Blitter.BlitCameraTexture(cmd, hiZTextures[i-1], hiZTextures[i], material, 1);
    }

    // 3. 各层暴露为全局纹理 + mip 信息
    for (int i = 0; i < runtimeMipCount; i++)
        cmd.SetGlobalTexture("_HiZTexture_" + i, hiZTextures[i]);
    cmd.SetGlobalInt("_HiZMipCount", runtimeMipCount);
    cmd.SetGlobalFloat("_HiZMaxMip", runtimeMipCount - 1);
}

降采样 Shader

float4 _HiZSrcSize;   // Feature 传入的源纹理尺寸

float4 FragHiZDownsample(Varyings input) : SV_Target
{
    float2 uv = input.texcoord;
    float2 o = (1.0 / _HiZSrcSize.xy) * 0.5;

    float d0 = _BlitTexture.Sample(sampler_PointClamp, uv + float2(-o.x, -o.y)).r;
    float d1 = _BlitTexture.Sample(sampler_PointClamp, uv + float2( o.x, -o.y)).r;
    float d2 = _BlitTexture.Sample(sampler_PointClamp, uv + float2(-o.x,  o.y)).r;
    float d3 = _BlitTexture.Sample(sampler_PointClamp, uv + float2( o.x,  o.y)).r;

    // reversed-z: max = 最近表面
    return max(max(d0, d1), max(d2, d3)).rrrr;
}

关键细节:Blitter 不设置 _BlitTextureSize(只有 padding 相关的 blit 才设),所以降采样 shader 不能依赖它算 texel size——必须由 Feature 通过 _HiZSrcSize 显式传入。这是踩了好几次坑才定位到的问题。


6.4 SSRMarchHiZ 的实现

SSRHit SSRMarchHiZ(float3 rayStartVS, float3 rayEndVS, int maxSteps, float thickness)
{
    SSRHit result = (SSRHit)0;

    float2 startUV = ProjectVStoUV(rayStartVS);
    float2 endUV   = ProjectVStoUV(rayEndVS);
    float2 startPos = startUV * _ScreenParams.xy;
    float2 endPos   = endUV   * _ScreenParams.xy;
    float2 rayDir2D = normalize(endPos - startPos);

    float invZ0 = 1.0 / rayStartVS.z;
    float invZ1 = 1.0 / rayEndVS.z;

    float2 pos      = startPos;
    float  maxMip   = _HiZMaxMip;
    float  mipLevel = maxMip * 0.5;   // 从中间 mip 开始

    [loop]
    for (int i = 0; i < maxSteps; i++)
    {
        float stride = exp2(mipLevel);
        pos += rayDir2D * stride;

        float2 uv = pos / _ScreenParams.xy;
        if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1) break;

        // 透视正确深度(1/z 沿屏幕线性)
        float t = saturate(distance(pos, startPos) / distance(endPos, startPos));
        float rayZ = 1.0 / lerp(invZ0, invZ1, t);

        // 采样 HiZ 当前 mip 层
        float sceneZ = LinearEyeDepth(SampleHiZ(uv, (int)mipLevel), _ZBufferParams);

        if (rayZ > sceneZ)   // 穿过最近表面
        {
            if (mipLevel <= 0.0)
            {
                // mip 0 命中
                float depthDiff = rayZ - sceneZ;
                if (depthDiff > 0.0 && depthDiff < thickness) {
                    result.hit = true;
                    result.hitUV = uv;
                    result.color = SampleSceneColor(uv);
                }
                break;
            }
            // 回溯 + 降 mip,用更小 stride 在精细层重新检测
            pos -= rayDir2D * stride;
            mipLevel -= 1.0;
        }
        else
        {
            // 未穿过,升 mip 大步跳过空旷区域
            mipLevel = min(maxMip, mipLevel + 1.0);
        }
    }
    return result;
}

SampleHiZ:多纹理 switch 采样

​ ​ 由于用的是独立 RT 数组而非 mip chain,shader 需要按 mipLevel 选纹理:

#define DECLARE_HIZ(i) TEXTURE2D(_HiZTexture_##i)
DECLARE_HIZ(0); DECLARE_HIZ(1); ... DECLARE_HIZ(7);

float SampleHiZ(float2 uv, int mip)
{
    [flatten]
    if (mip <= 0) return _HiZTexture_0.Sample(sampler_PointClamp, uv).r;
    else if (mip == 1) return _HiZTexture_1.Sample(sampler_PointClamp, uv).r;
    ...
    else return _HiZTexture_7.Sample(sampler_PointClamp, uv).r;
}

起始 mipLevel 的选择

​ ​ 从 maxMip / 2 开始,而不是从 0:

  • 从 mip 0 开始的问题:stride=1px,射线起点在地板表面,rayZ ≈ sceneZ,容易因数值噪声误判穿过 → 自反射(反射到地板自身的边缘纹理)。
  • 从 maxMip/2 开始:stride 更大,射线先离开起点表面,rayZsceneZ 拉开差距。代价是近距离(几像素内)的命中可能漏检,但 SSR 命中通常在反射目标上(远处),影响小。

6.5 踩坑实录

​ ​ 这一章的 HiZ 实现经历了大量调试,以下记录关键踩坑点(避免重复踩):

现象 根因 解决
shader 返回 scalar shader 编译失败,mip 0 无内容 return SampleSceneDepth(uv).r 是 float,函数签名 float4 .rrrr
mip chain 不创建 只有 mip 0 有内容 URP 14 的 RenderTextureDescriptor + RenderingUtils.ReAllocateIfNeeded 不可靠地创建 mip 链 改用独立 RT 数组
Blitter 渲染到带 mip RT 失败 ArgumentNullException RTHandles.Alloc(rt) 的 RTHandle .rt 属性不稳定,Blitter 拿不到纹理 渲染走临时 RT 中转,再 CopyTexture 填充
_BlitTextureSize 为 0 降采样全是边界值 Blitter 不设 _BlitTextureSize,shader 除以 0 Feature 传 _HiZSrcSize
GatherRed 编译失败 ps_4_0 不支持 GatherRed 在某些 shader model 不兼容 改用 4 次 Sample
HiZ 存 min(最远) 射线永远在前方,全 miss reversed-z 下 min = 最远,sceneZ 太大 改成 max(最近表面)
自反射 反射到地板自身的边缘 mip 0 起点 rayZ ≈ sceneZ 误判 起始 mipLevel 设高一些

​ ​ 最深刻的教训:坐标约定和 API 边界情况是最大的时间黑洞。HiZ 的算法本身不难(动态 mip 升降),但让 HiZ buffer 正确生成、让 Blitter 正确渲染、让 sampler 正确关联,花了远比写算法逻辑更多的时间。每一步都要用 Frame Debugger 验证中间结果。


6.6 三种 raymarch 方法对比

DDA 二分法 HiZ
步进驱动 屏幕每步 1 像素 DDA 粗步进 + 区间二分 动态 mip(2^mip 像素)
典型步数 数百~1024 粗步进数 + 10 20~30
命中精度 像素级(厚度窗口) 亚像素(二分收敛) 像素级(mip 0 步进)
近处命中
远处命中 △(步长固定,易跨过厚度窗口) ✓(二分兜底) ✓(mip 升降覆盖)
边缘质量 △(规律采样走样) ✓(命中连续) △(动态 mip 路径分歧,需模糊)
额外开销 HiZ buffer 生成(1 个 Feature + 降采样 pass)
适用场景 基础/教学 质量优先 性能优先

​ ​ HiZ 的核心价值是速度:同样一条射线,DDA 要走几百步,HiZ 只要二三十步。代价是命中质量略差(动态 mip 路径分歧导致边缘锯齿),需要配合 Jitter + 模糊掩盖。


6.7 遗留问题与展望

​ ​ HiZ 解决了性能问题,但仍有几个方向可以继续改进:

  • 边缘锯齿:HiZ 的动态 mip 导致相邻像素路径分歧,命中点 hitUV 跳变。目前靠 Jitter + 模糊掩盖。更彻底的方案是 HiZ 找到大致区域后接二分精化(HiZ 负责快速接近,二分负责精确命中),但实现上需要正确处理"穿过区间"的边界。
  • 时间累积(TAA):每帧 jitter 用不同的偏移(Halton 序列),多帧累积消除噪声。这是单帧模糊之外更高质量的方案。
  • 粗糙反射:当前是镜面反射。要做粗糙反射(磨砂表面),需要按 GGX 分布做重要采样,反射方向本身发散。
  • HiZ 的进一步优化:用 Texture2DArray(所有层同尺寸打包)替代 switch 采样;用 ComputeShader 生成 mip 链(比 fragment shader ping-pong 更高效)。

本章小结

​ ​ 这一章我们实现了 HiZ(层次化深度)光线步进:

  1. HiZ buffer 生成:独立 RT 数组(绕开 mip chain 的 API 不稳定),每层是上一层 2×2 的 max(reversed-z 下 = 最近表面)。
  2. SSRMarchHiZ:动态升降 mip——空旷区域升 mip 大步跳过,遇到障碍降 mip 精细化,mip 0 确认命中。
  3. 起始 mipLevel = maxMip/2:避免起点附近的自反射。
  4. maxMip 作为全局参数:由 HiZFeature 设置,SSR shader 读取。

​ ​ HiZ 的性能优势明显(步数是 DDA 的 1/10),是 SSR 在实际项目里可用的关键。代价是命中质量略逊于二分法,需要配合 Jitter + 模糊。三种方法(DDA / 二分 / HiZ)各有适用场景,按需求切换。

​ ​ 到此,SSR 系列的核心算法(基础重建 → DDA → 二分 → Jitter → 模糊 → HiZ)已经完整。后续章节会转向应用层面:如何指定只让特定物体反射、如何与 PBR 材质粗糙度结合、如何做半透明物体的反射等。


许可协议:  CC BY 4.0