Skip to content

Wave Equation

A drum head, integrated from the PDE.

Intermediate / PDE / finite-difference / field

The wave equation u_tt = c-squared times the Laplacian, with damping, integrated by the standard leapfrog-in-time scheme on a grid with pinned edges. Drag anywhere to strike the membrane. It is the second-derivative sibling of the heat equation: same Laplacian, opposite character - waves carry information, diffusion erases it.

Tagged: hyperbolic PDE, leapfrog, ripples, fixed boundary

Live simulation

Drag on the drum head to launch ripples; pinned edges invert reflections.

Speed

Drag on the drum head to launch ripples; pinned edges invert reflections.

Mathematics

The wave equation couples acceleration to the Laplacian: u_tt = c^2 lap(u). Discretized with the leapfrog scheme, each cell's next displacement comes from its current displacement, its previous displacement, and its four neighbors - the same stencil as heat, applied twice in time.

u_tt = c^2 (u_xx + u_yy) - b u_t

leapfrog: u^{n+1} = 2u^n - u^{n-1} + c^2 (sum neighbors - 4 u^n), damped

stability: c <= 1 in these units (CFL condition)

The contrast with the heat equation is the pedagogical point: same discrete Laplacian, but a second time derivative turns smoothing into propagation. Information travels outward at c cells per step instead of fading in place. Damping subtracts a fraction of the velocity term each step, so a struck drum eventually stills - and the amplitude metric measures exactly that decay.

How it works

The grid keeps two previous states; each tick applies the leapfrog update with the damping factor, a fixed number of sweeps. Pointer drags inject displacement into the previous-state buffer, which launches circular ripples. Edges are pinned at zero: reflections invert there, like a real fixed drum rim.

Rendering maps the signed displacement through a token-built diverging colormap: crests toward accent, troughs toward sky.

Implementation

src/engine/experiments/wave-equation.ts · 141 lines · the module running above

TypeScript · fixed-timestep engine

