一、动机
屏幕空间反射(SSR)在实时渲染中用于补充 Cubemap 无法提供的局部反射细节,难度集中在采样预算有限时如何兼顾光滑镜面和粗糙表面。
促使自行实现的直接原因是现有方案均存在缺陷:Unity 自带 PostProcess 的 SSR 效果很差;Asset Store 上的 CandelaSSRR 在纯镜面反射上表现不错,但对粗糙表面的处理直接退化为高斯模糊,缺乏物理正确的粗糙反射拉丝效果;论坛上一个开源的 Stochastic SSR 实现则使用 ImageEffect(OnRenderImage)驱动,导致与透明物体之间的渲染排序出现严重错误。此外该实现的 RayMarching 求交过于简单粗暴,反射结果在厚度判断不足时会出现恶心的拉长伪影。最终选择使用 CommandBuffer 来驱动整个 SSR 管线,因为 CommandBuffer 可以精确控制注入点,自带 Forward 物体的正确遮挡关系,避免了 ImageEffect 的排序问题。
Cubemap 反射是全局近似:捕获点与实际反射位置的偏差导致 Parallax Error,物体间的相互反射完全缺失(一个物体不会出现在另一个物体的 Cubemap 反射中)。SSR 直接利用当前帧的屏幕颜色作为反射源,天然包含了局部几何的相互反射关系。
镜面反射仅需单一反射方向追踪,实现相对直接。粗糙表面的反射则需要在 Specular Lobe 范围内进行多方向采样,采样策略和降噪管线的压力远大于镜面情况。对于 roughness = 0 的镜面,反射方向唯一确定(R = reflect(-V, N));roughness > 0 时,反射能量分散在以 R 为中心、由 GGX NDF 决定形状的 Lobe 内。完整求解需要在 Lobe 范围内积分所有方向的反射颜色,等价于每像素发射数十到数百根光线,在实时预算下不可行。
基于 Stochastic SSR 的方案,利用重要性采样在 GGX Lobe 内生成随机反射方向,配合 Temporal 和 Spatial 降噪恢复低噪声的反射结果。每像素仅发射 1 根光线(1 SPP),通过降噪管线在时间和空间上累积等效于多 SPP 的采样质量。
二、管线结构
整体分为四个 Pass:
- RayCasting:光线步进求交
- Resolve:采样反射颜色、邻域复用
- Temporal:时域降噪
- Combine:与场景颜色混合输出
使用 CommandBuffer 驱动整个管线,确保 Forward 渲染的物体正确参与遮挡。
各 Pass 的输入输出关系:
SceneDepth + GBuffer(Normal, Roughness) + SceneColor
│
▼
[RayCasting] → RT0(HitUV, HitDepth, Mask) + RT1(RayDir, PDF)
│
▼
[Resolve] → ReflectionColor + ReflectionMask
│
▼
[Temporal] + MotionVector + HistoryBuffer → DenoisedReflection
│
▼
[Combine] + SceneColor → FinalOutput
CommandBuffer 的 Blit 操作会替换当前的 RenderTarget 绑定。在 Forward 渲染管线中,SceneColor 在 Opaque 渲染完成后通过 cmd.Blit 拷贝到临时 RT,供 RayCasting 阶段作为反射源采样。这一拷贝操作的带宽开销约为 width * height * 4 bytes(RGBA8)或 width * height * 8 bytes(RGBA16F HDR),1080p 下约 8-16MB。
2.1 分辨率策略
SSR 各 Pass 可以在半分辨率下执行以降低开销。半分辨率下 RayCasting 和 Resolve 的像素数降为 1/4,性能提升接近 4×。代价是反射结果在 Combine 阶段需要上采样(Bilateral Upsample),边缘处可能出现锯齿。对于 roughness > 0.3 的表面,半分辨率的质量损失几乎不可察觉(反射本身就是模糊的);镜面反射建议使用全分辨率。
实际做法是按 roughness 分流:roughness < 0.1 的像素走全分辨率追踪,其余走半分辨率。RayCasting 前从 GBuffer 读 Roughness 做分类,分别填充两套 RT。
具体实现中,分类通过 Compute Shader 的 Indirect Dispatch 完成:先扫描 GBuffer Roughness 生成两个 Pixel List(全分辨率列表和半分辨率列表),然后对两个列表分别 Dispatch RayCasting Shader。这比在单个 Shader 内做 branch 更高效,避免了 Wave 内因分辨率不同导致的执行路径分歧(全分辨率像素需要写入不同的 RT,无法与半分辨率像素共享写入逻辑)。
2.2 Bilateral Upsample
半分辨率结果上采样到全分辨率时,简单的双线性插值会在深度不连续处(物体边缘)产生 bleeding,背景的反射色渗入前景边缘。Bilateral Upsample 通过深度和法线的相似性权重抑制跨边界插值:
half3 BilateralUpsample(half2 fullResUV)
{
half2 halfResUV = fullResUV; // 映射到半分辨率 UV
half fullDepth = SampleLinearDepth(fullResUV);
half3 fullNormal = DecodeNormal(tex2D(_GBufferNormal, fullResUV));
half3 result = 0;
half totalWeight = 0;
// 2x2 邻域的半分辨率 Texel
for (int i = 0; i < 4; i++)
{
half2 sampleUV = halfResUV + bilinearOffsets[i] * _HalfResTexelSize;
half sampleDepth = SampleLinearDepth_HalfRes(sampleUV);
half3 sampleNormal = DecodeNormal_HalfRes(sampleUV);
half depthWeight = exp(-abs(fullDepth - sampleDepth) / (fullDepth * 0.02));
half normalWeight = pow(max(dot(fullNormal, sampleNormal), 0), 32.0);
half weight = depthWeight * normalWeight;
result += tex2D(_SSR_HalfRes, sampleUV).rgb * weight;
totalWeight += weight;
}
return result / max(totalWeight, 0.001);
}
三、RayCasting Pass
3.1 光线生成
根据像素的 Roughness 和 View Direction,通过 GGX 重要性采样生成反射方向。采样方向偏离镜面反射方向的程度由粗糙度控制:粗糙度越高,采样分布越宽,反射越模糊。
GGX Importance Sampling 的流程:
- 生成均匀随机数 (u1, u2)
- 按 GGX NDF 的逆 CDF 变换得到微表面法线 H:
- θ_h = atan(α * sqrt(u1) / sqrt(1 - u1))
- φ_h = 2π * u2
- 由 V 和 H 计算反射方向 L = reflect(-V, H)
- PDF = D(H) * NdotH / (4 * VdotH)
3.2 逆 CDF 推导
GGX NDF 在球面坐标系下的 PDF(关于 H)为:
p(θ_h, φ_h) = D(θ_h) * cos(θ_h) * sin(θ_h)
= [α² / (π * (cos²θ_h * (α²-1) + 1)²)] * cos(θ_h) * sin(θ_h)
这一表达式由 NDF 的定义得出:微表面法线 m 的分布满足归一化条件 ∫ D(m) * (N·m) dω_m = 1。在球面坐标系中,dω = sin(θ)dθdφ,投影因子 N·m = cos(θ),因此联合 PDF 为 p(θ,φ) = D(θ)*cos(θ)*sin(θ)/(2π)(除以 2π 是因为各向同性时 φ 均匀分布在 [0,2π])。
对 φ_h 积分(各向同性时 φ 均匀分布)后得到边缘 PDF:
p(θ_h) = 2π * p(θ_h, φ_h) / (2π) = D(θ_h) * cos(θ_h) * sin(θ_h)
对 cos(θ_h) 的 CDF 求逆(令 t = cos²(θ_h)):
变量替换:令 t = cos²θ,则 cos θ = √t,sin θ = √(1-t),dθ = -dt / (2√t * √(1-t))。
将 p(θ)dθ 转换为关于 t 的 PDF:
p(t) = D(θ(t)) * √t * √(1-t) * |dθ/dt|
= [α² / (π * (t*(α²-1)+1)²)] * √t * √(1-t) * [1 / (2√t * √(1-t))]
= α² / (2π * (t*(α²-1)+1)²)
CDF 为对 p(t) 从 1 到 t 积分(注意 t=cos²θ,θ=0 对应 t=1,θ=π/2 对应 t=0):
CDF(t) = ∫_1^t α²/(2π*(s*(α²-1)+1)²) * (-ds) [因为 θ 增大时 t 减小]
= ∫_t^1 α²/(2π*(s*(α²-1)+1)²) ds
计算定积分(令 u = s*(α²-1)+1,du = (α²-1)ds):
= [α²/(2π)] * [1/((α²-1))] * [-1/u]_t^1
= [α²/(2π*(α²-1))] * [1/(t*(α²-1)+1) - 1/α²]
归一化后(CDF 从 0 到 1):
CDF(t) = 1 - t / [(α²-1)*t + 1]
设 CDF(t) = u1,解出:
t = (1 - u1) / (1 + (α²-1)*u1)
cos(θ_h) = sqrt(t) = sqrt((1-u1) / (1 + (α²-1)*u1))
这正是代码中的公式。验证边界条件:u1=0 时 cos(θ_h)=1(θ=0,法线正对表面),u1=1 时 cos(θ_h)=0(θ=π/2,法线平行表面)。对于 α→0 的镜面,(α²-1)*u1 ≈ -u1,cos(θ_h) ≈ sqrt((1-u1)/(1-u1)) = 1(几乎所有采样都集中在 θ=0 处)。
3.3 PDF 的计算
从 H 空间的 PDF 变换到 L 空间需要 Jacobian 修正。H 空间的 PDF 为 p(H) = D(H) * NdotH。由于 L = reflect(-V, H),L 与 H 的微分关系为 dω_L = 4 * VdotH * dω_H(反射变换的 Jacobian),因此:
p(L) = p(H) / (4 * VdotH) = D(H) * NdotH / (4 * VdotH)
这个 PDF 值在后续 Resolve 阶段用于判断邻域样本的可复用性,也可用于 MIS(Multiple Importance Sampling)权重计算(当前未使用 MIS)。
3.4 随机数生成
每像素的 (u1, u2) 使用 Blue Noise 纹理 + 帧间偏移生成:
half2 GetBlueNoise(uint2 pixelCoord, uint frameIndex)
{
half2 noise = LOAD_TEXTURE2D(_BlueNoiseTex, pixelCoord % _BlueNoiseSize).rg;
// 每帧应用不同的 Cranley-Patterson 旋转
half2 offset = half2(
frac(frameIndex * 0.7548776662), // Golden ratio 相关序列
frac(frameIndex * 0.5698402909)
);
return frac(noise + offset);
}
Blue Noise 的空间分布特性保证相邻像素的采样方向不同但在频域上均匀分布,Temporal 累积后收敛速度快于 White Noise。Cranley-Patterson 旋转确保帧间采样方向不重复,N 帧后等效于 N-SPP 的 stratified sampling。
Blue Noise 与 White Noise 的收敛差异量化:对于 1 SPP 的 SSR,White Noise 在单帧内的频域能量均匀分布,高频和低频噪声同样可见,视觉上呈现"雪花"状的粒状噪声。Blue Noise 将噪声能量集中在高频区间,频域上低频区几乎无能量,视觉上表现为细密的、无明显 Pattern 的抖动。人眼对大面积明暗波动的低频噪声远比像素级抖动的高频噪声敏感,因此 Blue Noise 即使在单帧也比 White Noise 看起来"干净"。经过 8 帧 Temporal 累积后,Blue Noise 的 RMS Error 约为 White Noise 的 60%。
偏移量 0.7548776662 和 0.5698402909 的选择:它们分别是 (√5-1)/2 ≈ 0.618 和 1/√3 ≈ 0.577 的某种变体(经过数值优化以最小化低维 discrepancy)。关键性质是它们与 1.0 的比值为无理数,确保 frac(frameIndex * c) 生成的序列永不重复(周期为无穷大),且在 [0,1] 区间内分布均匀。
half3 ImportanceSampleGGX(half2 u, half roughness, half3 N, half3 V, out half pdf)
{
half a = roughness * roughness;
half a2 = a * a;
half cosTheta = sqrt((1.0 - u.x) / (1.0 + (a2 - 1.0) * u.x));
half sinTheta = sqrt(1.0 - cosTheta * cosTheta);
half phi = 2.0 * PI * u.y;
half3 H = half3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);
H = TangentToWorld(H, N);
half3 L = reflect(-V, H);
half NdotH = saturate(dot(N, H));
half VdotH = saturate(dot(V, H));
pdf = D_GGX(NdotH, a2) * NdotH / (4.0 * VdotH + 1e-5);
return L;
}
TangentToWorld 将 Tangent Space 中的 H(以 N 为 Z 轴的局部坐标系)变换到 World Space。构建 Tangent Frame 的方式:选取与 N 不平行的参考向量(通常为 abs(N.z) < 0.999 ? (0,0,1) : (1,0,0)),通过两次叉积构建正交基 (T, B, N)。
RT1.w 存储的就是这个 PDF 值。Resolve 阶段利用相邻像素的 PDF 值判断样本可复用性:PDF 相近时采样方向相近,对应的反射内容大概率一致。
当 roughness = 0 时,cosTheta = 1(无论 u.x 取何值),H 退化为 N 方向,L 退化为标准反射方向 R。此时所有帧的采样方向相同,Temporal 不再提供额外样本——这是正确的行为,因为镜面反射只有一个有效方向。
3.5 光线步进与求交
在屏幕空间执行 Ray Marching,使用深度缓冲判断交点。
光线步进在屏幕空间(UV + Linear Depth)中执行,不经过 World Space,原因是深度缓冲本身就是屏幕空间的数据结构,直接在该空间步进可以避免频繁的 World ↔ Screen 变换。步进策略使用 Hierarchical Tracing(HiZ Tracing)可以加速收敛,但基础版本使用等步长线性步进。
3.6 线性步进(Linear Ray March)
线性步进是最直接的实现方式:在屏幕空间沿光线方向以固定步长移动,每步采样深度缓冲判断是否穿透表面。
算法参数与其影响:
| 参数 | 典型值 | 减小的影响 | 增大的影响 |
|---|---|---|---|
| MAX_STEPS | 64 | 命中率下降(远距离反射丢失) | 线性增加 GPU 耗时 |
| STEP_SIZE | 0.02 UV | 命中精度提升但覆盖距离缩短 | 覆盖距离增大但可能跨过薄物体 |
| Thickness | 1.0 world unit | 漏命中增多 | 错误命中增多 |
线性步进的参数:
#define MAX_STEPS 64
#define STEP_SIZE 0.02 // 屏幕空间步长(UV 单位)
half4 RayMarch(half3 rayOrigin, half3 rayDir, half2 screenSize)
{
half3 pos = rayOrigin; // (uv.x, uv.y, linearDepth)
half3 step = rayDir * STEP_SIZE;
for (int i = 0; i < MAX_STEPS; i++)
{
pos += step;
// 边界检查
if (pos.x < 0 || pos.x > 1 || pos.y < 0 || pos.y > 1)
return half4(0,0,0,0); // Miss
half surfaceDepth = SampleLinearDepth(pos.xy);
half depthDiff = pos.z - surfaceDepth;
if (depthDiff > 0 && depthDiff < _Thickness)
{
// Binary Refinement(可选,提升精度)
half3 hitPos = BinarySearch(pos - step, pos, 4);
return half4(hitPos.xy, hitPos.z, 1.0); // Hit
}
}
return half4(0,0,0,0); // Miss
}
光线方向的屏幕空间变换:World Space 的反射方向 L 需要变换为屏幕空间的步进方向 (du, dv, dDepth)。具体做法是将光线起点 P 和终点 P+L*t 分别投影到屏幕空间,然后对差值归一化:
half3 GetScreenSpaceRayDir(half3 worldPos, half3 worldDir, float4x4 viewProj)
{
half3 endPos = worldPos + worldDir * _RayLength;
half4 startClip = mul(viewProj, half4(worldPos, 1.0));
half4 endClip = mul(viewProj, half4(endPos, 1.0));
half3 startScreen = half3(startClip.xy / startClip.w * 0.5 + 0.5, LinearEyeDepth(startClip.z));
half3 endScreen = half3(endClip.xy / endClip.w * 0.5 + 0.5, LinearEyeDepth(endClip.z));
half3 dir = endScreen - startScreen;
// 归一化使得 max(|dir.x|, |dir.y|) = STEP_SIZE,保证每步至少跨越 STEP_SIZE 个 UV 单位
half maxComp = max(abs(dir.x), abs(dir.y));
return dir / maxComp * STEP_SIZE;
}
这种归一化方式确保步长在屏幕空间是均匀的:无论光线方向如何,每步的 UV 位移不超过 STEP_SIZE。接近屏幕平行的光线(dir.z 很小)会被正确处理,但深度方向的步进量很小,可能需要更多步数才能到达目标。
Binary Search Refinement 在初步命中后执行 4 次二分搜索,将命中精度提升到 1/16 步长。代价是每次命中额外 4 次深度采样,但由于仅在命中时执行(miss 的光线直接退出),平均开销较低。
half3 BinarySearch(half3 start, half3 end, int iterations)
{
half3 mid;
for (int i = 0; i < iterations; i++)
{
mid = (start + end) * 0.5;
half surfaceDepth = SampleLinearDepth(mid.xy);
if (mid.z > surfaceDepth)
end = mid; // 命中侧,收缩终点
else
start = mid; // 未命中侧,推进起点
}
return mid;
}
厚度判断:如果仅用单层深度做求交,光线穿过薄物体后会在背面产生错误的长条拉伸反射。解法是引入厚度约束(Thickness):当光线深度超过表面深度加上厚度阈值时,判定为穿透,不视为命中。
更精确的方案是使用背面深度缓冲(Back-Face Depth),可以准确判断光线是否真正进入了物体内部。代价是额外的一次 Depth Pre-Pass 和一张 RenderTarget。在性能预算充足时可启用此模式。
3.7 2D 屏幕空间步进
线性步进的步长选取需要平衡精度与覆盖距离:步长过大会跨过薄物体,过小则追踪范围不足。另一个做法是把光线投影到像素坐标系后用类似 DDA(Digital Differential Analyzer)的方式逐像素推进,确保每步恰好跨越一个像素边界。
这种方式不会跳过任何像素。对于近似水平的光线(在屏幕上跨越大量像素),每个潜在命中点都会被检测到,漏命中率显著降低。代价是步数与光线在屏幕上的像素跨度成正比:一根横跨半个屏幕的光线可能需要数百步,远多于固定 64 步的线性步进。
实现上,将光线起点和终点投影到像素坐标后,沿主轴(X 或 Y 中跨度更大的方向)逐像素步进,副轴按斜率插值,深度同样线性插值后与深度缓冲比较判断命中:
half4 RayMarch2D(half3 startScreen, half3 endScreen, half2 screenSize)
{
half2 delta = (endScreen.xy - startScreen.xy) * screenSize; // 像素坐标差
int stepCount = (int)max(abs(delta.x), abs(delta.y)); // 主轴像素数
half3 step = (endScreen - startScreen) / max(stepCount, 1);
half3 pos = startScreen;
for (int i = 0; i < min(stepCount, MAX_STEPS); i++)
{
pos += step;
if (pos.x < 0 || pos.x > 1 || pos.y < 0 || pos.y > 1)
return half4(0,0,0,0);
half surfaceDepth = SampleLinearDepth(pos.xy);
half depthDiff = pos.z - surfaceDepth;
if (depthDiff > 0 && depthDiff < _Thickness)
return half4(pos.xy, pos.z, 1.0);
}
return half4(0,0,0,0);
}
Morgan McGuire 的完整实现将步进循环与参数准备分离。以下是核心循环(rayIterations)与调用侧的参数准备段:
void rayIterations(sampler2D forntDepth, in bool traceBehindObjects, inout float2 P, inout float stepDirection, inout float end, inout int stepCount, inout uint maxSteps,
inout float sceneZ, inout float2 dP, inout float3 Q, inout float3 dQ, inout float k, inout float dk,
inout float rayZMin, inout float rayZMax, inout float prevZMaxEstimate,
inout bool permute, inout float2 hitPixel,
inout float2 invSize, inout float layerThickness)
{
bool stop = intersecting;
UNITY_LOOP
for (; (P.x * stepDirection) <= end && stepCount < maxSteps && !stop; P += dP, Q.z += dQ.z, k += dk, stepCount += 1) {
rayZMin = prevZMaxEstimate;
rayZMax = (dQ.z * 0.5 + Q.z) / (dk * 0.5 + k);
prevZMaxEstimate = rayZMax;
if (rayZMin > rayZMax) {
swap(rayZMin, rayZMax);
}
hitPixel = permute ? P.yx : P;
sceneZ = tex2Dlod(forntDepth, float4(hitPixel * invSize, 0, 0)).r;
sceneZ = -LinearEyeDepth(sceneZ);
bool isBehind = (rayZMin <= sceneZ);
intersecting = isBehind && (rayZMax >= sceneZ - layerThickness);
stop = traceBehindObjects ? intersecting : isBehind;
}
P -= dP, Q.z -= dQ.z, k -= dk;
}
调用侧的参数准备:
float xAlpha = (P1.x - xClip) / (P1.x - P0.x);
alpha = max(alpha, xAlpha);
}
P1 = lerp(P1, P0, alpha);
k1 = lerp(k1, k0, alpha);
Q1 = lerp(Q1, Q0, alpha);
#endif
P1 = (distanceSquared(P0, P1) < 0.0001) ? P0 + float2(0.01, 0.01) : P1;
float2 delta = P1 - P0;
bool permute = false;
if (abs(delta.x) < abs(delta.y)) {
permute = true;
delta = delta.yx;
P1 = P1.yx;
P0 = P0.yx;
}
float stepDirection = sign(delta.x);
float invdx = stepDirection / delta.x;
float2 dP = float2(stepDirection, invdx * delta.y);
float3 dQ = (Q1 - Q0) * invdx;
float dk = (k1 - k0) * invdx;
dP *= stepRate;
dQ *= stepRate;
dk *= stepRate;
P0 += dP * jitterFraction;
Q0 += dQ * jitterFraction;
k0 += dk * jitterFraction;
float3 Q = Q0;
float k = k0;
float prevZMaxEstimate = csOrigin.z;
stepCount = 0.0;
float rayZMax = prevZMaxEstimate, rayZMin = prevZMaxEstimate;
float sceneZ = 100000;
float end = P1.x * stepDirection;
bool intersecting = isIntersecting(rayZMin, rayZMax, sceneZ, layerThickness);
float2 P = P0;
int originalStepCount = 0;
rayIterations(forntDepth, traceBehindObjects, P, stepDirection, end, originalStepCount, maxSteps, intersecting,
sceneZ, dP, Q, dQ, k, dk,
rayZMin, rayZMax, prevZMaxEstimate, permute, hitPixel,
invSize, layerThickness);
2D 步进的精度优势在长距离浅角度光线上最明显(这类光线在线性步进中最容易跨过目标),但总步数更高导致性能不如固定步长版本。后续引入 HiZ 加速后,2D 步进的实用性进一步降低——HiZ 在空旷区域的大步跳跃能力同时解决了精度和性能问题。
3.8 背面深度的生成
背面深度通过渲染场景几何的背面(Cull Front)到单独的 Depth RT 获取:
// 在 Opaque Pass 之后执行
cmd.SetRenderTarget(backFaceDepthRT);
cmd.ClearRenderTarget(true, false, Color.black, 0.0f); // Reverse-Z: clear to 0
// 设置 Cull Front 的 RenderState
cmd.DrawRenderers(cullingResults, ref drawSettings_CullFront, ref filterSettings);
这相当于在标准 Depth Pre-Pass 的基础上额外渲染一次场景几何(仅需 Vertex Shader 输出 Position),增加约 50% 的 Depth Pass 开销。对于顶点数量较多的场景需要评估性价比。
3.9 求交判断伪代码
// 方案 A:常量厚度
bool hit = (rayDepth > surfaceDepth) && (rayDepth < surfaceDepth + _Thickness);
// 方案 B:背面深度
bool hit = (rayDepth > frontDepth) && (rayDepth < backDepth);
两种方案的行为差异:
- 常量厚度:
_Thickness过小时光线可能穿过表面而不命中(尤其是步长较大时),过大时会在空旷区域产生错误命中(将远处物体误判为当前表面的厚度延伸)。经验值通常设为 0.5-2.0(世界空间单位),需要根据场景尺度调整。 - 背面深度:精确判断光线是否位于物体内部(front < ray < back),不存在经验参数。但对于开放曲面(如单面平面)没有背面信息,需要 fallback 到常量厚度。
3.10 输出
RayCasting 输出两张 RenderTarget:
| RT 通道 | 含义 | 说明 |
|---|---|---|
| RT0.xy | HitUV | half2,命中点的屏幕坐标 |
| RT0.z | HitDepth | half,命中点的线性深度 |
| RT0.w | SSRMask | half,0=miss,1=hit |
| RT1.xyz | RayDirection | half3,归一化后的反射方向 |
| RT1.w | RayPDF_Val | half,重要性采样的 PDF 值 |
Ray PDF 中 xyz 编码的是基于 GGX 分布扰动后的方向信息:粗糙度越高分布越散,反射越呈拉丝模糊效果。w 通道的 PDF 值用于后续 Resolve 阶段的邻域样本权重计算。
RT 格式选择 RGBA16F(每像素 8 bytes × 2 张 = 16 bytes),1080p 下总显存占用约 32MB。半分辨率下降为 8MB。
选择 RGBA16F 而非 RGBA32F 的原因:HitUV 的精度需求为 1/1920 ≈ 0.0005,half 精度的最小步长在 [0,1] 范围内约为 0.001(10-bit 尾数),虽然接近极限但实测未观察到明显的 UV 量化伪影。HitDepth 存储 Linear Depth,通常覆盖 0.1-100m 范围,half 在此范围内的精度约为 0.1m 级别,对于反射求交的精度需求足够。如果场景深度范围很大,比如开放世界超过 1km,建议将 HitDepth 升级为 float32 或使用对数编码。
四、Resolve Pass
Resolve 阶段完成反射颜色的采样和邻域样本复用:
- 使用 Hit UV 采样反射贴图获取反射颜色
- 使用 Hit Mask 计算反射遮罩(用于后续与 Cubemap 混合)
- 利用 Hit PDF 和 Hit Depth 实现相邻像素共享采样结果
相邻像素复用的依据是:空间上相近且表面属性相似的像素,其反射结果具有高度相关性。通过 PDF 和深度的相似度判断,在满足条件时复用邻域采样,等效于提升采样率。这一策略与 NVIDIA SVGF 中的空间复用思路类似。
4.1 邻域复用的采样模式
复用邻域避开规则的 3×3 或 5×5 Kernel,改用旋转的 Poisson Disk 分布:
static const int RESOLVE_SAMPLES = 4;
static const half2 PoissonDisk[4] = {
half2(-0.94201624, -0.39906216),
half2( 0.94558609, -0.76890725),
half2(-0.09418410, -0.92938870),
half2( 0.34495938, 0.29387760)
};
half3 ResolveReflection(half2 uv)
{
half3 centerColor = SampleReflectionColor(uv);
half centerPDF = tex2D(_RT1, uv).w;
half centerDepth = SampleLinearDepth(uv);
half totalWeight = 1.0;
half3 result = centerColor;
// 旋转角度基于 Blue Noise
half angle = tex2D(_BlueNoise, uv * _NoiseScale).r * 2.0 * PI;
half2x2 rot = half2x2(cos(angle), -sin(angle), sin(angle), cos(angle));
for (int i = 0; i < RESOLVE_SAMPLES; i++)
{
half2 offset = mul(rot, PoissonDisk[i]) * _ResolveRadius;
half2 neighborUV = uv + offset;
if (!CanReuseNeighborSample(uv, neighborUV, centerDepth, centerPDF))
continue;
half3 neighborColor = SampleReflectionColor(neighborUV);
half weight = 1.0; // 可加入距离衰减
result += neighborColor * weight;
totalWeight += weight;
}
return result / totalWeight;
}
Poisson Disk 配合每像素不同的旋转角度,使得复用模式在空间上没有规律性,避免了规则 Kernel 产生的方块状 Pattern。
为何使用 4-tap 而非 8-tap:
4-tap 与 8-tap 的 trade-off:
| 配置 | Texture Fetch | 等效 SPP (最理想) | 噪声残留 | GPU 耗时 |
|---|---|---|---|---|
| 4-tap | 中心 + 4 邻域 = 5 次 | 5 | 中等 | 0.3 ms |
| 8-tap | 中心 + 8 邻域 = 9 次 | 9 | 低 | 0.55 ms |
选择 4-tap 是因为后续的 Temporal Filter 会进一步降噪:Resolve 阶段不需要消除所有噪声,只需将方差降低到 Temporal Filter 能有效处理的范围即可。如果 Temporal 被禁用(如 VR 场景中帧间相关性低),应切换到 8-tap 配置。
8-tap 的 Poisson Disk 参考:
static const half2 PoissonDisk8[8] = {
half2(-0.94201624, -0.39906216), half2( 0.94558609, -0.76890725),
half2(-0.09418410, -0.92938870), half2( 0.34495938, 0.29387760),
half2(-0.81544232, 0.87912464), half2(-0.38277543, 0.27676845),
half2( 0.97484398, 0.75648379), half2( 0.44323325, -0.97511554)
};
_ResolveRadius 的选取:半径过小时邻域样本与中心样本高度相关(采样到几乎相同的反射内容),复用收益低;半径过大则跨越表面不连续处的概率增大,导致更多样本被拒绝。经验上取 2-4 Texels(全分辨率下约 0.002-0.004 UV 单位)。对于高粗糙度表面可适当增大半径(因为其反射本身就是模糊的,空间一致性更高):
half adaptiveRadius = lerp(_ResolveRadiusMin, _ResolveRadiusMax, roughness);
4.2 邻域复用的条件判断
bool CanReuseNeighborSample(half2 centerUV, half2 neighborUV, half centerDepth, half centerPDF)
{
half neighborDepth = SampleLinearDepth(neighborUV);
half depthDiff = abs(centerDepth - neighborDepth) / centerDepth; // 相对深度差
half pdfNeighbor = tex2D(_RT1, neighborUV).w;
half pdfRatio = centerPDF / max(pdfNeighbor, 0.001);
// 深度相对差异 < 2%,且 PDF 比值在 [0.5, 2.0] 范围内
return depthDiff < _DepthThreshold && pdfRatio > 0.5 && pdfRatio < 2.0;
}
深度条件排除了深度不连续处的复用(如前景物体不应复用背景的反射)。使用相对深度差替代绝对值,阈值对近处和远处物体同样有效。
PDF 条件的物理含义:两个像素的 PDF 值相近,它们的 GGX Lobe 方向接近(对于相同 roughness,PDF 值由 NdotH 和 VdotH 决定,这些角度相近等价于法线和视角方向相近)。当法线或视角差异较大时(如曲面的边缘),PDF 差异会超过阈值,复用被拒绝,因为此时反射内容确实不同。
PDF 比值阈值 [0.5, 2.0] 的选取依据:考虑两个相邻像素,法线差异 Δθ 导致的 PDF 变化量为 dPDF/dθ ≈ PDF * (∂lnD/∂θ) * Δθ。对于 α = 0.3 的中等粗糙度表面,法线差异 5° 对应 PDF 比值约 1.5×。因此 [0.5, 2.0] 的阈值大致对应"允许法线差异不超过 5°"的几何条件。更严格的阈值(如 [0.7, 1.4])会拒绝更多样本但保留更高精度;更宽松的阈值(如 [0.3, 3.0])允许更多复用但可能引入轻微的方向偏差。
当条件满足时,中心像素可以直接使用邻域像素的 Hit UV 采样结果作为自己的额外样本,相当于免费增加了 SPP 数量。4 个邻域样本 + 1 个中心样本 = 等效 5 SPP(最理想情况下)。
4.3 反射颜色的 HDR 处理
从 SceneColor 采样的反射颜色可能包含很亮的值(如高光或发光体),直接参与邻域平均会导致 firefly(极亮像素主导整个邻域)。在 Resolve 阶段对颜色做 Tone Mapping 再做加权平均,最后 Inverse Tone Map 可以抑制此问题:
half3 ToneMap(half3 c) { return c / (1.0 + Luminance(c)); }
half3 InvToneMap(half3 c) { return c / (1.0 - Luminance(c)); }
// 在 Resolve 中
half3 mappedCenter = ToneMap(centerColor);
// ... 累加 ToneMap(neighborColor) ...
half3 resolvedMapped = result / totalWeight;
half3 resolved = InvToneMap(resolvedMapped);
经过 Resolve 后反射效果基本成型,但由于采样率有限,仍存在可见噪声。噪声的来源是每像素的随机采样方向不同——相邻像素可能采样到亮度差异较大的反射内容(如一个采样到亮色表面、相邻采样到暗色),形成高频噪声。
五、Temporal Filter
时域降噪用的是 Variance Clipping:计算邻域颜色的均值和方差构建 AABB,把历史帧颜色 Clamp 到当前帧的可信范围内,既压噪声又防鬼影(Ghosting)。
单帧 1 SPP 的结果经 Temporal 逐帧叠加,等效采样数随帧数线性增长。
5.1 Motion Vector 与历史采样
Temporal Filter 需要知道当前像素在上一帧的位置(Motion Reprojection):
half2 motionVector = tex2D(_MotionVectorTex, uv).xy;
half2 histUV = uv - motionVector;
half3 histColor = tex2D(_SSR_History, histUV).rgb;
对于静态物体,Motion Vector 仅包含相机运动产生的位移。对于动态物体,Motion Vector 还包含物体自身运动。如果 Motion Vector 不准确(如场景中有 Forward 渲染的半透明物体遮挡,Motion Vector 为零),Temporal 会产生 Ghosting。
SSR 的 Motion Vector 问题:Surface Motion vs Ray Motion
标准的 Motion Vector 记录的是表面几何体在屏幕上的位移(Surface Motion),但 SSR 反射的内容来自命中点处的场景:当命中点处的物体移动时,反射内容也随之变化,即使反射表面本身是静止的。
精确的 SSR Reprojection 应该考虑两个因素:
- 反射表面的运动(Surface Motion):决定反射 UV 在屏幕上的位移
- 命中点物体的运动(Hit Point Motion):决定反射内容的变化
完整的 SSR Motion Vector 计算:
half2 ComputeSSRMotionVector(half2 uv, half2 hitUV)
{
// 反射表面的 Motion Vector(决定采样位置)
half2 surfaceMotion = tex2D(_MotionVectorTex, uv).xy;
// 命中点的 Motion Vector(决定反射内容变化)
half2 hitMotion = tex2D(_MotionVectorTex, hitUV).xy;
// 综合:使用 surface motion 做 reprojection,但用 hit motion 判断内容是否有效
return surfaceMotion; // 简化版本:仅使用 surface motion
}
这里使用简化方案(仅 Surface Motion),代价是当反射中的物体移动时(如一个球在镜子中滚动),Temporal 会出现短暂的 Ghosting 直到 Variance Clipping 将旧数据拒绝。完整方案需要在 RayCasting 阶段额外输出 HitUV 对应的 Motion Vector,增加一次 Texture Fetch 和一张额外的 RT 通道。
5.2 基于 Ray Depth 的 Motion Vector 修正
上述简化方案在实际运行中存在一个更严重的问题:即使反射中没有动态物体,仅仅是相机运动就会产生剧烈拖影。根源在于——Camera Depth 计算的 Motion Vector 对应的是反射表面在屏幕上的位移,但 Temporal Filter 需要采样的是反射内容的历史位置,反射内容来自命中点,命中点的深度与反射表面的深度不同,因此它在屏幕上的帧间位移也不同。
修正方法是使用 RayCasting 阶段输出的 Hit Depth(RT0.z)替代 Camera Depth 来生成 Motion Vector:
half2 ComputeReflectionMotionVector(half2 uv, half hitDepth)
{
// 用 hitDepth 重建命中点的世界坐标
half3 hitWorldPos = ReconstructWorldPos(uv, hitDepth);
// 命中点在上一帧的屏幕位置
half4 prevClip = mul(_PrevViewProjection, half4(hitWorldPos, 1.0));
half2 prevUV = prevClip.xy / prevClip.w * 0.5 + 0.5;
// 命中点在当前帧的屏幕位置
half4 currClip = mul(_ViewProjection, half4(hitWorldPos, 1.0));
half2 currUV = currClip.xy / currClip.w * 0.5 + 0.5;
return currUV - prevUV;
}
修正前后的差异直观:Camera Depth 版本在相机旋转时反射区域出现整片甩尾残影,切换到 Ray Depth 后拖影基本消失。拖影问题解决后,可以放心增大 Temporal 累积强度(降低 blendFactor),噪声约减少 40%。
5.3 Variance Clipping 的具体流程
half3 TemporalFilter(half2 uv)
{
// 1. 采样当前帧 Resolve 结果和邻域统计
half3 m1 = 0, m2 = 0;
for (int i = 0; i < 9; i++) {
half3 c = SampleResolve(uv + offsets3x3[i] * _TexelSize);
m1 += c; m2 += c * c;
}
m1 /= 9.0; m2 /= 9.0;
half3 sigma = sqrt(abs(m2 - m1 * m1));
half3 boxMin = m1 - _ClampGamma * sigma;
half3 boxMax = m1 + _ClampGamma * sigma;
// 2. 获取历史帧颜色并 Clamp
half2 motionVector = tex2D(_MotionVectorTex, uv).xy;
half2 histUV = uv - motionVector;
half3 histColor = tex2D(_SSR_History, histUV).rgb;
histColor = clamp(histColor, boxMin, boxMax);
// 3. 混合
half3 currentColor = tex2D(_SSR_Resolve, uv).rgb;
half blendFactor = _TemporalBlendFactor; // 通常 0.05-0.15
// 可选:根据 Motion Vector 大小调整 blend factor
half motionLength = length(motionVector * _ScreenSize);
blendFactor = lerp(blendFactor, 0.5, saturate(motionLength * 0.1));
return lerp(histColor, currentColor, blendFactor);
}
3×3 邻域统计的计算细节:m1 为均值,m2 为二阶矩,sigma = sqrt(m2 - m1²) 为标准差(abs 用于防止浮点误差导致的负值)。9 个采样点的 offsets3x3 为:
static const half2 offsets3x3[9] = {
half2(-1,-1), half2(0,-1), half2(1,-1),
half2(-1, 0), half2(0, 0), half2(1, 0),
half2(-1, 1), half2(0, 1), half2(1, 1)
};
在 YCoCg 颜色空间中执行 Variance Clipping 效果更好:RGB 空间的 AABB 形状可能不包含视觉上相似的颜色(因为 RGB 空间中的欧几里得距离不对应感知距离)。YCoCg 变换将亮度与色度分离,使得 AABB 在感知空间中更"紧":
half3 RGB2YCoCg(half3 c) {
return half3(0.25*c.r + 0.5*c.g + 0.25*c.b, 0.5*c.r - 0.5*c.b, -0.25*c.r + 0.5*c.g - 0.25*c.b);
}
_ClampGamma 越小,Temporal 越激进地拒绝历史(更抗鬼影但更多噪声残留);越大则更多保留历史(更平滑但可能出现拖尾)。通常取 1.0~1.5 之间。
blendFactor 控制当前帧与历史帧的混合比例。0.05 相当于每帧仅注入 5% 新数据,等效于 20 帧的累积窗口。对于静态相机和静态场景,20 帧后反射质量等效于 20 SPP。Motion-Adaptive 策略在相机快速移动时增大 blendFactor(减少历史权重),防止运动模糊状的拖尾。
Motion-Adaptive blendFactor 的设计:
// motionLength 为屏幕空间位移的像素数
// motionLength = 0 → blendFactor = 0.05(静态,强累积)
// motionLength = 5 → blendFactor ≈ 0.275(中速运动)
// motionLength > 10 → blendFactor ≈ 0.5(快速运动,接近无累积)
half blendFactor = lerp(0.05, 0.5, saturate(motionLength * 0.1));
除了 Motion 之外,还可以引入 Roughness-Adaptive 策略:高粗糙度表面的反射本身就是模糊的(空间一致性高),可以使用更低的 blendFactor(更强的累积)来获得更干净的结果。低粗糙度的镜面反射需要保留清晰的高频细节,blendFactor 应适当增大:
half roughnessFactor = lerp(0.8, 1.2, roughness); // 粗糙度越高,blendFactor 越低
blendFactor *= roughnessFactor;
blendFactor = clamp(blendFactor, 0.02, 0.6);
5.4 Ghosting 的根源与对策
Ghosting 出现在以下情况:
- 物体运动后,上一帧的反射内容已失效,但 Temporal 仍在混合旧数据
- Disocclusion:上一帧被遮挡的区域在当前帧暴露,历史采样点来自不同表面
Variance Clipping 通过限制历史颜色到当前帧邻域的统计范围内来抑制 Ghosting。当历史颜色与当前帧差异过大(超出 mean ± gamma * sigma),会被 Clamp 到边界值,效果等同于"部分拒绝"历史。
更激进的方案是 Neighborhood Clamping(直接取邻域 min/max,不用 mean ± sigma),但这会过度拒绝历史,导致高频噪声残留。Variance Clipping 是二者之间的折中。
Disocclusion 检测的额外手段:
除了 Variance Clipping 外,可以通过深度一致性检测主动识别 Disocclusion 区域:
half currentDepth = SampleLinearDepth(uv);
half histDepth = SampleLinearDepth_History(histUV); // 上一帧深度(需要额外保存)
half depthConsistency = abs(currentDepth - histDepth) / currentDepth;
if (depthConsistency > 0.1) // 深度差异 > 10%,判定为 Disocclusion
blendFactor = 1.0; // 完全使用当前帧,丢弃历史
5.5 粗糙度分级策略
前述 Roughness-Adaptive 参数调整是连续的,但低粗糙度表面(roughness < 0.2)需要更激进的分级处理。连续调整在这个区间效果不够——Temporal 和 Resolve 的模糊效应即使被削弱,仍会让本应锐利的反射变软。
改进方案是对低粗糙度做离散分级:
- 关闭 Importance Sampling:roughness 接近 0 时 GGX Lobe 极窄,随机采样方向与镜面反射方向的偏差反而引入不必要的抖动。直接使用镜面反射方向 R = reflect(-V, N),消除采样噪声源。
- Resolve 偏移量归零:邻域复用对镜面反射没有意义——邻域像素的反射方向可能指向完全不同的场景内容。将
_ResolveRadius设为 0,仅使用中心样本。 - 降低 Temporal 混合强度:镜面反射需要保留帧间的锐利细节,过强的累积会模糊运动中的反射内容。
- 锐化补偿:在 Combine 阶段对低粗糙度的反射结果施加轻度锐化(Unsharp Mask 或 CAS),补偿 Temporal 残留的轻微柔化。
// 粗糙度分级判断(在各 Pass 入口处)
bool isLowRoughness = roughness < _RoughnessTierThreshold; // 通常 0.15-0.2
// RayCasting:低粗糙度跳过 Importance Sampling
half3 rayDir = isLowRoughness
? reflect(-V, N)
: ImportanceSampleGGX(blueNoise, roughness, N, V, pdf);
// Resolve:低粗糙度不做邻域复用
half resolveRadius = isLowRoughness ? 0.0 : _ResolveRadius;
// Temporal:低粗糙度降低累积强度
half temporalBlend = isLowRoughness ? 0.3 : _TemporalBlendFactor;
分级阈值通过 GBuffer Roughness 判断,不引入额外 Pass。效果上,低粗糙度表面的反射清晰度接近平面反射的锐度,高粗糙度表面不受影响。
六、Combine Pass
最终混合阶段将 Temporal 输出的反射结果与场景颜色合成:
half3 CombineSSR(half2 uv)
{
half3 reflColor = tex2D(_SSR_TemporalResult, uv).rgb;
half ssrMask = tex2D(_SSR_RayCastRT0, uv).w;
// Fresnel 衰减
half3 N = DecodeNormal(tex2D(_GBufferNormal, uv));
half3 V = normalize(_CameraPos - ReconstructWorldPos(uv));
half NdotV = saturate(dot(N, V));
half3 F0 = GetF0FromGBuffer(uv); // 非金属 0.04,金属取 Albedo
half3 fresnel = F_Schlick(F0, NdotV);
half3 sceneColor = tex2D(_SceneColorTex, uv).rgb;
// SSR Mask 控制覆盖范围(miss 区域保留 Cubemap 反射)
half3 finalColor = sceneColor + reflColor * ssrMask * fresnel;
// 可选:与 Cubemap 反射混合
// half3 cubemapRefl = tex2D(_CubemapReflection, uv).rgb;
// half3 finalRefl = lerp(cubemapRefl, reflColor, ssrMask);
// finalColor = sceneColor + finalRefl * fresnel;
return finalColor;
}
SceneColor 必须手动传入 Shader,因为前面 RayCasting 阶段的 Blit 操作会改变内置 _MainTex 的内容。
6.1 Fresnel 与能量守恒
Combine 阶段的 Fresnel 计算控制反射强度。对于非金属(F0 = 0.04),只有掠射角时反射明显;金属(F0 接近 1)在所有角度都有强反射。SSR 的反射颜色是"反射到的场景颜色",不是光源颜色,因此不需要额外的 NdotL 项。
Fresnel 在 SSR Combine 中的物理含义:
在完整的渲染方程中,Specular 反射项为 ∫ L_i(l) * f_s(l,v) * NdotL dω,其中 f_s 包含了 Fresnel 项。对于 IBL(包括 SSR 作为屏幕空间的局部 IBL),Split-Sum 近似将积分拆分为 PrefilterColor * (F0*Scale + Bias)。Combine 阶段乘以 Fresnel 对应的正是这里的 (F0*Scale + Bias) 项,它决定了有多少比例的反射光被表面"接受"。
使用 F_Schlick(F0, NdotV) 代替完整的 F0*envBRDF.x + envBRDF.y 是一个简化(省去了 BRDF LUT 采样)。在 roughness 较低的镜面场景中这一简化误差可忽略(Scale ≈ 1, Bias ≈ 0 时 Schlick 与完整形式几乎一致)。对于高粗糙度表面,完整形式的补偿效果更好;但高粗糙度时 SSR 的 Mask 命中率本身就较低(模糊反射更多 fallback 到 Cubemap),简化的影响进一步减小。
如果 Deferred Lighting Pass 中已经计算了 IBL Specular(使用 Cubemap),Combine 阶段应该用 SSR 结果替代 Cubemap 贡献,不能叠加。否则会出现双重反射(能量超过物理正确值)。处理方式是在 Lighting Pass 中暂时不输出 IBL Specular,改由 Combine Pass 根据 SSR Mask 混合 SSR 和 Cubemap:
half3 reflectionContribution = lerp(cubemapSpecular, ssrColor, ssrMask);
half3 finalColor = diffuseLighting + reflectionContribution * fresnel;
Smooth Fallback 到 Cubemap:ssrMask 为 0/1 的硬切换会在 SSR 覆盖边界处产生明显的反射跳变。将 ssrMask 做 Spatial Smooth(基于邻域平均或根据光线步进的最后位置做距离衰减)可以实现柔和的过渡:
// 基于光线末端位置的衰减(越接近 MAX_STEPS 限制,越可能是 miss 的边缘情况)
half confidence = saturate(1.0 - (float)hitStep / (float)MAX_STEPS);
half smoothMask = ssrMask * confidence;
6.2 Roughness-Based 反射模糊度一致性
当 SSR 使用 1 SPP Stochastic Sampling 时,单帧内的反射结果是锐利的(不管 roughness 多高,单根光线的命中结果本身不模糊)。模糊效果完全依赖 Temporal 和 Spatial 的累积。在 Temporal 尚未充分收敛时(如相机快速移动),高粗糙度表面的反射应该看起来模糊但实际呈现为噪声。
一种补偿策略是在 Combine 阶段基于 roughness 对反射结果做额外的模糊处理(如按 roughness 采样更高 Mip 的反射缓冲):
half mipLevel = roughness * _MaxSSRMip; // 粗糙度越高,采样越模糊的 Mip
half3 reflColor = tex2Dlod(_SSR_TemporalResult, half4(uv, 0, mipLevel)).rgb;
这需要在 Temporal 输出后额外生成 Mip Chain(一次 Downsample Pass),增加约 0.1ms 的开销。
6.3 PreintegratedGF 替代简化 Fresnel
前述 F_Schlick(F0, NdotV) 是对 Split-Sum 中 F0 * Scale + Bias 项的简化——它忽略了 Roughness 对 Fresnel 响应的调制。更精确的做法是使用 PreintegratedGF(预积分 Environment BRDF),同时考虑 Roughness 和 NdotV。
完整方案需要一张 2D LUT(以 NdotV 和 Roughness 为坐标,存储 Scale 和 Bias)。但生成和采样这张 LUT 增加管线复杂度,可以用 Unreal Engine Mobile 端的曲面拟合近似替代,直接通过数学函数逼近 LUT 输出:
half2 EnvBRDFApprox(half Roughness, half NdotV)
{
const half4 c0 = half4(-1, -0.0275, -0.572, 0.022);
const half4 c1 = half4(1, 0.0425, 1.04, -0.04);
half4 r = Roughness * c0 + c1;
half a004 = min(r.x * r.x, exp2(-9.28 * NdotV)) * r.x + r.y;
return half2(-1.04, 1.04) * a004 + r.zw;
}
// Combine 阶段替换 Fresnel
half2 envBRDF = EnvBRDFApprox(roughness, NdotV);
half3 specularColor = reflColor * (F0 * envBRDF.x + envBRDF.y);
切换后高饱和度材质(红色、紫色金属)掠射角处的暗边有所改善——简化 Fresnel 忽略 Bias 项导致这些区域能量偏低。对于常规材质视觉差异不大,但从能量守恒的角度看 PreintegratedGF 更正确,且计算开销几乎为零(几条 ALU 指令替代一次 Texture Fetch)。
七、效果分析
使用背面深度求交时,反射精度接近平面反射,完全消除了薄物体的拉伸伪影。使用常量厚度时效果略有折衷,但性能开销更低。
| 维度 | 背面深度 | 常量厚度 |
|---|---|---|
| 精度 | 精确判断物体内部 | 依赖经验阈值 |
| 额外开销 | +1 Depth Pre-Pass + 1 RT | 无 |
| 适用场景 | 薄物体密集(栅栏、植被) | 大多数通用场景 |
| 伪影 | 基本无 | 厚度过小→漏命中,过大→错误命中 |
在性能敏感场景中,常量厚度配合适当的 Falloff 可以覆盖大部分情况。薄物体场景建议开启背面深度模式。
粗糙表面的反射呈现物理正确的各向同性模糊(基于 GGX 分布),不是简单的高斯模糊,视觉上更加自然。GGX 模糊与高斯模糊的区别在于:GGX 的分布有更长的尾部(heavy tail),反射图像的模糊边缘呈渐变衰减,不会截断;GGX 模糊的宽度直接由粗糙度参数控制,不同视角下的模糊量自然一致。
7.1 综合效果
RayDepth MV 修正、粗糙度分级、PreintegratedGF、HiZ 加速全部叠加后的效果:
7.2 性能数据(1080p,中端 GPU)
| Pass | 全分辨率 | 半分辨率 |
|---|---|---|
| RayCasting (64 steps) | 1.2 ms | 0.35 ms |
| Resolve (4 neighbors) | 0.3 ms | 0.09 ms |
| Temporal | 0.2 ms | 0.2 ms (全分辨率执行) |
| Combine | 0.1 ms | 0.15 ms (含上采样) |
| 总计 | 1.8 ms | 0.79 ms |
RayCasting 占据了总时间的 65-70%,是性能优化的主要目标。步数(MAX_STEPS)与性能线性相关:从 64 降到 32 可节省约 0.6ms,代价是远距离反射的命中率下降。
性能瓶颈分析:RayCasting 的主要开销来自 Texture Fetch(每步一次深度采样)。在 64 步的循环中,Texture Cache 命中率取决于光线方向的连贯性:同一 Wave 内相邻像素的光线如果方向相近(低粗糙度时大概率如此),它们的深度采样地址相近,L1 Cache 命中率高;高粗糙度时光线方向发散,Cache Miss 增多,实际耗时可能高于线性预期。
八、HiZ 加速光线步进
线性步进的效率瓶颈很直接:大部分步进发生在空旷区域(光线远离任何表面),这些步进本质上是浪费的深度采样。Hierarchical Z-Buffer(HiZ)加速利用深度的 Mip Chain 在空旷区域跨越大步长,仅在接近表面时切换到细粒度步进。
8.1 HiZ Buffer 的生成
HiZ 是深度缓冲的 Mip Chain,每个高 Mip 的 Texel 存储对应低 Mip 2×2 区域的最小深度值(对于 Reverse-Z 则为最大值,即最远的深度):
// Compute Shader: 生成 HiZ Mip Chain
[numthreads(8,8,1)]
void GenerateHiZ(uint3 id : SV_DispatchThreadID)
{
half2 uv = (id.xy + 0.5) * _InputTexelSize;
// 采样上一级 Mip 的 2x2 区域
half d00 = _InputDepth.SampleLevel(sampler_point, uv + half2(-0.25,-0.25) * _InputTexelSize, 0);
half d10 = _InputDepth.SampleLevel(sampler_point, uv + half2( 0.25,-0.25) * _InputTexelSize, 0);
half d01 = _InputDepth.SampleLevel(sampler_point, uv + half2(-0.25, 0.25) * _InputTexelSize, 0);
half d11 = _InputDepth.SampleLevel(sampler_point, uv + half2( 0.25, 0.25) * _InputTexelSize, 0);
// Reverse-Z: 取最大值(最远深度)用于保守剔除
half maxDepth = max(max(d00, d10), max(d01, d11));
_OutputMip[id.xy] = maxDepth;
}
HiZ 共生成 log2(max(width,height)) 级 Mip(1080p 下约 11 级)。生成开销约 0.1-0.2ms(逐级 Dispatch Compute Shader)。
8.2 HiZ Tracing 算法
half4 HiZTrace(half3 rayOrigin, half3 rayDir, int maxLevel)
{
int level = 0; // 从最细 Mip 开始(也可从粗到细)
half3 pos = rayOrigin;
for (int i = 0; i < MAX_STEPS; i++)
{
half2 cellSize = _TexelSize * exp2(level); // 当前 Mip 的 Texel 大小
half stepLength = ComputeStepToNextCell(pos, rayDir, cellSize);
pos += rayDir * stepLength;
if (OutOfBounds(pos)) return half4(0,0,0,0);
half surfaceDepth = SampleHiZ(pos.xy, level);
if (pos.z > surfaceDepth)
{
// 光线低于表面(可能命中)
if (level == 0)
{
// 最细级别,执行精确求交
if (pos.z < surfaceDepth + _Thickness)
return half4(pos.xy, pos.z, 1.0);
else
pos += rayDir * STEP_SIZE; // 穿透,继续
}
else
{
level--; // 降 Mip,细化步进
}
}
else
{
level = min(level + 1, maxLevel); // 空旷区域,升 Mip 加速
}
}
return half4(0,0,0,0);
}
ComputeStepToNextCell 的实现:计算从当前位置到下一个 Cell 边界的最短距离。在当前 Mip Level 下,Cell 大小为 texelSize * 2^level。步长为光线沿 UV 方向到达下一个 Cell 边界所需的距离:
half ComputeStepToNextCell(half3 pos, half3 dir, half2 cellSize)
{
// 下一个 Cell 边界的位置
half2 cellBoundary = (floor(pos.xy / cellSize) + step(0, dir.xy)) * cellSize;
half2 t = (cellBoundary - pos.xy) / dir.xy; // 到达 X/Y 边界的参数化距离
return max(min(t.x, t.y), 0.001); // 取较近的边界,防止零步长
}
HiZ Tracing 的收益取决于场景深度复杂度。在空旷走廊(大段空白深度)中加速倍率可达 4-8×;在密集场景(物体填满屏幕)中加速有限,可能退化为接近线性步进的效率。
HiZ 与线性步进的性能对比:
| 场景类型 | 线性步进 (64 steps) | HiZ (maxLevel=5) | 加速比 |
|---|---|---|---|
| 空旷走廊 | 1.2 ms | 0.2 ms | 6× |
| 室内场景 | 1.2 ms | 0.5 ms | 2.4× |
| 密集森林 | 1.2 ms | 0.9 ms | 1.3× |
HiZ 的额外开销(Mip Chain 生成 + SampleLevel 的 Mip 参数计算)约 0.15ms,在空旷场景中被充分回收;在密集场景中净收益可能仅为 0.15ms。因此对于深度复杂度已知的场景,可以在 CPU 端动态选择步进策略。
8.3 实现细节
HiZ 的 Mip 深度链构建需要处理 Reverse-Z。标准描述中高层 Mip 取 2×2 区域的最小深度值(保守估计最近表面),但 Unity 默认使用 Reverse-Z(近平面为 1,远平面为 0),此时应取最大值才对应"最近"深度。实现中直接使用 max 操作,Mip 链共构建 5 级——更多层级在实测中收益递减,5 级对应 32×32 像素的最大跳跃粒度,对于 1080p 已经足够。
HiZ Trace 封装为独立函数接口,输入为光线起点、方向和步进参数,输出为命中信息。这样做的好处是其他需要光线追踪的场景(如半透明物体的表面反射追踪)可以直接复用同一套逻辑,不需要重复实现步进和求交。
8.4 实测性能对比
在 GTX 970M、1080p 全分辨率、各追踪模式最高参数下的实测数据:
| 配置 | 场景 A(室内) | 场景 B(复杂) |
|---|---|---|
| 无 SSR | 4 ms | 6 ms |
| HiZ Trace | 10 ms | 12.5 ms |
| Linear 2D Trace | 30 ms | 50 ms |
HiZ 在实测中平均比 Linear 2D 快 2-3 倍,差异比前文理论预期更大。原因是 Linear 2D 步进在长光线上的步数与像素跨度成正比(一根跨越 500 像素的光线就需要 500 步),而 HiZ 在空旷区域的 Mip 跳跃能力把这类长距离追踪压缩到十几步内完成。
九、屏幕边缘淡出
光线命中点接近屏幕边缘时,反射信息不完整(屏幕外的内容无法被 SSR 捕获)。直接使用边缘处的命中结果会产生硬截断伪影。通过对 HitUV 施加边缘淡出遮罩缓解:
half EdgeFade(half2 hitUV)
{
half2 d = abs(hitUV - 0.5) * 2.0; // 归一化到 [0,1]
half2 fade = saturate((1.0 - d) / _FadeWidth);
return fade.x * fade.y;
}
_FadeWidth 控制淡出区域的宽度,通常取 0.1-0.2(屏幕宽度的 10-20%)。计算逻辑:d 表示命中点到屏幕中心的距离(归一化到 [0,1],1 为屏幕边缘)。(1-d)/_FadeWidth 在边缘处为 0,向内 _FadeWidth 距离处达到 1。X 和 Y 方向独立计算然后相乘,确保四个角落的衰减更强。
9.1 垂直方向差异化处理
屏幕上方和下方的边缘淡出行为可以不同:上方边缘通常对应天空(SSR 命中天空的反射是正确的,不需要淡出),下方边缘对应地面(可能是有效反射)。可以对 Y 方向做非对称淡出:
half yFade = hitUV.y < 0.5
? saturate(hitUV.y / _FadeWidthBottom)
: saturate((1.0 - hitUV.y) / _FadeWidthTop);
9.2 光线起点的边缘剔除
除了命中点的边缘淡出,光线起点接近屏幕边缘时也应减弱 SSR 贡献:从屏幕边缘发射的光线可追踪范围很短(很快就离开屏幕),命中率低且结果不可靠。在 RayCasting 阶段对光线起点做 Roughness-Weighted 边缘剔除:
half originEdgeMask = EdgeFade(uv) * lerp(1.0, 0.5, roughness);
if (originEdgeMask < 0.01) return half4(0,0,0,0); // 跳过追踪
淡出后的区域自动 Fallback 到 Cubemap 反射(通过 SSRMask 的衰减实现),保证视觉上无硬边。
9.3 完整的 Mask 合成
最终的 SSR Mask 综合了多个衰减因子:
half ComputeFinalSSRMask(half2 uv, half2 hitUV, half roughness, half hitStep)
{
half hitMask = tex2D(_SSR_RayCastRT0, uv).w; // 基础命中 mask (0 or 1)
half edgeFade = EdgeFade(hitUV); // 命中点边缘淡出
half originFade = EdgeFade(uv); // 起点边缘淡出
half stepFade = 1.0 - saturate((float)hitStep / MAX_STEPS * 1.5); // 远距离衰减
half roughnessFade = saturate(1.0 - (roughness - 0.8) * 5.0); // 高粗糙度淡出
return hitMask * edgeFade * originFade * stepFade * roughnessFade;
}
roughnessFade 在 roughness > 0.8 时开始衰减 SSR 贡献:极高粗糙度的表面反射过于模糊,SSR 与 Cubemap 几乎无法区分,此时 fallback 到更廉价的 Cubemap 采样可以节省 RayCasting 开销而不损失视觉质量。
十、后续规划
- 在无背面深度的条件下改善求交精度(考虑多层深度 peeling 或基于法线的厚度估算)
- 为 Forward 材质和透明物体实现简化版 SSR(半透明物体的反射需要在 Resolve 阶段混合透明背后的场景颜色,HiZ Trace 的独立函数封装为此提供了基础)
- 探索 Spatiotemporal Blue Noise 替代当前的 Cranley-Patterson 旋转方案,进一步提升收敛速度
- 评估 AMD FidelityFX SSSR 的 Wave-Level 优化技巧在本管线中的适用性






























