水面が揺れる映像を見ると、簡単な計算でできていそうに見えます。ところが、sinを1個足しただけでは、あんな複雑な波にはなりません。波を鋭くしたら、変な折れ方をしたのは、仕様なのか、バグなのか。
この記事では、テクスチャと三角関数(sin・cos)だけで水面を作ります。Gerstner波という、ゲームや映像でよく使われる手法です。ただ実装して、見た目が整ったで終わりにはせず、波を尖らせるsteepという値を上げていくと、どこで波頭が折れてしまうかを、先に式から計算し、実際にその値で折れることを確かめました。
Gerstner波とは?
Gerstner波とは、
・1つの正弦波につき、高さ(y)だけでなく進む方向にも点をずらして、波頭を尖らせる波の作り方
です。ふつうの正弦波(サインカーブ)は、高さだけが上下するので、遠目に見ると単調な「うねり」にしかなりません。実際の海の波は、頂点が尖り、谷が広く緩やかです。Gerstner波は、進行方向にも点を寄せることで、この非対称な形を再現します。

1つの波の式は、パラメーターs(進行方向の位置)に対して、次のように書けます。
// 方向D(単位ベクトル)、角波数w=2π/波長、振幅A、位相の進む速さphi、急峻さQ の1つの波
x(s,t) = s + Q * A * D.x * cos(w * dot(D, s) + phi * t)
z(s,t) = s + Q * A * D.z * cos(w * dot(D, s) + phi * t)
y(s,t) = A * sin(w * dot(D, s) + phi * t)高さ(y)はいつものsinですが、水平方向(x, z)にもcosで同じ位相のずれを加えています。これで、波頭に近い点ほど互いに引き寄せられ、尖った形になります。法線も、この式を偏微分すれば解析的に求まります(数値的な近似は使っていません)。
WAVE_OUT VSMain(VS_IN In) {
WAVE_OUT o;
float2 xz = In.Position.xz;
float3 disp = float3(xz.x, 0.0f, xz.y);
float3 n = float3(0.0f, 1.0f, 0.0f);
[unroll]
for (int i = 0; i < WAVE_N; i++) {
float2 dir = WaveA[i].xy;
float freq = WaveA[i].z;
float amp = WaveA[i].w;
float phi = WaveB[i].x;
float Q = WaveB[i].y;
float theta = freq * dot(dir, xz) + phi * Param.x;
float s = sin(theta);
float c = cos(theta);
disp.x += Q * amp * dir.x * c;
disp.z += Q * amp * dir.y * c;
disp.y += amp * s;
n.x -= dir.x * freq * amp * c;
n.z -= dir.y * freq * amp * c;
n.y -= Q * freq * amp * s;
}
n = normalize(n);
float4 wpos = mul(float4(disp, 1.0f), World);
o.WorldPos = wpos.xyz;
o.Position = mul(mul(wpos, View), Projection);
o.Normal = normalize(mul(float4(n, 0.0f), World).xyz);
o.UV = xz * Param.y;
o.Height = disp.y;
return o;
}頂点シェーダーの中身は、この式を波の数だけ足し合わせているだけです。C++側では、波ごとに方向・波長・振幅・速さを決めた表を持っておき、毎フレーム、角波数・角速度・急峻さQを計算してGPUに渡しています。
▼WaveDemo.cpp
// 重ねる5つの波の設計値。方向(度)・波長・振幅・速さの倍率。
// 波長が短いほど速さの倍率を上げて、細かい波ほどせわしなく動くようにしている(物理的に厳密な分散関係ではない簡略化)。
struct WaveSetting { float dirDeg, wavelength, amp, speedMul; };
static const WaveSetting kWaveTable[WaveDemo::kWaveMax] = {
{ 20.0f, 22.0f, 0.50f, 1.00f },
{ -35.0f, 14.0f, 0.33f, 1.30f },
{ 100.0f, 9.0f, 0.22f, 1.60f },
{ 200.0f, 5.5f, 0.13f, 2.10f },
{-140.0f, 3.2f, 0.07f, 2.60f },
};波を重ねる:1個から5個へ
1個の波だけでは、全部の点が同じ方向に同じタイミングで動くので、「うねり」というより「傾いた板」に見えます。方向・波長・速さの違う波を重ねると、複雑な水面になります。

