Wave Simulation: How to Create Mesmerizing Wave Effects With Code
Waves are everywhere. Ocean swells, sound travelling through air, light bending around obstacles, ripples spreading from a stone dropped in a pond. The wave equation — one of the most elegant equations in physics — describes all of them with the same mathematics. And it turns out that simulating waves on a computer is both remarkably simple and endlessly beautiful.
Unlike particle systems or fluid simulations that require tracking thousands of individual elements, a wave simulation operates on a grid where each cell stores a single value: displacement. At every time step, each cell looks at its neighbours and accelerates toward their average. That is the entire algorithm. From this trivial rule emerge reflection, refraction, interference, diffraction, standing waves, and the Doppler effect — all the phenomena you studied in physics class, dancing across your screen in real time.
In this guide we build eight interactive wave simulations, starting from a vibrating string and ending with gallery-worthy generative wave art. Each example is self-contained, runs on a plain HTML Canvas with no external libraries, and stays under 50KB. Along the way you will learn the wave equation, numerical methods for solving PDEs, and techniques for turning physics into art.
The wave equation: one formula, infinite phenomena
The classical wave equation in two dimensions is:
∂²u/∂t² = c² (∂²u/∂x² + ∂²u/∂y²)
Here u is the displacement at position (x, y) and time t, and c is the wave speed. The equation says: the acceleration of a point equals the wave speed squared times the curvature at that point. Flat regions stay still. Curved regions accelerate toward flatness — which creates the wave motion.
To simulate this on a grid, we discretize space and time. For each cell at position (i, j), the discrete Laplacian (curvature) is simply the average of its four neighbours minus the cell itself:
laplacian = u[i-1][j] + u[i+1][j] + u[i][j-1] + u[i][j+1] - 4*u[i][j]
And the time update uses the Verlet method (which is numerically stable for wave equations): store the current state and the previous state, and compute the next state as:
next[i][j] = 2*current[i][j] - previous[i][j] + c² * laplacian
This leapfrog integration is second-order accurate, time-reversible, and energy-conserving — exactly the properties you want for wave simulation. Add a tiny damping factor (multiply by 0.999) to prevent energy from accumulating due to floating-point errors, and you have a rock-solid wave simulator in about ten lines of code.
Example 1: Vibrating string (1D wave)
We start with the simplest wave: a one-dimensional vibrating string. Click the canvas to pluck the string at different positions. The string is fixed at both ends (Dirichlet boundary conditions), so waves reflect back and forth, creating standing wave patterns. The colour encodes velocity — blue for upward motion, red for downward.
var c = document.createElement('canvas');
c.width = 580; c.height = 300;
c.style.background = '#0a0a1a';
c.style.borderRadius = '12px';
c.style.cursor = 'pointer';
document.currentScript.parentNode.insertBefore(c, document.currentScript);
var ctx = c.getContext('2d');
var N = 400;
var curr = new Float64Array(N);
var prev = new Float64Array(N);
var speed = 0.4;
var damping = 0.999;
// Initial pluck
for (var i = 0; i < N; i++) {
var x = i / N;
curr[i] = Math.sin(Math.PI * x) * 60;
prev[i] = curr[i];
}
c.addEventListener('click', function(e) {
var r = c.getBoundingClientRect();
var px = Math.floor((e.clientX - r.left) / c.width * N);
for (var i = 0; i < N; i++) {
var d = Math.abs(i - px);
if (d < 30) curr[i] += (30 - d) * 2;
}
});
function step() {
var next = new Float64Array(N);
for (var i = 1; i < N - 1; i++) {
var laplacian = curr[i - 1] + curr[i + 1] - 2 * curr[i];
next[i] = 2 * curr[i] - prev[i] + speed * speed * laplacian;
next[i] *= damping;
}
prev = curr;
curr = next;
}
function draw() {
ctx.fillStyle = 'rgba(10,10,26,0.3)';
ctx.fillRect(0, 0, c.width, c.height);
var midY = c.height / 2;
ctx.beginPath();
for (var i = 0; i < N; i++) {
var x = (i / N) * c.width;
var y = midY - curr[i];
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = '#4fc3f7';
ctx.lineWidth = 2;
ctx.stroke();
// Velocity coloring as dots
for (var i = 1; i < N - 1; i += 3) {
var vel = curr[i] - prev[i];
var x = (i / N) * c.width;
var y = midY - curr[i];
var r = Math.min(255, Math.max(0, -vel * 40 + 80));
var b = Math.min(255, Math.max(0, vel * 40 + 80));
ctx.fillStyle = 'rgb(' + r + ',80,' + b + ')';
ctx.fillRect(x - 1, y - 1, 3, 3);
}
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px monospace';
ctx.fillText('Click to pluck the string', 10, 20);
}
function animate() {
for (var s = 0; s < 3; s++) step();
draw();
requestAnimationFrame(animate);
}
animate();
Example 2: 2D ripple tank
Now we go to two dimensions. This is the classic ripple tank simulation — the digital equivalent of those shallow water tanks in physics labs. Click anywhere to drop a stone. Waves spread outward in concentric circles, reflect off the walls, and interfere with each other. The colour maps displacement: blue for troughs, white for peaks, dark for calm water.
var c = document.createElement('canvas');
c.width = 400; c.height = 400;
c.style.background = '#0a0a1a';
c.style.borderRadius = '12px';
c.style.cursor = 'crosshair';
document.currentScript.parentNode.insertBefore(c, document.currentScript);
var ctx = c.getContext('2d');
var W = 200, H = 200;
var curr = new Float32Array(W * H);
var prev = new Float32Array(W * H);
var spd = 0.45;
var damp = 0.998;
var img = ctx.createImageData(W, H);
function drop(cx, cy, r, amp) {
for (var dy = -r; dy <= r; dy++) {
for (var dx = -r; dx <= r; dx++) {
var d = Math.sqrt(dx * dx + dy * dy);
if (d < r) {
var ix = cx + dx, iy = cy + dy;
if (ix >= 0 && ix < W && iy >= 0 && iy < H) {
curr[iy * W + ix] += amp * Math.cos(d / r * Math.PI * 0.5);
}
}
}
}
}
drop(W / 2, H / 2, 8, 80);
c.addEventListener('click', function(e) {
var r = c.getBoundingClientRect();
var mx = Math.floor((e.clientX - r.left) / c.width * W);
var my = Math.floor((e.clientY - r.top) / c.height * H);
drop(mx, my, 6, 100);
});
function step() {
var next = new Float32Array(W * H);
for (var y = 1; y < H - 1; y++) {
for (var x = 1; x < W - 1; x++) {
var i = y * W + x;
var lap = curr[i - 1] + curr[i + 1] + curr[i - W] + curr[i + W] - 4 * curr[i];
next[i] = (2 * curr[i] - prev[i] + spd * spd * lap) * damp;
}
}
prev = curr;
curr = next;
}
function draw() {
var d = img.data;
for (var i = 0; i < W * H; i++) {
var v = curr[i];
var p = i * 4;
var bright = Math.max(0, Math.min(255, 30 + v * 2.5));
var blue = Math.max(0, Math.min(255, 60 + v * 3));
d[p] = bright * 0.6;
d[p + 1] = bright * 0.8;
d[p + 2] = blue;
d[p + 3] = 255;
}
ctx.putImageData(img, 0, 0);
ctx.drawImage(c, 0, 0, W, H, 0, 0, c.width, c.height);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px monospace';
ctx.fillText('Click to create ripples', 10, 20);
}
function animate() {
for (var s = 0; s < 2; s++) step();
draw();
requestAnimationFrame(animate);
}
animate();
Example 3: Wave interference patterns
When two wave sources oscillate at the same frequency, their waves overlap and create interference patterns — the hallmark of wave physics. Regions where peaks align produce constructive interference (bright bands). Regions where a peak meets a trough cancel out (dark bands). This example places two oscillating point sources and lets you see the interference pattern emerge in real time. The result is strikingly similar to the patterns Thomas Young observed in his famous double-slit experiment of 1801.
var c = document.createElement('canvas');
c.width = 400; c.height = 400;
c.style.background = '#0a0a1a';
c.style.borderRadius = '12px';
document.currentScript.parentNode.insertBefore(c, document.currentScript);
var ctx = c.getContext('2d');
var W = 200, H = 200;
var curr = new Float32Array(W * H);
var prev = new Float32Array(W * H);
var spd = 0.4;
var damp = 0.997;
var t = 0;
var img = ctx.createImageData(W, H);
var src1 = { x: Math.floor(W * 0.35), y: Math.floor(H * 0.5) };
var src2 = { x: Math.floor(W * 0.65), y: Math.floor(H * 0.5) };
var freq = 0.3;
var amp = 15;
function step() {
// Drive two point sources
curr[src1.y * W + src1.x] = Math.sin(t * freq) * amp;
curr[src2.y * W + src2.x] = Math.sin(t * freq) * amp;
t++;
var next = new Float32Array(W * H);
for (var y = 1; y < H - 1; y++) {
for (var x = 1; x < W - 1; x++) {
var i = y * W + x;
var lap = curr[i - 1] + curr[i + 1] + curr[i - W] + curr[i + W] - 4 * curr[i];
next[i] = (2 * curr[i] - prev[i] + spd * spd * lap) * damp;
}
}
// Keep sources driven
next[src1.y * W + src1.x] = Math.sin((t + 1) * freq) * amp;
next[src2.y * W + src2.x] = Math.sin((t + 1) * freq) * amp;
prev = curr;
curr = next;
}
function draw() {
var d = img.data;
for (var i = 0; i < W * H; i++) {
var v = curr[i];
var p = i * 4;
// Cyan-magenta colour mapping
if (v > 0) {
d[p] = Math.min(255, v * 15);
d[p + 1] = Math.min(255, 20 + v * 8);
d[p + 2] = Math.min(255, 40 + v * 12);
} else {
d[p] = Math.min(255, -v * 8);
d[p + 1] = Math.min(255, 10 - v * 4);
d[p + 2] = Math.min(255, 50 - v * 15);
}
d[p + 3] = 255;
}
ctx.putImageData(img, 0, 0);
ctx.drawImage(c, 0, 0, W, H, 0, 0, c.width, c.height);
// Mark sources
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(src1.x / W * c.width, src1.y / H * c.height, 4, 0, Math.PI * 2);
ctx.arc(src2.x / W * c.width, src2.y / H * c.height, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px monospace';
ctx.fillText('Two-source interference pattern', 10, 20);
}
function animate() {
step();
draw();
requestAnimationFrame(animate);
}
animate();
Example 4: Doppler effect
When a wave source moves, the waves in front get compressed (shorter wavelength, higher frequency) and the waves behind get stretched (longer wavelength, lower frequency). This is the Doppler effect — the reason an ambulance siren sounds higher-pitched as it approaches and lower as it recedes. In this simulation, a point source orbits in a circle while emitting continuous waves. Watch how the wavefronts bunch up ahead of the source and spread out behind it.
var c = document.createElement('canvas');
c.width = 400; c.height = 400;
c.style.background = '#0a0a1a';
c.style.borderRadius = '12px';
document.currentScript.parentNode.insertBefore(c, document.currentScript);
var ctx = c.getContext('2d');
var W = 200, H = 200;
var curr = new Float32Array(W * H);
var prev = new Float32Array(W * H);
var spd = 0.42;
var damp = 0.997;
var t = 0;
var img = ctx.createImageData(W, H);
function step() {
// Moving source in a circle
var angle = t * 0.02;
var sx = Math.floor(W / 2 + Math.cos(angle) * 40);
var sy = Math.floor(H / 2 + Math.sin(angle) * 40);
if (sx > 1 && sx < W - 2 && sy > 1 && sy < H - 2) {
curr[sy * W + sx] = Math.sin(t * 0.35) * 20;
}
t++;
var next = new Float32Array(W * H);
for (var y = 1; y < H - 1; y++) {
for (var x = 1; x < W - 1; x++) {
var i = y * W + x;
var lap = curr[i - 1] + curr[i + 1] + curr[i - W] + curr[i + W] - 4 * curr[i];
next[i] = (2 * curr[i] - prev[i] + spd * spd * lap) * damp;
}
}
prev = curr;
curr = next;
}
function draw() {
var d = img.data;
for (var i = 0; i < W * H; i++) {
var v = curr[i];
var p = i * 4;
var pos = Math.max(0, v);
var neg = Math.max(0, -v);
d[p] = Math.min(255, 8 + pos * 18 + neg * 4);
d[p + 1] = Math.min(255, 8 + pos * 6 + neg * 2);
d[p + 2] = Math.min(255, 20 + neg * 18 + pos * 4);
d[p + 3] = 255;
}
ctx.putImageData(img, 0, 0);
ctx.drawImage(c, 0, 0, W, H, 0, 0, c.width, c.height);
// Draw source position
var angle = t * 0.02;
var sx = c.width / 2 + Math.cos(angle) * (40 / W * c.width);
var sy = c.height / 2 + Math.sin(angle) * (40 / H * c.height);
ctx.fillStyle = '#ffeb3b';
ctx.beginPath();
ctx.arc(sx, sy, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px monospace';
ctx.fillText('Doppler effect — moving wave source', 10, 20);
}
function animate() {
for (var s = 0; s < 2; s++) step();
draw();
requestAnimationFrame(animate);
}
animate();
Example 5: Diffraction through slits
One of the most important demonstrations in wave physics is diffraction — waves bending around obstacles and through openings. When a plane wave encounters a barrier with one or two narrow slits, the waves spread out from each slit and interfere on the other side. This is the basis of Young's double-slit experiment, which proved the wave nature of light in 1801 and later became central to quantum mechanics. In this simulation, a plane wave source on the left encounters a barrier with two slits. Watch the classic interference fringes form on the right side.
var c = document.createElement('canvas');
c.width = 500; c.height = 400;
c.style.background = '#0a0a1a';
c.style.borderRadius = '12px';
document.currentScript.parentNode.insertBefore(c, document.currentScript);
var ctx = c.getContext('2d');
var W = 250, H = 200;
var curr = new Float32Array(W * H);
var prev = new Float32Array(W * H);
var spd = 0.4;
var damp = 0.998;
var t = 0;
var img = ctx.createImageData(W, H);
// Barrier with two slits
var barrierX = Math.floor(W * 0.35);
var slitWidth = 3;
var slitGap = 24;
var slit1Y = Math.floor(H / 2 - slitGap / 2);
var slit2Y = Math.floor(H / 2 + slitGap / 2);
var barrier = new Uint8Array(H);
for (var y = 0; y < H; y++) {
barrier[y] = 1; // blocked
if (Math.abs(y - slit1Y) < slitWidth) barrier[y] = 0;
if (Math.abs(y - slit2Y) < slitWidth) barrier[y] = 0;
}
function step() {
// Plane wave source on the left
for (var y = 1; y < H - 1; y++) {
curr[y * W + 2] = Math.sin(t * 0.25) * 12;
}
t++;
var next = new Float32Array(W * H);
for (var y = 1; y < H - 1; y++) {
for (var x = 1; x < W - 1; x++) {
// Enforce barrier
if (x === barrierX && barrier[y]) {
next[y * W + x] = 0;
continue;
}
var i = y * W + x;
var lap = curr[i - 1] + curr[i + 1] + curr[i - W] + curr[i + W] - 4 * curr[i];
next[i] = (2 * curr[i] - prev[i] + spd * spd * lap) * damp;
}
}
prev = curr;
curr = next;
}
function draw() {
var d = img.data;
for (var i = 0; i < W * H; i++) {
var v = curr[i];
var x = i % W;
var y = Math.floor(i / W);
var p = i * 4;
// Barrier pixels
if (x === barrierX && barrier[y]) {
d[p] = 60; d[p + 1] = 60; d[p + 2] = 70; d[p + 3] = 255;
continue;
}
var intensity = Math.abs(v) * 12;
if (v > 0) {
d[p] = Math.min(255, intensity * 0.3);
d[p + 1] = Math.min(255, 10 + intensity * 0.8);
d[p + 2] = Math.min(255, 20 + intensity);
} else {
d[p] = Math.min(255, intensity * 0.6);
d[p + 1] = Math.min(255, 5 + intensity * 0.2);
d[p + 2] = Math.min(255, 15 + intensity * 0.5);
}
d[p + 3] = 255;
}
ctx.putImageData(img, 0, 0);
ctx.drawImage(c, 0, 0, W, H, 0, 0, c.width, c.height);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px monospace';
ctx.fillText('Double-slit diffraction', 10, 20);
}
function animate() {
step();
draw();
requestAnimationFrame(animate);
}
animate();
Example 6: Standing waves
When a wave bounces back and forth between two fixed boundaries, something remarkable happens. The incident and reflected waves superpose to create a standing wave — a pattern that appears to vibrate in place rather than travel. Certain points (nodes) remain perfectly still, while others (antinodes) oscillate with maximum amplitude. Standing waves occur in musical instruments (guitar strings, organ pipes), microwave ovens, and even quantum mechanics (electron orbitals are standing waves). This simulation shows the first eight harmonic modes of a 2D rectangular membrane, like a drumhead.
var c = document.createElement('canvas');
c.width = 580; c.height = 460;
c.style.background = '#0a0a1a';
c.style.borderRadius = '12px';
document.currentScript.parentNode.insertBefore(c, document.currentScript);
var ctx = c.getContext('2d');
var modes = [
{nx: 1, ny: 1, label: '(1,1) Fundamental'},
{nx: 2, ny: 1, label: '(2,1)'},
{nx: 1, ny: 2, label: '(1,2)'},
{nx: 2, ny: 2, label: '(2,2)'},
{nx: 3, ny: 1, label: '(3,1)'},
{nx: 1, ny: 3, label: '(1,3)'},
{nx: 3, ny: 2, label: '(3,2)'},
{nx: 3, ny: 3, label: '(3,3)'}
];
var t = 0;
var cellW = 140, cellH = 110;
var gapX = 5, gapY = 5;
function draw() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);
t += 0.04;
for (var m = 0; m < 8; m++) {
var col = m % 4;
var row = Math.floor(m / 4);
var ox = 5 + col * (cellW + gapX);
var oy = 25 + row * (cellH + gapY + 18);
var mode = modes[m];
var freq = Math.sqrt(mode.nx * mode.nx + mode.ny * mode.ny);
// Draw mode
for (var py = 0; py < cellH; py++) {
for (var px = 0; px < cellW; px++) {
var x = px / cellW;
var y = py / cellH;
var val = Math.sin(mode.nx * Math.PI * x) * Math.sin(mode.ny * Math.PI * y) * Math.sin(t * freq);
var r, g, b;
if (val > 0) {
r = Math.min(255, val * 300);
g = Math.min(255, 20 + val * 100);
b = Math.min(255, 40 + val * 60);
} else {
r = Math.min(255, -val * 60);
g = Math.min(255, 20 - val * 80);
b = Math.min(255, 60 - val * 300);
}
ctx.fillStyle = 'rgb(' + Math.floor(r) + ',' + Math.floor(g) + ',' + Math.floor(b) + ')';
ctx.fillRect(ox + px, oy + py, 1, 1);
}
}
// Label
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = '10px monospace';
ctx.fillText(mode.label, ox + 2, oy + cellH + 12);
}
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px monospace';
ctx.fillText('Standing wave modes of a rectangular membrane', 10, 16);
}
function animate() {
draw();
requestAnimationFrame(animate);
}
animate();
Example 7: Shallow water equations
The shallow water equations (SWE) are a more physically accurate model for water surface waves. Unlike the simple wave equation, SWE model the actual water height and horizontal velocity, conserving mass and momentum. They capture phenomena like wave breaking, tsunamis, and tidal bores. This simulation uses a simplified SWE solver on a 2D grid. Click to add water splashes and watch how they propagate with realistic physics — notice how taller waves travel faster (a nonlinear effect absent from the linear wave equation).
var c = document.createElement('canvas');
c.width = 400; c.height = 400;
c.style.background = '#0a0a1a';
c.style.borderRadius = '12px';
c.style.cursor = 'pointer';
document.currentScript.parentNode.insertBefore(c, document.currentScript);
var ctx = c.getContext('2d');
var N = 150;
var h = new Float32Array(N * N); // water height
var u = new Float32Array(N * N); // x-velocity
var v = new Float32Array(N * N); // y-velocity
var g = 0.15; // gravity
var damp = 0.999;
var img = ctx.createImageData(N, N);
// Resting height = 10
for (var i = 0; i < N * N; i++) h[i] = 10;
// Initial splash
function splash(cx, cy, r, amp) {
for (var dy = -r; dy <= r; dy++) {
for (var dx = -r; dx <= r; dx++) {
var d = Math.sqrt(dx * dx + dy * dy);
if (d < r) {
var ix = cx + dx, iy = cy + dy;
if (ix > 0 && ix < N - 1 && iy > 0 && iy < N - 1) {
h[iy * N + ix] += amp * Math.cos(d / r * Math.PI * 0.5);
}
}
}
}
}
splash(N / 2, N / 2, 10, 8);
c.addEventListener('click', function(e) {
var r = c.getBoundingClientRect();
var mx = Math.floor((e.clientX - r.left) / c.width * N);
var my = Math.floor((e.clientY - r.top) / c.height * N);
splash(mx, my, 8, 10);
});
function step() {
// Update velocities from pressure gradient
for (var y = 1; y < N - 1; y++) {
for (var x = 1; x < N - 1; x++) {
var i = y * N + x;
u[i] -= g * (h[i + 1] - h[i - 1]) * 0.5;
v[i] -= g * (h[i + N] - h[i - N]) * 0.5;
u[i] *= damp;
v[i] *= damp;
}
}
// Update height from velocity divergence
for (var y = 1; y < N - 1; y++) {
for (var x = 1; x < N - 1; x++) {
var i = y * N + x;
h[i] -= h[i] * ((u[i + 1] - u[i - 1]) + (v[i + N] - v[i - N])) * 0.5;
h[i] = Math.max(0.1, h[i]);
}
}
}
function draw() {
var d = img.data;
for (var i = 0; i < N * N; i++) {
var val = (h[i] - 10) * 10;
var p = i * 4;
d[p] = Math.max(0, Math.min(255, 15 + val * 1.5));
d[p + 1] = Math.max(0, Math.min(255, 40 + val * 2));
d[p + 2] = Math.max(0, Math.min(255, 80 + val * 4));
d[p + 3] = 255;
}
ctx.putImageData(img, 0, 0);
ctx.drawImage(c, 0, 0, N, N, 0, 0, c.width, c.height);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px monospace';
ctx.fillText('Shallow water equations — click to splash', 10, 20);
}
function animate() {
for (var s = 0; s < 3; s++) step();
draw();
requestAnimationFrame(animate);
}
animate();
Example 8: Generative wave art
Our final example turns wave physics into generative art. Multiple wave sources oscillate at different frequencies and slowly drift across the canvas. The displacement field is rendered with a multi-layered colour palette that shifts over time, creating an ever-changing abstract painting. The additive blending of multiple wave fields produces moiré-like interference patterns that are unique at every moment. This is the kind of simulation you can stare at for hours — physics as meditation.
var c = document.createElement('canvas');
c.width = 500; c.height = 500;
c.style.background = '#0a0a1a';
c.style.borderRadius = '12px';
document.currentScript.parentNode.insertBefore(c, document.currentScript);
var ctx = c.getContext('2d');
var W = 200, H = 200;
var curr = new Float32Array(W * H);
var prev = new Float32Array(W * H);
var spd = 0.38;
var damp = 0.996;
var t = 0;
var img = ctx.createImageData(W, H);
// Multiple drifting sources
var sources = [];
for (var i = 0; i < 5; i++) {
sources.push({
x: W * 0.2 + Math.random() * W * 0.6,
y: H * 0.2 + Math.random() * H * 0.6,
freq: 0.15 + Math.random() * 0.2,
amp: 8 + Math.random() * 6,
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
phase: Math.random() * Math.PI * 2
});
}
function step() {
// Update and drive sources
for (var s = 0; s < sources.length; s++) {
var src = sources[s];
src.x += src.vx;
src.y += src.vy;
if (src.x < 5 || src.x > W - 5) src.vx *= -1;
if (src.y < 5 || src.y > H - 5) src.vy *= -1;
var sx = Math.floor(src.x);
var sy = Math.floor(src.y);
if (sx > 0 && sx < W - 1 && sy > 0 && sy < H - 1) {
curr[sy * W + sx] = Math.sin(t * src.freq + src.phase) * src.amp;
}
}
t++;
var next = new Float32Array(W * H);
for (var y = 1; y < H - 1; y++) {
for (var x = 1; x < W - 1; x++) {
var i = y * W + x;
var lap = curr[i - 1] + curr[i + 1] + curr[i - W] + curr[i + W] - 4 * curr[i];
next[i] = (2 * curr[i] - prev[i] + spd * spd * lap) * damp;
}
}
prev = curr;
curr = next;
}
function draw() {
var d = img.data;
var hueShift = t * 0.003;
for (var i = 0; i < W * H; i++) {
var v = curr[i];
var p = i * 4;
// Multi-layer colour mapping with time-varying hue
var hue = (Math.atan2(v, 5) / Math.PI + 1) * 0.5 + hueShift;
hue = hue % 1;
var sat = Math.min(1, Math.abs(v) * 0.15);
var light = 0.08 + Math.abs(v) * 0.03;
// HSL to RGB
var h2 = hue * 6;
var c2 = (1 - Math.abs(2 * light - 1)) * sat;
var x2 = c2 * (1 - Math.abs(h2 % 2 - 1));
var m = light - c2 / 2;
var r1, g1, b1;
if (h2 < 1) { r1 = c2; g1 = x2; b1 = 0; }
else if (h2 < 2) { r1 = x2; g1 = c2; b1 = 0; }
else if (h2 < 3) { r1 = 0; g1 = c2; b1 = x2; }
else if (h2 < 4) { r1 = 0; g1 = x2; b1 = c2; }
else if (h2 < 5) { r1 = x2; g1 = 0; b1 = c2; }
else { r1 = c2; g1 = 0; b1 = x2; }
d[p] = Math.floor((r1 + m) * 255);
d[p + 1] = Math.floor((g1 + m) * 255);
d[p + 2] = Math.floor((b1 + m) * 255);
d[p + 3] = 255;
}
ctx.putImageData(img, 0, 0);
ctx.drawImage(c, 0, 0, W, H, 0, 0, c.width, c.height);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '11px monospace';
ctx.fillText('Generative wave art — 5 drifting sources', 10, 20);
}
function animate() {
for (var s = 0; s < 2; s++) step();
draw();
requestAnimationFrame(animate);
}
animate();
Waves in the real world
The wave equation we simulated above is the foundation for an enormous range of real-world physics:
- Acoustics: sound waves in air, room acoustics, speaker design. The wave equation in 3D with varying density models how sound reflects, absorbs, and diffracts in concert halls.
- Electromagnetics: Maxwell's equations reduce to the wave equation for light, radio, and all electromagnetic radiation. Antenna design, fibre optics, and wireless communication all depend on wave simulation.
- Seismology: earthquake waves (P-waves and S-waves) propagate through the Earth according to the elastic wave equation. Seismologists use wave simulation to map the Earth's interior.
- Quantum mechanics: the Schrödinger equation is a wave equation. Electron orbitals, tunnelling, and interference are all wave phenomena. Every quantum computer exploits wave interference.
- Ocean engineering: the shallow water equations we implemented in Example 7 are used for tsunami modelling, coastal flooding prediction, and harbour design.
- Music: physical modelling synthesis simulates the wave equation on a string, membrane, or tube to synthesize realistic instrument sounds. The Karplus-Strong algorithm is a simplified version of our vibrating string.
Going further
If you want to push these simulations further, here are some directions:
- Variable wave speed: make c depend on position to simulate refraction (waves bending when entering a different medium). This is how lenses focus light.
- Absorbing boundaries: replace the reflecting walls with Perfectly Matched Layers (PML) that absorb waves without reflection, simulating infinite space.
- GPU acceleration: port the simulation to WebGL fragment shaders. Each pixel update is independent, so the GPU can run millions of cells in parallel at 60fps.
- 3D waves: extend to three dimensions for volumetric wave simulation. This is computationally expensive but produces stunning results, especially for acoustic simulation.
- Nonlinear waves: add nonlinear terms to get solitons (waves that maintain their shape), shock waves, and wave breaking.
- Spectral methods: use the FFT to solve the wave equation in frequency space. This is exact for periodic domains and much faster than finite differences for smooth solutions.
Every wave simulation on this page uses the same core algorithm: store two time steps, compute the Laplacian, leapfrog forward. The variety comes from boundary conditions, source configurations, colour mapping, and the specific wave equation used. That is the beauty of physics simulation — simple rules, infinite variety.
Want to see more wave physics in action? Explore the Lumitree interactive art tree, where visitors plant seeds that grow into unique micro-worlds — some of which use wave simulations as their visual foundation.