音で揺らした水面は、揺れの半分の周波数で模様を描く

皿の水を50 Hzで上下に揺らすと、水面には25 Hzで上下する格子の模様が立つ。容器が0.03 mmほど上下するだけで、波長およそ1 cmの波が育つ。

記事

音で揺らした水面は、揺れの半分の周波数で模様を描く

皿の水を50 Hzで上下に揺らすと、水面には25 Hzで上下する格子の模様が立つ。容器が0.03 mmほど上下するだけで、波長およそ1 cmの波が育つ。

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

現象

皿の水に立つ格子

スピーカーの上に浅い皿を置いて水を張り、低い音を鳴らしながら少しずつ大きくしていくと、ある音量を境に、平らだった水面に細かな模様が一面に立つ。音を小さくすると模様は消え、大きくするとまた現れる。音を高くすれば模様は細かく、低くすれば粗い。

この模様を最初に詳しく調べたのは、マイケル・ファラデーだ。1831年の論文で、彼は振動する板の上に水やインク、牛乳、水銀をのせ、表面にできる小さな山の並びを観察した1。山は直角に、きわめて規則正しく並び、一つの山のまわりを八つの山が囲む。高い音では小さく、低い音では大きい。そして、一つの山が同じ場所にもう一度現れるのは、板が2回振動するごとだと書き残している。水面は、揺らした周波数の半分で上下していたのである。

数字にすると、ふしぎさがはっきりする。50 Hzで、重力の0.3倍の加速度で揺らすとき、容器が上下する幅は片側0.03 mmにすぎない。それでも水面には、波長およそ1 cmの波が、揺れの2倍の周期で上下しながら育つ。小さな揺れが、なぜ別のリズムの大きな波になるのか。

仕組み

揺れる容器の中で、重力が強まったり弱まったりする

容器と一緒に上下しながら水を見ると、容器が上向きに加速するあいだ水は重くなり、下向きに加速するあいだは軽くなる。エレベーターが動き出す瞬間に、体が重く感じたり軽く感じたりするのと同じだ。容器の上下をと書くと、水が感じる重力は次のようになる。

は揺らす周波数(Hz)、は容器の上下の片側の幅(m)、は重力加速度9.8 m/s²である。は、揺れの加速度が重力の何倍かを表す。シミュレーションの「振幅Γ」はこの値だ。周波数の2乗が効くので、高い音ほど小さなで同じになる。冒頭の50 Hz・では、は0.03 mmになる。

水面の波は、盛り上がった所が重力と表面張力に引き戻されて上下する。波長の波がひとりでに上下する速さは、次の分散の式で決まる。

は波の角振動数(rad/s。1秒あたりの上下の回数の倍)、は波数(1/m)、は表面張力(N/m)、は液体の密度(kg/m³)、は水深(m)だ。水の場合、波長がおよそ1.7 cmより長い波はおもに重力で、それより短い波はおもに表面張力で引き戻される。水が浅いとが小さくなり、同じ波長でも上下がゆっくりになる。

容器を揺らすと、引き戻す力のうち重力の分だけが、周期的に強まったり弱まったりする。これはブランコの立ちこぎに似ている。立ってこぐ人は、いちばん低い所で立ち上がり、端に来るとしゃがむ。ブランコが1往復するあいだに、重心は2回上下する。揺れそのものを押すのではなく、揺れの速さを決める量(ブランコなら支点から重心までの長さ)を周期的に変えて、振れを育てる。これをパラメトリック共振という。

立つ・しゃがむを1往復に2回にすると、振れ幅は1往復ごとに少しずつ大きくなる。同じ幅でも1往復に1回では、振れはほとんど育たない。グラフを見ると、重心までの長さは、振れの角度の2倍の速さで上下している。

水面でも同じことが起きる。引き戻す力が変わる周期は、容器の揺れの周期そのものだ。立ちこぎと同じく、育つのは「自分が1回上下するあいだに、力の変化が2回来る」波、つまりで上下する波である。上下の周波数はになる。分散の式にを入れると、模様の波長が一つに決まる。水深10 mmの水なら、14 Hzで約36 mm、50 Hzで約9.9 mm、120 Hzで約5.2 mmだ2。音を高くすると模様が細かくなるのは、このためである。

10 20 50 100 200 2 5 10 20 50 100 揺らす周波数f(Hz) 模様の波長λ(mm) 水(水深10 mm) 水(水深3 mm) シリコーン油20 mm²/s(水深10 mm)

14 Hzで36 mm50 Hzで9.9 mm120 Hzで5.2 mm

揺らす周波数と模様の波長(分散の式から計算。水は20 °C、油は動粘度20 mm²/sのシリコーンオイル)

ただし、揺らせば必ず模様ができるわけではない。波は上下するたびに、液体の粘りや底との摩擦で少しずつ勢いを失う。揺れから受け取るエネルギーが、失う分を上回ったときだけ波は育つ。境目の振幅をしきい値と呼び、ブランコと同じ形の方程式(減衰のあるマシュー方程式)から、次のように見積もれる3。

は、波の振れ幅が減っていく速さ(1/s)だ。深い液体では、粘りによってになる(は動粘度、m²/s)4。かっこの中の項は、揺れで変わるのが重力だけで、表面張力は変わらないことの補正である。表面張力が主に引き戻す短い波ほど、揺れが効きにくい。

水深10 mmの水では、しきい値は50 Hzで約0.08、120 Hzで約0.37になる。動粘度が水の20倍のシリコーンオイルでは、50 Hzで約2.3と、30倍近い力で揺らさないと模様が立たない。粘る液体ほど強く揺らす必要があるのは、勢いを失う速さが粘りに比例するからだ。

10 20 50 100 200 0.01 0.1 1 10 揺らす周波数f(Hz) しきい値Γc(gの何倍)

水深10 mm・粘りと底の減衰だけで見積もった値 シリコーン油20 mm²/s水水はこの線より強いと模様が育つ弱いと消えていく

しきい値Γcと揺らす周波数(粘りと底の減衰だけで見積もった値)

どんな形の模様になるかは、この式からは決まらない。同じ波長の波がいくつもの向きに同時に育ち、その組み合わせ方は、波どうしが強め合ったり弱め合ったりする非線形の効果で決まる。実験では、条件によって平行な線、正方形、六角形などが現れる5。皿が波長の数倍ほどしかないと、壁で跳ね返った波どうしも重なる。このシミュレーションでは、円い皿で同心円や放射状の花びら、四角い容器で壁に沿った格子が現れる。

試す

音の高さと揺らす強さを変えてみる

題の下のシミュレーションは、直径10 cmの皿に水を10 mm張り、上から見ている。網点の白と黒は、左上から光を当てたときの水面の傾きを表す。断面のグラフは直径に沿った高さで、縦を大きく拡大してある(倍率はグラフの右上に出る)。振動波形の上の段は容器の上下、下の段は印の所の水面の上下だ。容器の揺れ2回で、水面は1回だけ上下する。水面は実際には1秒に何十回も上下するので、画面ではストロボで照らしたように、波の1周期ごとの姿を少しずつずらして見せている。

  1. 低い音で揺らす。14 Hzでは波長が約36 mmと、皿の直径に3つほどしか入らない。波は皿の壁で跳ね返り、皿全体を使った大きな模様にまとまる。
  2. 高い音で揺らす。120 Hzでは波長が約5.2 mmまで縮み、皿の中に細かな模様がびっしり並ぶ。中ほどには格子が、壁の近くには壁に沿った輪ができやすい。振動波形の横軸は水面が1回上下する時間で、そのあいだに容器は2回上下している。
  3. しきい値の手前まで弱める。50 Hzで、振幅をしきい値の0.7倍にすると、立っていた模様が数秒で消えていく。揺れから受け取るエネルギーより、粘りや摩擦で失うエネルギーのほうが大きいからだ。画面のしきい値は、壁ぎわの摩擦の分だけ、図の見積もりより少し高い。
  4. 四角い容器に替える。56 Hzでは、1辺10 cmに波長9.1 mmの波がちょうど11個入る。シミュレーションの壁では水面の傾きが0になるので、この条件に合う波が残り、壁に沿った正方形の格子になる。
  5. かき混ぜてから待つ。36 Hzで揺らしている水面を指がかき混ぜ、模様はいったん乱れる。指が離れてしばらく待つと、同じ波長の模様がまた育ってくる。波長を決めるのは揺らす周波数で、かき混ぜ方ではない。
  6. マイクの音で揺らす。マイクの使用を許可すると、10〜240 Hzでいちばん強い音の高さと大きさで皿を揺らす。ハミングの高さを変えると模様の細かさが変わり、声を小さくすると模様が消える。音は端末の中だけで使い、外へは送らない。マイクが使えないときは、画面にその知らせが出る。

