SSAO - Screen Space Ambient Occlusion 屏幕空间环境光遮蔽

今天实现了 SSAO(Screen Space Ambient Occlusion)——现代实时渲染管线中最重要的后处理技术之一。从 Crysis(2007)开始被引入游戏工业,此后成为 UE、Unity 等引擎的标配功能,也是《赛博朋克 2077》、《荒野大镖客2》等 AAA 游戏中真实感的重要来源。

本项目在纯 CPU + 软光栅化器上从零实现完整的 SSAO 管线,不依赖 GPU 或任何图形 API,完整掌控每个数学细节。

为什么需要 SSAO?

传统环境光的问题

实时渲染中,处理全局光照(Global Illumination)在计算上开销极大。最简单的近似是用一个常数环境光项代替所有间接光照:

1
ambient = albedo × k_a × L_ambient

这意味着场景中所有点受到同样强度的环境光,结果是球体底部贴地处和空旷的墙面获得完全相同的环境光强度——不真实,缺乏层次感。

现实中,环境光不是均匀到达的:

  • 狭窄缝隙里的光线被周围几何体阻挡
  • 凹陷区域(角落、裂缝)只能接收来自小立体角的间接光
  • 两个物体紧贴的接触面几乎没有间接光

这种遮蔽现象就是 Ambient Occlusion(AO)

AO 的物理含义

AO 是对某点 P 的法线半球上,被周围几何体遮挡的方向所占比例的度量:

1
AO(P) = 1/π × ∫_Ω V(ω) · (ω · n) dω

其中 V(ω) 是方向 ω 的可见函数(1=可见,0=被遮挡),(ω · n) 是余弦权重。

精确计算需要路径追踪,代价极高。SSAO 用一个关键近似:只在屏幕空间内,通过采样当前像素周围的深度信息来估计遮蔽


渲染管线设计

整个实现分为四个 Pass,展示了经典 Deferred Shading 的核心思想:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌──────────────────────────────────────────────────────┐
│ SSAO 渲染管线 │
│ │
│ Pass 1: 软光栅化 → G-Buffer │
│ 场景几何 → vsPos(视空间坐标) │
│ → vsNorm(视空间法线) │
│ → albedo(漫反射颜色) │
│ → zbuf(NDC 深度) │
│ ↓ │
│ Pass 2: SSAO 计算 │
│ 对每个有效像素: │
│ 噪声纹理 + G-Buffer法线 → TBN矩阵 │
│ 采样核 → 半球64个采样点 → 投影 → 深度比较 → aoRaw │
│ ↓ │
│ Pass 3: Box Blur 降噪 │
│ aoRaw → 4×4邻域平均 → aoBlur │
│ ↓ │
│ Pass 4: 最终着色 │
│ ambient × aoBlur + diffuse + specular → 输出 │
└──────────────────────────────────────────────────────┘

Pass 1:G-Buffer 构建

软光栅化器

项目使用纯 CPU 软光栅化器,实现了完整的几何管线:

坐标变换链World Space → View Space → Clip Space → NDC → Screen Space

1
2
3
4
5
6
7
8
9
// 顶点变换
Vec4 vsPos = viewMat * Vec4(worldPos, 1.0f); // 世界 → 视空间
Vec4 clipPos = projMat * vsPos; // 视空间 → 裁剪空间
// NDC: x/w, y/w, z/w
float ndcX = clipPos.x / clipPos.w;
float ndcY = clipPos.y / clipPos.w;
// 屏幕坐标(Y 轴翻转:NDC Y 向上,屏幕 Y 向下)
int px = (int)((ndcX * 0.5f + 0.5f) * W);
int py = (int)((-ndcY * 0.5f + 0.5f) * H); // Y轴翻转

法线变换:法线不能直接乘 ModelView 矩阵,需要使用法线矩阵(模型矩阵逆转置)。在只有刚体变换的场景中,旋转矩阵的逆转置等于它本身,因此 viewMat 的左上 3×3 可以直接用于变换法线方向:

1
2
3
4
5
6
// 法线变换(无缩放时 = 旋转部分,安全)
Vec3 vsNorm = Vec3{
viewMat.m[0][0]*n.x + viewMat.m[0][1]*n.y + viewMat.m[0][2]*n.z,
viewMat.m[1][0]*n.x + viewMat.m[1][1]*n.y + viewMat.m[1][2]*n.z,
viewMat.m[2][0]*n.x + viewMat.m[2][1]*n.y + viewMat.m[2][2]*n.z
}.normalize();

Cornell Box 场景

