From deb3b82134b9734ed829052623a7d7af5fa3f14c Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sat, 27 Jun 2026 14:52:45 -0500 Subject: [PATCH] fix(dashboard): correct WebGPU shader coordinate + frame-rate bugs (MiniMax M3 audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiniMax M3 (max-depth audit, fresh eyes on the shader MATH that Claude+Kimi's lifecycle-focused passes missed) found 4 pre-existing criticals, all verified: - C1 aspect-ratio inversion: vertex shader divided particle X by aspect, which SQUASHED the whole field into the center ~56% of a 16:9 screen (~22% empty bars each side). Fix: position now uses the full [-1,1] clip box (no divide); roundness comes from a proper px2ndc = 2/viewport corner offset so quads are sizePx x sizePx device px on any aspect ratio (also kills the 0.01 magic number). - C3 firewall flare landed at the wrong X (distance measured in unit-box space while the particle rendered in divided space). Fixed-for-free by C1; the crimson distance is now aspect-corrected so the halo is circular, centered on the catch. - C4 frame-rate-dependent flow absorption: the velocity lerp used a fixed 0.06 per-frame factor (not dt-scaled), so the field was 2x snappier at 120Hz ProMotion vs 60Hz. Fix: absorb = 1 - exp(-dt * 3.7) — identical feel at 60fps, correct everywhere else. VERIFIED: svelte-check 0 errors / 967 files; zero WGSL validation errors in the browser (a broken shader struct/syntax would throw at pipeline creation — none). NOTE: the on-screen full-width widening is not yet visually captured this session — the preview backend returns API 500 so the (app) layout error-states and the field doesn't mount; pending eyeball on a wide window against a live backend. Memory Cinema / Graph3D untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/core/BackdropEngine.svelte | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/lib/core/BackdropEngine.svelte b/apps/dashboard/src/lib/core/BackdropEngine.svelte index 74785fc..377d6b7 100644 --- a/apps/dashboard/src/lib/core/BackdropEngine.svelte +++ b/apps/dashboard/src/lib/core/BackdropEngine.svelte @@ -113,7 +113,12 @@ fn main(@builtin(global_invocation_id) gid: vec3u) { let rm = u.viewport.w; let speed = mix(1.0, 0.18, rm) * u.params.z; let flow = curlFlow(pr.pos.xyz * 1.1); - pr.vel = vec4f(mix(pr.vel.xyz, flow * 0.6, 0.06), pr.vel.w); + // Frame-rate-independent absorption toward the flow. 0.06/frame at 60fps => + // rate 3.7/s; exp form keeps the feel identical at 60fps but correct on + // 120Hz ProMotion / 30fps throttle (was a fixed per-frame lerp -> 2x snappier + // at 120Hz). u.params.y is dt. + let absorb = 1.0 - exp(-u.params.y * 3.7); + pr.vel = vec4f(mix(pr.vel.xyz, flow * 0.6, absorb), pr.vel.w); // --- reactive event impulse (scoped to the event origin) ----------------- let ecode = u.event.x; @@ -169,12 +174,17 @@ fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut ); let c = corners[vi]; let p = parts[ii]; - let aspect = u.viewport.x / max(u.viewport.y, 1.0); // gentle parallax depth so the cloud reads volumetric let depth = 1.7 + p.pos.z * 0.5; let sizePx = (2.6 + p.pos.w * 3.4) / depth; - var ndc = vec2f(p.pos.x / aspect, p.pos.y); - ndc = ndc + c * vec2f(sizePx * 0.01 / aspect, sizePx * 0.01); + // Position fills the FULL clip box [-1,1]^2 — do NOT divide x by aspect + // (that squashed the field into the center ~56% on 16:9). Roundness comes + // from the corner offset instead: convert sizePx (device px) to NDC per + // axis via px2ndc = 2/viewport, so the quad is sizePx x sizePx px and round + // on any aspect ratio. + var ndc = vec2f(p.pos.x, p.pos.y); + let px2ndc = vec2f(2.0 / max(u.viewport.x, 1.0), 2.0 / max(u.viewport.y, 1.0)); + ndc = ndc + c * (px2ndc * sizePx); let speed = length(p.vel.xyz); var out: VsOut; out.clip = vec4f(ndc, 0.0, 1.0); @@ -185,7 +195,12 @@ fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut // "angry" block. Crimson is reserved ONLY for this; nothing else turns red. var crimson = 0.0; if (u.event.x > 0.5 && u.event.x < 1.5) { - let d = length(p.pos.xy - vec2f(u.event.z, u.event.w)); + // p.pos.xy and the event origin (u.event.zw) are now in the SAME clip + // space (no aspect divide on position), so the flare lands exactly on + // the catch origin. Aspect-correct the distance so the halo is circular + // on wide screens rather than an x-stretched ellipse. + let aspect = u.viewport.x / max(u.viewport.y, 1.0); + let d = length((p.pos.xy - vec2f(u.event.z, u.event.w)) * vec2f(aspect, 1.0)); crimson = exp(-d * 1.6) * u.event.y; } out.crimson = clamp(crimson, 0.0, 1.0);