はじめに
体調が戻ってきたので、今日やる内容としてまず R3Fのモデルデータの扱いの気や落とし穴を、学習。
前回の記事:
Next.js #06 多段キャンバス構造の設計図:2D Canvas と 3D(R3F)を重ねて扱う方法
Next.js の App Router を使って、DOM UI・2D Canvas(HUD)・3D Canvas(R3F)をレイヤー別に分離し、負荷やバグを最小化しながら安定動作させる設計図を紹介します。Three.js ユーザーが R3F に移行する時の注 …
https://humanxai.info/posts/nextjs-06-multi-canvas-structure/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 の土台は完成する。
💬 コメント