屏幕空间反射(SSR)完整实现
屏幕空间反射(SSR)完整实现
从基础 raymarch 到 HiZ 层次化追踪的完整演进路径。
第一章 屏幕空间反射(SSR)基础实现
1.1 什么是屏幕空间反射
屏幕空间反射(Screen Space Reflection,简称 SSR)是一种基于屏幕空间数据的实时反射技术。与传统反射探针(Reflection Probe)或平面反射(Planar Reflection)不同,SSR 直接利用当前帧已经渲染好的深度贴图和颜色贴图来计算反射,不需要额外的渲染 Pass。
基本原理
SSR 的核心思路可以拆成三步:
- 重建:对屏幕上每一个需要反射的像素,根据它的深度值重建出它在观察空间(View Space)中的三维位置和法线。
- 反射:根据观察方向和法线计算出反射光线方向。
- 步进:沿着反射光线在观察空间里一步步前进(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 |
命中检测的厚度容差 | 太小会"跨过"薄物体导致漏检,太大会产生假反射 |
stepSize 和 thickness 的比例很关键。如果每步前进的距离(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 里,思路是:
- 在 view space 算射线的起点和终点:起点就是当前像素的观察空间位置,终点是起点沿反射方向走
maxDistance的位置。 - 把起点和终点都投影到屏幕 UV:得到
startUV和endUV,这两点连起来就是射线在屏幕上的投影线段。 - 在屏幕 UV 空间用 DDA 均匀划线:根据这条线段的屏幕像素长度决定步数,每步沿 UV 前进一个像素左右的距离。
- 每步检查命中:用当前 UV 采样场景深度,和射线在该位置的深度做比较。
关键性质:因为 3D 直线在透视投影下仍然是屏幕上的直线,所以 startUV 到 endUV 的连线就是射线在屏幕上的精确投影,沿这条线段步进就是在屏幕上沿射线走。
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 是屏幕空间的步进增量,invZStep 是 1/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
第一直觉是:既然 rayStartVS 和 rayEndVS 都知道,每步的射线位置直接线性插值就行:
// 错误!屏幕空间步进下这不对
float3 currentVS = lerp(rayStartVS, rayEndVS, i / numSteps);
float currentRayZ = currentVS.z;
在 view space 步进时这是对的(旧版本的固定步长就是这么做的),因为沿 view space 射线 z 确实线性变化。但在屏幕空间步进时这是错的——因为 currentUV 是屏幕空间线性插值,而 currentVS.z 是 view space 线性插值,两者在透视下不同步。
数值上感受一下差距(maxDistance = 1024,numSteps ≈ 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;
这样 currentRayZ 和 currentUV 在透视下精确对齐——它们对应的是同一个屏幕位置。近处每步 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 |
由屏幕像素跨度决定,clamp 到 maxSteps |
| 射线深度跟踪 | 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:
- 在 view space 算射线起终点,投影到屏幕得到 UV 线段。
- 步数由屏幕像素跨度决定(X、Y 轴分别乘对应分辨率),保证每步约一像素。
- 核心要点:屏幕空间步进下,射线深度必须用
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 = 1024、numSteps ≈ 500、thickness = 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;
}
}
每次迭代区间长度减半,lo 和 hi 始终从两侧夹住穿越点。二分的 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。
粗步进发现穿越时,prev 和 current 在屏幕上相差约 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 拆成"粗步进发现穿越 + 二分精确定位"两个阶段:
- 粗步进只检测
depthDiff的符号翻转(射线从前方穿到后方),条件宽松、不会漏检。 - 二分在穿越区间内反复折半,用
lo(前方)和hi(后方)夹住命中点,10 次迭代收敛到亚像素精度。 - 厚度测试降级为二分收敛后的最终确认,用来排除跨过缝隙/厚墙的误判。
同时把 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 系数乘各自的步进增量(stepUV 和 invZStep),相当于沿着射线"整体平移" 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 的"断带"问题:
- 断带的根源是所有像素的粗步进起点对齐,采样规律导致走样。
- 给每个像素的起点加一个基于 4×4 Bayer 表的
[0,1)偏移,错开相邻像素的采样路径。 - 偏移必须同时作用在 UV 和
1/z上(透视对齐),加在粗步进起点。 - 用
_JITTER_ONkeyword 控制开关。 - 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 乒乓:
rtA和rtB交替作为模糊的输入输出,避免分配更多 RT。 - 原图通过
SetGlobalTexture传给 Composite:source在最后一步(第 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 = true,blurSpread = 1.5~2(轻模糊,保细节) - 半光泽反射(打蜡地板):
jitterDither = true,blurSpread = 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 流水线:
- SSR pass 输出改为
(反射色, alpha),alpha 编码"反射强度",解耦颜色和强度。 - 两次分离卷积模糊(水平 + 垂直,各 5-tap),同时软化反射颜色和边缘。
- Composite pass 按 alpha 混合反射和原图,边缘自然渐变。
- 两个临时 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,每步:
- 步进
stride = 2^mipLevel像素 - 在当前
mipLevel层采样 HiZ,得到该区域的最近表面深度sceneZ - 比较射线深度
rayZ和sceneZ:rayZ < sceneZ(射线在该区域前方):整个区域空旷 → 升 mip,下一步走更大 striderayZ > sceneZ(射线穿过了该区域最近表面):可能命中 → 降 mip,用更小 stride 在精细层重新检测
- 降到 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 更大,射线先离开起点表面,
rayZ和sceneZ拉开差距。代价是近距离(几像素内)的命中可能漏检,但 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(层次化深度)光线步进:
- HiZ buffer 生成:独立 RT 数组(绕开 mip chain 的 API 不稳定),每层是上一层 2×2 的
max(reversed-z 下 = 最近表面)。 - SSRMarchHiZ:动态升降 mip——空旷区域升 mip 大步跳过,遇到障碍降 mip 精细化,mip 0 确认命中。
- 起始 mipLevel = maxMip/2:避免起点附近的自反射。
- maxMip 作为全局参数:由 HiZFeature 设置,SSR shader 读取。
HiZ 的性能优势明显(步数是 DDA 的 1/10),是 SSR 在实际项目里可用的关键。代价是命中质量略逊于二分法,需要配合 Jitter + 模糊。三种方法(DDA / 二分 / HiZ)各有适用场景,按需求切换。
到此,SSR 系列的核心算法(基础重建 → DDA → 二分 → Jitter → 模糊 → HiZ)已经完整。后续章节会转向应用层面:如何指定只让特定物体反射、如何与 PBR 材质粗糙度结合、如何做半透明物体的反射等。