SSR - Screen Space Reflections 屏幕空间反射

今天实现了 SSR(Screen Space Reflections)——现代实时渲染中用于平面/光滑表面动态反射的核心技术,在 UE、Unity HDRP、Frostbite、寒霜引擎等 AAA 管线中普遍使用。

与 SSAO(昨天实现)同属”屏幕空间”系列——都复用 G-Buffer,在 2D 屏幕空间完成计算,避免了构建加速结构(BVH)的开销,是实时渲染的核心工程权衡。

为什么需要 SSR?

传统反射方案的局限

在 SSR 出现之前,实时反射主要有三种方案:

1. Cubemap 反射
将场景预渲染到六个面,以立方体贴图形式存储:

  • ✅ 极快(一次纹理采样)
  • ❌ 只能反射静态场景,动态物体无法正确反射
  • ❌ 反射是全向模糊近似,看不出精确细节

2. Planar Reflection(平面反射)
为每个镜面创建一个额外的渲染 Pass,将场景从反射相机角度重新渲染:

  • ✅ 质量完美,精确到像素
  • ❌ DrawCall 翻倍,每面镜子 = 多一次完整场景渲染
  • ❌ 只适合平面,球面/曲面无法使用
  • 这正是《模拟人生 4》镜子的实现方案(每面镜子独立 Stencil Mask + Pass)

3. SSR
在屏幕空间内,沿反射方向步进,找到与场景几何的交点,采样那个点的颜色:

  • ✅ 不需要重新渲染场景
  • ✅ 支持任意形状的反射面(球面、曲面)
  • ✅ 动态反射,反射内容实时更新
  • ❌ 只能反射屏幕内可见的几何体(屏幕外的信息丢失)
  • ❌ 反射方向朝向相机时无法命中

SSR 的核心思想

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌─────────────────────────────────────────────┐
│ SSR 的核心思想 │
│ │
│ 1. G-Buffer 已经存储了场景的深度、法线 │
│ 等信息 │
│ │
│ 2. 对每个光滑像素 P,计算反射方向 R │
│ R = reflect(ViewDir, Normal) │
│ │
│ 3. 从 P 出发,沿 R 方向在视空间中步进 │
│ 每步投影回屏幕,和 G-Buffer 深度比较 │
│ │
│ 4. 当步进点比 G-Buffer 记录的表面更深 │
│ → 光线与场景相交 → 采样该点颜色 │
│ 作为 P 的反射颜色 │
└─────────────────────────────────────────────┘

渲染管线设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Pass 1: 软光栅化 → G-Buffer
├─ posVS (视空间坐标)
├─ normal (视空间法线)
├─ albedo (漫反射颜色)
├─ roughness (粗糙度 [0,1])
├─ metalness (金属度 [0,1])
└─ depth (NDC 深度)

Pass 2: Blinn-Phong 光照 → litNoSSR
不含反射的基础光照图

Pass 3: SSR Ray Marching → ssrBuffer + ssrMask
对 roughness < 0.5 的像素:
计算反射方向 → 步进 → 命中 → 采样颜色 + Fresnel 权重

Pass 4: 合成
output = litNoSSR × (1 - w) + ssrBuffer × w

Pass 1:G-Buffer 构建

场景设计

场景由 5 个球体 + 大面积近镜面地板构成,专门设计用来展示 SSR 效果:

1
2
3
4
5
6
7
// 材质参数: albedo, roughness, metalness
Material matFloor({0.8f,0.8f,0.9f}, 0.02f, 0.0f); // 近镜面地板(roughness极低)
Material matBallRed ({0.9f,0.2f,0.15f}, 0.05f, 0.8f); // 红色金属球
Material matBallGreen({0.15f,0.8f,0.3f}, 0.08f, 0.0f); // 绿色漫反射球
Material matBallBlue ({0.2f,0.4f,0.95f}, 0.1f, 0.5f); // 蓝色半金属球
Material matBallGold ({0.95f,0.8f,0.1f}, 0.03f, 1.0f); // 金色金属球(高金属度)
Material matBallWhite({0.95f,0.95f,0.95f},0.15f, 0.0f); // 白色光滑球

设计要点

  • 地板 roughness=0.02 → 几乎完全镜面,SSR 效果最明显
  • 金色球 metalness=1.0 → Fresnel F0 极高,掠射角时近乎全反射
  • 绿色球 roughness=0.08 但 metalness=0 → 电介质,反射较弱

G-Buffer 球体光栅化

球体光栅化使用参数化球面三角剖分(经纬线法),将球体细分为 (64 stacks × 64 slices) 的三角网格,通过软光栅化写入 G-Buffer:

