关联架构图:编辑器加载、图层与 Pixi 渲染架构图
这篇算是一篇补记,也算是把前面的加载与 Pixi 两条架构链路揉成一篇更容易入口的说明。
之前只看 Pixi(浏览器里的 2D WebGL 渲染引擎)渲染与输入时,很容易把注意力放在“画布怎么画出来”上。但把 Recent / Open Project 加载链路也放进来以后,整条线就更完整了:用户从 Home 点最近文件或 Open Project,系统要先确认工程身份、保存目标、草稿桶和 ready 条件,然后才把文档交给编辑器主状态;进入画布之后,Pixi、输入、历史、自动草稿再各自接棒。
所以这篇不打算把综合架构图逐节点搬过来。那样很像把说明书直接贴进导读,厚度有了,读者的咖啡也快凉了。我更想按一次真实打开和编辑的路径,把 Vectania Editor 现在这套设计背后的几个关键判断捋一遍。
先说结论
Vectania Editor 的架构可以先记住一句话:
打开工程先过 ready gate(打开就绪门,用来阻断未完整准备的文档进入画板),进入画板后由 Zustand store(由 Zustand 管理的编辑器内存状态仓库)保存业务真相;Pixi 只负责可重建的显示结果,输入先进入预览,最终通过命令提交。
这句话听起来有点像工程师给自己贴在显示器边上的便签,但它解决的是编辑器里非常日常的问题:
- 用户点最近文件时,不能只凭一个 recent 记录就直接冲进画板,句柄、权限、工程身份和草稿基线都要确认。
- 打开
.labpixi(项目主工程包格式)或兼容 JSON(只用于导入导出的兼容格式)时,文档必须在 loading 期间完整 ready,不能半开半编辑。 - 用户拖动一个节点时,画面要立刻动,右侧 X/Y 数值也要跟着变。
- 但拖动中的每一帧不能都变成正式历史记录,否则 Cmd+Z 会像翻账本一样,翻到人开始怀疑人生。
- 动画和 State Machine(状态机播放系统,用状态和 transition 驱动动画预览)播放时,节点显示可以被临时覆盖,但不能把原始节点属性偷偷改掉。
- 图片、矢量、mesh(用顶点、UV 和三角形索引描述的可变形贴图网格)、mask(渲染裁剪遮罩)、overlay(编辑辅助覆盖层)最终都要画到 Pixi 里,但它们大部分都不应该进入保存文件。
这些约束最后收束成两条主链路:打开链路和画布链路。
这里的 Pixi scene graph(Pixi 显示对象树)只是一棵由 Container、Graphics、Sprite、mesh 和 mask 组成的显示树。它很重要,但它不是文档户口本。
第一站:Home 不是传送门
综合架构图的 Recent / Open Project 部分最值得记住的点是:Home 页面不是直接把用户扔进编辑器,而是把点击动作转成 launch intent(打开意图,描述“打开最近文件、打开本地文件、恢复自动草稿”等来源和目标)。
这一步看起来像礼貌寒暄,其实很关键。因为最近文件可能没有权限了,工程包句柄可能失效了,JSON 可能需要重新选择,自动草稿也可能和当前工程身份不匹配。Home 如果直接进入画板,就等于还没验票就上车,短程可能没事,长途迟早补票补到手忙脚乱。
入口分流已经并入下一节的综合 ready 主图。这里先记住职责边界:Recent files 和 Open Project 不负责“加载文档所有细节”,只负责把来源收敛成类型明确的 launch intent;真正的解析、身份绑定和 ready 门禁由打开链路继续完成。
第二站:打开工程要先过 ready 门
打开主链路有几个来源:最近 .labpixi 工程包、最近 JSON、working document cache(本地工作缓存)、用户新选的工程包、用户新选的 JSON。它们最后都要走到标准化文档、工程身份和 ready 门禁。
这里的 DocumentWorker(负责文档解析、签名、序列化等后台任务的 Web Worker)可以帮忙解析文件,DraftWorker(负责自动草稿写入和恢复的 Web Worker)可以准备草稿上下文,Workspace Asset Store(工作区资源缓存,用来保存图片等二进制资源)可以恢复图片资源。但这些都只是准备工作。
真正能不能进入画板,要看 ready gate(打开就绪门,确认文档、身份、保存目标、草稿和首屏条件都满足)有没有放行。
这条链路里有几个项目专有词,第一次看会有点像打开保险箱时同时转三把钥匙:
- Project Identity(工程身份)回答“这到底是哪一个工程”,recent、保存目标和草稿都要认它。
- Save Target(保存目标)回答“保存时写回哪里”,比如
.labpixi文件句柄、JSON 句柄、download-only 或另存路径。 - Draft Bucket(草稿桶)回答“自动草稿写到哪个本地命名空间”。
- Merkle baseline(文档签名基线,用来判断相对保存点是否 dirty)和 Patch Base(自动草稿增量补丁的起点)回答“保存状态和草稿增量从哪里算起”。
这些信息不齐,loading 就不能结束。deadline log(超时等待记录)可以告诉我们还卡在哪里,但不能假装已经 ready。这个设计看起来有点严格,但比把用户放进一个“看起来能编辑,保存时才发现身份不明”的状态好太多。
第三站:进入 store,画布才真正活起来
打开 ready 后,标准化文档会通过打开恢复命令进入 Zustand store。这里的 store 是当前会话的业务真相,保存节点树、选择态、工具态、历史栈、动画运行态入口、工程身份、草稿辅助状态等。
之后 React UI(React 面板和应用壳)和 CanvasInput(画布输入总调度器)都可以接收用户意图,但正式修改业务状态时必须回到命令层。Renderer(Pixi 渲染同步器,项目里主要是 EditorRenderer)读取 store、runtime evaluated(动画或状态机求值后的当前帧显示覆盖)和 preview,再同步 Pixi 显示对象。
这条线已经在开头的端到端主图里完整表达:业务真相只能从 store 出发,Pixi 只是显示结果。Renderer 可以很聪明,缓存可以很积极,但都不能抢 store 的饭碗。
图层栏为什么也是 store 的投影
store 的单一真相不只约束画布,也约束左侧图层栏。图层栏会把同一份业务树投影成反向视觉顺序、虚拟行、chunk 占位和 clip 影子分组,但正式排序、重挂载、显隐、锁定和 clip usage 仍由命令提交。
左侧栏为了符合设计工具习惯,会把同级 children 反向展示,让越靠上的行代表越上层的图层;Renderer 仍按 store 中的 children 协调 Pixi child container。两种顺序只是不同投影,不是两套数据。
为什么 Pixi 不能成为真相
做编辑器时很容易被一个诱惑带走:既然用户看到的是 Pixi 对象,那能不能直接改 Container、Graphics、Sprite,然后从它们反推文档?
短期看很顺手,长期会很痛。就像把便签贴在冰箱上,然后宣布冰箱是知识库,气势很足,检索很难。
比如一个图片节点,在文档里保存的是资源引用、裁剪参数、fit 模式、mesh 数据、描边和填充。Renderer 里为了显示它,可能会派生出 Sprite(Pixi 图片显示对象)、TilingSprite(Pixi 平铺图片显示对象)、MeshSimple(Pixi 网格贴图显示对象)、mask、纹理缓存、Object URL(浏览器为 Blob 生成的临时可加载 URL),甚至还有 LOD(Level of Detail,按缩放和预算选择的图片清晰度层级)和 tile 策略。用户真正想保存的是前者,不是后者。
矢量节点也是同理。store 里保存路径点、contour、region fill、stroke、reveal path;Renderer 可能为了性能生成 GraphicsContext(Pixi 可复用图形上下文)、raster texture(栅格化纹理)或 Skia / CanvasKit(用于复杂矢量离屏栅格化的图形后端)缓存。缓存可以释放,可以重建,可以换策略,但路径数据必须稳定。
所以 Vectania Editor 把这些东西拆成三层:
- 第一层是 store 节点数据:矩形的圆角、图片的资源引用、矢量的路径、Frame 的 children。
- 第二层是资源桥:workspace asset Blob、Object URL、可加载图片 src、纹理加载状态。
- 第三层是渲染派生对象:NodeView(一个文档节点在 Pixi 里的派生视图)、Pixi Texture、Graphics、Sprite、mesh、mask、overlay。
只有第一层是业务真相。第二层是浏览器和文件系统之间的桥。第三层是为了显示和交互而生成的结果。
这样做的好处很直接:Renderer 可以大胆做 culling / materialization(按视口裁剪和按需物化 Pixi 对象)、texture release、vector cache、tile snapshot(视口活动时复用的临时画面快照)。只要 store 和资源引用还在,显示对象丢了也能重建。
交互为什么要先预览再提交
拖动节点是一个很好的例子。
用户按下鼠标后,CanvasInput 会根据当前工具、overlay 命中、内容命中和选择状态,决定这次交互是不是拖拽、框选、resize、钢笔编辑、裁剪、mesh 点编辑,或者只是 pan。真正进入拖拽后,pointermove 不会每一帧都正式写 nodes[id].x/y。
它会先写 interactionPreview(交互期临时预览快照)。
Renderer 读取这份 preview,让画面跟手。右侧属性栏也读取同一份 preview,把临时 X/Y 叠到当前选中节点上,所以用户会看到数值同步变化。与此同时,对齐吸附会生成 canvasGuides(拖动时的对齐参考线数据),overlay 层画出参考线。
等 pointerup 时,输入层才把最终位置交给命令层,由命令写入 store,并合并成一条 History Transaction(连续交互合并成一条可撤销记录的历史事务)。
这就是架构图里反复强调的“预览不是业务真相”。它带来的体验差异很大:
- 拖动时足够跟手,因为画面可以先按 preview 更新。
- 历史干净,因为一次拖动只提交一条记录。
- 取消交互可以回滚,因为事务开始时有快照。
- 属性栏和画布一致,因为它们消费同一份 preview,而不是各算各的。
视口 pan / zoom 也是类似思路。高频阶段先走 viewportInteraction(视口平移缩放的临时预览快照),Renderer 直接更新 viewportLayer(承载画布 pan / zoom 的 Pixi 容器)的 position 和 scale;交互结束或 idle 后,再把最终 viewport 提交回 store。
这套机制的关键不是“临时状态”本身,而是临时状态有明确边界:它可以驱动画面和面板预览,但不能混进保存、历史和工程文件。
输入层像一个交通路口
画布输入看起来只是 pointer event,实际上是整个编辑器里最容易长成大 if-else 的地方。
同一个 pointerdown,可能意味着很多事:
- 当前是 Rectangle 工具,那就应该开始画矩形。
- 当前在编辑钢笔路径,那就应该优先命中锚点和贝塞尔手柄。
- 点到裁剪框或渐变手柄,那就应该编辑 overlay 控件,而不是拖动节点。
- 点到普通节点内容,才进入选择、深选、拖拽或 transform。
- 空白处拖动,可能是框选。
- 按住空格,又可能是 pan。
Vectania Editor 在这里用了一个比较克制的设计:pointerdown 先过规则执行器,按优先级尝试工具态、钢笔态、overlay 命中、内容命中。第一条成功的规则接管本次交互,后续 pointermove / pointerup 都按当前 interactionState.kind 继续。
这比“每个 move 都重新猜一次用户想干什么”稳定很多。人都已经开始拖裁剪框了,系统还在问“你是不是其实想选中图片”,就有点像会议快结束才开始确认议题。
Spatial Index(空间索引,用节点包围盒加速 hover、点击和框选候选查找)和 precise hit testing(精确命中,用路径、mesh、手柄等几何规则做最终判断)也放在这条路上。大文档下不能每次 hover 都遍历所有节点,所以先用 bounds 和可见范围粗筛,再进入 vector path、image mesh、deformer handle、selection handle 等精确命中。空间索引只是加速结构,不保存业务状态。
输入层的职责到这里为止:解释输入、维护交互状态、生成 preview、最后提交命令。它不应该绕过命令层直接深改 store,也不应该让 Pixi 对象反向成为业务判断来源。
Renderer 做的是同步,不是决策
Renderer 的角色更像一个很忙的同步器。
它拿到 store snapshot(store 在某一刻的状态快照),再叠加 runtime evaluated 和 interaction preview,生成当前这一帧应该显示的状态。然后它决定哪些 NodeView 可以复用,哪些只要改 transform,哪些要重画几何,哪些要刷新纹理,哪些可以因为离开视口而释放。
Pixi 舞台本身也有固定分层:
- canvas background 负责编辑器工作区底色。
- viewportLayer 承载 pan / zoom。
- grid layer 负责网格。
- tile snapshot layer 在视口活动时作为临时快照代理。
- node layer 承载文档节点。
- overlay layer 画选框、手柄、参考线、钢笔点、裁剪框、mesh 控制点等编辑辅助。
这个分层的好处是,业务节点和编辑辅助不会混在一起。选框和参考线是帮助编辑的视觉,不应该成为文档 children;Frame、Group、Image、Vector 这些才是业务节点。
Renderer 还要处理动画、State Machine 和 Model rig(模型绑定和求值关系,用参数、骨骼、权重或网格点位驱动显示)的运行时覆盖。这里有个很重要的边界:runtime evaluated 表示“当前帧看起来是什么样”,不表示“用户已经修改了原始节点”。只有显式 apply、bake 或命令提交,预览结果才会回写业务数据。
也就是说,Renderer 可以非常复杂,但它复杂的是显示策略,不是业务所有权。
Worker 负责后台活,不负责画布真相
这套架构里还有一个容易误解的点:既然 Pixi、React、store、history 都在主线程,那 Worker(浏览器后台线程,用来处理可结构化克隆的数据任务)到底干什么?
答案是:Worker 处理可以结构化克隆的后台任务,比如文档解析、签名、序列化、草稿写入、日志文件 IO。它不持有 DOM,不持有 Pixi,不持有 WebGL,也不直接恢复当前画布。
打开文件时,.labpixi 或兼容 JSON 会先进入 DocumentWorker,产出 normalized document(标准化文档,完成版本迁移、资源引用整理和结构校验后的数据)。然后还要凑齐工程身份、保存目标、草稿桶、签名基线、autosave patch base 等 ready 条件,才能通过打开恢复命令写入 store。
自动草稿也是类似边界。DraftWorker 可以写 IndexedDB(浏览器本地数据库),可以 compact operation patch(整理自动草稿增量补丁),可以帮助恢复。但它不是撤销重做栈,也不是当前画布真相。
一句话:Worker 可以帮忙搬东西、整理东西、保存东西,但不能绕过主线程 store 和命令边界决定“编辑器现在是什么”。
| 执行域 | 主要职责 | 禁止成为 |
|---|---|---|
| 主线程 React / CanvasInput | UI、输入解释、命令提交、store、history、preview | Worker 私有业务副本 |
| 主线程 Pixi / Skia / CanvasKit | Renderer 同步、NodeView、overlay、栅格化、WebGL 提交 | 文档或业务真相 |
| Document Worker | 解析、签名、序列化、完整 JSON 打开 | 直接驱动 Pixi scene graph |
| DraftWorker | operation patch、compact、IndexedDB 草稿写入和恢复上下文 | 撤销重做历史 |
| Log Worker | 脱敏后的日志文件 IO | 完整敏感 payload 存储 |
| 浏览器 / GPU | File / Blob、IndexedDB、WebGL 命令与合成 | 可序列化业务状态 |
Worker 结果必须先回到主线程的 ready、command、autosave 或 logging 边界,再由 store 或可重建缓存触发 UI / Renderer。主 Pixi scene graph 不迁入 Worker。
历史和草稿是两套东西
综合主图把撤销重做历史和草稿投影放在同一条命令链路里,这一点很有必要。
很多编辑器问题都来自把“用户要撤销什么”和“系统要自动保存什么”混在一起。Vectania Editor 里这两件事是分开的:
- Cmd+Z / redo 回退的是 store 里的
history.past和history.future。 - 自动草稿依赖 Operation Journal(DocumentEngine 的文档操作日志,用于草稿、签名和恢复辅助)、autosave bridge 和 DraftWorker。
用户拖动节点时,连续 pointermove 会被合并成一个 history transaction。取消时用事务开始快照恢复,提交时生成一条可撤销记录。这是面向用户理解的历史。
自动草稿关心的是如何把文档增量安全写到本地恢复投影里。它可以节流,可以合并,可以后台 compact,但不能替代 undo/redo,也不能直接决定画布状态。
这个分离让两套语义都更干净。撤销重做服务编辑体验,草稿服务崩溃恢复和持久化辅助。一个管“我刚才那步能不能撤”,一个管“电脑刚才如果突然休息,我还能不能回来”,不要互相抢戏。
撤销重做与草稿投影的关系已经包含在前面的“画布输入、交互 preview 与历史”主图中:命令同时进入 store history 与 operation journal,但二者服务不同语义。
性能策略不是一个开关
画布编辑器的性能问题很少能靠一个“优化一下”解决。
Vectania Editor 在 Pixi 层做了几类分工:
- viewport 活动时优先 preview render,比如先移动
viewportLayer。 - 大文档下通过 culling 和 materialization 只物化需要显示或需要保护的节点。
- 图片纹理按 owner retain / release,离开视口可以释放。
- 矢量路径可以按复杂度选择 Graphics、GraphicsContext、raster texture 或 Skia bake。
- pan / zoom 期间可以复用 tile snapshot,等空闲后再回到真实 NodeView 同步。
- texture prewarm(纹理预热)、vector prewarm(矢量缓存预热)、autosave work 这些重活要经过 budget gate(视口活动期间判断重活是否应该延后的预算门)。
这里的关键词是 budget gate。它不只是为了快,而是为了让主线程知道当前更应该做什么。
用户正在拖动视口时,第一优先级是手感。复杂矢量缓存、纹理预热、空间索引重建、自动草稿整理都可以等一等。等 viewport idle 之后,再把延后的同步补回来。
不过这些优化都有同一条红线:缓存可以缺,预热可以延后,fallback 可以显示,但不能把“暂时没画出来”当成正确业务结果,更不能改 store。
交互活跃和 cooldown 期间,preview render 优先;普通 render、物化、缩放依赖绘制、预热、草稿与 IndexedDB 工作必须经过预算门。可见正确性和用户显式操作可以带 reason 放行,其它任务延后并在 idle 后分批补齐。
Tile Snapshot 只是 renderer-local 代理。覆盖足够时可临时承接 nodeLayer 的画面;部分覆盖或缺失时必须显示现有真实内容并记录降级原因,不能静默空白。性能优化不能改变 store、history、保存、签名和恢复语义。
这套架构真正保护的东西
把这些架构链路合起来看,我感觉它保护的不是某个具体模块,而是几个长期不变的判断。
第一,入口不能跳过 ready。Recent / Open Project 不是直接进入画板,而是生成 launch intent,再完成工程身份、保存目标、草稿桶、签名基线和首屏条件。
第二,业务真相必须集中。store 是当前会话里“文档是什么”的答案。UI、Pixi、Worker、缓存、历史、草稿都围绕它派生。
第三,用户操作必须有正式入口。React 面板和 CanvasInput 都不能为了方便直接改深层结构,正式修改要通过命令层,这样历史、autosave、runtime 标记和错误处理才有统一位置。
第四,高频交互要允许临时性。拖动、resize、path edit、mesh/deformer(通过控制点、路径、旋转或骨骼影响内容显示的变形控制器)点编辑、pan/zoom 都需要 preview,否则手感会很差。但 preview 必须清楚知道自己只是 preview。
第五,渲染对象必须可重建。NodeView、Texture、GraphicsContext、spatial index、stats、tile snapshot 都是派生层。可释放、可延后、可重建,才有性能优化空间。
第六,持久化入口要守住边界。.labpixi、JSON、IndexedDB 草稿都是投影,不是画布运行时本身。进入画板前必须完成标准化、身份、保存目标和草稿基线。
这些原则听起来像规矩,但实际是在给复杂功能留余地。后面无论继续做 State Machine、ModelBoard(模型编辑和绑定相关的工作区)、Live2D、mesh 变形,还是大文档性能,只要不打破这些边界,新功能就更容易接进来。
小结
如果只看表面,Vectania Editor 的 Pixi 架构是在解决“怎么把节点画到画布上”。但把 Open Project 加载链路一起看,就会发现它其实在解决一个更根本的问题:一个编辑器如何从打开文件开始,就在可交互、可撤销、可保存、可恢复、可扩展之间保持一致。
我的理解是,这套设计最有价值的地方不是用了 Pixi,也不是用了 Zustand,而是它坚持了几条朴素的边界:
- 没 ready 的,不进画板。
- 看见的,不一定是真的。
- 跟手的,不一定要立刻保存。
- 快的,不应该绕过正确性。
- 后台的,不应该直接接管前台真相。
- 可重建的,就不要变成业务来源。
有了这些边界,复杂度不会消失,但会被放到更合适的位置。Home 负责把入口变成 launch intent,ready gate 负责守门,store 和 commands 守住业务语义,Input 解释交互,Renderer 处理显示,Worker 做后台任务。
这大概就是我理解的 Vectania Editor 当前架构设计:它不是为了“层很多”而分层,而是为了让每个复杂问题都有一个不会互相抢方向盘的位置。