はじめに
redditなどで、アップされてるThree.jsのデモ動画でよく見るパラメーター変更用のインスペクタを実装してみたのでその備忘録メモです。
r/threejs
Three.js - 3D JavaScript Library Three.js is a cross-browser JavaScript library and API used to create and display animated 3D computer graphics in a web browser using WebGL
https://www.reddit.com/r/threejs/前回の記事:
[Next.js #11] FBM地形を削って“動く湖”を作る:反射・雲ドーム・水シェーダーの完全統合
FBMノイズ地形の生成、円形のくぼみで作る湖、GLSLによる水面シェーダー(FBM波・深度色・縁フェード)、雲ドーム、Reflectorによる鏡面反射、プレイヤー移動まで統合し、Next.js × React-Three-Fiber で“世界づくり”を1つの記 …
https://humanxai.info/posts/nextjs-11-r3f-fbm-lake-terrain-water-shader/
Next.js × R3F:インスペクタで世界を動かすデモ #Leva #R3F #nextjs#React #GLSL #FBM#threejs #javascript #Shader
Next.js × React-Three-Fiber のシーンにLeva(インスペクタ UI)を追加して、雲ドームの速度・色、湖の水シェーダー、ライトの向き をリアルタイムで調整できるようにしたデモです。▼ 使用技術・Next.js (App Router)・React ・React-Three-Fiber(R...
https://www.youtube.com/shorts/YtLZZTZ7Hjw1. 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 管理。
💬 コメント