struct RU { mvp: mat4x4f, nx: u32, ny: u32, hx: f32, hy: f32, wScale: f32, // vertical exaggeration in the 3D view colorRange: f32, // |w| mapped to full color [m] aspect: f32, // canvas width / height mode: u32, // 0: 2D, 1: 3D strikeX: f32, strikeY: f32, strikeR: f32, mountX: f32, mountY: f32, mountR: f32, lightX: f32, lightY: f32, lightZ: f32, showShape: u32, // add the stress-free initial shape to the displayed field (both views) colorMode: u32, // 0: color by the displayed field, 1: color by the thickness mountOn: u32, // draw the mount marker hMin: f32, // thickness range for colorMode 1 [m] hMax: f32, listenerX: f32, // unit direction of the listener from the plate center listenerY: f32, listenerZ: f32, listenerAt: f32, // distance from the plate center at which it is drawn [m] listenerRadius: f32, // widest radius of the microphone [m] listenerOn: u32, }; @group(0) @binding(0) var R: RU; @group(0) @binding(1) var w: array; @group(0) @binding(2) var w0: array; @group(0) @binding(3) var nw: array; // node weights, 0 outside the plate @group(0) @binding(4) var hn: array; // thickness per node @group(0) @binding(5) var cm: array; // cell membership, (nx - 1) * (ny - 1) const BG = vec3f(0.11, 0.12, 0.14); // Diverging map for the displacement: blue, light gray, red. fn colormap(t: f32) -> vec3f { let s = clamp(t, -1.0, 1.0); let mid = vec3f(0.93, 0.93, 0.93); let blue = vec3f(0.10, 0.35, 0.85); let red = vec3f(0.85, 0.20, 0.12); if (s < 0.0) { return mix(mid, blue, -s); } return mix(mid, red, s); } // Sequential map for the thickness: dark teal through green to pale yellow. fn thickmap(t: f32) -> vec3f { let s = clamp(t, 0.0, 1.0); let a = vec3f(0.13, 0.22, 0.40); let b = vec3f(0.20, 0.60, 0.55); let c = vec3f(0.98, 0.93, 0.55); if (s < 0.5) { return mix(a, b, 2.0 * s); } return mix(b, c, 2.0 * s - 1.0); } fn idx(i: i32, j: i32) -> u32 { let ii = clamp(i, 0, i32(R.nx) - 1); let jj = clamp(j, 0, i32(R.ny) - 1); return u32(jj) * R.nx + u32(ii); } // Displayed field: the deviation w, plus the initial shape when requested (the total shape). fn wAt(i: i32, j: i32) -> f32 { let k = idx(i, j); var z = w[k]; if (R.showShape != 0u) { z += w0[k]; } return z; } fn hAt(i: i32, j: i32) -> f32 { return hn[idx(i, j)]; } fn nwAt(i: i32, j: i32) -> f32 { if (i < 0 || i >= i32(R.nx) || j < 0 || j >= i32(R.ny)) { return 0.0; } return nw[u32(j) * R.nx + u32(i)]; } fn cellAt(ci: i32, cj: i32) -> f32 { if (ci < 0 || ci >= i32(R.nx) - 1 || cj < 0 || cj >= i32(R.ny) - 1) { return 0.0; } return cm[u32(cj) * (R.nx - 1u) + u32(ci)]; } // Color of a node from the displayed field value and the thickness. fn colorOf(val: f32, h: f32) -> vec3f { if (R.colorMode == 1u) { return thickmap((h - R.hMin) / max(R.hMax - R.hMin, 1e-9)); } return colormap(val / R.colorRange); } // Height used by the 3D view: the same field as the color. fn zAt(i: i32, j: i32) -> f32 { return wAt(i, j); } // Surface markers, painted on the plate in both views at the point p of the // plate (meters from the center): the mount as a dark ring at its radius, the // strike point as a yellow ring a little outside the tip. fn markerColor(p: vec2f, col: vec3f) -> vec3f { var c = col; if (R.mountOn != 0u) { let dm = distance(p, vec2f(R.mountX, R.mountY)); if (abs(dm - R.mountR) < 0.12 * R.mountR + 0.0015) { c = vec3f(0.1, 0.1, 0.1); } } let ds = distance(p, vec2f(R.strikeX, R.strikeY)); if (abs(ds - R.strikeR * 1.6) < 0.25 * R.strikeR) { c = vec3f(0.95, 0.75, 0.1); } return c; } // Slope along one axis, one-sided where a neighbor lies outside the plate. fn slope(i: i32, j: i32, di: i32, dj: i32, h: f32) -> f32 { let hasP = nwAt(i + di, j + dj) > 0.0; let hasM = nwAt(i - di, j - dj) > 0.0; if (hasP && hasM) { return (zAt(i + di, j + dj) - zAt(i - di, j - dj)) / (2.0 * h); } if (hasP) { return (zAt(i + di, j + dj) - zAt(i, j)) / h; } if (hasM) { return (zAt(i, j) - zAt(i - di, j - dj)) / h; } return 0.0; } // ---------- 2D view: full-screen triangle, bilinear lookup in the fragment shader ---------- struct V2 { @builtin(position) pos: vec4f, @location(0) uv: vec2f }; @vertex fn vs2d(@builtin(vertex_index) vi: u32) -> V2 { var p = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0)); var o: V2; o.pos = vec4f(p[vi], 0.0, 1.0); o.uv = p[vi]; return o; } @fragment fn fs2d(in: V2) -> @location(0) vec4f { let Lx = f32(R.nx - 1u) * R.hx; let Ly = f32(R.ny - 1u) * R.hy; // Fit the plate into the canvas with a margin, preserving its aspect ratio. let margin = 0.92; var sx = margin; var sy = margin; let plateAspect = Lx / Ly; if (plateAspect > R.aspect) { sy = margin * R.aspect / plateAspect; } else { sx = margin * plateAspect / R.aspect; } let px = in.uv.x / sx * 0.5 * Lx; let py = in.uv.y / sy * 0.5 * Ly; let bg = vec4f(BG, 1.0); if (abs(px) > 0.5 * Lx || abs(py) > 0.5 * Ly) { return bg; } let fi = (px + 0.5 * Lx) / R.hx; let fj = (py + 0.5 * Ly) / R.hy; let i0 = min(i32(floor(fi)), i32(R.nx) - 2); let j0 = min(i32(floor(fj)), i32(R.ny) - 2); // Only the cells of the plate are drawn (staircase edge, hole included). if (cellAt(i0, j0) == 0.0) { return bg; } let a = fi - f32(i0); let b = fj - f32(j0); let c = vec4f((1.0 - a) * (1.0 - b), a * (1.0 - b), (1.0 - a) * b, a * b); let val = dot(c, vec4f(wAt(i0, j0), wAt(i0 + 1, j0), wAt(i0, j0 + 1), wAt(i0 + 1, j0 + 1))); let h = dot(c, vec4f(hAt(i0, j0), hAt(i0 + 1, j0), hAt(i0, j0 + 1), hAt(i0 + 1, j0 + 1))); let col = markerColor(vec2f(px, py), colorOf(val, h)); return vec4f(col, 1.0); } // ---------- 3D view: height field ---------- struct V3 { @builtin(position) pos: vec4f, @location(0) color: vec3f, @location(1) normal: vec3f, @location(2) xy: vec2f }; /** * The plate's frame placed in the world: x and y run along the bounding box * and h along the plate's normal, and the world has the normal pointing up, so * that a cymbal lies flat the way it sits on a stand rather than standing on * edge like a hanging sheet. The map is linear, so it carries vectors (the * normals, the axis of the microphone) as well as points. */ fn world(x: f32, y: f32, h: f32) -> vec3f { return vec3f(x, h, -y); } @vertex fn vs3d(@builtin(vertex_index) vi: u32) -> V3 { let i = i32(vi % R.nx); let j = i32(vi / R.nx); let x = (f32(i) - 0.5 * f32(R.nx - 1u)) * R.hx; let y = (f32(j) - 0.5 * f32(R.ny - 1u)) * R.hy; let val = wAt(i, j); let z = zAt(i, j) * R.wScale; let dzdx = slope(i, j, 1, 0, R.hx) * R.wScale; let dzdy = slope(i, j, 0, 1, R.hy) * R.wScale; var o: V3; o.pos = R.mvp * vec4f(world(x, y, z), 1.0); o.color = colorOf(val, hAt(i, j)); o.normal = world(-dzdx, -dzdy, 1.0); o.xy = vec2f(x, y); return o; } fn shade(col: vec3f, normal: vec3f) -> vec3f { let l = normalize(vec3f(R.lightX, R.lightY, R.lightZ)); let diff = abs(dot(normalize(normal), l)); return col * (0.35 + 0.65 * diff) + vec3f(0.15) * pow(diff, 24.0); } @fragment fn fs3d(in: V3) -> @location(0) vec4f { // The markers are painted into the surface color, so the light falls on them // as it does on the plate and they read as part of the shape. return vec4f(shade(markerColor(in.xy, in.color), in.normal), 1.0); } // ---------- 3D view: the mount, as a post through the plate ---------- // // A small cylinder standing at the mount center, rising a little above the // plate and reaching further below it, in the manner of the rod and felt of a // stand. It is narrower than the mount radius, so it does not cover the ring // the surface shader paints there, and it shows through the center hole from // above, where the ring alone can be hard to place on a steep bell. Its size // is a fixed fraction of the mount radius and means nothing physically; the // mount itself is a spring and a dashpot spread over the nodes inside the ring. const MOUNT_SEG: u32 = 24u; const MOUNT_CAP: u32 = MOUNT_SEG * 3u; const MOUNT_SIDE: u32 = MOUNT_SEG * 6u; /** Vertices of the post: the sides, then a fan over each cap. */ const MOUNT_VERTS: u32 = MOUNT_SIDE + 2u * MOUNT_CAP; const TAU: f32 = 6.28318530718; /** * The height of the displayed surface over the mount, as the weighted mean of * the plate nodes inside the mount radius. A mean rather than the value at the * center, since on a cymbal with a center hole the mount center lies in the * hole, where there is no plate to read. */ fn mountTop() -> f32 { let ci = R.mountX / R.hx + 0.5 * f32(R.nx - 1u); let cj = R.mountY / R.hy + 0.5 * f32(R.ny - 1u); let ri = i32(ceil(R.mountR / R.hx)) + 1; let rj = i32(ceil(R.mountR / R.hy)) + 1; var sum = 0.0; var wgt = 0.0; for (var j = i32(round(cj)) - rj; j <= i32(round(cj)) + rj; j = j + 1) { for (var i = i32(round(ci)) - ri; i <= i32(round(ci)) + ri; i = i + 1) { let m = nwAt(i, j); if (m <= 0.0) { continue; } let dx = (f32(i) - 0.5 * f32(R.nx - 1u)) * R.hx - R.mountX; let dy = (f32(j) - 0.5 * f32(R.ny - 1u)) * R.hy - R.mountY; if (dx * dx + dy * dy > R.mountR * R.mountR) { continue; } sum = sum + m * zAt(i, j); wgt = wgt + m; } } if (wgt <= 0.0) { return 0.0; } return sum / wgt * R.wScale; } @vertex fn vs3dMount(@builtin(vertex_index) vi: u32) -> V3 { let surf = mountTop(); let hi = surf + 0.6 * R.mountR; let lo = surf - 1.4 * R.mountR; let rad = 0.45 * R.mountR; var ang = 0.0; var rr = rad; var z = lo; var normal = vec3f(0.0, 0.0, -1.0); if (vi < MOUNT_SIDE) { let seg = vi / 6u; let k = vi % 6u; // Two triangles per segment: (hi a0, lo a0, lo a1), (hi a0, lo a1, hi a1). var firstEdge = array(true, true, false, true, false, false); var atTop = array(true, false, false, true, false, true); let a0 = TAU * f32(seg) / f32(MOUNT_SEG); let a1 = TAU * f32(seg + 1u) / f32(MOUNT_SEG); ang = select(a1, a0, firstEdge[k]); z = select(lo, hi, atTop[k]); normal = vec3f(cos(ang), sin(ang), 0.0); } else { let m = vi - MOUNT_SIDE; let top = m >= MOUNT_CAP; let n = m % MOUNT_CAP; let seg = n / 3u; let k = n % 3u; let a0 = TAU * f32(seg) / f32(MOUNT_SEG); let a1 = TAU * f32(seg + 1u) / f32(MOUNT_SEG); ang = select(a0, a1, k == 2u); rr = select(rad, 0.0, k == 0u); // the fan's hub z = select(lo, hi, top); normal = vec3f(0.0, 0.0, select(-1.0, 1.0, top)); } let x = R.mountX + rr * cos(ang); let y = R.mountY + rr * sin(ang); var o: V3; o.pos = R.mvp * vec4f(world(x, y, z), 1.0); o.color = vec3f(0.42, 0.39, 0.36); o.normal = world(normal.x, normal.y, normal.z); o.xy = vec2f(x, y); return o; } @fragment fn fs3dMount(in: V3) -> @location(0) vec4f { return vec4f(shade(in.color, in.normal), 1.0); } // ---------- 3D view: the listener, as a microphone ---------- // // A microphone standing on the line from the center of the plate to the // listener, its grille facing the plate. It is a surface of revolution about // that line, drawn as a stack of bands from a profile of radius and color. // // The microphone is drawn at a fixed size, and nearer than the listener really // is: a listener a meter from a 16 inch cymbal cannot share a framing with it, // so the drawn distance follows the true one through a saturating map (see // drawnListenerDistance in renderer.ts). It therefore shows the direction // faithfully and the distance only in the sense of moving out as the listener // recedes; the distance that enters the sound is the field in the Listener // group, through the 1/r spreading and the spread of the propagation delays. const MIC_SEG: u32 = 16u; // segments around the axis const MIC_BANDS: u32 = 24u; // bands along it const MIC_SIDE: u32 = MIC_SEG * MIC_BANDS * 6u; /** Vertices of the microphone: the surface of revolution, then a fan closing its back. */ const MIC_VERTS: u32 = MIC_SIDE + MIC_SEG * 3u; const TAU_MIC: f32 = 6.28318530718; /** Length of the body as a multiple of its widest radius. */ const MIC_ASPECT: f32 = 4.2; /** 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. */ fn micRadius(t: f32) -> f32 { if (t < 0.18) { let q = (0.18 - t) / 0.18; return 0.95 * sqrt(max(0.0, 1.0 - q * q)); // the rounded grille } if (t < 0.52) { return 0.95; } // the grille if (t < 0.60) { return 1.0; } // the collar behind it return 0.80; // the body } fn micColor(t: f32) -> vec3f { if (t < 0.52) { return vec3f(0.15, 0.16, 0.18); } if (t < 0.60) { return vec3f(0.52, 0.52, 0.54); } return vec3f(0.62, 0.64, 0.67); } /** Any right-handed frame whose third axis is u. */ fn frameOf(u: vec3f) -> mat3x3f { var t = vec3f(0.0, 0.0, 1.0); if (abs(u.z) > 0.9) { t = vec3f(1.0, 0.0, 0.0); } let a = normalize(cross(t, u)); return mat3x3f(a, cross(u, a), u); } @vertex fn vs3dListener(@builtin(vertex_index) vi: u32) -> V3 { let u = normalize(vec3f(R.listenerX, R.listenerY, R.listenerZ)); let F = frameOf(u); let rad = R.listenerRadius; let len = MIC_ASPECT * rad; var t = 1.0; var ang = 0.0; var rr = 0.0; if (vi < MIC_SIDE) { let band = (vi / 6u) / MIC_SEG; let seg = (vi / 6u) % MIC_SEG; let k = vi % 6u; var firstEdge = array(true, true, false, true, false, false); var atBack = array(true, false, false, true, false, true); let a0 = TAU_MIC * f32(seg) / f32(MIC_SEG); let a1 = TAU_MIC * f32(seg + 1u) / f32(MIC_SEG); ang = select(a1, a0, firstEdge[k]); let t0 = f32(band) / f32(MIC_BANDS); let t1 = f32(band + 1u) / f32(MIC_BANDS); t = select(t0, t1, atBack[k]); rr = micRadius(t); } else { // The disc closing the back. let n = vi - MIC_SIDE; let seg = n / 3u; let k = n % 3u; let a0 = TAU_MIC * f32(seg) / f32(MIC_SEG); let a1 = TAU_MIC * f32(seg + 1u) / f32(MIC_SEG); ang = select(a0, a1, k == 2u); rr = select(micRadius(1.0), 0.0, k == 0u); } // The nose sits at the drawn distance; the body extends away from the plate. let p = F * vec3f(rad * rr * cos(ang), rad * rr * sin(ang), R.listenerAt + len * t); // Normal of a surface of revolution: perpendicular to the slope of the profile. let eps = 0.5 / f32(MIC_BANDS); let slope = (micRadius(min(1.0, t + eps)) - micRadius(max(0.0, t - eps))) / (2.0 * eps) / MIC_ASPECT; var normal = normalize(vec3f(cos(ang), sin(ang), -slope)); if (vi >= MIC_SIDE) { normal = vec3f(0.0, 0.0, 1.0); } let n = F * normal; var o: V3; o.pos = R.mvp * vec4f(world(p.x, p.y, p.z), 1.0); o.color = micColor(t); o.normal = world(n.x, n.y, n.z); o.xy = p.xy; return o; } @fragment fn fs3dListener(in: V3) -> @location(0) vec4f { return vec4f(shade(in.color, in.normal), 1.0); }