Learn Creative Coding (#59) - Wave Simulation

Thirteen episodes into the emergent systems arc. Grid automata (ep047-049), free-moving flocks (ep050-051), continuous chemistry (ep052-053), formal grammars (ep054-055), autonomous crawlers and slime molds (ep056), erosion and growth (ep057), swarm intelligence (ep058). Each of those systems creates structure through agents making local decisions -- cells checking neighbors, boids steering around each other, ants depositing pheromone. The structure emerges from discrete things doing discrete stuff.
Today we simulate something continuous. Waves. Drop a pebble in water and watch the ripple spread outward, bounce off walls, pass through each other, bend around obstacles. No agents. No cells making decisions. Just a surface that follows one equation -- the wave equation -- and produces interference patterns, reflections, diffraction, standing waves. All from pure math applied to a grid of height values.
The connection to what we've been doing: the wave equation uses the same laplacian operator we used for reaction-diffusion in episode 52. Same kernel, different equation, completely different visual result. Reaction-diffusion makes spots and stripes. Waves make... waves. Concentric ripples, interference fringes, Chladni patterns. Some of the most beautiful physics-based visuals you can get from a 2D simulation.
And the interactive potential is huge. Click to drop a stone. Draw walls and watch waves diffract around them. Place two sources and watch interference patterns form. It's the kind of thing that makes people go "wait, I can just... play with this?" Yes. That's the point :-)
The wave equation: one line of physics
The wave equation says: the acceleration at each point is proportional to the curvature of the surface at that point. In calculus notation it's d2u/dt2 = c2 * laplacian(u) where u is the height of the surface, t is time, and c is the wave speed. The laplacian measures how much a point differs from its neighbors -- same operation as in reaction-diffusion.
Discrete version for our grid: for each cell, the "next" height equals two times the current height minus the previous height, plus the wave speed squared times the laplacian of the current height. That's it. Three grids -- previous frame, current frame, next frame -- and one formula.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width = 400;
const H = canvas.height = 400;
// three height grids: previous, current, next
let prev = new Float32Array(W * H);
let curr = new Float32Array(W * H);
let next = new Float32Array(W * H);
const speed = 0.4; // wave propagation speed
const damping = 0.995; // energy loss per frame
Three Float32Array grids. Previous stores where the surface was last frame. Current stores where it is now. Next is where we compute where it will be. After computing next, we rotate: previous becomes current, current becomes next, next becomes the buffer we write into. Circular swap, no allocation.
The damping factor is critical. Without it (damping = 1.0), waves bounce forever and the simulation never settles -- it just gets noisier and noisier until it's visual garbage. With damping at 0.995, each frame loses half a percent of energy. Waves still travel far and bounce multiple times but they gradually fade. Looks natural. Real water has viscosity that does exactly this.
The update step
Here's the core -- the entire wave physics in one nested loop:
function updateWave() {
for (let y = 1; y < H - 1; y++) {
for (let x = 1; x < W - 1; x++) {
const idx = y * W + x;
// laplacian: average of 4 neighbors minus center (times 4)
const laplacian = curr[idx - 1] + curr[idx + 1] +
curr[idx - W] + curr[idx + W] -
4 * curr[idx];
// wave equation: next = 2*current - previous + speed^2 * laplacian
next[idx] = (2 * curr[idx] - prev[idx] + speed * speed * laplacian) * damping;
}
}
// rotate buffers
const tmp = prev;
prev = curr;
curr = next;
next = tmp;
}
The laplacian is left + right + up + down - 4*center. If a cell is lower than its neighbors (concave up), the laplacian is positive and the acceleration pushes it upward. If a cell is higher than its neighbors (concave down), the laplacian is negative and the acceleration pulls it down. This is why disturbances propagate -- a high point pushes its neighbors up, which pushes their neighbors up, spreading outward. And the 2*current - previous part handles the momentum. It's a second-order difference -- the surface remembers where it was going and keeps going that way unless the laplacian changes its mind.
One thing to notice: we skip the border cells (y = 0, y = H-1, x = 0, x = W-1). Those cells stay at zero. This gives us fixed boundary conditions -- the edges act like rigid walls that reflect waves. We'll look at other boundary types later.
Rendering: height to color
Map wave height to color. Positive height = warm colors, negative = cool colors, zero = dark:
function render() {
const imgData = ctx.createImageData(W, H);
for (let i = 0; i < W * H; i++) {
const h = curr[i];
// map height to color
let r, g, b;
if (h > 0) {
// positive: blue to white
const v = Math.min(1, h * 4);
r = Math.floor(40 + v * 215);
g = Math.floor(80 + v * 175);
b = Math.floor(180 + v * 75);
} else {
// negative: dark blue to black
const v = Math.min(1, -h * 4);
r = Math.floor(10 - v * 10);
g = Math.floor(20 + v * 30);
b = Math.floor(60 + v * 80);
}
imgData.data[i * 4 + 0] = r;
imgData.data[i * 4 + 1] = g;
imgData.data[i * 4 + 2] = b;
imgData.data[i * 4 + 3] = 255;
}
ctx.putImageData(imgData, 0, 0);
}
The * 4 scaling factor amplifies the visual contrast. Wave amplitudes tend to be small numbers (especially after damping), and without amplification the surface would look mostly flat. Cranking the contrast makes every ripple visible.
The color choice matters for readability. Positive = bright blue-white (crests), negative = dark navy-black (troughs). The contrast between crest and trough makes the wave fronts pop. You can read the wave direction instantly -- bright rings expanding outward from a disturbance source.
Making it interactive: click to disturb
The fun part. Click the canvas to create a splash:
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = Math.floor((e.clientX - rect.left) * W / rect.width);
const my = Math.floor((e.clientY - rect.top) * H / rect.height);
// create a circular disturbance
const radius = 5;
const strength = 2.0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
const ix = mx + dx;
const iy = my + dy;
if (ix >= 0 && ix < W && iy >= 0 && iy < H) {
curr[iy * W + ix] += strength * (1 - dist / radius);
}
}
}
}
});
function loop() {
updateWave();
render();
requestAnimationFrame(loop);
}
loop();
Click and a ring of ripples spreads outward from the click position. Click again while the first ring is still traveling and you get interference -- where two crests meet the water is extra bright (constructive interference), where a crest meets a trough they cancel out (destructive). Click rapidly in one spot and you get concentric rings. Click in two spots and you get the classic double-source interference pattern -- those hyperbolic fringe lines that every physics textbook shows.
The disturbance shape matters. A point source (single pixel set to a high value) creates a sharp circular ripple. The Gaussian-ish blob we're using (strength falls off linearly from center) creates a smoother, wider ripple that's more visually pleasing. In real life, a pebble hitting water pushes down a small area, not a single point.
Boundary conditions: walls, absorbers, wrapping
The simplest boundary is what we already have -- fixed edges. Every border cell stays at zero. Waves hit the edge and bounce back, perfectly reflected. This is how a drum head works. The edges are pinned and waves bounce between them.
But sometimes you don't want reflections. Maybe you want waves to leave the simulation cleanly, as if the canvas is a window into an infinite ocean. That's absorbing boundaries:
function updateWaveAbsorbing() {
for (let y = 1; y < H - 1; y++) {
for (let x = 1; x < W - 1; x++) {
const idx = y * W + x;
const laplacian = curr[idx - 1] + curr[idx + 1] +
curr[idx - W] + curr[idx + W] -
4 * curr[idx];
next[idx] = (2 * curr[idx] - prev[idx] + speed * speed * laplacian) * damping;
}
}
// absorbing boundaries: copy neighbor values with decay
for (let x = 0; x < W; x++) {
next[x] = curr[x + W] * 0.8; // top edge
next[(H - 1) * W + x] = curr[(H - 2) * W + x] * 0.8; // bottom edge
}
for (let y = 0; y < H; y++) {
next[y * W] = curr[y * W + 1] * 0.8; // left edge
next[y * W + W - 1] = curr[y * W + W - 2] * 0.8; // right edge
}
const tmp = prev;
prev = curr;
curr = next;
next = tmp;
}
The absorbing boundary trick: set each edge cell to a dampened copy of its inner neighbor. The wave arrives at the boundary, gets reduced by 20%, and no energy reflects inward. It's not a perfect absorber (you still get faint reflections) but it's simple and works well enough for visual purposes. A proper absorbing boundary (Perfectly Matched Layer or Mur's ABC) is overkill for creative coding.
Third option: periodic boundaries. Waves that leave one edge appear on the opposite edge. Toroidal space, like our boids and reaction-diffusion:
function getWrapped(field, x, y) {
const wx = ((x % W) + W) % W;
const wy = ((y % H) + H) % H;
return field[wy * W + wx];
}
function updateWavePeriodic() {
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const idx = y * W + x;
const laplacian = getWrapped(curr, x - 1, y) +
getWrapped(curr, x + 1, y) +
getWrapped(curr, x, y - 1) +
getWrapped(curr, x, y + 1) -
4 * curr[idx];
next[idx] = (2 * curr[idx] - prev[idx] + speed * speed * laplacian) * damping;
}
}
const tmp = prev;
prev = curr;
curr = next;
next = tmp;
}
Periodic boundaries mean no reflections and no absorption -- waves just wrap around. A ripple that exits the right edge enters from the left. After a while the whole canvas is a superposition of waves traveling in all directions, interfering with their own wrapped copies. Visually this creates repeating interference patterns that tile seamlessly.
Each boundary type gives different visal dynamics. Fixed boundaries: standing waves and reflections (drum). Absorbing: ripples that fade at the edges (pond). Periodic: infinite ocean (torus). Pick whichever suits the aesthetic you're after.
Obstacles: walls and gaps
Set some cells as fixed (height always zero) and waves reflect off them. This is where diffraction happens -- the bending of waves around obstacles and through gaps.
// obstacle grid: 1 = wall, 0 = open
const obstacles = new Uint8Array(W * H);
// draw a vertical wall with a gap
function createWallWithGap(wallX, gapY, gapHeight) {
for (let y = 0; y < H; y++) {
if (y < gapY || y > gapY + gapHeight) {
obstacles[y * W + wallX] = 1;
// make wall 3 pixels thick for visibility
if (wallX > 0) obstacles[y * W + wallX - 1] = 1;
if (wallX < W - 1) obstacles[y * W + wallX + 1] = 1;
}
}
}
// wall at x=200 with a gap from y=180 to y=220
createWallWithGap(200, 180, 40);
function updateWaveWithObstacles() {
for (let y = 1; y < H - 1; y++) {
for (let x = 1; x < W - 1; x++) {
const idx = y * W + x;
// obstacle cells don't move
if (obstacles[idx]) {
next[idx] = 0;
continue;
}
const laplacian = curr[idx - 1] + curr[idx + 1] +
curr[idx - W] + curr[idx + W] -
4 * curr[idx];
next[idx] = (2 * curr[idx] - prev[idx] + speed * speed * laplacian) * damping;
}
}
const tmp = prev;
prev = curr;
curr = next;
next = tmp;
}
Place a wave source on the left side of the wall. Waves hit the wall and reflect. But through the gap, waves pass through and spread out on the other side in a circular pattern -- even though the incoming waves were flat wave fronts (plane waves). This is Huygens' principle in action: every point on a wavefront acts as a new source. The gap acts like a new point source, radiating circular waves into the shadow zone behind the wall.
The size of the gap relative to the wavelength matters. Small gap (much smaller than wavelength) = strong diffraction, the wave spreads almost uniformly in all directions behind the wall. Large gap (much bigger than wavelength) = weak diffraction, the wave mostly goes straight through with just a little bending at the edges. You can play with the gapHeight parameter to see this.
The double-slit experiment
The most famous wave phenomenon. Two gaps in a wall, one wave source. The waves passing through each gap interfere with each other behind the wall, creating alternating bright and dark bands:
function createDoubleSlit(wallX, slit1Y, slit2Y, slitHeight, wallThickness) {
for (let y = 0; y < H; y++) {
const inSlit1 = y >= slit1Y && y <= slit1Y + slitHeight;
const inSlit2 = y >= slit2Y && y <= slit2Y + slitHeight;
if (!inSlit1 && !inSlit2) {
for (let dx = 0; dx < wallThickness; dx++) {
const wx = wallX + dx;
if (wx >= 0 && wx < W) {
obstacles[y * W + wx] = 1;
}
}
}
}
}
// two slits separated by 60 pixels
createDoubleSlit(180, 150, 210, 20, 3);
// continuous wave source on the left
function emitPlaneWave(x, frequency, time) {
const wavelength = W / frequency;
for (let y = 10; y < H - 10; y++) {
curr[y * W + x] = Math.sin(time * 0.15) * 0.5;
}
}
Instead of a single click disturbance, we drive a continuous plane wave from the left edge. The emitPlaneWave function sets a column of cells to a sine value that changes over time. This produces flat wavefronts traveling from left to right. When they hit the double slit, each slit becomes a circular source. The two circular waves overlap behind the wall and create the interference pattern -- alternating maxima (constructive) and minima (destructive) at angles determined by the slit separation and wavelength.
The pattern is hypnotic. Bright fringes fan out from behind the wall at regular angular intervals. Between fringes, the water is almost perfectly still -- two waves arriving exactly out of phase, canceling each other. Run it for a few seconds and the pattern stabilizes into a steady-state interference figure. This is the same physics that proved light is a wave in 1801 (Thomas Young's experiment). Our canvas simulation shows it in real time.
Refraction: waves that bend
Waves change speed when they enter a different medium. Light slows down in glass, sound speeds up in warm air, water waves slow down in shallow water. When a wave crosses a boundary between fast and slow regions at an angle, it bends. This is refraction -- the reason lenses focus light and pools look shallower than they are.
We can simulate this by making the wave speed vary across the canvas:
// speed map: different regions have different wave speeds
const speedMap = new Float32Array(W * H);
function createRefractionRegion() {
// default speed everywhere
speedMap.fill(0.4);
// slow region: a circular "lens" in the center
const cx = W / 2;
const cy = H / 2;
const lensRadius = 60;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const dx = x - cx;
const dy = y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < lensRadius) {
// slower speed inside the lens
speedMap[y * W + x] = 0.2;
}
}
}
}
createRefractionRegion();
function updateWaveWithRefraction() {
for (let y = 1; y < H - 1; y++) {
for (let x = 1; x < W - 1; x++) {
const idx = y * W + x;
if (obstacles[idx]) { next[idx] = 0; continue; }
const laplacian = curr[idx - 1] + curr[idx + 1] +
curr[idx - W] + curr[idx + W] -
4 * curr[idx];
const localSpeed = speedMap[idx];
next[idx] = (2 * curr[idx] - prev[idx] +
localSpeed * localSpeed * laplacian) * damping;
}
}
const tmp = prev;
prev = curr;
curr = next;
next = tmp;
}
Send a plane wave from the left toward the circular slow region. As wavefronts enter the lens, the part inside slows down while the part outside continues at normal speed. The wavefront bends inward. On the far side of the lens, the converging wavefronts focus to a bright point -- a focal point. You just built a lens from first principles. No optics library, no ray tracing. Just a region where waves go slower, and the focusing happens automatically from the wave equation.
If you use a noise-based speed map (different speeds everywhere based on Perlin noise from ep012), waves scatter and refract continuously. The wavefronts wobble and fragment as they pass through regions of varying speed. Looks like light passing through frosted glass or water rippling over a rocky bottom. Really pretty effect.
Standing waves and Chladni patterns
In a bounded region, reflected waves interfere with incoming waves to create patterns that don't move -- standing waves. At some points (nodes) the surface stays perfectly still. At other points (antinodes) it oscillates with maximum amplitude. The spatial pattern of nodes and antinodes depends on the shape of the region and the frequency of the driving oscillation.
Ernst Chladni discovered this in 1787 by sprinkling sand on vibrating metal plates. The sand collects at the nodes (where the plate isn't moving) and reveals the standing wave pattern. Beautiful geometric shapes that depend on the frequency of vibration.
We can recreate this digitally by driving the center of our simulation with a continuous oscillation:
let time = 0;
function updateWithDriver(driverX, driverY, frequency) {
time++;
// drive the center point with a sine wave
curr[driverY * W + driverX] = Math.sin(time * frequency) * 1.5;
// normal wave update
for (let y = 1; y < H - 1; y++) {
for (let x = 1; x < W - 1; x++) {
const idx = y * W + x;
if (x === driverX && y === driverY) continue; // don't overwrite driver
const laplacian = curr[idx - 1] + curr[idx + 1] +
curr[idx - W] + curr[idx + W] -
4 * curr[idx];
next[idx] = (2 * curr[idx] - prev[idx] + speed * speed * laplacian) * damping;
}
}
const tmp = prev;
prev = curr;
curr = next;
next = tmp;
}
After a few hundred frames (let the transients die down), the pattern stabilizes into a standing wave. The shape depends on the frequency. Low frequency = simple pattern with a few large regions. High frequency = complex pattern with many small cells. Change the frequency and the pattern morphs smoothly from one mode to another.
To visualize the Chladni pattern properly, we need to track the amplitude at each cell over time -- not just the instantaneous height. Accumulate the absolute value of the height:
const amplitude = new Float32Array(W * H);
let accFrames = 0;
function accumulateAmplitude() {
accFrames++;
for (let i = 0; i < W * H; i++) {
amplitude[i] += Math.abs(curr[i]);
}
}
function renderChladni() {
const imgData = ctx.createImageData(W, H);
// find max amplitude for normalization
let maxAmp = 0;
for (let i = 0; i < W * H; i++) {
if (amplitude[i] > maxAmp) maxAmp = amplitude[i];
}
for (let i = 0; i < W * H; i++) {
const a = amplitude[i] / maxAmp;
// invert: nodes (low amplitude) are bright, antinodes are dark
// this mimics sand collecting at nodes
const v = 1 - Math.pow(a, 0.3);
imgData.data[i * 4 + 0] = Math.floor(v * 220 + 20);
imgData.data[i * 4 + 1] = Math.floor(v * 200 + 15);
imgData.data[i * 4 + 2] = Math.floor(v * 160 + 10);
imgData.data[i * 4 + 3] = 255;
}
ctx.putImageData(imgData, 0, 0);
}
The inverted color map -- nodes bright, antinodes dark -- mimics actual Chladni plates where sand collects at the still points. The warm sepia palette makes it look like the historical plates. Different frequencies produce different geometric patterns: circles, crosses, star shapes, intricate tessellations. Sweep the frequency slowly and you can see one pattern dissolve and a new one form as the resonance shifts.
This is genuinely one of the prettiest things you can do with 2D wave simulation. The patterns are instantly recognizable to anyone who's seen a physics demonstration, and the ability to sweep through frequencies in real time makes it interactive and mesmerizing.
Gradient-based rendering: fake 3D
Instead of mapping height directly to color, use the height gradient (slope) to compute lighting. This makes the wave surface look like a 3D liquid:
function renderWave3D() {
const imgData = ctx.createImageData(W, H);
for (let y = 1; y < H - 1; y++) {
for (let x = 1; x < W - 1; x++) {
const idx = y * W + x;
// surface gradient
const dhdx = (curr[idx + 1] - curr[idx - 1]) * 0.5;
const dhdy = (curr[idx + W] - curr[idx - W]) * 0.5;
// normal vector (unnormalized)
const scale = 8.0;
const nx = -dhdx * scale;
const ny = -dhdy * scale;
const nz = 1;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
// light from upper-left
const lx = -0.5;
const ly = -0.7;
const lz = 1.0;
const lLen = Math.sqrt(lx * lx + ly * ly + lz * lz);
const dot = (nx * lx + ny * ly + nz * lz) / (len * lLen);
const shade = Math.max(0, Math.min(1, dot));
// specular highlight
const spec = Math.pow(shade, 16) * 0.6;
// base water color modulated by shading
const r = Math.floor(15 + shade * 50 + spec * 255);
const g = Math.floor(40 + shade * 90 + spec * 255);
const b = Math.floor(100 + shade * 120 + spec * 200);
imgData.data[idx * 4 + 0] = Math.min(255, r);
imgData.data[idx * 4 + 1] = Math.min(255, g);
imgData.data[idx * 4 + 2] = Math.min(255, b);
imgData.data[idx * 4 + 3] = 255;
}
}
// draw obstacles
for (let i = 0; i < W * H; i++) {
if (obstacles[i]) {
imgData.data[i * 4 + 0] = 60;
imgData.data[i * 4 + 1] = 55;
imgData.data[i * 4 + 2] = 50;
imgData.data[i * 4 + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
}
Same technique as the shaded relief in episode 57 (erosion). Compute the surface normal from the height gradient, dot it with a light direction. Slopes facing the light are bright, slopes facing away are dark. The specular highlight (pow(shade, 16)) adds a sharp glint on the steepest slopes facing the light -- looks like sunlight catching the water surface.
The scale = 8.0 exaggerates the surface curvature for dramatic lighting. Without it the waves are too gentle to create visible shading. Real-time water renderers do the same thing -- exaggerate the normal map to make small ripples catch the light.
With this rendering, clicking the surface looks like dropping stones in actual water. The concentric rings have depth, the light glints off wave crests, troughs are dark. Combined with the interactive click-to-disturb, it's one of those simulations that people don't want to stop playing with.
Multiple sources and interference
Place two continuous oscillating sources and watch the interference pattern:
const sources = [
{ x: 150, y: 200, freq: 0.12 },
{ x: 250, y: 200, freq: 0.12 }
];
function emitSources() {
time++;
for (const src of sources) {
curr[src.y * W + src.x] = Math.sin(time * src.freq) * 1.5;
}
}
function mainLoop() {
emitSources();
updateWave();
renderWave3D();
requestAnimationFrame(mainLoop);
}
mainLoop();
Two sources at the same frequency create a stable interference pattern. Between the sources, you get a series of hyperbolic nodal lines where the waves from both sources arrive exactly out of phase and cancel. Between the nodal lines are antinodal regions where the waves reinforce. The pattern looks like ripples in a bath tub when you tap both ends -- symmetrical, rhythmic, strangly hypnotic.
Change one source's frequency slightly and you get beat patterns -- the interference figure slowly drifts and pulses as the two frequencies go in and out of phase. At the "beat frequency" (difference between the two frequencies), the pattern cycles from maximum interference to maximum cancellation and back. Musical acoustics uses this same phenomenon -- two strings slightly out of tune produce audible beats.
Three sources arranged in a triangle produce even more complex interference. Four sources in a square. Ring of sources. Each configuration creates a unique stationary pattern. It's like a visual version of acoustic engineering -- manipulating wave superposition to create specific spatial patterns. Phased array antennas do exactly this to steer radar beams.
Creative exercise: the wave pool
Let's put it all together -- interactive wave pool with click-to-disturb, drawable obstacles, refraction regions, and 3D rendering:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width = 500;
const H = canvas.height = 500;
let prev = new Float32Array(W * H);
let curr = new Float32Array(W * H);
let next = new Float32Array(W * H);
const obstacles = new Uint8Array(W * H);
const speedMap = new Float32Array(W * H);
speedMap.fill(0.4);
let isDrawing = false;
let drawMode = 'disturb'; // 'disturb', 'wall', 'lens'
canvas.addEventListener('mousedown', (e) => {
isDrawing = true;
handleMouse(e);
});
canvas.addEventListener('mousemove', (e) => {
if (isDrawing) handleMouse(e);
});
canvas.addEventListener('mouseup', () => { isDrawing = false; });
function handleMouse(e) {
const rect = canvas.getBoundingClientRect();
const mx = Math.floor((e.clientX - rect.left) * W / rect.width);
const my = Math.floor((e.clientY - rect.top) * H / rect.height);
if (drawMode === 'disturb') {
// splash at mouse position
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 4) {
const ix = mx + dx;
const iy = my + dy;
if (ix > 0 && ix < W - 1 && iy > 0 && iy < H - 1) {
curr[iy * W + ix] += 2.0 * (1 - dist / 4);
}
}
}
}
} else if (drawMode === 'wall') {
// paint obstacle pixels
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const ix = mx + dx;
const iy = my + dy;
if (ix >= 0 && ix < W && iy >= 0 && iy < H) {
obstacles[iy * W + ix] = 1;
}
}
}
} else if (drawMode === 'lens') {
// paint slow-speed region
for (let dy = -8; dy <= 8; dy++) {
for (let dx = -8; dx <= 8; dx++) {
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 8) {
const ix = mx + dx;
const iy = my + dy;
if (ix >= 0 && ix < W && iy >= 0 && iy < H) {
speedMap[iy * W + ix] = 0.18;
}
}
}
}
}
}
// keyboard to switch modes
document.addEventListener('keydown', (e) => {
if (e.key === '1') drawMode = 'disturb';
if (e.key === '2') drawMode = 'wall';
if (e.key === '3') drawMode = 'lens';
if (e.key === 'c') {
// clear everything
prev.fill(0);
curr.fill(0);
next.fill(0);
obstacles.fill(0);
speedMap.fill(0.4);
}
});
Press 1 to splash waves, 2 to draw walls, 3 to paint slow-speed lens regions, C to clear. Draw a wall with a gap, splash waves on one side, watch them diffract through. Paint a lens region, send waves through it, watch them focus. Draw a complex maze of walls and watch waves navigate through it, bouncing and diffracting at every corner.
This is a playground. The wave equation handles all the physics automatically -- you just provide the geometry and disturbances, and the math does the rest. Reflection, refraction, diffraction, interference, standing waves, damping -- all from one equation, one update loop, and a bit of interactivity.
Connecting to the arc
Wave simulation sits at the intersection of several things we've built. The update kernel is the same laplacian as reaction-diffusion (ep052). The gradient-based rendering is the same as erosion hillshading (ep057). The continuous-field approach is the same as the pheromone fields in swarm intelligence (ep058). But the dynamics are fundamentally different -- reaction-diffusion creates static patterns, erosion carves permanent terrain, pheromone fields guide agents. Waves are oscillatory. They move, they bounce, they pass through each other without interacting. That's the unique thing about linear wave physics -- superposition. Two waves in the same place just add up. They don't compete or react. They coexist.
That makes wave simulation a different creative tool than anything else we've built. The other systems produce structure -- spots, branches, rivers, networks. Waves produce motion. The canvas is alive with traveling disturbances that never settle into a static form (unless you specifically drive standing waves). It's kinetic art generated by physics.
We've covered a lot of territory in this emergent systems arc. Thirteen episodes of different simulation approaches. Next up is the mini-project where we bring several of these systems together into something bigger -- an artificial ecosystem where multiple types of agents interact in a shared environment. Plants, herbivores, predators, chemical signals, territory... all the techniques from the past dozen episodes combined into one living simulation.
't Komt erop neer...
- The wave equation uses three grids (previous, current, next) and one formula:
next = 2*current - previous + speed^2 * laplacian. The laplacian is the same 4-neighbor average-minus-center kernel from reaction-diffusion. Damping (multiply by 0.995 each frame) prevents infinite bouncing - Fixed boundaries (edges pinned to zero) reflect waves like a drum head. Absorbing boundaries (edge cells copy inner neighbors with decay) let waves leave cleanly. Periodic boundaries (wrap-around) simulate an infinite torus. Each gives completely different visual dynamics
- Obstacles are cells pinned to zero. Waves reflect off them and diffract through gaps. Small gap relative to wavelength = strong diffraction (circular spreading). Large gap = weak diffraction (mostly straight-through). The double-slit configuration produces the classic interference fringe pattern
- Refraction happens when wave speed varies across the canvas. A circular slow region acts as a lens -- wavefronts bend inward and focus to a point behind the lens. A noise-based speed map scatters waves like frosted glass
- Standing waves form when a continuous driver oscillates at a fixed frequency in a bounded region. Nodes (zero amplitude) and antinodes (maximum amplitude) create Chladni-like patterns. Different frequencies produce diferent geometric mode shapes
- Gradient-based rendering computes surface normals from the height differences and dots them with a light vector. With specular highlights, the wave surface looks like actual liquid. Same technique as the erosion hillshading from episode 57
- Multiple oscillating sources at the same frequency create stable interference patterns. Slightly different frequencies create drifting beat patterns. Arranging sources in geometric configurations (lines, triangles, rings) produces increasingly complex interference figures
- The interactive wave pool combines all elements: click to splash, draw walls to create obstacles, paint lens regions for refraction, keyboard to switch modes. The wave equation handles all physics automatically from geometry and disturbances alone
Sallukes! Thanks for reading.
X
Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)
Your next target is to reach 200 upvotes.
You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word
STOP