Skip to content

关键架构决策(ADR)

这些是会影响多个阶段、且不易回退的取舍。与维护者既有计划冲突时,先确认再动手

决策速查

#决策推荐备选 / 触发切换的条件
D1显示 / 存储后端P1 用每图层 RenderTexture;P2 再上 256×256 tile若一开始就要 20k×20k 画布 / smudge,则直接上 tile(更重)
D2笔刷运算坐标系一律 document space,与 zoom / DPR 解耦
D3Pixi 版本先迁 v8(WebGL renderer 生产稳定)WebGPU 仅作实验开关
D4撤销粒度P1 笔迹区域快照;P2 tile before / after patch单张全画布快照 = 禁止
D5backend 可替换SurfaceBackend 接口从 P1 就抽象出来无(这是不返工的关键)
D6输入采集Pixi federated event 够用;另留 DOM getCoalescedEvents() 接入点给极致采样
D7UI 分层面板→Vue/DOM;overlay→pixi;输入+状态→core(headless controller)UI 状态的事实来源禁止搬进框架响应式
D8品牌 / 包分层品牌 = saier;scope 统一 @saier/*;可复用 UI 拆包、site 只消费已发布包实际改名(republish)须维护者确认

D1 — RenderTexture 先行,Tile 后置

背景:原始建议直接上 tile surface。tile 是正确的终局(大画布 / 低内存 undo / CPU 混色),但作为起点过重。

决策:P1 用「每图层一张 RenderTexture + 戳印渲染」,P2 才引入 256×256 tile。

理由

  • RenderTexture 方案在现有 Pixi 栈上增量最小,却已一次性解决:Graphics 爆炸、真橡皮、笔迹累积、真图层。
  • 因为 SurfaceBackend 接口从第一天抽象,换 tile 不返工。
  • 让执行 Agent 能更早交付可用线稿工具(M2),降低风险。

触发改为 tile-first:若产品目标明确包含 20k×20k 级画布、或一开始就要 smudge / 水彩。→ 这会重排 P1 / P2 工作量分配。

D2 — 笔刷运算一律 document space

所有 dab 的位置 / 半径都在文档像素坐标。缩放画布只改显示,不改笔刷的文档尺寸;导出按文档分辨率。这是确定性测试的前提,也避免 DPR / zoom 串味。

D3 — 先迁 Pixi v8

依赖已是 v8.19 且 build:lib 能过。但运行仍坏在一处真·breaking:v8 Application 是 async-init,new Application(options) 只警告、不建 renderer,而当前构造函数同步读 app.renderer → throw。(注意:v7 的 beginFill / drawCircleapp.view 在 v8 是 deprecated 垫片,能跑、非阻断——别误以为整个 v7 API 面都坏了。)raster 方案依赖的 ImageSource / CanvasSource / BufferImageSource、新 RenderTexture 都在 v8。生产用 WebGL renderer(稳定),WebGPU 仅作实验开关。所以 P0 的硬核 = 修 Application init + 测试地基(不是全面 API 迁移)。

D4 — 撤销粒度:禁止全画布快照

  • P1:截取受影响包围盒的 before / after 位图(区域快照)。
  • P2:tile before / after patch,撤销只恢复脏 tile。
  • 禁止:单张整画布快照做 undo(内存爆炸、长会话不可用)。

D5 — backend 从第一天就可替换

SurfaceBackend 是 core 与 Pixi 之间唯一的像素接口。core 不依赖任何 Pixi 类型(getDisplayHandle 返回 unknown)。这让未来可以同时支持:

Canvas2D backend
Pixi RenderTexture backend   // P1
Pixi tile texture backend    // P2
WebGL framebuffer backend
WebGPU backend
WASM brush backend

D6 — 输入采集双通道

Pixi 的 federated event(带 pressure / tilt / pointerType)作为通用交互路径保留;同时在 DOM 层留 pointermove + getCoalescedEvents() 接入点,给绘画提供更细粒度的原始点(更平滑曲线)。两者都支持。

D7 — UI 分层:面板交给框架,画布与状态留在 core

背景:「UI 面板交互层直接交给 Vue」是对的,但「UI / 交互」常把三件不同的东西混在一起,必须按归属切清。

这层是什么归谁为什么
面板 / chrome:工具栏、图层列表、笔刷滑块、取色器、菜单、弹窗Vue / DOM@saier/vue排版 / 文本输入 / 无障碍 / 主题,DOM 完胜;用 Pixi 画面板是反模式
画布内 overlay:笔刷光标、选区蚂蚁线、变换手柄、吸管预览pixi(Pixi)活在 document / screen 空间,随缩放 / 平移变换、逐帧重绘
落笔输入热路径:pointermove → dabcoreVue 响应式 / 事件绝不能夹在指针与笔刷引擎之间,否则延迟 + GC 毁掉「跟手」

状态边界:core 暴露 headless controller(命令式 setter + emitter / 可观察快照),Vue 只做薄皮镜像:

ts
// core(框架无关)
painter.setTool('brush')
painter.brush.setColor('#f70a8d')
painter.on('brush:change', (snap) => { /* ... */ })

