屏幕空间技术
需要预先学习:
1. 什么是屏幕空间
前向渲染与延迟渲染 的光照 Pass 展示了一种新视角:不再遍历场景,而是对屏幕上每个像素从纹理里取数据做计算。把这种思路推广——所有输入只有"当前视角下已渲染的屏幕图像(颜色、深度、法线)"、所有计算都发生在全屏 pass 上的技术,统称屏幕空间(screen space)技术。
它与 全局光照与局部光照 的关系:环境光遮蔽、间接光、反射这些 GI 效果需要整条光路的信息,实时算不动;屏幕空间技术退而求其次——屏幕上看得见的部分,就是我能拿来用的全部:
真实 GI: 光线在完整场景中反弹(包括屏幕外、被遮挡的部分)
屏幕空间: 光线只在一层"屏幕贴片"上反弹(屏幕拍到的 2.5D 深度场)
代价与收益同样明确:
| 优点 | 缺点 |
|---|---|
| 输入只是几张全屏纹理,复杂度与场景规模无关 | 屏幕外的信息不存在:反射/AO 在物体进入屏幕的边缘消失 |
| 天然适配全屏 pass / compute shader,实现独立于场景组织 | 被遮挡的几何不会被采样:被挡住的物体不产生 AO 或反射 |
| 与延迟渲染的 G-Buffer 天然契合 | 都是近似,物理正确性有限 |
常见的屏幕空间成员:SSAO(环境光遮蔽)、SSR(屏幕空间反射)、SSGI/SSDO(间接光)、DoF(景深)、Motion Blur(运动模糊)、甚至 Bloom(提取亮部再模糊)也在此列。这一篇讲透三个代表(SSAO、SSR)加两个后处理(DoF、Motion Blur),它们共享同一块地基。
2. 地基:从深度重建世界坐标
大多数屏幕空间算法的第一步,是回答"这个像素对应 3D 场景里的哪个点、表面朝向哪里"。深度缓冲(深度缓冲)存着每个像素的非线性深度(见 透视校正插值),由它可以重建(reconstruct)像素的世界坐标:
- 取像素的 NDC 坐标 —— 由像素坐标线性映射, 采样深度缓冲;
- 左乘逆视图投影矩阵 (或分两步:先逆投影到视图空间、再逆视图变换),除以 ;
- 得到该像素的世界坐标。
// 片元着色器:由屏幕像素 + 深度重建世界坐标
vec3 reconstructWorldPos(vec2 uv)
{
float depth = texture(gDepth, uv).r; // 非线性深度
vec4 ndc = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); // [0,1] -> [-1,1]
vec4 world = inverseViewProj * ndc; // 逆变换
return world.xyz / world.w; // 透视除法(见 [[透视校正插值]])
}
法线有两个来源:延迟管线直接从 G-Buffer 采样(见 前向渲染与延迟渲染);前向管线没有法线缓冲时,用深度缓冲的邻域差分估算屏幕空间法线——取右、上两个相邻像素的世界坐标差做叉积:
// 深度差分估算屏幕空间法线(无需 G-Buffer)
vec3 getNormalFromDepth(vec2 uv)
{
vec3 p = reconstructWorldPos(uv);
vec3 px = reconstructWorldPos(uv + vec2(texelSize.x, 0.0));
vec3 py = reconstructWorldPos(uv + vec2(0.0, texelSize.y));
return normalize(cross(px - p, py - p));
}
注:深度差分法线是"深度场的法线",在物体边缘处混入背景深度,出现硬边——它描述的是"屏幕上这片深度坡面"而不是物体表面本身。G-Buffer 法线(渲染时写下的)更干净,这也是屏幕空间技术与延迟渲染亲和的又一个原因。
3. SSAO:屏幕空间环境光遮蔽
全局光照与局部光照 提过 AO(ambient occlusion,环境光遮蔽):缝隙、交角处的间接光被周围几何挡住,形成柔和的暗角。SSAO(Screen-Space Ambient Occlusion)在屏幕空间近似它。
3.1 原理
每个像素根据重建出的位置 与法线 ,在 朝向的半球内撒若干采样点,检查每个采样点是否被几何挡住——判断方法是比较采样点的深度与深度缓冲中"该方向射线最先碰到的深度":
采样点(沿法线半球随机分布)
• •
• /‾‾‾‾‾‾\ •
/ 深度缓冲表面 \
────────●──────────────────●────────
P (像素) 采样点的深度
↑ 若采样点比深度缓冲更"深"(更远),
说明它藏在表面后面 —— 记一次遮挡
遮挡率 = 被挡采样点数 / 总采样数,再乘到环境光(或间接光)项上:
3.2 实现要点
采样核生成:在切线空间半球内随机取 64 个方向(半径随机),每个片元共用这一组(见 法线贴图与TBN矩阵 的 TBN 变换到世界)。
逐像素随机旋转:64 个采样点对全屏复用会在暗角处出现规则的条纹。给每个像素一个随机旋转角,把采样核整体转一个随机方向,条纹打散成高频噪声。
半径与范围检测:距离过远的"遮挡"不该计入(很远的墙不该遮挡脚边的缝隙),按采样点与 的距离做范围衰减。
// 片元着色器:SSAO 核心(节选)
float ssao(vec2 uv)
{
vec3 fragPos = reconstructWorldPos(uv);
vec3 normal = texture(gNormal, uv).rgb;
float occlusion = 0.0;
for (int i = 0; i < KERNEL_SIZE; ++i)
{
// 采样点:切线空间 -> 世界 -> 屏幕方向
vec3 samplePos = fragPos + (TBN * kernel[i]) * radius;
// 世界坐标 -> 屏幕空间,采样该处的深度缓冲
vec4 offset = projection * view * vec4(samplePos, 1.0);
offset.xyz /= offset.w;
offset.xy = offset.xy * 0.5 + 0.5;
float sceneDepth = reconstructWorldPos(offset.xy).z;
float sampleDepth = samplePos.z;
// 采样点藏在表面后面 -> 遮挡;距离太远则不计
if (sampleDepth > sceneDepth && sampleDepth - sceneDepth < range)
occlusion += 1.0;
}
// 随机旋转已并入 kernel 的 TBN(noiseTexture 提供),此处取平均
return 1.0 - occlusion / float(KERNEL_SIZE);
}
模糊 pass:随机旋转留下的噪声用一次小半径模糊(保边模糊,不糊掉物体边界)抹平,得到平滑的 AO 图。最后把 AO 乘进光照:ambient *= ao。
3.3 变体与工程现状
| 变体 | 改进点 |
|---|---|
| HBAO | 只沿水平集方向积分,考虑遮蔽者与被遮蔽者的角度,暗角更物理 |
| GTAO | HBAO 的各向异性修正版,接近离线渲染的 ground truth |
| Alchemy AO | 早期工业方案(Crytek),SSAO 的直接前身 |
| RTAO | 硬件光追(RTX)下的真 AO,无屏幕空间缺陷,但依赖硬件光追单元(见 光线追踪) |
注:SSAO 遮蔽的是间接光,只应乘在环境光 / 间接光项上——乘到直接光照上是最常见的实现错误(缝隙里太阳直射的地方也被压黑,物理上说不通)。
4. SSR:屏幕空间反射
镜面反射需要知道"反射方向上有什么"。PBR 的 IBL 用环境贴图回答,但环境贴图只有远景(skybox),反射不出近处的动态物体。SSR(Screen-Space Reflections)的答案:反射方向上的东西,在屏幕图像里找。
4.1 原理:光线步进
从像素位置 沿反射方向 发射射线,在屏幕空间步进(march):每前进一步,把采样点投影回屏幕坐标,与深度缓冲比较——
反射方向 r
\
\ 步进点1 步进点2 步进点3 命中!
\ • • •╱
\ |屏幕投影 | ╱
─────────●─────────────────●────────── 深度缓冲表面
P 命中处(反射源)
步进点比深度缓冲更远(藏在表面后):继续走;步进点穿过表面(比深度缓冲更近):命中,取命中处的屏幕颜色作为反射色。
// 片元着色器:SSR 光线步进(节选)
vec3 ssr(vec3 fragPos, vec3 normal, vec3 viewDir)
{
vec3 origin = fragPos;
vec3 dir = normalize(reflect(-viewDir, normal)); // 反射方向
vec3 pos = origin + dir * stepSize;
for (int i = 0; i < MAX_STEPS; ++i)
{
// 步进点投影到屏幕
vec4 clip = projection * view * vec4(pos, 1.0);
clip.xyz /= clip.w;
vec2 uv = clip.xy * 0.5 + 0.5;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0)
break; // 出屏:反射失败
float depth = reconstructWorldPos(uv).z;
if (pos.z > depth + bias) // 穿过表面:命中
{
vec2 hitUV = uv;
return texture(gColor, hitUV).rgb; // 命中处的颜色即反射色
}
pos += dir * stepSize;
}
return fallbackColor; // 未命中:退回环境贴图
}
4.2 关键改进
- 二分细化:固定步长要么漏掉小物体(步子太大跨过去)、要么浪费(步子太密)。先粗步进命中,再在区间内二分查找精确交点;
- Hi-Z 加速:用 剔除总结 的 Hi-Z 深度金字塔按"最近表面的粗略距离"自适应步长,快而不漏;
- 粗糙度衰减:粗糙表面的反射应是模糊的。按粗糙度在预滤波 mip 链(思路同 纹理 的 mipmap 与 IBL 的预滤波)上采样屏幕颜色,或按 TAAU 的思路做时间累积降噪;
- 菲涅尔加权:反射强度乘上 PBR 的菲涅尔项——掠射角反射强、正视时弱。
4.3 固有缺陷
SSR 的三个硬伤都源于"屏幕只有一层":
| 缺陷 | 现象 | 缓解手段 |
|---|---|---|
| 屏幕外裁剪 | 反射源走出画面,反射内容突然消失/截断 | 边缘衰减遮罩 + 环境贴图兜底 |
| 遮挡错误 | 屏幕里看不见的物体(被挡住)不出现在反射里 | 无法在屏幕空间解决,靠光追(光线追踪)或探针(见 全局光照与局部光照) |
| 前后混叠 | 命中检测只比一次深度,细长物体后的几何可能被误当反射源 | Hi-Z 步进、命中厚度上限 |
注:现代管线(UE 的 Lumen 之前)普遍是 SSR + IBL 环境贴图 + 反射探针三者混合:屏幕内交给 SSR,屏幕外远处交给探针/IBL,按屏幕边缘距离淡入淡出。纯 SSR 只在 demo 里存在。
5. DoF:景深
真实镜头无法让所有距离同时清晰:焦点前后存在弥散圆(circle of confusion,CoC)——一个点光源在成像面上散开的圆斑,大小取决于它与焦平面的距离。DoF(Depth of Field,景深)后处理用 CoC 模拟镜头的虚化。
5.1 CoC 的计算
由薄透镜成像公式(不用记推导,记住结论即可),CoC 直径与"像素深度偏离焦平面的量"近似线性:
- :焦距(用户设定的清晰距离);
- :该像素的场景深度(深度缓冲重建);
- :光圈,控制虚化强度。
离焦平面越远,CoC 越大,该像素应该越"散"。整个流程:
HDR 场景 ──> 计算 CoC 图 ──> 按 CoC 分离 near / far ──> 各自模糊 ──> 按 CoC 合成
5.2 实现要点
不能直接高斯模糊。Bloom 的高斯模糊是固定半径,而 DoF 每个像素的模糊半径不同(CoC 不同)——需要对每个像素按自身 CoC 采样邻居。直接逐像素变半径采样代价大,工业方案分两类:
- 降采样 + 分层模糊:把画面按 CoC 档位分成几层(近、中、远),每层固定半径模糊(可复用 Bloom 的降采样金字塔),合成时按 CoC 插值取层;
- 散景滤波(bokeh filter):把每个亮点直接画成一个六边形/圆形"光斑 sprite"(相机光圈叶片的形状),additive 累加,出来的虚化有真实镜头的"光斑感"。
半透明与 DoF 的顺序:半透明物体(半透明物体渲染)没有写深度,CoC 只由不透明表面决定,半透明物体跟随背景虚化——前景雾气跟着远处背景散焦,通常可接受,特写时会穿帮。
注意:DoF 作用于HDR 数据之后、色调映射之前(虚化是物理光学现象,色调映射 之后混合会破坏 HDR 线性关系),这与 Bloom 的位置要求一致。
6. Motion Blur:运动模糊
相机快门开启的时间内场景在动,感光面记录的是一段时间的积分——高速物体在照片上拖出方向性模糊。Motion Blur(运动模糊)用每像素的速度矢量(velocity vector)模拟这一积分。
6.1 速度缓冲
速度矢量回答"这个像素的画面内容在上一帧的哪里"——与 抗锯齿算法 TAA 的重投影是同一个东西:
- 静态场景:由当前帧与上一帧的视图投影矩阵计算——顶点着色器里把位置分别乘两个 VP,投影到 UV 相减(相机不动时速度为零);
- 动态物体:还需乘上一帧的模型矩阵(骨骼动画则要上一帧的骨骼),只靠相机矩阵算不出物体的自身运动。
// 顶点着色器:输出速度矢量
vec4 currPos = currViewProj * model * vec4(aPos, 1.0);
vec4 prevPos = prevViewProj * prevModel * vec4(aPos, 1.0);
currPos.xyz /= currPos.w;
prevPos.xyz /= prevPos.w;
Velocity = (currPos.xy - prevPos.xy) * 0.5; // NDC 差的一半,即 UV 位移
6.2 tile-max 两级模糊
逐像素沿速度方向采样是最直接的实现(每个像素按自己的 velocity 采样 N 次),但快速运动时相邻像素速度差异大,直接采样会闪烁。工业做法(Call of Duty 系提出的经典流程)分三级:
- tile max:把速度缓冲缩小(如 1/16 分辨率),每格取长度最大的速度——保证一格内按最快的运动模糊,避免快速物体"糊不过去";
- neighbor max:再对 tile max 结果取 3×3 邻域最大,得到保守的速度场;
- 方向模糊:全屏 pass 里每个像素按 neighbor max 速度沿方向采样 次取平均。
速度缓冲 ──tile max──> neighbor max ──┐
├──> 沿速度方向多次采样平均
HDR 场景 ─────────────────────────────┘
注:速度缓冲是现代管线的"公共设施":TAA/TAAU 的重投影(抗锯齿算法)、Motion Blur、自动曝光的时域平滑都要用它——在顶点着色器多输出一对 UV,多个后处理受益,这也是引擎把它做成独立 pass 的原因。
7. 总结
| 技术 | 输入 | 核心操作 | 主要缺陷 |
|---|---|---|---|
| 深度重建 | 深度缓冲 + 逆 VP 矩阵 | NDC 逆变换回世界空间 | 深度差分法线有硬边 |
| SSAO | 深度(法线) | 半球采样比较深度 | 屏幕外遮挡缺失、噪声 |
| SSR | 颜色 + 深度 | 沿反射方向屏幕空间步进 | 屏幕外反射缺失、遮挡错误 |
| DoF | 颜色 + 深度 | 按 CoC 变半径模糊 / 光斑 | 半透明无深度、与 TAA 交互复杂 |
| Motion Blur | 颜色 + 速度缓冲 | 沿速度方向多次采样平均 | 相机快切时全屏拖影(常直接关闭) |
它们的共同底色是全局光照与局部光照里"近似工具箱"的思路:算不起完整的全局信息,就把屏幕上已有的信息用到极致。共同的局限也一致——屏幕只是一扇窗,窗外的世界这些技术一无所知;突破这层局限的出路是体素化、探针(见 全局光照与局部光照 的实时 GI 表)与硬件光追(光线追踪)。



