0. この回のゴール
Next.js で「2D Canvas(UI)」と「3D Canvas(R3F)」を 切り替えたり同時に重ねて使う構造を作る。
- WebゲームUIの一般的な構成
- DOM / 2D / 3D の役割
- Next.js の App Router でどう分離するか
- R3F をどこに置くべきか
- “負荷が低くて破綻しない” 管理方法
前回の記事:
[Next.js #05] App Router の“基本 UI 構造”を理解する:layout / page / component / client-server の境界
Next.js(App Router) の UI を構成する layout / page / component の仕組みと、Server Component / Client Component の境界を正しく理解するための基礎回。R3F や WebXR を …
https://humanxai.info/posts/nextjs-05-app-router-ui-structure/1. なぜ多段キャンバス構造が必要か?
lain が Three.js 時代からずっとやってたあの構造:
- DOM(HTML) → メニュー、ボタン、UI
- Canvas 2D → ミニマップ、HPゲージ、文字、エフェクト
- Canvas 3D → ゲーム本体(R3F or Three.js)
ゲームっぽいサイトやインタラクティブな Web 演出では、 この 3階層の“役割分担” が自然に必要になる。
ポイントは:
3D と DOM は別物
→ ゲームUIを 3D Canvas に載せるとほぼ確実に破綻する。 → だから UI は DOM の領域に逃がす のが正解。
2D Canvas は 3D の仲間ではない
→ 3D の後ろでもなく“前”でもなく、重ねる HUD レイヤー。 → Flight Simulator や FPS の照準、ミニマップと同じ構造。
Webで“ゲーム構造”を再現しようとすると必ず三層になる
実際、Unity も Unreal も
- 3D シーン(World)
- 2D UI(HUD / UICanvas)
- System UI(インベントリ/メニュー)
でレイヤー分けされている。
Web でも全く同じ構造になる。
Three.js → R3F → Next.js と進化しても、 ゲーム的な表現をするならレイヤー分離は避けられない。
2. Next.js でやる時の「最悪パターン」
初心者が必ずやるミス:
1つの component に 2D と 3D を全部詰める
<GameScene>
<ThreeCanvas />
<canvas id="hud" />
<DomUI />
</GameScene>
こういう “全部入りコンポーネント” は Next.js では確実に破綻する。
理由:
- 再描画(re-render)が全部に飛ぶ
- State 変更が 2D と 3D の両方に伝播
- 3D Canvas が毎回再初期化される
- R3F の内部状態(カメラ、シーン、GLTF 等)が吹き飛ぶ
- FPS が落ちる
- メモリリークする
特に 3D Canvas の再マウントは致命的で、 “戻ってきたら真っ黒” になるのはこれが原因。
SSR(Server Side Rendering)と 3D Canvas を混ぜる
Next.js の App Router は デフォで Server Component。
R3F(Three.js)を直接 Server でレンダリングしようとすると:
window is not defineddocument is not defined- WebGLContext が作れない
- そもそも Canvas を生成できない
そして 3D コンポーネントだけ ビルド時に落ちる。
これを避けるには dynamic import(クライアント側限定) が必須。
const ThreeCanvas = dynamic(() => import('./ThreeCanvas'), {
ssr: false
});
Canvas が mount/unmount され続けて負荷が跳ね上がる
UI の状態によって Canvas を表示切替する場合、 このようなコードをやりがち:
{show3D && <ThreeCanvas />}
{show2D && <HudCanvas />}
でもこれ、 表示を切り替えるたびに Canvas が“破棄→再生成” される。
結果:
- WebGL コンテキストを毎回作り直す
- GPU メモリを再確保
- GLTFLoader の読み込みが再実行
- useFrame のループが多重化
- 画面が真っ黒 / カメラ壊れる
R3F や Three.js と最悪の相性。
つまり:
Next.js にそのまま Three.js/R3F を突っ込むと 全てが最悪のタイミングで噛み合わなくなる。
3つのレイヤー(DOM / 2D / 3D)は “構造として分ける” のが絶対に必要。
3. 正しい構造:レイヤー3段構成
これは Web のゲームUIやインタラクションを作る時の 最も安定していて、最も壊れにくい構造。
3D(R3F)、2D(HUD)、DOM(UI)を完全に“分離”する。
<div class="wrapper">
<Canvas id="canvas-3d"> ← R3F / Three.js(ゲーム本体)
</Canvas>
<canvas id="canvas-2d"> ← 2D HUD(ミニマップ、文字、HPバー)
</canvas>
<div id="dom-ui"> ← HTML UI(メニュー、設定、ボタン)
</div>
</div>
最上層:DOM UI → ボタン・メニュー・ダイアログなど、再描画コストの重いものは全部ここ。 React/Next.js が最も得意な領域。
中央:2D Canvas(HUD) → 3D の後ろでも前でもなく、“重ねるレイヤー”。 → 透明背景にしておくと、3D との合成が美しくなる。 → ミニマップ、照準、HPバー、文字演出など全部ここが最適。
最下層:3D(R3F/Three.js) → ゲームロジック・3Dモデル・アニメーション・ライトなど。
Next.js では 3D Canvas は Client Component にする
3D は WebGL を使うため、SSR では動かせない。
"use client";
App Router の page.tsx や layout.tsx で R3Fコンポーネントを読み込む時は必須。
2D Canvas も Client
2D Canvas も window / document を使うので
SSR では動作しない。
HUD部分は必ず Client Component にする。
DOM UI は Server Component でもOK(むしろ軽い)
メニューUI、ナビゲーション、静的な表示は Server Component のまま置いていい。
Next.js の強みを最大限使える構成。
この構造が強い理由
- 3D の負荷が DOM UI に影響しない
- DOM の再描画が 3D/2D に波及しない
- HUD は独立ループで滑らか
- Z-index や絶対配置で自由にレイヤーを制御できる
- スマホでも壊れにくい
- 切り替えやアニメも分離できる
- 3段構造はどのゲームエンジンでも共通(自然なアーキテクチャ)
4. Next.js App Router での推奨ファイル構成
多段キャンバス構造を Next.js (App Router) で扱う場合、 「レイヤーごとにフォルダを分ける」 のが一番壊れにくい。
app/
├ page.tsx ← サイトの土台(Server)
├ game/
│ ├ layout.tsx ← Client(Canvasを置く用)
│ ├ page.tsx ← DOM UIの差し替え
│ ├ ThreeCanvas.tsx ← R3F専用(3D)
│ └ HudCanvas.tsx ← 2D Canvas専用(HUD)
└ components/
└ ui/...
“game/layout.tsx” を Client にする理由
R3F や 2D Canvas は window と document に依存しているため、 Server Component では絶対に動かない。
だから、 Canvas を置くレイヤー(layout)だけ Client にする のが最も安全。
"use client";
export default function GameLayout({ children }) {
return (
<div className="wrapper">
<ThreeCanvas />
<HudCanvas />
{children}
</div>
);
}
これで 3D / 2D / DOM を App Router の層構造に直接マッピングできる。
“game/page.tsx” は DOM UI として自由に差し替え可能
メニュー・設定画面・ボタンなど UI のみをここで管理する。
layout.tsx が既に 3D + 2D を持っているので、 page.tsx は UIだけに専念 できる。
export default function GamePage() {
return (
<div id="dom-ui">
<MainMenu />
<Buttons />
</div>
);
}
UIを変更しても 3D/2D は再初期化されない = 超安定。
ThreeCanvas.tsx と HudCanvas.tsx を独立ファイルにする理由
3D と 2D の再描画 / 初期化を完全に分離できる。
- 3D Canvas:R3F の内部ループ(useFrame)が担当
- 2D Canvas:独立した requestAnimationFrame
- DOM:React の再レンダリング
それぞれ別世界として扱うことで、干渉がゼロになる。
これが「多段キャンバス構造」の最大の強み。
この構成のメリット(重要)
- 3D Canvas が 再マウントされない
- UI更新で 2D/3D が壊れない
- 2D HUD は常に滑らか
- DOM UI は SSR の恩恵を受けられる
- メンテナンス性が非常に高い
- スマホ・タブレットでも安定する
特に Next.js の App Router は “レイアウトを階層化する設計” が強いため、 多段キャンバス構造と相性がとても良い。
5. Canvas の切り替えロジック
R3F(3D)・2D Canvas(HUD)・DOM(UI)の 表示/非表示は グローバルステートで管理するのが最も安定。
Next.js + R3F + 2DCanvas は構造が複雑なので、 親コンポーネントから props で渡す方式はすぐ破綻する。 → 再レンダリングが連鎖して、3D Canvas が再初期化されるから。
そのため、Zustand のような外部ストアが最適。
Zustand を使った最小例
import { create } from "zustand";
export const useGameStore = create((set) => ({
mode: "3D", // "3D" | "2D" | "UI"
setMode: (mode) => set({ mode }),
}));
これだけで Canvas の状態管理が安定する。
UIボタンから切り替え
const { mode, setMode } = useGameStore();
<button onClick={() => setMode("3D")}>3D</button>
<button onClick={() => setMode("2D")}>2D</button>
<button onClick={() => setMode("UI")}>UI</button>
ゲーム中のメニューや設定項目で、 レイヤーを切り替える操作をここに配置すればOK。
実際の表示切り替え
const { mode } = useGameStore();
return (
<>
{mode === "3D" && <ThreeCanvas />}
{mode === "2D" && <HudCanvas />}
<DomUi />
</>
);
DomUi は常に表示しておく
DOM UI(メニュー・ボタン)は HUD や 3D Canvas の 上にある レイヤーなので、 常時表示させて問題ない。
この切り替え方式が強い理由
1. 3D Canvas が unmount されない
Zustand で mode を変えても ThreeCanvas 自体は存在を保てる(=状態が破壊されない)。
DOM の再描画 → 3D Canvas には無関係。
2. HUD(2D Canvas)の独立性が保たれる
2D Canvas は独自の requestAnimationFrame を持っているため、
3D のループと干渉しない。
そのため HUD が 常に滑らかに動く。
3. Next.js の再描画を完全に回避
React の state で管理すると再描画が伝播するが、 Zustand はコンポーネント単位で最小限に抑えられる。
→ 3D Canvas 再初期化の地獄を避けられる。
4. R3F と相性最高
R3F は内部に render-loop や scene/state を保持しているため、 “UIの再描画” に巻き込むのが最悪。
Zustand によるレイヤー制御は、 R3F と最も相性が良い方法。
結論:
Canvas の切り替えは “UIから切り離して扱う” のが正解。 再描画も初期化も最小限になり、 多段キャンバス構造が本来の力を発揮する。
6. 2D Canvas の描画ループ
2D Canvas(HUD)は、 R3F(3D)の描画ループとは完全に分離 して動かす。
理由はシンプル:
- 3D の FPS が落ちても
- HUD は “常に滑らか” に動作させる必要があるから。
最小構成の 2D Canvas ループ
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
let id;
const loop = () => {
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
// HPバー、ミニマップ、スコア、テキストなど
// drawHp(ctx);
// drawMiniMap(ctx);
// drawText(ctx, "READY!");
id = requestAnimationFrame(loop);
};
loop();
return () => cancelAnimationFrame(id);
}, []);
これを R3F と同じループにしない理由
① 3D の load に巻き込まれると HUD が停止する
- GLTF loader
- プロシージャル生成
- ライトやシャドウの処理
- 計算負荷の高い useFrame
これらが重くなると 3D がコマ落ち → HUD も巻き添え という最悪パターンになる。
HUD はゲームの「命綱」なので、 3D とは独立した生存ルートにしておく必要がある。
② HUD は「ゲームのフレームレート」に合わせる必要がない
3D → カメラ、モデル、アニメーション、ライト → 60fps が理想、重いと 30fps まで落ちる
HUD → HPバー、ミニマップ、テキスト → 30fps で十分、むしろ軽いほど良い
だから分離する。
③ R3F の useFrame に描画処理を混ぜると破綻する
たまにやらかすパターン:
useFrame(() => {
drawHUD(ctx); // ← これ最悪
});
- R3F のフレーム管理に乗ってしまう
- 3D の re-render の影響を全部食らう
- HUD が更新しないタイミングが発生する
- テキストが滲んだりチラついたりする
- z-index の前後関係が狂う
HUD は R3F に依存させてはいけない。
“2D Canvas の独立ループ” が生むメリット
3D が重くても HUD は止まらない
ゲームのミニマップや HPバーは 常に動いてほしい領域。
スマホでも安定
スマホは GPU が弱くて 3D が重い。 でも HUD が別ループだと UI が生き残る。
表示改善がしやすい
文字を太くしたり、ミニマップだけ高解像度で描いたり、 HUD だけ最適化できる。
ゲームエンジンと同じ構造
Unity / Unreal でも HUD → Canvas/UI 3D → World で完全に別ループ。
Web も同じ。
結論
HUD(2D Canvas)は 3D とは別ループで動かすべき。 これによって、 “ゲームっぽい Web UI” が初めて安定して成立する。
7. 3D Canvas の最適化(R3F)
R3F(React Three Fiber)は Three.js を React で包んだ仕組みなので、 Three.js の最適化知識がそのまま流用できる。
ただし React と組み合わさることで“独特の地雷”もあるため、 ここでは 最低限やるべき最適化項目 をまとめる。
Shadows を使いすぎない
影(シャドウ)は WebGL で最も重い処理のひとつ。 特に以下の設定は FPS を大きく削る。
- 高解像度の shadow map
- 複数 directional light
- キャラ+地面+オブジェクトで影を落とす
- ソフトシャドウ(PCF Soft)
影は 1ライト + ロー設定に抑えるのが正解。
R3F の影設定例:
<Canvas shadows>
<directionalLight castShadow shadow-mapSize={1024} />
</Canvas>
useFrame 内で「毎フレーム更新」をしない
初心者が必ずやるミス:
useFrame(() => {
mesh.position.x += 0.01;
mesh.rotation.y += 0.01;
});
これを 全オブジェクトでやると地獄。
R3F の useFrame は React の外にあるループなので、
処理を増やすと GPU も CPU も一気に重くなる。
回避策:
- ほんとうに動かしたい最小限だけ更新
- 動かしたいデータは
useRefに逃がす - なるべく R3F の animation 系ライブラリを使う(drei の useAnimations など)
useMemo / useRef で固定できるものは固定
React と同じで、 「作り直さない」ことで性能が改善する。
例:
const geometry = useMemo(() => new THREE.BoxGeometry(1,1,1), []);
const material = useRef(new THREE.MeshStandardMaterial());
これをしないと、 Canvas の再レンダリング時に 毎回新しいジオメトリ/マテリアル が生成されてしまう。
GLTFLoader の Preload は必須
モデル読み込みは最も重い処理なので、 Canvas の初期化と同時に読み始めるのが正解。
R3F/drei の場合:
useGLTF.preload("/models/character.glb");
これを書くだけで ロード時間が体感で半分以下 になる。
lain の Three.js 経験がそのまま使える理由
R3F の内部は Three.js そのままなので、
- ライトの重さ
- シャドウの解像度
- モデル数の上限
- アニメーションのコスト
- カリング(frustum)の考え方
- テクスチャのメモリ管理
これらは全部 Three.js と同じ“物理法則” で動く。
だから lain が過去に作ってきた:
- VR対応の Three.js 空間
- キャラアニメーション
- ミニゲームの 3D 描画
- モデル読み込みの最適化
これらの知識が R3F でも100%通用する。
まとめ
3D Canvas は 最適化しないと一瞬で重くなる。
そのため:
- 影は控えめに
- useFrame は必要最小限
- useMemo / useRef で資源を固定
- GLTF の preload を使う
これらを守るだけで、 Next.js + R3F の 3D 部分は安定して動くようになる。
8. 実例:FPS風 HUD + 3D モデル
多段キャンバス構造を理解するために、 もっとも分かりやすい「FPS風インターフェース」を例にする。
Web ゲーム・3Dサイト・インタラクティブUIの どれでも応用できる構造。
3D(R3F):キャラモデル・環境・ライト
最下層の 3D には、 R3F でキャラクターや武器モデルを表示する。
- カメラ
- ライト
- GLTF モデル(キャラ)
- 背景(床・空)
- アニメーション
<Canvas shadows camera={{ position: [0, 1.5, 3] }}>
<ambientLight intensity={0.5} />
<directionalLight castShadow position={[5, 5, 5]} />
<CharacterModel /> {/* GLTF */}
</Canvas>
※ ここは R3F の世界。 DOM に関係なく動き続ける。
2D Canvas(HUD):照準・HPバー・ミニマップ
3D Canvas の上に透明な 2D Canvas を重ねて、 FPS の HUD を描画する。
例:照準(クロスヘア)+ HPバー
const drawHUD = (ctx, w, h) => {
// クロスヘア
ctx.strokeStyle = "white";
ctx.beginPath();
ctx.moveTo(w/2 - 10, h/2);
ctx.lineTo(w/2 + 10, h/2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(w/2, h/2 - 10);
ctx.lineTo(w/2, h/2 + 10);
ctx.stroke();
// HPバー
ctx.fillStyle = "red";
ctx.fillRect(20, h - 40, hp * 2, 12);
};
2D の良さは 軽くて滑らか なこと。 3D が重くても影響を受けない。
DOM UI:メニュー・設定・ゲーム開始ボタン
最上層の DOM では、 React/Next.js の強みを活かせる UI を構築する。
<div className="menu">
<h2>GAME MENU</h2>
<button onClick={() => setMode("3D")}>Start</button>
<button onClick={() => setMode("UI")}>Settings</button>
</div>
- 文字
- ボタン
- ダイアログ
- 設定ウィンドウ
- トグル、スライダー
- ミッションログ
- インベントリ
“ゲーム的 UI” は全部 DOM が最適解。
3層が重なるとこうなる
[ DOM UI ] ← メニュー・設定画面・ログ
[ 2D Canvas ] ← HPバー / ミニマップ / クロスヘア
[ 3D Canvas ] ← キャラ / 武器 / 環境 / ライト
3層それぞれに役割があり、 互いに干渉しない。
- UI が再描画しても 3D は揺らがない
- 3D が重くても HUD は滑らか
- HUD が更新されても DOM のレイアウトは壊れない
これが 多段キャンバス構造の圧倒的メリット。
応用すると「Webでゲーム」が作れる
FPS風 HUD の例を基礎にすると:
- レースゲーム:速度計 / ミニマップ
- RPG:HP/MPバー、スキルスロット
- シューティング:スコア、ボム残数
- VRサイト:UIと3D空間を分離
- 3Dギャラリー:情報パネルを DOM で重ねる
- 企業サイト:モデル展示+HUD演出
全部この構造で実現可能。
まとめ
FPS風の例を使って示したが、 多段キャンバス構造はどんな分野でも通用する。
- 3D(R3F):世界
- 2D Canvas:HUD
- DOM:UI
この3つを分離することで、 「壊れにくく、再利用できる、拡張性の高い」 Next.js + 3D の設計パターンが完成する。
9. まとめ:Next.jsでゲームUIが作れる構造
今回の内容を一言でまとめると:
Next.js + R3F + 2D Canvas を“レイヤー別に分離”すると、
Web でもゲームUIが安定して動く。
これがすべて。
3D / 2D / DOM を明確に分離する
- 3D(R3F):世界・モデル・アニメーション
- 2D Canvas:HUD・照準・ミニマップ
- DOM:メニュー・ボタン・設定
役割を混ぜると必ず破綻する。
App Router はレイヤー管理に最適
layout.tsxに 3D + 2D を配置page.tsxに UI(DOM)を配置- レイヤーが自然に分かれ、再描画の呪いから解放される
R3F は必ず Client Component にする
WebGL は SSR では動かないため
"use client";
が必須。
HUD(2D Canvas)は独立ループ
3D が重くても HUD を生き残らせるために
requestAnimationFrame を独立させる。
→ ゲームUIとして必須の構造。
DOM UI は Server + Client 混在でOK
- SSR の速さ
- React の UI
- Client側のインタラクション
すべて両立できる。
レイヤー切替は Zustand が最強
- 3D を再初期化させない
- DOM の再描画を避けられる
- HUD・UI切替も安定
- App Router と相性最高
mode = "3D" | "2D" | "UI"
この発想が、安定したゲームUIの入口。
結論
Next.js でも “ゲームUIの正攻法” をそのまま実現できる。
- R3F で 3D
- 2D Canvas で HUD
- DOM で UI
- Zustand で制御
- App Router でレイヤー化
この設計を押さえれば、
「WebでゲームUIを作る」
= 現実的で安定した開発手法
になる。
💬 コメント