[Next.js #11] FBM地形を削って“動く湖”を作る:反射・雲ドーム・水シェーダーの完全統合

はじめに

前回の記事で、FBM ノイズを使って 地形 と 空(雲ドーム) を作成。

今回はその続きとして、ワールドに不可欠な “水” を実装していきます。

水面は単なる青い板ではなく、GLSL を使って

  • FBM(フラクタルノイズ)で動く波
  • 浅瀬〜深い部分の色変化
  • 縁のフェード(自然な湖の境界)

といった表現を追加し、より“世界として成立する” 表情を持たせています。

また、地形側では湖の位置だけを ノイズ地形を削って“くぼみ”を作る ことで、 水と地形が自然に一体化するようにしています。

今回実装した要素は次のとおりです。

  • FBM(フラクタルノイズ)で生成した巨大地形
  • FBM 波シェーダーで動く湖
  • 湖部分だけ地形をくぼませる処理
  • CloudMaterial(天球)+ Reflector(鏡面反射)
  • プレイヤー移動(WASD)と地形当たり判定

これらを Next.js × React-Three-Fiber × GLSL でひとつのシーンに統合し、 “動くワールド” として構築していきます。

前回の記事:


1. 実装内容

今回の実装で作った要素は次のとおりです。

ノイズ地形(FBM)

大量のフラクタルノイズで、広大な地形を生成しています。 高さによる色分けも行い、丘・平地・山が自然につながるように調整。

湖のくぼみ(Terrain Shaping)

地形の特定エリアにだけ “円形のフォールオフ” をかけ、 湖として使うためのくぼみ を形成しました。

水面の FBM 波シェーダー

GLSL で作った水シェーダー。

  • FBM 波形
  • 水深による色の変化
  • 縁のフェード
  • 高周波のキラキラ反射 を組み合わせたリアルな水面。

雲の大気(CloudMaterial)+ 鏡の反射(Reflector)

空は CloudMaterial のノイズで生成し、 シーン内に Reflector を置いて “鏡面反射” を組み込みました。

プレイヤーが歩けるワールド

WASD で移動でき、 地形の高さに合わせてキャラが沈まないよう当たり判定を実装。

2. 地形:FBM + 円形くぼみ

今回の地形は NoiseTerrain.tsx で生成しています。 平面ジオメトリに対して FBM(フラクタル・ブラウン運動) の高さを与え、そのあとに “湖用のくぼみ” を追加しています。


1. NoiseTerrain.tsx のコード解説

大まかな処理の流れは以下の通りです。

  1. 大きな PlaneGeometry を作る
  2. 各頂点について FBM ノイズから高さを計算
  3. 湖の中心点からの距離で“円形くぼみ”を作る
  4. 法線を再計算して滑らかな地形にする
  5. 高さに応じて色を割り当てる

こうすることで、ノイズ地形に自然な凹み(湖)ができる。

コード上のポイントはこれ:

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 ステップで作られています:

  1. Vertex Shader:頂点を上下させてリアルな波を作る
  2. 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 は、内部で以下を行う高度な処理:

  1. Reflector 用の仮のカメラを生成 → 通常のカメラを反射面で反転させた位置に置く

  2. その仮カメラでシーンをレンダリング

  3. 得られたテクスチャを平面に貼り付ける

  4. ノイズ・ぼかし・強度をミックスして鏡を完成させる

つまり実際は、

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 に入っている。

取得手順は:

  1. プレイヤーの現在位置を取得
  2. 地形のローカル座標に変換
  3. その座標が属するグリッドを逆算
  4. 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 の入り口にしたい人” に刺さる。