Chapter 49: Portal and Cell Culling
室内大型场景的可见性判断通常受建筑结构约束。玩家站在一个房间里时,视线先经过当前房间,再经过门洞、窗洞、走廊口、楼梯口,逐层扩展到其他空间。Portal and Cell Culling 的目标,是把这种结构约束变成渲染管线中的可见集合收集过程,让 CPU 在提交 draw call 之前先排除大量位于墙体后方的对象。
本章读完后,读者应能定位一个室内关卡中的 cell、portal、visibility graph,追踪相机从当前 cell 出发如何递归穿过开口,判断哪些对象进入可见集合,区分 frustum culling、portal traversal、occlusion query 和 dynamic object 处理各自回答的问题。
贯穿本章的场景是一层室内关卡:Room_A 是相机所在办公室,Corridor_B 是走廊,Room_C 是会议室,Stair_D 是楼梯间。Room_A 与 Corridor_B 之间有一个门洞 portal,Corridor_B 与 Room_C 之间有一扇可开关的门,Corridor_B 与 Stair_D 之间有一个楼梯口。墙体本身是稳定遮挡边界,门洞和楼梯口是视线传播入口。
本章的核心结论是:portal culling 先把室内空间组织成 cell graph,再从 camera cell 出发,用被 portal 多边形裁剪过的可见体递归遍历相邻 cell,最后收集 visible cells 内与当前裁剪体相交的对象。它的收益来自建筑结构提供的强遮挡边界,失败风险集中在 cell 划分错误、portal 多边形过宽、动态门状态滞后和过度激进的对象归属规则。
49.1 Portal Cell Visibility Graph for Indoor Culling
Portal cell visibility graph 是室内剔除的结构化输入。cell 是可见性单元,通常对应房间、走廊、楼梯间、电梯井或可被墙体稳定包围的区域。portal 是连接两个 cell 的可见开口,通常对应门洞、窗洞、走廊口、楼梯口或大型破洞。visibility graph 把 cell 作为节点,把 portal 作为带几何形状的边。
在贯穿场景中,Room_A、Corridor_B、Room_C、Stair_D 是四个 cell。Portal_AB 连接办公室和走廊,Portal_BC 连接走廊和会议室,Portal_BD 连接走廊和楼梯间。相机位于 Room_A 时,初始可见集合先包含 Room_A,后续是否看到 Corridor_B 取决于 Portal_AB 是否落入相机视锥。是否进一步看到 Room_C,取决于穿过 Portal_AB 后形成的裁剪体是否还能覆盖 Portal_BC。
这个 graph 与普通邻接图的差异在于边本身带有二维几何。普通 graph 只告诉系统两个 room 相邻;portal graph 还告诉系统视线能从哪个多边形开口穿过。剔除算法使用 portal polygon 裁剪当前可见体,得到更窄的子可见体。递归越深,可见体通常越窄,远处 cell 中能通过所有门洞链条进入画面的对象越少。
下面的图只表达本章贯穿场景的 cell 与 portal 关系,图中每条边都对应一个可以被投影、裁剪和开关状态控制的 portal 多边形。
图中的遍历起点由 camera cell 决定。相机在 Room_A 时,Portal_AB 是第一层开口。相机进入 Corridor_B 后,Portal_AB、Portal_BC、Portal_BD 都会进入候选边集合,但已经访问过的路径要带着裁剪状态处理,防止环状空间在递归中反复扩展。
Portal culling 的可见性判断遵循保守原则。过度保守会把更多对象送进后续 frustum test、occlusion test 或 draw submission,成本表现为 draw call、triangle、overdraw 增加。过度激进会把真实可见对象剔除,表现为门口物体突然消失、转身时房间内容闪烁、开门瞬间可见集合滞后。室内剔除的第一条工程边界,是把错误风险优先压到额外绘制一侧。
49.2 空间划分与网格组织策略
空间划分决定 portal culling 的上限。cell 划分过粗时,一个房间、走廊和多个侧室被合成大区域,portal traversal 很快扩展到大量对象,剔除收益下降。cell 划分过细时,graph 节点和 portal 数量增长,CPU 遍历、对象归属、编辑校验和 streaming 管理都会增加成本。稳定方案是让 cell 与建筑遮挡边界对齐,并让 portal 只覆盖真实可见开口。
Room_A 这类封闭办公室适合作为一个 cell,因为墙体能稳定阻断视线。Corridor_B 如果是一条长走廊,可以按拐角、门组或可见长度拆成多个 corridor cell。长直走廊拆分的依据是屏幕可见距离和门洞密度:相机从一端看向另一端时,如果整条走廊中大部分门洞都长期可见,拆分收益有限;如果走廊有转角、柱体、分段门厅,拆分能缩窄递归可见体。
Portal polygon 需要贴合开口的真实边界。门洞 portal 可以使用门框内侧四边形,窗洞 portal 可以使用玻璃可见范围,楼梯口 portal 可以使用楼梯井入口平面。portal 过大时,后续 cell 的裁剪体变宽,远处对象更容易进入候选集合。portal 过小时,玩家从门边斜看时可能丢失真实可见内容。工程上通常让 portal 略大于开口,但增长量要小于门框厚度、碰撞体误差和浮点误差共同产生的安全余量。
预计算可见集通常称为 Potentially Visible Set,简称 PVS。PVS 给每个 cell 保存一个潜在可见 cell 集合,用于快速给出上界。实时 portal traversal 给当前相机、当前门状态和当前视角计算更精确的子集。PVS 适合静态建筑和大型关卡的粗过滤,实时 traversal 适合处理相机方向、portal 裁剪和动态门状态。二者组合时,可以先用 PVS 限制候选 cell,再用 portal traversal 收窄本帧可见集合。
面向运行时的数据组织应把 cell、portal、对象归属和 streaming 信息放在同一套索引体系中。下面的简化结构表达关键字段,省略具体数学类型实现。
class Cell:
def __init__(self, name, bounds, portals, static_objects, dynamic_objects):
self.name = name
self.bounds = bounds
self.portals = portals
self.static_objects = static_objects
self.dynamic_objects = dynamic_objects
class Portal:
def __init__(self, name, from_cell, to_cell, polygon, is_open):
self.name = name
self.from_cell = from_cell
self.to_cell = to_cell
self.polygon = polygon
self.is_open = is_open
class RenderObject:
def __init__(self, name, bounds, mesh_id, material_id):
self.name = name
self.bounds = bounds
self.mesh_id = mesh_id
self.material_id = material_id
这段结构的重点是让 runtime 能从当前 cell 快速拿到 portal 列表,从 portal 快速找到相邻 cell,从 cell 快速拿到候选对象。bounds 服务于 frustum test 和 portal-clipped frustum test;is_open 服务于门状态、破坏状态和脚本状态;mesh_id 与 material_id 服务于后续 draw submission 的排序与 batching。
对象归属要处理跨 cell 情况。一个桌子完全位于 Room_C,可以归属到 Room_C。一扇打开后跨过门洞的门板,可能同时影响 Corridor_B 和 Room_C 的可见性与渲染归属。大型动态物体、粒子系统、角色骨架和玻璃墙这类跨界对象,应使用多 cell 引用或独立动态集合,并在本帧按 bounds 与可见 cell 集合重新测试。
49.3 实现快速可见性剔除管线
快速 portal culling 管线从 camera cell lookup 开始。相机位置通过空间索引、cell bounds、导航区域或编辑器烘焙数据定位到当前 cell。室内游戏通常可以把玩家所在 room、sector 或 navigation polygon 与 cell 绑定;自由相机工具则要使用点包含测试、最近 cell fallback 和 previous cell 缓存来处理边界位置。
初始可见体来自相机 frustum。遍历当前 cell 时,系统先收集当前 cell 中与可见体相交的对象,再检查每个 portal 是否与可见体相交。portal 可见时,系统使用相机位置和 portal polygon 的边界生成更窄的裁剪平面,把当前可见体缩成 child volume,然后进入相邻 cell。这个 child volume 代表“从相机出发并穿过当前 portal 后仍可能看到的空间”。
递归遍历的最小流程可以写成下面的伪代码。它省略具体平面求交、矩阵和 SIMD 优化,只保留管线顺序与关键判断点。
def collect_visible(camera, scene):
start_cell = locate_camera_cell(camera.position, scene)
visible_cells = set()
visible_objects = set()
pending = [(start_cell, camera.frustum, 0)]
while pending:
cell, volume, depth = pending.pop()
visit_key = make_visit_key(cell, volume, depth)
if scene.visited.contains(visit_key):
continue
scene.visited.add(visit_key)
visible_cells.add(cell)
for obj in cell.static_objects:
if intersects(obj.bounds, volume):
visible_objects.add(obj)
for obj in cell.dynamic_objects:
if intersects(obj.bounds, volume):
visible_objects.add(obj)
for portal in cell.portals:
if portal.is_open and intersects(portal.polygon, volume):
child_volume = clip_volume_by_portal(volume, camera.position, portal.polygon)
if child_volume.is_valid():
pending.append((portal.to_cell, child_volume, depth + 1))
return visible_cells, visible_objects
make_visit_key 应同时记录 cell 编号和裁剪状态。环形走廊或多门房间中,同一个 cell 可能通过不同 portal 链条进入,裁剪体不同,可见结果也不同。工程实现通常给递归深度、portal path、裁剪体近似签名或 portal 序列设置限制。目标是在保持保守可见的前提下控制遍历爆炸。
Portal clipping 的几何核心是从相机位置和 portal 多边形边界生成裁剪平面。四边形门洞有四条边,每条边与 eye 点共同形成一个侧向平面。child volume 由当前 volume 与这些平面求交得到。经过两三个门洞后,child volume 可能收缩成很小的角锥体,此时大量远处对象会在 bounds test 中被排除。
渲染管线中的位置通常在 CPU visibility pass 与 render graph 构建之间。visibility pass 输出 visible cells、visible objects、visible lights、visible reflection probes 和需要加载的 streaming cells。render graph 再按 pass、material、pipeline state 和资源绑定生成 draw lists。把 portal traversal 放在 draw submission 之前,可以直接减少 CPU 提交成本、GPU vertex work、fragment overdraw 和资源绑定压力。
调试时应同时显示三个层次:当前 camera cell、递归经过的 portal 链条、每层 child volume。只看最终 draw list 很难定位错误,因为丢失对象可能来自 camera cell lookup、portal polygon、door state、bounds test 或 visit key。一个稳定的调试 overlay 应显示 cell id、portal id、portal open state、visible cell count、visible object count 和本帧 traversal depth。
49.4 多层级剔除与动态场景处理
Portal culling 在完整引擎中通常位于多层级剔除链中。常见顺序是 distance budget 过滤超远对象,camera frustum 过滤屏幕外对象,portal traversal 过滤墙体后方 cell,occlusion 或 HZB 过滤被近处几何遮挡的对象,LOD 和 streaming 决定对象使用哪个资源层级。这个顺序的依据是成本与证据来源:便宜的 bounds test 放前面,依赖 depth buffer 或 GPU 结果的判断放后面。
Frustum culling 回答对象是否落入相机视锥。Portal traversal 回答视线是否能通过室内开口到达对象所在 cell。Occlusion culling 回答对象是否被已知深度遮挡。三者使用的证据不同,组合后才形成可迁移的室内剔除管线。Room_C 中的椅子可能处在相机 frustum 内,但如果 Portal_BC 关闭,portal traversal 会排除它;如果门打开但椅子被会议桌完全遮挡,后续 occlusion 可能进一步排除它。
动态门状态会直接改变 graph 的可遍历边。Portal_BC 对应会议室门,门关闭时可以把 portal 标记为 closed,遍历在 Corridor_B 停止进入 Room_C。门半开时有两种处理方式:一种是把 portal polygon 更新成当前开口形状,另一种是使用保守开口并交给后续 occlusion 或 depth test 收缩结果。前者更精确,维护成本更高;后者更稳定,可能产生额外绘制。
动态对象的归属应按 bounds 与 cell 关系更新。角色从 Corridor_B 走进 Room_C 时,如果只在进入 cell 中切换归属,门口跨界的几帧可能发生可见集合跳变。更稳妥的做法是给动态对象维护当前 cell、候选邻接 cell 和 expanded bounds。bounds 与多个 cell 或 portal 邻域相交时,对象加入多个 cell 的动态集合,渲染收集阶段使用对象 id 去重。
Streaming cell 与 portal traversal 可以共用可见性信息,但加载策略要比绘制策略更保守。当前帧可绘制集合只需要覆盖相机确实可能看到的对象;资源加载集合还要覆盖玩家即将移动到的邻接 cell。Room_A 中相机看向 Portal_AB 时,Corridor_B 必须进入渲染候选;Room_C 即使暂时未穿过 Portal_BC,也可能因为门即将打开或玩家快速前进而进入预加载集合。
多层级剔除也要处理透明物体、镜面、反射探针和阴影。透明玻璃门可能让 portal 在视觉上开启,但深度写入策略和排序规则会改变 occlusion 结果。镜面和反射探针会产生非相机视线方向的可见需求,阴影 pass 会从光源视角重新收集可见对象。工程上应把 camera visibility、shadow visibility、reflection visibility 分成不同 pass 的查询,portal graph 可以复用,输入视点和裁剪体要分别计算。
49.5 性能评估:门剔除对大型场景渲染的影响
评估 portal culling 收益要把 CPU 和 GPU 指标分开看。CPU 侧关注 traversal time、cell count、object bounds test count、draw call count、render item build time 和 state sorting cost。GPU 侧关注 vertex shader invocations、fragment shader invocations、overdraw、depth test 通过率、render target bandwidth 和每个 pass 的 GPU duration。只看帧率会掩盖收益来源,因为 portal traversal 可能减少 GPU 绘制,同时增加 CPU 可见性计算。
室内关卡测试应覆盖三类相机位置。第一类是封闭房间内部,例如 Room_A 只通过一扇门看到走廊,这类位置最能体现 portal 裁剪收益。第二类是走廊中段,例如 Corridor_B 同时看到多个门洞和楼梯口,这类位置检验递归深度、portal 数量和过度保守程度。第三类是门边斜视和快速转身,这类位置检验 portal polygon、camera cell lookup 和动态对象归属的稳定性。
一个可复用的评估表应把不同开门状态与不同相机位置放在同一组指标下比较。下面的表是指标组织方式,具体数值应来自项目自身的 profiler、GPU capture 或引擎统计面板。
| 场景状态 | 观察点 | CPU 证据 | GPU 证据 | 视觉证据 |
|---|---|---|---|---|
Room_A 门口内侧 | 只看到走廊入口 | visible cell count、bounds test count | vertex invocations、overdraw | 远处房间对象无闪烁 |
Corridor_B 中段 | 多个门洞同时可见 | traversal depth、portal test count | fragment cost、depth pass cost | 门洞边缘无物体跳变 |
Portal_BC 关闭 | 会议室门关闭 | Room_C 未进入 visible cells | draw call 减少、shadow caster 变化受控 | 门后对象无穿帮 |
| 快速转身 | 相机跨 cell 边界 | camera cell lookup 命中率 | GPU duration 波动范围 | 房间内容无突兀消失 |
门剔除的收益来自减少进入 draw list 的对象,但它的成本来自 CPU 遍历和维护数据结构。小型室内场景中,frustum culling 已经足够,portal graph 的编辑和运行时成本可能抵消收益。大型室内关卡、地铁站、飞船舱室、地下设施、城市建筑内部这类场景中,墙体提供稳定遮挡,portal culling 通常能显著减少远处房间、侧室和楼层的提交量。
错误剔除的排查顺序应从结构输入开始。第一步检查相机是否落入正确 cell;第二步检查当前 cell 的 portal 列表是否完整;第三步显示 portal polygon 与真实门洞是否对齐;第四步显示每层 child volume;第五步检查对象 bounds 是否覆盖真实几何;第六步检查 dynamic object 是否进入多个相关 cell;第七步再看 occlusion、LOD、streaming 和 shader pass。这个顺序能把“对象消失”拆成可观察的管线证据。
上线前的稳定性测试应使用可视化 overlay 和自动统计共同完成。overlay 负责让设计师看到 cell 边界、portal 开口、递归路径和对象归属;自动统计负责记录 visible cell count、visible object count、draw call count、portal traversal time 和每个相机点的最大递归深度。二者结合后,团队可以发现过宽 portal、漏连 cell、动态门状态滞后和跨 cell 对象归属错误。
最终判断顺序可以固定为:先确认建筑遮挡是否足够稳定,再决定 cell 粒度;先保证 portal polygon 覆盖真实开口,再做实时 traversal;先用保守 bounds 保证视觉稳定,再通过 profiler 找到过度保守带来的成本;先比较 draw call、overdraw 和 traversal time,再决定是否引入 PVS、HZB 或更细粒度 streaming。这个顺序把视觉正确性、CPU 成本和 GPU 收益放在同一个可验证框架中。
最小自检任务
给定一个室内关卡:相机位于 Room_A,Room_A 通过 Portal_AB 连接 Corridor_B,走廊再通过 Portal_BC 连接 Room_C。Portal_AB 打开,Portal_BC 关闭。Room_C 中有一张桌子,桌子的 bounds 完全位于 Room_C 内。请说明 portal culling 管线本帧应如何收集 visible cells 和 visible objects,并指出如果桌子在画面中闪烁,排查顺序应从哪些对象开始。
答案要点
本帧 camera cell lookup 应返回 Room_A。初始 visible cells 包含 Room_A,初始可见体是 camera frustum。系统检查 Portal_AB,它处于打开状态并与当前可见体相交时,生成穿过 Portal_AB 的 child volume,并进入 Corridor_B。在 Corridor_B 中,系统检查 Portal_BC,由于它处于关闭状态,遍历停止进入 Room_C,所以 Room_C 不进入本帧 visible cells。
Room_C 中桌子的 bounds 完全位于 Room_C 内,且 Room_C 未被遍历到,因此桌子不应进入本帧 visible objects。若桌子在门关闭状态下闪烁,排查顺序应先看 Portal_BC 的 open state 是否同步到 visibility pass,再看 Room_C 是否通过其他 portal path 被访问,再检查桌子是否被错误加入 Corridor_B 的动态集合,随后检查 bounds 是否跨过 portal 邻域,最后再查看 occlusion、LOD 和 streaming pass 的状态。
本章知识点总结
- Cell 定义:cell 是室内可见性单元,通常对应房间、走廊、楼梯间或其他被稳定遮挡边界包围的空间。
- Portal 定义:portal 是连接两个 cell 的可见开口,运行时通过它的多边形约束视线传播范围。
- Graph 作用:visibility graph 把 cell 组织成节点,把 portal 组织成带几何形状和状态的边。
- 遍历起点:portal traversal 从 camera cell 开始,初始可见体来自相机 frustum。
- 裁剪核心:每穿过一个 portal,系统使用 portal polygon 把当前可见体收窄成 child volume。
- 保守原则:过度保守表现为额外绘制,过度激进表现为可见对象丢失和画面闪烁。
- 划分粒度:cell 粒度应贴合建筑遮挡边界,并在剔除收益和遍历成本之间取得平衡。
- 对象归属:跨 cell 动态对象应进入多个相关动态集合,并在收集阶段使用对象 id 去重。
- 门状态:动态门直接改变 portal 的可遍历状态,半开门可以更新 portal polygon 或使用保守开口。
- 多级剔除:frustum、portal、occlusion、LOD 和 streaming 分别回答不同证据来源下的可见性问题。
- 性能证据:评估收益应同时观察 CPU traversal cost、draw call、GPU vertex work、fragment cost 和 overdraw。
- 排查顺序:对象消失时应先检查 camera cell、portal 列表、portal polygon、child volume、object bounds 和动态归属,再检查后续 pass。