【初心者向け】React Three Fiber x Drei x TypeScript入門!画像からピクセルアート&動的アニメーションを作成

【初心者向け】React Three Fiber x Drei x TypeScript入門!画像からピクセルアート&動的アニメーションを作成
  • URLをコピーしました!

最近のWebアプリケーション開発では、グラフィックスを活用してユーザー体験を向上させる取り組みが増えています。React Three Fiber(R3F)を使えば、Reactコンポーネントとして手軽にThree.jsの機能を活用できるため、誰でも簡単に魅力的な表現を実現できます。

今回は、Three.jsのラッパーライブラリDreiとTypeScriptを組み合わせ、画像から自動生成されるカラフルなピクセルアートと、動的なアニメーションエフェクトを実装する方法を徹底解説します!

📌 しかも、今回の実装では特別な3Dモデルは一切使用せず、Three.jsが標準で提供する基本ジオメトリ(Box、Planeなど)のみを用いて、ピクセルアートならではのレトロでありながら洗練された表現を作り出すことに挑戦します。
初心者の方でも理解しやすいステップバイステップの解説で、実際に手を動かしながら学べる内容になっていますので、ぜひチャレンジしてみてください!

💡 完成イメージ

📺 YouTubeでデモを見る:こちらのリンクから視聴できます。

TypeScript x React Three Fiberのレッスンや作品は、今後もどんどん作成していきます!
YouTubeで告知致しますので、ぜひYouTubeのチャンネル登録をして通知をお待ちください!

📺 YouTubeを見る:こちらのリンクから視聴できます。

React Three Fiberで何ができるのか知りたい方は、下記を参考にしてみてください!
簡単にできる作品を用意しております!

目次

🚀技術要素の紹介:このプロジェクトで使うツールとライブラリ

利用するツールやライブラリは、ご自身の使いやすいものに変更しても良いですが、このプロジェクトではこちらを前提に説明をしていきます。

VSCode
  • Microsoftが提供する無料のコードエディタです。
  • VSCodeでなくても良いですが、多くの拡張機能がありますので、おすすめです。
  • ESLintやPrettierを入れるのもおすすめです。
Node.js
  • ChromeのV8 JavaScriptエンジン上に構築されたJavaScriptランタイムです。
  • ブラウザの外でJavaScriptコードを実行することができます。
  • こちらは既にインストールされている前提で説明しますので、「https://nodejs.org/ja」からダウンロードしておいてください。
    ※長期安定版であるLTSバージョンをダウンロードすることをお勧めします。
Vite
  • モダンなウェブプロジェクトのためのビルドツールです。 速く軽量という特徴があります。
  • 以前まで使われていた「CRA(create-react-app)」は、公式HPでも記載がなく、古い技術になってしまいました。
  • これからは、Reactでアプリを作る際は、Viteが最良の選択肢となるはずです。
React
  • UI(ユーザーインターフェース)を構築するためのJavaScriptライブラリです。Facebookが開発し、現在も多くのウェブアプリで使われています。
Three.js
  • 3Dグラフィックスを簡単に作成するためのJavaScriptライブラリです。WebGLの複雑な操作を抽象化し、直感的な3D開発を可能にします。
  • 3Dグラフィックスを簡単に作成でき、WebGLの直接操作よりも扱いやすいです。
React Three Fiber
  • Three.jsをReactで扱えるようにするためのライブラリです。Reactのコンポーネント構造とThree.jsの3Dエンジンを融合します。
  • Reactの開発スタイルでThree.jsを扱えるため、直感的かつ効率的な開発が可能になります。
React Three Drei
  • React Three Fiberのための便利なユーティリティコンポーネント集です。よく使われるThree.jsの機能を簡単に追加できます。
  • 複雑なThree.jsの機能を短いコードで実現できるため、学習コストを削減できます。

🚀 React Three Fiber × Drei × TypeScript で「ピクセルアート」を作ろう!

📌環境構築は、こちらの記事で紹介してますので、まだの方は確認してみてください。

📌今回は、特殊な3Dモデルは利用せずに、React Three FiberやReact Three Dreiで利用可能な標準オブジェクトのみを利用します。
標準オブジェクトを詳しく知りたい方は、こちらの記事も参考にしてみてください。

今回、作成したソースコード全量は、GitHubで確認してください。

💾 GitHubリポジトリ:こちらのリンクでソースコードをチェック!

💡コントロールパネルのUIを作る!!

まずは、コントロールパネルは、ユーザーが画像のアップロード、ピクセルサイズの設定、そして各種アニメーションの操作を行うためのUIを作成していきます。

// ReactおよびThree.js関連のライブラリをインポート
import { useState } from "react";

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  // 状態管理:
  const [tempPixelSize, setTempPixelSize] =
    useState<number>(DEFAULT_PIXEL_SIZE); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      <div className="absolute top-4 left-4 bg-white shadow-lg p-4 rounded-lg z-10 w-64 space-y-4">
        {/* 画像ファイルの選択 */}
        <input
          type="file"
          accept="image/*"
          className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
        />

        {/* ピクセルサイズの入力フィールド */}
        <div className="flex items-center space-x-2">
          <label className="text-sm text-gray-700">Pixel Size:</label>
          <input
            type="number"
            value={tempPixelSize}
            onChange={(e) => setTempPixelSize(Number(e.target.value))}
            min="1"
            max="128"
            className="w-16 border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
          />
        </div>

        {/* 再表示ボタン */}
        <button className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 transition">
          再表示
        </button>

        {/* アニメーション制御ボタン */}
        <div className="flex flex-wrap gap-2">
          <button className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition">
            Explosion
          </button>
          <button className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition">
            Wave
          </button>
        </div>
      </div>
    </div>
  );
};

export default App;

今回は、シンプルなものを作りたいので、UIとしてはこんな感じにしておきます。
Explosion(爆発)やWave(波)はピクセルアートにアニメーションを付けるときに利用します。
ピクセルアートにアニメーションを付けれるのが、React Three Fiberでピクセルアートを作るメリットかなと思います。

💡コントロールパネルのイベントを作る!!

先ほど、作ったUIにイベントを入れていきます。

handleFileChange

handleFileChangeは、画像を選択した時の処理になります。
画像を選択すると、ピクセルサイズを更新してから、画像ソースを更新します。

// ReactおよびThree.js関連のライブラリをインポート
import { useState } from "react";

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  // 状態管理:
  const [tempPixelSize, setTempPixelSize] =
    useState<number>(DEFAULT_PIXEL_SIZE); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [pixelSize, setPixelSize] = useState<number>(tempPixelSize); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [imageSrc, setImageSrc] = useState<string | null>(null); // - imageSrc: 読み込んだ画像のData URL

  // ----------------------------------------------------
  // 画像ファイルが選択されたときの処理:
  // - ファイルを読み込み、Data URLに変換して imageSrc にセット
  // ----------------------------------------------------
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      const url = ev.target?.result;
      if (typeof url !== "string") return;
      // ピクセルサイズを一旦更新してから、画像ソースを設定
      setPixelSize(tempPixelSize);
      setImageSrc(url);
    };
    reader.readAsDataURL(file);
  };

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      <div className="absolute top-4 left-4 bg-white shadow-lg p-4 rounded-lg z-10 w-64 space-y-4">
        {/* 画像ファイルの選択 */}
        <input
          type="file"
          accept="image/*"
          onChange={handleFileChange}
          className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
        />

        {/* ピクセルサイズの入力フィールド */}
        ・・・
      </div>
    </div>
  );
};

export default App;

reloadImage

次が再表示ボタンのイベントです。
これは、ピクセルサイズを更新した際に、ピクセルサイズに合わせて再レンダリングさせるときに利用します。
tempPixelSizeをpixelSizeに反映する形です。

// ReactおよびThree.js関連のライブラリをインポート
import { useState } from "react";

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  // 状態管理:
  const [tempPixelSize, setTempPixelSize] =
    useState<number>(DEFAULT_PIXEL_SIZE); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [pixelSize, setPixelSize] = useState<number>(tempPixelSize); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [imageSrc, setImageSrc] = useState<string | null>(null); // - imageSrc: 読み込んだ画像のData URL

  // ----------------------------------------------------
  // 画像ファイルが選択されたときの処理:
  // - ファイルを読み込み、Data URLに変換して imageSrc にセット
  // ----------------------------------------------------
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    ・・・
  };

  // ----------------------------------------------------
  // 「再表示」ボタン押下時の処理:
  // - tempPixelSize の値を pixelSize に反映して画像を再描画
  // ----------------------------------------------------
  const reloadImage = () => {
    if (!imageSrc) return;
    setPixelSize(tempPixelSize);
  };

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      <div className="absolute top-4 left-4 bg-white shadow-lg p-4 rounded-lg z-10 w-64 space-y-4">
        ・・・

        {/* 再表示ボタン */}
        <button
          className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 transition"
          onClick={reloadImage}
        >
          再表示
        </button>

        {/* アニメーション制御ボタン */}
        <div className="flex flex-wrap gap-2">
          <button className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition">
            Explosion
          </button>
          <button className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition">
            Wave
          </button>
        </div>
      </div>
    </div>
  );
};

