GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

リサージュ曲線 作り方9選 — 波の干渉で描くキャンバス

リサージュ曲線とは、周波数の異なる二つのサイン波をX軸とY軸にそれぞれ与えたときにペンが描く閉曲線で、オシロスコープの画面で位相差を目で確かめるために使われる図形です。ここではその原理をMath.sinとキャンバスだけで九つの場面に広げ、すべてマイクなしでも動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

順番は難易度順ではなく、波が重なる仕組みがだんだん複雑になる順です。最初の三つは一つの波の形を描く基礎(01は二軸の合成、02は二つの点波源の和、03は反射で生まれる定在波)、真ん中の三つは重なりの結果を模様として見せる系統(04は節線に砂が積もるクラドニ図形、05は周波数差が作るうなり、06は同じ式を極座標に回したローズ)、最後の三つは、こうした波の模様を画面のさまざまな場所へ持ち込む応用です(07は格子二枚が作るモアレ、08は位相差スライダー、09はマイクの音を乗せる膜)。波の干渉 シミュレーションを探して辿り着いた方は02が、画面に残る一枚を選ぶなら04か07が先に目に入ります。キャンバスのピクセルをまるごと動かす場面が欲しいならhtml5 canvas アニメーション9種 — 水滴とブラックホールが隣にあり、粒子として散らばる側はパーティクル アニメーション canvas 9種 — ライブラリなしの作り方、音を可視化する軸はオーディオビジュアライザー 作り方 9選 — 棒からマイクまでへと続きます。

01リサージュ XY オシロスコープ

二つのサイン波をX・Yに与え、ペンが描く軌跡を残像とともに見せる、オシロスコープの画面をそのまま写した場面です。「リサージュ曲線 オシロスコープ」で検索して辿り着いた方に一番近い形です。

リサージュ XY オシロスコープ
新しいタブで開く

01 · リサージュ XY オシロスコープ — 計測 累積面積 16.829% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrameMath.singlobalCompositeOperation

ペンの座標はどうMath.sinの二行で決まるのか

var FX = 3, FY = 2;          // X・Y 周波数比 — 3:2 にすると曲線が閉じるまで2秒かかる
function point(th) {
  var a = Math.min(W, H) * 0.36;
  return {
    x: W * 0.5 + Math.sin(th * FX) * a,
    y: H * 0.5 + Math.sin(th * FY + phase) * a
  };
}

残像はなぜdestination-outで消しながら残るのか

ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,' + TRAIL + ')';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';

XはsinのFX倍、Yはsinの位相付きFY倍なので3:2比の閉曲線が位相に応じて傾きながら動き、MDNのglobalCompositeOperation文書が説明するdestination-outが毎フレーム透明度0.10ずつ消して残像を残します。累積面積16.83%・23/23フレームで、九つの中では動きの静かな部類に入ります。

02二重点波源 干渉フィールド

二点から広がる円形波が出会って強め合ったり弱め合ったりする模様を、ピクセルごとに距離を測って直接計算する波の干渉場面です。

二点波源の干渉フィールド
新しいタブで開く

02 · 二点波源の干渉フィールド — 計測 累積面積 54.069% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createImageDataputImageDataMath.hypot

ピクセルごとにMath.hypotを二回呼ぶ

var d1 = Math.hypot(x - cx1, y - cy1);
var d2 = Math.hypot(x - cx2, y - cy2);
var a1 = Math.sin(k * d1 - t);
var a2 = Math.sin(k * d2 - t);
var v = (a1 + a2) * 0.25 + 0.5;           // 0..1

結果はputImageDataで一度に描く

var i = (y * W + x) * 4;
buf[i]     = Math.round(C1[0] + (C2[0] - C1[0]) * v);
buf[i + 1] = Math.round(C1[1] + (C2[1] - C1[1]) * v);
buf[i + 2] = Math.round(C1[2] + (C2[2] - C1[2]) * v);
buf[i + 3] = 255;
ctx.putImageData(img, 0, 0);

距離dでの波の位相はsin(d/λ·2π−t)で、二つの波を足した振幅を明るさに置き換えると、MDNのMath.hypot文書の距離関数一つで物理の教科書にある干渉模様がそのまま現れます。累積面積54.07%・23/23フレームで、九つの中で二番目に広く動きます。

03定在波 弦のハーモニクス

