浜辺の波が岸と平行に寄せてくるのは、浅い所ほど遅いから

沖では斜めに進む波も、波打ち際では正面から寄せてくる。波長の長い波の速さは水深だけで決まり、浅い所ほど遅いからだ。津波も同じ理由で、水深5,000 mの海を時速800 kmほどで渡り、岸の近くで遅く高くなる。

記事

浜辺の波が岸と平行に寄せてくるのは、浅い所ほど遅いから

沖では斜めに進む波も、波打ち際では正面から寄せてくる。波長の長い波の速さは水深だけで決まり、浅い所ほど遅いからだ。津波も同じ理由で、水深5,000 mの海を時速800 kmほどで渡り、岸の近くで遅く高くなる。

作成日 2026年11月4日・更新日 2026年11月4日・執筆・監修 佐々木 航

現象

斜めに来た波も、波打ち際では正面から寄せる

砂浜に立って海を眺めると、波はたいてい岸と平行に並び、まっすぐこちらへ寄せてくる。気象庁の「波浪の知識」も、沖の波が海岸線に斜めに打ち寄せていても、波打ち際では波が正面から向かってきたように見えると書いている1。

波の向きを変えているのは、海の深さだ。波長が水深よりずっと長い波は、水深だけで速さが決まり、浅い所ほど遅く進む。沖から斜めに来た波の山は、岸に近い端から先に浅い所へ入って遅れ始める。遅れた端を軸にして、山の列は少しずつ回り、岸と平行に近づいていく。

津波も、まったく同じ決まりで動く。気象庁の解説図では、水深5,000 mの沖を時速800 kmで進む津波が、水深10 mまで来ると時速36 kmに落ちる。遅くなった前の波に後ろの波が追いつくので、そのぶん高くなる2。

画面は、一辺640 mの海と砂浜を斜め上から見たものだ。手前の板の列(造波板)が、周期10秒の波を沖から33°斜めに送り出す。水面の細い線は、2 mごとの等深線だ。ブイAとBをつかんで動かすと、その場所の水深と速さ、波高(山から谷までの高さ)、波の向きが操作パネルに出る。

仕組み

波の速さは、水深だけで決まる

波が進むとき、水は山の下から谷の下へ押し出され、行ったり来たりしている。波長が水深の20倍ほどより長い波では、この往復が海面から海底まで同じように起こる3。こうした波を、浅い水の波(長波)と呼ぶ。海の深さが4,000 mあっても、波長が100 kmを超える津波にとっては「浅い水」になる。

浅い水の波の動きは、2つのことで決まる。水面が傾いていれば、水は低い側へ押されて流れが速くなる。流れが集まる所では水がたまって水面が上がり、流れが散る所では下がる。この2つを式にすると、次のようになる。

は静かな海面から測った水面の高さ(m)、は流れの速さ(m/s)、は重力加速度9.8 m/s²、は水深(m)、は波の進む向きの位置(m)、は時刻(秒)である。水深は2つ目の式にだけ入っている。は幅1 mあたりに流れる水の量(m²/s)で、深い海ほど、同じ速さの流れでも運ぶ水の量が多い。そのぶん水面が早く持ち上がり、波の形が速く先へ送られる。2つをまとめると、波の速さ(m/s)は水深だけで決まる。

1 10 100 1,000 10,000 10 100 1,000 10 m で 36 km/h 100 m で 113 km/h 1,000 m で 357 km/h 4,000 m で 713 km/h 水深 h(m) 速さ c(km/h) c = √(g h)。目盛りは 10 倍ごと
水深と波の速さ。水深が100倍になると速さは10倍になる

周期(秒)が変わっても、速さは変わらない。1周期で1波長進むので、波長はになる。水深14 mの沖では秒速11.7 mで、周期10秒の波の波長は117 m。同じ波が水深3 mの所へ来ると秒速5.4 mに落ち、波長は54 mに縮む。

仕組み

曲がる、高くなる、崩れる、回り込む

斜めに進む波の向きが曲がるのは、光がガラスに入ると曲がるのと同じしくみだ。等深線をまたぐ所で、波の山は途切れずにつながっていなければならない。浅い側では1周期に進む距離が短いので、山の列はそこで向きを変えるしかない。進む向きと、等深線に直角な向きとのなす角をとすると、次の関係が成り立つ3。

岸 θ₁ θ₂ 沖(深い) 速さ c₁ 浅い所 速さ c₂ 等深線(ここから浅い) 細い線は波の山、太い矢印は波の進む向き。浅い所に先に入った側が遅れ、 向きは等深線に直角な側へ曲がる(図は c₂ が c₁ の 0.55 倍のとき)
等深線をまたいだ波の山は、浅い側で遅れて向きを変える

浅くなるほどは小さく、も小さくなる。だから波の向きは、岸に直角な向きへ近づいていく。水深14 mで33°だった波なら、水深3 mでは15°ほどになる計算だ。

波は浅くなると高くもなる。波が運ぶエネルギーは、波高の2乗と速さの積に比例する3。途中で失われなければ、遅くなった分だけ波高が増すしかない。岸にまっすぐ向かう波では、

となる。水深が16分の1になると、波高は2倍だ。この関係はグリーンの法則と呼ばれ、気象庁は沖の津波の高さから岸での高さを見積もるのに、この関係を使っている4。斜めに寄る波は、向きが曲がるにつれてエネルギーが横へ広がるので、これより少し低くなる。

ただし、波はいつまでも高くはなれない。波高が水深の0.78倍ほどに届くと、山が形を保てずに崩れる5。シミュレーションでは、崩れた波は残りの水深に見合う高さまで削られながら、白い泡を立てて岸へ進む。

海底の形がでこぼこしていると、向きの曲がり方が場所で変わる。岬の沖では等深線が海へ張り出しているので、両側の波が岬の先へ向かって曲がり、波のエネルギーが集まる。湾の奥では逆に、波が広がって低くなる1。

島の裏や防波堤の陰にも、波は届く。波の山は、隣の部分に押されて横へも広がるからだ。この回り込みを回折という。島の両側を回り込んだ波は、裏で重なり合う。反対に、崖や岸壁に当たった波は跳ね返る。跳ね返った波と来る波が重なると、崖ぎわでは波高が2倍近くになる1。山と谷が打ち消し合う所もでき、崖から4分の1波長の所では水面がほとんど上下しない。

試す

海を変えて、波を見比べる

  1. 斜めに寄る波の場面では、周期10秒、波高1 mの波が沖から33°斜めに進む。水深12 mのブイAでは向きが32°、波高が1.05 mなのに、水深3.3 mのブイBでは向きが16°、波高は1.33 mになる。が一定という関係から計算した15°と、エネルギーから見積もった1.34 mに近い。岸の近くでは波が白く崩れ、等深線に沿うように並ぶ。矢印の先の取っ手で沖の向きを21°や11°に変えても、ブイBの向きはいつも沖のほぼ半分になる。
  2. 岬の場面では、周期8秒の波が岸にまっすぐ向かう。岬の先のブイAは水深3.5 mで波高1.7 mほど、湾の奥のブイBは水深3.9 mで1.3 mほど。水深はほとんど同じなのに、岬の先の波は湾の奥の約1.3倍になる。等深線が張り出した所で、両側の波が岬へ曲がり込むからだ。
  3. 島の裏の場面では、半径43 mの島と、そのまわりの浅瀬に波が当たる。島の陰は静かになりそうだが、そうはならない。浅瀬で遅くなった波は島の後ろへ曲がり込み、両側から来た波が斜めに交わって、網の目のような模様を作る。島の前のブイAでは波高1.0 mほどなのに、島の岸から40 mほど後ろのブイBでは1.9 m近い。向きは「定まらない」と出る。ブイBを島から遠ざけると波高は下がり、島の岸から200 mほど後ろでは1 mを切る。そこで横へずらすと、波の高い所と低い所が交互に並ぶ。交わった波が強め合う所と、打ち消し合う所だ。
  4. 防波堤のすき間の場面では、幅70 mのすき間を、波長79 mの波が通り抜ける。すき間の正面、防波堤から64 m奥のブイAでは波高0.9 mほど、防波堤の陰のブイBでは0.1〜0.3 mほど。すき間を抜けた波は扇のように広がり、陰の側へも回り込む。ただ、正面から横へ64 mずれると、波高は正面の3分の1ほどに下がる。周期を14秒にすると波長が延び、横へ64 mずれても正面の7割を超える高さが残る。扇が横へ広がるのだ。
  5. 大陸棚の津波の場面は、一辺320 kmの海を、実際の100倍の速さで動かす。沖で1 m持ち上がった海面は、沖と岸の2方向へ0.5 mずつの津波になって分かれる。水深4,000 mの深海を時速713 kmで渡り、大陸斜面を上ると急に遅くなる。津波の山が水深100 mのブイBを通るのは、地震から40分ほどあと。高さは1 mほどと、深海(0.52 m)のおよそ2倍に育っている。グリーンの法則なら2.5倍になるが、画面の大陸斜面は幅50 kmほどと津波の波長より短く、一部が沖へ跳ね返るため2倍ほどにとどまる。海をたたくと、その下の海底が持ち上がり、新しい津波を起こせる。
  6. 崖の場面では、水深15 mの海が切り立った崖で終わる。崖ぎわのブイAでは波高が1.9 mほどと、沖の1 mの2倍近くになる。崖から30 m(4分の1波長)離れたブイBでは0.3 mに満たない。来る波と跳ね返った波が重なり、山と谷の位置が動かない波(重複波)になったからだ。ブイAでは波の向きが「定まらない」と出る。

