From 69aab30d38583e18883a6cb9eb1fa4f732705e30 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sat, 27 Jun 2026 11:55:47 -0500 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20Phase=203=20=E2=80=94=20even?= =?UTF-8?q?t-triggered=20field=20effects=20(firewall=20flash)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "earned by the backend" payoff: cognitive events drive the WebGPU field. - fieldEvents.ts: a tiny decoupled pub/sub bridge (emit/onFieldEvent) so the dashboard publishes cognitive events and the BackdropEngine reacts, with no coupling either way. Kinds: firewall / decay / birth. - BackdropEngine: uniform expanded to carry an event vec4 (code, fade, origin). Compute shader applies a scoped impulse — firewall = outward shockwave + energy spike, decay = inward cold collapse, birth = bright bloom. Render shader flares particles near a firewall origin toward an angry CRIMSON (reserved ONLY for the firewall block; nothing else turns red). Effect fades over 1.6s. - Black Box ?demo=firewall now emits a firewall field event, so catching the attack visibly flashes the living field crimson — the launch money-shot. Verified: svelte-check 0 errors / 967 files; WGSL compiles with no validation errors in-browser. NOTE: the on-screen crimson flash is not yet visually captured this session (preview GPU device stalled after many reloads); pending a clean GPU context. Memory Cinema / Graph3D untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/core/BackdropEngine.svelte | 78 ++++++++++++++++++- apps/dashboard/src/lib/core/fieldEvents.ts | 55 +++++++++++++ .../src/routes/(app)/blackbox/+page.svelte | 3 + 3 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 apps/dashboard/src/lib/core/fieldEvents.ts diff --git a/apps/dashboard/src/lib/core/BackdropEngine.svelte b/apps/dashboard/src/lib/core/BackdropEngine.svelte index 5702387..70ade98 100644 --- a/apps/dashboard/src/lib/core/BackdropEngine.svelte +++ b/apps/dashboard/src/lib/core/BackdropEngine.svelte @@ -14,6 +14,25 @@ webgpuAvailable, type VestigeGpuHandle } from './vestigeGpu'; + import { onFieldEvent, fieldEventCode, type FieldEvent } from './fieldEvents'; + + // 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. + const EVENT_DURATION = 1.6; + let evtCode = 0; + let evtAge = 0; + let evtX = 0; + let evtY = 0; + let evtIntensity = 1; + function triggerFieldEvent(e: FieldEvent) { + evtCode = fieldEventCode(e.kind); + evtAge = 0; + // map [0,1] origin to clip space [-1,1]; default center + evtX = (e.x ?? 0.5) * 2 - 1; + evtY = -((e.y ?? 0.5) * 2 - 1); + evtIntensity = e.intensity ?? 1; + } interface Props { /** particle budget at full tier; auto-scaled down for weak GPUs / mobile */ @@ -43,6 +62,7 @@ struct Particle { pos: vec4f, vel: vec4f }; struct U { viewport: vec4f, // w, h, dpr, reducedMotion params: vec4f, // time, dt, intensity, count + event: vec4f, // code (0 none,1 firewall,2 decay,3 birth), fade(1->0), ox, oy }; @group(0) @binding(0) var parts: array; @group(0) @binding(1) var u: U; @@ -94,6 +114,31 @@ fn main(@builtin(global_invocation_id) gid: vec3u) { 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); + + // --- reactive event impulse (scoped to the event origin) ----------------- + let ecode = u.event.x; + let efade = u.event.y; // 1 at trigger -> 0 at end + if (ecode > 0.5 && efade > 0.001) { + let origin = vec3f(u.event.z, u.event.w, 0.0); + let d = pr.pos.xyz - origin; + let dist = length(d) + 0.0001; + let dir = d / dist; + let reach = exp(-dist * 1.4) * efade; // local falloff + if (ecode < 1.5) { + // FIREWALL: a hard outward shockwave + energy spike (the "block") + pr.vel = vec4f(pr.vel.xyz + dir * reach * 0.9, pr.vel.w); + pr.pos = vec4f(pr.pos.xyz, min(pr.pos.w + reach * 0.6, 1.0)); + } else if (ecode < 2.5) { + // DECAY: pull inward + damp (a cold collapse, the Rac1 cascade fade) + pr.vel = vec4f(pr.vel.xyz - dir * reach * 0.5, pr.vel.w * (1.0 - reach * 0.5)); + pr.pos = vec4f(pr.pos.xyz, max(pr.pos.w - reach * 0.4, 0.0)); + } else { + // BIRTH: a gentle bright bloom outward + pr.vel = vec4f(pr.vel.xyz + dir * reach * 0.4, pr.vel.w); + pr.pos = vec4f(pr.pos.xyz, min(pr.pos.w + reach * 0.5, 1.0)); + } + } + pr.pos = vec4f(pr.pos.xyz + pr.vel.xyz * u.params.y * speed, pr.pos.w); // soft-wrap inside a unit box so the field is endless pr.pos = vec4f(fract(pr.pos.xyz * 0.5 + 0.5) * 2.0 - 1.0, pr.pos.w); @@ -104,7 +149,7 @@ fn main(@builtin(global_invocation_id) gid: vec3u) { // ---- WGSL: additive point render with violet palette --------------------- const RENDER_WGSL = /* wgsl */ ` struct Particle { pos: vec4f, vel: vec4f }; -struct U { viewport: vec4f, params: vec4f }; +struct U { viewport: vec4f, params: vec4f, event: vec4f }; @group(0) @binding(0) var parts: array; @group(0) @binding(1) var u: U; @@ -113,6 +158,7 @@ struct VsOut { @location(0) quad: vec2f, @location(1) glow: f32, @location(2) seed: f32, + @location(3) crimson: f32, // 0 normal violet .. 1 firewall crimson }; @vertex @@ -135,6 +181,14 @@ fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut out.quad = c; out.glow = clamp(0.35 + speed * 1.4 + p.pos.w * 0.4, 0.2, 1.6); out.seed = p.pos.w; + // FIREWALL (event code 1): particles near the origin flare crimson — the + // "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)); + crimson = exp(-d * 1.6) * u.event.y; + } + out.crimson = clamp(crimson, 0.0, 1.0); return out; } @@ -160,7 +214,10 @@ fn fs(in: VsOut) -> @location(0) vec4f { let energy = clamp(in.glow * (0.55 + in.seed * 0.6), 0.0, 1.0); // brightness floor so the field is visible even where the flow is slow let lum = 0.55 + in.glow * 0.9; - let col = palette(energy) * lum * falloff; + var col = palette(energy) * lum * falloff; + // firewall flare: blend toward an angry crimson + extra glow at the catch + let crimsonCol = vec3f(1.0, 0.12, 0.22) * (lum + in.crimson * 1.4) * falloff; + col = mix(col, crimsonCol, in.crimson); return vec4f(col, falloff); // premultiplied additive } `; @@ -224,7 +281,7 @@ fn fs(in: VsOut) -> @location(0) vec4f { new Float32Array(partBuf.getMappedRange()).set(partData); partBuf.unmap(); - const uBuf = device.createBuffer({ size: 32, usage: 0x40 | 0x8 }); // UNIFORM | COPY_DST + const uBuf = device.createBuffer({ size: 48, usage: 0x40 | 0x8 }); // UNIFORM | COPY_DST (3x vec4) const computeMod = device.createShaderModule({ code: COMPUTE_WGSL }); const renderMod = device.createShaderModule({ code: RENDER_WGSL }); @@ -302,9 +359,18 @@ fn fs(in: VsOut) -> @location(0) vec4f { const t = (now - startedAt) / 1000; const { device, context } = handle.gpu; - const u = new Float32Array(8); + // advance any active reactive effect; clear it once it has faded out + if (evtCode !== 0) { + evtAge += dt; + if (evtAge > EVENT_DURATION) evtCode = 0; + } + const evtNorm = evtCode === 0 ? 0 : Math.max(0, 1 - evtAge / EVENT_DURATION); + + 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; + // 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); const enc = device.createCommandEncoder(); @@ -386,10 +452,13 @@ fn fs(in: VsOut) -> @location(0) vec4f { } } + let unsubFieldEvents: (() => void) | null = null; + onMount(() => { disposed = false; window.addEventListener('resize', onResize); document.addEventListener('visibilitychange', onVisibility); + unsubFieldEvents = onFieldEvent(triggerFieldEvent); if (webgpuAvailable()) { // defer one frame so the route paints before GPU boot work requestAnimationFrame(() => { if (!disposed) tryBootWebgpu(); }); @@ -401,6 +470,7 @@ fn fs(in: VsOut) -> @location(0) vec4f { onDestroy(() => { disposed = true; + unsubFieldEvents?.(); if (!browser) return; // onDestroy also fires during SSR — no DOM there cancelAnimationFrame(raf); window.removeEventListener('resize', onResize); diff --git a/apps/dashboard/src/lib/core/fieldEvents.ts b/apps/dashboard/src/lib/core/fieldEvents.ts new file mode 100644 index 0000000..d187064 --- /dev/null +++ b/apps/dashboard/src/lib/core/fieldEvents.ts @@ -0,0 +1,55 @@ +// fieldEvents — a tiny pub/sub bridge so cognitive events (a firewall +// quarantine, a memory decaying, a new memory landing) can drive the WebGPU +// backdrop's reactive effects. The BackdropEngine subscribes; the dashboard +// (or the launch-footage demo) publishes. Decoupled: the engine never imports +// dashboard state, the dashboard never imports the engine. + +/** The reactive effects the field can play. Each maps to a shader impulse. */ +export type FieldEventKind = + | 'firewall' // a poisoned memory was quarantined — violet arc + crimson pulse + | 'decay' // a memory decayed / was suppressed — a cold plume fades out + | 'birth'; // a new memory landed — a bright synapse bloom + +export type FieldEvent = { + kind: FieldEventKind; + /** normalized origin in [0,1]x[0,1] (defaults to center) for scoped effects */ + x?: number; + y?: number; + /** 0..1 strength multiplier (defaults to 1) */ + intensity?: number; +}; + +type Listener = (e: FieldEvent) => void; + +const listeners = new Set(); + +/** Fire a field effect. Safe to call from anywhere, any time. */ +export function emitFieldEvent(e: FieldEvent): void { + for (const l of listeners) { + try { + l(e); + } catch { + // a broken listener must never break the publisher + } + } +} + +/** Subscribe (the BackdropEngine). Returns an unsubscribe fn. */ +export function onFieldEvent(l: Listener): () => void { + listeners.add(l); + return () => listeners.delete(l); +} + +/** Stable numeric code for the shader uniform (0 = none). */ +export function fieldEventCode(kind: FieldEventKind): number { + switch (kind) { + case 'firewall': + return 1; + case 'decay': + return 2; + case 'birth': + return 3; + default: + return 0; + } +} diff --git a/apps/dashboard/src/routes/(app)/blackbox/+page.svelte b/apps/dashboard/src/routes/(app)/blackbox/+page.svelte index 67fbcb6..1440f20 100644 --- a/apps/dashboard/src/routes/(app)/blackbox/+page.svelte +++ b/apps/dashboard/src/routes/(app)/blackbox/+page.svelte @@ -20,6 +20,7 @@ import { reveal } from '$lib/actions/reveal'; import ReceiptCard from '$components/ReceiptCard.svelte'; import LifeState from '$components/LifeState.svelte'; + import { emitFieldEvent } from '$lib/core/fieldEvents'; import { api, type TraceRunSummary, @@ -218,6 +219,8 @@ ]; loading = false; error = null; + // flash the living field crimson — the firewall catch, made visible + emitFieldEvent({ kind: 'firewall', intensity: 1 }); } onMount(() => {