在浏览器中构建一座可交互的 3D 建筑编辑器,面临的挑战远不止将三维模型渲染到屏幕上。如何高效管理数千个建筑构件的状态变更、如何在用户编辑时保持 60fps 的流畅体验、如何设计一套可扩展的渲染管线 —— 这些问题决定了编辑器的工程上限。Pascal Editor 作为基于 React Three Fiber 和 WebGPU 构建的建筑编辑器,在架构设计上给出了一套值得参考的解决方案。

扁平化节点存储与层级关系

传统的三维场景通常采用嵌套树形结构来组织对象,每个父节点包含子节点的引用。这种设计在简单场景中直观自然,但在建筑编辑器中,当一栋建筑包含数百面墙体、门窗、楼板时,嵌套结构的遍历和状态更新就会变得低效。Pascal Editor 采用了扁平字典存储的策略:所有节点都以 Record<id, Node> 的形式存放在一个平铺的对象中,节点之间的父子关系仅通过 parentId 字段维系。

这种设计的核心优势在于状态更新的局部性。当修改一面墙的厚度时,只需要更新字典中对应 ID 的节点数据,而不需要在树结构中逐层向上向下查找。节点的 children 数组仍然存在于内存中,供渲染层快速构建可视化的场景图,但核心数据模型始终保持扁平的 KV 结构。这种兼顾了数据一致性与遍历效率的权衡,是建筑类编辑器中值得借鉴的模式。

每个节点都继承自 BaseNode 接口,包含自动生成的唯一标识符、类型鉴别符、父节点引用、可见性标记以及可选的元数据。类型鉴别符在整个系统中扮演关键角色 —— 它决定了该节点应该由哪个渲染器(Renderer)来处理,也决定了更新时应该触发哪个几何系统(System)来重新计算。

三 Zustand 状态管理与职责分离

Pascal Editor 是一个典型的 Turborepo 单体仓库,包含三个核心包:@pascal-app/core 处理数据模型和核心逻辑,@pascal-app/viewer 负责 3D 渲染,而 apps/editor 则承载编辑器的交互功能。与此对应的,是三个独立的 Zustand store——useSceneuseVieweruseEditor

useScene 是整个编辑器的心脏,管理场景中所有节点的增删改查。它持久化到 IndexedDB,支持 50 步的撤销重做(通过 Zundo 中间件实现)。这个 store 遵循一个重要的原则:它不知道渲染层的任何事情。无论是 Three.js 还是 WebGPU,useScene 只关心数据层面的变更。当一个节点被创建或修改时,它会被自动标记为「脏节点」(dirty node),等待后续处理。

useViewer 管理渲染端的视图状态:当前选中的建筑、楼层、区域,层级的显示模式(堆叠、爆炸、单独显示),以及相机模式。这些状态不影响底层数据,但决定了用户看到什么以及如何交互。

useEditor 则处理编辑器特有的 UI 状态:当前激活的工具、图层可见性、侧边栏面板的开合状态。这种将「数据」「视图」「交互」三重职责彻底分离的设计,使得每个 store 的复杂度都得到了有效控制,也为后续的功能扩展留下了清晰的分界线。

脏节点更新模式:性能优化的关键

在 3D 编辑器中,最昂贵的操作不是渲染本身,而是几何体的重新计算。当用户拖动一面墙时,墙体本身的位置变化是廉价的,但墙体与门窗的交集运算、楼板的几何裁剪则可能耗费数十毫秒。如果每次变更都触发全量重算,编辑器很快就会陷入卡顿。

Pascal Editor 引入了脏节点(dirty nodes)模式来解决这个问题。每个 Zustand store 中维护着一个 Set<string> 类型的 dirtyNodes 集合。当节点被创建、更新或删除时,对应的 ID 被自动添加到该集合中。在每一帧的渲染循环中(通过 useFrame 钩子),系统遍历这个集合,仅对标记为脏的节点执行几何更新逻辑,完成后从集合中移除。

这个模式的精髓在于将变更检测与变更处理解耦。数据层面的修改是即时的(保证交互响应),而几何层面的计算是异步的(批量到下一帧处理)。如果用户在短时间内连续修改多个墙体,系统会将这些变更合并在同一个渲染帧中处理,避免每修改一次就触发一次独立的计算流程。实际的实现中,WallSystem 负责墙体的几何生成与门窗开洞的 CSG 运算,SlabSystem 处理楼板的 Polygon 几何,RoofSystem 生成屋面形状 —— 它们各自监听自己关心的节点类型,互不干扰。

这种按需更新的策略在大型建筑模型中效果尤为显著。假设一栋建筑有 200 面墙,用户只修改了其中一面,脏节点模式确保了 199 面墙的几何数据完全不需要重新计算。

React Three Fiber 与注册机制

在渲染层,Pascal Editor 使用 React Three Fiber(R3F)将声明式的 React 组件映射为 Three.js 的 3D 对象。每个节点类型都有对应的 Renderer 组件:WallRenderer、SlabRenderer、ZoneRenderer 等等。这些 Renderer 的职责非常明确:创建占位几何体,向全局注册表注册自己的引用,然后将渲染任务交给对应的 System。

这里的注册表(Scene Registry)是一个重要的基础设施。它维护着 Map<id, Object3D> 以及按类型分类的索引 byType。Renderer 通过 useRegistry 钩子将 Three.js 对象注册到这个全局表中,System 则通过这个表直接获取对象引用,而不需要在 Three.js 的场景图中遍历查找。这种双向绑定的设计,使得数据变更能够精准地定位到具体的 3D 对象,避免了全局搜索的开销。

R3F 的声明式特性在这里得到了充分利用。当节点数据变化时,React 会重新渲染对应的 Renderer 组件,触发 useRegistry 重新注册(如果 ID 变更)或更新引用。System 则在 useFrame 中检测脏节点并修改已注册对象的 geometry 和 transform 属性。整个流程中,React 负责状态驱动的 UI 更新,Three.js 负责高效的三维渲染,两者通过注册表解耦又通过脏节点机制联动。

工程实践的启示

Pascal Editor 的架构设计揭示了几个建筑类 3D 编辑器的通用原则。首先,数据模型与渲染模型的分离是复杂度控制的关键 —— 用扁平的字典存储业务数据,用分层的 Zustand store 管理不同职责的状态,用独立的渲染注册表桥接两者。其次,脏节点模式是将高频交互与重计算隔离的核心手段,它让编辑器在保持响应性的同时支持复杂的几何运算。最后,微前端的包结构(core 包、viewer 包、editor 包分离)使得核心渲染能力可以被独立复用,也为后续接入不同端的编辑器奠定了基础。

对于正在构建类似 WebGL 应用的团队而言,这套架构的可迁移性很强:脏节点模式可以应用于任何需要局部更新的实时渲染场景,三 Zustand store 的职责分离思想适用于各类中大型前端应用,而 R3F 与注册表的协作模式则为 React 生态中的 3D 项目提供了一个可扩展的参考模板。


资料来源:Pascal Editor GitHub 仓库(https://github.com/pascalorg/editor)