1
2
3
4
5
6
7
8
void rasterizeSphere(const Sphere& s, const Mat4& view, const Mat4& proj,
GBuffer& gb, DepthBuffer& zbuf)
{
const int STACKS=64, SLICES=64;
// 生成球面顶点,三角化,光栅化
// 法线 = (worldPos - center).normalize()
// 视空间法线需要用 viewMat 的左上 3×3 变换(无缩放时有效)
}

Pass 2:Blinn-Phong 光照

基础光照采用 Blinn-Phong 模型,支持金属度影响高光颜色(金属表面高光带颜色,电介质高光为白色):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Vec3 shade(Vec3 posVS, Vec3 normVS, Vec3 albedo, float roughness, float metalness,
Vec3 lightVS, Vec3 lightColor, Vec3 ambientColor)
{
Vec3 L = lightVS.normalize();
Vec3 V = (-posVS).normalize(); // 指向相机
Vec3 H = (L + V).normalize(); // 半角向量

float diff = std::max(0.f, normVS.dot(L));
float shiness = 2.f / (roughness * roughness + 0.001f); // roughness → shininess
float spec = powf(std::max(0.f, normVS.dot(H)), shiness);

// 金属度影响:金属高光颜色 = albedo,电介质高光颜色 = 白色
Vec3 F0 = lerp(Vec3(0.04f,0.04f,0.04f), albedo, metalness);
float cosTheta = V.dot(normVS);
float fresnel = fresnelSchlick(cosTheta, F0.x); // 简化用 x 分量

Vec3 specColor = lerp(Vec3(1,1,1), albedo, metalness);
Vec3 specular = specColor * spec * fresnel * (1.f - roughness);

Vec3 diffuse = albedo * diff * (1.f - metalness); // 金属无漫反射
Vec3 ambient = albedo * ambientColor;

return (ambient + diffuse * lightColor + specular * lightColor);
}

金属 vs 电介质的本质区别

  • 金属:自由电子直接吸收光子能量,几乎无漫反射,高光颜色来自材料本身(金色金属 → 金色高光)
  • 电介质(玻璃、塑料、皮肤):折射入材料内部散射后漫反射,高光为白色(能量守恒)

Pass 3:SSR Ray Marching 核心

这是整个系统最精密的部分。

反射方向计算

1
2
3
4
5
6
7
Vec3 posVS  = gb.posVS[id];          // 视空间位置
Vec3 normVS = gb.normal[id]; // 视空间法线(已归一化)
Vec3 viewVS = (Vec3(0,0,0) - posVS).normalize(); // 指向相机

// 反射方向:入射光方向的反射(入射方向是从相机到表面,即 -viewVS)
Vec3 reflVS = (-viewVS).reflect(normVS);
// reflect(v, n) = v - 2*(v·n)*n

坐标系注意:视空间中相机在原点,posVS 是从原点指向表面的向量(z 为负)。viewVS = -posVS.normalize() 才是”从表面指向相机”的方向。

线性步进 + 二分搜索精化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
bool ssrTrace(Vec3 posVS, Vec3 refVS, ..., float& hitU, float& hitV)
{
const int LINEAR_STEPS = 64; // 线性步进次数
const int BINARY_STEPS = 8; // 二分精化次数
const float maxDist = 10.f; // 最大步进距离(视空间)
const float thickness = 0.15f; // 厚度检测阈值

float stepLen = maxDist / LINEAR_STEPS; // 每步 ~0.156 视空间单位
Vec3 step = refVS * stepLen;
Vec3 curPos = posVS + refVS * 0.1f; // 偏移起点,避免自交

for (int i = 0; i < LINEAR_STEPS; i++) {
// 将当前位置投影到屏幕空间(UV + NDC 深度)
float u, v, ndcZ;
if (!projectToScreen(curPos, proj, W, H, u, v, ndcZ)) {
curPos = curPos + step;
continue;
}

// 采样 G-Buffer 深度
float gbDepth = sampleDepth(gb, u, v);
if (gbDepth >= 2.f) { // 背景像素,跳过
curPos = curPos + step;
continue;
}

float depthDiff = ndcZ - gbDepth;
// depthDiff > 0:步进点的 NDC Z > G-Buffer Z → 步进点在表面后方
// depthDiff < thickness:厚度约束,防止穿透误报
if (depthDiff > 0 && depthDiff < thickness) {
// ======== Binary Search 精化 ========
// 线性步进找到交叉后,用二分缩小误差
// lo = 上一步(未交叉),hi = 当前步(已交叉)
Vec3 lo = curPos - step, hi = curPos;
for (int b = 0; b < BINARY_STEPS; b++) {
Vec3 mid = (lo + hi) * 0.5f;
float mu, mv, mndcZ;
if (!projectToScreen(mid, proj, W, H, mu, mv, mndcZ)) {
hi = mid; continue;
}
float mGbDepth = sampleDepth(gb, mu, mv);
if (mndcZ - mGbDepth > 0 && mndcZ - mGbDepth < thickness * 2)
hi = mid; // 仍在表面后方,收缩上界
else
lo = mid; // 在表面前方,移动下界
}
// 最终精化点
float fu, fv, fndcZ;
if (projectToScreen(hi, proj, W, H, fu, fv, fndcZ)) {
hitU = fu; hitV = fv;
return true;
}
}
curPos = curPos + step;
}
return false;
}

