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