我用AI不断迭代到7W行代码的项目-像素画编辑器

像素画编辑器 项目架构与技术笔记

在找前端工作,上海、江苏、浙江、安徽
预览地址:在线像素艺术编辑器 | 免费像素画创作工具

一个约 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 分层结构

bash 复制代码
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 负责真相,渲染负责表现"的闭环:

markdown 复制代码
用户绘制 → 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:

ts 复制代码
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.lostreason === '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"
ts 复制代码
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 }) 同步创建,所以:

  1. 文档加载时 preloadAssets.load({ src: dataUrl, alias }) 异步加载,完成后设 scaleMode = 'nearest'(像素硬边)。
  2. 绘制热路径未命中缓存时,尝试同步兜底:new Image() + img.src = dataUrl(data URL 同步可用)+ new ImageSource + new Texture
  3. 都不行就触发异步 preload 并返回 null,下次 get 可用。

纹理替换时 invalidate 销毁旧 texture 并 Assets.unload(key) 释放显存,文档关闭时 clear 全量清理。这套机制保证了绘制热路径尽量不返回 null。

2.5 视口与锚点缩放

基于 pixi-viewport,但 关闭了内置 wheel ,手动挂 passive: false 的 wheel 监听,以便 preventDefault 并接管为 GSAP 动画。原因会在第五节详述。

锚点缩放的核心是:以鼠标世界坐标为锚点,动画期间保持锚点屏幕坐标不变

ts 复制代码
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 / 偏好

关键设计:注入式 actionsuseColorStoreuseClipboardStore 初始为 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 中。
ts 复制代码
// 用 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.vueshallowRef 中,不在 Pinia store 里。
  • 文档历史 useDocHistoryStore:全局跨帧,主要存 StructuralCommand(帧/图层增删改排序)。
undo/redo 非对称协调

这是最精巧的设计------undo 和 redo 的优先级是相反的:

ts 复制代码
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 压缩存储:

ts 复制代码
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 存字符串。
事务机制

替代定时器批处理,一笔笔画对应一条历史记录:

ts 复制代码
// 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,这是"在选区内拖动移动内容"直觉的来源:

  1. 锁定图层保护(drawingTools 命中锁定层拒绝)
  2. 粘贴预览模式直接走 updatePastePreview
  3. select 工具探测是否在选区内拖动
  4. 其他工具的选区内拖动探测(关键)
  5. currentTool 分支:fill / shapes / eyedropper / text / brush+eraser

多指触控时 forceInterruptForMultiTouch 会回滚 currentStroke 中的临时像素并重置所有工具状态,保证不残留半成品笔画。

4.2 画笔工具的几个细节

  • 连续画线 :用 Bresenham 算法 (getLinePixels) 补齐 lastDrawnPoscurrent 的中间点,避免快速移动产生断点。
  • 完美像素(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 结果,避免 getColorAtshouldSkip 双重查询。
  • 纹理模式用 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,被画笔、填充、魔术橡皮擦统一调用,实现"只在选区内绘制"的透明约束:

ts 复制代码
// 画笔绘制时
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 文字工具:光栅化与颜色投票

  1. 用 PixiJS TextStyle + CanvasTextMetrics.measureText 测量文本
  2. 创建离屏 canvas,按行 fillText 绘制
  3. 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

ts 复制代码
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:

ts 复制代码
// 移除内置 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 导致抽搐,onCompleteresume('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 步:

markdown 复制代码
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×8192imagePreprocessing.worker.tsMAX_IMAGE_DIMENSION = 8192,超限即抛错。8192²×4 ≈ 256MB,是浏览器单 Worker 内存的安全边界。

多重防线防止崩溃

  1. 第一道(主线程预压缩):上传时即压缩到 max 2048,从源头缩小图像,小于 500KB 直接跳过避免无谓解码。
  2. 第二道(Worker 解码后校验)loadImageFromArrayBufferdetectImageFormat 验证魔术字节(PNG/JPEG/GIF/WebP),createImageBitmap 后立即 assertImageDimensionWithinLimit,超限 imageBitmap.close() 释放再抛错。
  3. 第三道(网格尺寸约束)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'}) + applyPaletteGIFEncoder().writeFrame → Blob。

透明处理:全透明帧用 1 色调色板 + transparent:true, dispose:2;部分透明帧检测 alpha=0 像素设 transparentIndex=0

7.3 MP4(双引擎)

主路径(WebCodecs)mp4-muxerMuxer(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 文件格式,零拷贝:

css 复制代码
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 自动保存与配额管理

双模式realtimewatchDebounced(store.activePixelData, ..., { debounce: 800, deep: false })intervalsetInterval(performSave, 5000)deep: false 是关键------activePixelDatashallowRef,只在引用替换时触发。

配额管理三道防线

  1. navigator.storage.persist() 争取持久化存储权限,防止浏览器在压力下清除画布数据。
  2. available < estimatedSize * 2 预判空间不足则拒绝保存。
  3. 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 边)与逐文件精读交叉验证。

相关推荐
weedsfly1 小时前
前端懒加载全家桶:图片、路由、组件的按需加载
前端·javascript·面试
千帆_Evan1 小时前
🤖 AI 写代码总在重复造轮子?我们把 Utils 复用门禁做进了 Agent 运行时(已开源)
前端·agent
Kel1 小时前
多 Tab 聊天不翻车:纯前端实现跨标签页消息同步的三层协同架构
前端·javascript·人工智能
半个落月1 小时前
Promise 入门:理解 async/await 与 Promise.all
前端·javascript
廖菌1 小时前
DATASPACE3D 产品介绍:面向数字孪生与 Web3D 场景的一体化可视化搭建平台
前端·webgl
敖行客 Allthinker1 小时前
前端调试实战:DevTools调试工具 + 二分定位Bug法全总结
前端·typescript·chrome devtools
暗不需求1 小时前
手写深拷贝:从 JSON 到递归,彻底解决循环引用问题
前端·面试
灏仟亿前端技术团队1 小时前
DOMagic:把 AI 修改绑定到页面节点的前端开发态插件
前端