【ゲーム制作】三角関数で作る水面(Gerstner波)とは?|波が尖る・折れる境目を実測して確かめる

【ゲーム制作】三角関数で作る水面(Gerstner波)とは?|波が尖る・折れる境目を実測して確かめる ゲーム制作

水面が揺れる映像を見ると、簡単な計算でできていそうに見えます。ところが、sinを1個足しただけでは、あんな複雑な波にはなりません。波を鋭くしたら、変な折れ方をしたのは、仕様なのか、バグなのか。

この記事では、テクスチャと三角関数(sin・cos)だけで水面を作ります。Gerstner波という、ゲームや映像でよく使われる手法です。ただ実装して、見た目が整ったで終わりにはせず、波を尖らせるsteepという値を上げていくと、どこで波頭が折れてしまうかを、先に式から計算し、実際にその値で折れることを確かめました。

Gerstner波とは?

Gerstner波とは、

・1つの正弦波につき、高さ(y)だけでなく進む方向にも点をずらして、波頭を尖らせる波の作り方

です。ふつうの正弦波(サインカーブ)は、高さだけが上下するので、遠目に見ると単調な「うねり」にしかなりません。実際の海の波は、頂点が尖り、谷が広く緩やかです。Gerstner波は、進行方向にも点を寄せることで、この非対称な形を再現します。

三角関数を重ねて作った水面。波頭に泡立ちがあり、遠くまで自然に波立っている
波長・振幅・速さの違う5つの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個の波だけでは、全部の点が同じ方向に同じタイミングで動くので、「うねり」というより「傾いた板」に見えます。方向・波長・速さの違う波を重ねると、複雑な水面になります。

波を1個・3個・5個と重ねていったときの見た目の変化
左:波1個 中:波3個 右:波5個(既定)

この作例では、波長22m(いちばん大きい、ゆったりした波)から3.2m(細かい波)まで、5つの波を方向や速さを変えて重ねています。波長が短いほど速く動かすと、細かい波がせわしなく揺れて、全体が単調に見えなくなります(現実の海のような正確な分散関係ではなく、見た目のための簡略化です)。

テクスチャも2枚重ねて流す

形だけでなく、水面のテクスチャも1枚を流すだけだと、模様がそのまま繰り返して見えてしまいます。真上から見て、波を止めて確認しました。

真上から見たテクスチャの模様。1枚だけ流すと大きな網目が規則的に繰り返す
左:1枚だけ流す 右:速さと拡大率を変えた2枚を重ねて流す

左は、同じテクスチャがそのままの大きさで並ぶので、網目の大きさがきれいに揃って見えます。右は、拡大率を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(丸い) → 0.85(既定) → 1.0(境目、波頭が尖る) → 1.3 → 1.8(完全にめくれる)
  • steep = 0.3:波頭は丸く、ふつうの正弦波に近い形
  • steep = 0.85(既定値):波頭がしっかり尖るが、まだ折れていない
  • steep = 1.0:式のとおり、ちょうど境目。波頭が先端が完全に尖った形になる
  • steep = 1.3 / 1.8:波頭の先端がくぼんで、めくれる。1.8では明らかに「く」の字に折れ込んでいる

式で予測した境目(steep = 1.0)ぴったりで、波頭が完全に尖った形になり、それを超えると実際にめくれ始めました。念のため、折れた波頭を真上からワイヤーフレームで見て、本当に格子が自分自身と重なっているかを確認しました。

折れた波頭を真上からワイヤーフレームで見た画像。格子が自分自身と重なっている
steep=1.8を真上から見たところ。明るい帯(波頭)の右側で、格子の線が二重に重なっている

見た目の折れだけでなく、実際にメッシュが自分自身と重なっていることが、ワイヤーフレームでも確認できました。「なんとなく変な形になった」で終わらせず、式を見直したことで、原因と境目の数値がはっきり分かりました。

格子の分割数:GPU時間と見た目のトレードオフ

波の形が細かくなればなるほど、格子の分割数(--res)が粗いと、波頭の輪郭が角ばって見えます。

格子の分割数を変えたときの、波頭の輪郭の違い
左:格子1マス1.5m 右:格子1マス0.19m(既定160分割相当)

左は、波頭の輪郭が直線的にガタついているのが分かります。右は滑らかな曲線です。この作例の一番細かい波の波長は3.2mなので、格子1マスが1.5mだと、1つの波を2マス程度でしか表現できていません。

分割数を上げるほどGPU時間も増えるので、実際に測りました。

格子の頂点数を増やしたときのGPU時間の変化(対数グラフ)
頂点数とGPU時間(対数-対数)。RTX 3050 Laptopで計測
分割数(–res)頂点数1回の描画にかかる時間
401,6810.0067 ms
806,5610.0097 ms
160(既定)25,9210.0187 ms
24058,0810.0329 ms
320103,0410.0537 ms
1回の描画を20回繰り返した平均。RTX 3050 Laptop、1280×720

頂点数は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が境目で、実測でもぴったり確認できました。見た目がおかしいときほど、数式に戻って確かめる価値があります。

関連記事