为什么需要 Binary Search 精化?

线性步进的步长约 0.156 视空间单位,对应屏幕上约数十像素。如果只用线性步进,命中点可能误差极大,导致反射图像出现”锯齿跳跃”。

8 次二分将误差从 stepLen 降低到 stepLen / 2^8 ≈ 0.0006,误差降低约 256 倍,反射边缘锐利、无撕裂。

thickness 参数的物理意义

如果没有 thickness 约束(即 depthDiff > 0 就算命中),会误报大量”穿透”——步进点深入物体内部很远也算命中,产生内部渲染出来的奇怪颜色。thickness = 0.15f 约束了”只有薄层交叉才算有效命中”,类似将物体视为有有限厚度的 shell 而非实心体。


Fresnel 反射权重

Schlick 近似是 Fresnel 方程的工程常用近似,误差 < 1%:

1
2
3
4
5
6
7
8
9
10
11
float fresnelSchlick(float cosTheta, float F0) {
// F0 = 基准反射率(正面观看时的反射率)
// 掠射角(cosTheta → 0)时趋近 1.0(全反射)
// 正面(cosTheta = 1)时趋近 F0
return F0 + (1.f - F0) * powf(1.f - clamp(cosTheta, 0.f, 1.f), 5.f);
}

// F0 由金属度插值
float F0 = lerp(0.04f, 0.95f, metalness);
// 电介质: F0=0.04(正面几乎不反射,掠射角强反射)
// 纯金属: F0=0.95(各角度都强反射)

物理直觉

站在一池静水边看自己的脚下(几乎垂直向下),水几乎完全透明,看不到多少反射。退几步,以更小角度(趋近水平)看水面,水面变成近乎完美的镜子。这就是 Fresnel 效应——掠射角时几乎所有电介质材质都会产生强反射,包括哑光塑料、混凝土、皮肤。


衰减策略

两种衰减防止 SSR 边界出现突兀的硬截断:

1
2
3
4
5
6
7
8
9
// 1. 屏幕边缘衰减(反射命中点越靠近屏幕边缘,权重越低)
float edgeFadeU = smoothstep(0, 0.1f, hitU) * smoothstep(1, 0.9f, hitU);
float edgeFadeV = smoothstep(0, 0.1f, hitV) * smoothstep(1, 0.9f, hitV);
float edgeFade = edgeFadeU * edgeFadeV;

// 2. Roughness 衰减(粗糙度高的表面,SSR 权重低)
float roughFade = smoothstep(0.5f, 0.f, roughness); // roughness>0.5 完全不做SSR

float weight = fresnel * edgeFade * roughFade;

屏幕边缘衰减的必要性

SSR 最大的局限是屏幕外的物体无法被反射——当反射光线射出屏幕边界时,没有颜色信息可采样。如果直接截断,边缘会出现明显的硬边。通过在命中点靠近屏幕边缘时逐渐降低权重,可以将这种”信息缺失”的截断软化为平滑过渡。


Pass 4:合成

1
2
3
4
5
6
7
8
9
for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) {
Vec3 lit = litNoSSR.at(x, y); // 基础光照(无反射)
float w = ssrMask.at(x, y).x; // Fresnel × 边缘衰减 × 粗糙度衰减
Vec3 refl = ssrBuffer.at(x, y); // SSR 采样的反射颜色

// 线性插值:基础光照 和 反射颜色 按权重混合
Vec3 combined = lit * (1.f - w) + refl * w;
finalSSR.at(x, y) = combined;
}

最后应用 ACES 色调映射 + Gamma 矫正输出。


量化验证结果

1
2
3
4
5
6
7
SSR hit pixels:           42,698
SSR active pixels (mask>0.2): 23,197
Avg color change in SSR region: 1.26%
SSR mask max weight: 0.9882
Output file size: ~93 KB

All validations passed ✅