この作例では、波長22m(いちばん大きい、ゆったりした波)から3.2m(細かい波)まで、5つの波を方向や速さを変えて重ねています。波長が短いほど速く動かすと、細かい波がせわしなく揺れて、全体が単調に見えなくなります(現実の海のような正確な分散関係ではなく、見た目のための簡略化です)。
テクスチャも2枚重ねて流す
形だけでなく、水面のテクスチャも1枚を流すだけだと、模様がそのまま繰り返して見えてしまいます。真上から見て、波を止めて確認しました。

左は、同じテクスチャがそのままの大きさで並ぶので、網目の大きさがきれいに揃って見えます。右は、拡大率を1.7倍にして、流れる速さも0.6倍・逆方向にした2枚目を重ねています。周期がずれるので、大きな網目の繰り返しが目立たなくなりました。
// 水面のテクスチャを、速さと拡大率を変えた2枚を重ねて流す(1枚だけだと繰り返しが目立つ。Tint.w=0で失敗例の1枚だけに戻せる)
float2 uv1 = In.UV + Param.zw * Param.x;
float3 tex1 = gTex.Sample(gSamp, uv1).rgb;
float3 texColor = tex1;
if (Tint.w > 0.5f) {
float2 uv2 = In.UV * 1.7f - Param.zw * 0.6f * Param.x + float2(0.3f, 0.1f);
float3 tex2 = gTex.Sample(gSamp, uv2).rgb;
texColor = (tex1 + tex2) * 0.5f;
}2枚重ねるだけで、ほぼ追加コストなしに「同じ模様が繰り返している」という印象を消せます。よくあるテクニックですが、実際に真上から止めて比べると、効果がはっきり分かります。
格子の仕組み:頂点シェーダーで持ち上げているだけ
水面の実体は、最初は真っ平らな格子(グリッド)です。この格子の頂点を、頂点シェーダーの中でGerstner波の式に従って動かしています。ワイヤーフレームで表示すると、仕組みがそのまま見えます。

格子はCPU側で1回だけ、平らな状態で作っています。
▼DemoCommon.cpp(DemoMeshGrid)
DemoMesh DemoMeshGrid(float halfX, float halfZ, int segX, int segZ, XMFLOAT4 color) {
std::vector<VERTEX_3D> v;
std::vector<UINT> idx;
XMFLOAT3 up(0, 1, 0);
for (int j = 0; j <= segZ; j++) {
float tz = (float)j / (float)segZ;
float z = -halfZ + tz * 2.0f * halfZ;
for (int i = 0; i <= segX; i++) {
float tx = (float)i / (float)segX;
float x = -halfX + tx * 2.0f * halfX;
VERTEX_3D vt;
vt.Position = XMFLOAT3(x, 0.0f, z);
vt.Normal = up;
vt.Diffuse = color;
vt.TexCoord = XMFLOAT2(tx, tz);
v.push_back(vt);
}
}
for (int j = 0; j < segZ; j++) {
for (int i = 0; i < segX; i++) {
UINT a = j * (segX + 1) + i;
UINT b = a + 1;
UINT c = a + (segX + 1);
UINT d = c + 1;
AddTri(idx, v, a, b, c, up);
AddTri(idx, v, b, d, c, up);
}
}
return MakeMesh(v, idx);
}動きも見た目も、すべて頂点シェーダーとピクセルシェーダーの中で作っています。CPU側は、毎フレーム「今の時刻」と「波のパラメーター」を渡すだけです。
【重要】実際に踏んだ罠:急峻さを上げたら、波頭がめくれた
波を尖らせるsteep(急峻さQを決める係数)を上げていくと、最初は「かっこいい尖った波」になりますが、ある値を境に波頭が折れて、めくれてしまいます。最初は「バグかもしれない」と思いましたが、式を見直すと、これはちゃんと理由のある現象でした。
1つの波だけを、進行方向の断面が見えるように横から見ると、水平方向の位置はx(s) = s + Q * A * cos(w * s + phi * t)という式でした。これをsで微分すると、
dx/ds = 1 - Q * A * w * sin(w * s + phi * t)となります。この値が負になるsがあると、x(s)が一度増えてから減る、つまり同じ画面の位置に2つ以上の水面点が重なることになります。負になるかどうかは、Q * A * wが1を超えるかどうかで決まります。
この作例のコードでは、急峻さQをsteep / (w * A * 波の数)で計算しています。波が1個だけなら、波の数=1なので、Q * A * wはちょうどsteepと同じ値になります。つまり、
・波が1個のとき、steepが1を超えた瞬間から、理論上必ず波頭が折れる
という、計算だけで求まる、はっきりした境目があります。これを実際に確かめました。波1個を横から見る構図で、急峻さを0.3から1.8まで変えています。

