p5.js粒子流场生成艺术:基于Perlin噪声与UID种子的交互式宇宙
2026/9/18 6:00:58 网站建设 项目流程

先说结论:这版「三才流場」是我用 p5.js 折腾了大概两周的生成艺术项目,核心其实不复杂——就是一个基于 Perlin 噪声的粒子流场,加上鼠标交互和一套固定随机种子系统。但好玩的地方在于,我把“天、地、人”这套传统概念映射进了算法结构里,再配合一个固定的 UID 作为随机种子,让每个作品都能生成一个“只有这个编号才有的粒子宇宙”。如果你对 p5.js 粒子系统、噪声流场或者交互式生成艺术感兴趣,这篇文章应该能给你一份可以直接抄作业的完整方案,包括代码思路、参数调优和一些文档里不会写清楚的坑。

我常说,p5.js 这类的创意编程工具,真正的门槛不是语法,而是“如何把脑子里那个画面翻译成数学和状态机”。本文会从整体架构讲起,再一步步拆解流场算法、粒子系统、交互设计、渲染优化,最后落到实际调试过程中遇到的几个典型问题上,保证每个环节都有可跑通的代码和具体参数。

1. 整体架构:把“三才”翻译成算法模块

1.1 先理解“三才流場”到底在做什么

“三才”这个概念来自传统哲学里的天、地、人三才,它讲的是宇宙的三个基本层面:天代表时间、运动和变化;地代表空间、结构和承载;人代表感知、干预和互动。我在做这个 p5.js 项目的时候,一开始不是硬套概念,而是画完草图之后发现——一个粒子流场系统天然就可以拆成这三层。

  • 天才层:驱动粒子运动的噪声场。它随时间变化,每一帧都在流动,这是“变化”。
  • 地才层:承载粒子的画布空间、整体视觉结构,比如背景渐变、粒子颜色分区,这是“空间”。
  • 人才层:观众/用户的鼠标操作、键盘切换,让固定的算法第一次产生“人为扰动”,这是“互动”。

项目唯一标识符用 UID9622 来体现,它其实是一个随机种子。我后面会详细讲——种子值固定后,流场、粒子初始位置、颜色分布全部由这个 UID 推导出来,所以每个人只要拿到同一个 UID 并运行代码,就能得到完全一致的“宇宙”,但换一个 UID,整个世界的形态就全变了。这种“作品可复现、又可无限变异”的特性,恰好也是生成艺术最迷人的地方。

1.2 为什么选 p5.js 而不是 Processing 或 Three.js

我知道很多做生成艺术的朋友会纠结这个事情。我个人的经验是:如果你主要做二维粒子、流体、噪声类作品,p5.js 在快速迭代方面确实有优势——浏览器里直接刷新就能看效果,不需要编译,而且 p5.js 的draw()循环、noise()函数、createGraphics()blendMode()这些 API 几乎是为这类作品量身定做的。

相比 Three.js,p5.js 更轻量,我这种项目不需要 3D 渲染管线,也没有复杂光照和模型,硬上 Three.js 反而要处理一堆无关概念。相较 Processing,p5.js 的生态和分享路径更适合做“网页交互艺术”——我可以直接把成品嵌入一个 HTML 页面,别人打开链接就能玩,不需要装 Java。当然,如果粒子数量后期要做到 10 万以上并且还要保持 60fps,p5.js 的 Canvas 2D 会吃力,到那个量级我会考虑迁移到 WebGL 或点精灵方案,但那是后话。

1.3 模块划分与实际代码骨架

我写代码习惯先画模块图,但我不会真用 UML 工具,一般就在草稿纸上写流程。核心分四块:流场计算、粒子系统、交互控制、渲染合成。其中交互控制不直接改粒子位置,而是通过改写流场的力度、方向或扰动半径来影响粒子运动。这样设计的目的很明确:把交互和粒子解耦,方便后续加多种交互模式,也不会因为某次鼠标事件弄崩整个粒子集群。

// 伪代码骨架 let field = new FlowField(); let particles = []; function setup() { createCanvas(1200, 800); seedRandomFromUID("UID9622"); field.init(scale, zOffset); for (let i = 0; i < PARTICLE_COUNT; i++) { particles.push(new Particle(random(width), random(height))); } } function draw() { // 使用低透明度矩形做拖影,而不是清屏 background(0, 10); field.updateTime(); for (let p of particles) { let force = field.getForce(p.x, p.y, z); p.applyForce(force); p.update(); p.display(); } }