液体を「シリコーン油」に替えると、同じ周波数でも数十倍強く揺らさないと模様が立たない。立った模様は、水の格子とは違う縞になりやすく、円い皿では同心円の輪になる(シミュレーションでの近似)。

現実では

実験室と身近な所で

ファラデーは、ガラス板の代わりに細長い木の板をゆっくり振動させて、山1個の幅が2.5インチ(約6 cm)になるまで模様を大きくした。こうして目で追えるようにしたうえで、山が2組あり、一方が上がるとき他方が下がることを確かめている。水の量を減らすと山が小さく、増やすと大きくなることにも気づいていた1。浅いと同じ周波数で波長が短くなるという、分散の式のの効果である。冷たい油ではなかなか模様が立たず、温めて流れやすくすると立つことも書いている。

この現象の理論は、1954年にベンジャミンとアーセルがまとめた6。水面の波を成分に分けると、一つ一つがマシュー方程式に従い、揺らす周波数の半分の整数倍で上下する波だけが育ちうることを示した。油滴の実験に使うシリコーンオイルでも、いちばん先に現れるのは半分の周波数の波だ7。

21世紀に入って、この模様は思わぬ研究に使われている。2005年、クーデールらは、揺らしたシリコーンオイルの上で跳ねる直径1 mmほどの油滴が、自分の作った波に押されて水平に歩き出すことを報告した8。実験では、しきい値のわずかに下で揺らし、平らな液面に滴が跳ねるたびに半分の周波数の波が長く残るようにする。使う油の動粘度は10〜100 mm²/s、揺らす周波数は20〜150 Hz、加速度は重力の5倍まで、波長は3〜10 mmほどだ。スピーカーのウーファーにペトリ皿を貼り付け、ストロボの光で照らすだけでも見られるという7。

ブランコの立ちこぎは、パラメトリック共振の例としてよく使われる。ただ、実際にこぐ人の動きを測ると、それだけではない。18人の動きを調べた研究では、重心を上下させる分より、体を回して直接揺れを押す分が大きく、重心の上下の役目は振れが大きくなってから少し増える程度だった9。

省いていること

本物の皿との違い

  • 分散の式は、揺らす周波数の半分で立つ波長の所だけで本物に合わせている。それより長い波や短い波の速さは本物とずれるので、指でたたいた波紋の広がり方は目安にとどまる。
  • 波が勢いを失う原因は、液体の粘り、底との摩擦、壁ぎわの摩擦(帯で近似)だけを入れた。水面と壁の接する線での損失や、水面の汚れの膜による損失は省いている。本物の容器では減衰が大きく、しきい値はもっと高くなる。粘りを正しく入れたしきい値の計算はクマールとタッカーマンが示している10。
  • 模様の選ばれ方と、育ちきったときの高さは、傾きの2乗に比例する減衰で近似した。水では格子、粘る油では縞になるように項の混ぜ方を選んでおり、本物の流体の計算から導いたものではない。
  • 強く揺らしたときに波の山がくずれて滴が飛ぶこと、壁ぎわの水面の曲がりが出す波、スピーカーや皿そのものの共振は入れていない。揺れは正弦波1つで、マイクの音もいちばん強い成分だけを使う。
  • 画面は、実時間で計算した水面を、波の1周期ごとに光らせるストロボで見た姿だ。目で直接見る本物の模様は、速く上下してちらついて見える。
参考文献

参考文献

  1. Faraday, M.「On a peculiar class of acoustical figures; and on certain forms assumed by groups of particles upon vibrating elastic surfaces」『Philosophical Transactions of the Royal Society of London』121, 299–340, 1831.
  2. Benjamin, T. B., Ursell, F.「The stability of the plane free surface of a liquid in vertical periodic motion」『Proceedings of the Royal Society of London. Series A』225(1163), 505–515, 1954.
  3. Kumar, K., Tuckerman, L. S.「Parametric instability of the interface between two fluids」『Journal of Fluid Mechanics』279, 49–68, 1994.
  4. Edwards, W. S., Fauve, S.「Patterns and quasi-patterns in the Faraday experiment」『Journal of Fluid Mechanics』278, 123–148, 1994.
  5. Couder, Y., Protière, S., Fort, E., Boudaoud, A.「Walking and orbiting droplets」『Nature』437(7056), 208, 2005.
  6. Bush, J. W. M.「Pilot-wave hydrodynamics」『Annual Review of Fluid Mechanics』47, 269–292, 2015.
  7. Post, A. A., de Groot, G., Daffertshofer, A., Beek, P. J.「Pumping a playground swing」『Motor Control』11(2), 136–150, 2007.
  8. Landau, L. D., Lifshitz, E. M.『Mechanics』3rd ed., Pergamon Press, 1976.

注

  1. 文献1の第65・79・82・98・101節。山を作る液体の観察は、論文の後半(付録)にある。 ↩ ↩2

  2. 水は20 °Cの値(密度998 kg/m³、表面張力72.7 mN/m、動粘度1.00 mm²/s。NIST Chemistry WebBook)。シリコーンオイルは信越化学工業KF-96-20csの技術資料「シリコーンオイルKF-96性能試験結果」の値(動粘度20 mm²/s、比重0.950、25 °C)と、表面張力20.6 mN/mを使った(同資料では20〜21 mN/m)。 ↩

  3. 文献8の第27節。減衰のある振動子の振動数を周期的に変えるとき、振れが育つ条件は、変化の割合がを超えることになる。水面では、重力の分だけが変わるので、変化の割合はになる。 ↩

  4. 深い液体の波の、粘りによる減衰(H. Lamb『Hydrodynamics』第348節)。浅いときは、底の境界層による減衰も足した。 ↩

  5. 文献4(エドワーズとフォーブ、1994年)。粘りが100 mm²/sの液体を2つの周波数で揺らした実験。 ↩

  6. 文献2(ベンジャミンとアーセル、1954年)。 ↩

  7. 文献6(ブッシュ、2015年)。油滴の実験の条件と、ウーファーとストロボで見る方法もここから。 ↩ ↩2

  8. 文献5(クーデールら、2005年)。 ↩

  9. 文献7(ポストら、2007年)。 ↩

  10. 文献3(クマールとタッカーマン、1994年)。 ↩

プログラム
プログラム

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

このシミュレーションは、皿の水面を細かなマスに分け、GPUで全部のマスを同時に計算している。1回の刻みは2段に分かれる。まずマスごとに水面の曲がり具合を求め、次にそれを使って、上下の速さと高さを少しだけ進める。これを1コマに数十回くり返す。周波数や水深、液体を変えるたびに、まずCPUで分散の式としきい値を解き、GPUに渡す係数をそろえる。

液体と容器の数字

長さはmm、時間は秒で計算する。表面張力と密度は、式の中では(mm³/s²)の形でしか出てこないので、液体ごとにこの比を作っておく。容器は直径(四角なら1辺)100 mm。格子の細かさは「1波長に何マス入れるか」で決め、画質が高いほど多くする。

定数と単位(src/sims/faraday-waves.ts の 26〜39 行)
const G = 9810; // 重力加速度 mm/s²const D = 100; // 容器の直径(四角は 1 辺)mmconst LIQ: Record<string, { label: string; rho: number; sigma: number; nu: number }> = {  water: { label: "水", rho: 998.2, sigma: 0.0727, nu: 1.0 }, // 20 °C。σ N/m・ρ kg/m³・ν mm²/s  oil: { label: "シリコーン油", rho: 950, sigma: 0.0206, nu: 20 }, // ジメチルシリコーンオイル 20 mm²/s(25 °C)};const NLAM = [8, 9, 11], NMIN = [80, 96, 128], NMAX = [224, 288, 384]; // 1 波長のマスの数・格子の大きさ(画質 0・1・2)const CELLS = [1.2e6, 2.5e6, 5e6]; // 1 コマの計算量の上限(マスの数 × 刻みの数)const MAXSTEP = 64;const DRIFT = 1.5; // ストロボで見た水面の見かけの上下(回/秒)const PW = 256; // 断面を読む点の数const SB = 0.35; // 波がくだけ始める傾きconst FING = { d: 0.6, a: 3 }; // 指が押し下げる深さ・広がり mmconst WALL = 2.5; // 壁ぎわの帯の減衰 ÷ 粘りの減衰