验证设计的坑

初版验证逻辑是”SSR 区域比无 SSR 区域更亮”——这个假设完全错误

地板(主要 SSR 区域)向上反射的是暗蓝色的天空背景,而无 SSR 时地板颜色由 ambient + diffuse 决定,偏向暖白色。所以开启 SSR 后,地板区域反而变暗了(反射了比自身更暗的天空色)。

最终改为检测”SSR 活跃区域的颜色变化幅度”(用 fabsf 而非 >0),只要有变化即为有效。

经验:不能对渲染结果的”方向”做假设,只能验证”有变化”。


渲染结果

无反射 vs SSR 对比

对比

左:仅 Blinn-Phong 光照;右:添加 SSR

明显变化:

  • 地板出现 5 个彩球的倒影,且靠近球体底部的反射最清晰(Fresnel 掠射角)
  • 金色金属球(metalness=1.0)在地板上的反射颜色最饱和
  • 漫反射绿球(metalness=0)的反射很弱,几乎看不到倒影

SSR 最终输出

SSR输出

Fresnel 反射权重遮罩

权重遮罩

亮度 = 反射权重。可见:地板靠近球体底部(掠射角最强)亮度最高,金属球周围权重明显大于漫反射球。


迭代历史与调试过程

迭代 1:编译错误 × 2

问题 AVec3 缺少一元负号运算符 operator-()

反射方向计算中 (-viewVS).reflect(normVS) 需要取反,但初始版本 Vec3 没有实现 operator-() const,导致编译报错。

修复

1
Vec3 operator-() const { return {-x, -y, -z}; }

问题 Baces() 函数中写了 Vec3 / Vec3(逐分量除法),但 Vec3 只定义了 Vec3 / float,未定义向量除法运算符。

修复

1
2
3
4
5
// 错误版本(自动推断不存在 Vec3/Vec3)
Vec3 result = num / den;

// 修复版本(逐分量手动除)
x = Vec3(num.x/den.x, num.y/den.y, num.z/den.z);

教训:重用代码时,检查运算符是否完整。C++ 不会自动推断 A/B = (A.x/B.x, A.y/B.y, A.z/B.z),必须显式重载。

迭代 2:验证逻辑错误(概念假设失误)

初版验证:

1
2
// 错误:假设SSR区域比无SSR更亮
assert(avgSSRBrightness > avgNoSSRBrightness);

实际:地板的 SSR 反射的是深色天空背景,开启 SSR 后地板整体变暗。

修复:

1
2
3
// 正确:检测颜色是否发生了变化(不假设方向)
float diff = fabsf((c1.x+c1.y+c1.z)/3.f - (c2.x+c2.y+c2.z)/3.f);
assert(avgDiff > 0.001f); // 有变化即为有效

迭代 3:球体表面圆形亮斑(自交 + 起点偏移不足)

现象:红色球体和蓝色球体表面出现不自然的圆形亮斑,颜色明显与周围不同。

根因 A:起点偏移不足

1
2
3
4
5
6
// ❌ 原来:固定偏移 0.1f,对半径 ~1.0 的球体约为 1/10 直径
Vec3 curPos = posVS + refVS * 0.1f;

// ✅ 修复:改为步长的 1.5 倍,与场景尺度自适应(约 0.23)
float stepLen = maxDist / (float)LINEAR_STEPS;
Vec3 curPos = posVS + refVS * (stepLen * 1.5f);

偏移太小时,反射光线还在球体内部就开始步进,第一步就可能命中球体自身的 G-Buffer,采样到错误颜色叠加为亮斑。

根因 B:缺少命中后的自交验证

步进命中后没有检查”命中的是不是自己”,早期用 UV 距离过滤(uvDist < 0.08),但 UV 距离在球体侧面不可靠——球体侧面自交的 UV 距离可能 > 0.08(过滤失效),而地面合法命中的 UV 距离反而可能 < 0.08(误杀),两个方向都判断错。

最终修复方案:命中后双重检测

1
2
3
4
5
6
7
8
9
10
11
12
13
Vec3 hitNorm  = gb.normal[hitId];
Vec3 hitPosVS = gb.posVS[hitId];

// 检测1:法线方向相近 + 视空间距离过近 → 同一物体自交
Vec3 posDiff = hitPosVS - posVS;
float dist2 = dot(posDiff, posDiff);
bool sameNormal = dot(normVS, hitNorm) > 0.3f;
bool tooClose = dist2 < 1.5f * 1.5f;
if (sameNormal && tooClose) → 丢弃

