08 物体遮挡与遮挡剔除:看不见不等于没绘制
前置知识:第 04 章的深度测试、第 06 章的不可见深度代理、第 07 章的对象与片元区别。
本章示例:墙后五组粒子 · 源码。
预期画面:默认墙挡住内侧三组粒子,但页面显示 5 组仍提交、总计 6 次绘制调用;启用手工剔除后画面基本相同,提交 2 组、总计 3 次调用。
先看现象:同一画面,两种工作量
示例的墙在 z=0,五个 THREE.Points 粒子组在 z=-0.4,正交相机从正 z 方向看。默认墙宽为 3.8 世界单位,正好完整盖住中间三组,左右外侧两组可见。
选择“有墙:只靠深度遮挡”:墙先写深度,后面的粒子片元在墙内被深度测试挡掉,但五个 Points 对象仍提交。renderer.info.render.calls 为 6:一面墙加五组粒子。选择“有墙:手工剔除完全被挡的组”:代码在提交前将可证明全被盖住的三组设为 visible=false,调用数变成 3。画面几乎一样;省下的是三组对象的提交及其后续渲染工作。滑小墙宽到 1.8,两侧中间粒子组会部分露出,示例保留它们,调用数为 5。
这三种概念需要分开:
| 名称 | 判断位置 | 本例表现 |
|---|---|---|
| 物体遮挡(Object Occlusion) | 画面与深度关系 | 粒子在墙后,看不见或只露出一部分 |
| 深度测试造成的片元拒绝 | GPU 光栅化过程中 | 墙内的后方粒子片元不能更新颜色 |
| 遮挡剔除(Occlusion Culling) | 对象提交前的可见性决策 | 已证实全被挡的 Points 整组不提交 |
“物体遮挡”是视觉/空间事实;“遮挡剔除”是利用这个事实省掉工作。二者都与深度有关,但不是同一个渲染开关。
1. 深度写入如何产生遮挡
不透明墙通常保持 depthTest: true, depthWrite: true,先在覆盖的屏幕区域留下较近深度。粒子材质用 depthTest: true, depthWrite: false:粒子读取已有墙深度,墙后的片元被拒绝;粒子不把自身深度写回,避免软粒子之间硬裁。
const wallMaterial = new THREE.MeshBasicMaterial( {
depthTest: true,
depthWrite: true
} );
const particleMaterial = new THREE.PointsMaterial( {
map: particleTexture,
transparent: true,
depthTest: true,
depthWrite: false
} );
这只能决定片元能否更新画面。对象的场景遍历、矩阵处理、提交、顶点处理等工作可能仍发生;GPU 是否能在着色前快速拒绝片元,还取决于材质、混合和硬件路径,不能只从“看不见”推断具体节省了多少时间。colorWrite:false 的深度代理同样可能遮挡后续片元,却仍占绘制调用;它本身并不剔除后方对象。
2. 本例的保守整组判定
示例知道相机正对一面矩形墙、使用正交投影,且每组粒子有一个足够大的屏幕覆盖边界。只有当整个边界都落在墙投影内、并且整组位于墙后时,才跳过该组:
const fullyCovered = group.position.z < wall.position.z &&
Math.abs( group.position.x ) + boundRadius <= wallWidth / 2 &&
Math.abs( group.position.y ) + boundRadius <= wallHeight / 2;
group.visible = mode !== 'cull' || ! fullyCovered;
示例中的 boundRadius 由局部点位最大半径 0.35、点精灵的 21 CSS 像素直径换算到正交相机世界宽度 7.2,再加安全余量得到。它会随画布宽度重算。只有“完全被盖住”才能跳过;部分露出的组必须保留。这个公式依赖固定视角、墙形状与尺寸,不能直接复制到任意旋转相机、透视投影、凹形遮挡体或运动粒子。相机、墙、粒子范围变化时必须重新求界并重新判定;若界偏小,会错误地剔除可见像素。
group.visible=false 是示例的执行剔除手段,不是 Three.js 自动提供的通用遮挡检测。也要区分模拟与渲染:隐藏绘制不一定意味着可以暂停粒子的生命周期和位置更新;重新露出时仍需得到当前正确状态。
3. 它与视锥剔除、背面剔除有何区别
Three.js 对很多可渲染对象默认启用 frustumCulled:对象包围体在相机视锥外时,不提交该对象。墙后粒子仍在视锥内,所以视锥剔除帮不上忙。背面剔除判断三角形朝向,粒子组在墙后并不因此成为背面。深度测试是更晚的片元级比较,也不会自动告诉 JavaScript “整个 Points 已经不可见”。
从前到后可想成多层筛选:
场景/对象层:视锥剔除、手工遮挡剔除 → 决定是否提交对象
三角形层:背面剔除等 → 决定哪些图元继续
片元层:深度测试 → 决定采样能否更新颜色/深度
这些层可同时存在,但“深度测试通过率低”并不自动等于“对象已被剔除”。
4. 通用遮挡剔除大致怎样工作
真实场景里墙和目标不一定是正面矩形,通常用更保守的包围体与屏幕空间深度信息:
- 先得到可信的遮挡深度。可由不透明物体的正常绘制、深度预通道或简化遮挡体产生;遮挡体必须有足够覆盖,并按既定深度约定写入。
- 把待测对象的包围体投影到屏幕,得到覆盖矩形和“对象可能最近的深度”。包围体要包住所有可能显示的点精灵、动画位移与软边。
- 判断该矩形中每个采样位置是否都已有更近的遮挡深度。只要可能露出一点,就保守地保留对象。
- 利用结果决定后续帧或本帧后续通道是否提交对象,并在相机或场景变化时更新结果。
一种常见加速是分层深度缓冲(Hierarchical Z-Buffer,HZB):为深度图建立多级粗分辨率摘要。按本系列“近处深度小”的约定,若一个覆盖区域的最远已有深度上界仍比目标包围体最近深度更近,就能证明目标全被挡。只看中心点的深度不够:目标边缘可能露出墙外。另一类方法是 GPU 遮挡查询:先画包围体,询问有多少采样能通过深度测试,零可见样本可作为剔除信号。查询结果通常要异步使用,立即等待 GPU 回答可能造成 CPU/GPU 同步停顿。
Three.js r174 的 WebGLRenderer 有视锥剔除和深度测试,但没有一个把任意墙后对象自动判定并跳过的通用 occlusionCulling=true 材质或对象开关。本章的 CPU 规则只为教学场景成立。大型项目会结合静态空间划分、遮挡代理、异步 GPU 查询或深度金字塔,并为查询本身支付成本。
5. 什么时候不该剔除
透明烟雾、玻璃等通常不能作为“完全不透光”的遮挡体;若它们不写深度,也不会出现在普通不透明深度信息里。粒子效果还会扩张、旋转、随机运动,旧包围体或上一帧可见性可能过期。相机快速移动时,上一帧被挡对象可能突然露出,保守处理或暂时提交可避免闪烁。
若一个效果只有一两个绘制调用,复杂查询、构建深度金字塔、CPU 遍历及状态管理可能比直接绘制更贵。本例故意按整组 Points 为单位计数,因为一个组只是一笔绘制调用;renderer.info.render.calls 能证明是否提交,却不能单独证明 GPU 时间或帧率收益。衡量性能还要记录画布尺寸、像素比、粒子数、着色器和设备,并分别看 CPU 更新、数据上传、绘制调用与 overdraw。
常见问题
墙已经挡住所有粒子,为什么还有调用? 墙的深度让粒子片元失败,但 Points 对象仍在视锥内,渲染器仍提交它。第 08 页“只靠深度”模式就展示了这一点。
把墙 colorWrite=false 能帮忙剔除吗? 它可在写深度时充当不可见遮挡代理,但只影响颜色输出,不会自动跳过后方对象。还需独立的对象可见性判定。
把粒子组 visible=false 就万事大吉吗? 只有判定保守且及时更新才安全;部分可见、移动或突然露出的组不能被错误跳过。模拟状态也可能需要继续更新。
完成第 08 章练习,然后从第 09 章进入粒子系统的状态、发射与生命周期。参考:Object3D.frustumCulled 与 visible、WebGLRenderer.info。