从像素到粒子:p5.js 图像转动态粒子的设计与实现

前言及示例

事情是这样的,最近逐渐想把之前做过的一些交互特性和 webgl 相关的 demo 收集一下,写几篇博客进行记录,虽然还有很多不足,比如性能等都亟需优化,也欢迎各种指正。这是第一篇,下一篇是这个的基础上进行修改的另一个粒子系统。

第一个讲的是这个效果:Particles to Image,以下实践是在这个的基础上进行修改、重构得来的,有兴趣可以去看看原代码。

重构、移植到 react 和加入 ts 类型声明废了一些时间,还有一些细节的调整。移植后效果如下:

在线地址:p5-three-lab.vercel.app/examples

这篇博文和项目搭建写的都比较匆忙,有很多写的比较烂的地方,毕竟是为需求服务的,重构本身就比较赶,后续应该会不断完善。

部分段落有使用 AI 进行润色和辅助,虽然很 AI 但是通俗易懂就留着了(

思路与设计理念

这个动态粒子系统的核心思路是将静态图像转换为动态的粒子集合,每个粒子代表原图像中的一个像素点。通过物理模拟让粒子在不同图像之间平滑过渡,创造出富有生命力的视觉效果。

整个系统可以概括为四个关键阶段:

  1. 图像解构:将输入图像分解为像素数据,提取颜色和位置信息
  2. 粒子映射:为每个有效像素创建对应的粒子对象,建立图像与粒子的映射关系
  3. 物理模拟:实现粒子的运动规律,包括目标寻找、噪声扰动、鼠标交互等
  • 寻径行为:粒子向目标位置移动
  • 噪声扰动:添加 Perlin 噪声增加自然感
  • 交互响应:鼠标悬停排斥,点击吸引
  • 接近减速:临近目标时逐渐减速,避免震荡
  • 渐进式渲染 :通过颜色插值(lerpColor)和大小过渡实现平滑的视觉效果,让图像切换显得自然流畅。
  1. 视觉重构:通过粒子的位置和颜色变化重新构建视觉效果,实现动态的图像表现

在 2D 库里,p5.js 相对来说是成熟的,在这种完全不需要 3D 的情况下,提供了丰富的图形和数学函数,如 loadPixels()lerpColor()P5.Vector 等,内置有向量类极大简化了向量计算

原代码通过 loadPercentageresolution 参数控制粒子密度。

粒子系统架构总览与讲解

首先是系统的配置与类型定义,我们希望粒子切换的时候能够有过渡效果,并且可以通过 activeAnim 进行动画的暂停与继续。

typescript 复制代码
export type MySketchProps = SketchProps & {
  activeAnim: boolean;
  imageIdx: number; //
  id?: string;
  particleConfig?: ParticleConfig;
};

// 粒子配置接口
type ParticleConfig = {
  closeEnoughTarget: number; // 目标接近距离
  speed: number; // 移动速度
  mouseSize: number; // 鼠标影响范围
  scaleRatio: number; // 缩放比例
  particleSize: number; // 粒子大小
  maxSpeedRange?: [number, number]; // 最大速度范围
  maxForceRange?: [number, number]; // 最大力范围
  colorBlendRate?: [number, number]; // 颜色混合率
  noiseScale?: number; // 噪声缩放
  noiseStrength?: number; // 噪声强度
};

1. 图像预处理与像素采样

首先对输入图像进行预处理,包括尺寸调整和像素数据提取:

typescript 复制代码
p5.preload = () => {
  for (let i = 0; i < sourceImgInfos.length; i++) {
    const img = p5.loadImage(sourceImgInfos[i].url);
    const [width, height] = sourceImgInfos[i]?.resize ?? [0, 0];
    const scaleNum = sourceImgInfos[i]?.scaleNum ?? defaultConfig.scaleNum;

    if (width && height) {
      img.resize(width * scaleNum, height * scaleNum);
    } else {
      img.resize(img.width * scaleNum, img.height * scaleNum);
    }
    sourceImgs.push(img);
  }
};

关键是 p5.loadImage 这一步,会返回 p5.Image 对象。这个对象中含有 loadPixels 方法,将图像中每个像素的当前值加载到 img.pixels 数组,我们接下来会用到。

其他注意事项 :需要根据设备类型(移动端/桌面端)动态调整图像尺寸,移动端性能不高,所以 scaleNum 和图片 resize 大小都需要进行调整,平衡视觉效果与性能,如果原图太大,则需要缩小再进行 loadImage, 生成的结果再进行放大。

2. 图像切换 setImageIdx

图像切换部分负责将图像的像素数据转换为粒子的目标位置和颜色。下面我们深入分析每个关键步骤,先看这块的完整代码:

typescript 复制代码
function setImageIdx(idx: number) {
  // 1. 参数解构与默认值处理
  const {
    loadPercentage = defaultConfig.loadPercentage, // 粒子加载密度 (默认: 0.0007)
    resolution = defaultConfig.resolution, // 分辨率倍数 (移动端: 15, 桌面端: 5)
  } = sourceImgInfos[idx] ?? {};
  const sourceImg = sourceImgs[idx];

  // 2. 图像像素数据加载
  sourceImg.loadPixels();

  // 3. 对象池初始化
  const preParticleIndexes = allParticles.map((_, index) => index);

  // 4. 随机采样阈值预计算
  const randomThreshold = loadPercentage * resolution;

  // 5. 像素遍历与粒子分配
  for (let y = 0; y < imgHeight; y++) {
    for (let x = 0; x < imgWidth; x++) {
      // 6. RGBA 像素数据读取
      const pixelR = sourceImg.pixels[pixelIndex++];
      const pixelG = sourceImg.pixels[pixelIndex++];
      const pixelB = sourceImg.pixels[pixelIndex++];
      const pixelA = sourceImg.pixels[pixelIndex++];

      // 7. 透明度过滤优化(若 alpha 值小于 128,我们认为它是透明的,则跳过这个粒子)
      if (pixelA < 128) continue;

      // 8. 随机采样控制粒子密度,决定是否将粒子分配给该像素。
      if (p5.random(1.0) > randomThreshold) continue;

      const pixelColor = p5.color(pixelR, pixelG, pixelB);
      let newParticle: Particle;

      // 9. 智能粒子对象池管理
      if (preParticleIndexes.length > 0) {
        // 9a. 随机选择现有粒子进行复用
        const randomIndex = Math.floor(p5.random(preParticleIndexes.length));
        const index = preParticleIndexes[randomIndex];
        // 9b. O(1) 快速移除策略:将最后元素移到当前位置
        preParticleIndexes[randomIndex] =
          preParticleIndexes[preParticleIndexes.length - 1];
        preParticleIndexes.pop();
        newParticle = allParticles[index];
      } else {
        // 10. 创建新粒子 - 仅在对象池耗尽时
        newParticle = new Particle(
          p5.width / 2, // 初始 x 坐标(画布中心)
          p5.height / 2, // 初始 y 坐标(画布中心)
          p5,
          IS_MOBILE,
          currentParticleConfig
        );
        allParticles.push(newParticle);
      }

      // 11. 坐标系转换与粒子移动最终位置目标设置
      newParticle.target.x = x + p5.width / 2 - sourceImg.width / 2;
      newParticle.target.y = y + p5.height / 2 - sourceImg.height / 2;
      newParticle.endColor = pixelColor;
    }
  }

  // 12. 清理未分配的粒子
  const preLen = preParticleIndexes.length;
  if (preLen > 0) {
    for (let i = 0; i < preLen; i++) {
      const index = preParticleIndexes[i];
      allParticles[index].kill(); // 标记为死亡状态
      allParticles[index].endColor = p5.color(0); // 设置为透明
    }
  }
}

2.1 像素数据结构理解

p5.js 中的 pixels 数组是一维数组,按 RGBA 顺序存储。

  • 对于 100x100 的图像,数组长度为 100 * 100 * 4 = 40,000
  • 索引计算:像素(x,y) 的 R 通道位于 ((y * width + x) * 4) 位置
typescript 复制代码
// p5.js 中的 pixels 数组是一维数组,按 RGBA 顺序存储
// 对于 100x100 的图像,数组长度为 100 * 100 * 4 = 40,000
// 索引计算:像素(x,y) 的 R 通道位于 ((y * width + x) * 4) 位置
const pixelIndex = (y * sourceImg.width + x) * 4;
const [r, g, b, a] = [
  sourceImg.pixels[pixelIndex], // Red
  sourceImg.pixels[pixelIndex + 1], // Green
  sourceImg.pixels[pixelIndex + 2], // Blue
  sourceImg.pixels[pixelIndex + 3], // Alpha
];

2.2 对象池(Object Pool)模式分析

或许不应该这么叫,但是为了好懂简单这么叫先。

对象池是这个算法的性能核心,它解决了频繁创建/销毁对象导致的内存碎片问题。可以把对象池想象成一个"粒子回收站":

想象你在玩积木,每次搭建新的模型时,你有两种选择:

  1. 浪费方式:每次都去商店买新积木,用完就扔掉(对应频繁创建新对象)
  2. 环保方式:把用过的积木收集起来,下次直接重复使用(对应对象池模式)

显然第二种方式更高效,这就是对象池的核心思想。

typescript 复制代码
// 传统方法(性能差):
for (const pixel of pixels) {
  const particle = new Particle(); // 每次都创建新对象
  particles.push(particle);
}

// 对象池优化方法:
const preParticleIndexes = allParticles.map((_, index) => index);
// 这创建了一个索引数组 [0, 1, 2, ..., n-1]

// 随机选择策略避免视觉上的规律性
const randomIndex = Math.floor(p5.random(preParticleIndexes.length));
const actualIndex = preParticleIndexes[randomIndex];

// O(1) 移除技巧:swap and pop
preParticleIndexes[randomIndex] =
  preParticleIndexes[preParticleIndexes.length - 1];
preParticleIndexes.pop();

2.3 随机采样的数学原理

随机采样是控制粒子密度的核心机制。想象一下,如果我们为图像的每个像素都创建一个粒子,一张 1000x1000 的图片就会产生 100 万个粒子,这会让浏览器卡死。所以我们需要一个"筛选机制",只选择其中一部分像素来创建粒子。

typescript 复制代码
const randomThreshold = loadPercentage * resolution;
// 示例:loadPercentage = 0.0007, resolution = 5
// randomThreshold = 0.0035
// 意味着每个像素有 0.35% 的概率被选中创建粒子

// p5.random(1.0) 生成 [0, 1) 的随机数
// 只有当随机数 <= randomThreshold 时才创建粒子
if (p5.random(1.0) > randomThreshold) continue;

这个筛选过程就像抽奖一样:

  • 每个像素都有一次"抽奖"机会
  • randomThreshold 就是中奖概率,比如 0.0035 表示 0.35% 的中奖率
  • 如果这个像素"中奖"了,就为它创建一个粒子
  • 如果没中奖,就跳过这个像素

举个具体例子:如果 loadPercentage 是 0.0007,resolution 是 5,那么最终的中奖率就是 0.0035(即 0.35%)。对于一张 1000x1000 的图片,理论上会创建约 3500 个粒子,这个数量既能保持视觉效果,又不会让浏览器崩溃。

这种采样策略确保:

  • 密度控制:通过调整阈值控制粒子总数
  • 随机分布:避免规律性的网格模式,让粒子分布看起来更自然
  • 性能平衡:减少不必要的粒子创建,保持流畅的动画效果

2.4 坐标系转换的几何原理

坐标系转换是让图像在画布上居中显示的关键步骤。我们需要把图像坐标转换成画布坐标,这就像把一张照片贴到一块更大的画板上,需要计算贴在哪个位置才能居中。

typescript 复制代码
// 图像坐标转换为画布中心坐标
newParticle.target.x = x + p5.width / 2 - sourceImg.width / 2;
newParticle.target.y = y + p5.height / 2 - sourceImg.height / 2;

// 分解理解:
// x: 图像内的像素 x 坐标 (0 到 sourceImg.width-1)
// p5.width / 2: 画布中心 x 坐标
// sourceImg.width / 2: 图像中心偏移量
// 结果:将图像中心对齐到画布中心

让我们用一个具体例子来理解这个计算:

  • 假设画布宽度是 800px,图像宽度是 200px
  • 图像中某个像素的 x 坐标是 50
  • 画布中心点是 400 (800/2)
  • 图像中心偏移是 100 (200/2)
  • 最终粒子目标位置 = 50 + 400 - 100 = 350

这样计算的结果是,图像会以画布的中心为基准点进行定位,无论图像大小如何,都能完美居中显示。

2.5 透明度过滤的优化价值

透明度过滤是一个简单但非常有效的优化策略。就像筛选照片时,我们会跳过那些完全空白或透明的区域一样。

typescript 复制代码
if (pixelA < 128) continue; // 半透明阈值判断

这里的判断逻辑很简单:

  • pixelA 是像素的透明度值,范围是 0-255
  • 0 表示完全透明(看不见),255 表示完全不透明(完全可见)
  • 128 是中间值,我们把它作为"有意义"的阈值

通过跳过透明区域,我们避免了为"看不见的地方"创建粒子。

2.6 粒子生命周期管理

typescript 复制代码
// 步骤12:清理未分配的粒子
const preLen = preParticleIndexes.length;
if (preLen > 0) {
  for (let i = 0; i < preLen; i++) {
    const index = preParticleIndexes[i];
    allParticles[index].kill(); // 触发粒子的消失动画
    allParticles[index].endColor = p5.color(0); // 变透明
  }
}

这个清理步骤确保:

  • 平滑过渡:粒子不会突然消失,而是逐渐淡出
  • 内存优化:避免无效粒子占用计算资源
  • 视觉连贯性:保持切换时的平滑视觉效果

3. Particle 粒子类的核心实现与 p5 函数说明

Particle 类是这个系统的核心,实现了复杂的物理模拟和视觉效果,以下是完整代码:

typescript 复制代码
export class Particle {
  p5: P5CanvasInstance<MySketchProps>;

  // 物理属性
  pos: P5.Vector; // 当前位置
  vel: P5.Vector; // 速度向量
  acc: P5.Vector; // 加速度向量
  target: P5.Vector; // 目标位置
  distToTarget: number = 0;

  // 视觉属性
  currentColor: P5.Color; // 当前颜色
  endColor: P5.Color; // 目标颜色
  currentSize: number; // 当前大小

  // 生命周期状态
  isKilled: boolean = false;

  config: ParticleConfig;

  noiseOffsetX: number; // 随机噪声偏移 X
  noiseOffsetY: number; // 随机噪声偏移 Y

  // 优化用的可重用向量
  private tempVec1: P5.Vector;
  private tempVec2: P5.Vector;
  private tempVec3: P5.Vector;

  constructor(
    x: number,
    y: number,
    p5: P5CanvasInstance<MySketchProps>,
    isMobile?: boolean,
    config?: ParticleConfig
  ) {
    this.p5 = p5;
    this.config =
      config ??
      {
        /* 默认配置 */
      };

    // 初始化物理属性
    this.pos = new P5.Vector(x, y);
    this.vel = new P5.Vector(0, 0);
    this.acc = new P5.Vector(0, 0);
    this.target = new P5.Vector(0, 0);

    // 随机化属性增加自然感
    this.maxSpeed = p5.random(maxSpeedRange[0], maxSpeedRange[1]);
    this.maxForce = p5.random(maxForceRange[0], maxForceRange[1]);
    this.colorBlendRate = p5.random(
      colorBlendRateRange[0],
      colorBlendRateRange[1]
    );

    this.noiseOffsetX = p5.random(1000);
    this.noiseOffsetY = p5.random(1000);

    // 初始化可重用向量(避免频繁内存分配)
    this.tempVec1 = new P5.Vector();
    this.tempVec2 = new P5.Vector();
    this.tempVec3 = new P5.Vector();
  }
  /**
   * 粒子运动逻辑 - 整合了寻径、噪声扰动、鼠标交互等多种物理模拟
   * 该方法在每一帧被调用,负责更新粒子的位置、速度和加速度
   */
  public move() {
    const p5 = this.p5;
    const { closeEnoughTarget, speed, scaleRatio, mouseSize } = this.config;

    // 1. 添加 Perlin 噪声扰动,让粒子运动更自然
    const noiseScale = this.config.noiseScale ?? 0.005;
    const noiseStrength = this.config.noiseStrength ?? 0.6;
    this.tempVec1.set(
      p5.noise(
        this.noiseOffsetX + this.pos.x * noiseScale,
        this.pos.y * noiseScale
      ) *
        noiseStrength -
        noiseStrength / 2,
      p5.noise(
        this.noiseOffsetY + this.pos.y * noiseScale,
        this.pos.x * noiseScale
      ) *
        noiseStrength -
        noiseStrength / 2
    );
    this.acc.add(this.tempVec1);

    // 2. 计算到目标的距离(寻径行为核心)
    const dx = this.target.x - this.pos.x;
    const dy = this.target.y - this.pos.y;
    const distSq = dx * dx + dy * dy;
    this.distToTarget = Math.sqrt(distSq);

    // 3. 接近减速机制 - 防止粒子在目标位置震荡
    let proximityMult = 1;
    if (this.distToTarget < closeEnoughTarget) {
      proximityMult = this.distToTarget / closeEnoughTarget;
      this.vel.mult(0.9); // 强阻尼,快速稳定
    } else {
      this.vel.mult(0.95); // 轻阻尼,保持流畅运动
    }

    // 4. 朝向目标的寻径力
    if (distSq > 1) {
      this.tempVec2.set(this.target.x - this.pos.x, this.target.y - this.pos.y);
      this.tempVec2.normalize();
      this.tempVec2.mult(this.maxSpeed * proximityMult * speed);
      this.acc.add(this.tempVec2);
    }

    // 5. 鼠标交互系统
    const scaledMouseX = p5.mouseX / scaleRatio; // 这里是因为图像 scale 了,鼠标位置也需要
    const scaledMouseY = p5.mouseY / scaleRatio;
    const mouseDx = scaledMouseX - this.pos.x;
    const mouseDy = scaledMouseY - this.pos.y;
    const mouseDistSq = mouseDx * mouseDx + mouseDy * mouseDy;

    if (mouseDistSq < mouseSize * mouseSize) {
      const mouseDist = Math.sqrt(mouseDistSq);

      if (p5.mouseIsPressed) {
        // 鼠标按下:吸引粒子
        this.tempVec3.set(mouseDx, mouseDy);
      } else {
        // 鼠标悬停:排斥粒子
        this.tempVec3.set(-mouseDx, -mouseDy);
      }
      this.tempVec3.normalize();
      this.tempVec3.mult((mouseSize - mouseDist) * 0.05);
      this.acc.add(this.tempVec3);
    }

    // 6. 应用物理更新:加速度→速度→位置
    this.vel.add(this.acc);
    this.vel.limit(this.maxForce * speed);
    this.pos.add(this.vel);
    this.acc.mult(0); // 重置加速度,为下一帧准备

    // 7. 更新噪声偏移,保持噪声的连续性
    this.noiseOffsetX += 0.01;
    this.noiseOffsetY += 0.01;
  }

  /**
   * 粒子渲染逻辑 - 处理颜色过渡、大小变化和最终绘制
   * 该方法负责粒子的视觉表现,包括颜色插值和大小映射
   */
  public draw() {
    const p5 = this.p5;
    const { closeEnoughTarget, particleSize } = this.config;

    // 1. 颜色平滑过渡 - 使用线性插值实现自然的颜色变化
    this.currentColor = p5.lerpColor(
      this.currentColor,
      this.endColor,
      this.colorBlendRate
    );
    p5.stroke(this.currentColor);

    // 2. 基于距离的动态大小计算
    let targetSize = 2; // 默认最小大小
    if (!this.isKilled) {
      // 距离目标越近,粒子越大,营造"到达感"
      targetSize = p5.map(
        p5.min(this.distToTarget, closeEnoughTarget),
        closeEnoughTarget,
        0,
        0,
        particleSize
      );
    }

    // 3. 大小平滑过渡 - 避免突变,保持视觉连贯性
    this.currentSize = p5.lerp(this.currentSize, targetSize, 0.1);

    // 4. 设置绘制属性并渲染粒子
    p5.strokeWeight(this.currentSize);
    p5.point(this.pos.x, this.pos.y); // 以点的形式绘制粒子
  }
  // 边界检测 - 超出屏幕的粒子标记为死亡
  public isOutOfBounds(): boolean {
    const margin = 50;
    return (
      this.pos.x < -margin ||
      this.pos.x > this.p5.width + margin ||
      this.pos.y < -margin ||
      this.pos.y > this.p5.height + margin
    );
  }

  // 粒子的回收清理
  public kill(): void {}
}

p5.js 函数说明

在进行分步讲解之前,让我们先简单了解一下几个关键的 p5.js 函数:

p5.lerpColor() - 颜色线性插值

颜色插值就像调色板上混合颜料的过程。想象你要从红色慢慢变成蓝色,lerpColor 就能帮你计算中间的所有过渡颜色。

typescript 复制代码
// 语法:lerpColor(c1, c2, amt)
// 在两个颜色之间进行线性插值
// amt: 0-1 之间的值,0 返回 c1,1 返回 c2
this.currentColor = p5.lerpColor(
  this.currentColor,
  this.endColor,
  this.colorBlendRate
);

比如从红色到蓝色:

  • amt = 0:完全是红色
  • amt = 0.5:红蓝混合的紫色
  • amt = 1:完全是蓝色

p5.noise() - Perlin 噪声生成

Perlin 噪声可以想象成自然界的随机性,比如云彩的形状、水波的纹理。它不是完全随机的,而是有一定规律的"自然随机"。

typescript 复制代码
// 语法:noise(x, [y], [z])
// 生成连续的伪随机噪声值
const noiseValue = p5.noise(
  this.noiseOffsetX + this.pos.x * noiseScale,
  this.pos.y * noiseScale
);

这让粒子的运动看起来更像自然现象,而不是僵硬的机械运动。

p5.map() - 数值映射

map 函数就像一个比例尺转换器。比如你想把摄氏温度转换成华氏温度,或者把 0-100 的分数转换成 A-F 的等级。

typescript 复制代码
// 语法:map(value, start1, stop1, start2, stop2)
// 将值从一个范围映射到另一个范围
targetSize = p5.map(
  p5.min(this.distToTarget, closeEnoughTarget),
  closeEnoughTarget,
  0,
  0,
  particleSize
);

举例:上面的代码把距离转换成粒子大小

  • 距离很远时,粒子很小
  • 距离很近时,粒子很大
  • map 函数自动计算中间的比例

P5.Vector - 向量运算

typescript 复制代码
// 创建向量
const vel = new P5.Vector(x, y);

// 向量运算
vel.normalize(); // 标准化:保持方向,长度变成 1
vel.mult(magnitude); // 缩放:改变长度,保持方向
vel.add(otherVector); // 相加:两个力的合成
vel.limit(maxMag); // 限制大小:不让速度太快

就像物理学中的力的合成:如果一个粒子同时受到向右的力和向上的力,最终的运动方向就是这两个力的合成结果。

接下来分步进行讲解。

4. 粒子运动逻辑 move

粒子的运动系统结合了多种物理模拟技术:

4.1 寻径行为(Seek Behavior)

typescript 复制代码
/**
 * 粒子运动逻辑 - 整合了寻径、噪声扰动、鼠标交互等多种物理模拟
 * 该方法在每一帧被调用,负责更新粒子的位置、速度和加速度
 */
public move() {
  const p5 = this.p5;
  const { closeEnoughTarget, speed, scaleRatio, mouseSize } = this.config;

  // 1. 添加 Perlin 噪声扰动,让粒子运动更自然
  const noiseScale = this.config.noiseScale ?? 0.005;
  const noiseStrength = this.config.noiseStrength ?? 0.6;
  this.tempVec1.set(
    p5.noise(
      this.noiseOffsetX + this.pos.x * noiseScale,
      this.pos.y * noiseScale
    ) *
      noiseStrength -
      noiseStrength / 2,
    p5.noise(
      this.noiseOffsetY + this.pos.y * noiseScale,
      this.pos.x * noiseScale
    ) *
      noiseStrength -
      noiseStrength / 2
  );
  this.acc.add(this.tempVec1);

  // 2. 计算到目标的距离(寻径行为核心)
  const dx = this.target.x - this.pos.x;
  const dy = this.target.y - this.pos.y;
  const distSq = dx * dx + dy * dy;
  this.distToTarget = Math.sqrt(distSq);

  // 3. 接近减速机制 - 防止粒子在目标位置震荡
  let proximityMult = 1;
  if (this.distToTarget < closeEnoughTarget) {
    proximityMult = this.distToTarget / closeEnoughTarget;
    this.vel.mult(0.9); // 强阻尼,快速稳定
  } else {
    this.vel.mult(0.95); // 轻阻尼,保持流畅运动
  }

  // 4. 朝向目标的寻径力
  if (distSq > 1) {
    this.tempVec2.set(this.target.x - this.pos.x, this.target.y - this.pos.y);
    this.tempVec2.normalize();
    this.tempVec2.mult(this.maxSpeed * proximityMult * speed);
    this.acc.add(this.tempVec2);
  }

  // 5. 鼠标交互系统
  const scaledMouseX = p5.mouseX / scaleRatio; // 这里是因为图像 scale 了,鼠标位置也需要
  const scaledMouseY = p5.mouseY / scaleRatio;
  const mouseDx = scaledMouseX - this.pos.x;
  const mouseDy = scaledMouseY - this.pos.y;
  const mouseDistSq = mouseDx * mouseDx + mouseDy * mouseDy;

  if (mouseDistSq < mouseSize * mouseSize) {
    const mouseDist = Math.sqrt(mouseDistSq);

    if (p5.mouseIsPressed) {
      // 鼠标按下:吸引粒子
      this.tempVec3.set(mouseDx, mouseDy);
    } else {
      // 鼠标悬停:排斥粒子
      this.tempVec3.set(-mouseDx, -mouseDy);
    }
    this.tempVec3.normalize();
    this.tempVec3.mult((mouseSize - mouseDist) * 0.05);
    this.acc.add(this.tempVec3);
  }

  // 6. 应用物理更新:加速度→速度→位置
  this.vel.add(this.acc);
  this.vel.limit(this.maxForce * speed);
  this.pos.add(this.vel);
  this.acc.mult(0); // 重置加速度,为下一帧准备

  // 7. 更新噪声偏移,保持噪声的连续性
  this.noiseOffsetX += 0.01;
  this.noiseOffsetY += 0.01;
}
  1. Perlin 噪声扰动力详解

Perlin 噪声(柏林噪声)是计算机图形学中最重要的算法之一,由 Ken Perlin 在 1983 年发明,专门用于生成自然感的随机纹理和运动。在我们的粒子系统中,它的作用是让粒子的运动路径更加自然,避免过于机械化的直线运动。

typescript 复制代码
// 1. 噪声偏移的作用
this.noiseOffsetX = p5.random(1000); // 为每个粒子生成唯一的噪声起始点
this.noiseOffsetY = p5.random(1000);

// 2. 2D Perlin噪声生成自然随机力
const noiseForceX =
  p5.noise(this.noiseOffsetX + this.pos.x * 0.005, this.pos.y * 0.005) * 0.6 -
  0.3;
const noiseForceY =
  p5.noise(this.noiseOffsetY + this.pos.y * 0.005, this.pos.x * 0.005) * 0.6 -
  0.3;

// 3. 噪声偏移的更新(模拟时间流逝)
this.noiseOffsetX += 0.01;
this.noiseOffsetY += 0.01;

为什么需要噪声偏移(Noise Offset)?

想象一下,如果所有粒子都使用相同的噪声函数:

typescript 复制代码
// ❌ 错误做法:所有粒子使用相同的噪声
const force = p5.noise(this.pos.x * 0.005, this.pos.y * 0.005);

这会导致所有位于相同坐标的粒子受到完全相同的力,产生"同步化"运动,看起来很不自然。

通过为每个粒子分配不同的 noiseOffset,我们实际上是在噪声空间中为每个粒子"分配"了不同的起始位置:

typescript 复制代码
// ✅ 正确做法:每个粒子有独特的噪声偏移
// 粒子A: offset = 123.45
const forceA = p5.noise(123.45 + this.pos.x * 0.005, this.pos.y * 0.005);
// 粒子B: offset = 987.65
const forceB = p5.noise(987.65 + this.pos.x * 0.005, this.pos.y * 0.005);

噪声参数详解:

  1. noiseScale = 0.005 (噪声缩放因子)
    • 控制噪声的"粗糙度"或"频率"
    • 值越小,噪声变化越平滑,粒子运动越柔和
    • 值越大,噪声变化越剧烈,粒子运动越随机
typescript 复制代码
// 平滑运动(大尺度噪声)
const smoothForce = p5.noise(x * 0.001, y * 0.001);

// 剧烈运动(小尺度噪声)
const chaoticForce = p5.noise(x * 0.02, y * 0.02);
  1. noiseStrength = 0.6(噪声强度)

    • 控制噪声力的最大幅度
    • 通过 * 0.6 - 0.3 将 [0,1] 映射到 [-0.3, 0.3]
    • 这样粒子可以向任意方向受力
  2. 时间维度的噪声

    • 通过每帧增加 noiseOffset += 0.01,我们模拟了时间的流逝
    • 这让噪声场随时间缓慢变化,产生"风场"效果

噪声的视觉效果对比:

typescript 复制代码
// 无噪声:粒子直接移向目标,路径僵硬
const force = target.sub(position).normalize().mult(speed);

// 有噪声:粒子在移向目标的同时受到"风力"影响,路径自然
const seekForce = target.sub(position).normalize().mult(speed);
const noiseForce = calculatePerlinNoise(position, time);
const totalForce = seekForce.add(noiseForce);
  • 噪声值范围 [0,1],通过 * 0.6 - 0.3 转换为 [-0.3, 0.3] 的双向力
  • 使用位置坐标作为噪声输入,确保相邻粒子的力相似但不完全相同
  • 噪声偏移为每个粒子创建独特的"风场"体验,避免同步化运动
  1. 阻尼系数物理意义
  • vel.mult(0.9):强阻尼,模拟粘稠介质中的运动
  • vel.mult(0.95):轻阻尼,模拟空气阻力
  • 阻尼防止粒子震荡,确保稳定收敛到目标位置
  1. 力的叠加原理F_total = F_seek + F_noise + F_mouse
typescript 复制代码
this.acc.add(seekForce); // 寻径力
this.acc.add(noiseForce); // 噪声扰动力
this.acc.add(mouseForce); // 鼠标交互力

4.2 鼠标交互系统

typescript 复制代码
// 鼠标交互逻辑
const scaledMouseX = p5.mouseX / scaleRatio;
const scaledMouseY = p5.mouseY / scaleRatio;

// 使用平方距离比较(避免开方运算)
const mouseDx = scaledMouseX - this.pos.x;
const mouseDy = scaledMouseY - this.pos.y;
const mouseDistSq = mouseDx * mouseDx + mouseDy * mouseDy;
const mouseSizeSq = mouseSize * mouseSize;

if (mouseDistSq < mouseSizeSq) {
  const mouseDist = Math.sqrt(mouseDistSq); // 只在需要时计算开方

  if (p5.mouseIsPressed) {
    // 按下鼠标:吸引粒子
    this.tempVec3.set(mouseDx, mouseDy);
  } else {
    // 鼠标悬停:排斥粒子
    this.tempVec3.set(-mouseDx, -mouseDy);
  }

  this.tempVec3.normalize();
  this.tempVec3.mult((mouseSize - mouseDist) * 0.05);
  this.acc.add(this.tempVec3);
}

// 应用物理更新
this.vel.add(this.acc);
this.vel.limit(this.maxForce * speed);
this.pos.add(this.vel);
this.acc.mult(0); // 重置加速度

5. 粒子渲染 draw

typescript 复制代码
public draw() {
  const p5 = this.p5;
  const { closeEnoughTarget, particleSize } = this.config;

  // 平滑颜色过渡
  this.currentColor = p5.lerpColor(
    this.currentColor,
    this.endColor,
    this.colorBlendRate
  );

  p5.stroke(this.currentColor);

  // 基于距离的大小计算
  let targetSize = 2;
  if (!this.isKilled) {
    targetSize = p5.map(
      p5.min(this.distToTarget, closeEnoughTarget),
      closeEnoughTarget,
      0,
      0,
      particleSize
    );
  }

  // 平滑大小过渡
  this.currentSize = p5.lerp(this.currentSize, targetSize, 0.1);
  p5.strokeWeight(this.currentSize);
  p5.point(this.pos.x, this.pos.y);
}

6. 主渲染循环:p5.draw 的实现

p5.js 在 draw 中绘制每一帧,这个函数会不停地重复执行,类似 requestAnimationFrame(通常是 60FPS),更新所有粒子的状态并进行渲染。

typescript 复制代码
/**
 * 主渲染循环 - 每帧执行一次的核心函数
 * 负责粒子更新、渲染和内存管理
 */
p5.draw = () => {
  // 1. 早期返回优化 - 避免无效计算
  if (!(activeAnim && allParticles?.length)) {
    return; // 动画未激活或无粒子时直接返回
  }

  // 2. 清除上一帧的画布内容
  p5.clear();

  // 3. 双指针算法实现活跃粒子压缩
  let writeIndex = 0; // 写指针:指向下一个活跃粒子应存放的位置

  // 4. 遍历所有粒子,同时进行更新和生命周期管理
  for (let readIndex = 0; readIndex < allParticles.length; readIndex++) {
    const particle = allParticles[readIndex];

    // 5. 执行粒子的物理更新和渲染
    particle.move(); // 更新位置、速度、加速度
    particle.draw(); // 渲染到画布

    // 6. 生命周期检查和数组压缩
    if (!(particle.isKilled || particle.isOutOfBounds())) {
      // 粒子仍然活跃,需要保留
      if (writeIndex !== readIndex) {
        // 只有当位置不同时才进行赋值(避免自赋值)
        allParticles[writeIndex] = allParticles[readIndex];
      }
      writeIndex++; // 写指针前移
    }
    // 注意:死亡粒子会被自动跳过,不会被复制到新位置
  }

  // 7. 数组截断 - 移除末尾的死亡粒子引用
  allParticles.length = writeIndex;
};

之所以换成双指针,是因为原代码的死亡粒子清理方法效率很低:

  • 当数组前面没有死亡粒子时,writeIndex === readIndex
  • 此时赋值操作 a[i] = a[i] 是冗余的,可以跳过
  • 在粒子密集场景下,这个优化能减少大量无效操作
typescript 复制代码
// ❌ 原代码:每次删除都要移动大量元素
for (let i = allParticles.length - 1; i >= 0; i--) {
  if (allParticles[i].isKilled) {
    allParticles.splice(i, 1);
  }
}

// ✅ 双指针:单次遍历完成删除,O(n) 复杂度
let writeIndex = 0;
for (let readIndex = 0; readIndex < allParticles.length; readIndex++) {
  if (!allParticles[readIndex].isKilled) {
    allParticles[writeIndex++] = allParticles[readIndex];
  }
}
allParticles.length = writeIndex;

p5 在 React 中的使用

原代码该实现的都实现了,但是在 react 中接入的话,那显然还是在 react 中进行比较好,下面是迁移到 react 中的实践记录:

1. 技术选型:@p5-wrapper/react

在 React 中集成 p5.js 有多种方案,最终选择了 @p5-wrapper/react 库,主要是因为该有的他都有:

  1. TypeScript 支持:完整的类型定义,开发体验佳
  2. React 生命周期集成:自动处理组件挂载/卸载
  3. Props 响应式更新 :支持通过 updateWithProps 实时更新 sketch 参数
bash 复制代码
pnpm i @p5-wrapper/react p5
pnpm i -D @types/p5

2. 组件架构设计

2.1 核心组件结构

typescript 复制代码
// 类型定义
export type MySketchProps = SketchProps & {
  activeAnim: boolean; // 动画开关
  imageIdx: number; // 当前图像索引
  id?: string; // DOM 容器 ID
  particleConfig?: ParticleConfig; // 粒子配置
};

// 主组件
export const DynamicParticleGL = ({
  activeAnim,
  imageIdx,
  id = "particle-container",
  getSourceImgInfos,
  particleConfig,
}: DynamicParticleGLProps) => {
  // 使用 useMemo 缓存 sketch 函数,避免重复创建
  const wrappedSketch = useMemo(() => {
    return function sketch(p5: P5CanvasInstance<MySketchProps>) {
      // sketch 实现...
    };
  }, [getSourceImgInfos]);

  return (
    <ReactP5Wrapper
      sketch={wrappedSketch}
      activeAnim={activeAnim}
      imageIdx={imageIdx}
      id={id}
      particleConfig={particleConfig}
    />
  );
};

2.2 Sketch 函数的 React 化改造

原生 p5.js 写法:

javascript 复制代码
function sketch(p5) {
  p5.setup = () => {
    /* ... */
  };
  p5.draw = () => {
    /* ... */
  };
}

React 包装后的写法:

typescript 复制代码
function sketch(p5: P5CanvasInstance<MySketchProps>) {
  // 状态变量
  let activeAnim = false;
  let canvas: P5.Renderer;
  const allParticles: Array<Particle> = [];

  // 响应 React props 变化
  p5.updateWithProps = (props) => {
    activeAnim = props.activeAnim ?? false;
    setImageIdx(props?.imageIdx || 0);

    // 动态配置更新
    if (props.particleConfig) {
      Object.assign(currentParticleConfig, props.particleConfig);
    }

    // DOM 容器动态绑定
    if (canvas && props.id) {
      canvas.parent(props.id);
    }
  };

  p5.preload = () => {
    /* 图像预加载 */
  };
  p5.setup = () => {
    /* 初始化设置 */
  };
  p5.draw = () => {
    /* 渲染循环 */
  };
}

3. Props 驱动的状态更新

typescript 复制代码
// 在演示组件中
const [active, setActive] = useState(true);
const [imageIdx, setImageIdx] = useState(0);

// 实时配置更新(使用 Leva 控制面板)
const particleControls = useControls("Particle System", {
  particleSize: { value: isMobile ? 4 : 5, min: 1, max: 20, step: 1 },
  speed: { value: 3, min: 0.1, max: 10, step: 0.1 },
  // ... 更多配置项
});

// 配置转换
const particleConfig = {
  closeEnoughTarget: particleControls.closeEnoughTarget,
  speed: particleControls.speed,
  maxSpeedRange: [
    particleControls.maxSpeedMin,
    particleControls.maxSpeedMax,
  ] as [number, number],
  // ... 其他配置项映射
};

4. useMemo 缓存 Sketch 函数

typescript 复制代码
// 避免每次渲染都重新创建 sketch 函数
const wrappedSketch = useMemo(() => {
  return function sketch(p5: P5CanvasInstance<MySketchProps>) {
    // sketch 实现
  };
}, [getSourceImgInfos]); // 只有当图像源配置变化时才重新创建

5. TypeScript 类型安全

5.1 完整的类型定义

typescript 复制代码
// 粒子配置类型
export type ParticleConfig = {
  closeEnoughTarget: number;
  speed: number;
  mouseSize: number;
  scaleRatio: number;
  particleSize: number;
  maxSpeedRange?: [number, number];
  maxForceRange?: [number, number];
  colorBlendRate?: [number, number];
  noiseScale?: number;
  noiseStrength?: number;
};

// 图像源配置类型
type SourceImageInfo = {
  url: string;
  scaleNum?: number;
  resize?: [number, number];
  loadPercentage?: number;
  resolution?: number;
};

// 组件 Props 类型
interface DynamicParticleGLProps {
  activeAnim?: boolean;
  imageIdx: number;
  id?: string;
  getSourceImgInfos?: (isMobile: boolean) => SourceImageInfo[];
  particleConfig?: ParticleConfig;
}

5.2 P5 实例类型扩展

typescript 复制代码
// 扩展 P5 实例类型,支持自定义 props
export type MySketchProps = SketchProps & {
  activeAnim: boolean;
  imageIdx: number;
  id?: string;
  particleConfig?: ParticleConfig;
};

// 在 sketch 函数中使用强类型
function sketch(p5: P5CanvasInstance<MySketchProps>) {
  p5.updateWithProps = (props: MySketchProps) => {
    // TypeScript 会提供完整的类型提示和检查
    activeAnim = props.activeAnim ?? false;
    setImageIdx(props.imageIdx || 0);
  };
}

这种集成方式既保留了 p5.js 强大的图形处理能力,又充分利用了 React 的组件化和状态管理优势,为构建复杂的交互式可视化应用提供了可靠的技术基础。

应用场景与示例

虽然粒子效果的性能目前还是十分堪忧,不建议在要求性能的场景下使用,适合展示型的网页。

能想到的应用场景大概有:

  1. 作为企业品牌 Logo 的动态展示
  2. 作为轮播 Logo 等的背景
  3. 作为背景加上一层遮罩

Refs

本文随时修订中,有错漏可直接评论

相关推荐
加班是不可能的,除非双倍日工资2 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi2 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip2 小时前
vite和webpack打包结构控制
前端·javascript
excel3 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国3 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼3 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy3 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT3 小时前
promise & async await总结
前端
Jerry说前后端3 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天4 小时前
A12预装app
linux·服务器·前端