[Next.js #04] R3F × WebXR の構造:Canvas ループと XR の衝突を理解する

はじめに

R3FのレンダリングループとWebXRはなぜ噛み合わないのか?

この記事は R3Fの内部構造 を“XRの視点”から解説することで、 普通は絶対に気づけない落とし穴を全部暴く。

前回の記事:

1. なぜ R3F × WebXR は“理論上”ぶつかるのか?

React Three Fiber(R3F)は、React の state と Three.js の世界を同期させるフレームワークだ。 そのために、R3F は独自の「レンダリングループ(render loop)」を持ち、Canvas 内の描画進行を完全に管理しようとする。

  • React の state 更新
  • R3F の内部 clock
  • useFrame の実行順
  • gl.render() の発火タイミング

これらすべてを R3F が主導するという思想で設計されている。


しかし、WebXR はまったく逆の構造を持つ。

WebXR ではブラウザが、 XRSession 専用の animation loop(requestAnimationFrame) を所有している。

  • XR デバイス(Quest など)が持つ固有の FPS(72Hz / 90Hz / 120Hz)
  • HMD の姿勢トラッキング
  • コントローラーの入力
  • IPD / projection 計算
  • ルームスケール座標の更新

これらすべてを ブラウザ側が絶対的な主導権を持つ。


結論:どちらも「主役になろうとする」

→ R3F:WebGL のフレーム進行を握りたい

→ WebXR:XRSession のフレーム進行を握りたい

この瞬間に 2つの “render loop の主導権争い” が発生する。


Three.js の内部動作がさらに問題を悪化させる

Three.js は XR に入った瞬間、

gl.setAnimationLoop( XR  animationLoop )

を内部で自動的に呼ぶため、 レンダリングループが XR に乗っ取られる。

つまり:

◎ XR → gl.render() の支配権を獲得

◎ R3F → 主導権を失い、useFrame だけ別ループで動く


###「useFrame がズレた別ループで回る」のが最大の致命傷

R3F の useFrame() は本来、 R3F 自身が管理する render loop の一部だ。

しかし XR では:

  • R3F の loop は 非XR的な 60Hz 近辺
  • XR の loop は デバイス依存の 72〜120Hz

つまり…

→ useFrame の delta が XR と一致しない

→ カメラや VRM の更新が“1フレーム遅れる”

→ 酔いやすくなる

→ パフォーマンスが不安定になる

→ コントローラーの動きに遅延が出る

特に VRM・MMD など “アニメーションがシビアなモデル” は顕著だ。


まとめ

R3F と WebXR は設計レベルでこうなっている:

  • どちらも自分の render loop を主導したい
  • Three.js が XR 側の animationLoop に乗っ取られる
  • useFrame が XR のフレームレートと同期しない
  • 結果:XR 内でズレ・遅延・ブラックアウトが発生する

これは“バグ”ではなく、構造的に必然の衝突だ。

2. R3F Canvas の独自 render loop が XR と干渉する

R3F(React Three Fiber)は、Canvas 内部で React の state 管理と Three.js の描画を統合するための独自ループを持っている。

その内部構造を極端に簡略化すると、以下のようになる。

Canvas
 └─ renderLoop()
      ├─ advance(state)            // React / R3F の世界を進める
      ├─ state.clock.update()      // 時間管理(R3F専用)
      ├─ run useFrame callbacks    // R3F のフック
      └─ gl.render(scene, camera)  // 最終描画

このループは React の再レンダ → R3F の state 更新 → useFrame の実行 という流れをひとまとめにしており、 R3F の“主導権”で進むように設計されている。


一方、WebXR は完全に別の世界で動いている

WebXR は一般的な requestAnimationFrame() ではなく、 XR デバイス固有のレートで動く専用の animation loop を持つ。

navigator.xr.requestAnimationFrame( timestamp )
 └─ renderer.setAnimationLoop( XRLoop )
        ├─ updateXRPose()         // HMD の姿勢
        ├─ updateControllers()     // コントローラー入力
        └─ renderer.render()       // 最終描画(XR視点)

これは Three.js が XR モードに入ると 自動的に setAnimationLoop() を XR に渡すため、 Three.js のレンダリング権限は XR に完全に移る。


“R3F の renderLoop() と XR の renderLoop()” は 別宇宙

R3F と WebXR のループは:

タイミングも

フレームレートも

timestamp も

更新順も

####責任を持つ処理も

すべて違う。

つまり、

両者は同期しない。できる構造になっていない。


根本的な問題:R3F の clock は XR の timestamp を知らない

R3F は内部で state.clock を持っている。

これは:

  • React の状態更新
  • useFrame の delta
  • R3F のアニメーション制御
  • invalidate / advance のタイミング

などに使われる重要なオブジェクトだが、

→ XR の animationLoop の timestamp は受け取らない

→ つまり XR の世界と時間軸が別

その結果…


useFrame(state, delta) が XR ではズレる理由

R3F の delta は R3F の clock 基準。

XR の delta は XRSession の timestamp 基準。

比較してみると:

項目 R3F WebXR
基準 React / R3F の clock XR デバイスの内部クロック
FPS ほぼ 60Hz Quest は 72 / 90 / 120Hz
更新タイミング ブラウザ rAF 依存 XR デバイス依存
処理順 useFrame → gl.render XR pose → controller → gl.render

→ 完全に別軸で動き続ける。

そのため XR 内で:

  • アニメーションが遅れる
  • VRM の足が地滑りする
  • コントローラーの反応がワンテンポ遅れる
  • カメラがワンフレーム“後追い”になる
  • VR 空間の動きに微細な酔いが発生する

という XR 特有の問題が起きる。


結論:R3F のループと XR のループは「構造的に同期しない」

これはバグではない。

R3F と WebXR は、別の目的で作られた2つのループが “偶然出会ってしまった” 結果。

3. “R3F useFrame が XR と同期しない” 問題

R3F の useFrame() は本来、 R3F が持つ独自のレンダリングループに完全依存している。

しかし XR に入った瞬間、 Three.js は gl.setAnimationLoop() を XR 専用のループへ切り替えるため、 R3F のループと XR のループが別周波数で動作する。


XR と R3F のフレームレートは一致しない

XR デバイス(例:Quest2/3)は、 デバイス固有のフレームレートで駆動する。

モード フレームレート
Quest 2 72Hz / 90Hz
Quest 3 90Hz / 120Hz

一方、R3F のループは ブラウザ側の rAF (60Hz 前後) に依存しているため、 XR に入っても“勝手に 60Hz 付近で回り続ける”。

これによって delta(前フレームとの差分時間)が噛み合わない。


結論1:R3F の delta が XR と一致しない

R3F の delta は 1/60 秒付近で進むが、 XR の delta は 1/72・1/90・1/120 秒で進む。

結果:

  • useFrame の世界は 60Hz
  • XR の世界は 72〜120Hz

という“2つの別世界”が生まれる。


結論2:動きが“1フレーム遅れて反映”される

React Three Fiber では、 すべての状態更新(カメラ・物体移動・VRMの骨更新)が useFrame の流れに乗るため、

XR 側の pose 更新(72〜120Hz) ↓ useFrame の反応(60Hz)

となり、常に 1 フレーム遅れて反映される。

これが:

  • ジャダー(細かいブレ)
  • 遅延した視点移動
  • カメラのカクつき
  • モデルの僅かな“痙攣”

を発生させる。


結論3:VRM / MMD のリグがズレる理由もこれ

VRM や MMD は アニメーションの姿勢がフレーム単位で厳密。 しかし、useFrame が XR と同期していないため:

  • hips(腰)が地滑りする
  • 足 IK が一瞬遅れて反応する
  • モーションのリズムがズレる
  • 手・腕が XR カメラの位置に追従しない
  • 走りモーションが“常に 1 フレーム後ろ”になる

こういった XR 特有の “違和感” が生じる。


結論4:XR 空間では酔いやすくなる

VR の酔いは 「視点移動の同期ズレ」 が最大の原因。 R3F useFrame と XR が別ループで動く以上、 このズレは構造的に発生する。

  • 首の回転 → 実描画まで数ms遅れる
  • カメラのマトリクス更新が1フレーム遅延
  • 動き出しと見た目のズレが脳に負担

特に 90Hz/120Hz の高速レンダリングでは 60Hz の遅れがより顕著に見える。


まとめ:R3F useFrame は「XR の世界線」に届いていない

R3F の useFrame は:

  • 60Hz
  • R3F 独自の clock
  • React のループ基準
  • WebXR の timestamp 不使用

という “XR 非対応のタイムライン” で動く。

そのため XR 内では:

  • 動作の遅延
  • モデルのズレ
  • VRM の足の同期崩れ
  • コントローラ反応の鈍さ
  • 軽い酔い

が発生する。

これは 実装ミスではなく構造的問題。

4. 解決策1:frameloop=“never” + setAnimationLoop() を自前で奪う

R3F と XR のループが同期しない最大の原因は、 R3F が自前の render loop を持っていることだ。

これを避ける最も根本的な方法が、

R3F のループを“完全に止めて”、XR のループに従わせる

という設計である。


Step1:R3F の内部ループを完全停止(frameloop=“never”)

R3F の <Canvas> には frameloop という設定がある。

<Canvas frameloop="never">

これにより R3F は:

  • useFrame() を自動では呼ばない
  • gl.render() を自動では呼ばない
  • advance() を自動では実行しない

つまり R3F の「自前ループ」が丸ごと停止する。

R3F の render loop が止まるということは、 React の state 更新 → useFrame → render の連鎖が勝手に走らなくなる。

これが XR 統合の前提になる。


Step2:XR の animationLoop 内で R3F を手動で advance する

Three.js は XR モードになると

gl.setAnimationLoop(loop)

に制御を移す。 ここに R3F の root.advance() を挿入するのがポイント。

例:

gl.setAnimationLoop((timestamp, frame) => {
  // --- XR の世界 ---
  // XR の head pose / controllers を更新
  // frame.getViewerPose() などの処理

  // --- R3F の世界 ---
  root.advance(timestamp / 1000); // ★ R3F の更新を XR 時間に合わせる
});

R3F に渡す時間は XR の timestamp をそのまま使うことで、 R3F 内部の clock と XR 内の clock が 完全に統一される。


効果:R3F と XR のループが「1本化」される

これで実現するのは:

XR が全体のループ主導権を握る

R3F は XR が支配する時間軸(timestamp)で advance される

useFrame の delta が XR と一致する

カメラ・VRM・アニメーションが XR に同期する

1フレーム遅れが消える

つまり R3F と XR が同じ “世界線” に立つ。

これが最も美しい統合方法。

具体的メリット

● 1. VRM / MMD のアニメーションが XR と一致する

VRM の hips が地滑りする問題が改善される。

● 2. カメラの動きが XR と完全同期

酔いが減り、姿勢の遅延がなくなる。

● 3. useFrame の挙動が XR の FPS と一致

120Hz の世界でも正しく動く。

● 4. 反射・水面・キャラクターの制御が安定

特に root.advance を XR ループに乗せることで すべてのフレーム処理が XR の“1本化”される。


副作用(ここが重要)

この方式は強力だが、いくつか注意点もある。


1. React 再レンダリングの頻度に注意

advance() は「R3F の world 更新」だけであり React コンポーネントの再描画は直接関係ない。

React 側の再レンダが増えると XR の 90Hz/120Hz にボトルネックを作る。


2. Suspense は XR 中に再マウントを引き起こす

ループを XR に統一しても React の再マウントは XR の描画を乱す点は変わらない。

(これが後の章で扱う“小さなブラックアウト現象”につながる。)


3. R3F の invalidate も使わなくなる

frameloop=“never” では invalidate() を呼んでも R3F の自動ループは動かない。

XR の animationLoop が唯一の心臓となる。


結論

この方法は、R3F × XR の統合として もっとも根本的で、もっとも強力で、もっとも安定する。

「R3F の世界を XR の世界線に合わせることで、 useFrame・アニメ・カメラが完全に同期する」

という状態を実現できる。

5. 解決策2:useFrame の “priority” を使って XR 更新を最前に置く

frameloop="never"setAnimationLoop() を使わず、 R3F のデフォルトループのまま XR と少しでも同期させたい場合に使えるのが

useFrame の priority(優先順位)

である。


R3F の useFrame は “優先度付きのコールスタック”

R3F の useFrame() は、以下のように priority(数字) を指定できる。

useFrame(
  (state, delta) => {
    // XR に合わせたい更新処理
  },
  1 // ★ priority(数字が大きいほど先に実行される)
);

デフォルトの priority は 0。 数字が大きいほど 早い段階で実行される。


これにより、XR に関係する処理を最前にまとめられる

たとえば次のような処理を priority 1 にすれば、 R3F の通常レンダリングより“前”で実行される:

  • HMD(head pose)の追従
  • コントローラー位置の更新
  • VRM の hips・root の位置合わせ
  • XR 空間内のアバターの補正

結果として、R3F の描画が XR の状態を後追いしにくくなる。

priority を使うメリット(軽減できるズレ)

◎ 1. コントローラーの“操作遅延”が減る

XRInputSource の位置や回転を R3F 描画より前に反映できる。

◎ 2. VRM の腰・足が少し安定する

まったく同期できないわけではなく、 “見た目の遅れ”が少しだけ目立たなくなる。

◎ 3. 視点(head pose)のズレが若干緩和

R3F が持つ 60Hz の遅延を “描画前に先に計算する”という形で隠せる。

結果として、 「XR内の違和感が 30〜50% 程度は軽減される」 という効果がある。

しかし、この方式は「完全な同期」ではない

priority をいくら調整しても、 根本的に R3F と XR のループは別物である。

R3F は 60Hz の世界

XR は 72 / 90 / 120Hz の世界

両者の delta は一致しない

R3F の clock は XR timestamp を知らない

useFrame の順序を変えても“loop 自体は別”

つまり…

priority は“順番”の調整であって、 “ループの統一”ではない。

構造的問題は残る。


なぜ完全解決にならないのか?

priority 方式では、

  • R3F の clock は 60Hz
  • XR の clock は 72〜120Hz
  • useFrame は R3F のクロックに従う
  • WebXR の animationLoop は XR デバイス側で進む

という関係が 絶対に変わらない。

そのため、

・優先順位は変わる → でも“ズレは残る”

・遅延は少し減る → でも“完全同期しない”

・酔いは減る → でも“完全には消えない”

という、「軽減」止まりの対策である。


まとめ:priority は “小手先の最適化”、Loop統一ほどの効果はない

priority 方式はこうまとめられる:

  • 実装が簡単
  • 一部のズレが減る
  • useFrame の順序が整う
  • 表面的な遅延が軽減される
  • ✖ しかし根本的な同期にはならない
  • ✖ VRM/MMD の完全同期は不可能
  • ✖ XR の timestamp は利用されない

したがって:

短期的な応急処置にはなるが、
XR で本格運用するには不十分。

6. XRControllerModelFactory が R3F と相性最悪な理由

React Three Fiber(R3F)は、Three.js の SceneGraph を 「React の state 管理」+「差分レンダリング(reconciliation)」 の思想で包み込んだフレームワークである。

つまり R3F は、 SceneGraph のオブジェクトを「React が管理する存在」として扱う。

React が管理する、ということは:

  • props → SceneGraph へ反映
  • 再レンダ時に差分チェック
  • 不整合があれば reconciler が修正
  • unmount 時に破棄

という React 流のライフサイクルが必ず適用される。


しかし XRControllerModelFactory は“React 無視”で動く

XRControllerModelFactory(Three.js 純正)は、 XR コントローラーのモデルを Raw Three.js の世界 で勝手に構築し、

  • 子オブジェクトの追加
  • transform の変更
  • ツリーの再構築
  • マテリアルの差し替え
  • pose に応じて更新

などを 直接 mutate(破壊的操作) する。

つまり:

Three.js の生の SceneGraph を、 React の知らないところで勝手に書き換える。


React の世界観と真っ向から衝突する

React / R3F は本来こう考えている:

「SceneGraph の状態は全部 React の管理下にある」

しかし XRControllerModelFactory は:

「いや、俺が Three.js 直で書き換える」

この結果、

  • React が “正しい状態” だと思っている SceneGraph
  • XRControllerModelFactory が“実際に書き換えた” SceneGraph

この2つがズレる。


具体的に起きる破綻(XR で出る不具合の正体)

1. コントローラーモデルが突然 “消える”

R3F 再レンダ時に、 「React が持っている SceneGraph」と一致しないため、 reconciler が “存在しない子要素扱い” して破棄する。

2. モデルが “1フレーム遅れて反応する”

React の reconcile → Three.js の mutate という 二重更新 が発生する。

3. コントローラーの位置がズレ続ける

R3F の useFrame のタイミングと XRControllerModelFactory のタイミングが別軸で進むため、 常に時間差が出る。

4. マテリアルが突然リセットされる

reconciler が“差分を修正”しようとするため、 XRControllerModelFactory が設定したマテリアルが上書きされる。

5. 特定の環境で「手が地面に落ちる」

モデルの transform が React と Three.js によって 上書き合戦になり、 ワールド座標が狂う。


この問題は R3F の構造上“修正不能”

重要なのは:

R3F と XRControllerModelFactory は設計思想が正反対 ・R3F:差分レンダリング(reconciler) ・XR系:破壊的 mutate(imperative)

このギャップは調整で治せる類の問題ではない。


安定させるための正しい使い方(推奨パターン)

R3F と XRControllerModelFactory を共存させるためには、 “React 管理に入れない” という方針が必要になる。


1. コントローラーモデルを R3F 管理にしない

// React コンポーネントにしない
<primitive object={controllerModel} />

これはダメ。 React が “勝手に触っていいもの” と認識してしまう。


2. Three.js の raw object として直接 add する

// XRSession 開始時などで
scene.add( controllerGrip );

ここではっきり 「このオブジェクトは React の管理外」 に置く。


3. Suspense や re-mount に巻き込まない

Suspense の fallback 切り替えが起きると SceneGraph の一部が再構築されるため、

  • コントローラーモデルが消える
  • XR pose 更新が初期化される
  • カメラが 0,0,0 に戻る

などが発生する。

したがって:

<Suspense fallback={null}>
   {/* XR コントローラーを含めない */}
</Suspense>

は必須。


まとめ:XRControllerModelFactory は R3F の“最凶の衝突ポイント”

  • XR コントローラーモデルは Three.js が破壊的に mutate
  • R3F は React の思想で差分更新
  • その2つは根本的に相容れない
  • 結果:消える / ズレる / 反応しない / 崩壊 が起きる
  • React 管理に入れず “raw Three.js object” として扱うのが唯一の安定策

7. Suspense が XR 内だと危険な理由

React の <Suspense> は、本来 Web UI を滑らかにするための仕組みだ。

・コンポーネントがロード中 ・非同期ローダーで遅延 ・fallback の切り替え ・再レンダー / 再マウントによるレイアウト再計算

これらを “自動的に置き換える” メカニズムを持つ。

しかし、この 「一部のコンポーネントを再マウントする」 という性質が、XR に入った瞬間、致命的な不具合を起こす。


XR 内の SceneGraph は「絶対に再構築してはならない」

WebXR の世界では、

  • HMD の head pose
  • controller grip pose
  • viewer pose(視点)
  • reference space(floor / local-floor)
  • projection / view matrix
  • input source state

これらすべてが 「継続性」 を前提に更新される。

つまり:

1フレーム前と “同じオブジェクト” を更新し続けることが前提の世界。

ところが React Suspense は “コンポーネントのツリーを一度破壊して新しいものに置き換える”。

→ これが XR session と全く噛み合わない


Suspense が XR 中に起こす「壊滅的な現象」

ポーズの再計算がズレる

再マウント時、head pose / controller pose が “初期状態” になる。

  • カメラの位置が 0,0,0 に戻る
  • コントローラーの gripSpace の姿勢が再計算
  • 直前のフレームと整合性が消える

XR が必要とする「前フレームとの継続性」が崩壊する。


カメラが 0,0,0 に飛ぶ

R3F が生成し直した camera や scene が XRSession の viewerPose と一致しないため、

  • XR が “初期姿勢” で再アタッチ
  • その瞬間に camera が 0,0,0 に瞬間移動
  • 利用者視点では “真っ黒” や “床の中” が一瞬表示される

手が地面に埋まる

controller グリップが再マウントされると:

  • XR が把握する gripSpace(コントローラーの空間座標)
  • R3F 内のコントローラーモデルの transform

一致しなくなる

その結果:

  • グリップだけ床に落ちる
  • モデルだけ数十cmズレる
  • 次のフレームで急に正位置に戻る → “ガクッ” と落ちる挙動になる

画面が“瞬間ブラックアウト”する

Suspense の fallback が発動すると SceneGraph が瞬時に変わる。

XRSession は「見ていた SceneGraph が突然消える」ため、 XR compositor がセーフティとして 1フレーム黒を挿入する

これがユーザー視点では “チラつき” / “ブラックアウト” に見える。


なぜ XR では re-mount が絶対に許されないのか?

理由は明確で、

WebXR は “連続フレーム” が前提の API だから。

  • headPose
  • inputSource
  • viewerPose
  • referenceSpace
  • anchors
  • hitTestSource

いずれも 「前フレーム → 次フレーム」 の Δ を取っている

つまり一度でも SceneGraph が破壊(unmount)されると:

  • XR がトラッキングロスト扱い
  • pose の履歴がリセット
  • index の参照が崩壊
  • XR compositor が fallback を発動

こうして XR 特有の破綻が起きる。


だから XR の Suspense は「非推奨」ではなく「構造的に使えない」

まとめると:

再マウントは XR の pose と一致しなくなる

fallback が SceneGraph を破壊する

コンポーネントのライフサイクルが XR と噛み合わない

R3F の reconciler が XR の状態を知らない

head pose / controller pose が初期化される

ブラックアウトが起きる

R3F × XR では、 Suspense は UI の便利機能ではなく“破壊装置”になる。


実際の推奨パターン

XR 内では Suspense を使わない

ローダーは XR セッション開始前に完了させる

遅延読み込みの内容は XR 空間に入れない

fallback={null} は強制的 re-mount を発生させるので NG

コントローラーモデルは Suspense の外で raw Three.js 管理


結論:XR セッション中に “Scene を書き換える React の仕組み” はすべて危険

Suspense はその代表例であり、 XR × R3F の最大の地雷のひとつ。

8. 何が最も重くなるのか? R3F × XR のパフォーマンス問題

R3F は「UIフレームワークの哲学」を Three.js に持ち込んでいるため、 WebXR の 90〜120Hz 世界では、構造的に負荷が跳ね上がる。

ここでは XR 時に特有のボトルネックを正確に列挙する。


1. Scene graph の “diff 計算” が XR では致命的に重い

R3F の本質は 「React の reconciler(差分更新)」

React と同じく、

  • “前フレームのツリー”
  • “今フレームのツリー”

を比較し、

どのノードに変化があったか?
どの子を差し替えるべきか?
どの props が変わったか?

これらを 毎フレーム行う。


🟥 VR はフレームレートが高すぎて破綻

通常 Web は 60fps 前提だが、XR は:

  • 72Hz(Quest2)
  • 80Hz(Quest3 デフォルト)
  • 90Hz(PCVR)
  • 120Hz(ハイエンド)

→ 差分計算が単純に 1.5〜2倍の頻度で走る。

これは Three.js(命令型更新)には存在しないコスト。 R3F だけが背負う“UI式コスト”である。


2. useFrame の多重呼び出し問題(最大の地獄)

R3F は useFrame() を登録した数だけ “毎フレーム呼び出す”。

たとえば:

  • カメラ制御
  • コントローラー更新
  • VRM アニメ
  • postprocessing
  • minimap
  • UI overlay
  • physics
  • エフェクト

これらが 10〜20 個あると、

90Hz × 20 = 1800 callbacks/秒
120Hz × 20 = 2400 callbacks/秒

🟥 useFrame の数が多いほど指数的に重くなる

React の仕組みの上に Three.js を乗せているため、 フレーム単位での関数コールが非常に多い

Three.js ネイティブでは、 単純に自分で必要な update を1回呼ぶだけなのでこの問題は存在しない。


3. XR Input の更新が “二重で走る” バグ構造

XR のコントローラーや HPD pose は本来、

Three.js 側(XRManager)が XRSession の timestamp に同期して更新する。

ところが R3F を併用すると:

Three.js 側:XRManager が更新

R3F 側:useFrame() が独自に “遅れて”更新

つまり、

同じ入力が 2回更新される。

このとき:

  • ボタンイベント
  • gripPose / targetRayPose
  • controller の transform
  • inputSource の更新

これらが R3F の delta / clock と XRSession の timestamp の差によってズレて呼ばれる

結果:

  • 手がガクッと揺れる
  • ボタンが 2回押された扱いになる
  • ポインターがカクつく
  • VRM の腕がわずかにブレ続ける

などの症状が起きる。


4. React の「再レンダー」が XR の 120Hz と相性最悪

React はコンポーネントの state が変わると ツリーの一部を再レンダーする。

これが XR では:

  • 120fps中に再レンダーが挟まる
  • SceneGraph の一部が再構築される
  • その瞬間「XR compositor が1フレ黒を差し込む」

結果:

カクつき / ブラックアウト / pose リセット Suspense と同じ地雷がふつうの再レンダーでも起きる。


5. Canvas の内部 clock が XR timestamp と一致しない

R3F の clock は

performance.now()

WebXR の timestamp は

XR timestamp (より正確・専用の値)

この差によって:

  • VRM のリグが “足ずれ” を起こす
  • 物理演算の delta が安定しない
  • カメラのスムースモーションが破綻する

まとめ:R3F × XR が重くなる理由は「React 由来の負荷」+「XR との二重更新」

🟥 1. SceneGraph の diff → 120Hz だと暴力的な負荷

🟥 2. useFrame の多重コール → 1000〜3000回/秒のコール

🟥 3. XR input の二重更新 → “ブレ”“遅延”の温床

🟥 4. React の再レンダーが XR compositor に刺さる

🟥 5. clock が XR timestamp と合わずアニメがズレる


結論:R3F は「通常の Three.js より重い」のではなく

XR で“構造的に Two-loop(2つのループ)を抱えてしまう”

だから XR では弱い。