export default App;

controlExplosionAnimationとcontrolWaveAnimation

アニメーションの制御ボタンのイベントは、下記のような形になります。
定数でANIMATION_TIMEを設定しているので、その秒数でアニメーションして、同じ秒数で元に戻して、defaultモードに戻します。

// ReactおよびThree.js関連のライブラリをインポート
import { useState } from "react";

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  // 状態管理:
  const [tempPixelSize, setTempPixelSize] =
    useState<number>(DEFAULT_PIXEL_SIZE); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [pixelSize, setPixelSize] = useState<number>(tempPixelSize); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [imageSrc, setImageSrc] = useState<string | null>(null); // - imageSrc: 読み込んだ画像のData URL

  const [animation, setAnimation] = useState("default"); // - animation: 現在のアニメーションモード

  // ----------------------------------------------------
  // 画像ファイルが選択されたときの処理:
  // - ファイルを読み込み、Data URLに変換して imageSrc にセット
  // ----------------------------------------------------
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    ・・・
  };

  // ----------------------------------------------------
  // 「再表示」ボタン押下時の処理:
  // - tempPixelSize の値を pixelSize に反映して画像を再描画
  // ----------------------------------------------------
  const reloadImage = () => {
    ・・・
  };

  // ----------------------------------------------------
  // Explosionアニメーションの制御:
  // - 爆発の開始、終了、そして元に戻す処理をタイムアウトで順次実行
  // ----------------------------------------------------
  const controlExplosionAnimation = () => {
    setAnimation("explosion_start");
    setTimeout(() => {
      console.log("explosion_end");
      setAnimation("explosion_end");
    }, ANIMATION_TIME * 1000);
    setTimeout(() => {
      console.log("default");
      setAnimation("default");
    }, ANIMATION_TIME * 2000);
  };

  // ----------------------------------------------------
  // Waveアニメーションの制御:
  // - 波の開始、終了、そして元に戻す処理をタイムアウトで順次実行
  // ----------------------------------------------------
  const controlWaveAnimation = () => {
    setAnimation("wave_start");
    setTimeout(() => {
      setAnimation("wave_end");
    }, ANIMATION_TIME * 1000);
    setTimeout(() => {
      setAnimation("default");
    }, ANIMATION_TIME * 2000);
  };

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      <div className="absolute top-4 left-4 bg-white shadow-lg p-4 rounded-lg z-10 w-64 space-y-4">
        ・・・

        {/* アニメーション制御ボタン */}
        <div className="flex flex-wrap gap-2">
          <button
            className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition"
            onClick={controlExplosionAnimation}
          >
            Explosion
          </button>
          <button
            className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition"
            onClick={controlWaveAnimation}
          >
            Wave
          </button>
        </div>
      </div>
    </div>
  );
};

export default App;

💡現時点の全体ソースはこちら

// ReactおよびThree.js関連のライブラリをインポート
import { useState } from "react";

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  // 状態管理:
  const [tempPixelSize, setTempPixelSize] =
    useState<number>(DEFAULT_PIXEL_SIZE); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [pixelSize, setPixelSize] = useState<number>(tempPixelSize); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [imageSrc, setImageSrc] = useState<string | null>(null); // - imageSrc: 読み込んだ画像のData URL

  const [animation, setAnimation] = useState("default"); // - animation: 現在のアニメーションモード

  // ----------------------------------------------------
  // 画像ファイルが選択されたときの処理:
  // - ファイルを読み込み、Data URLに変換して imageSrc にセット
  // ----------------------------------------------------
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      const url = ev.target?.result;
      if (typeof url !== "string") return;
      // ピクセルサイズを一旦更新してから、画像ソースを設定
      setPixelSize(tempPixelSize);
      setImageSrc(url);
    };
    reader.readAsDataURL(file);
  };

  // ----------------------------------------------------
  // 「再表示」ボタン押下時の処理:
  // - tempPixelSize の値を pixelSize に反映して画像を再描画
  // ----------------------------------------------------
  const reloadImage = () => {
    if (!imageSrc) return;
    setPixelSize(tempPixelSize);
  };

  // ----------------------------------------------------
  // Explosionアニメーションの制御:
  // - 爆発の開始、終了、そして元に戻す処理をタイムアウトで順次実行
  // ----------------------------------------------------
  const controlExplosionAnimation = () => {
    setAnimation("explosion_start");
    setTimeout(() => {
      console.log("explosion_end");
      setAnimation("explosion_end");
    }, ANIMATION_TIME * 1000);
    setTimeout(() => {
      console.log("default");
      setAnimation("default");
    }, ANIMATION_TIME * 2000);
  };

  // ----------------------------------------------------
  // Waveアニメーションの制御:
  // - 波の開始、終了、そして元に戻す処理をタイムアウトで順次実行
  // ----------------------------------------------------
  const controlWaveAnimation = () => {
    setAnimation("wave_start");
    setTimeout(() => {
      setAnimation("wave_end");
    }, ANIMATION_TIME * 1000);
    setTimeout(() => {
      setAnimation("default");
    }, ANIMATION_TIME * 2000);
  };

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      <div className="absolute top-4 left-4 bg-white shadow-lg p-4 rounded-lg z-10 w-64 space-y-4">
        {/* 画像ファイルの選択 */}
        <input
          type="file"
          accept="image/*"
          onChange={handleFileChange}
          className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
        />

        {/* ピクセルサイズの入力フィールド */}
        <div className="flex items-center space-x-2">
          <label className="text-sm text-gray-700">Pixel Size:</label>
          <input
            type="number"
            value={tempPixelSize}
            onChange={(e) => setTempPixelSize(Number(e.target.value))}
            min="1"
            max="128"
            className="w-16 border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
          />
        </div>

        {/* 再表示ボタン */}
        <button
          className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 transition"
          onClick={reloadImage}
        >
          再表示
        </button>

        {/* アニメーション制御ボタン */}
        <div className="flex flex-wrap gap-2">
          <button
            className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition"
            onClick={controlExplosionAnimation}
          >
            Explosion
          </button>
          <button
            className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition"
            onClick={controlWaveAnimation}
          >
            Wave
          </button>
        </div>
      </div>
    </div>
  );
};

export default App;

💡画像からピクセルデータを作成!!

今回は、画像を読み込んでそのピクセルデータを生成して、画面表示をしていきます。
なので、画像からピクセルデータを生成する処理を作成していきましょう。

※説明上、ファイルなどは分けておりませんが。コンポーネントレベルでファイルを分けるなどした方が、可読性などは高いと思います。

createPixelData関数の作成

// ReactおよびThree.js関連のライブラリをインポート
import { useState } from "react";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  ・・・
};

export default App;

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  // Canvas要素を作成し、指定のサイズに設定
  const canvas = document.createElement("canvas");
  canvas.width = targetWidth;
  canvas.height = targetHeight;
  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("No 2D context available");

  // 画像をCanvas上に描画し、ピクセル情報を取得
  ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
  const imgData = ctx.getImageData(0, 0, targetWidth, targetHeight);
  const data = imgData.data;

  const result: PixelInfo[] = [];
  let idx = 0;
  // 画像の各ピクセルに対して、RGBAの値を取得しPixelInfoに変換
  for (let y = 0; y < targetHeight; y++) {
    for (let x = 0; x < targetWidth; x++) {
      const r = data[idx],
        g = data[idx + 1],
        b = data[idx + 2],
        a = data[idx + 3];
      idx += 4;
      // 透明度が低いピクセルは無視する(a < 30の場合)
      if (a < 30) continue;
      result.push({
        // 画像の中心を原点とするため、x,y座標を調整
        x: x - targetWidth / 2,
        y: -y + targetHeight / 2,
        z: 0,
        // RGB値を 0~1 の範囲に変換してTHREE.Colorを作成
        color: new THREE.Color(r / 255, g / 255, b / 255),
      });
    }
  }
  return result;
};

処理としては、下記の流れです。処理は若干複雑ですが、やっていることはシンプルだと思います。

  • 画像とピクセルサイズをもらう
  • 画像をCanvas上に描画して、ピクセル情報を取得
  • ピクセルのRGBA情報を取得してPixelInfoに格納

呼び出し部分を作成(useEffect)

