Technical note

空间分桶与局部重建

记录 CAD/CAE Viewer 在 OSG 合批之后继续优化大模型交互的过程:为什么一个 Geometry 太大会让局部显隐、高亮和重建变重,以及空间分桶如何让显示引擎从能画出来走向能局部交互。


背景:合批之后还有新问题

前面几篇文章里,我一直在围绕一个问题展开:CAD/CAE Viewer 不能让每个 face、edge 都变成一个独立 OSG 节点。

节点、Drawable 和状态对象太多时,大模型交互会被拖慢。合批显示解决了这个问题的一部分:多个 face 可以合并到一个 DisplayBatch,多个 edge 也可以合并到较少的 Geometry,OSG 场景树不再直接等于 CAD 拓扑树。

但合批之后会出现另一个问题:

如果一个 Geometry 太大,局部交互仍然会变重。

例如:

隐藏一个小对象,却要处理一个很大的 Geometry;
高亮一小片区域,却要扫描或更新很大范围;
局部替换几个 face,却牵动整个 batch;
拾取或框选时,候选范围仍然太大;
ShowAll 或 ResetStyle 时,恢复路径变得笨重。

所以合批不是终点。

它只是把“太多小节点”的问题,变成了“批次粒度怎么控制”的问题。

空间分桶就是为了解决这个下一层问题。

普通合批的边界

最简单的合批方式,是按数量切分。

比如每个 batch 放一批 face,或者按三角形数量达到一定规模就切出一个 batch。这样可以控制单个 Geometry 的大小,也能避免所有 face 都塞进一个超级 Geometry。

但只按数量切分有一个明显缺陷:它不一定保留空间局部性。

如果一个 batch 里的 face 分散在模型各处,那么这个 batch 的包围盒会很大。即使用户只看模型的一小部分,OSG 也很难把这个 batch 剔除掉。

更重要的是,局部操作也很难只影响小范围。

可以简单理解成:

按数量切分:
  每个 bucket 大小比较均匀,
  但空间上可能很分散。

按空间切分:
  每个 bucket 尽量覆盖局部区域,
  局部交互更容易命中少量 bucket。

对于 CAD/CAE 大模型,空间局部性很重要。

工程模型中,用户经常只操作某个零件、某个 body、某块面、某个局部区域。显示引擎需要利用这种局部性,而不是每次都把操作扩大到全局。

DisplayBucket 的作用

DisplayBucket 可以理解为介于“一个超级 Geometry”和“每个 face 一个节点”之间的中间粒度。

它不是业务对象,也不是单个拓扑 face。

它是显示引擎为了渲染和交互组织出来的批次单元。

一个简化结构是:

DisplayData.faces
  -> material / opacity grouping
  -> spatial grouping
  -> DisplayBucket
      -> DisplayBatch
      -> Geometry
      -> RangeTable records

DisplayBucket 的目标有几个:

控制 Geode / Drawable 数量;
避免单个 Geometry 过大;
保留空间局部性;
支持局部显隐、高亮和重建;
缩小拾取、框选、hover 的候选范围;
让 RangeTable 能把 face / edge 定位到当前显示批次。

它本质上是一个工程折中:

比 face 粗,
比全局 Geometry 细。

没有 bucket,合批容易走向一个或少数几个大 Geometry。 bucket 太碎,又会重新接近旧 Viewer 的节点爆炸问题。

所以 DisplayBucket 的价值不只是“分组”,而是给大模型交互留下局部边界。

为什么不能只按数量切分

只按数量切分的问题,在大模型上会很明显。

假设一个模型很长,或者由许多分散零件组成。如果按导入顺序或 face 数量切 bucket,一个 bucket 可能同时包含模型左端、右端和中间的面。

它的包围盒会覆盖很大范围。

这会带来几个后果。

第一,视锥剔除效果差。

只要这个大包围盒和视锥相交,整个 bucket 都可能被认为需要参与渲染准备。

第二,局部拾取候选多。

鼠标只在某个局部区域操作,但相关 bucket 可能覆盖很大空间,候选 face 仍然很多。

第三,局部高亮不够轻。

选中一个局部对象时,它所在的 bucket 可能还包含很多远处 face,Selection Layer 需要做更多过滤和构建。

第四,局部重建不够局部。

修改少量 face,却可能要重建一个覆盖很大空间的 bucket。

所以 bucket 不能只追求数量均匀,还要尽量让空间范围合理。

空间分桶的判断很简单:

显示引擎不只要知道“这个 bucket 有多少三角形”,
还要知道“这个 bucket 覆盖了哪里”。

oversized bucket 为什么麻烦

