开源 3D 建筑编辑器 Pascal Editor 的 systems 机制:渲染器只占位,几何靠脏节点批量重建

2026-08-05

本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。

Pascal Editor 这套架构里最值得抄的一点,是它把「谁负责挂载」和「谁负责造几何」彻底切开了:React 组件树只负责给每个节点挂一个空容器并登记自己,真正生成三角形的活儿全交给一批叫 systems 的东西,按「哪些节点数据变脏了」批量执行。 这不是渲染层的小技巧,而是一条你写任何「数据驱动的重活儿」都能复用的分工线——尤其当改动来自一个一次吐一百条操作的 Agent,而不是一个人手拖一面墙的时候。

先做个消歧:Pascal Editor 是一个跑在浏览器里的开源 3D 建筑编辑器(React Three Fiber + WebGPU),跟 Pascal 这门编程语言没有任何关系,也跟压强单位没关系。它是个 Turborepo 单仓,根目录下 apps/ 2 个、packages/ 9 个,许可证 MIT(Copyright 2026 Pascal Group Inc.)。它自带一个 MCP 服务器包,让 AI Agent 可以直接调工具去建模——这也是本文最后要落到的地方。

一、渲染器只挂一个空壳,这解决的是什么问题

按常规写法,一个「墙」组件会在 React 里同时干两件事:算出这面墙的网格数据,然后把它渲染出来。问题在于,墙的几何不是墙自己能算清楚的。两面墙在拐角相交时,各自的端头要沿角平分线斜切一刀,接缝才不会互相穿插——建模里管这个叫斜接(mitering)。门窗要在墙上挖洞,得拿门洞的体积去做实体布尔减法(把一个立体从另一个立体里”减掉”的运算)。墙底下如果压着一块楼板(slab,就是一层楼的地面板),墙就得被抬高或截短。这些输入全在别的节点身上。

于是 Pascal 的做法是把渲染拆成两半。wiki/architecture/node-definitions.md 把它写成「三复选框模型」:一个节点类型(kind)在注册表里可以声明三个互相独立的可选字段——def.geometry 是一个纯函数构建器,def.renderer 是自定义 React 组件,def.system 是每帧跑的组件。谁都不设就没有;设了就参与。没有 def.renderer 的类型,框架会挂一个 <ParametricNodeRenderer>,按文档的说法,它就是「一个薄薄的空 <group>」,做三件事:向 sceneRegistry 登记自己、挂上指针事件、在挂载时调一次 useScene.getState().markDirty(node.id)

也就是说,React 这一侧的产出只有一个坐标正确的空容器和一条「我脏了,谁来填我」的通知。

wiki/architecture/systems.md 给系统的定义很直白:系统是「渲染 null 的 React 组件」,用 useFrame 做每帧逻辑。模板就这么点:

// packages/core/src/systems/my-system.tsx
import { useFrame } from '@react-three/fiber'
import { useScene } from '../store/use-scene'

export function MySystem() {
  const nodes = useScene(s => s.nodes)

  useFrame(() => {
    // compute and write back derived state
  })

  return null
}

系统还分两层:packages/core/src/systems/ 是纯逻辑,文档明确要求「不许 import Three.js」;packages/viewer/src/systems/ 才碰 Three.js 对象,但反过来要求「不许写业务逻辑」。这条边界的价值在最后一节会显出来——因为 MCP 服务器就是靠 core 不依赖浏览器才能跑起来的。

二、脏节点集合,才是这套机制真正的调度中心

useScene 里有一个 dirtyNodes: Set<AnyNodeId>,配一对 markDirty / clearDirty。所有系统每帧干的第一件事都是看这个集合空不空,空就立刻返回。GeometrySystem 的第一行就是:

