GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Wave Interference Simulation: 9 Canvas Scenes

A wave interference simulation shows what happens on screen when two or more waves combine, and this set builds nine of those scenes with nothing but Math.sin

Auto-plays · click a tile to jump to its section · all nine in one zip

The order below is not by difficulty but by how many waves get layered on top of each other. The first three draw a single wave's own shape (01 combines two axes, 02 sums two point sources, 03 reflects a string into a standing wave), the middle three turn overlap into a pattern you can stare at (04 piles sand along nodal lines, 05 turns a small frequency gap into a swelling beat, 06 bends the same equation into polar coordinates), and the last three put these wave patterns to work (07 crosses two gratings into a moiré, 08 hands you the phase slider directly, 09 lets a membrane react to real sound). If you came here chasing a wave interference animation, item 02 is the one to open first; if you want something that stays interesting as a static frame, 04 or 07 earn that spot. For a scene that pushes whole canvas buffers around instead of single points, HTML5 Canvas Animation: 9 Warp & Droplet Effects sits next door, particle-based motion lives in Canvas Particle Animation: 9 Engine Patterns, and the sound-reactive thread continues in Audio Visualizer JavaScript: 9 Parts, No Library.

01Lissajous XY oscilloscope

Two sine waves drive the X and Y axes while an afterglow trail keeps the pen's path visible, recreating an oscilloscope screen on canvas.

Lissajous XY scope
Open in a new tab

01 · Lissajous XY scope — Measured area 16.829% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrameMath.singlobalCompositeOperation

How the pen gets its coordinates each frame

var FX = 3, FY = 2;          // X:Y frequency ratio — 3:2 takes 2s for the curve to close
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
  };
}

Why the trail fades instead of just clearing

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

X is sin(3θ) and Y is sin(2θ+phase), so the 3:2 loop tilts as the phase term shifts, and MDN's globalCompositeOperation reference explains the destination-out mode that wipes 0.10 of alpha every frame instead of erasing outright — that partial wipe is what a lissajous curve oscilloscope needs to keep a trail instead of a single dot.

02Two-source ripple interference field

Circular waves spread from two points and either reinforce or cancel, and every pixel gets its own distance-based brightness instead of drawn lines.

Two-source ripple interference field
Open in a new tab

02 · Two-source ripple interference field — Measured area 54.069% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createImageDataputImageDataMath.hypot

Why Math.hypot runs twice per pixel

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

How the buffer becomes one putImageData call

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);

The phase at distance d is sin(d/λ·2π−t), and adding the two waves before mapping the sum to brightness is exactly the constructive interference simulation from a physics textbook — one Math.hypot call per distance (MDN's Math.hypot reference). It measured 54.07% cumulative area across 23/23 frames — second only to item 07.

03Standing-wave string harmonics

A string pinned at both ends steps through the 1st, 2nd, and 3rd harmonics as its node count grows — the same physics behind a plucked guitar string.

Standing wave jump-rope harmonics
Open in a new tab

03 · Standing wave jump-rope harmonics — Measured area 10.665% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrameMath.sinlineTo

What shapes the string on each frame

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);
}

Why the node dots never move

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();
}

A standing wave is the product of a spatial term sin(nπs) and a time term cos(ωt), so the whole string ticks on one shared clock, and the n−1 points where sin(nπs)=0 stay put no matter how hard the rest of the string swings.

04Chladni sand-pattern plate

Sand settles along the nodal lines of a vibrating plate, and this scene recreates the classic Chladni experiment by evaluating the standing-wave equation at every pixel.

Chladni sand-pattern plate
Open in a new tab

04 · Chladni sand-pattern plate — Measured area 32.851% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sincreateImageDataMath.random

Where the nodal lines actually sit

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);
}

Why sand only appears near zero

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);
}

The (n, m) pair walks (2,3)→(3,4)→(4,2)→(5,3), and blending the two modes by a progress value keeps the transition smooth while Math.random scatters the grains so the pile reads as real sand rather than a flat gradient. Its heavy contrast — average intensity 106.0 — leaves the nodal lines crisp.

05Beat-frequency amplitude envelope

Two waves just 1Hz apart add up to a beat that swells and fades, and a dashed envelope curve traces the swing on top of the composite waveform.

Beat-frequency amplitude envelope
Open in a new tab

05 · Beat-frequency amplitude envelope — Measured area 42.926% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sinrequestAnimationFramestrokeStyle

What draws the combined waveform

var F1 = 9, F2 = 10;          // frequencies in Hz — the 1Hz gap is what creates the beat
var WINDOW = 1.2;             // visible time window (seconds)
var SCROLL = 0.6;             // how fast the waveform scrolls (seconds per second)
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();

How the envelope differs from the waveform

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([]);

Adding two sines produces a signal whose amplitude is bounded by cos(π·Δf·t) — a standard trig identity — which is why a 1Hz gap yields a 1-second beat cycle, and the dashed envelope traces exactly that instantaneous upper bound.

06Polar rose Lissajous

The same sine-and-cosine math from item 01 gets fed into polar coordinates instead of Cartesian ones, and out comes a rotating flower instead of a closed loop.

Polar rose Lissajous
Open in a new tab

06 · Polar rose Lissajous — Measured area 17.09% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.cosMath.sinrequestAnimationFrame

Where the radius comes from

var K = 5 / 4;                // frequency ratio n/d that sets petal count
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
  };
}

Why the loop runs to 8π instead of 2π

var N = 480;                 // K is a fraction, so a full closed loop needs many samples
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);
}

