GPU Gems 1 · Chapter 29. Efficient Occlusion Culling
用 occlusion query 和 early-z 在正确粒度拒绝不可见几何,结合上一帧结果、前后排序和 bounding box 控制 GPU/CPU 成本。
学习目标
- 能区分 frustum culling、occlusion query 和 early-z rejection 的粒度与代价
- 能实现不修改 color/depth buffer 的 bounding-box query,并在 visible pixels 超过阈值时绘制完整对象
- 能用多 outstanding queries 或上一帧结果避免同帧读取造成的 CPU/GPU pipeline flush
- 能根据对象复杂度、遮挡率、排序、分辨率和 CPU overhead 判断 occlusion culling 是否值得
遮挡剔除的收益来自“不画不可见的东西”,但 query 本身也需要提交、栅格化和读取结果。GPU Gems 1 第 29 章因此把它当成一个时序和成本问题:在正确粒度测试可见性,用廉价代理替换复杂对象,再把结果读取延迟到 GPU 有时间完成之后。
↡通过不渲染视锥外或被更近几何遮挡的对象来减少渲染工作的策略 要先回答“在哪一层拒绝”:frustum 是对象级 CPU 测试,query 是几何级 GPU 判断,early-z 是 fragment 级拒绝。不同层级不能互相替代。
1. 三种可见性判断的边界
occlusion query 让 GPU 返回对象最终能通过 frustum、scissor、alpha、stencil 和 depth 等测试的可见像素数;如果对象代表零像素或低于阈值,就可以跳过完整几何。early-z 则在 rasterizer 中比较 fragment 深度,失败时不再 fetch texture 或执行 fragment program,从 per-fragment 层节省带宽。
↡让 GPU 统计一个几何代理通过可见性测试的像素数量、供应用决定是否绘制完整对象的查询机制 需要应用参与状态设置和结果决策;↡在光栅化阶段先做 depth test,失败 fragment 不再取纹理或执行 fragment shader 的硬件拒绝路径 多数情况下由 GPU 自动执行,但仍受前后排序、stencil 使用和渲染状态影响。两者都受益于 front-to-back order,但工作粒度和控制权不同。
2. 用 bounding box 做不写入缓冲的 query
↡用来近似复杂对象空间范围、在 occlusion query 中代替完整对象进行可见性测试的简单几何代理 把复杂模型替换成少量三角形。规范步骤是:创建 query,关闭 color mask,关闭 depth write,开始计数,渲染 bbox 做 depth test,结束 query,恢复渲染状态并读取 visible pixels;结果大于零或阈值时才提交完整对象。
beginQuery(query);
setColorMask(false, false, false, false);
setDepthWrite(false);
drawBoundingBox(object.bounds);
endQuery(query);
setColorMask(true, true, true, true);
setDepthWrite(true);
if (queryResult(query) > visiblePixelThreshold) {
drawFullObject(object);
}query 期间的状态恢复是资源契约的一部分。若测试 bbox 写了 depth,代理就会成为后续对象的遮挡者;若 color 没有关闭,测试还会污染最终画面。实现中还要保存原始 depth write、color mask、cull mode 和 stencil 状态,而不是假设所有调用者都使用默认值。
3. 处理 CPU/GPU 解耦:不要让结果读取变成栅栏
正常渲染中 CPU 把命令放入 GPU queue 后可以继续工作;读取 query result 则要求 GPU 已完成 query 之前的命令,天真实现会把 CPU 卡在等待点。即便大多数对象都被遮挡,若每个 query 都同步读取,节省的完整对象绘制也可能抵不过 pipeline flush。
更好的策略是创建多个 outstanding queries,先发出一批 bbox 测试,再稍后读取结果;更稳妥的游戏循环则在下一帧读取上一帧结果。上一帧可见对象本帧先按完整对象绘制并继续发 query;上一帧不可见对象本帧只发 bbox query。不可见对象需要每帧重新测试,避免它刚刚进入视野却仍被错误跳过;可见对象的可见性检查可以降低频率,但要接受一帧延迟和轻微 popping 风险。
for (Object& object : scene) {
const bool wasVisible = object.previousQueryVisible;
beginQuery(object.query);
if (wasVisible) {
drawFullObject(object);
} else {
disableColorAndDepthWrites();
drawBoundingBox(object.bounds);
}
endQuery(object.query);
}
for (Object& object : scene) {
object.previousQueryVisible = readResultFromPreviousFrame(object.query);
}4. 让 opaque 成为 occluder,再处理 occludee
↡已经写入 depth buffer、可以遮挡后续对象的 opaque 场景几何 不是必须预先列出的特殊对象:在改进的排序策略中,任何已经渲染且可见的 opaque 对象都能成为后续对象的 occluder。↡可能被更近几何遮挡、因此适合接受 occlusion query 的对象 则在已有深度信息后进行测试。
opaque objects 应按距离 front-to-back 排序,先写入 depth;translucent objects 不能写入可复用的深度,因此只能作为 occludee,并按 back-to-front 绘制。这样 query 得到的深度环境更有用,许多远处或小物体可以在完整 shader 之前被拒绝。
包围盒的松紧也会影响正确性:盒子太大时可能可见而对象本身隐藏,下一帧就会在“画对象/画 bbox”间来回切换;盒子太复杂时,测试成本又接近直接绘制。静态对象可用紧凑盒子或多盒子,动画对象可存每帧、每动画或动画 union 的 bounds,再用 CPU 更新成本和 query 节省做对照。
5. 识别 query 不值得的场景
query 不是对所有对象都划算。若对象只有少量三角形,CPU 准备 bbox、设置状态和发 query 可能比直接画完整对象更贵;如果对象 fragment shader 极其复杂且占据较多像素,bbox depth-only 测试即使有额外像素也可能很便宜,query 才更有价值。
高分辨率下,简单 shader 的对象更容易变成 fill-rate limited;这时整个 bbox 可能覆盖很多像素,测试比直接绘制对象更贵。观察者进入 bbox 内部时,还必须考虑 back faces,否则 back-face culling 可能让 bbox 没有像素通过并产生错误“不可见”。距离观察者太近的模型通常应跳过 query。
Efficient Occlusion Culling Lab
先预测:遮挡率和对象复杂度要多高,query 才值得?
预测:复杂度和遮挡比例都低时,query 可能比直接绘制更贵;同帧读取还会增加 CPU/GPU stall。
交互实验用对象复杂度、遮挡比例、查询时序和分辨率表达成本方向:复杂度和遮挡率都高,跳过的几何才可能覆盖 query 成本;同帧读取会额外增加 stall;高分辨率会抬高 bbox 的 per-pixel 成本。数值是可解释的示意,不替代目标 GPU 的 timestamp、query latency 和 frame-time 数据。
6. lens flare 是一个可验证的应用
lens flare 不是部分透明地显示的物体,而是相机光学效果,通常要在已渲染场景上整体叠加。旧做法读取 depth buffer 会强制 GPU 完成队列;query 方案可以在光源位置和深度处测试一个像素,下一帧读取结果判断 flare 是否可见。
如果只测试一个像素,效果是 yes/no,容易 popping。可以根据 flare 屏幕尺寸测试一个小 block,例如 16×16,再用通过的像素比例调制亮度,得到更平滑的 fade。这个应用再次说明:延迟一个 frame 通常比同步读回更值得,query 结果还可以同时表达可见程度而不触碰 depth buffer。
小结:用代理、排序和延迟换取可见性收益
- occlusion culling 的层级要和成本匹配:先 frustum,再 query 或 early-z;不要用昂贵 query 做便宜 CPU 判断。
- occlusion query 用不写 color/depth 的 bounding box 统计 visible pixels,超过阈值才绘制完整对象。
- 多 outstanding queries 或上一帧读取避免 pipeline flush;不可见对象要及时复测,可见对象可以接受一帧延迟。
- opaque front-to-back 几何能成为 occluder,translucent 几何主要是 occludee;bounds 太松会产生 popping,太复杂会吃掉收益。
- 对象复杂度、遮挡率、CPU submit、分辨率、query latency 和画质都要进入实机回归;lens flare 是 query 的典型应用。
练习
问题 1|状态题 写出一次安全的 bounding-box occlusion query 必须关闭和恢复的状态,并说明为什么不能让 bbox 更新 depth。
问题 2|时序题 为什么“同帧发 query、同帧立即读 result”可能比直接绘制更慢?给出一种降低 stall 的改法。
问题 3|收益判断 一个只有 200 个三角形、fragment shader 很简单的对象和一个 2000 个三角形、fragment shader 很复杂的对象,哪个更适合 query?还要观察哪些变量?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- occlusion culling
- 通过不渲染视锥外或被更近几何遮挡的对象来减少渲染工作的策略。
- occlusion query
- 统计几何代理通过可见性测试的像素数量、供应用决定是否绘制完整对象的查询机制。
- early-z rejection
- 在光栅化阶段先做 depth test、失败 fragment 不再执行 shader 的硬件路径。
- bounding box
- 近似复杂对象空间范围、用于 occlusion query 的简单几何代理。
- occluder
- 已写入 depth buffer、可以遮挡后续对象的 opaque 几何。
- occludee
- 可能被更近几何遮挡、适合接受 occlusion query 的对象。