如果你经常逛 Hacker News,最近大概率见过一个叫 Loop-me 的小项目。标题写得很克制:“my simple generative art loop project”。按 Show HN 的惯例,这通常是作者在展示一个自己做了很久、终于能在浏览器里跑起来的作品。但真正让我停下来想多一会的,不是它的画面多惊艳,而是这个名字本身:loop。
生成艺术这几年很火,市面上有大量可以让“随机生成一张好看图”的库和工具。可一旦你想让画面动起来、循环播放、输出成视频或者现场装置,难度会突然跳一个台阶。因为你要解决的不再是“怎么画”,而是一个更基础的问题:怎么让一个随机系统在时间和空间上形成可重复、可预期、可以无限循环的完整节奏。
Loop-me 这类项目真正有意思的地方就在这里。它看起来是一个生成艺术工具,实际是一个关于“循环”的工程练习。这篇文章想聊一聊:生成艺术里的 loop 到底是什么,为什么它是这类项目的核心;然后会从一个最小示例出发,演示如何从零写一个能循环播放的 generative art 项目,包括随机种子、帧循环、轨迹设计和导出视频的完整流程。
读完以后,你会明白为什么很多生成艺术项目写着写着就只剩“随机”,而真正成熟的作品拼的其实是“循环设计”。同时也会拿到一套可以直接跑起来的代码骨架,稍作修改就能变成自己的作品。
1. 生成艺术项目的核心不是“随机”,而是“循环”
很多人刚接触生成艺术时,会陷入一个误区:觉得只要随机值够多、颜色够丰富,画面自然会好看。但实际做上两三个项目就会发现,完全随机的画面很容易让人审美疲劳,而且很难形成“作品感”。真正的生成艺术作品,通常是在极小的随机扰动里,藏着一套稳定的规则。
在这类项目里,“循环”至少有三层含义。
第一层是帧循环。画面不是静止的,而是每一帧都在更新。浏览器通过requestAnimationFrame或定时器不断触发绘制,每一帧之间只差几十毫秒,连起来就是动画。这层循环是任何动态生成艺术作品的地基。
第二层是周期轨迹。一个画面如果只有随机,运动方向会乱。想要形成“循环播放”的观感,通常要给每个元素设定周期性的运动规律,例如角度从 0 到 2π 再回到 0,或者位置在某个范围内来回振荡。画面结束时和开始时几乎一样,播放起来就是无缝循环。
第三层是规则闭环。生成作品不能每次刷新都不一样,也不能跑到一半突然发散。成熟的思路是:用同一个随机种子初始化一次,之后所有随机数都从这个种子推导。这样每次打开都是同一套参数,再结合上面的周期轨迹,整个作品就变成一个确定性系统。
Loop 这个单词在不同技术领域含义不太一样。最近 AI Agent 圈经常说agent loop、loop engineering,核心是“模型持续观察、决策、执行”的工作循环;而在生成艺术里,loop 更多是“时间轴上的自我重复+视觉上的有序变化”。两者有一个共同点:没有 loop,系统就是一次性的,有了 loop,系统才具备持续演化和复用的可能。
2. 从 Loop-me 看这类项目的定位:解决“生成一次”和“持续输出”之间的落差
一个很有意思的现象是:很多静态生成图很好看,一旦要变成动态内容,作者就会发现无从下手。这里的问题不是画图能力,而是架构设计。
Loop-me 这一类项目,本质上是在解决一个很具体的落差:生成一次很容易,持续输出很难。
做静态生成图时,你只需要跑到第 1 帧,输出一张图就结束。但做一个 loop 项目,你需要确保:
- 程序能无限运行,不会内存泄漏,也不会因为帧数积累导致画面失控;
- 每一帧之间是平滑过渡的,不能出现跳变或闪烁;
- 在任意时刻暂停,往前回溯,你都能解释当前画面的状态;
- 最好还能导出成视频或 GIF,方便分享。
这意味着你必须把“状态管理”和“渲染流程”分离。随机值只在初始化阶段生成一次,时间轴决定每帧的绘制参数,渲染器只负责把状态画出来。这个设计思想,和游戏开发里的update/render分离非常相似。
从读者角度说,这项目的目标受众很明确:想从“随手画生成图”进阶到“做出可循环动画作品”的创意编程爱好者。它不追求复杂物理模拟,也不需要 GPU 跑深度学习模型,核心是让一个简单规则能够在循环中产生足够丰富的视觉变化。门槛不高,但对代码组织能力是有要求的。
3. 生成艺术循环系统的基础概念
在动手写代码之前,花三分钟把四个基础概念理清楚,会比直接抄代码有效得多。
3.1 帧循环 Frame Loop
帧循环是动画系统的心脏。浏览器每次刷新屏幕前,会调用你注册的回调函数;你在回调里更新状态、绘制画面。核心代码如下:
function animate() { update(); // 更新逻辑状态 draw(); // 绘制画面 requestAnimationFrame(animate); // 注册下一帧 }这里的requestAnimationFrame不是定时器,它和屏幕刷新率同步,常见是 60fps。使用它的最大好处是:浏览器在标签页不可见时会自动暂停,节省 CPU。如果你在写生成艺术 demo,优先使用它,而不是setInterval。
3.2 循环时间 Loop Duration
要形成“无缝循环动画”,必须有一个总时长概念。比如你的作品设计为 600 帧循环一次,那么当前帧号必须落在0 <= frame < 600范围内。超过 600 就取模回到 0。这样一来,第 0 帧和第 600 帧几乎是同一个状态,视频播放起来才能首尾相接。
frame = (frame + 1) % totalFrames;这是整个动画的节拍器。你没有它,画面就会越跑越乱,而且永远无法导出成一段可循环视频。
3.3 种子随机 Seeded Random
普通Math.random()每次运行结果都不一样,这会导致作品不可复现。生成艺术项目里,通常需要“可复现随机”:同一个种子,永远产生同一串随机数。
常见的做法是使用带种子的伪随机数生成器。它相当于一个公式:seed -> 随机数序列。第一次运行后得到的参数可以被保存,分享给别人的时候,别人也能得到一模一样的结果。
3.4 绘制状态 Draw State
每一帧绘制前,你需要一个确定的“状态对象”,它包含当前位置、颜色、半径、透明度等参数。状态由时间推进,而不是由用户操作直接修改。这样做的好处是画面可回放、可调试。
| 概念 | 解决的问题 | 常见错误 |
|---|---|---|
| 帧循环 | 让画面持续更新 | 使用 setInterval,导致帧率不稳定 |
| 循环时间 | 让动画首尾相接 | 帧号无限累加,从不取模 |
| 种子随机 | 让作品可复现 | 直接使用 Math.random() |
| 绘制状态 | 让画面可回放可调试 | 在 draw 里直接修改业务逻辑 |
这四个概念组合起来,就是一个生成艺术循环项目的最小架构。
4. 环境准备与前置条件
为了让示例跑起来,你需要一个能运行 Canvas 的环境。这里我用 Node.js 来演示,因为你可以在命令行直接生成图片或视频,这对测试和导出比较友好。
具体前置条件如下:
- Node.js 环境,建议使用 18 或更高版本版本请以实际安装为准,本文不会依赖太新的特性;
- npm 包管理器,通常随 Node.js 一起安装;
- 浏览器或 Node Canvas 支持,用来执行 Canvas API;
- 如果你只做前端演示,也可以跳过安装,直接把代码放进 HTML 文件里打开即可。
本文演示使用的是canvasnpm 包,它能在 Node.js 里模拟浏览器 Canvas 环境。安装命令:
mkdir loop-me && cd loop-me npm init -y npm install canvas这里的版本会和系统环境相关,安装失败时优先检查 Node 版本和编译工具链。如果你不想折腾本地环境,也可以把后面的代码复制到在线编辑器如 CodePen 中运行,把document相关部分稍作调整即可。
5. 核心流程拆解与完整代码实现
下面从一个最小示例出发,演示如何构建可循环的生成艺术项目。整个流程分成四个步骤:初始化项目、实现种子随机、设计循环动画、导出结果。
5.1 初始化项目结构与颜色主题
先创建一个index.js文件,并引入 Canvas:
// index.js const { createCanvas } = require('canvas'); const width = 800; const height = 800; const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d'); // 设定一个深色背景主题 ctx.fillStyle = '#0f0f1b'; ctx.fillRect(0, 0, width, height);这一步做的事情很简单:创造一块 800×800 的画布,填充深色底色。后面的所有绘制都在这个画布上进行。
5.2 实现可复现的随机数生成器
不用Math.random(),改用一个带种子的伪随机函数。这里用的是经典的mulberry32,代码很简短,但足够均匀:
// random.js function mulberry32(seed) { return function () { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } module.exports = mulberry32;用法很简单:
const rand = mulberry32(20240606); console.log(rand()); // 0.xxxxxxxx console.log(rand()); // 0.xxxxxxxx换个 seed,得到的序列不同;同一个 seed,序列永远相同。这是作品可复现的基础。
5.3 设计循环动画的主逻辑
现在进入核心部分。我们要生成 120 个粒子对象,每个粒子都有固定的基础位置、半径、速度和相位。颜色从色轮中均匀采样,这样整体会比较协调。
// index.js 续 const mulberry32 = require('./random'); const seed = 20240606; const rand = mulberry32(seed); const N = 120; const loops = []; for (let i = 0; i < N; i++) { const baseColor = Math.floor(rand() * 360); loops.push({ cx: rand() * width, cy: rand() * height, r: 30 + rand() * 200, speed: 0.3 + rand() * 1.2, phase: rand() * Math.PI * 2, color: `hsl(${baseColor}, 70%, 60%)`, lineWidth: 1 + rand() * 3, }); } const totalFrames = 600; let frame = 0; function draw(t) { // 每一帧都清空画布,保持画面干净 ctx.fillStyle = '#0f0f1b'; ctx.fillRect(0, 0, width, height); const progress = (t % totalFrames) / totalFrames; const angle = progress * Math.PI * 2; ctx.lineCap = 'round'; for (const p of loops) { // 内弧线:角度随时间正向变化 ctx.beginPath(); ctx.strokeStyle = p.color; ctx.lineWidth = p.lineWidth; ctx.arc( p.cx, p.cy, p.r * 0.5, angle * p.speed + p.phase, angle * p.speed + p.phase + Math.PI * 1.2 ); ctx.stroke(); // 外弧线:角度随时间反向变化,形成交错感 ctx.beginPath(); ctx.arc( p.cx, p.cy, p.r, -angle * p.speed + p.phase, -angle * p.speed + p.phase + Math.PI * 0.8 ); ctx.stroke(); } } function animate() { frame = (frame + 1) % totalFrames; draw(frame); // 导出第 0 帧为 PNG,验证结构 if (frame === 0) { const fs = require('fs'); fs.writeFileSync('frame_0000.png', canvas.toBuffer('image/png')); console.log('saved frame_0000.png'); } setTimeout(animate, 33); // 模拟约 30fps } animate();这段代码的核心思想是:每个粒子拥有两条弧线,一条从内圈开始,一条从外圈开始,角度都随时间匀速变化,但方向相反;整体构成一个“向内聚拢又向外扩散”的视觉循环。由于所有参数都来自同一个 seed,整个动画的轨迹是确定的。
5.4 单帧静态输出与循环视频导出
上面的代码已经能持续生成动画帧,但实际项目里你不可能一直开着终端去看画面。更常见的需求是输出成视频。一个简单办法是:每一帧保存成 PNG,再用 ffmpeg 合成视频。
你可以在animate里把每一帧保存下来:
// 在 draw 之后增加保存逻辑 frame = (frame + 1) % totalFrames; draw(frame); const padded = String(frame).padStart(4, '0'); fs.writeFileSync(`out/frame_${padded}.png`, canvas.toBuffer('image/png'));等到 600 帧全部输出后,用 ffmpeg 合成循环视频:
ffmpeg -framerate 30 -i out/frame_%04d.png -c:v libx264 -pix_fmt yuv420p -loop 0 loop-me.mp4这里-loop 0表示输出文件本身是可循环的,便于在社交平台上作为短视频发布。
5.5 关键逻辑解释
mulberry32(seed)让随机参数在每次运行前全部确定,保证输出稳定;frame = (frame + 1) % totalFrames让时间始终周期性回到起点,实现无缝循环;- 颜色不直接随机 RGB,而是采样 HSL 色相,保证画面饱和度相对统一;
- 用双向旋转的弧线,而不是完整圆环,是为了在简单规则下产生更丰富的图形层次。
6. 运行结果与效果验证
在项目根目录执行:
node index.js正常运行时,你会看到控制台间歇性地打印保存文件的消息。第一次运行后,项目目录下会生成out文件夹,里面是一个frame_0000.png到frame_0599.png的图片序列。
打开其中任意一张,你看到的应该是深色背景上分布着几十条彩色弧线。不同帧之间弧线的弧度略有差异,但这种差异是连续的,而不是跳变。当所有帧按照 30fps 合成视频后,会形成一个平滑循环的作品。
如果打开图片发现画面空白、颜色过暗或者图案全部重叠在一个位置,多数情况下不是随机种子的问题,而是状态没有随时间更新。可以先检查draw函数里是否真正使用了progress变量。如果所有图形都不随帧变化,通常是因为循环参数没有进入绘制逻辑。
6.1 验证目标
| 检查项 | 预期结果 | 失败时排查方向 |
|---|---|---|
| 静态画面 | 深色背景上有多条彩色弧线 | 检查背景颜色和loops数组是否为空 |
| 动画连续性 | 相邻帧之间弧线角度变化很小 | 检查速度值是否过大或过小 |
| 循环性 | 第 0 帧和第 599 帧画面接近 | 检查取模逻辑和周期参数 |
| 可复现性 | 删除输出目录重跑,结果一致 | 检查是否误用了Math.random() |
7. 常见问题与排查思路
实际运行中,新手最容易遇到的几个问题如下。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
canvas包安装失败 | 系统缺少编译工具或 Node 版本不匹配 | 查看 npm 报错日志;尝试在浏览器环境直接运行 HTML 版本 | 升级 Node 版本;或使用canvas官方预编译包 |
| 画面全黑 | 绘制逻辑中有报错,或loops为空数组 | 在draw开头打印loops.length | 检查循环初始化代码是否被跳过 |
| 画面不随时间变化 | 没有使用frame作为参数传入绘制函数 | 在draw函数中打印progress | 确认draw(progress)使用了外部时间变量 |
| 动画运行一段时间后卡顿 | 每一帧都在创建新对象或路径没有beginPath | 检查是否有无限新增数组元素 | 所有分配都写在初始化阶段,绘制阶段只更新状态 |
| 视频导出后跳帧 | 帧率与保存速度不一致 | 检查ffmpeg -framerate参数和实际帧数 | 统一导出帧率为 30fps,或者导出时带时间戳 |
其中最容易踩坑的是“路径没有关闭”导致的性能问题。在 Canvas 绘制中,每次beginPath后才能开始新路径;如果漏掉,所有弧线会累积成一条巨大路径,第一帧之后画面就会卡死。
8. 生成艺术 loop 项目的工程建议
跑通上面的最小示例之后,如果想认真做一个属于自己的 loop 项目,有几点工程经验值得参考。
8.1 先定循环时钟,再想艺术风格
很多初学者一上来先选颜色、调参数,最后才想起“动画怎么循环”。更合适的顺序是:先确定总帧数和帧率(例如 600 帧、30fps,刚好 20 秒循环一次),再围绕这个时间长度来设计运动轨迹。这样所有视觉元素都会自然嵌在同一个节奏里。
8.2 使用配置文件管理超参数
把粒子数量、颜色范围、半径范围、循环帧数等独立成一个配置对象。这样你可以快速跑几个不同版本的种子,对比画面效果,而不需要每次改代码。
// config.js module.exports = { seed: 20240606, totalFrames: 600, width: 800, height: 800, particleCount: 120, minRadius: 30, maxRadius: 230, };8.3 保存种子与输出文件的对应关系
生成艺术的核心财产是“能在不同时间复现同一效果”。建议每次输出视频或图片时,把种子、配置文件、代码版本一并记录到元数据文件里。以后想复刻某个好看画面,只需要恢复这组参数。
8.4 绘制逻辑保持“无状态”
绘制函数只根据传入的时间参数计算结果,不要依赖全局变量。这样做的好处是:它可以被反复调用,也可以被回放到任意时间点,调试起来非常方便。
8.5 性能优化优先看路径数量和离屏 Canvas
如果你发现动画在浏览器里掉帧,先看每帧画了多少条弧线、多少个粒子。生成艺术很多时候是“视觉复杂度”和“绘制性能”之间的平衡。可以把静态背景预先渲染到离屏 Canvas 上,只把动态元素留在主循环里重绘。
8.6 导出视频时注意视频编码
用 ffmpeg 导出时,建议加上-pix_fmt yuv420p,否则视频在部分播放器上会出现色偏或无法播放的问题。这个参数与具体平台播放器的兼容性有关,是实际发布短视频时很容易踩的坑。
9. 总结与后续方向
回到 Loop-me 这个项目。它之所以值得聊,不是因为它的算法有多复杂,而是它把生成艺术里最关键的一层“循环”简化成了一个足够直观的工程结构。任何生成艺术作品,如果没法循环,就很难成为被反复观看和传播的内容;而一旦掌握了 loop 的设计方法,你的作品就从“一张好看的图”升级成“一个可以一直看下去的系统”。
下一步值得深入的方向有三个。第一是交互式 loop:让鼠标位置或音频输入影响循环参数,生成艺术会从纯视觉变成可玩的作品。第二是多通道时间系统:不同元素使用不同周期,比如 A 元素 5 秒循环一次,B 元素 7 秒循环一次,两个周期叠加会出现复杂的长期变化。第三是引入机器学习模型:例如用 StyleGAN 或 Stable Diffusion 生成纹理,再嵌入到循环动画系统中,让生成的图案更有个性。
如果看完这篇文章你手痒了,建议先不要急着追求炫技,把上面的最小示例完整跑一遍,改一改 seed、调一调粒子数量,看看同样一段代码能产生多少种不同画面。生成艺术真正的乐趣,就是在最简单的规则里发现足够复杂的意外。