资讯动态

artcraft:轻量可组合的浏览器端创作工具架构与性能优化实践

发布时间:2026/10/20 15:59:32 来源:尧图企业网站定制
1. 项目缘起与整体设计思路1.1 为什么会有 artcraft 这个想法做创意工具这些年我最大的感受就是大部分所谓的“创作辅助平台”最后都变成了素材堆砌站。你打开一个网站满屏的模板、预设、滤镜看起来选择很多实际上真正能让你从零开始把脑子里的画面做出来的工具少得可怜。artcraft 这个项目最初就是冲着这个痛点去的——我想做一个轻量、可组合、不预设审美的创作工具集。artcraft 这个名字本身就说明了它的定位art 是艺术craft 是手艺。它不是一个“一键生成”的黑箱而是一套让你能像木匠打磨家具一样一步步把想法做出来的工具。核心用户是那些有创作欲望但不想被复杂软件绑架的人——独立设计师、内容创作者、手工爱好者甚至只是想在周末做点小东西的普通人。从技术选型上讲artcraft 走的是模块化 浏览器优先的路线。为什么不做桌面端因为桌面端意味着安装、更新、平台适配这些成本最终都会转嫁到用户身上。浏览器端虽然性能有天花板但对于 artcraft 的目标场景——二维图形处理、轻量级动画、纹理生成——现代浏览器的 Canvas 和 WebGL 已经足够能打。而且浏览器天然跨平台用户打开就能用这个体验上的优势是桌面端比不了的。1.2 核心架构的取舍逻辑artcraft 的架构可以概括为三层核心层、模块层、界面层。核心层负责最基础的图形运算和状态管理模块层是各种功能单元比如笔刷引擎、图层系统、导出器界面层则是用户直接操作的面板。为什么这么分因为我在早期版本里犯过一个错误把所有功能都塞进一个大的渲染循环里结果就是改一个笔刷参数整个画布都要重绘性能直接崩掉。后来我把核心层抽出来让它只负责“什么时候需要重绘”和“重绘哪一块”模块层各自管理自己的状态界面层只负责发指令和收结果。这样改完之后即使同时开十几个图层帧率也能稳在 60fps 左右。另一个关键决策是状态管理用不可变数据结构。听起来有点学院派但实际收益很大撤销/重做变得极其简单只需要保存状态快照的引用多人协作时冲突检测也容易做。代价是内存占用会高一些但 artcraft 的场景里一个画布的状态数据通常也就几 MB完全在可接受范围内。提示如果你也在做类似的创作工具强烈建议把撤销/重做放在架构设计的第一天就考虑而不是后期打补丁。我见过太多项目因为撤销逻辑写得太耦合最后只能支持“撤销一步”用户体验直接打折。1.3 目标场景与边界定义artcraft 不是要替代 Photoshop 或 Procreate这一点必须说清楚。它的目标场景是快速原型、轻量创作、教学演示、以及作为其他应用的嵌入式组件。比如你想给博客文章画个示意图用 artcraft 五分钟就能搞定或者你在做一个在线教育产品想把 artcraft 嵌进去让学生画思维导图它的模块化设计让这种集成变得很简单。边界定义清楚之后很多技术决策就顺了。比如为什么不支持 CMYK 色彩模式因为目标场景里没人需要印刷级色彩管理。为什么不支持 3D 模型导入因为那会引入巨大的解析和渲染复杂度偏离了“轻量”的初衷。知道不做什么比知道做什么更重要。2. 核心细节解析与实操要点2.1 笔刷引擎的实现细节笔刷是 artcraft 最核心的模块也是我花时间最多的地方。一个看起来简单的“画线”操作背后涉及采样、插值、压感模拟、纹理映射四个环节。采样环节我用的是指针事件 合并采样的策略。浏览器原生的 pointermove 事件触发频率不稳定有时候一帧触发好几次有时候好几帧才触发一次。如果直接拿事件坐标画线快速移动时会出现明显的折线感。我的做法是维护一个采样缓冲区每帧从缓冲区里取点用 Catmull-Rom 样条插值补出中间点。这样即使事件触发不均匀画出来的线也是平滑的。压感模拟这块artcraft 没有依赖硬件压感因为大部分鼠标和触摸屏没有而是用速度反推压力。逻辑很简单指针移动越快模拟的压力越小线条越细移动越慢压力越大线条越粗。这个模型当然不完美但对于手绘草图来说效果已经足够自然。参数上我设了一个速度阈值范围低于 0.5 像素/毫秒算“慢”高于 5 像素/毫秒算“快”中间线性映射。纹理映射是让笔刷有“质感”的关键。artcraft 的笔刷纹理是一张 256x256 的灰度图每个像素代表该位置的“墨水浓度”。绘制时根据笔刷大小和旋转角度把纹理采样到画布上。这里有个性能陷阱如果每个绘制点都重新采样整张纹理开销会很大。我的优化是预计算纹理的 mipmap根据笔刷大小选择合适层级的 mipmap采样开销直接降了一个数量级。// 笔刷纹理采样的核心逻辑简化版 function sampleBrushTexture(texture, x, y, size, angle) { const mipLevel Math.floor(Math.log2(size / 32)); const mip texture.mipmaps[mipLevel]; const cos Math.cos(angle); const sin Math.sin(angle); // 将画布坐标转换到纹理坐标 const tx (x * cos - y * sin) / size * mip.width; const ty (x * sin y * cos) / size * mip.height; return bilinearSample(mip, tx, ty); }注意mipmap 的层级选择要留一点余量不要刚好卡在边界上否则笔刷边缘会出现闪烁。我一般会选比理论值低一级的 mipmap牺牲一点锐度换稳定性。2.2 图层系统的状态同步图层系统是 artcraft 里第二复杂的模块。每个图层有自己的画布、混合模式、透明度、可见性。用户操作比如切换图层、调整透明度需要实时反映到最终渲染结果上。这里的关键问题是状态同步的粒度。早期版本里任何图层属性变化都会触发全画布重绘结果就是调整透明度滑块时卡顿明显。后来我改成了脏矩形标记每个图层维护一个“脏区域”列表属性变化时只标记受影响的区域渲染时只重绘这些区域。混合模式的实现也有讲究。Canvas 2D 原生支持globalCompositeOperation但它的混合模式有限而且不同浏览器的实现有细微差异。artcraft 的做法是常用混合模式正片叠底、滤色、叠加用原生实现特殊混合模式用 WebGL 着色器实现。这样既保证了性能又保证了效果一致性。图层顺序的管理用的是双向链表 索引映射。双向链表保证插入和删除是 O(1)索引映射保证按顺序遍历是 O(n)。为什么不用数组因为数组的插入删除是 O(n)当图层数量多的时候比如 50 个以上频繁调整顺序会明显卡顿。2.3 导出器的格式选择与压缩策略导出功能看起来简单实际上坑很多。artcraft 支持 PNG、JPEG、WebP 三种格式每种格式的导出策略都不一样。PNG 是无损格式适合线条图、文字图。但 PNG 的压缩率取决于编码器的实现浏览器原生的canvas.toBlob()用的是内置编码器压缩率一般。我的优化是导出前先做一次颜色量化把颜色数从 1600 万降到 256 色如果用户接受的话PNG 体积能减少 60% 以上。当然这个选项默认关闭用户需要手动开启。JPEG 是有损格式适合照片类内容。关键参数是质量因子我默认设的是 0.85。这个值是我测试了几十张图之后定的低于 0.8 时线条边缘会出现明显锯齿高于 0.9 时体积增加很快但肉眼几乎看不出区别。0.85 是一个比较好的平衡点。WebP 是 Google 推的格式压缩率比 JPEG 好 30% 左右但兼容性稍差Safari 从 14 版才开始支持。artcraft 的策略是如果浏览器支持 WebP 编码优先用 WebP否则回退到 JPEG。检测方法很简单function supportsWebP() { const canvas document.createElement(canvas); canvas.width 1; canvas.height 1; return canvas.toDataURL(image/webp).startsWith(data:image/webp); }提示导出大尺寸图片时不要一次性把整个画布画到离屏 canvas 上再导出那样内存峰值会很高。我的做法是分块导出把画布切成 1024x1024 的块逐块绘制到离屏 canvas逐块编码最后合并。这样内存占用能控制在 50MB 以内即使导出 8000x8000 的图也不会崩。3. 实操过程与核心环节实现3.1 从零搭建开发环境的完整步骤artcraft 的开发环境不复杂但有几个细节容易踩坑。我按顺序列一下。第一步初始化项目。我用的是 Vite TypeScript 的组合。Vite 的冷启动速度比 Webpack 快很多TypeScript 则是为了保证模块之间的接口清晰。初始化命令npm create vitelatest artcraft -- --template vanilla-ts cd artcraft npm install第二步安装核心依赖。artcraft 的依赖很少主要是三个gl-matrix矩阵运算、lodash-es工具函数、comlinkWeb Worker 通信。为什么用lodash-es而不是lodash因为前者支持 tree-shaking打包体积能小一半。npm install gl-matrix lodash-es comlink npm install -D types/lodash-es第三步配置 Vite。默认配置下Vite 会把所有依赖打包到一个 chunk 里首屏加载会慢。我的配置是把核心层和模块层分开打包核心层优先加载模块层按需加载。// vite.config.ts export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { core: [./src/core/index.ts], modules: [./src/modules/index.ts] } } } } });第四步搭建开发服务器。Vite 自带 HMR热模块替换但 artcraft 的画布状态在 HMR 时会丢失。我的解决办法是把画布状态序列化到 sessionStorageHMR 后自动恢复。这样改代码时不用重新画一遍。注意sessionStorage 有 5MB 的大小限制如果画布状态太大序列化会失败。我的做法是只序列化图层元数据不序列化像素数据像素数据在 HMR 后重新渲染。3.2 笔刷模块的完整实现流程笔刷模块的代码量大概 800 行我按功能拆成四个文件brush-state.ts状态管理、brush-sampler.ts采样插值、brush-renderer.ts渲染、brush-presets.ts预设。brush-state.ts里定义笔刷的核心参数interface BrushState { size: number; // 笔刷大小单位像素 opacity: number; // 不透明度0-1 flow: number; // 流量0-1 hardness: number; // 硬度0-1 spacing: number; // 间距相对于笔刷大小的比例 angle: number; // 旋转角度弧度 textureId: string; // 纹理 ID }spacing这个参数值得说一下。它控制的是两个笔刷印记之间的距离。如果 spacing 是 0.1意味着每移动笔刷大小 10% 的距离就画一个印记。spacing 越小线条越连续但性能开销越大。我的默认值是 0.15大部分场景下够用。如果用户画的是快速草图可以调到 0.3 省性能如果画的是精细线条可以调到 0.05。brush-sampler.ts的核心是样条插值。我用的 Catmull-Rom 样条因为它计算简单而且曲线经过所有控制点不像 B 样条会偏离控制点。插值的密度由 spacing 决定function interpolatePoints(points: Point[], spacing: number): Point[] { const result: Point[] []; for (let i 0; i points.length - 1; i) { const p0 points[Math.max(0, i - 1)]; const p1 points[i]; const p2 points[i 1]; const p3 points[Math.min(points.length - 1, i 2)]; const distance Math.hypot(p2.x - p1.x, p2.y - p1.y); const steps Math.max(1, Math.ceil(distance / spacing)); for (let t 0; t steps; t) { const s t / steps; result.push(catmullRom(p0, p1, p2, p3, s)); } } return result; }brush-renderer.ts负责把插值后的点画到画布上。这里的关键是用离屏 canvas 做中间缓冲。为什么不直接画到主画布因为主画布的混合模式是全局的如果直接画笔刷印记之间的重叠部分会重复应用混合导致颜色变深。我的做法是先把所有印记画到一个透明的离屏 canvas 上再把这个离屏 canvas 以正确的混合模式画到主画布上。function renderStroke(ctx: CanvasRenderingContext2D, points: Point[], brush: BrushState) { const offscreen document.createElement(canvas); offscreen.width ctx.canvas.width; offscreen.height ctx.canvas.height; const offCtx offscreen.getContext(2d)!; for (const point of points) { const texture getBrushTexture(brush.textureId); offCtx.globalAlpha brush.flow; offCtx.drawImage(texture, point.x - brush.size / 2, point.y - brush.size / 2, brush.size, brush.size); } ctx.globalAlpha brush.opacity; ctx.drawImage(offscreen, 0, 0); }提示离屏 canvas 的创建开销不小不要每次绘制都新建。我的做法是维护一个离屏 canvas 池用完之后清空内容放回池里下次直接取。这样能减少 80% 的 GC 压力。3.3 图层混合模式的性能优化实录图层混合是 artcraft 里性能最敏感的部分。我做过一个测试10 个图层每个图层 2000x2000 像素全部用“叠加”混合模式在 Chrome 上渲染一帧需要 120ms完全没法交互。优化分三步走。第一步把不变化的图层缓存成位图。如果一个图层的像素数据和混合模式都没变就没必要每帧重新计算混合。我用一个dirty标记来跟踪只有 dirty 的图层才参与混合计算。第二步用 WebGL 做混合计算。Canvas 2D 的混合是 CPU 做的WebGL 是 GPU 做的性能差距在 10 倍以上。我把混合逻辑写成着色器// 叠加混合的片段着色器 precision mediump float; uniform sampler2D uBase; uniform sampler2D uBlend; uniform float uOpacity; varying vec2 vTexCoord; void main() { vec4 base texture2D(uBase, vTexCoord); vec4 blend texture2D(uBlend, vTexCoord); vec3 result; if (base.r 0.5) { result 2.0 * base.rgb * blend.rgb; } else { result 1.0 - 2.0 * (1.0 - base.rgb) * (1.0 - blend.rgb); } gl_FragColor vec4(mix(base.rgb, result, uOpacity), base.a); }第三步分块渲染。即使有了 WebGL一次性渲染整个画布还是会有峰值开销。我把画布切成 512x512 的块每帧只渲染视口内的块。这样即使画布很大渲染开销也只和视口大小相关和画布总大小无关。这三步做完之后同样的测试场景渲染一帧降到了 8ms稳稳跑在 60fps。3.4 导出功能的完整实现与参数调优导出功能的代码在exporter.ts里核心逻辑是分块编码 流式合并。async function exportCanvas(canvas: HTMLCanvasElement, format: string, quality: number): PromiseBlob { const chunkSize 1024; const chunks: Blob[] []; for (let y 0; y canvas.height; y chunkSize) { for (let x 0; x canvas.width; x chunkSize) { const w Math.min(chunkSize, canvas.width - x); const h Math.min(chunkSize, canvas.height - y); const offscreen document.createElement(canvas); offscreen.width w; offscreen.height h; const ctx offscreen.getContext(2d)!; ctx.drawImage(canvas, x, y, w, h, 0, 0, w, h); const blob await new PromiseBlob((resolve) { offscreen.toBlob((b) resolve(b!), format, quality); }); chunks.push(blob); } } return new Blob(chunks, { type: format }); }参数调优方面我做了几组对比测试。PNG 导出 4000x4000 的图不量化颜色时体积约 12MB量化到 256 色后约 4.5MB量化到 64 色后约 2.8MB。JPEG 质量 0.85 时约 3.2MB质量 0.75 时约 2.1MB。WebP 质量 0.85 时约 2.4MB。我的建议是线条图用 PNG 256 色量化照片图用 WebP 质量 0.85兼容性优先用 JPEG 质量 0.85。这个组合在体积和画质之间取得了比较好的平衡。注意分块导出时块与块之间的接缝处可能会出现细微的色差尤其是 JPEG 这种有损格式。解决办法是让相邻块有 16 像素的重叠区域编码后裁掉重叠部分。这样接缝就看不出来了。4. 常见问题与排查技巧实录4.1 笔刷绘制断线或卡顿的排查思路这是被问得最多的问题。断线通常有三个原因采样频率不够、插值算法有问题、渲染阻塞。排查顺序是这样的先看pointermove事件的触发频率。在控制台里打一行window.addEventListener(pointermove, () console.log(performance.now()))看看两次事件之间的间隔。如果间隔超过 32ms也就是低于 30fps说明事件触发本身就不够密需要加插值。如果事件频率正常但还是断线检查插值算法的steps计算。我见过有人把steps写死成 1那肯定断线。正确的做法是根据两点距离动态计算steps。如果前两项都正常那就是渲染阻塞了。打开 Chrome 的 Performance 面板录一段绘制过程看看有没有长任务。常见的长任务是纹理采样和离屏 canvas 创建。纹理采样用 mipmap 优化离屏 canvas 用对象池优化。现象可能原因排查方法解决方案快速移动时断线采样频率不够打印 pointermove 时间戳加样条插值慢速移动时断线插值 steps 计算错误检查 steps 公式按距离动态计算绘制时整体卡顿渲染阻塞主线程Performance 面板录帧离屏 canvas 池化线条边缘锯齿纹理采样精度不够检查 mipmap 层级降低一级 mipmap4.2 图层混合结果与预期不符的调试方法混合模式的结果不对通常是因为颜色空间的问题。Canvas 2D 默认用的是 sRGB 颜色空间但混合计算如果在 sRGB 空间做结果会和 Photoshop 等专业软件不一致。Photoshop 是在线性空间做混合的。解决办法是在混合前把颜色从 sRGB 转到线性空间混合完再转回 sRGB。转换公式不复杂function sRGBToLinear(c: number): number { return c 0.04045 ? c / 12.92 : Math.pow((c 0.055) / 1.055, 2.4); } function linearToSRGB(c: number): number { return c 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; }这个转换会增加一些计算量但对于追求准确混合结果的场景是值得的。如果性能敏感可以只在导出时做转换编辑时用近似算法。另一个常见问题是透明度叠加。如果两个图层都是 50% 透明度叠加后的透明度不是 50%而是 75%。这是正常的 alpha 合成公式alpha a1 a2 * (1 - a1)。很多人会误以为是 50%然后在代码里写错。4.3 导出图片体积过大的压缩技巧导出体积大无非两个原因分辨率太高、压缩参数太保守。分辨率方面我建议按用途定分辨率。网页展示用 1920 宽就够了打印用 300dpi 对应实际尺寸。不要无脑导出 8000x8000那是浪费。压缩参数方面前面已经说了 PNG 量化、JPEG 质量 0.85、WebP 质量 0.85。这里补充一个技巧导出前先做一次锐化。锐化能让压缩后的图片看起来更清晰这样你就可以用更低的质量参数体积更小但观感不差。锐化的强度建议在 0.5 到 1.0 之间太高会出现光晕。还有一个容易被忽略的点元数据。PNG 和 JPEG 都可以嵌入元数据比如创建时间、软件版本这些数据会增大体积。导出时可以用canvas.toBlob()的第三个参数控制或者用专门的库如piexifjs清除元数据。清除元数据通常能减少 5% 到 10% 的体积。4.4 跨浏览器兼容性问题的速查表artcraft 在 Chrome、Firefox、Safari、Edge 上都测试过兼容性问题主要集中在三个地方Canvas 混合模式、WebP 编码、指针事件。问题ChromeFirefoxSafariEdge解决方案globalCompositeOperation的overlay支持支持14 支持支持低版本用着色器回退WebP 编码支持支持14 支持支持检测后回退 JPEGpointermove的pressure支持支持不支持支持用速度模拟压力OffscreenCanvas支持支持16.4 支持支持低版本用普通 canvascanvas.toBlob()的quality参数支持支持支持支持无需处理提示Safari 的 Canvas 实现有一些“特色”行为比如drawImage在缩放时默认不做平滑处理需要手动设置imageSmoothingEnabled true。这个坑我踩过画出来的线条全是锯齿找了半天才发现是 Safari 的默认值不一样。4.5 性能优化的独家避坑经验最后分享几个我在 artcraft 开发过程中总结的性能优化经验都是文档里不会写的。第一个不要在主线程做像素级操作。getImageData和putImageData是同步的处理大图时会阻塞主线程几百毫秒。我的做法是把像素操作放到 Web Worker 里用comlink做通信。这样主线程始终流畅用户感知不到卡顿。第二个requestAnimationFrame 里不要做重计算。rAF 的回调应该只做渲染计算逻辑放到 rAF 之前或者用requestIdleCallback。我见过有人在 rAF 里做路径规划结果帧率直接掉到 20fps。第三个缓存一切可以缓存的东西。笔刷纹理、字体渲染结果、混合后的图层位图这些都应该缓存。缓存的 key 要设计好比如笔刷纹理的 key 是textureId size angle这样不同参数组合能命中不同的缓存。第四个用willReadFrequently提示浏览器。如果你频繁调用getImageData创建 canvas context 时加上{ willReadFrequently: true }Chrome 会优化内存布局读取速度能快 3 到 5 倍。const ctx canvas.getContext(2d, { willReadFrequently: true });这个参数是 Chrome 特有的其他浏览器会忽略所以加上没有副作用。第五个监控内存使用。artcraft 长时间运行后内存会缓慢增长主要是离屏 canvas 和纹理没有及时释放。我的做法是加一个内存监控当performance.memory.usedJSHeapSize超过阈值时主动清理缓存。这个 API 也是 Chrome 特有的但足够用来做开发期的监控。这些经验都是我在实际项目中一点点试出来的有些是踩了坑才明白有些是看了源码才理解。artcraft 这个项目还在持续迭代后面我打算把笔刷引擎单独抽出来做成一个独立的库方便其他项目集成。如果你也在做类似的创作工具希望这些内容能帮你少走一些弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