FINGは指で水面を押す強さ、WALLは壁ぎわの摩擦の強さである。どちらも本物の値を測ったものではなく、見え方を決める目安だ。

分散の式から、波長と刻みを決める

solveKは、本文の分散の式にを入れ、波数を二分法で求める。小さすぎると大きすぎるの間を半分ずつ狭めていく、確実な解き方である。

分散としきい値(src/sims/faraday-waves.ts の 41〜115 行)
// Phys: つまみから作る計算の数。f 周波数・wc 波の角振動数 πf・k と lam 波数と波長・Gc しきい値・N 格子のマス数・dx マスの幅・// np 波の 1 周期の刻みの数・dt 刻み・hg と hs 重力と表面張力の項の係数・U 1 刻みの減衰・Z 容器の上下の片振幅(mm)interface Phys {  f: number; om: number; wc: number; k: number; lam: number; h: number; s: number; nu: number; Gam: number;  gam: number; Gc: number; Gf: number; m: number; N: number; dx: number; np: number; dt: number;  hg: number; hs: number; ct: number; U: number; Fd: number; rlin: number; rnl: number; loc: number; brk: number; wall: number; wd: number; Z: number; liq: string; shape: string;} function solveK(f: number, h: number, s: number) {  const w2 = (Math.PI * f) ** 2;  let lo = 1e-5, hi = 60;  for (let i = 0; i < 90; i++) { const m = 0.5 * (lo + hi); if ((G * m + s * m * m * m) * Math.tanh(m * h) > w2) hi = m; else lo = m; }  return 0.5 * (lo + hi);}/** 9 点のラプラシアンの大きさ × dx²(波数 (tx, ty)/dx の波に対して) */const L9 = (tx: number, ty: number) => (20 - 8 * Math.cos(tx) - 8 * Math.cos(ty) - 4 * Math.cos(tx) * Math.cos(ty)) / 6; /** 1 つの波(k_c)の、この計算の刻みでのしきい値(フロケの方法: 揺れの 1 周期の写像の固有値が 1 を超える Γ) */function threshold(P: Phys) {  const half = P.np / 2;  const rho = (Gam: number) => {    let a1 = 1, b1 = 0, a2 = 0, b2 = 1; // (η, v/ω)    for (let n = 0; n < half; n++) {      const W2 = P.ct * P.wc * P.wc * (P.Gf * (1 - Gam * Math.cos(P.om * n * P.dt)) + (1 - P.Gf));      b1 = P.U * (P.Fd * b1 - (P.dt * W2 * a1) / P.wc); a1 += P.dt * P.wc * b1;      b2 = P.U * (P.Fd * b2 - (P.dt * W2 * a2) / P.wc); a2 += P.dt * P.wc * b2;    }    const tr = a1 + b2, det = a1 * b2 - a2 * b1, disc = tr * tr - 4 * det;    if (disc >= 0) { const q = Math.sqrt(disc); return Math.max(Math.abs(tr + q), Math.abs(tr - q)) / 2; }    return Math.sqrt(Math.abs(det));  };  if (rho(60) <= 1) return Infinity;  let lo = 0, hi = 60;  for (let i = 0; i < 36; i++) { const m = 0.5 * (lo + hi); if (rho(m) > 1) hi = m; else lo = m; }  return hi;} function physics(api: Api): Phys {  const f = clamp(api.num("freq") || 40, 5, 400), Gam = Math.max(0, api.num("amp") || 0), h = clamp(api.num("depth") || 10, 1, 50);  const liq = LIQ[api.str("liquid")] ? api.str("liquid") : "water", L = LIQ[liq];  const s = (L.sigma / L.rho) * 1e9, nu = L.nu; // σ/ρ mm³/s²  const wc = Math.PI * f, om = 2 * wc, k = solveK(f, h, s), lam = TAU / k, q = api.quality;  const N = clamp(Math.round(((D / lam) * NLAM[q] + 4) / 16) * 16, NMIN[q], NMAX[q]);  const dx = D / (N - 4);  // 格子の上で、波数 k の波に効く Δ と Δ² の大きさ(向きで平均)  const th = k * dx; let K1 = 0, K2 = 0, q5 = 0; const nA = 24;  for (let i = 0; i < nA; i++) {    const p = (i / nA) * TAU, tx = th * Math.cos(p), ty = th * Math.sin(p), a = L9(tx, ty) / (dx * dx);    K1 += a / nA; K2 += (a * a) / nA; q5 += (4 * Math.sin(tx / 2) ** 2 + 4 * Math.sin(ty / 2) ** 2) / nA;  }  const he = Math.tanh(k * h) / k, hg = (he * k * k) / K1, hs = (he * k ** 4) / K2;  const Kmax = 6 / (dx * dx); // 壁のある格子のラプラシアンの大きさの上限(余裕をみた値)  const wmax = Math.sqrt(hg * G * (1 + Math.max(Gam, 1)) * Kmax + hs * s * Kmax * Kmax);  let np = Math.max(16, Math.ceil((TAU * wmax) / (1.8 * wc)));  np += np % 2;  const dt = 2 / f / np;  const ct = ((2 / (wc * dt)) * Math.sin((wc * dt) / 2)) ** 2; // 時間の刻みで振動が速くなる分の補正  const x2 = 2 * k * h;  const gam = 2 * nu * k * k + (x2 > 40 ? 0 : (k * Math.sqrt((nu * wc) / 2)) / Math.sinh(x2)); // 粘り + 底の境界層  const rlin = Math.min(0.1, (4 * nu * dt) / (dx * dx));  const Fd = 1 - rlin * q5;  const U = Math.min(1, Math.exp(-2 * gam * dt) / Fd); // k_c での減衰がちょうど 2γ になるように残りを一様に掛ける  const delta = gam / wc, m = clamp((delta - 0.03) / 0.12, 0, 1) * 0.7; // 粘る液体ほど縞になりやすい形を混ぜる  const bdiv = (470 * gam) / (k * k); // 傾きの 2 乗に比例する減衰の強さ(Γ = 2Γc で傾き 0.15 ほどで飽和)  // 壁の境界層の減衰: 壁から λ/4 ほどの帯に、粘りの減衰の WALL 倍を置く。容器全体で平らにならした分をしきい値の計算に足す  const wd = lam / 4, gw = WALL * gam, gwAvg = gw * ((2 * wd) / (D / 2));  const P: Phys = {    f, om, wc, k, lam, h, s, nu, Gam, gam, Gc: 0, Gf: G / (G + s * k * k), m, N, dx, np, dt, hg, hs, ct, U, Fd, rlin,    rnl: (dt * bdiv * (1 - m)) / (dx * dx), loc: dt * 3 * 470 * gam * m, brk: dt * 60 * gam, wall: 2 * gw * dt, wd: wd / dx, Z: (Gam * G) / (om * om), liq, shape: api.str("shape"),  };  P.Gc = threshold({ ...P, U: P.U * Math.exp(-2 * gwAvg * dt) });  return P;}/** 飽和したときの傾きの目安 */const satSlope = (Gam: number, Gc: number) => Math.sqrt(0.0225 * Math.max(0, Gam / Gc - 1));

GPUの上ではをそのまま扱えないので、をと置き換え、ラプラシアン(曲がり具合)で書ける形にした。係数hg・hsは、マス目の上で計算したときに、ちょうどの波がで上下するように合わせてある。マス目が粗いと曲がり具合が少し小さく出るので、その分もここで補う。

1刻みの時間dtは、いちばん細かい波でも計算が壊れない長さから決める。表面張力の効く細かい波ほど速く上下するので、波の1周期を数十から数百の刻みに分けることになる。

thresholdは、本文のしきい値を、実際の計算と同じ刻みで求める。波を1つだけ取り出し、揺れの1周期分を2通りの始め方で進めて、1周期後に振れが何倍になるかを調べる。1倍を超えれば育つ。その境目のを二分法で探している。

GPUで水面の式を解く