どの場面でも、「なぞると」を「海底を盛る」にして海をなぞると、そこに浅瀬ができる。浅瀬の上で遅くなった波は内側へ曲がり、浅瀬の裏に集まる。「海底を掘る」で溝を作ると、波は溝をよけるように両側へ分かれる。

現実では

沖合20 kmで6.7 m、港の入口で10.8 m

気象庁によると、沖の津波はジェット機に匹敵する速さで伝わる。それでも、2007年8月16日にペルーで起きた地震の津波は、太平洋を横切って日本に届くまでに20時間以上かかった2。気象庁が公開しているこの津波のアニメーションでは、途中のハワイなどで跳ね返ったり散らばったりしながら、後続の波が次々に日本へ来る様子が見える。

津波警報の予想も、同じ考え方で計算されている。気象庁は、地震による海底の隆起や沈降がそのまま海面の凹凸になると考えて、海を格子に分けて津波の高さと流れの速さを時間を追って計算する。地震が起きてから計算したのでは間に合わないので、起こりうる断層ごとの結果を前もって蓄えておき、地震の位置と規模から引き出す。計算の格子は一定の大きさなので、浅くて地形の込み入った岸の近くでは誤差が大きくなる。そこで、岸から15 kmほど沖の点までを計算し、そこから先はグリーンの法則で水深1 mでの高さに直して、沿岸の津波の高さとしている4。

2011年3月11日の東北地方太平洋沖地震の津波も、岸へ近づくにつれて高くなった。国土交通省港湾局の発表によると、釜石港の沖合約20 km、水深204 mの所にあるGPS波浪計は、第1波の高さを6.7 mと観測した。港湾空港技術研究所がこの記録をもとに計算すると、第1波は港の入口の防波堤の前で10.8 mに達していた6。

2011年当時、釜石港の入口には津波防波堤があった。同じ計算で防波堤が無かった場合と比べると、港の中の津波の高さは13.7 mから8.0 mへ4割下がり、市街地を守る高さ4.0 mの防潮堤を越えるまでの時間は6分遅れた。防波堤の外と内では、水位に最大8.3 mの差がついた。防波堤自体も大きく壊れている。発表は、水位の差で港の入口や防波堤のすき間に強い流れが起き、土台の石が洗い流されたためと推測している6。

普段の波についても、気象庁は同じ現象を説明している。砕けたときの波高は、沖の波高の2倍以上になることがある。崖のような海岸や防波堤では、跳ね返った波と来る波の山が重なり、元の2倍近い波が出ることもある。孤立した島の裏では、両側から回り込んだ波が重なり合って高くなることもあるという1。

省いていること

本物の海と違うところ

  • どの波も速さで進むとした。本物の波は、波長が水深の20倍より短くなると、これより遅く、周期の長い波ほど速い3。周期5秒の波を水深14 mの所に送ると、画面の波は本物よりかなり速く進む。気象庁の解説にある、浅くなり始めた所で波高がいったん元の9割ほどに下がる変化も出ない1
  • 波の高さは水深より十分小さいとして計算した。本物の波は、浅くなると山がとがって谷が平らになり、前に傾いて崩れる。シミュレーションでは、静かな水面から山までが水深の0.39倍を超えた分を、その場で削って崩れる代わりにしている
  • 水深0.3 mより浅い所(津波の場面は40 m)を、動かない壁とした。浜を駆け上がる水や、陸へ入り込む津波は計算しない
  • 斜めに寄る波、岬、崖の場面は、左右の端をつないでいる。同じ形の海岸が横にどこまでも続く形だ。その代わり、造波板が作る波の向きは、端でつながる向き(浜の場面で周期10秒なら0°、11°、21°、33°)に限られる
  • 高さは縦に引き伸ばして描いた。津波のほかの場面は海底も波も6倍、津波の場面は海底を10倍、波を12,000倍にしてある。風と海流、潮の満ち引きは無く、浜の断面は砂浜でよく見られる形を単純にしたものだ7。海を分けるますの細かさは画質で変わり、測った波高が1割ほど違うことがある。波が打ち消し合う所の0.3 mに満たない値は、それより大きく変わる
参考文献

参考文献

  1. 気象庁「波浪の知識」. https://www.data.jma.go.jp/kaiyou/db/wave/comment/elmknwl.html
  2. 気象庁「津波発生と伝播のしくみ」. https://www.jma.go.jp/jma/kishou/know/jishin/tsunami/generation.html
  3. Dean, R. G., Dalrymple, R. A.『Water Wave Mechanics for Engineers and Scientists』World Scientific, 1991.
  4. 気象庁「津波を予測するしくみ」. https://www.jma.go.jp/jma/kishou/know/jishin/tsunami/ryoteki.html
  5. McCowan, J.「On the highest wave of permanent type」『The London, Edinburgh, and Dublin Philosophical Magazine and Journal of Science』38(233), 351–358, 1894.
  6. 国土交通省港湾局「釜石港における津波による被災過程を検証」報道発表資料, 2011年4月1日. https://www.mlit.go.jp/report/press/port05_hh_000019.html
  7. Dean, R. G., Dalrymple, R. A.「Equilibrium Beach Profiles」『Coastal Processes with Engineering Applications』162–209, Cambridge University Press, 2001.
  8. Green, G.「On the Motion of Waves in a variable canal of small depth and width」『Mathematical Papers of the Late George Green』223–230, Cambridge University Press(再刊), 2014.

注

  1. 参考文献1の「屈折」「砕波」「反射」「回折」「浅水変形」の項。 ↩ ↩2 ↩3 ↩4 ↩5

  2. 参考文献2。 ↩ ↩2

  3. 参考文献3。波長が水深の20倍より長い(水深が波長の20分の1より浅い)波を、浅い水の波として扱う。等深線をまたいで波の山がつながる条件から、が一定という関係(波のスネルの法則)が出る。 ↩ ↩2 ↩3 ↩4

  4. 参考文献4。の関係は、ジョージ・グリーンの名をとってグリーンの法則と呼ばれる(参考文献8)。 ↩ ↩2

  5. 参考文献5。崩れずに進める一つの山の波(孤立波)の高さの上限が、水深の約0.78倍になる。 ↩

  6. 参考文献6。沖合の距離と水深は、発表の別添2の図による。防波堤の前の10.8 m、防波堤の効果、被災の過程は、港湾空港技術研究所の数値計算による。発表は、被災の過程と原因にはさらに詳しい解析や実験が必要としている。 ↩ ↩2

  7. 参考文献7。岸からの距離の2/3乗に比例して深くなる断面(平衡断面)を使った。 ↩

