Pascal Editor 场景注册表:3D 建筑编辑器如何绕开树遍历
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
一个交互式三维编辑器真正的性能命门,往往不在渲染,而在”每帧要找东西”这件事上。 Pascal Editor 是一个跑在浏览器里的开源 3D 建筑编辑器(和 Pascal 编程语言没有任何关系,也不是压强单位),它给这个问题的答案很朴素:维护一张全局可变的 Map,把节点 ID 直接指向活着的三维对象,谁要用谁就 get 一下,不许沿着场景树往下爬。仓库里 wiki/architecture/scene-registry.md 把这层东西的定位写得很直白——避免树遍历,让系统和选择管理器做 O(1) 查找。
这篇只讲这一层索引:它长什么样、注册和清理发生在什么时刻、下游哪些功能靠它吃饭、以及它划清了哪些边界。建模算法、三维数学、渲染管线都不在范围内。
一、它到底在替谁省事
先把两个建筑侧的名词交代清楚,后面会反复用到。**墙(wall)**就是竖着的那道分隔面;**楼板(slab)**是一层楼的水平承重板,你脚下踩的那块;**层(level)**是把同一楼层的东西挂在一起的容器节点;**区域(zone)**是在平面上圈出的一块房间范围。在这个项目里它们都是”节点”,各自有 ID、有类型、有参数,packages/nodes/src/ 下一个子目录对应一种。这个目录当前有 46 个子目录,其中 shared/ 放的是公共代码不算节点类型,所以是 45 种节点类型——你自己 ls 一遍就能对上。
问题出在,节点数据和屏幕上那个真实的三维对象是两回事。节点是纯数据:这堵墙从哪到哪、多高多厚。屏幕上被渲染出来、能被鼠标射线打中、能算包围盒的,是三维引擎里的对象实例。包围盒是把一个物体整个装进去的最小长方体,用来快速判断「点在不在里面」「两个东西有没有交叠」,比逐个三角面比对便宜太多。中间那道映射,如果不显式维护,唯一的办法就是从场景根节点往下遍历,一层层比对标识,找到为止。
这个代价在什么时候会咬人?看几个真实调用点就明白了:
- 你按住鼠标框选,
box-select-tool.tsx要对每个候选 ID 拿到对象算它在不在框里; - 你切到第一人称走进模型里,
build-collider-world.ts要为整层楼构建碰撞体,它是直接遍历sceneRegistry.nodes的键取对象; - 你选中几堵墙,描边高亮要把这些对象塞进后处理的轮廓通道(画面渲染完之后再叠一遍的描边效果,它要的是对象本身,不是数据);
- 你导出 GLB 文件(三维模型的通用交换格式,一个二进制文件装下几何、材质和层级),
glb-export.ts要把导出树上的克隆件和原始对象一一配对,才能把身份信息盖回去。
这些全是”高频 + 一次要很多个”的场景。遍历一次场景树的成本,乘上每帧、乘上选中集合的大小,很快就不是常数级别的事情了。注册表的存在,就是把这份成本前置到”渲染器挂载时写一次”。
二、这张表的实际结构
打开 packages/core/src/hooks/scene-registry/scene-registry.ts,导出的东西一共就四样:nodes、revision、byType、clear()。
nodes 是主查找表,ID 到三维对象。它不是普通的 Map,而是一个自己写的子类:
class RevisionedMap<K, V> extends Map<K, V> {
revision = 0
override set(key: K, value: V) {
if (this.has(key) && this.get(key) === value) return this
super.set(key, value)
this.revision += 1
return this
}
...
}
关键在那个提前 return:写入同一个键、同一个对象引用,版本号不动。删一个不存在的键,版本号也不动。仓库里 scene-registry.test.ts 就是逐条断言这件事的。这个 revision 的用途在下游——测量模块 packages/nodes/src/measurement/surface-query.ts 用它当缓存钥匙,只有当 revision !== sceneRegistry.revision 或者超过刷新间隔时才重建射线上下文。换句话说,版本号是这层索引对外的”变了没有”信号,语义必须干净,不能被无意义的重复写入污染。
byType 是按类型分的桶,类型名到 ID 集合。它的实现有个值得学的取舍——用 Proxy 挡在前面,第一次访问某个类型时才惰性创建 Set:
const byTypeProxy = new Proxy({} as ByTypeMap, {
get(_target, key) {
if (typeof key !== 'string') return undefined
let set = byTypeStore.get(key)
if (!set) {
set = new Set<string>()
byTypeStore.set(key, set)
}
return set
},
...
})
文件顶部的注释解释了动机:以前有一份硬编码的已知类型清单做预置,现在所有类型都从节点定义注册表流过,这份清单就冗余了。类型上写成”任意字符串键都返回 Set”,是为了让开启了 noUncheckedIndexedAccess 的调用方不必对一个运行时不可能出现的 undefined 分支做防御。
这里牵出另一层同名容易混淆的东西:packages/core/src/registry/registry.ts 里的 nodeRegistry,那是节点定义注册表,管的是”系统里存在哪些种类的节点、每种有什么能力”,跟三维对象没关系。它提供 registerNode、getSelectableKinds、isRegistryMovable、bakePolicyOf 这类查询,并且用 loadPlugin 把插件带来的类型也纳进来。两张表分工清楚:一张回答”这个种类是什么”,一张回答”这个实例现在在哪”。顺带一提,nodeRegistry._register 对重复种类的处理是分环境的——生产环境直接抛错,开发环境(热更新)打一条警告后替换,注释里写明了理由是要让插件之间的种类冲突可见,而不是静默覆盖。
三、四个组成部分与你会在哪儿碰上它
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
sceneRegistry.nodes | ID 到运行时三维对象的主查找表,写入去重并驱动版本号 | packages/core/src/hooks/scene-registry/scene-registry.ts | 任何要拿到某个节点实际对象的地方:选中高亮、框选、导出、碰撞 |
sceneRegistry.byType | 按节点类型分桶的 ID 集合,Proxy 惰性建桶 | 同上 | 要”遍历所有电梯 / 所有门 / 所有吊顶”时,避免扫全表 |
sceneRegistry.revision | 成员变化时才自增的版本号 | 同上 | 下游做缓存失效判断,例如测量的射线上下文 |
useRegistry(id, type, ref) | 渲染器侧的注册与卸载清理钩子 | 同上(同文件导出) | 你新增一种节点渲染器时,必须调它 |
nodeRegistry(易混淆的另一张表) | 节点定义注册表:种类、能力、插件归属 | packages/core/src/registry/registry.ts | 判断某种类能否选中、能否移动、烘焙策略是什么 |
注册这一侧写得很克制,整个钩子就是一个 useLayoutEffect:
export function useRegistry(id: string, type: string, ref: React.RefObject<THREE.Object3D>) {
useLayoutEffect(() => {
const obj = ref.current
if (!obj) return
sceneRegistry.nodes.set(id, obj)
sceneRegistry.byType[type]!.add(id)
return () => {
sceneRegistry.nodes.delete(id)
sceneRegistry.byType[type]!.delete(id)
}
}, [id, type, ref])
}
用 useLayoutEffect 而不是 useEffect,架构文档里给的理由是注册要同步完成,在首次绘制之前就可用。清理挂在返回函数里,组件卸载即摘除,两个桶一起清。依赖数组是 [id, type, ref],意味着 ID 或类型一变就是”先摘旧的、再挂新的”。
顺着看渲染器侧的例子,packages/viewer/src/components/renderers/parametric-node-renderer.tsx 是那种”只有几何定义、没有自定义渲染器”的通用兜底渲染器,它的注释把职责列得很清楚:挂一个空的 group、把这个 group 注册进 sceneRegistry 好让几何系统能找到它并往里注入子对象。这就是文档里那条”一个节点 ID 只注册一次,多个网格时注册最外层 group”的具体落法。
四、下游是怎么吃这层索引的
看几个真实消费者,你会更容易判断自己项目里值不值得抄这套。
选中描边。架构文档给的同步模式是把轮廓通道用的数组原地清空再 push,而不是每次分配新数组——为的是别在高频路径上制造垃圾。选择管理器在 packages/editor/src/components/editor/selection-manager.tsx 和 packages/viewer/src/components/viewer/selection-manager.tsx 各有一份,都是 sceneRegistry.nodes.get(id) 拿对象。
第一人称碰撞。build-collider-world.ts 里的用法是混合的:需要按类型批量时走 sceneRegistry.byType.level! 和 sceneRegistry.byType.site ?? [],需要全量时直接 for (const [nodeId, object] of sceneRegistry.nodes)。同一个文件里还能看到它遍历 sceneRegistry.nodes.keys() 做另一轮筛选。
相机取景。packages/viewer/src/lib/hero-pose.ts 要算整个模型的包围盒,做法是 Object.entries(sceneRegistry.byType) 逐类型遍历,按传入的排除类型跳过,再对每个对象求包围盒并合并。这里正好用到了那个 Proxy 的 ownKeys 陷阱——它返回的是底层存储已有的键。
导出。glb-export.ts 里有多处 for (const [id, original] of sceneRegistry.nodes),用来把烘焙策略为剥离的种类从导出产物里摘掉——烘焙指把参数化的节点定稿成静态几何,剥离就是这一步直接不带上它;被剥的是扫描点云(激光扫描得到的海量三维散点)和参考底图(垫在下面对齐用的平面图)这类只服务于编辑、体积又大的东西,同时把带节点身份的对象标记出来免于被裁剪。它的注释写明了配对逻辑:克隆整棵树后做两次同序的前序遍历来一一对应,从而在不改动任何一边的前提下,把注册表里的活引用映射到导出树上。
测量。前面提到的 surface-query.ts,除了拿 revision 做缓存,pointer-support-cap.ts 里还有一段典型写法:对一组”可作为顶面承托”的种类,逐个 sceneRegistry.byType[kind] ?? [] 拿 ID,再 sceneRegistry.nodes.get(nodeId) 拿对象做射线检测,中途还要用节点数据判断可见性和是否属于当前活动层。这段能看出这层索引的定位——它只回答”对象在哪”,业务判断仍然回到节点数据上。
这套思路和站内几篇讲检索的文章是同一族问题的不同侧面:RAG 检索调优 讲的是把语义相近的文本片段捞出来,技能检索机制 讲的是从一堆能力描述里挑对的那个,AI 缓存策略 讲的是结果复用与失效;而这篇讲的是运行时内存里的精确索引——键是确定的 ID,没有排序、没有相似度、没有召回率,唯一的指标是”拿得够不够快、会不会拿到脏引用”。
五、边界与代价:它明确不管什么
这个设计换来的东西不是白拿的。
它是全局单例,而且可变。 模块顶层就 new 出来了,没有作用域隔离,一个页面里同时开两份互不干扰的场景是不成立的。代价体现在切换场景时必须显式收尾——packages/editor/src/lib/scene.ts 里那段重置交互状态的函数就干这个:先把轮廓通道的两个数组同步清空,注释写明是防止旧场景的三维引用泄进后处理管线,然后调 sceneRegistry.clear()。
clear() 清的是内容不是骨架。 看实现,它清空主表、并把每个类型桶的 Set 逐个 clear(),但底层存储里那些键本身不会消失。所以清场之后再 Object.entries(sceneRegistry.byType),你仍然会枚举到曾经出现过的类型,只是集合是空的。写遍历逻辑时别把”这个键存在”当成”这类东西存在”。
它明确禁止核心系统使用。 架构文档的规则一节写得很硬:核心系统只跟纯节点数据打交道,只有查看器侧的系统和选择管理器才允许做三维对象查找。这条规则的价值在于让核心逻辑保持可测试、不依赖渲染环境。
不许缓存查询结果。 同一节规则里另一条是永远不要持有过期引用,用的时候现取,别跨帧缓存。原因不难推:对象随组件卸载而摘除,你手里那份引用不会自动变 null。
不许手工写入。 只有 useRegistry 有权增删,其他人都是只读消费者。测试文件里直接 sceneRegistry.nodes.set(...) 是测试专用的手法,不是业务代码的示范。
并非所有节点都进这张表。 packages/viewer/src/components/viewer/glb-scene.tsx 里有一句注释交代得很清楚:在烘焙产物这条路径上,层节点刻意不进 sceneRegistry,免得参数化的层系统去重新堆叠它们。所以”注册表里有的就是场景里全部”这个假设不成立。
它和 AI 那条链路是隔开的。 这个项目自带一套让 Agent 直接建模的 MCP 服务器(packages/mcp/),但在整个 packages/mcp/src/ 下 grep sceneRegistry,一条引用都没有。Agent 操作的是节点数据与场景存储,不是浏览器进程里那些活的三维对象。这个分层是合理的——MCP 服务器跑在 Node/Bun 进程里,压根没有渲染上下文。
顺带说清这条链路的实际代价,因为它会在你机器上留下东西:MCP 侧的场景是落在本地 SQLite 文件里的,packages/mcp/src/storage/sqlite-driver.ts 会按运行时优先尝试 bun:sqlite、其次 node:sqlite,打不开就抛错。传输层有两种,stdio.ts 走标准输入输出,http.ts 起一个 HTTP 服务并默认绑定回环地址;一旦你把它挪到非回环地址上,代码会强制要求提供鉴权令牌,来源是 PASCAL_MCP_HTTP_TOKEN 环境变量或显式传入的参数,缺了就直接报错。这两点意味着:接上 Agent 之后,你的模型数据落在本地磁盘的数据库文件里,而 Agent 在授权范围内可以增删改场景里的节点——把这个端口暴露到局域网之前,先想清楚谁能连上它。关于工具边界怎么划,可以对照 Agent 最小权限设计 和 MCP 安全边界 一起看。
六、上手与避坑清单
新增渲染器忘了调 useRegistry。 会踩是因为不调它照样能渲染出来,画面看着完全正常,坏的是选不中、框选漏掉、导出时身份丢失、第一人称能穿过去——症状分散在四五个不相干的功能上,很难往”没注册”上想。避法是把这一步当成渲染器模板的必填项,写完新渲染器先做一次选中测试,选不中就回来查注册。
同一个节点注册了多个网格。 会踩是因为一个渲染器里塞两个 mesh 各调一次钩子看上去很自然。后果是主表里同一个 ID 只会留下最后写入的那个,前一个静默丢失,而类型桶里的 ID 又还在——你会得到一个”能查到 ID、拿到的对象却不是你以为的那个”的状态。避法照文档:注册代表这个节点的最外层 group。
把 get 的结果存起来跨帧用。 会踩是因为在一个手势的开始处取一次对象、后续帧直接复用,性能直觉上是对的。但对象可能在中途因为重挂载被换掉,你手上的旧引用还指着已经离开场景树的东西,表现为拖动到一半目标不动了、或者高亮留在原地。避法是每次用之前重新 sceneRegistry.nodes.get(id),这个查找本来就是 O(1),省不出什么。
在核心系统里图省事查了三维对象。 会踩是因为当下确实是最短路径。后果是这段核心逻辑从此只能在有渲染上下文的环境跑,单元测试要么跑不起来要么得造一堆假对象。避法是把需要的几何信息通过节点数据或系统入参传进来。
用 Object.entries(byType) 当作”当前存在哪些类型”。 会踩是因为 Proxy 让访问过的类型永久留在底层存储里,即使那类节点早已清空。避法是遍历时判断集合非空,或者干脆改用节点定义注册表去问”系统里有哪些种类”。
测试之间没有互相清场。 会踩是因为这是模块级单例,跨用例是共享的,上一个用例注册的东西会漏到下一个。仓库里的做法很一致——glb-export.test.ts、pointer-support-cap.test.ts、scene-registry.test.ts 都在用例开头调 sceneRegistry.clear()。抄这个习惯就行。
收个尾
这层索引的设计取向可以概括成一句话:把”找东西”的成本从每次查询挪到每次挂载,然后用一套硬规则守住引用不变脏。 全局单例、只读消费、不许缓存、核心系统禁用、切场景必清——五条规则里有四条是在防脏引用,剩下一条是在防架构腐化。你如果要在自己的编辑器类应用里抄这套,真正需要抄的是这几条规则,而不是那几十行 Map 代码。
接着往下读的话,推荐这个顺序:先看 wiki/architecture/scene-registry.md 的规则一节,把约束背下来;再看 packages/core/src/hooks/scene-registry/scene-registry.ts 全文,一百来行,Proxy 和版本号两处注释值得逐句读;然后挑一个消费者跟到底,packages/editor/src/components/editor/first-person/build-collider-world.ts 是不错的选择,因为它把三种访问方式(主表全量、类型桶批量、单键点查)都用上了。想再往外扩一层,就去看 packages/core/src/registry/registry.ts,理解节点定义注册表和场景注册表的分工,这两张表不搞混,这个项目的架构基本就通了。
自检清单,写完一个新渲染器逐条对一遍:注册了吗、注册的是最外层吗、卸载路径试过吗、跨帧有没有缓存对象、核心系统里有没有偷偷查表、测试开头清场了吗。六条都过,这层索引就不会成为你的 bug 来源。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源浏览器 3D 建筑编辑器 Pascal Editor:场景状态的撤销层与落盘层怎么拆 和 开源 3D 建筑编辑器 Pascal Editor 的 systems 机制:渲染器只占位,几何靠脏节点批量重建。