[Next.js #12] R3Fに“インスペクタ”を付ける:Levaで雲・水・ライトをリアルタイム調整

はじめに

redditなどで、アップされてるThree.jsのデモ動画でよく見るパラメーター変更用のインスペクタを実装してみたのでその備忘録メモです。

前回の記事:

1. Leva を導入してインスペクタを表示する

React-Three-Fiber では、<Leva /> を Canvas の外側 に置けば、すぐに右上にパネルが表示されます。 ただし 何も設定していないと、本当に「出てないように見える」 ので注意が必要です。


1-1. Leva の基本動作(最小コード)

import { Leva } from "leva";

export default function Page() {
  return (
    <>
      <Canvas>{/* ... */}</Canvas>
      <Leva collapsed={false} />
    </>
  );
}

デフォルトでは 右上 にパネルが現れます。 ただし、ここでよく起きる罠が次。


1-2. UI が「出ない」時の最初のチェックポイント

Leva が画面に表示されない原因の 80% は “項目がゼロだから”。

Leva は 何も設定しないと完全に透明 のため、 パネルがあるのに見えない状態になります。

▼ 最小のテストコード(必ず出る)

import { useControls } from "leva";

function DebugParams() {
  useControls("Debug", {
    testValue: 123
  });
  return null;
}

これを <Canvas> の外に置く:

<Canvas>{/* ... */}</Canvas>
<DebugParams />
<Leva collapsed={false} />

→ 必ず右上に UI が表示される。


1-3. portal の意味と配置の罠

Leva のパネルは内部で “Portal(別DOMに描画)” を使っているため、 Next.js のレイアウトや overflow: hidden の影響を受けやすい。

特にあなたのように <div style={{ overflow: hidden }}> で Canvas を囲んでいる場合…

❌ NG

<div style={{ overflow: hidden }}>
  <Leva />
</div>

→ パネルがクリップされて 消えたように見える。

1-3-1. 確実に表示させるには portal を使う

<Leva portal={{ target: document.body }} collapsed={false} />

これで Leva は 最上位の body に描画されるため、 レイアウトによる隠蔽が完全に回避される。


1-4. Leva の位置をカスタマイズしたい場合

Leva は内部で CSS Modules を使うため、 style={{ position: 'fixed', right: 0 }} が 効く項目と効かない項目がある。

位置を本気で変えたい場合は CSS の上書きが必要になるが、 この記事では 罠の説明だけ にして詳しく触れなくて良い。

2. useControls で値を作る(最小の成功体験)

Leva の本体は <Leva /> ではなく、実は useControls()useControls() に値を渡すと 即座に UI が生成され、状態が React に同期される。

まずはこれが最小の“成功体験”。


2-1. useControls(“Cloud”, {…}) の構造

import { useControls } from "leva";

const { cloudSpeed, cloudColor } = useControls("Cloud", {
  cloudSpeed: { value: 0.02, min: 0, max: 0.2, step: 0.005 },
  cloudColor: { value: "rgb(255,255,255)" },
});

▼ 解説

  • "Cloud" … UI グループ名(折りたたみ可能)
  • cloudSpeed … スライダー
  • cloudColor … カラーピッカー
  • { cloudSpeed, cloudColor } が 常に最新の値として返る

これを CloudDome や Shader に渡すだけで、リアルタイム更新が完成する。


2-2. Leva が勝手に UI 部品を判断する

useControls() は渡した値に応じて自動的に UI を選ぶ。

値の型 / 設定 UIとしてどう表示される?
{ value: 0.5, min: 0, max: 1 } スライダー
{ value: true } トグル(ON/OFF)
value: "rgb(255,255,255)" カラーピッカー
"text" テキスト入力
options: {} ドロップダウン
value: [x, y, z] 3DベクトルUI

あなたが朝いじっていた “雲スピード” はスライダー、 “雲カラー” はカラーピッカーに自動変換されている。


2-3. rgb と rgba の違い(警告の原因)

あなたが遭遇したこれ:

Warning: Color format not supported: rgba(...)

⬛ 原因 → Three.js の Color() が alpha をサポートしていない。 new THREE.Color("rgba(255,255,255,0.5)") → パースできないため警告が出る。

同時に Leva 側も “rgba は扱うけど、Three の Color に渡すと壊れる”状態になる。

正しい書き方

色だけなら(透明度無し)

cloudColor: { value: "rgb(255,255,255)" },

透明度も動かしたい場合

透明度だけ別パラメータに切り出すのが正解。

const { cloudColor, cloudAlpha } = useControls("Cloud", {
  cloudColor: { value: "rgb(200,200,255)" },
  cloudAlpha: { value: 1.0, min: 0, max: 1 },
});

Shader側は:

uniform vec3 cloudColor;
uniform float cloudAlpha;

gl_FragColor = vec4(cloudColor * cloud, cloudAlpha);

2-4. “使えるUIを作る”最小構成(実例)

CloudDomeで使うならこれがベスト。

const { cloudSpeed, cloudColor } = useControls("Cloud", {
  cloudSpeed: { value: 0.02, min: 0.0, max: 0.2, step: 0.005 },
  cloudColor: { value: "rgb(255,255,255)" },
});

3. 作った値を R3F のシーンに流し込む

useControls() で UI を作っただけではシーンは変わりません。 ここでは、作った値を R3F のライト / 地形 / ShaderMaterial に流し込み、 “見た目がリアルタイムで変わる” ところまで実装します。


(1)ライトに流す:一番簡単で効果が大きい

まずはライトから。 シェーダーより簡単で、変化が一番わかりやすい。


ambientLight と directionalLight を UI で操作する

const { ambient, dirIntensity, dirX, dirY, dirZ } = useControls("Light", {
  ambient:      { value: 0.2, min: 0, max: 2 },
  dirIntensity: { value: 0.5, min: 0, max: 2 },
  dirX: { value: 5, min: -20, max: 20 },
  dirY: { value: 5, min: -20, max: 20 },
  dirZ: { value: 5, min: -20, max: 20 },
});

Canvas 内で値を反映する:

<ambientLight intensity={ambient} />
<directionalLight
  intensity={dirIntensity}
  position={[dirX, dirY, dirZ]}
/>

これで何が起きる?

  • 光量がリアルタイムで変わる
  • 太陽光の向きが直感的に変わる
  • 影の方向も変化して「体験できる」

最初にやるならコレが一番気持ちいい。


(2)Terrain のパラメータを変える

地形は FBM と PlaneGeometry を使っているので、 再生成コストが高い=ここは記事に重要な注意点がある。


地形のスケール・高さを UI 化する

const { terrainScale, heightScale } = useControls("Terrain", {
  terrainScale: { value: 0.01, min: 0.001, max: 0.05 },
  heightScale:  { value: 10,   min: 1,     max: 30 },
});

NoiseTerrain に値を渡す

<NoiseTerrain scale={terrainScale} heightScale={heightScale} />

useMemo の罠:地形が毎回再生成される(超重い)

地形生成は PlaneGeometry + FBM + 法線計算なので、 毎レンダーで計算すると 100% フリーズする。

そのため NoiseTerrain は必ずこうする:

  • 初回だけジオメトリ生成(useMemo)
  • UI が変わったら計算を再実行するにしても、  useEffect+依存配列 の形にする

例:

const geometry = useMemo(() => {
  return generateTerrain(scale, heightScale); // ← 重い処理
}, [scale, heightScale]);

この設計がないと、 読者は「UIを動かすと固まる」罠に落ちる。 (あなたも体感したはずのやつ)


(3)Shader uniforms を UI で操作する

ここが一番おいしい。 CloudDome でやった 雲の speed / color を UI から動かす。


UI 側(useControls)

const { cloudSpeed, cloudColor } = useControls("Cloud", {
  cloudSpeed: { value: 0.02, min: 0, max: 0.2, step: 0.005 },
  cloudColor: { value: "rgb(255,255,255)" },
});

CloudDome 側

export function CloudDome({ cloudSpeed, cloudColor }) {
  const mat = useRef<any>();

  useFrame((state) => {
    if (!mat.current) return;

    mat.current.time = state.clock.elapsedTime;
    mat.current.speed = cloudSpeed;
    mat.current.cloudColor = new THREE.Color(cloudColor);
  });

  return (
    <mesh renderOrder={-2}>
      <sphereGeometry args={[500, 64, 64]} />
      <cloudMaterial ref={mat} side={THREE.BackSide} transparent />
    </mesh>
  );
}

これで UI と Shader が完全同期する

  • speed → 雲の流れが速くなる
  • cloudColor → 雲の色がリアルタイムに変化

uniforms が「止まる」問題(time が更新されない原因)

あなたが実際に踏んだ、この記事の一番重要な罠。

NG パターン

const uniforms = {
  time: { value: 0 }
};

これをレンダー内で書くと 毎回新しいオブジェクトに置き換わるため:

  • ShaderMaterial が uniforms の参照を失い
  • time が更新されなくなる → 水の揺れが止まる/雲が固まる

uniforms を useRef / useMemo で固定する

const uniforms = useMemo(() => ({
  time: { value: 0 },
}), []);

または:

const uniforms = useRef({
  time: { value: 0 }
}).current;

この形にすると、ShaderMaterial が 同じ uniforms を参照し続ける。


このセクションのゴール

  • UI でライトが変わる
  • UI で地形が変わる(重さも理解できる)
  • UI で Shader のパラメータが動く
  • uniforms が止まる問題の正体を理解する

4. “動かない / 止まる / 出ない” 系の罠をまとめて潰す

Leva × R3F × Shader を組み合わせると、 初心者だけでなく 中級者でも必ず詰むポイント がいくつかあります。

ここでは、この記事で触れた内容の中から 特に多いトラブルを “症状” から逆引きできる形で整理します。


4-1. Leva が表示されない理由ベスト3

原因1:表示項目がゼロ(最も多い)

Leva は パネルが空だと完全に透明になる。

症状:

  • <Leva /> を置いても何も見えない

解決:

useControls("Debug", { testValue: 1 });

とりあえず最低1項目作る。


原因2:親要素が overflow: hidden でパネルがクリップされる

Leva のパネルは DOM の右上に描画されるが、 Canvas を囲んでいる div が overflow すると 消えたように見える。

解決:

<Leva portal={{ target: document.body }} />

=body に直接描画されるため確実に表示される。


原因3:Canvas の中に Leva を入れてしまう

Leva は React DOM 用。 Canvas(WebGL レイヤー)の中に置くと描画されない。

解決:Canvas の外に置くこと。


4-2. uniforms が止まる理由(time が更新されない)

原因:uniforms オブジェクトを毎レンダーで作っている

const uniforms = { time: { value: 0 } };  // ← NG

これがレンダーのたびに新しく生成されると、 ShaderMaterial が 参照を失って 更新されなくなる。

症状:

  • 水の揺れが止まる
  • 雲が動かない
  • “UIを動かすと急に固まる”

正しいパターン:uniforms を固定する(useMemo or useRef)

const uniforms = useMemo(() => ({
  time: { value: 0 }
}), []);

または:

const uniforms = useRef({
  time: { value: 0 }
}).current;

この形にすることで ShaderMaterial が 常に同じ uniform オブジェクトを参照し続ける。


4-3. ShaderMaterial の useRef と useMemo の正しい使い方

まとめると:

役割 正しい保持先 間違い
ShaderMaterial のインスタンス useRef state / 普通の変数
uniforms のオブジェクト useMemo or useRef レンダー内で生成
time の更新 useFrame レンダー外 or 依存関係なし

正しい例

const mat = useRef<any>();

useFrame((state) => {
  if (!mat.current) return;
  mat.current.uniforms.time.value = state.clock.elapsedTime;
});

4-4. UI 更新 → シェーダーが壊れる時のチェックリスト

Leva の値を動かした瞬間に 「Shader が白くなる / 動かない / エラー出る」 というトラブルがよく起きる。

原因を “5秒で特定” できるチェックリストにした:


1. uniforms を useMemo で固定しているか?

→ 固定されていないと 参照が上書きされて動かなくなる。


2. カラーが rgba になっていないか?

Three.Color は rgba() を受け付けない。

症状:

  • 警告
  • シェーダー側で色が正しく入らない

解決:rgb のみ

cloudColor: { value: "rgb(255,255,255)" }

3. mat.current が undefined になっていないか?

UI 更新でシェーダーが再作成されるパターンがある。

対策: ref は最上層の <shaderMaterial> に確実に付ける。


4. Shader の uniform 名が完全一致しているか?

R3F は コンパイルに成功しても silent fail することがよくある。

例:

uniform float speed;
mat.current.speed = cloudSpeed;  // ← NG (uniform名が違う)

5. useFrame 内の更新が time 以外の state に依存していないか?

useFrame は 状態を読むと再レンダーでズレる。

理想形:

useFrame((state) => {
  mat.current.time = state.clock.elapsedTime;
});

このセクションのゴール

読者が以下を理解し、二度と同じ罠に落ちない:

  • なぜ UI が出ないのか
  • なぜ Shader が止まるのか
  • uniforms がどう破壊されるのか
  • R3F でシェーダーを安全に動かす方法

あなたが朝2時間かけて踏み抜いた内容が ここで すべて体系化されて理解できる。

5. まとめ:世界は“編集可能”になると一気に伸びる

今回の Next.js #12 – Leva UI インスペクタ実装 で得たものは、 単なる「UIが付いた」ではありません。

あなたが作ってきた 世界(Terrain / Cloud / Water / Light) が “リアルタイムに編集できる道具” に進化した。

これはゲームエンジン(Unity / Unreal)の本質と全く同じで、 「実装 → 再起動 → 調整」の地獄ループから解放される大きな転換点です。


この回でできるようになったこと(総まとめ)

1. Leva パネルを確実に表示できる

  • 表示項目ゼロ問題
  • overflow hidden 問題
  • Canvas 内配置問題 → 100% もう詰まらない。

2. useControls で UI を生やし、即座に値を取得できる

  • グループ化
  • スライダー / カラーピッカー
  • rgba の罠 → カラーピッカーの扱いを完全に理解。

3. ライト・地形・シェーダーに UI 値を流し込める

  • Light intensity & position
  • Terrain FBM スケール
  • Shader uniforms(speed / color / time) → 世界の見た目をその場で変えられる。

4. “動かない / 止まる / 出ない” すべての罠を回避

  • uniforms 再生成の地雷
  • ShaderMaterial と useRef の関係
  • Leva と DOM の衝突 → 挙動不審の原因を1発で切り分けられる。

今回到達した地点は「ワールドエディタの入口」

あなたがずっと Unity でやってきた

Inspector でパラメータを変更 → 見た目が即変わる

を Next.js + R3F でも実現した。

ここからできることは一気に広がる:

  • プレイヤー速度や当たり判定サイズを UI 管理
  • Shader の色・波・透明度を UI スライダー化
  • 地形プリセット(山岳 / 平原 / 島)
  • 雲・光・水の “天候システム”
  • UI でパラメータを JSON 保存 → 読み込み → 自作エディタ化

あなたが毎朝・徹夜で積み上げてる世界は もう「手書きコードの実験」じゃない。

すでに ツールとして成立し始めている。


次にすすめる方向

① UI パラメータの JSON プリセット保存 / 読み込み

→ これで “ワールドエディタ” が完成。

② モデルビューアにキャラ切替ボタンを追加

→ glTF / PMX の差し替え UI。

③ R3F 用のミニエディタを 1 ページ丸ごと作る

→ Terrain / Cloud / Water / Light を全部 UI 管理。