// @saier/vue(Vue 薄皮)
export function usePainter(painter: Painter) {
  const brush = reactive({ size: 10, color: '#000' })
  painter.on('brush:change', s => Object.assign(brush, s)) // core → UI
  watch(() => brush.size, v => painter.brush.setSize(v)) // UI → core
  return { brush }
}

当前需修复的泄漏(rewrite 时清理,见 Roadmap P1/P5):

  • packages/controlsPainterControls.vuev-model="PainterBrush.color"PainterOptionsBar.vue 直接写 PainterBrush.enablePressure —— UI 在操纵库的静态内部状态,应改走 controller API。
  • examples/vueusePixiPainter 直接读 painter.canvas.container.children 当图层数据 —— rewrite 后应读 document.layers(框架无关模型),而非 Pixi 场景图。

禁止:把 UI 状态的事实来源搬进框架响应式(Vue ref / React state)。core 保持框架无关(成本≈0,Painter 本就是带 emitter 的普通类),这样 Vue 面板与未来的 React usePainter 是同一 core 的两张皮。

D8 — 包分层与命名:UI 是包,不是 site 的私产

背景:「支持用户直接使用的 UI 都放在 site 里吗?」——不应该。现在 UI 是散的:可复用组件在 packages/controls(Vue),组装好的编辑器散落在 examples/vuesite/,而 site/ 同时扛「落地页 + 文档 + 交互 demo」。把可用 UI 埋进 site 会让它无法发布、无法复用

决策:按「库 / 应用 / 展示」三类分层——

  • 可发布的库(SDK)@saier/core(引擎,无 Pixi)、@saier/pixi(Pixi 后端)、@saier/vue(UI 面板组件)、可选 @saier/react
  • 展示 / 应用site(落地 + 交互 demo,只消费 @saier/vue,不自己定义面板)、examples/*(最小集成片段)、docs(VitePress 书面文档)。
  • 原则:可复用 UI = 包site 只组装与展示。一个开箱即用的整装编辑器 = saier(umbrella,re-export core + pixi + 默认装配)。

命名(已决定):品牌 = saier(赛尔,独立开源品牌,与 PaintTool SAI 无关,取刀锋 / 笔锋意象)。scope 统一 @saier/*

角色状态
saier品牌 / umbrella 整装包(re-export core + pixi + 默认装配)由现 pixi-painter 迁移(restructure;过渡期保留 pixi-painter 作 deprecated alias)
@saier/core引擎,无 PixiP1 新建
@saier/pixiPixi 后端(与未来 @saier/canvas / webgpu 并列)P1 新建
@saier/vueVue UI 面板@pixi-painter/controls 迁移
@saier/shodo书法引擎已改名(原 plugin-shado 拼写错)

目录约定 packages/{core,pixi,vue,shodo,saier}(dir 与包名后缀一致)。品牌张力已消解:核心刻意与 Pixi 无关,而 saier 本就不含 pixi,名实相符。

执行进度:① shodo 包名已修@saier/shodo(安全、零引用);② 全部设计文档已统一到 @saier/* 口径;③ 新包 @saier/core / @saier/pixiP1-01 建包时落地;④ 已发布包的实际改名pixi-painter → saier@pixi-painter/controls → @saier/vue)涉及 republish + deprecate alias,作为 restructure 步骤、动手前再确认。

包拆分(已决定)最小拆分 + lockstep 版本。只拆承重的一刀——@saier/core(无 pixi 依赖)⊥ @saier/pixi,让依赖图强制"核心与 Pixi 解耦"这个命题(比 lint 可靠);core 内再加 ESLint no-restricted-importspixi.js 作双保险。其余不过度拆:@saier/shodo 已是包、@saier/vue 可晚点独立、saier(umbrella) re-export 串起来。全部 lockstep 一起 bump(像 @vue/*),消掉独立版本负担、可后续再调。→ P1-01 维持原样(core + pixi 两包)。


明确的非目标 / 暂不做

  • 不在早期做 16-bit / float / HDR 管线、不自研 WebGL / WebGPU renderer(满足「放弃 Pixi」条件前不碰)。
  • 不把绘制核心搬进 Worker(P7 之后再评估)。
  • 不追求 PSD 完整兼容(P8 仅可选导出)。
  • 不在 P1 引入 tile(避免过度工程,按 D1)。

何时才值得彻底脱离 Pixi、纯原生重写

满足下列多条时再考虑(否则现在纯重写大概率是亏的):

  1. 要做 16-bit / float / HDR 绘画管线;
  2. 要完全自定义 GPU blend / compute brush simulation;
  3. 要把绘制核心放 Worker、主线程只显示;
  4. 要非常大的画布(20k×20k、几十上百图层);
  5. 需要确定性像素输出,不想被 Pixi premultiplied alpha / texture format 影响;
  6. 准备长期维护自己的 WebGL / WebGPU renderer。

Released under the MPL-2.0 License.