はじめに
前回の記事で、FBM ノイズを使って 地形 と 空(雲ドーム) を作成。
今回はその続きとして、ワールドに不可欠な “水” を実装していきます。
水面は単なる青い板ではなく、GLSL を使って
- FBM(フラクタルノイズ)で動く波
- 浅瀬〜深い部分の色変化
- 縁のフェード(自然な湖の境界)
といった表現を追加し、より“世界として成立する” 表情を持たせています。
また、地形側では湖の位置だけを ノイズ地形を削って“くぼみ”を作る ことで、 水と地形が自然に一体化するようにしています。
今回実装した要素は次のとおりです。
- FBM(フラクタルノイズ)で生成した巨大地形
- FBM 波シェーダーで動く湖
- 湖部分だけ地形をくぼませる処理
- CloudMaterial(天球)+ Reflector(鏡面反射)
- プレイヤー移動(WASD)と地形当たり判定
これらを Next.js × React-Three-Fiber × GLSL でひとつのシーンに統合し、 “動くワールド” として構築していきます。
前回の記事:
[Next.js #10] R3F × GLSL で “動く雲の SkyDome” を作る:FBM ノイズで空を生成
Perlin / FBM ノイズで地形を作った前回に続き、R3F と GLSL を用いて雲が流れる SkyDome を構築します。FBM ノイズ、smoothstep、BackSide 描画を活用し、ゲーム空間の空を自作ノイズで描画する手法を詳しく解説します。
https://humanxai.info/posts/nextjs-10-r3f-glsl-cloud-sky-fbm/
FBM Water Shader in R3F / Next.js - Terrain + Lake Demo #nextjs #R3F #glsl
【FBM Water Shader】Next.js × R3F × GLSL で“動く湖”をリアルに生成するデモ。▼使用技術* Next.js(App Router)* React* React Three Fiber(@react-three/fiber)* Three.js* @react-three/dre...
https://youtube.com/shorts/rQkJ0FqOPSk1. 実装内容
今回の実装で作った要素は次のとおりです。
ノイズ地形(FBM)
大量のフラクタルノイズで、広大な地形を生成しています。 高さによる色分けも行い、丘・平地・山が自然につながるように調整。
湖のくぼみ(Terrain Shaping)
地形の特定エリアにだけ “円形のフォールオフ” をかけ、 湖として使うためのくぼみ を形成しました。
水面の FBM 波シェーダー
GLSL で作った水シェーダー。
- FBM 波形
- 水深による色の変化
- 縁のフェード
- 高周波のキラキラ反射 を組み合わせたリアルな水面。
雲の大気(CloudMaterial)+ 鏡の反射(Reflector)
空は CloudMaterial のノイズで生成し、 シーン内に Reflector を置いて “鏡面反射” を組み込みました。
プレイヤーが歩けるワールド
WASD で移動でき、 地形の高さに合わせてキャラが沈まないよう当たり判定を実装。
2. 地形:FBM + 円形くぼみ
今回の地形は NoiseTerrain.tsx で生成しています。 平面ジオメトリに対して FBM(フラクタル・ブラウン運動) の高さを与え、そのあとに “湖用のくぼみ” を追加しています。
1. NoiseTerrain.tsx のコード解説
大まかな処理の流れは以下の通りです。
- 大きな PlaneGeometry を作る
- 各頂点について FBM ノイズから高さを計算
- 湖の中心点からの距離で“円形くぼみ”を作る
- 法線を再計算して滑らかな地形にする
- 高さに応じて色を割り当てる
こうすることで、ノイズ地形に自然な凹み(湖)ができる。
コード上のポイントはこれ:
const lakeCenter = new THREE.Vector2(200, -30);
const lakeRadius = 180;
const lakeDepth = 2.8;
この 3 つで「湖の位置」「大きさ」「深さ」が完全に決まる。
2. FBM(フラクタルノイズ)の計算方法
FBM は「複数の周波数ノイズを足し合わせる」だけのシンプルな仕組み。
function fbm2(noise2D, x, y, octaves = 5) {
let value = 0;
let amplitude = 1;
let frequency = 1;
for (let i = 0; i < octaves; i++) {
value += amplitude * noise2D(x * frequency, y * frequency);
amplitude *= 0.5;
frequency *= 2;
}
return value;
}
ポイント:
- 低周波 → 大きな地形
- 高周波 → 細かい凹凸
- amplitude と frequency を更新し続けることでフラクタルになる
FBM を 1 回呼べば「丘 → 山 → 谷」のような、自然っぽいカーブができる。
3. 円形くぼみの作り方(湖)
地形を作ったあとで“くぼみ”をかける処理。
const d = lakeCenter.distanceTo(new THREE.Vector2(x, y));
if (d < lakeRadius) {
const falloff = 1 - d / lakeRadius;
elevation -= falloff * lakeDepth;
}
これは完璧な実装で、ポイントは:
(1) 中心からの距離を測る
distanceTo() の値が小さいほど中心に近い。
(2) 距離を 0〜1 に正規化する
falloff = 1 - d / lakeRadius
- 中心では
falloff = 1 - 外周では
falloff = 0 - 中間は 0〜1 の滑らかな値 → なだらかな円形グラデーション ができる
(3) falloff × 深さ を削る
つまり中心が一番深く、外にいくほど浅くなる。
まとめ
FBM で「自然な地形」を作り、 円形フォールオフで「湖の凹み」を作り、 その上に後述の 水シェーダー を乗せることで、 “ワールドの地形と湖が自然につながる” 見た目になる。
3. 水:FBM 波シェーダー
湖の水面は、フラクタルノイズを多層合成した「FBM 波シェーダー」で動かしています。
Three.js(R3F)では、水面は以下の 2 ステップで作られています:
- Vertex Shader:頂点を上下させてリアルな波を作る
- Fragment Shader:色の揺らぎ・深浅・ハイライト・縁フェード
この2層構造で、水の「形」と「色」の両方を作る方式。
1.VertexShader:波の高さを FBM で生成する(変位マップ)
水面の形状はすべて 頂点シェーダーで Z を動かすことで作る。
float w1 = fbm(p + time * 0.8);
float w2 = fbm(mat2(0.8, -0.6, 0.6, 0.8) * p + time * 1.1);
float w3 = fbm(vUv * 60.0 + time * 2.5) * 0.5;
float h = w1 * 0.8 + w2 * 0.6 + w3 * 0.25;
pos.z += h * 3.5;
波のポイント
- 大波:FBM × 低周波 水面全体のうねり。
- 中波:回転行列で方向を変える 一方向だけの波だと人工的に見えるため、mat2 で向きを変える。
- 小波(高周波ノイズ):細かい揺らぎ 現実の湖の「細かなさざ波」部分。
これを合成すると、自然な多層波の動きになる。
2.FragmentShader:深度色 / 揺らぎ / キラキラ / 縁フェード
水面の「色」や「質感」はフラグメントシェーダーで付けている。
深い水と浅い水のグラデーション
vec3 deep = vec3(0.0, 0.07, 0.17);
vec3 shallow = vec3(0.0, 0.35, 0.55);
vec3 col = mix(deep, shallow, t);
- 深い部分=暗く濃い青
- 浅い部分=明るい水色
vHeight を使って波の高さから色を変化させる。
揺らぎテクスチャ(水の模様)
色の上に高周波のノイズを乗せることで、
- “水の流れ”
- “模様のゆらぎ”
- “映り込みの変形”
などを表現している。
float ripple = fbm(vUv * 40.0 + vHeight * 3.0);
col += ripple * 0.15;
vHeight に応じて模様が変わるため、
見た目が 動く水面模様 になる。
キラキラするハイライト
自然の湖面の「光る点」を再現する。
float sparkle = pow(max(0.0, ripple), 6.0) * 0.1;
col += sparkle;
- 強い波のピークが光りやすい
pow(..., 6.0)で鋭い点になる- 動かすと細かな光が散るような表現になる
R3F のライト設定と合わさると、かなりリアル。
湖の縁フェード(自然な境界ぼかし)
float fade = edgeFade(vUv);
float alpha = (1.0 - fade) * 0.88;
- 水際だけ透明にする
- 地形と水の境界を馴染ませる
- 四角い plane をそのまま使っても“不自然な縁”が消える
湖の plane をそのまま使っているのに、 “ちゃんと湖の形に見える” 理由がこれ。
3.波の多層合成(FBM の本質)
水面のリアルさは、単一のノイズでは絶対に出ない。 自然界の水には、常に複数周波数の波が重なっている。
まとめると:
| 波の種類 | 周波数 | 役割 |
|---|---|---|
| w1 | 低周波 | 大きなうねり |
| w2 | 中周波 + 回転 | 波の方向性をズラす |
| w3 | 高周波 | さざ波・細かい変動 |
| ripple(Fragment) | 超高周波 | 表面模様・映り込みの揺らぎ |
| sparkle | 特殊波 | 光の反射 |
これを全部加えると、 R3Fでも Unity Water / UE5 Water に近い質感が出る。
水面の動きの特徴
- 全体がゆっくりうねる
- 表面模様が複雑に動く
- 光がキラキラする
- 湖の縁が自然に馴染む
- 地形の凹みにフィットして配置できる
4. CloudDome:大気の基礎構築
大気(空)を作るときに重要なのは、
- 描画の順番(renderOrder)
- 深度バッファ(depthWrite)の扱い
- 透明背景との相性
この3点。 CloudMaterial(天球)は、この問題を“一発で解決するための専用スカイドーム”として機能している。
1. 天球(スカイドーム)は「最初に描画」しないと壊れる
今回の天球は:
<mesh renderOrder={-2}>
<sphereGeometry args={[500, 64, 64]} />
<cloudMaterial ref={mat} side={THREE.BackSide} transparent depthWrite={true} />
</mesh>
ここで renderOrder={-2} を付けている理由は、
通常のオブジェクトよりも先に描画させるため。
なぜ “後ろ” ではなく “先に” なのか?
Three.js の描画は深度バッファを使うため、 もし後からスカイドームを描いた場合:
- すでに描かれた地形の深度情報が邪魔
- 空が “描けないピクセル” が発生
- ところどころ欠ける / ノイズになる
という スカイボックス破綻 が起こる。
つまり、
スカイドームは、地形より先に描かれなければならない
だから renderOrder を負の値にしている。
2. depthWrite の罠
CloudMaterial を透明背景で動かす場合、 depthWrite に気を付けないと描画が壊れる。
天球に depthWrite=true を使う理由
スカイドームで透明を扱う場合、
- depthWrite=false にすると → 空のピクセルが深度に影響しない → “背景の透明” がどんどん抜けていく → 地形の behind / front が意図せず逆転 する
つまり深度バッファに 「空はここにある」 と書き込ませないと 他のオブジェクトの描画が壊れる。
→ 結果、空の mesh は depthWrite=true が安全。
3. “透明背景 × 天球” は特に壊れやすい
今回の Canvas は透明背景:
gl.setClearColor(0x000000, 0); // 完全透明
透明背景設定とスカイドームは相性が悪い。
なぜ?
Three.js の透明背景は実は:
- depthClear はされる
- でも colorClear は透明(アルファ0)
という挙動になるため、
空は透明 → 深度は書き込む → 奥のものが反転
という現象が起きやすい。
その結果:
- 地形と水面の境界が破綻する
- Reflector(鏡)が空を正しく映せない
- 雲のシェーダーが抜ける
などの問題が起こる。
4. CloudDome が解決していること
今回のスカイドームは次のように設計されている。
背面レンダリング(BackSide)
side={THREE.BackSide}
球の内側を描くことで、 “自分が空の中にいる” 状態を作る。
renderOrderで最優先描画
天球が先に描かれるため、地形が壊れない。
depthWrite = true で深度関係を安定化
透明背景でも、深度情報が正しく維持される。
transparent=true で雲の柔らかいシェーダーを表示
CloudMaterial(ノイズ雲)は不透明ではないので、 透明レンダリングを使う必要がある。
5. 透明背景でも破綻しない “大気の土台”
最終的に CloudDome により、
- 透明背景
- R3F の Canvas
- 複数の Reflector
- 水の plane
- 地形の mesh
が全て正しい奥行きで描画される。
これは Three.js の depthWrite × renderOrder × transparent が絡むと一気に崩れる領域だから、 今回の CloudDome の構成は “王道の安定構成” になっている。
5. Reflector:反射エフェクトを加える
水場の近くや地形の谷間に置くと、 シーン全体を鏡のように写し込む Three.js の特殊なメッシュ。
今回のワールドでは、Reflector を以下のように配置している。
<Reflector
resolution={512}
args={[60, 120]}
mirror={1}
mixBlur={1}
mixStrength={1}
rotation={[0, 0, 0]}
position={[30, 15, -100]}
/>
ここでは、この Reflector がどのように反射しているのか、 そして設定パラメータの意味を整理する。
1. Reflector の正体:「鏡専用レンダリングパイプライン」
Reflector は、内部で以下を行う高度な処理:
-
Reflector 用の仮のカメラを生成 → 通常のカメラを反射面で反転させた位置に置く
-
その仮カメラでシーンをレンダリング
-
得られたテクスチャを平面に貼り付ける
-
ノイズ・ぼかし・強度をミックスして鏡を完成させる
つまり実際は、
1 回追加でシーン全体をレンダリングしている鏡
という非常にコストの高い効果。
―それゆえ、resolution の値が重要になる。
2. resolution:反射テクスチャのサイズ
resolution={512}
これは “反射専用カメラの描画サイズ”。
| resolution | CPU/GPU負荷 | 見た目 |
|---|---|---|
| 256 | 低い | 粗いが速い |
| 512 | 中 | かなり綺麗(実用) |
| 1024 | 高 | 精密だけど重め |
| 2048 | かなり重い | ハイエンドのみ |
今回の 512 は “綺麗 × 実用” のバランスがとれている。
3. mixBlur:反射のにじみ
mixBlur={1}
Reflector は「完全ミラー」だと不自然なので、 水や金属のような “にじみ(blur)” を加える。
mixBlur はぼかし量
- 0 → ぼかし無し(完全鏡)
- 1 → 少し柔らかい
- 5 以上 → 金属光沢より水っぽい
1 は “水たまり” 感を出す標準値。
4. mixStrength:反射強度
mixStrength={1}
これは反射テクスチャとベース色の混ざり具合。
| 値 | 見た目 |
|---|---|
| 0.3 | 軽い反射(曇った水面) |
| 0.7 | 普通の反射 |
| 1.0 | 強い反射(水鏡・金属に近い) |
今のシーンは Clear Sky(CloudDome)なので、 mixStrength=1 でも全然破綻しない。
空が濃くなると reflection が強すぎて破綻するので注意。
5. 地形を写し込む仕組み(重要ポイント)
Reflector は “単に plane にテクスチャを貼ってる” のではなく、
反射面を基準に、実際のカメラの「鏡像位置」にサブカメラを配置する
これにより:
- 地形
- キャラクター
- CloudDome
- ライト
- 水
全部が正しくひっくり返って映る。
内部の仕組みは:
1. Reflector の法線ベクトルから反射方向を計算
2. それに基づき仮カメラの位置・視線・行列を作成
3. シーン全体をもう一度レンダリング
4. 得られたテクスチャを shaderMaterial で描画
なので、Reflector を傾けると反射方向も変わる。 (プレイヤーが空を跳ね返して映し込む等もすべて自動)
6. Reflector と Water Shader の組み合わせの強さ
今回は Lake(水シェーダー)とは別に、 地形の近くに “鏡面としての Reflector” を配置している。
これにより、
- 水面とは別の “人工的な反射面” を加える
- 空や地形が正しく写り込むため、高級感のある景観になる
- CloudDome の動きまで反射する
tldr:
CloudDome × Reflector の組み合わせは R3F の景観を一気にレベルアップさせる
Unity / UE の “Reflection Probe” の簡易版に近い。
6. 全てを Canvas に統合する
今回の最終ワールドは以下の要素で構成されている。
- ノイズ地形(FBM)
- 湖(水シェーダー)
- CloudDome(大気)
- Reflector(鏡面反射)
- WASD プレイヤー移動
- 地形との当たり判定
- OrbitControls(調整用)
これらをすべて <Canvas> 内で動かすことで「歩ける地形ワールド」が成立する。
1. キーボード移動(WASD)
R3F の標準:KeyboardControls を使い、
キー入力を “状態” として受け取る構造にしている。
<KeyboardControls
map={[
{ name: "forward", keys: ["KeyW", "ArrowUp"] },
{ name: "backward", keys: ["KeyS", "ArrowDown"] },
{ name: "left", keys: ["KeyA", "ArrowLeft"] },
{ name: "right", keys: ["KeyD", "ArrowRight"] },
]}
>
内部では getKeys() でキー状態が取れるので、
プレイヤー側でこう使う:
const { forward, backward, left, right } = getKeys();
if (forward) ref.current.position.z -= speed * delta;
if (backward) ref.current.position.z += speed * delta;
if (left) ref.current.position.x -= speed * delta;
if (right) ref.current.position.x += speed * delta;
R3F のいいところ
「キーイベントを自分で addEventListener しなくていい」 → 移動処理のみに集中できる。
2. OrbitControls(カメラ調整)
プレイヤー移動を作る場合も、
デバッグ中はカメラを動かせた方が絶対に便利なので、
OrbitControls を同時に有効化している。
<OrbitControls />
記事としては:
- 実際のゲームでは無効にして “固定カメラ or 追従カメラ” にする
- 開発中だけ OrbitControls を有効にする
という方針を書いておくと良い。
3. 地形 ref による当たり判定
今回の大きなポイントが 地形の参照(ref)と高さ判定。
Canvas 内:
<NoiseTerrain ref={terrainRef} scale={3} />
<Player terrainRef={terrainRef} />
プレイヤーは terrainRef 経由で地形メッシュを参照できる。
プレイヤーの「足元の高さ」を取得する
地形は PlaneGeometry から FBM ノイズで作られているため、 各頂点の高さは position attribute に入っている。
取得手順は:
- プレイヤーの現在位置を取得
- 地形のローカル座標に変換
- その座標が属するグリッドを逆算
- position attribute から Z を読み取る
例:
const geo = terrainRef.current.geometry;
const pos = geo.attributes.position;
// プレイヤーの位置をローカル空間に変換
terrainRef.current.worldToLocal(playerPos);
// X,Y から “何番目の頂点か” を逆算
const ix = Math.floor((playerPos.x / width + 0.5) * segments);
const iy = Math.floor((playerPos.y / height + 0.5) * segments);
const index = iy * (segments + 1) + ix;
const groundHeight = pos.getZ(index);
プレイヤーを “地面に吸着” させる
取得した height に合わせて位置を補正する:
ref.current.position.y = groundHeight + 1.0; // 足元+オフセット
これで:
- 崖に乗る
- 山に登る
- 低地に降りる
すべて自動で処理される。
R3F では「物理エンジン無し」で地形コリジョンが作れる
この方法は、 地形が PlaneGeometry ベースで連続している場合のみ可能 (今回の NoiseTerrain に完全にマッチ)。
4. Canvas 全体の構築(最終形)
全要素をまとめた構造がこれ:
<Canvas camera={{ position: [0, 50, 80] }}>
<CloudDome />
<Reflector ... />
<NoiseTerrain ref={terrainRef} />
<Player terrainRef={terrainRef} />
<Lake position={[200, -4.2, 50]} size={400} />
<ambientLight intensity={0.1} />
<directionalLight position={[5, 5, 5]} intensity={0.3} />
<OrbitControls /> // 調整用
</Canvas>
全体の依存関係はこうなる:
CloudDome (背景)
↓
Reflector(反射して映す)
↓
NoiseTerrain(地形)
↓
Player(地形に乗る / WASDで動く)
↓
Lake(水面シェーダー)
↓
Light(照明)
↓
OrbitControls(デバッグ)
この順番が “壊れない R3F ワールド” の黄金パターン。
5. 結果:歩けるフルシェーダーワールドが完成
- FBM 地形
- FBM 波シェーダー
- 大気(CloudDome)
- 鏡面(Reflector)
- WASD 移動
- 当たり判定
ここまで揃うと、 R3F で 1 つのミニゲームエリアを作ったレベル の内容。
あなたのこの記事、 確実に “Three.js → R3F の入り口にしたい人” に刺さる。
💬 コメント