[Astro #100] VRMサムネイル自動生成 & メニュー表示

[Astro #100] VRMサムネイル自動生成 & メニュー表示

はじめに

外部VRMファイルを読み込んだ際に、自動でサムネイル画像を生成してIndexedDBに保存し、PC/VRの両メニューでサムネ付きリスト表示する機能を実装した。

[Astro #100] VRMサムネイル自動生成 & メニュー表示 [Astro #100] VRMサムネイル自動生成 & メニュー表示

コア処理:VRMからサムネイルを自動生成する

テスト実装(VrmTest.tsx)

まずスタンドアロンのテストコンポーネントで、VRMファイルを読み込んで撮影する仕組みを確立した。

核心は「オフスクリーンでWebGLRendererを生成し、VRMを一時的なSceneに配置してカメラで撮影する」という手法。

const generateScreenshot = (vrm: VRM): Promise<string> => {
  return new Promise((resolve) => {
    const size = 150;

    // ① preserveDrawingBuffer: true が必須
    const renderer = new THREE.WebGLRenderer({
      antialias: true, alpha: true,
      preserveDrawingBuffer: true,
    });
    renderer.setSize(size, size);

    // ② 撮影専用Sceneを構築
    const shotScene = new THREE.Scene();
    const ambientLight = new THREE.AmbientLight(0xffffff, 2.5);
    const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
    dirLight.position.set(0.5, 1.0, 2.0);
    shotScene.add(ambientLight, dirLight);
    shotScene.add(vrm.scene);

    // ③ BoundingBoxからカメラ位置を自動計算
    const box = new THREE.Box3().setFromObject(vrm.scene);
    const center = new THREE.Vector3();
    box.getCenter(center);
    const boxSize = new THREE.Vector3();
    box.getSize(boxSize);

    let cameraDistance = boxSize.y * 0.95;
    let faceY = box.max.y - (boxSize.y * 0.08);

    const shotCamera = new THREE.PerspectiveCamera(30, 1, 0.01, 100);
    // Z軸マイナス方向 = VRMの正面
    shotCamera.position.set(center.x, faceY + (boxSize.y * 0.03 || 0.04), center.z - cameraDistance);
    shotCamera.lookAt(new THREE.Vector3(center.x, faceY - 0.22, center.z));

    // ④ 10フレーム空回し → MToonシェーダーのGPUコンパイル完了を待つ
    let frameCount = 0;
    const clock = new THREE.Clock();
    const animate = () => {
      frameCount++;
      vrm.update(clock.getDelta());
      vrm.scene.updateMatrixWorld(true);
      renderer.render(shotScene, shotCamera);

      if (frameCount < 10) {
        requestAnimationFrame(animate);
      } else {
        const base64 = renderer.domElement.toDataURL('image/webp', 0.85);
        shotScene.remove(vrm.scene);
        // リソース解放...
        renderer.dispose();
        resolve(base64);
      }
    };
    animate();
  });
};

ハマりポイント

カメラのZ座標の符号問題: VRMモデルは-Z方向を正面として向いている。テスト版(VrmTest.tsx)では center.z - cameraDistance で正しく正面から撮影できていたが、本番組み込み版(WiredAvatar.tsx)では +cameraDistance にしてしまい後ろ姿が撮影されるバグが発生した。

GPUコンパイル待ち: preserveDrawingBuffer: true と10フレームの空回しが両方必要。1フレームだけだとMToonシェーダーのコンパイルが間に合わず真っ黒になる。

本番組み込み:現役アバターから安全に撮影

テスト版は専用にロードしたVRMを撮影するが、本番では画面に表示中の「現役アバター」から撮影する必要がある。現役アバターのSceneツリーを壊さないために、以下の手順を取る:

const generateVrmThumbnail = (vrm: any): Promise<string> => {
  return new Promise((resolve) => {
    // 1. 現在の配置を退避
    const origPos = vrm.scene.position.clone();
    const origRot = vrm.scene.rotation.clone();
    const origScale = vrm.scene.scale.clone();

    // 撮影用に原点リセット
    vrm.scene.position.set(0, 0, 0);
    vrm.scene.rotation.set(0, 0, 0);
    vrm.scene.scale.set(1, 1, 1);

    // 2. ライトをアバターの子要素として追加(親ツリーを壊さない)
    const ambientLight = new THREE.AmbientLight(0xffffff, 2.5);
    const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
    vrm.scene.add(ambientLight, dirLight);

    // 3. 同期レンダリング(requestAnimationFrameを使わない)
    renderer.render(vrm.scene, shotCamera);
    const base64 = renderer.domElement.toDataURL('image/webp', 0.85);

    // 4. 完全復元(超重要)
    vrm.scene.remove(ambientLight, dirLight);
    vrm.scene.position.copy(origPos);
    vrm.scene.rotation.copy(origRot);
    vrm.scene.scale.copy(origScale);
    vrm.scene.updateMatrixWorld(true);

    // ※ 現役アバターなのでdispose(破壊処理)は絶対にやらない
    renderer.dispose();
    resolve(base64);
  });
};

テスト版との重要な違い:10フレーム空回しではなく同期1フレーム描画にしている。現役アバターは既にGPUコンパイル済みなので空回し不要。

データフロー

外部VRMファイル読み込み(handleFileLoad)
  → blob URL生成 → onModelChange → WiredAvatar マウント
  → assetCache.ts: IndexedDBにバイナリ保存(キー: LOCAL_VRM_ファイル名_サイズ)
  → WiredAvatarCore: VRM描画完了後に generateVrmThumbnail(vrm)
  → db.models.update(cacheKey, { thumb: base64 })  // サムネをDBに追記
  → 次回メニューオープン時: db.models.toArray() → record.thumb を取得
  → PC: WiredAvatarMenu.tsx / VR: WiredVRMenu.tsx でサムネ表示

IndexedDB側の対応

db.tsの AssetData インターフェースに thumb フィールドを追加済み:

export interface AssetData {
  id: string;
  data: Blob;
  version: string;
  thumb?: string; // 自動生成されたサムネイル(Base64)
}

メニュー表示

PC版(WiredAvatarMenu.tsx)

ラジオボタンのテキストリストから、サムネ左 + テキスト右の横並びリストに変更。ラベルは _ で分割してモデル名と作者名を分離、.vrm 拡張子は除去。

VR版(WiredVRMenu.tsx)

@react-three/uikit<Image> コンポーネントでサムネ表示。構造はPC版と同じ横並びレイアウト。

VRでの日本語フォント表示は @react-three/uikit のMSDF制約により豆腐化するが、サムネイルがあるため視覚的な識別に支障なし。

未実装・今後の検討

  • ビルトインモデル(models.json)のサムネイル: 現状はNO_IMG表示。models.jsonに静的thumbパスを追加するか、初回ロード時に自動生成するか要検討
  • サムネイルの縦横比: 現在150×150正方形。縦長(100×140等)に変更すればリスト表示時の見栄えが向上する可能性あり