useFrame(() => {
  if (dirtyNodes.size === 0) return
  const nodes = useScene.getState().nodes

这个「数据变了才算」的约定往下推,衍生出四个很有意思的设计。

第一,不消费脏标记的类型要主动退出。 markDirty 里有一句 if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return。文档解释得很实在:site、building、level、zone、guide 这类纯结构性的类型没有任何系统消费它们的脏标记,如果不拦,这些标记永远不会被清掉,会一路累积到整个会话结束,把每个系统「集合为空就早退」的优化直接废掉。

第二,脏了不等于要重建。 GeometrySystem 里有一个 builtGeometryKeyRef,缓存每个节点上次构建时的几何 key。类型可以声明 def.geometryKey,系统会把它和全局的外观输入拼成一条字符串:shading|textures|colorPreset|sceneTheme|<几何 key>|<子节点拖拽覆盖 key>,一致就直接 clearDirty 跳过这一轮。注释里给的场景很具体:把一个物件拖到搁板上,会把搁板标脏,但搁板的板子根本没变——不跳过就白白 dispose 一次再重建一次,还会连带触发指针进出事件的抖动。

第三,同类同父的节点先合批预计算。 重建循环分三段:第一段按 ${kind}::${parentId} 把脏节点分组;第二段对声明了 def.computeLevelData 的类型,把该层里同类的兄弟节点全捞出来跑一次预计算;第三段才逐个节点重建,每个节点从 ctx.levelData 里拿本批的预计算结果。注释点名了动机:墙的斜接是典型的 O(N²),一帧里十几面墙同时脏,不合批就要重复算十几遍全层关系。

第四,构建器必须是纯函数。 def.geometry 拿到的第二个参数是 GeometryContext,里面是 resolve / children / siblings / parent / levelBaseAt / levelData / materials。文档的规则写死了:构建器内部不许 import useScene,要读场景就走 ctx;同时构建器只能产出局部坐标的子对象,节点自身的位置和旋转由渲染器通过 JSX 绑定。geometry-system.tsx 里专门留了一段大写的 NOTE 解释为什么重建时绝不重置 group.position:那是 React 绑的属性,系统一旦命令式清零,React 没理由在下一帧重新下发,节点就永远卡在原点了。

组成部分它负责什么仓库位置你什么时候会碰到它
ParametricNodeRenderer挂空 group、登记 sceneRegistry、挂载时 markDirtypackages/viewer/src/components/renderers/parametric-node-renderer.tsx新增一个不需要自定义 React 的节点类型时
GeometrySystem读脏集合,调 def.geometry 重建子对象并 dispose 旧的packages/viewer/src/systems/geometry/geometry-system.tsx你的几何没刷新,或刷新得太频繁时
WallSystem墙的斜接、开洞布尔、相邻墙级联、每帧预算packages/viewer/src/systems/wall/wall-system.tsx拖墙卡顿、拐角接缝不对时
useScene 的脏集合dirtyNodes / markDirty / clearDirtypackages/core/src/store/use-scene.ts加新类型、或发现脏标记清不掉时
系统的分层规则core 不碰 Three.js,viewer 不写业务wiki/architecture/systems.md决定新逻辑该放哪个包时
MCP 工具与限制无头驱动场景图的工具清单与已知边界packages/mcp/README.md让 Agent 直接改场景时

三、墙系统:批量改动下的预算与降级

GeometrySystem 是通用路径,但有些类型的活儿它覆盖不了,WallSystem 就是最典型的一个。挂载顺序上,packages/viewer/src/components/viewer/index.tsx<SceneRenderer /> 在前,<FloorElevationSystem /><GeometrySystem /><StairOpeningSystem /><RegisteredSystems /> 依次在后——文档说明了理由:多数 viewer 系统要消费渲染器挂载时写进 sceneRegistry 的数据,所以必须排在渲染器后面。帧优先级上,GeometrySystemuseFrame 是 2,WallSystem 是 4。

单面墙的重建流程(generateExtrudedWall)大致是:算出这面墙在地面上的投影多边形(footprint,也就是墙的平面轮廓),转到墙的局部坐标,用 THREE.ExtrudeGeometry 沿高度方向拉起来成一个体,再把门窗洞、分段抬高的底面轮廓这些「要挖掉的体积」包成 three-bvh-csgBrush,用 SUBTRACTION 一路减掉,最后重投 UV(贴图坐标)并按内外面、按高度分带分配材质组。如果墙开了 fillToTerrain,还要沿周长采样地面高度做一块填充几何,跟主体合并——这就是所谓高度场,地面不是一个平面而是一张按 x、z 查询高度的表。

真正跟本文主题相关的是它对批量的处理。文件里那几个常量上方有一段注释把问题写得很清楚:拖动墙端点时,每一次 pointermove 都会 markDirty(wallId),不节流的话,每一 tick 不但要重建被拖的那面墙,还要重建所有共享拐角的相邻墙,T 形节点或一个房间就是三四倍的量。它的策略是分两档:

const DRAG_FLUSH_MS = 80
const MAX_WALL_REBUILDS_PER_FRAME = 8
const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME
const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8

被直接拖的墙每帧全保真重建,不打折;相邻墙则丢进 pendingAdjacentByLevel 队列,等脏标记流停了 DRAG_FLUSH_MS 才做一次尾沿刷新——注释里说这是标准 CAD 行为,拐角先保持拖动前的接缝,松手后再归位。而当一帧里脏墙数量超过阈值(比如导入一个大文件),它切到渐进模式:每帧最多重建 8 面,且第一面之后一旦超过 8 毫秒就跳出,剩下的留到下一帧。斜接数据本身走 getCachedLevelMiters 的层级缓存,并且系统在卸载时调 clearLevelMiterCache()——systems.md 专门列了这条规则,模块级缓存活得比组件挂载久,按层或节点 ID 建索引的缓存,在同一个标签页里每打开一个项目就多涨一截。

对你意味着什么:这套机制的关键不是”标脏”,而是”标脏之后允许你重新决定做多少、什么时候做”。 直接在事件回调里同步改几何,你就永远没有这个决定权。

四、这套模式和 Agent 批量改场景为什么合得来

Pascal 在 packages/mcp 里带了一个 MCP 服务器(bunx pascal-mcp,支持 stdio 与本地 HTTP)。它的定位按仓库 README 的说法,是「以无头方式在 Bun 里运行,不需要浏览器、WebGPU、React 或外部数据库服务」,把编辑器 UI 用的那套场景变更暴露成 MCP 工具。工具实现摊在 packages/mcp/src/tools/ 目录下,README 的工具表里包含 get_scenefind_nodescreate_roomcreate_walladd_dooradd_windowplace_itemcut_openingapply_patchundoredovalidate_scenecheck_collisions 等。

关键的一条是 apply_patch。它的工具描述原文是:批量的 create/update/delete 操作原子应用,所有 patch 在任何一条被应用前先全部校验,整批构成一个 undo 步骤。这跟脏节点机制是同一个思路的两端:Agent 一次吐出几十条改动,不必也不该在中间产生几十次重算;改动先落到数据上,重算由消费方按自己的预算安排。前面说的合批预计算、几何 key 跳过、每帧 8 面墙的上限,全都是为这种「一次来一大坨」的输入准备的。人手拖一面墙其实不太需要它们。

这里也顺带说清本篇跟站内几篇相邻文章的分工。Agent 并发编排 讲的是多个 Agent 同时干活时怎么切分与汇合,工作流编排 讲的是把一串步骤固化成可复用的流程,缓存与幂等 讲的是重复调用怎么不出乱子——那三篇讲的都是编排层。本篇讲的是编排之下的那一层:当上游一次性推来一批改动,被改的那个系统自己该用什么结构去消化。两者可以叠加,但解决的不是同一个问题。

五、边界与代价:它明确不管什么

这套设计放弃了实时一致性。系统在 useFrame 里跑,意味着「数据已改」和「几何已更新」之间天然隔着至少一帧。geometry-system.tsx 里对未挂载节点的处理是 if (!group) continue // mount hasn't run — keep dirty for next frame——保持脏、下帧再来。任何依赖「改完立刻能量到正确网格」的逻辑,都不能直接建在这上面。

它也放弃了「代码在哪就在哪读」的直观性。一个类型的行为散在四处:schema 在 packages/nodes/src/<kind>/,构建器是个纯函数,重建循环在通用系统里,额外的每帧逻辑在 per-kind 的 def.system 里。packages/nodes/src/ 下有 46 个子目录(其中 shared/ 不是节点类型,所以节点类型是 45 种),排查一个「几何不对」的问题,你得先判断它属于这条链上的哪一环。文档里那份「坑」清单本身就是代价的证据:忘了给构建产物打 userData.__fromGeometry 标记,重建时会把 React 挂载的托管子节点一起 dispose 掉,现象是拖一个物件到搁板上它就消失了。

最要紧的是无头模式下这套机制根本不跑。README 的限制一节写得很明白:墙斜接、楼板三角化、CSG 开洞、屋顶与楼梯生成都活在编辑器的 React hook 里,无头模式不会重新生成派生几何,但节点数据仍然完全可改。同一节还说,dirtyNodes 在无头模式下会一直累积,因为没有渲染器消费它,需要的话自己调 bridge.flushDirty() 排空。export_glb 直接返回 not_implemented,理由也是它依赖 Three.js 渲染器。所以 Agent 那条链上,几何是欠着的:数据结构正确不等于模型正确,要看到真几何,得让 @pascal-app/viewer 在浏览器宿主里跑一遍。

还有一批边界跟 systems 无关但你必须知道,因为它们发生在你自己的机器上:

  • 数据落在本地。 通过 MCP 保存的场景写进 ~/.pascal/data/pascal.db 这个 SQLite 文件,可以用 PASCAL_DATA_DIR 换目录、PASCAL_DB_PATH 指定确切文件路径。编辑器和 MCP 服务器共享同一目录时,变更还会记进本地的 scene_events 流,编辑器页面通过 /api/scenes/:id/events 用 SSE 订阅。
  • 暴露端口意味着什么。 README 里绑定非 loopback 主机时要求配 PASCAL_MCP_HTTP_TOKEN 承载令牌。默认只在本地回环上跑;一旦 --host 0.0.0.0,谁能连上谁就能改你的场景库。
  • 可能出网。 视觉类工具接收图片 URL,packages/mcp/src/lib/safe-fetch.ts 是为此写的 SSRF 防护:拦回环、链路本地(含 169.254.169.254 这个云元数据地址)、私有网段和非 http(s) 协议,带体积上限与超时,允许用 PASCAL_ALLOWED_ASSET_ORIGINS 配白名单。注释里直说了这是补一个曾经存在的洞——早先这几个工具直接裸调 fetch(url)
  • Agent 有权改什么。 工具清单里有 delete_nodecascade: true 时级联删)和 duplicate_level。虽然每次改动都进 undo 历史,但那是编辑器内的历史,不是你的备份。关于这类授权边界怎么划,可以看 MCP 的安全边界

六、上手与避坑清单

1. 先判断你要不要写系统。 会踩:习惯性地为新类型建一套 renderer.tsx + system.tsx。怎么避:systems.md 开头就有一条加粗建议,如果你这个类型的唯一任务是「脏了就重建几何」,那就只设 def.geometry,让框架的 <GeometrySystem> 接管重建循环,per-kind 的系统留给动画、跨类型脏级联、按名字戳材质这些额外责任。文档还给了从旧写法收敛过去的四步迁移路径。

2. 别在重建里动 group.position 会踩:直觉上重建完顺手把变换归位。怎么避:记住变换归渲染器,系统只换子对象。症状很好认——节点一重建(提交移动、改尺寸、刷材质)就瞬移到原点。这条在 geometry-system.tsxnode-definitions.md 里各写了一遍,说明它真的被踩过。

3. 给构建产物打标记。 会踩:自定义系统命令式往登记的 group 里塞子对象,重建时一把全清。怎么避:跟 markGeometryBuildOutput 一样打 userData.__fromGeometrydisposeChildren 只回收带标记的,React 托管的子节点留在原地。

4. 预览要克隆材质。 会踩:def.preview 直接把构建器返回的网格设成半透明。怎么避:构建器普遍在模块作用域缓存材质,同一材质被场景里所有同类实例共用,改一处就全场景变透明。克隆、改克隆体、卸载时只 dispose 克隆体。

5. 新类型如果不消费脏标记,记得 dirtyTracking: false 会踩:加了个纯组织性的容器类型,没人重建它,脏标记一直挂着。怎么避:照 node-definitions.md 的说法声明这个字段,以后它真长出 def.geometry 了再删掉。

6. 模块级缓存要在卸载时清。 会踩:为了跨帧复用把 Map 提到模块作用域,然后它比组件活得久。怎么避:像 WallSystem 那样在 unmount effect 里调 clearLevelMiterCache(),同一标签页里每开一个项目都往里加一份的缓存最危险。

7. 让 Agent 改场景时,先想清楚”谁来算几何”。 会踩:拿 MCP 生成了一堆节点,去看模型发现墙拐角没接、门洞没挖,以为工具有 bug。怎么避:那是无头模式的既定行为,派生几何要靠浏览器里的 viewer 跑一遍;如果你和编辑器共享同一个 PASCAL_DATA_DIR 并用 load_scene 载入已保存的场景,开着的页面会通过事件流同步。另外,每次改动前会做版本检查,别的进程先写了新版本你就会拿到 live_sync_version_conflict,得重新 load_scene

收束

Pascal Editor 这套 systems 的价值,不在于建筑本身,而在于它把一个很常见的工程结构做得足够干净:产生变更的一方只负责把变更写进数据并标记范围,消费变更的一方自己决定合批、跳过、限速和降级。 你把「Agent 一次吐一百条操作」代进去,会发现每一条设计都对得上号。

想接着往下读,建议按这个顺序:先看 wiki/architecture/systems.md 定分层规则,再看 wiki/architecture/node-definitions.md 弄清三个可选字段各自的适用场景,然后读 packages/viewer/src/systems/geometry/geometry-system.tsx 的三段式重建循环,最后拿 packages/viewer/src/systems/wall/wall-system.tsx 当反例——看一个通用路径覆盖不了的类型,会长出多少额外的预算和缓存逻辑。wiki/architecture/ 下一共 20 份 md(1 份 README 索引加 19 篇分主题),另外根目录同时放着 AGENTS.mdCLAUDE.mdGEMINI.md 三份 Agent 约定文件,动手改之前值得先扫一眼。想了解 MCP 这层协议本身怎么回事,可以从 MCP 协议是什么 入手。

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 场景注册表:3D 建筑编辑器如何绕开树遍历开源 3D 建筑编辑器 Pascal Editor 的空间查询机制拆解

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。