1// WebGPU rendering of the plate: a 2D color map or a lit 3D height field of
2// the displacement (plus the stress-free shape), colored by the displacement
3// or by the thickness. Both views paint the mount and the strike point on the
4// surface as rings; the 3D view also stands a post at the mount, so that it
5// stays visible when the ring is edge-on or lost on a steep bell, and stands a
6// microphone where the listener is, framing the view to reach both.
8import { type Grid, cellMask, insideCell } from "../params.ts";
9import { lookAt, multiply, perspective } from "./mat4.ts";
10import shaderCode from "./shaders.wgsl?raw";
12export type ViewMode = "2d" | "3d";
13export type ColorMode = "field" | "thickness";
15export interface Marker {
16 x: number;
17 y: number;
18 r: number;
19}
21/** Vertices of the mount post: 24 segments of two side triangles, plus a fan over each cap (see shaders.wgsl). */
22const MOUNT_VERTS = 24 * 6 + 2 * 24 * 3;
23/** Vertices of the microphone: a 16 by 24 surface of revolution, plus a fan closing its back (see shaders.wgsl). */
24const MIC_VERTS = 16 * 24 * 6 + 16 * 3;
25/** Its widest radius and the length of its body [m]: a large-diaphragm microphone, the same whatever the plate. */
26const MIC_RADIUS = 0.028;
27const MIC_ASPECT = 4.2; // must match shaders.wgsl
28const MIC_LENGTH = MIC_RADIUS * MIC_ASPECT;
30/** Vertical field of view of the 3D view [rad], the framing margin around the scene, and the orbit limits. */
31const FOV = 0.7;
32const FIT_MARGIN = 1.1;
33const DIST_MIN = 0.05;
34const DIST_MAX = 200;
36type Vec3 = [number, number, number];
37const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
38const cross = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
39const unit = (a: Vec3): Vec3 => { const n = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / n, a[1] / n, a[2] / n]; };
41/**
42 * The smallest orbit radius, about the plate's center along `dir`, at which
43 * every one of `points` still falls inside the frustum, each point taken with
44 * the radius it needs around it. The view is much wider than it is tall, so
45 * fitting what is actually on screen rather than a bounding sphere makes a
46 * large difference to how much of it the plate fills.
47 */
48function fitDistance(points: [Vec3, number][], dir: Vec3, aspect: number): number {
49 const fwd: Vec3 = [-dir[0], -dir[1], -dir[2]];
50 const right = unit(cross(fwd, [0, 1, 0]));
51 const up = cross(right, fwd);
52 const tanH = Math.tan(FOV / 2);
53 let need = 0;
54 for (const [p, r] of points) {
55 const depth = dot(p, dir);
56 const v = (Math.abs(dot(p, up)) + r) / tanH;
57 const h = (Math.abs(dot(p, right)) + r) / (tanH * aspect);
58 need = Math.max(need, depth + Math.max(v, h));
59 }
60 return Math.max(DIST_MIN, Math.min(DIST_MAX, need * FIT_MARGIN));
61}
63export class Renderer {
64 mode: ViewMode = "2d";
65 /** Vertical exaggeration of the 3D view. */
66 exaggeration = 1;
67 /** Color range |w| [m]; 0 means automatic (from the current maximum). */
68 colorRange = 0;
69 /** Show the total shape (initial shape plus deviation) rather than the deviation alone, in both views. */
70 showShape = true;
71 colorMode: ColorMode = "field";
72 /** Orbit about the plate's center: yaw about the vertical, pitch above the plane of the plate. */
73 camera = { yaw: 0.7, pitch: 0.5, dist: 0.95 };
74 /**
75 * Until the wheel is used, the orbit radius is chosen to frame the whole
76 * scene, the microphone included. A listener a meter from a 16 inch cymbal
77 * is drawn a meter from its center, so the cymbal is small in the view; that
78 * is what a microphone a meter up really looks like. A double-click returns
79 * to this framing, and to the opening angle, after moving the view.
80 */
81 private userZoomed = false;
82 /** Markers drawn in both views; read at every render. */
83 strike: Marker = { x: 0, y: 0, r: 0 };
84 mount: Marker | null = null;
85 /** Listener position in the plate's frame (z along the normal); the 3D view stands a microphone on that line. */
86 listener: [number, number, number] | null = null;
88 private device: GPUDevice;
89 private canvas: HTMLCanvasElement;
90 private ctx: GPUCanvasContext;
91 private format: GPUTextureFormat;
92 private uniform: GPUBuffer;
93 private pipe2d: GPURenderPipeline;
94 private pipe3d: GPURenderPipeline;
95 private pipe3dMount: GPURenderPipeline;
96 private pipe3dListener: GPURenderPipeline;
97 private bgLayout: GPUBindGroupLayout;
98 private index: GPUBuffer | null = null;
99 private indexCount = 0;
100 private depth: GPUTexture | null = null;
101 private grid: Grid | null = null;
102 private bindGroups = new Map<GPUBuffer, GPUBindGroup>();
104 private w0: GPUBuffer | null = null;
105 private w0Max = 0;
106 private nw: GPUBuffer | null = null;
107 private hn: GPUBuffer | null = null;
108 private cm: GPUBuffer | null = null;
109 private hRange: [number, number] = [0, 1];
111 /** The grid of the field currently set, or null before setField. */
112 get fieldGrid(): Grid | null {
113 return this.grid;
114 }
116 /** The renderer draws nothing until setField gives it a grid. */
117 constructor(device: GPUDevice, canvas: HTMLCanvasElement) {
118 this.device = device;
119 this.canvas = canvas;
120 this.ctx = canvas.getContext("webgpu") as GPUCanvasContext;
121 this.format = navigator.gpu.getPreferredCanvasFormat();
122 this.ctx.configure({ device, format: this.format, alphaMode: "opaque" });
123 this.uniform = device.createBuffer({ size: 176, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // RU in shaders.wgsl: 44 f32
124 const module = device.createShaderModule({ code: shaderCode });
125 const vis = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT;
126 this.bgLayout = device.createBindGroupLayout({
127 entries: [
128 { binding: 0, visibility: vis, buffer: { type: "uniform" } },
129 { binding: 1, visibility: vis, buffer: { type: "read-only-storage" } },
130 { binding: 2, visibility: vis, buffer: { type: "read-only-storage" } },
131 { binding: 3, visibility: vis, buffer: { type: "read-only-storage" } },
132 { binding: 4, visibility: vis, buffer: { type: "read-only-storage" } },
133 { binding: 5, visibility: vis, buffer: { type: "read-only-storage" } },
134 ],
135 });
136 const layout = device.createPipelineLayout({ bindGroupLayouts: [this.bgLayout] });
137 this.pipe2d = device.createRenderPipeline({
138 layout,
139 vertex: { module, entryPoint: "vs2d" },
140 fragment: { module, entryPoint: "fs2d", targets: [{ format: this.format }] },
141 primitive: { topology: "triangle-list" },
142 });
143 const depthStencil: GPUDepthStencilState = { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" };
144 this.pipe3d = device.createRenderPipeline({
145 layout,
146 vertex: { module, entryPoint: "vs3d" },
147 fragment: { module, entryPoint: "fs3d", targets: [{ format: this.format }] },
148 primitive: { topology: "triangle-list", cullMode: "none" },
149 depthStencil,
150 });
151 this.pipe3dMount = device.createRenderPipeline({
152 layout,
153 vertex: { module, entryPoint: "vs3dMount" },
154 fragment: { module, entryPoint: "fs3dMount", targets: [{ format: this.format }] },
155 primitive: { topology: "triangle-list", cullMode: "none" },
156 depthStencil,
157 });
158 this.pipe3dListener = device.createRenderPipeline({
159 layout,
160 vertex: { module, entryPoint: "vs3dListener" },
161 fragment: { module, entryPoint: "fs3dListener", targets: [{ format: this.format }] },
162 primitive: { topology: "triangle-list", cullMode: "none" },
163 depthStencil,
164 });
165 this.installControls();
166 }
168 /**
169 * Sets the grid and the per-node buffers the views read: the stress-free
170 * shape (added to the displayed field when showShape is on; w0Max is its
171 * largest magnitude, for the automatic color range), the node weights (zero
172 * outside the plate), and the thickness with its range for the thickness
173 * coloring. Called at every Start and for the preview of the design; the
174 * camera and the canvas controls persist.
175 */
176 setField(grid: Grid, w0: GPUBuffer, nw: GPUBuffer, hn: GPUBuffer, w0Max: number, hRange: [number, number]): void {
177 this.grid = grid;
178 this.w0 = w0;
179 this.w0Max = w0Max;
180 this.nw = nw;
181 this.hn = hn;
182 this.hRange = hRange;
183 this.bindGroups.clear();
184 const cmData = cellMask(grid);
185 this.cm?.destroy();
186 this.cm = this.device.createBuffer({ size: Math.max(16, cmData.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
187 this.device.queue.writeBuffer(this.cm, 0, cmData);
188 // Triangle indices of the height field (cells of the plate only).
189 const { nx, ny } = grid;
190 const idx = new Uint32Array((nx - 1) * (ny - 1) * 6);
191 let k = 0;
192 for (let j = 0; j < ny - 1; j++) {
193 for (let i = 0; i < nx - 1; i++) {
194 if (!insideCell(grid, i, j)) continue;
195 const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
196 idx[k++] = a; idx[k++] = b; idx[k++] = d;
197 idx[k++] = a; idx[k++] = d; idx[k++] = c;
198 }
199 }
200 this.indexCount = k;
201 this.index?.destroy();
202 this.index = this.device.createBuffer({ size: Math.max(16, idx.byteLength), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
203 this.device.queue.writeBuffer(this.index, 0, idx);
204 }
206 private installControls(): void {
207 let dragging = false, lastX = 0, lastY = 0;
208 this.canvas.addEventListener("pointerdown", (e) => {
209 dragging = true; lastX = e.clientX; lastY = e.clientY;
210 this.canvas.setPointerCapture(e.pointerId);
211 });
212 this.canvas.addEventListener("pointerup", () => (dragging = false));
213 this.canvas.addEventListener("pointermove", (e) => {
214 if (!dragging || this.mode !== "3d") return;
215 this.camera.yaw += (e.clientX - lastX) * 0.01;
216 this.camera.pitch = Math.max(-1.5, Math.min(1.5, this.camera.pitch + (e.clientY - lastY) * 0.01));
217 lastX = e.clientX; lastY = e.clientY;
218 });
219 this.canvas.addEventListener("wheel", (e) => {
220 if (this.mode !== "3d") return;
221 e.preventDefault();
222 this.userZoomed = true;
223 this.camera.dist = Math.max(DIST_MIN, Math.min(DIST_MAX, this.camera.dist * Math.exp(e.deltaY * 0.001)));
224 }, { passive: false });
225 this.canvas.addEventListener("dblclick", () => {
226 this.camera.yaw = 0.7;
227 this.camera.pitch = 0.5;
228 this.userZoomed = false;
229 });
230 }
232 /**
233 * The points the 3D framing has to hold: the corners of the box around the
234 * plate at its displayed height, and the two ends of the microphone, each
235 * with the radius it needs around it.
236 */
237 private fitPoints(L: [number, number, number] | null, Ln: number, zmax: number): [Vec3, number][] {
238 const g = this.grid!;
239 const hx = 0.5 * (g.nx - 1) * g.hx, hy = 0.5 * (g.ny - 1) * g.hy, hz = Math.max(zmax * this.exaggeration, 1e-4);
240 // The plate's frame in the world (see `world` in shaders.wgsl): x, height, -y.
241 const pts: [Vec3, number][] = [];
242 for (const sx of [-1, 1]) for (const sy of [-1, 1]) for (const sz of [-1, 1]) pts.push([[sx * hx, sz * hz, -sy * hy], 0]);
243 if (L && Ln > 0) {
244 for (const d of [Ln, Ln + MIC_LENGTH]) {
245 const s = d / Ln;
246 pts.push([[L[0] * s, L[2] * s, -L[1] * s], MIC_RADIUS]);
247 }
248 }
249 return pts;
250 }
252 private resize(): void {
253 const dpr = window.devicePixelRatio || 1;
254 const w = Math.max(1, Math.floor(this.canvas.clientWidth * dpr));
255 const h = Math.max(1, Math.floor(this.canvas.clientHeight * dpr));
256 if (this.canvas.width !== w || this.canvas.height !== h || !this.depth) {
257 this.canvas.width = w;
258 this.canvas.height = h;
259 this.depth?.destroy();
260 this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
261 }
262 }
264 /** Renders the w field held in `wBuffer` (f32 per node). `wmax`, the largest |w|, drives the automatic color range. */
265 render(encoder: GPUCommandEncoder, wBuffer: GPUBuffer, wmax: number): void {
266 if (!this.grid || !this.w0 || !this.nw || !this.hn || !this.cm || !this.index) return;
267 this.resize();
268 const aspect = this.canvas.width / this.canvas.height;
269 const auto = wmax + (this.showShape ? this.w0Max : 0);
270 const range = this.colorRange > 0 ? this.colorRange : Math.max(auto, 1e-6);
271 const u = new Float32Array(44);
272 const ui = new Uint32Array(u.buffer);
273 // The microphone stands where the listener is, at the listener distance from the center of the plate,
274 // so the framing has to reach out to it as well as around the plate.
275 const L = this.listener;
276 const Ln = L ? Math.hypot(L[0], L[1], L[2]) : 0;
277 const drawListener = !!L && Ln > 0;
278 const dir: Vec3 = [
279 Math.cos(this.camera.pitch) * Math.sin(this.camera.yaw),
280 Math.sin(this.camera.pitch),
281 Math.cos(this.camera.pitch) * Math.cos(this.camera.yaw),
282 ];
283 const sceneRadius = this.fitPoints(drawListener && this.mode === "3d" ? L : null, Ln, auto);
284 if (!this.userZoomed && this.mode === "3d") this.camera.dist = fitDistance(sceneRadius, dir, aspect);
285 const eye: Vec3 = [this.camera.dist * dir[0], this.camera.dist * dir[1], this.camera.dist * dir[2]];
286 const reach = Math.max(...sceneRadius.map(([p]) => Math.hypot(p[0], p[1], p[2])));
287 const near = 0.02 * this.camera.dist, far = 4 * this.camera.dist + 4 * reach;
288 const mvp = multiply(perspective(FOV, aspect, near, far), lookAt(eye, [0, 0, 0], [0, 1, 0]));
289 u.set(mvp, 0);
290 ui[16] = this.grid.nx; ui[17] = this.grid.ny;
291 u[18] = this.grid.hx; u[19] = this.grid.hy;
292 u[20] = this.exaggeration; u[21] = range; u[22] = aspect;
293 ui[23] = this.mode === "3d" ? 1 : 0;
294 u[24] = this.strike.x; u[25] = this.strike.y; u[26] = this.strike.r;
295 u[27] = this.mount?.x ?? 0; u[28] = this.mount?.y ?? 0; u[29] = this.mount?.r ?? 0;
296 u[30] = 0.35; u[31] = 0.85; u[32] = 0.4; // light, from above and to one side
297 ui[33] = this.showShape ? 1 : 0;
298 ui[34] = this.colorMode === "thickness" ? 1 : 0;
299 ui[35] = this.mount ? 1 : 0;
300 u[36] = this.hRange[0]; u[37] = this.hRange[1];
301 if (drawListener) { u[38] = L![0] / Ln; u[39] = L![1] / Ln; u[40] = L![2] / Ln; }
302 u[41] = Ln;
303 u[42] = MIC_RADIUS;
304 ui[43] = drawListener ? 1 : 0;
305 this.device.queue.writeBuffer(this.uniform, 0, u);
307 let bg = this.bindGroups.get(wBuffer);
308 if (!bg) {
309 bg = this.device.createBindGroup({
310 layout: this.bgLayout,
311 entries: [
312 { binding: 0, resource: { buffer: this.uniform } },
313 { binding: 1, resource: { buffer: wBuffer } },
314 { binding: 2, resource: { buffer: this.w0 } },
315 { binding: 3, resource: { buffer: this.nw } },
316 { binding: 4, resource: { buffer: this.hn } },
317 { binding: 5, resource: { buffer: this.cm } },
318 ],
319 });
320 this.bindGroups.set(wBuffer, bg);
321 }
322 const view = this.ctx.getCurrentTexture().createView();
323 const clear = { r: 0.11, g: 0.12, b: 0.14, a: 1 };
324 if (this.mode === "2d") {
325 const pass = encoder.beginRenderPass({ colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }] });
326 pass.setPipeline(this.pipe2d);
327 pass.setBindGroup(0, bg);
328 pass.draw(3);
329 pass.end();
330 } else {
331 const pass = encoder.beginRenderPass({
332 colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }],
333 depthStencilAttachment: { view: this.depth!.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
334 });
335 pass.setPipeline(this.pipe3d);
336 pass.setBindGroup(0, bg);
337 pass.setIndexBuffer(this.index, "uint32");
338 pass.drawIndexed(this.indexCount);
339 if (this.mount) {
340 // The post at the mount; the ring on the surface is painted by fs3d.
341 pass.setPipeline(this.pipe3dMount);
342 pass.setBindGroup(0, bg);
343 pass.draw(MOUNT_VERTS);
344 }
345 if (drawListener) {
346 pass.setPipeline(this.pipe3dListener);
347 pass.setBindGroup(0, bg);
348 pass.draw(MIC_VERTS);
349 }
350 pass.end();
351 }
352 }
353}