working-set空间索引回答“附近可能有谁”,Working Set 回答“Renderer 现在应该为谁付出成本”。它们不是同一个概念。
学习目标
- 理解全量扫描为什么会进入 Camera 热路径;
- 手算一次 AABB 相交和 RBush 分支剪枝;
- 推导
Visible + Overscan + Pinned; - 计算前后两帧的
enter / stay / exit; - 用指标证明 Document 与 Renderer 容量已经解耦。
1. 为什么只做“视口裁剪”还不够
最直接的实现会在每次平移时遍历所有节点:
const visible = document.values().filter((node) => intersects(node.bounds, viewBounds))
这虽然只绘制 visible 节点,但查询仍然是 O(N)。当 pointermove 每秒触发几十次、Document 有几万节点时,主线程仍会反复扫描完整作品。
空间索引的作用不是让一次矩形比较更快,而是提前组织节点,让查询跳过整片不相交区域。
2. RBush 里保存什么
RBush 属于 R-tree:叶子保存节点的世界坐标 AABB,中间分支保存一组孩子的最小外包矩形。
interface SpatialEntry {
nodeId: string
minX: number
minY: number
maxX: number
maxY: number
}
圆形、旋转矩形或路径也先计算 AABB 做粗筛;框选或命中需要精确时,再对少量候选执行真实几何判断。粗筛宁可多返回附近节点,不能漏掉真实可见节点。
3. 手算一次分支剪枝
四个节点:
| 节点 | World AABB |
|---|---|
| A | [0, 0, 80, 60] |
| B | [120, 40, 180, 140] |
| C | [40, 220, 140, 320] |
| D | [220, 180, 280, 260] |
教学分组:
Root [0,0,280,320]
├─ G1 [0,0,180,140]
│ ├─ A [0,0,80,60]
│ └─ B [120,40,180,140]
└─ G2 [40,180,280,320]
├─ C [40,220,140,320]
└─ D [220,180,280,260]
Camera 查询 Q=[100,10,300,150]:Root 相交;G1 相交,继续检查 A/B;G2 的 minY=180 > Q.maxY=150,因此 C/D 整组跳过;最终只有 B 命中。
矩形相交:
function intersects(a: Bounds, b: Bounds): boolean {
return !(
a.maxX < b.minX || a.minX > b.maxX ||
a.maxY < b.minY || a.minY > b.maxY
)
}
4. Camera 移动和节点移动的区别
Camera 平移/缩放
→ 重新计算查询 Bounds
→ spatialIndex.search()
→ 不修改索引
节点移动/缩放/旋转
→ World AABB 改变
→ remove(oldEntry) + insert(newEntry)
新增节点 insert,删除节点 remove。不要在 Camera 每次变化时重建整棵树。
Core 只依赖 SpatialIndex 端口。LinearSpatialIndex 适合教学和小数据测试;生产大文档可替换为 RBushSpatialIndex。
5. Working Set 由三部分组成
Visible:屏幕当前能看到
Overscan:视口外即将进入的一圈
Pinned:正在拖动、编辑或播放,不能中断
集合公式:
VisibleSet = index.search(exactBounds)
NearbySet = index.search(expandedBounds)
WorkingSet = NearbySet ∪ PinnedSet
Overscan 是 NearbySet - VisibleSet。如果配置使用屏幕像素,必须换成 World 单位:
overscanWorld = overscanPx / camera.zoom
否则 zoom 改变时预加载带在屏幕上会忽宽忽窄。
6. 用四个节点推导 Working Set
A:位于 Overscan
B:当前 Visible
C:远离 Camera
D:被拖出视口,但仍 Pinned
于是:
VisibleSet = { B }
NearbySet = { A, B }
PinnedSet = { D }
WorkingSet = { A, B, D }
C 仍然存在于 Document、SpatialIndex 和持久化存储,只是当前不创建 Renderer handle。
7. 前后帧为什么要做集合差分
previous = { B, C }
next = { A, B, D }
enter = next - previous = { A, D }
stay = next ∩ previous = { B }
exit = previous - next = { C }
| 集合 | 动作 | 关键点 |
|---|---|---|
| enter | hydrate | 创建 handle、获取资源租约 |
| stay | reuse/update | 不重复创建对象 |
| exit | grace 后 dehydrate | 释放运行时成本,不删 Document |
如果每帧销毁并重建整个 Working Set,空间索引节省的查询成本又会被对象抖动抵消。
8. 完整算法
function calculateFrame(view: ViewState, pinned: Set<string>) {
const exact = view.worldBounds
const expanded = expand(exact, view.overscanPx / view.camera.zoom)
const visible = ids(spatialIndex.search(exact))
const nearby = ids(spatialIndex.search(expanded))
const next = union(nearby, pinned)
return {
visible,
workingSet: next,
enter: difference(next, previous),
stay: intersection(next, previous),
exit: difference(previous, next),
}
}
关系闭包也可能加入 Working Set,例如箭头进入时补充端点节点。但必须设置深度或数量上限,防止一条关系链把整幅 Document 拉进当前工作集。
9. 操作 Demo
Demo 固定保存 1,200 个节点:
- 记录初始
Document / candidates / Working Set / handles; - 平移到远区,观察 Document 不变、enter/exit 变化;
- 把 overscan 从 180px 改成 0,快速平移并观察加载频率;
- 改成 600px,观察 Working Set 和内存代价;
- 拖动一个节点越过视口边界,确认 Pinned 让交互不中断;
- 等待稳定后检查 handle 数接近 Working Set。
10. 如何验收“真的有界”
Document:1,200 → 12,000
Working Set:仍主要由视口面积和节点密度决定
Renderer handles:稳定后接近 Working Set
Camera 移动:不重建完整 SpatialIndex
若 Document ≈ Working Set ≈ renderedObjectCount,画布只是坐标可以无限平移,并没有实现容量虚拟化。
常见错误
- 用 Canvas Local Bounds 查询 World 索引;
- Camera 移动时更新所有索引项;
- 把 Visible 直接等同于 Working Set;
- 忽略 Pinned,拖拽一出视口就丢失 handle;
- overscan 不除 zoom;
- 只隐藏离屏对象,不释放对象与资源;
- 为了关系闭包把整张图无界拉入 Working Set。
课后实验
分别实现 Linear 与 RBush 两个 SpatialIndex,生成聚集分布和均匀分布节点。记录搜索候选数、查询耗时与 Working Set 大小;不要只比较一次查询,要模拟连续 Camera 平移。