diff --git a/README.md b/README.md index 9298d44..5755c3b 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 @@ -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 diff --git a/src/render.js b/src/render.js index 72347b1..88fb0b3 100644 --- a/src/render.js +++ b/src/render.js @@ -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]; diff --git a/src/scenes.js b/src/scenes.js index 9f7a47b..8cd0ebb 100644 --- a/src/scenes.js +++ b/src/scenes.js @@ -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', diff --git a/src/wave.js b/src/wave.js index 5cdb71d..f2fc58a 100644 --- a/src/wave.js +++ b/src/wave.js @@ -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); @@ -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; diff --git a/test/render.test.js b/test/render.test.js index f13b9f2..d029fbf 100644 --- a/test/render.test.js +++ b/test/render.test.js @@ -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; diff --git a/test/scenes.test.js b/test/scenes.test.js index 85cebb5..d98e6ba 100644 --- a/test/scenes.test.js +++ b/test/scenes.test.js @@ -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); } @@ -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'); diff --git a/test/wave.test.js b/test/wave.test.js index 24f33e1..04bbf0a 100644 --- a/test/wave.test.js +++ b/test/wave.test.js @@ -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);