场景是一个 4×4×4 的 Cornell Box,共 8824 个三角形(高细分网格确保法线插值精度):

几何体 位置 颜色
地面(12×12 细分) y=0 浅灰
天花板 y=4 浅灰
后墙 z=-2 中灰
左墙 x=-2 红色
右墙 x=+2 绿色
大球(r=0.55) 中央 浅灰
2 个中球 前侧 蓝/橙
2 个小球 靠墙 红/绿
高盒(0.9×1.3×0.9) 左后 米黄
矮盒(1.1×0.6×0.9) 右前 米黄

有效像素:338,773 / 480,000(70.6%)。


Pass 2:SSAO 核心算法

采样核生成

SSAO 通过在法线半球内随机采样来估计遮蔽。这 64 个采样向量在离线阶段预生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void buildKernel(mt19937& rng) {
uniform_real_distribution<float> rnd(0,1), rnd11(-1,1);
for (int i = 0; i < KERNEL_SIZE; i++) {
Vec3 s = {rnd11(rng), rnd11(rng), rnd(rng)}; // z > 0,在半球内
s = s.normalize() * rnd(rng);

// 关键:加速插值(Accelerating Interpolation)
// 让采样点在靠近原点(fragPos)附近密集分布
// 物理意义:近距离遮蔽比远距离遮蔽更重要,更常见
float scale = float(i) / KERNEL_SIZE;
scale = 0.1f + scale * scale * 0.9f; // 二次曲线,尾部才扩展
kernel[i] = s * scale;
}
}

为什么用加速插值?

如果均匀分布采样半径,大量样本会落在离当前像素很远的位置,这些位置的遮蔽贡献与近距离相比往往弱得多,却消耗了宝贵的采样预算。二次曲线确保更多样本集中在 fragPos 附近的小半径内。

噪声纹理 + TBN 矩阵

核心问题:采样核是在切线空间(以法线为 Z 轴的局部坐标系)中定义的,但 G-Buffer 中的坐标在视空间(Camera Space)。需要通过 TBN 矩阵做空间变换。

直接随机旋转会增加噪声,SSAO 利用 4×4 小噪声纹理的平铺重复来引入微小随机旋转,同时保持 Blur Pass 后的连贯性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void buildNoise(mt19937& rng) {
uniform_real_distribution<float> rnd11(-1, 1);
for (int i = 0; i < NOISE_DIM * NOISE_DIM; i++)
// 噪声向量只在 XY 平面内旋转(绕 Z 轴),不影响法线方向
noise[i] = Vec3(rnd11(rng), rnd11(rng), 0).normalize();
}

// ---- 在 computeSSAO 中 ----

// 从 4×4 噪声纹理取随机旋转向量(tile 重复)
Vec3 randVec = noise[(py % NOISE_DIM) * NOISE_DIM + (px % NOISE_DIM)];

// Gram-Schmidt 正交化:构造切线 T,使 T ⊥ N
Vec3 tangent = (randVec - normal * normal.dot(randVec)).normalize();
Vec3 bitangent = normal.cross(tangent);
// TBN:[T, B, N] 三列,将切线空间向量变换到视空间

Gram-Schmidt 正交化的几何直觉

randVec - normal * (randVec · normal) 等价于从 randVec 中去除沿 normal 方向的分量,得到 randVec 在垂直于 normal 的平面上的投影。这个投影向量 T 就是切线,由于它在 normal 的垂直面内,满足 T · N = 0

遮蔽判断

这是整个算法最微妙的部分,也是最容易出 bug 的地方:

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
for (int i = 0; i < KERNEL_SIZE; i++) {
Vec3 s = kernel[i];
// 切线空间 → 视空间(TBN 乘以切线空间向量)
Vec3 sampleVS = tangent*s.x + bitangent*s.y + normal*s.z;
Vec3 samplePos = fragPos + sampleVS * SSAO_RADIUS; // 视空间采样点

// 将采样点投影到屏幕,取对应的 G-Buffer 真实深度
int sx, sy; float ndcZ;
if (!projectToPixel(samplePos, sx, sy, ndcZ)) continue;

float sceneZ = gbuf.vsPos[sidx].z; // 视空间 Z(负值,越负越远)

// ⚠️ 关键:视空间 Z 坐标判断方向
// 视空间中相机在原点,物体在 -Z 方向
// sceneZ = -3.5,samplePos.z = -3.8 → sceneZ > samplePos.z
// 意味着:场景真实表面比采样点更靠近相机
// → 采样点在真实表面的后面 → 被遮蔽 ✅
float occluded = (sceneZ > samplePos.z + SSAO_BIAS) ? 1.0f : 0.0f;

// 范围衰减:遮蔽源离当前像素越远,权重越低
float dist = fabsf(fragPos.z - sceneZ);
float rangeCheck = 1.0f - smoothstep(0.0f, SSAO_RADIUS, dist);

occlusion += occluded * rangeCheck;
}

