每日编程实践: Screen Space Ambient Occlusion (SSAO)
背景与动机
为什么需要环境光遮蔽?
在传统 Phong/Blinn-Phong 光照模型中,环境光(Ambient)是一个常量——整个场景里所有点的环境光照贡献完全相同,不管这个点是在空旷的平台中央,还是被夹在两堵墙的角落里。这在物理上显然是错误的:角落里的点被周围几何体遮挡,从半球方向射来的间接光理应更少。
没有环境光遮蔽的场景会显得”扁平”、”塑料感强”——所有表面的暗部都亮得不自然。加上 AO 之后,角落变暗、物体底部变暗、缝隙变暗,整体立体感和质感都会有质的提升。
工业界的实际使用场景
Ambient Occlusion 的概念最早由 Zhukov 等人在 1998 年提出,但直到 Crytek 在 2007 年 GDC 上介绍 Screen Space Ambient Occlusion (SSAO) 之后才真正走入实时渲染的主流——当时是为了给《孤岛危机》服务的。
今天,AO 技术在各大引擎里几乎无处不在:
- Unity:有 SSAO(URP/HDRP 均支持),HDRP 还有基于射线的 RTAO
- Unreal Engine:内置 SSAO,后来又加了 GTAO(Ground Truth Ambient Occlusion)
- Frostbite(EA):用 SSAO + 预烘焙 Bent Normal 的组合
- 游戏案例:《赛博朋克 2077》、《荒野大镖客》、《古墓丽影》等 AAA 游戏均启用了 AO
核心吸引力在于:SSAO 只需要 G-Buffer 中的深度和法线,不需要光线追踪,可以在单帧内完成,性能代价极低(通常 0.5~2ms per frame on GPU)。
SSAO 的本质问题
SSAO 本质上是在屏幕空间近似计算这样一个积分:
1 | AO(p) = (1/π) ∫_Ω V(p, ω) cos(θ) dω |
其中 V(p, ω) 是可见性函数(0 = 被遮挡,1 = 可见),θ 是样本方向与法线的夹角。
这个积分的真正答案需要射线追踪或预烘焙,SSAO 用屏幕空间深度比较来近似 V(p, ω)——用已有的深度缓冲来判断某个采样点是否”被遮挡”。这当然不精确,但速度够快,视觉上够用。
核心原理
1. G-Buffer:存储什么信息
SSAO 需要在一个单独的 Pass 中访问几何信息,因此必须先渲染一个 G-Buffer:
| 通道 | 内容 | 用途 |
|---|---|---|
| Depth | view-space 线性深度 | 重建 view-space 位置,做深度比较 |
| Normal | view-space 法线(单位向量) | 定义 TBN 矩阵,限定半球采样方向 |
| Position | view-space 3D 位置 | 偏移采样点 |
| Albedo | 漫反射颜色 | 用于最终着色 |
注意所有数据都在 view space(相机坐标系)而非 world space,这样 SSAO 的计算更简单——相机就在原点,深度比较直观。
2. 半球采样核(Kernel)
SSAO 的核心是在片元法线方向的切线空间半球内随机采若干个样本点,然后把这些点投影回屏幕空间,查看对应深度值与样本的 view-space 深度的大小关系。
采样核的生成:
1 | Vec3 sample = { |
然后做”加速插值”——让更多样本集中在靠近片元的区域(小范围遮蔽更重要):
1 | float scale = float(i) / numSamples; |
这种分布的直觉是:越靠近表面的遮挡物越有意义——远处的物体对局部 AO 贡献极小,但如果均匀分布,远处样本会浪费大量采样预算。
为什么是切线空间?
如果直接在 view space 采样,不同片元的朝向不同,半球方向不对——比如一个朝上的地板和一个朝右的墙,它们的法线不同,但我们希望采样始终在法线方向的半球内。切线空间保证了采样核与法线对齐。
TBN 矩阵构建:
1 | T = normalize(randomVec - N × (N·randomVec)) // Gram-Schmidt 正交化 |
其中 randomVec 是来自随机噪声纹理的旋转向量,用于打破规律性,防止出现带状/轮廓状的 banding artifacts。
3. 深度比较与遮蔽判断
对于每个样本点 samplePos(view space),我们:
- 将其投影到裁剪空间,再转换到屏幕坐标 (sx, sy)
- 在 G-Buffer 的深度通道中查询 (sx, sy) 处的实际深度值
gbufferDepth - 如果
gbufferDepth >= samplePos.z + bias,说明此样本点位于真实几何体”内部”,即被遮挡
数学上:在 view space 中,z 轴朝相机方向为正(经过我的坐标系设置),更靠近相机的物体有更大(更正)的深度值。如果查询到的 G-Buffer 深度比样本点的深度更靠近相机,说明样本点处有实际几何体阻挡,贡献遮蔽。
Range Check(范围检查):
1 | float rangeCheck = 1.0f - clamp(abs(fragPos.z - gbufferDepth) / radius, 0.0, 1.0); |
如果 G-Buffer 中查询到的深度与当前片元差距很大(远超采样半径),说明这个”遮蔽”很可能来自无关的背景几何体(比如一个球体通过球心看过去,球的正背面都会产生虚假遮蔽)。Range Check 将这种远距离的遮蔽贡献逐渐衰减为 0。
Bias(偏置):
加一个小的 bias 值是为了防止自遮蔽(self-occlusion)——法线偏差导致采样点非常接近表面,使得 G-Buffer 中的深度比样本点略深,产生错误的遮蔽。常见值 0.025~0.05。
最终遮蔽值:
1 | occlusion = Σ(rangeCheck) / numSamples // 0 = 完全不遮蔽, 1 = 完全遮蔽 |
4. 模糊 Pass
原始 SSAO 的结果非常噪声——因为随机核只有 64 个样本,采样数不足,加上 4×4 噪声纹理的随机旋转。直接使用会显出明显的颗粒感。
模糊策略:简单的 Box Blur(5×5 均值滤波)可以显著平滑噪声,同时 SSAO 本身的低频特性(AO 值变化不剧烈)决定了模糊不会损失太多高频细节。
更高质量的做法是 Bilateral Blur(双边模糊)——在颜色/深度边缘处减小模糊半径,防止 AO 从一个物体”渗透”到相邻物体。本实现使用 Box Blur 作为简化版本。
5. 最终合成
1 | finalColor = albedo * ambientIntensity * (1 - occlusion * 0.85) // AO 影响环境光 |
注意:AO 只影响环境光,不影响直接光的漫反射/高光。这在物理上是合理的——AO 近似的是来自各方向的间接光(已经被多次弹射的光),而直接光来自确定方向,其遮挡应该由阴影算法(Shadow Mapping 等)来处理。
实现架构
整体渲染管线
1 | CPU Side (软光栅): |
关键数据结构
GBuffer:
1 | struct GBuffer { |
深度存储的是 view-space 正深度(-z in view space,因为相机看向 -z 方向),这样深度比较时”更靠近相机 = 更大的值”,逻辑更直观。
场景几何体:
1 | struct Vertex { |
为了保持代码简洁,这里不区分材质,直接在 Vertex 里存 albedo。更完整的实现会有 MaterialID + 材质数组。
投影矩阵约定:
采用 OpenGL 风格的右手坐标系,view space 中 -Z 朝前(相机看向 -Z 方向),Y 朝上,X 朝右。perspective() 函数生成的矩阵中 m[3][2] = -1,在变换后 w = -z(即正值)。
软光栅化的设计权衡
本项目没有使用 OpenGL/Vulkan,而是用 CPU 软光栅来演示 SSAO 的完整流程。这样做的好处是:
- 所有细节一目了然,不需要处理 Shader 语言和 API 调用
- 坐标系变换可以单步调试
- 便于理解 G-Buffer 的生成方式
代价是性能差(800×600 光栅化 + SSAO 约 2~5 秒),但对于教学目的完全可接受。
关键代码解析
三角形光栅化与 G-Buffer 填充
光栅化的核心是**重心坐标(Barycentric Coordinates)**插值。对于屏幕空间的像素点 P,计算其相对于三角形三顶点的权重 w0, w1, w2(通过叉积面积比):
1 | auto edge = [](const Vec3& a, const Vec3& b, const Vec3& p) { |
透视正确插值是一个经常被忽略的细节。直接用 w0/w1/w2 插值会在透视投影下产生错误(仿射插值的纹理会扭曲),必须在插值时除以 view-space 的深度:
1 | // 1/w(view-space 深度的倒数)在屏幕空间中是线性的 |
这里的数学原理是:透视除法使得屏幕空间坐标 = clip space / w,而 1/w 在屏幕空间中是线性的(w 本身不是)。所以先插值 1/w,再乘以 depth 恢复原始 view-space 属性。
深度测试:保留距相机最近(最大 depth 值)的片元:
1 | if(depth < gb.depth[idx]) continue; // 当前片元更远,丢弃 |
SSAO 核心循环
1 | void computeSSAO(GBuffer& gb, const Mat4& proj, |
关键细节解析:
TBN(kernel[i])将切线空间的半球样本旋转对齐到片元法线方向。没有这步,所有片元的采样半球方向相同(比如全部朝上),朝侧面的墙面就不会产生正确遮蔽。noise[(py%4)*4 + (px%4)]是 4×4 平铺的随机旋转——不同像素用不同的随机旋转,采样核的方向各异,打破规律性,避免 banding。w_ = -samplePos.z:view space 中 z 为负值(相机看 -Z 方向),所以-z才是正的深度值,与投影矩阵的约定一致(m[3][2] = -1即 w = -z)。- bias = 0.05:实测发现不加 bias 会有明显的自遮蔽黑点,特别是在平坦表面上(法线对齐不精确时)。
采样核生成的加速插值
1 | std::vector<Vec3> genSSAOKernel(int n, std::mt19937& rng) { |
scale = lerp(0.1, 1.0, t²) 的效果:第 0 个样本的 scale = 0.1(紧靠片元),第 63 个样本的 scale = 1.0(最远)。这样 64 个样本中约有一半集中在 0~0.5 半径范围内,对局部遮蔽的捕捉更精细。
踩坑实录
Bug 1:深度比较逻辑反向导致全白或全黑
症状:整个右半屏幕几乎全白(ssao 值为 0),或者全黑(ssao 值为 1)。
错误假设:以为 view-space depth 是负值(z 朝后的右手系),depth 比较用 sampleDepth <= -samplePos.z + bias。
真实原因:在这套软光栅实现中,存储的 GBuffer.depth 是 -vp.z(正值,靠相机 = 大),而 samplePos.z 也是直接来自 view-space(负值)。所以 -samplePos.z 才是正的深度值,与 gb.depth 的符号匹配。比较方向是:如果 gbufferDepth >= -samplePos.z + bias,说明 G-Buffer 里的几何体在样本点”后面”(深度更大 = 更靠相机),即遮挡了样本点。
修复:反复用 cout 打印 fragPos.z、samplePos.z、sampleDepth 的实际数值,确认符号和量级后修正比较逻辑。
教训:SSAO 的深度比较非常容易因坐标系约定而出错。每次遇到全亮/全暗的结果,先打印 3~5 个像素的实际深度值,确认数值符号。
Bug 2:unused variable 编译警告
症状:g++ -Wall -Wextra 报 warning: unused variable 'z_'。
原因:在手动实现投影变换时,写了四行(x’, y’, z’, w’),但 SSAO Pass 中只用了 x’/w’ 和 y’/w’ 来计算屏幕坐标,z’ 不需要(我们直接用 view-space 的 -samplePos.z 做深度比较,而不是 clip-space z)。
修复:直接删除 z_ 那一行。
教训:写手动矩阵变换时,先明确每个分量是否真的被用到,避免写出 dead code。
Bug 3:图像右半整体比左半暗但不明显
症状:运行后发现右半(SSAO)与左半(无SSAO)差距很小,几乎看不出来。
错误假设:以为 radius = 0.5 够用了。
真实原因:场景的 Cornell Box 尺寸是 ±5(half-extent = 5),而 SSAO radius = 0.5 相对场景太小,只能探测非常近的遮蔽,效果微弱。
修复:将 radius 从 0.5 调大到 1.5,效果明显增强(左右均值差从 ~2 增大到 ~16)。
量化对比:
| radius | 左均值 | 右均值 | 差值 | 效果 |
|---|---|---|---|---|
| 0.5 | 73.2 | 71.0 | 2.2 | 几乎不可见 |
| 1.0 | 73.2 | 65.1 | 8.1 | 可见但不明显 |
| 1.5 | 73.2 | 57.6 | 15.6 | 明显遮蔽效果 |
教训:SSAO 的 radius 必须与场景尺度匹配,不能使用固定的”经验值”。
Bug 4:Valid pixels = W×H(背景也被算为有效像素)
症状:验证脚本输出 Valid pixels: 480000/480000,而明显场景不会覆盖全部像素。
分析:GBuffer 初始化时 depth = -1e9(极小值),在 shade 函数中当 depth < -1e8 时返回背景色,但这些背景像素在 SSAO 计算中被 if(gb.depth[idx] < -1e8f) continue 正确跳过——只是我的”有效像素计数”统计逻辑有问题,实际上 480000 = 800×600 是总像素数,而有效像素数更接近 480000 - 背景像素数。
分析后判断:这个 Bug 不影响视觉效果,只是统计信息不准确。可以用 gb.depth[idx] > -1e8f 过滤背景像素。
保留:因为对最终输出无影响,保留原始统计,下次改进。
效果验证与数据
输出图像统计
1 | 文件大小:1.4 MB(> 10KB ✅) |
左右对比数据
| 区域 | 像素均值亮度 | 说明 |
|---|---|---|
| 左半(无SSAO) | 73.26 | 普通 Blinn-Phong 光照 |
| 右半(有SSAO) | 57.58 | AO 遮蔽环境光,整体变暗 |
| 差值 | 15.68 | SSAO 导致 ~21% 亮度降低 |
渲染性能(CPU 单线程)
| 阶段 | 耗时 |
|---|---|
| 场景构建(3332 三角形) | < 10ms |
| G-Buffer 光栅化 | ~300ms |
| SSAO(64 样本,800×600) | ~1.5s |
| Box Blur(5×5) | ~50ms |
| 最终着色 + 输出 | ~200ms |
| 总计 | ~2s |
SSAO 是最慢的阶段,480000 像素 × 64 样本 = 约 3100 万次深度采样,CPU 单线程情况下约 1.5 秒。GPU 实现(fragment shader)通常 < 1ms。
视觉对比
下图左半为普通 Phong 光照,右半为加入 SSAO 后的结果(黄线为分界):

可以观察到:
- 右半的墙角交接处明显更暗(主要 AO 效果区域)
- 高柱子底部和球体底部变暗(与地板接触区域)
- 红墙/绿墙与天花板、地板的夹角处 产生明显遮蔽
- 空旷区域(如天花板中央)AO 值接近 0,亮度基本不变
总结与延伸
SSAO 的局限性
- 屏幕边缘误差:采样点投影到屏幕外时无法获取深度信息,边缘附近的遮蔽不准确
- 深度不连续性:锐利边缘(前景物体 vs 背景)处,背景的深度信息不该影响前景的 AO
- 法线精度依赖:如果 G-Buffer 的法线不准确(比如低多边形模型),TBN 矩阵也会偏差
- 无法捕捉远处遮蔽:radius 限制了只能探测局部邻域,大规模遮蔽(如室内 vs 室外)无法体现
- 非物理:SSAO 只是近似,不能用于物理准确渲染
改进方向
- HBAO(Horizon-Based AO):沿水平方向搜索遮挡视角,比 SSAO 更物理准确,减少半球采样数量
- GTAO(Ground Truth AO):更接近真实半球积分,Unreal Engine 5 使用
- RTAO(Ray Traced AO):RTX 硬件加速,完全物理准确但有性能代价
- Bilateral Blur:比 Box Blur 更好,保留边缘、防止 AO 跨越不同物体渗透
- Temporal AA + SSAO:利用时序累积更多样本,降低单帧采样数量(从 64 降到 8-16)
- Interleaved Sampling:不同像素使用不同偏移的采样核子集,配合 Denoiser 重建
与本系列其他文章的关联
本系列已经实现了多种光照技术,SSAO 补充了”近似间接光遮蔽”这一环:
| 日期 | 技术 | 类型 |
|---|---|---|
| 03-20 | 球谐函数环境光照 | 间接光(远场) |
| 03-21 | SPPM 光子映射 | 全局光照(精确) |
| 03-22 | BDPT 双向路径追踪 | 全局光照(精确) |
| 03-23 | Voxel Cone Tracing | 间接光(近场,实时近似) |
| 03-24 | 次表面散射 | 材质特效 |
| 03-25 | SSAO | 环境光遮蔽(屏幕空间近似) |
SSAO 在”精确度 vs 性能”的谱系上处于最低成本端,适合所有实时应用;光子映射/BDPT 在精确端,适合离线渲染。实际游戏引擎往往两者都要——用 SSAO 做实时 AO,用烘焙的 AO Map(离线路径追踪生成)来补充精度。
今天这个项目用纯 CPU 软光栅实现了完整的 SSAO 渲染管线,从 G-Buffer 构建、半球采样、深度比较、模糊,到最终合成,每个环节的代码都是亲手写的,没有依赖任何图形 API。这种”手写”的方式最适合学习——遇到任何 Bug 都可以直接打断点、打印中间值,没有 GPU 调试的黑盒感。
下一步可能方向:HBAO 实现(对 SSAO 的直接改进),或者 Temporal Reprojection(利用上一帧数据,降低当前帧采样成本)。
代码仓库:daily-coding-practice/2026/03/03-25-ssao
系列索引:每日编程实践合集











