[Astro #49] IndexedDBを活用した3Dアセットの完全キャッシュ化とバージョニング管理

[Astro #49] IndexedDBを活用した3Dアセットの完全キャッシュ化とバージョニング管理

はじめに

WebXR環境(React Three Fiber + Astro)において、VRMモデルやGLBステージ、巨大なWebPスカイボックス、そしてBGMなどの読み込み時間は、ユーザー体験を損なう最大のボトルネックでした。

今回は、ブラウザのローカルストレージである「IndexedDB」を活用し、これらの重いアセットを完全キャッシュ化。

さらに、更新漏れ(古いキャッシュが残り続ける問題)を防ぐための「JSONベースのバージョニング戦略」と、長時間のVR滞在を想定した「メモリリーク対策」を実装し、リロードしても即座に世界が復元される爆速の「Wired」環境を構築しました。

[Astro #49] IndexedDBを活用した3Dアセットの完全キャッシュ化とバージョニング管理

1. キャッシュ&バージョニングの基本戦略

アセット(バイナリデータ)を IndexedDB に保存する際、最も厄介なのが「ファイルの中身を更新したのに、ブラウザが古いキャッシュを読み込み続けてしまう」という問題です。

これを解決するため、以下のような役割分担を行いました。

  • JSONファイル(サーバー側): アセットのパスと「バージョン情報(version)」を記載した司令塔。src/data/ フォルダに集約し、ビルド時に静的インポート(import)することで通信オーバーヘッドをゼロ化。
  • IndexedDB(ブラウザ側): Dexie.js を使用し、重いバイナリデータ(Blob)と、その時点のバージョン情報をセットで保管する。

【判定ロジック】

  1. インポートしたJSONの version と、IndexedDB内の version を比較する。
  2. 一致すれば(CACHE_HIT:ネットワーク通信を行わず、DBから爆速で blob:http... という仮想URLを生成して読み込む。
  3. 不一致なら(DOWNLOAD:新しく fetch してバイナリを取得し、DBを新しいデータとバージョンで上書き保存する。

これにより、JSONのバージョン文字列を書き換えるだけで、世界中のユーザーのキャッシュを自動的にパージ・更新できる堅牢なマスターキーが完成しました。

2. 実装のコア:db.tsassetCache.ts

IndexedDBの泥臭い操作を隠蔽し、現代的な async/await で扱うために Dexie.js を導入しました。

データベースの定義 (src/lib/db.ts) モデル、アニメーション、ステージ、スカイボックス、そしてオーディオごとにテーブルを分割しました。 注意点:Vite環境でビルドエラーを防ぐため、Dexieの Table は必ず import type で読み込むこと。

キャッシュ取得ユーティリティ (src/utils/assetCache.ts) 任意のテーブルとJSONエントリーを渡し、URLを解決する共通関数 getCachedAssetUrl を作成。たった一つの汎用的な関数で、3DモデルからMP3まで、あらゆるアセットのキャッシュ解決を一手に引き受けます。

3. 各コンポーネントへの適用と「2段構え」設計

準備したユーティリティを、React Three Fiber の各コンポーネントに組み込みましたが、ここでReact特有の「ライフサイクルの壁」に直面しました。

アバター(VRM)のキャッシュ化における罠と解決

当初、useLoaderuseGLTF にそのままBlob URLを渡そうとしましたが、Reactの仕様上「フックの実行を await で待つことはできない」ため、通信が二重に走ったり、キャッシュが無視されたりする問題が発生しました。

これを解決するため、コンポーネントを 「ガード(親)」「コア(子)」 に分割する設計パターンを採用しました。

// 1. ガード用コンポーネント(親)
export const WiredAvatar = ({ modelPath, ...props }) => {
  // ① IndexedDBからBlob URLを引っ張り出す(非同期)
  // ② 揃うまでは null を返してレンダリングをストップ!
  // ③ 揃ったら、key={modelPath} を付けて子コンポーネントをマウント
}

// 2. コアコンポーネント(子)
const WiredAvatarCore = ({ resolvedModelUrl, ...props }) => {
  // ① 親から渡された、絶対に確実な Blob URL を使って Three.js のローダーを回す
}

親が「完璧な準備(Blob生成)」を整えてから子を起動することで、無駄なネットワークリクエストが物理的に発生しない、真の完全キャッシュ化を実現しました。

オーディオシステム(BGM)のメモリリーク対策

オーディオ(WiredAudio.tsx)でも同様のキャッシュ機構を実装。さらに、VR空間での長時間滞在を見据え、「一つ前の曲の Blob URL を記憶しておき、次の曲が流れる瞬間に URL.revokeObjectURL() でメモリから解放する」 という useRef を使った堅牢なクリーンアップ処理を導入。何百回曲を切り替えてもブラウザが重くならない「行儀の良い」システムに仕上げました。

4. 開発中に遭遇した「見えない敵」

実装の最終盤、ログ上はキャッシュヒットしているのに、なぜか常に最初のモデル(lain01.vrm)が表示され、ネットワーク通信が発生し続けるという厄介なバグに遭遇しました。

原因は「安全装置の暴発」でした。 以前の開発中、壊れたパスによるクラッシュを防ぐために useWiredVRM.ts 内に仕込んでいた以下のハードコードが原因でした。

// 過去のコード(NG)
if (!modelPath || modelPath.startsWith('blob:')) {
  return DEFAULT_VRM_PATH; // Blob URLが来たら問答無用でlain01にすり替える
}

「せっかくIndexedDBから安全なBlob URLを生成したのに、最後の最後でエラー扱いされて弾かれていた」というオチです。この不要なフォールバックを削除した瞬間、すべての通信が静まり返り、指定したアバターが瞬時に現れました。

まとめ

去年の夏には数日かかっていたIndexedDBの実装が、今回は「汎用的なキャッシュ取得関数の設計」と「コンポーネントの2段構えによる確実な非同期処理」によって、圧倒的な短時間で完了しました。

「とりあえず動く」状態から脱却し、DB設計という強固な基盤を敷いたことで、リロードのたびに待たされていた状態から、瞬時に空間とアバターが立ち上がる「真のWired」体験へ到達。

探索的プロトタイピングのフェーズを経て、ついに本番デプロイに耐えうる最強の舞台装置が完成しました。