aoRaw[idx] = 1.0f - (occlusion / KERNEL_SIZE);

BIAS 的作用:如果不加 BIAS,同一平面上的采样点由于浮点精度问题,可能被判定为”遮蔽了自己”(Self-shadowing Artifact),导致表面产生条纹状噪声。BIAS = 0.12f ≈ RADIUS × 0.15,给遮蔽判断留出一个小的容错区间。

一个常见错误:rangeCheck 不能用 fabsf(samplePos.z - sceneZ) 来计算,而必须用 fabsf(fragPos.z - sceneZ)。前者是采样点和场景深度的差,随 RADIUS 变化;后者才是遮蔽源与当前像素的实际距离,用于控制”远处遮蔽无效”的物理含义。


Pass 3:模糊降噪

4×4 噪声纹理意味着相邻像素使用不同的随机旋转方向,导致原始 SSAO 图有明显噪点。Box Blur 在一个 5×5 邻域(半径 R=2)内平均,消除高频噪声:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void blurAO() {
const int R = 2;
for (int py = 0; py < H; py++)
for (int px = 0; px < W; px++) {
float sum = 0; int n = 0;
for (int dy = -R; dy <= R; dy++)
for (int dx = -R; dx <= R; dx++) {
int nx = px+dx, ny = py+dy;
if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
sum += aoRaw[ny*W+nx]; n++;
}
aoBlur[py*W+px] = n > 0 ? sum/n : aoRaw[py*W+px];
}
}

为什么 4×4 噪声 + Blur 等价于更多采样数?

如果用 512 个采样核,可以在不引入噪声的情况下得到平滑结果,但性能下降约 8 倍。SSAO 的经典权衡是:少量采样(64)× 随机扰动(噪声纹理)× 空间滤波(Blur),在视觉质量相近的情况下大幅降低采样成本。这是实时渲染中「时空权衡(Temporal-Spatial Tradeoff)」思想的体现,TAA 也是类似逻辑。


Pass 4:最终合成

SSAO 只影响环境光项,不影响直接光照(diffuse + specular):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Vec3 shade(Vec3 vsPos, Vec3 vsNorm, Vec3 albedo, float ao) {
Vec3 L = (lightVS - vsPos).normalize();
Vec3 V = (-vsPos).normalize();
Vec3 H = (L + V).normalize();

float diff = max(0.0f, vsNorm.dot(L));
float spec = powf(max(0.0f, vsNorm.dot(H)), 64.0f);

// ✅ SSAO 只乘以 ambient:物理正确
Vec3 ambient = albedo * 0.55f * ao; // 有 AO:最低 0.55×0.627=0.345
Vec3 diffuse = albedo * diff * 0.65f;
Vec3 specular = Vec3(1,1,1) * spec * 0.25f;

return ambient + diffuse + specular;
}

为什么只影响 ambient?

从物理上看,SSAO 近似的是间接光(环境光/漫反射)被遮蔽的现象,不是直接光被遮蔽(那是 Shadow Map 的职责)。如果把 AO 乘在整个光照输出上,会错误地压暗直接光照强烈的区域,产生视觉上的不协调感(俗称”SSAO 晕”)。

最后应用 ACES 色调映射(防止高光过曝)+ Gamma 矫正(sRGB 显示器校正)。


量化验证结果

1
2
3
4
5
6
7
8
渲染分辨率:  800 × 600
三角形数: 8,824
有效像素: 338,773 / 480,000 (70.6%)

AO 范围: [0.627, 1.000]
平均: 0.991
平均遮蔽率: 0.9%(全局均值,开放空间较多)
最强遮蔽区: 63% AO → 环境光降低 37%

为什么平均遮蔽率只有 0.9%?

Cornell Box 是一个开放的室内空间,大部分表面都能看到大面积的天花板和墙壁,只有物体正下方的接触区域、墙角缝隙等处产生强遮蔽。全局平均低,但局部对比鲜明——这正是 SSAO 视觉价值的所在。


渲染结果

无 SSAO vs 有 SSAO 对比

对比图:左无SSAO,右有SSAO

左侧:纯 Blinn-Phong,所有点环境光相同;右侧:开启 SSAO,球体底部、盒子边缘、墙角处明显暗化