固定された両端の弦が第一・第二・第三ハーモニクスへ順に切り替わり、節が増えていく、ギター弦の原理を見せる場面です。

定常波なわとびハーモニクス
新しいタブで開く

03 · 定常波なわとびハーモニクス — 計測 累積面積 10.665% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrameMath.sinlineTo

弦の形はsin(nπs)・cos(ωt)で決まる

for (var i = 0; i <= SEG; i++) {
  var s = i / SEG;
  var x = x0 + (x1 - x0) * s;
  var y = mid - Math.sin(Math.PI * n * s) * amp * Math.cos(t);
  if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}

節はなぜ絶対に動かない点になるのか

ctx.fillStyle = NODE;
for (var k = 1; k < n; k++) {
  var nx = x0 + (x1 - x0) * (k / n);
  ctx.beginPath();
  ctx.arc(nx, mid, 4, 0, Math.PI * 2);
  ctx.fill();
}

定在波は空間部分sin(nπs)と時間部分cos(ωt)の積なので弦全体が同じ時計で振動し、sin(nπs)=0になる地点のn−1個は振幅が常に0なので揺れても動きません。

04クラドニ図形 砂模様パターン

振動板の上で砂が節線に沿って積もるクラドニ実験を、板全体のピクセルに定在波の式を当てはめて再現した場面です。

クラドニ図形の砂模様
新しいタブで開く

04 · クラドニ図形の砂模様 — 計測 累積面積 32.851% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.sincreateImageDataMath.random

節線はどこで二つのサイン積の差が0になるのか

function chladni(x, y, n, m) {
  return Math.sin(n * Math.PI * x) * Math.sin(m * Math.PI * y) -
         Math.sin(m * Math.PI * x) * Math.sin(n * Math.PI * y);
}

砂はなぜ値が0に近いところだけ濃く見えるのか

var v = Math.abs(chladni(nx, ny, n, m));
if (v < 0.05) {
  var jitter = Math.random() * 0.35;
  var t = Math.min(1, (0.05 - v) / 0.05 + jitter);
  buf[i]     = Math.round(PLATEC[0] + (SANDC[0] - PLATEC[0]) * t);
  buf[i + 1] = Math.round(PLATEC[1] + (SANDC[1] - PLATEC[1]) * t);
  buf[i + 2] = Math.round(PLATEC[2] + (SANDC[2] - PLATEC[2]) * t);
}

(n, m)の組が(2,3)→(3,4)→(4,2)→(5,3)へ移るとき二つのモードを進行率で混ぜて滑らかにつなぎ、Math.randomの散らばりが本物の砂粒のように見せます。強度平均106の濃いコントラストで節線がはっきりと浮かび上がります。

05うなり 振幅エンベロープ

周波数が1Hzだけ違う二つの波を足して大きくなったり消えたりする、うなりの様子を、合成波の上に点線の包絡線まで描いて見せる場面です。

うなり振幅エンベロープ
新しいタブで開く

05 · うなり振幅エンベロープ — 計測 累積面積 42.926% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.sinrequestAnimationFramestrokeStyle

合成波は二つのサインの和で作る

