1// Live view of the cymbal: a lit 3D height field rendered with WebGPU from the mesh and the
2// simulation's state buffer (w read straight from it, no readback), or a 2D colour map on a
3// canvas when WebGPU is unavailable. Drag to orbit, wheel to zoom.
5/** What the renderer needs of a mesh; both a live Setup's mesh and a modes file satisfy it. */
6export interface PlateMesh { n: number; e: number; xy: Float32Array | Float64Array; tri: Int32Array | Uint32Array }
7export type NodalField = Float32Array | Float64Array;
9type Mat4 = Float32Array;
10function perspective(fovy: number, aspect: number, near: number, far: number): Mat4 {
11 const f = 1 / Math.tan(fovy / 2), m = new Float32Array(16);
12 m[0] = f / aspect; m[5] = f; m[10] = far / (near - far); m[11] = -1; m[14] = (near * far) / (near - far);
13 return m;
14}
15function lookAt(eye: number[], target: number[], up: number[]): Mat4 {
16 const sub = (a: number[], b: number[]) => a.map((x, i) => x - b[i]);
17 const norm = (a: number[]) => { const l = Math.hypot(...a); return a.map((x) => x / l); };
18 const cross = (a: number[], b: number[]) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
19 const z = norm(sub(eye, target)), x = norm(cross(up, z)), y = cross(z, x);
20 const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
21 return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1]);
22}
23function mul(a: Mat4, b: Mat4): Mat4 {
24 const m = new Float32Array(16);
25 for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + j] * b[i * 4 + k]; m[i * 4 + j] = s; }
26 return m;
27}
29const SHADER = /* wgsl */ `
30struct U { mvp: mat4x4<f32>, exag: f32, wScale: f32, zMid: f32, pad: f32 }
31@group(0) @binding(0) var<uniform> u: U;
32@group(0) @binding(1) var<storage, read> pos: array<f32>;
33@group(0) @binding(2) var<storage, read> state: array<f32>;
34struct VSOut { @builtin(position) clip: vec4<f32>, @location(0) world: vec3<f32>, @location(1) color: vec3<f32> }
35@vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
36 let w = state[5u * vi + 2u];
37 let p = vec3<f32>(pos[3u * vi], pos[3u * vi + 1u], pos[3u * vi + 2u] + u.exag * w - u.zMid);
38 let t = clamp(w / u.wScale, -1.0, 1.0);
39 let bronze = vec3<f32>(0.80, 0.62, 0.32);
40 let c = mix(bronze, select(vec3<f32>(0.15, 0.35, 1.0), vec3<f32>(1.0, 0.2, 0.15), t > 0.0), abs(t));
41 var o: VSOut; o.clip = u.mvp * vec4<f32>(p, 1.0); o.world = p; o.color = c; return o;
42}
43@fragment fn fs(in: VSOut) -> @location(0) vec4<f32> {
44 let n = normalize(cross(dpdx(in.world), dpdy(in.world)));
45 let l = normalize(vec3<f32>(0.4, 0.3, 1.0));
46 let shade = 0.3 + 0.7 * abs(dot(n, l));
47 return vec4<f32>(in.color * shade, 1.0);
48}`;
50export class PlateView {
51 private readonly canvas: HTMLCanvasElement;
52 private mesh: PlateMesh | null = null; private z0: NodalField | null = null; private hRef = 1e-3;
53 private w: Float32Array | null = null;
54 // camera
55 private az = 0.6; private el = 0.9; private dist = 0.7;
56 exaggeration = 20;
57 // WebGPU
58 private device: GPUDevice | null = null; private ctx: GPUCanvasContext | null = null; private format: GPUTextureFormat = "bgra8unorm";
59 private pipeline: GPURenderPipeline | null = null; private uniform: GPUBuffer | null = null; private posBuf: GPUBuffer | null = null;
60 private stateBuf: GPUBuffer | null = null; private ownState = false; private indexBuf: GPUBuffer | null = null;
61 private depth: GPUTexture | null = null; private bindGroup: GPUBindGroup | null = null;
63 constructor(canvas: HTMLCanvasElement, device: GPUDevice | null) {
64 this.canvas = canvas; this.device = device;
65 if (device) {
66 this.ctx = canvas.getContext("webgpu");
67 if (this.ctx) {
68 this.format = navigator.gpu.getPreferredCanvasFormat();
69 this.ctx.configure({ device, format: this.format, alphaMode: "opaque" });
70 const module = device.createShaderModule({ code: SHADER });
71 this.pipeline = device.createRenderPipeline({
72 layout: "auto", vertex: { module, entryPoint: "vs" }, fragment: { module, entryPoint: "fs", targets: [{ format: this.format }] },
73 primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil: { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" },
74 });
75 this.uniform = device.createBuffer({ size: 80, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
76 } else this.device = null;
77 }
78 let dragging = false, lx = 0, ly = 0;
79 canvas.addEventListener("pointerdown", (e) => { dragging = true; lx = e.clientX; ly = e.clientY; canvas.setPointerCapture(e.pointerId); });
80 canvas.addEventListener("pointermove", (e) => {
81 if (!dragging) return;
82 this.az -= (e.clientX - lx) * 0.01; this.el = Math.min(1.5, Math.max(0.05, this.el + (e.clientY - ly) * 0.01)); lx = e.clientX; ly = e.clientY; this.render();
83 });
84 canvas.addEventListener("pointerup", () => { dragging = false; });
85 canvas.addEventListener("wheel", (e) => { e.preventDefault(); this.dist *= Math.exp(e.deltaY * 0.001); this.render(); }, { passive: false });
86 }
88 get usesWebGPU() { return this.device !== null; }
90 resetView() { this.az = 0.6; this.el = 0.9; this.dist = 0.7; this.render(); }
92 /** Switch between reading w from the live simulation buffer and uploading frames from the CPU. */
93 useOwnState(): void {
94 const d = this.device;
95 if (!d || !this.mesh || this.ownState) return;
96 this.stateBuf = d.createBuffer({ size: 5 * this.mesh.n * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
97 this.ownState = true;
98 this.bindGroup = d.createBindGroup({ layout: this.pipeline!.getBindGroupLayout(0), entries: [
99 { binding: 0, resource: { buffer: this.uniform! } }, { binding: 1, resource: { buffer: this.posBuf! } }, { binding: 2, resource: { buffer: this.stateBuf } } ] });
100 }
102 /** Install a mesh; ``stateBuffer`` is the simulator's state buffer (5 floats per node) or null to upload w from the CPU. */
103 setMesh(mesh: PlateMesh, z0: NodalField, hRef: number, stateBuffer: GPUBuffer | null) {
104 this.mesh = mesh; this.z0 = z0; this.hRef = hRef; this.w = new Float32Array(mesh.n);
105 const d = this.device;
106 if (d) {
107 this.posBuf?.destroy(); this.indexBuf?.destroy(); if (this.ownState) this.stateBuf?.destroy();
108 const pos = new Float32Array(3 * mesh.n);
109 for (let n = 0; n < mesh.n; n++) { pos[3 * n] = mesh.xy[2 * n]; pos[3 * n + 1] = mesh.xy[2 * n + 1]; pos[3 * n + 2] = z0[n]; }
110 this.posBuf = d.createBuffer({ size: pos.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
111 d.queue.writeBuffer(this.posBuf, 0, pos);
112 this.indexBuf = d.createBuffer({ size: mesh.tri.byteLength, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
113 d.queue.writeBuffer(this.indexBuf, 0, new Uint32Array(mesh.tri));
114 if (stateBuffer) { this.stateBuf = stateBuffer; this.ownState = false; }
115 else { this.stateBuf = d.createBuffer({ size: 5 * mesh.n * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); this.ownState = true; }
116 this.bindGroup = d.createBindGroup({ layout: this.pipeline!.getBindGroupLayout(0), entries: [
117 { binding: 0, resource: { buffer: this.uniform! } }, { binding: 1, resource: { buffer: this.posBuf } }, { binding: 2, resource: { buffer: this.stateBuf } } ] });
118 }
119 this.render();
120 }
122 /** Fixed reference amplitude for the colour map [m]; 0 restores the thickness-based default. */
123 colorScale = 0;
125 private uploadBuf: Float32Array<ArrayBuffer> | null = null;
127 /** Update w from the CPU (used with the CPU backend, for movie frames, or before a run). */
128 setDisplacement(w: Float32Array) {
129 this.w = w;
130 if (this.device && this.ownState && this.stateBuf) {
131 if (!this.uploadBuf || this.uploadBuf.length !== 5 * w.length) this.uploadBuf = new Float32Array(new ArrayBuffer(5 * w.length * 4));
132 const st = this.uploadBuf;
133 for (let n = 0; n < w.length; n++) st[5 * n + 2] = w[n];
134 this.device.queue.writeBuffer(this.stateBuf, 0, st);
135 }
136 this.render();
137 }
139 render() {
140 const cv = this.canvas, dpr = window.devicePixelRatio || 1;
141 const W = Math.max(1, Math.round(cv.clientWidth * dpr)), H = Math.max(1, Math.round(cv.clientHeight * dpr));
142 if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; this.depth?.destroy(); this.depth = null; }
143 if (!this.mesh) return;
144 if (this.device && this.ctx && this.bindGroup) this.renderGpu(W, H); else this.render2d();
145 }
147 private renderGpu(W: number, H: number) {
148 const d = this.device!, mesh = this.mesh!;
149 if (!this.depth) this.depth = d.createTexture({ size: [W, H], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
150 let zMin = Infinity, zMax = -Infinity;
151 for (const z of this.z0!) { zMin = Math.min(zMin, z); zMax = Math.max(zMax, z); }
152 const zMid = 0.5 * (zMin + zMax), R = this.dist;
153 const eye = [R * Math.cos(this.el) * Math.cos(this.az), R * Math.cos(this.el) * Math.sin(this.az), R * Math.sin(this.el)];
154 const mvp = mul(perspective(0.6, W / H, 0.02, 20), lookAt(eye, [0, 0, 0], [0, 0, 1]));
155 const u = new Float32Array(20); u.set(mvp); u[16] = this.exaggeration; u[17] = this.colorScale > 0 ? this.colorScale : 3 * this.hRef; u[18] = zMid;
156 d.queue.writeBuffer(this.uniform!, 0, u);
157 const enc = d.createCommandEncoder();
158 const pass = enc.beginRenderPass({
159 colorAttachments: [{ view: this.ctx!.getCurrentTexture().createView(), clearValue: { r: 0.08, g: 0.086, b: 0.1, a: 1 }, loadOp: "clear", storeOp: "store" }],
160 depthStencilAttachment: { view: this.depth.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
161 });
162 pass.setPipeline(this.pipeline!); pass.setBindGroup(0, this.bindGroup!); pass.setIndexBuffer(this.indexBuf!, "uint32");
163 pass.drawIndexed(3 * mesh.e); pass.end();
164 d.queue.submit([enc.finish()]);
165 }
167 private render2d() {
168 const cv = this.canvas, ctx = cv.getContext("2d")!, mesh = this.mesh!, w = this.w!;
169 const dpr = window.devicePixelRatio || 1;
170 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
171 const cw = cv.clientWidth, ch = cv.clientHeight;
172 ctx.fillStyle = "#14161a"; ctx.fillRect(0, 0, cw, ch);
173 let R = 0; for (let n = 0; n < mesh.n; n++) R = Math.max(R, Math.hypot(mesh.xy[2 * n], mesh.xy[2 * n + 1]));
174 const s = (0.46 * Math.min(cw, ch)) / R, cx = cw / 2, cy = ch / 2, scale = this.colorScale > 0 ? this.colorScale : 3 * this.hRef;
175 for (let e = 0; e < mesh.e; e++) {
176 const a = mesh.tri[3 * e], b = mesh.tri[3 * e + 1], c = mesh.tri[3 * e + 2];
177 const t = Math.max(-1, Math.min(1, (w[a] + w[b] + w[c]) / (3 * scale)));
178 const base = [204, 158, 82], tint = t > 0 ? [255, 51, 38] : [38, 89, 255], k = Math.abs(t);
179 ctx.fillStyle = `rgb(${base.map((v, i) => Math.round(v + k * (tint[i] - v))).join(",")})`;
180 ctx.beginPath(); ctx.moveTo(cx + s * mesh.xy[2 * a], cy - s * mesh.xy[2 * a + 1]);
181 ctx.lineTo(cx + s * mesh.xy[2 * b], cy - s * mesh.xy[2 * b + 1]); ctx.lineTo(cx + s * mesh.xy[2 * c], cy - s * mesh.xy[2 * c + 1]);
182 ctx.closePath(); ctx.fill();
183 }
184 }
185}