🦅

Three.jsで画像に収束するパーティクルアニメーションを作る方法

に公開

以下のようなアニメーションを作成する方法をまとめました。

1. テクスチャ読み込み

threejsでテクスチャを読み込みます。

sim.js
  const loader = new THREE.TextureLoader();
  loader.load('../assets/images/image-01.jpg', (texture) => {
    const width = texture.image.width;
    const height = texture.image.height;
  }

2. canvasから画像データ取得

流れとしては以下の手順です。

  1. createElementでcanvas要素を作成
sim.js
  const loader = new THREE.TextureLoader();
  loader.load('../assets/images/image-01.jpg', (texture) => {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
  }
  1. drawImageで画像描画
sim.js
    ctx.drawImage(texture.image, 0, 0, width, height);
  1. getImageData(startX, startY, endX, endY)で画像データ取得
    以下のようにすることでcanvas領域のすべてのピクセル情報を取得することができます。
sim.js
    const imageData = ctx.getImageData(0, 0, width, height).data;

3. 色情報、位置情報を配列として格納

3.1 色情報の取得

色情報RGBAチャネルがそれぞれ連続で格納されており、4つの要素で一つのピクセルの色情報を表現しています。

const r = imageData[0];
const g = imageData[1];
const b = imageData[2];
const a = imageData[3];

forループを画像の縦のピクセル、横のピクセルの数だけ二重ループする必要があります。その際に現在の配列のindexを取得する方法は以下となります。

index = (y * width + x) \times 4
sim.js
   for (let y = 0; y < height; y += gap) {
      for (let x = 0; x < width; x += gap) {
        const index = (y * width + x) * 4;
      }
    }

e.g. width=100, x=10, y=5の場合 (x:0~width, y:0~height)
11行6列目のピクセル情報を取得したいので

index = (5 * 100 + 10) \times 4 = 510 \times 4 = 2040

となります。


ガンマ補正しない場合


ガンマ補正した場合

最終的な色情報を格納する処理は以下となります。

sim.js
  // 画像のピクセルデータを格納する配列
  let imagePixelData = [];
  const gap = 1;

    // 画像のピクセルデータを取得
    for (let y = 0; y < height; y += gap) {
      for (let x = 0; x < width; x += gap) {
        const index = (y * width + x) * 4;

        // ガンマ補正をかける
        function sRGBToLinear(v) {
          v = v / 255;
          return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
        }

        // rgbaデータを取得
        const r = sRGBToLinear(imageData[index]);
        const g = sRGBToLinear(imageData[index + 1]);
        const b = sRGBToLinear(imageData[index + 2]);
        const alpha = imageData[index + 3] / 255;

        imagePixelData.push(r, g, b, alpha);

3.2. 位置情報の取得

threejsの画面サイズの取得について
視野角や奥行きを考慮して画面サイズを取得するには以下のようにします。

  const w = window.innerWidth;
  const h = window.innerHeight;

  const fieldOfView = 45; // 視野角
  const aspectRatio = w / h;
  const cameraDepth = 10; // カメラ深度
  const height3D = Math.tan((fieldOfView * Math.PI / 180) / 2) * cameraDepth;
  const width3D = height3D * aspectRatio;

また、canvasからthreejsの座標系へ変換する際は画像のアスペクト比を考慮する必要があります。

    const width = texture.image.width;
    const height = texture.image.height;
    const imageAspect = width / height;
    const displayHeight = height3D;
    const displayWidth = displayHeight * imageAspect;

パーティクルを画像に収束させるためには画像の元の座標とランダムな座標値(初期値)を格納した配列2つが必要です。まず初めに画像の元の座標を格納する配列を作成します。

sim.js
  // 画像の元の位置を保持する配列
  let originImagePosData = [];
  const gap = 1;

    for (let y = 0; y < height; y += gap) {
      for (let x = 0; x < width; x += gap) {
        const originPosX = (x / width - 0.5) * displayWidth;
        const originPosY = -(y / height - 0.5) * displayHeight;
        originImagePosData.push(originPosX, originPosY, 0);
      }
    }

次にランダムな初期値を格納する配列を作成します。
-1〜1の範囲にthreejsの画面幅を掛けることで表示領域内に収まるようにランダムな位置を割り当てています。

sim.js
        const randomPosX = (Math.random() * 2 - 1) * width3D;
        const randomPosY = (Math.random() * 2 - 1) * height3D;
        imagePosData.push(randomPosX, randomPosY, 0);

4. Pointsオブジェクトの作成

1~3で画像の情報を配列に格納することができたのでそれをthreejsで操作するためにPointsオブジェクトを作成します。

4.1. BufferGeometryの作成

今回は扱うパーティクルの数が画像のピクセル数となり大量なのでBufferGeometryを使用します。大量のパーティクルを扱う際に適しています。
(Shaderよりは劣りますが、それでも十分なパフォーマンスが発揮できます。)

BufferAttributeの第2引数は1頂点あたりに使用する要素数を指定します。
位置データ(positionData)は x, y, z の 3要素 で1頂点を構成するので 3
色データ(colorData)は r, g, b, a の 4要素 で1頂点の色を指定するので 4
となります

sim.js
    const geometry = new THREE.BufferGeometry();
    const positionData = new Float32Array(imagePosData);
    const colorData = new Float32Array(imagePixelData);
    geometry.setAttribute('position', new THREE.BufferAttribute(positionData, 3));
    geometry.setAttribute('color', new THREE.BufferAttribute(colorData, 4));

5. ランダムな位置から元の画像の位置へ収束させる

初期位置から元の画像の位置へ収束させるには以下の式を使用します。

currentPos = currentPos + (originPos - currentPos) \times convergenceRate
sim.js
  function positionUpdate() {
    const convergenceRate = 0.03;
    const positions = points.geometry.attributes.position.array;
    for (let i = 0; i < positions.length; i++) {
      positions[i] += (originImagePosData[i] - positions[i]) * convergenceRate;
      positions[i + 1] += (originImagePosData[i + 1] - positions[i + 1]) * convergenceRate;
    }
    points.geometry.attributes.position.needsUpdate = true;
  }

convergenceRateの値を変更することで収束速度を制御することができます。

6. 注意点まとめ

7. その他注意点

画像の解像度が高くFPSが低下する場合、解像度を下げましょう。以下のサイトで解像度を調整できます。
https://www.adobe.com/jp/express/feature/image/resize

8. 参考

今回作成したプログラムは以下から確認できます。
https://github.com/fejwy57hg/my-technical-submissions/tree/main/image-to-particle

以下の解説動画が大変勉強になりました。
https://youtu.be/vAJEHf92tV0?si=UlqamciyPclRJvXx

Discussion