ピクセルデータは、画像かピクセルサイズが変更されたときに更新したいので、useEffect()で[pixelSize, imageSrc]を監視して更新をします。

// ReactおよびThree.js関連のライブラリをインポート
import { useState, useEffect } from "react";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  // 状態管理:
  const [pixels, setPixels] = useState<PixelInfo[] | null>(null); // - pixels: 画像から生成されたピクセル情報の配列
  const [fileChangeCount, setFileChangeCount] = useState<number>(0); // - fileChangeCount: 画像の変更回数(再読み込みのトリガーに使用)
  const [tempPixelSize, setTempPixelSize] =
    useState<number>(DEFAULT_PIXEL_SIZE); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [pixelSize, setPixelSize] = useState<number>(tempPixelSize); // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  const [imageSrc, setImageSrc] = useState<string | null>(null); // - imageSrc: 読み込んだ画像のData URL
  const [animation, setAnimation] = useState("default"); // - animation: 現在のアニメーションモード

  ・・・

  // ----------------------------------------------------
  // 画像または pixelSize が変更されたときに、画像からピクセルデータを生成する
  // ----------------------------------------------------
  useEffect(() => {
    if (!imageSrc) return;
    // 画像が変わった際のトリガーとして fileChangeCount を更新
    setFileChangeCount((prev) => prev + 1);
    // 新たな画像を読み込み、onload イベントでピクセルデータを生成
    const img = new Image();
    img.onload = () => {
      const pix = createPixelData(img, pixelSize, pixelSize);
      setPixels(pix);
    };
    img.src = imageSrc;
  }, [pixelSize, imageSrc]);

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      ・・・
    </div>
  );
};

export default App;

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  // Canvas要素を作成し、指定のサイズに設定
  const canvas = document.createElement("canvas");
  canvas.width = targetWidth;
  canvas.height = targetHeight;
  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("No 2D context available");

  // 画像をCanvas上に描画し、ピクセル情報を取得
  ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
  const imgData = ctx.getImageData(0, 0, targetWidth, targetHeight);
  const data = imgData.data;

  const result: PixelInfo[] = [];
  let idx = 0;
  // 画像の各ピクセルに対して、RGBAの値を取得しPixelInfoに変換
  for (let y = 0; y < targetHeight; y++) {
    for (let x = 0; x < targetWidth; x++) {
      const r = data[idx],
        g = data[idx + 1],
        b = data[idx + 2],
        a = data[idx + 3];
      idx += 4;
      // 透明度が低いピクセルは無視する(a < 30の場合)
      if (a < 30) continue;
      result.push({
        // 画像の中心を原点とするため、x,y座標を調整
        x: x - targetWidth / 2,
        y: -y + targetHeight / 2,
        z: 0,
        // RGB値を 0~1 の範囲に変換してTHREE.Colorを作成
        color: new THREE.Color(r / 255, g / 255, b / 255),
      });
    }
  }
  return result;
};

💡ピクセル表示用のCanvasを用意!!

最初にCanvasを配置して、カメラやライティングなどを用意しておきましょう。

// ReactおよびThree.js関連のライブラリをインポート
import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { useState, useEffect, useRef } from "react";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  
  ・・・

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      ・・・

      {/* ----------------- メインCanvas:ピクセルシーン ----------------- */}
      <div className="absolute top-0 left-0 w-full h-full z-[1]">
        <Canvas camera={{ far: 5000, position: [0, 0, 100] }}>
          {/* 基本の照明設定 */}
          <ambientLight intensity={1} />
          <directionalLight position={[100, 200, 100]} intensity={1} />
          {/* マウス操作でシーンの回転が可能なOrbitControls */}
          <OrbitControls />
          {/* ピクセルデータが存在する場合のみPixelGridをレンダリング */}
          )}
        </Canvas>
      </div>
    </div>
  );
};

export default App;

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  ・・・
  return result;
};

💡ピクセルを表示させる!!

// ReactおよびThree.js関連のライブラリをインポート
import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { useState, useEffect, useRef } from "react";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// PixelGridコンポーネント:
// - ピクセルデータのグループをレンダリングし、
// - 表示アニメーション(段階的な拡大)と移動アニメーション(爆発・波)を制御
// ===================================================================
type PixelGridProps = {
  pixelSize: number;
  pixels: PixelInfo[];
  animation: string;
  fileChangeCount: number;
};

const PixelGrid = ({
  pixelSize,
  pixels,
  animation,
  fileChangeCount,
}: PixelGridProps) => {
  // Three.js の Group オブジェクトへの参照。全ピクセルをまとめるために使用
  const groupRef = useRef<THREE.Group>(null);

  // groupRefにより管理されるグループ内に、各PixelBoxコンポーネントを配置してレンダリング
  return (
    <group ref={groupRef}>
      {pixels.map((pixel, i) => (
        <PixelBox key={i} pixel={pixel} scale={1} />
      ))}
    </group>
  );
};

// ===================================================================
// PixelBoxコンポーネント:
// - 単一のピクセル(箱)を描画するためのコンポーネント
// ===================================================================
type PixelBoxProps = {
  pixel: PixelInfo;
  scale: number;
};

const PixelBox = ({ pixel, scale }: PixelBoxProps) => {
  // mesh: Three.js のオブジェクトで、位置とスケールを指定して箱ジオメトリとマテリアルを使用
  return (
    <mesh position={[pixel.x, pixel.y, pixel.z]} scale={[scale, scale, scale]}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={pixel.color} />
    </mesh>
  );
};

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  ・・・

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      ・・・

      {/* ----------------- メインCanvas:ピクセルシーン ----------------- */}
      <div className="absolute top-0 left-0 w-full h-full z-[1]">
        <Canvas camera={{ far: 5000, position: [0, 0, 100] }}>
          {/* 基本の照明設定 */}
          <ambientLight intensity={1} />
          <directionalLight position={[100, 200, 100]} intensity={1} />
          {/* マウス操作でシーンの回転が可能なOrbitControls */}
          <OrbitControls />
          {/* ピクセルデータが存在する場合のみPixelGridをレンダリング */}
          {pixels && (
            <PixelGrid
              pixelSize={pixelSize}
              pixels={pixels}
              animation={animation}
              fileChangeCount={fileChangeCount}
            />
          )}
        </Canvas>
      </div>
    </div>
  );
};

export default App;

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  ・・・
};

これで一応、ピクセルアートは表示されていると思います。
これでもいいんですが、React Three Fiberで作っているので、アニメーション等を追加していきます。

💡表示アニメーションを追加

表示アニメーションは、徐々に描画されているのを再現してみようと思います。
ちょっとずつピクセルのscaleを0⇒1にしていくイメージです。

// ReactおよびThree.js関連のライブラリをインポート
import { OrbitControls } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { useState, useEffect, useRef } from "react";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// PixelGridコンポーネント:
// - ピクセルデータのグループをレンダリングし、
// - 表示アニメーション(段階的な拡大)と移動アニメーション(爆発・波)を制御
// ===================================================================
type PixelGridProps = {
  pixelSize: number;
  pixels: PixelInfo[];
  animation: string;
  fileChangeCount: number;
};

const PixelGrid = ({
  pixelSize,
  pixels,
  animation,
  fileChangeCount,
}: PixelGridProps) => {
  // Three.js の Group オブジェクトへの参照。全ピクセルをまとめるために使用
  const groupRef = useRef<THREE.Group>(null);

  // ----------------------------------------------------
  // 表示アニメーション:ピクセルが段階的に表示される処理
  // ----------------------------------------------------
  // 各ピクセルのスケール状態を管理(初期状態は全て0=非表示)
  const [scales, setScales] = useState<number[]>(Array(pixels.length).fill(0));
  // scaleProgressRef: バッチごとの表示進捗(累積時間)を保持
  const scaleProgressRef = useRef(0);
  // batchIndexRef: 現在表示中のバッチ番号を管理
  const batchIndexRef = useRef(0);

  // useFrame で毎フレームの更新処理を行い、段階的にピクセルを表示
  useFrame((_, delta) => {
    // すべてのバッチが表示済みならこれ以上処理を行わない
    if (batchIndexRef.current > pixels.length / pixelSize) return;

    // 前フレームからの経過時間を加算
    scaleProgressRef.current += delta;

    // 現在のバッチを表示するための時間の閾値を計算
    const threshold =
      batchIndexRef.current * (ANIMATION_TIME / (pixels.length / pixelSize));

    // 経過時間が閾値を超えたら、次のバッチのピクセルを表示開始
    if (scaleProgressRef.current > threshold) {
      // 現在のスケール状態のコピーを作成
      const newScales = [...scales];
      // 現在のバッチに属するピクセルのインデックス範囲を算出
      const startIndex = batchIndexRef.current * pixelSize;
      const endIndex = Math.min(startIndex + pixelSize, pixels.length);

      // 該当するピクセルのスケールを1にして表示させる
      for (let i = startIndex; i < endIndex; i++) {
        newScales[i] = 1;
      }
      // 更新したスケール状態をセット
      setScales(newScales);
      // 次のバッチに進む
      batchIndexRef.current += 1;
    }
  });

  // groupRefにより管理されるグループ内に、各PixelBoxコンポーネントを配置してレンダリング
  return (
    <group ref={groupRef}>
      {pixels.map((pixel, i) => (
        <PixelBox key={i} pixel={pixel} scale={scales[i]} />
      ))}
    </group>
  );
};

