Storm Ahead は、このサイトにある小さなリアルタイム 3D シーンです。黒い空の下、重いうねりに揺られる木造の帆船。カメラを横切って斜めに降る雨、周囲を回るサメと海蛇、そして左クリックで狙って撃てる、船首のクロスボウ兵。three.js(r160)を使ってブラウザで動いており、既製のモデルやテクスチャは一切使っていません——すべてコードで作っています。
この記事では、海と船の動きの仕組みを、実際のソースコードの抜粋とともに解説します。要点を先に言うと、安上がりな高さ関数ひとつが、海についての唯一の基準になっていて、水に触れるもの——メッシュ、船、サメ、水しぶき、クロスボウの矢——はすべて、水面がどこにあるかを同じ関数に問い合わせています。
海全体をひとつの高さ関数で
本格的な海のレンダラーでは、FFT スペクトルや Gerstner 波がよく使われます。私たちが使っているのはずっと単純なもの——異なる方向へ異なる速さで進む 5 つのサイン波の和です。
function waveH(x, z, t) {
return Math.sin(x * 0.055 + t * 0.9) * 1.55
+ Math.sin(z * 0.042 - t * 0.7 + 1.7) * 1.25
+ Math.sin((x + z) * 0.03 + t * 0.55) * 1.85
+ Math.sin(x * 0.14 - z * 0.09 + t * 1.6) * 0.42
+ Math.sin((x * 0.75 - z) * 0.11 + t * 2.1) * 0.22;
}
最初の 3 項がうねりです。波長は長く、振幅は大きく(1.25〜1.85 単位)、速さはゆっくりで、x 方向、z 方向、斜め方向に進みます。残りの 2 項は、その上に乗る短く速い小さな波(チョップ)で、振幅は小さめ。方向がばらばらなので、波の山がわかりやすい繰り返しの格子状に並ぶことはなく、完全に決定論的なのに水面は不規則に見えます。
なぜもっと物理的に正確な方法を使わないのか。この関数はいつでも、どこでも、CPU 上で呼び出せて、同じ (x, z, t) には常に同じ答えを返すからです。このシーンにとっては、スペクトルの正確さよりこの性質のほうがはるかに重要でした。船もサメも矢も、目に見える水面と食い違わずにいられるのは、この性質のおかげです。
メッシュを動かし、法線を計算する
海は 520 × 520 単位、112 × 112 分割——頂点数 113 × 113 = 12,769——の PlaneGeometry 1 枚を、水平に寝かせたものです。毎フレーム新しい高さを書き込み、法線も自前で計算し直します。
function updateOcean(t) {
const p = oceanGeo.attributes.position, n = oceanGeo.attributes.normal;
const e = 0.6;
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), z = p.getZ(i);
const h = waveH(x, z, t);
p.setY(i, h);
const hx = waveH(x + e, z, t), hz = waveH(x, z + e, t);
let nx = -(hx - h) / e, nz = -(hz - h) / e;
const inv = 1 / Math.hypot(nx, 1, nz);
n.setXYZ(i, nx * inv, inv, nz * inv);
}
p.needsUpdate = true;
n.needsUpdate = true;
}
法線は差分法で求めます。高さ場 y = h(x, z) の面法線は (−∂h/∂x, 1, −∂h/∂z) に比例します。x 方向と z 方向にそれぞれ 0.6 単位ずらした位置の高さをサンプリングして各方向の傾きを見積もり、正規化します。computeVertexNormals() より安上がりで(三角形をたどる必要がない)、メッシュの解像度に左右されない滑らかなライティングが得られます。
約 1.28 万の頂点を毎フレーム CPU で更新するのは無駄では? 少しはそうです——1 頂点あたり 3 回の高さ評価をしています。同じ計算を頂点シェーダーに移せば速くなりますが、そうすると海の形が JavaScript だけでなく GLSL にも存在することになり、2 つのコピーを常に一致させておかなければなりません。このメッシュサイズなら CPU 版でも十分に速く、実装をひとつに保てることのほうが、節約できる数ミリ秒より価値がありました。
onBeforeCompile で波頭に泡を
水には標準の MeshStandardMaterial(濃いティール、roughness 0.38、わずかな metalness)を使い、空から生成した環境マップで照らしているので、反射はタダで手に入ります。泡は、独自のマテリアルを書く代わりに onBeforeCompile で組み込みシェーダーにパッチを当てて追加しています。頂点シェーダーが高さをフラグメントシェーダーに渡し、フラグメントシェーダーが波頭付近の色を白くします。
float crest = smoothstep(1.9, 3.7, vH);
float streak = 0.7 + 0.3 * sin(vWp.x * 0.35 + vWp.z * 0.5 + uTime * 0.8)
* sin(vWp.x * 0.13 - vWp.z * 0.21 - uTime * 0.5);
diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.87, 0.93, 0.95), crest * streak * 0.92);
crest は高さ 1.9 未満で 0、3.7 を超えると 1、その間は滑らかに変化するので、泡立つのはいちばん高い山だけです。streak は泡を漂うまだら模様に分けて、一様な白い波頭に見えないようにします。#include <color_fragment> にパッチを当てるので、three.js のライティング、影、霧、トーンマッピングはすべてそのまま使えます。
船を波に乗せる
船は物理シミュレーションしていません。剛体も、浮力も、質量もありません。代わりに船体の下の海をサンプリングし、それに合わせて船の向きを決めています。サンプルは 5 つで十分です。中心に 1 つ、船首と船尾の方向に(船体の長さ方向に ±10)、そして左舷と右舷に 1 つずつ(幅方向に ±4)。
const hC = waveH(0, 0, t);
const hBow = waveH(10, 0, t), hStern = waveH(-10, 0, t);
const hPort = waveH(0, -4, t), hStar = waveH(0, 4, t);
const k = 1 - Math.exp(-dt * 2.1);
shipState.y += (hC * 0.85 - shipState.y) * k;
shipState.rz += (Math.atan2(hBow - hStern, 20) * 0.85 - shipState.rz) * k;
shipState.rx += (Math.atan2(hPort - hStar, 8) * 0.8 - shipState.rx) * k;
- 上下揺れ(ヒーブ)は中心の高さに追従します。
- 縦揺れ(ピッチ)は、船尾のサンプルから船首のサンプルへ引いた線の角度です。サンプル間の距離が 20 単位なので
atan2(高さの差, 20)になります。 - 横揺れ(ロール)は、同じ考え方を 8 単位の船幅方向に当てはめたものです。
船の重さを感じさせるポイントが 2 つあります。ひとつは、目標値を 80〜85% に縮めていること。船体が水面の傾きに完全に一致することはありません——重い船は波のひとつひとつに律儀に追従したりしないものです。もうひとつは、船が目標値へ一気に飛ぶのではなく、k = 1 − e−2.1·dt で徐々に近づくこと。この指数平滑化は 30、60、144 fps のどれでも同じように振る舞い(フレームごとの単純な lerp(a, b, 0.05) ではそうはいきません)、自然な遅れも生みます。船はいつも波よりほんの少し遅れていて、まるで慣性があるかのように見えます。
船体はグループなので、マスト、帆、手すり、樽、ランタン、クロスボウ兵はすべてこの動きを自動的に受け継ぎます。
船首の水しぶきと、水に触れるそのほかのもの
船の姿勢がわかれば、船首が波に突っ込む瞬間がわかります。上下揺れと縦揺れから船首のワールド座標での高さを見積もり、そのすぐ前の海面の高さと比べて、水面が 0.35 単位以上高ければ大きな水しぶきのスプライトを出します——ただし、しぶきが出っぱなしにならないよう、最短でも 0.9 秒に 1 回までです。
const bowWorldY = shipState.y + Math.sin(shipState.rz) * 12;
if (t > bowSplashCd && waveH(12, 0, t) > bowWorldY + 0.35) {
spawnFrom(splashes, _v1.set(13.5, waveH(13.5, 0, t) + 0.5, (Math.random() - 0.5) * 3), 4.5);
bowSplashCd = t + 0.9;
}
水しぶきは 26 個のスプライトからなる小さなオブジェクトプールから出てきて、大きくなり、上昇し、消えていくので、プレイ中に新たなメモリ確保は発生しません。水の中のほかのものも、すべて同じ waveH 呼び出しで動いています。
- サメは、その場の波の高さに連動した深さで船の周りを回り、8〜15 秒ごとに半周期のサイン曲線を描いて跳び上がります。水から出るときと戻るときには水しぶきが上がります。
- 海蛇は円に沿って並べた 10 個の球です。各節の高さは波の高さに進行する「こぶ」を足したもので、背中がうねりの中を転がるように見え、頭が水面を割るたびに水しぶきが上がります。
- クロスボウの矢は単純な重力で飛び、その位置での高さが
waveHを下回った瞬間に着水とみなします。
クロスボウの照準も同じ「単純で予測可能」の考え方です。目標までの飛行時間(距離 / 速さ)を計算し、動く目標に対してはその速度の 80% にその時間を掛けた分だけ先読みし、落下を打ち消すために狙う点を ½·g·t² だけ持ち上げます。厳密な弾道計算ではありませんが、この距離なら思ったところに当たります。
箱から船体を、平面から帆を
船体は分割した BoxGeometry から始まり、頂点をひとつずつ曲げて船の形にしていきます。長さ方向には、べき乗の曲線で幅を絞ります——船首では鋭く、船尾ではゆるやかに。「舷弧(シアー)」の曲線で両端を持ち上げて甲板が船首と船尾で反り上がるようにし、船底は上部より細くします。甲板は同じ関数で曲げた薄い箱なので、ぴったり収まります。
帆は分割した平面を sin(πx)·sin(πy)0.7 で外側へ押し出したもの。端ではゼロ、中央でいちばん膨らむ滑らかなふくらみです。小さな頂点シェーダーのパッチで時間とともに 2 つのサイン波を加え、穏やかにはためかせています。マストの先の赤い旗も同じ技のより速く強いバージョンで、旗の自由端に向かうほど振幅が大きくなります。テクスチャ——船体の板、甲板、帆布、雲、水しぶき、雨——はすべて、起動時に canvas に描いて作っています。
嵐を演出する:空・霧・雨・稲妻・カラーグレーディング
海が嵐らしく見えるのは、まわりにあるもののおかげです。
- 空。three.js の
Skyオブジェクトを、高い濁度と低い太陽で使い、かすんだ曇り空のドームにしています。そこから生成した PMREM 環境マップで水と船を照らします。 - 霧。灰青色の指数フォグが水平線を飲み込み、520 単位の海の平面の端を隠して、シーンに閉ざされた空気感を与えます。
- 雨。筋状のテクスチャと加算ブレンドを使った 800 個のポイントが、少しずつ違う速さで斜めに降ります。位置は毎フレームひとつの型付き配列で更新します。
- 雲。中心から少なくとも 130 単位離れた輪の上をスプライトがゆっくり漂うので、カメラと船の間に割り込むことはありません。
- 稲妻。ふだんは強度ゼロの平行光源。ときどき空のランダムな位置で最大まで光り、まだ明るいうちにもう一度ちらつくこともあり、指数関数的に減衰します(
flash *= 0.03dt)。稲妻の発生確率はフレームごとに判定しているので、フレームレートが高いほど少しだけ頻繁になります——許容している簡略化です。 - 暖かなランタン。船尾の点光源ひとつが、2 つのサイン波を足し合わせてちらつきます。シーンで唯一の暖色の光で、目の休まる場所になっています。
- ポストプロセス。低めの露出での ACES フィルミック・トーンマッピング、控えめなブルーム、そして独自のグレーディングパス。S 字カーブでコントラストを付け、影を鋼のような青に、ハイライトをわずかに暖色に寄せ、ビネットで四隅を暗くします。
軽く保つために
ふつうの Web ページの中で動くので、行儀よくふるまう必要があります。
- デバイスピクセル比は 1.75 で頭打ち。高 DPI のスマホで 3 倍の解像度で描く必要はありません。
- フレーム時間は 50 ms で頭打ち。止まっていたタブが再開したときに、船ががくんと揺れません。
- 矢、水しぶき、火花は固定のプールから。プレイ中に新たに生成されるものはありません。
- シーンが画面外にスクロールしたときやタブが非表示のときは描画を止め、「視差効果を減らす」を好む訪問者にはカメラのゆっくりした自動周回をオフにします。
まとめ
- 数本のサイン波の和で、様式化された海としては十分。方向、波長、速さをばらばらに選ぶのがコツです。
- 高さ関数を唯一の基準にする。水に触れるものがすべて同じ関数をサンプリングすれば、すべてが食い違いません。
- 浮かぶ船に物理はいらない。船首・船尾・左舷・右舷をサンプリングして上下揺れ・縦揺れ・横揺れを求め、減衰させ、少しだけ縮める。
- フレームレートに依存しない平滑化には
1 − e−rate·dtを。 - 雰囲気が仕事の半分:霧、雨、一瞬の光、カラーグレーディングが、青い平面を嵐に変えます。
このシーンは Storm Ahead で動いています——カメラを回し、マウスで狙って、サメに矢を何本か放ってみてください。賭け金のかかった海が好きなら、私たちが開発中のサバイバルゲーム Island Escape(孤島脱出)も、最後は大海原への賭けで締めくくられます。