Next.js #06 多段キャンバス構造の設計図:2D Canvas と 3D(R3F)を重ねて扱う方法

0. この回のゴール

Next.js で「2D Canvas(UI)」と「3D Canvas(R3F)」を 切り替えたり同時に重ねて使う構造を作る。

  • WebゲームUIの一般的な構成
  • DOM / 2D / 3D の役割
  • Next.js の App Router でどう分離するか
  • R3F をどこに置くべきか
  • “負荷が低くて破綻しない” 管理方法

前回の記事:

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 defined
  • document 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を作る」
= 現実的で安定した開発手法

になる。