banner
cos

cos

愿热情永存,愿热爱不灭,愿生活无憾
github
tg_channel
bilibili

從像素到粒子:p5.js 圖像轉動態粒子的設計與實現

前言及示例#

事情是這樣的,最近逐漸想把之前做過的一些交互特性和 webgl 相關的 demo 收集一下,寫幾篇博客進行記錄,雖然還有很多不足,比如性能等都亟需優化,也歡迎各種指正。這是第一篇,下一篇是這個的基礎上進行修改的另一個粒子系統。

第一個講的是這個效果:Particles to Image,以下實踐是在這個的基礎上進行修改、重構得來的,有興趣可以去看看原代碼。

重構、移植到 react 和加入 ts 類型聲明耗了一些時間,還有一些細節的調整。移植後效果如下:

Example 1

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

這篇博文和項目搭建寫的都比較匆忙,有很多寫的比較爛的地方,畢竟是為需求服務的,重構本身就比較趕,後續應該會不斷完善。

部分段落有使用 AI 進行潤色和輔助,雖然很 AI 但是通俗易懂就留著了(

思路與設計理念#

這個動態粒子系統的核心思路是將靜態圖像轉換為動態的粒子集合,每個粒子代表原圖像中的一個像素點。通過物理模擬讓粒子在不同圖像之間平滑過渡,創造出富有生命力的視覺效果。

整個系統可以概括為四個關鍵階段:

  1. 圖像解構:將輸入圖像分解為像素數據,提取顏色和位置信息
  2. 粒子映射:為每個有效像素創建對應的粒子對象,建立圖像與粒子的映射關係
  3. 物理模擬:實現粒子的運動規律,包括目標尋找、噪聲擾動、鼠標交互等
  • 尋徑行為:粒子向目標位置移動
  • 噪聲擾動:添加 Perlin 噪聲增加自然感
  • 交互響應:鼠標懸停排斥,點擊吸引
  • 接近減速:臨近目標時逐漸減速,避免震蕩
  • 漸進式渲染:通過顏色插值(lerpColor)和大小過渡實現平滑的視覺效果,讓圖像切換顯得自然流暢。
  1. 視覺重構:通過粒子的位置和顏色變化重新構建視覺效果,實現動態的圖像表現

在 2D 庫裡,p5.js 相對來說是成熟的,在這種完全不需要 3D 的情況下,提供了豐富的圖形和數學函數,如 loadPixels()lerpColor()P5.Vector 等,內置有向量類極大簡化了向量計算

原代碼通過 loadPercentageresolution 參數控制粒子密度。

粒子系統架構總覽與講解#

首先是系統的配置與類型定義,我們希望粒子切換的時候能夠有過渡效果,並且可以通過 activeAnim 進行動畫的暫停與繼續。

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. 圖像預處理與像素採樣#

首先對輸入圖像進行預處理,包括尺寸調整和像素數據提取:

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#

圖像切換部分負責將圖像的像素數據轉換為粒子的目標位置和顏色。下面我們深入分析每個關鍵步驟,先看這塊的完整代碼:

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) 位置
// 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. 環保方式:把用過的積木收集起來,下次直接重複使用(對應對象池模式)

顯然第二種方式更高效,這就是對象池的核心思想。

// 傳統方法(性能差):
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 萬個粒子,這會讓瀏覽器卡死。所以我們需要一個 "篩選機制",只選擇其中一部分像素來創建粒子。

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 坐標系轉換的幾何原理#

坐標系轉換是讓圖像在畫布上居中顯示的關鍵步驟。我們需要把圖像坐標轉換成畫布坐標,这就像把一張照片貼到一塊更大的畫板上,需要計算貼在哪個位置才能居中。

// 圖像坐標轉換為畫布中心坐標
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 透明度過濾的優化價值#

透明度過濾是一個簡單但非常有效的優化策略。就像篩選照片時,我們會跳過那些完全空白或透明的區域一樣。

if (pixelA < 128) continue; // 半透明閾值判斷