// ===================================================================
// PixelBoxコンポーネント:
// - 単一のピクセル(箱)を描画するためのコンポーネント
// ===================================================================
type PixelBoxProps = {
  pixel: PixelInfo;
  scale: number;
};

const PixelBox = ({ pixel, scale }: PixelBoxProps) => {
  // mesh: Three.js のオブジェクトで、位置とスケールを指定して箱ジオメトリとマテリアルを使用
  return (
    <mesh position={[pixel.x, pixel.y, pixel.z]} scale={[scale, scale, scale]}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={pixel.color} />
    </mesh>
  );
};

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {

  ・・・

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      ・・・

      {/* ----------------- メインCanvas:ピクセルシーン ----------------- */}
      <div className="absolute top-0 left-0 w-full h-full z-[1]">
        <Canvas camera={{ far: 5000, position: [0, 0, 100] }}>
          {/* 基本の照明設定 */}
          <ambientLight intensity={1} />
          <directionalLight position={[100, 200, 100]} intensity={1} />
          {/* マウス操作でシーンの回転が可能なOrbitControls */}
          <OrbitControls />
          {/* ピクセルデータが存在する場合のみPixelGridをレンダリング */}
          {pixels && (
            <PixelGrid
              pixelSize={pixelSize}
              pixels={pixels}
              animation={animation}
              fileChangeCount={fileChangeCount}
            />
          )}
        </Canvas>
      </div>
    </div>
  );
};

export default App;

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  ・・・
};

💡画像再読み込みに対応!!

このままだと、画像を再読み込みした際に表示アニメーションが効かず、すぐ表示されてしまいます。
画像を再読み込みした際や、ピクセルサイズを変更した際に、状態をリセットするためにuseEffectを追加します。

// ReactおよびThree.js関連のライブラリをインポート
import { OrbitControls } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { useState, useEffect, useRef } from "react";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// PixelGridコンポーネント:
// - ピクセルデータのグループをレンダリングし、
// - 表示アニメーション(段階的な拡大)と移動アニメーション(爆発・波)を制御
// ===================================================================
type PixelGridProps = {
  pixelSize: number;
  pixels: PixelInfo[];
  animation: string;
  fileChangeCount: number;
};

const PixelGrid = ({
  pixelSize,
  pixels,
  animation,
  fileChangeCount,
}: PixelGridProps) => {
  // Three.js の Group オブジェクトへの参照。全ピクセルをまとめるために使用
  const groupRef = useRef<THREE.Group>(null);

  // ----------------------------------------------------
  // 表示アニメーション:ピクセルが段階的に表示される処理
  // ----------------------------------------------------
  // 各ピクセルのスケール状態を管理(初期状態は全て0=非表示)
  const [scales, setScales] = useState<number[]>(Array(pixels.length).fill(0));
  // scaleProgressRef: バッチごとの表示進捗(累積時間)を保持
  const scaleProgressRef = useRef(0);
  // batchIndexRef: 現在表示中のバッチ番号を管理
  const batchIndexRef = useRef(0);
  // 画像ファイルが変更されたかを判定するための参照
  const prevFileChangeCountRef = useRef(fileChangeCount);

  // 画像再読み込み時に、全ピクセルの表示状態(スケール)をリセットする
  useEffect(() => {
    if (fileChangeCount !== prevFileChangeCountRef.current) {
      // ピクセルを全て非表示にするため、スケールを0にリセット
      setScales(Array(pixels.length).fill(0));
      // バッチ表示用の累積時間とバッチインデックスを初期化
      scaleProgressRef.current = 0;
      batchIndexRef.current = 0;
      // 変更回数の参照も更新
      prevFileChangeCountRef.current = fileChangeCount;
    }
  }, [fileChangeCount, pixels.length, pixelSize]);

  // useFrame で毎フレームの更新処理を行い、段階的にピクセルを表示
  useFrame((_, delta) => {
    // すべてのバッチが表示済みならこれ以上処理を行わない
    if (batchIndexRef.current > pixels.length / pixelSize) return;

    // 前フレームからの経過時間を加算
    scaleProgressRef.current += delta;

    // 現在のバッチを表示するための時間の閾値を計算
    const threshold =
      batchIndexRef.current * (ANIMATION_TIME / (pixels.length / pixelSize));

    // 経過時間が閾値を超えたら、次のバッチのピクセルを表示開始
    if (scaleProgressRef.current > threshold) {
      // 現在のスケール状態のコピーを作成
      const newScales = [...scales];
      // 現在のバッチに属するピクセルのインデックス範囲を算出
      const startIndex = batchIndexRef.current * pixelSize;
      const endIndex = Math.min(startIndex + pixelSize, pixels.length);

      // 該当するピクセルのスケールを1にして表示させる
      for (let i = startIndex; i < endIndex; i++) {
        newScales[i] = 1;
      }
      // 更新したスケール状態をセット
      setScales(newScales);
      // 次のバッチに進む
      batchIndexRef.current += 1;
    }
  });

  // groupRefにより管理されるグループ内に、各PixelBoxコンポーネントを配置してレンダリング
  return (
    <group ref={groupRef}>
      {pixels.map((pixel, i) => (
        <PixelBox key={i} pixel={pixel} scale={scales[i]} />
      ))}
    </group>
  );
};

// ===================================================================
// PixelBoxコンポーネント:
// - 単一のピクセル(箱)を描画するためのコンポーネント
// ===================================================================
type PixelBoxProps = {
  pixel: PixelInfo;
  scale: number;
};

const PixelBox = ({ pixel, scale }: PixelBoxProps) => {
  // mesh: Three.js のオブジェクトで、位置とスケールを指定して箱ジオメトリとマテリアルを使用
  return (
    <mesh position={[pixel.x, pixel.y, pixel.z]} scale={[scale, scale, scale]}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={pixel.color} />
    </mesh>
  );
};

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {

  ・・・

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      ・・・

      {/* ----------------- メインCanvas:ピクセルシーン ----------------- */}
      <div className="absolute top-0 left-0 w-full h-full z-[1]">
        <Canvas camera={{ far: 5000, position: [0, 0, 100] }}>
          {/* 基本の照明設定 */}
          <ambientLight intensity={1} />
          <directionalLight position={[100, 200, 100]} intensity={1} />
          {/* マウス操作でシーンの回転が可能なOrbitControls */}
          <OrbitControls />
          {/* ピクセルデータが存在する場合のみPixelGridをレンダリング */}
          {pixels && (
            <PixelGrid
              pixelSize={pixelSize}
              pixels={pixels}
              animation={animation}
              fileChangeCount={fileChangeCount}
            />
          )}
        </Canvas>
      </div>
    </div>
  );
};

export default App;

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  ・・・
};

これで画像を再読み込みしても、正しく動作することが確認できると思います。

pixelSizeも監視対象になっているため、Pixel Sizeを更新しても、再表示されるようになっています。

💡爆発アニメーションを追加!!

次に、爆発アニメーションを追加します。
やっていることは結構シンプルで、最初にランダムな座標を計算して、useFrameでその位置に徐々に移動させるという形です。もとに戻すときは、その逆をしてあげればOKです。

// ReactおよびThree.js関連のライブラリをインポート
import { OrbitControls } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { useState, useEffect, useRef } from "react";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// PixelGridコンポーネント:
// - ピクセルデータのグループをレンダリングし、
// - 表示アニメーション(段階的な拡大)と移動アニメーション(爆発・波)を制御
// ===================================================================
type PixelGridProps = {
  pixelSize: number;
  pixels: PixelInfo[];
  animation: string;
  fileChangeCount: number;
};

