p5.js粒子流场生成艺术:基于Perlin噪声与UID种子的交互式宇宙
发布时间:2026/9/18 6:01:04 作者:尧图编辑部 阅读量:1,286

先说结论这版「三才流場」是我用 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.jsp5.js 更轻量我这种项目不需要 3D 渲染管线也没有复杂光照和模型硬上 Three.js 反而要处理一堆无关概念。相较 Processingp5.js 的生态和分享路径更适合做“网页交互艺术”——我可以直接把成品嵌入一个 HTML 页面别人打开链接就能玩不需要装 Java。当然如果粒子数量后期要做到 10 万以上并且还要保持 60fpsp5.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.003到0.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键重新生成粒子但保持当前 UIDs键保存 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粒子数量减半你会得到一张非常有“星轨”质感的作品。