/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim / src / render / renderer.ts
872 lines · 36.1 KBBlameHistoryRaw
1// Drawing of the plate: a 2D color map, or (WebGPU only) a lit 3D height
2// field of the displayed field plus, optionally, the stress-free profile,
3// colored by the height or by the thickness. Both views paint the strike
4// point, the stand pad and the listener as markers. Without WebGPU the map is
5// drawn with the 2D canvas API and the 3D mode falls back to the map.
6//
7// On the GPU the plate is one indexed mesh: a static vertex buffer of node
8// positions (x, y), a per-frame vertex buffer of height, slope and color per
9// node, and an index buffer of two triangles per cell of the plate built once
10// per setPlate. The 2D map is the same mesh under an orthographic camera with
11// the heights zeroed. Markers are a small triangle mesh rebuilt on the CPU
12// whenever the markers, the view or the field change.
14import { type Plate, insideCell, insideNode, nodeX, nodeY, thicknessRange } from "../params.ts";
15import { type Mat4, type Vec3, add, cross, dot, lookAt, multiply, ortho, perspective, scale, unit } from "./mat4.ts";
16import shaderSource from "./shaders.wgsl?raw";
18export type ViewMode = "2d" | "3d";
19export type ColorMode = "height" | "thickness";
21export interface ViewOptions {
22 mode: ViewMode;
23 color: ColorMode;
24 /** Add the stress-free profile w0 to the displayed field. */
25 showProfile: boolean;
26 /** Vertical scale factor of the 3D height field (1 = true scale). */
27 exaggeration: number;
28 /** Color range in meters (symmetric +-range for the height); 0 = automatic from the field. */
29 range: number;
32export interface Markers {
33 /** Plate coordinates [m]. */
34 strike: { x: number; y: number; radius: number } | null;
35 /** Pad annulus of the stand [m]. */
36 stand: { x: number; y: number; innerRadius: number; outerRadius: number } | null;
37 /** Relative to the plate center [m], z along the normal. */
38 listener: { x: number; y: number; z: number } | null;
41type RGB = [number, number, number];
43const BG: RGB = [0.11, 0.12, 0.14];
44const COLOR_STRIKE: RGB = [0.95, 0.78, 0.12];
45const COLOR_STAND: RGB = [0.1, 0.1, 0.1];
46const COLOR_POST: RGB = [0.55, 0.55, 0.58];
47const COLOR_LINE: RGB = [0.55, 0.55, 0.55];
48const COLOR_MIC_HEAD: RGB = [0.32, 0.32, 0.35];
49const COLOR_MIC_BODY: RGB = [0.72, 0.73, 0.76];
50const LIGHT: Vec3 = [0.35, -0.5, 0.8];
52/** Vertical field of view of the 3D camera [rad] and the framing margin. */
53const FOV = 0.7;
54const FIT_MARGIN = 1.08;
55/** The drawn listener distance saturates at this many plate radii from the center (about one radius outside the edge). */
56const LISTENER_REACH = 1.8;
57/** Fill fraction of the canvas taken by the plate in the 2D map. */
58const FILL_2D = 0.82;
59const RING_SEGMENTS = 64;
60const MESH_STRIDE = 16; // bytes per node in the per-frame buffer: z, slope x, slope y, packed color
61const MARKER_STRIDE = 28; // bytes per marker vertex: position, normal, packed color
62const AUTO_RANGE_FLOOR = 1e-6;
64// ---------- Color maps (shared by the GPU path and the 2D fallback) ----------
66/** Diverging blue-white-red for t in [-1, 1]. */
67function diverging(t: number): RGB {
68 const s = Math.max(-1, Math.min(1, t));
69 const mid: RGB = [0.95, 0.95, 0.95];
70 const end: RGB = s < 0 ? [0.1, 0.35, 0.85] : [0.85, 0.2, 0.12];
71 const a = Math.abs(s);
72 return [mid[0] + (end[0] - mid[0]) * a, mid[1] + (end[1] - mid[1]) * a, mid[2] + (end[2] - mid[2]) * a];
75const VIRIDIS: RGB[] = [
76 [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],
77 [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],
78];
80/** Sequential viridis-like map for t in [0, 1]. */
81function viridis(t: number): RGB {
82 const s = Math.max(0, Math.min(1, t)) * (VIRIDIS.length - 1);
83 const i = Math.min(VIRIDIS.length - 2, Math.floor(s));
84 const f = s - i;
85 const a = VIRIDIS[i], b = VIRIDIS[i + 1];
86 return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
89/** RGBA8 packed as the little-endian u32 a vertex attribute of format unorm8x4 reads. */
90function pack(c: RGB): number {
91 const r = Math.round(Math.max(0, Math.min(1, c[0])) * 255);
92 const g = Math.round(Math.max(0, Math.min(1, c[1])) * 255);
93 const b = Math.round(Math.max(0, Math.min(1, c[2])) * 255);
94 return (r | (g << 8) | (b << 16) | (255 << 24)) >>> 0;
97function css(c: RGB): string {
98 return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`;
101// ---------- Marker geometry ----------
103/** A growable triangle list in the marker vertex layout (position, flat normal, packed color). */
104class MarkerMesh {
105 f = new Float32Array(7 * 4096);
106 u = new Uint32Array(this.f.buffer);
107 count = 0;
109 clear(): void {
110 this.count = 0;
111 }
112 get byteLength(): number {
113 return this.count * MARKER_STRIDE;
114 }
115 private ensure(nVerts: number): void {
116 const need = (this.count + nVerts) * 7;
117 if (need <= this.f.length) return;
118 const f = new Float32Array(Math.max(need, this.f.length * 2));
119 f.set(this.f.subarray(0, this.count * 7));
120 this.f = f;
121 this.u = new Uint32Array(f.buffer);
122 }
123 tri(a: Vec3, b: Vec3, c: Vec3, color: number): void {
124 this.ensure(3);
125 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]]));
126 for (const p of [a, b, c]) {
127 const o = this.count * 7;
128 this.f[o] = p[0]; this.f[o + 1] = p[1]; this.f[o + 2] = p[2];
129 this.f[o + 3] = n[0]; this.f[o + 4] = n[1]; this.f[o + 5] = n[2];
130 this.u[o + 6] = color;
131 this.count++;
132 }
133 }
134 quad(a: Vec3, b: Vec3, c: Vec3, d: Vec3, color: number): void {
135 this.tri(a, b, c, color);
136 this.tri(a, c, d, color);
137 }
138 /** An annulus of mean radius r and half-width hw about (cx, cy), lifted to zAt(x, y). */
139 ring(cx: number, cy: number, r: number, hw: number, zAt: (x: number, y: number) => number, color: number): void {
140 const ri = Math.max(0, r - hw), ro = r + hw;
141 for (let s = 0; s < RING_SEGMENTS; s++) {
142 const a0 = (2 * Math.PI * s) / RING_SEGMENTS, a1 = (2 * Math.PI * (s + 1)) / RING_SEGMENTS;
143 const p = (rad: number, a: number): Vec3 => {
144 const x = cx + rad * Math.cos(a), y = cy + rad * Math.sin(a);
145 return [x, y, zAt(x, y)];
146 };
147 this.quad(p(ri, a0), p(ro, a0), p(ro, a1), p(ri, a1), color);
148 }
149 }
150 /** A closed cylinder from p0 to p1. */
151 cylinder(p0: Vec3, p1: Vec3, radius: number, segments: number, color: number): void {
152 const axis = unit([p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]]);
153 const [e1, e2] = frame(axis);
154 const rim = (p: Vec3, a: number): Vec3 => add(p, add(scale(e1, radius * Math.cos(a)), scale(e2, radius * Math.sin(a))));
155 for (let s = 0; s < segments; s++) {
156 const a0 = (2 * Math.PI * s) / segments, a1 = (2 * Math.PI * (s + 1)) / segments;
157 this.quad(rim(p0, a0), rim(p1, a0), rim(p1, a1), rim(p0, a1), color);
158 this.tri(p0, rim(p0, a1), rim(p0, a0), color);
159 this.tri(p1, rim(p1, a0), rim(p1, a1), color);
160 }
161 }
162 /** A box centered at c with the unit axes (ax, ay, az) and half sizes h. */
163 box(c: Vec3, ax: Vec3, ay: Vec3, az: Vec3, h: Vec3, color: number): void {
164 const corner = (sx: number, sy: number, sz: number): Vec3 =>
165 add(c, add(scale(ax, sx * h[0]), add(scale(ay, sy * h[1]), scale(az, sz * h[2]))));
166 const faces: [Vec3, Vec3, Vec3, Vec3][] = [
167 [corner(-1, -1, 1), corner(1, -1, 1), corner(1, 1, 1), corner(-1, 1, 1)],
168 [corner(-1, -1, -1), corner(-1, 1, -1), corner(1, 1, -1), corner(1, -1, -1)],
169 [corner(1, -1, -1), corner(1, 1, -1), corner(1, 1, 1), corner(1, -1, 1)],
170 [corner(-1, -1, -1), corner(-1, -1, 1), corner(-1, 1, 1), corner(-1, 1, -1)],
171 [corner(-1, 1, -1), corner(-1, 1, 1), corner(1, 1, 1), corner(1, 1, -1)],
172 [corner(-1, -1, -1), corner(1, -1, -1), corner(1, -1, 1), corner(-1, -1, 1)],
173 ];
174 for (const [a, b, cc, d] of faces) this.quad(a, b, cc, d, color);
175 }
178/** Two unit vectors completing a right-handed frame with the unit vector u. */
179function frame(u: Vec3): [Vec3, Vec3] {
180 const t: Vec3 = Math.abs(u[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];
181 const e1 = unit(cross(t, u));
182 return [e1, cross(u, e1)];
185/**
186 * The distance at which a listener is drawn, given its true distance d: it
187 * follows d at first and saturates at `limit`, so a listener a meter away
188 * still appears just outside the plate.
189 */
190function saturate(d: number, limit: number): number {
191 return limit * Math.tanh(d / limit);
194// ---------- Renderer ----------
196export class PlateRenderer {
197 readonly has3d: boolean;
199 private canvas: HTMLCanvasElement;
200 private device: GPUDevice | null;
201 private gpuCtx: GPUCanvasContext | null = null;
202 private ctx2d: CanvasRenderingContext2D | null = null;
203 private format: GPUTextureFormat = "bgra8unorm";
204 private meshPipeline: GPURenderPipeline | null = null;
205 private markerPipeline: GPURenderPipeline | null = null;
206 private uniformBuf: GPUBuffer | null = null;
207 private bindGroup: GPUBindGroup | null = null;
208 private xyBuf: GPUBuffer | null = null;
209 private nodeBuf: GPUBuffer | null = null;
210 private indexBuf: GPUBuffer | null = null;
211 private indexCount = 0;
212 private markerBuf: GPUBuffer | null = null;
213 private markerCapacity = 0;
214 private depth: GPUTexture | null = null;
216 private plate: Plate | null = null;
217 private field: Float32Array | Float64Array | null = null;
218 private opts: ViewOptions = { mode: "2d", color: "height", showProfile: true, exaggeration: 1, range: 0 };
219 private markers: Markers = { strike: null, stand: null, listener: null };
221 // Derived per plate
222 private hRange = { min: 0, max: 1 };
223 private w0Max = 0;
224 /** Largest in-plane distance of a plate node from the center [m], and half of the larger box side. */
225 private plateRadius = 0.1;
226 private boxRadius = 0.1;
227 private runMax = 0;
229 // Per-frame node records: display value, height (exaggerated), and the packed vertex records.
230 private val = new Float32Array(0);
231 private z = new Float32Array(0);
232 private nodeData = new ArrayBuffer(0);
233 private zMax = 0;
234 private markerMesh = new MarkerMesh();
236 private geomDirty = true;
237 private fieldDirty = true;
238 private markerDirty = true;
239 private imageDirty = true;
240 private image: HTMLCanvasElement | null = null;
242 // Orbit camera of the 3D view
243 private azimuth = -Math.PI / 2 + 0.3;
244 private elevation = Math.PI / 6;
245 private zoom = 1;
246 private frameRequested = false;
247 private destroyed = false;
248 private listeners: [string, EventListener, AddEventListenerOptions | undefined][] = [];
250 constructor(canvas: HTMLCanvasElement, device: GPUDevice | null) {
251 this.canvas = canvas;
252 this.device = device;
253 this.has3d = device !== null;
254 if (device) {
255 this.gpuCtx = canvas.getContext("webgpu") as GPUCanvasContext | null;
256 if (!this.gpuCtx) throw new Error("WebGPU canvas context unavailable");
257 this.format = navigator.gpu.getPreferredCanvasFormat();
258 this.gpuCtx.configure({ device, format: this.format, alphaMode: "opaque" });
259 this.createPipelines(device);
260 } else {
261 this.ctx2d = canvas.getContext("2d");
262 if (!this.ctx2d) throw new Error("2D canvas context unavailable");
263 }
264 this.installControls();
265 }
267 private createPipelines(device: GPUDevice): void {
268 const module = device.createShaderModule({ code: shaderSource });
269 this.uniformBuf = device.createBuffer({ size: 96, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
270 const bgLayout = device.createBindGroupLayout({
271 entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
272 });
273 this.bindGroup = device.createBindGroup({ layout: bgLayout, entries: [{ binding: 0, resource: { buffer: this.uniformBuf } }] });
274 const layout = device.createPipelineLayout({ bindGroupLayouts: [bgLayout] });
275 const depthStencil: GPUDepthStencilState = { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" };
276 const fragment: GPUFragmentState = { module, entryPoint: "fsMain", targets: [{ format: this.format }] };
277 this.meshPipeline = device.createRenderPipeline({
278 layout,
279 vertex: {
280 module,
281 entryPoint: "vsMesh",
282 buffers: [
283 { arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }] },
284 {
285 arrayStride: MESH_STRIDE,
286 attributes: [
287 { shaderLocation: 1, offset: 0, format: "float32" },
288 { shaderLocation: 2, offset: 4, format: "float32x2" },
289 { shaderLocation: 3, offset: 12, format: "unorm8x4" },
290 ],
291 },
292 ],
293 },
294 fragment,
295 primitive: { topology: "triangle-list", cullMode: "none" },
296 depthStencil,
297 });
298 this.markerPipeline = device.createRenderPipeline({
299 layout,
300 vertex: {
301 module,
302 entryPoint: "vsMarker",
303 buffers: [
304 {
305 arrayStride: MARKER_STRIDE,
306 attributes: [
307 { shaderLocation: 0, offset: 0, format: "float32x3" },
308 { shaderLocation: 1, offset: 12, format: "float32x3" },
309 { shaderLocation: 2, offset: 24, format: "unorm8x4" },
310 ],
311 },
312 ],
313 },
314 fragment,
315 primitive: { topology: "triangle-list", cullMode: "none" },
316 depthStencil,
317 });
318 }
320 private installControls(): void {
321 if (!this.has3d) return;
322 let dragging = false, lastX = 0, lastY = 0;
323 const on = <K extends keyof HTMLElementEventMap>(type: K, fn: (e: HTMLElementEventMap[K]) => void, opts?: AddEventListenerOptions) => {
324 this.canvas.addEventListener(type, fn, opts);
325 this.listeners.push([type, fn as EventListener, opts]);
326 };
327 on("pointerdown", (e) => {
328 if (this.opts.mode !== "3d") return;
329 dragging = true; lastX = e.clientX; lastY = e.clientY;
330 this.canvas.setPointerCapture(e.pointerId);
331 });
332 on("pointerup", () => { dragging = false; });
333 on("pointercancel", () => { dragging = false; });
334 on("pointermove", (e) => {
335 if (!dragging || this.opts.mode !== "3d") return;
336 this.azimuth -= (e.clientX - lastX) * 0.008;
337 this.elevation = Math.max(-1.45, Math.min(1.45, this.elevation + (e.clientY - lastY) * 0.008));
338 lastX = e.clientX; lastY = e.clientY;
339 this.markerDirty = true;
340 this.requestFrame();
341 });
342 on("wheel", (e) => {
343 if (this.opts.mode !== "3d") return;
344 e.preventDefault();
345 this.zoom = Math.max(0.05, Math.min(20, this.zoom * Math.exp(e.deltaY * 0.001)));
346 this.requestFrame();
347 }, { passive: false });
348 on("dblclick", () => {
349 if (this.opts.mode !== "3d") return;
350 this.azimuth = -Math.PI / 2 + 0.3;
351 this.elevation = Math.PI / 6;
352 this.zoom = 1;
353 this.requestFrame();
354 });
355 }
357 private requestFrame(): void {
358 if (this.frameRequested) return;
359 this.frameRequested = true;
360 requestAnimationFrame(() => {
361 this.frameRequested = false;
362 this.render();
363 });
364 }
366 // ---------- Public API ----------
368 /** New geometry (grid, mask, thickness, profile); resets the automatic color range and the field (to zeros). */
369 setPlate(plate: Plate): void {
370 this.plate = plate;
371 const g = plate.grid;
372 this.hRange = thicknessRange(plate);
373 let w0Max = 0, rp = 0;
374 for (let j = 0; j < g.ny; j++) {
375 for (let i = 0; i < g.nx; i++) {
376 if (!insideNode(g, i, j)) continue;
377 w0Max = Math.max(w0Max, Math.abs(plate.w0[j * g.nx + i]));
378 rp = Math.max(rp, Math.hypot(nodeX(g, i), nodeY(g, j)));
379 }
380 }
381 this.w0Max = w0Max;
382 this.plateRadius = Math.max(rp, 1e-3);
383 this.boxRadius = Math.max(plate.Lx, plate.Ly) / 2 || 1e-3;
384 this.runMax = 0;
385 this.field = null;
386 this.val = new Float32Array(g.n);
387 this.z = new Float32Array(g.n);
388 this.nodeData = new ArrayBuffer(g.n * MESH_STRIDE);
389 this.image = null;
390 this.geomDirty = true;
391 this.fieldDirty = true;
392 this.markerDirty = true;
393 this.imageDirty = true;
394 this.updateRunMax();
395 }
397 /** The displayed field per grid node [m]; null shows zeros (the design preview). The array is read at render time. */
398 setField(w: Float32Array | Float64Array | null): void {
399 this.field = w && this.plate && w.length >= this.plate.grid.n ? w : null;
400 this.updateRunMax();
401 this.fieldDirty = true;
402 this.markerDirty = true;
403 this.imageDirty = true;
404 }
406 setOptions(opts: Partial<ViewOptions>): void {
407 this.opts = { ...this.opts, ...opts };
408 this.fieldDirty = true;
409 this.markerDirty = true;
410 this.imageDirty = true;
411 }
413 setMarkers(m: Markers): void {
414 this.markers = m;
415 this.markerDirty = true;
416 }
418 /** Matches the canvas to its CSS size and devicePixelRatio; recreates the depth buffer when the size changes. */
419 resize(): void {
420 const dpr = window.devicePixelRatio || 1;
421 const w = Math.max(1, Math.round(this.canvas.clientWidth * dpr));
422 const h = Math.max(1, Math.round(this.canvas.clientHeight * dpr));
423 if (this.canvas.width === w && this.canvas.height === h && (this.depth || !this.device)) return;
424 this.canvas.width = w;
425 this.canvas.height = h;
426 if (this.device) {
427 this.depth?.destroy();
428 this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
429 }
430 this.markerDirty = true;
431 }
433 render(): void {
434 if (this.destroyed) return;
435 this.resize();
436 if (this.device) this.renderGpu(this.device);
437 else this.renderFallback();
438 }
440 destroy(): void {
441 this.destroyed = true;
442 for (const [type, fn, opts] of this.listeners) this.canvas.removeEventListener(type, fn, opts);
443 this.listeners = [];
444 this.xyBuf?.destroy(); this.nodeBuf?.destroy(); this.indexBuf?.destroy();
445 this.markerBuf?.destroy(); this.uniformBuf?.destroy(); this.depth?.destroy();
446 this.xyBuf = this.nodeBuf = this.indexBuf = this.markerBuf = this.uniformBuf = null;
447 this.depth = null;
448 this.gpuCtx?.unconfigure();
449 this.gpuCtx = null;
450 this.device = null;
451 this.plate = null;
452 }
454 // ---------- Field and color ----------
456 private updateRunMax(): void {
457 const pl = this.plate, w = this.field;
458 if (!pl || !w) return;
459 const g = pl.grid;
460 let m = 0;
461 if (g.mask) {
462 for (let k = 0; k < g.n; k++) if (g.mask[k]) m = Math.max(m, Math.abs(w[k]));
463 } else {
464 for (let k = 0; k < g.n; k++) m = Math.max(m, Math.abs(w[k]));
465 }
466 if (isFinite(m)) this.runMax = Math.max(this.runMax, m);
467 }
469 /** The color range in use for the height coloring [m]. */
470 private colorRange(): number {
471 if (this.opts.range > 0) return this.opts.range;
472 return Math.max(this.runMax + (this.opts.showProfile ? this.w0Max : 0), AUTO_RANGE_FLOOR);
473 }
475 private colorOf(val: number, h: number, range: number): RGB {
476 if (this.opts.color === "thickness") return viridis((h - this.hRange.min) / Math.max(this.hRange.max - this.hRange.min, 1e-9));
477 return diverging(val / range);
478 }
480 /** Recomputes the displayed value, the height and the vertex records of every node. */
481 private rebuildField(): void {
482 const pl = this.plate!;
483 const g = pl.grid;
484 const w = this.field;
485 const exag = this.opts.exaggeration;
486 const range = this.colorRange();
487 const val = this.val, z = this.z;
488 let zMax = 0;
489 for (let k = 0; k < g.n; k++) {
490 const v = (w ? w[k] : 0) + (this.opts.showProfile ? pl.w0[k] : 0);
491 val[k] = v;
492 z[k] = v * exag;
493 if (!g.mask || g.mask[k]) zMax = Math.max(zMax, Math.abs(z[k]));
494 }
495 this.zMax = zMax;
496 const f = new Float32Array(this.nodeData), u = new Uint32Array(this.nodeData);
497 for (let j = 0; j < g.ny; j++) {
498 for (let i = 0; i < g.nx; i++) {
499 const k = j * g.nx + i;
500 const o = k * 4;
501 if (!insideNode(g, i, j)) { f[o] = 0; f[o + 1] = 0; f[o + 2] = 0; u[o + 3] = 0; continue; }
502 f[o] = z[k];
503 f[o + 1] = this.slope(i, j, 1, 0, g.hx);
504 f[o + 2] = this.slope(i, j, 0, 1, g.hy);
505 u[o + 3] = pack(this.colorOf(val[k], pl.h[k], range));
506 }
507 }
508 this.fieldDirty = false;
509 }
511 /** Slope of the displayed height along one axis, one-sided where a neighbor lies outside the plate. */
512 private slope(i: number, j: number, di: number, dj: number, h: number): number {
513 const g = this.plate!.grid, z = this.z;
514 const hasP = insideNode(g, i + di, j + dj), hasM = insideNode(g, i - di, j - dj);
515 const k = j * g.nx + i, kp = (j + dj) * g.nx + i + di, km = (j - dj) * g.nx + i - di;
516 if (hasP && hasM) return (z[kp] - z[km]) / (2 * h);
517 if (hasP) return (z[kp] - z[k]) / h;
518 if (hasM) return (z[k] - z[km]) / h;
519 return 0;
520 }
522 /**
523 * The displayed height at (x, y), bilinear over the plate nodes of its cell
524 * (missing corners dropped), or `fallback` when no corner is in the plate.
525 */
526 private surfaceZ(x: number, y: number, fallback: number): number {
527 const g = this.plate!.grid;
528 const fi = x / g.hx + (g.nx - 1) / 2, fj = y / g.hy + (g.ny - 1) / 2;
529 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)));
530 const a = Math.max(0, Math.min(1, fi - i0)), b = Math.max(0, Math.min(1, fj - j0));
531 let s = 0, wsum = 0;
532 const acc = (i: number, j: number, wt: number) => {
533 if (wt <= 0 || !insideNode(g, i, j)) return;
534 s += wt * this.z[j * g.nx + i]; wsum += wt;
535 };
536 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);
537 return wsum > 0 ? s / wsum : fallback;
538 }
540 /** Mean displayed height of the plate nodes within `r` of (cx, cy), or 0 when there are none. */
541 private meanZWithin(cx: number, cy: number, r: number): number {
542 const g = this.plate!.grid;
543 let s = 0, c = 0;
544 for (let j = 0; j < g.ny; j++) {
545 for (let i = 0; i < g.nx; i++) {
546 if (!insideNode(g, i, j) || Math.hypot(nodeX(g, i) - cx, nodeY(g, j) - cy) > r) continue;
547 s += this.z[j * g.nx + i]; c++;
548 }
549 }
550 return c > 0 ? s / c : 0;
551 }
553 // ---------- Listener placement ----------
555 /** Where the listener is drawn in the 2D map: its in-plane direction, at a saturated distance from the center. */
556 private listener2d(): { x: number; y: number; dir: [number, number] } | null {
557 const L = this.markers.listener;
558 if (!L) return null;
559 const rho = Math.hypot(L.x, L.y);
560 const dir: [number, number] = rho > 1e-9 ? [L.x / rho, L.y / rho] : [0, -1];
561 const d = saturate(rho, 1.15 * this.boxRadius);
562 return { x: dir[0] * d, y: dir[1] * d, dir };
563 }
565 /** Where the listener is drawn in 3D: its true direction, at a distance saturating about one plate radius outside the edge. */
566 private listener3d(): { p: Vec3; u: Vec3 } | null {
567 const L = this.markers.listener;
568 if (!L) return null;
569 const d = Math.hypot(L.x, L.y, L.z);
570 if (d < 1e-9) return null;
571 const u: Vec3 = [L.x / d, L.y / d, L.z / d];
572 return { p: scale(u, saturate(d, LISTENER_REACH * this.plateRadius)), u };
573 }
575 // ---------- Markers ----------
577 private rebuildMarkers(is3d: boolean): void {
578 const mesh = this.markerMesh;
579 mesh.clear();
580 const R = this.boxRadius;
581 const lw = 0.012 * R; // width of the ring lines
582 const lift = 0.004 * R; // markers float this far above the surface
583 const { strike, stand, listener } = this.markers;
584 const zStand = stand ? this.meanZWithin(stand.x, stand.y, stand.outerRadius) : 0;
585 const zAt = is3d ? (x: number, y: number) => this.surfaceZ(x, y, zStand) + lift : () => lift;
586 if (stand) {
587 const dark = pack(COLOR_STAND);
588 if (stand.innerRadius > 0) mesh.ring(stand.x, stand.y, stand.innerRadius, lw / 2, zAt, dark);
589 mesh.ring(stand.x, stand.y, stand.outerRadius, lw / 2, zAt, dark);
590 if (is3d) {
591 const ro = stand.outerRadius;
592 const rod = stand.innerRadius > 0 ? 0.6 * stand.innerRadius : 0.3 * ro;
593 mesh.cylinder([stand.x, stand.y, zStand - 2 * ro], [stand.x, stand.y, zStand + 0.5 * ro], rod, 20, pack(COLOR_POST));
594 }
595 }
596 if (strike) mesh.ring(strike.x, strike.y, Math.max(strike.radius, 2 * lw), lw / 2, zAt, pack(COLOR_STRIKE));
597 if (listener) {
598 if (is3d) {
599 const l = this.listener3d();
600 if (l) {
601 const [e1, e2] = frame(l.u);
602 const head = 0.035 * R, body = 0.025 * R, len = 0.06 * R;
603 const zc = this.surfaceZ(0, 0, zStand);
604 const start: Vec3 = [0, 0, zc + lift];
605 const end = add(l.p, scale(l.u, -head));
606 if (dot(end, l.u) > dot(start, l.u)) mesh.cylinder(start, end, 0.003 * R, 6, pack(COLOR_LINE));
607 mesh.box(l.p, e1, e2, l.u, [head, head, head], pack(COLOR_MIC_HEAD));
608 mesh.box(add(l.p, scale(l.u, head + len)), e1, e2, l.u, [body, body, len], pack(COLOR_MIC_BODY));
609 }
610 } else {
611 const l = this.listener2d();
612 if (l) {
613 const rm = 0.045 * R;
614 const z1 = lift, z2 = 2 * lift;
615 // Line from the plate center, then the symbol: a round head and a stem pointing away from the center.
616 const [dx, dy] = l.dir;
617 const nx = -dy, ny = dx, hw = 0.3 * lw;
618 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));
619 mesh.ring(l.x, l.y, rm / 2, rm / 2, () => z2, pack(COLOR_MIC_BODY));
620 const sw = 0.28 * rm, s0 = 0.8 * rm, s1 = 2.2 * rm;
621 mesh.quad(
622 [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],
623 [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],
624 pack(COLOR_MIC_HEAD),
625 );
626 }
627 }
628 }
629 this.markerDirty = false;
630 }
632 // ---------- Cameras ----------
634 /** Half extents of the orthographic 2D view in plate coordinates. */
635 private extent2d(aspect: number): { W: number; H: number } {
636 const pl = this.plate!;
637 let need = Math.max(pl.Lx / 2, (pl.Ly / 2) * aspect);
638 const l = this.listener2d();
639 if (l) {
640 const m = 0.12 * this.boxRadius; // extent of the microphone symbol
641 need = Math.max(need, Math.abs(l.x) + m, (Math.abs(l.y) + m) * aspect);
642 }
643 const W = need / FILL_2D;
644 return { W, H: W / aspect };
645 }
647 private camera2d(aspect: number): Mat4 {
648 const { W, H } = this.extent2d(aspect);
649 return multiply(ortho(-W, W, -H, H, 0.5, 1.5), lookAt([0, 0, 1], [0, 0, 0], [0, 1, 0]));
650 }
652 /**
653 * The orbit camera about the plate center. The distance is fitted, unless
654 * the user has zoomed, so that the plate (an in-plane circle of the plate
655 * radius at the displayed heights) and the microphone fill the view; the
656 * frustum is then shifted so that the fitted scene, not the plate center,
657 * sits in the middle of the canvas, which matters when the microphone is
658 * high above the plate.
659 */
660 private camera3d(aspect: number): { mvp: Mat4; eye: Vec3 } {
661 const el = this.elevation, az = this.azimuth;
662 const dir: Vec3 = [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
663 const fwd = scale(dir, -1);
664 const right = unit(cross(fwd, [0, 0, 1]));
665 const up = cross(right, fwd);
666 const horiz = unit(Math.hypot(fwd[0], fwd[1]) > 1e-6 ? [fwd[0], fwd[1], 0] : [1, 0, 0]);
667 const Rp = this.plateRadius, zm = Math.max(this.zMax, 1e-4);
668 const pts: [Vec3, number][] = [];
669 for (const s of [-1, 1]) for (const sz of [-1, 1]) {
670 pts.push([add(scale(right, s * Rp), [0, 0, sz * zm]), 0]);
671 pts.push([add(scale(horiz, s * Rp), [0, 0, sz * zm]), 0]);
672 }
673 const l = this.listener3d();
674 if (l) pts.push([l.p, 0.12 * this.boxRadius]);
675 const tanH = Math.tan(FOV / 2), tanW = tanH * aspect;
676 // Screen bounds of the points, as tangents of the angles from the view axis, seen from distance D.
677 const bounds = (D: number) => {
678 let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
679 for (const [p, r] of pts) {
680 const depth = D - dot(p, dir);
681 if (depth <= 1e-6) return null;
682 const px = dot(p, right) / depth, py = dot(p, up) / depth, pr = r / depth;
683 x0 = Math.min(x0, px - pr); x1 = Math.max(x1, px + pr);
684 y0 = Math.min(y0, py - pr); y1 = Math.max(y1, py + pr);
685 }
686 return { x0, x1, y0, y1 };
687 };
688 const fits = (D: number) => {
689 const b = bounds(D);
690 return !!b && b.x1 - b.x0 <= (2 * tanW) / FIT_MARGIN && b.y1 - b.y0 <= (2 * tanH) / FIT_MARGIN;
691 };
692 // The symmetric fit about the center always holds; bisect down from it toward the depth of the scene.
693 let hi = 0, lo = 0, reach = 0;
694 for (const [p, r] of pts) {
695 const v = (Math.abs(dot(p, up)) + r) / tanH, h = (Math.abs(dot(p, right)) + r) / tanW;
696 hi = Math.max(hi, dot(p, dir) + Math.max(v, h));
697 lo = Math.max(lo, dot(p, dir) + r);
698 reach = Math.max(reach, Math.hypot(p[0], p[1], p[2]) + r);
699 }
700 hi = Math.max(hi * FIT_MARGIN, 1e-3);
701 lo = Math.min(lo + 1e-3, hi);
702 for (let it = 0; it < 24; it++) {
703 const mid = (lo + hi) / 2;
704 if (fits(mid)) hi = mid; else lo = mid;
705 }
706 const auto = hi;
707 const b = bounds(auto);
708 const sx = b ? (b.x0 + b.x1) / 2 / tanW : 0, sy = b ? (b.y0 + b.y1) / 2 / tanH : 0;
709 const shift = new Float32Array(16);
710 shift[0] = shift[5] = shift[10] = shift[15] = 1;
711 shift[12] = -sx; shift[13] = -sy;
712 const dist = Math.max(1e-3, auto * this.zoom);
713 const eye = scale(dir, dist);
714 const near = Math.max(1e-4, 0.02 * dist), far = dist + 4 * reach + 1;
715 const mvp = multiply(shift, multiply(perspective(FOV, aspect, near, far), lookAt(eye, [0, 0, 0], [0, 0, 1])));
716 return { mvp, eye };
717 }
719 // ---------- WebGPU path ----------
721 private uploadGeometry(device: GPUDevice): void {
722 const g = this.plate!.grid;
723 const xy = new Float32Array(g.n * 2);
724 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); }
725 const idx = new Uint32Array((g.nx - 1) * (g.ny - 1) * 6);
726 let c = 0;
727 for (let cj = 0; cj < g.ny - 1; cj++) {
728 for (let ci = 0; ci < g.nx - 1; ci++) {
729 if (!insideCell(g, ci, cj)) continue;
730 const a = cj * g.nx + ci, b = a + 1, d = a + g.nx, e = d + 1;
731 idx[c++] = a; idx[c++] = b; idx[c++] = e;
732 idx[c++] = a; idx[c++] = e; idx[c++] = d;
733 }
734 }
735 this.indexCount = c;
736 this.xyBuf?.destroy(); this.nodeBuf?.destroy(); this.indexBuf?.destroy();
737 this.xyBuf = device.createBuffer({ size: Math.max(16, xy.byteLength), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
738 device.queue.writeBuffer(this.xyBuf, 0, xy);
739 this.nodeBuf = device.createBuffer({ size: Math.max(16, g.n * MESH_STRIDE), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
740 this.indexBuf = device.createBuffer({ size: Math.max(16, c * 4), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
741 if (c > 0) device.queue.writeBuffer(this.indexBuf, 0, idx, 0, c);
742 this.geomDirty = false;
743 }
745 private renderGpu(device: GPUDevice): void {
746 const ctx = this.gpuCtx;
747 if (!ctx || !this.depth || !this.meshPipeline || !this.markerPipeline || !this.uniformBuf || !this.bindGroup) return;
748 const is3d = this.opts.mode === "3d";
749 const aspect = this.canvas.width / this.canvas.height;
750 const clear = { r: BG[0], g: BG[1], b: BG[2], a: 1 };
751 const encoder = device.createCommandEncoder();
752 const pass = encoder.beginRenderPass({
753 colorAttachments: [{ view: ctx.getCurrentTexture().createView(), clearValue: clear, loadOp: "clear", storeOp: "store" }],
754 depthStencilAttachment: { view: this.depth.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
755 });
756 if (this.plate) {
757 if (this.geomDirty) this.uploadGeometry(device);
758 if (this.fieldDirty) {
759 this.rebuildField();
760 device.queue.writeBuffer(this.nodeBuf!, 0, this.nodeData);
761 }
762 if (this.markerDirty) {
763 this.rebuildMarkers(is3d);
764 const bytes = this.markerMesh.byteLength;
765 if (bytes > this.markerCapacity) {
766 this.markerBuf?.destroy();
767 this.markerCapacity = Math.max(1024, bytes * 2);
768 this.markerBuf = device.createBuffer({ size: this.markerCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
769 }
770 if (bytes > 0) device.queue.writeBuffer(this.markerBuf!, 0, this.markerMesh.f.buffer, 0, bytes);
771 }
772 let mvp: Mat4, eye: Vec3;
773 if (is3d) ({ mvp, eye } = this.camera3d(aspect));
774 else { mvp = this.camera2d(aspect); eye = [0, 0, 1]; }
775 const u = new Float32Array(24);
776 u.set(mvp, 0);
777 u[16] = eye[0]; u[17] = eye[1]; u[18] = eye[2]; u[19] = is3d ? 1 : 0;
778 u[20] = LIGHT[0]; u[21] = LIGHT[1]; u[22] = LIGHT[2]; u[23] = is3d ? 1 : 0;
779 device.queue.writeBuffer(this.uniformBuf, 0, u);
781 pass.setBindGroup(0, this.bindGroup);
782 if (this.indexCount > 0) {
783 pass.setPipeline(this.meshPipeline);
784 pass.setVertexBuffer(0, this.xyBuf!);
785 pass.setVertexBuffer(1, this.nodeBuf!);
786 pass.setIndexBuffer(this.indexBuf!, "uint32");
787 pass.drawIndexed(this.indexCount);
788 }
789 if (this.markerMesh.count > 0 && this.markerBuf) {
790 pass.setPipeline(this.markerPipeline);
791 pass.setVertexBuffer(0, this.markerBuf);
792 pass.draw(this.markerMesh.count);
793 }
794 }
795 pass.end();
796 device.queue.submit([encoder.finish()]);
797 }
799 // ---------- 2D canvas fallback ----------
801 /** The cell colors as an image of (nx - 1) by (ny - 1) pixels, row 0 at the top (+y up). */
802 private rebuildImage(): void {
803 const pl = this.plate!;
804 const g = pl.grid;
805 const cw = g.nx - 1, ch = g.ny - 1;
806 if (!this.image) {
807 this.image = document.createElement("canvas");
808 this.image.width = cw; this.image.height = ch;
809 }
810 const ictx = this.image.getContext("2d")!;
811 const img = ictx.createImageData(cw, ch);
812 const range = this.colorRange();
813 const val = this.val;
814 for (let cj = 0; cj < ch; cj++) {
815 for (let ci = 0; ci < cw; ci++) {
816 if (!insideCell(g, ci, cj)) continue;
817 const a = cj * g.nx + ci, b = a + 1, c = a + g.nx, d = c + 1;
818 const v = 0.25 * (val[a] + val[b] + val[c] + val[d]);
819 const h = 0.25 * (pl.h[a] + pl.h[b] + pl.h[c] + pl.h[d]);
820 const rgb = this.colorOf(v, h, range);
821 const o = 4 * ((ch - 1 - cj) * cw + ci);
822 img.data[o] = Math.round(rgb[0] * 255); img.data[o + 1] = Math.round(rgb[1] * 255);
823 img.data[o + 2] = Math.round(rgb[2] * 255); img.data[o + 3] = 255;
824 }
825 }
826 ictx.putImageData(img, 0, 0);
827 this.imageDirty = false;
828 }
830 private renderFallback(): void {
831 const ctx = this.ctx2d!;
832 const W = this.canvas.width, H = this.canvas.height;
833 ctx.setTransform(1, 0, 0, 1, 0, 0);
834 ctx.fillStyle = css(BG);
835 ctx.fillRect(0, 0, W, H);
836 const pl = this.plate;
837 if (!pl) return;
838 if (this.fieldDirty) this.rebuildField();
839 if (this.imageDirty) this.rebuildImage();
840 const { W: hw } = this.extent2d(W / H);
841 const s = W / (2 * hw); // pixels per meter
842 const px = (x: number) => W / 2 + x * s, py = (y: number) => H / 2 - y * s;
843 ctx.imageSmoothingEnabled = false;
844 ctx.drawImage(this.image!, px(-pl.Lx / 2), py(pl.Ly / 2), pl.Lx * s, pl.Ly * s);
846 const R = this.boxRadius;
847 const lw = 0.012 * R * s;
848 const ring = (x: number, y: number, r: number, color: RGB) => {
849 ctx.beginPath();
850 ctx.arc(px(x), py(y), r * s, 0, 2 * Math.PI);
851 ctx.strokeStyle = css(color); ctx.lineWidth = lw;
852 ctx.stroke();
853 };
854 const { strike, stand } = this.markers;
855 if (stand) {
856 if (stand.innerRadius > 0) ring(stand.x, stand.y, stand.innerRadius, COLOR_STAND);
857 ring(stand.x, stand.y, stand.outerRadius, COLOR_STAND);
858 }
859 if (strike) ring(strike.x, strike.y, Math.max(strike.radius, 0.024 * R), COLOR_STRIKE);
860 const l = this.listener2d();
861 if (l) {
862 const rm = 0.045 * R * s;
863 ctx.beginPath(); ctx.moveTo(px(0), py(0)); ctx.lineTo(px(l.x), py(l.y));
864 ctx.strokeStyle = css(COLOR_LINE); ctx.lineWidth = 0.6 * lw; ctx.stroke();
865 const [dx, dy] = l.dir;
866 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);
867 ctx.strokeStyle = css(COLOR_MIC_HEAD); ctx.lineWidth = 0.56 * rm; ctx.stroke();
868 ctx.beginPath(); ctx.arc(px(l.x), py(l.y), rm, 0, 2 * Math.PI);
869 ctx.fillStyle = css(COLOR_MIC_BODY); ctx.fill();
870 }
871 }
↑↓ move↵openescclose