后面每一节我会把这条主循环里的每个部分都展开讲透。

2. 核心流场:让粒子“自己知道往哪流”

2.1 Perlin 噪声流场的基本原理

流场(Flow Field)这个概念,玩过生成艺术的人应该不陌生。它的思想其实非常简单:把画布划分成一定间距的网格,每个格子中心点取一个角度值,然后把角度转成向量,粒子运动到某个格子附近时,就受到该格子向量的牵引。

关键就是:每个格子的角度怎么来?如果纯随机,粒子会像布朗运动一样乱撞,画面没有连续感;如果用正弦函数,效果又过于规整。Perlin 噪声的价值在于,它可以在随机中保持空间连续性——相邻点之间的噪声值平滑过渡,所以用噪声值映射到角度,就能得到一个“既有随机性又有流动感”的向量场。

我在 p5.js 里的核心实现:

function getAngle(x, y, z) { let n = noise(x * 0.005, y * 0.005, z); return n * TWO_PI * 2; // 两圈映射,让角度变化更丰富 }

这里z是时间维度,每一帧让z += 0.0005+= 0.001,就能让整个流场动起来。颗粒度0.005是经验值,太小(比如 0.001)会导致全场角度几乎一致,粒子全往同一个方向飞,画面变成长长的线条,缺乏细节;太大(比如 0.05)会让相邻格子的角度差异过大,流场变成一团乱麻,粒子运动像是在布朗运动。0.0030.008之间是我在 1200 宽的画布上试出的最佳区间。

2.2 三维噪声里的“时间轴”到底管什么

p5.js 的noise()函数默认支持三维参数,这正是做流场动画的利器。我不需要真的做一个 3D 空间,而是把第三维当作“时间”。每一帧全局z增加一点,所有粒子访问到的噪声值就都在一个时间切片上平滑移动。

有朋友会问:直接对每个粒子用noise(x, y, frameCount)不行吗?理论上可以,但如果不做网格缓存——就是先算好网格点的角度,再让粒子去格子索引查向量——性能会非常差。因为每个粒子每一帧都要调用一次noise()函数,而noise()本身是开销较大的计算。一万个粒子就意味着一万次三维噪声求值,帧率很容易跌破 30。

我的优化方式是:先按固定网格分辨率(比如 20 像素)建一个二维数组,每隔几帧(比如每 5 帧)重新计算一次所有网格点的角度,然后粒子查找时直接四舍五入到最近的网格点索引,读取预计算向量。这个方案的取舍点是“流场精度”与“计算开销”的折中。实测下来,网格分辨率 20 像素时,视觉上几乎看不出和逐点计算的差别,但性能提升了三倍以上。

2.3 UID 种子系统:如何让“UID9622”变成唯一世界

这个项目叫“UID9622专属宇宙”,所以 UID 必须真正参与到生成逻辑里,而不只是写死在标题上。p5.js 的random()函数底层是伪随机数生成器,默认每次运行都会重新播种。要实现“同一个 UID 得到同一个宇宙”,我得在setup()最开始自己种下种子。

// 把字符串 UID 转换成数字种子 function hashUID(str) { let h = 0; for (let i = 0; i < str.length; i++) { h = (h * 31 + str.charCodeAt(i)) >>> 0; } return h; } function seedRandomFromUID(uid) { randomSeed(hashUID(uid)); noiseSeed(hashUID(uid + "_noise")); }

这里注意一个很容易踩的坑:randomSeed()noiseSeed()需要分开种。如果你的流场使用noise(),而粒子的初始位置使用random(),只种一个的话,下次运行结果还是对不上。更细节的是,给noiseSeed()种子的值如果恰好和randomSeed()一样,某些情况下会产生视觉上相似的噪声分布,所以我习惯给噪声种子字符串加一个后缀。

有了这套种子系统,别人运行你的代码时只要把 UID 换成UID9622,看到的就是完全一样的粒子轨迹和背景结构。这叫“可控的随机”——代码里有随机,但随机序列是固定种子决定的,因此作品既是无穷无尽的,又是可复现的。很多生成艺术家会把种子印在作品标题或 NFT 里,观众想复现,只需要找到对应 UID。

2.4 模式切换:几种流场形态的数学组合

为了让交互更有趣,我给“天才层”设计了 4 种流场模式,通过键盘数字键 1-4 切换:

模式数学原理视觉效果
1 默认流动noise(x, y, z) * TWO_PI * 2柔和、绵延的云带状流动
2 漩涡在噪声角基础上叠加以画布中心为圆心的旋转角粒子绕中心旋转,形成漩涡
3 放射角度由粒子相对画布中心的方位角决定,再加噪声扰动从中心向外放射或内聚
4 引力陷阱在画布几个固定点周围叠加指向性向量场粒子被多颗“恒星”吸引

模式切换的核心就一行:在getForce()返回值上增加一个模式偏移量。比如漩涡模式的实现:

let centerX = width / 2; let centerY = height / 2; let dx = x - centerX; let dy = y - centerY; let angleToCenter = atan2(dy, dx) + HALF_PI; let angle = getAngle(x, y, time) * 0.6 + angleToCenter * 0.4;

不同模式的比例因子我调了很久,默认0.6 + 0.4的组合既能保留噪声场的流动性,又能清晰看出漩涡结构。如果噪声比例太高,用户根本看不出模式有区别;如果漩涡比例太高,画面又容易被“吸”成一个死板的同心圆。这个 0.6/0.4 的比例我称它为“流动感与结构感”的平衡点。

3. 粒子系统:每一个点都有自己的脾气

3.1 粒子属性设计

流场决定了粒子的方向,但粒子本身的属性决定了画面的质感。我最初写粒子只存了x, y, vx, vy,跑起来后发现画面过于单调。迭代之后,我给每个粒子增加了下面这些属性:

  • life:生命周期,从 255 逐渐衰减到 0,粒子在生命末期变得透明,然后重新出生,实现无限循环的“生死更替”。
  • size:粒子半径,初始时在 0.5 到 2.5 之间随机,个别粒子可以是大亮点,增加景深。
  • hue:色相,根据粒子所在区域在色环上取不同区间,形成左右冷暖对比。
  • age:年龄,用于控制粒子的拖尾长度。每帧绘制时,我会根据age调整圆点的透明度,越老的粒子越淡。

这里的关键设计是:粒子不会永久存活。如果没有生命周期,所有粒子最终都会聚集在流场的某个“吸引子”或角落,画面逐渐失去活力。让粒子在生命耗尽时重新在画布随机位置出生,才能保证整个系统长期维持一种“动态平衡”。

3.2 位置更新与速度限制

粒子更新逻辑是所有流场类作品最核心的部分,也最容易出错。标准流程是:

applyForce(force) { this.vx += force.x; this.vy += force.y; } update() { this.vx *= 0.9; // 阻尼 this.vy *= 0.9; let speed = dist(0, 0, this.vx, this.vy); if (speed > MAX_SPEED) { this.vx = (this.vx / speed) * MAX_SPEED; this.vy = (this.vy / speed) * MAX_SPEED; } this.x += this.vx; this.y += this.vy; }

阻尼系数 0.9 的作用是模拟摩擦力,没有它,粒子一旦被流场加速就会越跑越快,最终飞离画布。但阻尼太高也不行,粒子会几乎“黏”在原地,流场效果完全体现不出来。0.88 到 0.93 是我实测下来比较舒服的范围。

速度上限MAX_SPEED我一般设 2.5 到 4。这个值决定了粒子的拖尾长度和整体动态感。数值越大,粒子的运动越“凌厉”,但不是越大越好——速度过大时,每帧位移超过一个网格单元,粒子会直接跳过流场中的细节区域,画面变得像遍布随机短线,失去了流场的连续性。

3.3 渲染层:拖影、混合模式与色彩书写

p5.js 画粒子最笨的方法就是每帧清空背景,然后画所有点。但那样画面会闪烁,也没有“流场”那种被时间拖曳出的丝绢感。我用了经典的“半透明蒙层”技巧:

// 在 draw 开头 background(0, 8);

每次画背景时,不把它设成不透明,而是叠加一层透明黑。这样上一帧的粒子画面会保留少部分,新一帧的画面叠加在上面,视觉上就形成了拖影。8是透明度,值越大拖影越短,值越小拖影越长。注意,这个值对最终画面影响极大——透明度为 2 时画面几乎像长曝光摄影,粒子会拖出极长的轨迹;透明度为 16 时画面则更接近短线动画,干净利落。

混合模式我用的是默认BLEND,但在绘制粒子时配合colorMode(HSB, 360, 100, 100, 255),这样我能用色相来控制美学风格。比如某个粒子hue = 200,它就偏蓝色,再让它的亮度随life衰减,这样粒子生命周期的视觉反馈会很强。

3.4 粒子数量与帧率的取舍

这可能是很多 p5.js 粒子项目实际碰到的头号问题:粒子一多,帧率就崩。我这张 1200×800 的画布,粒子数量从 3000 到 8000 都有试过,差别很微妙:

粒子数帧率(普通笔记本)视觉观感
200060fps干净,适合大面积留白构图
500050-55fps饱满,细节丰富,比较推荐
800035-40fps密集,画面略显拥挤,卡顿感出现
1200020fps左右发丝感很强,但交互响应迟钝

如果一定要上 12000 粒子,就必须改用drawingContext直接操作 Canvas 原生 API,或者用createGraphics()做离屏渲染 +image()一次贴回来,减少大量绘制指令调用。我在最终版本里把粒子数控制在 5000 左右,因为我还保留了大量交互功能,粒子太多会导致鼠标拖拽时的响应明显掉帧,体验反而不如少点粒子。

4. 交互设计:人是如何参与“生成”的

4.1 鼠标拖拽:把“人的意志”注入流场

大多数流场作品都是一段无人值守的动画,观众只能看。但我这个项目既然叫“完整交互版”,就必须把人放进去。鼠标交互我实现了三种效果,按住鼠标左键拖拽时,粒子会受到一个朝向鼠标当前位置的吸引力;按住右键拖拽时,粒子会被反向推开;鼠标中键点击,会在点击位置生成一个短暂的“脉冲波”,把周围粒子炸开。

交互的实现不复杂,关键是在流场向量的基础上叠加一个“人工向量”。因为我前面已经把流场解耦成了getForce()函数,所以交互也只需在返回前加一段:

let mouse = createVector(mouseX, mouseY); let dir = p5.Vector.sub(mouse, this.pos); let d = dir.mag(); if (d < 200) { let strength = map(d, 0, 200, 1, 0); dir.normalize(); dir.mult(force * strength * 0.3); result.add(dir); }

这里有个细节:交互作用的距离不能太大,否则整个画面的粒子都被鼠标吸走,流场自身的形态会被完全破坏。我限制在 200 像素半径内,而且强度随距离衰减,这样鼠标只是在局部“扰动”宇宙,而不是“接管”整个宇宙。

4.2 键盘控制:模式切换与视觉参数微调

键盘交互我做了两套体系。第一套是数字键 1-4 切换流场模式,这个前面讲过了。第二套是几个视觉微调键——c键切换是否显示粒子轨迹(其实就是开关背景半透明蒙层),+/-键调节粒子数量,r键重新生成粒子但保持当前 UID,s键保存 PNG。

r键的重新生成很有用,因为粒子初始位置是随机的,有时候你切换流场模式后,画面布局并不理想,这时不改变流场、只重新撒一把粒子,往往能看到一个意外的好看构图。s键保存 PNG 我是这么实现的:

function keyPressed() { if (key === 's') { saveCanvas('UID9622_frame' + frameCount, 'png'); } }

一定要用saveCanvas()而不是自己截屏,否则容易被浏览器 UI 或其他元素干扰。p5.js 的saveCanvas()会直接保存当前画布为图片文件,高清且干净。

4.3 交互与流场的叠加顺序问题

当我同时存在噪声流场、鼠标力场和漩涡/放射等模式时,力的叠加顺序直接影响画面稳定。我曾经把鼠标作用力放在流场之前计算,结果鼠标附近的粒子像发了疯一样乱跳,完全没有任何流动感。排查后发现:两个力的量级差太多了。流场力经过归一化后向量大小通常只有 0.5 到 1.5,而鼠标力如果直接映射可能高达 5 以上,二者叠加后粒子速度被鼠标力主导,流场纹理直接被吞没。

我的解决方案是对所有外力做一个统一的“权重归一化”。在applyForce()之前,先把流场力和鼠标力等都乘以一个权重系数,再相加。这个系数通常保持在 0.2 到 0.8 之间,并且我可以动态调节鼠标影响力:

let totalForce = p5.Vector.add(flowForce.mult(0.8), mouseForce.mult(0.4)); particle.applyForce(totalForce);

交互不是“更强更好”,而是“恰到好处”。

5. 调试实录:那些文档里不会告诉你的坑

5.1 帧率不稳:罪魁祸首往往是噪声缓存失效

我最早一版代码的帧率极其不稳定,有时候 60fps,过几秒就掉到 20fps。排查时发现,每帧都对所有网格点调用noise()是非常重的 CPU 负担,于是我做了一个“每 5 帧更新一次流场网格”的优化。但帧率还是忽高忽低。

后来仔细一查,问题出在粒子数组的动态增删上。原设计里粒子死亡后立即splice()出数组,而 JavaScript 的splice()操作会移动数组后续所有元素,当粒子数量达到几千时,这就成为性能隐患。解决方案是“对象池”:死亡粒子标记为alive = false,新粒子直接复用池里的空隙,数组长度保持不变。只改这一处,帧率就稳定在了 55fps 左右。这个坑网上几乎没有人提,但实际影响很大。

5.2 粒子堆积在画布边缘,画面一半是黑的

流场粒子撞到画布边界后,如果没有边界处理,它们就会卡在边缘上一动不动,越积越多。我开始的做法是简单的回弹:碰到边界就把速度反向并衰减。结果发现粒子喜欢聚集在四个角,视觉上特别丑。

后来换成“环绕”策略:粒子穿出左边界就回到右边,穿出上边界就回到下边。这样视觉上更加连续,尤其适合宇宙主题——粒子从一侧消失,又从另一侧回归,仿佛真的在一个无限空间里流动。实现很简单:

if (this.x < 0) this.x = width; if (this.x > width) this.x = 0; if (this.y < 0) this.y = height; if (this.y > height) this.y = 0;

这个方法唯一的缺点是在流场模式为“漩涡”时,粒子大量在侧面出界又回来,会形成一条不自然的直线痕迹。所以我还加了一个小扰动:出界后位置随机偏移 5-20 像素,破坏掉直线感。

5.3 Canvas 高清屏适配:文本清晰不算完,粒子也要清晰

我把代码放到视网膜屏上测试时发现,粒子边缘发虚,对比度不够。正常情况下的 CSS 像素和物理像素有比例关系,p5.js 的createCanvas(width, height)默认只在 CSS 层做缩放,物理像素并没有用满。要解决这个问题,需要手动把画布分辨率翻倍:

function setup() { let scale = window.devicePixelRatio || 1; pixelDensity(scale); createCanvas(1200, 800); }

pixelDensity(scale)是 p5.js 提供的方法,调用了以后画布内部像素数量按设备像素比放大,粒子边缘会更尖锐、色彩更扎实。但注意,画布像素翻倍也会让计算量翻倍,如果机器性能一般,可以把scale固定为 1,或者只在导出图片时才临时切成 2。我在交互预览时用 1,保存图片时会临时把pixelDensity(2)重新调用一次并重绘几帧再保存,这样既流畅又能输出高分辨率作品。

5.4 “专属宇宙”该怎么分享:给观众一个低门槛入口

这也算调试之外的经验了。做完了项目,把 HTML 和 p5.js 库打包发给朋友,很多人直接打开文件发现白屏——原因大多是本地 file 协议下 p5.js 库加载有跨域限制。最好用的方案是直接把整个项目放到任意静态托管页面上,或者用一个简单的本地静态服务起 http 服务器。

分享时我不喜欢只丢两个文件,而是做了单页版:把p5.js库内联进 HTML 的 script 标签里,这样对方只要下载一个 HTML 文件就能离线运行整个交互宇宙。同时,在页面左上角显示当前 UID,观众按下r键刷新粒子后,整个画面的纹理仍然由 UID9622 决定——这份确定性,才是“专属宇宙”的核心体验。

写在最后:一些个人心得

做这个项目最大的体会是:p5.js 粒子流场看似是一个数学游戏,但真正把它做出“作品感”的,往往不是算法复杂度,而是对参数和交互反馈的控制。粒子太多会掩盖交互,参数太大会失去流动感;给观众一个鼠标,不是让他们控制一切,而是让他们在既有秩序中制造一点意外。我花在调 0.6/0.4 比例、粒子数量、阻尼系数上的时间,远多于写算法本身,但这些细微差别最终决定了这个“宇宙”到底像活的还是像程序。

如果你也想做一个基于 UID 的生成作品,我的建议是:先不要急着堆功能。把最简单的噪声流场跑通,加上一个固定种子,然后用一周时间每天跑同一个 UID,去观察画面上细微的流动变化。你会发现,同一个种子生成的世界里,仍然有大量不可预测的瞬间。这种“稳定中的不确定”,恰恰是生成艺术里最值得玩味的地方。最后再分享一个小技巧:保存 GIF 或长曝光截图时,把背景透明蒙层的透明度临时调到 3,粒子数量减半,你会得到一张非常有“星轨”质感的作品。

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

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

立即咨询