用 20,000 个点画一只会呼吸的水母:p5.js 生成艺术
把一段高度压缩的 p5.js 数学代码展开为可交互的发光水母:支持鼠标跟随、视差、扰动、拖尾、渐变和实时参数控制。
一段不到两行的 p5.js 代码,可以让 20,000 个半透明点组合成一只持续呼吸、摆动和流动的“水母”。它没有模型文件,没有图片,也没有粒子物理引擎;每一个点的位置都来自同一组由索引和时间驱动的数学公式。
我把原始作品展开为一个完整的交互网页,并在保持核心公式不变的基础上加入了:
- 鼠标与触摸跟随;
- 基于深度的视差;
- 随指针距离增强的局部扰动;
- 可调节的帧间拖尾;
- Canvas 发光和 Screen 混合;
- 沿纵向、深度与时间流动的 HSB 渐变;
- 粒子数量、速度、拖尾、发光、色相等实时控制;
- 暂停、重置、隐藏面板与 PNG 保存。
移动鼠标或在触屏上拖动,观察水母的跟随、视差与扰动。若设备性能有限,可先降低“粒子数量”或关闭发光。
原始代码
原始 code-golf 版本为:
1
2
a=(y,d=mag(k=(8+sin(i/19+t))*cos(i/49),e=y/8-12)**2/79+1)=>point(k/d*4-e*sin(k)+k/(d*d)*(12+d*6*sin(d*d-t+cos(t/3)+.3*sin(e)))+200,12*sin(d*2.6-t)+d*66+40)
t=0,draw=$=>{t||createCanvas(w=400,w);background(9).stroke(w,66);for(t+=PI/80,i=2e4;i--;)a(i/99)}//
这里用到了大量压缩技巧:省略变量声明、复用全局变量、用默认参数执行赋值,并把初始化塞入 draw()。展开后,它的核心计算可以写成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const y = i / 99;
const k = (8 + sin(i / 19 + t)) * cos(i / 49);
const e = y / 8 - 12;
const d = mag(k, e) ** 2 / 79 + 1;
const x =
k / d * 4
- e * sin(k)
+ k / (d * d) *
(12 + d * 6 * sin(d * d - t + cos(t / 3) + 0.3 * sin(e)))
+ 200;
const py = 12 * sin(d * 2.6 - t) + d * 66 + 40;
point(x, py);
y 在这里不是屏幕纵坐标,而是从粒子索引换算出的采样参数。真正的屏幕纵坐标是最后计算出的 py。
公式如何构成水母
1. 采样轴
1
y = i / 99
i 原本从 0 扫描到 19,999。除以 99 后,得到一条约为 0 到 202 的参数轴。每个索引都对应一个确定的点,因此这里所谓的“粒子”,更准确地说是参数化点云的离散采样。
交互版本允许改变粒子数量,但不会直接改变公式中的采样范围。它把任意数量的点重新映射回原作的固定区间:
1
sampleI = n / (count - 1) × 19999
所以无论绘制 3,000 还是 30,000 个点,水母的完整轮廓都会保留,改变的只是密度与性能负载。
2. 横向摆动 k
1
k = (8 + sin(i / 19 + t)) × cos(i / 49)
8 + sin(...) 给出稳定的基本宽度和小幅呼吸,cos(i/49) 则以另一种频率改变符号与幅度。两个不同频率相乘会形成拍频和干涉,因此轮廓不会像规则圆环一样机械。
时间 t 进入第一个正弦波,让相位沿点云持续传播。k 后续既参与横坐标,又参与深度 d,所以它同时影响宽度、卷曲和纵向层次。
3. 中心化参数 e
1
e = y / 8 - 12
这一步压缩采样轴,并把零点移到采样区间内部,使 e 同时拥有负值和正值。后面的 -e × sin(k) 借此产生左右翻折;sin(e) 又会给内部波纹加入细小褶皱。
4. 深度层级 d
1
2
d = mag(k, e)² / 79 + 1
= (k² + e²) / 79 + 1
d 是点 (k,e) 到参数原点的平方距离,经缩放后再加 1。平方距离让远处点增长更快,形成明显的内外层次。
+1 也很关键:后面多次出现 1/d 和 1/d²,它避免了除以零。最终纵坐标包含 66d,所以 d 越大的点总体越靠下,形成从伞盖到裙边的展开。
5. 内部波相位
1
phase = d² - t + cos(t / 3) + 0.3 × sin(e)
d²让不同深度拥有非线性变化的相位,产生层状波纹;-t让波纹持续向前传播;cos(t/3)是低频的全局推拉;0.3sin(e)沿采样轴加入不规则的小褶皱。
6. 最终横坐标
1
2
3
4
x = 4k/d
- e × sin(k)
+ k/d² × (12 + 6d × sin(phase))
+ 200
它可以理解为四层叠加:
4k/d:中心附近的横向摆动;越远离核心,贡献越小。-e × sin(k):产生左右翻折和交叉的裙边结构。k/d² × (...):把中心收束与沿深度传播的波结合起来。+200:把数学原点移到原始 400 像素画布的水平中心。
第三项还可以展开为:
1
12k/d² + 6k/d × sin(phase)
第一部分在核心附近更强,第二部分负责沿深度传播的摆动。1/d² 与 1/d 的不同衰减速度,是伞盖内部和外围运动尺度不一致的原因之一。
7. 最终纵坐标
1
py = 12 × sin(2.6d - t) + 66d + 40
66d 是向下展开的主要骨架,12sin(2.6d-t) 则在纵向加入传播波,让各层上下起伏。+40 为顶部留出空间。
水母感并不来自某个单独的“水母函数”,而是来自多层结构同时作用:平方距离建立深度,倒数项收束核心,多组不同频率的三角函数制造褶皱和干涉,时间再让横向与纵向相位共同流动。
动画与视觉效果
主循环与时间
p5.js 只调用一次 setup(),然后持续调用 draw()。原作每帧执行:
1
t += π / 80
网页版本加入了帧时间修正:
1
t += (π / 80) × speed × dt
这样动画速度更接近真实时间,不会在 120 Hz 屏幕上快一倍,也不会因为短暂掉帧产生过大的时间跳变。
Alpha 叠加
单个点的透明度很低,但大量点在相近位置重叠后,亮度会逐渐累积。作品对粒子使用 Screen 混合模式,使重叠区域越来越亮,适合表现光、水和雾状体积。
拖尾
网页没有在每帧彻底清空画布,而是在旧画面上盖一层半透明深色矩形。旧像素因此经过多帧逐渐衰减:
- 拖尾为 0 时,覆盖层接近不透明,上一帧立即消失;
- 拖尾提高后,覆盖层变得更透明,运动轨迹保留得更久。
这种方式不需要保存历史粒子,直接利用 Canvas 已有的帧缓冲即可。
发光
发光由 Canvas 的 shadowBlur 与稀疏的第二层粗点共同完成。为了避免绘制量直接翻倍,第二层只抽样一部分粒子。发光比纯点绘制更消耗性能,因此低性能设备应优先降低发光强度。
颜色渐变
作品使用 HSB 色彩空间,色相由纵向位置、深度和时间共同决定:
1
2
3
hue = baseHue
+ hueSpread × verticalMix
+ 18 × sin(0.45t + 0.7d)
水母上下区域因此拥有不同颜色,渐变还会随时间缓慢流动。亮度、饱和度和透明度同时依赖深度,进一步强化了体积感。
鼠标跟随、视差与扰动
指针位置先从画布坐标映射到 -1…1,再经过两层线性插值。第一层过滤突然跳变,第二层制造滞后,视觉上更像柔软身体带着惯性追随。
d 被进一步转换成一个近似深度值。靠近核心的点获得更多指针位移,远层获得较少位移,因此产生柔和的二维视差。
鼠标离画面中心越远,pointerEnergy 越高。它会放大一组依赖粒子索引与时间的正弦扰动,所以移动到边缘时水母会变得更活跃,而回到中心后逐渐平静。
参数和快捷键
| 参数 | 默认值 | 作用 |
|---|---|---|
| 粒子数量 | 18,000 | 点云密度;对性能影响最大 |
| 动画速度 | 1× | 时间变量增长速度 |
| 拖尾长度 | 78% | 旧帧保留时间 |
| 发光强度 | 18 px | 光晕模糊半径 |
| 基础色相 | 190° | 整体主色调 |
| 渐变跨度 | 95° | 水母上下区域的颜色差异 |
| 鼠标扰动 | 0.8 | 指针引发的局部波动 |
| 跟随幅度 | 34 px | 整体跟随与视差强度 |
快捷键:
| 按键 | 功能 |
|---|---|
| Space | 暂停或继续 |
| H | 隐藏或显示面板 |
| R | 恢复默认值 |
| S | 保存当前画面为 PNG |
| T | 开关拖尾 |
| G | 开关发光 |
[ / ] | 减少或增加粒子 |
| ↑ / ↓ | 调整速度 |
| ← / → | 调整色相 |
暂停只会停止时间演化,鼠标交互仍然有效,便于观察同一数学形态在不同指针位置下的变化。
性能建议
如果动画不够流畅,按这个顺序调整:
- 将粒子数量降到 8,000–12,000;
- 降低发光强度,或按
G关闭发光; - 缩短拖尾,防止画面过度累积;
- 关闭其他高负载页面或应用。
网页固定使用 pixelDensity(1),避免高分屏把 Canvas 实际像素数放大数倍。窗口改变尺寸时,公式仍然在原始的 400 × 400 坐标系里计算,只在外部统一缩放,因此水母不会因屏幕比例不同而变形。
后续可以继续做什么
- 增加“深海蓝”“霓虹紫”“极光绿”等预设;
- 把参数写进 URL,让特定视觉状态可以分享;
- 用 Web Audio API 让低频控制呼吸、高频控制发光;
- 使用
MediaRecorder录制 WebM 动画; - 把公式迁移到 WebGL 顶点着色器,扩展到十万级粒子;
- 同时绘制多个不同大小、色相和时间偏移的水母;
- 加入自动性能调节,根据实时帧率降低光晕或粒子密度。
从短短两行 code-golf 到完整的交互作品,真正保持不变的是它的数学骨架。所有鼠标控制、发光和渐变都只是叠加层;水母之所以像软体生物一样呼吸,仍然来自 i、t、距离衰减和多组周期函数之间的干涉。