/ proof-of-concept / fmm3dbie-webgpu-poc
Sign in
proof-of-concept / fmm3dbie-webgpu-poc
fmm3dbie-webgpu-poc / src / main.ts
698 lines · 28.2 KBCodeBlameHistory
2 * The app: problem controls, the solve pipeline (near quadrature, optional
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 3 * self-test, main problem, energy balance, field on three planes), and the
4 * views. There are two problems: the room (a point source inside a rounded
5 * cube with impedance walls, the interior equation) and the cymbal (a listener
6 * near a rigid cymbal in free space, the exterior equation with lambda = 0,
7 * solved with the source at the listener). The operator is kept between solves
8 * when only the wall admittance or the source changes, since its near weights
9 * depend only on k and the surface.
11import { roundedCube, type Surface } from './surface.ts';
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 12import { cymbal, CymbalShape, CYMBAL_DEFAULTS, insideTest, ringLayout } from './cymbal.ts';
14 ALPHA,
15 energyBalance,
16 green,
17 inside,
18 maskedPoints,
19 relError,
20 roomRhs,
21 selftestPoints,
22 selftestRhs,
23 slicePoints,
24 type CVec,
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 25 type Slice,
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 27import { BOX, boxSlices, C_SOUND, farFieldPower, LISTENER, RADIUS, selftestPoints as cymbalPoints, selftestSources, sourceSum } from './cymbalProblem.ts';
28import { leggauss } from './gauss.ts';
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 29import { adapterDescription, Gpu } from './gpu/device.ts';
30import { Operator } from './gpu/operator.ts';
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 31import { colorLimits, type Field, type Mode, type SliceField } from './view/field.ts';
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 32import { SliceView } from './view/slices.ts';
33import { RoomView } from './view/room3d.ts';
34import { drawColorbar, DIVERGING, SEQUENTIAL } from './view/colormaps.ts';
35import { drawConvergence } from './view/convergence.ts';
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 36import { fieldGif } from './view/exportGif.ts';
38const $ = <T extends HTMLElement>(id: string): T => {
39 const el = document.getElementById(id);
40 if (!el) throw new Error(`no #${id}`);
41 return el as T;
42};
44const A = 1.0;
45const EXTERIOR_SOURCE = [1.7, 0.6, -0.5];
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 47type Problem = 'room' | 'cymbal';
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 50 problem: Problem;
51 // the room
53 beta: number;
54 m: number;
55 p: number;
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 56 // the cymbal: frequency (Hz), target patch size (cm), rim thickness (mm)
57 freq: number;
58 h: number;
59 tRim: number;
60 // both
61 norder: number;
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 62 x0: [number, number, number];
63 eps: number;
64 ngrid: number;
65 selftest: boolean;
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 68interface Preset {
69 label: string;
70 values: Partial<Params>;
73const PRESETS: Record<Problem, Record<string, Preset>> = {
74 room: {
75 quick: { label: 'quick (1,176 points)', values: { k: 6, beta: 0.2, m: 2, norder: 6 } },
76 small: { label: 'small (4,704 points)', values: { k: 10, beta: 0.2, m: 4, norder: 6 } },
77 medium: { label: 'medium (17,496 points)', values: { k: 10, beta: 0.2, m: 6, norder: 8 } },
78 large: { label: 'large (48,600 points)', values: { k: 40, beta: 0.05, m: 10, norder: 8 } },
79 },
80 cymbal: {
81 quick: { label: 'quick (4,800 points, 20 mm rim)', values: { freq: 1000, h: 10, tRim: 20, norder: 4 } },
82 small: { label: 'small (19,012 points)', values: { freq: 1000, h: 6, tRim: 10, norder: 6 } },
83 medium: { label: 'medium (35,964 points)', values: { freq: 3000, h: 4, tRim: 10, norder: 8 } },
84 large: { label: 'large (42,444 points)', values: { freq: 8000, h: 3, tRim: 10, norder: 8 } },
85 },
86};
87const COMMON: Omit<Params, 'problem' | 'x0'> = { k: 10, beta: 0.2, m: 4, p: 8, freq: 1000, h: 6, tRim: 10, norder: 6, eps: 1e-5, ngrid: 101, selftest: false };
88const DEFAULTS: Record<Problem, Params> = {
89 room: { ...COMMON, problem: 'room', x0: [-0.35, 0.2, 0.1] },
90 cymbal: { ...COMMON, ...PRESETS.cymbal.small.values, problem: 'cymbal', x0: [...LISTENER] },
93const ui = {
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 94 problem: $<HTMLSelectElement>('problem'),
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 95 preset: $<HTMLSelectElement>('preset'),
96 k: $<HTMLInputElement>('k'),
97 beta: $<HTMLInputElement>('beta'),
98 m: $<HTMLInputElement>('m'),
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 99 freq: $<HTMLInputElement>('freq'),
100 h: $<HTMLInputElement>('h'),
101 tRim: $<HTMLInputElement>('tRim'),
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 102 norder: $<HTMLSelectElement>('norder'),
103 p: $<HTMLSelectElement>('p'),
104 x0: [$<HTMLInputElement>('x0'), $<HTMLInputElement>('y0'), $<HTMLInputElement>('z0')],
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 105 x0label: $('x0label'),
106 x0name: $('x0name'),
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 107 eps: $<HTMLSelectElement>('eps'),
108 ngrid: $<HTMLSelectElement>('ngrid'),
109 selftest: $<HTMLInputElement>('selftest'),
110 solve: $<HTMLButtonElement>('solve'),
111 cancel: $<HTMLButtonElement>('cancel'),
112 derived: $('derived'),
113 progress: $<HTMLProgressElement>('progress'),
114 status: $('status'),
115 err: $('err'),
116 mode: $<HTMLSelectElement>('mode'),
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 117 levelOption: $<HTMLOptionElement>('levelOption'),
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 118 animate: $<HTMLInputElement>('animate'),
119 showWalls: $<HTMLInputElement>('showWalls'),
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 120 wallsName: $('wallsName'),
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 121 showPlanes: [$<HTMLInputElement>('showXY'), $<HTMLInputElement>('showXZ'), $<HTMLInputElement>('showYZ')],
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 122 exportGif: $<HTMLButtonElement>('exportGif'),
123 hint: $('hint'),
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 124 results: $<HTMLTableElement>('results'),
125 conv: $<HTMLCanvasElement>('conv'),
126 empty: $('empty'),
127 cbar: $<HTMLCanvasElement>('cbar'),
128 cbarHi: $('cbarHi'),
129 cbarLo: $('cbarLo'),
130 hover: $('hover'),
131 device: $('device'),
132};
134const fmtBytes = (b: number) => (b >= 2 ** 30 ? `${(b / 2 ** 30).toFixed(2)} GB` : `${(b / 2 ** 20).toFixed(0)} MB`);
135const fmtS = (s: number) => (s < 10 ? `${s.toFixed(2)} s` : `${s.toFixed(1)} s`);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 137const cymbalShape = (p: Params) => new CymbalShape({ ...CYMBAL_DEFAULTS, tRim: p.tRim / 1000 });
138const wavenumber = (p: Params) => (p.problem === 'room' ? p.k : (2 * Math.PI * p.freq) / C_SOUND);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 140// ------------------------------------------------------------------ params
142function readParams(): Params {
143 return {
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 144 problem: ui.problem.value as Problem,
146 beta: Number(ui.beta.value),
147 m: Math.round(Number(ui.m.value)),
148 p: Number(ui.p.value),
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 149 freq: Number(ui.freq.value),
150 h: Number(ui.h.value),
151 tRim: Number(ui.tRim.value),
152 norder: Number(ui.norder.value),
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 153 x0: ui.x0.map((e) => Number(e.value)) as [number, number, number],
154 eps: Number(ui.eps.value),
155 ngrid: Number(ui.ngrid.value),
156 selftest: ui.selftest.checked,
157 };
160function writeParams(p: Params) {
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 161 if (ui.problem.value !== p.problem) {
162 ui.problem.value = p.problem;
163 }
164 applyProblem(p.problem);
166 ui.beta.value = String(p.beta);
167 ui.m.value = String(p.m);
168 ui.p.value = String(p.p);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 169 ui.freq.value = String(p.freq);
170 ui.h.value = String(p.h);
171 ui.tRim.value = String(p.tRim);
172 ui.norder.value = String(p.norder);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 173 p.x0.forEach((v, i) => (ui.x0[i].value = String(+v.toFixed(3))));
174 ui.eps.value = String(p.eps);
175 if (![...ui.eps.options].some((o) => o.value === ui.eps.value)) ui.eps.value = '1e-5';
176 ui.ngrid.value = String(p.ngrid);
177 ui.selftest.checked = p.selftest;
178 matchPreset();
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 181/** Show the controls and labels of one problem, and its named cases. */
182function applyProblem(problem: Problem) {
183 const room = problem === 'room';
184 document.querySelectorAll('.room-only').forEach((el) => el.classList.toggle('hidden', !room));
185 document.querySelectorAll('.cymbal-only').forEach((el) => el.classList.toggle('hidden', room));
186 if (ui.preset.dataset.problem !== problem) {
187 ui.preset.replaceChildren(
188 ...Object.entries(PRESETS[problem]).map(([v, pr]) => new Option(pr.label, v)),
189 new Option('custom', 'custom'),
190 );
191 ui.preset.dataset.problem = problem;
192 }
193 ui.x0name.textContent = room ? 'source (x, y, z)' : 'listener (x, y, z) in m';
194 ui.x0label.title = room
195 ? 'Source position; must be inside the room. You can also click a slice.'
196 : 'Listener position, where the source is placed (reciprocity); must be outside the cymbal. You can also click a slice.';
197 ui.wallsName.textContent = room ? 'walls' : 'cymbal';
198 ui.levelOption.textContent = room ? 'level (dB re median)' : 'level (dB re free field)';
199 ui.hint.textContent = room ? 'drag to turn · scroll to zoom · the nearer walls are cut away' : 'drag to turn · scroll to zoom';
202function presetMatches(p: Params, v: Partial<Params>): boolean {
203 return Object.entries(v).every(([key, val]) => p[key as keyof Params] === val);
207 const p = readParams();
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 208 const name = Object.entries(PRESETS[p.problem]).find(([, pr]) => presetMatches(p, pr.values))?.[0];
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 209 ui.preset.value = name ?? 'custom';
212function validate(p: Params): string | null {
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 213 if (p.problem === 'room') {
214 if (!(p.k > 0 && p.k <= 80)) return 'the wavenumber must be between 0 and 80';
215 if (!(p.beta > 0)) return 'the wall admittance must be positive (absorbing walls)';
216 if (!(p.m >= 1 && p.m <= 16)) return 'patches per face edge must be between 1 and 16';
217 if (!inside(p.x0, A, p.p, 0.04)) return 'the source must be inside the room, a little away from the walls';
218 return null;
219 }
220 if (!(p.freq >= 100 && p.freq <= 16000)) return 'the frequency must be between 100 Hz and 16 kHz';
221 if (!(p.h >= 2 && p.h <= 20)) return 'the patch size must be between 2 and 20 cm';
222 if (!(p.tRim >= 4 && p.tRim <= 40)) return 'the rim thickness must be between 4 and 40 mm';
223 if (!p.x0.every((v) => Math.abs(v) <= 3)) return 'the listener must be within 3 m of the cymbal';
224 if (insideTest(cymbalShape(p), 0.01)(p.x0)) return 'the listener must be outside the cymbal, at least 1 cm from it';
228// ------------------------------------------------------------------ derived
230let deviceLimit = Infinity;
232function describe(p: Params): { html: string; blocking: boolean } {
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 233 const npols = (p.norder + 1) ** 2;
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 234 const rfac = p.norder <= 6 ? 2.0 : 1.25;
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 235 let npts: number;
236 let perWl: number;
237 let perNode: number;
238 let size: string;
239 if (p.problem === 'room') {
240 npts = 6 * p.m * p.m * npols;
241 const wl = (2 * Math.PI) / p.k;
242 perWl = ((p.norder + 1) * p.m * wl) / (2 * A);
243 // near patches per node, measured on the named cases: about 9 at rfac 2, 5.5 at rfac 1.25, capped by the patch count
244 perNode = Math.min(6 * p.m * p.m, rfac >= 2 ? 9.5 : 5.8);
245 size = `room <b>${((2 * A) / wl).toFixed(1)}</b> wavelengths wide`;
246 } else {
247 const L = ringLayout(cymbalShape(p), p.h / 100);
248 const npatches = 2 * (L.mC ** 2 + L.counts.reduce((a, b) => a + b, 0)) + L.nrim;
249 npts = npatches * npols;
250 const wl = C_SOUND / p.freq;
251 perWl = ((p.norder + 1) * wl) / (p.h / 100);
252 // measured on the named cases: about 15 near patches per node at rfac 2 and 5 at rfac 1.25 (the thin body)
253 perNode = rfac >= 2 ? 15 : 5.5;
254 size = `cymbal <b>${((2 * RADIUS) / wl).toFixed(1)}</b> wavelengths across`;
255 }
256 const wbytes = npts * perNode * 6 * npols * 8;
258 `<b>${npts.toLocaleString()}</b> points`,
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 259 size,
260 `<span class="${perWl < 5 ? 'warn' : ''}" title="nodes per wavelength along the surface, on average">${perWl.toFixed(1)} nodes per wavelength</span>`,
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 261 `<span class="${wbytes > deviceLimit ? 'bad' : wbytes > 0.5 * deviceLimit ? 'warn' : ''}" title="storage for the near weights, estimated; this GPU allows ${fmtBytes(deviceLimit)} per buffer">near weights ≈ ${fmtBytes(wbytes)}</span>`,
262 ];
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 263 if (wbytes > 1.3 * deviceLimit) parts.push(`<span class="bad">probably more than this GPU allows (${fmtBytes(deviceLimit)})</span>`);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 264 if (perWl < 5) parts.push('<span class="warn">under-resolved: expect large errors</span>');
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 265 let blocking = false;
267 if (err) {
268 parts.push(`<span class="bad">${err}</span>`);
269 blocking = true;
270 }
271 return { html: parts.join(' · '), blocking };
274function updateDerived() {
275 const d = describe(readParams());
276 ui.derived.innerHTML = d.html;
277 if (!running) ui.solve.disabled = d.blocking;
280// ------------------------------------------------------------------ status
282let running = false;
283let cancelled = false;
284class Cancelled extends Error {}
286function setStatus(text: string, fraction?: number) {
287 ui.status.textContent = text;
288 if (fraction === undefined) ui.progress.removeAttribute('value');
289 else ui.progress.value = fraction;
292function checkCancel() {
293 if (cancelled) throw new Cancelled('cancelled');
296// ------------------------------------------------------------------ results
298type Row = [string, string, string?];
300function showResults(rows: Row[]) {
301 const tb = document.createElement('tbody');
302 for (const [a, b, cls] of rows) {
303 const tr = document.createElement('tr');
304 const td1 = document.createElement('td');
305 td1.textContent = a;
306 const td2 = document.createElement('td');
307 td2.textContent = b;
308 if (cls) td2.className = cls;
309 tr.append(td1, td2);
310 tb.append(tr);
311 }
312 ui.results.replaceChildren(tb);
315const quality = (e: number, tol: number) => (e < 10 * tol ? '' : e < 1e-2 ? 'warn' : 'bad');
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 317/** Pause so that the page can repaint the status line before a long CPU step. */
318const yieldToPage = () => new Promise((r) => setTimeout(r, 0));
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 320// ------------------------------------------------------------------ main
322async function start() {
323 if (!navigator.gpu) throw new Error('this browser has no WebGPU. Chrome and Edge 113+, Safari 26+, and Firefox 141+ on Windows have it; on Linux, Chrome may need chrome://flags/#enable-unsafe-webgpu.');
324 const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
325 if (!adapter) throw new Error('WebGPU found no adapter on this machine.');
326 const lim = adapter.limits;
327 const device = await adapter.requestDevice({
328 requiredLimits: { maxStorageBufferBindingSize: lim.maxStorageBufferBindingSize, maxBufferSize: lim.maxBufferSize },
329 });
330 device.lost.then((info) => {
331 if (info.reason !== 'destroyed') {
332 ui.err.textContent = `The GPU device was lost (${info.message}). This can happen when one dispatch runs too long; reload the page to continue.`;
333 }
334 });
335 deviceLimit = Math.min(device.limits.maxStorageBufferBindingSize, device.limits.maxBufferSize);
336 const software = /swiftshader/i.test(adapterDescription(adapter));
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 337 ui.device.textContent = `WebGPU adapter: ${adapterDescription(adapter)}; largest buffer ${fmtBytes(deviceLimit)}.` + (software ? ' This is a software adapter, so only the quick cases are practical.' : '');
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 338 const gpu = new Gpu(device, adapter);
340 const slices = new SliceView(
341 $('slices'),
342 (x) => {
343 const p = readParams();
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 344 if (validate({ ...p, x0: x })) return;
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 345 x.forEach((v, i) => (ui.x0[i].value = String(+v.toFixed(3))));
346 updateDerived();
347 if (!running) void solve();
348 },
349 (t) => (ui.hover.textContent = t),
350 );
351 const room = new RoomView(device, $<HTMLCanvasElement>('room'));
353 let op: Operator | null = null;
354 let opKey = '';
355 let surf: Surface | null = null;
356 let field: Field | null = null;
357 let conv: { label: string; res: number[]; color: string }[] = [];
358 let lastEps = 1e-5;
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 359 let fieldTitle = '';
361 /** The three planes as a GIF, in the current display mode. */
362 const buildGif = () => (field ? fieldGif(field, ui.mode.value as Mode, { title: fieldTitle }) : null);
364 const redrawConv = () => drawConvergence(ui.conv, conv, lastEps);
366 const updateColorbar = () => {
367 const mode = ui.mode.value as Mode;
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 368 const diverging = mode === 'pressure' || (field?.level.diverging ?? false);
369 drawColorbar(ui.cbar, diverging ? DIVERGING : SEQUENTIAL);
371 ui.cbarHi.textContent = ui.cbarLo.textContent = '';
372 } else if (mode === 'pressure') {
373 ui.cbarHi.textContent = `+${field.umax.toPrecision(2)}`;
374 ui.cbarLo.textContent = `−${field.umax.toPrecision(2)}`;
375 } else {
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 376 ui.cbarHi.textContent = `+${field.level.max} dB`;
377 ui.cbarLo.textContent = `${field.level.min} dB`;
379 };
381 async function solve() {
382 const p = readParams();
383 const bad = validate(p);
384 if (bad) {
385 ui.err.textContent = bad;
386 return;
387 }
388 running = true;
389 cancelled = false;
390 ui.solve.disabled = true;
391 ui.cancel.disabled = false;
392 ui.err.textContent = '';
393 lastEps = p.eps;
394 const rows: Row[] = [];
395 // the previous solution no longer matches the controls, so take it down
396 field = null;
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 397 ui.exportGif.disabled = true;
399 room.clear();
400 ui.empty.textContent = 'Solving…';
401 ui.empty.style.display = '';
402 showResults([['solving…', '']]);
403 updateColorbar();
404 conv = [];
405 redrawConv();
406 try {
407 const t0 = performance.now();
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 408 const isRoom = p.problem === 'room';
409 const k = wavenumber(p);
410 const key = isRoom ? `room|${p.k}|${p.m}|${p.norder}|${p.p}` : `cymbal|${p.freq}|${p.h}|${p.norder}|${p.tRim}`;
411 const lam = isRoom ? -p.beta : 0;
412 const shape = isRoom ? null : cymbalShape(p);
414 if (!op || key !== opKey) {
415 op?.destroy();
416 op = null;
417 opKey = '';
418 setStatus('building the surface…');
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 419 await yieldToPage();
420 surf = isRoom ? roundedCube(A, p.p, p.m, p.norder) : cymbal(shape!, p.h / 100, p.norder);
421 const o = new Operator(gpu, surf, k, ALPHA, lam, isRoom ? 0 : 1, {
423 checkCancel();
424 setStatus(`${stage}: ${(100 * f).toFixed(0)}%`, f);
425 },
426 });
427 setStatus('near quadrature…', 0);
428 const info = await o.setup();
429 op = o;
430 opKey = key;
431 setupS = info.setupSeconds;
432 }
433 const o = op!;
434 const s = surf!;
435 o.lam = lam;
436 const info = o.info;
437 rows.push(['points', s.npts.toLocaleString()]);
438 rows.push(['near pairs · quadtree leaves', `${info.npairs.toLocaleString()} · ${info.nleaves.toLocaleString()}`]);
439 rows.push(['near weights', fmtBytes(info.weightsBytes)]);
440 rows.push(['near quadrature (setup)', setupS ? fmtS(setupS) : `reused (${fmtS(info.setupSeconds)} earlier)`]);
441 const npts = s.npts;
442 const xs = s.srcvals.subarray(0, 3 * npts);
443 const nrm = s.srcvals.subarray(9 * npts, 12 * npts);
444 conv = [];
446 if (p.selftest) {
447 const series = { label: 'self-test', res: [] as number[], color: '#8b949e' };
448 conv.push(series);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 449 let rhs: CVec;
450 let exact: (x: Float64Array) => CVec;
451 let xt: Float64Array;
452 if (isRoom) {
453 rhs = selftestRhs(xs, nrm, EXTERIOR_SOURCE, k, lam);
454 exact = (x) => green(x, EXTERIOR_SOURCE, k);
455 xt = selftestPoints(50, A);
456 } else {
457 setStatus('self-test: the data of the interior sources…');
458 await yieldToPage();
459 const { x0s, ws } = selftestSources(shape!);
460 rhs = sourceSum(xs, x0s, ws, k, nrm);
461 exact = (x) => sourceSum(x, x0s, ws, k);
462 xt = cymbalPoints(shape!, 50);
463 }
464 const sol = await o.solve(rhs, p.eps, 400, (it, r) => {
466 series.res.push(r);
467 redrawConv();
468 setStatus(`self-test GMRES: iteration ${it}, residual ${r.toExponential(1)}`);
469 });
470 setStatus('self-test: evaluating…');
471 const ut = await o.eval(xt, sol.x, sol.phi1, 'self-test field');
472 const ub = await o.evalOnSurface(sol.x, sol.phi1);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 473 const e1 = relError(ut, exact(xt));
474 const e2 = relError(ub, exact(xs));
475 rows.push([isRoom ? 'self-test error, interior · walls' : 'self-test error, around · on the cymbal', `${e1.toExponential(1)} · ${e2.toExponential(1)}`, quality(Math.max(e1, e2), p.eps)]);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 478 const series = { label: isRoom ? 'room problem' : 'cymbal problem', res: [] as number[], color: '#2f81f7' };
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 480 const sol = await o.solve(roomRhs(xs, nrm, p.x0, k, lam), p.eps, 400, (it, r) => {
482 series.res.push(r);
483 redrawConv();
484 setStatus(`GMRES: iteration ${it}, residual ${r.toExponential(1)}`);
485 });
486 rows.push(['GMRES iterations · residual', `${sol.niter} · ${sol.rres.toExponential(1)}`]);
487 rows.push(['solve', `${fmtS(sol.seconds)} (${((1000 * sol.seconds) / sol.niter).toFixed(0)} ms per iteration)`]);
489 // energy balance
490 setStatus('energy balance…');
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 491 await yieldToPage();
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 492 const v0 = await o.eval(Float64Array.from(p.x0), sol.x, sol.phi1, 'field at the source');
493 const vw = await o.evalOnSurface(sol.x, sol.phi1);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 494 const gw = green(xs, p.x0, k);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 495 const uWall = new Float64Array(2 * npts);
496 for (let i = 0; i < 2 * npts; i++) uWall[i] = gw[i] + vw[i];
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 497 if (isRoom) {
498 const eb = energyBalance(k, lam, [v0[0], v0[1]], s.wts, uWall);
499 rows.push(['power: source · walls', `${eb.pSrc.toFixed(6)} · ${eb.pWall.toFixed(6)}`]);
500 rows.push(['energy mismatch', eb.mismatch.toExponential(1), quality(eb.mismatch, p.eps)]);
501 } else {
502 const pSrc = k / (4 * Math.PI) + v0[1];
503 // the far field pattern costs (number of directions) x (number of nodes) on the CPU
504 const L = Math.ceil(k * 0.6) + 20;
505 if ((L + 1) * (2 * L + 2) * npts < 2e8) {
506 const pFar = farFieldPower(k, p.x0, xs, nrm, s.wts, sol.x, sol.phi1, ALPHA, leggauss);
507 const mismatch = Math.abs(pSrc - pFar) / pSrc;
508 rows.push(['power: source · to infinity', `${pSrc.toFixed(6)} · ${pFar.toFixed(6)}`]);
509 rows.push(['energy mismatch', mismatch.toExponential(1), quality(mismatch, p.eps)]);
510 } else {
511 rows.push(['power from the source', `${pSrc.toFixed(6)} (the far field check is skipped at this size)`]);
512 }
513 }
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 515 // the field on the three planes
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 516 const tf = performance.now();
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 517 const sl: Slice[] = isRoom ? slicePoints(p.ngrid, p.x0, A, p.p).slices : boxSlices(p.ngrid, p.x0, shape!);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 518 const sfs: SliceField[] = [];
519 for (const [si, slice] of sl.entries()) {
520 const { pts, index } = maskedPoints(slice.pts, slice.mask);
521 const v = await o.eval(pts, sol.x, sol.phi1, `field on the ${slice.name}-plane (${si + 1} of 3)`);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 522 const g = green(pts, p.x0, k);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 523 const u = new Float64Array(2 * slice.mask.length).fill(NaN);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 524 const ref = isRoom ? undefined : new Float64Array(slice.mask.length).fill(NaN);
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 525 for (let q = 0; q < index.length; q++) {
526 u[2 * index[q]] = g[2 * q] + v[2 * q];
527 u[2 * index[q] + 1] = g[2 * q + 1] + v[2 * q + 1];
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 528 if (ref) ref[index[q]] = Math.hypot(g[2 * q], g[2 * q + 1]);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 530 sfs.push({ slice, u, ref });
532 const nfield = sl.reduce((acc, x) => acc + x.mask.reduce((a, b) => a + b, 0), 0);
533 rows.push(['field on the planes', `${fmtS((performance.now() - tf) / 1000)} (${nfield.toLocaleString()} points)`]);
534 rows.push(['total', fmtS((performance.now() - t0) / 1000)]);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 535 const lims = colorLimits(sfs, p.x0, k);
536 if (isRoom) {
537 field = {
538 k, x0: [...p.x0], ngrid: p.ngrid, slices: sfs, uWall, freeFieldLevel: false, ...lims,
539 level: { min: -30, max: 15, diverging: false, label: 're median' },
540 scene: { center: [0, 0, 0], radius: 1, cutaway: true },
541 };
542 } else {
543 const [r, z] = shape!.crossSection();
544 const c = BOX.map(([lo, hi]) => (lo + hi) / 2) as [number, number, number];
545 field = {
546 k, x0: [...p.x0], ngrid: p.ngrid, slices: sfs, uWall, freeFieldLevel: true, ...lims,
547 level: { min: -15, max: 15, diverging: true, label: 're free field' },
548 scene: { center: c, radius: 0.45, cutaway: false },
549 outline: { r, z, radius: RADIUS },
550 };
551 }
552 fieldTitle = isRoom
553 ? `Point source at (${p.x0.join(', ')}) in a room, k = ${p.k}, wall admittance ${p.beta}`
554 : `Listener at (${p.x0.join(', ')}) m near a rigid cymbal, ${p.freq} Hz; u(x) is the pressure at the listener due to a source at x`;
556 room.setField(s, field);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 557 ui.exportGif.disabled = false;
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 558 ui.empty.style.display = 'none';
559 updateColorbar();
560 showResults(rows);
561 redrawConv();
562 setStatus(`done in ${fmtS((performance.now() - t0) / 1000)}`, 1);
563 } catch (e) {
564 ui.empty.textContent = 'No solution: press Solve to compute the field.';
565 if (e instanceof Cancelled) {
566 setStatus('cancelled', 0);
567 // a cancelled setup leaves no usable operator
568 if (op && !op.info) {
569 op.destroy();
570 op = null;
571 opKey = '';
572 }
573 } else {
574 console.error(e instanceof Error ? `${e.message}\n${e.stack}` : String(e));
575 ui.err.textContent = e instanceof Error ? e.message : String(e);
576 setStatus('failed', 0);
577 op?.destroy();
578 op = null;
579 opKey = '';
580 }
581 if (rows.length) showResults(rows);
582 } finally {
583 running = false;
584 ui.cancel.disabled = true;
585 updateDerived();
586 }
587 }
589 ui.solve.addEventListener('click', () => void solve());
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 590 ui.exportGif.addEventListener('click', () => {
591 const p = readParams();
592 setStatus('writing the GIF…');
593 setTimeout(() => {
594 const gif = buildGif();
595 if (!gif) return;
596 const url = URL.createObjectURL(new Blob([gif as Uint8Array<ArrayBuffer>], { type: 'image/gif' }));
597 const a = document.createElement('a');
598 a.href = url;
599 const what = p.problem === 'room' ? `room-k${p.k}` : `cymbal-${p.freq}Hz`;
600 a.download = `fmm3dbie-${what}-${ui.mode.value}.gif`;
601 a.click();
602 setTimeout(() => URL.revokeObjectURL(url), 10_000);
603 setStatus(`wrote ${a.download} (${(gif.length / 2 ** 20).toFixed(1)} MB)`, 1);
604 }, 0);
605 });
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 606 ui.cancel.addEventListener('click', () => {
607 cancelled = true;
608 setStatus('cancelling…');
609 });
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 610 ui.problem.addEventListener('change', () => {
611 const problem = ui.problem.value as Problem;
612 const d = DEFAULTS[problem];
613 writeParams({ ...d, x0: [...d.x0], ...(software ? PRESETS[problem].quick.values : {}) });
614 updateDerived();
615 });
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 616 ui.preset.addEventListener('change', () => {
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 617 const pr = PRESETS[readParams().problem][ui.preset.value];
618 if (pr) writeParams({ ...readParams(), ...pr.values });
620 });
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 621 for (const el of [ui.k, ui.beta, ui.m, ui.freq, ui.h, ui.tRim, ui.norder, ui.p, ...ui.x0, ui.eps, ui.ngrid, ui.selftest]) {
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 622 el.addEventListener('input', () => {
623 matchPreset();
624 updateDerived();
625 });
626 }
627 ui.mode.addEventListener('change', () => {
628 updateColorbar();
629 room.invalidate();
630 });
631 for (const el of [ui.showWalls, ...ui.showPlanes]) el.addEventListener('change', () => room.invalidate());
632 ui.x0.forEach((el) => el.addEventListener('keydown', (e) => e.key === 'Enter' && !running && void solve()));
634 // the frame loop: one period of the pressure every 2 s
635 let phase = 0;
636 let last = performance.now();
637 const frame = (t: number) => {
638 const dt = (t - last) / 1000;
639 last = t;
640 const animating = ui.animate.checked && ui.mode.value === 'pressure' && field !== null;
641 if (animating) phase = (phase + dt * Math.PI) % (2 * Math.PI);
642 const mode = ui.mode.value as Mode;
643 const ph = mode === 'pressure' ? phase : 0;
644 if (field && (animating || slicesDirty)) {
645 slices.draw(mode, ph);
646 slicesDirty = false;
647 }
648 // ?noroom skips the 3D view: in headless Chrome, presenting to a WebGPU canvas breaks later async calls
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 649 if (!noRoom) {
650 // the checkboxes by plane name, in the order of the field's planes
651 const byName = { xy: ui.showPlanes[0].checked, xz: ui.showPlanes[1].checked, yz: ui.showPlanes[2].checked };
652 const planes = (field?.slices.map((sf) => byName[sf.slice.name]) ?? [false, false, false]) as [boolean, boolean, boolean];
653 room.draw(mode, ph, { walls: ui.showWalls.checked, planes }, animating);
654 }
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 655 requestAnimationFrame(frame);
656 };
657 let slicesDirty = true;
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 658 // (the test hook __app__.showRoom() turns the view on after a solve, for screenshots)
659 let noRoom = location.search.includes('noroom');
bc338a7WebGPU port of the fmm3dbie impedance solver as an interactive web appJeremy Magland 660 ui.mode.addEventListener('change', () => (slicesDirty = true));
661 ui.animate.addEventListener('change', () => (slicesDirty = true));
662 requestAnimationFrame(frame);
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 664 // ?problem=cymbal selects the cymbal at load, ?case=<name> a named case, ?autosolve solves at once
665 const query = new URLSearchParams(location.search);
666 const initial: Problem = query.get('problem') === 'cymbal' ? 'cymbal' : 'room';
667 const d0 = DEFAULTS[initial];
668 writeParams({ ...d0, x0: [...d0.x0], ...(software ? PRESETS[initial].quick.values : {}) });
670 updateColorbar();
671 redrawConv();
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 672 const named = PRESETS[initial][query.get('case') ?? ''];
673 if (named) {
674 writeParams({ ...readParams(), ...named.values });
676 }
70e5eeeAdd the cymbal problem and GIF exportJeremy Magland 677 setStatus(software ? 'ready (software adapter: choose the quick case)' : 'ready', 0);
678 if (query.has('autosolve')) void solve();
679 (window as unknown as { __app__: unknown }).__app__ = {
680 solve,
681 get field() {
682 return field;
683 },
684 showRoom() {
685 noRoom = false;
686 room.invalidate();
687 },
688 buildGif,
689 };
692start().catch((e) => {
693 console.error(e);
694 ui.err.textContent = e instanceof Error ? e.message : String(e);
695 setStatus('unavailable', 0);
696});
698export type { CVec };
↑↓ move↵openescclose