从零搭建创意工具artcraft:Electron+Rust+WebGL全复盘
1. 从零搭建一个叫 artcraft 的创意工具我的完整复盘第一次看到 artcraft 这个词脑子里蹦出来的画面很直接——art 是艺术craft 是手艺合在一起就是“把艺术当手艺来做”。这个项目标题本身就带着一种气质不追求花哨的概念而是强调动手、强调打磨、强调从粗糙到精致的那个过程。我拿到这个标题的时候第一反应不是去查它到底是个什么现成产品而是想如果让我从零做一个叫 artcraft 的东西它应该长什么样我的判断是artcraft 最合理的定位是一个面向创意工作者的轻量级工具集核心解决的是“脑子里有想法但手上没有趁手工具快速把它变成可看、可改、可交付的东西”这个痛点。它可能是一个本地运行的创意辅助应用也可能是一套可复用的组件库甚至是一个把多种创意处理能力打包在一起的桌面工具。不管具体形态如何它的核心价值在于降低从灵感到成品的摩擦系数。这篇文章适合谁看如果你是一个独立开发者想做一个自己用得顺手的创意工具如果你是一个设计师或内容创作者想理解这类工具背后的设计逻辑如果你是一个刚入门的技术爱好者想找一个完整的项目练手——那这篇复盘对你都有参考价值。我会把整个项目的设计思路、技术选型、实操步骤、踩过的坑全部摊开来讲不藏私也不说废话。2. 项目整体设计与思路拆解2.1 为什么选择“工具集”而不是“单一功能应用”做 artcraft 这个项目第一个要做的决策就是它到底是一个大而全的单一应用还是一个由多个小工具组成的集合我试过两种路线最后选了后者。单一功能应用的问题是你很容易陷入“为了做一个功能而做一个功能”的陷阱。比如你决定做一个图片滤镜工具那你就得把全部精力押在滤镜算法上一旦用户觉得“这个滤镜不够多”或者“我还想加点文字”你就得不断往里面塞新功能最后变成一个臃肿的怪物。而工具集的思路是反过来的我先定义一组核心的创意处理能力每个能力做成一个独立的、可组合的模块用户需要什么就调用什么。这个选择背后的逻辑其实很简单创意工作本身就是非线性的。一个人可能先想调个色然后觉得需要加个边框接着又想生成一段纹理叠上去。如果每个步骤都要切换不同的软件创作流就被打断了。artcraft 要做的就是把这些高频的、轻量的创意操作收拢到一个统一的入口里但内部保持模块化。具体来说我把 artcraft 的核心能力拆成了四个模块图像处理、文本排版、纹理生成、导出与分享。每个模块独立开发、独立测试最后通过一个统一的主界面串联起来。这样做的好处是我可以在任何一个模块上快速迭代不会牵一发而动全身。2.2 技术选型为什么是这套组合技术选型这块我纠结了很久。最开始想用纯 Web 技术栈因为跨平台方便浏览器打开就能用。但实测下来发现图像处理这种计算密集型的操作在浏览器里跑性能瓶颈很明显尤其是处理大尺寸图片的时候页面直接卡死。后来我转向了Electron 原生模块的方案。Electron 负责界面和跨平台打包核心的图像处理逻辑用 Rust 写成原生模块通过 Node.js 的 N-API 调用。这个组合的好处是界面开发效率高用 HTML/CSS/JS 就能搞定计算性能有保障Rust 处理像素级操作比纯 JS 快一个数量级。提示如果你也想用 Rust 写原生模块给 Electron 用建议直接用 napi-rs 这个框架它把 N-API 的绑定封装得很干净比手写 C 绑定省心太多。纹理生成模块我用的是WebGL因为纹理生成本质上是着色器运算GPU 并行处理比 CPU 快得多。而且 WebGL 在 Electron 里可以直接用不需要额外的原生依赖。文本排版模块则用了Canvas 2D API因为文本渲染对精度要求高Canvas 的文本 API 足够成熟配合自定义的字体加载逻辑能满足大部分排版需求。数据存储方面我选了SQLite作为本地数据库存用户的项目文件、素材库、操作历史。为什么不用 JSON 文件因为当项目数量多起来之后JSON 的读写效率太低而且没有事务支持容易写坏文件。SQLite 单文件、零配置、支持事务对于本地应用来说是最稳妥的选择。2.3 架构设计分层与解耦整个 artcraft 的架构我分成了三层界面层、逻辑层、数据层。界面层就是 Electron 的渲染进程负责所有用户能看到、能点击的东西。这一层我尽量保持“薄”不写复杂的业务逻辑只做展示和事件转发。逻辑层跑在 Electron 的主进程和原生模块里负责实际的图像处理、纹理生成、文件读写。数据层就是 SQLite 和文件系统负责持久化。三层之间通过明确的接口通信。界面层调用逻辑层用 IPC进程间通信逻辑层调用数据层用封装好的 DAO数据访问对象。这样做的好处是每一层都可以独立测试和替换。比如我后来想把纹理生成从 WebGL 换成 WebGPU只需要改逻辑层的一个模块界面层完全不用动。还有一个重要的设计决策是操作历史的实现。创意工具的用户经常需要“撤销”和“重做”这个功能如果做得不好用户体验会非常糟糕。我的做法是每次用户执行一个操作就把这个操作和它的参数记录到 SQLite 里形成一个操作链。撤销就是回退到上一个状态重做就是前进到下一个状态。为了节省存储空间我只存操作的参数不存完整的图像快照需要恢复的时候重新执行一遍操作链即可。3. 核心细节解析与实操要点3.1 图像处理模块像素操作的性能优化图像处理是 artcraft 最核心的模块也是最吃性能的部分。我在这里踩的坑最多收获也最大。第一个关键点是像素数据的布局。在 Rust 里处理图像我用的是imagecrate 来解码和编码但实际的像素操作我不用它的 API而是把像素数据转成一个扁平的Vecu8然后自己写循环。为什么因为imagecrate 的 API 虽然方便但每次调用都有函数开销对于需要遍历每个像素的操作来说这个开销累积起来很可观。直接操作扁平数组编译器能更好地做向量化优化。第二个关键点是并行处理。Rust 的rayoncrate 让并行迭代变得非常简单。比如做一个亮度调整我可以把图像按行切分每一行交给一个线程去处理。实测下来在 8 核机器上处理一张 4000x3000 的图片串行需要 800ms 左右并行只需要 120ms。这个提升是肉眼可见的。use rayon::prelude::*; fn adjust_brightness(pixels: mut [u8], factor: f32) { pixels.par_chunks_mut(4).for_each(|pixel| { pixel[0] (pixel[0] as f32 * factor).min(255.0) as u8; pixel[1] (pixel[1] as f32 * factor).min(255.0) as u8; pixel[2] (pixel[2] as f32 * factor).min(255.0) as u8; }); }第三个关键点是内存管理。图像处理很吃内存一张 4000x3000 的 RGBA 图片原始数据就是 48MB。如果同时打开多个项目内存很容易爆掉。我的做法是只在内存里保留当前正在编辑的图像其他项目的图像数据存在磁盘上需要的时候再加载。加载的时候用内存映射文件mmap这样操作系统可以帮你管理内存分页比直接读文件快很多。注意用 mmap 的时候要小心文件被外部修改的情况。我的做法是在打开项目的时候计算一个校验和每次保存的时候对比一下如果文件被外部改过就提示用户重新加载。3.2 纹理生成模块着色器入门与实战纹理生成是 artcraft 里最有“艺术感”的部分。用户可以通过调整参数生成各种风格的纹理比如噪点、条纹、渐变、大理石纹等等。这些纹理可以叠加到图像上也可以单独导出作为素材。实现纹理生成的核心是片段着色器Fragment Shader。简单来说着色器就是一段跑在 GPU 上的程序它接收每个像素的坐标作为输入输出这个像素的颜色。你可以在着色器里写数学公式用坐标算出颜色从而生成各种图案。比如生成一个简单的噪点纹理着色器大概长这样precision highp float; varying vec2 vUv; uniform float uScale; float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123); } void main() { vec2 st vUv * uScale; float r random(st); gl_FragColor vec4(vec3(r), 1.0); }这段代码的逻辑是把像素坐标乘以一个缩放系数然后用一个伪随机函数算出 0 到 1 之间的值作为灰度值输出。调整uScale就能控制噪点的密度。但光有噪点还不够用户想要的是“好看的”纹理。所以我在此基础上加了几层处理噪声叠加、颜色映射、边缘柔化。噪声叠加是把多个不同频率的噪点按权重加起来模拟自然纹理的层次感。颜色映射是把灰度值映射到一个颜色渐变上让纹理有色彩。边缘柔化是用高斯模糊对纹理做轻微模糊避免噪点太锐利。实操心得着色器调试很痛苦因为你看不到中间变量。我的做法是把中间结果直接输出成颜色比如把某个变量的值映射到红色通道这样就能直观地看到它的分布。调试完再改回正常输出。3.3 文本排版模块字体加载与布局计算文本排版看起来简单实际上坑很多。第一个坑是字体加载。Electron 默认只能用系统字体但创意工具的用户往往需要特定的字体。我的解决方案是允许用户导入字体文件TTF/OTF然后用FontFaceAPI 动态加载。const fontFace new FontFace(CustomFont, url(${fontPath})); await fontFace.load(); document.fonts.add(fontFace);第二个坑是布局计算。Canvas 2D 的fillText只能画单行文本多行文本需要自己算换行。我的做法是先把文本按空格和标点拆成单词然后逐个测量宽度超过容器宽度就换行。这个逻辑听起来简单但处理中英文混排、标点避头尾、行高调整的时候细节非常多。第三个坑是文本渲染质量。Canvas 默认的文本渲染在某些字体上会有锯齿尤其是小字号的时候。我的优化方案是把文本先渲染到一个离屏 Canvas 上尺寸放大两倍然后用drawImage缩小绘制到主 Canvas 上。这样相当于做了超采样抗锯齿文字边缘会平滑很多。3.4 导出与分享模块格式选择与质量控制导出功能是用户完成创作后的最后一步也是最容易出问题的一步。我支持了三种导出格式PNG、JPEG、WebP。PNG 是无损格式适合需要透明背景或者对画质要求极高的场景。但 PNG 的文件体积很大一张 4000x3000 的图片可能超过 20MB。JPEG 是有损格式体积小但不支持透明而且压缩率调太高会有明显的块状伪影。WebP 是折中方案支持透明压缩效率比 PNG 高很多但兼容性不如前两者。我的做法是在导出界面让用户自己选格式和质量。对于 JPEG 和 WebP提供一个质量滑块范围 1 到 100。默认值我设的是 85这个值在画质和体积之间平衡得比较好。实测下来一张 4000x3000 的图片PNG 导出约 18MBJPEG 质量 85 约 2.5MBWebP 质量 85 约 1.8MB。提示导出大图的时候不要在主线程做编码否则界面会卡死。我的做法是把编码任务放到 Worker 线程或者原生模块里主线程只负责显示进度条。4. 实操过程与核心环节实现4.1 环境搭建与项目初始化第一步是搭环境。我用的工具链是Node.js 20 LTS Rust 1.75 Electron 28。为什么选这几个版本Node.js 20 是当前的 LTS 版本稳定性有保障。Rust 1.75 对 napi-rs 的支持比较完善。Electron 28 是当时的最新稳定版对 WebGPU 的支持已经可用了。初始化项目的命令如下mkdir artcraft cd artcraft npm init -y npm install electron --save-dev npm install napi-rs --save-dev cargo init --lib native然后配置Cargo.toml加上 napi 的依赖[package] name artcraft-native version 0.1.0 edition 2021 [lib] crate-type [cdylib] [dependencies] napi { version 2, features [napi4] } napi-derive 2 image 0.24 rayon 1.8接着在src/lib.rs里写第一个原生函数use napi_derive::napi; #[napi] pub fn add(a: i32, b: i32) - i32 { a b }编译命令是napi build --release生成的.node文件会被 Electron 直接加载。这一步跑通之后后面的开发就顺了。4.2 图像处理流水线的实现图像处理流水线的核心是操作链的设计。每个操作是一个独立的函数接收图像数据返回处理后的图像数据。多个操作串联起来就形成了一条流水线。我定义了一个Operationtraitpub trait Operation { fn apply(self, image: mut ImageData) - Result(), String; fn name(self) - str; fn params(self) - serde_json::Value; }然后实现了几个具体的操作BrightnessAdjust、ContrastAdjust、GaussianBlur、Sharpen、Crop、Resize。每个操作都实现了这个 trait可以独立调用也可以组合调用。流水线的执行逻辑是从数据库里读出操作链按顺序执行每个操作最后输出结果。为了支持撤销我在执行每个操作之前先把当前状态存到一个临时缓冲区里。撤销的时候直接从缓冲区恢复。注意操作链的执行顺序很重要。比如先裁剪再模糊和先模糊再裁剪结果是不一样的。我的做法是在界面上让用户看到操作链的顺序并且允许拖拽调整。4.3 纹理生成的着色器实现细节纹理生成模块的入口是一个 WebGL 画布。用户选择纹理类型调整参数画布实时更新。参数通过 uniform 变量传给着色器。以大理石纹理为例着色器的核心逻辑是precision highp float; varying vec2 vUv; uniform float uTime; uniform float uScale; uniform float uTurbulence; float noise(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } float fbm(vec2 p) { float sum 0.0; float amp 0.5; for (int i 0; i 5; i) { sum amp * noise(p); p * 2.0; amp * 0.5; } return sum; } void main() { vec2 st vUv * uScale; float n fbm(st uTurbulence * fbm(st)); float marble sin(st.x * 10.0 n * 5.0) * 0.5 0.5; vec3 color mix(vec3(0.9, 0.9, 0.85), vec3(0.2, 0.2, 0.25), marble); gl_FragColor vec4(color, 1.0); }这段代码的关键是fbm函数也就是分形布朗运动。它把多个不同频率的噪声叠加起来模拟自然纹理的细节层次。uTurbulence参数控制湍流强度值越大纹理越扭曲。实操心得着色器的性能瓶颈通常在循环次数上。fbm里的循环次数从 5 降到 3帧率能从 30fps 提到 60fps但纹理细节会明显减少。我的做法是在预览的时候用 3 次循环导出的时候用 5 次循环兼顾流畅度和质量。4.4 文本排版的实际操作流程文本排版的操作流程分四步输入文本、选择字体、调整样式、定位与导出。输入文本的时候我支持多行输入用户可以在文本框里直接换行。选择字体的时候除了系统字体还可以导入自定义字体。调整样式包括字号、字重、颜色、行高、字间距、对齐方式。定位就是拖拽文本框到画布上的任意位置。排版计算的核心函数是layoutTextfunction layoutText(text, font, maxWidth, lineHeight) { const lines []; const paragraphs text.split(\n); for (const paragraph of paragraphs) { const words paragraph.split(/(\s)/); let currentLine ; for (const word of words) { const testLine currentLine word; const metrics ctx.measureText(testLine); if (metrics.width maxWidth currentLine ! ) { lines.push(currentLine); currentLine word; } else { currentLine testLine; } } if (currentLine ! ) { lines.push(currentLine); } } return lines; }这个函数的逻辑是先按换行符拆段落再按空格拆单词逐个累加测量宽度超过最大宽度就换行。对于中文这种没有空格分隔的语言需要额外处理——我的做法是如果检测到连续的中文字符就按字符逐个测量。4.5 导出流程的完整实现导出流程分三步渲染最终图像、编码为目标格式、写入文件。渲染最终图像的时候我需要把图像层、纹理层、文本层按顺序合成到一张画布上。合成的时候要注意图层的混合模式比如纹理层可能是“叠加”模式文本层可能是“正常”模式。编码这一步PNG 和 JPEG 我用的是 Rust 的imagecrateWebP 用的是webpcrate。编码参数通过 napi 从 JS 传给 Rust。#[napi] pub fn encode_image( data: Buffer, width: u32, height: u32, format: String, quality: u8, ) - ResultBuffer, String { let img image::RgbaImage::from_raw(width, height, data.to_vec()) .ok_or(Invalid image data)?; let mut output Vec::new(); match format.as_str() { png { img.write_to(mut Cursor::new(mut output), image::ImageFormat::Png) .map_err(|e| e.to_string())?; } jpeg { let mut encoder image::codecs::jpeg::JpegEncoder::new_with_quality( mut Cursor::new(mut output), quality ); encoder.encode_image(img).map_err(|e| e.to_string())?; } webp { let encoder webp::Encoder::from_rgba(data, width, height); let encoded encoder.encode(quality as f32); output encoded.to_vec(); } _ return Err(Unsupported format.to_string()), } Ok(output.into()) }写入文件的时候我用了 Electron 的dialog.showSaveDialog让用户选择保存路径然后用fs.writeFile写入。写入大文件的时候用流式写入比一次性写入更稳妥避免内存峰值过高。5. 常见问题与排查技巧实录5.1 原生模块加载失败这是最常见的问题尤其是在跨平台打包的时候。症状是 Electron 启动时报错Cannot find module artcraft-native或者Module version mismatch。原因通常有两个一是.node文件没有被打包进去二是 Node.js 的 ABI 版本和编译时用的版本不一致。排查方法是先检查node_modules里有没有.node文件然后用node -p process.versions.modules查看当前 ABI 版本对比编译时的版本。解决方案是在package.json里配置build字段确保.node文件被包含在打包结果里。同时用electron-rebuild重新编译原生模块确保 ABI 版本匹配。npx electron-rebuild -f -w artcraft-native5.2 图像处理内存溢出处理大尺寸图片的时候如果同时打开多个项目内存很容易爆掉。症状是应用突然崩溃或者系统变得极其卡顿。排查方法是用process.memoryUsage()监控内存使用情况找到内存增长最快的操作。通常罪魁祸首是图像数据的多次拷贝。比如从 Rust 返回图像数据到 JS 的时候如果用了Buffer.from做拷贝就会多一份内存。解决方案是尽量用Buffer的零拷贝方式传递数据。napi-rs 支持直接返回Buffer底层用的是同一块内存不需要拷贝。另外及时释放不再使用的图像数据不要等到 GC 自动回收。5.3 着色器编译错误WebGL 着色器编译错误的信息通常很模糊只告诉你哪一行有问题但不告诉你具体是什么问题。排查方法是在编译着色器的时候把gl.getShaderInfoLog的输出打印出来仔细看错误信息。常见的错误包括变量未声明、类型不匹配、精度限定符缺失。比如在片段着色器里浮点数默认精度是mediump如果你需要高精度必须显式声明precision highp float;。提示不同 GPU 对精度的支持不一样。有些移动端 GPU 不支持highp所以如果你的应用要跑在移动端最好用mediump或者做精度检测。5.4 文本渲染模糊文本渲染模糊通常是因为 Canvas 的分辨率和显示分辨率不匹配。比如 Canvas 的尺寸是 800x600但 CSS 显示尺寸是 400x300浏览器就会把 Canvas 放大两倍导致模糊。解决方案是把 Canvas 的width和height属性设置为显示尺寸的两倍然后用 CSS 把显示尺寸设回正常值。这样浏览器在渲染的时候会做降采样文字边缘会清晰很多。const dpr window.devicePixelRatio || 1; canvas.width displayWidth * dpr; canvas.height displayHeight * dpr; canvas.style.width displayWidth px; canvas.style.height displayHeight px; ctx.scale(dpr, dpr);5.5 常见问题速查表问题现象可能原因排查方法解决方案原生模块加载失败ABI 版本不匹配检查process.versions.modules用electron-rebuild重新编译图像处理内存溢出数据多次拷贝监控process.memoryUsage()用零拷贝方式传递 Buffer着色器编译错误精度限定符缺失打印getShaderInfoLog显式声明precision highp float文本渲染模糊Canvas 分辨率不匹配检查 Canvas 尺寸和 CSS 尺寸按 devicePixelRatio 缩放导出文件过大格式或质量参数不当对比不同格式的导出体积调整质量参数或换格式操作撤销失效操作链记录不完整检查数据库中的操作记录确保每个操作都写入操作链6. 项目扩展与个人经验分享6.1 后续可以怎么扩展artcraft 目前是一个本地工具但它的架构留了很多扩展口。第一个扩展方向是插件系统。因为核心能力已经模块化了只要定义一个插件接口第三方开发者就可以写自己的图像处理算法或者纹理生成器打包成插件加载进来。第二个扩展方向是云端同步。用户的项目文件、素材库、自定义字体都可以同步到云端换设备的时候不用手动拷贝。这个功能需要设计一套同步协议处理冲突合并的问题。第三个扩展方向是协作编辑。多个用户可以同时编辑同一个项目实时看到对方的修改。这个功能的技术难度最高需要引入 CRDT无冲突复制数据类型或者 OT操作转换算法。6.2 我在这个项目里踩过的坑第一个坑是过早优化。项目初期我花了很多时间在 Rust 原生模块的性能调优上结果后来发现大部分用户处理的图片尺寸并不大纯 JS 的性能也够用。如果重来一次我会先用 JS 快速实现功能等真的遇到性能瓶颈再迁移到 Rust。第二个坑是忽视错误处理。图像处理涉及大量的文件读写、内存分配、GPU 操作每一步都可能出错。我一开始没有认真处理错误导致用户遇到问题时只看到“应用崩溃了”完全不知道发生了什么。后来我加了一套统一的错误处理机制每个操作都返回Result错误信息会显示在界面上用户体验好了很多。第三个坑是低估了字体处理的复杂度。我以为加载字体就是FontFace一行代码的事结果发现不同字体的度量信息不一样行高、基线、字间距都需要单独调整。而且有些字体文件损坏或者格式不支持加载会失败。后来我加了一套字体校验和回退机制加载失败就自动切换到系统默认字体。6.3 给想复现这个项目的朋友几点建议如果你也想做一个类似 artcraft 的创意工具我的建议是先做减法再做加法。不要一上来就想把所有功能都做进去先选一个最核心的功能把它做到能用、好用然后再逐步扩展。我一开始就是贪多结果每个模块都做了一半没有一个能拿得出手。另外重视用户反馈。我早期版本只给自己用觉得挺顺手的。后来给几个朋友试用发现他们完全不知道怎么操作。界面上的按钮他们看不懂操作流程他们觉得绕。后来我重新设计了界面加了引导提示情况才好转。最后保持代码的可测试性。图像处理的结果很难用肉眼判断对错所以自动化测试非常重要。我给每个操作都写了单元测试用固定的输入图片对比输出图片的像素值。这样每次改代码跑一遍测试就知道有没有改坏东西。这个项目我断断续续做了大半年从最初的 idea 到能用的版本中间推翻重来了好几次。但每次重来我都对“创意工具应该怎么设计”有了更深的理解。artcraft 这个名字说到底就是一种态度把艺术创作当成一门手艺来打磨不急不躁一步一步来。