FS_AとFS_Bは、マスごとに同時に動く小さなプログラム(シェーダー)である。FS_Aが高さの曲がり具合と傾きの2乗を求め、FS_Bがそれを使って速さと高さを進める。

水面の式(src/sims/faraday-waves.ts の 117〜196 行)
// GPU の小さなプログラム(シェーダー)。1 つのマスごとに同時に動く。r = 高さ η(mm)、g = 上下の速さ v(mm/s)const HEAD = `#version 300 esprecision highp float;precision highp int;precision highp sampler2D;uniform float uN;uniform float uR;uniform float uShape;out vec4 o;bool inside(ivec2 c) {  int n = int(uN);  if (c.x < 0 || c.y < 0 || c.x >= n || c.y >= n) return false;  vec2 p = vec2(c) + 0.5 - uN * 0.5;  return uShape < 0.5 ? dot(p, p) < uR * uR : max(abs(p.x), abs(p.y)) < uR;}float hash(vec2 p, float s) { vec3 q = fract(vec3(p.xyx) * 0.1031 + s * 0.0137); q += dot(q, q.yzx + 33.33); return fract((q.x + q.y) * q.z); }`;// 1. Δη と傾きの 2 乗const FS_A = `uniform sampler2D uS;uniform float uIdx2;uniform float uIdx;float at(ivec2 q, float c0) { return inside(q) ? texelFetch(uS, q, 0).r : c0; }void main() {  ivec2 c = ivec2(gl_FragCoord.xy);  if (!inside(c)) { o = vec4(0.0); return; }  float e0 = texelFetch(uS, c, 0).r;  float l = at(c + ivec2(-1, 0), e0), r = at(c + ivec2(1, 0), e0), d = at(c + ivec2(0, -1), e0), u = at(c + ivec2(0, 1), e0);  float ld = at(c + ivec2(-1, -1), e0), rd = at(c + ivec2(1, -1), e0), lu = at(c + ivec2(-1, 1), e0), ru = at(c + ivec2(1, 1), e0);  float lap = (4.0 * (l + r + d + u) + (ld + rd + lu + ru) - 20.0 * e0) / 6.0 * uIdx2;  // 傾き: ふつうは両隣の差 ÷ 2。壁ぎわで片方が外なら、内側との差をそのまま使う(半分に小さく見積もらない)  float hx = inside(c + ivec2(-1, 0)) && inside(c + ivec2(1, 0)) ? 0.5 : 1.0;  float hy = inside(c + ivec2(0, -1)) && inside(c + ivec2(0, 1)) ? 0.5 : 1.0;  float gx = hx * (r - l) * uIdx, gy = hy * (u - d) * uIdx;  o = vec4(lap, gx * gx + gy * gy, 0.0, 0.0);}`;// 2. 1 刻み進めるconst FS_B = `uniform sampler2D uS;uniform sampler2D uT;uniform float uIdx2, uDt, uGt, uHg, uHs, uSs, uCt, uU, uRlin, uRnl, uLoc, uBrk, uSb2, uNoise, uSeed, uWall, uWd;uniform vec4 uF;float lt(ivec2 q, float c0) { return inside(q) ? texelFetch(uT, q, 0).r : c0; }void main() {  ivec2 c = ivec2(gl_FragCoord.xy);  vec4 s0 = texelFetch(uS, c, 0);  ivec2 nb[4] = ivec2[4](ivec2(1, 0), ivec2(-1, 0), ivec2(0, 1), ivec2(0, -1));  if (!inside(c)) {    float sum = 0.0, n = 0.0;    for (int i = 0; i < 4; i++) { ivec2 q = c + nb[i]; if (inside(q)) { sum += texelFetch(uS, q, 0).r; n += 1.0; } }    o = vec4(n > 0.0 ? sum / n : 0.0, 0.0, 0.0, 0.0);    return;  }  vec4 t0 = texelFetch(uT, c, 0);  float a0 = t0.r;  float l = lt(c + ivec2(-1, 0), a0), r = lt(c + ivec2(1, 0), a0), d = lt(c + ivec2(0, -1), a0), u = lt(c + ivec2(0, 1), a0);  float ld = lt(c + ivec2(-1, -1), a0), rd = lt(c + ivec2(1, -1), a0), lu = lt(c + ivec2(-1, 1), a0), ru = lt(c + ivec2(1, 1), a0);  float lap2 = (4.0 * (l + r + d + u) + (ld + rd + lu + ru) - 20.0 * a0) / 6.0 * uIdx2;  float force = uCt * (uHg * uGt * a0 - uHs * uSs * lap2);  if (uF.w != 0.0) {    vec2 q = vec2(c) + 0.5 - uF.xy;    float a2 = uF.z * uF.z, r2 = dot(q, q);    force += uF.w * (4.0 * r2 / (a2 * a2) - 4.0 / a2) * exp(-r2 / a2) * uIdx2;  }  float v = s0.g;  float vn = v + uDt * force;  for (int i = 0; i < 4; i++) {    ivec2 q = c + nb[i];    if (!inside(q)) continue;    float vq = texelFetch(uS, q, 0).g, s2q = texelFetch(uT, q, 0).g;    vn += min(uRlin + uRnl * 0.5 * (t0.g + s2q), 0.12) * (vq - v);  }  vn += uNoise * (hash(vec2(c), uSeed) - 0.5);  float s2 = t0.g;  // 壁ぎわの減衰(壁の境界層と接触線): 壁からの距離 dw(マス)で弱まる帯  vec2 pc = vec2(c) + 0.5 - uN * 0.5;  float dw = uR - (uShape < 0.5 ? length(pc) : max(abs(pc.x), abs(pc.y)));  vn *= uU / (1.0 + uLoc * s2 + uBrk * max(0.0, s2 - uSb2) / uSb2 + uWall * exp(-dw / uWd));  o = vec4(s0.r + uDt * vn, vn, 0.0, 0.0);}`;

FS_Bのforceが、本文の2つの式をつないだ部分だ。uGtに入る揺れる容器の中の重力が、重力の項uHg * uGt * a0だけに掛かり、表面張力の項uHs * uSs * lap2には掛からない。しきい値の式のかっこの中の補正は、この違いから生まれる。

速さには、隣のマスとの差をならす減衰をかける。粘りによる分(uRlin)と、傾きが大きい所ほど強くなる分(uRnl)で、後者が模様の高さを頭打ちにする。壁の外のマスは中のマスと同じ値とみなすので、壁の所で水面の傾きは0になる。

実時間とストロボ

水面は1秒に数十回上下するので、そのまま描くとちらついて見えない。planは、計算は実時間で進めながら、波のちょうど1周期分ずつまとめて進めた所だけを画面に出す。1周期後の水面は同じ形に戻るので、少しずつずらして見せると、ゆっくり上下するように見える。ストロボの光で照らすのと同じ仕組みだ。

