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;
  • 云端资产服务和账号权限系统;
  • 对所有项目通用的“最佳”缓存预算。

这些能力应由宿主或独立适配器提供,而不是进入框架无关内核。