[Astro #40] 3Dモデルロード時のプログレスダイアログ実装とレンダリングの落とし穴

[Astro #40] 3Dモデルロード時のプログレスダイアログ実装とレンダリングの落とし穴

1. 概要

React Three Fiber (R3F) などの環境で、VRMのような容量の大きい3Dモデルを読み込む際、ユーザーの離脱を防ぐためにローディング進捗(プログレスダイアログ)を表示することが推奨される。

本記事では、プログレスダイアログの実装において発生しやすいReactのレンダリングサイクルに起因するエラーとその回避策、およびThree.jsのネイティブ機能を用いた実装手法についてまとめる。

前回の記事:

動画:

2. 発生する問題:useProgress によるレンダリングの衝突

R3F環境においてローディングUIを実装する際、一般的には @react-three/drei が提供する useProgress フックが用いられる。しかし、複雑なコンポーネントツリーや Suspense の境界において、以下のエラーが発生することがある。

Cannot update a component (LoadingOverlay) while rendering a different component (WiredAvatar).

エラーの原因

このエラーは、Reactが「あるコンポーネント(WiredAvatar)を描画・解決している最中に、別のコンポーネント(LoadingOverlay)のStateが更新された」ことを検知した際にスローされる。

  1. useLoader が非同期でモデルのダウンロードを開始。
  2. Reactは Suspense にフォールバックし、LoadingOverlay を表示。
  3. Three.jsのローダーが高頻度で進捗イベントを発火し、useProgress 内部で setState が呼ばれる。
  4. モデルのロード完了とコンポーネントの再描画タイミングが重なり、Reactの厳格なレンダリングフェーズ中にStateの更新が衝突する。

3. 解決策:イベントループの活用によるState更新の遅延

ReactのレンダリングサイクルとThree.jsの非同期ロード処理の衝突を避けるためには、ローディング進捗の管理をReactツリー(drei のフック)から切り離し、Three.jsのグローバルな LoadingManager を直接監視するアプローチが有効である。

実装の要点

  1. THREE.DefaultLoadingManager のフック: R3Fの背後で動いているグローバルなマネージャーの onProgress を上書きし、進捗を取得する。
  2. 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>
  );
};