生成艺术(generative art)近年经常出现在前端、创意编程和动效设计场景里,它解决的核心问题不是“怎么画一张图”,而是“怎么定义一套规则让图自己长出来”。Loop-me 是一个非常典型的轻量生成艺术项目:它把循环(loop)当作生成规则的一部分,让程序以固定周期生成同一套噪声场、粒子和色彩,播放时首尾相接,几乎看不出重新开始的痕迹。很多人第一次做这类作品时,画面能动,但最后一帧切回第一帧时跳变非常明显;问题通常不在随机算法,而在时间参数没有真正参与循环。下面的内容会基于一个可运行的 p5.js 最小项目,拆解 Loop-me 的循环设计、核心代码、验证方式和常见断点。
1. 循环不是播放模式,而是一种生成约束
1.1 先理解什么叫“可循环的生成艺术”
生成艺术并不是让计算机完全随机地乱画。它的流程通常是:创作者定义一组规则、参数、随机种子和状态更新方式,然后由程序在每一帧或每一次迭代中根据这些规则生成新的图像内容。艺术家控制的是规则空间,而不是每一个像素。Loop-me 这个名字里有两个关键词:一个是 “Loop”,另一个是“me”。从工程角度看,Loop 代表这个项目的输出形式必须是可以无缝循环播放的动画序列;从设计角度看,它意味着“循环”不是一个后期拼接动作,而是在生成阶段就写进算法的约束条件。
在实际创作中,很多人会把生成艺术理解成“让程序自动画图”,于是每一帧都重新随机一次,结果单帧画面很好看,但连续播放时没有连续性。Loop-me 这类项目强调的则是另一种能力:让同一个系统在有限时间内完成一轮演化,然后平滑回到起点。这种方式非常适合做动态壁纸、循环背景、展示型动效和社交媒体上的短循环视频,因为它不需要观众关注剧情,只需要视觉上持续呼吸。
一个循环动画在视觉上是否成立,取决于两件事:第一,系统在每一帧有没有可重复的确定性;第二,时间参数从终点回到起点时,画面的状态变化是否足够平滑。前者靠随机种子和状态变量保证,后者靠时间归一化和周期函数保证。理解了这两点,再看 Loop-me 的代码就会非常清楚。
1.2 无缝循环依赖 f(0) 与 f(1) 的边界关系
如果把一帧画面理解成关于时间 t 的函数 F(t),那么一个长度为 T 帧的循环动画会依次绘制 F(0), F(1), ..., F(T-1)。播放到最后一张 F(T-1) 后,下一轮会重新播放 F(0)。因此,循环边界是否平滑,取决于 F(T-1) 和 F(0) 在视觉上是否接近,也取决于两者之间的过渡是否自然。
这里容易产生一个误区:很多人以为只要满足 F(T) = F(0) 就能无缝循环。严格来说,F(T) 这一帧在实际播放中并不会出现,因为序列只保存 0 到 T-1。真正需要关注的其实是 F(T-1) 与 F(0) 的差。当 T 足够大时,这两个时间点的间隔只有 1/T,只要动画变化速度不快,肉眼就很难察觉。但如果直接使用frameCount作为时间坐标,让时间从 0 一直增长到很大的数,那么 F(T-1) 和 F(0) 之间可能已经积累了明显的时间差,跳变就不可避免。
更稳妥的做法是使用周期函数来驱动一切动态量。比如让角度参数取progress * TWO_PI,其中 progress 从 0 线性增长到接近 1,那么当 progress 回到 0 时,角度也回到初始位置,系统自然闭合。周期函数保证 F(0) 与 F(1) 在数学上相等,同时因为帧率较高,F(T-1) 与 F(0) 的视觉差值会非常小。Loop-me 在粒子部分正是采用这种思路,而在噪声场部分,则可以结合速度控制来实现单向流动感。
1.3 Loop-me 如何把 loop 变成设计目标
Loop-me 的设计核心可以总结成一句话:所有随时间变化的内容,都必须由同一个归一化时间参数 progress 来驱动。progress 的取值范围是 [0, 1),它表示当前帧在一个完整循环中的位置。这样设计有三个好处。
第一,可复现性更强。只要随机种子不变,每一次运行同一套参数都会得到同样的结果,这非常有利于调试和导出。第二,便于控制循环边界。如果某个动态参数希望严格闭环,只需要让它成为 progress 的周期函数;如果希望它单向流动,只需要让它在 progress 变化时缓慢推进,保证边界差足够小。第三,代码结构更清晰。所有函数都接收 progress 作为参数,而不是各自去读全局的 frameCount,排查问题时只需要检查 progress 的计算过程。
Loop-me 的价值不在于算法有多复杂,而在于把“循环”从播放模式提升为生成约束。这个思路可以迁移到很多场景中,包括动画系统、数据可视化、WebGL 特效,甚至扩展到 agent loop 这类工程系统的设计中:一个稳定、可终止、可复现的循环,在设计时同样需要考虑边界条件、状态收敛和参数可见性。
2. 环境准备与最小项目骨架
2.1 运行环境和前置知识
Loop-me 的示例使用 p5.js 实现,因为它能直接在浏览器中运行,安装依赖少,适合快速验证生成艺术效果。运行本文示例只需要三样东西:一个现代浏览器、一个文本编辑器、一个可以访问 p5.js CDN 的网络环境。如果想离线运行,可以下载 p5.min.js 放置到本地 js 目录。
前置知识方面,不需要掌握复杂算法。会写基础 JavaScript 函数、理解变量和 for 循环,了解 p5.js 的 setup 和 draw 两个生命周期方法,就足够阅读本文。如果之前写过 Processing 或 p5.js 的任何示例,上手会更快。
建议环境如下:
| 项目 | 推荐版本或工具 |
|---|---|
| 浏览器 | Chrome、Edge、Firefox 任一最新稳定版 |
| p5.js | 1.9.x 或 2.x 均可,需确认 CDN 地址可用 |
| 编辑器 | VS Code 或其他任意文本编辑器 |
| 静态服务器(可选) | python3 -m http.server 8000 或 npx serve |
| ffmpeg(可选) | 4.x 以上,用于导出 GIF 或视频 |
2.2 项目目录
Loop-me 的最小项目结构非常简单,只有三个文件。config.js 单独存放所有可调参数,sketch.js 负责绘制逻辑,index.html 负责加载依赖和入口脚本。
loop-me/ ├── index.html ├── config.js └── sketch.js把参数从绘制逻辑中拆出来的原因是,生成艺术项目在调参阶段非常频繁,如果每次调参数都要去翻绘制函数,很容易改错位置。单独一个配置层可以让你快速对比不同参数下的画面效果。后续如果项目变大,还可以增加 lib 目录存放 p5.js,增加 frames 目录存放导出的帧序列。
2.3 入口 HTML 与配置层
index.html 的任务很明确:引入 p5.js,再依次引入 config.js 和 sketch.js。脚本顺序不能颠倒,因为 sketch.js 在初始化时就会读取 CONFIG 对象。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Loop-me</title> <script src="https://cdn.jsdelivr.net/npm/p5@1.9.4/lib/p5.min.js"></script> <script src="config.js"></script> <script src="sketch.js"></script> </head> <body> <main></main> </body> </html>config.js 中定义了一个全局对象 CONFIG,集中存放画布尺寸、循环时长、帧率、噪声参数、粒子参数和调色板。这样做的目的是让整个项目成为“参数驱动的生成器”,以后想生成不同风格的循环动画,只需要改配置,不需要动代码逻辑。
const CONFIG = { SEED: 20240101, SIZE: 800, LOOP_DURATION: 4, FPS: 30, TOTAL_FRAMES: 4 * 30, GRID_SIZE: 40, NOISE_SCALE: 0.08, NOISE_SPEED: 2, MIN_LINE: 4, MAX_LINE: 18, PARTICLE_COUNT: 40, PARTICLE_BASE_RADIUS: 120, PARTICLE_RADIUS_RANGE: 260, PARTICLE_SIZE: 3, ORBIT_TURNS: 1, PALETTE: [ [23, 24, 44], [232, 104, 80], [245, 202, 110] ] };这里注意到 TOTAL_FRAMES 是通过 LOOP_DURATION 和 FPS 相乘得到的。循环时长 4 秒、帧率 30,那么总帧数就是 120 帧。这样设置比直接写一个固定数字更直观,改循环时长时不容易漏改帧数。
3. 实现一个可无缝循环的噪声场
3.1 用 progress 统一所有动态时间
sketch.js 的入口逻辑集中在 setup 和 draw 两个函数中。setup 负责创建画布、设置帧率、固定随机种子,并初始化噪声采样的相位。draw 每帧执行,先计算当前帧在一个循环中的 progress,再根据 progress 绘制噪声场和粒子。
let phase; function setup() { createCanvas(CONFIG.SIZE, CONFIG.SIZE); frameRate(CONFIG.FPS); randomSeed(CONFIG.SEED); phase = random(1000); noStroke(); } function draw() { const progress = (frameCount % CONFIG.TOTAL_FRAMES) / CONFIG.TOTAL_FRAMES; background(colorAt(0, CONFIG.PALETTE, 0.1)); drawNoiseField(progress); drawParticles(progress); }progress 的计算方式是整个项目的关键:frameCount % CONFIG.TOTAL_FRAMES会把帧号限制在 0 到 119 之间,再除以 120 得到 0 到约 0.9917 的小数。它表示当前帧在这个循环中的位置。由于求余运算天然具有周期性,progress 会一直在这个范围内循环,不会无限增长。
background(colorAt(0, CONFIG.PALETTE, 0.1))中的透明度 0.1 表示每帧覆盖一层淡淡的背景色,制造运动残影效果。如果你想要干净的画面,可以把最后的透明度参数改成 1。这里要注意,background 带透明度时,画面会累积之前几帧的痕迹,能明显提升动画的流动感,但如果背景透明度太低,残影会变得非常脏,需要根据画面反复调整。
3.2 noise 采样如何回归循环边界
噪声函数是生成艺术中最常用的随机源。它和 random 不同,noise 在空间上是连续的:相邻的自变量通常能得到相近的结果。因此它非常适合用来生成平滑流动的线条、粒子和纹理。但在循环动画中,noise 有一个常见陷阱:如果你直接使用frameCount或者progress的线性值作为 noise 的第三个采样轴,那么当 progress 从接近 1 回到 0 时,采样点会发生一次明显跳变。
Loop-me 的默认做法是控制推进速度,让这种跳变尽可能小。噪声场函数中,noise 的第三维参数是progress * CONFIG.NOISE_SPEED,NOISE_SPEED 默认只有 2。最后一帧的 progress 是约 0.9917,采样坐标约为 1.9834,而下一轮第一帧的采样坐标是 0,两者差值只有约 0.03。在 Perlin 噪声中,这个差值造成的角度变化非常小,肉眼看不出跳动。
function drawNoiseField(progress) { const step = CONFIG.GRID_SIZE; for (let x = 0; x <= width; x += step) { for (let y = 0; y <= height; y += step) { const n = noise( x * CONFIG.NOISE_SCALE + phase, y * CONFIG.NOISE_SCALE + phase, progress * CONFIG.NOISE_SPEED ); const angle = n * TWO_PI; const length = map(n, 0, 1, CONFIG.MIN_LINE, CONFIG.MAX_LINE); stroke(colorAt(progress * 0.4 + n * 0.3, CONFIG.PALETTE, 0.8)); strokeWeight(1.5); line( x, y, x + cos(angle) * length, y + sin(angle) * length ); } } }如果你追求数学上严格无缝,可以把噪声场的采样坐标改成周期形式。例如用 cos 和 sin 构造一个环形路径,让 progress 从 0 到 1 时,采样点绕一圈回到原位。这样 progress=0 和 progress=1 位置完全相同,视觉闭合最可靠。
const timeX = phase + cos(progress * TWO_PI) * CONFIG.NOISE_SPEED; const timeY = phase + sin(progress * TWO_PI) * CONFIG.NOISE_SPEED; const n = noise( x * CONFIG.NOISE_SCALE + timeX, y * CONFIG.NOISE_SCALE + timeY );不过周期式采样会让噪声场出现一种“往返摆动”的视觉感觉,而不是单方向的流动。Loop-me 作为简单项目,默认选择线性推进加低速控制,同时保留周期式写法作为严格闭环的替代方案。实际项目里可以根据画面风格二选一,两种写法都在一个循环系统内成立。
3.3 用粒子把“循环感”固定住
粒子是生成艺术中制造循环感的重要元素。Loop-me 的粒子系统围绕画布中心旋转,每个粒子分布在不同的半径上。半径由噪声决定,而旋转角度则由 progress 与黄金角共同计算。
function drawParticles(progress) { const cx = width / 2; const cy = height / 2; noStroke(); fill(colorAt(0.8, CONFIG.PALETTE, 1)); for (let i = 0; i < CONFIG.PARTICLE_COUNT; i++) { const golden = i * 2.399963; const radius = CONFIG.PARTICLE_BASE_RADIUS + noise(i * 0.3, phase) * CONFIG.PARTICLE_RADIUS_RANGE; const orbit = golden + progress * TWO_PI * CONFIG.ORBIT_TURNS; const px = cx + cos(orbit) * radius; const py = cy + sin(orbit) * radius; circle(px, py, CONFIG.PARTICLE_SIZE); } }这里的关键参数是 ORBIT_TURNS,它表示整个循环过程中粒子旋转多少圈。它必须是整数,因为只有整数圈才能保证 progress 从 0 到 1 后,粒子的角度位置与起点完全一致。如果写成 1.25,那么循环结束时会比起点多转了四分之一圈,画面必然出现跳变。
radius 使用 noise 计算时,只依赖粒子的索引 i 和固定的 phase 值,不依赖 progress,所以半径在整个循环中是静止的。如果你希望半径也参与循环运动,可以把 noise 的采样参数改成progress * TWO_PI的周期函数,确保首尾一致。
4. 参数表与颜色系统
4.1 CONFIG 参数速查
Loop-me 的调参体验非常重要。参数并不是越多越好,而是每个参数都要有明确的视觉影响。下面表格列出本文示例中主要参数的含义、默认值和调整方向。
| 参数 | 含义 | 默认值 | 调小影响 | 调大影响 |
|---|---|---|---|---|
| SIZE | 画布边长 | 800 | 画布更小,细节密度相对更高 | 画布更大,计算量增加 |
| LOOP_DURATION | 一个循环时长 | 4 秒 | 循环更快,边界跳动更明显 | 变化更慢,导出文件更大 |
| FPS | 播放帧率 | 30 | 动画更卡顿 | 更流畅,导出成本更高 |
| TOTAL_FRAMES | 总帧数 | 120 | 循环太短,变化不完整 | 循环太长,容易暴露重复感 |
| GRID_SIZE | 噪声网格间距 | 40 | 线条更密,细节更多 | 线条更疏,画面更简洁 |
| NOISE_SCALE | 噪声空间缩放 | 0.08 | 频率更低,纹理更宽 | 频率更高,纹理更碎 |
| NOISE_SPEED | 噪声时间推进速度 | 2 | 流动更慢,边界更平滑 | 流动更快,边界跳变更明显 |
| ORBIT_TURNS | 粒子旋转圈数 | 1 | 粒子转得更慢 | 粒子转得更快 |
| PARTICLE_COUNT | 粒子数量 |