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 ノイズを少しだけ加算します。
- 視覚効果: 法線が細かく乱れることで、光の反射(Specular Highlight)が複雑に散乱します。これが、夕陽を浴びてキラキラと輝く水面や、月明かりの反射といった「質感」を生み出します。形は変えずに、光の当たり方だけを騙すテクニックです。
B. Domain Warping の応用:流れを「歪ませる」
海流や川の流れは、決して一直線ではありません。所々で渦を巻いたり、よどんだりします。 第6回で学んだ Domain Warping(座標のねじれ) を、Gerstner Wave や FBM の入力座標(UV)に適用します。
- 手法: 波の計算を行う前に、参照する座標 そのものを低周波の FBM でずらします。
この「歪んだ座標 」を使って波を計算します。
- 視覚効果: 規則正しく打ち寄せていた波の列が、有機的にぐにゃりと曲がります。 これにより、「沖合の複雑な潮の流れ」や「岩場に当たって乱れる波」といった、流体力学シミュレーション(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回のまとめ
今回の記事で、私たちは「静止画の世界」を脱出し、「動き続ける物理の世界」へと足を踏み入れました。
- Gerstner Wave で、尖った物理的な波の形を作る。
- Noise (FBM) で、有機的な揺らぎとディテールを足す。
- Fresnel & Color で、光を透かし、反射させる。
これらを組み合わせることで、Three.js の画面の中に、永遠に波打ち続ける「あなただけの海」が生まれます。
💬 コメント