English | 简体中文
解决的不是“怎么画图形”
普通 Canvas Demo 通常可以完成缩放、平移、新增节点。真实产品的难点是:
- Document 节点数持续增长时,渲染对象数不能线性增长;
- 原始图片仍需保留时,解码 bitmap 和 GPU texture 必须可以回收;
- 人、脚本和 Agent 同时改动作品时,必须经过同一个可校验、可撤销的事务入口;
- 替换 Canvas2D、Konva、React 或 Vue 时,不应改变作品格式和历史;
- 浏览器刷新后需要恢复的是作品与历史,不是上一个进程的场景对象。
本项目因此选择“小型内核 + 端口 + 可选适配器”,而不是再造一个包含 UI、账号和业务模型的完整白板应用。
分层与所有权
| 层 | 负责 | 不负责 |
|---|---|---|
| Host Application | UI、路由、选区、输入事件、业务 schema、Agent/provider | 不应绕过 Document 直接修改 Renderer 作为作品真相 |
| CommandBus | 将 create/update/delete 作为一个事务提交,管理幂等与 Undo/Redo | 不生成 AI 计划,不绘制 |
| DocumentStore | 保存可序列化作品真相、revision 和 commit 通知 | 不保存 DOM、Konva Node、ImageBitmap |
| SpatialIndex | 根据 world bounds 返回候选 ID | 不决定节点是否持久化 |
| WorkingSet | 计算 enter/stay/pendingExit/exit,处理 pinned 和 exit grace | 不删除 Document 节点 |
| RendererPort | 挂载、更新、释放当前 Working Set | 不拥有 Document 或独立 Undo 栈 |
| ResourcePool | 解码资源的去重、lease、字节预算、LRU 淘汰 | 不代替原始资产存储 |
| Persistence | Snapshot、History、Asset manifest/blob 的持久化 | 不持久化 GPU/DOM/renderer handle |
| Host Agent | 选择模型、prompt、provider,生成候选计划 | 不直接拿到 Renderer 或可变 Document |
五条不变量
1. Document 是唯一作品真相
任何可见的持久更改最终都要反映在 DocumentStore。Renderer 中有但 Document 中没有的对象,只能是交互 preview 等短命运行时状态。
2. dehydrate 不等于 delete
dehydrate(id) 表示节点离开渲染窗口,只释放场景对象、解码资源和事件。delete 是用户或宿主 Agent 产生的 Document 命令。混淆两者会在平移视口时丢作品数据。
3. 运行时容量由视口与预算决定
Document 可以有大量节点,但 Renderer 对象数应近似于“当前视口 + overscan + pinned + exit grace”中的节点数。已解码资源总量则必须受 ResourcePool.budgetBytes 约束。
4. 所有持久写入经过 revision 事务
expectedRevision 与当前 revision 不同时整个事务拒绝;同一事务中任何 before 不匹配也拒绝。不会先改一半节点再报错。
5. Agent 是可替换的命令生产者
Agent 不是内核服务。它与鼠标、属性面板、脚本一样,只能产生候选 CanvasCommand[]。不可信输出需要经过宿主 Policy 和 parseRemoteAgentPlan 后才能进入 CommandBus。
一帧的执行顺序
sequenceDiagram
participant Host as Host UI
participant Runtime as CanvasRuntime
participant Index as SpatialIndex
participant Set as WorkingSet
participant Renderer as RendererPort
Host->>Runtime: refresh(camera, viewport)
Runtime->>Runtime: createViewState + overscan
Runtime->>Index: search(expandedBounds)
Index-->>Runtime: candidate ids
Runtime->>Set: reconcile(candidates, pinned, now)
Set-->>Runtime: enter / stay / pendingExit / exit
Runtime->>Renderer: setView(view)
Runtime->>Renderer: hydrate(enter)
Runtime->>Renderer: update(stay)
Runtime->>Renderer: dehydrate(exit)
Runtime->>Renderer: draw(active nodes)
pendingExit 仍属于 active,用于吸收视口边缘抖动。被 pinned 的节点(例如正在拖拽)即使离开视口也不会在交互中被释放。
复杂度与选型
| 实现 | 查询成本 | 适合 |
|---|---|---|
LinearSpatialIndex |
O(n) 扫描 bounds | 小型项目、测试、初期集成 |
RBushSpatialIndex |
空间树候选查询 | 大文档、频繁平移缩放 |
| Canvas2D Adapter | 每帧重画 active nodes | 轻量基线、自定义绘制 |
| Konva Adapter | 为 active nodes 维护场景对象 | 需要场景树、丰富图形与交互 |
空间索引只解决候选节点查询,不会自动解决解码内存、Konva Node 数量、视频 player 数量或 History 字节数。这些分别由 Working Set、Renderer 释放策略、ResourcePool 和 CommandBus 预算处理。
非目标
内核故意不提供:
- 产品 UI、工具栏、快捷键映射或路由;
- 固定的业务节点类型;
- 多人协同协议或 CRDT;
- 一个内置 AI Agent 或模型 provider;
- 云端资产服务和账号权限系统;
- 对所有项目通用的“最佳”缓存预算。
这些能力应由宿主或独立适配器提供,而不是进入框架无关内核。