[Astro #49] IndexedDBを活用した3Dアセットの完全キャッシュ化とバージョニング管理
はじめに
WebXR環境(React Three Fiber + Astro)において、VRMモデルやGLBステージ、巨大なWebPスカイボックス、そしてBGMなどの読み込み時間は、ユーザー体験を損なう最大のボトルネックでした。
今回は、ブラウザのローカルストレージである「IndexedDB」を活用し、これらの重いアセットを完全キャッシュ化。
さらに、更新漏れ(古いキャッシュが残り続ける問題)を防ぐための「JSONベースのバージョニング戦略」と、長時間のVR滞在を想定した「メモリリーク対策」を実装し、リロードしても即座に世界が復元される爆速の「Wired」環境を構築しました。
1. キャッシュ&バージョニングの基本戦略
アセット(バイナリデータ)を IndexedDB に保存する際、最も厄介なのが「ファイルの中身を更新したのに、ブラウザが古いキャッシュを読み込み続けてしまう」という問題です。
これを解決するため、以下のような役割分担を行いました。
- JSONファイル(サーバー側): アセットのパスと「バージョン情報(
version)」を記載した司令塔。src/data/フォルダに集約し、ビルド時に静的インポート(import)することで通信オーバーヘッドをゼロ化。 - IndexedDB(ブラウザ側):
Dexie.jsを使用し、重いバイナリデータ(Blob)と、その時点のバージョン情報をセットで保管する。
【判定ロジック】
- インポートしたJSONの
versionと、IndexedDB内のversionを比較する。 - 一致すれば(
CACHE_HIT):ネットワーク通信を行わず、DBから爆速でblob:http...という仮想URLを生成して読み込む。 - 不一致なら(
DOWNLOAD):新しくfetchしてバイナリを取得し、DBを新しいデータとバージョンで上書き保存する。
これにより、JSONのバージョン文字列を書き換えるだけで、世界中のユーザーのキャッシュを自動的にパージ・更新できる堅牢なマスターキーが完成しました。
2. 実装のコア:db.ts と assetCache.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)のキャッシュ化における罠と解決
当初、useLoader や useGLTF にそのまま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」体験へ到達。
探索的プロトタイピングのフェーズを経て、ついに本番デプロイに耐えうる最強の舞台装置が完成しました。