プログラム
プログラム

このシミュレーションのプログラム

このシミュレーションは、海を一辺96〜192個のますに分け、1コマごとに「水面の傾きで流れを速める → 流れの出入りで水面を上げ下げする」をくり返している。波の速さも、浅い所で向きが曲がることも、式には直接書かれていない。この2つの手順をくり返すだけで、ひとりでに現れる。立体の形・影・視点の計算はthree.js(WebGLで3Dを描く部品集)に任せ、記事の物理はふつうの数の配列で計算している。

長さはメートル、時間は秒

計算はすべてメートルと秒で行う。画面の中の海は一辺100の単位で描き、場面ごとに縮尺(W ÷ L)を掛けて直す。640 mの浜も320 kmの海も同じ大きさに見えるのは、このためだ。

定数と単位(src/sims/shallow-waves.ts の 26〜39 行)
const G = 9.81; // 重力加速度 m/s²const NQ = [96, 128, 192]; // 一辺のますの数(画質 0・1・2)const STEPMAX = [12, 8, 6]; // 1 コマに進める刻みの上限(これより速い再生は、計算が追いつく分だけになる)const SPIN = [10e6, 18e6, 32e6]; // 場面の最初に、波を浜まで届かせておく計算の量の上限(ますの数 × 刻みの数)const SPINMS = 6; // その早送りに 1 コマで使ってよい時間(ミリ秒・1/60 秒のコマあたり)const CFL = 0.5; // c Δt / Δx(この方法は 2 次元で 0.71 まで安定)const KB = 0.39; // 砕ける高さ ÷ 水深(静かな水面から山まで)const W = 100; // 画面の中の海の一辺(描画の単位)。縮尺は場面ごとに W ÷ Lconst SPONGE = 0.06; // 端で波を吸い込む帯の幅(一辺に対する割合)const ZONE = 0.14; // 造波板の前で波を作る帯の幅(一辺に対する割合)const ZP = 0.5 - SPONGE - 0.01; // 造波板の位置(海の中心から沖へ・一辺に対する割合)const UNI = ZP - ZONE - 0.02; // ここより沖は水深がそろっている(造波板の作る波が乱れないように)const SPEEDS = ["1", "3", "10", "30", "100", "300"];const NSEG = 24; // 造波板の板の数

CFLは、1回の刻みで波が進む距離を、ます1つの幅の何割までにするかを決める数である。この計算方法は2次元で0.71を超えると、刻みごとに誤差がふくらんで計算が発散する。余裕をみて0.5にした。KBは砕ける高さの目安で、静かな水面から山までが水深の0.39倍、つまり波高が水深の約0.78倍になった所で波を崩す。

海底の形を関数で決める

場面ごとに、位置から海底の高さを返す関数を1つずつ持っている。海面が0で、上が正、水の中は負になる。

