年初有位老朋友要过生日,我想送张贺卡。手绘是不可能手绘的,我连一条顺滑的直线都画不利索,于是理所当然地打开编辑器,准备用代码来画一张。半小时后,我得到了一团随机散点,外加几条歪歪扭扭的曲线,说它是“抽象”都算客气。那段经历让我想明白一件事:问题不在于我不懂画,而在于我没有一套能将“脑海里的画面”翻译成“程序参数”的流水线。也就是从那时起,我断断续续搭了一个代号叫artcraft的生成艺术小工具。它不是一个完整的产品,更像是我自己的一张工作台——输入种子、调一批参数,输出可复现的矢量图形或位图。这篇文章没有产品宣传,只有完整的架构思路、算法细节、实战环节和踩坑记录,适合正在玩 p5.js/Processing,但总觉得作品差一口气的开发者;也适合对生成艺术感兴趣、却不知道怎么把这些随机数组织成“作品”的设计师。
1. 为什么我会做这样一个工具
1.1 手画不行,纯随机又没灵魂
先说最初的痛点。前几年我想做一组壁纸,试过直接用随机函数在画布上撒点、连线、画圈。刚开始还觉得挺有“数据艺术感”,但看久了就会发现,纯随机生成的东西有一个致命毛病:没有组织感。随机点会扎堆,线条会乱窜,整个画面像一台坏掉的电视在出雪花,而不是一件“作品”。
那段时间我也试过现成的纹理生成器和滤镜。它们的问题是太固定了,本质是别人预设好的规则集合,改几个滑块只能微调,很难调出自己真正想要的画面。我从这条路里得到的核心教训是:生成艺术不该是“让电脑随便画”,而是设计一个系统,让电脑在这个系统里“有纪律地随机”。这就像掷骰子——你接受结果是随机的,但骰子必须是公平的六面体,而不是随便一团废纸。
于是我很自然地把目光转回了代码。既然市面上找不到一个满足我要求的工具,那就自己做一个。这就是 artcraft 最早的动机。
1.2 我要的是“可控的偶然”
“可控的偶然”是我后来一直在用的设计原则。大多数随机生成失败,不是随机用得不够,而是随机用错了地方。
举一个具体的分层场景:
- 全局随机:整幅画的构图方向、视觉重心落在哪,比如“偏左下角组织线条”,这种随机是艺术层面的选择。
- 局部随机:一条线条的抖动程度、一个圆点的半径变化,这种随机让画面产生手感和生命力。
- 禁止随机:海报上的文字、签名、固定尺寸的边缘、需要精确对齐的元素,这些地方必须完全确定。
artcraft 整个架构都是围绕这三层随机展开的。全局随机由种子决定,局部随机由种子派生出来的子生成器控制,禁止随机的部分直接用绝对坐标。所有随机决策都必须可复现——这一点后文会专门展开。
1.3 定位:不是绘画软件,是一条生成流水线
我一开始也想过,干脆把 artcraft 做成一个类似画板的软件:左边工具栏,右边画布,用户手动画。但很快放弃了,因为市面上成熟的绘画工具太多了,我没有必要也不该去和它们竞争。
artcraft 的定位是一条生成流水线:输入是“种子 + 参数组”,输出是 SVG/PNG/GIF,中间是三个不透明的阶段——生成器负责造出基础元素,合成器负责把元素组织到画布上,渲染器负责最终输出。整个过程可以一键重放。
打个比方,它不像绘画软件,更像一套胶片冲印流程。参数是底片,种子是显影配方。同样的底片配不同配方,冲出的照片气质完全不同,但每张照片都有迹可循。
2. artcraft 的核心架构:把“艺术直觉”翻译成“程序参数”
2.1 四层流水线的职责划分
artcraft 的内部结构并不复杂,我把整个项目拆成了四层:
- 种子层:一个整数种子会生成一个可复现的伪随机数流。每个生成器从主随机流里申请一个独立子流,这样互不干扰,程序行为可预期。
- 生成器层:根据种子和参数生成基础元素,包括点、线、多边形、噪声场。这一层只管“造东西”,不管“摆东西”。
- 合成器层:负责把所有元素组织到画布上。它处理图层顺序、坐标变换、裁剪区域、混合模式。如果把生成器比作生产零件的工厂,合成器就是装配线。
- 渲染器层:把合成后的结构化对象输出成 SVG、PNG 或其他格式,同时处理 DPI、尺寸、背景色等问题。
用代码表达,大概是这种感觉:
const artcraft = createArtcraftClient(); const project = artcraft.createProject({ seed: 20241117, width: 1200, height: 800, output: 'svg' }); project.addLayer(ContourLayer); project.addLayer(DotLayer); project.addLayer(ColorFieldLayer); project.render();层与层之间不直接互相调用,中间的通信通过统一数据格式:带属性标注的路径集合。这样做的好处是,任何一个环节出了问题,都能单独替换。比如我想把渲染器从 SVG 换成 Canvas 2D,完全不需要动生成器和合成器。
2.2 参数设计:让调参像调色,而不是改源码
早期版本的 artcraft 参数表直接暴露底层函数参数,比如noiseOctaves: 7、maxDistance: 38.5。结果我自己过两天回来看都一脸懵,根本不知道调到什么程度是合理的。后来我换了一套思路:所有参数必须能直接映射到“你眼睛看到的东西”。
举个例子。用户不应该理解分形噪声的 octave 是什么,他只知道自己想要“更细腻的纹理”。于是我把底层参数包了一层:
detailLevel:0 到 1,内部映射到噪声叠加次数和频率缩放。wobble:线条抖动量,内部映射到贝塞尔控制点的高斯扰动方差。sparseness:点的疏密度,内部映射到泊松圆盘采样的最小距离。
命名上,我尽量用“人话”,避免直接用底层术语。这听起来是小事,但特别重要。工具一旦超过两天不用,你就会忘掉那些抽象参数的含义;而一套“所见即所得”的参数表,能让任何基础的人坐下来玩十分钟就知道怎么调整。
参数还要分组。artcraft 的参数面板大致分五组:构图、纹理、线条、颜色、输出。每组下面才是具体旋钮。有些组合参数可以打包成“预设”,比如“柔和印刷风”“荧光噪点风”,一个预设就是一整套参数快照。这样新手可以直接从预设开始改,而不用面对一张空空的参数表单。
2.3 为什么默认输出 SVG,而不是直接给 PNG
很多人问过:生成艺术不是应该直接输出位图吗?为什么要绕一圈先出 SVG,再转位图?
原因有三。第一,SVG 是可缩放、可再编辑的。一次生成的矢量结果,放到名片上、墙体海报上都能保持清晰,甚至还能在矢量软件里继续调整路径。第二,SVG 文件通常比同尺寸 PNG 小一个数量级。一张 4K 尺寸、包含大量圆点和曲线的作品,PNG 可能要几十 MB,而 SVG 往往只有几 MB。第三,SVG 是“结构化”的,这意味着我可以对结果做程序化后处理:批量改颜色、替换线条宽度、裁剪局部区域,全部不需要重新生成。
所以在 artcraft 里,底层数据模型是一批带属性的矢量路径对象,光栅化成 PNG 只是渲染流水线的一个输出端口。
3. 关键算法与实现细节:让生成艺术告别“电脑味”
3.1 分形噪声铺底:所有柔和的纹理都从它开始
生成纹理、云层、地形、渐变背景,我几乎都离不开分形噪声。它的原理可以简化成一句话:把多个不同频率、不同振幅的平滑噪声叠加起来。单独一层平滑噪声太“均匀”了,像一块完全平滑的丝绸;而把大山套小山、小山套石头的波形加起来,才接近自然界的复杂纹理。
我用的是一个简化版的分形布朗运动实现:
function fbm(x, y, octaves = 5, lacunarity = 2, gain = 0.5) { let amplitude = 0.5; let frequency = 1; let sum = 0; let norm = 0; for (let i = 0; i < octaves; i++) { sum += amplitude * noise.perlin2(x * frequency, y * frequency); norm += amplitude; amplitude *= gain; frequency *= lacunarity; } return sum / norm; }几个关键参数的作用:
octaves:叠加层数。更多层意味着更多细节,但计算量也线性增加。lacunarity:频率放大倍数,通常取 2。数值越大,高频细节被放大得越快。gain:振幅衰减比例,通常小于 1。它保证高频层的贡献越来越小,最终叠加结果收敛,不会爆掉。
单人项目里完全不用实现复杂的噪声算法,直接找一个可靠的梯度噪声实现就能用。关键是理解 fbm 的组合方式,而不是从零写一个哈希梯度函数。
3.2 让随机点看起来“有组织感”:泊松圆盘采样与黄金角
随机点本身不是问题,问题在于没有约束的随机点会在视觉上扎堆。现实里你不会希望一张海报上的圆点左边挤成一团、右边空出一大片。想让点阵既自然又均匀,我主要用两种方案。
第一种是泊松圆盘采样。它保证任意两个点之间的距离不小于某个最小值,同时又能保留随机性。实现核心是用网格加速距离查询:把画布切成若干个单元格,每个点只落在一个格子里,检测冲突时只要查相邻格子就行,然后通过一个活跃点列表不断往外生长。
function poissonDisk(width, height, radius, rng) { const cellSize = radius / Math.sqrt(2); const cols = Math.ceil(width / cellSize); const grid = new Array(cols * Math.ceil(height / cellSize)); const points = []; const active = []; // 随机初始点 const start = { x: rng() * width, y: rng() * height }; // ...后续生成与冲突检测 return points; }第二种是黄金角螺旋。以 137.5 度为角度增量生成的点列,会自然形成向日葵花盘、星系旋臂这类构图,视觉上均匀却没有网格感。这个角度在圆上的含义是:连续旋转后永远趋近黄金分割比例,所以不会出现明显的直线对齐痕迹。
这两种方案我通常结合使用:大面积铺点用泊松圆盘,需要漩涡或放射状构图时用黄金角螺旋。
3.3 颜色调和:把色彩理论直接写进代码
刚开始我尝试用 RGB 空间做随机颜色,结果所有颜色都像儿童糖果:过于鲜艳、互不协调。后来我切换到 HSL 空间,才发现问题出在最基本的地方。
HSL 的好处是三个维度在视觉上相对独立:色相决定颜色倾向,饱和度决定鲜艳程度,明度决定深浅。做调和方案时,我只需要固定一个基准色相,然后按规则偏移,就很容易得到协调的整套配色。
artcraft 里内置了常见的调和规则,写出来并不复杂:
function buildPalette(baseHue, scheme = 'analogous', count = 8, rng) { const rules = { monochrome: [0], analogous: [-30, 30], complementary: [180], splitComplementary: [150, 210], triadic: [120, 240], }; const offsets = rules[scheme]; const palette = []; for (let i = 0; i < count; i++) { const offset = offsets[i % offsets.length]; const hue = (baseHue + offset + rng() * 12 - 6 + 360) % 360; const saturation = clamp(60 + rng() * 30, 0, 100); const lightness = clamp(55 + rng() * 25, 0, 100); palette.push({ hue, saturation, lightness }); } return palette; }注意中间的抖动值:rng() * 12 - 6。纯按公式来会显得非常机械,像电脑配色;加了这个 12 以内的抖动,颜色才“活”起来,像人挑出来的。
3.4 笔触模拟:一条有生命的手绘线是怎么来的
我想让曲线的边缘有一点点手绘的味道,可以把一条数学上完美的曲线拆成控制点,再给每个控制点加高斯扰动。最简单的方式是取相邻点的中点做偏移,然后把它们拼成贝塞尔曲线:
function wobblyLine(pts, wobble = 4, rng) { const controls = []; for (let i = 0; i < pts.length - 1; i++) { const p = pts[i]; const next = pts[i + 1]; const mid = { x: (p.x + next.x) / 2 + (rng() - 0.5) * wobble, y: (p.y + next.y) / 2 + (rng() - 0.5) * wobble, }; controls.push(mid); } // 拼成 SVG path return `M ${pts[0].x} ${pts[0].y} C ...`; }wobble参数背后用的是高斯扰动,而不是平均分布,因为平均分布的偏转容易产生呆板的锯齿感。更进阶的做法是给线条叠加粗细渐变:线条两端逐渐变细,模拟铅笔和毛笔的物理笔触。这一步直接决定了最终作品是“软件画的”还是“手绘的”。
4. 实战:一张等高线与圆点海报的完整生成链路
4.1 先定方向,再写代码
理论讲了一堆,实际跑一遍最有价值。我在 artcraft 里做了一个自测项目:生成一张 A4 竖版海报,风格定为山体等高线叠加渐变圆点。
A4 竖版意味着画布是 2480×3508 像素(300 DPI)。我先定下参考参数:
const config = { seed: 817204, page: { width: 2480, height: 3508, margin: 160 }, background: { gradient: true, colors: ['#e9e2d0', '#b2c6d8'] }, contours: { count: 24, strokeWidth: 2, opacity: 0.35 }, dots: { count: 620, minR: 6, maxR: 48, scheme: 'splitComplementary' }, }; const project = artcraft.createProject(config);这里有两个细节值得说明。第一,margin: 160是出血和装订线的空间,做印刷品构图时,元素不能贴到边缘。第二,配色用#e9e2d0到#b2c6d8,一个偏暖的沙色、一个偏冷的雾蓝,配合分裂互补色的小圆点,整体层次很容易拉开。
4.2 实现过程:从噪声场到等值线再到圆点
整个生成链路分五步:
- 初始化项目:根据种子派生多个随机子流,生成构图重心位置。
- 生成背景:用 fbm 噪声生成一个柔和渐变场,作为整个画面的底色。
- 提取等高线:在噪声场上画 24 条等值线。等值线的算法我用的是经典的网格边线插值思路:遍历每一个小格,判断四个角的值与阈值的相对关系,连出线段,再把所有网格的线段拼起来。这个过程类似地形图的山体等高线,只不过这里的“高度”来自噪声场。
- 生成圆点:用泊松圆盘采样在画布上撒 620 个不重叠的点,每个点的半径从 6 到 48 随机分布,颜色从调色板里取。
- 合成分层:背景在最底层,等高线在中层,圆点放在前景;最终渲染成 SVG,再由离屏画布光栅化成 PNG。
落地之后,我还会执行一次命令行式生成,把 seed 和 config 都作为参数传入:
node make-poster.js --config poster.art.json --seed 817204这样每次跑出的海报都是同一张。想换风格,只需要替换 config 文件。
4.3 输出阶段的三个关键检查点
输出阶段不只是点一下导出按钮,我实操中总结出三个高频检查点:
- 尺寸与 DPI:生成 SVG 时要带上正确的
viewBox,按 1:1 对应 2480×3508 像素;如果后续要印刷,再按目标纸张尺寸换算成毫米。 - 出血位:我会故意把背景色块多画一圈,盖过画布边缘,防止切割时出现白边。
- 透明度图层:SVG 里几十个半透明图层叠加,在屏幕上看着没问题,但印刷前必须做扁平化,否则打印解析时又慢又容易出错。
这一步做完,一张可以直接发朋友圈的生成海报就出来了。算法复杂度不高,但每层之间的配合才真正决定了画面的品质。
5. 踩坑复盘:种子、性能、参数与印刷现场的真实教训
5.1 种子管理:可复现是生成艺术的底线
这是 artcraft 发展过程中最大的教训。早期版本里,我直接在代码各处调用Math.random(),结果每次运行都生成一张完全不同的图。调出一个满意的效果,想回头调整几个参数,结果整个画面变成了另一幅画,完全没法迭代。
后来我立了一个铁规矩:每一个随机决策都必须从同一个种子派生,绝不能调用全局随机函数。代码里所有随机源都从一个可复现的伪随机对象里获取。
我还会为每一次“出图”保存一个 manifest 文件,里面记录 seed 和完整参数快照:
{ "seed": 817204, "config": { "background": { "gradient": true, "colors": ["#e9e2d0", "#b2c6d8"] }, "contours": { "count": 24, "strokeWidth": 2, "opacity": 0.35 }, "dots": { "count": 620, "minR": 6, "maxR": 48 } } }每次实验都保留这个 JSON,就好比摄影师记录拍摄参数。遇到满意的结果,可以随时找回;遇到翻车的,也能精确定位是哪一步出了问题。这事听着麻烦,实际一劳永逸。
5.2 性能优化:从“跑十分钟”到“秒出图”
等值线提取的时候,我第一次直接在 2480×3508 的整图层上逐像素计算,结果跑了十几分钟才出一张图,简直没法用。换了个思路:先在低分辨率上算出噪声场,比如整幅图的四分之一,再做双线性插值放大。噪声本身是低频纹理,插值带来的视觉损失几乎看不出来,性能从十几秒直接降到两秒以内。
第二个性能坑来自 SVG 体量。等高线加几千个圆点,一次性写到 SVG 文件里,文件可能堆到十几 MB,浏览器打开都卡。对策是合并重复路径、删除密集但视觉上无感知的多余点。大部分情况下,一条等值线根本不需要几百个点,缩减到七八十个就足够平滑。
第三个坑是内存。4K 画布每个像素按 RGBA 算要 34 MB,如果我同时保留三层中间结果,内存会迅速吃掉几百 MB。我的做法是能即时渲染的就即时渲染,不保留完整像素缓冲,渲染完成马上释放。
5.3 参数爆炸与默认值设计
生成工具最大的困境是“旋钮太多,人容易晕”。artcraft 早期有上百个可调参数,我自己都记不住,更别提其他人。后来我把参数按功能和可视化效果重新分组,每组只保留一个主旋钮加两个微调项,其余参数全部设好安全默认值。
安全默认值的定义是:就算用户把它调到极端,程序也不应该崩溃或者输出一张明显破损的图。为此我写了统一钳制函数,保证每个参数都在安全范围内:
function clamp(v, min, max) { return Math.min(Math.max(v, min), max); }别小看这个函数。少了它,用户把密度调到 100%,程序可能瞬间生成几十万条路径,直接把浏览器拖垮。有了钳制,行为就变成“密度再高也只在一个合理范围内”。
5.4 打印现场的一个具体翻车:密集圆点的摩尔纹
做那批海报的时候,我第二次打印就翻了车。样张显示,圆点密集区域出现了明显的波纹,像两排纱窗叠在一起产生的干涉条纹。这个现象叫摩尔纹,本质是规则网点阵和打印栅格之间发生了空间频率干涉。
我当时的第一反应是“生成精度不够”,后来查了印刷经验才明白:规则排列的等间距网点,在特定缩放比例下几乎必出摩尔纹。解决方式是给圆点的位置和半径都加入 5% 到 10% 的随机微调,打破网点阵的周期性。同时把密度整体下调一点,并为印刷单独保留一份“低密度版本”。这个坑让我明白:生成作品在屏幕上看着舒服和印刷出来舒服,是两套完全不同的验收标准。
6. 再往前走:动效、AI 协作与 artcraft 的生态位
6.1 把静态图变成时间轴动画
artcraft 的下一步,我打算做时间轴化。思路很简单:把参数组中的每一个值,看作一条随时间变化的曲线;每帧读一次曲线当前的参数值,然后走一遍生成流水线,输出一帧图像。连续帧拼起来就是一段没有任何关键帧动画师介入的生成动画。
这比单独生成静态图的难点在于:必须保证参数变化是平滑的,否则帧与帧之间会跳变。解决办法是在粒子系统里加入“缓存池”,让每个粒子在帧间的颜色、位置都做线性插值,而不是每帧重新随机。
6.2 和 AI 生成模型结合的思路
很多人最近都在讨论 AI 生成图像,我的观点是大模型擅长“描述”,但不擅长“精确控制几何”。artcraft 的价值恰好相反,擅长精确控制,但缺少一点点“文字直觉”。
所以我的思路是双向的:一方面用自然语言描述想要的画面气质,让模型输出一组 artcraft 参数建议;另一方面,让 artcraft 把参数精确渲染成矢量图形,再由人微调。比如输入一句“安静的等高线海报,女儿红配色”,模型给出一组初始baseHue、contourCount等参数,然后我打开预览,动两下旋钮,就能拿到一张完整作品。这不是魔法,只是把原来 prompt 工程里的“抽卡”,变成了“调可感知参数”。
6.3 有一些方向,还是要克制一点
工具做到现在,我最大的体会是“边界感”很重要。artcraft 不会去做的三件事:
- 不做位图滤镜。成熟商业软件在这条路上积累太深,不是一个小项目该碰的领域。
- 不做商业排版系统。文字排版、网格设计、复杂版式已经有非常专业的工具。
- 不做重物理模拟。粒子物理、流体模拟、刚体碰撞,每一个都是大坑,放进生成艺术工具里只会让项目无限膨胀。
artcraft 的舒适区只有一个:快速生成可复现的实验性图案,而且参数要好玩、可沉浸式调节。在这个窄口里做深,比什么都想做却都做不深要实际得多。
artcraft 目前还是我脚本集合里的一堆 JavaScript 文件,离一个完整的作品还有距离。但如果再让我做一次,我一定会从第一天就把种子归档和参数验证写进核心流程,而不是等踩了坑再补。生成艺术的核心从来不是“随机”,而是“规则与随机之间的对话”。你如果也想做类似的小工具,不用急着从零搭架构,先把一个噪声函数、一堆受约束的点和一条矢量输出管线跑通,后面所有事情都会顺很多。