時間の進め方(src/sims/faraday-waves.ts の 340〜391 行)
/** * 1 コマで進める刻みの数を決める。 * 速いとき(実時間)は、波の 1 周期ぶんをまとめて進めて同じ形に戻った所だけを見せる(ストロボ)。見かけの上下は 1 秒に DRIFT 回。 * 指で触った直後は、まとめて進める分をやめて、見かけ 1.5 回/秒のスローで見せる。 * 1 コマの計算量(マスの数 × 刻み)が上限を超えるときは、実時間より遅く進める。 */function plan(api: Api, s: S) {  const P = s.P, dt = api.dt, np = P.np;  const budget = Math.max(1, Math.min(MAXSTEP, Math.floor(CELLS[api.quality] / (P.N * P.N))));  const rate = DRIFT + (P.f / 2 - DRIFT) * s.fast; // 1 秒に進める波の周期の数  if (rate <= 6 || np > budget) {    s.strobe = false;    s.acc += rate * dt * np;    const n = Math.min(budget, Math.floor(s.acc)); s.acc -= n; if (s.acc > np) s.acc = 0;    return n;  }  s.strobe = true;  s.acc += DRIFT * dt * np;  const nd = Math.min(budget, Math.floor(s.acc)); s.acc -= nd; if (s.acc > np) s.acc = 0;  s.accW += (rate - DRIFT) * dt;  let nw = Math.floor(s.accW); s.accW -= nw;  if (nd + nw * np > budget) { nw = Math.max(0, Math.floor((budget - nd) / np)); s.accW = Math.min(s.accW, 0.99); }  return nd + nw * np;} /** steps 回、刻み dt で進める。1 回 = Δη を求める(FS_A)→ 速さと高さを更新(FS_B)の 2 段 */function run(api: Api, s: S, steps: number) {  const g = s.g!, gl = g.gl, P = s.P, N = P.N;  const F = s.finger, fOn = !!F && F.on;  const toCell = (mm: number) => mm / P.dx + N / 2;  const pA = g.pA, pB = g.pB;  pA.use(); grid(pA, P); pA.set("uIdx2", 1 / (P.dx * P.dx)); pA.set("uIdx", 1 / P.dx);  pB.use(); grid(pB, P);  pB.set("uIdx2", 1 / (P.dx * P.dx)); pB.set("uDt", P.dt); pB.set("uHg", P.hg); pB.set("uHs", P.hs); pB.set("uSs", P.s); pB.set("uCt", P.ct); pB.set("uU", P.U);  pB.set("uRlin", P.rlin); pB.set("uRnl", P.rnl); pB.set("uLoc", P.loc); pB.set("uBrk", P.brk); pB.set("uSb2", SB * SB); pB.set("uWall", P.wall); pB.set("uWd", P.wd);  pB.set("uNoise", (2e-4 * P.wc * P.wc * P.dt) / P.k); // 床や空気からいつも来ている、ごく小さな揺れ  for (let n = 0; n < steps; n++) {    gl.bindFramebuffer(gl.FRAMEBUFFER, g.Tfb); gl.viewport(0, 0, N, N);    pA.use(); pA.tex("uS", 0, g.S.read); g.quad.draw();    g.S.bindWrite(); gl.viewport(0, 0, N, N);    pB.use(); pB.tex("uS", 0, g.S.read); pB.tex("uT", 1, g.T);    pB.set("uGt", G * (1 - P.Gam * Math.cos(P.om * s.t))); // 揺れる容器の中の重力 g(t)    pB.set("uSeed", (s.seedN = (s.seedN + 1) % 997));    if (fOn) {      const k = (n + 1) / steps, x = F!.px + (F!.x - F!.px) * k, y = F!.py + (F!.y - F!.py) * k;      pB.set("uF", toCell(x), toCell(y), FING.a / P.dx, P.ct * P.hg * G * FING.d);    } else pB.set("uF", 0, 0, 1, 0);    g.quad.draw(); g.S.swap();    s.t += P.dt;  }  if (F) { F.px = F.x; F.py = F.y; }}

1コマに進める刻みの数には上限があり、マスの数が多い高い周波数では、実時間より遅く進めることがある。そのときは、画面の下に「実際の何倍」と出る。

指・音・マイク

指は、水面を押す圧力として式に入れる。置いたままならくぼみになり、動かすと引き波が立ち、離すとくぼみが戻って波紋になる。

指で触る(src/sims/faraday-waves.ts の 556〜595 行)
/** 画面の点を、容器の中心からの mm に直す(容器の外なら null) */function fingerAt(api: Api, s: S): [number, number] | null {  const L = layout(api), scale = (L.S * 0.96) / D;  const x = (api.pointer.x - L.cx) / scale, y = -(api.pointer.y - L.cy) / scale, lim = D / 2 - FING.a;  const inside = s.P.shape === "square" ? Math.max(Math.abs(x), Math.abs(y)) < D / 2 : Math.hypot(x, y) < D / 2;  if (!inside) return null;  if (s.P.shape === "square") return [clamp(x, -lim, lim), clamp(y, -lim, lim)];  const r = Math.hypot(x, y);  return r > lim ? [(x / r) * lim, (y / r) * lim] : [x, y];}/** * 指は、水面を押す圧力 p(深さ 0.6 mm・広がり 3 mm の山形)として式に入れる(FS_B の uF)。 * 置いたまま = くぼみ、動かす = 引き波(かき混ぜる)、離す = くぼみが戻って波紋になる。触っている間と直後はスローで見せる */function touch(type: "down" | "move" | "up" | "cancel" | "wheel", api: Api, s: S) {  if (type === "down") {    const p = fingerAt(api, s);    if (!p) return;    s.auto = 0;    s.finger = { x: p[0], y: p[1], px: p[0], py: p[1], on: true, up: false, t0: api.t, auto: false };    s.slowT = 2;    api.emit("touch", { kind: "water" });  } else if (type === "move") {    const F = s.finger;    if (!F || !F.on || F.auto || !api.pointer.down) return;    const p = fingerAt(api, s); if (!p) return;    F.x = p[0]; F.y = p[1];  } else if (type === "up" || type === "cancel") {    if (s.finger && !s.finger.auto) s.finger.up = true;  }}/** 場面「かき混ぜたあと」: 見えない指で、半径 24 mm の円を 1.3 秒で 1 周なぞる */function autoStir(api: Api, s: S, dt: number) {  if (s.auto <= 0) return;  s.auto -= dt; s.autoA += (dt * TAU) / 1.3;  const x = 24 * Math.cos(s.autoA), y = 24 * Math.sin(s.autoA);  if (!s.finger || !s.finger.auto) s.finger = { x, y, px: x, py: y, on: true, up: false, t0: api.t, auto: true };  else { s.finger.x = x; s.finger.y = y; }  if (s.auto <= 0) s.finger.up = true;}

音は、揺らす周波数の正弦波を小さな音量で鳴らすだけだ。マイクは、押して許可されたときだけ開く。音を周波数ごとの強さに分け、10〜240 Hzでいちばん強い所を探して、その周波数と大きさでつまみを動かす。音はどこにも送らず、止めるとすぐに閉じる。

音とマイク(src/sims/faraday-waves.ts の 467〜542 行)
/** 音の大きさ: 振幅に合わせて少しだけ大きくする(小さな音量のまま) */const volOf = (api: Api) => clamp(0.02 + 0.025 * api.num("amp"), 0.02, 0.07);function soundOn(api: Api, s: S) {  if (s.osc) return;  const ac = api.audio();  if (!ac) { api.note("音を出せませんでした。もう一度「音を鳴らす」を押すと鳴ります"); api.set("sound", false); return; }  const osc = ac.createOscillator(), gain = ac.createGain();  osc.type = "sine"; osc.frequency.value = s.P.f; gain.gain.value = 0;  osc.connect(gain); gain.connect(ac.destination); osc.start();  gain.gain.setTargetAtTime(volOf(api), ac.currentTime, 0.05);  s.osc = osc; s.gain = gain;  if (s.P.f < 100 && !s.lowNoted) { s.lowNoted = true; api.note("100 Hz より低い音は、スマホやノートパソコンのスピーカーでは小さく聞こえます", 3200); }}function soundOff(s: S) {  const o = s.osc, gn = s.gain; s.osc = null; s.gain = null;  if (!o || !gn) return;  try { gn.gain.setTargetAtTime(0, o.context.currentTime, 0.03); } catch { /* 閉じた後 */ }  setTimeout(() => { try { o.stop(); o.disconnect(); gn.disconnect(); } catch { /* 止めた後 */ } }, 250);}function soundUpdate(api: Api, s: S) {  if (!s.osc || !s.gain) return;  const t = s.osc.context.currentTime;  s.osc.frequency.setTargetAtTime(s.P.f, t, 0.03);  s.gain.gain.setTargetAtTime(volOf(api), t, 0.08);} /** マイク: 押して許可したときだけ開く。音は端末の中で周波数ごとの強さにするだけで、どこへも送らない。止めたらすぐ閉じる */function micOn(api: Api, s: S) {  if (s.mic || s.micPending) return;  const fail = (why: string) => { s.micPending = false; s.micErr = why; if (s.alive) { api.note(why, 4200); api.set("mic", false); } };  const md = (globalThis.navigator as Navigator | undefined)?.mediaDevices;  if (!md || typeof md.getUserMedia !== "function") { fail("このブラウザではマイクを使えないため、マイクで揺らせません"); return; }  s.micPending = true; s.micErr = "";  md.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, video: false }).then((stream) => {    s.micPending = false;    if (!s.alive || !api.bool("mic")) { stream.getTracks().forEach((t) => t.stop()); return; }    let ac = api.audio(), own: AudioContext | null = null;    if (!ac) { try { own = ac = new AudioContext(); } catch { ac = null; } }    if (!ac) { stream.getTracks().forEach((t) => t.stop()); fail("音の読み取りを始められなかったため、マイクで揺らせません"); return; }    if (ac.state === "suspended") ac.resume().catch(() => {});    const src = ac.createMediaStreamSource(stream), an = ac.createAnalyser();    an.fftSize = 8192; an.smoothingTimeConstant = 0.5; src.connect(an);    s.mic = { stream, src, an, buf: new Float32Array(an.frequencyBinCount), f: s.P.f, db: -120, tSet: 0, own };    s.micErr = "";    api.note("マイクの音で揺らしています。10〜240 Hz でいちばん強い音の高さと大きさを使い、音は端末の外へ送りません", 4200);  }).catch((e: unknown) => {    const n = (e as { name?: string })?.name;    fail(n === "NotAllowedError" || n === "SecurityError" ? "マイクの使用が許可されなかったため、マイクで揺らせません" : "マイクが見つからないため、マイクで揺らせません");  });}function micOff(s: S) {  const m = s.mic; s.mic = null;  if (!m) return;  m.stream.getTracks().forEach((t) => t.stop());  try { m.src.disconnect(); m.an.disconnect(); } catch { /* 切った後 */ }  m.own?.close().catch(() => {});}/** マイクの音を周波数ごとの強さに分け、10〜240 Hz でいちばん強い所の周波数と大きさで、つまみを動かす */function micUpdate(api: Api, s: S, dt: number) {  const m = s.mic; if (!m) return;  m.an.getFloatFrequencyData(m.buf);  const bw = m.an.context.sampleRate / m.an.fftSize;  let best = -Infinity, bi = -1;  for (let i = Math.max(1, Math.ceil(10 / bw)); i <= Math.floor(240 / bw) && i < m.buf.length - 1; i++) if (m.buf[i] > best) { best = m.buf[i]; bi = i; }  if (bi < 0 || !Number.isFinite(best)) return;  const y0 = m.buf[bi - 1], y2 = m.buf[bi + 1], den = y0 - 2 * best + y2;  const fpk = (bi + (Number.isFinite(den) && Math.abs(den) > 1e-6 ? clamp((0.5 * (y0 - y2)) / den, -0.5, 0.5) : 0)) * bw;  m.db = best;  const level = clamp((best + 85) / 50, 0, 1); // −85 dB で 0、−35 dB で 1  if (level > 0.05) m.f += (fpk - m.f) * Math.min(1, dt / 0.25);  m.tSet += dt;  if (m.tSet < 0.15) return;  m.tSet = 0;  const fr = Math.round(clamp(m.f, 10, 240)), gm = Math.round(Math.min(6, s.P.Gc * 4 * level) * 100) / 100;  if (fr !== api.num("freq") || gm !== api.num("amp")) { api.set("freq", fr); api.set("amp", gm); update(api, s, true); }}

描く

上から見た水面は、マスの高さを双三次の補間でなめらかにつなぎ、傾きから光の当たり方を求めて網点にしている。断面のグラフはGPUから直径に沿った256点の高さを読み出したもので、読み出しを待たずに次のコマで受け取るので、画面は止まらない。

描画(src/sims/faraday-waves.ts の 597〜803 行)
// 上から見た水面: 高さを双三次の補間でなめらかにし、傾きから光の当たり方(陰影と照り返し)を出して、白と黒の網点にするconst FS_SHOW = `uniform sampler2D uS;uniform float uDx, uD, uCell, uBase, uGain, uSpec, uShin;uniform vec3 uL;uniform vec3 uC;${DITHER}float eta(ivec2 q) { return texelFetch(uS, clamp(q, ivec2(0), ivec2(int(uN) - 1)), 0).r; }void cr(float t, out vec4 w, out vec4 dw) {  float t2 = t * t, t3 = t2 * t;  w = vec4(-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2);  dw = vec4(-1.5 * t2 + 2.0 * t - 0.5, 4.5 * t2 - 5.0 * t, -4.5 * t2 + 4.0 * t + 0.5, 1.5 * t2 - t);}vec4 row(ivec2 i0, int j) { return vec4(eta(i0 + ivec2(-1, j)), eta(i0 + ivec2(0, j)), eta(i0 + ivec2(1, j)), eta(i0 + ivec2(2, j))); }void main() {  vec2 fc = gl_FragCoord.xy;  vec2 mm = (fc - uC.xy) / uC.z;  float rr = uShape < 0.5 ? length(mm) : max(abs(mm.x), abs(mm.y));  if (rr > uD * 0.5) { o = vec4(uPaper, 1.0); return; }  vec2 g = mm / uDx + uN * 0.5 - 0.5;  ivec2 i0 = ivec2(floor(g)); vec2 f = g - floor(g);  vec4 wx, dwx, wy, dwy; cr(f.x, wx, dwx); cr(f.y, wy, dwy);  vec4 r0 = row(i0, -1), r1 = row(i0, 0), r2 = row(i0, 1), r3 = row(i0, 2);  vec4 a = vec4(dot(wx, r0), dot(wx, r1), dot(wx, r2), dot(wx, r3));  vec4 b = vec4(dot(dwx, r0), dot(dwx, r1), dot(dwx, r2), dot(dwx, r3));  float ex = dot(wy, b) / uDx, ey = dot(dwy, a) / uDx;  vec3 n = normalize(vec3(-ex, -ey, 1.0));  float dif = dot(n, uL) - uL.z;  vec3 H = normalize(uL + vec3(0.0, 0.0, 1.0));  float sp = max(0.0, pow(max(dot(n, H), 0.0), uShin) - pow(H.z, uShin));  float tone = uBase + uGain * dif + uSpec * sp;  o = vec4(monoCell(clamp(tone, 0.0, 1.0), fc, uCell), 1.0);}`; interface Box { x: number; y: number; w: number; h: number }interface Lay { land: boolean; k: number; cx: number; cy: number; S: number; cap: Box; sec: Box | null; scope: Box; gauge: Box | null }/** * 配置は描画面の縦横から決める。横長なら左に容器・右にグラフ、縦長や正方形に近ければ上に容器・下にグラフ 2 つ。 * 文字の大きさの単位 k は api.u(1) を、小さな画面でも読める範囲に収めたもの */function layout(api: Api): Lay {  const W = api.w, H = api.h, k = clamp(api.u(1), 0.82, 1.3), U = (n: number) => n * k;  const pad = U(14), cap = U(28);  if (W >= H * 1.1) {    const S = Math.max(U(120), Math.min(H - 2 * pad - cap, W * 0.6 - pad, W - 4 * pad - U(240)));    const pw = clamp(W - S - 4 * pad, U(200), Math.min(S * 1.05, U(600)));    const x0 = Math.max(pad, (W - (S + 2 * pad + pw)) / 2), y0 = Math.max(pad, (H - S - cap) / 2);    const px = x0 + S + 2 * pad, ax = U(44), bh = S + cap, plotW = pw - ax;    const base = { land: true, k, cx: x0 + S / 2, cy: y0 + S / 2, S, cap: { x: x0, y: y0 + S + cap * 0.72, w: S, h: 0 } };    // 低い描画面(スマホで操作パネルを開いたときなど)では、振動波形だけを大きく出す    if (bh < U(340)) return { ...base, sec: null, gauge: null, scope: { x: px + ax, y: y0 + U(30), w: plotW, h: Math.max(U(60), bh - U(56)) } };    const secH = bh * 0.27, scH = bh * 0.33, secY = y0 + U(34);    return { ...base, sec: { x: px + ax, y: secY, w: plotW, h: secH }, scope: { x: px + ax, y: secY + secH + U(62), w: plotW, h: scH },      gauge: { x: px, y: y0 + bh - U(16), w: pw, h: 0 } };  }  const want = U(230);  const S = Math.max(U(120), Math.min(W - 2 * pad, H - 2 * pad - cap - want));  const rest = H - 2 * pad - S - cap, plotsH = Math.min(rest, Math.max(want, S * 0.7));  const y0 = pad + (rest - plotsH) / 2;  const top = y0 + S + cap + U(30), ax = U(38), gap = U(18);  const bw = (W - 2 * pad - 2 * ax - gap) / 2, bh = Math.max(U(60), plotsH - U(30) - U(66));  const base = { land: false, k, cx: W / 2, cy: y0 + S / 2, S, cap: { x: W / 2 - S / 2, y: y0 + S + cap * 0.72, w: S, h: 0 } };  if (bh < U(80)) return { ...base, sec: null, gauge: null, scope: { x: pad + ax, y: top, w: W - 2 * pad - ax, h: Math.max(U(40), plotsH - U(56)) } };  return { ...base, sec: { x: pad + ax, y: top, w: bw, h: bh }, scope: { x: pad + 2 * ax + bw + gap, y: top, w: bw, h: bh },    gauge: { x: pad, y: y0 + S + cap + plotsH - U(18), w: W - 2 * pad, h: 0 } };}const niceUp = (v: number) => { const e = Math.pow(10, Math.floor(Math.log10(v))), m = v / e; return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * e; };const decs = (v: number) => (v >= 1 ? 1 : v >= 0.1 ? 2 : 3);/** 文字が幅に収まるように大きさを下げる */function fitSize(api: Api, str: string, maxW: number, size: number) {  api.ctx.font = `400 ${size}px sans-serif`;  const w = api.ctx.measureText(str).width;  return w > maxW ? Math.max(size * 0.6, (size * maxW) / w) : size;} function show(api: Api, s: S) {  const g = s.g!, gl = g.gl, P = s.P, L = layout(api);  toScreen(gl, api.glw, api.glh);  const sx = api.glw / api.w, sy = api.glh / api.h, scale = (L.S * 0.96) / D;  const p = g.pShow; p.use(); grid(p, P);  p.tex("uS", 0, g.S.read);  p.set("uDx", P.dx); p.set("uD", D); p.set("uCell", Math.max(1, Math.round(sx)));  p.set("uC", L.cx * sx, (api.h - L.cy) * sy, scale * sx);  const l = [-0.42, 0.5, 0.76], ln = Math.hypot(l[0], l[1], l[2]); // 左上から差す光  p.set("uL", l[0] / ln, l[1] / ln, l[2] / ln);  p.set("uBase", 0.13); p.set("uGain", 6.5); p.set("uSpec", 1.4); p.set("uShin", 90);  p.set("uInk", ...api.inkRGB); p.set("uPaper", ...api.paperRGB);  g.quad.draw();} function overlay(api: Api, s: S) {  const ctx = api.ctx, P = s.P, L = layout(api), U = (n: number) => n * L.k;  ctx.clearRect(0, 0, api.w, api.h);  const scale = (L.S * 0.96) / D, R = (D / 2) * scale;  // 容器の縁  if (P.shape === "square") api.rect(L.cx - R, L.cy - R, 2 * R, 2 * R, { w: U(2.2) }); else api.circle(L.cx, L.cy, R, { w: U(2.2) });  // 断面を取る直径と、波形を見る印の所  api.line(L.cx - R, L.cy, L.cx + R, L.cy, { w: U(0.8), dash: [U(3), U(5)] });  const px = L.cx + ((s.probeI + 0.5) / PW - 0.5) * D * 0.99 * scale;  if (s.have) { api.circle(px, L.cy, U(6), { w: U(1.4), fill: "paper" }); api.dot(px, L.cy, U(2)); }  // 指(触っている所)と、マウスの指す所  const F = s.finger, Pt = api.pointer;  if (F && F.on) api.circle(L.cx + F.x * scale, L.cy - F.y * scale, FING.a * 1.6 * scale, { w: U(1.4), dash: [U(2), U(3)] });  else if (Pt.inside && !Pt.down && Pt.kind === "mouse" && Math.hypot(Pt.x - L.cx, Pt.y - L.cy) < R) api.circle(Pt.x, Pt.y, FING.a * 1.6 * scale, { w: U(1), dash: [U(1.5), U(4)] });  // 下の行: 物差しと、時間の流れ(マイクが使えないときは、その知らせ)  const cy = L.cap.y, fs = U(11.5), lw = 10 * scale, bx = L.cap.x;  api.line(bx, cy - fs * 0.35, bx + lw, cy - fs * 0.35, { w: U(1.4) });  api.line(bx, cy - fs * 0.35 - U(4), bx, cy - fs * 0.35 + U(4), { w: U(1.4) }); api.line(bx + lw, cy - fs * 0.35 - U(4), bx + lw, cy - fs * 0.35 + U(4), { w: U(1.4) });  api.text("1 cm", bx + lw + U(6), cy, { size: fs, font: "mono" });  const msg = s.micErr && !s.mic ? s.micErr : speedText(s), mx = bx + lw + U(48), mw = L.cap.x + L.cap.w - mx;  if (s.micErr && !s.mic) api.label(msg, mx, cy, { size: fitSize(api, msg, mw - U(8), fs) });  else api.text(msg, mx, cy, { size: fitSize(api, msg, mw, fs) });  if (L.sec) section(api, s, L.sec, U);  scope(api, s, L.scope, U);  if (L.gauge) gauge(api, s, L.gauge, U);}function speedText(s: S) {  const r = s.speed;  if (s.strobe) return r >= 0.93 ? "実時間・ストロボで見た水面" : `実際の ${fmt(r, 2)} 倍・ストロボで見た水面`;  if (r >= 0.93) return "実時間";  return r > 0 ? `スロー(実際の 1/${Math.max(2, Math.round(1 / r))})` : "止まっている";} /** 断面: 直径に沿った高さ。縦は横の何十倍にも拡大し、下の水は点で塗る */function section(api: Api, s: S, r: Box, U: (n: number) => number) {  const P = s.P, narrow = r.w < U(250);  let mx = 0; for (let i = 0; i < PW; i++) mx = Math.max(mx, s.env[i]);  const Y = niceUp(Math.max(0.02, mx * 1.15));  const X = (i: number) => r.x + ((i + 0.5) / PW) * r.w, Yp = (y: number) => r.y + r.h / 2 - (clamp(y, -Y * 1.3, Y * 1.3) / Y) * (r.h / 2);  api.text("直径に沿った断面", r.x - U(40), r.y - U(16), { size: U(12.5), weight: 600 });  if (!narrow) api.text(`高さは ${Math.round(r.h / 2 / Y / (r.w / D))} 倍に拡大`, r.x + r.w, r.y - U(16), { size: U(10.5), align: "right" });  const pts: [number, number][] = [];  for (let i = 0; i < PW; i++) pts.push([X(i), Yp(s.have ? s.prof[i] : 0)]);  api.dots([...pts, [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]], { gap: U(5), r: U(0.75) });  api.line(r.x, r.y + r.h, r.x + r.w, r.y + r.h, { w: U(1) });  api.line(r.x, Yp(0), r.x + r.w, Yp(0), { w: U(0.6), dash: [U(2), U(4)] });  api.poly(pts, { w: U(1.8) });  const d = decs(Y);  api.text(`+${fmt(Y, d)}`, r.x - U(5), Yp(Y) + U(4), { size: U(10), align: "right", font: "mono" });  api.text(`−${fmt(Y, d)}`, r.x - U(5), Yp(-Y) + U(4), { size: U(10), align: "right", font: "mono" });  api.text("mm", r.x - U(5), Yp(0) + U(4), { size: U(10), align: "right", font: "mono" });  api.text("−50", r.x, r.y + r.h + U(14), { size: U(10), font: "mono" });  api.text("50 mm", r.x + r.w, r.y + r.h + U(14), { size: U(10), align: "right", font: "mono" });  // 波長 λ の物差し  const lw = (P.lam / D) * r.w;  if (lw < r.w * 0.55) {    const y = r.y + U(4), x0 = r.x + r.w * (narrow ? 0.08 : 0.45) - lw / 2 * (narrow ? 0 : 1);    api.line(x0, y, x0 + lw, y, { w: U(1.2) }); api.line(x0, y - U(4), x0, y + U(4), { w: U(1.2) }); api.line(x0 + lw, y - U(4), x0 + lw, y + U(4), { w: U(1.2) });    api.label(`λ ${fmt(P.lam, 1)} mm`, x0 + lw + U(6), y + U(4), { size: U(10.5), font: "mono" });  }  if (s.have) { const x = X(s.probeI), y = Yp(s.prof[s.probeI]); api.circle(x, y, U(5), { w: U(1.3), fill: "paper" }); api.dot(x, y, U(1.8)); }} /** 振動波形: 上 = 容器の上下(揺らす音の波形)、下 = 印の所の水面の上下。横は波の 1 周期(= 揺れの 2 周期)で折りたたむ */function scope(api: Api, s: S, r: Box, U: (n: number) => number) {  const P = s.P, narrow = r.w < U(250), h2 = r.h * 0.42, gap = r.h - 2 * h2;  const c1 = { x: r.x, y: r.y, w: r.w, h: h2 }, c2 = { x: r.x, y: r.y + h2 + gap, w: r.w, h: h2 };  const Xp = (ph: number) => r.x + ph * r.w;  api.text("振動波形", r.x - U(36), r.y - U(16), { size: U(12.5), weight: 600 });  if (!narrow) api.text("波の 1 周期 = 揺れの 2 周期", r.x + r.w, r.y - U(16), { size: U(10.5), align: "right" });  const mid1 = c1.y + c1.h / 2, A1 = c1.h * 0.42;  api.line(c1.x, mid1, c1.x + c1.w, mid1, { w: U(0.6), dash: [U(2), U(4)] });  const n = 120, pts1: [number, number][] = [];  for (let i = 0; i <= n; i++) { const ph = i / n; pts1.push([Xp(ph), mid1 - A1 * Math.cos(2 * TAU * ph) * (P.Gam > 0 ? 1 : 0)]); }  api.poly(pts1, { w: U(1.4) });  const zt = `±${fmt(P.Z * 1000, P.Z < 0.01 ? 1 : 0)} µm`;  api.label(narrow ? `容器 ${fmt(P.f, 0)} Hz` : `容器の上下  f ${fmt(P.f, 0)} Hz  ${zt}`, c1.x, c1.y + U(2), { size: U(10.5) });  const mid2 = c2.y + c2.h / 2;  api.line(c2.x, mid2, c2.x + c2.w, mid2, { w: U(0.6), dash: [U(2), U(4)] });  const c0 = s.fit.ok ? s.fit.c : 0;  let mx = 0; for (const p of s.samples) mx = Math.max(mx, Math.abs(p.y - c0));  if (s.fit.ok) mx = Math.max(mx, s.fit.amp);  const Ym = niceUp(Math.max(0.01, mx * 1.1)), k2 = (c2.h * 0.46) / Ym, now = api.t;  for (const p of s.samples) { const age = clamp((now - p.t) / 2.4, 0, 1); api.dot(Xp(p.ph), mid2 - (p.y - c0) * k2, U(2.2 - 1.3 * age)); }  if (s.fit.ok && s.fit.amp > 2e-4) {    const pts2: [number, number][] = [];    for (let i = 0; i <= n; i++) { const ph = i / n; pts2.push([Xp(ph), mid2 - (s.fit.a * Math.cos(TAU * ph) + s.fit.b * Math.sin(TAU * ph)) * k2]); }    api.poly(pts2, { w: U(1.8) });  }  const at = s.fit.ok && s.fit.amp > 2e-4 ? `±${fmt(s.fit.amp, decs(s.fit.amp))} mm` : "ほぼ平ら";  api.label(narrow ? `水面 ${fmt(P.f / 2, 1)} Hz` : `水面(印の所)  f/2 ${fmt(P.f / 2, 1)} Hz  ${at}`, c2.x, c2.y + U(2), { size: U(10.5) });  const Tw = 2000 / P.f;  api.line(r.x, r.y + r.h, r.x + r.w, r.y + r.h, { w: U(1) });  for (const ph of [0, 0.5, 1]) {    api.line(Xp(ph), r.y + r.h, Xp(ph), r.y + r.h + U(4), { w: U(1) });    api.text(`${fmt(ph * Tw, Tw < 20 ? 1 : 0)}${ph === 1 ? " ms" : ""}`, Xp(ph), r.y + r.h + U(15), { size: U(10), align: ph === 0 ? "left" : ph === 1 ? "right" : "center", font: "mono" });  }  const phNow = (((s.t / (2 / P.f)) % 1) + 1) % 1;  api.line(Xp(phNow), r.y, Xp(phNow), r.y + r.h, { w: U(1), dash: [U(1.5), U(3)] });} /** 振幅の目盛り: 今の Γ を斜線で、しきい値 Γc を太い縦線で */function gauge(api: Api, s: S, r: Box, U: (n: number) => number) {  const P = s.P, fin = Number.isFinite(P.Gc), top = fin ? Math.max(P.Gam, P.Gc) : P.Gam;  const M = niceUp(Math.max(0.05, top * 1.5)), lab = U(118), x1 = r.x + r.w - U(44);  const X = (v: number) => r.x + lab + (clamp(v, 0, M) / M) * (x1 - r.x - lab);  api.text(`振幅 Γ ${fmt(P.Gam, 2)} g`, r.x, r.y + U(4), { size: U(11.5), weight: 600 });  api.line(X(0), r.y, X(M), r.y, { w: U(1) });  if (P.Gam > 0) api.hatch([[X(0), r.y - U(5)], [X(P.Gam), r.y - U(5)], [X(P.Gam), r.y + U(5)], [X(0), r.y + U(5)]], { gap: U(3), angle: 60, w: U(1) });  api.rect(X(0), r.y - U(5), X(P.Gam) - X(0), U(10), { w: U(1) });  api.text(`${fmt(M, M < 1 ? 2 : 1)} g`, X(M) + U(5), r.y + U(4), { size: U(10), font: "mono" });  if (fin && P.Gc <= M) {    const x = X(P.Gc);    api.line(x, r.y - U(10), x, r.y + U(10), { w: U(2.2) });    api.text(`しきい値 Γc ${fmt(P.Gc, 2)} g`, x, r.y + U(22), { size: U(10.5), align: x > X(M * 0.6) ? "right" : "left" });  } else api.text("しきい値 Γc はこの目盛りより上", X(M), r.y + U(22), { size: U(10.5), align: "right" });}

振動波形の下の段の点は、読み出した高さを「波の1周期のうちのどの時点か」で並べ直したものだ。点に正弦波を最小二乗法で当てはめ、太い線で描いている。

ブランコの立ちこぎ

本文の途中の小さなシミュレーションは、長さの変わる振り子の式を4次のルンゲ・クッタ法で1/240秒ずつ解いている。重心までの長さを正弦波で変え、その変化の速さが、振れを速めたり遅めたりする項に入る。

ブランコの式(src/sims/faraday-waves-swing.ts の 8〜35 行)
const g = 9.81; // m/s²const C = 0.02; // 空気の抵抗(1/秒)const HIST = 900; // グラフに残す点の数(30 秒)const SEAT = 0.95; // 重心(ふだんの位置)から座板までの長さ minterface S { th: number; om: number; t: number; tp: number; hold: boolean; hist: Float32Array; histL: Float32Array; hn: number; hi: number; hT: number; maxNow: number; peak: number; noted45: boolean; step: (dt: number, f: (h: number) => void) => number } const L0 = (api: Api) => api.num("len");const pumpF = (api: Api) => (api.str("pump") === "none" ? 0 : (Math.sqrt(g / L0(api)) / TAU) * Number(api.str("pump")));/** 重心までの長さ L と、その変わる速さ L'(こぐ周期の正弦波) */function lenAt(api: Api, tp: number): [number, number] {  const dL = api.str("pump") === "none" ? 0 : api.num("dl") / 100, fp = pumpF(api), w = TAU * fp;  return [L0(api) - dL * Math.cos(w * tp), dL * w * Math.sin(w * tp)];}function deriv(api: Api, th: number, om: number, tp: number): [number, number] {  const [L, Ld] = lenAt(api, tp);  return [om, (-2 * Ld * om) / L - (g / L) * Math.sin(th) - C * om];}/** 4 次のルンゲ・クッタ法で h 秒進める */function rk4(api: Api, s: S, h: number) {  const { th, om, tp } = s;  const [a1, b1] = deriv(api, th, om, tp);  const [a2, b2] = deriv(api, th + (h / 2) * a1, om + (h / 2) * b1, tp + h / 2);  const [a3, b3] = deriv(api, th + (h / 2) * a2, om + (h / 2) * b2, tp + h / 2);  const [a4, b4] = deriv(api, th + h * a3, om + h * b3, tp + h);  s.th = th + (h / 6) * (a1 + 2 * a2 + 2 * a3 + a4);  s.om = om + (h / 6) * (b1 + 2 * b2 + 2 * b3 + b4);  s.tp = tp + h;}

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

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

読み込んでいます

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

読み込んでいます

姉妹サイト

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