水面と葉っぱ

WebGL2とGLSLだけで描いた池の水面。FFTの波と、カーソルで生まれる流れに葉っぱが浮かび、エサをまくと鯉が食べに来る。

斜めから見下ろした池の水面。小石の敷かれた底が透けて見え、太陽のきらめきが帯になって映る水の中で、紅白や黄金、浅黄などの鯉がエサに集まっている。水面には赤いモミジや黄色いイチョウの葉が浮かんでいる。

Three.jsなどの3Dライブラリを使わず、WebGL2とGLSLのシェーダーを直接書いて作った水面のシミュレーションです。処理を3つに分けています。波のうねりは、風の向きと強さから作った波のスペクトルを、GPU上の逆FFT(高速フーリエ変換)で毎フレーム高さと傾きに戻して計算しています。カーソルで生まれる流れは波とは別に、2次元の流体(移流・渦の強調・圧力の計算)としてGPUで解いています。その流れの速度をGPUから非同期で読み戻し、葉っぱをその場の流れと渦に合わせて動かしています。池には7匹の鯉が泳いでいて、クリックでエサをまくと気づいて寄ってきて食べます。鯉は池の平面のテクスチャに描き、水面のシェーダーで屈折させて水の中に見せています。池は斜めから見下ろす視点で、画面の各ピクセルから視線を飛ばして水面との交点を求めて描いています。水面の色は、屈折して見える池の底と鯉、波が集めた光(コースティクス)、葉っぱと鯉の影、木漏れ日、空の映り込みを1枚のシェーダーで合成しています。

波と流れを別々に計算する理由

水面の「うねり」と、カーソルでかき回した「流れ」は、性質がまったく違います。うねりは風が作る細かな波が重なったもので、場所ごとに独立して揺れます。流れは水そのものが移動するもので、押せば渦ができ、しばらく残ります。

1つの仕組みで両方を表そうとすると、どちらかが嘘っぽくなります。そこで波はFFT、流れは流体シミュレーションと完全に分け、最後に「流れの速度で波の模様を運ぶ」フローマップでつなぎました。かき回した場所だけ、さざ波が流されていくように見えます。

FFTで波を作る

Tessendorf の方式に沿って、風の速さと向きから Phillips スペクトルを作り、初期値だけCPUで乱数から生成してGPUに渡しています。毎フレームの時間発展と逆FFTはすべてシェーダーで行います。

逆FFTは Stockham 形式の radix-2 バタフライを、横 log2(N) 段・縦 log2(N) 段のレンダーパスとして実装しました。高さと2方向の傾きは、出力が実数になる性質を使って1枚のテクスチャに詰め、1回の逆FFTで同時に求めています。

  • 分散関係に表面張力の項を入れ、小さなさざ波ほど速く進むようにしている(池のスケールでは効く)
  • スペクトルの絶対値に頼らず、水面の傾きのRMSで振幅を正規化。スライダーの値が見た目と素直に対応する
  • 同じ波テクスチャを縮尺と向きを変えて2回重ね、タイルの繰り返しを見えにくくしている
  • 風向きを変えても波の「くせ」が変わらないよう、乱数の種は固定

流れと葉っぱ

流れは Stable Fluids(移流 → 渦の強調 → 発散 → 圧力のヤコビ反復 → 勾配の差し引き)をGPUで解いています。画面の端は池の岸として扱い、壁を突き抜ける速度を打ち消しています。何も触らなくても池がゆっくり巡るよう、発散を生まないカールノイズの力を弱く足しています。

葉っぱはCPUで動かしています。流れの速度場を縮小して readPixels でピクセルバッファに読み戻し、フェンスで完了を確かめてから取り出すので、GPUを待って描画が止まることはありません。葉っぱはその場の流れに引きずられ、速度場の回転(渦度)に合わせて回り、ほかの葉っぱとぶつかると押し合います。

当初は読み戻した同じフレームで次の読み戻しを要求していたため、Chrome が読み戻し用の写しを毎回捨てる警告が出ていました。次の要求を1フレーム遅らせることで解消しています。

鯉とエサ

鯉は7匹。紅白・大正三色・昭和三色・黄金・丹頂・紅鯉・浅黄の模様を、1枚のシェーダーでノイズから描き分けています。泳ぎ方はCPUで決めていて、普段はゆっくり回遊し、ほかの鯉や池の端をよけます。泳ぐ速さに合わせて尾を振る速さが変わり、曲がるときは体が弧を描きます。

鯉はポリゴンとして画面に直接描かず、池の平面のテクスチャへ描いています。水面のシェーダーが、視線が屈折して水中に入る位置でこのテクスチャを読むので、波に合わせて鯉の姿が揺らぎます。深さに応じた水の色、木漏れ日、葉っぱの影も鯉にかかり、池の底には鯉のぼやけた影が落ちます。

クリックでまいたエサは水面に浮かび、葉っぱと同じ流れに乗って漂います。着水の波紋が届く範囲の鯉は気づいて速く泳ぎ寄り、口の近くまで来たエサを吸い寄せて食べます。泳ぐ鯉は流体の計算に弱い力を加えるので、近くの葉っぱやエサも少し押されます。

斜めから見る:計算は平面、描画だけ投影

波・流れ・葉っぱの計算はすべて水平な池の平面の上で行い、描画のときだけ斜めから見下ろすカメラで投影しています。水面はポリゴンを並べず、画面の各ピクセルから視線を飛ばして水面との交点を求める方式です。

カメラに横の傾きがないので、見えている水面は台形になります。流れの計算範囲はその台形を覆う長方形にし、葉っぱは台形の中に収まるようにしています。カーソルの位置も同じ計算で水面上の点に戻すため、遠くをなぞれば遠くの水が動きます。

奥の水面ではテクスチャが縮小されてちらつくので、FFTの波のテクスチャには毎フレームミップマップを作り、池の底の細かな模様は1ピクセルの大きさに応じて平均色に寄せています。

水面の色を1枚のシェーダーで作る

斜めから見ると、手前は「水越しに見える底」、奥は「水面に映る空と岸の木」が主役になります。視線と水面の角度からフレネルの式で2つを混ぜています。波の法線で屈折させた位置に小石と砂の底を描き、水深に応じて赤から順に光が吸収されるようにしました。

底に落ちる光の集まり(コースティクス)は、波の傾きの発散から近似しています。葉っぱは一度小さなテクスチャに影として描き、水深ぶんずらして底に落としています。頭上の木の葉を表すノイズで木漏れ日と空の映り込みも揺らしています。

使用技術
  • WebGL2
  • GLSL
  • FFT(Stockham)
  • Stable Fluids
  • JavaScript
担当
  • 企画
  • シェーダー実装
  • 波と流体のシミュレーション
  • 見た目の調整
成果
  • 3Dライブラリなしで、WebGL2とGLSLだけで実装
  • 256×256の逆FFTを毎フレームGPUで計算して波を生成
  • 波とは独立した流体シミュレーションで、カーソルの動きを流れに変換
  • 流れの読み戻しを非同期にし、描画を止めずに葉っぱを動かす
  • エサに寄ってくる鯉を、屈折・影・水の色まで含めて水中に合成
制作期間
2026-09
ステータス
公開中

← 制作実績の一覧へ戻る