Three.js を使って 3D モデルをレンダリングする機能を開発していたところ、「モバイルでいくつかモデルを見ていると突然ページがリロードされる」というバグ報告が上がってきた。
最初は単なるネットワークの問題やブラウザ互換性の問題だと思ったが、再現してみると確かなパターンがあった。iPhone の Safari で 3〜4 個ほど異なる 3D モデルを連続して読み込むと、ブラウザが強制的にページをリフレッシュする現象が繰り返し発生したのだ。
モバイルブラウザでこの種の強制リロードが起こる最も一般的な原因はメモリ不足である。iOS Safari はメモリがある閾値を超えると容赦なくページを落とす。そこでメモリリークの可能性に重点を置いて調査することにした。
問題
Chrome DevTools の Memory タブでヒープスナップショットを複数回取得し、メモリ使用量の変化を追跡した。予想どおり、3D モデルを読み込むたびに JSArrayBufferData が該当 GLB ファイルのサイズ分だけ蓄積され続けていた。10MB のモデルを 5 回読み込むと、約 50MB がそのままメモリに残る状況だった。
useEffect のクリーンアップ関数で geometry.dispose()
, material.dispose()
, texture.dispose()
をすべて呼び出し、明示的に null
を代入するなど、念入りに処理していたにもかかわらず、メモリ使用量は減らなかった。
原因
原因は、性能最適化のためにプロジェクト初期にグローバル設定していた次の 1 行だった。
THREE.Cache.enabled = true;
この設定は、GLTFLoader が同一 URL のファイルを再度要求する際にネットワークコストを削減するため、メモリにキャッシュしておく機能である。一般的な状況では有効な最適化だが、本プロジェクトの特殊な事情が問題となった。
セキュリティ上の理由から、3D モデルファイルはプリサインド URL を使用しており、一度ダウンロードされるとその URL は即座に失効し、新しい URL に置き換えられる仕組みだった。つまり、同じモデルであってもアクセスのたびに完全に異なる URL を持つため、Three.js のキャッシュからは「別ファイル」と認識され、再利用されずに蓄積され続けてしまった。
解決
原因が分かれば解決策は明確だった。useEffect のクリーンアップ関数で既存の dispose ロジックに加え、キャッシュも明示的に削除するようにした。
useEffect(() => {
return () => {
// ...
THREE.Cache.remove(fbxUrl);
THREE.Cache.remove(glbUrl);
};
}, []);
修正後に再度メモリをプロファイリングすると、ページ遷移のたびに JSArrayBufferData がきれいに整理されることが確認できた。モバイルでも 10 個を超えるモデルを連続して読み込んでも、強制リロードなしで安定して動作した。
THREE.Cache.remove と dispose
THREE.Cache.remove は「ファイルキャッシュレベル」のメモリを扱う。一方で dispose は Three.js オブジェクトのリソースを「GPU メモリレベル」で解放する。つまり、管理するレイヤーが異なる。
Cache.remove
: Three.js のファイルキャッシュレベルで管理(ネットワークから読み込んだ元ファイルデータ)dispose
: GPU メモリレベルで管理(geometry, material, texture などの WebGL リソース)
完全なメモリ解放のためには両方が必要である。
まとめ
性能最適化のために有効化したキャッシュ機能が、特殊な状況では逆にメモリリークの原因になり得ることが分かった。また、初期設定時に THREE.Cache.enabled
のようなキャッシュ機能を本当に有効にする必要があるのか、プロジェクトの特性上どのような副作用があり得るのかを事前に検討する習慣の大切さも学んだ。
参考