每日编程实践: SSAO - Screen Space Ambient Occlusion 屏幕空间环境光遮蔽
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 | ┌──────────────────────────────────────────────────────┐ |
Pass 1:G-Buffer 构建
软光栅化器
项目使用纯 CPU 软光栅化器,实现了完整的几何管线:
坐标变换链:World Space → View Space → Clip Space → NDC → Screen Space
1 | // 顶点变换 |
法线变换:法线不能直接乘 ModelView 矩阵,需要使用法线矩阵(模型矩阵逆转置)。在只有刚体变换的场景中,旋转矩阵的逆转置等于它本身,因此 viewMat 的左上 3×3 可以直接用于变换法线方向:
1 | // 法线变换(无缩放时 = 旋转部分,安全) |
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 | void buildKernel(mt19937& rng) { |
为什么用加速插值?
如果均匀分布采样半径,大量样本会落在离当前像素很远的位置,这些位置的遮蔽贡献与近距离相比往往弱得多,却消耗了宝贵的采样预算。二次曲线确保更多样本集中在 fragPos 附近的小半径内。
噪声纹理 + TBN 矩阵
核心问题:采样核是在切线空间(以法线为 Z 轴的局部坐标系)中定义的,但 G-Buffer 中的坐标在视空间(Camera Space)。需要通过 TBN 矩阵做空间变换。
直接随机旋转会增加噪声,SSAO 利用 4×4 小噪声纹理的平铺重复来引入微小随机旋转,同时保持 Blur Pass 后的连贯性:
1 | void buildNoise(mt19937& rng) { |
Gram-Schmidt 正交化的几何直觉:
randVec - normal * (randVec · normal) 等价于从 randVec 中去除沿 normal 方向的分量,得到 randVec 在垂直于 normal 的平面上的投影。这个投影向量 T 就是切线,由于它在 normal 的垂直面内,满足 T · N = 0。
遮蔽判断
这是整个算法最微妙的部分,也是最容易出 bug 的地方:
1 | for (int i = 0; i < KERNEL_SIZE; i++) { |
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 | void blurAO() { |
为什么 4×4 噪声 + Blur 等价于更多采样数?
如果用 512 个采样核,可以在不引入噪声的情况下得到平滑结果,但性能下降约 8 倍。SSAO 的经典权衡是:少量采样(64)× 随机扰动(噪声纹理)× 空间滤波(Blur),在视觉质量相近的情况下大幅降低采样成本。这是实时渲染中「时空权衡(Temporal-Spatial Tradeoff)」思想的体现,TAA 也是类似逻辑。
Pass 4:最终合成
SSAO 只影响环境光项,不影响直接光照(diffuse + specular):
1 | Vec3 shade(Vec3 vsPos, Vec3 vsNorm, Vec3 albedo, float ao) { |
为什么只影响 ambient?
从物理上看,SSAO 近似的是间接光(环境光/漫反射)被遮蔽的现象,不是直接光被遮蔽(那是 Shadow Map 的职责)。如果把 AO 乘在整个光照输出上,会错误地压暗直接光照强烈的区域,产生视觉上的不协调感(俗称”SSAO 晕”)。
最后应用 ACES 色调映射(防止高光过曝)+ Gamma 矫正(sRGB 显示器校正)。
量化验证结果
1 | 渲染分辨率: 800 × 600 |
为什么平均遮蔽率只有 0.9%?
Cornell Box 是一个开放的室内空间,大部分表面都能看到大面积的天花板和墙壁,只有物体正下方的接触区域、墙角缝隙等处产生强遮蔽。全局平均低,但局部对比鲜明——这正是 SSAO 视觉价值的所在。
渲染结果
无 SSAO vs 有 SSAO 对比

左侧:纯 Blinn-Phong,所有点环境光相同;右侧:开启 SSAO,球体底部、盒子边缘、墙角处明显暗化
| 区域 | 变化 |
|---|---|
| 大球底部(接触地面) | 接触阴影 ✅ |
| 高盒与左墙夹角 | 角落暗化 ✅ |
| 小球贴墙处 | 接触遮蔽 ✅ |
| 天花板中央 | 几乎不变(无遮蔽) ✅ |
SSAO 遮蔽贴图

灰度图:越暗 = AO 值越低 = 遮蔽越强。球体底部、高盒底角最暗。
单独对比
| 无 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 | 错误版本(原始理解): |
这类坐标系方向错误非常难靠肉眼排查,因为产生的视觉结果仍然是”有 SSAO 效果”,只是遮蔽区域反了。
SSAO 与相关技术对比
| 技术 | 原理 | 质量 | 性能 | 适用场景 |
|---|---|---|---|---|
| SSAO | 屏幕空间深度采样 | 中 | 高 | 实时游戏 |
| HBAO | 视角相关半球采样 | 中高 | 中 | 高端游戏 |
| GTAO | Ground Truth AO | 高 | 低 | 次世代游戏 |
| RTAO | 光线追踪 AO | 极高 | 极低 | DXR/DLSS 辅助 |
| 光照贴图 | 离线烘焙 | 极高 | 极高 | 静态场景 |
SSAO 的核心优势是实时可用,主要局限:
- 只能使用屏幕内的深度信息(屏幕边缘物体的遮蔽会丢失)
- 对薄物体(草、头发)效果差
- 高 RADIUS 时会产生”光晕”伪影(Halo Artifact)
技术总结
五个关键认知
View Space Z 为负 — 相机在原点,物体在 -Z 方向。遮蔽判断
sceneZ > samplePos.z + bias意味着场景面比采样点更靠近相机,即采样点在几何体后方。rangeCheck 必须用 fragPos.z — 不能用
samplePos.z - sceneZ,因为采样点位置本身受到法线和 RADIUS 影响;只有当前像素的 Z 和场景 Z 的差值才代表遮蔽源的真实距离。噪声 + Blur 是采样效率的工程权衡 — 64 样本 × 4×4 噪声平铺 ≈ 5×5 邻域内 64×25=1600 次随机采样的统计期望,但只需 64+blur 的计算量。
SSAO 只作用于 ambient — 对 diffuse/specular 同样乘 AO 是常见的错误做法,会产生视觉上不自然的整体压暗。
场景尺度决定参数 — 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 | g++ -O2 -std=c++17 ssao.cpp -o ssao |
完成时间: 2026-03-13 → 图片重新生成 2026-03-16
代码行数: 584 行 C++17
迭代次数: 4 次(含 2026-03-16 断言修复)
编译器: g++ -O2 -std=c++17













