如果你需要一个不撞车的视觉素材库与其去素材网站碰运气不如自己写一个程序化生成工具。artcraft是我最近在折腾的一个小项目名字拆开就是“艺术”加“手艺”——用可配置的代码工艺批量产出有设计感的矢量与位图素材。它目前包含分形树、粒子流动画、对称万花筒、渐变纹理四类生成器覆盖了我在实际工作中遇到的多数配图需求。本文不打算讲太多抽象概念直接基于artcraft的源码结构把从零搭建一个生成工具的完整过程拆开选型、核心模块、踩坑、导出细节、扩展方向。不管你是前端开发者想入门创意编程还是设计师想给自己做点趁手的小插件这里面都有可抄的作业。1. 项目定位与设计思路1.1 做一个生成工具而不是又一套“滤镜”先明确一点滤镜是对已有图像的固定处理生成器是从参数到图像的直接映射。用artcraft生成一张图只需要一组JSON参数这让“素材”变成“可复现的配方”。在设计初期我认真对比过六边形蜂窝纹理、渐变网格、故障风等常见特效最终决定先做四类基础生成器。原因是这几类在视觉上差异足够大实现复杂度从简单到进阶适合作为工具包的骨架模块。同时生成器的接口可以复用后续加新类型只是往注册表里添一个函数。可能有人会问Photoshop加滤镜不香吗确实香但滤镜处理的素材一旦变大、变多手动调参的时间会成倍上涨。前一段时间我需要给一份演示文稿配12张风格统一的底纹如果靠截图或者下载素材光是统一色调就要折腾一下午。用artcraft这类工具预置一套配色、换几个种子值12张图几分钟就能出完而且相互之间不会出现两两相同的构图。1.2 技术选型Canvas 2D是当时最不后悔的选项选型阶段我列过一个对比把Canvas 2D、WebGL、SVG、直接操作DOM这几条路都过了一遍方案上手门槛2D绘制效率导出成本适合场景Canvas 2D低中高低程序化图案、交互式生成器WebGL高极高中高大规模粒子、3D效果、着色器艺术SVG低低低简单矢量图形、可交互矢量素材原生DOM/CSS最低低中轻量几何拼接、纯排版类图形最终选择Canvas 2D理由有三层。第一2D程序化图案的绘制压力远没有到非WebGL不可的程度一屏内几千个图形元素Canvas 2D配合离屏缓存完全能跑到60帧。第二导出PNG直接调用canvas.toBlob不涉及GPU异步读取少踩很多坑——WebGL方案如果需要稳定截帧得在创建上下文时设置preserveDrawingBuffer否则经常导出空白图。第三Canvas 2D所有浏览器行为一致调试成本低对新手也友好。为什么不选SVG程序化生成大量路径时DOM节点会爆炸几万条路径会让页面明显变慢。但我保留了SVG导出的扩展点这个后面会讲到。1.3 代码结构生成器注册表与统一接口artcraft的目录结构非常克制核心代码就几个文件src/ core/ canvas.js // 画布初始化与尺寸适配 renderer.js // 主渲染循环 random.js // 种子随机数实现 palette.js // 调色板解析与插值 generators/ index.js // 生成器注册表 fractalTree.js particleFlow.js mandala.js gradientTexture.js exporter.js // 高清导出模块这种结构的核心思想每种生成器只暴露一个统一函数generate(ctx, config, random)核心渲染器完全不知道某个生成器内部怎么画只负责调用和呈现结果。后续你加一个“噪点水墨”生成器不需要动渲染器任何代码。这就是“开闭原则”在小型项目里的落地对扩展开放对修改关闭。2. 核心模块拆解2.1 种子随机数让每张图都有“身份证”程序化生成最让人上头的特性不是随机而是“可控的随机”。同一张图的全部随机行为都来自一个种子值种子相同、参数相同输出必然相同。这带来一个非常实用的场景生成了一张满意的图把种子和参数存成JSON以后随时可以复现甚至可以在社区里分享自己的“配方”。注意Math.random()是不能用的。原因有两点第一它不可复现没有任何种子参数第二不同浏览器的实现细节有差异同一套代码在不同浏览器里出的图可能完全不同。我在artcraft里使用了经典的 mulberry32 算法代码很短但分布质量足够应付视觉生成需求function mulberry32(seed) { let a seed 0; return function() { a 0x6D2B79F5; let t a; t Math.imul(t ^ t 15, t | 1); t ^ t Math.imul(t ^ t 7, t | 61); return ((t ^ t 14) 0) / 4294967296; } }这个算法生成的序列周期很长2的32次方级别、实现简单、无外部依赖。初始化时传入种子后续所有随机取值都用这个函数不要混用Math.random否则复现性就废了。提示调试代码时可以把种子固定成一个值比如12345保证同一个bug可以反复稳定复现等逻辑稳定了再换成随机种子。这里还有一个工程细节最好把随机数生成器实例放进config里传给生成器而不是用一个全局变量避免多个生成器之间互相污染随机序列。artcraft里renderer在每次绘制前new一个随机实例并把它传给generate函数。2.2 调色板决定作品质感的第一要素程序化生成里图形算法只决定构图真正决定“好不好看”的往往是配色。artcraft内置了十二套预置调色板覆盖了霓虹、莫兰迪、低饱和复古、黑白极简等常见风格。每套调色板本质上是一个纯字符串数组比如霓虹风格const palettes { neon: [#0ff, #f0f, #ff0, #fff, #000], morandi: [#a8a29e, #c0a9a1, #85756e, #d6cec2, #ede7dc], ... };实际绘制时我不会直接取某个颜色而是定义一个颜色采样函数palette.sample(random())让生成器在调色板里随机挑一个色如果需要渐变过渡则用线性插值lerp在两个颜色之间连续取值。这会带来更丰富的画面层次。实现颜色插值要小心很多人喜欢先用parseInt把十六进制转成RGB再分别插值这样没问题但要注意通道值要转成浮点数再算不然中间色会偏。也可以用HSL插值色彩过渡会更自然但色相跨越较大时容易产生脏灰色需要谨慎处理。artcraft里的默认做法是RGB线性插值简单可控适合大多数场景。2.3 坐标对称系统一个万花筒核心对称性是把简单图形变成复杂图案的最廉价手段。artcraft里的mandala生成器用了一个对称系统绘制一个扇区然后通过旋转变换复制N份每份再可选地做镜像翻转就能形成万花筒效果。核心代码局部function drawSector(ctx, config, rnd) { // 在0到1/对称数的角度范围内画弧线和几何元素 } function renderMandala(ctx, config, rnd) { const n config.symmetry || 8; const baseAngle Math.PI * 2 / n; for (let i 0; i n; i) { ctx.save(); ctx.translate(cx, cy); ctx.rotate(i * baseAngle); if (config.mirror) ctx.scale(1, -1); drawSectorWithMirror(ctx, config, rnd); ctx.restore(); } }这里的关键点是ctx.save/restore。如果忘记恢复变换下一轮迭代会在错误的坐标系上继续叠加旋转画面会变成一团乱麻。另一个细节镜像翻转时我是在扇区内做局部镜像还是整体做scale两者效果不一样。artcraft选择在扇区内做局部镜像这样每个对称单元内部也会出现对称细节最终效果更繁复华丽。3. 实操从零搭建核心渲染流程3.1 画布初始化与高清屏适配Canvas的经典坑直接设置canvas.width 800、height 600在Retina屏幕上显示会发虚。因为物理像素和CSS像素不一致浏览器会把一张800×600的画布拉伸到更大的显示区域。正确做法是乘以devicePixelRatioDPRconst dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);之后所有绘制坐标仍然按照CSS像素来写底层的像素密度交给DPR处理。不加这行setTransform后续用鼠标交互时坐标会错位老项目里这个问题能排查半天。这个适配逻辑在artcraft里被拆到了core/canvas.js里所有生成器只需要关心CSS像素坐标不用重复处理DPR。3.2 主循环与分层渲染artcraft的界面结构很简单左侧参数面板右侧画布。用户拖动滑块或点击“随机化”后会触发一次重新渲染。我没有用固定帧率循环而是采用“脏标记”模式只有参数变化时才重绘静态图场景下CPU占用趋近零。只有在粒子流动画等动态生成器里才启动requestAnimationFrame循环。动态生成器与静态生成器在架构上是同一套接口只是动态生成器内部自己不画完而是回调一个update方法。这个设计让我后续加交互动画不用大改核心渲染器。分层渲染方面我会用两个离屏canvas一层画背景纹理一层画主体图形。因为背景纹理生成成本通常较高画一次后缓存下来主体图形参数变化时只重绘这一层能省下不少计算量。如果后续接火焰、雨滴等特效可以把特效也单独分层用globalCompositeOperation做叠加。3.3 生成器注册表与参数面板联动参数面板的每个控件滑块、开关、下拉框都对应config对象里的一个字段。变化时渲染器拿到新config重新执行generate。为了让新增生成器不必改参数面板代码我约定每个生成器额外导出一个configSchema描述有哪些参数、范围、默认值。fractalTree.configSchema { depth: { type: range, min: 6, max: 14, default: 9 }, angle: { type: range, min: 0, max: 180, default: 25 }, length: { type: range, min: 50, max: 400, default: 220 }, ... };参数面板根据configSchema自动生成UI。这个设计对后面扩展特别重要你想加一个“噪点山水”生成器只需要写generate和configSchema两个东西UI和导出逻辑全是现成的。我在做第三个生成器时深有体会整个接入过程不到半小时其中大部分时间花在调配色上。4. 三个典型生成器的实现细节4.1 分形树生成器递归与随机夹角的核心实现分形树是artcraft里实现成本最低、视觉收益最高的生成器。核心原理是把“画一根树枝”这个操作递归地应用到子树枝上。每一层树枝都按一个角度旋转长度按比例缩短。代码核心function drawBranch(ctx, x, y, length, angle, depth, config, rnd) { if (depth 0) return; const endX x Math.cos(angle) * length; const endY y Math.sin(angle) * length; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(endX, endY); ctx.stroke(); const branchAngle1 angle config.angle * (rnd() - 0.5) * 2; const branchAngle2 angle - config.angle * (rnd() - 0.5) * 2; const newLength length * config.growth; drawBranch(ctx, endX, endY, newLength, branchAngle1, depth - 1, config, rnd); drawBranch(ctx, endX, endY, newLength, branchAngle2, depth - 1, config, rnd); }这里有个容易忽略的视觉细节树干的宽度应该随深度递减否则越往下越像铁丝网。最简单的方式是lineWidth也乘一个递减系数或者根据depth设置lineWidth baseWidth * (depth / maxDepth)。我用后者树干和树枝有粗细变化后分形树立刻有了“插画感”。分形树容易踩坑的地方是递归深度过大导致性能雪崩。树的调用量是指数级增长深度10、每层分两叉最终画线超过1000条深度14就超过16000条普通机器已经开始掉帧。所以我把深度滑块的默认值设为9上限设为14并在文档里注明“深度超过12时建议使用离屏缓存导出不要实时预览”。4.2 粒子流动画噪声场让粒子“有想法”粒子流是artcraft里最强的动态效果。简单粒子系统如果只是让粒子沿固定方向运动画面会非常呆板。我采用流场flow field方案把画布划分成网格每个格点有一个从噪声函数计算出的角度粒子每帧读取自己所在格子的角度然后转向并前进。用一个简单的值噪声作为流场来源function noiseAngle(x, y, time) { const v Math.sin(x * 0.02 time * 0.1) Math.sin(y * 0.015 - time * 0.08) Math.sin((x y) * 0.01 time * 0.05); return v * Math.PI * 2; }把三个不同频率、不同相位的正弦波叠加在一起视觉上已经够接近Perlin噪声的平滑效果而且代码短很多。如果你想要更高质量的流场可以用一维柏林噪声公式套二维坐标效果会更好。粒子流动画最容易出问题的部分是性能。粒子数量到2000以上时逐帧调用ctx.beginPath、moveTo、lineTo、stroke会有明显开销。我的建议是两条路优先使用对象池预分配粒子数组而不是每帧new Particle开启离屏Canvas把上一帧的粒子位置绘制成“轨迹残影”避免每帧重绘全部历史路径。具体来说每一帧先在离屏canvas上用globalAlpha0.08的黑色矩形覆盖旧画面形成渐隐的拖尾效果再在上面画当前帧粒子。这样粒子数量虽然多绘制复杂度从O(N×历史帧)降到了O(N)4000个粒子也能稳定60帧。4.3 渐变纹理从值噪声到“布料质感”渐变纹理生成器主要服务海报底纹场景。它的原理是取多个不同频率的值噪声进行加权叠加再映射到调色板的多个颜色上。叠加次数越多纹理越细腻。实现思路生成一张低频噪声图作为基底生成几张频率翻倍的高频噪声图乘以对应权重后相加对最终的噪声值做归一化在调色板里的相邻颜色之间插值取色。这里最关键的参数是“频率”和“倍频数”。频率太低纹理看起来像几块大斑点频率太高则全是噪点缺少大块面。我实测下来基底频率在0.005到0.01之间、倍频数为4到6层时纹理既有绸缎感又有细节。还有一个容易遇到的问题直接给噪声值取整再映射到调色板会出现颜色断层banding。解决办法是在映射前加入一点微小的随机扰动dither比如给噪声值加上一个±0.02的随机偏移人眼就几乎看不出断层了。5. 常见问题与排查技巧5.1 画布导出模糊或空白怎么办导出一张图时最常见的做法是canvas.toBlob()。但有两个坑第一如果导出尺寸和屏幕显示尺寸不一致直接导出会得到一张分辨率不够的图第二如果canvas上有视频帧和WebGL内容或绘制异步未完成toBlob会拿到空白。我的高清导出方案创建一个离屏canvas尺寸为目标导出尺寸比如4096×4096然后按照目标尺寸重新执行一次generate。生成器在绘制时统一使用相对坐标0~1范围这样在预览、导出、不同屏幕尺寸下都能得到正确的比例。注意WebGL方案导出时如果遇到空白需要在创建上下文时传入 preserveDrawingBuffer: true。这个坑我帮某位同事排查过Canvas 2D不存在这个问题但如果你的工具未来要接WebGL渲染器提前留个心眼。5.2 参数调整后随机序列错乱这是我自己踩过的大坑给某个生成器加了新参数后发现“相同种子得不到相同结果”。排查下来原因是新参数在生成器内部引入了额外的random()调用导致随机数序列的消费顺序变了。也就是说种子相同但后续每个随机数对应的“位置”全变了。解决办法是“先记录后取值”在一个生成器内部把所有随机数需求先在开头一次性取到一个数组里后面绘图时只消费数组中的值。这样就算以后改逻辑只要随机数组的生成方式不变旧配方的复现性就能保持。这也是为什么我强烈建议配置里保存一个seed和一个schema版本号版本号变化时提醒用户“这张图的配方需要重新生成”。5.3 动效卡顿与后台耗电粒子流动画卡顿的排查路径一般是先看粒子数量再看是否每帧创建新对象最后看是否有不必要的阴影和过滤效果。Canvas 2D里阴影shadowBlur特别消耗性能一段绘制路径如果开了shadowBlur且半径很大帧率会直接掉到一半以下。我通常的做法是动态预览时关掉阴影导出时才开启高精度阴影。耗电的问题容易被忽略。即使页面不可见requestAnimationFrame仍在继续跑移动端设备会持续掉电。一个快速修复监听visibilitychange事件页面隐藏时暂停渲染循环显示时再恢复。这个小改动能让后台耗电降低到接近零。5.4 参数面板操作体验优化滑块拖动触发重绘时如果生成器计算量较大界面会一卡一卡。比较有效的优化方案有三个防抖停止拖动300毫秒后再重绘双缓冲当前渲染完成后才允许下一帧渲染进度提示超过一定耗时就在预览区显示“渲染中”。artcraft里我用了防抖加“是否在渲染中”标志位实测在深度12的分形树上拖动滑块预览也不会出现明显掉帧。如果你需要更极致的体验可以把预览分辨率临时降到一半松手后再全分辨率重绘这个方案我也在测试中。6. 应用场景与扩展方向6.1 实际落地案例我把artcraft生成的作品用在了几类真实场景中文章配图与封面生成几何纹理后加一行标题文字比网上找图更贴合主题文创周边预研把分形树图案导出高清PNG再丢进印刷排版软件里做杯子和帆布包的效果图动态背景粒子流生成器输出的画面用录屏工具录制后作为会议或直播间的动态背景。这些场景的共同点是都需要“低成本获得一批有风格一致性的素材”。传统做法是请设计师手绘或者购买图片素材库artcraft这种方案把成本压缩到了几乎零而且素材可复用、可再生成。6.2 与AI生图工作流的结合最近我把artcraft和AI生图工具结合起来用先用artcraft生成一批构图骨架比如黑白版式的分形轮廓再作为结构参考传给AI绘图工具由它补全材质与光影。好处是构图可控性大幅提升——AI不会在基础构图上跑偏你也不会陷入反复重抽卡的循环。反过来AI生成的图片也可以扔进artcraft做色调映射或纹理叠加统一成一套视觉风格。这个流程里artcraft相当于一个“可控的构图引擎”AI相当于“质感渲染器”两者互补性很强。如果你平时也在做AI生图建议试试这条链路。6.3 后续演进方向我计划接下来做几件事把生成器从代码注册改为可视化编排用户可以在界面上拖拽组合多个生成器形成“生成流水线”加入SVG导出让生成结果可以无损进入矢量编辑流程探索WebGPU版本的渲染后端目标是把分形树的实时交互深度从14提升到18以上做一个配方分享社区雏形种子加参数变成可复制的短链接方便收藏和交流。程序化生成工具永远不会无聊因为每加一种生成器就是在解锁一种新的视觉语言。最后再说说个人的一点体会。写artcraft的过程中最让我上头的不是最终漂亮的图案而是那些参数组合带来的意外惊喜——你设定了一堆限制条件然后穷举式地搜索参数结果里偶尔会冒出一两幅连你自己都想不到的好作品。这大概就是程序化生成的独特魅力它不是一个“自动出图”的按钮而是一个帮助你探索视觉可能性边界的工具。如果你也想做类似的东西我的建议是先做一个最简陋的版本——一个画布、一个滑块、一个随机按钮——跑通从参数到图像的闭环再慢慢加生成器。别一开始就想着做完美的大平台先把一个生成器做到自己满意你会发现后面的路顺畅得多。最后分享一个小技巧每次点随机化之前先把当前种子存个档这样就算随出了不满意的图也不会丢失之前的“配方”。