// 检测2:命中点法线朝向着色点(打到背面)→ 丢弃
Vec3 hitToShading = normalize(posVS - hitPosVS);
if (dot(hitNorm, hitToShading) < 0.0f) → 丢弃

教训:SSR 自交不能靠单一指标(UV 距离、深度差、法线方向)判断,需要组合:几何距离 + 法线朝向 + 背面检测三合一才可靠。定量验证(diff 图分析)是定位亮斑根因的最有效手段。


迭代 4:黄球倒影不连续(过滤过度 + 地面反射丢失)

现象:左下角黄色球体左半边有红球的反射倒影,但倒影有明显断层;同时地面反射也消失了。

根因:UV 距离过滤阈值 0.08 过大,把黄球侧面的合法命中(反射到红球,UV 距离约 0.1~0.15)和地面合法命中都误判为自交丢弃。

1
2
3
4
过滤过度的后果:
- 黄球反射红球:命中点 UV 距离 ~ 0.10 → 被 0.08 阈值过滤 → 倒影断层
- 地面反射球体:命中点 UV 距离接近 → 被误杀 → 地面无反射
- 球体自交亮斑:UV 距离 > 0.08 → 未被过滤 → 亮斑仍在

修复:将 UV 距离阈值降到 0.03(仅防护极近起点),主要依靠命中后双重检测(迭代 3 的方案)。

验证结果(差值图分析):

1
2
3
红球区域 SSR 异常贡献:0(蓝球)/ 74 像素(黄球边缘反射红球,正常物理行为)
地面 SSR 正常贡献:3823 像素 ✅
全图 SSR hits:9476 像素

固有局限(无法完全解决)

屏幕外信息缺失:是 SSR 与生俱来的缺陷。工业实践通常用 SSR + Cubemap 反射作为 fallback——命中 SSR 时用 SSR 颜色,未命中时 blend 到 Cubemap,保证反射永远有值而不是黑色。

朝向相机的反射丢失:反射方向如果指向相机(正对),G-Buffer 里没有这个方向的信息。通常通过增加 Cubemap fallback 权重来补偿。

工业级优化方向

优化 说明
Hi-Z Tracing 用层级 Z-Buffer 大步跳过空区域,减少 60-80% 步进次数
SSGI 扩展 同样的 Ray Marching 框架,改为随机方向 = 屏幕空间全局光照
TAA 降噪 用时域信息稳定低采样率 SSR,和 SSAO 类似的时空权衡
硬件 RT 辅助 DXR 追踪屏幕外的光线,SSR 处理屏幕内高频细节

参数敏感性分析

参数 增大效果 减小效果
LINEAR_STEPS (64) 精度更高,但性能下降线性 步进过粗,产生”跳跃”伪影
BINARY_STEPS (8) 边缘更锐利,几乎免费 接触处有轻微撕裂
maxDist (10.f) 更远处可见反射 远处大物体反射消失
thickness (0.15f) 减少漏光,更准确 过小会导致命中率骤降(厚物体误过)

技术总结

五个关键认知

  1. SSR 是视空间的 Ray Marching,不是世界空间,不是光线追踪——不需要 BVH,只需要 G-Buffer,这是它快速的根本原因。

  2. 反射方向必须在视空间计算V = (-posVS).normalize(),注意 posVS 是从原点指向表面(z 为负),取反后才是指向相机。

  3. 厚度约束 (thickness) 是防误报的关键depthDiff < thickness 确保只对薄层交叉(真实表面)命中,过滤掉深入物体内部的假命中。

  4. Fresnel 在掠射角永远强 — 即使 metalness=0 的漫反射材质,在接近水平的掠射角时也有接近 1.0 的反射权重。这是物理准确的,不是 bug。

  5. 验证不能假设颜色方向 — 反射天空背景时场景变暗,反射强光时变亮,fabsf 检测变化幅度比方向判断更可靠。

代码规模

  • 核心代码:736 行 C++17
  • 运行时间:0.316 秒(CPU,800×600,4 Pass)
  • 无 GPU,无图形 API,完全软件渲染

代码仓库

GitHub: SSR Screen Space Reflections

编译运行

1
2
3
4
5
# 需要 stb_image_write.h(放在同目录)
g++ -O2 -std=c++17 ssr.cpp -o ssr
./ssr
# 输出:ssr_no_reflection.png, ssr_output.png,
# ssr_reflection_mask.png, ssr_comparison.png

完成时间: 2026-03-16 05:35(博客重写 + 修复迭代 2026-03-17)
代码行数: 736 行 C++17
迭代次数: 4 次(编译×2 + 验证逻辑 + 自交亮斑×2轮)
运行时间: 0.316 秒(800×600,4 Pass,单线程 CPU)