区域 变化
大球底部(接触地面) 接触阴影 ✅
高盒与左墙夹角 角落暗化 ✅
小球贴墙处 接触遮蔽 ✅
天花板中央 几乎不变(无遮蔽) ✅

SSAO 遮蔽贴图

SSAO Map - 灰度遮蔽图

灰度图:越暗 = AO 值越低 = 遮蔽越强。球体底部、高盒底角最暗。

单独对比

无 SSAO 有 SSAO
无SSAO 有SSAO

迭代历史与调试过程

迭代 1:多余括号导致编译失败

computeSSAO 函数的 for 循环里多了一对 {},使函数体末尾出现多余的 } 而报 expected declaration before '}' token

教训:复杂循环嵌套时,养成立即对齐缩进的习惯,IDE 的括号匹配高亮非常有用。

迭代 2:断言条件不匹配实际场景

原始断言 minAO < 0.5f 要求最深遮蔽超过 50%。但 Cornell Box 是开放室内空间,最深遮蔽只有 37.3%(minAO=0.627),并非代码错误,而是场景尺度决定的。

调试过程:用 -DNDEBUG 跳过断言先看数据,确认 AO 值范围和视觉效果后,将断言改为 minAO < 0.8f(符合实际场景特征)。

教训:断言阈值要根据场景特征标定,不能凭直觉设定”看起来合理”的数值。运行几组不同参数的对比实验,再定义合理范围。

遮蔽方向判断的坑

初期对 View Space Z 的方向理解有误:相机在原点,物体在 -Z 方向,所以 vsPos.z 是负数,越负越远。

1
2
3
4
5
错误版本(原始理解):
sceneZ >= samplePos.z + bias → 真实表面比采样点更远 → 采样点在表面前方 → 未遮蔽

正确版本(修复后):
sceneZ > samplePos.z + bias → 真实表面比采样点更靠近相机(z更大更接近0)→ 采样点在后方 → 被遮蔽

这类坐标系方向错误非常难靠肉眼排查,因为产生的视觉结果仍然是”有 SSAO 效果”,只是遮蔽区域反了。


SSAO 与相关技术对比

技术 原理 质量 性能 适用场景
SSAO 屏幕空间深度采样 实时游戏
HBAO 视角相关半球采样 中高 高端游戏
GTAO Ground Truth AO 次世代游戏
RTAO 光线追踪 AO 极高 极低 DXR/DLSS 辅助
光照贴图 离线烘焙 极高 极高 静态场景

SSAO 的核心优势是实时可用,主要局限:

  • 只能使用屏幕内的深度信息(屏幕边缘物体的遮蔽会丢失)
  • 对薄物体(草、头发)效果差
  • 高 RADIUS 时会产生”光晕”伪影(Halo Artifact)

技术总结

五个关键认知

  1. View Space Z 为负 — 相机在原点,物体在 -Z 方向。遮蔽判断 sceneZ > samplePos.z + bias 意味着场景面比采样点更靠近相机,即采样点在几何体后方。

  2. rangeCheck 必须用 fragPos.z — 不能用 samplePos.z - sceneZ,因为采样点位置本身受到法线和 RADIUS 影响;只有当前像素的 Z 和场景 Z 的差值才代表遮蔽源的真实距离。

  3. 噪声 + Blur 是采样效率的工程权衡 — 64 样本 × 4×4 噪声平铺 ≈ 5×5 邻域内 64×25=1600 次随机采样的统计期望,但只需 64+blur 的计算量。

  4. SSAO 只作用于 ambient — 对 diffuse/specular 同样乘 AO 是常见的错误做法,会产生视觉上不自然的整体压暗。

  5. 场景尺度决定参数 — SSAO_RADIUS 是视空间单位,场景坐标越大需要越大的 RADIUS。视空间中物体的 Z 坐标范围(-3 到 -7 左右)决定了合理的 RADIUS 量级(0.5 到 2.0)。

代码量

  • 核心代码:584 行 C++17
  • 运行时间:约 1.2 秒(CPU,800×600,单线程)
  • 无任何第三方依赖(除 stb_image_write.h)

代码仓库

GitHub: SSAO Screen Space Ambient Occlusion

编译运行

1
2
3
g++ -O2 -std=c++17 ssao.cpp -o ssao
./ssao
# 输出:ssao_off.png, ssao_on.png, ssao_map.png, ssao_comparison.png

完成时间: 2026-03-13 → 图片重新生成 2026-03-16
代码行数: 584 行 C++17
迭代次数: 4 次(含 2026-03-16 断言修复)
编译器: g++ -O2 -std=c++17