mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
fix(dashboard): close remaining BackdropEngine audit findings (M3 H1-H4 + mediums)
Clears the rest of the MiniMax M3 audit backlog (all verified, svelte-check green): - H1: Canvas2D fallback now honors decay (inward pull+damp) and birth (outward bloom) events, not just firewall — full event parity with the WGSL path for pre-iOS-26 / no-WebGPU users. - H2: noise/flow time is now an `activeT` accumulator advanced by dt only while drawing, so a backgrounded tab no longer fast-forwards the curl flow and snaps it to a new direction on resume (was wall-clock `(now-startedAt)`). Both the WebGPU and Canvas2D paths share it. - H3: prefers-reduced-motion is now reactive ($state + a matchMedia change listener) so toggling the OS setting mid-session takes effect next frame (was a const snapshot at init). - H4: documented that `count` is read once at boot (buffer sizing); `intensity` is the live knob. - M3: cache the Canvas2D context once in bootFallback (was getContext per frame). - M4: coalesce resize bursts to one resize/frame via rAF (was thrashing the swapchain config + 2D backing store on every drag event). - M7: register device.lost BEFORE pipeline creation (was after the handle, leaving a race window where a loss mid-build never fired the fallback). - L2/L4/L1: drop the empty onMount return (single onDestroy teardown); reset lastT in bootFallback so a WebGPU recovery sees no giant dt; document the intentionally-non-reactive animation state. svelte-check 0 errors / 967 files. Cosmetic-only items left (M1 dead DPR slot, M2 dead vel.w, M5 sizePx naming, L3 alpha-mode smell) — no behavior, deferred. Memory Cinema / Graph3D untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b41b1b90de
commit
ba5a28d5f9
1 changed files with 85 additions and 25 deletions
|
|
@ -19,6 +19,9 @@
|
|||
// The active reactive effect (a firewall catch / decay / birth). `code` is the
|
||||
// shader uniform; `age` counts up in seconds and the effect fades out by
|
||||
// EVENT_DURATION, after which code resets to 0. Pointer/origin in clip space.
|
||||
// These animation/event vars are intentionally plain `let`, NOT $state: they're
|
||||
// read only from imperative rAF callbacks (never a reactive/template context),
|
||||
// so reactivity would be pure overhead. Don't bind them in markup (L1).
|
||||
const EVENT_DURATION = 1.6;
|
||||
let evtCode = 0;
|
||||
let evtAge = 0;
|
||||
|
|
@ -35,9 +38,13 @@
|
|||
}
|
||||
|
||||
interface Props {
|
||||
/** particle budget at full tier; auto-scaled down for weak GPUs / mobile */
|
||||
/**
|
||||
* Particle budget at full tier; auto-scaled down for weak GPUs / mobile.
|
||||
* NOTE: read ONCE at GPU boot (sizes the buffer) — changing it at runtime
|
||||
* has no effect until remount. `intensity` is the live, per-frame knob (H4).
|
||||
*/
|
||||
count?: number;
|
||||
/** 0..1 ambient intensity — higher = brighter, faster drift */
|
||||
/** 0..1 ambient intensity — live per-frame: higher = brighter, faster drift */
|
||||
intensity?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
|
@ -50,7 +57,10 @@
|
|||
const WORKGROUP = 64;
|
||||
// Guard every browser-global behind `browser`: this component server-side
|
||||
// renders, and prefersReducedMotion / matchMedia don't exist on the server.
|
||||
const reduced = browser ? prefersReducedMotion() : false;
|
||||
// Reactive ($state) + a media-query listener so toggling the OS reduced-motion
|
||||
// setting mid-session takes effect on the next frame (was a const snapshot at
|
||||
// init that never updated). Read live in the draw loops + pickCount.
|
||||
let reduced = $state(browser ? prefersReducedMotion() : false);
|
||||
|
||||
// ---- WGSL: curl-noise particle field -------------------------------------
|
||||
// Particles advect through a divergence-free (curl-of-noise) flow so the field
|
||||
|
|
@ -256,6 +266,11 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
let disposed = false;
|
||||
let startedAt = 0;
|
||||
let lastT = 0;
|
||||
// Active-seconds clock for the shader noise time: accumulated by dt ONLY while
|
||||
// drawing, so a backgrounded tab doesn't fast-forward the curl flow and make it
|
||||
// snap to a new direction on resume (H2 — wall-clock t had this bug). evtAge
|
||||
// already uses per-frame accumulation; this gives the noise potential the same.
|
||||
let activeT = 0;
|
||||
|
||||
// Tracks whether the tick() chain is currently live, so any caller can ask to
|
||||
// (re)start the loop without risking a second parallel chain. `tick` clears it
|
||||
|
|
@ -332,6 +347,18 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
// no longer 'booting' (or we were disposed) abort so we don't start a 2nd loop.
|
||||
if (disposed || mode !== 'booting') { releaseBuffers(partBuf, uBuf); return; }
|
||||
if (!gpu) { bootFallback(); return; }
|
||||
// Register device-loss BEFORE pipeline creation so a loss during the async
|
||||
// pipeline build still triggers the fallback (M7 — was registered after the
|
||||
// handle, leaving a race window). Guard with a flag so we don't double-fall.
|
||||
let lostHandled = false;
|
||||
const onDeviceLost = () => {
|
||||
if (disposed || lostHandled) return;
|
||||
lostHandled = true;
|
||||
releaseBuffers(partBuf, uBuf);
|
||||
handle = null;
|
||||
bootFallback();
|
||||
};
|
||||
gpu.device.lost?.then?.(onDeviceLost);
|
||||
try {
|
||||
const device = gpu.device;
|
||||
const particleCount = pickCount();
|
||||
|
|
@ -387,13 +414,7 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
]
|
||||
});
|
||||
handle = { gpu, partBuf, uBuf, computePipe, renderPipe, computeBind, renderBind, particleCount };
|
||||
|
||||
gpu.device.lost?.then?.(() => {
|
||||
if (disposed) return;
|
||||
releaseBuffers(); // destroy this handle's buffers before dropping it
|
||||
handle = null;
|
||||
bootFallback();
|
||||
});
|
||||
// device.lost handler already registered above (M7), before pipeline build.
|
||||
|
||||
mode = 'webgpu';
|
||||
if (bootWatchdog) { clearTimeout(bootWatchdog); bootWatchdog = null; }
|
||||
|
|
@ -436,7 +457,7 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
const now = performance.now();
|
||||
const dt = Math.min((now - lastT) / 1000, 0.05);
|
||||
lastT = now;
|
||||
const t = (now - startedAt) / 1000;
|
||||
activeT += dt; // monotonic active-time (no tab-hidden jump) for the noise clock
|
||||
const { device, context } = handle.gpu;
|
||||
|
||||
// advance any active reactive effect; clear it once it has faded out
|
||||
|
|
@ -448,7 +469,7 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
|
||||
const u = new Float32Array(12);
|
||||
u[0] = gpuCanvas!.width; u[1] = gpuCanvas!.height; u[2] = clampDpr(1.5); u[3] = reduced ? 1 : 0;
|
||||
u[4] = t; u[5] = dt; u[6] = intensity; u[7] = handle.particleCount;
|
||||
u[4] = activeT; u[5] = dt; u[6] = intensity; u[7] = handle.particleCount;
|
||||
// event vec4: code, fade(1->0), origin handled below via x/y, intensity
|
||||
u[8] = evtCode; u[9] = evtNorm * evtIntensity; u[10] = evtX; u[11] = evtY;
|
||||
device.queue.writeBuffer(handle.uBuf, 0, u);
|
||||
|
|
@ -482,12 +503,14 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
|
||||
// ---- Canvas2D fallback (no WebGPU: pre-iOS-26 iPhones, old GPUs) ----------
|
||||
let fbParts: { x: number; y: number; vx: number; vy: number; e: number }[] = [];
|
||||
let fbCtx: CanvasRenderingContext2D | null = null; // cached once (M3 — was per-frame)
|
||||
function bootFallback() {
|
||||
if (disposed || !fallbackCanvas) return;
|
||||
if (mode === 'fallback' && fbParts.length) return; // idempotent: don't double-start
|
||||
if (bootWatchdog) { clearTimeout(bootWatchdog); bootWatchdog = null; }
|
||||
mode = 'fallback';
|
||||
resize();
|
||||
fbCtx = fallbackCanvas.getContext('2d');
|
||||
const n = (globalThis as any).innerWidth < 760 ? 520 : 1400;
|
||||
fbParts = Array.from({ length: n }, () => ({
|
||||
x: Math.random(), y: Math.random(),
|
||||
|
|
@ -495,6 +518,7 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
}));
|
||||
startedAt = performance.now();
|
||||
fbLastT = 0;
|
||||
lastT = performance.now(); // defensive: if WebGPU later recovers, no giant dt (L4)
|
||||
// startLoop() is a no-op if the chain is already live (e.g. called from
|
||||
// drawWebgpu's catch inside tick) — tick simply routes to drawFallback
|
||||
// next frame. When called cold (watchdog / no-WebGPU) it starts the chain.
|
||||
|
|
@ -508,17 +532,22 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
let fbLastT = 0;
|
||||
function drawFallback() {
|
||||
if (disposed || !fallbackCanvas || mode !== 'fallback') return;
|
||||
const ctx = fallbackCanvas.getContext('2d');
|
||||
const ctx = fbCtx ?? (fbCtx = fallbackCanvas.getContext('2d')); // cached (M3)
|
||||
if (!ctx) return;
|
||||
const w = fallbackCanvas.width, h = fallbackCanvas.height;
|
||||
const nowMs = performance.now();
|
||||
const t = (nowMs - startedAt) / 1000;
|
||||
const dt = fbLastT ? Math.min((nowMs - fbLastT) / 1000, 0.05) : 0.016;
|
||||
fbLastT = nowMs;
|
||||
// advance any active reactive event (firewall flash also works here so
|
||||
// pre-iOS-26 / no-WebGPU users still see the catch)
|
||||
activeT += dt; // shared active-time clock (no tab-hidden jump)
|
||||
const t = activeT;
|
||||
// advance any active reactive event. ALL three kinds (firewall/decay/birth)
|
||||
// are honored here so pre-iOS-26 / no-WebGPU users get full event parity
|
||||
// with the WGSL path (H1 — only firewall used to fire).
|
||||
if (evtCode !== 0) { evtAge += dt; if (evtAge > EVENT_DURATION) evtCode = 0; }
|
||||
const fwFade = evtCode === 1 ? Math.max(0, 1 - evtAge / EVENT_DURATION) * evtIntensity : 0;
|
||||
const evtFade = evtCode !== 0 ? Math.max(0, 1 - evtAge / EVENT_DURATION) * evtIntensity : 0;
|
||||
const fwFade = evtCode === 1 ? evtFade : 0; // firewall: crimson + outward shock
|
||||
const decayFade = evtCode === 2 ? evtFade : 0; // decay: inward pull + damp
|
||||
const birthFade = evtCode === 3 ? evtFade : 0; // birth: gentle outward bloom
|
||||
const ox = (evtX + 1) * 0.5, oy = (1 - evtY) * 0.5; // back to [0,1]
|
||||
ctx.fillStyle = 'rgba(5,3,13,0.18)'; // trail fade on void
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
|
@ -531,15 +560,31 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
// scale the flow force by the ambient `intensity` prop (was ignored)
|
||||
p.vx = p.vx * 0.92 + fx * (reduced ? 0.3 : 1) * intensity;
|
||||
p.vy = p.vy * 0.92 + fy * (reduced ? 0.3 : 1) * intensity;
|
||||
// firewall shockwave: push outward from the origin
|
||||
// scoped event impulses from the origin (mirror the WGSL compute branches)
|
||||
let crimson = 0;
|
||||
if (fwFade > 0) {
|
||||
if (evtFade > 0) {
|
||||
const dx = p.x - ox, dy = p.y - oy;
|
||||
const dist = Math.hypot(dx, dy) + 1e-4;
|
||||
const reach = Math.exp(-dist * 2.2) * fwFade;
|
||||
p.vx += (dx / dist) * reach * 0.01 * fstep;
|
||||
p.vy += (dy / dist) * reach * 0.01 * fstep;
|
||||
crimson = Math.min(reach * 1.6, 1);
|
||||
const ux = dx / dist, uy = dy / dist;
|
||||
if (fwFade > 0) {
|
||||
// firewall: hard outward shockwave + crimson flare at the catch
|
||||
const reach = Math.exp(-dist * 2.2) * fwFade;
|
||||
p.vx += ux * reach * 0.01 * fstep;
|
||||
p.vy += uy * reach * 0.01 * fstep;
|
||||
crimson = Math.min(reach * 1.6, 1);
|
||||
} else if (decayFade > 0) {
|
||||
// decay: pull inward + damp (cold collapse)
|
||||
const reach = Math.exp(-dist * 2.2) * decayFade;
|
||||
p.vx -= ux * reach * 0.006 * fstep;
|
||||
p.vy -= uy * reach * 0.006 * fstep;
|
||||
p.vx *= 1 - reach * 0.5 * Math.min(fstep, 2);
|
||||
p.vy *= 1 - reach * 0.5 * Math.min(fstep, 2);
|
||||
} else if (birthFade > 0) {
|
||||
// birth: gentle outward bloom
|
||||
const reach = Math.exp(-dist * 2.2) * birthFade;
|
||||
p.vx += ux * reach * 0.005 * fstep;
|
||||
p.vy += uy * reach * 0.005 * fstep;
|
||||
}
|
||||
}
|
||||
p.x += p.vx * fstep; p.y += p.vy * fstep;
|
||||
if (p.x < 0) p.x += 1; if (p.x > 1) p.x -= 1;
|
||||
|
|
@ -558,7 +603,13 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
// NOTE: no rAF here — tick() owns scheduling.
|
||||
}
|
||||
|
||||
function onResize() { resize(); }
|
||||
// Coalesce resize bursts (window drags fire dozens/sec) to one resize per frame
|
||||
// so we don't thrash the swapchain config + Canvas2D backing store (M4).
|
||||
let resizeRaf = 0;
|
||||
function onResize() {
|
||||
if (resizeRaf) return;
|
||||
resizeRaf = requestAnimationFrame(() => { resizeRaf = 0; if (!disposed) resize(); });
|
||||
}
|
||||
function onVisibility() {
|
||||
if (document.hidden) {
|
||||
stopLoop();
|
||||
|
|
@ -573,11 +624,17 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
|
||||
let unsubFieldEvents: (() => void) | null = null;
|
||||
let bootWatchdog: ReturnType<typeof setTimeout> | null = null;
|
||||
let reducedMq: MediaQueryList | null = null;
|
||||
function onReducedChange() { reduced = !!reducedMq?.matches; }
|
||||
|
||||
onMount(() => {
|
||||
disposed = false;
|
||||
window.addEventListener('resize', onResize);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
// Live-track the reduced-motion preference so a mid-session OS toggle is
|
||||
// honored on the next frame (H3).
|
||||
reducedMq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMq.addEventListener('change', onReducedChange);
|
||||
unsubFieldEvents = onFieldEvent(triggerFieldEvent);
|
||||
if (webgpuAvailable()) {
|
||||
// defer one frame so the route paints before GPU boot work
|
||||
|
|
@ -591,7 +648,7 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
} else {
|
||||
bootFallback();
|
||||
}
|
||||
return () => {};
|
||||
// teardown lives in onDestroy (single cleanup path) — no return here (L2).
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -600,8 +657,11 @@ fn fs(in: VsOut) -> @location(0) vec4f {
|
|||
if (bootWatchdog) { clearTimeout(bootWatchdog); bootWatchdog = null; }
|
||||
if (!browser) return; // onDestroy also fires during SSR — no DOM there
|
||||
stopLoop();
|
||||
if (resizeRaf) { cancelAnimationFrame(resizeRaf); resizeRaf = 0; }
|
||||
window.removeEventListener('resize', onResize);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
reducedMq?.removeEventListener('change', onReducedChange);
|
||||
fbCtx = null;
|
||||
releaseBuffers(); // destroy GPU buffers so they don't outlive the component
|
||||
handle = null;
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue