说实话第一次看到BlooomMedusas这个名字的时候我愣了几秒。六个字母重复排列、水母的复数拼写乍一看像个手滑打错的单词又像某个乐队没想好的专辑名。但把Blooom拆开看确实有意思——它比Bloom多了两个字母像是在强调持续绽放的过程而Medusas本身就是水母与神话美杜莎的双关。一个在缓慢呼吸、绽放另一个则意味着幻觉般的危险美感。直觉告诉我这是一个生成艺术项目的代号。我花了一周多的时间把BlooomMedusas从脑中的意象变成了一个可交互的实时数字水母群落几团半透明的光影漂浮在深色背景里伞盖一收一放像花朵呼吸触手在流体中摇曳颜色在色环上缓慢漂移。这篇文章就是把整个从灵感到落地的过程完整拆开包括设计思路、核心算法、代码实现、踩坑记录以及后续扩展的几种方向。想看代码的直接跳到第3节想理解为什么这样写的建议从头顺着读。1. 项目起因与整体设计思路1.1 意象拆解为什么是水母与绽放的组合做生成艺术的人都会遇到一个共同的困境空有技术没有意象。你熟练掌握了噪声、粒子系统、贝塞尔曲线但打开编辑器时不知道该画什么。blocks、shapes、patterns做多了都像数学公式的视觉化练习没有温度。BlooomMedusas恰好解决了这个困境。水母本身就是近乎完美的生成艺术载体身体是半透明的N层叠加触手是天然的自由曲线游动轨迹是典型的流体运动而发光和渐变又能把数字媒介的优势发挥到极致。更妙的是水母的伞盖收缩——那一下一下的脉冲本身就是bloom的过程。我把这个脉冲看作一朵花在反复开放和闭合而不是简单的水母游动。这个意象组合带来的直接好处是你不需要刻意加特效去美化它。水母的真实运动节奏已经足够优雅你要做的是用代码模拟它的生物节律然后把它放大到视觉层面。我甚至觉得这是生成艺术里最健康的一种工作方式——图像逻辑先于技术逻辑先想明白它应该是什么感觉再决定用什么技术去表达它。1.2 核心审美目标优雅、错觉、呼吸感在动手之前我先给自己定了三条审美原则全程没有改过第一优雅优先于复杂。市面上很多水母生成作品喜欢堆粒子一条水母配几千个粒子场面确实壮观但动态细节一塌糊涂。我的目标是让每条水母在低粒子数下依然耐看靠运动本身产生观赏性而不是靠数量硬撑。第二制造半透明错觉。水母最迷人的地方在于它既在这里又不在这里。真水母的身体是半透明的胶质光线穿过时会有一种虚实的暧昧感。在数字环境里我要用多层低透明度的叠加来模拟这种通透感而不是简单地把opacity调低——前者是光的折射错觉后者只是透明度参数。第三呼吸感是灵魂。我对自己的要求是即使你把画面截图静止下来也能看到它在呼吸的痕迹。水母的伞盖缩放、触手的摆动幅度、粒子扩散的半径全部要关联到一个统一的节奏源上形成一套协调的呼吸系统。观感上不能是一堆零件各自乱动。1.3 技术路线选型为什么用p5.js而不是Processing或Three.js确定意象之后面临技术选型。我不想用Processing虽然Processing在桌面端编程方面成熟稳定但还要额外处理Java运行时和窗口管理的问题。没有用Three.js的原因更简单——2D构图更能接近深海薄膜的质感而且我可不想为了体积感而烧掉过多精力在光照模型上。最终选择了p5.js理由是三个一是上手快JavaScript语法对前端背景的人没有台阶而且图形API足够直接。二是Web端传播容易浏览器打开就能运行不需要装环境。三是p5.js的2D渲染在配合globalCompositeOperation和shadowBlur时有足够的视觉效果空间。当然p5.js也有它的短板2D性能上限比WebGL低很多粒子数量超过一定阈值就会掉帧。我的应对策略很明确——不追求大粒子量把性能预算花在每一条水母的精细化上。做这个项目的核心经验就是如果你的视觉目标能用2D优雅地解决就没必要为了技术含量硬上3D。2. 核心技术选型与原理拆解2.1 运动的核心Perlin噪声驱动而不是物理模拟水母游动看起来应该悠然自得像在水中漂浮但又有自主方向感。很多初做水母的开发者第一反应是上物理引擎浮力、阻力、重力、流体场。我可以明确地告诉你这是杀鸡用牛刀。物理引擎做出来的运动偏死重水母游动需要的是生物性的柔和随机感不是精确的牛顿力学。我的方案是用Perlin噪声驱动速度和方向每条水母维护两个噪声偏移量x方向和y方向以时间为增量采样噪声值映射到速度向量。核心是这一小段逻辑const noiseScale 0.0008; const t frameCount * 0.02 this.seed; this.vel.x (noise(xBase, this.seed) - 0.5) * this.speed; this.vel.y (noise(yBase, this.seed) - 0.5) * this.speed; this.pos.add(this.vel);为什么用噪声而不是随机因为Perlin噪声的采样值在时间维度上是连续的——前几帧的数值和后几帧的数值是平滑过渡的产生的运动轨迹天然带有柔和曲线的特征。而随机函数是白噪声每一帧的值完全独立生成的运动轨迹就是高频抖动像触电一样完全没有优雅可言。这里有个关键细节this.seed必须确保每条水母有唯一值。如果所有水母共享同一个噪声种子它们会以完全相同的节奏游动看上去像克隆军队。给我的每条水母在构造函数里分配一个0到1000的随机种子噪声采样时加上种子的偏移量就能让每一条水母的轨迹独立演化。2.2 触手的画法三次贝塞尔曲线的连续扰动水母的触手是最难处理的部分既要有曲线的柔美感又不能像两根弹簧一样僵直地甩动。一开始我试过用三角函数叠加画波动曲线效果很丑——所有触手的摆动频率相同看起来机械随后换成了贝塞尔曲线但静态贝塞尔曲线没有动态。最终方案是用三阶贝塞尔曲线并让中间两个控制点随时间做平滑扰动。具体逻辑是每条触手根部固定在伞盖边缘下方的锚点末端自由浮动。控制点A和B的位置分别加一个基于时间的噪声偏移const baseA createVector(this.pos.x len * 0.3, this.pos.y len * 0.4); const baseB createVector(this.pos.x - len * 0.2, this.pos.y len * 0.8); const noiseA p5.Vector.fromAngle(noise(t, this.seed) * TWO_PI); const noiseB p5.Vector.fromAngle(noise(t 50, this.seed) * TWO_PI); ctrlA baseA.add(noiseA.mult(swayAmplitude)); ctrlB baseB.add(noiseB.mult(swayAmplitude));你可以把控制点想象成一条触手内部的脊梁——位置不断微调曲线整体摆动感就有了。触手末端的细节也值得追求我在末端加了一个渐细的处理用strokeWeight逐步衰减宽度再用低透明度描边模拟触手末端的半透明感。如果你不做这个细节画出来的触手就是一条粗细均匀的线条远远看去像海带完全没有水母的轻盈。2.3 Bloom节奏系统一个函数统一所有动画节奏这个项目最重要的突破是把绽放感变成一个统一的节奏系统——我起名叫breathCycle。它本质上是一个正弦波函数周期控制着伞盖的张合、触手摆动的幅度、粒子扩散的半径三者的节奏完全同步const breath map(sin(radians(frameCount * breathRate)), -1, 1, 0.6, 1.2); this.umbrellaScale breath; this.swayAmplitude 20 * breath; this.particleSpread 15 * breath;你可能会问把所有动画都同步到同一个正弦波上不是会显得单调吗动笔之前我也担心这个。实际效果恰恰相反——水母本身就是靠统一的脉冲节律来生存的生物所有部位共享同一套节奏反而增强了它是活物的真实感。而且我并没有让每条水母的breathRate完全相同每条水母有一个随机的相位偏移导致它们的绽放节奏互相错开即使共用节奏函数整个群落的视觉层次依然丰富。2.4 色彩设计逻辑HSL漂移代替固定色板水母的色彩是塑造气质的主要因素。我见过很多水母生成作品用固定的霓虹色板——紫配蓝配粉。不能说不好看但看多了容易腻而且这种高饱和配色容易让画面显得低龄化。我的方案是让每条水母的色相在时间轴上缓慢漂移整体色域限定在深蓝、青、紫、粉这个区间。核心是用HSL的色相角度做连续旋转每条水母初始化一个基线色相然后随时间做微小增量this.hue (this.baseHue frameCount * this.hueDrift) % 360;hueDrift控制色相漂移的速度我把它设在每帧0.01到0.03之间——视觉上几乎是觉察不到的渐变但当你在屏幕前坐五分钟会感受到整群水母的色调在不知不觉地过渡。这种没有察觉的变化才是高级的它能潜移默化地维持新鲜感又不会像霓虹闪烁那样引起视觉疲劳。我给每条水母的饱和度和明度也做了差异化饱和度控制在50%~75%之间明度控制在55%~75%之间。这个区间在高对比的深色背景上刚好能保持通透感又不会刺眼。3. 实操过程从零构建BlooomMedusas3.1 环境准备与项目结构规划开发环境我选择了最稳妥的一套VSCode p5.js CDN Live Server插件。不需要装任何额外的依赖。项目结构非常清爽blooom-medusas/ ├── index.html ├── style.css └── sketch.jsindex.html里只需要引入p5.js和两个文件没有框架、没有构建工具。我实际使用中发现p5.js 1.9.0稳定2.x版本目前API变动较大如果只是复刻这个项目建议锁版本script srchttps://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js/script script srcsketch.js/scriptstyle.css我做了两件事把body的margin清零、overflow设为hidden确保画布全屏无滚动条再把canvas设为display:block避免页面出现意想不到的间距问题。不想在样式上花时间的可以直接把这三行贴进去html, body { margin: 0; padding: 0; overflow: hidden; } canvas { display: block; }sketch.js是整个项目的核心我把所有逻辑都放在这一个文件里一共约200行。对单一页面项目来说不用拆分成多文件反而更利于阅读和调试。等代码量超过500行再考虑模块化也不迟。3.2 搭建基础类结构Medusa与Particle我把整个项目抽象成两个角色Medusa和Particle。Medusa负责水母本体的运动、伞盖绘制、触手生成Particle负责围绕水母的微粒特效——我把它设计成围绕伞盖边缘漂浮的细小光点随着绽放节奏向外扩散再被收回。这种职责分离很重要它保证每条水母的状态是自包含的位置、种子、颜色、呼吸相位都在构造时初始化。渲染循环只需要遍历数组调用update()和display()。如果你把粒子和水母的逻辑混在一起后续想调整粒子密度或者加交互逻辑会变得非常痛苦。Medusa类的初始化核心参数如下class Medusa { constructor(x, y) { this.pos createVector(x, y); this.seed random(1000); this.baseHue random(220, 320); this.hueDrift random(0.01, 0.03); this.speed random(0.25, 0.5); this.size random(40, 80); this.tentacles 8; this.breathRate random(0.8, 1.4); this.breathPhase random(TWO_PI); } }各参数的语义很直观需要解释的是baseHue区间。我的主视觉背景是深蓝紫色调hue大约220~280水母本身的色相也锁定在这个色域里但往更高的色相值延伸——偏粉、偏紫红。这么设计是为了让前景和背景构成一个同色系的层次关系而不是撞色的跳脱感。同色系配色在深色背景上天然带有高级感。Particle类的设计更简单核心是记录它所属的水母引用、距离伞盖中心的偏移角度和半径以及当前扩散的程度。在Medusa.update()里调用particle.update(breath)让粒子半径随时间增长或收缩。3.3 主程序初始化、绘制循环与交互反馈setup()和draw()在整个项目里是总控负责创建画布、初始化水母群、绘制背景和处理鼠标交互。我直接给出关键代码每一段后面附注释说明let medusas []; let bgGradient; function setup() { createCanvas(windowWidth, windowHeight); colorMode(HSB, 360, 100, 100, 100); frameRate(60); for (let i 0; i 8; i) { medusas.push(new Medusa(random(width), random(height))); } }colorMode(HSB, 360, 100, 100, 100)是我特别要提的一点。p5.js默认是RGB色彩模式但水母的色彩设计全是在HSL维度上操作的如果坚持用RGB我要手动维护三组通道值的连续变化写作复杂度成倍上升。切换HSB模式后我只需要控制色相一个维度饱和度和明度区间设好就不用管了。draw()循环的骨架function draw() { drawBackground(); medusas.forEach(m { m.update(); m.display(); }); handleMouseInteraction(); }背景我是用深色半透明矩形叠加实现的这种拖尾效果是生成艺术里常见的技巧function drawBackground() { push(); noStroke(); fill(230, 30, 6, 12); rect(0, 0, width, height); pop(); }这里有个一般人容易忽略的细节fill的alpha值最后一个参数12决定了画面残影的持久度。alpha越大背景覆盖越彻底画面越干净alpha越小前几帧的物体会有更长的拖尾。我试了很多个值最终锁定在10到15之间——这个区间的残影刚好够制造余晖感又不至于让画面糊成一片光污染。如果你想要干脆利落的运动画面把这个数调到30以上。3.4 关键代码伞盖绘制与光晕叠加伞盖是水母的视觉重心。我的实现是画三到四层半透明椭圆每层的半径和透明度不同叠加出通透的透镜感。核心代码如下displayUmbrella() { const layers 3; for (let i 0; i layers; i) { const layerScale 1 - i * 0.2; const layerAlpha 35 - i * 8; push(); translate(this.pos.x, this.pos.y); scale(this.umbrellaScale); fill(this.hue, this.sat, this.brightness, layerAlpha); ellipse(0, 0, this.size * layerScale, this.size * 0.7 * layerScale); pop(); } }为什么是三层而不是五层因为每层的计算成本相同但视觉收益递减。三层以上叠加后的颜色会过度偏向白色失去水母应有的半透明胶质感。而椭圆的长宽比用1:0.7这是我从大量水母照片里观察到的平均比例视觉上最接近实际水母的伞部。光晕glow效果是这个项目的点睛之笔。p5.js 2D模式里可以通过drawingContext.shadowBlur制造外发光drawingContext.shadowBlur 25; drawingContext.shadowColor hsla(${this.hue}, 80%, 70%, 0.5);注意这个API不是p5.js封装好的必须通过drawingContext直接访问底层canvas 2D上下文。使用它有个性能隐患shadowBlur的模糊计算在每帧大量绘制时开销很大。我的经验是只在伞盖的最后一层时开启光晕不要在细节部分滥用if (i layers - 1) { drawingContext.shadowBlur 25; drawingContext.shadowColor hsla(${this.hue}, 80%, 70%, 0.5); } else { drawingContext.shadowBlur 0; }这样整条水母有一个柔和的光晕轮廓但内部层不参与模糊计算性能上省了一大截。实测下来这样优化后帧率比全程开启shadowBlur提升了大约40%。3.5 鼠标交互的设计扰动而不介入交互方面我做了一个轻量扰动的设计鼠标位置对周围水母产生一个排斥力但作用范围有限且会随时间衰减。这个设计让观众感觉自己是一个观察者而不是掌控者——你伸手去碰水母它们散开然后又悠悠地游回来。实现方式是在handleMouseInteraction()里遍历水母数组当鼠标与水母的距离小于某个阈值时把水母的位置往鼠标的反方向偏移function handleMouseInteraction() { if (!mouseIsPressed) return; medusas.forEach(m { const d dist(mouseX, mouseY, m.pos.x, m.pos.y); if (d 120) { const force (120 - d) / 120 * 0.8; const angle atan2(m.pos.y - mouseY, m.pos.x - mouseX); m.pos.x cos(angle) * force; m.pos.y sin(angle) * force; } }); }交互设计的原则是留白。我不让鼠标变成持续的力场只在按下鼠标的瞬间产生一次推开水母的脉冲。这样画面大部分时间保持自然的游动只有在观众主动介入时才出现短暂的扰动比持续的流体场交互要更有节奏感也更适合沉浸式的观赏场景。4. 常见问题与排查技巧实录4.1 水母像死鱼噪声参数调不对第一次跑通代码时我差点放弃。水母是动了但看起来像一具被绳子拉动的死体僵硬地平行移动完全没有游泳的起伏感。排查后发现问题出在噪声采样频率上noiseScale的值太小导致每帧采样到的噪声值几乎没有变化速度向量被锁定在一个固定的方向上。噪声采样的规律需要理解noise(x)的x参数变化越快输出值变化越大。想让水母游动有忽左忽右的悠然感噪声参数需要在一个合适的范围内变化——既不能变化太慢僵直平移也不能变化太快抖动抽搐。我的经验值是每帧采样偏移量在0.001到0.01之间比较合适根据期望的流畅度微调。const t frameCount * 0.002 this.seed; this.vel.x (noise(t, this.seed) - 0.5) * this.speed;还有另一个细节速度向量直接控制位置的话水母会偶尔飘出屏幕然后消失在边缘。我没有用边界检测去夹住它而是让噪声场在位置接近边界时产生一个指向中心的方向偏置。这叫软边界约束比硬边界检测优雅得多水母会在屏幕边缘缓慢掉头而不是被物理反弹回来。4.2 触手像面条一样乱甩触手控制点加噪声扰动后出现了一个新问题某些触手摆动幅度大了会看起来像煮过的面条软趴趴地到处乱甩。根源在于swayAmplitude这个参数的取值范围过大。控制点的扰动幅度在20像素左右时触手动作已经足够明显但我为了追求飘逸感把它调到了50结果动作过头了。这个问题的教训是生成艺术中动作幅度和质量不成正比。过大的扰动不仅会让曲线失去水母应有的从容感还会在画面中引入大量高频运动让观众的视线无法聚焦。最后我把swayAmplitude锚定到24并且加入了一个基于触手索引的衰减——靠近伞盖的地方摆动小末端摆动大。你可以在贝塞尔曲线中看到这种渐变的动态效果比全程统一幅度要自然得多const swayFactor map(i, 0, tentacles.length, 0.3, 1.0);4.3 粒子特效吃满性能记下这三板斧当初给每条水母配了120个粒子模拟水中浮游生物结果MacBook Air的风扇瞬间起飞。性能优化的三板斧总结如下第一粒子数量必须按视觉优先级分布。水母周围30~40个粒子已足够120个粒子在视觉上的增量远小于性能的损耗。第二减少粒子的独立计算每个粒子如果单独访问噪声函数生成运动轨迹开销巨大。我让粒子共享所属水母的breathCycle值只维护各自的相位和半径视觉上效果差别很小。第三控制shadowBlur的使用频率。这是最大的性能杀手我的做法是给粒子图层一次性开光晕不要在逐个粒子的绘制里开关光晕。优化完成后帧率稳定在55 fps以上CPU占用比之前低了将近一半。建议你做这个项目时开场的粒子数量从30开始视实际性能往上加不要一上来就追求视觉饱和。4.4 画面发灰发脏透明度叠加超出预期半透明叠加的效果本来是想模拟光线的穿透但实际运行时发现画面灰蒙蒙的像隔着一层脏玻璃。排查下来根源在于HSB模式下透明度的叠加规则多个半透明层叠过多饱和度会被稀释颜色逐渐向白色靠拢于是画面发灰。解决办法有几个方向把伞盖的层数从5层减到3层减少颜色被稀释的次数把每层的fillalpha值调低让叠加后整体颜色更通透把初始明度调低一些给透明的叠加留出空间。颜色设计上也要注意HSB模式下明度设置太高叠加更多层后容易发白。把明度控制在55~70之间饱和度控制在60~80之间整体画面就会保持浓郁但通透的质感。4.5 刷新率不稳定导致动画忽快忽慢不同设备的刷新率不同同一套帧速率逻辑在144Hz和60Hz屏幕上呈现的速度差可以达到两倍以上。如果水母的游动速度用frameCount作为时间基准在刷新率不同的设备上会出现明显差异。标准解法是用deltaTime做时间归一化。p5.js内置的deltaTime变量是上一帧到当前帧的毫秒数。我可以把它除以目标帧率60转换成一个时间倍率所有运动参数乘上这个倍率const dt deltaTime / 16.67; // 所有与时间相关的运动参数乘以dt this.vel.x * dt;用deltaTime有另一个好处页面切到后台再切回来时不会因为帧率突降导致水母突然瞬移一大段距离。我试过用frameCount做时间基准时页面从后台恢复后水母会瞬间跳到几十帧之后的位置观感极其突兀。换成deltaTime之后这个问题就消失了。4.6 问题速查表表现触发原因快速解决办法水母僵直平移噪声采样频率太低将frameCount * 0.002调大到0.005触手乱甩控制点扰动幅度过大swayAmplitude从50下调到24左右画面灰蒙蒙半透明层数过多减少伞盖层数降低明度帧率低shadowBlur滥用只给最重要的图层开光晕水母跑出画面缺乏边界约束加软边界靠近边缘时给中心偏置力5. 扩展方向与个人经验总结5.1 进阶方向一从2D到3D的视觉升级做完2D版本后我一直觉得水母是特别适合3D化的题材——它那半透明的身体在三维空间中的体积感、触手的空间飘动、光线的折射效果都有巨大的表现潜力。如果你想往这个方向走我建议用Three.js来重做核心思路是用SphereGeometry做伞盖片元着色器里叠加噪声扰动顶点位置模拟触手的波形运动触手可以用CatmullRomCurve3生成三维贝塞尔曲线沿曲线放置一系列透明小球制造体积感。材质方面可以试试MeshPhysicalMaterial的透光属性transmission官方示例里已经有很多半透明材质的现成写法。5.2 进阶方向二音频驱动与互动演出生成艺术发展到最后往往会和声音产生关系。水母的呼吸节奏如果绑定到现场音乐的低频信号视觉和听觉会形成强烈的共振。技术上并不复杂用p5.AudioIn获取麦克风输入通过getLevel()提取振幅映射到breathRate和umbrellaScale上。我试过用低频正弦波驱动水母呼吸效果非常带感——低音炮每震动一下整片水母群体会同步绽放一次那种视听同步的震撼是同屏粒子数翻倍也换不来的。如果你有现场演出的需求这个方向值得投入。5.3 进阶方向三输出序列帧做衍生品实时渲染的项目做完了如果只留在网页上很可惜。我尝试过把渲染结果导出为序列帧再用After Effects合成一段短视频效果相当惊艳。具体做法是在draw()里加一行saveCanvas(frame_ nf(frameCount, 4), png)连续记录30秒左右渲染输出为一组PNG后期导入AE处理。这样做还有一个好处你可以把某个特别好看的帧单独抽出来用高分辨率重渲染打印成装饰画或者做成明信片。数字艺术并不是只能看屏幕的落地的实体衍生品能带来完全不同的满足感。坐标系渲染时我建议把pixelDensity(2)加到setup()开头输出分辨率翻倍印刷和后期处理的画质就有保障了。5.4 个人实际操作中的体会做完BlooomMedusas这个项目我最大的体会是生成艺术品的上限不是技术而是审美决策。Perlin噪声、贝塞尔曲线、HSB色彩模型这些技术本身的文档数不胜数任何人花一个下午就能跑通但要让水母有呼吸感、触手有柔韧性、色彩有高级感这些全是需要反复调整参数、不断靠直觉打磨的主观选择。第二不要太早固定技术方案。我一开始想用物理引擎做流体模拟如果当时没忍住用了整个项目会走向完全不同的方向。生成艺术核心在生成而不在艺术——代码给你提供的是起点和边界最终的画面是你在参数空间里不断探索的结果。给自己留出调整的余地审美判断的空间比技术基建的空间更重要。第三留白是一种能力。不管做什么生成项目克制比堆叠更难。BlooomMedusas从最初每条水母120个粒子、15条触手最后精简到40个粒子、8条触手画面反而更舒服了。少即是多在生成艺术里永远是真理。