SSAO、半球采样与环境光遮蔽边界
SSAO、半球采样与环境光遮蔽边界:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释 环境光遮蔽 为什么让墙角、缝隙、物体接触处变暗才更真实(纯常量环境光把这些地方照得和平面一样亮、显假),以及 SSAO 怎么用 G-buffer 的位置/法线在 屏幕空间 近似估出「每个片元被周围挡了多少」
- 能改出 SSAO 的「接触处出暗角」效果:在 demo 里 开关 SSAO 看球与地面、球与球接触处的暗角有无,再调 半径 看暗角范围、调 强度 看压暗力度,并切到 AO 灰度图 看遮蔽本身长什么样
- 能回答:SSAO 为什么只把 AO 乘到环境光上、不动直接光?如果乘到了整体颜色或直接光上,画面会怎样?
为什么墙角总是更暗
留意一下你周围:墙的两面相交的那道角、桌子底下、书页合拢的缝里、两个物体挨在一起的接触处——这些地方总是比旁边的平面更暗一点。不是因为有谁专门给它们打了阴影,而是因为它们三面被围住,四面八方漫射过来的光被周围的东西挡掉了大半,能照进去的自然少。
可你之前给场景加的环境光,是给所有表面统一加一个常数——墙角和空旷的平面拿到的环境光一模一样。结果墙角缝隙和平整墙面一样亮,画面就塌成一片、没有立体感、一眼假。越是该有「脏角、暗缝」的地方越平,越不像真东西。
这一章要解决的就是这个:怎么让那些被周围挡住的角落、缝隙、接触处,自动地暗下去。没有它,环境光是张「平贴的均匀漆」;有了它,画面立刻多出一层接触阴影的脏旧感和厚重感——这正是真实世界里光的样子。
环境光遮蔽:被挡住的地方该更暗
先给那个「墙角更暗」的现象起个正名。它叫 ↡Ambient Occlusion,环境光遮蔽,常缩写 AO。描述一个表面点能接收到多少四面八方漫射来的环境光:开阔处(如平整墙面中央)几乎不被遮挡、能收满环境光、该亮;而墙角、缝隙、物体接触处被周围几何围住,大部分方向的环境光被挡掉、能收的少、该暗。所以 AO 是『每个点被周围挡了多少』的一个 0~1 系数(1=完全不挡、0=被挡死),把它乘到环境光项上,就能给画面加上接触阴影那层立体的脏旧感。常量环境光对所有点一视同仁,照不出这种细节。(ambient occlusion,AO):描述一个表面点能接收到多少四面八方漫射来的环境光。开阔处几乎没遮挡、收得满、该亮;墙角缝隙被围住、收得少、该暗。
所以 AO 本质是个 0~1 的系数——1 表示完全不被挡(开阔),0 表示被挡死(深缝)。算出每个点的 AO,乘到环境光上,墙角缝隙就自动暗下去了。难点只有一个:怎么知道一个点被周围挡了多少?严格算要看它周围真实的几何,太贵。
屏幕空间 SSAO:不算真几何,借 G-buffer 近似
实时里我们用一个聪明的近似——↡Screen-Space Ambient Occlusion,屏幕空间环境光遮蔽,缩写 SSAO。一种实时近似 AO 的技术:不去遍历场景里真实的几何来判断遮挡(太贵),而是直接利用延迟着色已经存好的 G-buffer——里面每个屏幕像素都记着它对应表面点的位置和法线。SSAO 在每个片元的法线半球内撒若干采样点,把它们投影回屏幕、读 G-buffer 在那处存的表面深度做比较,估出这个片元周围被实体挡住的比例。因为整个判断只在屏幕空间、靠已有的深度/位置缓冲完成,所以又快又能塞进延迟管线,是它『屏幕空间』三个字的由来。(screen-space ambient occlusion,屏幕空间环境光遮蔽)。它不去遍历场景的真实几何,而是直接用延迟着色那章存好的 G-buffer——里面每个屏幕像素都记着它对应表面点的位置和法线。
有了这两样,SSAO 就能在屏幕空间里近似判断「这个片元周围被实体挡了多少」,全程不碰真几何,又快又能直接塞进延迟管线。这也是它名字里「屏幕空间」的由来。它的代价也来自这里:屏幕外、相机背后或被当前可见表面挡住的几何没有 G-buffer 记录,不能参与遮蔽判断,所以 SSAO 是高效的局部近似,不是全局 AO。具体怎么近似?分两步:在片元法线半球内撒点,再用深度比较判断每个点有没有被挡。
半球核采样:在法线半球里撒点,看几个被挡
第一步是 ↡Hemisphere Kernel,半球核(采样核)。SSAO 估遮蔽的采样方式:以当前片元为中心,在它法线 N 朝向的那个半球(不是整球)内,预先取一组随机分布的偏移向量(这组向量就叫采样核 kernel,常 16~64 个),把它们加到片元位置上得到若干采样点。逐个判断这些采样点有没有被周围几何挡住,挡住的比例就是这个片元的遮蔽程度。之所以用半球而非整球:朝向表面背面的采样点永远在几何内部、会让平坦表面也凭空变暗,限制在法线半球内才只采到表面上方有意义的空间。核里的采样点还常按距中心由近到远加权,让靠近表面的遮挡贡献更大。(hemisphere kernel)。以当前片元为中心,在它法线 N 朝向的半球内取一组随机偏移向量(这组向量就叫采样核,常 16~64 个),加到片元位置上得到若干采样点。下面这张图把它画清楚:
数有多少采样点落进了周围几何里——挡得越多,这片元被围得越严、能收的环境光越少、就该越暗。注意是 半球不是整球:如果用整球,朝向表面背面的采样点永远在几何内部,会让一面平整的墙也凭空变暗(这是后面要讲的一个坑)。限制在法线半球内,才只采到表面上方那一侧有意义的空间。
深度比较:怎么判断一个采样点被挡了
第二步,怎么知道某个采样点「落进几何里」了?靠深度比较(depth comparison):把这个采样点投影到屏幕,读出 G-buffer 在那个屏幕位置存的真实表面深度 D,再比一比深浅。
如果采样点自己比 D 离相机更远,说明它正藏在那处真实表面的后面、被前面的实体挡住了——遮蔽 +1;如果它比 D 更近,说明它前面是空的、没东西挡——不计。逐个采样点这样比、累加被挡的个数,就得到这片元的遮蔽程度。关键是:整个判断只读 G-buffer 已存的深度、不重算几何,所以快——这正是「屏幕空间」省钱的地方。
随机旋转 + 模糊:把丑陋的条带换成干净 AO
直接这么做会有个难看的副作用。为了快,采样核只能取很少几个点(16~64),且整屏用同一套固定方向——结果 AO 上爬满规则的条带(banding),像水波纹一样刺眼。修法是两步配合:
第一步,↡Random Rotation Noise,随机旋转(噪声)。SSAO 去条带的关键技巧:因为采样核点数少又整屏共用固定方向,AO 会出现规则的条带 banding。做法是预生成一张很小的随机向量纹理(如 4×4),在屏幕上平铺,用它逐像素地把采样核绕法线旋转一个随机角度——这样相邻像素的采样方向各不相同,原本规则的条带就被打散成细碎的、各处随机的噪点。它把『刺眼的规则条带』换成了『可控的随机噪点』,噪点再交给下一步的模糊去抹平。(random rotation):用一张很小的随机向量纹理(如 4×4)平铺整屏,逐像素把采样核绕法线转一个随机角度。相邻像素采样方向就各不相同,规则条带被打散成细碎噪点。
第二步,↡AO Blur,AO 模糊(去噪)。随机旋转把条带换成了噪点,画面虽不再有规则条带、却变得颗粒粗糙。AO 模糊就是在算完 SSAO 后、把 AO 灰度图过一道很小的盒式模糊(如 4×4 邻域取平均),把这些噪点抹平成柔和连续的 AO,再拿去乘环境光。因为随机旋转用的 noise 纹理是 4×4 平铺的,模糊核取 4×4 邻域正好抹掉一个完整噪声周期。随机旋转负责打散条带、模糊负责抹平噪点,两步缺一不可。(AO blur):算完 SSAO 后,把 AO 灰度图过一道很小的盒式模糊(如 4×4 邻域取平均),把噪点抹平成柔和连续的 AO,再拿去乘环境光。下面这张图把「条带 → 噪点 → 干净」三态并排:
随机旋转负责把条带打散成噪点,模糊负责把噪点抹平——两步缺一不可。只旋转不模糊,画面是颗粒;只模糊不旋转,规则条带糊不掉。
范围检查:别让远处的背景误暗
还有一个必须做的修正。深度比较只看「采样点是不是比那处表面更远」,但有个陷阱:如果那处表面是很远的背景(比如片元在物体边缘,旁边就是几米外的墙),深度差巨大——按规则它也会被算成「被挡」,于是物体边缘凭空多一圈黑边、黑晕。
修法叫 ↡Range Check,范围检查。SSAO 的一个必要修正:深度比较只看采样点是否比该处表面更远,但如果那处表面是离得很远的背景(深度差巨大),它本不该对当前片元造成遮蔽,否则物体边缘会凭空出现黑边黑晕。范围检查就是按『当前片元深度与该处表面深度之差』做加权:深度差在采样半径量级内的遮挡才算数(权重接近 1),差得太远的(远背景)权重衰减到接近 0、不计入遮蔽。常用 smoothstep(0, 1, radius / abs(深度差)) 之类的平滑权重实现。(range check):按「当前片元深度与那处表面深度之差」做加权——深度差在采样半径量级内的遮挡才算数,差得太远的(远背景)权重衰减到接近 0、不计。这样既保住了近处接触缝的暗角,又不让远背景误暗。
收尾:只把 AO 乘到环境光上
最后一步最关键,也最容易做错。SSAO 算出的 AO,只乘到环境光项上,直接光(漫反射、镜面)一概不动:
vec3 ambient = ambientColor * Albedo * AO; // 只有这里乘 AO
vec3 lighting = ambient + diffuse + specular; // 直接光照旧为什么?因为 AO 描述的是「漫射环境光被周围挡了多少」——那是没有明确来向、四面八方匀着来的光。而直接光有明确光源方向,它该不该照到、照多亮,由阴影映射那套(N·L、shadow map)管,跟 AO 是两码事。把 AO 乘到直接光或整体颜色上,画面会整体发黑、过暗、脏——这是头号误区,后面单列。
动手:开关 SSAO,看接触处的暗角
猜一猜:下面这块画布里有一块地面和几个球,球就坐在地面上、彼此还挨得很近。先把「SSAO 开关」关掉——只剩纯常量环境光。你觉得球和地面接触的那一圈会是什么样?是和旁边的空地一样亮、看不出球是「坐在」地上的,还是接触处自带一圈暗角、像真的压着地面?先关着看一眼,再打开。
这块画布里,片段着色器用 ray-sphere 求交程序化渲了一块地面 + 几个挨在一起的球。对每个命中片元,它在该片元的法线半球方向做若干次短距探测,估出周围被挡了多少(近似的 AO),再把这份 AO 只乘到环境光上。四个控件:SSAO 开关(关=纯常量环境光、接触处不暗;开=接触/缝隙处暗角出现)、半径(探测的影响范围,调大暗角铺得更广)、强度(AO 压暗的力度),以及 查看(最终合成 / 只看 AO 灰度图——白=不遮挡、黑=强遮挡):
实时演示加载中…
谜底:关掉 SSAO,球和地面接触的那一圈、球与球挨着的缝隙,都和旁边的空地一样亮——球像是「浮」在地上、缝隙平塌塌的,一眼假。打开 SSAO,接触处和缝隙立刻自动暗下去一圈,球终于像「坐」在地上、挨在一起的球之间有了厚重的接触阴影。再把半径拖大,暗角铺得更广(探测够得更远);把强度拖大,同样的遮蔽被压得更黑。最后切到 AO 灰度图:开阔的平地是白的(不遮挡),接触缝隙是一团团黑(强遮挡)——这张灰度图就是 SSAO 真正算出来的东西,最终合成时它只乘在环境光上。
流程走一遍:从半球采样到只乘环境光
把 SSAO 串成正经的三步流程,每步配图示意那一刻的数据形态:
① SSAO pass:半球核采样 + 深度比较,输出 AO 灰度
对每个可见片元,采 G-buffer 拿到它的位置/法线,在 法线半球内撒一组随机采样点(用 noise 纹理逐像素 旋转采样核去条带)。把每个采样点投影到屏幕、读 G-buffer 那处深度做深度比较 + 范围检查 ,累加被挡的个数 → 输出一张 AO 灰度图(白=不挡、黑=强遮挡)。
左右擦一擦:关 SSAO vs 开 SSAO
把「关 SSAO(接触处一样亮、显假)」和「开 SSAO(接触缝隙暗、立体)」定格成两态——左边纯常量环境光、接触处和平面一样亮,右边接触缝隙处自动出暗角:
左右两图各取一态作示意——重点在那句话:关 SSAO 时墙角缝隙和平面拿到一样的环境光、画面塌成平片;开 SSAO 后接触处被自动压暗一圈,球与地面、球与球之间有了厚重的接触阴影。一句话:SSAO 把均匀的环境光,按「每个点被周围挡了多少」分了三六九等。
代码逐段拆解
真正的 SSAO 代码就三块,全建立在延迟着色的 G-buffer(位置/法线)之上:SSAO pass(半球采样 + 深度比较,输出 AO 灰度)、模糊 pass(小盒式模糊抹噪点)、光照 pass(ambient *= AO)。和 demo 里的解析近似不同,这里是工业界的真做法:对屏幕空间的 G-buffer 深度做投影采样。
第一块:SSAO pass 片段着色器——半球采样 + 深度比较
核心着色器:采 G-buffer 拿到当前片元的视空间位置/法线,用 noise 纹理旋转后的 TBN 把预生成的半球核旋到法线朝向,for 循环每个采样点——投影到屏幕、读 G-buffer 那处深度、比较累加遮蔽:
#version 300 es
precision highp float;
in vec2 TexCoords;
uniform sampler2D gPosition; // G-buffer:视空间位置
uniform sampler2D gNormal; // G-buffer:视空间法线
uniform sampler2D texNoise; // 4x4 随机向量纹理(平铺)
uniform vec3 samples[64]; // 预生成的半球核(CPU 端造,见下)
uniform mat4 projection; // 把视空间采样点投影回屏幕
uniform float radius; // 采样半径(对应 demo「半径」)
uniform float power; // 压暗力度(对应 demo「强度」)
out float FragColor; // 只输出一个 AO 灰度值
const vec2 noiseScale = vec2(1280.0 / 4.0, 720.0 / 4.0); // 屏幕/noise 平铺
void main() {
vec3 fragPos = texture(gPosition, TexCoords).xyz; // 当前片元视空间位置
vec3 normal = normalize(texture(gNormal, TexCoords).xyz);
vec3 randVec = texture(texNoise, TexCoords * noiseScale).xyz; // 逐像素随机向量
// 用随机向量 + 法线构 TBN:把半球核旋到法线半球、且逐像素随机旋转(去条带)
vec3 tangent = normalize(randVec - normal * dot(randVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);
float occlusion = 0.0; // 下一块继续:循环采样 + 深度比较 + range check
}// 接上:循环每个采样点,投影到屏幕、读 G-buffer 深度比较
for (int i = 0; i < 64; i++) {
// 1) 半球核方向旋到法线朝向,按半径放到片元周围
vec3 samplePos = fragPos + (TBN * samples[i]) * radius;
// 2) 投影到屏幕,拿到该采样点对应的屏幕 uv
vec4 offset = projection * vec4(samplePos, 1.0);
offset.xyz /= offset.w; // 透视除法 → NDC
offset.xy = offset.xy * 0.5 + 0.5; // NDC[-1,1] → uv[0,1]
// 3) 读 G-buffer 在该屏幕位置存的真实表面深度(视空间 z)
float sampleDepth = texture(gPosition, offset.xy).z;
// 4) range check:那处表面离当前片元太远(远背景)就别算遮蔽
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
// 5) 深度比较:该处表面比采样点更近(+bias 防自遮挡) = 采样点被挡 → 遮蔽+1
occlusion += (sampleDepth >= samplePos.z + 0.025 ? 1.0 : 0.0) * rangeCheck;
}
// 遮蔽比例 → AO 系数(1 不挡、0 挡死),power 调压暗力度
FragColor = pow(clamp(1.0 - occlusion / 64.0, 0.0, 1.0), power);注意上面用的是 G-buffer 的 视空间 位置/法线(不是世界空间)——这样投影矩阵 projection 才能把采样点直接投回屏幕。半球核 samples[64] 和 texNoise 这两样要在 CPU 端预先造好:
第二块:模糊 pass 片段着色器——小盒式模糊抹噪点
旋转采样核换来的 AO 带颗粒,过一道很小的盒式模糊抹平。因为 noise 是 4×4 平铺的,模糊核取 4×4 邻域正好抹掉一个完整噪声周期:
#version 300 es
precision highp float;
in vec2 TexCoords;
uniform sampler2D ssaoInput; // 上一步带噪点的 AO 灰度图
out float FragColor;
void main() {
// 一个纹素的大小(用于按整数偏移取邻域)
vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 0));
float result = 0.0;
// 4x4 邻域取平均(对应 noise 的 4x4 平铺周期)
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;
}
}
// 16 个样本求平均 → 抹平噪点的干净 AO
FragColor = result / 16.0;
}模糊后这张干净 AO 就能拿去乘环境光了。最后一块最关键也最短——在光照 pass 里把 AO 只乘到环境光项上:
第三块:光照 pass——ambient *= AO,直接光不动
// 光照 pass 里:采 G-buffer + 采模糊后的 AO
float AmbientOcclusion = texture(ssaoBlur, TexCoords).r;
vec3 Albedo = texture(gAlbedoSpec, TexCoords).rgb;
// 关键:AO 只乘到「环境光」项上
vec3 ambient = vec3(0.3) * Albedo * AmbientOcclusion;
vec3 lighting = ambient; // 直接光从这里往后加,一概不乘 AO
// for 循环每盏灯累加 漫反射 + 镜面(这些是直接光,不动 AO)
for (int i = 0; i < activeLights; i++) {
vec3 L = normalize(lights[i].Position - FragPos);
float diff = max(dot(Normal, L), 0.0);
lighting += lights[i].Color * Albedo * diff; // 直接光:不乘 AO
// …镜面同理,也不乘 AO(从略)
}
FragColor = vec4(lighting, 1.0);// 第一遍·SSAO pass:绑 SSAO FBO,喂 G-buffer 位置/法线 + noise,渲全屏四边形
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
glClear(GL_COLOR_BUFFER_BIT);
ssaoShader.use();
// (绑 gPosition/gNormal/texNoise + 传 samples[64]/projection/radius/power,从略)
RenderQuad();
// 第二遍·模糊 pass:绑 blur FBO,把上一步 AO 图过 4x4 盒式模糊
glBindFramebuffer(GL_FRAMEBUFFER, ssaoBlurFBO);
glClear(GL_COLOR_BUFFER_BIT);
blurShader.use();
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer); // 输入:带噪点 AO
RenderQuad();
// 第三遍·光照 pass:绑回默认帧缓冲,光照里采模糊后的 AO 乘环境光
glBindFramebuffer(GL_FRAMEBUFFER, 0);
lightingShader.use();
glBindTexture(GL_TEXTURE_2D, ssaoBlurColorBuffer); // 干净 AO 喂给光照
RenderQuad();三遍串起来就是完整的 SSAO:几何 pass 填 G-buffer(上一章)→ SSAO pass 半球采样输出 AO 灰度 → 模糊 pass 抹噪点 → 光照 pass 把 AO 只乘环境光。和上面 demo 的解析近似对照着看:demo 用「点在不在球/地面内部」代替了「投影读 G-buffer 深度」,但「法线半球撒点数遮蔽 → 只压环境光」的内核完全一致。
容易踩的坑
小结
- 环境光遮蔽 AO 描述一个点被周围挡住、收不到多少环境光:墙角缝隙接触处该暗;常量环境光对所有点一视同仁、照不出这种立体细节
- SSAO 在屏幕空间近似 AO:直接用延迟着色的 G-buffer(位置/法线),不重算真几何,又快又能塞进延迟管线
- 做法两步:在片元法线半球内撒随机采样核 → 每个采样点投影到屏幕、深度比较 + 范围检查 累加被挡的个数 = 遮蔽
- 去噪两步配合:noise 纹理逐像素旋转采样核把条带换成噪点 → 小盒式模糊把噪点抹平成干净 AO
- 收尾铁律:算出的 AO 只乘到环境光(
ambient *= AO),直接光(漫反射/镜面/阴影)一概不动,否则画面整体发黑
练习
问题 1(改 Demo 代码型) 在上面那个 demo 里把 SSAO 走一遍:① 先关 SSAO、再开,球与地面接触处、球与球缝隙处分别有什么变化?这对应哪条原理?② 把「查看」切到 AO 灰度图,描述开阔平地和接触缝隙分别是什么颜色(白/黑),它代表什么?③ 把「半径」拖到最大、再把「强度」拖到最大,两者把画面改成不同的样子——分别改了什么?
问题 2(问答型 · C 型核心) 有人做 SSAO:半球采样、深度比较、范围检查、noise 旋转、模糊全做对了,AO 灰度图也很干净,墙角缝隙该黑的都黑。可一接到光照里,整个画面(连受光的开阔亮面、镜面高光)都灰暗了一截。问题最可能出在哪一行?怎么改?为什么?
问题 3(选型/排错题 · C 型) 下面三个 SSAO 症状,各对应哪个被漏掉/调错的环节,怎么修?① AO 上有规则的水波纹条带;② 物体边缘紧挨远处墙时,边缘多一圈黑晕;③ 暗角糊成一大片污渍、接触缝细节反而没了。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 环境光遮蔽
Ambient Occlusion,环境光遮蔽(AO)。描述一个表面点能收到多少四面八方漫射来的环境光:开阔处(平整墙面中央)几乎不被挡、收得满、该亮;墙角、缝隙、物体接触处被周围围住、收得少、该暗。所以 AO 是个
0~1系数(1=完全不挡、0=被挡死),乘到环境光项上就给画面加上接触阴影那层立体感。常量环境光对所有点一视同仁,照不出这种细节。详见本章「环境光遮蔽」一节。- SSAO
Screen-Space Ambient Occlusion,屏幕空间环境光遮蔽。一种实时近似 AO 的技术:不遍历真实几何(太贵),而是直接用延迟着色已存好的 G-buffer(每个屏幕像素的位置/法线),在每个片元的法线半球内撒采样点、投影回屏幕读 G-buffer 深度比较,估出周围被挡的比例。整个判断只在屏幕空间、靠已有缓冲完成,所以又快又能塞进延迟管线。详见本章「屏幕空间 SSAO」一节。
- 半球核采样
Hemisphere Kernel,半球核(采样核)。SSAO 估遮蔽的采样方式:以片元为中心、在它法线 N 朝向的半球(不是整球)内取一组随机偏移向量(这组就叫采样核,常 16~64 个),加到片元位置得到若干采样点,逐个判断有没有被几何挡住。用半球而非整球是为了不让朝向表面背面的采样点把平坦面也凭空算暗。详见本章「半球核采样」一节。
- 随机旋转 noise
Random Rotation Noise,随机旋转(噪声)。SSAO 去条带的关键技巧:采样核点数少又整屏共用固定方向会让 AO 出现规则条带 banding。做法是用一张很小的随机向量纹理(如
4×4)平铺整屏,逐像素把采样核绕法线转一个随机角度,相邻像素方向各异,规则条带就被打散成细碎噪点;噪点再交给模糊抹平。详见本章「随机旋转- 模糊」一节。
- 范围检查
Range Check,范围检查。SSAO 的必要修正:深度比较只看采样点是否比那处表面更远,但如果那处是离得很远的背景(深度差巨大),它本不该遮挡当前片元,否则物体边缘会凭空出黑边黑晕。范围检查按「当前片元与采样处的深度差」加权:差在采样半径量级内的遮挡才算(权重≈1),差太远的(远背景)权重衰减到≈0。详见本章「范围检查」一节。
- AO 模糊
AO Blur,AO 模糊(去噪)。随机旋转把条带换成了噪点、画面变颗粒;AO 模糊就是算完 SSAO 后把 AO 灰度图过一道很小的盒式模糊(如
4×4邻域取平均,正好抹掉一个 noise 平铺周期),把噪点抹平成柔和连续的 AO,再拿去乘环境光。随机旋转负责打散条带、模糊负责抹平噪点,两步缺一不可。详见本章「随机旋转- 模糊」一节。
版本、来源与运行边界
本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。
正式概念与状态责任
- ssao:在“SSAO、半球采样与环境光遮蔽边界”中由G-buffer、SSAO kernel/noise texture、occlusion FBO 与 blur pass负责解释其输入、受控状态和可观察结果;运行时以kernel/noise 输入、sample position、projected UV、depth delta、occlusion 与 blur 输出定位它的第一处变化。
- sample kernel:在“SSAO、半球采样与环境光遮蔽边界”中由G-buffer、SSAO kernel/noise texture、occlusion FBO 与 blur pass负责解释其输入、受控状态和可观察结果;运行时以kernel/noise 输入、sample position、projected UV、depth delta、occlusion 与 blur 输出定位它的第一处变化。
- noise texture:在“SSAO、半球采样与环境光遮蔽边界”中由G-buffer、SSAO kernel/noise texture、occlusion FBO 与 blur pass负责解释其输入、受控状态和可观察结果;运行时以kernel/noise 输入、sample position、projected UV、depth delta、occlusion 与 blur 输出定位它的第一处变化。
- occlusion:在“SSAO、半球采样与环境光遮蔽边界”中由G-buffer、SSAO kernel/noise texture、occlusion FBO 与 blur pass负责解释其输入、受控状态和可观察结果;运行时以kernel/noise 输入、sample position、projected UV、depth delta、occlusion 与 blur 输出定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“旋转样本到法线半球,投影采样邻域深度,累积后模糊并用于环境项”发生后,G-buffer、SSAO kernel/noise texture、occlusion FBO 与 blur pass应怎样改变view-space position/normal、kernel samples、TBN、投影坐标、range check 和 occlusion;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
SSAO、半球采样与环境光遮蔽边界:状态合同
在 view space 用半球 kernel、旋转噪声与 depth 比较估计遮蔽,再单独模糊
验证场景
官方教程正式概念
logl-38 · 基线帧
ssao:固定 context、资源内容与输入事件,执行“旋转样本到法线半球,投影采样邻域深度,累积后模糊并用于环境项”
冻结输入:ssao
G-buffer、SSAO kernel/noise texture、occlusion FBO 与 blur pass记录view-space position/normal、kernel samples、TBN、投影坐标、range check 和 occlusion
结果:得到可重复的初始 GL 状态与资源身份
观测:kernel/noise 输入、sample position、projected UV、depth delta、occlusion 与 blur 输出中的初始快照
预期:G-buffer、SSAO kernel/noise texture、occlusion FBO 与 blur pass得到可复查结果,并持续满足“样本、法线和深度比较处于同一 view space;越界投影不伪造遮蔽”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“旋转样本到法线半球,投影采样邻域深度,累积后模糊并用于环境项”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“样本、法线和深度比较处于同一 view space;越界投影不伪造遮蔽”。
CPU command · GL state · GPU result
SSAO、半球采样与环境光遮蔽边界:五段轨迹
当前观测:kernel/noise 输入、sample position、projected UV、depth delta、occlusion 与 blur 输出中的初始快照
不变量:样本、法线和深度比较处于同一 view space;越界投影不伪造遮蔽
实验三:单故障与同输入恢复
注入“把 G-buffer world-space position 与 view-space sample 比较,摄像机移动时遮蔽漂移”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有kernel/noise 输入、sample position、projected UV、depth delta、occlusion 与 blur 输出一起恢复才算修复。
Single fault · first divergence · replay
SSAO、半球采样与环境光遮蔽边界:反例与恢复
故障:把 G-buffer world-space position 与 view-space sample 比较,摄像机移动时遮蔽漂移
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“把 G-buffer world-space position 与 view-space sample 比较,摄像机移动时遮蔽漂移”
样本、法线和深度比较处于同一 view space;越界投影不伪造遮蔽
kernel/noise 输入、sample position、projected UV、depth delta、occlusion 与 blur 输出
最小可重放检查
unit: logl-38
owner: G-buffer、SSAO kernel/noise texture、occlusion FBO 与 blur pass
state_or_resource: view-space position/normal、kernel samples、TBN、投影坐标、range check 和 occlusion
command: 旋转样本到法线半球,投影采样邻域深度,累积后模糊并用于环境项
pass_invariant: 样本、法线和深度比较处于同一 view space;越界投影不伪造遮蔽
single_fault: 把 G-buffer world-space position 与 view-space sample 比较,摄像机移动时遮蔽漂移
required_evidence: kernel/noise 输入、sample position、projected UV、depth delta、occlusion 与 blur 输出复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。