Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ static server works too.

| Control | Effect |
| --- | --- |
| Scene | Load one of ten built-in set-ups (see below) |
| Scene | Load one of twelve built-in set-ups (see below) |
| Frequency | Oscillation rate of every source, in thousandths of a cycle per step |
| Damping | Uniform energy loss; useful to settle a busy tank |
| Speed | Simulation steps per animation frame |
Expand Down Expand Up @@ -65,6 +65,10 @@ pick a tool.
- **Shallow-water lens** – a round patch of slow water focuses plane waves.
- **Refraction at a boundary** – waves slow down and shorten as they cross
into shallower water.
- **Doppler shift** – a source gliding at less than half the wave speed;
wavefronts bunch up ahead of it and stretch out behind.
- **Mach cone** – a source outrunning its own waves, leaving a V-shaped shock
front behind it.
- **Empty tank** – start from nothing.

## How it works
Expand All @@ -88,7 +92,10 @@ u_next = (2u − (1 − σ) u_prev + (c·Δt/Δx)² ∇²u) / (1 + σ)
- Walls pin the displacement to zero, which reflects waves with a phase
inversion.
- Sources add `A·sin(2πft + φ)` to their cell every step (a "soft" source), so
waves pass through them instead of scattering off a clamped cell.
waves pass through them instead of scattering off a clamped cell. A source
can also drift with a velocity in cells per step; it is then injected into
the four surrounding cells with bilinear weights so it glides smoothly, and
it bounces off the tank border and any wall it meets.

