从零理解 SSAO:屏幕空间环境光遮蔽的原理与实现

📅 2026/8/24 8:10:16
从零理解 SSAO:屏幕空间环境光遮蔽的原理与实现
本文将带你彻底搞懂 SSAO 的核心思想逐行拆解 shader 中最关键的采样与深度比较并解释那张神秘的随机旋转噪声纹理到底在干什么。文末附完整可运行代码。一、SSAO 是什么先看它解决什么问题现实世界里物体的角落、缝隙、凹陷处总是更暗一些——因为这些地方被周围的几何体挡住了大部分环境光。但在实时渲染里简单的环境光ambient light通常是一个均匀的常数所有地方一样亮。结果就是画面发平、缺乏立体感角落该暗的地方不暗。SSAOScreen Space Ambient Occlusion屏幕空间环境光遮蔽就是用来近似这种角落变暗效果的技术。它的核心思想一句话对每个像素在它周围的半球空间内撒一堆采样点看有多少点被几何体挡住了。挡得越多说明这里越是角落/缝隙就越暗。它的名字里屏幕空间意味着它不需要知道整个场景的信息只利用已经渲染到屏幕上的深度和法线数据来估算遮蔽——所以又快又通用。二、准备工作需要什么输入SSAO 是一个后处理post-process跑在几何渲染之后。它依赖一个 G-Buffer 提供的两张纹理外加一张噪声纹理输入纹理 ├── gPosition → 每个像素的【视图空间坐标】(view space position) ├── gNormal → 每个像素的【视图空间法线】(view space normal) └── texNoise → 一张 4×4 的随机旋转噪声纹理⚠️关键位置和法线必须是**视图空间view space**的而非世界空间。因为遮蔽的深度比较要在相机视角下进行。三、核心算法拆解3.1 采样核预先生成的半球方向我们要在像素周围的半球里撒点探测遮蔽。这些点不是运行时随机生成的太慢而是CPU 端预先生成 64 个方向向量传进 shader。它们分布在一个指向 Z 的单位半球内Z半球朝向 │ · ·│· · ← 采样点分布在半球内 · · │ · · 且越靠近中心越密集 ─────●───── 切线平面CPU 端生成代码Cstd::vectorglm::vec3ssaoKernel;for(unsignedinti0;i64;i){glm::vec3sample(randomFloats(-1.0,1.0),// x随机randomFloats(-1.0,1.0),// y随机randomFloats(0.0,1.0));// z 只取正 → 半球而非整球sampleglm::normalize(sample);sample*randomFloats(0.0,1.0);// 撒进球体内部而非只在球面// 让采样点更集中于原点附近近处遮蔽更重要floatscale(float)i/64.0f;scalelerp(0.1f,1.0f,scale*scale);sample*scale;ssaoKernel.push_back(sample);}两个关键设计z只取正值 → 得到的是半球而非整球只关心表面外侧用scale * scale做加权 → 让采样点更靠近中心因为近距离的遮蔽对结果贡献更大3.2 随机旋转噪声纹理SSAO 的省钱魔法这张4×4 的极小纹理是 SSAO 里非常巧妙的设计。理解它得先明白一个现实矛盾想要好效果又不想付出高代价。它是什么一张 4×4 纹理里面存的不是颜色而是随机的旋转向量texNoise4×416个像素每个存一个随机方向 ┌────┬────┬────┬────┐ │ ↗ │ ↖ │ → │ ↘ │ ├────┼────┼────┼────┤ │ ← │ ↘ │ ↗ │ ↑ │ ← 每个格子存一个随机方向 ├────┼────┼────┼────┤ │ ↑ │ → │ ↙ │ ← │ ├────┼────┼────┼────┤ │ ↘ │ ↑ │ ↖ │ ↗ │ └────┴────┴────┴────┘CPU 端生成std::vectorglm::vec3ssaoNoise;for(unsignedinti0;i16;i)// 4×4 16 个{glm::vec3noise(randomFloats(-1.0,1.0),// x随机randomFloats(-1.0,1.0),// y随机0.0f);// z 0只在切线平面内旋转ssaoNoise.push_back(noise);}为什么需要它理想情况下每个像素应撒上千个采样点才够准但那太慢。我们只用64 个代价是采样不足会产生瑕疵。如果所有像素都用完全相同的采样核方向每个像素采样图案高度一致 ↓ 瑕疵在画面上对齐排列 ↓ 出现规则的、显眼的【条带/波纹】(banding)加入随机旋转后每个像素的采样半球被随机转了个角度没有随机旋转 有随机旋转 ▓▓▓░░░▓▓▓░░░ ▓░▓░░▓░▓░▓ ▓▓▓░░░▓▓▓░░░ ← 规则条带 ░▓░▓▓░▓░░▓ ← 打散成杂乱噪点 ▓▓▓░░░▓▓▓░░░ ▓░░▓░▓▓░▓░ ↑ ↑ 一眼看出是假的 杂乱但可后期抹平它的核心职责把规则的、无法消除的条带变成杂乱的、可以用模糊轻松抹掉的噪点。配合后续的模糊 Pass随机旋转把瑕疵变成高频噪点 ↓ 模糊 Pass把高频噪点抹平 ↓ 最终效果看起来像用了上千采样点实际只用了 64 个本质随机旋转 模糊 用欺骗视觉的方式花 64 个采样的钱买到接近上千采样的效果。一个精妙细节为什么是 4×4 且要平铺噪声纹理会在全屏反复平铺重复周期正好是 4×4。而后续模糊 Pass 也用 4×4 的核——二者尺寸精心对齐模糊能干净地抹掉噪声图案不留残影。3.3 完整 SSAO Fragment Shader可运行#version 330 core out float FragColor; // 输出单通道遮蔽值 (0遮蔽/暗, 1无遮蔽/亮) in vec2 TexCoords; // 当前像素的屏幕UV坐标 // ---- G-Buffer 输入 ---- uniform sampler2D gPosition; // 视图空间位置 uniform sampler2D gNormal; // 视图空间法线 uniform sampler2D texNoise; // 随机旋转噪声纹理 // ---- 采样核 参数 ---- uniform vec3 samples[64]; // 半球采样核CPU端预生成 uniform mat4 projection; // 投影矩阵 // ---- 可调参数 ---- int kernelSize 64; // 采样点数量 float radius 0.5; // 采样半径 float bias 0.025; // 深度偏移防止自遮蔽 // 噪声纹理平铺缩放屏幕1280x720、噪声4x4 const vec2 noiseScale vec2(1280.0/4.0, 720.0/4.0); void main() { // ① 取出当前像素的位置、法线、随机向量 vec3 fragPos texture(gPosition, TexCoords).xyz; vec3 normal normalize(texture(gNormal, TexCoords).rgb); vec3 randomVec normalize(texture(texNoise, TexCoords * noiseScale).xyz); // ② 构建 TBN 矩阵把采样核旋转对齐到当前像素法线 vec3 tangent normalize(randomVec - normal * dot(randomVec, normal)); vec3 bitangent cross(normal, tangent); mat3 TBN mat3(tangent, bitangent, normal); // ③ 遍历采样核逐个采样并做深度比较 float occlusion 0.0; for(int i 0; i kernelSize; i) { // (a) 把采样点从切线空间转到视图空间得到实际采样位置 vec3 samplePos TBN * samples[i]; samplePos fragPos samplePos * radius; // (b) 把这个视图空间采样点投影回屏幕求它的UV vec4 offset vec4(samplePos, 1.0); offset projection * offset; // 视图空间 → 裁剪空间 offset.xyz / offset.w; // 透视除法 → NDC [-1,1] offset.xyz offset.xyz * 0.5 0.5; // NDC → UV [0,1] // (c) 在该UV处读取G-Buffer里真实几何的深度 float sampleDepth texture(gPosition, offset.xy).z; // (d) 深度比较 范围检查 float rangeCheck smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth)); occlusion (sampleDepth samplePos.z bias ? 1.0 : 0.0) * rangeCheck; } // ④ 归一化并反转遮蔽越多 → 值越小 → 越暗 occlusion 1.0 - (occlusion / kernelSize); FragColor occlusion; }四、核心一采样怎么写逐行讲【第②步】TBN 矩阵——让半球贴合表面vec3 tangent normalize(randomVec - normal * dot(randomVec, normal)); vec3 bitangent cross(normal, tangent); mat3 TBN mat3(tangent, bitangent, normal);采样核是朝Z的半球但每个像素的法线方向都不同TBN 矩阵的作用把标准朝上的半球旋转到与当前像素法线对齐randomVec引入随机旋转让相邻像素采样方向各异就是噪声纹理的功劳第一行用了Gram-Schmidt 正交化从随机向量里减掉法线方向的分量得到一个垂直于法线的切线。【第③(a)步】算出真实采样位置vec3 samplePos TBN * samples[i]; // 切线空间 → 视图空间对齐法线 samplePos fragPos samplePos * radius; // 以当前像素为中心按半径撒出去此刻samplePos就是我想去探测的那个空间位置。【第③(b)步】投影回屏幕——采样的灵魂vec4 offset vec4(samplePos, 1.0); offset projection * offset; // 视图空间 → 裁剪空间 offset.xyz / offset.w; // 透视除法 → NDC [-1,1] offset.xyz offset.xyz * 0.5 0.5; // NDC → UV [0,1]这段是 SSAO 里最容易懵的地方它在做一件事把一个 3D 采样点拍扁到 2D 屏幕上求出它对应的 UVsamplePos视图空间的3D点 │ × projection ↓ 裁剪空间坐标带 w 分量 │ ÷ w 透视除法 ↓ NDC 坐标范围 [-1, 1] │ × 0.5 0.5 ↓ UV 坐标范围 [0, 1] ← 就能拿去查纹理了为什么要这么做因为 G-Buffer 是 2D 纹理我必须知道这个 3D 采样点如果拍到屏幕上会落在哪个像素才能去查那个位置的真实几何深度。五、核心二深度比较怎么写逐行讲【第③©步】读取该处的真实深度float sampleDepth texture(gPosition, offset.xy).z;现在我们手上有两个深度值变量含义samplePos.z我想探测的采样点的深度半球里那个虚拟点sampleDepth该屏幕位置上真实几何体的深度【第③(d)步】比较——遮蔽判断的核心occlusion (sampleDepth samplePos.z bias ? 1.0 : 0.0) * rangeCheck;先看核心比较视图空间里相机看向 -Z值越大越靠近相机情况A采样点在几何体【前面】没被挡 相机 → ●采样点(samplePos.z 更近) 真实表面(sampleDepth 更远) → sampleDepth samplePos.z → 不遮蔽加 0 情况B采样点在几何体【后面】被挡住了 相机 → 真实表面(sampleDepth 更近) ●采样点(samplePos.z 更远) → sampleDepth samplePos.z → 遮蔽加 1大白话我朝表面外撒了个探测点。如果这个点比该方向上真实几何体的表面还要深被埋进实体里了说明这个方向被挡住 → 计一次遮蔽。bias的作用加一个小偏移防止自遮蔽surface acne。由于浮点精度问题平坦表面上的采样点可能被误判为被自己挡住产生难看的暗纹bias把阈值推开一点消除误判。range check——防止隔空遮蔽float rangeCheck smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));解决什么当前景物体和背景墙深度差很大时背景像素采样可能撞到远处前景被误判为遮蔽导致前景边缘出现一圈黑边。abs(fragPos.z - sampleDepth)深度差差距越大 →rangeCheck越接近 0 →这次遮蔽被削弱甚至忽略效果只有距离相近的表面才算真正遮蔽六、最后一步归一化 反转occlusion 1.0 - (occlusion / kernelSize); FragColor occlusion;occlusion / kernelSize64 个采样累加取平均归一化到 [0,1]1.0 - ...反转 → 遮蔽多角落输出暗遮蔽少开阔输出亮七、别忘了模糊 Pass单帧 SSAO 因为用了随机旋转会有噪点。标准做法是再跑一个模糊 Pass对 4×4 邻域取平均#version 330 core out float FragColor; in vec2 TexCoords; uniform sampler2D ssaoInput; void main() { vec2 texelSize 1.0 / vec2(textureSize(ssaoInput, 0)); float result 0.0; for (int x -2; x 2; x) for (int y -2; y 2; y) { vec2 offset vec2(float(x), float(y)) * texelSize; result texture(ssaoInput, TexCoords offset).r; } FragColor result / 16.0; // 4×416 个样本取平均 }因为噪声纹理是 4×4 平铺的模糊 4×4 正好把随机图案抹平。八、完整流程回顾1. G-Buffer Pass → 输出视图空间的 位置 法线 ↓ 2. SSAO Pass → 半球采样 深度比较 → 遮蔽值有噪点 ↓ 3. Blur Pass → 4×4 模糊 → 抹平噪点 ↓ 4. Lighting Pass → 环境光 × 遮蔽值 → 角落变暗画面立体九、核心逐行速记【采样】 撒点 → samples[i]预生成的半球方向 对齐 → TBN * samples[i]旋转到贴合法线 定位 → fragPos ... * radius视图空间采样点 投影 → projection → /w → *0.50.5拍扁到屏幕UV 【深度比较】 读真值 → sampleDepth texture(gPosition, uv).z 判遮蔽 → sampleDepth samplePos.z bias ? 1 : 0 采样点被埋进实体 被遮蔽 防误判 → bias消自遮蔽 防隔空 → rangeCheck远近物体不互相影响十、延伸一类通用的图形学技巧SSAO 里随机旋转 模糊的思路本质是**“随机扰动 后期滤波”**在图形学里无处不在技术随机扰动后期处理SSAO随机旋转采样核模糊软阴影 PCF抖动采样偏移多样本平均蒙特卡洛光追随机光线方向降噪器 (denoiser)TAA 抗锯齿抖动相机时间累积它们都在解决同一个问题采样不够时用随机化把结构性瑕疵转成随机噪声再滤掉。理解了 SSAO你其实已经摸到了现代实时渲染中一大类算法的门道。