像素画编辑器 项目架构与技术笔记
在找前端工作,上海、江苏、浙江、安徽
一个约 7 万行 的浏览器端像素编辑器,从 AI Demo 到工程化产品的重构记录。目前完成度约40%,还有许多有创意的思路还在迭代中。
为什么写这个
写这个项目的起点其实很简单:女朋友喜欢拼豆,我就想做一个能“画点像素”的小工具。
这个项目从一开始就不算复杂:一个画布、几个按钮、能画能擦。真正让我把它往前推的,不是“多加一个工具”,而是我开始认真用它以后发现,像素画编辑器并不是一个简单的画图程序。
最开始的版本写得很快,能画、能擦,基本功能也都能跑。问题是它很快就开始变得不顺手:一旦画布稍微复杂一点,交互就慢,撤销也不稳定,后面再加图层、帧、导出,代码就越来越难改。
当时我最明显的感受是,Demo 级别的实现很容易跑通,但真正能长期维护的东西,必须从数据结构和渲染方式开始重新整理。那几个最难啃的点也很具体:
- 代码耦合严重。画布数据、工具状态、显示参数、交互逻辑全挤在一起,改一个地方经常要去多个文件里找关联。
- 项目架构混乱。最开始的版本没有清晰的职责边界,模块之间的依赖关系很乱,排查问题和扩展功能都很痛苦。
- 工具之间耦合严重。画笔、填充、选区、变换这些工具不是各自独立,而是相互牵连,新增或者删除一个工具时,往往需要改好几个地方。
- 各个工具的行为不统一。有些工具是直接改像素,有些则是走特殊流程,交互模式、撤销语义、选区约束都不一致,导致整体体验很难收敛。
- 历史记录管理混乱。一开始没有统一的事务和命令模型,撤销重做逻辑散落在各处,结果是很难保证行为一致,也很难排查问题。
- 性能问题明显。单独画 128×128 的像素还行,到了多图层、多帧、纹理像素,主线程就会明显卡顿。
- 状态管理混乱。Store 里各种状态并没有真正分层,很多逻辑是靠“顺手塞进去”的,后面维护成本非常高。
- AI 过渡复杂化和代码冗余。前期为了赶进度,很多功能是靠 AI 生成或快速拼接出来的,结果是代码重复、结构不稳定、后续很难再收敛。
- 撤销没有边界。一开始是把整张画布整个快照下来,十几步操作之后,内存就吃得很夸张。
于是我把项目拆成了几个模块:渲染层、状态层、工具层、导出管线、Worker 流水线和存储层。后来这套结构慢慢稳定下来,才有了现在这种支持多图层、多帧、图像转像素、PNG / GIF / MP4 / ZIP / 图纸模式导出、WebGPU 渲染、本地优先云同步的编辑器。
这篇文章不是一份“我做了一个编辑器”的流水账,而是我在重构过程中把一些问题想明白后的记录。里面大部分内容都不是“理论上应该这样”,而是“我当时碰到这个问题,最后是怎么处理的”。如果你也在做复杂的 Canvas 应用,这里面很多取舍可能会有参考价值。
技术栈:Vue 3.5 (Composition API) + TypeScript + Vite 8 + Pinia + PixiJS 8 + pixi-viewport + GSAP + idb (IndexedDB) + Web Workers + PrimeVue。
一、项目架构总览
整套架构的核心设计哲学可以浓缩成三句话:职责正交、真实来源唯一、渲染层与状态层双向解耦。
1.1 分层结构
src/
├── utils/pixi/ 渲染层(PixiJS 8,约 1.9 万行)
│ ├── core/ 应用实例、视口
│ ├── layers/ 各类图层
│ ├── managers/ 历史、网格、滤镜
│ ├── tools/ 画笔、形状、选区、裁剪、文字
│ └── types/
├── utils/workers/ Web Worker
├── utils/export/ 导出管线
├── store/ Pinia 状态(按领域拆分)
├── composables/ 组合式逻辑(canvas / image / tools / palette ...)
└── views/ 页面
1.2 数据流闭环
我后来给整个编辑器定了一个很简单的规则:谁持有真实数据,谁就负责更新;渲染只负责把当前状态画出来,不去替别人决定数据应该是什么。整个数据流可以理解成一个“Store 负责真相,渲染负责表现”的闭环:
用户绘制 → pixelLayer.setPixel → historyManager.commitTransaction
→ canvasDocStore.setActivePixelData → store.activePixelData 变化
→ watch → pixelLayer.setLayersData(同步统计)
→ autoSave(watchDebounced 800ms 写入 IndexedDB)
undo → Command.undo → pixelLayer.setPixel → getAllPixelData
→ setActivePixelData → store → autoSave
绘制时渲染层先行、再回写 store;undo/redo 时 Command 操作渲染层、再回写 store。这种"谁产生数据谁先动"的策略避免了双向同步中的竞态。
二、PixiJS 渲染层架构
PixiJS 8 是整个编辑器的渲染基座。我把它封装成 一套坐标系 + 10 类图层 + 3 个 Manager,所有像素操作最终都落到这一层。
2.1 渲染器优先级与 fallback
PixiApp 优先尝试 WebGPU,失败后 fallback 到 WebGL:
const webgpuResult = await this._tryInit(container, { ...config, preference: 'webgpu' });
if (webgpuResult.success) {
return { preference: 'webgpu', didFallback: false };
}
// WebGPU 失败,fallback 到 WebGL
const webglResult = await this._tryInit(container, { ...config, preference: 'webgl' });
这里有几个容易遗漏的边界情况被收敛在 PixiApp 内:
- 高 DPI 适配:
resolution: window.devicePixelRatio+autoDensity: true,物理像素按 dpr 放大但 CSS 尺寸不变,解决手机 dpr=2~3 模糊问题。 antialias: false:像素艺术必须硬边,抗锯齿会破坏像素感。- GPU device lost 监听:WebGPU 下监听
device.lost,reason === 'destroyed'视为正常销毁,其他情况回调上层 toast 提示。这是 WebGPU 特有的健壮性处理,WebGL 无等价机制。
上层只拿到 { preference, didFallback } 做提示,不感知底层细节。
2.2 双坐标系与图层分层
画布由 2 个根容器 + 10 类图层 组成,分为"世界坐标系(viewport 内,随缩放/平移变换)"和"屏幕坐标系(stage 内,固定)"两套:
世界坐标系图层(挂在 viewport 上):
| 顺序 | 图层 | zIndex | 职责 |
|---|---|---|---|
| 1 | BackgroundLayer | 0 | 纯色 / 棋盘格 / 渐变背景 |
| 2 | PixelLayer | 10 | 核心像素绘制层(颜色 + 纹理) |
| 3 | ReferenceLayer | 20 | 参考图片叠加 |
| 4 | GridLayer | 30 | 网格线(1/5/10 格多套) |
| 5 | SelectionLayer | 40 | 选区边框、行军蚁、拖拽预览 |
| 6 | CropLayer | 50 | 裁剪框 + 8 手柄 + 九宫格 |
屏幕坐标系图层(挂在 stage 上,不随视口变换):
| 图层 | 作用 |
|---|---|
| CoordinateLayer | 类 PS 标尺,固定在屏幕边缘的 X/Y 坐标标签 |
| GuideLineLayer | 从标尺拖出的水平/垂直辅助线 |
| GuideLineLayer.overlayContainer | 拖拽辅助线时的坐标 tooltip(最顶层) |
设计取舍:把功能层与用户数据层放在同一容器内通过 zIndex 分层,避免跨容器同步坐标;功能层 eventMode = 'none',保证不拦截事件。
2.3 PixelLayer:混合像素渲染策略
像素画在渲染上有一个很实际的问题:如果每个像素都当成一个独立矩形,画布一大就很重;但如果所有像素都塞进一张大贴图,又很难支持局部修改和纹理混用。
所以最后走的是一种折中:纯色像素和纹理像素分开处理
- 颜色像素 → 用
Graphics.rect().fill(color)绘制为矢量矩形,每个图层共用一个 Graphics。PixiJS v8 内部会批量为相同 fill 构建一个 mesh,drawcall 极少。 - 纹理像素 → 每个像素创建独立
Sprite,挂到同一 container,key ="layerIndex:x,y"。
if (data.textureId !== undefined) {
this._createTextureSprite(layer.index, x, y, data.textureId, cellSize);
} else if (data.color) {
g.rect(x * cellSize, y * cellSize, cellSize, cellSize).fill(data.color);
}
取舍:颜色像素走 Graphics(批量、省显存),纹理像素必须独立 Sprite 才能贴不同 texture,但通过 TextureCache 复用底层纹理避免重复上传。这是一种"不需要纹理的尽量不用纹理"的省显存策略。
2.4 TextureCache:纹理复用机制
用 Map<number, Texture> 缓存纹理。关键设计是 "预加载为主 + data URL 同步兜底" 双通道:
PixiJS v8 不再支持 Texture.from({ src, label }) 同步创建,所以:
- 文档加载时
preload用Assets.load({ src: dataUrl, alias })异步加载,完成后设scaleMode = 'nearest'(像素硬边)。 - 绘制热路径未命中缓存时,尝试同步兜底:
new Image()+img.src = dataUrl(data URL 同步可用)+new ImageSource+new Texture。 - 都不行就触发异步
preload并返回 null,下次 get 可用。
纹理替换时 invalidate 销毁旧 texture 并 Assets.unload(key) 释放显存,文档关闭时 clear 全量清理。这套机制保证了绘制热路径尽量不返回 null。
2.5 视口与锚点缩放
基于 pixi-viewport,但 关闭了内置 wheel,手动挂 passive: false 的 wheel 监听,以便 preventDefault 并接管为 GSAP 动画。原因会在第五节详述。
锚点缩放的核心是:以鼠标世界坐标为锚点,动画期间保持锚点屏幕坐标不变。
const anchorScreenX = anchorWorld.x * this.viewport.scale.x + this.viewport.x;
const anchorScreenY = anchorWorld.y * this.viewport.scale.y + this.viewport.y;
gsap.to(this.viewport.scale, {
x: targetScale, y: targetScale, duration, ease: 'power2.out',
onUpdate: () => {
const s = this.viewport.scale.x;
// 锚点屏幕坐标不变 → 反推 viewport.x/y
this.viewport.x = anchorScreenX - anchorWorld.x * s;
this.viewport.y = anchorScreenY - anchorWorld.y * s;
}
});
为防止 viewport ↔ store 双向同步死循环,用了 isUpdatingZoom / isAnimatingZoom 双标志位切断循环。
三、状态层:Pinia 与双栈历史记录
3.1 Store 拆分原则
13 个 Pinia store 按"显示状态与业务状态分离、真实来源与 UI 副本分离、store 不依赖渲染层生命周期"切分:
| Store | 职责 |
|---|---|
useCanvasDocStore | 画布文档数据唯一真实来源 |
useDocHistoryStore | 文档级(结构操作)历史栈,全局跨帧 |
useHistoryStore | 历史记录 UI 聚合层 |
useCanvasDisplayStore | 显示参数(cellSize/zoom/grid/bg) |
useToolStore | 工具配置 |
useColorStore | 颜色与注入式颜色操作 |
useTextureStore | 纹理资产 UI 副本(同步到 displayStore) |
useClipboardStore | 剪贴板 + 注入式选区操作 |
usePlaybackStore / useOnionSkinStore | 播放 / 洋葱皮运行时 |
useAppStore / useUIStore / useSettingsStore | 全局应用 / 纯 UI / 偏好 |
关键设计:注入式 actions。useColorStore、useClipboardStore 初始为 no-op,由 draw/index.vue 在画布就绪后注入真实实现。这让 store 不依赖 Pixi 渲染层生命周期,组件通过 store 调用时不感知 CanvasContent 是否挂载。
3.2 文档数据:双层级设计
useCanvasDocStore 维护两级数据:
- 序列化层(与 IndexedDB 对齐):
frames: PxFrame[],每个layer.data是紧凑三元组数组[asset_id, x, y][]。 - 运行时层:
activePixelData = shallowRef<Map<string, PixelValue>>,只有活跃帧×活跃图层的像素数据在 Map 中。
// 用 Map 而非 TypedArray:O(1) 读写 + 支持稀疏存储(像素画大量空白格)
const activePixelData = shallowRef<Map<string, PixelValue>>(new Map());
// 用 shallowRef 而非 ref:避免 Map 内部变化触发深度响应式
切换帧/图层时通过 flushActiveLayer(写回)和 loadActiveLayer(读出)双向同步,_isDirty 标志避免无谓序列化。这种设计的优点是内存占用低、切换成本低;代价是必须维护 flush/load 一致性,所有操作入口都必须记得调用 _ensureFlushed。
3.3 历史记录:Command 模式 + TypedArray 压缩
历史记录这块花了很长时间,因为像素画的撤销并不是简单地存一张快照。那样做最简单,但很快就会把内存吃爆。后来我换成了更实用的方式:每次绘制按一条事务记录,记录改了哪些像素,再配上压缩结构来节省空间。
双栈并行
- 像素历史 HistoryManager:per-frame 作用域,切换帧时清空像素历史但保留结构操作。实例化在
CanvasContent.vue的shallowRef中,不在 Pinia store 里。 - 文档历史 useDocHistoryStore:全局跨帧,主要存
StructuralCommand(帧/图层增删改排序)。
undo/redo 非对称协调
这是最精巧的设计——undo 和 redo 的优先级是相反的:
canvasActions.undo = () => {
if (canvas.canUndo?.()) canvas.undo?.(); // 像素历史优先撤回
else docHistoryStore.undo(); // 像素栈空才撤回结构操作
};
canvasActions.redo = () => {
if (docHistoryStore.canRedo) docHistoryStore.redo(); // 结构操作优先重做
else canvas.redo?.(); // 再重做像素
};
为什么非对称? undo 优先像素是为了"在新帧上绘制后,Ctrl+Z 会先撤回绘制,而不是直接删除新帧";redo 顺序与 undo 对称。这保证了用户对"最近操作"的直觉预期。
TypedArray 压缩
每条像素变更用 CompressedPixelChanges 压缩存储:
class CompressedPixelChanges {
private coords: Uint16Array; // [x, y] per change
private colorIds: Uint16Array; // [oldColorId, newColorId]
private infoIds: Uint16Array; // [oldInfoId, newInfoId]
private textureIds: Uint16Array; // [oldTexId, newTexId]
}
- 每条像素变更占 4 个 Uint16Array × 2 = 16 bytes,比 JS 对象小约 25 倍。
ColorRegistry将颜色字符串映射为 uint16 ID,0xFFFF保留为"透明/删除"哨兵,避免 TypedArray 存字符串。
事务机制
替代定时器批处理,一笔笔画对应一条历史记录:
// pointerDown 时
historyManager.beginTransaction(tool);
// 绘制中
historyManager.appendToTransaction(change);
// pointerUp 时
historyManager.commitTransaction(); // 提交为一条 PixelCommand
undo 时丢弃未提交事务。语义清晰且无定时器竞态。
淘汰策略
双重上限:maxBytes 默认 10MB(内存优先)+ maxSteps 默认 100(步数兜底),从最旧 shift() 淘汰。maxSteps 可通过 localStorage + 自定义事件 maxUndoStepsChanged 实时调整。
四、工具层与算法
工具层采用 三层组织:顶层调度(useCanvasInteraction / useToolManager)、交互适配(interactions/)、工具实现(brush/ / fill/ / selection/ 等)。
4.1 事件路由:选区优先
鼠标按下事件的路由顺序有一个关键设计——选区拖动优先于绘制。任意工具下点击选区内部都会触发 startDrag,这是"在选区内拖动移动内容"直觉的来源:
- 锁定图层保护(drawingTools 命中锁定层拒绝)
- 粘贴预览模式直接走
updatePastePreview - select 工具探测是否在选区内拖动
- 其他工具的选区内拖动探测(关键)
- 按
currentTool分支:fill / shapes / eyedropper / text / brush+eraser
多指触控时 forceInterruptForMultiTouch 会回滚 currentStroke 中的临时像素并重置所有工具状态,保证不残留半成品笔画。
4.2 画笔工具的几个细节
- 连续画线:用 Bresenham 算法 (
getLinePixels) 补齐lastDrawnPos到current的中间点,避免快速移动产生断点。 - 完美像素(Pixel Perfect):size=1 时启用,维护最近 3 个点,若形成"正交阶梯+对角跨越"则移除中间点。关键是 只移除本次笔画刚画的像素,恢复到
oldColor而非置空,避免误删底层已有内容。 - 对称绘画:以画布中心
centerX=(width-1)/2为轴,mirrorX = round(2*centerX - x),支持水平/垂直/双轴对称。 - 魔术橡皮擦:复用
floodFill算法擦除连通同色区域。
4.3 洪水填充(Flood Fill)
用标准 BFS 四连通(数组队列 + Set<string> visited),不是扫描线。
大画布优化有两层:
pixelDataCache缓存getPixelData结果,避免getColorAt和shouldSkip双重查询。- 纹理模式用
beginBatch/endBatch避免逐像素全量重绘(O(n²)→O(n)),颜色模式用batchSetPixels。
容差:FloodFill 本身只做严格相等匹配。容差逻辑在魔术棒 SelectionHelper.isColorMatch 中实现(欧几里得距离 ≤ tolerance)。
4.4 选区与工具联动
这是工具层最复杂的一块。选区支持 5 种类型:矩形、椭圆、套索、魔术棒(连通)、魔术棒(全局同色)。
套索算法
LassoHelper 用 射线法 判定点是否在多边形内:从测试点向右水平发射,统计与多边形边的交叉次数,奇数为内。先算多边形 AABB 并裁剪到画布范围,避免遍历全画布。只保留有像素内容的格子。
SelectionTransformer 变换
所有变换都是 提取 → 删除原位置 → 写回新位置 三步:
- 翻转:
newX = startX + (width-1-relativeX) - 旋转 90°:
(relX, relY) → (relY, width-1-relX) - 旋转 180°:复用
flipHorizontal + flipVertical - 旋转 270°:
(relX, relY) → (height-1-relY, relX)
moveWithBounds 支持超出画布边界的像素保留,标记 outOfBounds,取消选区时由 cleanOutOfBoundsPixels 清理。
SelectionConstraint 统一约束接口
提供静态方法 isInSelection / filterPixelsInSelection,被画笔、填充、魔术橡皮擦统一调用,实现"只在选区内绘制"的透明约束:
// 画笔绘制时
if (SelectionConstraint.shouldConstrainToSelection(selectionPixels)) {
if (!SelectionConstraint.isInSelection(selectionPixels, x, y)) return;
}
图层隔离
选区操作只读写 activePixelData(活跃图层),pixelLayer 仅用于渲染同步。复制从合并视图读取(所见即所得),剪切只剪活跃图层。这是"用户看到什么就复制什么,但操作只影响当前层"的直觉来源。
4.5 变换工具:Worker 阈值切换
- 阈值切换:
WORKER_THRESHOLD = 50_000,像素数 < 50k 主线程同步,≥ 50k 走 Worker。 - 数据格式:
Int32Array,每像素 6 个 int32(x, y, r, g, b, a),STRIDE = 6,避免字符串解析开销。 - 通信:复制 buffer(
buf.buffer.slice(0))后 Transferable 传输,保留原始 buf 供 colorInfo 查找。Worker 单例懒加载。
4.6 文字工具:光栅化与颜色投票
- 用 PixiJS
TextStyle+CanvasTextMetrics.measureText测量文本 - 创建离屏 canvas,按行
fillText绘制 processCoverage:按cellSize分格,统计每格的 alpha 覆盖率,超过阈值(默认 0.35)则视为该格有点
富文本模式用 颜色投票:每个 cell 内按字符区域匹配,加权累计各颜色权重,取最大值作为 winnerColor。这实现了多色文字的精确像素化。
文字工具状态用 AbortController 支持取消,debounce(80ms) 避免频繁光栅化。
4.7 几何算法集合
getLinePixels:Bresenham 直线,支持strokeWidth,粗线时用圆形扩展 + Set 去重。getCirclePixels:半像素偏移(r+0.5)²判定消除正交方向尖角;空心圆用圆环距离判定避免对角线镂空。getRectanglePixels:填充直接遍历,空心用多层描边 + Set 去重。getTrianglePixels:填充用扫描线,空心对三边用 Bresenham + 圆形扩展。
五、GSAP 与 PixiJS 的动画联动
| 场景 | 实际技术 | 原因 |
|---|---|---|
| 视口缩放过渡 | GSAP | 需要缓动曲线(power2.out) |
| 选区行军蚁 | requestAnimationFrame | 线性匀速 dashOffset += 0.5,无需缓动 |
| 文字光标闪烁 | setInterval(530) | 固定间隔切换 visible |
| 加载动画 | SVG SMIL <animate> | 自带循环无需 JS |
| 启动淡出 | CSS class fade-out + setTimeout(300) | 一次性过渡 |
| 引导教程 | driver.js | 第三方库 |
5.1 GSAP × PixiJS 联动方式
ViewportManager 中的联动方式是:直接 tween PixiJS Viewport 对象的 scale 属性,不使用 PixiJS ticker。
gsap.to(this.viewport.scale, {
x: targetScale, y: targetScale, duration, ease: 'power2.out',
onUpdate: () => {
const s = this.viewport.scale.x;
this.viewport.x = anchorScreenX - anchorWorld.x * s;
this.viewport.y = anchorScreenY - anchorWorld.y * s;
}
});
GSAP 用自带的 RAF ticker 驱动 tween,下一帧 Pixi 渲染自然拾取被改写的 scale 值。
5.2 为什么关闭 pixi-viewport 内置 wheel
pixi-viewport 自带的 .wheel() 是即时跳变,没有过渡动画。为了实现平滑缩放,必须接管 wheel:
// 移除内置 wheel,手动挂 passive: false 监听
this.viewport.off('wheel');
canvas.addEventListener('wheel', this._onWheel, { passive: false });
// wheel 事件中 preventDefault 后调 _animateZoom(targetScale, mouseWorld, 0.18)
冲突规避:动画期间 viewport.plugins.pause('drag') 暂停 drag 插件,避免 GSAP 与 pixi-viewport 同时写 viewport.position 导致抽搐,onComplete 里 resume('drag')。
5.3 取舍
直接 tween Pixi 对象属性而非走 Pixi ticker 的优点是动画曲线由 GSAP 统一管理;缺点是 GSAP 与 Pixi 两套 RAF 并存(GSAP 用自己的,Pixi 用 app.ticker),理论上存在微小抖动风险,但 0.18~0.22s 的短动画下不可见。gsap.killTweensOf(this.viewport.scale) 在三处使用(动画开头防叠加、centerView 初始化前取消、destroy 清理)防止内存泄漏。
行军蚁用 RAF 而非 GSAP,是因为它每帧只重绘 antsGraphics 一层(静态层缓存),且线性匀速无需缓动曲线,用 RAF 更轻量。同时缓存了 _buildMergedBorderSegments 把逐像素边按行/列合并为长线段,把 O(像素数) 降到 O(周长/cellSize)。
六、Web Worker 流水线
项目共使用 6 个 Web Worker,全部采用原生 self.onmessage / self.postMessage 通信,未使用 Comlink。
| Worker | 职责 |
|---|---|
| imagePreprocessing | 图像加载 / 降噪 / 边缘检测 / 网格采样 |
| colorMatchPalette | K-Means 量化 + CIEDE2000 调色板匹配 + 空间去噪 |
| deserializePixelData | 非活跃图层异步反序列化 |
| renderFrameBitmaps | 全帧预渲染为 ImageData(播放/洋葱皮) |
| exportWorker | 静态 PNG/JPG 导出(OffscreenCanvas 全渲染管线) |
| gifEncoder.worker | GIF 编码(gifenc 库) |
为什么用原生而非 Comlink:代价是手写消息类型与 Promise 包装;收益是零运行时依赖、对 Transferable 控制更直接。Transferable 仅在两处使用(预处理输入的 ArrayBuffer、帧渲染输出的多个 ArrayBuffer),其余走结构化克隆。
6.1 图像转像素完整流水线
这是 Worker 应用最密集的场景,跨主线程与 2 个 Worker,分 6 步:
1. 上传(主线程)→ ObjectURL 预览
2. 压缩(库内 Worker)→ browser-image-compression,max 2048,<500KB 跳过
3. 裁剪(主线程)→ cropperjs
4. 尺寸调整(主线程)→ 格子数 16~maxWidth,倍率 ×1/×2/×3
5. 预处理(Worker)→ 降噪 / 边缘检测 / 网格采样
6. 颜色量化/匹配(Worker)→ K-Means + CIEDE2000
6.2 颜色压缩:分层精度策略
colorMatchPalette.worker 采用 三阶段策略,核心是"快速聚类 + 精确匹配"的分层设计:
阶段一:K-Means 量化(Redmean 距离)。当唯一颜色数 > K_THRESHOLD=16 时启动,用 K-Means++ 播种,最大 15 次迭代。距离用 Redmean 加权平方距离(比 LAB 快很多),仅用于聚类。最终选每个簇内最接近质心的真实输入色作为代表。
阶段二:CIEDE2000 调色板匹配(WebGPU 优先,CPU 兜底)。将输入 LAB 和调色板 LAB 上传为 storage buffer,WGSL shader 完整实现 CIEDE2000 公式,每线程处理一个输入色遍历全部调色板取最小距离索引,workgroup_size=64。失败则 CPU 逐个计算 deltaE2000Sq(平方比较省 sqrt)。
阶段三:多数投票空间滤波。对每个像素看 3×3 邻域,若自身颜色仅出现 1 次且某邻居颜色出现 ≥3 次,则替换为邻居主色,消除孤立噪点、修复断线。
距离公式取舍:聚类用 Redmean(快),最终匹配用 CIEDE2000(准)。这是典型的"精度/性能分层"——K-Means 只需相对距离做划分,Redmean 足够;调色板匹配是最终视觉结果,必须用感知最准确的 CIEDE2000。
抖动(dithering):未实现。采样是每格取主色,匹配是最近邻硬映射,无 Floyd-Steinberg 误差扩散。这符合像素艺术风格(避免噪点),但牺牲了渐变表现力。
6.3 超大图片处理
尺寸硬限 8192×8192。imagePreprocessing.worker.ts 中 MAX_IMAGE_DIMENSION = 8192,超限即抛错。8192²×4 ≈ 256MB,是浏览器单 Worker 内存的安全边界。
多重防线防止崩溃:
- 第一道(主线程预压缩):上传时即压缩到 max 2048,从源头缩小图像,小于 500KB 直接跳过避免无谓解码。
- 第二道(Worker 解码后校验):
loadImageFromArrayBuffer先detectImageFormat验证魔术字节(PNG/JPEG/GIF/WebP),createImageBitmap后立即assertImageDimensionWithinLimit,超限imageBitmap.close()释放再抛错。 - 第三道(网格尺寸约束):
gridWidth > imageData.width直接拒绝,UI 端把格子数上限锁在图片像素内。
未采用分块处理:Worker 内一次性 getImageData 取全图到内存,降噪/边缘检测是全图卷积。真正的防爆靠的是 8192 上限 + 预压缩,而非流式/分块。这是一个有意的取舍——分块处理会让卷积算法在块边界处理变得复杂,而 8192 上限对像素艺术场景已经足够。
七、导出管线
支持 5 种导出格式,按格式分流:
7.1 PNG / JPG(静态导出)
走 exportWorker.ts,Worker 内 calculateLayout → OffscreenCanvas 绘制(像素→网格→十格线→坐标→水印→图纸头→色号列表)→ convertToBlob → 主线程 Image 回贴到 HTMLCanvasElement 返回。
7.2 GIF
主线程 renderFrame 逐帧渲染 → postMessage 帧数据到 gifEncoder.worker → Worker 用 gifenc 逐帧 quantize(rgba, 256, {format:'rgba4444'}) + applyPalette → GIFEncoder().writeFrame → Blob。
透明处理:全透明帧用 1 色调色板 + transparent:true, dispose:2;部分透明帧检测 alpha=0 像素设 transparentIndex=0。
7.3 MP4(双引擎)
主路径(WebCodecs):mp4-muxer 的 Muxer(ArrayBufferTarget) + VideoEncoder,codec avc1.42001f(Baseline 3.1),5Mbps,每 30 帧一个 keyframe。MP4 不支持透明,故 compositeOnWhiteBackground 把 RGBA 合成到白底。
兜底路径(ffmpeg.wasm):WebCodecs 不可用时用 @ffmpeg/ffmpeg,每帧转 PNG 写入虚拟文件系统,concat demuxer + libx264 + yuv420p + crf 23。
取舍:主路径零依赖、快,但浏览器兼容性受限;兜底重(加载 wasm)但通用。
7.4 ZIP
主线程 renderFrame 逐帧 → imageDataToBlob(OffscreenCanvas convertToBlob)→ JSZip 打包,文件名 frame-001.png 零填充,跳过空 Blob 帧并记录。
7.5 导出渲染器
5 个渲染器各自负责独立图层,在 Worker 与主线程按序调用:
| 渲染器 | 渲染内容 |
|---|---|
| pixelRenderer | 色块 + 纹理 + 色号文字(格子≥20px 时显示,对比色用 WCAG 亮度) |
| gridRenderer | 普通网格线 + 十格实线 + 五格虚线 |
| blueprintRenderer | 图纸模式头部:标题、署名、尺寸信息、二维码、色号列表 |
| coordinateRenderer | 四周坐标数字 |
| watermarkRenderer | 水印:单居中或重复平铺,水平/斜角(-30°)两种布局 |
关键设计:渲染器接收 RenderingContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,同一套代码可同时跑在主线程(同步预览)和 Worker(导出)。
7.6 一个性能瓶颈点
多帧导出的帧渲染在主线程,不在 Worker。只有 GIF 编码阶段进 Worker,MP4/ZIP 全程主线程。这是潜在的 UI 卡顿风险点(尤其高 scale 多帧)。这是当前架构的一个已知取舍——帧渲染需要访问纹理资产,跨 Worker 传递纹理成本高,所以留在主线程。
八、存储层:IndexedDB 与 PX 文件格式
8.1 双库隔离
项目使用 两个物理隔离的 IndexedDB 数据库:
| 库 | 版本 | store | 索引 |
|---|---|---|---|
canvas-db | v3 | canvases (keyPath: id) | by-updatedAt |
pixel-world-palettes | v1 | palettes (keyPath: id) | by-lastUsedAt, by-isSystem |
为什么分库:canvas-db 体量大且频繁写入,pixel-world-palettes 数据相对稳定且需被多个画布共享引用。分库避免大对象写入触发其他无关记录的事务冲突,也让两者可独立演进 schema 版本。
版本迁移:v1/v2 → v3 的 migrateCanvasDbUpgrade 把根级 layers 数组迁移为 frames: [{ name: 'Frame 1', layers }]。迁移在 upgrade 事务内同步完成所有 await,避免 TransactionInactiveError 与 initDB 提前 resolve 的竞态。用 fast-check 做了 30 次随机属性测试验证幂等性。
8.2 PX 文件格式
StoredCanvas 对齐 PxFile 接口,IndexedDB 存储格式 = PX 文件格式,零拷贝:
StoredCanvas {
id, version, content_type: 'application/vnd.mzb.px+json'
metadata: { title, updated_at, created_at, thumbnail_url, ... }
scene: { dimension, size: [w,h], color_depth, coordinate }
assets: { colors?: PxColorAsset[], textures?: PxTextureAsset[] }
frames: [{ name, layers: [{ name, visible, opacity, data: PixelData[] }] }]
}
- 像素数据三元组
PixelData = [asset_id, x, y],而非存 Map。asset_id 同时覆盖颜色资产与纹理资产(约定 id=0 为透明)。每个像素从{x,y,color}~40B 降到 12B。 - 纹理仅支持内嵌 base64
data_url,MIME 限定 png/jpeg/webp。 - 颜色资产 ID 的"规范化升序分配":两遍扫描 + 按规范化颜色升序分配,保证分配结果与 Map 迭代顺序无关,从而幂等。这牺牲了一定 CPU 换取幂等性,避免重复保存时颜色 ID 漂移导致整张画布 diff 失控。
8.3 自动保存与配额管理
双模式:realtime 用 watchDebounced(store.activePixelData, ..., { debounce: 800, deep: false });interval 用 setInterval(performSave, 5000)。deep: false 是关键——activePixelData 是 shallowRef,只在引用替换时触发。
配额管理三道防线:
navigator.storage.persist()争取持久化存储权限,防止浏览器在压力下清除画布数据。available < estimatedSize * 2预判空间不足则拒绝保存。- 5s 超时 + 3 次防抖重试(3s 间隔)。
未保存变更检测以 saveStatus 状态机为信号而非 dirty flag:saving / error → true,saved → false,idle 且有保存记录 → false。简化心智模型——状态机本身即脏标记。
8.4 云同步:本地优先 + LWW
IndexedDB 是数据真相源,云端是备份与跨设备同步通道。
- 上传:单请求原子上传(元数据 + raw JSON + 缩略图一次性
POST /artworks),避免多请求半成功状态。 - 冲突解决:最后写入获胜(LWW),基于
updated_at时间戳。云端有更新时仅标记outdated不自动拉取,把决策权交给用户。像素画作品冲突合并几乎不可能自动完成,LWW + 手动选择比自动覆盖更安全。 - 跨账户检测:基于
username(非内部 id)判定。检测到后把所有带旧账户标记的作品重新生成 id 并清除synced_at/synced_username,使其变回纯本地作品。
九、性能优化汇总
把散落在各节的性能优化点汇总成一张表,方便对照:
9.1 渲染层优化
| 优化点 | 机制 | 位置 |
|---|---|---|
| 混合像素渲染 | 颜色像素走 Graphics 矢量批量,纹理像素走独立 Sprite | PixelLayer.ts |
| 纹理复用 | TextureCache 预加载 + data URL 同步兜底 | TextureCache.ts |
| 批量与脏标记 | beginBatch/endBatch + _lastRenderedRefs 引用比较跳过未变层 | PixelLayer.ts |
| 增量重绘 | batchRenderPixels 无删除无纹理时只重绘变更像素的 rect | PixelLayer.ts |
| 静态/动画分离 | SelectionLayer 静态层引用比较,动画层只画虚线 | SelectionLayer.ts |
| 线段合并 | 行军蚁边界按行/列合并为长线段,O(像素数)→O(周长/cellSize) | SelectionLayer.ts |
| 视口裁剪 | TextLayer 只创建可见区域内的 BitmapText,不可见的复用不销毁 | TextLayer.ts |
| 显示阈值 | GridLayer/TextLayer 按 cellSize 阈值跳过整层渲染 | GridManager.ts |
| 纹理缓存键 | BackgroundLayer 用 ${size}_${color1}_${color2}_${blockSize} 复用 TilingSprite | BackgroundLayer.ts |
| rAF 节流 | viewport-moved 事件用 rAF 合并多帧,平移不重绘网格 | usePixiCore.ts |
9.2 状态层优化
| 优化点 | 机制 |
|---|---|
| shallowRef | activePixelData 用 shallowRef 避免深度响应式 |
| 稀疏存储 | Map 存储 + 三元组序列化,空白格不占内存 |
| 历史压缩 | TypedArray + ColorRegistry,每条变更 16 bytes,比 JS 对象小 25 倍 |
| 事务机制 | 一笔画一条历史记录,无定时器竞态 |
| 双重淘汰 | 内存上限 10MB + 步数 100,从最旧 shift |
| 非活跃图层异步反序列化 | 切换帧时非活跃图层用 Web Worker 反序列化,不阻塞主线程 |
9.3 Worker 优化
| 优化点 | 机制 |
|---|---|
| 颜色量化分层 | 聚类用 Redmean(快),匹配用 CIEDE2000(准)+ WebGPU 加速 |
| 变换阈值切换 | < 50k 像素主线程同步,≥ 50k 走 Worker |
| Int32Array 紧凑编码 | 每像素 6 个 int32,避免字符串解析 |
| Transferable 零拷贝 | 预处理输入 + 帧渲染输出用 Transferable |
| 双路径帧渲染 | 纯色直写 Uint8ClampedArray 比 OffscreenCanvas 快,纹理必须走 canvas |
9.4 存储优化
| 优化点 | 机制 |
|---|---|
| 双库隔离 | 画布与调色板分库,避免事务冲突 |
| 零拷贝序列化 | IndexedDB 存储格式 = PX 文件格式 |
| 索引降序读 | by-updatedAt 索引 prev 游标,不在内存排序 |
| 60s 节流 | 调色板 updateLastUsedAt 带 60s 节流避免频繁 put |
| 缩略图按需生成 | 帧预览切换或面板可见时才生成 |
| localStorage 分工 | 小体积偏好快照走 localStorage,大体积画布走 IndexedDB |
十、结语:架构是取舍的累积
回过头看这次重构,真正花时间的并不是“加功能”,而是把几个原本靠巧合能跑的地方变成可控的系统。
其中最重要的几个决定,其实都和“少做一点”有关:
- 不做整张画布快照式历史。一开始想偷懒,直接把整张画布存下来,十几步操作就把内存吃爆。改成事务式记录和压缩结构后,内存占用会小很多。
- 不把所有东西塞进一个 Pixi 容器。看起来简单,但事件拦截和层级管理很快就会变乱。拆成多层之后,虽然代码变多,但每一层的职责更清楚。
- 不把量化做得太“像照片”。Floyd-Steinberg 这种抖动会让像素画看起来更“脏”,我更愿意牺牲一点渐变表现力,保留硬边和清晰的像素感。
- 不把冲突合并做得太智能。像素画的冲突合并很难自动处理,LWW + 手动选择比“自动覆盖”更稳妥。
- 不把所有动画都交给 GSAP。只有需要缓动曲线的地方才用它,其他像行军蚁、光标闪烁、加载动画,都用更轻量的方式。
架构不是一次性的设计图,而是很多次判断的结果。很多时候真正重要的,不是“我把什么做出来了”,而是“我决定不把什么做成一坨乱东西”。
如果你也在做复杂的 Canvas 应用,这种折腾过程里,很多问题最后都不是技术问题,而是如何把一个看起来能跑的 Demo,慢慢变成一个真的能长期使用的工具。
项目信息:Pixel World v0.0.6,Vue 3.5 + TypeScript + PixiJS 8,约 7 万行代码,347 个被索引文件,5261 个代码节点。
作者:沐泽白
声明:本文所有代码引用均基于实际仓库分析,通过 CodeGraph(5261 节点 / 11317 边)与逐文件精读交叉验证。