☰
基于Canvas 2D的程序化生成工具实战:从种子随机数到视觉素材库
2026/10/15 12:15:35 网站建设 项目流程

如果你需要一个不撞车的视觉素材库,与其去素材网站碰运气,不如自己写一个程序化生成工具。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的画布拉伸到更大的显示区域。正确做法是乘以devicePixelRatio(DPR):

const 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上用globalAlpha=0.08的黑色矩形覆盖旧画面,形成渐隐的拖尾效果,再在上面画当前帧粒子。这样粒子数量虽然多,绘制复杂度从O(N×历史帧)降到了O(N),4000个粒子也能稳定60帧。

4.3 渐变纹理:从值噪声到“布料质感”

渐变纹理生成器主要服务海报底纹场景。它的原理是取多个不同频率的值噪声进行加权叠加,再映射到调色板的多个颜色上。叠加次数越多,纹理越细腻。

实现思路:

  1. 生成一张低频噪声图,作为基底;
  2. 生成几张频率翻倍的高频噪声图,乘以对应权重后相加;
  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 后续演进方向

我计划接下来做几件事:

  1. 把生成器从代码注册改为可视化编排,用户可以在界面上拖拽组合多个生成器,形成“生成流水线”;
  2. 加入SVG导出,让生成结果可以无损进入矢量编辑流程;
  3. 探索WebGPU版本的渲染后端,目标是把分形树的实时交互深度从14提升到18以上;
  4. 做一个配方分享社区雏形,种子加参数变成可复制的短链接,方便收藏和交流。

程序化生成工具永远不会无聊,因为每加一种生成器,就是在解锁一种新的视觉语言。最后再说说个人的一点体会。写artcraft的过程中,最让我上头的不是最终漂亮的图案,而是那些参数组合带来的意外惊喜——你设定了一堆限制条件,然后穷举式地搜索参数,结果里偶尔会冒出一两幅连你自己都想不到的好作品。这大概就是程序化生成的独特魅力:它不是一个“自动出图”的按钮,而是一个帮助你探索视觉可能性边界的工具。

如果你也想做类似的东西,我的建议是先做一个最简陋的版本——一个画布、一个滑块、一个随机按钮——跑通从参数到图像的闭环,再慢慢加生成器。别一开始就想着做完美的大平台,先把一个生成器做到自己满意,你会发现后面的路顺畅得多。最后分享一个小技巧:每次点随机化之前,先把当前种子存个档,这样就算随出了不满意的图,也不会丢失之前的“配方”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询