Colours: amber for crests, blue for troughs, dark slate for still water. The
intensity view keeps an exponential running average of `u²`, normalises it by
Expand Down
5 changes: 4 additions & 1 deletion src/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,10 @@ export function paintField(field, rgba, { mode = 'displacement', scale = 1, inte
}
for (const src of field.sources) {
if (!src.enabled) continue;
const i = field.index(src.x, src.y) * 4;
const x = Math.round(src.x);
const y = Math.round(src.y);
if (!field.inBounds(x, y)) continue;
const i = field.index(x, y) * 4;
rgba[i] = SOURCE_COLOR[0];
rgba[i + 1] = SOURCE_COLOR[1];
rgba[i + 2] = SOURCE_COLOR[2];
Expand Down
16 changes: 16 additions & 0 deletions src/scenes.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ export const SCENES = [
field.fillSpeed(px(field, 0.5), 0, field.width - 1, field.height - 1, 0.5);
},
},
{
id: 'doppler',
name: 'Doppler shift',
description: 'A source gliding across the tank at less than half the wave speed. Wavefronts bunch up ahead of it and stretch out behind, so an observer in front hears a higher pitch.',
apply(field, { frequency }) {
field.addSource(px(field, 0.2), py(field, 0.5), { frequency, vx: 0.3 });
},
},
{
id: 'mach',
name: 'Mach cone',
description: 'A source outrunning its own waves. No wavefront can get ahead of it, and the crests it leaves behind pile up along a V-shaped shock front.',
apply(field, { frequency }) {
field.addSource(px(field, 0.1), py(field, 0.5), { frequency, amplitude: 1.5, vx: 0.95 });
},
},
{
id: 'empty',
name: 'Empty tank',
Expand Down
71 changes: 65 additions & 6 deletions src/wave.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,69 @@ export class WaveField {

/**
* Add a continuously oscillating point source.
* frequency is in cycles per step, phase in radians. Returns the source.
* frequency is in cycles per step, phase in radians. vx/vy give a drift in
* cells per step; a moving source bounces off the tank's inner border and
* off walls. Returns the source.
*/
addSource(x, y, { frequency = 0.02, amplitude = 1, phase = 0 } = {}) {
addSource(x, y, { frequency = 0.02, amplitude = 1, phase = 0, vx = 0, vy = 0 } = {}) {
if (!this.inBounds(x, y)) throw new RangeError('source outside the grid');
const source = { x, y, frequency, amplitude, phase, enabled: true };
const source = { x, y, frequency, amplitude, phase, vx, vy, enabled: true };
this.sources.push(source);
return source;
}

/** Move a drifting source one step, reflecting off the border and walls. */
_moveSource(src) {
if (!src.vx && !src.vy) return;
const minX = 1;
const minY = 1;
const maxX = this.width - 2;
const maxY = this.height - 2;
let nx = src.x + src.vx;
let ny = src.y + src.vy;
if (nx < minX || nx > maxX) {
src.vx = -src.vx;
nx = Math.min(maxX, Math.max(minX, nx));
}
if (ny < minY || ny > maxY) {
src.vy = -src.vy;
ny = Math.min(maxY, Math.max(minY, ny));
}
// Reflect off walls along each axis independently so a source sliding
// past a barrier keeps its tangential motion.
if (this.wall[this.index(Math.round(nx), Math.round(src.y))]) {
src.vx = -src.vx;
nx = src.x;
}
if (this.wall[this.index(Math.round(src.x), Math.round(ny))]) {
src.vy = -src.vy;
ny = src.y;
}
src.x = nx;
src.y = ny;
}

/**
* Add value into the four cells around a fractional position with bilinear
* weights, so a moving source glides instead of hopping cell to cell.
*/
_inject(buffer, x, y, value) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const fx = x - x0;
const fy = y - y0;
const { width, wall } = this;
const put = (cx, cy, w) => {
if (w <= 0 || !this.inBounds(cx, cy)) return;
const i = cy * width + cx;
if (!wall[i]) buffer[i] += value * w;
};
put(x0, y0, (1 - fx) * (1 - fy));
put(x0 + 1, y0, fx * (1 - fy));
put(x0, y0 + 1, (1 - fx) * fy);
put(x0 + 1, y0 + 1, fx * fy);
}

removeSource(source) {
const i = this.sources.indexOf(source);
if (i >= 0) this.sources.splice(i, 1);
Expand Down Expand Up @@ -197,9 +251,14 @@ export class WaveField {
this.time += 1;
for (const src of this.sources) {
if (!src.enabled) continue;
const i = this.index(src.x, src.y);
if (wall[i]) continue;
next[i] += src.amplitude * Math.sin(2 * Math.PI * src.frequency * this.time + src.phase);
this._moveSource(src);
const value = src.amplitude * Math.sin(2 * Math.PI * src.frequency * this.time + src.phase);
if (Number.isInteger(src.x) && Number.isInteger(src.y)) {
const i = this.index(src.x, src.y);
if (!wall[i]) next[i] += value;
} else {
this._inject(next, src.x, src.y, value);
}
}

this.prev = cur;
Expand Down
5 changes: 5 additions & 0 deletions test/render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ test('paintField writes opaque pixels, marks walls and sources, and honours the
const px = (x, y) => Array.from(rgba.subarray(f.index(x, y) * 4, f.index(x, y) * 4 + 3));
assert.deepEqual(px(1, 1), WALL_COLOR);
assert.deepEqual(px(6, 4), SOURCE_COLOR);
// A drifting source between cells is marked on the nearest one.
f.sources[0].x = 5.4;
paintField(f, rgba, { scale: 0.5 });
assert.deepEqual(px(5, 4), SOURCE_COLOR);
assert.notDeepEqual(px(6, 4), SOURCE_COLOR);
assert.deepEqual(px(3, 3), displacementColor(1), 'scale 0.5 maps u=0.5 to full colour');
assert.deepEqual(px(0, 0), displacementColor(0));
f.cur[f.index(3, 3)] = -0.3;
Expand Down
14 changes: 14 additions & 0 deletions test/scenes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ test('every scene loads on small and large grids without leaving the field', ()
assert.equal(applied, scene);
for (const s of f.sources) {
assert.ok(f.inBounds(s.x, s.y), `${scene.id}: source at ${s.x},${s.y} outside ${w}x${h}`);
assert.ok(Number.isInteger(s.x) && Number.isInteger(s.y), `${scene.id}: sources start on a cell`);
assert.equal(f.wall[f.index(s.x, s.y)], 0, `${scene.id}: source inside a wall`);
assert.equal(s.frequency, 0.03);
}
Expand Down Expand Up @@ -88,6 +89,19 @@ test('the antiphase pair produces a node on the midline where the in-phase pair
assert.ok(antiPhase < inPhase * 0.1, `antiphase ${antiPhase} vs in-phase ${inPhase}`);
});

test('the Doppler source is slower than the waves and the Mach source faster', () => {
const f = new WaveField(120, 80);
loadScene(f, 'doppler');
assert.ok(Math.hypot(f.sources[0].vx, f.sources[0].vy) < f.courant);
loadScene(f, 'mach');
assert.ok(Math.hypot(f.sources[0].vx, f.sources[0].vy) > f.courant);
// Both keep running for a long time without leaving the tank or blowing up.
for (let t = 0; t < 1500; t++) f.step();
const s = f.sources[0];
assert.ok(f.inBounds(Math.round(s.x), Math.round(s.y)));
assert.ok(Number.isFinite(f.energy()));
});

test('the lens slows waves inside the disc only', () => {
const f = new WaveField(200, 100);
loadScene(f, 'lens');
Expand Down
66 changes: 66 additions & 0 deletions test/wave.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,72 @@ test('an oscillating source keeps injecting energy', () => {
assert.throws(() => f.addSource(100, 100), RangeError);
});

test('a source at a fractional position is split between neighbouring cells', () => {
const f = new WaveField(21, 21);
f.addSource(10.5, 10, { frequency: 0.25, amplitude: 1 }); // sin(pi/2) = 1 on step 1
f.step();
const a = f.cur[f.index(10, 10)];
const b = f.cur[f.index(11, 10)];
assert.ok(Math.abs(a - 0.5) < 1e-6 && Math.abs(b - 0.5) < 1e-6, `${a} ${b}`);
assert.equal(f.cur[f.index(10, 11)], 0);
});

test('a drifting source moves each step and bounces off the border', () => {
const f = new WaveField(41, 21);
const s = f.addSource(35, 10, { vx: 1, vy: 0 });
f.step();
assert.equal(s.x, 36);
for (let i = 0; i < 100; i++) {
f.step();
assert.ok(s.x >= 1 && s.x <= 39, `source left the tank at x=${s.x}`);
assert.equal(s.y, 10);
}
assert.ok(s.vx < 0 || s.x < 35, 'the source turned around');
const t = f.addSource(20, 2, { vx: 0, vy: -0.5 });
for (let i = 0; i < 10; i++) f.step();
assert.ok(t.y >= 1);
assert.ok(t.vy > 0);
});

test('a drifting source reflects off walls', () => {
const f = new WaveField(41, 21);
f.fillWall(30, 0, 31, 20);
const s = f.addSource(20, 10, { vx: 1 });
for (let i = 0; i < 15; i++) {
f.step();
assert.ok(s.x < 30, `source entered the wall at x=${s.x}`);
}
assert.ok(s.vx < 0 && s.x < 29, `source did not turn back: x=${s.x} vx=${s.vx}`);
// A still source is left alone.
const r = f.addSource(5, 5);
f.step();
assert.equal(r.x, 5);
assert.equal(r.y, 5);
});

// Count sign changes of the displacement along a row segment.
function zeroCrossings(field, y, x0, x1) {
let n = 0;
let last = 0;
for (let x = x0; x <= x1; x++) {
const v = field.cur[field.index(x, y)];
if (v !== 0 && last !== 0 && Math.sign(v) !== Math.sign(last)) n++;
if (v !== 0) last = v;
}
return n;
}

test('a moving source shows a Doppler shift: shorter waves ahead, longer behind', () => {
const f = new WaveField(321, 61, { spongeWidth: 16 });
const s = f.addSource(60, 30, { frequency: 0.05, vx: 0.3 });
for (let t = 0; t < 200; t++) f.step();
const x = Math.round(s.x);
assert.ok(Math.abs(x - 120) <= 1);
const ahead = zeroCrossings(f, 30, x + 10, x + 70);
const behind = zeroCrossings(f, 30, x - 70, x - 10);
assert.ok(ahead > behind * 1.8, `ahead ${ahead} vs behind ${behind}`);
});

test('sources can be removed and cleared', () => {
const f = new WaveField(11, 11);
const a = f.addSource(3, 3);
Expand Down
Loading