[Astro #100] VRMサムネイル自動生成 & メニュー表示
はじめに
外部VRMファイルを読み込んだ際に、自動でサムネイル画像を生成してIndexedDBに保存し、PC/VRの両メニューでサムネ付きリスト表示する機能を実装した。
コア処理: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等)に変更すればリスト表示時の見栄えが向上する可能性あり