- steep = 0.3:波頭は丸く、ふつうの正弦波に近い形
- steep = 0.85(既定値):波頭がしっかり尖るが、まだ折れていない
- steep = 1.0:式のとおり、ちょうど境目。波頭が先端が完全に尖った形になる
- steep = 1.3 / 1.8:波頭の先端がくぼんで、めくれる。1.8では明らかに「く」の字に折れ込んでいる
式で予測した境目(steep = 1.0)ぴったりで、波頭が完全に尖った形になり、それを超えると実際にめくれ始めました。念のため、折れた波頭を真上からワイヤーフレームで見て、本当に格子が自分自身と重なっているかを確認しました。

見た目の折れだけでなく、実際にメッシュが自分自身と重なっていることが、ワイヤーフレームでも確認できました。「なんとなく変な形になった」で終わらせず、式を見直したことで、原因と境目の数値がはっきり分かりました。
格子の分割数:GPU時間と見た目のトレードオフ
波の形が細かくなればなるほど、格子の分割数(--res)が粗いと、波頭の輪郭が角ばって見えます。

左は、波頭の輪郭が直線的にガタついているのが分かります。右は滑らかな曲線です。この作例の一番細かい波の波長は3.2mなので、格子1マスが1.5mだと、1つの波を2マス程度でしか表現できていません。
分割数を上げるほどGPU時間も増えるので、実際に測りました。

| 分割数(–res) | 頂点数 | 1回の描画にかかる時間 |
|---|---|---|
| 40 | 1,681 | 0.0067 ms |
| 80 | 6,561 | 0.0097 ms |
| 160(既定) | 25,921 | 0.0187 ms |
| 240 | 58,081 | 0.0329 ms |
| 320 | 103,041 | 0.0537 ms |
頂点数は40分割から320分割で約61倍に増えましたが、GPU時間は約8倍にしか増えませんでした。頂点数が少ないうちは、1回の描画そのものにかかる固定的なコストの割合が大きく、頂点1個あたりの実質コストは、分割数を上げるほど下がっています(40分割では1頂点あたり約4ナノ秒、320分割では約0.5ナノ秒)。「格子を細かくすると重くなる」という直感は正しいものの、分割数を2倍にしても2倍重くなるわけではないのは、実測しないと分からない点でした。
水面のよくある失敗例と対処法
①急峻さを上げすぎて、波頭が折れる
波の数が1個なら、境目はsteep = 1.0とはっきり計算できます。波を複数重ねる場合は、それぞれの波が単独で1を超えないように、急峻さの合計や個々の値を確認します。
②テクスチャを1枚だけ流して、模様の繰り返しが目立つ
拡大率と流れる速さを変えた2枚目を重ねるだけで、追加コストをほとんどかけずに解決できます。
③格子の分割数を上げすぎる
分割数を上げてもGPU時間は線形には増えませんが、無限に上げられるわけでもありません。波長の一番短い波が、格子1マスあたり最低4〜5点は表現できる分割数を目安にします。
注意点
- 波が1個のときの急峻さの境目は
steep = 1.0とはっきり計算できる(Q*A*wが1を超えると折れる) - 波を重ねるときは、方向・波長・速さをずらす。同じ方向・速さでは「傾いた板」にしかならない
- テクスチャは2枚重ねて流すと、追加コストがほぼ無いまま繰り返しが目立たなくなる
- 格子の分割数は波長の一番短い波を基準に決める
- GPU時間は頂点数に単純比例しない。実測して確かめる
結論
- Gerstner波は、高さ(sin)と進行方向のずれ(cos)を組み合わせて、尖った波頭を作る
- 急峻さを上げすぎると波頭が折れる。波が1個のときは
steep = 1.0が正確な境目だと式から求まり、実測でもぴったり確認できた - テクスチャは2枚重ねて流すと、模様の繰り返しがほぼコスト無しで目立たなくなる
- 格子の分割数を61倍にしても、GPU時間は8倍程度。思ったより重くならないことも、実測すれば分かる
「波が折れた」ことは、最初は失敗に見えました。ところが、式を見直すと、ちゃんとした理由と、正確に計算できる境目がありました。波が1個のときはsteep = 1.0が境目で、実測でもぴったり確認できました。見た目がおかしいときほど、数式に戻って確かめる価値があります。


