[Next.js #07] R3FでglTF/VRMを扱うための“橋渡し”基礎まとめ

はじめに

体調が戻ってきたので、今日やる内容としてまず R3Fのモデルデータの扱いの気や落とし穴を、学習。

前回の記事:

1. “Three.js と R3F の違い” ( glTF / VRM )

Three.js を知っている人ほど、 R3F(React Three Fiber)で 必ず同じ落とし穴に落ちる。

理由はとてもシンプルで、 Three.js と R3F は “思想そのもの” が違うから。

この章では、その「思想の差」だけに集中する。


Three.js は『命令的(imperative)』

Three.js の世界では、 開発者が “すべての流れ” を直接コントロールする。

自分で:

  • シーンを作る
  • カメラを作る
  • ライトを置く
  • glTF を読み込む
  • VRM をセットアップ
  • アニメを更新
  • ループを回す(requestAnimationFrame)

全部 “人間が明示的に命令する”。

scene.add(model);
mixer.update(delta);
renderer.render(scene, camera);

この “命令的スタイル” は、 VRM のような複雑なモデルでも 安定して動く。 なぜなら、開発者が 一度構築した状態を勝手に書き換えられることがない からだ。


R3F は『宣言的(declarative)』

一方の R3F は、 React のルールの中で Three.js を動かす仕組み。

ここが根本的に違う。

R3F では、UI と同じように 「宣言」 する:

<Canvas>
  <Model />
</Canvas>

この “宣言” に対して、 React は次のように判断して動く:

  • もう一度描画するべきか?
  • コンポーネントを破棄して再作成するべきか?
  • 状態は変わったか?
  • 依存が変わったか?

つまり、

R3F では「モデルの存在そのもの」が React の再レンダーに巻き込まれる。

Three.js には存在しなかった概念。


React が再レンダーすると何が起きるのか?

React が以下のように判断すると、 モデルの 生成・破棄・再作成 が発生する。

  • props が変わる
  • state が変わる
  • 親コンポーネントが更新される
  • Suspense の状態が変わる
  • キャッシュの状況が変わる

その結果──

VRM が壊れる

  • ボーン階層の参照が途切れる
  • spring bone が初期化される
  • blendshape がリセット
  • animation mixer が止まる
  • humanoid の内部構造が破損
glTF も安定しない場合がある
  • primitive が再生成される
  • dispose が勝手に走る
  • material が作り直される
  • scene graph が差し替わる

これらは Three.js では一切起きない挙動 だ。


なぜ VRM は “特に” 壊れやすいのか?

VRM は内部に

  • humanoid の骨格構造
  • spring bone
  • constraint
  • blendshape proxy
  • meta
  • extension
  • 初期化処理
  • ボーンの参照テーブル

など、React の再レンダーと相性最悪の構造 を持っている。

Three.js では一度セットアップすれば固定だが、 R3F では 「再レンダー=モデル破壊フラグ」 になり得る。


だから glTF / VRM の処理を Three.js のまま移植すると地獄を見る

結論:

R3F は Three.js と同じ API を使うけど、

Three.js の書き方を移植すると 100% 壊れる。

理由は “思想の違い” であり、 知識不足でも理解不足でもない。


この章のまとめ(核心)

  • Three.js は命令的 → 自分で全部制御する世界

  • R3F は宣言的 → React に再レンダー権限がある世界

  • glTF/VRM は 「再レンダーされること」自体に弱い

  • Three.js の感覚で書くと、 モデル破壊・アニメ停止・参照喪失が必ず起きる

この「思想の差」を理解することが R3F で glTF/VRM を扱うための 唯一の最初のステップ になる。

2. glTF の読み込みは“API の違い”ではなく“思想の違い”

Three.js と R3F の glTF 読み込みは、 見た目の違い以上に「思想」が決定的に異なる。 ここを理解しないと、 glTF が「勝手に再読み込みされる」「順序が安定しない」などの 不可解なバグに必ずぶつかる。


Three.js の glTF 読み込み(命令的)

loader.load("model.glb", (gltf) => {
  scene.add(gltf.scene);
});

Three.js では:

  • 読み込むタイミングを自分で決める
  • コールバックが終わったら即 scene に追加
  • その後は破壊されない(状態が固定される)

つまり、

「読み込み」と「表示」は完全に人間の制御下にある。


R3F の glTF 読み込み(宣言的)

const gltf = useGLTF("/model.glb");
return <primitive object={gltf.scene} />;

この書き方自体が 「React のレンダーツリーの一部」になる。

つまり、R3F は

  • 読み込むタイミング
  • 読み込み後の描画
  • コンポーネントの再レンダー
  • キャッシュ
  • Suspense

これらすべてを “React のルール” で決定する。


React Suspense が絡む

useGLTF() は 「まだ読み込まれていない → Throw Promise」 という React Suspense の仕組みに乗って動く。

そのため、 読み込みの途中はコンポーネントが “保留状態” になる。

そして、読み込み完了後に React が再レンダーを発火する。

つまり:

Three.js ➡ “読み込む → 完了 → 追加” の線形処理 R3F ➡ “状態に応じて React が何度でも評価” というまったく別の動作をする。


キャッシュが絡む

R3F / drei の useGLTF() は 自動キャッシュ が入る。

  • 一度読み込んだら再ロードされない
  • URL が変わらなければキャッシュが返る
  • Suspense との組み合わせで挙動が変わる

キャッシュが賢すぎるぶん、 「読み込み直したい時に直らない」 というバグの原因にもなる。

(※R3F経験者が一度はぶつかる罠)


レンダーツリーが絡む

React は 「コンポーネントの戻り値=ツリー」 として扱う。

<primitive object={gltf.scene} /> も React が「これは Three.js のノードだ」と判断してレンダーツリーにマウントする。

その結果:

  • 親が再レンダーすると  ➡ primitive が再評価される
  • 条件付きレンダーが変わると  ➡ シーンツリーから detach / attach が発生
  • ステート変更で  ➡ マテリアルやメッシュ参照が再生成されることもある

Three.js では起きない問題が普通に起きる。


これを知らずに書くと起きる地獄

  • glTF が勝手に再読み込みされる
  • モデルが付いたり消えたりする
  • “一瞬だけ消える” 現象が出る
  • アニメーションが止まる
  • material が毎回新しく生成される
  • 親コンポーネントの再レンダーで破壊される

全部、R3F の思想(Reactのルール)を知らずに書くと起きる。

この章のまとめ(核心)

  • Three.js は「人間が読み込むタイミングを決める」

  • R3F は「React が読み込みも再レンダーも決める」

  • Suspense が glTF の未読状態を管理する

  • キャッシュが挙動を変える

  • レンダーツリーが glTF を“UIの一部”として扱う

結論:

glTF の挙動は API の違いではなく“React の哲学”の違いで決まる。

3. R3F で VRM を扱う時の “地雷”

R3F × VRM は、 Three.js の感覚で書くと必ず壊れる。

理由はシンプルで、 VRM は「一度初期化して固定して使う」構造なのに、 React は“何度でも作り直す”思想だから。

この章では、 実際に R3F × VRM で起きる“全ての地雷”を 回避策とセットでまとめる。


地雷1:React の再レンダーで VRM の内部コンポーネントが破壊される

● 何が起きる?

  • VRM の humanoid 構造が消える
  • spring bone manager が undefined になる
  • animation mixer の参照が飛ぶ
  • 目・口の制御が一瞬止まる

● なぜ?

React が 「このモデル、再評価しとくか」 と判断した瞬間、 <primitive object={vrm.scene} /> を detach → attach する。

VRM は detach に弱いため破壊される。

● 回避策(超重要)

VRM インスタンスを React の再レンダーから完全に隔離する。

const vrmRef = useRef<VRM | null>(null);
useEffect(() => { /* VRMの生成は1回だけ */ }, []);

地雷2:humanoid のボーンが上書きされる

● 何が起きる?

  • 足が曲がらなくなる
  • 首が固定される
  • 目線が動かない
  • ポーズを変えると破綻

● 原因

R3F の再レンダー時に

  • new primitive
  • new object
  • new skeleton

として扱われるケースがある。

Three.js では起きない挙動。

● 回避策

絶対に props/state の変更で VRM を作り直さない。

悪い例:

<Model url={state.url} />  // URL変わるたびにVRM再構築→死亡

良い例:

// URL変更時だけVRM再生成、それ以外はref命令だけ

地雷3:VRMSpringBone が useFrame と喧嘩する

● 症状

  • 髪がブレる
  • 揺れが消える
  • フレーム落ちの瞬間に暴れる
  • 一度だけ跳ねて止まる

● 理由

spring bone は内部で 独自の update ループ を持つ。

React の再レンダーや useFrame の順番次第で 「更新が2回」「更新が0回」というズレ が出る。

● 回避策(鉄板)

useFrame の中で必ず 手動 update を呼ぶ。

useFrame((_, delta) => {
  vrmRef.current?.update(delta);
});

VRM.update() は spring bone / lookAt / blendshape を “すべてまとめて” 正しい順で更新する。 これに一本化するのが正解。


地雷4:BlendShapeProxy が一瞬リセットされる

● 症状

  • 瞬きが消える
  • 表情が戻る
  • 口パクが0に戻る
  • 感情切り替えで「ピクッ」と硬直する

● 原因

React が以下のどれかで判定した時、 VRM内部のexpression manager の参照が差し替わる:

  • props の変更
  • state の変更
  • 親コンポーネントの再レンダー
  • Suspense の解決タイミング

● 回避策

表情値を React で変えない。

  • ❌ NG:setExpression({ angry: 1.0 })
  • OK:Zustand などで 数値だけ保持 し、 useFrame で vrm に命令して適用する。
vrm.expressionManager.setValue("angry", angryValue);

地雷5:useGLTF のキャッシュと VRM の meta が衝突する

● 何が起きる?

  • VRM を差し替えたのに古い meta が残る
  • 男女モデルを切り替えると壊れる
  • 表情スロット数が狂う

● なぜ?

useGLTF() は URLが同じだと glTF をキャッシュから返す。

VRM は glTF に VRM 拡張データを載せているため、 meta 情報もキャッシュされてしまい、 差し替え時に古い情報が残る。

● 回避策

  • URL にクエリを付ける(キャッシュバスター)
  • もしくは GLTFLoader を手動で使って VRM化 する

R3F × VRM の実装者は 100% 一度これにハマる。


地雷6:Zustand で “ポーズ状態” を管理しないと破綻する

● 問題点

React の state を VRM に使うと:

  • 再レンダーのたびにポーズが初期化
  • blendshape がリセット
  • 視線が neutral 状態に戻る
  • ちょっとした props 変更でモデル破壊

● 回避策(超重要)

Zustand で “モデルには触れない数値だけ” を保持する。

// store
pose: { happy: 0.2, angry: 0.0 }

これを useFrame で VRM に“命令”として反映する。


この章のまとめ(核心)

  • VRM は 「一度初期化したら固定して使う」 モデル
  • React は 「必要なら何度でも再作成する」 フレームワーク
  • ここが最大の衝突点

そのため、

  • VRM インスタンスは useRef に隔離する
  • React の再レンダーで VRM を巻き込まない
  • 更新は useFrame に一本化する
  • 状態は Zustand などに “値だけ” 持たせる

これが R3F × VRM の唯一の安定ルート。

4. R3F モデルビューアーの設計思想

R3F は Three.js と違い、 コンポーネント単位でシーンを構築する。 だから 「最初に設計の思想を理解すること」 が最重要。

以下の方針を守ると、 glTF でも VRM でも壊れず、 Next.js でも安定動作する。


モデルは “コンポーネント” にする(責務を極端に狭くする)

モデルのロジック(読み込み・ref保持・アニメ)は ひとつのコンポーネントに閉じ込める。

理由:

  • React の再レンダーがモデルに波及しにくくなる
  • useRef で VRM/glTF の実体を固定しやすい
  • ビューアーの他の UI 変更に巻き込まれない

構造としては:

<Canvas>
  <Scene>
    <Lights />
    <Camera />
    <Model />  ← こいつは独立
  </Scene>
</Canvas>

モデルは Canvas 関連の props と切り離した単機能レイヤー にする。


カメラは “別コンポーネント” にする(モデルと絶対に混ぜない)

R3F 初心者がやりがちな失敗:

  • モデルのコンポーネント内で OrbitControls を使う
  • Model と Camera を同じコンポーネントに書く
  • カメラがモデル読み込みのたびに初期化される

これ全部バグの元。

正解の思想:

「カメラは常に Scene の最上層」

<Canvas>
  <CameraController />
  <Model />
</Canvas>

理由:

  • カメラは “UI 的役割” なので React 再レンダーと近い
  • モデルの状態変化に巻き込まれてはいけない
  • カメラ状態は Zustand で保持しやすい

ライトは “React 優先” に統合(Three.js のクセを引きずらない)

Three.js の灯りはシーンと密結合していたが、 R3Fでは ライトもコンポーネント。

<Lights />

にまとめておくと:

  • UI / 設定と同期しやすくなる
  • Suspense の影響を受けない
  • モデル切り替え時にライトが破壊されない
  • State 変更に影響されにくい

「モデルの中にライトが入る」 という構造は NG。 (glTF のライトは例外)


アニメは useFrame に責務を集める(命令的な1本化)

アニメーション・視線・表情・ポーズ等、 VRM/glTF のすべての update は useFrame に集約。

useFrame((_, delta) => {
  vrm.update(delta);          // SPRING / BLENDSHAPE / LOOKAT
  mixer.update(delta);        // glTF の Animation
});

理由:

  • React の再レンダーに影響されなくなる
  • モデルの内部状態が狂わない
  • 描画順序を常に一定にできる
  • ループは Three.js 方式で “命令” に戻せる

R3F でもアニメは 命令的に戻す方が正しい。


状態は Zustand に逃がす(React state をモデルに使わない)

VRM/glTF の内部状態を React state に持つと地獄になる。

React state は:

  • 変更 → 再レンダー
  • 再レンダー → primitive 差し替え
  • primitive 差し替え → VRM 壊れる

この流れ。

正しいのは:

「VRM/glTF は useRef」 「状態(数値やフラグ)は Zustand」 「反映は useFrameで命令」

この3分離。

これで再レンダー地獄を完全回避できる。


Next.js の public/ 配下からモデルを読む(App Router 最適化)

Next.js の特徴:

  • /public 内のファイルは  直接 URL として扱える(ビルド後もパスが安定)
  • Edge / static export に強い
  • App Router のルートと衝突しない

最も安定するパターン:

/public/models/avatar.vrm
/public/models/tree.glb

R3Fでは:

const gltf = useGLTF("/models/tree.glb");

URL が Webpack/Vite のパス解決に乗らないので、 R3FのキャッシュとNext.jsのビルドが干渉しない。

model ファイルは /public が最強。


この章のまとめ(思想の核)

  • モデルは単機能コンポーネント
  • カメラは必ず別コンポーネント
  • ライトは React 的に統合
  • アニメは命令的(useFrame)に一本化
  • 状態は Zustand に逃がす(React state を使わない)
  • モデルは public から読み込む

この設計を守るだけで、

  • glTF の破壊
  • VRM の初期化バグ
  • 表情リセット
  • spring boneの停止
  • Suspenseの再マウント
  • Next.jsとの競合

これらを全部回避できる。

5. R3F × glTF/VRM の最小コード構造

ここまでの 1〜4章は 思想・地雷・設計 の部分に集中した。

最後に、最小構造 コード図だけ で示す。

R3F × VRM / glTF の鉄板構造は これだけ覚えればOK。


全体構造(Next.js App Router + R3F)

app/
 ├ page.tsx
 └ components/
      ├ ViewerCanvas.tsx       ← Canvas(唯一の入口)
      ├ CameraController.tsx   ← カメラ制御(独立)
      ├ Lights.tsx             ← ライト(独立)
      ├ VrmModel.tsx           ← VRM/glTF(モデル本体)
      └ useVrmStore.ts         ← Zustand(状態だけ)

Canvas が最上位、モデルは最下層 これが一番壊れない構造。


page.tsx(Next.js)

export default function Page() {
  return (
    <div className="viewer-wrapper">
      <ViewerCanvas />
    </div>
  );
}

ページの責務は Canvas を置くだけ。

UI はここに重ねても Canvas には干渉しない。


ViewerCanvas.tsx(唯一の Canvas 管理)

export function ViewerCanvas() {
  return (
    <Canvas shadows camera={{ position: [0, 1.2, 2.5] }}>
      <Lights />
      <CameraController />
      <Suspense fallback={null}>
        <VrmModel url="/models/avatar.vrm" />
      </Suspense>
    </Canvas>
  );
}

この構造の意図:

  • Canvas を複数作らない(地獄)
  • Camera / Lights をモデルから切り離す
  • Suspense 内に “モデルだけ” を置く
  • VRM の実体が UI / state に巻き込まれない

VrmModel.tsx(モデル本体:useRef+useFrame)

export function VrmModel({ url }) {
  const gltf = useGLTF(url);
  const vrmRef = useRef(null);
  const mixerRef = useRef(null);

  useEffect(() => {
    // VRM 変換は1回だけ
    VRM.from(gltf).then((vrm) => {
      vrmRef.current = vrm;
      mixerRef.current = new THREE.AnimationMixer(vrm.scene);
    });
  }, [gltf]);

  useFrame((_, delta) => {
    vrmRef.current?.update(delta);      // SPRING / LOOKAT / BLENDSHAPE
    mixerRef.current?.update(delta);    // glTF Animation
  });

  return <primitive object={gltf.scene} />;
}

ここで大事なのは 2つだけ:

  • VRM は useRef に閉じ込める(再レンダーから隔離)
  • 状態反映は useFrame で命令的に行う

CameraController.tsx(モデルと切り離す理由)

export function CameraController() {
  const { camera, gl } = useThree();
  return <OrbitControls camera={camera} gl={gl} enableDamping />;
}
  • カメラは UI の一部
  • モデルのライフサイクルに巻き込ませない
  • OrbitControls は “モデルの責務じゃない”

Lights.tsx(R3F の定石)

export function Lights() {
  return (
    <>
      <ambientLight intensity={0.4} />
      <directionalLight position={[3, 5, 2]} intensity={1.2} castShadow />
    </>
  );
}

ライトは 宣言的に書くのが正解。 Three.js の「scene に add する」という癖は捨てる。


useVrmStore.ts(状態を React では持たない理由)

export const useVrmStore = create(() => ({
  angry: 0,
  happy: 0,
  lookAt: [0, 1, 2],
}));

値だけ保持し、 useFrame で VRM に適用する。

React state を使うと VRM が再レンダーに巻き込まれて破壊される。


この章のまとめ(構造の核心)

  • Canvas は 1つ
  • Camera / Lights / Model は完全に別コンポーネント
  • VRM は useRef に閉じ込めて “再レンダー禁止”
  • 状態は Zustand → useFrame で命令して反映
  • Suspense 内は「モデルだけ」にする
  • public/ 配下からロードする

この構造だけ守れば R3F × glTF / VRM の土台は完成する。