// Live view of the cymbal: a lit 3D height field rendered with WebGPU from the mesh and the // simulation's state buffer (w read straight from it, no readback), or a 2D colour map on a // canvas when WebGPU is unavailable. Drag to orbit, wheel to zoom. /** What the renderer needs of a mesh; both a live Setup's mesh and a modes file satisfy it. */ export interface PlateMesh { n: number; e: number; xy: Float32Array | Float64Array; tri: Int32Array | Uint32Array } export type NodalField = Float32Array | Float64Array; type Mat4 = Float32Array; function perspective(fovy: number, aspect: number, near: number, far: number): Mat4 { const f = 1 / Math.tan(fovy / 2), m = new Float32Array(16); m[0] = f / aspect; m[5] = f; m[10] = far / (near - far); m[11] = -1; m[14] = (near * far) / (near - far); return m; } function lookAt(eye: number[], target: number[], up: number[]): Mat4 { const sub = (a: number[], b: number[]) => a.map((x, i) => x - b[i]); const norm = (a: number[]) => { const l = Math.hypot(...a); return a.map((x) => x / l); }; 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]]; const z = norm(sub(eye, target)), x = norm(cross(up, z)), y = cross(z, x); const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; 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]); } function mul(a: Mat4, b: Mat4): Mat4 { const m = new Float32Array(16); 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; } return m; } const SHADER = /* wgsl */ ` struct U { mvp: mat4x4, exag: f32, wScale: f32, zMid: f32, pad: f32 } @group(0) @binding(0) var u: U; @group(0) @binding(1) var pos: array; @group(0) @binding(2) var state: array; struct VSOut { @builtin(position) clip: vec4, @location(0) world: vec3, @location(1) color: vec3 } @vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut { let w = state[5u * vi + 2u]; let p = vec3(pos[3u * vi], pos[3u * vi + 1u], pos[3u * vi + 2u] + u.exag * w - u.zMid); let t = clamp(w / u.wScale, -1.0, 1.0); let bronze = vec3(0.80, 0.62, 0.32); let c = mix(bronze, select(vec3(0.15, 0.35, 1.0), vec3(1.0, 0.2, 0.15), t > 0.0), abs(t)); var o: VSOut; o.clip = u.mvp * vec4(p, 1.0); o.world = p; o.color = c; return o; } @fragment fn fs(in: VSOut) -> @location(0) vec4 { let n = normalize(cross(dpdx(in.world), dpdy(in.world))); let l = normalize(vec3(0.4, 0.3, 1.0)); let shade = 0.3 + 0.7 * abs(dot(n, l)); return vec4(in.color * shade, 1.0); }`; export class PlateView { private readonly canvas: HTMLCanvasElement; private mesh: PlateMesh | null = null; private z0: NodalField | null = null; private hRef = 1e-3; private w: Float32Array | null = null; // camera private az = 0.6; private el = 0.9; private dist = 0.7; exaggeration = 20; // WebGPU private device: GPUDevice | null = null; private ctx: GPUCanvasContext | null = null; private format: GPUTextureFormat = "bgra8unorm"; private pipeline: GPURenderPipeline | null = null; private uniform: GPUBuffer | null = null; private posBuf: GPUBuffer | null = null; private stateBuf: GPUBuffer | null = null; private ownState = false; private indexBuf: GPUBuffer | null = null; private depth: GPUTexture | null = null; private bindGroup: GPUBindGroup | null = null; constructor(canvas: HTMLCanvasElement, device: GPUDevice | null) { this.canvas = canvas; this.device = device; if (device) { this.ctx = canvas.getContext("webgpu"); if (this.ctx) { this.format = navigator.gpu.getPreferredCanvasFormat(); this.ctx.configure({ device, format: this.format, alphaMode: "opaque" }); const module = device.createShaderModule({ code: SHADER }); this.pipeline = device.createRenderPipeline({ layout: "auto", vertex: { module, entryPoint: "vs" }, fragment: { module, entryPoint: "fs", targets: [{ format: this.format }] }, primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil: { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" }, }); this.uniform = device.createBuffer({ size: 80, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); } else this.device = null; } let dragging = false, lx = 0, ly = 0; canvas.addEventListener("pointerdown", (e) => { dragging = true; lx = e.clientX; ly = e.clientY; canvas.setPointerCapture(e.pointerId); }); canvas.addEventListener("pointermove", (e) => { if (!dragging) return; 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(); }); canvas.addEventListener("pointerup", () => { dragging = false; }); canvas.addEventListener("wheel", (e) => { e.preventDefault(); this.dist *= Math.exp(e.deltaY * 0.001); this.render(); }, { passive: false }); } get usesWebGPU() { return this.device !== null; } resetView() { this.az = 0.6; this.el = 0.9; this.dist = 0.7; this.render(); } /** Switch between reading w from the live simulation buffer and uploading frames from the CPU. */ useOwnState(): void { const d = this.device; if (!d || !this.mesh || this.ownState) return; this.stateBuf = d.createBuffer({ size: 5 * this.mesh.n * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); this.ownState = true; this.bindGroup = d.createBindGroup({ layout: this.pipeline!.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: this.uniform! } }, { binding: 1, resource: { buffer: this.posBuf! } }, { binding: 2, resource: { buffer: this.stateBuf } } ] }); } /** Install a mesh; ``stateBuffer`` is the simulator's state buffer (5 floats per node) or null to upload w from the CPU. */ setMesh(mesh: PlateMesh, z0: NodalField, hRef: number, stateBuffer: GPUBuffer | null) { this.mesh = mesh; this.z0 = z0; this.hRef = hRef; this.w = new Float32Array(mesh.n); const d = this.device; if (d) { this.posBuf?.destroy(); this.indexBuf?.destroy(); if (this.ownState) this.stateBuf?.destroy(); const pos = new Float32Array(3 * mesh.n); 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]; } this.posBuf = d.createBuffer({ size: pos.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); d.queue.writeBuffer(this.posBuf, 0, pos); this.indexBuf = d.createBuffer({ size: mesh.tri.byteLength, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST }); d.queue.writeBuffer(this.indexBuf, 0, new Uint32Array(mesh.tri)); if (stateBuffer) { this.stateBuf = stateBuffer; this.ownState = false; } else { this.stateBuf = d.createBuffer({ size: 5 * mesh.n * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); this.ownState = true; } this.bindGroup = d.createBindGroup({ layout: this.pipeline!.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: this.uniform! } }, { binding: 1, resource: { buffer: this.posBuf } }, { binding: 2, resource: { buffer: this.stateBuf } } ] }); } this.render(); } /** Fixed reference amplitude for the colour map [m]; 0 restores the thickness-based default. */ colorScale = 0; private uploadBuf: Float32Array | null = null; /** Update w from the CPU (used with the CPU backend, for movie frames, or before a run). */ setDisplacement(w: Float32Array) { this.w = w; if (this.device && this.ownState && this.stateBuf) { if (!this.uploadBuf || this.uploadBuf.length !== 5 * w.length) this.uploadBuf = new Float32Array(new ArrayBuffer(5 * w.length * 4)); const st = this.uploadBuf; for (let n = 0; n < w.length; n++) st[5 * n + 2] = w[n]; this.device.queue.writeBuffer(this.stateBuf, 0, st); } this.render(); } render() { const cv = this.canvas, dpr = window.devicePixelRatio || 1; const W = Math.max(1, Math.round(cv.clientWidth * dpr)), H = Math.max(1, Math.round(cv.clientHeight * dpr)); if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; this.depth?.destroy(); this.depth = null; } if (!this.mesh) return; if (this.device && this.ctx && this.bindGroup) this.renderGpu(W, H); else this.render2d(); } private renderGpu(W: number, H: number) { const d = this.device!, mesh = this.mesh!; if (!this.depth) this.depth = d.createTexture({ size: [W, H], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT }); let zMin = Infinity, zMax = -Infinity; for (const z of this.z0!) { zMin = Math.min(zMin, z); zMax = Math.max(zMax, z); } const zMid = 0.5 * (zMin + zMax), R = this.dist; 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)]; const mvp = mul(perspective(0.6, W / H, 0.02, 20), lookAt(eye, [0, 0, 0], [0, 0, 1])); 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; d.queue.writeBuffer(this.uniform!, 0, u); const enc = d.createCommandEncoder(); const pass = enc.beginRenderPass({ colorAttachments: [{ view: this.ctx!.getCurrentTexture().createView(), clearValue: { r: 0.08, g: 0.086, b: 0.1, a: 1 }, loadOp: "clear", storeOp: "store" }], depthStencilAttachment: { view: this.depth.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" }, }); pass.setPipeline(this.pipeline!); pass.setBindGroup(0, this.bindGroup!); pass.setIndexBuffer(this.indexBuf!, "uint32"); pass.drawIndexed(3 * mesh.e); pass.end(); d.queue.submit([enc.finish()]); } private render2d() { const cv = this.canvas, ctx = cv.getContext("2d")!, mesh = this.mesh!, w = this.w!; const dpr = window.devicePixelRatio || 1; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const cw = cv.clientWidth, ch = cv.clientHeight; ctx.fillStyle = "#14161a"; ctx.fillRect(0, 0, cw, ch); 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])); const s = (0.46 * Math.min(cw, ch)) / R, cx = cw / 2, cy = ch / 2, scale = this.colorScale > 0 ? this.colorScale : 3 * this.hRef; for (let e = 0; e < mesh.e; e++) { const a = mesh.tri[3 * e], b = mesh.tri[3 * e + 1], c = mesh.tri[3 * e + 2]; const t = Math.max(-1, Math.min(1, (w[a] + w[b] + w[c]) / (3 * scale))); const base = [204, 158, 82], tint = t > 0 ? [255, 51, 38] : [38, 89, 255], k = Math.abs(t); ctx.fillStyle = `rgb(${base.map((v, i) => Math.round(v + k * (tint[i] - v))).join(",")})`; ctx.beginPath(); ctx.moveTo(cx + s * mesh.xy[2 * a], cy - s * mesh.xy[2 * a + 1]); 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]); ctx.closePath(); ctx.fill(); } } }