海底の形(src/sims/shallow-waves.ts の 41〜108 行)
interface Spec {  L: number; // 海の一辺 m  h0: number; // 沖の水深 m  hcap: number; // 掘れる深さの上限 m(刻み Δt はこの深さの波の速さで決める)  hmin: number; // これより浅い所は陸(動かない壁)  top: number; // 盛れる高さの上限 m  rate: number; // 指でなぞったとき、海底が 1 秒に上下する高さ m  fric: number; // 底の摩擦で流れが弱まる割合(1 秒あたり。津波のような長い波では、ほとんど効かない大きさにする)  per: boolean; // 左右の端をつなぐ  maker: boolean; // 造波板があるか  vb: number; vw: number; // 縦の倍率(海底・波)  hide: boolean; // 水の下の海底を描かない(波を海底より大きく引き伸ばす場面で、谷が海底を突き抜けて見えないように)  levels: number[]; // 等深線を引く深さ m  bar: number; barText: string; // 物差しの長さ m と文字  buoys: [number, number][]; // ブイ A・B の位置(一辺に対する割合。z は沖が正)  cam: [number, number, number]; // 視点の向き・見下ろす角度(rad)・距離  bed(x: number, z: number, L: number): number; // 海底の高さ m(静かな海面が 0・上が正)。x は右、z は沖が正}/** 砂浜の断面: 岸から d(m)離れた所の水深。D で沖の深さ h0 に届く(砂浜でよく見られる、d の 2/3 乗に比例する形) */const dean = (d: number, h0: number, D: number) => h0 * Math.min(1, Math.pow(Math.max(0, d) / D, 2 / 3));/** 岬の海岸線の z(m)。真ん中が沖へ張り出し、左右の端は湾になる(端どうしがつながる形) */const capeZ = (x: number, L: number) => -0.36 * L + 0.3 * L * (0.5 + 0.5 * Math.cos((TAU * x) / L)) ** 3;const even = (a: number, b: number, n: number) => Array.from({ length: n }, (_, i) => a + i * b); const SPECS: Record<string, Spec> = {  beach: {    L: 640, h0: 14, hcap: 18, hmin: 0.3, top: 6, rate: 5, fric: 0.002, per: true, maker: true, vb: 6, vw: 6, hide: false,    levels: even(2, 2, 6), bar: 100, barText: "100 m", buoys: [[0.12, 0.16], [0.12, -0.2]], cam: [0.55, 0.8, 152],    bed: (x, z, L) => { const d = z + 0.26 * L; return d > 0 ? -dean(d, 14, (UNI + 0.26) * L) : Math.min(5, -0.05 * d); },  },  cape: {    L: 640, h0: 14, hcap: 18, hmin: 0.3, top: 8, rate: 5, fric: 0.002, per: true, maker: true, vb: 6, vw: 6, hide: false,    levels: even(2, 2, 6), bar: 100, barText: "100 m", buoys: [[0, -0.02], [-0.46, -0.27]], cam: [0.15, 0.95, 150],    bed: (x, z, L) => { const zs = capeZ(x, L), d = z - zs; return d > 0 ? -dean(d, 14, UNI * L - zs) : Math.min(10, -0.08 * d); },  },  island: {    L: 720, h0: 12, hcap: 16, hmin: 0.3, top: 12, rate: 5, fric: 0.002, per: false, maker: true, vb: 6, vw: 6, hide: false,    levels: even(2, 2, 5), bar: 100, barText: "100 m", buoys: [[0.12, 0.16], [0, -0.16]], cam: [0.35, 1.0, 145],    bed: (x, z, L) => {      const r = Math.hypot(x, z + 0.04 * L), Ri = 0.06 * L, Rs = 0.27 * L;      return r < Ri ? 12 * (1 - (r / Ri) ** 2) : -12 * Math.min(1, Math.pow((r - Ri) / (Rs - Ri), 0.8));    },  },  harbor: {    L: 640, h0: 10, hcap: 14, hmin: 0.3, top: 6, rate: 5, fric: 0.002, per: false, maker: true, vb: 6, vw: 6, hide: false,    levels: even(2, 2, 4), bar: 100, barText: "100 m", buoys: [[0, -0.12], [-0.27, -0.12]], cam: [0.2, 1.25, 140],    bed: (x, z, L) => {      if (Math.abs(z + 0.02 * L) < 0.012 * L && Math.abs(x) > 0.055 * L) return 3; // 防波堤(すき間の幅 70 m)      const d = z + 0.41 * L; return d > 0 ? -dean(d, 10, 0.17 * L) : Math.min(4, -0.04 * d);    },  },  tsunami: {    L: 320000, h0: 4000, hcap: 5200, hmin: 40, top: 400, rate: 1500, fric: 2e-5, per: false, maker: false, vb: 10, vw: 12000, hide: true,    levels: [100, 150, 200, 500, 1000, 2000, 3000], bar: 50000, barText: "50 km", buoys: [[0, 0.16], [0, -0.22]], cam: [-0.75, 0.62, 150],    bed: (x, z, L) => {      const zs = -0.3 * L, zb = -0.03 * L, zd = 0.12 * L;      if (z < zs) return Math.min(300, 0.004 * (zs - z)); // 陸      if (z < zb) return -(60 + (140 * (z - zs)) / (zb - zs)); // 大陸棚(60〜200 m)      if (z < zd) { const t = (z - zb) / (zd - zb); return -(200 + 3800 * t * t * (3 - 2 * t)); } // 大陸斜面      return -4000; // 深海    },  },  cliff: {    L: 640, h0: 15, hcap: 19, hmin: 0.3, top: 12, rate: 5, fric: 0.002, per: true, maker: true, vb: 6, vw: 6, hide: false,    levels: even(2, 2, 7), bar: 100, barText: "100 m", buoys: [[0.2, -0.212], [0.02, -0.17]], cam: [0.75, 0.66, 148],    bed: (x, z, L) => (z < -0.22 * L ? 12 : -15),  },};

浜の断面は、岸からの距離の2/3乗で深くなる形(dean)にした。砂浜でよく見られる形で、岸の近くほど急に深くなる。岬は、海岸線そのものを沖へ張り出させ、深さはそこからの距離で決めている。こうすると、等深線も岬に沿って沖へふくらみ、波を岬へ集める形になる。hcapは掘れる深さの上限で、刻みの長さはこの深さの波の速さで決める。指で深く掘っても計算が壊れないようにするためだ。

流れを進め、水面を進める

本文の2本の式を、そのまま格子の上で解いている。水面の高さはますの中心に、幅1 mあたりの流量とは、ますとますの境目に置く。こうずらして置く(スタガード格子)と、水面の差から流れを、流れの差から水面を、どちらも隣どうしの引き算だけで求められる。

浅水の方程式(src/sims/shallow-waves.ts の 137〜184 行)
/** 1 刻み Δt 進める。流量 P・Q を水面の傾きで進め(運動の式)、その流量の出入りで水面 η を進める(水の量の式) */function advance(s: S, dt: number) {  const { N, eta, P, Q, cP, cQ, sP, sQ, sE, h, wet, lim } = s;  const N1 = N + 1, fr = 1 - s.sp.fric * dt;  // x の向きの流量: ∂P/∂t = −g h ∂η/∂x(cP = g h Δt ÷ Δx。陸との境目は 0 にしてあり、水が通らない)  for (let j = 0; j < N; j++) {    const r = j * N, f0 = j * N1;    for (let i = 1; i < N; i++) { const f = f0 + i; P[f] = (P[f] * fr - cP[f] * (eta[r + i] - eta[r + i - 1])) * sP[f]; }    if (s.sp.per) { P[f0] = (P[f0] * fr - cP[f0] * (eta[r] - eta[r + N - 1])) * sP[f0]; P[f0 + N] = P[f0]; }    else {      // いちばん外の境目は、外へ出ていく波だけを通す(進む波では P = c η。左の端は外が −x なので符号が逆)      P[f0] = wet[r] ? -Math.sqrt(G * h[r]) * eta[r] : 0;      P[f0 + N] = wet[r + N - 1] ? Math.sqrt(G * h[r + N - 1]) * eta[r + N - 1] : 0;    }  }  // z の向きの流量(境目 f は、ます f − N と f のあいだ)  const nn = N * N;  for (let f = N; f < nn; f++) Q[f] = (Q[f] * fr - cQ[f] * (eta[f] - eta[f - N])) * sQ[f];  const last = nn - N;  for (let i = 0; i < N; i++) {    Q[i] = wet[i] ? -Math.sqrt(G * h[i]) * eta[i] : 0;    Q[nn + i] = wet[last + i] ? Math.sqrt(G * h[last + i]) * eta[last + i] : 0;  }  // 水面: ∂η/∂t = −(∂P/∂x + ∂Q/∂z)。砕ける高さを超えた分は削る  const r = dt / s.dx, clip = s.clip, cr = s.cr;  let nc = 0;  for (let j = 0; j < N; j++) {    const k0 = j * N, f0 = j * N1;    for (let i = 0; i < N; i++) {      const k = k0 + i;      if (!wet[k]) continue;      const f = f0 + i;      let e = (eta[k] - r * (P[f + 1] - P[f] + Q[k + N] - Q[k])) * sE[k];      const m = lim[k];      if (e > m || e < -m) { clip[nc] = k; cr[nc++] = m / Math.abs(e); e = e > 0 ? m : -m; }      eta[k] = e;    }  }  // 砕けた所は、流れも同じ割合で弱める(波のエネルギーがそこで失われる)。泡の時刻を記す  for (let n = 0; n < nc; n++) {    const k = clip[n], q = cr[n], f = k + ((k / N) | 0);    P[f] *= q; P[f + 1] *= q; Q[k] *= q; Q[k + N] *= q; s.tb[k] = s.time;  }  if (s.sp.per) for (let j = 0; j < N; j++) P[j * N1 + N] = P[j * N1];  s.brk = nc;  s.time += dt;  wave(s, dt);}

前半のP[f] = (P[f] * fr - cP[f] * (eta[...] - eta[...]))は、本文のに水深を掛けた形である。cPにはを前もって入れておき、陸との境目では0にして水を通さない。後半はで、ますに入った水と出た水の差だけ水面を上げ下げする。流れを先に進め、その新しい流れで水面を進めるので、波のエネルギーが長い時間たってもずれにくい。

いちばん外の境目では、外へ出ていく波だけを通す。進む波では流量がになるので、境目の流量をそう決めると、波は跳ね返らずに外へ抜ける。砕ける高さを超えたますは、水面と周りの流れを同じ割合で削り、その時刻を泡の表示のために覚えておく。

造波板と、波を吸い込む帯

造波板の前の帯では、水面と流量を、造波板が作るはずの進む波(、流量)へ毎回少しずつ近づける。

造波と吸収(src/sims/shallow-waves.ts の 186〜270 行)
/** 造波板の波: 周期 T・沖の波長・向き。左右の端をつなぐ場面では、向きを端でつながる値に合わせる */function waveParams(api: Api, s: S, sync: boolean) {  const w = s.wave, sp = s.sp;  w.T = clamp(api.num("T") || 10, 2, 40); w.om = TAU / w.T; w.c0 = Math.sqrt(G * sp.h0); w.k0 = w.om / w.c0;  let th = (clamp(api.num("theta") || 0, -45, 45) * Math.PI) / 180;  if (sp.per) {    // 一辺 L に入る波の数が、横の向きに整数 n になる向きだけを使う(sin θ = n λ ÷ L)    const m = (s.L * w.k0) / TAU, nmax = Math.floor(m * Math.SQRT1_2 + 1e-9);    const n = clamp(Math.round(m * Math.sin(th)), -nmax, nmax);    w.kx = (TAU * n) / s.L; th = Math.asin(clamp(w.kx / w.k0, -1, 1));    const deg = Math.round((th * 180) / Math.PI);    if (sync && deg !== Math.round(api.num("theta"))) api.set("theta", deg);  } else w.kx = w.k0 * Math.sin(th);  w.th = th; w.kz = -Math.sqrt(Math.max(0, w.k0 * w.k0 - w.kx * w.kx));  w.aim = sp.maker ? Math.max(0, api.num("H")) / 2 : 0;  zone(s);} /** 造波板の前の帯のますと、そこで目指す波の位相(cos と sin)を用意する */function zone(s: S) {  const { N, L, dx } = s, w = s.wave, Z = s.zone, zp = ZP * L, z0 = (ZP - ZONE) * L, ws = Math.max(6, Math.round(SPONGE * N));  let n = 0;  if (s.sp.maker) for (let j = 0; j < N; j++) {    const z = -L / 2 + (j + 0.5) * dx;    if (z < z0 || z > zp) continue;    const t = (z - z0) / (zp - z0), al = 1 - Math.exp(-3 * w.om * t * t * s.dt); // 造波板に近いほど強く近づける    for (let i = 0; i < N; i++) {      if (!s.sp.per && (i < ws || i > N - 1 - ws)) continue;      const k = j * N + i;      if (!s.wet[k] || n >= Z.k.length) continue;      const x = cx(s, i), pe = w.kx * x + w.kz * (z - zp), pp = pe - (w.kx * dx) / 2, pq = pe - (w.kz * dx) / 2;      Z.k[n] = k; Z.al[n] = al;      Z.cE[n] = Math.cos(pe); Z.sE[n] = Math.sin(pe); Z.cP[n] = Math.cos(pp); Z.sP[n] = Math.sin(pp); Z.cQ[n] = Math.cos(pq); Z.sQ[n] = Math.sin(pq);      n++;    }  }  Z.n = n;} /** 造波板: 帯の中の水面と流量を、進む波 η = a sin(ωt − k·x)・流量 = c η(波の向き)に近づける。戻ってきた波もここで吸い込まれる */function wave(s: S, dt: number) {  const w = s.wave, Z = s.zone;  w.ph = (w.ph + w.om * dt) % TAU;  w.a += (w.aim - w.a) * Math.min(1, dt / w.T); // 振れ幅は 1 周期ほどかけて変える(急に動かすと乱れる)  if (!Z.n) return;  const { eta, P, Q, cP, cQ, N } = s, a = w.a, sn = Math.sin(w.ph), cs = Math.cos(w.ph), ux = (w.c0 * w.kx) / w.k0, uz = (w.c0 * w.kz) / w.k0;  for (let n = 0; n < Z.n; n++) {    const k = Z.k[n], al = Z.al[n], f = k + ((k / N) | 0);    eta[k] += al * (a * (sn * Z.cE[n] - cs * Z.sE[n]) - eta[k]); // sin(φ − ψ) = sin φ cos ψ − cos φ sin ψ    if (cP[f] > 0) P[f] += al * (ux * a * (sn * Z.cP[n] - cs * Z.sP[n]) - P[f]);    if (cQ[k] > 0) Q[k] += al * (uz * a * (sn * Z.cQ[n] - cs * Z.sQ[n]) - Q[k]);  }} /** 海底の形が決まったら(または変わったら)、水深・陸・境目の係数・端の吸い込み・表示の形を作り直す */function depth(s: S) {  const { N, dx, dt, sp, bed, h, wet, eta, P, Q, cP, cQ, sE, sP, sQ, lim } = s, N1 = N + 1, nn = N * N;  for (let k = 0; k < nn; k++) {    const d = -bed[k];    wet[k] = d > sp.hmin ? 1 : 0;    h[k] = wet[k] ? d : 0;    lim[k] = KB * h[k];    if (!wet[k]) eta[k] = 0;  }  const gdt = (G * dt) / dx, cf = (a: number, b: number) => (wet[a] && wet[b] ? gdt * 0.5 * (h[a] + h[b]) : 0);  // 端の帯: 外へ近いほど強く弱める(帯を通り抜ける間に波がおよそ e の 3 乗分の 1 になる強さ)  const ws = Math.max(6, Math.round(SPONGE * N)), c0 = Math.sqrt(G * sp.h0);  for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {    const k = j * N + i, d = Math.min(j, N - 1 - j, sp.per ? 1e9 : i, sp.per ? 1e9 : N - 1 - i);    if (d < ws && wet[k]) {      const c = Math.max(Math.sqrt(G * h[k]), 0.3 * c0), sig = ((9 * c) / (ws * dx)) * ((ws - d) / ws) ** 2;      sE[k] = Math.exp(-sig * dt);    } else sE[k] = 1;  }  for (let j = 0; j < N; j++) {    const r = j * N, f0 = j * N1;    for (let i = 1; i < N; i++) { cP[f0 + i] = cf(r + i - 1, r + i); sP[f0 + i] = 0.5 * (sE[r + i - 1] + sE[r + i]); }    if (sp.per) { cP[f0] = cP[f0 + N] = cf(r + N - 1, r); sP[f0] = sP[f0 + N] = 0.5 * (sE[r + N - 1] + sE[r]); }    else { cP[f0] = cP[f0 + N] = 0; sP[f0] = sP[f0 + N] = 1; }    for (let i = 0; i <= N; i++) if (cP[f0 + i] === 0 && (sp.per || (i > 0 && i < N))) P[f0 + i] = 0;  }  for (let f = N; f < nn; f++) { cQ[f] = cf(f - N, f); sQ[f] = 0.5 * (sE[f - N] + sE[f]); if (cQ[f] === 0) Q[f] = 0; }  for (let i = 0; i < N; i++) { cQ[i] = cQ[nn + i] = 0; sQ[i] = sQ[nn + i] = 1; }  shapeGfx(s);}

近づける割合は造波板に近いほど強い。帯の中では、浜や崖で跳ね返って戻ってきた波も目標の波へ引き戻されるので、帯は波を作る所と吸い込む所を兼ねる。波の形のは、場所で決まるのとを前もって計算しておき、時刻の分だけを毎回掛け合わせている。左右の端をつないだ場面では、端と端で波の山がつながる必要がある。一辺に横向きの波が整数個入る向き()だけを使うのは、このためだ。

海の端の帯では、波を外側ほど強く弱める。帯を抜ける間に波がおよそ倍になる強さにしてあり、斜めに当たった波もほとんど跳ね返らない。

ブイで測る

ブイの所の水面を、刻みごとに記録している。

測る(src/sims/shallow-waves.ts の 272〜297 行)
/** ブイの所の水面の上下(波高)・波の向き・いちばん高い水位を測る。向きは「水面の高さ × 流量」の平均(波のエネルギーが運ばれる向き) */function measure(s: S, dt: number) {  const win = s.sp.maker ? 1.5 * s.wave.T : 900;  for (const b of s.buoys) {    const k = b.k;    if (k < 0 || !s.wet[k]) continue;    const e = s.eta[k], f = k + ((k / s.N) | 0);    const pc = 0.5 * (s.P[f] + s.P[f + 1]), qc = 0.5 * (s.Q[k] + s.Q[k + s.N]);    if (e > b.mx) b.mx = e;    if (e < b.mn) b.mn = e;    if (e > b.top) b.top = e;    b.tw += dt;    if (b.tw >= win) { b.pmx = b.mx; b.pmn = b.mn; b.mx = -1e9; b.mn = 1e9; b.tw = 0; }    const a = Math.min(1, dt / win);    b.ex += (e * pc - b.ex) * a; b.ez += (e * qc - b.ez) * a; b.e2 += (e * e - b.e2) * a;  }}const resetBuoy = (b: Buoy) => { b.mx = b.pmx = -1e9; b.mn = b.pmn = 1e9; b.tw = 0; b.ex = b.ez = b.e2 = 0; b.top = 0; };/** 波高(山から谷まで)。直近の 2 つの区間(1.5 周期ずつ)の、いちばん高い所と低い所の差 */const heightOf = (b: Buoy) => { const hi = Math.max(b.mx, b.pmx), lo = Math.min(b.mn, b.pmn); return hi > lo ? hi - lo : 0; };/** 波の向き(度・まっすぐ岸へ向かう向きから右回りが正)。進む波でなければ(跳ね返った波と重なっている所など)null */function dirOf(s: S, b: Buoy) {  const c = Math.sqrt(G * s.h[b.k]), m = Math.hypot(b.ex, b.ez);  if (!(b.e2 > 1e-8) || m < 0.35 * c * b.e2) return null;  return (Math.atan2(b.ex, -b.ez) * 180) / Math.PI;}

波高は、1.5周期ずつの2つの区間で、いちばん高い所と低い所の差をとる。波の向きは、水面の高さと流量を掛けた値の平均から求める。進む波では、山の所で水が前へ、谷の所で後ろへ流れるので、この平均は波が進む向き(エネルギーが運ばれる向き)を指す。崖の前のように、来る波と跳ね返った波が重なってその場で上下している所では平均がほとんど0になり、「定まらない」と出る。

指の操作

ブイと矢印の取っ手は、画面の上での距離でつかむ。指の下の海面の位置は、視点から指の方向へのばした線と、高さ0の平面との交点で求める。

操作(src/sims/shallow-waves.ts の 355〜431 行)
const SEA = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);/** 画面の点の下にある海面の位置(m)。海の外なら null */function ground(s: S, x: number, y: number) {  const p = s.v.onPlane(x, y, SEA);  if (!p) return null;  const X = p.x / s.k, Z = p.z / s.k;  return Math.abs(X) < s.L / 2 && Math.abs(Z) < s.L / 2 ? { x: X, z: Z } : null;}const ARX = -0.28; // 波の向きの矢印の根元(一辺に対する割合)const arrowAt = (s: S) => { const x0 = ARX * s.L, zc = (ZP - ZONE - 0.05) * s.L, len = 0.16 * s.L, w = s.wave; return [x0, zc, x0 + (len * w.kx) / w.k0, zc + (len * w.kz) / w.k0]; };/** 指の下につかめる物(ブイ A・B・波の向きの取っ手)があるか */function grab(api: Api, s: S) {  const p = api.pointer, R = api.u(28);  let best: string | null = null, bd = R;  s.buoys.forEach((b, n) => { const q = s.v.project(b.m.position); const d = Math.hypot(q.x - p.x, q.y - p.y); if (q.on && d < bd) { bd = d; best = "AB"[n]; } });  if (s.sp.maker) { const a = arrowAt(s), q = s.v.project([a[2] * s.k, 0.6, a[3] * s.k]), d = Math.hypot(q.x - p.x, q.y - p.y); if (q.on && d < bd) best = "arrow"; }  return best;}function dragTo(api: Api, s: S, what: string) {  const g = ground(s, api.pointer.x, api.pointer.y);  if (!g) return;  if (what === "arrow") {    const zc = (ZP - ZONE - 0.05) * s.L, dx = g.x - ARX * s.L, dz = g.z - zc;    if (Math.hypot(dx, dz) < 0.04 * s.L) return;    const deg = clamp((Math.atan2(dx, -dz) * 180) / Math.PI, -45, 45);    api.set("theta", Math.round(deg)); waveParams(api, s, true);    return;  }  const b = s.buoys[what === "A" ? 0 : 1], k = cellAt(s, g.x, g.z);  if (k < 0 || !s.wet[k]) return;  b.fx = g.x; b.fz = g.z; b.k = k; resetBuoy(b);}/** 指でなぞった所の海底を盛る・掘る(なめらかな山の形に) */function sculpt(api: Api, s: S) {  const c = s.sculpt;  if (!c) return;  const sg = api.str("touch") === "raise" ? 1 : -1, R = 0.05 * s.L, amt = sg * s.sp.rate * api.dt, { N } = s;  for (let j = 0; j < N; j++) {    const dz = (cx(s, j) - c.z) / R;    if (Math.abs(dz) > 3) continue;    for (let i = 0; i < N; i++) {      const dx = (cx(s, i) - c.x) / R;      if (Math.abs(dx) > 3) continue;      const k = j * N + i;      s.bed[k] = clamp(s.bed[k] + amt * Math.exp(-0.5 * (dx * dx + dz * dz)), -s.sp.hcap, s.sp.top);    }  }  depth(s); zone(s);  if (!s.noted.sculpt) { s.noted.sculpt = true; api.note(sg > 0 ? "盛った所は浅くなり、その上で波が遅くなります" : "掘った所は深くなり、その上で波が速くなります", 2600); }}/** たたいた所に岩を落とす。津波の場面では、その下の海底が持ち上がる(地震) */function tapAt(api: Api, s: S) {  const g = ground(s, api.pointer.x, api.pointer.y);  if (!g) return;  const k = cellAt(s, g.x, g.z);  if (k < 0 || !s.wet[k]) { if (!s.noted.land) { s.noted.land = true; api.note("陸の上です。海の上をたたくと岩が落ちます", 2000); } return; }  if (s.key === "tsunami") {    uplift(s, g.x, g.z, Math.max(0.5, api.num("H")), 0.05 * s.L, 0.05 * s.L);    api.note("海底が持ち上がり、真上の海面も同じ形に盛り上がりました", 2400);    return;  }  if (s.rocks.length >= 8) { const r = s.rocks.shift(); if (r) s.v.scene.remove(r.m); }  const m = new THREE.Mesh(s.rockGeo, s.mats.rock);  m.castShadow = true; s.v.scene.add(m);  s.rocks.push({ m, x: g.x, z: g.z, y: 30, vy: 0, hit: false });}/** 岩は本物の重力で落ち、海面に着いた所の水を押し下げる */function rocks(s: S, dt: number) {  for (const r of s.rocks) {    r.vy -= G * dt; r.y += r.vy * dt;    if (!r.hit && r.y <= 0) { r.hit = true; const w = Math.max(10, 2 * s.dx); uplift(s, r.x, r.z, -2.5, w, w); } // 岩が押しのけた水(格子で表せる幅にしてある)    r.m.position.set(r.x * s.k, r.y * s.sp.vb * s.k + 1.1, r.z * s.k);  }  const gone = s.rocks.filter((r) => r.y < -s.sp.hcap);  for (const r of gone) s.v.scene.remove(r.m);  if (gone.length) s.rocks = s.rocks.filter((r) => r.y >= -s.sp.hcap);}

海底を盛る・掘るときは、指の下を中心に、なめらかな山の形で海底を上下させる。そのたびに水深と境目の係数、等深線を作り直す。岩は本物の重力加速度で落ち、海面に着いた所の水面を押し下げる。この凹みが輪になって広がる。

three.jsが受け持つ描画

水面と海底は、どちらも格子の点を頂点にした面(heightGrid)で描いている。

描画(src/sims/shallow-waves.ts の 433〜665 行)
// 水面は自前の材質(明るさだけを出す)。光の当たり方・照り返し・高さ・泡(砕けた所)・等深線を 1 つの明るさにまとめる。白黒の網点への変換は共通部品がするconst WATER_VS = /* glsl */ `attribute float aC;attribute float aF;attribute float aE;varying vec3 vN;varying vec3 vP;varying float vC;varying float vF;varying float vE;void main() {  vec4 w = modelMatrix * vec4(position, 1.0);  vP = w.xyz; vN = normalize(mat3(modelMatrix) * normal);  vC = aC; vF = aF; vE = aE;  gl_Position = projectionMatrix * viewMatrix * w;}`;const WATER_FS = /* glsl */ `uniform vec3 uSun;uniform float uLine;varying vec3 vN;varying vec3 vP;varying float vC;varying float vF;varying float vE;void main() {  vec3 n = normalize(vN), l = normalize(uSun), e = normalize(cameraPosition - vP);  // 平らな水面の明るさを 0.28 にして、傾きによる光の当たり方の差を強めに見せる(小さな波の形も網点で読めるように。大きな波は白と黒に振り切れる)  float dif = max(dot(n, l), 0.0) - l.y;  float spe = pow(max(dot(n, normalize(l + e)), 0.0), 48.0);  float b = 0.28 + 2.2 * dif + 0.45 * spe + 0.2 * clamp(vE, -1.5, 1.5);  b = max(b, 0.95 * vF);  // 等深線: 深さの目盛り vC が整数になる所に細い線(線が詰まりすぎる急な所では消す)  // (深さがそろって目盛りが変わらない所には引かない)  float fw = fwidth(vC), d = abs(fract(vC + 0.5) - 0.5), w = max(fw, 1e-4);  float ln = (1.0 - smoothstep(0.5 * w, 1.5 * w, d)) * (1.0 - smoothstep(0.2, 0.45, fw)) * step(1e-4, fw) * step(0.5, vC) * uLine;  b = mix(b, 0.8, ln);  gl_FragColor = vec4(vec3(clamp(b, 0.0, 1.0)), 1.0);}`;const SUN: [number, number, number] = [-0.35, 0.55, 0.76]; // 太陽へ向かう向き(沖の左上。カメラの後ろから照らし、平らな水面が光らないように) /** 一辺 N の格子の配列と、水面・海底・断面の形を用意する */function alloc(s: S, N: number) {  const nn = N * N, nf = (N + 1) * N, zn = N * (Math.ceil(ZONE * N) + 3);  s.N = N;  s.bed = new Float32Array(nn); s.h = new Float32Array(nn); s.wet = new Uint8Array(nn); s.eta = new Float32Array(nn);  s.P = new Float32Array(nf); s.Q = new Float32Array(nf); s.cP = new Float32Array(nf); s.cQ = new Float32Array(nf);  s.sP = new Float32Array(nf); s.sQ = new Float32Array(nf); s.sE = new Float32Array(nn); s.lim = new Float32Array(nn); s.tb = new Float32Array(nn);  s.clip = new Int32Array(nn); s.cr = new Float32Array(nn);  s.zone = { k: new Int32Array(zn), al: new Float32Array(zn), cE: new Float32Array(zn), sE: new Float32Array(zn), cP: new Float32Array(zn), sP: new Float32Array(zn), cQ: new Float32Array(zn), sQ: new Float32Array(zn), n: 0 };  const v = s.v;  if (s.g) for (const m of [s.g.water, s.g.land.mesh, s.g.soil, s.g.wall]) { v.scene.remove(m); m.geometry.dispose(); }  const side = (W * (N - 1)) / N;  const wg = heightGrid(N - 1, N - 1, side, side, s.mats.water);  const aC = new Float32Array(nn), aF = new Float32Array(nn), aE = new Float32Array(nn);  wg.geo.setAttribute("aC", new THREE.BufferAttribute(aC, 1)); wg.geo.setAttribute("aF", new THREE.BufferAttribute(aF, 1)); wg.geo.setAttribute("aE", new THREE.BufferAttribute(aE, 1));  wg.mesh.frustumCulled = false; wg.mesh.receiveShadow = false;  const land = heightGrid(N - 1, N - 1, side, side, s.mats.land);  land.mesh.castShadow = true; land.mesh.frustumCulled = false;  const soil = new THREE.Mesh(skirtGeo(N), s.mats.soil), wall = new THREE.Mesh(skirtGeo(N), s.mats.wall);  soil.frustumCulled = wall.frustumCulled = false;  v.scene.add(wg.mesh, land.mesh, soil, wall);  s.g = {    water: wg.mesh, wpos: (wg.geo.attributes.position as THREE.BufferAttribute).array as Float32Array, wnor: (wg.geo.attributes.normal as THREE.BufferAttribute).array as Float32Array,    wy: new Float32Array(nn), aC, aF, aE, land, landY: new Float32Array(nn), soil, wall, base: 0,  };}/** 海の四辺の断面(縦の帯)。辺ごとに N 個の点の上と下 */function skirtGeo(N: number) {  const geo = new THREE.BufferGeometry(), idx: number[] = [];  geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(4 * N * 2 * 3), 3));  for (let e = 0; e < 4; e++) for (let n = 0; n < N - 1; n++) { const a = (e * N + n) * 2; idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }  geo.setIndex(idx);  return geo;}/** 辺 e(0 = 岸の側・1 = 沖の側・2 = 左・3 = 右)の n 番目のます */const edgeCell = (N: number, e: number, n: number) => (e === 0 ? n : e === 1 ? (N - 1) * N + n : e === 2 ? n * N : n * N + N - 1); /** 海底の形が変わったときの表示: 陸と海底の高さ・等深線の目盛り・断面 */function shapeGfx(s: S) {  const g = s.g;  if (!g) return;  const { N, sp, bed, wet, h } = s, nn = N * N, yb = sp.vb * s.k, lv = sp.levels;  g.base = -sp.hcap * yb - 2;  for (let k = 0; k < nn; k++) {    g.landY[k] = sp.hide && wet[k] ? g.base - 2 : bed[k] * yb;    // 等深線の目盛り: 深さが levels[n] のとき n + 1    const d = h[k];    let c = 0;    if (wet[k]) {      if (d <= lv[0]) c = d / lv[0];      else { let n = 0; while (n < lv.length - 1 && d > lv[n + 1]) n++; c = n + 1 + (n < lv.length - 1 ? (d - lv[n]) / (lv[n + 1] - lv[n]) : 0); }    }    g.aC[k] = c;  }  // 陸の点は、となりの水の点と同じ目盛りにする(岸の三角形に等深線が引かれないように)  for (let k = 0; k < nn; k++) if (!wet[k]) {    const i = k % N;    g.aC[k] = Math.max(i > 0 && wet[k - 1] ? g.aC[k - 1] : 0, i < N - 1 && wet[k + 1] ? g.aC[k + 1] : 0, k >= N && wet[k - N] ? g.aC[k - N] : 0, k < nn - N && wet[k + N] ? g.aC[k + N] : 0);  }  g.land.set(g.landY);  (g.water.geometry as THREE.BufferGeometry).attributes.aC.needsUpdate = true;  const pos = (g.soil.geometry as THREE.BufferGeometry).attributes.position as THREE.BufferAttribute, a = pos.array as Float32Array;  for (let e = 0; e < 4; e++) for (let n = 0; n < N; n++) {    const k = edgeCell(N, e, n), i = k % N, j = (k / N) | 0, o = (e * N + n) * 6, X = cx(s, i) * s.k, Z = cx(s, j) * s.k;    a[o] = X; a[o + 1] = bed[k] * yb; a[o + 2] = Z; a[o + 3] = X; a[o + 4] = g.base; a[o + 5] = Z;  }  pos.needsUpdate = true; g.soil.geometry.computeVertexNormals();  const wp = (g.wall.geometry as THREE.BufferGeometry).attributes.position.array as Float32Array;  wp.set(a);} /** 毎コマ: 水面の高さ・面の向き・泡・断面の水の上の辺・造波板・ブイ */function draw(api: Api, s: S) {  const g = s.g;  if (!g) return;  const { N, sp, eta, wet, bed } = s, nn = N * N, yw = sp.vw * s.k, yb = sp.vb * s.k, y = g.wy, pos = g.wpos, nor = g.wnor;  const aref = sp.maker ? Math.max(0.25, api.num("H") / 2) : 0.3, foamT = sp.maker ? 0.6 * s.wave.T : 300, t = s.time;  for (let k = 0; k < nn; k++) {    const yy = wet[k] ? eta[k] * yw : g.base - 1; // 陸の点では水面を海底の下へ沈め、岸で陸の面と交わらせる    y[k] = yy; pos[3 * k + 1] = yy;    g.aE[k] = wet[k] ? eta[k] / aref : 0;    const f = 1 - (t - s.tb[k]) / foamT;    g.aF[k] = f > 0 ? f : 0;  }  // 面の向き: 水面の傾き(となりのますとの差)から。陸のとなりでは片側の差を使う  const d2 = 2 * s.dx * s.k;  for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {    const k = j * N + i, o = 3 * k;    if (!wet[k]) { nor[o] = 0; nor[o + 1] = 1; nor[o + 2] = 0; continue; }    const l = i > 0 && wet[k - 1] ? y[k - 1] : y[k], r = i < N - 1 && wet[k + 1] ? y[k + 1] : y[k];    const b = j > 0 && wet[k - N] ? y[k - N] : y[k], f = j < N - 1 && wet[k + N] ? y[k + N] : y[k];    const gx = (r - l) / d2, gz = (f - b) / d2, m = 1 / Math.sqrt(1 + gx * gx + gz * gz);    nor[o] = -gx * m; nor[o + 1] = m; nor[o + 2] = -gz * m;  }  const at = (g.water.geometry as THREE.BufferGeometry).attributes;  at.position.needsUpdate = true; at.normal.needsUpdate = true; at.aF.needsUpdate = true; at.aE.needsUpdate = true;  // 断面の水: 上の辺は水面、下の辺は海底  const wp = (g.wall.geometry as THREE.BufferGeometry).attributes.position as THREE.BufferAttribute, w = wp.array as Float32Array;  for (let e = 0; e < 4; e++) for (let n = 0; n < N; n++) {    const k = edgeCell(N, e, n), o = (e * N + n) * 6;    if (wet[k]) { w[o + 1] = y[k]; w[o + 4] = bed[k] * yb; } else { w[o + 1] = w[o + 4] = bed[k] * yb; }  }  wp.needsUpdate = true;  // 造波板: 板が前後に動き、となりの板と少しずつずれて、斜めの波を作る  const wv = s.wave, amp = clamp(wv.a * 1.6, 0, 1.4);  for (const m of s.pads) if (m.visible) m.position.z = ZP * W + 0.6 - amp * Math.cos(wv.ph - (wv.kx * m.position.x) / s.k);  for (const b of s.buoys) b.m.position.set(b.fx * s.k, (b.k >= 0 && wet[b.k] ? eta[b.k] * yw : 0) + 0.2, b.fz * s.k);} /** つかめる物の印: 点線の輪(地の色で縁取りして、明るい水面の上でも見えるように) */function ring(api: Api, x: number, y: number, r: number) {  api.circle(x, y, r, { w: api.u(4), stroke: "paper" });  api.circle(x, y, r, { w: api.u(1.6), dash: [api.u(3), api.u(3)] });} /** 3D の絵の上に重ねる文字と印: ブイの名前・波の向きの矢印と取っ手・物差し・なぞる範囲 */function overlay(api: Api, s: S) {  const v = s.v, U = api.u, k = s.k, sp = s.sp;  if (sp.maker) {    const a = arrowAt(s), p = v.project([a[0] * k, 0.6, a[1] * k]), q = v.project([a[2] * k, 0.6, a[3] * k]);    if (p.on && q.on) {      api.arrow(p.x, p.y, q.x, q.y, { w: U(6), head: U(15), stroke: "paper" });      api.arrow(p.x, p.y, q.x, q.y, { w: U(2.5), head: U(11) });      ring(api, q.x, q.y, U(15));      api.label(`θ ${fmt((s.wave.th * 180) / Math.PI, 0)}°`, q.x + U(20), q.y + U(5), { size: U(13) });    }    const span = sp.per ? 1 : 1 - 2 * SPONGE, c = v.project([-W * span * 0.42, 2.2, ZP * W + 0.6]);    if (c.on) api.label("造波板", c.x, c.y - U(6), { size: U(12), align: "center" });  }  if (s.key === "tsunami") {    // 岸へ向かう津波の山(左寄りの列で、海面がいちばん高い所)    const N = s.N, i = N >> 2;    let best = -1, top = 0.05;    for (let j = 0; j < N; j++) { const q = j * N + i; if (s.wet[q] && cx(s, j) < 0.2 * s.L && s.eta[q] > top) { top = s.eta[q]; best = j; } }    if (best >= 0) {      const p = v.project([cx(s, i) * k, top * sp.vw * k + 1, cx(s, best) * k]);      if (p.on) api.label(`津波の山 ${fmt(top, 2)} m`, p.x, p.y - U(14), { size: U(13), align: "center" });    }  }  s.buoys.forEach((b, n) => {    const p = v.project(b.m.position);    if (!p.on) return;    ring(api, p.x, p.y, U(14));    api.label("AB"[n], p.x + U(16), p.y - U(10), { size: U(14), weight: 700 });  });  // 物差し(手前の辺に沿って)と縦の倍率  const z = (W / 2) * ((s.N - 1) / s.N) + 1.5, x0 = -W * 0.42, x1 = x0 + sp.bar * k;  const a = v.project([x0, 0, z]), b = v.project([x1, 0, z]);  if (a.on && b.on) {    for (const [w, c] of [[U(5), "paper"], [U(2), "ink"]] as const) {      api.line(a.x, a.y, b.x, b.y, { w, stroke: c });      api.line(a.x, a.y - U(5), a.x, a.y + U(5), { w, stroke: c }); api.line(b.x, b.y - U(5), b.x, b.y + U(5), { w, stroke: c });    }    const vert = sp.vb === sp.vw ? `縦は ${sp.vb} 倍` : `縦は海底 ${sp.vb} 倍・波 ${sp.vw.toLocaleString("ja-JP")} 倍`;    const tx = clamp(Math.min(a.x, b.x), U(8), api.w - U(230)), ty = Math.min(Math.max(a.y, b.y) + U(22), api.h - U(10));    api.label(`${sp.barText}(${vert})`, tx, ty, { size: U(12) });  }  // 盛る・掘るの範囲  if (api.str("touch") !== "view" && api.pointer.inside && s.drag !== "A" && s.drag !== "B" && s.drag !== "arrow") {    const g = ground(s, api.pointer.x, api.pointer.y);    if (g) {      const R = 0.05 * s.L * k * 1.6, c = v.project([g.x * k, 0, g.z * k]), e = v.project([g.x * k + R, 0, g.z * k]);      ring(api, c.x, c.y, Math.max(U(8), Math.hypot(e.x - c.x, e.y - c.y)));    }  }} function readout(api: Api, s: S) {  const sp = s.sp, w = s.wave, ts = s.key === "tsunami", lines: string[] = [];  const dep = (h: number) => (h >= 100 ? Math.round(h).toLocaleString("ja-JP") : fmt(h, h < 10 ? 1 : 0));  const vel = (c: number) => (ts ? `${Math.round(c * 3.6).toLocaleString("ja-JP")} km/h` : `${fmt(c, 1)} m/s`);  if (sp.maker) lines.push(`沖  水深 ${dep(sp.h0)} m  速さ ${vel(w.c0)}  波長 ${fmt(w.c0 * w.T, 0)} m`);  else lines.push(`沖  水深 ${dep(sp.h0)} m  速さ ${vel(Math.sqrt(G * sp.h0))}`);  s.buoys.forEach((b, n) => {    const name = `ブイ${"AB"[n]}`;    if (b.k < 0 || !s.wet[b.k]) { lines.push(`${name}  陸の上`); return; }    const h = s.h[b.k], c = Math.sqrt(G * h);    if (ts) { lines.push(`${name}  水深 ${dep(h)} m  速さ ${vel(c)}  いちばん高い水位 ${fmt(Math.max(0, b.top), 2)} m`); return; }    const d = dirOf(s, b);    lines.push(`${name}  水深 ${dep(h)} m  速さ ${vel(c)}  波高 ${fmt(heightOf(b), 2)} m  向き ${d === null ? "定まらない" : fmt(d, 0) + "°"}`);  });  const want = Number(api.str("speed")) || 1, got = api.playing && s.cap > 0.5 ? clamp(Math.round(s.spd), 1, want) : want;  const sp2 = `実際の ${got} 倍の速さ${got < want ? "(計算が追いつく上限)" : ""}`;  if (ts) { const m = Math.floor(s.time / 60), sec = Math.floor(s.time % 60); lines.push(`地震から  ${m}分${sec}秒(${sp2})`); }  else lines.push(`時間  ${s.spin > 0 ? "波が岸に届くまで早送り中" : sp2}`);  api.readout(lines);} function notes(api: Api, s: S) {  if (s.key !== "tsunami" && s.brk > 0 && !s.noted.brk && api.t > 4) { s.noted.brk = true; api.note("浅い所で波が崩れています。波高が水深の 0.8 倍ほどになると、波は形を保てません", 3400); }  const B = s.buoys[1];  if (s.key === "tsunami" && !s.noted.shelf && B.top > 0.3) { s.noted.shelf = true; api.note("大陸棚に入った津波は遅くなり、そのぶん高くなります", 3400); }}

水面は毎コマ、頂点の高さと面の向きだけを書き換える。面の向きは、隣の点との高さの差(水面の傾き)から計算する。水面の材質は自前で、光の当たり方と照り返し、水面の高さ、砕けた所の泡、等深線を1つの明るさにまとめて出すだけである。その明るさを白と黒の網点に直す仕事と、輪郭線を引く仕事は、全記事に共通の部品が受け持つ。陸のますでは水面の点を海底より下へ沈めておく。こうすると、水面と陸の面が交わる所が、そのまま波打ち際になる。

縦の長さは引き伸ばして描いている。津波のほかの場面は海底も波も6倍、津波の場面は海底を10倍、波を12,000倍にした。津波の場面で水の下の海底を描かないのは、波だけを大きく引き伸ばすと、谷が海底を突き抜けて見えてしまうからだ。

画面の部品(描画・場面の切り替え・画質の自動調整・GPU の網点)は全記事で共通です。シミュレーションの共通の仕組みで解説しています。

プログラム全体を見る(src/sims/shallow-waves.ts・782 行)

読み込んでいます

姉妹サイト

株式会社Game が運営するメディアです。