const PixelGrid = ({
  pixelSize,
  pixels,
  animation,
  fileChangeCount,
}: PixelGridProps) => {
  // Three.js の Group オブジェクトへの参照。全ピクセルをまとめるために使用
  const groupRef = useRef<THREE.Group>(null);

  // ----------------------------------------------------
  // 表示アニメーション:ピクセルが段階的に表示される処理
  // ----------------------------------------------------
  
  ・・・

  // ----------------------------------------------------
  // 移動アニメーション:爆発や波のエフェクトでピクセルの位置を変化
  // ----------------------------------------------------
  // 前回のアニメーション状態と比較し、変化があれば進行度をリセットするための参照
  const lastAnimationRef = useRef(animation);
  // アニメーションの進行度(0~1)を管理
  const animationProgressRef = useRef(0);

  // 各ピクセルに対しランダムな散乱先の座標を生成(爆発エフェクト用)
  const scatterPositionsRef = useRef(
    pixels.map(() => ({
      x: (Math.random() - 0.5) * 100,
      y: (Math.random() - 0.5) * 100,
      z: (Math.random() - 0.5) * 100,
    }))
  );

  // 画像やピクセルデータが更新された場合、ランダムな散乱座標を再生成する
  useEffect(() => {
    scatterPositionsRef.current = pixels.map(() => ({
      x: (Math.random() - 0.5) * 100,
      y: (Math.random() - 0.5) * 100,
      z: (Math.random() - 0.5) * 100,
    }));
  }, [pixels]);

  // useFrame で毎フレーム、各ピクセルの位置をアニメーション進捗に合わせて更新
  useFrame((_, delta) => {
    // アニメーション状態が変化した場合、進行度をリセットする
    if (animation !== lastAnimationRef.current) {
      lastAnimationRef.current = animation;
      animationProgressRef.current = 0;
    }
    // "default" 状態では位置の更新は行わない
    if (animation === "default") return;

    // アニメーションの進行度を更新(ANIMATION_TIMEで正規化)
    if (animationProgressRef.current < 1) {
      animationProgressRef.current += delta / ANIMATION_TIME;
      if (animationProgressRef.current > 1) animationProgressRef.current = 1;
    }
    // 補間を滑らかにするため、sin関数を利用して進行度を変換
    const smoothProgress = Math.sin(
      (animationProgressRef.current * Math.PI) / 2
    );

    // 各ピクセルの新しい位置を計算し、更新する
    pixels.forEach((pixel, i) => {
      // 基本のターゲット座標は元の座標
      let targetX = pixel.x;
      let targetY = pixel.y;
      let targetZ = pixel.z;

      // 爆発開始: 元の位置からランダムな散乱位置へ移動
      if (animation === "explosion_start") {
        targetX =
          scatterPositionsRef.current[i].x * smoothProgress +
          pixel.x * (1 - smoothProgress);
        targetY =
          scatterPositionsRef.current[i].y * smoothProgress +
          pixel.y * (1 - smoothProgress);
        targetZ =
          scatterPositionsRef.current[i].z * smoothProgress +
          pixel.z * (1 - smoothProgress);
      }
      // 爆発終了: 散乱位置から元の位置へ戻る
      else if (animation === "explosion_end") {
        targetX =
          scatterPositionsRef.current[i].x * (1 - smoothProgress) +
          pixel.x * smoothProgress;
        targetY =
          scatterPositionsRef.current[i].y * (1 - smoothProgress) +
          pixel.y * smoothProgress;
        targetZ =
          scatterPositionsRef.current[i].z * (1 - smoothProgress) +
          pixel.z * smoothProgress;
      }

      // 対応するピクセルのメッシュの位置を更新
      groupRef.current?.children[i]?.position.set(targetX, targetY, targetZ);
    });
  });

  // groupRefにより管理されるグループ内に、各PixelBoxコンポーネントを配置してレンダリング
  return (
    <group ref={groupRef}>
      {pixels.map((pixel, i) => (
        <PixelBox key={i} pixel={pixel} scale={scales[i]} />
      ))}
    </group>
  );
};

// ===================================================================
// PixelBoxコンポーネント:
// - 単一のピクセル(箱)を描画するためのコンポーネント
// ===================================================================
type PixelBoxProps = {
  pixel: PixelInfo;
  scale: number;
};

const PixelBox = ({ pixel, scale }: PixelBoxProps) => {
  // mesh: Three.js のオブジェクトで、位置とスケールを指定して箱ジオメトリとマテリアルを使用
  return (
    <mesh position={[pixel.x, pixel.y, pixel.z]} scale={[scale, scale, scale]}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={pixel.color} />
    </mesh>
  );
};

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {

  ・・・

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      ・・・

      {/* ----------------- メインCanvas:ピクセルシーン ----------------- */}
      <div className="absolute top-0 left-0 w-full h-full z-[1]">
        <Canvas camera={{ far: 5000, position: [0, 0, 100] }}>
          {/* 基本の照明設定 */}
          <ambientLight intensity={1} />
          <directionalLight position={[100, 200, 100]} intensity={1} />
          {/* マウス操作でシーンの回転が可能なOrbitControls */}
          <OrbitControls />
          {/* ピクセルデータが存在する場合のみPixelGridをレンダリング */}
          {pixels && (
            <PixelGrid
              pixelSize={pixelSize}
              pixels={pixels}
              animation={animation}
              fileChangeCount={fileChangeCount}
            />
          )}
        </Canvas>
      </div>
    </div>
  );
};

export default App;

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  ・・・
};

簡易的ではありますが、1ピクセル毎に分裂している感じは出ているのではないかなと思います!!

💡波アニメーションを追加!!

波アニメーションも追加しましょう。
これも基本的には爆発アニメーションと同じ要領で追加していけるかなと思います。

// ReactおよびThree.js関連のライブラリをインポート
import { OrbitControls } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { useState, useEffect, useRef } from "react";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
const DEFAULT_PIXEL_SIZE = 64; // - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
const ANIMATION_TIME = 3; // - ANIMATION_TIME: アニメーションにかかる時間(秒)

// ===================================================================
// PixelGridコンポーネント:
// - ピクセルデータのグループをレンダリングし、
// - 表示アニメーション(段階的な拡大)と移動アニメーション(爆発・波)を制御
// ===================================================================
type PixelGridProps = {
  pixelSize: number;
  pixels: PixelInfo[];
  animation: string;
  fileChangeCount: number;
};

const PixelGrid = ({
  pixelSize,
  pixels,
  animation,
  fileChangeCount,
}: PixelGridProps) => {
  // Three.js の Group オブジェクトへの参照。全ピクセルをまとめるために使用
  const groupRef = useRef<THREE.Group>(null);

  // ----------------------------------------------------
  // 表示アニメーション:ピクセルが段階的に表示される処理
  // ----------------------------------------------------
  
  ・・・

  // ----------------------------------------------------
  // 移動アニメーション:爆発や波のエフェクトでピクセルの位置を変化
  // ----------------------------------------------------
  // 前回のアニメーション状態と比較し、変化があれば進行度をリセットするための参照
  const lastAnimationRef = useRef(animation);
  // アニメーションの進行度(0~1)を管理
  const animationProgressRef = useRef(0);

  // 各ピクセルに対しランダムな散乱先の座標を生成(爆発エフェクト用)
  const scatterPositionsRef = useRef(
    pixels.map(() => ({
      x: (Math.random() - 0.5) * 100,
      y: (Math.random() - 0.5) * 100,
      z: (Math.random() - 0.5) * 100,
    }))
  );

  // 画像やピクセルデータが更新された場合、ランダムな散乱座標を再生成する
  useEffect(() => {
    scatterPositionsRef.current = pixels.map(() => ({
      x: (Math.random() - 0.5) * 100,
      y: (Math.random() - 0.5) * 100,
      z: (Math.random() - 0.5) * 100,
    }));
  }, [pixels]);

  // useFrame で毎フレーム、各ピクセルの位置をアニメーション進捗に合わせて更新
  useFrame((_, delta) => {
    // アニメーション状態が変化した場合、進行度をリセットする
    if (animation !== lastAnimationRef.current) {
      lastAnimationRef.current = animation;
      animationProgressRef.current = 0;
    }
    // "default" 状態では位置の更新は行わない
    if (animation === "default") return;

    // アニメーションの進行度を更新(ANIMATION_TIMEで正規化)
    if (animationProgressRef.current < 1) {
      animationProgressRef.current += delta / ANIMATION_TIME;
      if (animationProgressRef.current > 1) animationProgressRef.current = 1;
    }
    // 補間を滑らかにするため、sin関数を利用して進行度を変換
    const smoothProgress = Math.sin(
      (animationProgressRef.current * Math.PI) / 2
    );

    // 各ピクセルの新しい位置を計算し、更新する
    pixels.forEach((pixel, i) => {
      // 基本のターゲット座標は元の座標
      let targetX = pixel.x;
      let targetY = pixel.y;
      let targetZ = pixel.z;

      // 爆発開始: 元の位置からランダムな散乱位置へ移動
      if (animation === "explosion_start") {
        targetX =
          scatterPositionsRef.current[i].x * smoothProgress +
          pixel.x * (1 - smoothProgress);
        targetY =
          scatterPositionsRef.current[i].y * smoothProgress +
          pixel.y * (1 - smoothProgress);
        targetZ =
          scatterPositionsRef.current[i].z * smoothProgress +
          pixel.z * (1 - smoothProgress);
      }
      // 爆発終了: 散乱位置から元の位置へ戻る
      else if (animation === "explosion_end") {
        targetX =
          scatterPositionsRef.current[i].x * (1 - smoothProgress) +
          pixel.x * smoothProgress;
        targetY =
          scatterPositionsRef.current[i].y * (1 - smoothProgress) +
          pixel.y * smoothProgress;
        targetZ =
          scatterPositionsRef.current[i].z * (1 - smoothProgress) +
          pixel.z * smoothProgress;
      }
      // 波の開始: XとZの座標に基づいて、Z軸方向に波の動きを与える
      else if (animation === "wave_start") {
        targetZ =
          Math.sin(
            (pixel.x + pixel.z + animationProgressRef.current * 10) * 0.3
          ) *
          5 *
          smoothProgress;
      }
      // 波の終了: 波の効果を徐々に打ち消して元の状態に戻す
      else if (animation === "wave_end") {
        targetZ =
          Math.sin(
            (pixel.x + pixel.z + (1 - animationProgressRef.current) * 10) * 0.3
          ) *
          5 *
          (1 - smoothProgress);
      }

      // 対応するピクセルのメッシュの位置を更新
      groupRef.current?.children[i]?.position.set(targetX, targetY, targetZ);
    });
  });

  // groupRefにより管理されるグループ内に、各PixelBoxコンポーネントを配置してレンダリング
  return (
    <group ref={groupRef}>
      {pixels.map((pixel, i) => (
        <PixelBox key={i} pixel={pixel} scale={scales[i]} />
      ))}
    </group>
  );
};