A fractional K like 5/4 only closes after 8π of angle, and the slow spin of the whole shape turns the output of this lissajous curve generator into something you could drop straight into a mandala-style loading screen.

07Moiré grating interference

Two striped layers share the same 6px pitch but sit 7 degrees apart, and the far coarser moiré stripe that appears is interference translated into print.

Moiré grating interference
Open in a new tab

07 · Moiré grating interference — Measured area 60.433% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createImageDataputImageDataMath.sin

How a single grating is drawn

var ANGLE = 7 * Math.PI / 180;   // angle gap between the two layers — smaller means wider moire bands
var PITCH = 6;                    // width of one stripe (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;
}

Why the two layers multiply their brightness

var a = stripe(x, y, 0, phase);
var b = stripe(x, y, ANGLE, -phase * 0.6);
var v = a * b;                                 // multiplying the two layers is what creates the moire

The smaller the angle between the two gratings, the wider the resulting moiré band — a straightforward inverse relationship — and pushing the two phases in opposite directions makes the coarse bands ripple like flowing water. It measured the widest cumulative area of the nine at 60.43%.

08Phase-difference comparison panel

Drag a phase δ slider and the same two waves morph from a line to an ellipse to a circle, turning the abstract idea of phase into something you can feel with a mouse.

Phase-difference comparison panel
Open in a new tab

08 · Phase-difference comparison panel — Measured area 27.153% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

::-webkit-slider-thumbMath.sinrequestAnimationFrame

What X and Y actually compute

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);
}

What happens when you grab the slider

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));
}

At δ=0 the shape is a diagonal line, at π/2 it becomes a perfect circle, and at π it flips to the opposite diagonal — the whole definition of phase difference exposed through a single input[type=range]. Grabbing the slider stops the automatic sweep for good, so you keep whatever δ you landed on.

09Circular membrane vibration modes (mic input)

A circular membrane cycles through its vibration modes on a synthetic signal by default, and pressing a button lets real sound drive the same math.

Circular membrane vibration modes (mic input)
Open in a new tab

09 · Circular membrane vibration modes (mic input) — Measured area 18.58% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getUserMediaAnalyserNoderequestAnimationFrame

Why it starts from a seeded random number

var SEED = 7;                // seed for the synthetic signal — fixed so a 24-frame capture repeats the same pattern
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);
}

What happens if the mic permission is denied

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);         // analysis only — never routed to the speakers (avoids feedback)
  micOn = true;
}).catch(function () {
  micOn = false;
  stateEl.textContent = 'Permission denied — playing a synthetic signal';
});

MDN's getUserMedia reference hands back a stream that feeds an AnalyserNode, and a denied prompt falls back cleanly to the same seeded synthetic signal instead of freezing the animation.

Where it breaks — the trap

Why does a renderer with zero animations fail to capture frames?

None of these nine scenes uses a single CSS @keyframes rule. The capture tool this project uses normally drives a CSS animation clock forward to grab frames, and with zero keyframes that clock has nothing to grab onto — it has to wait on a live canvas loop instead. So every bit of motion here runs inside requestAnimationFrame, and all nine items still captured 23 of 23 frames. Any canvas-only scene without CSS animation has to follow that same structure if it goes through an automated capture step.

Why does Math.random make measurements drift between runs?

Item 04's sand scatter used raw Math.random at first, and that made the pattern different on every frame, which made the measured numbers drift too.

Mode progression stays tied to time t, and only the part that is genuinely meant to look random — the grain scatter itself — keeps its unseeded Math.random call.

Why does getUserMedia get blocked during headless capture?

Running item 09 through automated capture never even shows a mic permission dialog — the request is simply denied. That is why the default path is a synthetic signal and getUserMedia is only attempted after a real click: the preview and every measurement run on synthetic audio, and only a live visitor's page ever hears an actual microphone. The HTML, CSS, and JS for all nine scenes — every trap included — ship in a password-protected zip below the post, and the password is adp2w7yb.

Accessibility (reduced-motion)

When prefers-reduced-motion is on, none of the nine scenes loops — each one freezes on a single representative frame instead. Item 01 holds phase 0.3 with its trail, 02 holds an interference pattern at phase 0.25, 03 holds the 1st harmonic at peak amplitude, 04 holds the nodal lines of the (2,3) mode, 05 holds one instant of the waveform and its envelope, 06 holds the petals at rotation 0.4, 07 holds the grating at phase 0.35, 08 holds the circle at δ=π/2, and 09 holds the membrane at level 0.5. None of the nine ends up on a blank canvas. The slider in 08 and the mic button in 09 both stay reachable by keyboard, following the guidance in MDN's prefers-reduced-motion reference.

FAQ

Where should a beginner start with a wave interference simulation like this one?

Start with item 08's phase-difference panel. Dragging δ and watching a line become an ellipse and then a circle shows you, hands-on, why the 3:2 curve in item 01 twists the way it does.

Is the two-source ripple demo accurate enough to use as classroom material?

Item 02 is built from the same two-point-source equation found in any physics textbook's interference diagram, so it projects well on a classroom screen. Changing the single wavelength constant WAVE shifts the band spacing, which doubles as a live experimental variable.

Can the difference between a standing wave and a traveling wave be seen directly in the code?

Item 03's sin(nπs)·cos(ωt) product is the very definition of a standing wave — watch the node points stay fixed on screen, then compare it with item 02's sin(k·d−t) form, a traveling wave, to see exactly where the time term sits.

Enter the archive password

The password is inside this article. You will find it as you read.