// WebGPU rendering of the plate: a 2D color map or a lit 3D height field of // the displacement (plus the stress-free shape), colored by the displacement // or by the thickness. Both views paint the mount and the strike point on the // surface as rings; the 3D view also stands a post at the mount, so that it // stays visible when the ring is edge-on or lost on a steep bell, and stands a // microphone where the listener is, framing the view to reach both. import { type Grid, cellMask, insideCell } from "../params.ts"; import { lookAt, multiply, perspective } from "./mat4.ts"; import shaderCode from "./shaders.wgsl?raw"; export type ViewMode = "2d" | "3d"; export type ColorMode = "field" | "thickness"; export interface Marker { x: number; y: number; r: number; } /** Vertices of the mount post: 24 segments of two side triangles, plus a fan over each cap (see shaders.wgsl). */ const MOUNT_VERTS = 24 * 6 + 2 * 24 * 3; /** Vertices of the microphone: a 16 by 24 surface of revolution, plus a fan closing its back (see shaders.wgsl). */ const MIC_VERTS = 16 * 24 * 6 + 16 * 3; /** Its widest radius and the length of its body [m]: a large-diaphragm microphone, the same whatever the plate. */ const MIC_RADIUS = 0.028; const MIC_ASPECT = 4.2; // must match shaders.wgsl const MIC_LENGTH = MIC_RADIUS * MIC_ASPECT; /** Vertical field of view of the 3D view [rad], the framing margin around the scene, and the orbit limits. */ const FOV = 0.7; const FIT_MARGIN = 1.1; const DIST_MIN = 0.05; const DIST_MAX = 200; type Vec3 = [number, number, number]; const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; const 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]]; const 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]; }; /** * The smallest orbit radius, about the plate's center along `dir`, at which * every one of `points` still falls inside the frustum, each point taken with * the radius it needs around it. The view is much wider than it is tall, so * fitting what is actually on screen rather than a bounding sphere makes a * large difference to how much of it the plate fills. */ function fitDistance(points: [Vec3, number][], dir: Vec3, aspect: number): number { const fwd: Vec3 = [-dir[0], -dir[1], -dir[2]]; const right = unit(cross(fwd, [0, 1, 0])); const up = cross(right, fwd); const tanH = Math.tan(FOV / 2); let need = 0; for (const [p, r] of points) { const depth = dot(p, dir); const v = (Math.abs(dot(p, up)) + r) / tanH; const h = (Math.abs(dot(p, right)) + r) / (tanH * aspect); need = Math.max(need, depth + Math.max(v, h)); } return Math.max(DIST_MIN, Math.min(DIST_MAX, need * FIT_MARGIN)); } export class Renderer { mode: ViewMode = "2d"; /** Vertical exaggeration of the 3D view. */ exaggeration = 1; /** Color range |w| [m]; 0 means automatic (from the current maximum). */ colorRange = 0; /** Show the total shape (initial shape plus deviation) rather than the deviation alone, in both views. */ showShape = true; colorMode: ColorMode = "field"; /** Orbit about the plate's center: yaw about the vertical, pitch above the plane of the plate. */ camera = { yaw: 0.7, pitch: 0.5, dist: 0.95 }; /** * Until the wheel is used, the orbit radius is chosen to frame the whole * scene, the microphone included. A listener a meter from a 16 inch cymbal * is drawn a meter from its center, so the cymbal is small in the view; that * is what a microphone a meter up really looks like. A double-click returns * to this framing, and to the opening angle, after moving the view. */ private userZoomed = false; /** Markers drawn in both views; read at every render. */ strike: Marker = { x: 0, y: 0, r: 0 }; mount: Marker | null = null; /** Listener position in the plate's frame (z along the normal); the 3D view stands a microphone on that line. */ listener: [number, number, number] | null = null; private device: GPUDevice; private canvas: HTMLCanvasElement; private ctx: GPUCanvasContext; private format: GPUTextureFormat; private uniform: GPUBuffer; private pipe2d: GPURenderPipeline; private pipe3d: GPURenderPipeline; private pipe3dMount: GPURenderPipeline; private pipe3dListener: GPURenderPipeline; private bgLayout: GPUBindGroupLayout; private index: GPUBuffer | null = null; private indexCount = 0; private depth: GPUTexture | null = null; private grid: Grid | null = null; private bindGroups = new Map(); private w0: GPUBuffer | null = null; private w0Max = 0; private nw: GPUBuffer | null = null; private hn: GPUBuffer | null = null; private cm: GPUBuffer | null = null; private hRange: [number, number] = [0, 1]; /** The grid of the field currently set, or null before setField. */ get fieldGrid(): Grid | null { return this.grid; } /** The renderer draws nothing until setField gives it a grid. */ constructor(device: GPUDevice, canvas: HTMLCanvasElement) { this.device = device; this.canvas = canvas; this.ctx = canvas.getContext("webgpu") as GPUCanvasContext; this.format = navigator.gpu.getPreferredCanvasFormat(); this.ctx.configure({ device, format: this.format, alphaMode: "opaque" }); this.uniform = device.createBuffer({ size: 176, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // RU in shaders.wgsl: 44 f32 const module = device.createShaderModule({ code: shaderCode }); const vis = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT; this.bgLayout = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: vis, buffer: { type: "uniform" } }, { binding: 1, visibility: vis, buffer: { type: "read-only-storage" } }, { binding: 2, visibility: vis, buffer: { type: "read-only-storage" } }, { binding: 3, visibility: vis, buffer: { type: "read-only-storage" } }, { binding: 4, visibility: vis, buffer: { type: "read-only-storage" } }, { binding: 5, visibility: vis, buffer: { type: "read-only-storage" } }, ], }); const layout = device.createPipelineLayout({ bindGroupLayouts: [this.bgLayout] }); this.pipe2d = device.createRenderPipeline({ layout, vertex: { module, entryPoint: "vs2d" }, fragment: { module, entryPoint: "fs2d", targets: [{ format: this.format }] }, primitive: { topology: "triangle-list" }, }); const depthStencil: GPUDepthStencilState = { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" }; this.pipe3d = device.createRenderPipeline({ layout, vertex: { module, entryPoint: "vs3d" }, fragment: { module, entryPoint: "fs3d", targets: [{ format: this.format }] }, primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil, }); this.pipe3dMount = device.createRenderPipeline({ layout, vertex: { module, entryPoint: "vs3dMount" }, fragment: { module, entryPoint: "fs3dMount", targets: [{ format: this.format }] }, primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil, }); this.pipe3dListener = device.createRenderPipeline({ layout, vertex: { module, entryPoint: "vs3dListener" }, fragment: { module, entryPoint: "fs3dListener", targets: [{ format: this.format }] }, primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil, }); this.installControls(); } /** * Sets the grid and the per-node buffers the views read: the stress-free * shape (added to the displayed field when showShape is on; w0Max is its * largest magnitude, for the automatic color range), the node weights (zero * outside the plate), and the thickness with its range for the thickness * coloring. Called at every Start and for the preview of the design; the * camera and the canvas controls persist. */ setField(grid: Grid, w0: GPUBuffer, nw: GPUBuffer, hn: GPUBuffer, w0Max: number, hRange: [number, number]): void { this.grid = grid; this.w0 = w0; this.w0Max = w0Max; this.nw = nw; this.hn = hn; this.hRange = hRange; this.bindGroups.clear(); const cmData = cellMask(grid); this.cm?.destroy(); this.cm = this.device.createBuffer({ size: Math.max(16, cmData.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); this.device.queue.writeBuffer(this.cm, 0, cmData); // Triangle indices of the height field (cells of the plate only). const { nx, ny } = grid; const idx = new Uint32Array((nx - 1) * (ny - 1) * 6); let k = 0; for (let j = 0; j < ny - 1; j++) { for (let i = 0; i < nx - 1; i++) { if (!insideCell(grid, i, j)) continue; const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1; idx[k++] = a; idx[k++] = b; idx[k++] = d; idx[k++] = a; idx[k++] = d; idx[k++] = c; } } this.indexCount = k; this.index?.destroy(); this.index = this.device.createBuffer({ size: Math.max(16, idx.byteLength), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST }); this.device.queue.writeBuffer(this.index, 0, idx); } private installControls(): void { let dragging = false, lastX = 0, lastY = 0; this.canvas.addEventListener("pointerdown", (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; this.canvas.setPointerCapture(e.pointerId); }); this.canvas.addEventListener("pointerup", () => (dragging = false)); this.canvas.addEventListener("pointermove", (e) => { if (!dragging || this.mode !== "3d") return; this.camera.yaw += (e.clientX - lastX) * 0.01; this.camera.pitch = Math.max(-1.5, Math.min(1.5, this.camera.pitch + (e.clientY - lastY) * 0.01)); lastX = e.clientX; lastY = e.clientY; }); this.canvas.addEventListener("wheel", (e) => { if (this.mode !== "3d") return; e.preventDefault(); this.userZoomed = true; this.camera.dist = Math.max(DIST_MIN, Math.min(DIST_MAX, this.camera.dist * Math.exp(e.deltaY * 0.001))); }, { passive: false }); this.canvas.addEventListener("dblclick", () => { this.camera.yaw = 0.7; this.camera.pitch = 0.5; this.userZoomed = false; }); } /** * The points the 3D framing has to hold: the corners of the box around the * plate at its displayed height, and the two ends of the microphone, each * with the radius it needs around it. */ private fitPoints(L: [number, number, number] | null, Ln: number, zmax: number): [Vec3, number][] { const g = this.grid!; 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); // The plate's frame in the world (see `world` in shaders.wgsl): x, height, -y. const pts: [Vec3, number][] = []; 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]); if (L && Ln > 0) { for (const d of [Ln, Ln + MIC_LENGTH]) { const s = d / Ln; pts.push([[L[0] * s, L[2] * s, -L[1] * s], MIC_RADIUS]); } } return pts; } private resize(): void { const dpr = window.devicePixelRatio || 1; const w = Math.max(1, Math.floor(this.canvas.clientWidth * dpr)); const h = Math.max(1, Math.floor(this.canvas.clientHeight * dpr)); if (this.canvas.width !== w || this.canvas.height !== h || !this.depth) { this.canvas.width = w; this.canvas.height = h; this.depth?.destroy(); this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT }); } } /** Renders the w field held in `wBuffer` (f32 per node). `wmax`, the largest |w|, drives the automatic color range. */ render(encoder: GPUCommandEncoder, wBuffer: GPUBuffer, wmax: number): void { if (!this.grid || !this.w0 || !this.nw || !this.hn || !this.cm || !this.index) return; this.resize(); const aspect = this.canvas.width / this.canvas.height; const auto = wmax + (this.showShape ? this.w0Max : 0); const range = this.colorRange > 0 ? this.colorRange : Math.max(auto, 1e-6); const u = new Float32Array(44); const ui = new Uint32Array(u.buffer); // The microphone stands where the listener is, at the listener distance from the center of the plate, // so the framing has to reach out to it as well as around the plate. const L = this.listener; const Ln = L ? Math.hypot(L[0], L[1], L[2]) : 0; const drawListener = !!L && Ln > 0; const dir: Vec3 = [ Math.cos(this.camera.pitch) * Math.sin(this.camera.yaw), Math.sin(this.camera.pitch), Math.cos(this.camera.pitch) * Math.cos(this.camera.yaw), ]; const sceneRadius = this.fitPoints(drawListener && this.mode === "3d" ? L : null, Ln, auto); if (!this.userZoomed && this.mode === "3d") this.camera.dist = fitDistance(sceneRadius, dir, aspect); const eye: Vec3 = [this.camera.dist * dir[0], this.camera.dist * dir[1], this.camera.dist * dir[2]]; const reach = Math.max(...sceneRadius.map(([p]) => Math.hypot(p[0], p[1], p[2]))); const near = 0.02 * this.camera.dist, far = 4 * this.camera.dist + 4 * reach; const mvp = multiply(perspective(FOV, aspect, near, far), lookAt(eye, [0, 0, 0], [0, 1, 0])); u.set(mvp, 0); ui[16] = this.grid.nx; ui[17] = this.grid.ny; u[18] = this.grid.hx; u[19] = this.grid.hy; u[20] = this.exaggeration; u[21] = range; u[22] = aspect; ui[23] = this.mode === "3d" ? 1 : 0; u[24] = this.strike.x; u[25] = this.strike.y; u[26] = this.strike.r; u[27] = this.mount?.x ?? 0; u[28] = this.mount?.y ?? 0; u[29] = this.mount?.r ?? 0; u[30] = 0.35; u[31] = 0.85; u[32] = 0.4; // light, from above and to one side ui[33] = this.showShape ? 1 : 0; ui[34] = this.colorMode === "thickness" ? 1 : 0; ui[35] = this.mount ? 1 : 0; u[36] = this.hRange[0]; u[37] = this.hRange[1]; if (drawListener) { u[38] = L![0] / Ln; u[39] = L![1] / Ln; u[40] = L![2] / Ln; } u[41] = Ln; u[42] = MIC_RADIUS; ui[43] = drawListener ? 1 : 0; this.device.queue.writeBuffer(this.uniform, 0, u); let bg = this.bindGroups.get(wBuffer); if (!bg) { bg = this.device.createBindGroup({ layout: this.bgLayout, entries: [ { binding: 0, resource: { buffer: this.uniform } }, { binding: 1, resource: { buffer: wBuffer } }, { binding: 2, resource: { buffer: this.w0 } }, { binding: 3, resource: { buffer: this.nw } }, { binding: 4, resource: { buffer: this.hn } }, { binding: 5, resource: { buffer: this.cm } }, ], }); this.bindGroups.set(wBuffer, bg); } const view = this.ctx.getCurrentTexture().createView(); const clear = { r: 0.11, g: 0.12, b: 0.14, a: 1 }; if (this.mode === "2d") { const pass = encoder.beginRenderPass({ colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }] }); pass.setPipeline(this.pipe2d); pass.setBindGroup(0, bg); pass.draw(3); pass.end(); } else { const pass = encoder.beginRenderPass({ colorAttachments: [{ view, clearValue: clear, loadOp: "clear", storeOp: "store" }], depthStencilAttachment: { view: this.depth!.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" }, }); pass.setPipeline(this.pipe3d); pass.setBindGroup(0, bg); pass.setIndexBuffer(this.index, "uint32"); pass.drawIndexed(this.indexCount); if (this.mount) { // The post at the mount; the ring on the surface is painted by fs3d. pass.setPipeline(this.pipe3dMount); pass.setBindGroup(0, bg); pass.draw(MOUNT_VERTS); } if (drawListener) { pass.setPipeline(this.pipe3dListener); pass.setBindGroup(0, bg); pass.draw(MIC_VERTS); } pass.end(); } } }