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. Keyboard: Space runs or pauses, . steps, r resets.
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
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import { sampleInto } from "../render/colormaps"; |
| 3 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 4 | import { 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 | */ |
| 12 | export 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.