空间分桶也不是切一次就结束。

某些 bucket 仍然可能过大,可以称为 oversized bucket。

oversized bucket 的问题不一定只是三角形数量超大,也可能是空间覆盖太大。一个 bucket 如果包围盒占了模型整体很大比例,它就会拖慢很多后续操作。

对显隐来说,完整隐藏一个 bucket 很快,但如果只隐藏 bucket 里的少量 face,就要走局部处理。bucket 越大,混合状态越多,处理成本越高。

对高亮来说,Selection Layer 需要把选中目标聚合到 bucket 中。bucket 太大时,局部高亮可能退化成大范围扫描或大批次构建。

对拾取来说,空间候选不够精确。尤其是 hover 或框选这种高频交互,候选范围太大会放大 CPU 开销。

对局部重建来说,oversized bucket 会让“局部”变得不局部。修改少量面,却要处理一大块 Geometry,收益会下降。

所以空间分桶里经常需要额外处理 oversized bucket:检测它、继续切分它,或者在局部更新时对它走更谨慎的 fallback。

公开文章里不适合展开具体阈值和策略,但这个工程问题本身是很典型的。

dirty bucket:让局部变化有边界

有了 bucket 之后,显示引擎可以把很多操作从“全局重建”收敛到“脏 bucket 处理”。

例如隐藏一组 face。

旧思路可能是重新构建可见 DisplayData,再生成整份 Geometry。bucket 思路下,可以先判断这些 face 落在哪些 bucket 里:

selected faces
  -> locate bucket ids
  -> mark dirty buckets
  -> rebuild or patch affected buckets

这样,如果用户只操作一个局部区域,理论上只需要处理少数 bucket。

dirty bucket 的意义不只是减少计算量,也让状态恢复更可控。

ShowAll、ClearScene、ResetStyle 这类操作可以知道哪些 bucket 曾被修改,哪些需要恢复,哪些基础 batch 没动过。

这对大模型很重要。

大模型里最怕所有操作都退回全量重建。dirty bucket 给局部变化画了一条边界。

partial rebuild 和 partial replacement

dirty bucket 只是标记,后面还需要真正处理。

partial rebuild 可以理解为只重建受影响的 bucket,而不是重建整份模型。

partial replacement 可以理解为把某个 bucket 的旧显示批次替换成新的显示批次,同时保持其他 bucket 不变。

简化流程可以写成:

operation targets
  -> affected bucket ids
  -> rebuild affected bucket geometry
  -> replace old bucket batch
  -> update RangeTable ranges
  -> keep unaffected buckets unchanged

这里最重要的不是具体算法,而是工程意义:

局部操作不应该总是变成全局操作。

不过 partial replacement 也会带来一致性问题。

旧 bucket 的 Geometry 变了,RangeTable 中对应的 primitive range 也必须更新。Selection Layer 里如果有旧高亮,也必须清理或重建。拾取缓存、hover 状态、边显示缓存,都可能需要同步处理。

所以局部重建不是简单“替换一个节点”。

它是 DisplayBatch、RangeTable、Selection Layer、可见性状态之间的一次一致性更新。

这也是显示引擎难维护的地方:画面更新只是表层,真正重要的是更新后各层语义仍然一致。

bucket 粒度的取舍

bucket 粒度没有绝对正确答案。

bucket 太小,会重新接近旧问题:

Geode / Drawable 数量增加;
场景树遍历变重;
StateSet 和批次管理成本上升;
RangeTable 和 bucket 索引更多;
批量构建和调度更碎。

bucket 太大,又会带来新问题:

局部隐藏影响范围大;
局部高亮构建重;
拾取候选范围大;
oversized bucket 多;
partial rebuild 成本高;
空间剔除效果弱。

因此 bucket 粒度是一个平衡问题。

它要同时考虑:

渲染:
  Geode / Drawable 数量;
  Geometry 大小;
  StateSet 数量。

交互:
  拾取候选范围;
  hover 频率;
  框选范围;
  Selection Layer 构建成本。

更新:
  dirty bucket 数量;
  partial rebuild 成本;
  RangeTable 更新范围;
  ShowAll / ResetStyle 恢复路径。

这也是为什么空间分桶通常需要运行时诊断和调参,而不是靠一个固定规则解决所有模型。

不同模型的拓扑分布、空间分布和用户操作方式都不一样。bucket 策略必须保留一定弹性。

RangeTable 的一致性压力

空间分桶让 RangeTable 更重要,也更难维护。

在普通合批里,一个 face 对应某个 batch 的一段 primitive range。引入 bucket 后,face 还会对应到某个 bucket。

