// Drawing of the plate: a 2D color map, or (WebGPU only) a lit 3D height // field of the displayed field plus, optionally, the stress-free profile, // colored by the height or by the thickness. Both views paint the strike // point, the stand pad and the listener as markers. Without WebGPU the map is // drawn with the 2D canvas API and the 3D mode falls back to the map. // // On the GPU the plate is one indexed mesh: a static vertex buffer of node // positions (x, y), a per-frame vertex buffer of height, slope and color per // node, and an index buffer of two triangles per cell of the plate built once // per setPlate. The 2D map is the same mesh under an orthographic camera with // the heights zeroed. Markers are a small triangle mesh rebuilt on the CPU // whenever the markers, the view or the field change. import { type Plate, insideCell, insideNode, nodeX, nodeY, thicknessRange } from "../params.ts"; import { type Mat4, type Vec3, add, cross, dot, lookAt, multiply, ortho, perspective, scale, unit } from "./mat4.ts"; import shaderSource from "./shaders.wgsl?raw"; export type ViewMode = "2d" | "3d"; export type ColorMode = "height" | "thickness"; export interface ViewOptions { mode: ViewMode; color: ColorMode; /** Add the stress-free profile w0 to the displayed field. */ showProfile: boolean; /** Vertical scale factor of the 3D height field (1 = true scale). */ exaggeration: number; /** Color range in meters (symmetric +-range for the height); 0 = automatic from the field. */ range: number; } export interface Markers { /** Plate coordinates [m]. */ strike: { x: number; y: number; radius: number } | null; /** Pad annulus of the stand [m]. */ stand: { x: number; y: number; innerRadius: number; outerRadius: number } | null; /** Relative to the plate center [m], z along the normal. */ listener: { x: number; y: number; z: number } | null; } type RGB = [number, number, number]; const BG: RGB = [0.11, 0.12, 0.14]; const COLOR_STRIKE: RGB = [0.95, 0.78, 0.12]; const COLOR_STAND: RGB = [0.1, 0.1, 0.1]; const COLOR_POST: RGB = [0.55, 0.55, 0.58]; const COLOR_LINE: RGB = [0.55, 0.55, 0.55]; const COLOR_MIC_HEAD: RGB = [0.32, 0.32, 0.35]; const COLOR_MIC_BODY: RGB = [0.72, 0.73, 0.76]; const LIGHT: Vec3 = [0.35, -0.5, 0.8]; /** Vertical field of view of the 3D camera [rad] and the framing margin. */ const FOV = 0.7; const FIT_MARGIN = 1.08; /** The drawn listener distance saturates at this many plate radii from the center (about one radius outside the edge). */ const LISTENER_REACH = 1.8; /** Fill fraction of the canvas taken by the plate in the 2D map. */ const FILL_2D = 0.82; const RING_SEGMENTS = 64; const MESH_STRIDE = 16; // bytes per node in the per-frame buffer: z, slope x, slope y, packed color const MARKER_STRIDE = 28; // bytes per marker vertex: position, normal, packed color const AUTO_RANGE_FLOOR = 1e-6; // ---------- Color maps (shared by the GPU path and the 2D fallback) ---------- /** Diverging blue-white-red for t in [-1, 1]. */ function diverging(t: number): RGB { const s = Math.max(-1, Math.min(1, t)); const mid: RGB = [0.95, 0.95, 0.95]; const end: RGB = s < 0 ? [0.1, 0.35, 0.85] : [0.85, 0.2, 0.12]; const a = Math.abs(s); return [mid[0] + (end[0] - mid[0]) * a, mid[1] + (end[1] - mid[1]) * a, mid[2] + (end[2] - mid[2]) * a]; } const VIRIDIS: RGB[] = [ [0.267, 0.005, 0.329], [0.283, 0.141, 0.458], [0.254, 0.265, 0.530], [0.207, 0.372, 0.553], [0.164, 0.471, 0.558], [0.128, 0.567, 0.551], [0.135, 0.659, 0.518], [0.267, 0.749, 0.441], [0.478, 0.821, 0.318], [0.741, 0.873, 0.150], [0.993, 0.906, 0.144], ]; /** Sequential viridis-like map for t in [0, 1]. */ function viridis(t: number): RGB { const s = Math.max(0, Math.min(1, t)) * (VIRIDIS.length - 1); const i = Math.min(VIRIDIS.length - 2, Math.floor(s)); const f = s - i; const a = VIRIDIS[i], b = VIRIDIS[i + 1]; return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f]; } /** RGBA8 packed as the little-endian u32 a vertex attribute of format unorm8x4 reads. */ function pack(c: RGB): number { const r = Math.round(Math.max(0, Math.min(1, c[0])) * 255); const g = Math.round(Math.max(0, Math.min(1, c[1])) * 255); const b = Math.round(Math.max(0, Math.min(1, c[2])) * 255); return (r | (g << 8) | (b << 16) | (255 << 24)) >>> 0; } function css(c: RGB): string { return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`; } // ---------- Marker geometry ---------- /** A growable triangle list in the marker vertex layout (position, flat normal, packed color). */ class MarkerMesh { f = new Float32Array(7 * 4096); u = new Uint32Array(this.f.buffer); count = 0; clear(): void { this.count = 0; } get byteLength(): number { return this.count * MARKER_STRIDE; } private ensure(nVerts: number): void { const need = (this.count + nVerts) * 7; if (need <= this.f.length) return; const f = new Float32Array(Math.max(need, this.f.length * 2)); f.set(this.f.subarray(0, this.count * 7)); this.f = f; this.u = new Uint32Array(f.buffer); } tri(a: Vec3, b: Vec3, c: Vec3, color: number): void { this.ensure(3); const n = unit(cross([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]])); for (const p of [a, b, c]) { const o = this.count * 7; this.f[o] = p[0]; this.f[o + 1] = p[1]; this.f[o + 2] = p[2]; this.f[o + 3] = n[0]; this.f[o + 4] = n[1]; this.f[o + 5] = n[2]; this.u[o + 6] = color; this.count++; } } quad(a: Vec3, b: Vec3, c: Vec3, d: Vec3, color: number): void { this.tri(a, b, c, color); this.tri(a, c, d, color); } /** An annulus of mean radius r and half-width hw about (cx, cy), lifted to zAt(x, y). */ ring(cx: number, cy: number, r: number, hw: number, zAt: (x: number, y: number) => number, color: number): void { const ri = Math.max(0, r - hw), ro = r + hw; for (let s = 0; s < RING_SEGMENTS; s++) { const a0 = (2 * Math.PI * s) / RING_SEGMENTS, a1 = (2 * Math.PI * (s + 1)) / RING_SEGMENTS; const p = (rad: number, a: number): Vec3 => { const x = cx + rad * Math.cos(a), y = cy + rad * Math.sin(a); return [x, y, zAt(x, y)]; }; this.quad(p(ri, a0), p(ro, a0), p(ro, a1), p(ri, a1), color); } } /** A closed cylinder from p0 to p1. */ cylinder(p0: Vec3, p1: Vec3, radius: number, segments: number, color: number): void { const axis = unit([p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]]); const [e1, e2] = frame(axis); const rim = (p: Vec3, a: number): Vec3 => add(p, add(scale(e1, radius * Math.cos(a)), scale(e2, radius * Math.sin(a)))); for (let s = 0; s < segments; s++) { const a0 = (2 * Math.PI * s) / segments, a1 = (2 * Math.PI * (s + 1)) / segments; this.quad(rim(p0, a0), rim(p1, a0), rim(p1, a1), rim(p0, a1), color); this.tri(p0, rim(p0, a1), rim(p0, a0), color); this.tri(p1, rim(p1, a0), rim(p1, a1), color); } } /** A box centered at c with the unit axes (ax, ay, az) and half sizes h. */ box(c: Vec3, ax: Vec3, ay: Vec3, az: Vec3, h: Vec3, color: number): void { const corner = (sx: number, sy: number, sz: number): Vec3 => add(c, add(scale(ax, sx * h[0]), add(scale(ay, sy * h[1]), scale(az, sz * h[2])))); const faces: [Vec3, Vec3, Vec3, Vec3][] = [ [corner(-1, -1, 1), corner(1, -1, 1), corner(1, 1, 1), corner(-1, 1, 1)], [corner(-1, -1, -1), corner(-1, 1, -1), corner(1, 1, -1), corner(1, -1, -1)], [corner(1, -1, -1), corner(1, 1, -1), corner(1, 1, 1), corner(1, -1, 1)], [corner(-1, -1, -1), corner(-1, -1, 1), corner(-1, 1, 1), corner(-1, 1, -1)], [corner(-1, 1, -1), corner(-1, 1, 1), corner(1, 1, 1), corner(1, 1, -1)], [corner(-1, -1, -1), corner(1, -1, -1), corner(1, -1, 1), corner(-1, -1, 1)], ]; for (const [a, b, cc, d] of faces) this.quad(a, b, cc, d, color); } } /** Two unit vectors completing a right-handed frame with the unit vector u. */ function frame(u: Vec3): [Vec3, Vec3] { const t: Vec3 = Math.abs(u[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0]; const e1 = unit(cross(t, u)); return [e1, cross(u, e1)]; } /** * The distance at which a listener is drawn, given its true distance d: it * follows d at first and saturates at `limit`, so a listener a meter away * still appears just outside the plate. */ function saturate(d: number, limit: number): number { return limit * Math.tanh(d / limit); } // ---------- Renderer ---------- export class PlateRenderer { readonly has3d: boolean; private canvas: HTMLCanvasElement; private device: GPUDevice | null; private gpuCtx: GPUCanvasContext | null = null; private ctx2d: CanvasRenderingContext2D | null = null; private format: GPUTextureFormat = "bgra8unorm"; private meshPipeline: GPURenderPipeline | null = null; private markerPipeline: GPURenderPipeline | null = null; private uniformBuf: GPUBuffer | null = null; private bindGroup: GPUBindGroup | null = null; private xyBuf: GPUBuffer | null = null; private nodeBuf: GPUBuffer | null = null; private indexBuf: GPUBuffer | null = null; private indexCount = 0; private markerBuf: GPUBuffer | null = null; private markerCapacity = 0; private depth: GPUTexture | null = null; private plate: Plate | null = null; private field: Float32Array | Float64Array | null = null; private opts: ViewOptions = { mode: "2d", color: "height", showProfile: true, exaggeration: 1, range: 0 }; private markers: Markers = { strike: null, stand: null, listener: null }; // Derived per plate private hRange = { min: 0, max: 1 }; private w0Max = 0; /** Largest in-plane distance of a plate node from the center [m], and half of the larger box side. */ private plateRadius = 0.1; private boxRadius = 0.1; private runMax = 0; // Per-frame node records: display value, height (exaggerated), and the packed vertex records. private val = new Float32Array(0); private z = new Float32Array(0); private nodeData = new ArrayBuffer(0); private zMax = 0; private markerMesh = new MarkerMesh(); private geomDirty = true; private fieldDirty = true; private markerDirty = true; private imageDirty = true; private image: HTMLCanvasElement | null = null; // Orbit camera of the 3D view private azimuth = -Math.PI / 2 + 0.3; private elevation = Math.PI / 6; private zoom = 1; private frameRequested = false; private destroyed = false; private listeners: [string, EventListener, AddEventListenerOptions | undefined][] = []; constructor(canvas: HTMLCanvasElement, device: GPUDevice | null) { this.canvas = canvas; this.device = device; this.has3d = device !== null; if (device) { this.gpuCtx = canvas.getContext("webgpu") as GPUCanvasContext | null; if (!this.gpuCtx) throw new Error("WebGPU canvas context unavailable"); this.format = navigator.gpu.getPreferredCanvasFormat(); this.gpuCtx.configure({ device, format: this.format, alphaMode: "opaque" }); this.createPipelines(device); } else { this.ctx2d = canvas.getContext("2d"); if (!this.ctx2d) throw new Error("2D canvas context unavailable"); } this.installControls(); } private createPipelines(device: GPUDevice): void { const module = device.createShaderModule({ code: shaderSource }); this.uniformBuf = device.createBuffer({ size: 96, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); const bgLayout = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }], }); this.bindGroup = device.createBindGroup({ layout: bgLayout, entries: [{ binding: 0, resource: { buffer: this.uniformBuf } }] }); const layout = device.createPipelineLayout({ bindGroupLayouts: [bgLayout] }); const depthStencil: GPUDepthStencilState = { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" }; const fragment: GPUFragmentState = { module, entryPoint: "fsMain", targets: [{ format: this.format }] }; this.meshPipeline = device.createRenderPipeline({ layout, vertex: { module, entryPoint: "vsMesh", buffers: [ { arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }] }, { arrayStride: MESH_STRIDE, attributes: [ { shaderLocation: 1, offset: 0, format: "float32" }, { shaderLocation: 2, offset: 4, format: "float32x2" }, { shaderLocation: 3, offset: 12, format: "unorm8x4" }, ], }, ], }, fragment, primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil, }); this.markerPipeline = device.createRenderPipeline({ layout, vertex: { module, entryPoint: "vsMarker", buffers: [ { arrayStride: MARKER_STRIDE, attributes: [ { shaderLocation: 0, offset: 0, format: "float32x3" }, { shaderLocation: 1, offset: 12, format: "float32x3" }, { shaderLocation: 2, offset: 24, format: "unorm8x4" }, ], }, ], }, fragment, primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil, }); } private installControls(): void { if (!this.has3d) return; let dragging = false, lastX = 0, lastY = 0; const on = (type: K, fn: (e: HTMLElementEventMap[K]) => void, opts?: AddEventListenerOptions) => { this.canvas.addEventListener(type, fn, opts); this.listeners.push([type, fn as EventListener, opts]); }; on("pointerdown", (e) => { if (this.opts.mode !== "3d") return; dragging = true; lastX = e.clientX; lastY = e.clientY; this.canvas.setPointerCapture(e.pointerId); }); on("pointerup", () => { dragging = false; }); on("pointercancel", () => { dragging = false; }); on("pointermove", (e) => { if (!dragging || this.opts.mode !== "3d") return; this.azimuth -= (e.clientX - lastX) * 0.008; this.elevation = Math.max(-1.45, Math.min(1.45, this.elevation + (e.clientY - lastY) * 0.008)); lastX = e.clientX; lastY = e.clientY; this.markerDirty = true; this.requestFrame(); }); on("wheel", (e) => { if (this.opts.mode !== "3d") return; e.preventDefault(); this.zoom = Math.max(0.05, Math.min(20, this.zoom * Math.exp(e.deltaY * 0.001))); this.requestFrame(); }, { passive: false }); on("dblclick", () => { if (this.opts.mode !== "3d") return; this.azimuth = -Math.PI / 2 + 0.3; this.elevation = Math.PI / 6; this.zoom = 1; this.requestFrame(); }); } private requestFrame(): void { if (this.frameRequested) return; this.frameRequested = true; requestAnimationFrame(() => { this.frameRequested = false; this.render(); }); } // ---------- Public API ---------- /** New geometry (grid, mask, thickness, profile); resets the automatic color range and the field (to zeros). */ setPlate(plate: Plate): void { this.plate = plate; const g = plate.grid; this.hRange = thicknessRange(plate); let w0Max = 0, rp = 0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { if (!insideNode(g, i, j)) continue; w0Max = Math.max(w0Max, Math.abs(plate.w0[j * g.nx + i])); rp = Math.max(rp, Math.hypot(nodeX(g, i), nodeY(g, j))); } } this.w0Max = w0Max; this.plateRadius = Math.max(rp, 1e-3); this.boxRadius = Math.max(plate.Lx, plate.Ly) / 2 || 1e-3; this.runMax = 0; this.field = null; this.val = new Float32Array(g.n); this.z = new Float32Array(g.n); this.nodeData = new ArrayBuffer(g.n * MESH_STRIDE); this.image = null; this.geomDirty = true; this.fieldDirty = true; this.markerDirty = true; this.imageDirty = true; this.updateRunMax(); } /** The displayed field per grid node [m]; null shows zeros (the design preview). The array is read at render time. */ setField(w: Float32Array | Float64Array | null): void { this.field = w && this.plate && w.length >= this.plate.grid.n ? w : null; this.updateRunMax(); this.fieldDirty = true; this.markerDirty = true; this.imageDirty = true; } setOptions(opts: Partial): void { this.opts = { ...this.opts, ...opts }; this.fieldDirty = true; this.markerDirty = true; this.imageDirty = true; } setMarkers(m: Markers): void { this.markers = m; this.markerDirty = true; } /** Matches the canvas to its CSS size and devicePixelRatio; recreates the depth buffer when the size changes. */ resize(): void { const dpr = window.devicePixelRatio || 1; const w = Math.max(1, Math.round(this.canvas.clientWidth * dpr)); const h = Math.max(1, Math.round(this.canvas.clientHeight * dpr)); if (this.canvas.width === w && this.canvas.height === h && (this.depth || !this.device)) return; this.canvas.width = w; this.canvas.height = h; if (this.device) { this.depth?.destroy(); this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT }); } this.markerDirty = true; } render(): void { if (this.destroyed) return; this.resize(); if (this.device) this.renderGpu(this.device); else this.renderFallback(); } destroy(): void { this.destroyed = true; for (const [type, fn, opts] of this.listeners) this.canvas.removeEventListener(type, fn, opts); this.listeners = []; this.xyBuf?.destroy(); this.nodeBuf?.destroy(); this.indexBuf?.destroy(); this.markerBuf?.destroy(); this.uniformBuf?.destroy(); this.depth?.destroy(); this.xyBuf = this.nodeBuf = this.indexBuf = this.markerBuf = this.uniformBuf = null; this.depth = null; this.gpuCtx?.unconfigure(); this.gpuCtx = null; this.device = null; this.plate = null; } // ---------- Field and color ---------- private updateRunMax(): void { const pl = this.plate, w = this.field; if (!pl || !w) return; const g = pl.grid; let m = 0; if (g.mask) { for (let k = 0; k < g.n; k++) if (g.mask[k]) m = Math.max(m, Math.abs(w[k])); } else { for (let k = 0; k < g.n; k++) m = Math.max(m, Math.abs(w[k])); } if (isFinite(m)) this.runMax = Math.max(this.runMax, m); } /** The color range in use for the height coloring [m]. */ private colorRange(): number { if (this.opts.range > 0) return this.opts.range; return Math.max(this.runMax + (this.opts.showProfile ? this.w0Max : 0), AUTO_RANGE_FLOOR); } private colorOf(val: number, h: number, range: number): RGB { if (this.opts.color === "thickness") return viridis((h - this.hRange.min) / Math.max(this.hRange.max - this.hRange.min, 1e-9)); return diverging(val / range); } /** Recomputes the displayed value, the height and the vertex records of every node. */ private rebuildField(): void { const pl = this.plate!; const g = pl.grid; const w = this.field; const exag = this.opts.exaggeration; const range = this.colorRange(); const val = this.val, z = this.z; let zMax = 0; for (let k = 0; k < g.n; k++) { const v = (w ? w[k] : 0) + (this.opts.showProfile ? pl.w0[k] : 0); val[k] = v; z[k] = v * exag; if (!g.mask || g.mask[k]) zMax = Math.max(zMax, Math.abs(z[k])); } this.zMax = zMax; const f = new Float32Array(this.nodeData), u = new Uint32Array(this.nodeData); for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const k = j * g.nx + i; const o = k * 4; if (!insideNode(g, i, j)) { f[o] = 0; f[o + 1] = 0; f[o + 2] = 0; u[o + 3] = 0; continue; } f[o] = z[k]; f[o + 1] = this.slope(i, j, 1, 0, g.hx); f[o + 2] = this.slope(i, j, 0, 1, g.hy); u[o + 3] = pack(this.colorOf(val[k], pl.h[k], range)); } } this.fieldDirty = false; } /** Slope of the displayed height along one axis, one-sided where a neighbor lies outside the plate. */ private slope(i: number, j: number, di: number, dj: number, h: number): number { const g = this.plate!.grid, z = this.z; const hasP = insideNode(g, i + di, j + dj), hasM = insideNode(g, i - di, j - dj); const k = j * g.nx + i, kp = (j + dj) * g.nx + i + di, km = (j - dj) * g.nx + i - di; if (hasP && hasM) return (z[kp] - z[km]) / (2 * h); if (hasP) return (z[kp] - z[k]) / h; if (hasM) return (z[k] - z[km]) / h; return 0; } /** * The displayed height at (x, y), bilinear over the plate nodes of its cell * (missing corners dropped), or `fallback` when no corner is in the plate. */ private surfaceZ(x: number, y: number, fallback: number): number { const g = this.plate!.grid; const fi = x / g.hx + (g.nx - 1) / 2, fj = y / g.hy + (g.ny - 1) / 2; const i0 = Math.max(0, Math.min(g.nx - 2, Math.floor(fi))), j0 = Math.max(0, Math.min(g.ny - 2, Math.floor(fj))); const a = Math.max(0, Math.min(1, fi - i0)), b = Math.max(0, Math.min(1, fj - j0)); let s = 0, wsum = 0; const acc = (i: number, j: number, wt: number) => { if (wt <= 0 || !insideNode(g, i, j)) return; s += wt * this.z[j * g.nx + i]; wsum += wt; }; acc(i0, j0, (1 - a) * (1 - b)); acc(i0 + 1, j0, a * (1 - b)); acc(i0, j0 + 1, (1 - a) * b); acc(i0 + 1, j0 + 1, a * b); return wsum > 0 ? s / wsum : fallback; } /** Mean displayed height of the plate nodes within `r` of (cx, cy), or 0 when there are none. */ private meanZWithin(cx: number, cy: number, r: number): number { const g = this.plate!.grid; let s = 0, c = 0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { if (!insideNode(g, i, j) || Math.hypot(nodeX(g, i) - cx, nodeY(g, j) - cy) > r) continue; s += this.z[j * g.nx + i]; c++; } } return c > 0 ? s / c : 0; } // ---------- Listener placement ---------- /** Where the listener is drawn in the 2D map: its in-plane direction, at a saturated distance from the center. */ private listener2d(): { x: number; y: number; dir: [number, number] } | null { const L = this.markers.listener; if (!L) return null; const rho = Math.hypot(L.x, L.y); const dir: [number, number] = rho > 1e-9 ? [L.x / rho, L.y / rho] : [0, -1]; const d = saturate(rho, 1.15 * this.boxRadius); return { x: dir[0] * d, y: dir[1] * d, dir }; } /** Where the listener is drawn in 3D: its true direction, at a distance saturating about one plate radius outside the edge. */ private listener3d(): { p: Vec3; u: Vec3 } | null { const L = this.markers.listener; if (!L) return null; const d = Math.hypot(L.x, L.y, L.z); if (d < 1e-9) return null; const u: Vec3 = [L.x / d, L.y / d, L.z / d]; return { p: scale(u, saturate(d, LISTENER_REACH * this.plateRadius)), u }; } // ---------- Markers ---------- private rebuildMarkers(is3d: boolean): void { const mesh = this.markerMesh; mesh.clear(); const R = this.boxRadius; const lw = 0.012 * R; // width of the ring lines const lift = 0.004 * R; // markers float this far above the surface const { strike, stand, listener } = this.markers; const zStand = stand ? this.meanZWithin(stand.x, stand.y, stand.outerRadius) : 0; const zAt = is3d ? (x: number, y: number) => this.surfaceZ(x, y, zStand) + lift : () => lift; if (stand) { const dark = pack(COLOR_STAND); if (stand.innerRadius > 0) mesh.ring(stand.x, stand.y, stand.innerRadius, lw / 2, zAt, dark); mesh.ring(stand.x, stand.y, stand.outerRadius, lw / 2, zAt, dark); if (is3d) { const ro = stand.outerRadius; const rod = stand.innerRadius > 0 ? 0.6 * stand.innerRadius : 0.3 * ro; mesh.cylinder([stand.x, stand.y, zStand - 2 * ro], [stand.x, stand.y, zStand + 0.5 * ro], rod, 20, pack(COLOR_POST)); } } if (strike) mesh.ring(strike.x, strike.y, Math.max(strike.radius, 2 * lw), lw / 2, zAt, pack(COLOR_STRIKE)); if (listener) { if (is3d) { const l = this.listener3d(); if (l) { const [e1, e2] = frame(l.u); const head = 0.035 * R, body = 0.025 * R, len = 0.06 * R; const zc = this.surfaceZ(0, 0, zStand); const start: Vec3 = [0, 0, zc + lift]; const end = add(l.p, scale(l.u, -head)); if (dot(end, l.u) > dot(start, l.u)) mesh.cylinder(start, end, 0.003 * R, 6, pack(COLOR_LINE)); mesh.box(l.p, e1, e2, l.u, [head, head, head], pack(COLOR_MIC_HEAD)); mesh.box(add(l.p, scale(l.u, head + len)), e1, e2, l.u, [body, body, len], pack(COLOR_MIC_BODY)); } } else { const l = this.listener2d(); if (l) { const rm = 0.045 * R; const z1 = lift, z2 = 2 * lift; // Line from the plate center, then the symbol: a round head and a stem pointing away from the center. const [dx, dy] = l.dir; const nx = -dy, ny = dx, hw = 0.3 * lw; mesh.quad([nx * hw, ny * hw, z1], [-nx * hw, -ny * hw, z1], [l.x - nx * hw, l.y - ny * hw, z1], [l.x + nx * hw, l.y + ny * hw, z1], pack(COLOR_LINE)); mesh.ring(l.x, l.y, rm / 2, rm / 2, () => z2, pack(COLOR_MIC_BODY)); const sw = 0.28 * rm, s0 = 0.8 * rm, s1 = 2.2 * rm; mesh.quad( [l.x + dx * s0 + nx * sw, l.y + dy * s0 + ny * sw, z2], [l.x + dx * s0 - nx * sw, l.y + dy * s0 - ny * sw, z2], [l.x + dx * s1 - nx * sw, l.y + dy * s1 - ny * sw, z2], [l.x + dx * s1 + nx * sw, l.y + dy * s1 + ny * sw, z2], pack(COLOR_MIC_HEAD), ); } } } this.markerDirty = false; } // ---------- Cameras ---------- /** Half extents of the orthographic 2D view in plate coordinates. */ private extent2d(aspect: number): { W: number; H: number } { const pl = this.plate!; let need = Math.max(pl.Lx / 2, (pl.Ly / 2) * aspect); const l = this.listener2d(); if (l) { const m = 0.12 * this.boxRadius; // extent of the microphone symbol need = Math.max(need, Math.abs(l.x) + m, (Math.abs(l.y) + m) * aspect); } const W = need / FILL_2D; return { W, H: W / aspect }; } private camera2d(aspect: number): Mat4 { const { W, H } = this.extent2d(aspect); return multiply(ortho(-W, W, -H, H, 0.5, 1.5), lookAt([0, 0, 1], [0, 0, 0], [0, 1, 0])); } /** * The orbit camera about the plate center. The distance is fitted, unless * the user has zoomed, so that the plate (an in-plane circle of the plate * radius at the displayed heights) and the microphone fill the view; the * frustum is then shifted so that the fitted scene, not the plate center, * sits in the middle of the canvas, which matters when the microphone is * high above the plate. */ private camera3d(aspect: number): { mvp: Mat4; eye: Vec3 } { const el = this.elevation, az = this.azimuth; const dir: Vec3 = [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)]; const fwd = scale(dir, -1); const right = unit(cross(fwd, [0, 0, 1])); const up = cross(right, fwd); const horiz = unit(Math.hypot(fwd[0], fwd[1]) > 1e-6 ? [fwd[0], fwd[1], 0] : [1, 0, 0]); const Rp = this.plateRadius, zm = Math.max(this.zMax, 1e-4); const pts: [Vec3, number][] = []; for (const s of [-1, 1]) for (const sz of [-1, 1]) { pts.push([add(scale(right, s * Rp), [0, 0, sz * zm]), 0]); pts.push([add(scale(horiz, s * Rp), [0, 0, sz * zm]), 0]); } const l = this.listener3d(); if (l) pts.push([l.p, 0.12 * this.boxRadius]); const tanH = Math.tan(FOV / 2), tanW = tanH * aspect; // Screen bounds of the points, as tangents of the angles from the view axis, seen from distance D. const bounds = (D: number) => { let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity; for (const [p, r] of pts) { const depth = D - dot(p, dir); if (depth <= 1e-6) return null; const px = dot(p, right) / depth, py = dot(p, up) / depth, pr = r / depth; x0 = Math.min(x0, px - pr); x1 = Math.max(x1, px + pr); y0 = Math.min(y0, py - pr); y1 = Math.max(y1, py + pr); } return { x0, x1, y0, y1 }; }; const fits = (D: number) => { const b = bounds(D); return !!b && b.x1 - b.x0 <= (2 * tanW) / FIT_MARGIN && b.y1 - b.y0 <= (2 * tanH) / FIT_MARGIN; }; // The symmetric fit about the center always holds; bisect down from it toward the depth of the scene. let hi = 0, lo = 0, reach = 0; for (const [p, r] of pts) { const v = (Math.abs(dot(p, up)) + r) / tanH, h = (Math.abs(dot(p, right)) + r) / tanW; hi = Math.max(hi, dot(p, dir) + Math.max(v, h)); lo = Math.max(lo, dot(p, dir) + r); reach = Math.max(reach, Math.hypot(p[0], p[1], p[2]) + r); } hi = Math.max(hi * FIT_MARGIN, 1e-3); lo = Math.min(lo + 1e-3, hi); for (let it = 0; it < 24; it++) { const mid = (lo + hi) / 2; if (fits(mid)) hi = mid; else lo = mid; } const auto = hi; const b = bounds(auto); const sx = b ? (b.x0 + b.x1) / 2 / tanW : 0, sy = b ? (b.y0 + b.y1) / 2 / tanH : 0; const shift = new Float32Array(16); shift[0] = shift[5] = shift[10] = shift[15] = 1; shift[12] = -sx; shift[13] = -sy; const dist = Math.max(1e-3, auto * this.zoom); const eye = scale(dir, dist); const near = Math.max(1e-4, 0.02 * dist), far = dist + 4 * reach + 1; const mvp = multiply(shift, multiply(perspective(FOV, aspect, near, far), lookAt(eye, [0, 0, 0], [0, 0, 1]))); return { mvp, eye }; } // ---------- WebGPU path ---------- private uploadGeometry(device: GPUDevice): void { const g = this.plate!.grid; const xy = new Float32Array(g.n * 2); for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) { xy[2 * (j * g.nx + i)] = nodeX(g, i); xy[2 * (j * g.nx + i) + 1] = nodeY(g, j); } const idx = new Uint32Array((g.nx - 1) * (g.ny - 1) * 6); let c = 0; for (let cj = 0; cj < g.ny - 1; cj++) { for (let ci = 0; ci < g.nx - 1; ci++) { if (!insideCell(g, ci, cj)) continue; const a = cj * g.nx + ci, b = a + 1, d = a + g.nx, e = d + 1; idx[c++] = a; idx[c++] = b; idx[c++] = e; idx[c++] = a; idx[c++] = e; idx[c++] = d; } } this.indexCount = c; this.xyBuf?.destroy(); this.nodeBuf?.destroy(); this.indexBuf?.destroy(); this.xyBuf = device.createBuffer({ size: Math.max(16, xy.byteLength), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST }); device.queue.writeBuffer(this.xyBuf, 0, xy); this.nodeBuf = device.createBuffer({ size: Math.max(16, g.n * MESH_STRIDE), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST }); this.indexBuf = device.createBuffer({ size: Math.max(16, c * 4), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST }); if (c > 0) device.queue.writeBuffer(this.indexBuf, 0, idx, 0, c); this.geomDirty = false; } private renderGpu(device: GPUDevice): void { const ctx = this.gpuCtx; if (!ctx || !this.depth || !this.meshPipeline || !this.markerPipeline || !this.uniformBuf || !this.bindGroup) return; const is3d = this.opts.mode === "3d"; const aspect = this.canvas.width / this.canvas.height; const clear = { r: BG[0], g: BG[1], b: BG[2], a: 1 }; const encoder = device.createCommandEncoder(); const pass = encoder.beginRenderPass({ colorAttachments: [{ view: ctx.getCurrentTexture().createView(), clearValue: clear, loadOp: "clear", storeOp: "store" }], depthStencilAttachment: { view: this.depth.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" }, }); if (this.plate) { if (this.geomDirty) this.uploadGeometry(device); if (this.fieldDirty) { this.rebuildField(); device.queue.writeBuffer(this.nodeBuf!, 0, this.nodeData); } if (this.markerDirty) { this.rebuildMarkers(is3d); const bytes = this.markerMesh.byteLength; if (bytes > this.markerCapacity) { this.markerBuf?.destroy(); this.markerCapacity = Math.max(1024, bytes * 2); this.markerBuf = device.createBuffer({ size: this.markerCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST }); } if (bytes > 0) device.queue.writeBuffer(this.markerBuf!, 0, this.markerMesh.f.buffer, 0, bytes); } let mvp: Mat4, eye: Vec3; if (is3d) ({ mvp, eye } = this.camera3d(aspect)); else { mvp = this.camera2d(aspect); eye = [0, 0, 1]; } const u = new Float32Array(24); u.set(mvp, 0); u[16] = eye[0]; u[17] = eye[1]; u[18] = eye[2]; u[19] = is3d ? 1 : 0; u[20] = LIGHT[0]; u[21] = LIGHT[1]; u[22] = LIGHT[2]; u[23] = is3d ? 1 : 0; device.queue.writeBuffer(this.uniformBuf, 0, u); pass.setBindGroup(0, this.bindGroup); if (this.indexCount > 0) { pass.setPipeline(this.meshPipeline); pass.setVertexBuffer(0, this.xyBuf!); pass.setVertexBuffer(1, this.nodeBuf!); pass.setIndexBuffer(this.indexBuf!, "uint32"); pass.drawIndexed(this.indexCount); } if (this.markerMesh.count > 0 && this.markerBuf) { pass.setPipeline(this.markerPipeline); pass.setVertexBuffer(0, this.markerBuf); pass.draw(this.markerMesh.count); } } pass.end(); device.queue.submit([encoder.finish()]); } // ---------- 2D canvas fallback ---------- /** The cell colors as an image of (nx - 1) by (ny - 1) pixels, row 0 at the top (+y up). */ private rebuildImage(): void { const pl = this.plate!; const g = pl.grid; const cw = g.nx - 1, ch = g.ny - 1; if (!this.image) { this.image = document.createElement("canvas"); this.image.width = cw; this.image.height = ch; } const ictx = this.image.getContext("2d")!; const img = ictx.createImageData(cw, ch); const range = this.colorRange(); const val = this.val; for (let cj = 0; cj < ch; cj++) { for (let ci = 0; ci < cw; ci++) { if (!insideCell(g, ci, cj)) continue; const a = cj * g.nx + ci, b = a + 1, c = a + g.nx, d = c + 1; const v = 0.25 * (val[a] + val[b] + val[c] + val[d]); const h = 0.25 * (pl.h[a] + pl.h[b] + pl.h[c] + pl.h[d]); const rgb = this.colorOf(v, h, range); const o = 4 * ((ch - 1 - cj) * cw + ci); img.data[o] = Math.round(rgb[0] * 255); img.data[o + 1] = Math.round(rgb[1] * 255); img.data[o + 2] = Math.round(rgb[2] * 255); img.data[o + 3] = 255; } } ictx.putImageData(img, 0, 0); this.imageDirty = false; } private renderFallback(): void { const ctx = this.ctx2d!; const W = this.canvas.width, H = this.canvas.height; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = css(BG); ctx.fillRect(0, 0, W, H); const pl = this.plate; if (!pl) return; if (this.fieldDirty) this.rebuildField(); if (this.imageDirty) this.rebuildImage(); const { W: hw } = this.extent2d(W / H); const s = W / (2 * hw); // pixels per meter const px = (x: number) => W / 2 + x * s, py = (y: number) => H / 2 - y * s; ctx.imageSmoothingEnabled = false; ctx.drawImage(this.image!, px(-pl.Lx / 2), py(pl.Ly / 2), pl.Lx * s, pl.Ly * s); const R = this.boxRadius; const lw = 0.012 * R * s; const ring = (x: number, y: number, r: number, color: RGB) => { ctx.beginPath(); ctx.arc(px(x), py(y), r * s, 0, 2 * Math.PI); ctx.strokeStyle = css(color); ctx.lineWidth = lw; ctx.stroke(); }; const { strike, stand } = this.markers; if (stand) { if (stand.innerRadius > 0) ring(stand.x, stand.y, stand.innerRadius, COLOR_STAND); ring(stand.x, stand.y, stand.outerRadius, COLOR_STAND); } if (strike) ring(strike.x, strike.y, Math.max(strike.radius, 0.024 * R), COLOR_STRIKE); const l = this.listener2d(); if (l) { const rm = 0.045 * R * s; ctx.beginPath(); ctx.moveTo(px(0), py(0)); ctx.lineTo(px(l.x), py(l.y)); ctx.strokeStyle = css(COLOR_LINE); ctx.lineWidth = 0.6 * lw; ctx.stroke(); const [dx, dy] = l.dir; ctx.beginPath(); ctx.moveTo(px(l.x) + dx * 0.8 * rm, py(l.y) - dy * 0.8 * rm); ctx.lineTo(px(l.x) + dx * 2.2 * rm, py(l.y) - dy * 2.2 * rm); ctx.strokeStyle = css(COLOR_MIC_HEAD); ctx.lineWidth = 0.56 * rm; ctx.stroke(); ctx.beginPath(); ctx.arc(px(l.x), py(l.y), rm, 0, 2 * Math.PI); ctx.fillStyle = css(COLOR_MIC_BODY); ctx.fill(); } } }