/ proof-of-concept / steelsim41
Sign in
proof-of-concept / steelsim41
Show the mount and the listener in the 3D view; lay the plate flat
The 3D view drew no markers at all, which mattered more once it became the default view. Both views now paint the same two rings on the plate, the mount and the strike point, from one shader function. The 3D view adds a post at the mount, since on a steep bell the ring alone is easily lost and, seen edge-on, invisible, and a microphone on the line to the listener. The microphone is drawn nearer than the listener is: at a meter from a 16 inch cymbal no framing holds both at true scale, so the drawn distance follows the true one through a saturating map and the microphone shrinks as the listener recedes. The direction is faithful. The 3D world now puts the plate's normal up, so a cymbal lies flat the way it sits on a stand rather than standing on edge like a hanging sheet, and the camera opens looking down on it from about thirty degrees. The default strike is now 8 m/s at 0.9 of the width, near the edge.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 6201bf2edca5 parent 7710254 Browse files
6 changed files+338−36
README.mdmodified+7−3View file
@@ -17,7 +17,7 @@ Requires Node 20 or later and a browser with WebGPU (Chrome or Edge 113+; Firefo
1717
1818 ## Using the app
1919
20-The sidebar holds the controls at its top, then the design script and the strike and simulation parameters. The main area shows the plate (a lit 3D height field by default, or a 2D color map; drag to orbit, wheel to zoom), the movie and playback toolbar, and the spectrogram of the listener signal.
20+The sidebar holds the controls at its top, then the design script and the strike and simulation parameters. The main area shows the plate (a lit 3D height field by default, or a 2D color map; drag to orbit, wheel to zoom), the movie and playback toolbar, and the spectrogram of the listener signal. The 3D view lays the plate flat, with its normal pointing up, the way a cymbal sits on a stand, and opens looking down on it from about thirty degrees.
2121
2222 The head of the sidebar, with the recorded duration and the Start, Pause, Stop, and Continue buttons, does not scroll, so the controls stay in reach however long the script is. The bar between the sidebar and the view can be dragged to widen the panel (double-click to return to 420 px); the width is remembered in the browser. The panel shows controls and measured quantities only: the explanatory prose is folded away under About, Writing a script, and the two Notes headings, which can be opened when it is wanted.
2323
@@ -60,7 +60,11 @@ end
6060
6161 Note that the functions may follow the statements that use them, as they do in MATLAB, and that a handle created above them may call them: numbl hoists the definitions, and the simulator appends its sampling code after the whole script. A function defined in the script also shadows one of the same name defined anywhere else. `inside`, `mount`, `profile`, and `loss` are optional (`mount` may also give `x` and `y`). The profile's mean plane is removed before use, since only its slopes enter the physics and a tilt would add a spurious membrane coupling.
6262
63-**Strike and simulation.** The strike point is given as fractions of the bounding box (the yellow ring in the view), with the stick speed, its effective mass, the Hertz contact stiffness and exponent, and the tip radius. The listener distance and direction can be changed while a simulation runs. The grid spacing (8 mm by default), the in-plane mass factor, the sample rate, and the frame rate take effect at the next Start; the Notes under the group report the grid, the frequencies at which grid dispersion lowers the partials by 3 and 10 percent, the time step, and the 60 dB decay times of the design's losses. The rigid-body projection applies to free plates only; a mounted plate takes the reaction of the strike through the mount.
63+**Markers.** Both views paint two rings on the plate: the mount, dark, at its radius, and the strike point, yellow, just outside the tip. The 3D view adds two markers of its own. A short post stands at the mount, since on a steep bell the ring alone is easily lost and, seen edge-on, invisible; it shows where and how wide the mount is, and its length means nothing. A microphone stands on the line from the center of the plate to the listener, grille facing the plate.
64+
65+The microphone is drawn nearer than the listener is. A listener a meter from a 16 inch cymbal is four times as far away as the cymbal is wide, so no framing holds both at true scale, and the plate already fills most of the view. The drawn distance therefore follows the true one through a saturating map that keeps the microphone in the thin annulus around the plate, and the microphone shrinks a little as the listener recedes. The direction is faithful. The distance is the field in the Listener group, and it is that field that sets the 1/r spreading and the spread of the propagation delays, not the drawing.
66+
67+**Strike and simulation.** The strike point is given as fractions of the bounding box (the yellow ring in the view), with the stick speed, its effective mass, the Hertz contact stiffness and exponent, and the tip radius. The default is a hard hit, 8 m/s at 0.9 of the width, which lands near the edge of a cymbal. The listener distance and direction can be changed while a simulation runs, and the microphone moves with them. The grid spacing (8 mm by default), the in-plane mass factor, the sample rate, and the frame rate take effect at the next Start; the Notes under the group report the grid, the frequencies at which grid dispersion lowers the partials by 3 and 10 percent, the time step, and the 60 dB decay times of the design's losses. The rigid-body projection applies to free plates only; a mounted plate takes the reaction of the strike through the mount.
6468
6569 **Running.** Press Start. The stick strikes the plate at rest in its stress-free shape, and the recording of audio and movie frames starts at the impact. The view shows the total shape by default (profile plus vibration); untick Show profile to see the vibration alone, which makes a small vibration on a curved cymbal visible. Pause halts the run temporarily; Stop ends it, keeps the audio and movie, and unlocks the script and the fields. To extend a finished run, raise the duration and press Continue. After the run, drag the time slider to explore the movie; Play starts the audio from the slider position and moves the movie and the spectrogram cursor with it; Live returns to the running view and Design to the stress-free shape. The audio can be played or downloaded as a 16-bit WAV at any time; Absolute level maps 1 Pa to full scale shifted by the gain slider (the peak level in dBFS is shown with a clipping warning), and Loudness and Peak normalize each run instead.
6670
@@ -90,7 +94,7 @@ The default grid spacing of 8 mm keeps the simulation interactive but lowers the
9094 - `src/sim/shaders/`: WGSL kernels (time step, rigid-body sums and projection, listener sum, display extraction).
9195 - `src/sim/simulation.ts`: WebGPU driver (buffers, batch encoding, readback).
9296 - `src/selftest.ts`: the in-browser GPU-versus-CPU comparison.
93-- `src/render/`: 2D and 3D WebGPU rendering of the plate.
97+- `src/render/`: 2D and 3D WebGPU rendering of the plate, with the markers of the mount, the strike point, and the listener.
9498 - `src/audio/`: FFT, spectrogram, WAV encoder, playback, level mapping.
9599 - `src/ui/editor.ts`, `src/ui/form.ts`, and `src/main.ts`: the script editor, the parameter form, and the rest of the interface.
96100 - `tests/`: the three test files above.
src/main.tsmodified+4−3View file
@@ -2,7 +2,7 @@
22 // views, spectrogram, playback, WAV download, and the test hooks on
33 // window.steelsim.
44
5-import { type Plate, type SimParams, countInside, defaultParams, massProperties, mountPatch, nodeWeights, strikePoint, thicknessRange } from "./params.ts";
5+import { type Plate, type SimParams, countInside, defaultParams, listenerPoint, massProperties, mountPatch, nodeWeights, strikePoint, thicknessRange } from "./params.ts";
66 import { DesignError, evaluatePlate, profileStats } from "./design.ts";
77 import { PRESETS } from "./presets.ts";
88 import { Simulation, type SimStatus } from "./sim/simulation.ts";
@@ -475,6 +475,7 @@ function markers(pl: Plate): void {
475475 const [sx, sy] = strikePoint(params, pl);
476476 renderer.strike = { x: sx, y: sy, r: params.contactRadius };
477477 renderer.mount = pl.mount ? { x: pl.mount.x, y: pl.mount.y, r: pl.mount.radius } : null;
478+ renderer.listener = listenerPoint(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
478479 }
479480
480481 function frameBody(): void {
@@ -491,6 +492,7 @@ function frameBody(): void {
491492 cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4),
492493 canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio,
493494 params.strikeFracX, params.strikeFracY, params.contactRadius,
495+ params.listenerDist, params.listenerAzimuth, params.listenerElevation,
494496 ];
495497 if (previewMode || !sim) {
496498 // The stress-free shape of the design that the next Start will use.
@@ -659,8 +661,7 @@ async function init(): Promise<void> {
659661 form.onChange = (key, isLive) => {
660662 if (key === "spacing") { rebuildPlate(); return; }
661663 if (!isLive) { showPreview(); return; }
662- if (!sim) return;
663- if (key.startsWith("listener")) sim.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
664+ if (key.startsWith("listener")) sim?.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
664665 };
665666 editor.onDesign = () => rebuildPlate();
666667 buildSplitter();
src/params.tsmodified+12−2View file
@@ -26,9 +26,9 @@ export interface SimParams {
2626 }
2727
2828 export const defaultParams: SimParams = {
29- strikeFracX: 0.7,
29+ strikeFracX: 0.9,
3030 strikeFracY: 0.5,
31- strikeVelocity: 3,
31+ strikeVelocity: 8,
3232 stickMass: 0.03,
3333 contactStiffness: 5e8,
3434 contactExponent: 1.5,
@@ -317,6 +317,16 @@ export function strikePoint(p: SimParams, pl: Plate): [number, number] {
317317 return [(p.strikeFracX - 0.5) * pl.Lx, (p.strikeFracY - 0.5) * pl.Ly];
318318 }
319319
320+/**
321+ * The listener, in the plate's own frame: x and y along the bounding box, z
322+ * along the plate's normal. Zero azimuth and elevation puts the listener on
323+ * the normal through the center of the plate.
324+ */
325+export function listenerPoint(dist: number, azimuthDeg: number, elevationDeg: number): [number, number, number] {
326+ const az = (azimuthDeg * Math.PI) / 180, el = (elevationDeg * Math.PI) / 180;
327+ return [dist * Math.sin(az) * Math.cos(el), dist * Math.sin(el), dist * Math.cos(az) * Math.cos(el)];
328+}
329+
320330 /** The plate node nearest to a point, by index; -1 when the plate has no nodes. */
321331 export function nearestNode(g: Grid, x: number, y: number): number {
322332 let nearest = -1, best = Infinity;
src/render/renderer.tsmodified+76−7View file
@@ -1,6 +1,9 @@
11 // WebGPU rendering of the plate: a 2D color map or a lit 3D height field of
22 // the displacement (plus the stress-free shape), colored by the displacement
3-// or by the thickness.
3+// or by the thickness. Both views paint the mount and the strike point on the
4+// surface as rings; the 3D view also stands a post at the mount, so that it
5+// stays visible when the ring is edge-on or lost on a steep bell, and stands a
6+// microphone on the line to the listener.
47
58 import { type Grid, cellMask, insideCell } from "../params.ts";
69 import { lookAt, multiply, perspective } from "./mat4.ts";
@@ -15,6 +18,32 @@ export interface Marker {
1518 r: number;
1619 }
1720
21+/** Vertices of the mount post: 24 segments of two side triangles, plus a fan over each cap (see shaders.wgsl). */
22+const MOUNT_VERTS = 24 * 6 + 2 * 24 * 3;
23+/** Vertices of the microphone: a 16 by 24 surface of revolution, plus a fan closing its back (see shaders.wgsl). */
24+const MIC_VERTS = 16 * 24 * 6 + 16 * 3;
25+/** Its widest radius, as a fraction of the longer side of the plate's bounding box. */
26+const MIC_RADIUS_FRACTION = 0.038;
27+
28+/**
29+ * How the microphone that stands for the listener is drawn, given the true
30+ * listener distance and the longer side of the plate's bounding box.
31+ *
32+ * A listener a meter from a 16 inch cymbal is four times as far away as the
33+ * cymbal is wide, so no framing can hold both at true scale; and the plate
34+ * already fills most of the view, which leaves only a thin annulus around it
35+ * for the microphone to stand in. The drawn distance therefore follows the
36+ * true one through a saturating map that stays inside that annulus, and the
37+ * microphone shrinks a little as the listener recedes, for the same reason a
38+ * distant object looks small. The direction is drawn faithfully; the distance
39+ * itself is the field in the Listener group, and it is that field, not this
40+ * drawing, that sets the 1/r spreading and the spread of the delays.
41+ */
42+export function drawnListener(dist: number, maxL: number): { at: number; radius: number } {
43+ const f = dist / (dist + maxL); // 0 at the plate, approaching 1 far away
44+ return { at: maxL * (0.26 + 0.36 * f), radius: MIC_RADIUS_FRACTION * maxL * (1.25 - 0.5 * f) };
45+}
46+
1847 export class Renderer {
1948 mode: ViewMode = "2d";
2049 /** Vertical exaggeration of the 3D view. */
@@ -24,10 +53,13 @@ export class Renderer {
2453 /** Show the total shape (initial shape plus deviation) rather than the deviation alone, in both views. */
2554 showShape = true;
2655 colorMode: ColorMode = "field";
27- camera = { yaw: 0.6, pitch: 0.5, dist: 1.0 };
28- /** Markers drawn in the 2D view; read at every render. */
56+ /** Orbit about the plate's center: yaw about the vertical, pitch above the plane of the plate. */
57+ camera = { yaw: 0.7, pitch: 0.5, dist: 0.95 };
58+ /** Markers drawn in both views; read at every render. */
2959 strike: Marker = { x: 0, y: 0, r: 0 };
3060 mount: Marker | null = null;
61+ /** Listener position in the plate's frame (z along the normal); the 3D view stands a microphone on that line. */
62+ listener: [number, number, number] | null = null;
3163
3264 private device: GPUDevice;
3365 private canvas: HTMLCanvasElement;
@@ -36,6 +68,8 @@ export class Renderer {
3668 private uniform: GPUBuffer;
3769 private pipe2d: GPURenderPipeline;
3870 private pipe3d: GPURenderPipeline;
71+ private pipe3dMount: GPURenderPipeline;
72+ private pipe3dListener: GPURenderPipeline;
3973 private bgLayout: GPUBindGroupLayout;
4074 private index: GPUBuffer | null = null;
4175 private indexCount = 0;
@@ -62,7 +96,7 @@ export class Renderer {
6296 this.ctx = canvas.getContext("webgpu") as GPUCanvasContext;
6397 this.format = navigator.gpu.getPreferredCanvasFormat();
6498 this.ctx.configure({ device, format: this.format, alphaMode: "opaque" });
65- this.uniform = device.createBuffer({ size: 160, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
99+ this.uniform = device.createBuffer({ size: 176, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // RU in shaders.wgsl: 44 f32
66100 const module = device.createShaderModule({ code: shaderCode });
67101 const vis = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT;
68102 this.bgLayout = device.createBindGroupLayout({
@@ -82,12 +116,27 @@ export class Renderer {
82116 fragment: { module, entryPoint: "fs2d", targets: [{ format: this.format }] },
83117 primitive: { topology: "triangle-list" },
84118 });
119+ const depthStencil: GPUDepthStencilState = { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" };
85120 this.pipe3d = device.createRenderPipeline({
86121 layout,
87122 vertex: { module, entryPoint: "vs3d" },
88123 fragment: { module, entryPoint: "fs3d", targets: [{ format: this.format }] },
89124 primitive: { topology: "triangle-list", cullMode: "none" },
90- depthStencil: { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" },
125+ depthStencil,
126+ });
127+ this.pipe3dMount = device.createRenderPipeline({
128+ layout,
129+ vertex: { module, entryPoint: "vs3dMount" },
130+ fragment: { module, entryPoint: "fs3dMount", targets: [{ format: this.format }] },
131+ primitive: { topology: "triangle-list", cullMode: "none" },
132+ depthStencil,
133+ });
134+ this.pipe3dListener = device.createRenderPipeline({
135+ layout,
136+ vertex: { module, entryPoint: "vs3dListener" },
137+ fragment: { module, entryPoint: "fs3dListener", targets: [{ format: this.format }] },
138+ primitive: { topology: "triangle-list", cullMode: "none" },
139+ depthStencil,
91140 });
92141 this.installControls();
93142 }
@@ -170,7 +219,7 @@ export class Renderer {
170219 const aspect = this.canvas.width / this.canvas.height;
171220 const auto = wmax + (this.showShape ? this.w0Max : 0);
172221 const range = this.colorRange > 0 ? this.colorRange : Math.max(auto, 1e-6);
173- const u = new Float32Array(40);
222+ const u = new Float32Array(44);
174223 const ui = new Uint32Array(u.buffer);
175224 const eye = [
176225 this.camera.dist * Math.cos(this.camera.pitch) * Math.sin(this.camera.yaw),
@@ -185,11 +234,20 @@ export class Renderer {
185234 ui[23] = this.mode === "3d" ? 1 : 0;
186235 u[24] = this.strike.x; u[25] = this.strike.y; u[26] = this.strike.r;
187236 u[27] = this.mount?.x ?? 0; u[28] = this.mount?.y ?? 0; u[29] = this.mount?.r ?? 0;
188- u[30] = 0.4; u[31] = 0.5; u[32] = 1.0;
237+ u[30] = 0.35; u[31] = 0.85; u[32] = 0.4; // light, from above and to one side
189238 ui[33] = this.showShape ? 1 : 0;
190239 ui[34] = this.colorMode === "thickness" ? 1 : 0;
191240 ui[35] = this.mount ? 1 : 0;
192241 u[36] = this.hRange[0]; u[37] = this.hRange[1];
242+ // Only the direction of the listener is drawn; its distance is far outside the framing of the plate.
243+ const L = this.listener;
244+ const Ln = L ? Math.hypot(L[0], L[1], L[2]) : 0;
245+ const drawListener = !!L && Ln > 0;
246+ if (drawListener) { u[38] = L![0] / Ln; u[39] = L![1] / Ln; u[40] = L![2] / Ln; }
247+ const mic = drawnListener(Ln, Math.max((this.grid.nx - 1) * this.grid.hx, (this.grid.ny - 1) * this.grid.hy));
248+ u[41] = mic.at;
249+ u[42] = mic.radius;
250+ ui[43] = drawListener ? 1 : 0;
193251 this.device.queue.writeBuffer(this.uniform, 0, u);
194252
195253 let bg = this.bindGroups.get(wBuffer);
@@ -224,6 +282,17 @@ export class Renderer {
224282 pass.setBindGroup(0, bg);
225283 pass.setIndexBuffer(this.index, "uint32");
226284 pass.drawIndexed(this.indexCount);
285+ if (this.mount) {
286+ // The post at the mount; the ring on the surface is painted by fs3d.
287+ pass.setPipeline(this.pipe3dMount);
288+ pass.setBindGroup(0, bg);
289+ pass.draw(MOUNT_VERTS);
290+ }
291+ if (drawListener) {
292+ pass.setPipeline(this.pipe3dListener);
293+ pass.setBindGroup(0, bg);
294+ pass.draw(MIC_VERTS);
295+ }
227296 pass.end();
228297 }
229298 }
src/render/shaders.wgslmodified+237−18View file
@@ -22,8 +22,12 @@ struct RU {
2222 mountOn: u32, // draw the mount marker
2323 hMin: f32, // thickness range for colorMode 1 [m]
2424 hMax: f32,
25- pad1: u32,
26- pad2: u32,
25+ listenerX: f32, // unit direction of the listener from the plate center
26+ listenerY: f32,
27+ listenerZ: f32,
28+ listenerAt: f32, // distance from the plate center at which it is drawn [m]
29+ listenerRadius: f32, // widest radius of the microphone [m]
30+ listenerOn: u32,
2731 };
2832
2933 @group(0) @binding(0) var<uniform> R: RU;
@@ -82,6 +86,19 @@ fn colorOf(val: f32, h: f32) -> vec3f {
8286 }
8387 // Height used by the 3D view: the same field as the color.
8488 fn zAt(i: i32, j: i32) -> f32 { return wAt(i, j); }
89+// Surface markers, painted on the plate in both views at the point p of the
90+// plate (meters from the center): the mount as a dark ring at its radius, the
91+// strike point as a yellow ring a little outside the tip.
92+fn markerColor(p: vec2f, col: vec3f) -> vec3f {
93+ var c = col;
94+ if (R.mountOn != 0u) {
95+ let dm = distance(p, vec2f(R.mountX, R.mountY));
96+ if (abs(dm - R.mountR) < 0.12 * R.mountR + 0.0015) { c = vec3f(0.1, 0.1, 0.1); }
97+ }
98+ let ds = distance(p, vec2f(R.strikeX, R.strikeY));
99+ if (abs(ds - R.strikeR * 1.6) < 0.25 * R.strikeR) { c = vec3f(0.95, 0.75, 0.1); }
100+ return c;
101+}
85102 // Slope along one axis, one-sided where a neighbor lies outside the plate.
86103 fn slope(i: i32, j: i32, di: i32, dj: i32, h: f32) -> f32 {
87104 let hasP = nwAt(i + di, j + dj) > 0.0;
@@ -127,20 +144,22 @@ fn fs2d(in: V2) -> @location(0) vec4f {
127144 let c = vec4f((1.0 - a) * (1.0 - b), a * (1.0 - b), (1.0 - a) * b, a * b);
128145 let val = dot(c, vec4f(wAt(i0, j0), wAt(i0 + 1, j0), wAt(i0, j0 + 1), wAt(i0 + 1, j0 + 1)));
129146 let h = dot(c, vec4f(hAt(i0, j0), hAt(i0 + 1, j0), hAt(i0, j0 + 1), hAt(i0 + 1, j0 + 1)));
130- var col = colorOf(val, h);
131- // Markers: the mount (dark ring) and the strike point (yellow ring).
132- if (R.mountOn != 0u) {
133- let dm = distance(vec2f(px, py), vec2f(R.mountX, R.mountY));
134- if (abs(dm - R.mountR) < 0.12 * R.mountR + 0.0015) { col = vec3f(0.1, 0.1, 0.1); }
135- }
136- let ds = distance(vec2f(px, py), vec2f(R.strikeX, R.strikeY));
137- if (abs(ds - R.strikeR * 1.6) < 0.25 * R.strikeR) { col = vec3f(0.95, 0.75, 0.1); }
147+ let col = markerColor(vec2f(px, py), colorOf(val, h));
138148 return vec4f(col, 1.0);
139149 }
140150
141151 // ---------- 3D view: height field ----------
142152
143-struct V3 { @builtin(position) pos: vec4f, @location(0) color: vec3f, @location(1) normal: vec3f };
153+struct V3 { @builtin(position) pos: vec4f, @location(0) color: vec3f, @location(1) normal: vec3f, @location(2) xy: vec2f };
154+
155+/**
156+ * The plate's frame placed in the world: x and y run along the bounding box
157+ * and h along the plate's normal, and the world has the normal pointing up, so
158+ * that a cymbal lies flat the way it sits on a stand rather than standing on
159+ * edge like a hanging sheet. The map is linear, so it carries vectors (the
160+ * normals, the axis of the microphone) as well as points.
161+ */
162+fn world(x: f32, y: f32, h: f32) -> vec3f { return vec3f(x, h, -y); }
144163
145164 @vertex
146165 fn vs3d(@builtin(vertex_index) vi: u32) -> V3 {
@@ -152,17 +171,217 @@ fn vs3d(@builtin(vertex_index) vi: u32) -> V3 {
152171 let dzdx = slope(i, j, 1, 0, R.hx) * R.wScale;
153172 let dzdy = slope(i, j, 0, 1, R.hy) * R.wScale;
154173 var o: V3;
155- o.pos = R.mvp * vec4f(x, y, z, 1.0);
174+ o.pos = R.mvp * vec4f(world(x, y, z), 1.0);
156175 o.color = colorOf(val, hAt(i, j));
157- o.normal = normalize(vec3f(-dzdx, -dzdy, 1.0));
176+ o.normal = world(-dzdx, -dzdy, 1.0);
177+ o.xy = vec2f(x, y);
158178 return o;
159179 }
160180
181+fn shade(col: vec3f, normal: vec3f) -> vec3f {
182+ let l = normalize(vec3f(R.lightX, R.lightY, R.lightZ));
183+ let diff = abs(dot(normalize(normal), l));
184+ return col * (0.35 + 0.65 * diff) + vec3f(0.15) * pow(diff, 24.0);
185+}
186+
161187 @fragment
162188 fn fs3d(in: V3) -> @location(0) vec4f {
163- let l = normalize(vec3f(R.lightX, R.lightY, R.lightZ));
164- let n = normalize(in.normal);
165- let diff = abs(dot(n, l));
166- let col = in.color * (0.35 + 0.65 * diff) + vec3f(0.15) * pow(diff, 24.0);
167- return vec4f(col, 1.0);
189+ // The markers are painted into the surface color, so the light falls on them
190+ // as it does on the plate and they read as part of the shape.
191+ return vec4f(shade(markerColor(in.xy, in.color), in.normal), 1.0);
192+}
193+
194+// ---------- 3D view: the mount, as a post through the plate ----------
195+//
196+// A small cylinder standing at the mount center, rising a little above the
197+// plate and reaching further below it, in the manner of the rod and felt of a
198+// stand. It is narrower than the mount radius, so it does not cover the ring
199+// the surface shader paints there, and it shows through the center hole from
200+// above, where the ring alone can be hard to place on a steep bell. Its size
201+// is a fixed fraction of the mount radius and means nothing physically; the
202+// mount itself is a spring and a dashpot spread over the nodes inside the ring.
203+
204+const MOUNT_SEG: u32 = 24u;
205+const MOUNT_CAP: u32 = MOUNT_SEG * 3u;
206+const MOUNT_SIDE: u32 = MOUNT_SEG * 6u;
207+/** Vertices of the post: the sides, then a fan over each cap. */
208+const MOUNT_VERTS: u32 = MOUNT_SIDE + 2u * MOUNT_CAP;
209+const TAU: f32 = 6.28318530718;
210+
211+/**
212+ * The height of the displayed surface over the mount, as the weighted mean of
213+ * the plate nodes inside the mount radius. A mean rather than the value at the
214+ * center, since on a cymbal with a center hole the mount center lies in the
215+ * hole, where there is no plate to read.
216+ */
217+fn mountTop() -> f32 {
218+ let ci = R.mountX / R.hx + 0.5 * f32(R.nx - 1u);
219+ let cj = R.mountY / R.hy + 0.5 * f32(R.ny - 1u);
220+ let ri = i32(ceil(R.mountR / R.hx)) + 1;
221+ let rj = i32(ceil(R.mountR / R.hy)) + 1;
222+ var sum = 0.0;
223+ var wgt = 0.0;
224+ for (var j = i32(round(cj)) - rj; j <= i32(round(cj)) + rj; j = j + 1) {
225+ for (var i = i32(round(ci)) - ri; i <= i32(round(ci)) + ri; i = i + 1) {
226+ let m = nwAt(i, j);
227+ if (m <= 0.0) { continue; }
228+ let dx = (f32(i) - 0.5 * f32(R.nx - 1u)) * R.hx - R.mountX;
229+ let dy = (f32(j) - 0.5 * f32(R.ny - 1u)) * R.hy - R.mountY;
230+ if (dx * dx + dy * dy > R.mountR * R.mountR) { continue; }
231+ sum = sum + m * zAt(i, j);
232+ wgt = wgt + m;
233+ }
234+ }
235+ if (wgt <= 0.0) { return 0.0; }
236+ return sum / wgt * R.wScale;
237+}
238+
239+@vertex
240+fn vs3dMount(@builtin(vertex_index) vi: u32) -> V3 {
241+ let surf = mountTop();
242+ let hi = surf + 0.6 * R.mountR;
243+ let lo = surf - 1.4 * R.mountR;
244+ let rad = 0.45 * R.mountR;
245+ var ang = 0.0;
246+ var rr = rad;
247+ var z = lo;
248+ var normal = vec3f(0.0, 0.0, -1.0);
249+ if (vi < MOUNT_SIDE) {
250+ let seg = vi / 6u;
251+ let k = vi % 6u;
252+ // Two triangles per segment: (hi a0, lo a0, lo a1), (hi a0, lo a1, hi a1).
253+ var firstEdge = array<bool, 6>(true, true, false, true, false, false);
254+ var atTop = array<bool, 6>(true, false, false, true, false, true);
255+ let a0 = TAU * f32(seg) / f32(MOUNT_SEG);
256+ let a1 = TAU * f32(seg + 1u) / f32(MOUNT_SEG);
257+ ang = select(a1, a0, firstEdge[k]);
258+ z = select(lo, hi, atTop[k]);
259+ normal = vec3f(cos(ang), sin(ang), 0.0);
260+ } else {
261+ let m = vi - MOUNT_SIDE;
262+ let top = m >= MOUNT_CAP;
263+ let n = m % MOUNT_CAP;
264+ let seg = n / 3u;
265+ let k = n % 3u;
266+ let a0 = TAU * f32(seg) / f32(MOUNT_SEG);
267+ let a1 = TAU * f32(seg + 1u) / f32(MOUNT_SEG);
268+ ang = select(a0, a1, k == 2u);
269+ rr = select(rad, 0.0, k == 0u); // the fan's hub
270+ z = select(lo, hi, top);
271+ normal = vec3f(0.0, 0.0, select(-1.0, 1.0, top));
272+ }
273+ let x = R.mountX + rr * cos(ang);
274+ let y = R.mountY + rr * sin(ang);
275+ var o: V3;
276+ o.pos = R.mvp * vec4f(world(x, y, z), 1.0);
277+ o.color = vec3f(0.42, 0.39, 0.36);
278+ o.normal = world(normal.x, normal.y, normal.z);
279+ o.xy = vec2f(x, y);
280+ return o;
281+}
282+
283+@fragment
284+fn fs3dMount(in: V3) -> @location(0) vec4f {
285+ return vec4f(shade(in.color, in.normal), 1.0);
286+}
287+
288+// ---------- 3D view: the listener, as a microphone ----------
289+//
290+// A microphone standing on the line from the center of the plate to the
291+// listener, its grille facing the plate. It is a surface of revolution about
292+// that line, drawn as a stack of bands from a profile of radius and color.
293+//
294+// The microphone is drawn at a fixed size, and nearer than the listener really
295+// is: a listener a meter from a 16 inch cymbal cannot share a framing with it,
296+// so the drawn distance follows the true one through a saturating map (see
297+// drawnListenerDistance in renderer.ts). It therefore shows the direction
298+// faithfully and the distance only in the sense of moving out as the listener
299+// recedes; the distance that enters the sound is the field in the Listener
300+// group, through the 1/r spreading and the spread of the propagation delays.
301+
302+const MIC_SEG: u32 = 16u; // segments around the axis
303+const MIC_BANDS: u32 = 24u; // bands along it
304+const MIC_SIDE: u32 = MIC_SEG * MIC_BANDS * 6u;
305+/** Vertices of the microphone: the surface of revolution, then a fan closing its back. */
306+const MIC_VERTS: u32 = MIC_SIDE + MIC_SEG * 3u;
307+const TAU_MIC: f32 = 6.28318530718;
308+/** Length of the body as a multiple of its widest radius. */
309+const MIC_ASPECT: f32 = 4.2;
310+
311+/** Radius at t along the microphone, t = 0 at the nose facing the plate and 1 at the back, in fractions of its widest radius. */
312+fn micRadius(t: f32) -> f32 {
313+ if (t < 0.18) {
314+ let q = (0.18 - t) / 0.18;
315+ return 0.95 * sqrt(max(0.0, 1.0 - q * q)); // the rounded grille
316+ }
317+ if (t < 0.52) { return 0.95; } // the grille
318+ if (t < 0.60) { return 1.0; } // the collar behind it
319+ return 0.80; // the body
320+}
321+
322+fn micColor(t: f32) -> vec3f {
323+ if (t < 0.52) { return vec3f(0.15, 0.16, 0.18); }
324+ if (t < 0.60) { return vec3f(0.52, 0.52, 0.54); }
325+ return vec3f(0.62, 0.64, 0.67);
326+}
327+
328+/** Any right-handed frame whose third axis is u. */
329+fn frameOf(u: vec3f) -> mat3x3f {
330+ var t = vec3f(0.0, 0.0, 1.0);
331+ if (abs(u.z) > 0.9) { t = vec3f(1.0, 0.0, 0.0); }
332+ let a = normalize(cross(t, u));
333+ return mat3x3f(a, cross(u, a), u);
334+}
335+
336+@vertex
337+fn vs3dListener(@builtin(vertex_index) vi: u32) -> V3 {
338+ let u = normalize(vec3f(R.listenerX, R.listenerY, R.listenerZ));
339+ let F = frameOf(u);
340+ let rad = R.listenerRadius;
341+ let len = MIC_ASPECT * rad;
342+ var t = 1.0;
343+ var ang = 0.0;
344+ var rr = 0.0;
345+ if (vi < MIC_SIDE) {
346+ let band = (vi / 6u) / MIC_SEG;
347+ let seg = (vi / 6u) % MIC_SEG;
348+ let k = vi % 6u;
349+ var firstEdge = array<bool, 6>(true, true, false, true, false, false);
350+ var atBack = array<bool, 6>(true, false, false, true, false, true);
351+ let a0 = TAU_MIC * f32(seg) / f32(MIC_SEG);
352+ let a1 = TAU_MIC * f32(seg + 1u) / f32(MIC_SEG);
353+ ang = select(a1, a0, firstEdge[k]);
354+ let t0 = f32(band) / f32(MIC_BANDS);
355+ let t1 = f32(band + 1u) / f32(MIC_BANDS);
356+ t = select(t0, t1, atBack[k]);
357+ rr = micRadius(t);
358+ } else {
359+ // The disc closing the back.
360+ let n = vi - MIC_SIDE;
361+ let seg = n / 3u;
362+ let k = n % 3u;
363+ let a0 = TAU_MIC * f32(seg) / f32(MIC_SEG);
364+ let a1 = TAU_MIC * f32(seg + 1u) / f32(MIC_SEG);
365+ ang = select(a0, a1, k == 2u);
366+ rr = select(micRadius(1.0), 0.0, k == 0u);
367+ }
368+ // The nose sits at the drawn distance; the body extends away from the plate.
369+ let p = F * vec3f(rad * rr * cos(ang), rad * rr * sin(ang), R.listenerAt + len * t);
370+ // Normal of a surface of revolution: perpendicular to the slope of the profile.
371+ let eps = 0.5 / f32(MIC_BANDS);
372+ let slope = (micRadius(min(1.0, t + eps)) - micRadius(max(0.0, t - eps))) / (2.0 * eps) / MIC_ASPECT;
373+ var normal = normalize(vec3f(cos(ang), sin(ang), -slope));
374+ if (vi >= MIC_SIDE) { normal = vec3f(0.0, 0.0, 1.0); }
375+ let n = F * normal;
376+ var o: V3;
377+ o.pos = R.mvp * vec4f(world(p.x, p.y, p.z), 1.0);
378+ o.color = micColor(t);
379+ o.normal = world(n.x, n.y, n.z);
380+ o.xy = p.xy;
381+ return o;
382+}
383+
384+@fragment
385+fn fs3dListener(in: V3) -> @location(0) vec4f {
386+ return vec4f(shade(in.color, in.normal), 1.0);
168387 }
src/sim/simulation.tsmodified+2−3View file
@@ -21,6 +21,7 @@ import {
2121 thicknessRange,
2222 nodeX,
2323 nodeY,
24+ listenerPoint,
2425 nodeWeight,
2526 nodeWeights,
2627 CFL,
@@ -336,9 +337,7 @@ export class Simulation {
336337 /** Recomputes the per-node weights and delays of the listener sum. Takes effect on later samples. */
337338 setListener(dist: number, azimuthDeg: number, elevationDeg: number): void {
338339 const g = this.grid;
339- const az = (azimuthDeg * Math.PI) / 180;
340- const el = (elevationDeg * Math.PI) / 180;
341- const L = [dist * Math.sin(az) * Math.cos(el), dist * Math.sin(el), dist * Math.cos(az) * Math.cos(el)];
340+ const L = listenerPoint(dist, azimuthDeg, elevationDeg);
342341 const data = new Float32Array(2 * g.n);
343342 let dmin = Infinity;
344343 for (let j = 0; j < g.ny; j++) {
↑↓ move↵openescclose