[Astro #40] 3Dモデルロード時のプログレスダイアログ実装とレンダリングの落とし穴
1. 概要
React Three Fiber (R3F) などの環境で、VRMのような容量の大きい3Dモデルを読み込む際、ユーザーの離脱を防ぐためにローディング進捗(プログレスダイアログ)を表示することが推奨される。
本記事では、プログレスダイアログの実装において発生しやすいReactのレンダリングサイクルに起因するエラーとその回避策、およびThree.jsのネイティブ機能を用いた実装手法についてまとめる。
前回の記事:
[Astro #39] VRMアバターの待機アニメーションをJSONで動的&ランダムに制御 // PROTOCOL.LAIN
React Three Fiber環境でのVRMアバター開発において、待機モーション(Idle状態)を重み付け抽選で動的に切り替える仕組みを実装。JSONファイルを用いた設定管理や、スムーズなステート移行など、アバターに自然な「生命感」を演出する実装方法を解説します。
lain-lab.com動画:
2. 発生する問題:useProgress によるレンダリングの衝突
R3F環境においてローディングUIを実装する際、一般的には @react-three/drei が提供する useProgress フックが用いられる。しかし、複雑なコンポーネントツリーや Suspense の境界において、以下のエラーが発生することがある。
Cannot update a component (LoadingOverlay) while rendering a different component (WiredAvatar).
エラーの原因
このエラーは、Reactが「あるコンポーネント(WiredAvatar)を描画・解決している最中に、別のコンポーネント(LoadingOverlay)のStateが更新された」ことを検知した際にスローされる。
- useLoader が非同期でモデルのダウンロードを開始。
- Reactは Suspense にフォールバックし、LoadingOverlay を表示。
- Three.jsのローダーが高頻度で進捗イベントを発火し、
useProgress内部でsetStateが呼ばれる。 - モデルのロード完了とコンポーネントの再描画タイミングが重なり、Reactの厳格なレンダリングフェーズ中にStateの更新が衝突する。
3. 解決策:イベントループの活用によるState更新の遅延
ReactのレンダリングサイクルとThree.jsの非同期ロード処理の衝突を避けるためには、ローディング進捗の管理をReactツリー(drei のフック)から切り離し、Three.jsのグローバルな LoadingManager を直接監視するアプローチが有効である。
実装の要点
- THREE.DefaultLoadingManager のフック: R3Fの背後で動いているグローバルなマネージャーの
onProgressを上書きし、進捗を取得する。 - setTimeout によるタスクのキューイング: 取得した進捗で
setStateを呼ぶ際、setTimeout(..., 0)を使用する。これにより、State更新処理が現在のコールスタック(Reactのレンダリング処理)から切り離され、JavaScriptのイベントループの次のサイクルで実行されるため、レンダリング中の衝突を完全に回避できる。
4. 実装コード
以下は、上記の問題を解決した独立型のプログレスダイアログコンポーネントの実装例である。
import React, { useState, useEffect } from 'react';
import { Html } from '@react-three/drei';
import * as THREE from 'three';
export const LoadingOverlay = () => {
const [progress, setProgress] = useState(0);
useEffect(() => {
const manager = THREE.DefaultLoadingManager;
const originalOnProgress = manager.onProgress;
manager.onProgress = (url, itemsLoaded, itemsTotal) => {
if (originalOnProgress) {
originalOnProgress(url, itemsLoaded, itemsTotal);
}
// レンダリングフェーズとの衝突を避けるため、更新を次のイベントループへ回す
setTimeout(() => {
setProgress((itemsLoaded / itemsTotal) * 100);
}, 0);
};
return () => {
manager.onProgress = originalOnProgress;
};
}, []);
return (
<Html center>
<div style={{
background: 'rgba(0, 20, 0, 0.75)',
backdropFilter: 'blur(5px)',
color: '#00ffcc',
border: '1px solid rgba(0, 255, 204, 0.4)',
padding: '20px 30px',
fontFamily: 'monospace',
pointerEvents: 'none',
}}>
<div>SYSTEM_LOADING...</div>
<div style={{ width: '100%', height: '2px', background: 'rgba(0, 255, 204, 0.2)', margin: '10px 0' }}>
<div style={{ width: `${progress}%`, height: '100%', background: '#00ffcc' }} />
</div>
<div style={{ fontSize: '12px' }}>[ DATA_FETCHING: {Math.round(progress)}% ]</div>
</div>
</Html>
);
};