1import { BaseExperiment } from "../core/base-experiment";
2import { sampleInto } from "../render/colormaps";
3import type { ParameterDef, PointerState, ThemeColors } from "../core/types";
4import { divergingFor } from "../render/diverging-cache";
5
6/**
7 * The 2-D wave equation u_tt = c^2 lap(u) - b u_t on a grid with fixed
8 * edges, integrated with the standard leapfrog-in-time finite difference.
9 * Drag on the surface to launch ripples; damping bleeds energy like a real
10 * drum head.
11 */
12export class WaveEquationExperiment extends BaseExperiment {
13 readonly id = "wave-equation";
14
15 private n = 80;
16 private drawSize = 0;
17 private offsetX = 0;
18 private offsetY = 0;
19 private u = new Float32Array(0);
20 private uPrev = new Float32Array(0);
21 private uNext = new Float32Array(0);
22 private image: ImageData | null = null;
23 private scratch: HTMLCanvasElement | null = null;
24 private steps = 0;
25 private amplitude = 0;
26 private pointer = { x: 0, y: 0, down: false, inside: false };
27
28 protected params(): ParameterDef[] {
29 return [
30 { key: "speed", label: "Wave speed c", min: 0.2, max: 0.9, step: 0.05, defaultValue: 0.5 },
31 { key: "damping", label: "Damping", min: 0, max: 0.02, step: 0.001, defaultValue: 0.004 },
32 { key: "iterations", label: "Steps per tick", min: 1, max: 6, step: 1, defaultValue: 2 },
33 ];
34 }
35
36 protected onReset(): void {
37 this.n = 80;
38 const cell = Math.min(this.width, this.height) / this.n;
39 this.drawSize = cell * this.n;
40 this.offsetX = Math.floor((this.width - this.drawSize) / 2);
41 this.offsetY = Math.floor((this.height - this.drawSize) / 2);
42 const size = this.n * this.n;
43 this.u = new Float32Array(size);
44 this.uPrev = new Float32Array(size);
45 this.uNext = new Float32Array(size);
46 // Initial: a Gaussian pulse at the center.
47 const cx = this.n / 2;
48 const cy = this.n / 2;
49 for (let y = 1; y < this.n - 1; y++) {
50 for (let x = 1; x < this.n - 1; x++) {
51 const d = Math.hypot(x - cx, y - cy);
52 this.u[y * this.n + x] = Math.exp(-(d * d) / 18) * Math.cos(d * 1.1);
53 }
54 }
55 this.steps = 0;
56 this.amplitude = 1;
57 }
58
59 resize(width: number, height: number): void {
60 this.width = width;
61 this.height = height;
62 this.reset();
63 }
64
65 onPointer(state: PointerState): void {
66 this.pointer = state;
67 if (state.inside && state.down) {
68 const gx = ((state.x - this.offsetX) / this.drawSize) * this.n;
69 const gy = ((state.y - this.offsetY) / this.drawSize) * this.n;
70 for (let y = Math.max(1, Math.floor(gy - 2)); y < Math.min(this.n - 1, Math.ceil(gy + 2)); y++) {
71 for (let x = Math.max(1, Math.floor(gx - 2)); x < Math.min(this.n - 1, Math.ceil(gx + 2)); x++) {
72 this.uPrev[y * this.n + x] += Math.exp(-((x - gx) ** 2 + (y - gy) ** 2) / 3) * 0.8;
73 }
74 }
75 }
76 }
77
78 protected onUpdate(): void {
79 const n = this.n;
80 const c = this.num("speed");
81 const damping = this.num("damping");
82 const iterations = this.num("iterations");
83 for (let it = 0; it < iterations; it++) {
84 for (let y = 1; y < n - 1; y++) {
85 for (let x = 1; x < n - 1; x++) {
86 const i = y * n + x;
87 const lap = this.u[i - 1] + this.u[i + 1] + this.u[i - n] + this.u[i + n] - 4 * this.u[i];
88 this.uNext[i] = 2 * this.u[i] - this.uPrev[i] + c * c * lap;
89 this.uNext[i] *= 1 - damping;
90 }
91 }
92 // Fixed edges: uNext already zero on the boundary (never written).
93 this.uPrev.set(this.u);
94 this.u.set(this.uNext);
95 this.steps += 1;
96 }
97 let maxAbs = 0;
98 for (let i = 0; i < this.u.length; i++) maxAbs = Math.max(maxAbs, Math.abs(this.u[i]));
99 this.amplitude = maxAbs;
100 }
101
102 render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void {
103 ctx.fillStyle = theme.bg;
104 ctx.fillRect(0, 0, this.width, this.height);
105 if (!this.image || this.image.width !== this.n) {
106 this.image = new ImageData(this.n, this.n);
107 this.scratch = document.createElement("canvas");
108 this.scratch.width = this.n;
109 this.scratch.height = this.n;
110 }
111 const diverging = divergingFor(theme);
112 let maxAbs = 1e-6;
113 for (let i = 0; i < this.u.length; i++) maxAbs = Math.max(maxAbs, Math.abs(this.u[i]));
114 const data = this.image.data;
115 for (let i = 0; i < this.u.length; i++) {
116 const t = Math.min(1, Math.max(0, 0.5 + (this.u[i] / maxAbs) * 0.5));
117 sampleInto(diverging, t, data, i * 4);
118 }
119 this.scratch!.getContext("2d")!.putImageData(this.image, 0, 0);
120 ctx.imageSmoothingEnabled = true;
121 ctx.drawImage(this.scratch!, this.offsetX, this.offsetY, this.drawSize, this.drawSize);
122 }
123
124 getMetrics() {
125 return {
126 steps: this.steps.toLocaleString("en-US"),
127 amplitude: this.amplitude.toExponential(2),
128 waveSpeed: this.num("speed").toFixed(2),
129 damping: this.num("damping").toFixed(3),
130 };
131 }
132
133 describe(): string {
134 return `A drum head of ${this.n} by ${this.n} cells after ${this.steps.toLocaleString("en-US")} wave-equation steps; peak amplitude ${this.amplitude.toExponential(1)}. Drag to add ripples.`;
135 }
136
137 entities(): number {
138 return this.n * this.n;
139 }
140}
141

Performance

Time per tick
O(cells x iterations) per tick
Space
Three grid buffers
Notes
Push c to its slider maximum and the CFL stability limit is approached - instability is one slider away, by design.

Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.