每日编程实践: SSR - Screen Space Reflections 屏幕空间反射
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 | ┌─────────────────────────────────────────────┐ |
渲染管线设计
1 | Pass 1: 软光栅化 → G-Buffer |
Pass 1:G-Buffer 构建
场景设计
场景由 5 个球体 + 大面积近镜面地板构成,专门设计用来展示 SSR 效果:
1 | // 材质参数: albedo, roughness, metalness |
设计要点:
- 地板 roughness=0.02 → 几乎完全镜面,SSR 效果最明显
- 金色球 metalness=1.0 → Fresnel F0 极高,掠射角时近乎全反射
- 绿色球 roughness=0.08 但 metalness=0 → 电介质,反射较弱
G-Buffer 球体光栅化
球体光栅化使用参数化球面三角剖分(经纬线法),将球体细分为 (64 stacks × 64 slices) 的三角网格,通过软光栅化写入 G-Buffer:
1 | void rasterizeSphere(const Sphere& s, const Mat4& view, const Mat4& proj, |
Pass 2:Blinn-Phong 光照
基础光照采用 Blinn-Phong 模型,支持金属度影响高光颜色(金属表面高光带颜色,电介质高光为白色):
1 | Vec3 shade(Vec3 posVS, Vec3 normVS, Vec3 albedo, float roughness, float metalness, |
金属 vs 电介质的本质区别:
- 金属:自由电子直接吸收光子能量,几乎无漫反射,高光颜色来自材料本身(金色金属 → 金色高光)
- 电介质(玻璃、塑料、皮肤):折射入材料内部散射后漫反射,高光为白色(能量守恒)
Pass 3:SSR Ray Marching 核心
这是整个系统最精密的部分。
反射方向计算
1 | Vec3 posVS = gb.posVS[id]; // 视空间位置 |
坐标系注意:视空间中相机在原点,posVS 是从原点指向表面的向量(z 为负)。viewVS = -posVS.normalize() 才是”从表面指向相机”的方向。
线性步进 + 二分搜索精化
1 | bool ssrTrace(Vec3 posVS, Vec3 refVS, ..., float& hitU, float& hitV) |
为什么需要 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 | float fresnelSchlick(float cosTheta, float F0) { |
物理直觉:
站在一池静水边看自己的脚下(几乎垂直向下),水几乎完全透明,看不到多少反射。退几步,以更小角度(趋近水平)看水面,水面变成近乎完美的镜子。这就是 Fresnel 效应——掠射角时几乎所有电介质材质都会产生强反射,包括哑光塑料、混凝土、皮肤。
衰减策略
两种衰减防止 SSR 边界出现突兀的硬截断:
1 | // 1. 屏幕边缘衰减(反射命中点越靠近屏幕边缘,权重越低) |
屏幕边缘衰减的必要性:
SSR 最大的局限是屏幕外的物体无法被反射——当反射光线射出屏幕边界时,没有颜色信息可采样。如果直接截断,边缘会出现明显的硬边。通过在命中点靠近屏幕边缘时逐渐降低权重,可以将这种”信息缺失”的截断软化为平滑过渡。
Pass 4:合成
1 | for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) { |
最后应用 ACES 色调映射 + Gamma 矫正输出。
量化验证结果
1 | SSR hit pixels: 42,698 |
验证设计的坑:
初版验证逻辑是”SSR 区域比无 SSR 区域更亮”——这个假设完全错误。
地板(主要 SSR 区域)向上反射的是暗蓝色的天空背景,而无 SSR 时地板颜色由 ambient + diffuse 决定,偏向暖白色。所以开启 SSR 后,地板区域反而变暗了(反射了比自身更暗的天空色)。
最终改为检测”SSR 活跃区域的颜色变化幅度”(用 fabsf 而非 >0),只要有变化即为有效。
经验:不能对渲染结果的”方向”做假设,只能验证”有变化”。
渲染结果
无反射 vs SSR 对比

左:仅 Blinn-Phong 光照;右:添加 SSR
明显变化:
- 地板出现 5 个彩球的倒影,且靠近球体底部的反射最清晰(Fresnel 掠射角)
- 金色金属球(metalness=1.0)在地板上的反射颜色最饱和
- 漫反射绿球(metalness=0)的反射很弱,几乎看不到倒影
SSR 最终输出

Fresnel 反射权重遮罩

亮度 = 反射权重。可见:地板靠近球体底部(掠射角最强)亮度最高,金属球周围权重明显大于漫反射球。
迭代历史与调试过程
迭代 1:编译错误 × 2
问题 A:Vec3 缺少一元负号运算符 operator-()。
反射方向计算中 (-viewVS).reflect(normVS) 需要取反,但初始版本 Vec3 没有实现 operator-() const,导致编译报错。
修复:
1 | Vec3 operator-() const { return {-x, -y, -z}; } |
问题 B:aces() 函数中写了 Vec3 / Vec3(逐分量除法),但 Vec3 只定义了 Vec3 / float,未定义向量除法运算符。
修复:
1 | // 错误版本(自动推断不存在 Vec3/Vec3) |
教训:重用代码时,检查运算符是否完整。C++ 不会自动推断 A/B = (A.x/B.x, A.y/B.y, A.z/B.z),必须显式重载。
迭代 2:验证逻辑错误(概念假设失误)
初版验证:
1 | // 错误:假设SSR区域比无SSR更亮 |
实际:地板的 SSR 反射的是深色天空背景,开启 SSR 后地板整体变暗。
修复:
1 | // 正确:检测颜色是否发生了变化(不假设方向) |
迭代 3:球体表面圆形亮斑(自交 + 起点偏移不足)
现象:红色球体和蓝色球体表面出现不自然的圆形亮斑,颜色明显与周围不同。
根因 A:起点偏移不足
1 | // ❌ 原来:固定偏移 0.1f,对半径 ~1.0 的球体约为 1/10 直径 |
偏移太小时,反射光线还在球体内部就开始步进,第一步就可能命中球体自身的 G-Buffer,采样到错误颜色叠加为亮斑。
根因 B:缺少命中后的自交验证
步进命中后没有检查”命中的是不是自己”,早期用 UV 距离过滤(uvDist < 0.08),但 UV 距离在球体侧面不可靠——球体侧面自交的 UV 距离可能 > 0.08(过滤失效),而地面合法命中的 UV 距离反而可能 < 0.08(误杀),两个方向都判断错。
最终修复方案:命中后双重检测
1 | Vec3 hitNorm = gb.normal[hitId]; |
教训:SSR 自交不能靠单一指标(UV 距离、深度差、法线方向)判断,需要组合:几何距离 + 法线朝向 + 背面检测三合一才可靠。定量验证(diff 图分析)是定位亮斑根因的最有效手段。
迭代 4:黄球倒影不连续(过滤过度 + 地面反射丢失)
现象:左下角黄色球体左半边有红球的反射倒影,但倒影有明显断层;同时地面反射也消失了。
根因:UV 距离过滤阈值 0.08 过大,把黄球侧面的合法命中(反射到红球,UV 距离约 0.1~0.15)和地面合法命中都误判为自交丢弃。
1 | 过滤过度的后果: |
修复:将 UV 距离阈值降到 0.03(仅防护极近起点),主要依靠命中后双重检测(迭代 3 的方案)。
验证结果(差值图分析):
1 | 红球区域 SSR 异常贡献:0(蓝球)/ 74 像素(黄球边缘反射红球,正常物理行为) |
固有局限(无法完全解决)
屏幕外信息缺失:是 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) |
减少漏光,更准确 | 过小会导致命中率骤降(厚物体误过) |
技术总结
五个关键认知
SSR 是视空间的 Ray Marching,不是世界空间,不是光线追踪——不需要 BVH,只需要 G-Buffer,这是它快速的根本原因。
反射方向必须在视空间计算 —
V = (-posVS).normalize(),注意 posVS 是从原点指向表面(z 为负),取反后才是指向相机。厚度约束 (thickness) 是防误报的关键 —
depthDiff < thickness确保只对薄层交叉(真实表面)命中,过滤掉深入物体内部的假命中。Fresnel 在掠射角永远强 — 即使 metalness=0 的漫反射材质,在接近水平的掠射角时也有接近 1.0 的反射权重。这是物理准确的,不是 bug。
验证不能假设颜色方向 — 反射天空背景时场景变暗,反射强光时变亮,
fabsf检测变化幅度比方向判断更可靠。
代码规模
- 核心代码:736 行 C++17
- 运行时间:0.316 秒(CPU,800×600,4 Pass)
- 无 GPU,无图形 API,完全软件渲染
代码仓库
GitHub: SSR Screen Space Reflections
编译运行:
1 | # 需要 stb_image_write.h(放在同目录) |
完成时间: 2026-03-16 05:35(博客重写 + 修复迭代 2026-03-17)
代码行数: 736 行 C++17
迭代次数: 4 次(编译×2 + 验证逻辑 + 自交亮斑×2轮)
运行时间: 0.316 秒(800×600,4 Pass,单线程 CPU)