這裡的判斷邏輯很簡單:

  • pixelA 是像素的透明度值,範圍是 0-255
  • 0 表示完全透明(看不見),255 表示完全不透明(完全可見)
  • 128 是中間值,我們把它作為 "有意義" 的閾值

通過跳過透明區域,我們避免了為 "看不見的地方" 創建粒子。

2.6 粒子生命周期管理#

// 步驟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 類是這個系統的核心,實現了複雜的物理模擬和視覺效果,以下是完整代碼:

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 就能幫你計算中間的所有過渡顏色。

// 語法: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 噪聲可以想象成自然界的隨機性,比如雲彩的形狀、水波的紋理。它不是完全隨機的,而是有一定規律的 "自然隨機"。

// 語法: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 的等級。

// 語法:map(value, start1, stop1, start2, stop2)
// 將值從一個範圍映射到另一個範圍
targetSize = p5.map(
  p5.min(this.distToTarget, closeEnoughTarget),
  closeEnoughTarget,
  0,
  0,
  particleSize
);

舉例:上面的代碼把距離轉換成粒子大小

  • 距離很遠時,粒子很小
  • 距離很近時,粒子很大
  • map 函數自動計算中間的比例

P5.Vector - 向量運算

// 創建向量
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)#

/**
 * 粒子運動邏輯 - 整合了尋徑、噪聲擾動、鼠標交互等多種物理模擬
 * 該方法在每一幀被調用,負責更新粒子的位置、速度和加速度
 */
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 年發明,專門用於生成自然感的隨機紋理和運動。在我們的粒子系統中,它的作用是讓粒子的運動路徑更加自然,避免過於機械化的直線運動。

// 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)?

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

// ❌ 錯誤做法:所有粒子使用相同的噪聲
const force = p5.noise(this.pos.x * 0.005, this.pos.y * 0.005);

這會導致所有位於相同坐標的粒子受到完全相同的力,產生 "同步化" 運動,看起來很不自然。

通過為每個粒子分配不同的 noiseOffset,我們實際上是在噪聲空間中為每個粒子 "分配" 了不同的起始位置:

// ✅ 正確做法:每個粒子有獨特的噪聲偏移
// 粒子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(噪聲縮放因子)
    • 控制噪聲的 "粗糙度" 或 "頻率"
    • 值越小,噪聲變化越平滑,粒子運動越柔和
    • 值越大,噪聲變化越劇烈,粒子運動越隨機
// 平滑運動(大尺度噪聲)
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,我們模擬了時間的流逝
    • 這讓噪聲場隨時間緩慢變化,產生 "風場" 效果

噪聲的視覺效果對比:

// 無噪聲:粒子直接移向目標,路徑僵硬
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
this.acc.add(seekForce); // 尋徑力
this.acc.add(noiseForce); // 噪聲擾動力
this.acc.add(mouseForce); // 鼠標交互力

4.2 鼠標交互系統#

// 鼠標交互邏輯
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#

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),更新所有粒子的狀態並進行渲染。

/**
 * 主渲染循環 - 每幀執行一次的核心函數
 * 負責粒子更新、渲染和內存管理
 */
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] 是冗餘的,可以跳過
  • 在粒子密集場景下,這個優化能減少大量無效操作
// ❌ 原代碼:每次刪除都要移動大量元素
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 參數
pnpm i @p5-wrapper/react p5
pnpm i -D @types/p5

2. 組件架構設計#

2.1 核心組件結構#

// 類型定義
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 寫法:

function sketch(p5) {
  p5.setup = () => {
    /* ... */
  };
  p5.draw = () => {
    /* ... */
  };
}

React 包裝後的寫法:

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 驅動的狀態更新#

// 在演示組件中
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 函數#

// 避免每次渲染都重新創建 sketch 函數
const wrappedSketch = useMemo(() => {
  return function sketch(p5: P5CanvasInstance<MySketchProps>) {
    // sketch 實現
  };
}, [getSourceImgInfos]); // 只有當圖像源配置變化時才重新創建

5. TypeScript 類型安全#

5.1 完整的類型定義#

// 粒子配置類型
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 實例類型擴展#

// 擴展 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#

本文隨時修訂中,有錯漏可直接評論

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。