2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2224%22 fill=%22%230b0e12%22/><rect x=%2218%22 y=%2218%22 width=%2264%22 height=%2264%22 rx=%2218%22 fill=%22none%22 stroke=%22%239aa4af%22 stroke-width=%225%22/><circle cx=%2242%22 cy=%2256%22 r=%228%22 fill=%22%23b2182b%22/><circle cx=%2242%22 cy=%2256%22 r=%2219%22 fill=%22none%22 stroke=%22%232166ac%22 stroke-width=%225%22/></svg>" />
7 <title>fmm3dbie-webgpu-poc: a room with impedance walls, or a listener near a cymbal, solved on your GPU</title>
8 <style>
9 :root {
10 --bg: #ffffff;
11 --ink: #1f2328;
12 --ink-2: #57606a;
13 --line: #d0d7de;
14 --accent: #0969da;
15 --panel-bg: #f6f8fa;
16 --warn: #9a5b00;
17 --bad: #cf222e;
18 color-scheme: light dark;
19 }
20 @media (prefers-color-scheme: dark) {
21 :root {
22 --bg: #14171a;
23 --ink: #e6e9ec;
24 --ink-2: #9aa4af;
25 --line: #333b44;
26 --accent: #58a6ff;
27 --panel-bg: #1a1e23;
28 --warn: #e3a341;
29 --bad: #ff7b72;
30 }
31 }
32 * { box-sizing: border-box; }
33 body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, sans-serif; }
34 main { max-width: 1180px; margin: 0 auto; padding: 20px 16px 56px; }
35 h1 { font-size: 20px; margin: 0 0 2px; }
36 h2 { font-size: 15px; margin: 22px 0 6px; }
37 .sub { color: var(--ink-2); margin: 0 0 12px; font-size: 13.5px; max-width: 78ch; }
38 a { color: var(--accent); }
39 .panel { background: var(--panel-bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px 14px; }
40 .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px 16px; }
41 .field { display: flex; flex-direction: column; gap: 2px; font-size: 12.5px; color: var(--ink-2); }
42 .field > span { white-space: nowrap; }
43 input[type='number'], select, button {
44 font: inherit; font-size: 13.5px; color: var(--ink); background: var(--bg);
45 border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; min-width: 0;
46 }
47 input[type='number'] { width: 100%; font-variant-numeric: tabular-nums; }
48 .xyz { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px; }
49 button { cursor: pointer; }
50 button:hover:not(:disabled) { border-color: var(--accent); }
51 button:disabled { opacity: 0.5; cursor: default; }
52 button.primary { border-color: var(--accent); color: var(--accent); font-weight: 600; min-width: 6em; }
53 .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-top: 10px; }
54 .row label { font-size: 13px; color: var(--ink-2); white-space: nowrap; }
55 .derived { font-size: 13px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
56 .derived b { color: var(--ink); font-weight: 600; }
57 .warn { color: var(--warn); }
58 .bad { color: var(--bad); }
59 progress { width: 220px; height: 10px; vertical-align: middle; accent-color: var(--accent); }
60 #status { font-size: 13px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
61 #err { color: var(--bad); white-space: pre-wrap; font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; margin: 8px 0 0; }
62 #err:empty { display: none; }
63 .view-bar { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: center; margin: 14px 0 6px; font-size: 13px; color: var(--ink-2); }
64 .stage { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 14px; align-items: start; }
65 @media (max-width: 900px) { .stage { grid-template-columns: minmax(0, 1fr); } }
66 .room { position: relative; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #0b0e12; aspect-ratio: 4 / 3; }
67 #room { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; }
68 #room:active { cursor: grabbing; }
69 .room .hint { position: absolute; left: 10px; bottom: 8px; font-size: 11.5px; color: #9aa4af; pointer-events: none; }
70 .room .empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #9aa4af; font-size: 14px; text-align: center; padding: 20px; }
71 .cbar { position: absolute; right: 10px; top: 10px; bottom: 10px; display: flex; flex-direction: column; align-items: center; gap: 4px; color: #c9d1d9; font-size: 11px; font-variant-numeric: tabular-nums; pointer-events: none; }
72 .cbar canvas { flex: 1; width: 14px; min-height: 60px; border: 1px solid #333b44; border-radius: 2px; }
73 .side { display: flex; flex-direction: column; gap: 12px; }
74 table { border-collapse: collapse; width: 100%; font-size: 13px; font-variant-numeric: tabular-nums; }
75 td { padding: 3px 4px; border-bottom: 1px solid var(--line); vertical-align: top; }
76 td:first-child { color: var(--ink-2); }
77 td:last-child { text-align: right; }
78 #conv { width: 100%; height: 150px; display: block; }
79 .slices { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
80 @media (max-width: 700px) { .slices { grid-template-columns: minmax(0, 1fr); } }
81 figure.slice { margin: 0; }
82 figure.slice canvas { width: 100%; aspect-ratio: 1; display: block; border: 1px solid var(--line); border-radius: 6px; background: repeating-conic-gradient(var(--panel-bg) 0 25%, var(--bg) 0 50%) 0 0 / 16px 16px; cursor: crosshair; }
83 figure.slice figcaption { font-size: 12px; color: var(--ink-2); margin-top: 3px; }
84 #hover { min-height: 1.5em; font-size: 12.5px; color: var(--ink-2); font-variant-numeric: tabular-nums; margin-top: 4px; }
85 .notes { font-size: 13.5px; color: var(--ink-2); max-width: 82ch; }
86 .notes p { margin: 6px 0; }
87 .notes code { font-size: 12.5px; }
88 #device { font-size: 12px; color: var(--ink-2); margin-top: 18px; }
89 .hidden { display: none ; }
90 </style>
91 </head>
92 <body>
93 <main>
94 <h1>fmm3dbie-webgpu-poc</h1>
95 <p class="sub">
96 Acoustic fields at a fixed frequency, solved with a high-order boundary integral equation on your GPU through WebGPU, in single precision: a point source in a closed room with partially absorbing walls, or a listener near a rigid cymbal in free space. This is a browser port of a <a href="https://vault1.magland.org/proof-of-concept/fmm3dbie-gpu-poc">CUDA proof of concept</a> for
97 <a href="https://github.com/fastalgorithms/fmm3dbie">fmm3dbie</a>'s Helmholtz impedance solver. Choose the problem, press Solve, then turn the view with the mouse and click a slice to move the source.
98 </p>
100 <div class="panel">
101 <div class="grid">
102 <label class="field" title="The room: a point source in a closed room with impedance walls. The cymbal: a listener near a rigid cymbal in free space, solved with the source at the listener (reciprocity)."><span>problem</span>
103 <select autocomplete="off" id="problem">
104 <option value="room" selected>room</option>
105 <option value="cymbal">listener near a cymbal</option>
106 </select>
107 </label>
108 <label class="field" title="Named cases from the CUDA proof of concept, plus a quick one for slow GPUs"><span>case</span>
109 <select autocomplete="off" id="preset"></select>
110 </label>
111 <label class="field room-only" title="Wavenumber k = 2π / wavelength, for a room of half-width 1"><span>wavenumber k</span><input autocomplete="off" id="k" type="number" min="0.5" max="60" step="0.5" /></label>
112 <label class="field room-only" title="Normalized wall admittance β > 0; smaller β means more reflective walls"><span>wall admittance β</span><input autocomplete="off" id="beta" type="number" min="0.001" max="5" step="0.01" /></label>
113 <label class="field room-only" title="Patches per edge of each cube face (6 m² patches in all)"><span>patches per face edge m</span><input autocomplete="off" id="m" type="number" min="1" max="16" step="1" /></label>
114 <label class="field cymbal-only" title="Frequency in Hz; the speed of sound is 343 m/s and the cymbal is 40 cm across"><span>frequency (Hz)</span><input autocomplete="off" id="freq" type="number" min="100" max="16000" step="100" /></label>
115 <label class="field cymbal-only" title="Target patch size in cm; the mesh is also refined toward the rim and across the bell, whatever this is"><span>patch size (cm)</span><input autocomplete="off" id="h" type="number" min="2" max="20" step="0.5" /></label>
116 <label class="field cymbal-only" title="Thickness at the rim in mm; the cymbal is twice as thick at the center. Real cymbals are about 1 mm thick; thinner needs more patches."><span>rim thickness (mm)</span><input autocomplete="off" id="tRim" type="number" min="4" max="40" step="1" /></label>
117 <label class="field" title="Polynomial order per patch; (order + 1)² nodes each"><span>order per patch</span>
118 <select autocomplete="off" id="norder"><option>4</option><option>5</option><option>6</option><option>7</option><option>8</option><option>9</option><option>10</option></select>
119 </label>
120 <label class="field room-only" title="The walls are |x|^p + |y|^p + |z|^p = 1: p = 2 is a sphere, larger p a sharper cube, which needs more patches or a higher order for the same accuracy"><span>room shape exponent p</span>
121 <select autocomplete="off" id="p"><option>2</option><option>4</option><option>6</option><option selected>8</option><option>10</option><option>12</option><option>16</option><option>20</option><option>30</option></select>
122 </label>
123 <label class="field" id="x0label" title="Source position; must be inside the room. You can also click a slice."><span id="x0name">source (x, y, z)</span>
124 <span class="xyz"><input autocomplete="off" id="x0" type="number" step="0.05" /><input autocomplete="off" id="y0" type="number" step="0.05" /><input autocomplete="off" id="z0" type="number" step="0.05" /></span>
125 </label>
126 <label class="field" title="Relative residual at which GMRES stops"><span>GMRES tolerance</span>
127 <select autocomplete="off" id="eps"><option value="1e-4">1e-4</option><option value="1e-5" selected>1e-5</option><option value="1e-6">1e-6</option></select>
128 </label>
129 <label class="field" title="Grid points per side of the three slices"><span>slice resolution</span>
130 <select autocomplete="off" id="ngrid"><option>61</option><option selected>101</option><option>151</option></select>
131 </label>
132 </div>
133 <div class="row">
134 <button id="solve" class="primary">Solve</button>
135 <button id="cancel" disabled>Cancel</button>
136 <label title="Also solve a problem with a known solution and report the error: the field of a source outside the room, or of sources spread inside the cymbal"><input autocomplete="off" id="selftest" type="checkbox" /> self-test</label>
137 <span class="derived" id="derived"></span>
138 </div>
139 <div class="row" style="margin-top: 6px"><progress id="progress" max="1" value="0"></progress><span id="status">loading…</span></div>
140 <pre id="err"></pre>
141 </div>
143 <div class="view-bar">
144 <label>show
145 <select autocomplete="off" id="mode">
146 <option value="pressure">pressure Re(u e^−iωt)</option>
147 <option value="level" id="levelOption">level (dB re median)</option>
148 </select>
149 </label>
150 <label><input autocomplete="off" id="animate" type="checkbox" checked /> animate over one period</label>
151 <label><input autocomplete="off" id="showWalls" type="checkbox" checked /> <span id="wallsName">walls</span></label>
152 <label><input autocomplete="off" id="showXY" type="checkbox" checked /> xy</label>
153 <label><input autocomplete="off" id="showXZ" type="checkbox" checked /> xz</label>
154 <label><input autocomplete="off" id="showYZ" type="checkbox" /> yz</label>
155 <button id="exportGif" disabled title="The three planes side by side as one GIF: animated over one period for the pressure, a single image for the level">Export GIF</button>
156 </div>
158 <div class="stage">
159 <div class="room">
160 <canvas id="room"></canvas>
161 <div class="empty" id="empty">Press Solve to compute the field.</div>
162 <div class="cbar"><span id="cbarHi"></span><canvas id="cbar" width="14" height="256"></canvas><span id="cbarLo"></span></div>
163 <div class="hint" id="hint">drag to turn · scroll to zoom · the nearer walls are cut away</div>
164 </div>
165 <div class="side">
166 <div class="panel">
167 <table id="results"><tbody><tr><td>no solve yet</td><td></td></tr></tbody></table>
168 </div>
169 <div class="panel"><canvas id="conv"></canvas></div>
170 </div>
171 </div>
173 <div class="slices" id="slices"></div>
174 <div id="hover"></div>
176 <h2>What is computed</h2>
177 <div class="notes">
178 <p>The room is the superellipsoid |x|<sup>p</sup> + |y|<sup>p</sup> + |z|<sup>p</sup> = 1, with a point source inside and impedance walls (∂u/∂n − ikβu = 0). We solve fmm3dbie's combined field integral equation on 6m² curved patches: near interactions use precomputed quadrature weights, far interactions a direct sum, and GMRES runs on the CPU. The GPU work is in single precision.</p>
179 <p>The cymbal is 40 cm across, stationary and sound-hard, in free space, with a listener nearby. We put the source at the listener: by reciprocity the field u(x) is then the pressure at the listener due to a unit source at x, so one solve gives the listener's response to a source anywhere. The planes cut a 1 m box around the cymbal, which only selects where the field is shown; the integral equation needs no truncation. Since fmm3dbie's equations need a closed surface, the cymbal is a thin closed body with a raised bell, 20 mm thick at the center and thinner toward the rounded rim. The level is shown relative to the free field, so that 0 dB means no effect of the cymbal.</p>
180 <p>The self-test solves a problem with a known solution and reports the error. The energy mismatch compares the power from the source with the power absorbed by the walls, or, for the cymbal, with the power radiated to infinity. Large cases may need more GPU memory than the browser allows.</p>
181 </div>
182 <div id="device"></div>
183 </main>
184 <script type="module" src="/src/main.ts"></script>
185 </body>
186</html>