1struct RU {
2 mvp: mat4x4f,
3 nx: u32,
4 ny: u32,
5 hx: f32,
6 hy: f32,
7 wScale: f32, // vertical exaggeration in the 3D view
8 colorRange: f32, // |w| mapped to full color [m]
9 aspect: f32, // canvas width / height
10 mode: u32, // 0: 2D, 1: 3D
11 strikeX: f32,
12 strikeY: f32,
13 strikeR: f32,
14 mountX: f32,
15 mountY: f32,
16 mountR: f32,
17 lightX: f32,
18 lightY: f32,
19 lightZ: f32,
20 showShape: u32, // add the stress-free initial shape to the displayed field (both views)
21 colorMode: u32, // 0: color by the displayed field, 1: color by the thickness
22 mountOn: u32, // draw the mount marker
23 hMin: f32, // thickness range for colorMode 1 [m]
24 hMax: f32,
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,
31};
33@group(0) @binding(0) var<uniform> R: RU;
34@group(0) @binding(1) var<storage, read> w: array<f32>;
35@group(0) @binding(2) var<storage, read> w0: array<f32>;
36@group(0) @binding(3) var<storage, read> nw: array<f32>; // node weights, 0 outside the plate
37@group(0) @binding(4) var<storage, read> hn: array<f32>; // thickness per node
38@group(0) @binding(5) var<storage, read> cm: array<f32>; // cell membership, (nx - 1) * (ny - 1)
40const BG = vec3f(0.11, 0.12, 0.14);
42// Diverging map for the displacement: blue, light gray, red.
43fn colormap(t: f32) -> vec3f {
44 let s = clamp(t, -1.0, 1.0);
45 let mid = vec3f(0.93, 0.93, 0.93);
46 let blue = vec3f(0.10, 0.35, 0.85);
47 let red = vec3f(0.85, 0.20, 0.12);
48 if (s < 0.0) { return mix(mid, blue, -s); }
49 return mix(mid, red, s);
50}
51// Sequential map for the thickness: dark teal through green to pale yellow.
52fn thickmap(t: f32) -> vec3f {
53 let s = clamp(t, 0.0, 1.0);
54 let a = vec3f(0.13, 0.22, 0.40);
55 let b = vec3f(0.20, 0.60, 0.55);
56 let c = vec3f(0.98, 0.93, 0.55);
57 if (s < 0.5) { return mix(a, b, 2.0 * s); }
58 return mix(b, c, 2.0 * s - 1.0);
59}
61fn idx(i: i32, j: i32) -> u32 {
62 let ii = clamp(i, 0, i32(R.nx) - 1);
63 let jj = clamp(j, 0, i32(R.ny) - 1);
64 return u32(jj) * R.nx + u32(ii);
65}
66// Displayed field: the deviation w, plus the initial shape when requested (the total shape).
67fn wAt(i: i32, j: i32) -> f32 {
68 let k = idx(i, j);
69 var z = w[k];
70 if (R.showShape != 0u) { z += w0[k]; }
71 return z;
72}
73fn hAt(i: i32, j: i32) -> f32 { return hn[idx(i, j)]; }
74fn nwAt(i: i32, j: i32) -> f32 {
75 if (i < 0 || i >= i32(R.nx) || j < 0 || j >= i32(R.ny)) { return 0.0; }
76 return nw[u32(j) * R.nx + u32(i)];
77}
78fn cellAt(ci: i32, cj: i32) -> f32 {
79 if (ci < 0 || ci >= i32(R.nx) - 1 || cj < 0 || cj >= i32(R.ny) - 1) { return 0.0; }
80 return cm[u32(cj) * (R.nx - 1u) + u32(ci)];
81}
82// Color of a node from the displayed field value and the thickness.
83fn colorOf(val: f32, h: f32) -> vec3f {
84 if (R.colorMode == 1u) { return thickmap((h - R.hMin) / max(R.hMax - R.hMin, 1e-9)); }
85 return colormap(val / R.colorRange);
86}
87// Height used by the 3D view: the same field as the color.
88fn 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.
92fn 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}
102// Slope along one axis, one-sided where a neighbor lies outside the plate.
103fn slope(i: i32, j: i32, di: i32, dj: i32, h: f32) -> f32 {
104 let hasP = nwAt(i + di, j + dj) > 0.0;
105 let hasM = nwAt(i - di, j - dj) > 0.0;
106 if (hasP && hasM) { return (zAt(i + di, j + dj) - zAt(i - di, j - dj)) / (2.0 * h); }
107 if (hasP) { return (zAt(i + di, j + dj) - zAt(i, j)) / h; }
108 if (hasM) { return (zAt(i, j) - zAt(i - di, j - dj)) / h; }
109 return 0.0;
110}
112// ---------- 2D view: full-screen triangle, bilinear lookup in the fragment shader ----------
114struct V2 { @builtin(position) pos: vec4f, @location(0) uv: vec2f };
116@vertex
117fn vs2d(@builtin(vertex_index) vi: u32) -> V2 {
118 var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
119 var o: V2;
120 o.pos = vec4f(p[vi], 0.0, 1.0);
121 o.uv = p[vi];
122 return o;
123}
125@fragment
126fn fs2d(in: V2) -> @location(0) vec4f {
127 let Lx = f32(R.nx - 1u) * R.hx;
128 let Ly = f32(R.ny - 1u) * R.hy;
129 // Fit the plate into the canvas with a margin, preserving its aspect ratio.
130 let margin = 0.92;
131 var sx = margin; var sy = margin;
132 let plateAspect = Lx / Ly;
133 if (plateAspect > R.aspect) { sy = margin * R.aspect / plateAspect; } else { sx = margin * plateAspect / R.aspect; }
134 let px = in.uv.x / sx * 0.5 * Lx;
135 let py = in.uv.y / sy * 0.5 * Ly;
136 let bg = vec4f(BG, 1.0);
137 if (abs(px) > 0.5 * Lx || abs(py) > 0.5 * Ly) { return bg; }
138 let fi = (px + 0.5 * Lx) / R.hx;
139 let fj = (py + 0.5 * Ly) / R.hy;
140 let i0 = min(i32(floor(fi)), i32(R.nx) - 2); let j0 = min(i32(floor(fj)), i32(R.ny) - 2);
141 // Only the cells of the plate are drawn (staircase edge, hole included).
142 if (cellAt(i0, j0) == 0.0) { return bg; }
143 let a = fi - f32(i0); let b = fj - f32(j0);
144 let c = vec4f((1.0 - a) * (1.0 - b), a * (1.0 - b), (1.0 - a) * b, a * b);
145 let val = dot(c, vec4f(wAt(i0, j0), wAt(i0 + 1, j0), wAt(i0, j0 + 1), wAt(i0 + 1, j0 + 1)));
146 let h = dot(c, vec4f(hAt(i0, j0), hAt(i0 + 1, j0), hAt(i0, j0 + 1), hAt(i0 + 1, j0 + 1)));
147 let col = markerColor(vec2f(px, py), colorOf(val, h));
148 return vec4f(col, 1.0);
149}
151// ---------- 3D view: height field ----------
153struct V3 { @builtin(position) pos: vec4f, @location(0) color: vec3f, @location(1) normal: vec3f, @location(2) xy: vec2f };
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 */
162fn world(x: f32, y: f32, h: f32) -> vec3f { return vec3f(x, h, -y); }
164@vertex
165fn vs3d(@builtin(vertex_index) vi: u32) -> V3 {
166 let i = i32(vi % R.nx); let j = i32(vi / R.nx);
167 let x = (f32(i) - 0.5 * f32(R.nx - 1u)) * R.hx;
168 let y = (f32(j) - 0.5 * f32(R.ny - 1u)) * R.hy;
169 let val = wAt(i, j);
170 let z = zAt(i, j) * R.wScale;
171 let dzdx = slope(i, j, 1, 0, R.hx) * R.wScale;
172 let dzdy = slope(i, j, 0, 1, R.hy) * R.wScale;
173 var o: V3;
174 o.pos = R.mvp * vec4f(world(x, y, z), 1.0);
175 o.color = colorOf(val, hAt(i, j));
176 o.normal = world(-dzdx, -dzdy, 1.0);
177 o.xy = vec2f(x, y);
178 return o;
179}
181fn 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}
187@fragment
188fn fs3d(in: V3) -> @location(0) vec4f {
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}
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.
204const MOUNT_SEG: u32 = 24u;
205const MOUNT_CAP: u32 = MOUNT_SEG * 3u;
206const MOUNT_SIDE: u32 = MOUNT_SEG * 6u;
207/** Vertices of the post: the sides, then a fan over each cap. */
208const MOUNT_VERTS: u32 = MOUNT_SIDE + 2u * MOUNT_CAP;
209const TAU: f32 = 6.28318530718;
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 */
217fn 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}
239@vertex
240fn 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}
283@fragment
284fn fs3dMount(in: V3) -> @location(0) vec4f {
285 return vec4f(shade(in.color, in.normal), 1.0);
286}
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.
302const MIC_SEG: u32 = 16u; // segments around the axis
303const MIC_BANDS: u32 = 24u; // bands along it
304const MIC_SIDE: u32 = MIC_SEG * MIC_BANDS * 6u;
305/** Vertices of the microphone: the surface of revolution, then a fan closing its back. */
306const MIC_VERTS: u32 = MIC_SIDE + MIC_SEG * 3u;
307const TAU_MIC: f32 = 6.28318530718;
308/** Length of the body as a multiple of its widest radius. */
309const MIC_ASPECT: f32 = 4.2;
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. */
312fn 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}
322fn 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}
328/** Any right-handed frame whose third axis is u. */
329fn 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}
336@vertex
337fn 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}
384@fragment
385fn fs3dListener(in: V3) -> @location(0) vec4f {
386 return vec4f(shade(in.color, in.normal), 1.0);
387}