局部替换时,某个 bucket 的 Geometry 可能被换掉,原来的 Drawable 和 primitive range 也可能失效。

这意味着 RangeTable 必须跟着变化。

如果 RangeTable 没更新,可能出现:

画面上有 face,但拾取不到;
拾取命中了新 Geometry,却返回旧 face;
工程树选中对象后,高亮定位到旧 batch;
ShowAll 恢复时基础 range 和 replacement range 混在一起。

所以 RangeTable 不能只是导入时生成一次的静态表。

只要 DisplayBatch 可以局部替换,它就必须有对应的一致性策略。

公开文章里不展开具体实现,但工程判断很明确:

局部重建能不能稳定,关键不只在 Geometry 替换,
还在 RangeTable 是否同步。

Selection Layer 也受 bucket 影响

Selection Layer 同样依赖 bucket 粒度。

如果 bucket 具有良好的空间局部性,选中一块区域时,高亮可以按少量 bucket 聚合。这样生成的高亮 batch 更少,也更容易分块提交。

如果 bucket 过大或空间分散,Selection Layer 就会面对更多过滤和 fallback。

尤其是大范围框选、工程树选中复杂 body、Ctrl+A 这类操作,完整高亮可能很重,需要 hint 或降级策略。

bucket 粒度还会影响清理。

局部重建后,旧高亮可能引用旧 Geometry;隐藏对象后,对应高亮也应该清理。Selection Layer 必须能根据 bucket 和 target 的变化及时更新。

所以空间分桶不是只服务渲染,它也直接影响选择高亮的可控性。

这也是这几篇文章之间的关系:

DisplayBatch 控制基础渲染批次;
RangeTable 保留拓扑语义;
Selection Layer 管理临时交互反馈;
DisplayBucket 决定局部操作的影响范围。

这几层任何一层失控,最终都会反映到用户交互上。

从能画出来到能局部交互

CAD 大模型显示优化有一个阶段性变化。

第一阶段是“能画出来”。

这时重点是减少节点爆炸、控制内存、合并 Geometry,让模型能稳定显示。

第二阶段是“能交互”。

这时问题变成:鼠标能不能流畅 hover,框选会不会卡,隐藏局部对象是否需要全量重建,工程树选择能不能及时反馈。

第三阶段是“能局部维护”。

这时需要关心 dirty bucket、partial rebuild、partial replacement、缓存恢复、状态一致性。

空间分桶正好处在第一阶段和第二阶段之间。它让合批后的模型不只是一个大块,而是有可操作的局部边界。

这也是它在 CAD/CAE Viewer 中很关键的原因:工程用户很少只是看模型,他们会不断选择、隐藏、隔离、修复、检查局部区域。

没有空间局部性,大模型交互很难做细。

后续方向

空间分桶之后,还有一些方向可以继续推进,但不适合在公开文章里展开完整细节。

动态 bucket 是一个方向。

不同模型的拓扑分布差异很大,固定切分规则不一定总是合适。后续可以根据模型形态、操作频率和可见性统计调整 bucket 策略。

缓存预热也是一个方向。

某些局部重建或高亮数据可以提前准备,减少用户操作时的前台等待。

局部替换还可以继续优化。

目标是让局部显隐、材质恢复、选择高亮和 RangeTable 更新尽量只影响必要范围。

这些方向的共同目标不是追求某个单点指标,而是让大模型交互变得可预测:

用户操作局部区域时,
Viewer 不应该总是付出全局成本。

小结

OSG 合批解决了“太多小节点”的问题。

但如果合批后的 Geometry 太大,局部交互仍然会变重。

DisplayBucket 是两者之间的折中。它让显示引擎既能控制 Geode / Drawable 数量,又能保留空间局部性。

空间分桶进一步让 bucket 不只是按数量切分,而是尽量对应模型中的局部区域。

dirty bucket、partial rebuild 和 partial replacement 的意义,是把局部隐藏、高亮、替换和恢复限制在受影响的 bucket 内,而不是动整份模型。

当然,这也带来了 RangeTable、Selection Layer 和显示状态的一致性压力。bucket 粒度太粗或太细都会出问题。

所以空间分桶不是一个单独的性能技巧,而是 CAD 大模型 Viewer 从“能画出来”走向“能局部交互”的关键结构。

到这里,显示引擎专题的第一阶段就基本闭环了:从节点爆炸开始,到 DisplayData、合批、RangeTable、Selection Layer,再到空间分桶和局部重建,核心问题其实一直是同一个——如何在保留 CAD 语义的同时,让大模型交互保持可控。