var F1 = 9, F2 = 10;          // 二つの波の周波数(Hz) — 差の1Hzがうなりの周期を作る
var WINDOW = 1.2;             // 画面に見える時間窓(秒)
var SCROLL = 0.6;             // 波形が流れる速さ(秒/秒)
ctx.strokeStyle = WAVE;
ctx.lineWidth = 2;
ctx.beginPath();
for (var x = 0; x <= W; x += 2) {
  var t = t0 + (x / W) * WINDOW;
  var v = Math.sin(2 * Math.PI * F1 * t) + Math.sin(2 * Math.PI * F2 * t);
  var y = mid - v * scale;
  if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();

包絡線はなぜ|cos(π·Δf·t)|・2になるのか

ctx.strokeStyle = ENV;
ctx.lineWidth = 1.6;
ctx.setLineDash([5, 4]);
ctx.beginPath();
for (var xi = 0; xi <= W; xi += 2) {
  var ti = t0 + (xi / W) * WINDOW;
  var env = Math.abs(Math.cos(Math.PI * (F1 - F2) * ti)) * 2;
  var ye = mid - env * scale;
  if (xi === 0) ctx.moveTo(xi, ye); else ctx.lineTo(xi, ye);
}
ctx.stroke();
ctx.setLineDash([]);

二つのサインの和はcos(π·Δf·t)を包絡線に持つという三角関数の恒等式のとおり1秒周期のうなりが現れ、点線の包絡線がその瞬間の最大振幅を上下から包みます。

06極座標ローズ・リサージュ

同じサイン・コサインの式を直交座標の代わりに極座標へ入れ、回転する花びら模様が出る、01番の極座標版です。

極座標ローズ・リサージュ
新しいタブで開く

06 · 極座標ローズ・リサージュ — 計測 累積面積 17.09% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.cosMath.sinrequestAnimationFrame

半径はcos(K·θ)で決まる

var K = 5 / 4;                // 花びらの数を決める周波数比 n/d
function petalPoint(th, spin, R) {
  var r = Math.cos(K * th) * R;
  var a = th + spin;
  return {
    x: W * 0.5 + Math.sin(a) * r,
    y: H * 0.5 + Math.cos(a) * r
  };
}

描く範囲はなぜθ 0→8πも必要なのか

var N = 480;                 // Kが分数なので一周描くには標本が多く必要
for (var i = 0; i <= N; i++) {
  var p = petalPoint(i / N * Math.PI * 8, spin, R);
  if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}

K=5/4のように分数だと曲線が閉じるまでに8π必要で、spinが全体をゆっくり回してマンダラ風のローディング画面にそのまま使える模様を描きます。

07モアレ格子干渉

縞模様二枚を7度ずらして重ね、ずっと太いモアレ縞が流れる、干渉の紙面印刷版です。

モアレ格子干渉
新しいタブで開く

07 · モアレ格子干渉 — 計測 累積面積 60.433% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createImageDataputImageDataMath.sin

格子一枚は回転軸サイン縞で作る

var ANGLE = 7 * Math.PI / 180;   // 二層の角度差 — 小さいほど模様の間隔が広くなる
var PITCH = 6;                    // 縞一本分(px)
function stripe(x, y, angle, phase) {
  var rx = x * Math.cos(angle) + y * Math.sin(angle);
  var v = Math.sin(rx / PITCH * Math.PI * 2 + phase) * 0.5 + 0.5;
  return v;
}

重なりは二つの明るさの積で決まる

var a = stripe(x, y, 0, phase);
var b = stripe(x, y, ANGLE, -phase * 0.6);
var v = a * b;                                 // 二層の明るさを掛けて重なり(モアレ)を作る

縞の角度差が小さいほどモアレの間隔が広くなる周期の関係がそのまま現れ、位相を互いに反対方向へ押して太い縞が波のように流れます。累積面積60.43%で九つの中で一番広く動きます。

08位相差 比較パネル

位相δのスライダーを動かすと同じ二つの波が直線→楕円→円へ変わる過程を三つの区画に分けて見せる、リサージュ曲線の実験道具です。

位相差比較パネル
新しいタブで開く

08 · 位相差比較パネル — 計測 累積面積 27.153% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

::-webkit-slider-thumbMath.sinrequestAnimationFrame

X=sin(ωt)、Y=sin(ωt+δ)は何を表しているのか

var N = 120;
for (var i = 0; i <= N; i++) {
  var th = i / N * Math.PI * 2;
  var px = cxr + Math.sin(th) * R;
  var py = mid - Math.sin(th + delta) * R;
  if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}

スライダーを掴むとなぜ自動ループが止まるのか

if (manual) {
  delta = slider.value / 100 * Math.PI * 2;
} else {
  var u = (sec % LOOP) / LOOP;
  delta = (u < 0.5 ? u * 2 : (1 - u) * 2) * Math.PI * 2;
  if (slider) slider.value = String(Math.round(delta / (Math.PI * 2) * 100));
}

δ=0なら対角線の直線、π/2なら正円、πならもう一方の対角線になる位相差の定義をinput[type=range]一つで手を動かして確かめられ、触れた瞬間から自動往復が止まります。

位相差 δ 波形 見え方
0 直線 対角線を往復するだけの動き
π/2 円 一定半径で回る正円
π 直線(逆向き) もう一方の対角線

09円形膜 振動モード(マイク入力)

円形の振動板がモードを切り替えながら鳴るデモにマイクボタンを載せ、実際の音の振幅が板を動かすようにした場面です。

円形膜の振動モード(マイク入力)
新しいタブで開く

09 · 円形膜の振動モード(マイク入力) — 計測 累積面積 18.58% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getUserMediaAnalyserNoderequestAnimationFrame

合成信号はなぜシード固定乱数で始めるのか

var SEED = 7;                // 合成信号のシード — 固定しないと24フレーム撮影のたびに模様が変わる
function seeded(i) {
  var x = Math.sin(i * 127.1 + SEED * 311.7) * 43758.5453;
  return x - Math.floor(x);
}
function synthLevel(sec) {
  var u = (sec % LOOP) / LOOP * Math.PI * 2;
  return 0.38 + 0.30 * Math.sin(u) * Math.sin(u * 0.5 + 0.7) + 0.10 * Math.sin(u * 3.1);
}

マイクはgetUserMedia拒否時にどう合成へ戻るのか

navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
  var src = audioCtx.createMediaStreamSource(stream);
  analyser = audioCtx.createAnalyser();
  analyser.fftSize = 1024;
  audioBuf = new Uint8Array(analyser.fftSize);
  src.connect(analyser);         // 解析のみ — スピーカーには出力しない(ハウリング防止)
  micOn = true;
}).catch(function () {
  micOn = false;
  stateEl.textContent = '許可が拒否されました — 合成信号で再生します';
});

