[Noise 入門 #11] Procedural Water — ノイズで海面を波立たせる(Gerstner Wave vs Noise)

1. 「水」を表現する2つのアプローチ

静止した地形(Terrain)と異なり、水面は常に動いています。しかし、単に頂点を上下に動かすだけでは「水らしさ」は生まれません。 手続き型(Procedural)で海を作る際、避けて通れない2つの主要なアプローチ、「Noise-based」と「Gerstner Waves」について、その仕組みと決定的な違いを整理します。

A. Noise-based Water(ノイズベース:地形の応用)

これは、第1集で学んだ「地形生成」の応用です。Perlin Noise や Simplex Noise を使い、時間経過(uTime)とともに頂点を上下(Y軸方向)にのみ動かす手法です。

  • 仕組み: 頂点のXZ座標をノイズ関数に入力し、その戻り値をY座標(高さ)に加算します。

$$P.y += \text{noise}(P.xz * \text{frequency} + \text{time}) * \text{amplitude}$$

  • 視覚的特徴: 波の形が「正弦波(サインカーブ)」に近くなります。つまり、山(頂点)と谷(底)が同じような丸みを帯びます。

  • メリット:

  • 実装が極めて簡単(数行のシェーダーコードで済む)。

  • 計算負荷が軽い。

  • 遠景の海や、穏やかな湖、プールのような水面には十分。

  • デメリット:

  • 「水特有の鋭さ」が出ない。波の頂上が丸くなるため、荒れた海を作ろうとすると「青いゼリー」や「ゴムシート」が揺れているように見えてしまいます。

// GLSLイメージ: 単純な正弦波のような波になる
float height = cnoise(uv * 4.0 + uTime) * 0.5;
vec3 pos = position + vec3(0.0, height, 0.0);

B. Gerstner Waves(ゲルストナー波:物理的アプローチ)

こちらは流体力学に基づいたアプローチです。実際の波は、水分子がその場で円運動(トロコイド運動)をすることで発生します。これを模倣し、頂点を上下だけでなく「水平方向(XZ)」にも移動させるのが最大の特徴です。

  • 仕組み: 波が高い場所(頂点)に向かって、周囲の頂点を「ギュッと寄せる」動きを加えます。

  • Y軸(高さ): Sine波で動かす。

  • XZ軸(水平): Cosine波を使って、波の進行方向へ頂点をずらす。

  • 視覚的特徴: 頂点が水平方向に移動して密集することで、波の頂上が鋭く尖り(Crest)、谷が平たく広がります(Trough)。これは実際の海面で見られる形状そのものです。

  • メリット:

  • 圧倒的にリアル。「チャプチャプ」した波ではなく、「ザッパーン」という鋭い波が作れる。

  • 複数の波(波長や向きが違うもの)を重ね合わせることで、嵐の海から凪まで表現自在。

  • デメリット:

  • 計算コストが少し高い(三角関数 sin/cos を多用するため)。

  • パラメータ調整が難しい(振幅を大きくしすぎると、頂点が自己交差し、ポリゴンが裏返る「ループ現象」が起きる)。

// GLSLイメージ: 頂点が波の進行方向(D)へずれる
float theta = dot(D, position.xz) * k + uTime * c;
pos.x += cos(theta) * amplitude; // 水平移動!これが鋭さを生む
pos.y += sin(theta) * amplitude; // 上下移動

まとめ:どちらを使うべきか?

特徴 Noise-based Water Gerstner Waves
動きの次元 1次元(Y軸のみ) 3次元(XYZすべて)
波の形状 丸い、正弦波的、穏やか 鋭い(尖る)、トロコイド波的
質感 ゼリー、布、地形っぽい 液体、荒波、物理的
主な用途 遠景、湖、プール、魔法の液体 近景の海、嵐、サーフィンの波

結論: リアルな海を作るなら、ベースの形状には Gerstner Wave を使い、その表面の細かい「さざ波(detail)」として Noise (FBM) を合成する。これが現代のゲームグラフィックスにおける「水」の最適解です。

2. Gerstner Wave の数学的構造

「波」と聞くと、多くの人は高校数学で習った のグラフを思い浮かべます。しかし、現実の海面で起こっているのは、単なる上下運動ではありません。

水粒子はその場で円運動(回転)をしています。 この「回転する粒子の動き」を頂点の座標変換として記述したのが、19世紀にフランツ・ゲルストナーが発見した Gerstner Wave です。

数式を解読する

提示した数式は一見複雑に見えますが、GLSLで実装するために必要な要素は以下の2つだけです。

$$\begin{cases} x’ = x + \sum \mathbf{D}.x \times \cos(\theta) \ y’ = y + \sum \sin(\theta) \ z’ = z + \sum \mathbf{D}.z \times \cos(\theta) \end{cases}$$

ここで、$\theta = \text{dot}(\mathbf{D}, \text{position}.xz) \times \text{frequency} + \text{time} \times \text{speed}$ です。

1. 垂直移動 (Sine):波の高さを決める

$$P.y += A \sin(\theta)$$

これは単純な上下動です。

  • (Amplitude): 波の高さ。
  • : 時間経過と共に -1 〜 +1 を繰り返すリズム。
  • これだけだと、波は丸い丘(正弦波)のままです。

2. 水平移動 (Cosine):波を「尖らせる」マジック

$$P.xz += \mathbf{D} \cos(\theta)$$

ここが Gerstner Wave の魂です。 通常、グリッドの頂点 は固定されていますが、Gerstner Wave では頂点自体を波の進行方向 に向かってずらします。

  • なぜ Cosine なのか? Sine(高さ)が になるとき、Cosine(横ずれ)は最大になります。円運動を想像してください。一番高いところに行く手前で、ぐっと横に動くイメージです。
  • なぜ「尖る」のか? 波の頂上(Crest)付近では、頂点が波の進行方向とは逆側に引き寄せられ、頂点同士が密集します。 逆に、波の谷(Trough)では頂点が拡散します。 この「密度の偏り」によって、ポリゴンがギュッと詰まり、鋭いエッジ(尖った波頭)が形成されるのです。

GLSLでの実装イメージ

この数学的構造を、実際にShader(GLSL)で記述すると以下のようになります。

vec3 gerstnerWave(
    vec3 position,
    vec2 direction, // 波の進行方向 (例: vec2(1.0, 0.5))
    float steepness, // 波の鋭さ (0.0 〜 1.0)
    float wavelength // 波長
) {
    // 波数 k = 2π / λ
    float k = 2.0 * 3.14159 / wavelength;
    // 速度 c = sqrt(g / k)
    float c = sqrt(9.8 / k);

    vec2 d = normalize(direction);

    // 波の進行状態 (位相)
    float f = k * (dot(d, position.xz) - c * uTime);

    // 振幅 (Steepnessで調整)
    float a = steepness / k;

    // ★ Cosine: 水平方向への変位(波を尖らせる)
    float tangent = cos(f);

    // ★ Sine: 垂直方向への変位(波を高くする)
    float binormal = sin(f);

    return vec3(
        d.x * (a * tangent), // X軸のズレ
        a * binormal,        // Y軸のズレ(高さ)
        d.y * (a * tangent)  // Z軸のズレ
    );
}

この関数を複数の波(異なる方向、異なる波長)で呼び出し、結果を足し合わせる()ことで、複雑でリアルな海面が生まれます。

注意点: steepness(鋭さ)を大きくしすぎると、頂点が移動しすぎて自分自身を追い越してしまい、ポリゴンが裏返る「ループ現象(自己交差)」が発生します。これが「波が砕ける」直前の状態です。

3. ノイズによる「ランダムな揺らぎ」の追加

Gerstner Wave で作った波は、計算式通りに動くため、どうしても規則的(Periodic)になりがちです。遠目には海に見えても、近くで見ると「CGで作ったプール」のように見えてしまうのです。 この「人工的な臭い」を消すために、ノイズを使って「計算されたランダム性(Chaos)」を注入します。

A. 微細な波紋:法線(Normal)を揺らす

波の表面に見える、チリチリとした細かいさざ波。これを頂点移動(Vertex Displacement)で表現しようとすると、ポリゴン数が数百万あっても足りません。 そこで、法線(Normal Map)だけに高周波のノイズを適用します。

  • 手法: Gerstner Wave で計算した法線 $\mathbf{N}_{geo}$ に対し、高い周波数(Frequency)を持つ FBM ノイズを少しだけ加算します。
$$\mathbf{N}_{final} = \text{normalize}(\mathbf{N}_{geo} + \epsilon \nabla \text{FBM}(p \cdot \text{high\_freq}))$$
  • 視覚効果: 法線が細かく乱れることで、光の反射(Specular Highlight)が複雑に散乱します。これが、夕陽を浴びてキラキラと輝く水面や、月明かりの反射といった「質感」を生み出します。形は変えずに、光の当たり方だけを騙すテクニックです。

B. Domain Warping の応用:流れを「歪ませる」

海流や川の流れは、決して一直線ではありません。所々で渦を巻いたり、よどんだりします。 第6回で学んだ Domain Warping(座標のねじれ) を、Gerstner Wave や FBM の入力座標(UV)に適用します。

  • 手法: 波の計算を行う前に、参照する座標 そのものを低周波の FBM でずらします。
$$q = p + \text{FBM}(p + t) \times \text{distortion\_strength}$$

この「歪んだ座標 」を使って波を計算します。

  • 視覚効果: 規則正しく打ち寄せていた波の列が、有機的にぐにゃりと曲がります。 これにより、「沖合の複雑な潮の流れ」や「岩場に当たって乱れる波」といった、流体力学シミュレーション(Fluid Simulation)を使わなければ表現できないような複雑な挙動を、非常に低いコストで模倣できます。

GLSLでの実装イメージ(概念)

// 1. 物理的な大波(Gerstner)を作る
vec3 pos = getGerstnerWavePos(uv);
vec3 normal = getGerstnerWaveNormal(uv);

// 2. 座標を歪ませる(Domain Warping)
vec2 warpedUV = uv + fbm(uv * 0.5 + uTime * 0.1) * 0.2;

// 3. 微細なノイズを法線に重ねる(Detail Noise)
// 歪ませた座標を使うことで、ノイズ自体も流れるように動く
float detail = fbm(warpedUV * 20.0 + uTime * 0.5);
normal += vec3(dFdx(detail), 1.0, dFdy(detail)) * 0.1; // 法線を摂動させる
normal = normalize(normal);

// 結果:うねりながら、表面がキラキラ輝く海面

物理的な「波の頂点」を作る Gerstner Wave と、視覚的な「ディテール」を作る Noise。この2つを組み合わせることで、遠景の迫力と近景のリアリティを両立した「Procedural Water」が完成します。

4. 視覚的仕上げ:透明度とフレネル反射

Gerstner Wave で形を作り、FBM でディテールを足しましたが、今のままではまだ「不透明な青い物体」です。 これを「水」に変えるための最後のピースが、光の振る舞いを模倣する Fresnel(フレネル)効果 と 深みのある色彩設計 です。

A. Fresnel 反射:視線角度が生むリアリティ

水面を見たとき、足元(垂直方向)は水底が透けて見えますが、遠くの水平線(浅い角度)を見ると空が鏡のように反射して見えます。この現象を フレネル効果 と呼びます。

  • 物理的な仕組み: 光の反射率は、入射角によって変化します。 視線が水面に対して垂直に近いほど透過(Refraction)し、水平に近いほど反射(Reflection)します。
  • GLSLでの実装(Schlickの近似式): 重い物理計算を避けるため、近似式を使います。視線ベクトル(viewDir)と法線ベクトル(normal)の内積をとることで、視線角度を計算します。

$$F = F_0 + (1.0 - F_0) \times (1.0 - (\mathbf{V} \cdot \mathbf{N}))^5$$

  • : 視線と法線の内積(どれくらい垂直か)。
  • : 反射強度(0.0 〜 1.0)。これを使って、水の色と空の色(環境マップ)をブレンドします。
// 視線と法線の角度を計算(0.0: 平行, 1.0: 垂直)
float fresnelTerm = dot(viewDir, normal);
// 角度に応じて反射率を変化させる(Schlick近似)
fresnelTerm = clamp(1.0 - fresnelTerm, 0.0, 1.0);
fresnelTerm = pow(fresnelTerm, 5.0); // 5乗することでエッジの反射を鋭くする

// ベースの色(水)と反射色(空)をブレンド
vec3 finalColor = mix(waterColor, skyColor, fresnelTerm);

B. Color Gradation:深海から波頭へ

水の色は一色ではありません。光の吸収(Absorption)と散乱によって、深さに応じて劇的に色が変化します。

  • Deep Color(深海色): 光が届かない深部。濃いネイビーや暗いエメラルドグリーン(例: #001e0f)。
  • Shallow Color(浅瀬色): 光が透過する表面付近。明るいシアンや透明度の高い青(例: #006994)。
  • Foam(波頭の白): Gerstner Wave で「尖った」部分、つまり波の頂点(Yが高い場所)には、空気が混ざって白くなります。これを再現するために、頂点の高さ(Height)に応じて白を混ぜます。
// 高さによる色の変化(mix)
float heightFactor = smoothstep(-1.0, 1.0, vPosition.y); // 波の高さを0~1に正規化
vec3 baseColor = mix(deepColor, shallowColor, heightFactor);

// 波頭(高いところ)を白く飛ばす
float foam = smoothstep(0.8, 1.0, heightFactor);
vec3 waterSurface = mix(baseColor, vec3(1.0), foam); // 白(泡)をブレンド

仕上げ:Specular Highlight(太陽の輝き)

最後に、太陽光の反射(Specular)を加えます。ここで、セクション3で追加した「微細な法線ノイズ」が活きてきます。 法線が細かく乱れているため、太陽の反射が「大きな白い丸」にならず、無数の細かい光の粒(Glitter)として散らばります。これが、水面特有の「キラキラ感」の正体です。


第11回のまとめ

今回の記事で、私たちは「静止画の世界」を脱出し、「動き続ける物理の世界」へと足を踏み入れました。

  1. Gerstner Wave で、尖った物理的な波の形を作る。
  2. Noise (FBM) で、有機的な揺らぎとディテールを足す。
  3. Fresnel & Color で、光を透かし、反射させる。

これらを組み合わせることで、Three.js の画面の中に、永遠に波打ち続ける「あなただけの海」が生まれます。