1/**
2 * Drawing the pressure field, straight out of the buffer the solver wrote.
3 *
4 * There is no readback in the display path: the fragment shader reads the
5 * solver's storage buffer directly, so a frame costs one draw call and no
6 * GPU-to-CPU round trip. Adapted from acoustic-scattering-3d's renderer; the
7 * differences are a rectangular domain instead of a cube, and the body drawn
8 * from the wall mask instead of from a speed contrast.
9 *
10 * The picture is a ray march. Each pixel casts one ray, intersects it with
11 * the domain box, and steps along it accumulating emission front to back:
12 * the pressure goes through a diverging colormap about zero, and the opacity
13 * goes as a power of |p|, so quiet regions are transparent and the
14 * wavefronts are what you see. The body is added as a grey emission where
15 * the wall mask says solid, which shows the box and its sound hole without
16 * needing its own kind of drawing.
17 *
18 * Two honest limitations. Sampling is nearest-neighbour, not trilinear: the
19 * field lives in a storage buffer rather than a filterable 3D texture, so
20 * trilinear would be eight fetches per sample and the march takes tens of
21 * millions of samples a frame. With the ray step set near the cell size the
22 * difference is visible mainly as a faint stippling on strong wavefronts.
23 * And the compositing is emission only, with no lighting and no shadowing,
24 * so what is behind a strong feature is dimmed but never occluded correctly.
25 *
26 * A clip plane on y is provided because a volume render of a wavefield is
27 * mostly the outside of a wavefield. Pulling the clip in cuts the picture
28 * lengthwise through the string and the cavity, which is the view that
29 * actually shows the instrument working.
30 */
31import type { ColormapFunc } from './colormaps.ts';
33const SHADER = `
34struct View {
35 eye: vec4f, // xyz: eye position, w: tan(fov/2)
36 right: vec4f, // xyz: camera right, w: aspect ratio
37 up: vec4f, // xyz: camera up, w: pressure the colormap saturates at
38 fwd: vec4f, // xyz: camera forward, w: contrast exponent
39 bg: vec4f, // rgb: background, w: body strength
40 dims: vec4f, // Lx, Ly, Lz, h
41 grid: vec4f, // nx, ny, nz, ray steps
42 misc: vec4f, // opacity, clip y, (unused), (unused)
43 m: vec4f, // xyz: microphone position, w: whether to draw it
44};
46@group(0) @binding(0) var<uniform> V: View;
47@group(0) @binding(1) var<storage, read> p: array<f32>;
48@group(0) @binding(2) var<storage, read> wall: array<f32>;
49@group(0) @binding(3) var cmap: texture_2d<f32>;
50@group(0) @binding(4) var samp: sampler;
52struct VSOut {
53 @builtin(position) pos: vec4f,
54 @location(0) ndc: vec2f,
55};
57@vertex
58fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
59 // One oversized triangle covering the viewport.
60 var xy = array<vec2f, 3>(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0));
61 var out: VSOut;
62 let q = xy[vi];
63 out.pos = vec4f(q, 0.0, 1.0);
64 out.ndc = q;
65 return out;
66}
68fn voxel(q: vec3f) -> u32 {
69 let n = vec3i(i32(V.grid.x), i32(V.grid.y), i32(V.grid.z));
70 let h = V.dims.w;
71 let g = clamp(vec3i(floor((q + 0.5 * V.dims.xyz) / h)), vec3i(0), n - vec3i(1));
72 return u32(g.x + n.x * (g.y + n.y * g.z));
73}
75// A point on the box's surface is on an edge when two of its three distances
76// to the bounding planes vanish, so the test is on the median of the three.
77fn edge(q: vec3f) -> f32 {
78 let d = abs(abs(q) - 0.5 * V.dims.xyz);
79 let lo = min(d.x, min(d.y, d.z));
80 let hi = max(d.x, max(d.y, d.z));
81 let mid = d.x + d.y + d.z - lo - hi;
82 let w = 0.004 * V.dims.x;
83 return 1.0 - smoothstep(w, 2.0 * w, mid);
84}
86@fragment
87fn fs(in: VSOut) -> @location(0) vec4f {
88 let half = 0.5 * V.dims.xyz;
89 let eye = V.eye.xyz;
90 let dir = normalize(V.fwd.xyz
91 + in.ndc.x * V.right.w * V.eye.w * V.right.xyz
92 + in.ndc.y * V.eye.w * V.up.xyz);
94 let inv = 1.0 / dir;
95 let ta = (-half - eye) * inv;
96 let tb = (half - eye) * inv;
97 let lo = min(ta, tb);
98 let hi = max(ta, tb);
99 let t1 = min(min(hi.x, hi.y), hi.z);
100 var t0 = max(max(lo.x, lo.y), lo.z);
102 var col = V.bg.rgb;
103 if (t1 <= max(t0, 0.0)) { return vec4f(col, 1.0); }
104 t0 = max(t0, 0.0);
106 let lineCol = vec3f(0.42, 0.47, 0.55);
107 col = mix(col, lineCol, 0.5 * edge(eye + dir * t1));
109 let steps = i32(V.grid.w);
110 let dl = (t1 - t0) / f32(steps);
111 // Opacity is quoted per cell, so a longer ray step is proportionally more
112 // opaque and the picture does not change brightness with the quality knob.
113 let unit = dl / V.dims.w;
114 let scale = max(V.up.w, 1e-20);
115 let grey = vec3f(0.55, 0.58, 0.63);
117 var acc = vec3f(0.0);
118 var alpha = 0.0;
119 for (var k = 0; k < steps; k = k + 1) {
120 if (alpha > 0.995) { break; }
121 let q = eye + dir * (t0 + (f32(k) + 0.5) * dl);
122 if (q.y > V.misc.y) { continue; }
123 let i = voxel(q);
124 let v = clamp(p[i] / scale, -1.0, 1.0);
125 var a = pow(abs(v), V.fwd.w) * V.misc.x * unit;
126 var rgb = textureSampleLevel(cmap, samp, vec2f(0.5 + 0.5 * v, 0.5), 0.0).rgb;
127 if (V.bg.w > 0.0) {
128 let m = clamp(1.0 - wall[i], 0.0, 1.0);
129 let am = m * V.bg.w * unit;
130 let tot = a + am;
131 if (tot > 1e-12) { rgb = (a * rgb + am * grey) / tot; }
132 a = tot;
133 }
134 a = clamp(a, 0.0, 1.0);
135 acc = acc + (1.0 - alpha) * a * rgb;
136 alpha = alpha + (1.0 - alpha) * a;
137 }
138 col = acc + (1.0 - alpha) * col;
139 col = mix(col, lineCol, 0.75 * edge(eye + dir * t0));
141 // The microphone, a dot in a ring at the ray's closest approach to it.
142 // Drawn on top rather than composited into the march: a marker, not a
143 // thing in the scene.
144 if (V.m.w > 0.0) {
145 let toM = V.m.xyz - eye;
146 let tm = dot(toM, dir);
147 if (tm > 0.0) {
148 let d = length(toM - tm * dir) / V.dims.x;
149 let dotm = 1.0 - smoothstep(0.004, 0.007, d);
150 let ring = 1.0 - smoothstep(0.0015, 0.004, abs(d - 0.016));
151 col = mix(col, vec3f(1.0), 0.9 * max(dotm, ring));
152 }
153 }
154 return vec4f(col, 1.0);
155}
156`;
158/** Where the camera is looking from. Angles in radians, distance in metres. */
159export interface Camera {
160 az: number;
161 el: number;
162 dist: number;
163}
165/** The camera's orthonormal frame, shared with the overlay so its lines land
166 * on the volume's pixels. */
167export interface CameraFrame {
168 eye: [number, number, number];
169 right: [number, number, number];
170 up: [number, number, number];
171 fwd: [number, number, number];
172 tanHalfFov: number;
173 aspect: number;
174}
176export const TAN_HALF_FOV = Math.tan((32 * Math.PI) / 360);
178const cross = (a: number[], b: number[]): [number, number, number] => [
179 a[1] * b[2] - a[2] * b[1],
180 a[2] * b[0] - a[0] * b[2],
181 a[0] * b[1] - a[1] * b[0],
182];
183const norm = (a: [number, number, number]): [number, number, number] => {
184 const m = Math.hypot(a[0], a[1], a[2]) || 1;
185 return [a[0] / m, a[1] / m, a[2] / m];
186};
188export function cameraFrame(camera: Camera, aspect: number): CameraFrame {
189 const { az, el, dist } = camera;
190 const ce = Math.cos(el);
191 const toEye: [number, number, number] = [
192 ce * Math.cos(az),
193 ce * Math.sin(az),
194 Math.sin(el),
195 ];
196 const eye: [number, number, number] = [toEye[0] * dist, toEye[1] * dist, toEye[2] * dist];
197 const fwd: [number, number, number] = [-toEye[0], -toEye[1], -toEye[2]];
198 const right = norm(cross([0, 0, 1], fwd));
199 const up = cross(fwd, right);
200 return { eye, right, up, fwd, tanHalfFov: TAN_HALF_FOV, aspect };
201}
203export interface DrawOptions {
204 frame: CameraFrame;
205 /** Pressure the colormap saturates at, in both directions. */
206 scale: number;
207 opacity: number;
208 contrast: number;
209 /** Samples along each ray. */
210 steps: number;
211 /** Everything with y above this is not drawn, in metres. */
212 clipY: number;
213 /** Strength of the body's grey emission, 0 to turn it off. */
214 body: number;
215 /** Microphone position in metres, or null to not draw it. */
216 mic?: { x: number; y: number; z: number } | null;
217}
219export interface VolumeDims {
220 nx: number;
221 ny: number;
222 nz: number;
223 Lx: number;
224 Ly: number;
225 Lz: number;
226 h: number;
227}
229export class VolumeView {
230 readonly canvas: HTMLCanvasElement;
232 #device: GPUDevice;
233 #context: GPUCanvasContext;
234 #pipeline: GPURenderPipeline;
235 #layout: GPUBindGroupLayout;
236 #uniform: GPUBuffer;
237 #host = new ArrayBuffer(9 * 16);
238 #sampler: GPUSampler;
239 #cmapTexture: GPUTexture;
240 #bindGroup: GPUBindGroup | null = null;
241 #dims: VolumeDims | null = null;
242 /** Background, matched to the CSS so the box sits on the page. */
243 bg: [number, number, number] = [0.043, 0.055, 0.071];
245 constructor(device: GPUDevice, canvas: HTMLCanvasElement) {
246 this.#device = device;
247 this.canvas = canvas;
249 const context = canvas.getContext('webgpu');
250 if (!context) throw new Error('this canvas has no WebGPU context');
251 this.#context = context;
252 const format = navigator.gpu.getPreferredCanvasFormat();
253 context.configure({ device, format, alphaMode: 'opaque' });
255 this.#layout = device.createBindGroupLayout({
256 label: 'volume-view',
257 entries: [
258 { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
259 { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } },
260 { binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } },
261 { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
262 { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
263 ],
264 });
266 const module = device.createShaderModule({ code: SHADER, label: 'volume-view' });
267 this.#pipeline = device.createRenderPipeline({
268 label: 'volume-view',
269 layout: device.createPipelineLayout({ bindGroupLayouts: [this.#layout] }),
270 vertex: { module, entryPoint: 'vs' },
271 fragment: { module, entryPoint: 'fs', targets: [{ format }] },
272 primitive: { topology: 'triangle-list' },
273 });
275 this.#uniform = device.createBuffer({
276 label: 'volume-view-uniform',
277 size: this.#host.byteLength,
278 usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
279 });
280 this.#sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear' });
281 this.#cmapTexture = device.createTexture({
282 label: 'colormap',
283 size: [256, 1],
284 format: 'rgba8unorm',
285 usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
286 });
287 }
289 /** Point the view at the buffers of a (new) simulation: the host-owned
290 * pressure state and the wall mask, which is what the body is drawn from. */
291 setSource(pressure: GPUBuffer, wall: GPUBuffer, dims: VolumeDims): void {
292 this.#dims = dims;
293 this.#bindGroup = this.#device.createBindGroup({
294 layout: this.#layout,
295 entries: [
296 { binding: 0, resource: { buffer: this.#uniform } },
297 { binding: 1, resource: { buffer: pressure } },
298 { binding: 2, resource: { buffer: wall } },
299 { binding: 3, resource: this.#cmapTexture.createView() },
300 { binding: 4, resource: this.#sampler },
301 ],
302 });
303 }
305 setColormap(cmap: ColormapFunc): void {
306 const data = new Uint8Array(256 * 4);
307 for (let i = 0; i < 256; i++) {
308 const [r, g, b] = cmap(i / 255);
309 data[4 * i] = r;
310 data[4 * i + 1] = g;
311 data[4 * i + 2] = b;
312 data[4 * i + 3] = 255;
313 }
314 this.#device.queue.writeTexture(
315 { texture: this.#cmapTexture },
316 data,
317 { bytesPerRow: 256 * 4 },
318 { width: 256, height: 1 },
319 );
320 }
322 /** Draw one frame. */
323 draw(o: DrawOptions): void {
324 const bg = this.#bindGroup;
325 const dims = this.#dims;
326 if (!bg || !dims) return;
328 const { eye, right, up, fwd } = o.frame;
329 const f = new Float32Array(this.#host);
330 f.set([eye[0], eye[1], eye[2], o.frame.tanHalfFov], 0);
331 f.set([right[0], right[1], right[2], o.frame.aspect], 4);
332 f.set([up[0], up[1], up[2], o.scale], 8);
333 f.set([fwd[0], fwd[1], fwd[2], o.contrast], 12);
334 f.set([this.bg[0], this.bg[1], this.bg[2], o.body], 16);
335 f.set([dims.Lx, dims.Ly, dims.Lz, dims.h], 20);
336 f.set([dims.nx, dims.ny, dims.nz, o.steps], 24);
337 f.set([o.opacity, o.clipY, 0, 0], 28);
338 f.set([o.mic?.x ?? 0, o.mic?.y ?? 0, o.mic?.z ?? 0, o.mic ? 1 : 0], 32);
339 this.#device.queue.writeBuffer(this.#uniform, 0, this.#host);
341 const enc = this.#device.createCommandEncoder({ label: 'volume-view' });
342 const pass = enc.beginRenderPass({
343 colorAttachments: [
344 {
345 view: this.#context.getCurrentTexture().createView(),
346 clearValue: { r: this.bg[0], g: this.bg[1], b: this.bg[2], a: 1 },
347 loadOp: 'clear',
348 storeOp: 'store',
349 },
350 ],
351 });
352 pass.setPipeline(this.#pipeline);
353 pass.setBindGroup(0, bg);
354 pass.draw(3);
355 pass.end();
356 this.#device.queue.submit([enc.finish()]);
357 }
359 /**
360 * Match the canvas's backing store to its CSS size. Device pixel ratio is
361 * ignored: the march is the whole cost of a frame and it scales with
362 * pixels, so a retina display would pay four times over for a picture that
363 * is already smooth.
364 */
365 resize(): void {
366 const rect = this.canvas.getBoundingClientRect();
367 const w = Math.max(1, Math.round(rect.width));
368 const h = Math.max(1, Math.round(rect.height));
369 if (this.canvas.width !== w || this.canvas.height !== h) {
370 this.canvas.width = w;
371 this.canvas.height = h;
372 }
373 }
375 destroy(): void {
376 this.#uniform.destroy();
377 this.#cmapTexture.destroy();
378 }
379}