banner
cos

cos

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

ピクセルから粒子へ:p5.js 画像を動的粒子に変換するデザインと実装

前言及示例#

事の次第は、最近、以前に作成したいくつかのインタラクティブな機能や webgl に関連するデモを集めて、いくつかのブログを書いて記録しようと思っていることです。まだ多くの不足があり、パフォーマンスなどの最適化が急務ですが、さまざまな指摘を歓迎します。これは最初の記事で、次の記事はこれを基に修正した別の粒子システムです。

最初に紹介するのはこの効果です:Particles to Image。以下の実践はこれを基に修正、再構築したもので、興味があれば元のコードを見てみてください。

再構築、React への移植、TS 型宣言の追加に少し時間がかかりましたが、いくつかの詳細な調整も行いました。移植後の効果は以下の通りです:

Example 1

オンラインアドレス:https://p5-three-lab.vercel.app/examples

このブログ記事とプロジェクトの構築はかなり急いで書かれたもので、書き方が不十分な部分が多くありますが、結局のところニーズに応えるためのものであり、再構築自体もかなり急いで行ったため、今後も継続的に改善していく予定です。

一部の段落では AI を使用して潤色と補助を行っていますが、非常に AI 的ではありますが、わかりやすいのでそのまま残しています(

思路与设计理念#

この動的粒子システムの核心的なアイデアは、静的画像を動的な粒子の集合に変換することです。各粒子は元の画像の 1 つのピクセルを表します。物理シミュレーションを通じて、粒子が異なる画像間でスムーズに遷移し、生命感あふれる視覚効果を生み出します。

全体のシステムは 4 つの重要な段階に要約できます:

  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 と画像のリサイズサイズを調整し、視覚効果と性能のバランスを取る必要があります。元の画像が大きすぎる場合は、縮小してから 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], // 赤
  sourceImg.pixels[pixelIndex + 1], // 緑
  sourceImg.pixels[pixelIndex + 2], // 青
  sourceImg.pixels[pixelIndex + 3], // アルファ
];

2.2 オブジェクトプール(Object Pool)パターンの分析#

おそらくこう呼ぶべきではないが、わかりやすくするためにこのように呼んでいます。

オブジェクトプールはこのアルゴリズムのパフォーマンスの核心であり、オブジェクトの頻繁な作成 / 破棄によるメモリの断片化問題を解決します。オブジェクトプールを「粒子リサイクルステーション」と考えてみてください:

積み木を使って新しいモデルを作るとき、あなたには 2 つの選択肢があります:

  1. 無駄な方法:毎回新しい積み木を買い、使い終わったら捨てる(頻繁に新しいオブジェクトを作成することに対応)
  2. 環境に優しい方法:使った積み木を集めて、次回は直接再利用する(オブジェクトプールパターンに対応)

明らかに、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) 削除テクニック:スワップとポップ
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; // ここでは画像がスケールされているため、マウス位置も調整する必要があります
    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 の 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 を通じてスケッチパラメータをリアルタイムで更新可能
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 を使用してスケッチ関数をキャッシュし、再作成を避けます
  const wrappedSketch = useMemo(() => {
    return function sketch(p5: P5CanvasInstance<MySketchProps>) {
      // スケッチの実装...
    };
  }, [getSourceImgInfos]);

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

2.2 スケッチ関数の 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 でスケッチ関数をキャッシュ#

// 毎回レンダリングするたびにスケッチ関数を再作成しないようにします
const wrappedSketch = useMemo(() => {
  return function sketch(p5: P5CanvasInstance<MySketchProps>) {
    // スケッチの実装
  };
}, [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;
};

// スケッチ関数内で強い型を使用します
function sketch(p5: P5CanvasInstance<MySketchProps>) {
  p5.updateWithProps = (props: MySketchProps) => {
    // TypeScript は完全な型ヒントとチェックを提供します
    activeAnim = props.activeAnim ?? false;
    setImageIdx(props.imageIdx || 0);
  };
}

この統合方法は、p5.js の強力なグラフィック処理能力を保持しつつ、React のコンポーネント化と状態管理の利点を最大限に活用し、複雑なインタラクティブなビジュアライゼーションアプリケーションを構築するための信頼できる技術基盤を提供します。

アプリケーションシーンと例#

粒子効果のパフォーマンスは現在も非常に懸念されており、パフォーマンスが要求されるシーンでの使用はお勧めしませんが、展示型のウェブページには適しています。

考えられるアプリケーションシーンは次のようなものです:

  1. 企業ブランドロゴの動的な展示
  2. スライドショーロゴなどの背景
  3. 背景に一層のマスクを追加

Refs#

この記事は随時修正中です。誤りや漏れがあれば直接コメントしてください。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。