/ concept-collection / acoustic-scattering-3d
Sign in
concept-collection / acoustic-scattering-3d
acoustic-scattering-3d / src / main.ts
403 lines · 13.9 KBBlameHistoryRaw
1/**
2 * The app: wiring between the solver (sim.ts), the scenes (scenes.ts), the
3 * volume renderer (render/volume.ts) and the page.
4 */
5import { requestAcousticDevice, maxGridSide } from './device.ts';
6import { makeGrid, stableDt, type Grid } from './grid.ts';
7import { Sim, MAX_STEPS_PER_FRAME, MIC_CAPACITY, type SourceParams } from './sim.ts';
8import { playTrace } from './audio.ts';
9import { scenes, sceneKeys, buildMedium, type Medium, type SceneParam } from './scenes.ts';
10import { VolumeView, type Camera } from './render/volume.ts';
11import { colormaps, colormapNames } from './render/colormaps.ts';
12import { C_AIR, DOMAIN, POOR_RESOLUTION, fmtLength, fmtTime } from './units.ts';
14const $ = <T extends HTMLElement>(id: string): T => {
15 const el = document.getElementById(id);
16 if (!el) throw new Error(`no #${id}`);
17 return el as T;
18};
20const err = $('err');
21const report = (e: unknown) => {
22 err.textContent = e instanceof Error ? e.message : String(e);
23};
25/** A labelled slider with a live readout, in one of the .sliders grids. */
26function slider(
27 parent: HTMLElement,
28 p: SceneParam,
29 onInput: (v: number) => void,
30): { set: (v: number) => void } {
31 const row = document.createElement('label');
32 row.className = 'slider';
33 const name = document.createElement('span');
34 name.textContent = p.label;
35 const input = document.createElement('input');
36 input.type = 'range';
37 input.min = String(p.min);
38 input.max = String(p.max);
39 input.step = String(p.step);
40 input.value = String(p.value);
41 const out = document.createElement('output');
42 const fmt = (v: number) => `${+v.toPrecision(3)}${p.unit ? ' ' + p.unit : ''}`;
43 out.textContent = fmt(p.value);
44 input.addEventListener('input', () => {
45 const v = Number(input.value);
46 out.textContent = fmt(v);
47 onInput(v);
48 });
49 row.append(name, input, out);
50 parent.append(row);
51 return {
52 set: (v) => {
53 input.value = String(v);
54 out.textContent = fmt(v);
55 },
56 };
59async function start() {
60 const device = await requestAcousticDevice();
61 device.addEventListener('uncapturederror', (e) => report((e as GPUUncapturedErrorEvent).error.message));
63 // --- state ---------------------------------------------------------------
64 const gridSel = $<HTMLSelectElement>('gridsize');
65 for (const n of [64, 96, 128, 160, 192]) {
66 if (n > maxGridSide(device)) break;
67 const opt = document.createElement('option');
68 opt.value = String(n);
69 opt.textContent = `${n}³`;
70 if (n === 128) opt.selected = true;
71 gridSel.append(opt);
72 }
74 let sceneKey = 'sphere';
75 let grid: Grid = makeGrid(Number(gridSel.value), DOMAIN);
76 let cfl = 0.5;
77 let running = false;
78 let stepsPerFrame = 4;
80 const source: SourceParams = {
81 f: 1200,
82 cycles: 2,
83 cw: 0,
84 point: 0,
85 x0: -0.32 * DOMAIN,
86 y0: 0,
87 z0: 0,
88 w: 0.02 * DOMAIN,
89 };
90 const sceneValues: Record<string, Record<string, number>> = {};
91 for (const k of sceneKeys) {
92 sceneValues[k] = Object.fromEntries(scenes[k].params.map((p) => [p.key, p.value]));
93 }
95 const camera: Camera = { az: -2.35, el: 0.5, dist: 2.6 * DOMAIN };
96 const viewState = { opacity: 1.6, contrast: 1.6, clip: 0.5 };
97 let quality = 192;
98 let colormap = 'coolwarm';
99 let scaleMode: 'auto' | 'fixed' = 'fixed';
100 let scale = 1;
101 let showMedium = true;
102 /** Microphone position, metres. In the forward-scattering shadow of the
103 * default sphere, which is where there is something to hear. */
104 const mic = { x: 0.25 * DOMAIN, y: 0, z: 0 };
106 let medium: Medium = buildMedium(scenes[sceneKey], grid, sceneValues[sceneKey]);
107 let sim = new Sim(device, grid, medium, source, stableDt(grid.h, medium.cmax, cfl));
108 const view = new VolumeView(device, $<HTMLCanvasElement>('view'));
109 view.setSource(sim.pressures, sim.speed, grid.n, grid.L);
110 view.setColormap(colormaps[colormap]);
112 const applyProbe = () => {
113 const toIdx = (v: number) => Math.floor((v + grid.L / 2) / grid.h);
114 sim.setProbe(toIdx(mic.x), toIdx(mic.y), toIdx(mic.z));
115 };
116 applyProbe();
118 // --- rebuilds ------------------------------------------------------------
119 const applyDt = () => {
120 const dt = stableDt(grid.h, medium.cmax, cfl);
121 if (dt !== sim.dt) {
122 sim.dt = dt;
123 // One sample per step: two timesteps would be two sample rates in one
124 // trace.
125 sim.resetTrace();
126 }
127 };
129 /** New medium on the existing grid (a scene parameter moved): rebuild the
130 * sim but keep the run going conceptually — the field restarts, which is
131 * honest, since the old field belongs to the old medium. */
132 const rebuildSim = () => {
133 sim.destroy();
134 medium = buildMedium(scenes[sceneKey], grid, sceneValues[sceneKey]);
135 sim = new Sim(device, grid, medium, source, stableDt(grid.h, medium.cmax, cfl));
136 view.setSource(sim.pressures, sim.speed, grid.n, grid.L);
137 applyProbe();
138 updateStatics();
139 };
141 // --- controls ------------------------------------------------------------
142 const sceneSel = $<HTMLSelectElement>('scene');
143 for (const k of sceneKeys) {
144 const opt = document.createElement('option');
145 opt.value = k;
146 opt.textContent = scenes[k].label;
147 sceneSel.append(opt);
148 }
150 const sceneParamsBox = $('sceneparams');
151 const buildSceneSliders = () => {
152 sceneParamsBox.replaceChildren();
153 $('scene-title').textContent = `scene — ${scenes[sceneKey].label.toLowerCase()}`;
154 $('blurb').textContent = scenes[sceneKey].blurb;
155 for (const p of scenes[sceneKey].params) {
156 slider(sceneParamsBox, { ...p, value: sceneValues[sceneKey][p.key] }, (v) => {
157 sceneValues[sceneKey][p.key] = v;
158 rebuildSim();
159 });
160 }
161 };
163 sceneSel.addEventListener('change', () => {
164 sceneKey = sceneSel.value;
165 const want = scenes[sceneKey].source;
166 if (want) {
167 Object.assign(source, want);
168 const s = source as unknown as Record<string, number>;
169 for (const [k, set] of Object.entries(sourceSliders)) set.set(s[k]);
170 }
171 buildSceneSliders();
172 rebuildSim();
173 });
175 gridSel.addEventListener('change', () => {
176 grid = makeGrid(Number(gridSel.value), DOMAIN);
177 rebuildSim();
178 });
180 const runBtn = $<HTMLButtonElement>('runpause');
181 const setRunning = (r: boolean) => {
182 running = r;
183 runBtn.textContent = r ? 'Pause' : 'Run';
184 };
185 runBtn.addEventListener('click', () => setRunning(!running));
186 $('restart').addEventListener('click', () => {
187 sim.restart();
188 });
190 // Source sliders. Moving one does not restart: the source term reads these
191 // every step, so the change simply takes effect.
192 const sourceSliders: Record<string, { set: (v: number) => void }> = {};
193 const params = $('params');
194 const sourceDefs: (SceneParam & { key: keyof SourceParams })[] = [
195 { key: 'f', label: 'frequency', min: 200, max: 4000, step: 20, value: source.f, unit: 'Hz' },
196 { key: 'cycles', label: 'pulse length', min: 0.5, max: 12, step: 0.5, value: source.cycles },
197 { key: 'cw', label: 'continuous', min: 0, max: 1, step: 0.05, value: source.cw },
198 { key: 'point', label: 'plane ↔ point', min: 0, max: 1, step: 0.05, value: source.point },
199 { key: 'x0', label: 'x position', min: -0.4 * DOMAIN, max: 0.4 * DOMAIN, step: 0.01, value: source.x0, unit: 'm' },
200 { key: 'y0', label: 'y position', min: -0.4 * DOMAIN, max: 0.4 * DOMAIN, step: 0.01, value: source.y0, unit: 'm' },
201 { key: 'z0', label: 'z position', min: -0.4 * DOMAIN, max: 0.4 * DOMAIN, step: 0.01, value: source.z0, unit: 'm' },
202 ];
203 for (const p of sourceDefs) {
204 sourceSliders[p.key] = slider(params, p, (v) => {
205 source[p.key] = v;
206 });
207 }
209 // The microphone: three position sliders, a Listen button, a readout.
210 const micparams = $('micparams');
211 for (const axis of ['x', 'y', 'z'] as const) {
212 slider(
213 micparams,
214 { key: axis, label: `mic ${axis}`, min: -0.45 * DOMAIN, max: 0.45 * DOMAIN, step: 0.01, value: mic[axis], unit: 'm' },
215 (v) => {
216 mic[axis] = v;
217 applyProbe();
218 },
219 );
220 }
221 let playNote = '';
222 $('listen').addEventListener('click', () => {
223 sim
224 .readTrace()
225 .then(async (tr) => {
226 if (!tr || tr.length < 2) return;
227 const played = await playTrace(tr, sim.dt);
228 playNote =
229 played.peak > 0
230 ? ` — played ${fmtTime(played.duration)}`
231 : ' — the microphone heard only silence';
232 })
233 .catch(report);
234 });
236 const viewparams = $('viewparams');
237 slider(viewparams, { key: 'op', label: 'opacity', min: 0.2, max: 6, step: 0.1, value: viewState.opacity }, (v) => {
238 viewState.opacity = v;
239 });
240 slider(viewparams, { key: 'ct', label: 'contrast', min: 0.5, max: 3, step: 0.1, value: viewState.contrast }, (v) => {
241 viewState.contrast = v;
242 });
243 slider(viewparams, { key: 'clip', label: 'clip x', min: -0.5, max: 0.5, step: 0.01, value: viewState.clip }, (v) => {
244 viewState.clip = v;
245 });
247 const cmapSel = $<HTMLSelectElement>('colormap');
248 for (const name of colormapNames) {
249 const opt = document.createElement('option');
250 opt.value = name;
251 opt.textContent = name;
252 cmapSel.append(opt);
253 }
254 cmapSel.value = colormap;
255 cmapSel.addEventListener('change', () => {
256 colormap = cmapSel.value;
257 view.setColormap(colormaps[colormap]);
258 drawColorbar();
259 });
261 $<HTMLSelectElement>('scalemode').addEventListener('change', (e) => {
262 scaleMode = (e.target as HTMLSelectElement).value as typeof scaleMode;
263 });
264 $<HTMLInputElement>('showmedium').addEventListener('change', (e) => {
265 showMedium = (e.target as HTMLInputElement).checked;
266 });
267 $<HTMLSelectElement>('quality').addEventListener('change', (e) => {
268 quality = Number((e.target as HTMLSelectElement).value);
269 });
270 $<HTMLSelectElement>('spf').addEventListener('change', (e) => {
271 stepsPerFrame = Math.min(Number((e.target as HTMLSelectElement).value), MAX_STEPS_PER_FRAME);
272 });
274 const cflSlider = $<HTMLInputElement>('cfl');
275 const dtout = $('dtout');
276 cflSlider.addEventListener('input', () => {
277 cfl = Number(cflSlider.value);
278 applyDt();
279 updateStatics();
280 });
282 // --- orbit ---------------------------------------------------------------
283 const canvas = $<HTMLCanvasElement>('view');
284 let dragging = false;
285 let last = [0, 0];
286 canvas.addEventListener('pointerdown', (e) => {
287 dragging = true;
288 last = [e.clientX, e.clientY];
289 canvas.setPointerCapture(e.pointerId);
290 });
291 canvas.addEventListener('pointermove', (e) => {
292 if (!dragging) return;
293 camera.az -= (e.clientX - last[0]) * 0.008;
294 camera.el = Math.min(1.5, Math.max(-1.5, camera.el + (e.clientY - last[1]) * 0.008));
295 last = [e.clientX, e.clientY];
296 });
297 canvas.addEventListener('pointerup', () => (dragging = false));
298 canvas.addEventListener('pointercancel', () => (dragging = false));
299 canvas.addEventListener(
300 'wheel',
301 (e) => {
302 e.preventDefault();
303 camera.dist = Math.min(8 * DOMAIN, Math.max(1.1 * DOMAIN, camera.dist * Math.exp(e.deltaY * 0.001)));
304 },
305 { passive: false },
306 );
308 // --- readouts ------------------------------------------------------------
309 const stats = $('stats');
310 const domaininfo = $('domaininfo');
312 const drawColorbar = () => {
313 const cb = $<HTMLCanvasElement>('cbar');
314 const ctx = cb.getContext('2d')!;
315 const f = colormaps[colormap];
316 for (let i = 0; i < cb.height; i++) {
317 const [r, g, b] = f(1 - i / (cb.height - 1));
318 ctx.fillStyle = `rgb(${r},${g},${b})`;
319 ctx.fillRect(0, i, cb.width, 1);
320 }
321 };
322 drawColorbar();
324 const updateStatics = () => {
325 applyDt();
326 const lam = C_AIR / source.f;
327 const cells = lam / grid.h;
328 domaininfo.textContent =
329 `A ${fmtLength(grid.L)} cube of air at ${grid.n}³ points (${fmtLength(grid.h)} cells); ` +
330 `at ${source.f} Hz a wavelength is ${fmtLength(lam)}, ${cells.toFixed(1)} cells.`;
331 dtout.textContent = fmtTime(sim.dt);
332 dtout.classList.toggle('unstable', cfl >= 1);
333 };
334 updateStatics();
336 // --- frame loop ----------------------------------------------------------
337 let frames = 0;
338 let lastFps = performance.now();
339 let msPerFrame = 0;
341 const frame = () => {
342 view.resize();
343 if (running) {
344 sim.run(stepsPerFrame);
345 if (scaleMode === 'auto') {
346 // A rebuild can destroy the sim while a readback is in flight; a
347 // rejection then is about the old sim and not worth reporting.
348 sim
349 .peak()
350 .then((m) => {
351 if (m !== null && m > 0) scale = Math.max(scale * 0.98, m * 0.85);
352 })
353 .catch(() => {});
354 }
355 }
356 view.draw(sim.pressureIndex, {
357 camera,
358 scale: Math.max(scale, 1e-20),
359 opacity: viewState.opacity,
360 contrast: viewState.contrast,
361 steps: quality,
362 clipX: viewState.clip * grid.L,
363 medium: showMedium ? 0.35 : 0,
364 cref: C_AIR,
365 cdev: Math.max(medium.cmax - C_AIR, C_AIR - medium.cmin, 1e-6),
366 mic,
367 });
369 frames++;
370 const now = performance.now();
371 if (now - lastFps > 500) {
372 msPerFrame = (now - lastFps) / frames;
373 frames = 0;
374 lastFps = now;
376 const cellsPerLam = C_AIR / source.f / grid.h;
377 const resolution =
378 cellsPerLam < POOR_RESOLUTION
379 ? ` — <span class="warn">${cellsPerLam.toFixed(1)} cells per wavelength: mostly grid dispersion</span>`
380 : '';
381 const rate = running ? ` — ${msPerFrame.toFixed(1)} ms/frame` : ' — paused';
382 stats.innerHTML =
383 `step <b>${sim.steps}</b> — t = ${fmtTime(sim.t)} — dt = ${sim.dt.toExponential(3)} s` +
384 ` — c ∈ [${(medium.cmin / C_AIR).toPrecision(3)}, ${(medium.cmax / C_AIR).toPrecision(3)}]·c₀` +
385 rate +
386 resolution;
387 $('recinfo').textContent =
388 sim.recorded === 0
389 ? 'nothing recorded yet'
390 : `${sim.recorded.toLocaleString('en-US')} samples — ${fmtTime(sim.recorded * sim.dt)}` +
391 (sim.recorded >= MIC_CAPACITY ? ' (full)' : '') +
392 playNote;
393 }
394 $('cbhi').textContent = `+${scale.toPrecision(2)}`;
395 $('cblo').textContent = `−${scale.toPrecision(2)}`;
396 requestAnimationFrame(frame);
397 };
399 buildSceneSliders();
400 requestAnimationFrame(frame);
403start().catch(report);
moveopenescclose