// ===================================================================
// PixelBoxコンポーネント:
// - 単一のピクセル(箱)を描画するためのコンポーネント
// ===================================================================
type PixelBoxProps = {
  pixel: PixelInfo;
  scale: number;
};

const PixelBox = ({ pixel, scale }: PixelBoxProps) => {
  // mesh: Three.js のオブジェクトで、位置とスケールを指定して箱ジオメトリとマテリアルを使用
  return (
    <mesh position={[pixel.x, pixel.y, pixel.z]} scale={[scale, scale, scale]}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={pixel.color} />
    </mesh>
  );
};

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {

  ・・・

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      ・・・

      {/* ----------------- メインCanvas:ピクセルシーン ----------------- */}
      <div className="absolute top-0 left-0 w-full h-full z-[1]">
        <Canvas camera={{ far: 5000, position: [0, 0, 100] }}>
          {/* 基本の照明設定 */}
          <ambientLight intensity={1} />
          <directionalLight position={[100, 200, 100]} intensity={1} />
          {/* マウス操作でシーンの回転が可能なOrbitControls */}
          <OrbitControls />
          {/* ピクセルデータが存在する場合のみPixelGridをレンダリング */}
          {pixels && (
            <PixelGrid
              pixelSize={pixelSize}
              pixels={pixels}
              animation={animation}
              fileChangeCount={fileChangeCount}
            />
          )}
        </Canvas>
      </div>
    </div>
  );
};

export default App;

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  ・・・
};

これで波打つ感じは表現できているかなと思います。

💡背景を追加!!

これは無くても良いのですが、ちょっと寂しかったので、背景も追加してみます。
React Three DreiにStarとCloudがあるので、良い感じに組み合わせておきます。

// ReactおよびThree.js関連のライブラリをインポート
import React, { useState, useRef, memo, useEffect } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { Cloud, OrbitControls, Stars } from "@react-three/drei";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
// - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
// - ANIMATION_TIME: アニメーションにかかる時間(秒)
const DEFAULT_PIXEL_SIZE = 64;
const ANIMATION_TIME = 3;

// ===================================================================
// PixelGridコンポーネント:
// - ピクセルデータのグループをレンダリングし、
// - 表示アニメーション(段階的な拡大)と移動アニメーション(爆発・波)を制御
// ===================================================================
type PixelGridProps = {
  pixelSize: number;
  pixels: PixelInfo[];
  animation: string;
  fileChangeCount: number;
};

const PixelGrid = ({
  pixelSize,
  pixels,
  animation,
  fileChangeCount,
}: PixelGridProps) => {

  ・・・

  // groupRefにより管理されるグループ内に、各PixelBoxコンポーネントを配置してレンダリング
  return (
    <group ref={groupRef}>
      {pixels.map((pixel, i) => (
        <PixelBox key={i} pixel={pixel} scale={scales[i]} />
      ))}
    </group>
  );
};

// ===================================================================
// PixelBoxコンポーネント:
// - 単一のピクセル(箱)を描画するためのコンポーネント
// ===================================================================
type PixelBoxProps = {
  pixel: PixelInfo;
  scale: number;
};

const PixelBox = ({ pixel, scale }: PixelBoxProps) => {
  // mesh: Three.js のオブジェクトで、位置とスケールを指定して箱ジオメトリとマテリアルを使用
  return (
    <mesh position={[pixel.x, pixel.y, pixel.z]} scale={[scale, scale, scale]}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={pixel.color} />
    </mesh>
  );
};

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {

  ・・・

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      ・・・
      {/* ----------------- メインCanvas:ピクセルシーン ----------------- */}
      ・・・

      {/* ----------------- 背景Canvas:星と雲のシーン ----------------- */}
      <div className="absolute top-0 left-0 w-full h-full z-[-1]">
        <Canvas
          camera={{ position: [0, 5, 15], fov: 50 }}
          style={{ background: "black" }}
        >
          <ambientLight intensity={0.5} />
          <pointLight position={[10, 10, 10]} intensity={0.5} color="white" />
          <BackgroundScene />
        </Canvas>
      </div>
    </div>
  );
};

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  ・・・
  return result;
};

// ===================================================================
// BackgroundSceneコンポーネント:
// - 背景に星空と雲を表示するためのコンポーネント
// - memoでラップすることで不要な再描画を防止
// ===================================================================
const BackgroundScene = memo(() => {
  return (
    <>
      {/* Starsコンポーネントで星空を表現 */}
      <Stars
        radius={100} // 星が存在する空間の半径
        depth={50} // 星の配置される深さの範囲
        count={5000} // 表示する星の総数
        factor={6} // 星の大きさ調整用の係数
        saturation={1} // 色の鮮やかさ
        fade // 遠くの星がフェードアウトする効果
      />
      {/* Cloudコンポーネントで雲を表現 */}
      <Cloud
        position={[0, 0, 0]} // 雲の中心位置
        opacity={0.1} // 雲の不透明度(低いほど透明)
        speed={0.2} // 雲の動く速度
        scale={[10, 10, 10]} // 雲全体のサイズ
        segments={20} // 雲を構成するパーティクルの数
      />
    </>
  );
});

export default App;

動画だとわかりずらいかもしれませんが、煙が上がっている感じでいい感じになっています。

💡最終ソースコード!!

💾 GitHubリポジトリ:こちらのリンクでソースコードをチェック!

// ReactおよびThree.js関連のライブラリをインポート
import React, { useState, useRef, memo, useEffect } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { Cloud, OrbitControls, Stars } from "@react-three/drei";
import * as THREE from "three";

// -----------------------------------------------------------------
// PixelInfo 型: 各ピクセルの位置(x, y, z)と色(THREE.Color)を管理
// -----------------------------------------------------------------
type PixelInfo = {
  x: number;
  y: number;
  z: number;
  color: THREE.Color;
};

// 定数設定:
// - DEFAULT_PIXEL_SIZE: 画像を縮小する際のデフォルトのサイズ(ピクセル数)
// - ANIMATION_TIME: アニメーションにかかる時間(秒)
const DEFAULT_PIXEL_SIZE = 64;
const ANIMATION_TIME = 3;

// ===================================================================
// PixelGridコンポーネント:
// - ピクセルデータのグループをレンダリングし、
// - 表示アニメーション(段階的な拡大)と移動アニメーション(爆発・波)を制御
// ===================================================================
type PixelGridProps = {
  pixelSize: number;
  pixels: PixelInfo[];
  animation: string;
  fileChangeCount: number;
};

