如果你经常逛 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、调一调粒子数量看看同样一段代码能产生多少种不同画面。生成艺术真正的乐趣就是在最简单的规则里发现足够复杂的意外。