MDNのgetUserMedia文書でストリームを受け取りAnalyserNodeのRMSを読み、許可を拒否すると安全にシード固定の合成信号へ戻ります。

どこで壊れるか — 落とし穴

レンダラーはなぜアニメーション0個でフレームを撮れないのか

この九つはCSSの@keyframesを一つも使っていません。デモ撮影の道具はCSSアニメーションの時計を直接動かしてフレームを取り出しますが、keyframesが0個だとその時計がつかむものがなく、実時間で動くキャンバスを待つ必要があります。そこで動き全部をrequestAnimationFrameのループの中に置き、23/23フレームを全部撮影しました。CSSアニメーションなしでキャンバスだけを回す場面を検査ツールに通すときは、この構造を必ず守る必要があります。

Math.randomを使うとなぜ測定が毎回変わるのか

04番の砂粒の散らばりにMath.randomをそのまま使うと、フレームごとに模様が変わって測る値が揺れます。モードの進行は時間tだけに結び付け、砂粒の散らばりのように本来ランダムであるべき部分にだけMath.randomを残しました。

getUserMediaはなぜヘッドレスで権限ダイアログに阻まれるのか

09番を自動撮影すると、マイクの権限ダイアログは表示すらされずそのまま拒否されます。そこで既定を合成信号にし、ボタンを押したときだけgetUserMediaを試す構造にしました。プレビューと測定は合成信号のまま進み、実際のページでだけ音が乗ります。上の落とし穴をすべて避けた九つのHTML・CSS・JS原本は記事下の暗号化zipに入っていて、開く暗号はadp2w7ybです。

アクセシビリティ

prefers-reduced-motionが有効な環境では、九つとも通常のループを回さず代表となる一枚だけを残します。01は位相0.3の曲線と残像、02は位相0.25の干渉模様、03は第一ハーモニクスの最大振幅、04は(2,3)モードの節線、05はある瞬間の波形と包絡線、06は回転0.4の花びら、07は位相0.35の格子、08はδ=π/2の円、09はレベル0.5の振動板一枚です。どの項目も空のキャンバスでは終わりません。08のスライダーと09のマイクボタンはキーボードで到達でき、MDNのprefers-reduced-motion文書の推奨に沿っています。

FAQ

リサージュ曲線とは何か、最初はどこから見ればいいのか

リサージュ曲線 書き方で検索して辿り着いた方も、まずは08番の位相差スライダーから見てください。δの値を動かすと直線が楕円になり、さらに円になる過程を手で確かめられると、01番の3:2曲線がなぜあの形で動いているのかがすぐに分かります。

音波干渉のデモを授業教材として使ってもいいですか

02番が物理教科書にある二重点波源の干渉模様と同じ式で描かれているので、画面に映すのに一番向いています。波長WAVEの値を一つ変えるだけで模様の間隔が変わるので、実験変数としても見せられます。

定在波と進行波の違いをコードで確認できますか

03番のsin(nπs)・cos(ωt)の積構造がその定義そのものです。節点が動かないことを画面で確かめ、02番のようなsin(k·d−t)形の進行波と比べると、時間項の位置がどう違うかが目で分かります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。