const PixelGrid = ({
  pixelSize,
  pixels,
  animation,
  fileChangeCount,
}: PixelGridProps) => {
  // Three.js の Group オブジェクトへの参照。全ピクセルをまとめるために使用
  const groupRef = useRef<THREE.Group>(null);

  // ----------------------------------------------------
  // 表示アニメーション:ピクセルが段階的に表示される処理
  // ----------------------------------------------------
  // 各ピクセルのスケール状態を管理(初期状態は全て0=非表示)
  const [scales, setScales] = useState<number[]>(Array(pixels.length).fill(0));
  // scaleProgressRef: バッチごとの表示進捗(累積時間)を保持
  const scaleProgressRef = useRef(0);
  // batchIndexRef: 現在表示中のバッチ番号を管理
  const batchIndexRef = useRef(0);
  // 画像ファイルが変更されたかを判定するための参照
  const prevFileChangeCountRef = useRef(fileChangeCount);

  // 画像再読み込み時に、全ピクセルの表示状態(スケール)をリセットする
  useEffect(() => {
    if (fileChangeCount !== prevFileChangeCountRef.current) {
      // ピクセルを全て非表示にするため、スケールを0にリセット
      setScales(Array(pixels.length).fill(0));
      // バッチ表示用の累積時間とバッチインデックスを初期化
      scaleProgressRef.current = 0;
      batchIndexRef.current = 0;
      // 変更回数の参照も更新
      prevFileChangeCountRef.current = fileChangeCount;
    }
  }, [fileChangeCount, pixels.length, pixelSize]);

  // useFrame で毎フレームの更新処理を行い、段階的にピクセルを表示
  useFrame((_, delta) => {
    // すべてのバッチが表示済みならこれ以上処理を行わない
    if (batchIndexRef.current > pixels.length / pixelSize) return;

    // 前フレームからの経過時間を加算
    scaleProgressRef.current += delta;

    // 現在のバッチを表示するための時間の閾値を計算
    const threshold =
      batchIndexRef.current * (ANIMATION_TIME / (pixels.length / pixelSize));

    // 経過時間が閾値を超えたら、次のバッチのピクセルを表示開始
    if (scaleProgressRef.current > threshold) {
      // 現在のスケール状態のコピーを作成
      const newScales = [...scales];
      // 現在のバッチに属するピクセルのインデックス範囲を算出
      const startIndex = batchIndexRef.current * pixelSize;
      const endIndex = Math.min(startIndex + pixelSize, pixels.length);

      // 該当するピクセルのスケールを1にして表示させる
      for (let i = startIndex; i < endIndex; i++) {
        newScales[i] = 1;
      }
      // 更新したスケール状態をセット
      setScales(newScales);
      // 次のバッチに進む
      batchIndexRef.current += 1;
    }
  });

  // ----------------------------------------------------
  // 移動アニメーション:爆発や波のエフェクトでピクセルの位置を変化
  // ----------------------------------------------------
  // 前回のアニメーション状態と比較し、変化があれば進行度をリセットするための参照
  const lastAnimationRef = useRef(animation);
  // アニメーションの進行度(0~1)を管理
  const animationProgressRef = useRef(0);

  // 各ピクセルに対しランダムな散乱先の座標を生成(爆発エフェクト用)
  const scatterPositionsRef = useRef(
    pixels.map(() => ({
      x: (Math.random() - 0.5) * 100,
      y: (Math.random() - 0.5) * 100,
      z: (Math.random() - 0.5) * 100,
    }))
  );

  // 画像やピクセルデータが更新された場合、ランダムな散乱座標を再生成する
  useEffect(() => {
    scatterPositionsRef.current = pixels.map(() => ({
      x: (Math.random() - 0.5) * 100,
      y: (Math.random() - 0.5) * 100,
      z: (Math.random() - 0.5) * 100,
    }));
  }, [pixels]);

  // useFrame で毎フレーム、各ピクセルの位置をアニメーション進捗に合わせて更新
  useFrame((_, delta) => {
    // アニメーション状態が変化した場合、進行度をリセットする
    if (animation !== lastAnimationRef.current) {
      lastAnimationRef.current = animation;
      animationProgressRef.current = 0;
    }
    // "default" 状態では位置の更新は行わない
    if (animation === "default") return;

    // アニメーションの進行度を更新(ANIMATION_TIMEで正規化)
    if (animationProgressRef.current < 1) {
      animationProgressRef.current += delta / ANIMATION_TIME;
      if (animationProgressRef.current > 1) animationProgressRef.current = 1;
    }
    // 補間を滑らかにするため、sin関数を利用して進行度を変換
    const smoothProgress = Math.sin(
      (animationProgressRef.current * Math.PI) / 2
    );

    // 各ピクセルの新しい位置を計算し、更新する
    pixels.forEach((pixel, i) => {
      // 基本のターゲット座標は元の座標
      let targetX = pixel.x;
      let targetY = pixel.y;
      let targetZ = pixel.z;

      // 爆発開始: 元の位置からランダムな散乱位置へ移動
      if (animation === "explosion_start") {
        targetX =
          scatterPositionsRef.current[i].x * smoothProgress +
          pixel.x * (1 - smoothProgress);
        targetY =
          scatterPositionsRef.current[i].y * smoothProgress +
          pixel.y * (1 - smoothProgress);
        targetZ =
          scatterPositionsRef.current[i].z * smoothProgress +
          pixel.z * (1 - smoothProgress);
      }
      // 爆発終了: 散乱位置から元の位置へ戻る
      else if (animation === "explosion_end") {
        targetX =
          scatterPositionsRef.current[i].x * (1 - smoothProgress) +
          pixel.x * smoothProgress;
        targetY =
          scatterPositionsRef.current[i].y * (1 - smoothProgress) +
          pixel.y * smoothProgress;
        targetZ =
          scatterPositionsRef.current[i].z * (1 - smoothProgress) +
          pixel.z * smoothProgress;
      }
      // 波の開始: XとZの座標に基づいて、Z軸方向に波の動きを与える
      else if (animation === "wave_start") {
        targetZ =
          Math.sin(
            (pixel.x + pixel.z + animationProgressRef.current * 10) * 0.3
          ) *
          5 *
          smoothProgress;
      }
      // 波の終了: 波の効果を徐々に打ち消して元の状態に戻す
      else if (animation === "wave_end") {
        targetZ =
          Math.sin(
            (pixel.x + pixel.z + (1 - animationProgressRef.current) * 10) * 0.3
          ) *
          5 *
          (1 - smoothProgress);
      }

      // 対応するピクセルのメッシュの位置を更新
      groupRef.current?.children[i]?.position.set(targetX, targetY, targetZ);
    });
  });

  // groupRefにより管理されるグループ内に、各PixelBoxコンポーネントを配置してレンダリング
  return (
    <group ref={groupRef}>
      {pixels.map((pixel, i) => (
        <PixelBox key={i} pixel={pixel} scale={scales[i]} />
      ))}
    </group>
  );
};

// ===================================================================
// PixelBoxコンポーネント:
// - 単一のピクセル(箱)を描画するためのコンポーネント
// ===================================================================
type PixelBoxProps = {
  pixel: PixelInfo;
  scale: number;
};

const PixelBox = ({ pixel, scale }: PixelBoxProps) => {
  // mesh: Three.js のオブジェクトで、位置とスケールを指定して箱ジオメトリとマテリアルを使用
  return (
    <mesh position={[pixel.x, pixel.y, pixel.z]} scale={[scale, scale, scale]}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={pixel.color} />
    </mesh>
  );
};

// ===================================================================
// Appコンポーネント:
// - UIの全体管理(画像読み込み、ピクセルサイズ設定、アニメーション操作)
// - 2つのCanvas(ピクセルシーンと背景シーン)を配置
// ===================================================================
const App = () => {
  // 状態管理:
  // - pixels: 画像から生成されたピクセル情報の配列
  // - tempPixelSize: 入力フォーム上の一時的なピクセルサイズ
  // - pixelSize: 実際に適用されるピクセルサイズ
  // - fileChangeCount: 画像の変更回数(再読み込みのトリガーに使用)
  // - imageSrc: 読み込んだ画像のData URL
  // - animation: 現在のアニメーションモード
  const [pixels, setPixels] = useState<PixelInfo[] | null>(null);
  const [tempPixelSize, setTempPixelSize] =
    useState<number>(DEFAULT_PIXEL_SIZE);
  const [pixelSize, setPixelSize] = useState<number>(tempPixelSize);
  const [fileChangeCount, setFileChangeCount] = useState<number>(0);
  const [imageSrc, setImageSrc] = useState<string | null>(null);
  const [animation, setAnimation] = useState("default");

  // ----------------------------------------------------
  // 画像ファイルが選択されたときの処理:
  // - ファイルを読み込み、Data URLに変換して imageSrc にセット
  // ----------------------------------------------------
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      const url = ev.target?.result;
      if (typeof url !== "string") return;
      // ピクセルサイズを一旦更新してから、画像ソースを設定
      setPixelSize(tempPixelSize);
      setImageSrc(url);
    };
    reader.readAsDataURL(file);
  };

  // ----------------------------------------------------
  // 「再表示」ボタン押下時の処理:
  // - tempPixelSize の値を pixelSize に反映して画像を再描画
  // ----------------------------------------------------
  const reloadImage = () => {
    if (!imageSrc) return;
    setPixelSize(tempPixelSize);
  };

  // ----------------------------------------------------
  // 画像または pixelSize が変更されたときに、画像からピクセルデータを生成する
  // ----------------------------------------------------
  useEffect(() => {
    if (!imageSrc) return;
    // 画像が変わった際のトリガーとして fileChangeCount を更新
    setFileChangeCount((prev) => prev + 1);
    // 新たな画像を読み込み、onload イベントでピクセルデータを生成
    const img = new Image();
    img.onload = () => {
      const pix = createPixelData(img, pixelSize, pixelSize);
      setPixels(pix);
    };
    img.src = imageSrc;
  }, [pixelSize, imageSrc]);

  // ----------------------------------------------------
  // Explosionアニメーションの制御:
  // - 爆発の開始、終了、そして元に戻す処理をタイムアウトで順次実行
  // ----------------------------------------------------
  const controlExplosionAnimation = () => {
    setAnimation("explosion_start");
    setTimeout(() => {
      console.log("explosion_end");
      setAnimation("explosion_end");
    }, ANIMATION_TIME * 1000);
    setTimeout(() => {
      console.log("default");
      setAnimation("default");
    }, ANIMATION_TIME * 2000);
  };

  // ----------------------------------------------------
  // Waveアニメーションの制御:
  // - 波の開始、終了、そして元に戻す処理をタイムアウトで順次実行
  // ----------------------------------------------------
  const controlWaveAnimation = () => {
    setAnimation("wave_start");
    setTimeout(() => {
      setAnimation("wave_end");
    }, ANIMATION_TIME * 1000);
    setTimeout(() => {
      setAnimation("default");
    }, ANIMATION_TIME * 2000);
  };

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      {/* ----------------- コントロールパネル ----------------- */}
      <div className="absolute top-4 left-4 bg-white shadow-lg p-4 rounded-lg z-10 w-64 space-y-4">
        {/* 画像ファイルの選択 */}
        <input
          type="file"
          accept="image/*"
          onChange={handleFileChange}
          className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
        />

        {/* ピクセルサイズの入力フィールド */}
        <div className="flex items-center space-x-2">
          <label className="text-sm text-gray-700">Pixel Size:</label>
          <input
            type="number"
            value={tempPixelSize}
            onChange={(e) => setTempPixelSize(Number(e.target.value))}
            min="1"
            max="128"
            className="w-16 border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
          />
        </div>

        {/* 再表示ボタン */}
        <button
          className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 transition"
          onClick={reloadImage}
        >
          再表示
        </button>

        {/* アニメーション制御ボタン */}
        <div className="flex flex-wrap gap-2">
          <button
            className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition"
            onClick={controlExplosionAnimation}
          >
            Explosion
          </button>
          <button
            className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition"
            onClick={controlWaveAnimation}
          >
            Wave
          </button>
        </div>
      </div>

      {/* ----------------- メインCanvas:ピクセルシーン ----------------- */}
      <div className="absolute top-0 left-0 w-full h-full z-[1]">
        <Canvas camera={{ far: 5000, position: [0, 0, 100] }}>
          {/* 基本の照明設定 */}
          <ambientLight intensity={1} />
          <directionalLight position={[100, 200, 100]} intensity={1} />
          {/* マウス操作でシーンの回転が可能なOrbitControls */}
          <OrbitControls />
          {/* ピクセルデータが存在する場合のみPixelGridをレンダリング */}
          {pixels && (
            <PixelGrid
              pixelSize={pixelSize}
              pixels={pixels}
              animation={animation}
              fileChangeCount={fileChangeCount}
            />
          )}
        </Canvas>
      </div>

      {/* ----------------- 背景Canvas:星と雲のシーン ----------------- */}
      <div className="absolute top-0 left-0 w-full h-full z-[-1]">
        <Canvas
          camera={{ position: [0, 5, 15], fov: 50 }}
          style={{ background: "black" }}
        >
          <ambientLight intensity={0.5} />
          <pointLight position={[10, 10, 10]} intensity={0.5} color="white" />
          <BackgroundScene />
        </Canvas>
      </div>
    </div>
  );
};

// ===================================================================
// createPixelData関数:
// - 指定された画像からCanvasを用いてピクセルデータを取得し、
// - PixelInfo型の配列に変換する
// ===================================================================
const createPixelData = (
  img: HTMLImageElement,
  targetWidth: number,
  targetHeight: number
): PixelInfo[] => {
  // Canvas要素を作成し、指定のサイズに設定
  const canvas = document.createElement("canvas");
  canvas.width = targetWidth;
  canvas.height = targetHeight;
  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("No 2D context available");

  // 画像をCanvas上に描画し、ピクセル情報を取得
  ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
  const imgData = ctx.getImageData(0, 0, targetWidth, targetHeight);
  const data = imgData.data;

  const result: PixelInfo[] = [];
  let idx = 0;
  // 画像の各ピクセルに対して、RGBAの値を取得しPixelInfoに変換
  for (let y = 0; y < targetHeight; y++) {
    for (let x = 0; x < targetWidth; x++) {
      const r = data[idx],
        g = data[idx + 1],
        b = data[idx + 2],
        a = data[idx + 3];
      idx += 4;
      // 透明度が低いピクセルは無視する(a < 30の場合)
      if (a < 30) continue;
      result.push({
        // 画像の中心を原点とするため、x,y座標を調整
        x: x - targetWidth / 2,
        y: -y + targetHeight / 2,
        z: 0,
        // RGB値を 0~1 の範囲に変換してTHREE.Colorを作成
        color: new THREE.Color(r / 255, g / 255, b / 255),
      });
    }
  }
  return result;
};

// ===================================================================
// BackgroundSceneコンポーネント:
// - 背景に星空と雲を表示するためのコンポーネント
// - memoでラップすることで不要な再描画を防止
// ===================================================================
const BackgroundScene = memo(() => {
  return (
    <>
      {/* Starsコンポーネントで星空を表現 */}
      <Stars
        radius={100} // 星が存在する空間の半径
        depth={50} // 星の配置される深さの範囲
        count={5000} // 表示する星の総数
        factor={6} // 星の大きさ調整用の係数
        saturation={1} // 色の鮮やかさ
        fade // 遠くの星がフェードアウトする効果
      />
      {/* Cloudコンポーネントで雲を表現 */}
      <Cloud
        position={[0, 0, 0]} // 雲の中心位置
        opacity={0.1} // 雲の不透明度(低いほど透明)
        speed={0.2} // 雲の動く速度
        scale={[10, 10, 10]} // 雲全体のサイズ
        segments={20} // 雲を構成するパーティクルの数
      />
    </>
  );
});

export default App;

最後に

今回は、「React Three Fiberを使って、画像からピクセルアート&動的アニメーションを作成」ということで、React Three FiberとReact Three Dreiを使って、ピクセルアートとピクセルアートにアニメーションを付けてみました!!

アニメーションに関しては、自由に実装を増やせばもっとかっこいいものが作れると思いますので、ぜひ試してみてください!

📌実際の動きに関しては、下記のYouTubeを見てもらえると詳細に理解できると思います!

📺 YouTubeでデモを見る:こちらのリンクから視聴できます。

📌今回作成したコードは、GitHubに載せていますので、こちらも確認してみてください!

💾 GitHubリポジトリ:こちらのリンクでソースコードをチェック!

📌Meshyを利用して、3Dオブジェクトに置き換えれば、さらに理想に近づけることも可能!!

Meshyは、AIで簡単に3Dオブジェクトを生成できるサービスです。
これを利用することで、簡単に理想の3Dオブジェクトを生成することが可能なので、さらに理想に近づけることができると思います!

📺 Meshyを確認する:こちらのリンクから公式ページで確認できます。

参考になった方は、是非チャンネル登録をお願いします!

TypeScript x React Three Fiberのレッスンや作品は、今後もどんどん作成していきます!
YouTubeで告知致しますので、ぜひYouTubeのチャンネル登録をして通知をお待ちください!

📺 YouTubeを見る:こちらのリンクから視聴できます。

React Three Fiberで何ができるのか知りたい方は、下記を参考にしてみてください!
簡単にできる作品を用意しております!

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

情報セキュリティを勉強するために始めたブログです。
新人のため、広い心を持って見ていただけると嬉しく思います。
楽しくプログラミングを勉強するために、「Teech Lab.」もありますので、ソフトウェア開発にも興味があればぜひ覗いて見てください!

目次