mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
feat(dashboard): MSDF text engine — the all-WebGPU text linchpin
Day-1 linchpin of the zero-DOM Cognitive OS. Every label, receipt id, and number in the dashboard now renders as glowing in-canvas MSDF glyphs instead of DOM text. Checked-in JetBrains Mono mtsdf atlas (512x512, 95 glyphs) + loader + pure layout helper + FramePass + trap-checked WGSL. Built by a GPT-5.5 swarm against an adversarially-verified spec (5 verifiers + 3 skeptics caught 6 silent-fail traps before a line was written: V-flip, release-binary atlas embed, base-path fetch, linear rgba8unorm, premultiplied blend, ASCII-only charset). Conductor live-GPU audit on the real dashboard found one more at runtime: the aspect transform pushed text off-screen in portrait; fixed to keep glyphs square in both orientations. Renders cyan, crisp, upright, glowing, glyph-by-glyph, landscape + portrait. check 0 errors, build green, golden V-flip test 2/2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f167be2663
commit
389ddfa294
11 changed files with 1314 additions and 112 deletions
374
.council/GOD-TIER-ALLWEBGPU-PLAN.md
Normal file
374
.council/GOD-TIER-ALLWEBGPU-PLAN.md
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
# GOD-TIER PLAN — The All-WebGPU Cognitive OS Dashboard
|
||||
### Vestige · HN launch July 14 2026 · written July 9 (T-5 days)
|
||||
|
||||
> **THE ONE RULE THAT OVERRIDES EVERYTHING:** The ENTIRE dashboard renders in
|
||||
> WebGPU. **Text and all. Zero DOM chrome.** Every pixel a user sees — labels,
|
||||
> memory content, receipt IDs, numbers, buttons, the cursor's effect — is drawn
|
||||
> by the GPU into one canvas. This is not negotiable and it is the whole point:
|
||||
> **a full-screen WebGPU canvas is the only artifact that survives a screen
|
||||
> recording, an OS screenshot, or a phone camera pointed at the monitor — which
|
||||
> is the only thing that makes people share it.** A DOM dashboard is a website.
|
||||
> An all-WebGPU dashboard is footage.
|
||||
>
|
||||
> Sam, verbatim: *"EVERYTHING needs to be WebGPU/RawGPU so the visuals can be to
|
||||
> the ABSOLUTE MAX"* and *"I WANT THIS WHOLE DASHBOARD AS WEBGPU BECAUSE THAT'S
|
||||
> THE ONLY THING THAT WILL GET USERS TO SHARE THIS."*
|
||||
|
||||
DOM's ONLY remaining job: a single invisible accessibility mirror (offscreen
|
||||
`aria-live` + focusable proxies) so screen-readers/keyboard work. It is never
|
||||
seen. It carries zero visual styling. `app.css` becomes ~empty.
|
||||
|
||||
Branch: `feat/dashboard-live-max`. Repo: `/Users/entity002/vestige`. Reuse the
|
||||
raw-WebGPU `ObservatoryEngine` + `PostChain`. Never touch Memory Cinema or the
|
||||
Graph field.
|
||||
|
||||
---
|
||||
|
||||
## 0. WHY THIS IS THE SHAREABLE MOVE (the launch thesis)
|
||||
|
||||
Verified precedents (Vestige memory, HN Algolia, July 2026):
|
||||
- ghostty-blackhole (raytraced black hole whose SIZE = context fill) went viral
|
||||
because **the visual IS a live gauge** and it's pure canvas — remixable.
|
||||
- XorDev singularity shader (381 chars) → ~60M views, remixed into shadcn/godot.
|
||||
- pilot flight-globes 1539pts, neal.fun, wplace.live 0→1M users in 4 days.
|
||||
- **The 5 transferable mechanics:** (1) personalization — render THEIR data;
|
||||
(2) one absurd verifiable number in the title; (3) receipts — every flourish
|
||||
is a gauge of real state; (4) deterministic 12s perfect loop = remixable clip;
|
||||
(5) chain moments into one journey ending in a personal payoff.
|
||||
|
||||
A DOM dashboard fails all five the moment someone screen-records it (the DOM
|
||||
chrome screams "web app"). An all-WebGPU organism passes all five: it looks like
|
||||
a living brain, it's a single shareable surface, every glow is a real gauge, and
|
||||
the 720-frame loop is already deterministic.
|
||||
|
||||
**The moat is the conjunction:** raw-WebGPU engine (ours) × real Vestige-only
|
||||
data (FSRS decay, RSB causal receipts, contradiction pairs, suppression, live
|
||||
events) × the discipline test (swap real data for Math.random and the viewer can
|
||||
tell). Nobody else has all three. Rendering the TEXT in-canvas too is what makes
|
||||
it uncopyable *and* unscreenshottable-as-a-webpage.
|
||||
|
||||
---
|
||||
|
||||
## 1. WHAT ALREADY EXISTS (verified in-repo, reuse — do not rebuild)
|
||||
|
||||
- `lib/observatory/engine.ts` — `ObservatoryEngine`: FramePass plugin system
|
||||
(`addPass`, `compute(encoder)` + `render(pass)`), params uniform buffer,
|
||||
deterministic `DemoClock` (720-frame loop), DPR clamp, `setPaused`,
|
||||
`preFrameHook`, `wallNowMs`, `totalFrames`, GPU picking.
|
||||
- `post/post-chain.ts` — HDR chain: `SCENE_FORMAT='rgba16float'` scene → mip
|
||||
bloom → Khronos PBR neutral tonemap → grain → vignette. **Text drawn into the
|
||||
scene texture gets bloom for free** — that's how labels "glow."
|
||||
- `live-bridge.ts` — real WebSocket `VestigeEvent` → GPU mutation, with the live
|
||||
lanes (liveKind/liveFrame/liveEnergy/projectionDays) + the metaball/firewall/
|
||||
dream/causal-recall machinery.
|
||||
- `cognitive-palette.ts` — the invented color language (blackwater base, FSRS
|
||||
oxygen ramp, magenta=RSB-only, scarlet=immune, indigo=bitemporal, cyan accent).
|
||||
- `route-scene.ts` — `RouteSceneModel` + `Provenance` + `assertProvenance` (the
|
||||
discipline test as a type constraint).
|
||||
- Working organ passes to copy: `reasoning/reasoning-theater-pass.ts`,
|
||||
`contradictions/contradictions-pass.ts`, `blackbox/blackbox-pass.ts`.
|
||||
- `RouteStage.svelte` — the organ shell (engine lifecycle, WebGPU fallback,
|
||||
pause, reduced-motion). **Must be rebuilt to host the text layer + input layer
|
||||
and remove all DOM panels.**
|
||||
|
||||
**THE ONE MISSING PIECE (the linchpin):** there is NO text pipeline in the
|
||||
observatory. All-WebGPU text = we build an MSDF glyph renderer. This is the
|
||||
critical path. Everything else is variation on proven passes.
|
||||
|
||||
---
|
||||
|
||||
## 2. WEBGPU PLATFORM WE CAN RELY ON (verified July 5 2026, Chrome stable)
|
||||
|
||||
SHIPPED (use freely): HDR **extended-range canvas** (`toneMapping:{mode:'extended'}`
|
||||
+ rgba16float + display-p3 — ignitions brighter than page-white on M1 Max XDR —
|
||||
the "gasp" nobody else has), subgroups (134/144/145), shader-f16 (120),
|
||||
dual-source-blending (130), timestamp-query (121, dev perf HUD only),
|
||||
texture-formats tier1/tier2, IMMEDIATES/push-constants (149-150).
|
||||
NOT shipped (do NOT depend on): multi-draw-indirect, bindless.
|
||||
Cross-browser: Safari 26 all Apple, Firefox 141+ Windows/145+ AS-Mac. **WebGPU
|
||||
absent → the accessibility DOM mirror shows a static "open in Chrome/Safari for
|
||||
the live view" + the last server-rendered OG still. Never a black screen.**
|
||||
|
||||
**Capture/share gotchas (verified, launch-critical):** WebGPU canvases IGNORE
|
||||
`preserveDrawingBuffer` — snapshot must copy `getCurrentTexture()` IN THE SAME
|
||||
TASK as the render (gpuweb#1781). Clip export: `mediabunny` (WebCodecs
|
||||
canvas→MP4). Safari clipboard: pass `Promise<Blob>` inside `ClipboardItem`. Bake
|
||||
the wordmark into the POST CHAIN (not DOM) so it survives OS screen recordings
|
||||
(the Loom/tldraw viral-watermark move).
|
||||
|
||||
---
|
||||
|
||||
## 3. THE ARCHITECTURE — one canvas, four GPU layers, zero DOM chrome
|
||||
|
||||
Every route = `ObservatoryEngine` rendering into the HDR scene texture, composed
|
||||
in this Z order, all in WebGPU:
|
||||
|
||||
```
|
||||
L0 BLACKWATER FIELD full-bleed metaball/organism substrate (the "tissue")
|
||||
L1 ORGAN PASS(es) the route's bespoke hero (chambers/synapse/rings/…)
|
||||
L2 MSDF TEXT LAYER every label/number/content, drawn in-scene (gets bloom)
|
||||
L3 INTERACTION LAYER GPU cursor field, click shockwaves, hover ignition
|
||||
→ POST CHAIN bloom + tonemap + grain + vignette + baked wordmark
|
||||
```
|
||||
|
||||
Input: ONE transparent full-canvas hit-layer. Pointer/keyboard events →
|
||||
CPU hit-test against a GPU-picking ID buffer (already have picking) OR a CPU
|
||||
rect/quadtree of the laid-out text/controls → drive `live-bridge` + API calls.
|
||||
No `<button>`, no `<div>` panel. A "button" is an MSDF label + a pickable quad +
|
||||
a click-shockwave. Selection/focus is a GPU highlight, not a CSS outline.
|
||||
|
||||
Accessibility mirror (offscreen, visually `sr-only`, never rendered): mirror the
|
||||
scene model into `aria-live` regions + focusable proxies so keyboard/SR users
|
||||
get the data and can trigger the same actions. This is the ONLY DOM.
|
||||
|
||||
---
|
||||
|
||||
## 4. THE UNIFYING VISUAL LANGUAGE (locked — "Causal Bioluminescent Cortex")
|
||||
|
||||
A dark local brain in a jar; routes are organs of one organism. From
|
||||
`cognitive-palette.ts` (source of truth):
|
||||
- **Base:** blackwater `#020307`. Never purple. Ever.
|
||||
- **Retention = oxygen:** luciferin `#E9FFB7` (fresh) → healthy `#A8FF5E` →
|
||||
amber-debt `#8A4B18` → sediment `#2A160B` (forgotten). Radius = √stability.
|
||||
- **Cyan `#22C7DE`** = the single interactive accent (focus, hover, selection).
|
||||
- **Scarlet `#FF3B30`/`#B90D2B`** = immune (contradiction/veto/suppression scar).
|
||||
- **Magenta `#FF2DF7`** = RSB retrograde causal axon ONLY. Never chrome.
|
||||
- **Indigo `#7C6CFF`** = bitemporal transaction-time parallax ONLY.
|
||||
- **Text is bioluminescent, not chrome:** labels are **etched glowing scars** —
|
||||
they materialize glyph-by-glyph when backed by a real memory/event/receipt,
|
||||
colored by their semantic (a memory's label glows at its retention color; a
|
||||
veto reason glows scarlet; a causal receipt id glows magenta). Text drawn into
|
||||
the HDR scene BEFORE bloom, so it glows like the tissue.
|
||||
|
||||
Motion grammar: chemotaxis, elastic axons, immune clamping, retrograde firing,
|
||||
metabolic breathing, sedimentation, scar persistence, **click-as-incision**
|
||||
(every click cuts/probes real tissue → receipt-backed wave).
|
||||
|
||||
---
|
||||
|
||||
## 5. THE CRITICAL PATH — MSDF Text Engine (build FIRST, everything needs it)
|
||||
|
||||
Reference: Red Blob "SDF Fonts" + WebGPU-samples `textRenderingMsdf`
|
||||
(`msdfText.wgsl`, median-of-RGB distance, screen-space width via `fwidth`/
|
||||
derivatives, smoothstep alpha). Verified real, spec-current.
|
||||
|
||||
Deliverables:
|
||||
1. **Checked-in MSDF atlas** for ONE crisp mono font (JetBrains Mono / IBM Plex
|
||||
Mono). Generate offline with `msdf-atlas-gen` → a PNG atlas + JSON
|
||||
(`planeBounds`/`atlasBounds` per glyph). Commit both to
|
||||
`apps/dashboard/static/msdf/`. NEVER generate at runtime (toolchain risk).
|
||||
2. `lib/observatory/text/msdf-atlas.ts` — loads the atlas PNG as a `texture_2d`
|
||||
+ parses the JSON into a glyph metrics map.
|
||||
3. `lib/observatory/text/text-layer.ts` — a `FramePass`. CPU lays out strings →
|
||||
per-glyph instance buffer: `{glyphRect, atlasRect, worldAnchor,
|
||||
semanticColorRGBA, ageFrame, confidence}`. GPU draws one instanced quad per
|
||||
glyph, samples the MSDF atlas, applies `smoothstep` AA, tints by
|
||||
`semanticColor`, and (signature move) reveals glyph-by-glyph over `ageFrame`
|
||||
for the "etched scar materializes" effect. Renders into the scene texture
|
||||
(pre-bloom) so text glows.
|
||||
4. `shaders/msdf-text.wgsl.ts` — the vertex (place glyph quad at worldAnchor +
|
||||
glyphRect) + fragment (median MSDF distance → alpha → semantic color * glow).
|
||||
Single module; render-only; storage buffers `var<storage, read>` (trap T6).
|
||||
No reserved-word struct fields (traps: not `meta`, not `active`).
|
||||
5. A `layout` helper: wrap/truncate/align strings to a world-space box, return
|
||||
glyph instances. This replaces every DOM `<p>`/`<span>`/label.
|
||||
|
||||
Acceptance: a test route renders "hello · 5de3e41f · trust 51%" as glowing
|
||||
in-canvas text, selectable via pick, materializing glyph-by-glyph, tinted by
|
||||
semantic color. Once this lands, all text everywhere is this layer.
|
||||
|
||||
---
|
||||
|
||||
## 6. THE INTERACTION ENGINE (every click means something, in-canvas)
|
||||
|
||||
`lib/observatory/interaction/` — reuse the existing GPU picking + click-shockwave:
|
||||
- **Cursor field:** the pointer is a soft light in the tissue; nearby cells lean
|
||||
toward it (chemotaxis) — the field *flinches* on movement. Pure GPU, cursor
|
||||
pos as a uniform.
|
||||
- **Hover = ignition:** hovering a pickable object (memory/event/receipt) ignites
|
||||
it + its real neighbors (spreading activation), and its MSDF label
|
||||
materializes. Drives off the pick ID buffer.
|
||||
- **Click = incision + receipt:** click fires a shockwave from the exact object;
|
||||
for a real action (suppress/promote/merge/appeal) the wave + state change play
|
||||
AFTER the API succeeds; for inspect, a receipt panel (MSDF text) etches in.
|
||||
No optimistic fake pulses.
|
||||
- **Controls are organisms:** a "button" = MSDF label + pickable quad + hover
|
||||
ignition + click shockwave. A slider (e.g. duplicate threshold, forgetting
|
||||
horizon) = a draggable pickable node that physically moves the field.
|
||||
- Keyboard: the accessibility mirror's focusable proxies map 1:1 to pickable
|
||||
objects; Tab moves a GPU focus-ring (cyan) through them; Enter = click.
|
||||
|
||||
---
|
||||
|
||||
## 7. PER-ROUTE ORGANS (all-WebGPU, real data, discipline test governs)
|
||||
|
||||
Each = `RouteStage` mounting: L0 field + L1 organ pass + L2 text + L3 interaction.
|
||||
The route's `*-scene.ts` adapter turns real API/events → `RouteSceneModel`
|
||||
(`assertProvenance`). DONE organs need their DOM panels replaced by the text
|
||||
layer.
|
||||
|
||||
**Flagships (highest share value, do first):**
|
||||
1. **Reasoning — Eight-Stage Thought Organ.** `/deep_reference` replays as 8
|
||||
glowing chambers; evidence cells flow chamber→chamber on compute Bezier
|
||||
splines; a stage lights only if it has real output; contradiction/supersession
|
||||
interrupts cut the path; the answer + evidence render as MSDF scars in-scene.
|
||||
Click a chamber → its receipt etches in. *(pass exists — swap DOM answer panel
|
||||
for MSDF text.)*
|
||||
2. **Blackbox — Agent Flight Recorder.** Real `/traces` as a nervous-system
|
||||
trace: tool calls = impulses on lanes, retrievals = green branches,
|
||||
suppressions = red clamps, writes = cell births, vetoes = immune gates,
|
||||
receipt IDs = MSDF beads. Run list + event log rendered in-canvas. *(pass
|
||||
exists — the run list + event log must become MSDF text, not DOM.)*
|
||||
3. **Contradictions — Immune Synapse Arena.** Dual-channel signed metaball field;
|
||||
higher-trust membrane thickens; unresolved pairs spark scarlet arcs; the pair
|
||||
text + verdict etch in scarlet. *(pass exists.)*
|
||||
4. **Duplicates — Synaptic Fusion Chamber.** Similarity slider physically pushes
|
||||
twin cells together (neck = smoothstep(0.78,0.98,sim)); mismatch tokens =
|
||||
glowing filaments (MSDF); real merge fuses nuclei + receipt ring. *(pass
|
||||
exists.)*
|
||||
5. **Timeline — Bitemporal Growth Rings.** Cut cross-section of the brain:
|
||||
valid-time rings + indigo transaction-time shadows; supersessions cut seams;
|
||||
MSDF date ticks engraved in the rings. *(pass exists.)*
|
||||
|
||||
**Shared effect:** RSB retrograde magenta axon (research D3) — reusable pass,
|
||||
used by Reasoning + Blackbox; target→cause backward wavefront + permanent
|
||||
cause-latch brightening. Magenta lives only here.
|
||||
|
||||
**Sweep organs (same L0 field + text + real-metric adapter, not bespoke heroes):**
|
||||
feed (event bloodstream), schedule (forgetting-debt orrery), patterns
|
||||
(cross-project mycelium/physarum), memories (cellular atlas), explore
|
||||
(chemoattractant probe), importance (salience furnace), dreams (REM forge over
|
||||
the live dream storm), intentions (future-tense germline seeds), memory-prs
|
||||
(immune review capsules), stats (metabolic vitals). Each: `*-scene.ts` adapter +
|
||||
the shared field + MSDF readouts. Nothing static, nothing purple, nothing DOM.
|
||||
|
||||
**The graph route + Memory Cinema:** UNTOUCHED (already all-WebGPU + protected).
|
||||
|
||||
---
|
||||
|
||||
## 8. THE SHARE LOOP — "PLANT A THOUGHT" (the viral wedge, all-WebGPU)
|
||||
|
||||
One public URL, no install. Type a thought/name/handle. Your text is
|
||||
simultaneously the **seed** (phantomBrain: deterministic one-of-a-kind
|
||||
constellation from your text) AND the **label** (MSDF-rendered — your words
|
||||
glowing in the void). The birth engine fires: dust converges → your thought
|
||||
ignites in thin-film iridescence brighter than page-white on XDR → edges engrave
|
||||
to a phantom constellation → settles to your mind-signature. 12s seamless loop,
|
||||
footage-grade through the post chain. Share mints
|
||||
`vestige.dev/mind?t=your-thought` (deterministic replay forever + OG still from
|
||||
`getCurrentTexture` same-task copy). Every share visually unique by construction.
|
||||
End-card (MSDF): *"This is a toy mind. Vestige renders your agent's REAL one."* →
|
||||
install → the real Observatory. This reuses the birth choreography + seeded
|
||||
determinism that already exist; new work = text→seed+label + share/OG mechanics.
|
||||
|
||||
---
|
||||
|
||||
## 9. THE HARD TRAPS (all found live — every worker card carries these)
|
||||
|
||||
- **T-DOM:** ZERO visible DOM. If a route renders a styled `<div>`/`<p>`/`<button>`
|
||||
the user can see, it's a RED card. Text = MSDF layer. Controls = pickable
|
||||
quads. The only DOM is the invisible a11y mirror.
|
||||
- **T3 (rgba16float):** field ping-pong = `rgba16float` render-attachment
|
||||
textures; separable blur = FULLSCREEN RENDER PASSES; NEVER
|
||||
`texture_storage_2d<...,write>`+textureStore (not portable). Copy the proven
|
||||
field pipeline from reasoning-theater-pass.ts.
|
||||
- **T6 (module split):** a render pipeline's WGSL module must not declare
|
||||
`read_write` storage or write storage textures (compute-only). Render-stage
|
||||
storage buffers are `var<storage, read>`. Split modules per pipeline.
|
||||
- **WGSL reserved words:** never a var OR struct field named `active`, `meta`,
|
||||
`filter`, `sample`, `texture`, `binding`, `common`, `override`. (`active` broke
|
||||
Organ 1; `meta` broke Blackbox.) Pick names like `beat`/`info`.
|
||||
- **Struct-field match:** every `struct.field` referenced in WGSL must EXIST in
|
||||
the struct. TS build does not catch this.
|
||||
- **Pipeline-layout scoping (web-verified vs W3C spec):** a pipeline's layout
|
||||
need only cover the SELECTED entry point's statically-reached bindings; a
|
||||
shared multi-entry-point module is fine. Watch texture sampleType `float` vs a
|
||||
filtering sampler (creation error).
|
||||
- **FramePass order:** engine calls ALL `compute(encoder)` then ONE main scene
|
||||
pass calls ALL `render(pass)`. Field passes encode their own offscreen
|
||||
splat+blur inside `compute(encoder)`; draw membrane + text in `render()`.
|
||||
- **One-shot events = labeled deterministic replay,** never fake streaming.
|
||||
- **Capture:** snapshot copies `getCurrentTexture()` in the same task (WebGPU
|
||||
ignores preserveDrawingBuffer). Wordmark baked in the post chain.
|
||||
|
||||
## 9b. LIFECYCLE CONTRACT (every organ, all in-canvas)
|
||||
loading / ready / stale / empty / error / reduced-motion / paused — all rendered
|
||||
as GPU states (a "loading" organ breathes dim; "empty" = calm blackwater +
|
||||
honest MSDF line; "error" = scarlet pulse + MSDF message). WebGPU absent → the
|
||||
a11y DOM mirror + a static message + last OG still. Reduced-motion freezes
|
||||
ambient drift but KEEPS discrete event pulses. Persistent GPU pause control.
|
||||
|
||||
## 9c. PROTECTED (never touch)
|
||||
`MemoryCinema.svelte`, `graph/cinema/*`, the Graph field's 64-byte NodeState +
|
||||
the 8 existing observatory shaders. Reuse the engine; don't fork it.
|
||||
|
||||
## 9d. GATE (every card, run — don't self-assess)
|
||||
`pnpm --filter @vestige/dashboard check` = 0 errors; `build` succeeds; **AND the
|
||||
CONDUCTOR does a live-GPU audit** (Preview console + getCompilationInfo +
|
||||
screenshot) — because check/build cannot catch invalid-pipeline runtime errors
|
||||
or "still looks like slop." "Renders with real data" ≠ "looks category-of-one" —
|
||||
hold BOTH bars.
|
||||
|
||||
---
|
||||
|
||||
## 10. BUILD ORDER — 5 days to launch (T-5)
|
||||
|
||||
**Day 1 (today) — THE FOUNDATION (blocks everything):**
|
||||
1. MSDF text engine (§5): atlas + msdf-atlas.ts + text-layer.ts + msdf-text.wgsl
|
||||
+ layout helper. This is the critical path — nothing all-WebGPU works without
|
||||
it. De-risk it FIRST with a one-line test route.
|
||||
2. Rebuild `RouteStage.svelte`: remove ALL DOM panels; host L0 field + L1 organ +
|
||||
L2 text + L3 interaction + the invisible a11y mirror. One canvas.
|
||||
3. Interaction engine (§6): cursor field + hover ignition + click shockwave +
|
||||
pickable-quad "controls", reusing GPU picking.
|
||||
|
||||
**Day 2 — CONVERT THE 3 DONE FLAGSHIPS TO ALL-WEBGPU:**
|
||||
Reasoning, Contradictions, Blackbox already have organ passes — strip their DOM
|
||||
panels, render all their text/controls via the MSDF + interaction layers. These
|
||||
become the proof the all-WebGPU pattern works end-to-end. Live-GPU audit each.
|
||||
|
||||
**Day 3 — THE OTHER 2 FLAGSHIPS + RSB axon:**
|
||||
Duplicates + Timeline organs, all-WebGPU. Build the shared RSB retrograde-axon
|
||||
pass; wire into Reasoning + Blackbox.
|
||||
|
||||
**Day 4 — THE SWEEP ORGANS (parallel swarm, all-WebGPU):**
|
||||
The 10 remaining routes through the same shell: L0 field + MSDF readouts + real
|
||||
adapter. GPT-5.5 swarm (gateway dispatcher, verifier gate) — cards MUST say "all
|
||||
WebGPU, zero DOM, spawn subagents, build the files, gate." Conductor live-audits
|
||||
each.
|
||||
|
||||
**Day 5 — "PLANT A THOUGHT" + SHARE + POLISH:**
|
||||
The public share page (§8), OG-still capture, mediabunny clip export, baked
|
||||
wordmark, the extended-range HDR "gasp", the mega-PR, final live-audit sweep of
|
||||
every route (no DOM, no purple, gates green, looks insane). Launch July 14.
|
||||
|
||||
---
|
||||
|
||||
## 11. HOW WE EXECUTE (Opus conducts, GPT-5.5 builds, Vestige remembers)
|
||||
|
||||
- **Opus = conductor/auditor:** recall Vestige FIRST before every non-trivial
|
||||
move (the failure this session was substituting faded memory for the record);
|
||||
set precise cards; live-GPU audit every organ; protect Cinema/Graph field;
|
||||
hold the visual bar; commit only what's verified.
|
||||
- **GPT-5.5 = builder** on `--yolo`, xhigh, spawning its own `delegate_task`
|
||||
subagents; for design/visual work give it the `claude-design` skill +
|
||||
computer-use so it iterates by LOOKING at the real page. Swarm (kanban +
|
||||
gateway dispatcher) for parallel organs; verifier profile gates on real exit
|
||||
codes.
|
||||
- **Vestige = memory:** every decision/correction/finding saved the moment it
|
||||
lands; recall before diagnosing. The dashboard IS the demo of this — it must
|
||||
never again be built against a forgotten decision.
|
||||
- **ONE THING AT A TIME.** Stop the swarm before switching workstreams. No
|
||||
parallel contradictory builds.
|
||||
|
||||
---
|
||||
|
||||
## 12. THE ACCEPTANCE TEST (are we done?)
|
||||
Open any route. Screen-record it on a phone. Does the recording look like a
|
||||
living brain nobody else has — or like a web dashboard? If any DOM chrome, any
|
||||
purple, any static panel is visible, it's not done. Every glyph glows, every
|
||||
click cuts tissue and returns a receipt, every color means a real Vestige-only
|
||||
fact, and the whole thing is one shareable WebGPU surface. That — and only that —
|
||||
is the launch.
|
||||
```
|
||||
```
|
||||
126
.council/MSDF-BUILD-SPEC.md
Normal file
126
.council/MSDF-BUILD-SPEC.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# MSDF TEXT ENGINE — VERIFIED BUILD SPEC (trap-free, first-try-correct)
|
||||
|
||||
Produced by an adversarial verification workflow (5 verifiers read real source + 3 skeptics
|
||||
attacked the silent-fail risks + WGSL validated under naga 30.0.0 / MSL 2.1). Every fix below
|
||||
is verified against the real repo, atlas JSON, and the shipped reasoning-theater-pass.ts.
|
||||
The swarm worker builds against THIS, not raw council files.
|
||||
|
||||
Repo: /Users/entity002/vestige · branch feat/dashboard-live-max · cd there, work in the real repo.
|
||||
|
||||
---
|
||||
|
||||
## THE 6 SILENT-FAIL TRAPS (each passes `pnpm check` and fails at runtime — resolve ALL)
|
||||
|
||||
1. **V-FLIP (upside-down glyphs).** atlas `yOrigin:"bottom"`; GPU texture V is top-down.
|
||||
On the CPU when packing instances: `u0=left/512, u1=right/512, v0=1-top/512, v1=1-bottom/512`.
|
||||
Verified for 'A' (atlasBounds top=373.5): `v0 = 1 - 373.5/512 = 0.2705`. Omit → every glyph
|
||||
upside down, passes check silently. GOLDEN TEST: assert the top-most vertex samples
|
||||
`1 - atlasBounds.top/512` (0.2705 for 'A') so inverted wiring fails LOUD.
|
||||
Shader mapping that matches this pack order (corner.y=0 is quad BOTTOM in +Y-up NDC):
|
||||
`let v = mix(uvMax.y, uvMin.y, corner.y);` where uvMin=(u0,v0)=glyph-top, uvMax=(u1,v1)=glyph-bottom.
|
||||
DO NOT use `out.uv = tex_off + corner*tex_ext` with tex_off=(u0,v0=glyph-top) + positive
|
||||
extent on a +Y-up quad — that double-negates the flip → upside down. (If you keep the
|
||||
additive form, set tex_off=(u0,v1=bottom) and tex_ext.y=v0-v1 NEGATIVE.)
|
||||
|
||||
2. **ATLAS NEVER LOADS IN THE RELEASE BINARY.** Production embeds apps/dashboard/build via
|
||||
include_dir! at compile time. The committed build/ tree is STALE (Jun 21, no msdf/). At
|
||||
runtime GET /dashboard/msdf/jetbrains-mono.png hits the SPA fallback → returns index.html
|
||||
with HTTP 200 (not 404) → `fetch().json()` throws parsing HTML, texture never creates, the
|
||||
ENTIRE text layer silently no-ops. FIX: run `pnpm --filter @vestige/dashboard build` BEFORE
|
||||
any cargo build (adapter-static copies static/msdf/ → build/msdf/). CI already does this
|
||||
(release.yml, pages.yml). Never test the embedded/release build against the stale committed
|
||||
build/ without rebuilding the dashboard first. HARDEN (separate small fix): make
|
||||
serve_dashboard_asset return a real 404 for .png/.json/.woff2 paths instead of the 200 SPA
|
||||
fallback, so a missing atlas fails loud.
|
||||
|
||||
3. **BASE PATH.** Fetch via `import { base } from '$app/paths'` → `${base}/msdf/jetbrains-mono.{png,json}`.
|
||||
NOT `import.meta.env.BASE_URL`, NOT hardcoded `/msdf/...`. Default base `/dashboard`
|
||||
(svelte.config.js VESTIGE_BASE_PATH ?? '/dashboard'; CI → `/vestige`). Codebase standard is
|
||||
$app/paths base everywhere. Hardcoded 404s in prod.
|
||||
|
||||
4. **ATLAS TEXTURE FORMAT = `rgba8unorm`, NOT `rgba8unorm-srgb`.** MTSDF channels are LINEAR
|
||||
distance values; an sRGB view corrupts the median → fuzzy/blown edges. Upload via
|
||||
fetch→blob→createImageBitmap→copyExternalImageToTexture (net-new; no existing example in repo).
|
||||
Usage: TEXTURE_BINDING | COPY_DST.
|
||||
|
||||
5. **BLEND = premultiplied over-blend, NOT additive.** Text is INK, not glowing energy —
|
||||
additive AA fringes go over-bright/crunchy. Use `color/alpha: {srcFactor:'one',
|
||||
dstFactor:'one-minus-src-alpha', operation:'add'}` and output premultiplied `vec4f(rgb*alpha, alpha)`.
|
||||
It STILL glows because it's drawn into the pre-bloom rgba16float HDR scene; the glow comes
|
||||
from the post chain, not the blend mode. (The reference pass uses one/one additive because
|
||||
its marks ARE energy — text is the exception.)
|
||||
|
||||
6. **ASPECT + ASCII + ROUTE NAME.**
|
||||
- Aspect: NDC is [-1,1] both axes but viewport isn't square. Size glyphs in NDC-Y units,
|
||||
divide X by `aspect = viewport_w/viewport_h` (params[6]/params[7]) IN THE VERTEX SHADER,
|
||||
and apply the SAME divide in pickAt or clicks miss.
|
||||
- ASCII-only atlas (95 glyphs, U+0020–U+007E). The demo `·` (U+00B7) and `…` (U+2026) are
|
||||
NOT present → they silently drop. Use `|` as separator and `...` for truncation. Filter
|
||||
any input string to 0x20–0x7E; substitute `?` (0x3F) for out-of-range codepoints.
|
||||
- `_msdftest` is a PUBLIC route at /dashboard/_msdftest (SvelteKit does NOT hide leading-
|
||||
underscore dirs — that's Remix/React-Router). Fine as a scratch name; just know it's routable.
|
||||
|
||||
CONFIRMED CLEAN (adversarial verdict, not a trap): the WGSL validates under naga 30.0.0 +
|
||||
MSL 2.1 (the M1 Max Metal path Dawn uses), ZERO errors/warnings. Params struct = 16 f32 = 64
|
||||
bytes = engine.paramsBuffer.byteLength. Single rgba16float color attachment, no depthStencil/
|
||||
multisample — matches engine.ts main render pass. `in`/`out` are legal WGSL (only `input`/`output`
|
||||
are reserved). Bind layout b0 uniform(V|F) / b1 read-only-storage(V) / b2 filtering-sampler(F) /
|
||||
b3 float-texture(F) is a valid pairing with a filterable rgba8unorm atlas.
|
||||
|
||||
---
|
||||
|
||||
## DELIVERABLE FILES (5)
|
||||
|
||||
### 1. layout.ts — pure (text, opts) → GlyphInstance[]
|
||||
GlyphInstance = 8 floats, 32-byte stride (matches webgpu-samples Char{texOffset,texExtent,size,offset}):
|
||||
`{ x,y,w,h (quad in NDC/em from planeBounds), u,v,uw,vh (atlas UV, V ALREADY FLIPPED) }`.
|
||||
Placement (verified vs real metrics, monospace advance=0.6 for ALL 95 glyphs incl space):
|
||||
```
|
||||
penX=0, penY=0 (baseline, +Y up)
|
||||
per char: '\n' → penX=0, penY-=1.32(lineHeight); else
|
||||
g=glyphMap[cp] (fallback '?' if missing)
|
||||
if g.planeBounds: emit {x:penX+pb.left, y:pb.bottom, w:pb.right-pb.left, h:pb.top-pb.bottom, u,v,uw,vh}
|
||||
penX += 0.6 (ALWAYS, even for space/no-bounds)
|
||||
```
|
||||
Wrap/truncate in em units: maxChars=floor(maxWidth_em/0.6). Truncate with `...` (ASCII).
|
||||
A full line box spans em [-0.30, +1.02] around baseline (descender..ascender) for pick-rect sizing.
|
||||
|
||||
### 2. msdf-atlas.ts — loader
|
||||
fetch `${base}/msdf/jetbrains-mono.json` → glyph Map<unicode, {advance, planeBounds?, atlasBounds?}>
|
||||
+ atlas{distanceRange:4,size:48,width:512,height:512} + metrics{lineHeight:1.32,ascender:1.02,descender:-0.3}.
|
||||
fetch `${base}/msdf/jetbrains-mono.png` → blob → createImageBitmap → device.createTexture(rgba8unorm,
|
||||
TEXTURE_BINDING|COPY_DST) → copyExternalImageToTexture. Sampler: linear/linear, clamp-to-edge.
|
||||
|
||||
### 3. text-layer.ts — FramePass (render-only, no compute)
|
||||
Copy the reasoning-theater-pass.ts pattern: own createBindGroupLayout + createPipelineLayout
|
||||
(NOT auto — paramsBuffer is engine-shared), ensurePipeline/ensureResources guards. Instance data
|
||||
= flat Float32Array in a STORAGE|COPY_DST buffer read `var<storage, read>` indexed by
|
||||
instance_index (NOT vertex attributes — the shipped code has zero vertex buffers). init() is
|
||||
async (atlas fetch); render() no-ops until pipeline+bindGroup+glyphCount ready. setText(items)
|
||||
builds the buffer + a runs[] list of NDC AABBs for picking. pickAt(ndcX,ndcY) AABB-tests runs
|
||||
(apply the aspect divide). draw(6, glyphCount). Target engine.sceneFormat, premultiplied over-blend.
|
||||
|
||||
### 4. shaders/msdf-text.wgsl.ts — single render-only module (VERIFIED, validates clean)
|
||||
struct Params (16 f32, exact engine layout) @group0@binding0 uniform;
|
||||
struct Glyph {anchor_size:vec4f, quad_offset:vec4f, uv_rect:vec4f, info:vec4f, color:vec4f}
|
||||
(info.x=age_frame, info.y=reveal_span for glyph-by-glyph typewriter; NO reserved field names —
|
||||
uses info/beat/data2, avoids meta/active/filter/sample/texture/binding/common/override);
|
||||
@binding1 var<storage,read> glyphs; @binding2 filtering sampler; @binding3 texture_2d<f32> atlas.
|
||||
QUAD = 6-vert unit quad [0,1]². vertex: pos = anchor + quad_offset + corner*size, x/=aspect;
|
||||
uv: u=mix(uvMin.x,uvMax.x,corner.x), v=mix(uvMax.y,uvMin.y,corner.y). fragment:
|
||||
`fn median(c)=max(min(c.r,c.g),min(max(c.r,c.g),c.b))`; screen-space AA = fwidth(uv)*texDims →
|
||||
texels/px, *distanceRange(4), px_dist=range*(dist-0.5), cov=clamp(px_dist+0.5,0,1)
|
||||
[≡ smoothstep(-.5,.5,px_dist)]; reveal=clamp((frame-age_frame)/span,0,1); alpha=cov*base_a*reveal;
|
||||
discard if <0.001; return vec4f(rgb*alpha, alpha).
|
||||
|
||||
### 5. routes/(app)/_msdftest/+page.svelte — test route (zero DOM beyond the canvas)
|
||||
mounts ObservatoryCanvas demo="recall-path" seed="msdf-test-v1"; onready(engine)→ new TextLayerPass,
|
||||
await init(), setText("hello | 5de3e41f | trust 51%") [note `|` not `·`], engine.addPass. Pointer
|
||||
host div (transparent, paints nothing) forwards pointerdown → NDC → pass.pickAt. ObservatoryCanvas
|
||||
renders exactly ONE <canvas>. (Flag: ObservatoryCanvas still has a DOM .fallback panel — later card.)
|
||||
|
||||
## ACCEPTANCE
|
||||
Test route renders "hello | 5de3e41f | trust 51%" as glowing in-canvas MSDF text, RIGHT-SIDE UP,
|
||||
crisp at any zoom, materializing glyph-by-glyph, pickable. Gate: `pnpm --filter @vestige/dashboard
|
||||
check` (0 errors) + `build`. Conductor live-GPU audit: getCompilationInfo clean + screenshot shows
|
||||
upright crisp glowing text (the V-flip golden test must pass).
|
||||
|
|
@ -1,124 +1,195 @@
|
|||
# ORGAN BUILD BRIEF — swarm worker cards read this FIRST
|
||||
# ORGAN BUILD BRIEF v2 — ALL-WEBGPU, ZERO DOM (swarm workers read this FIRST)
|
||||
|
||||
You are a GPT-5.5 worker building ONE Cognitive OS dashboard organ. Branch
|
||||
`feat/dashboard-live-max` at `/Users/entity002/vestige` (cd there; the kanban
|
||||
scratch workspace is just your home — do the real work in the repo).
|
||||
You are a GPT-5.5 worker building ONE piece of the Vestige **Cognitive OS** dashboard.
|
||||
Branch `feat/dashboard-live-max` at `/Users/entity002/vestige` (cd there; the kanban
|
||||
scratch home is not the repo — do the real work in the repo).
|
||||
|
||||
## 🔴 THIS IS A BUILD TASK. YOU MUST WRITE + SAVE REAL FILES. (read twice)
|
||||
A "design", "plan", "recommendation", "handoff", or "reconnaissance" is a
|
||||
FAILURE. You are NOT done until three NEW files exist on disk and the gate is
|
||||
green. If you finish without writing files, you have failed the card.
|
||||
Source of truth for architecture: `.council/GOD-TIER-ALLWEBGPU-PLAN.md`. Read it.
|
||||
|
||||
MANDATORY DEFINITION OF DONE (all must be TRUE, verify each yourself):
|
||||
1. `apps/dashboard/src/lib/observatory/<organ>/<organ>-scene.ts` EXISTS on disk
|
||||
(you wrote it — `ls` it to confirm).
|
||||
2. `apps/dashboard/src/lib/observatory/<organ>/<organ>-pass.ts` EXISTS on disk
|
||||
(you wrote the FramePass + WGSL — `ls` it to confirm).
|
||||
3. `apps/dashboard/src/routes/(app)/<organ>/+page.svelte` is MODIFIED to mount
|
||||
`<RouteStage organ="<organ>" passes={...} scene={...} .../>` full-bleed
|
||||
(`git diff` it to confirm your edit is there).
|
||||
4. `pnpm --filter @vestige/dashboard check` prints `0 ERRORS`. You RAN it and
|
||||
pasted the real output.
|
||||
5. `pnpm --filter @vestige/dashboard build` succeeded. You RAN it.
|
||||
In your completion summary you MUST paste: the `ls -la` of your organ dir, the
|
||||
`git diff --stat` showing your changed files, and the tail of the real check +
|
||||
build output. NO files + no gate output = the verifier bounces you as RED.
|
||||
---
|
||||
|
||||
## HOW TO BUILD IT (do the work, don't just plan it)
|
||||
1. FIRST copy the working reference to steal its exact structure:
|
||||
`cp apps/dashboard/src/lib/observatory/reasoning/reasoning-theater-pass.ts
|
||||
apps/dashboard/src/lib/observatory/<organ>/<organ>-pass.ts` then rewrite it
|
||||
for your organ's hero + real data. Same for the scene adapter (copy
|
||||
reasoning-scene.ts). This guarantees the WebGPU-safe pipeline (all render
|
||||
passes, no storage textures, split modules, no reserved words, real struct
|
||||
fields) instead of reinventing it and re-hitting the traps.
|
||||
2. You MAY use `delegate_task` to PARALLELIZE the WRITING — but each subagent
|
||||
must WRITE ITS FILE, not return prose. E.g. delegate: "write the full
|
||||
contents of <organ>-scene.ts to disk and confirm with ls" — not "recommend a
|
||||
design." If a subagent returns a design instead of a written file, YOU write
|
||||
the file from its design. The card is not done until the files are on disk.
|
||||
3. Then YOU integrate, wire the route, and RUN the gate. Paste the output.
|
||||
The bar is "people lose their minds" AND it renders — but step 0 is the files
|
||||
must exist. A brilliant design with no files is a RED card. GO WRITE CODE.
|
||||
## 🚫 THE ONE RULE THAT OVERRIDES EVERYTHING — ZERO DOM. EVERY LINE BREATHES WebGPU.
|
||||
|
||||
READ FIRST, in order:
|
||||
1. `/Users/entity002/vestige/.council/ROUND3-FINAL-PLAN.md` — the full plan +
|
||||
§6b traps T1-T6 (BINDING).
|
||||
2. `/Users/entity002/vestige/.council/gpt55-round2-research.md` — the WGSL
|
||||
recipes (metaball field D1, spline advection D2, retrograde axon D3).
|
||||
3. WORKING REFERENCE CODE — copy the proven patterns, don't reinvent:
|
||||
- `apps/dashboard/src/lib/observatory/reasoning/reasoning-theater-pass.ts`
|
||||
(the metaball field: additive splat -> render-pass blur -> membrane, all
|
||||
render passes, no storage textures; compute spline advection).
|
||||
- `apps/dashboard/src/lib/observatory/contradictions/contradictions-pass.ts`
|
||||
(dual-channel signed field, scarlet seam).
|
||||
- `apps/dashboard/src/lib/observatory/RouteStage.svelte` (the organ shell).
|
||||
- `apps/dashboard/src/lib/observatory/route-scene.ts` (RouteSceneModel +
|
||||
assertProvenance) and `cognitive-palette.ts` (the color language).
|
||||
The ENTIRE dashboard renders in WebGPU into a **single `<canvas>`**. Text, labels,
|
||||
memory content, receipt IDs, numbers, buttons, controls, cursor, focus, selection,
|
||||
loading / empty / error / paused states — **100% drawn by the GPU into that one
|
||||
canvas.** The ONLY non-WebGPU element allowed on the page is the `<canvas>` itself.
|
||||
|
||||
**FORBIDDEN (any one = RED card, no exceptions):**
|
||||
- ANY `<div>`, `<p>`, `<span>`, `<button>`, `<ul>`, list panel, detail panel, label,
|
||||
tooltip, overlay, or badge that a user can see OR that is present in the DOM tree.
|
||||
- An "invisible" / `sr-only` / `aria-live` accessibility mirror. **Sam rejected it
|
||||
explicitly** ("FUCK TO THE NO"). Do NOT add one to "solve" accessibility.
|
||||
- A DOM/SVG fallback panel for "WebGPU absent". The absent-WebGPU state is drawn on
|
||||
the canvas too (a static honest message via the text layer + last frame), never a
|
||||
DOM node. Never a black screen either.
|
||||
- `app.css` styling of any visual element. It is effectively empty.
|
||||
|
||||
Accessibility is handled canvas-side (pickable quads + GPU focus ring) or DEFERRED —
|
||||
never by reintroducing DOM. The shareable-canvas thesis wins: a full-screen WebGPU
|
||||
canvas survives a screen-recording / OS screenshot / phone camera; a DOM dashboard
|
||||
reads as a generic web app and dies as a shareable. That is the whole point.
|
||||
|
||||
**ACCEPTANCE TEST (the verifier + the human conductor both run it):** screen-record the
|
||||
route. If a single HTML element is visible OR present in the DOM tree beyond `<canvas>`,
|
||||
the card is WRONG. Text = MSDF glyphs. A "button" = MSDF label + pickable quad +
|
||||
click-shockwave. Focus/selection = a GPU cyan highlight, not a CSS outline.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 THIS IS A BUILD TASK — YOU MUST WRITE + SAVE REAL FILES (read twice)
|
||||
|
||||
A "design", "plan", "recommendation", "handoff", or "reconnaissance" is a FAILURE.
|
||||
You are NOT done until real files exist on disk AND the gate is green AND there is
|
||||
zero DOM. If you finish without writing files, you failed the card.
|
||||
|
||||
You MUST paste, in your completion summary:
|
||||
- `ls -la` of the files you created,
|
||||
- `git diff --stat` of what you changed,
|
||||
- the real tail of `pnpm --filter @vestige/dashboard check` (**0 errors**) and `build`,
|
||||
- a one-line confirmation you did NOT add any DOM element and did NOT touch protected files.
|
||||
|
||||
No files + no gate output = the verifier bounces you RED.
|
||||
|
||||
---
|
||||
|
||||
## 🔥 WORK TO THE ABSOLUTE MAX — SPAWN YOUR SUBAGENTS (non-negotiable)
|
||||
|
||||
Do NOT build solo. Use `delegate_task(tasks=[...])` to fan out PARALLEL subagents.
|
||||
Delegation is configured for **5 concurrent children, spawn depth 2** — use it.
|
||||
|
||||
Recommended fan-out per organ card (each subagent WRITES ITS OWN FILE — a subagent that
|
||||
returns prose instead of a written file is wasted; if that happens, YOU write the file):
|
||||
- **subagent A** — research the REAL API/event payload for this organ (curl the running
|
||||
brain on `http://127.0.0.1:3931`, read the Rust handler) and write findings to
|
||||
`.council/notes/<organ>-data.md`. Confirms the discipline-test signal EXISTS before any WGSL.
|
||||
- **subagent B** — write `<organ>/<organ>-scene.ts` (adapter: real payload → RouteSceneModel).
|
||||
- **subagent C** — write `<organ>/<organ>-pass.ts` (the FramePass + WGSL hero).
|
||||
- **subagent D** — write the MSDF text/label layout for this organ (uses the shared text layer).
|
||||
- then **YOU** integrate, wire the route `+page.svelte`, run the gate, paste output.
|
||||
|
||||
**⚠️ CONTAINER-COLLISION TRAP (verified from Hermes docs):** parallel subagents SHARE
|
||||
ONE container/filesystem. Concurrent `cd`, env mutations, and writes to the SAME path
|
||||
COLLIDE. So: give each subagent a DISTINCT target file path, tell each to use absolute
|
||||
paths (no `cd`), and never have two subagents write the same file. Integration (one
|
||||
writer) is YOUR job after they return.
|
||||
|
||||
The bar is **"people lose their minds"** AND it renders AND it is zero-DOM — not "it compiles".
|
||||
|
||||
---
|
||||
|
||||
## THE VERIFIED CONTRACTS (read from real source — conform exactly)
|
||||
|
||||
**FramePass** (`$lib/observatory/engine.ts`): `interface FramePass { compute?(encoder:
|
||||
GPUCommandEncoder, frame: number): void; render?(pass: GPURenderPassEncoder, frame:
|
||||
number): void }`. The engine runs **ALL** passes' `compute(encoder)` first, THEN opens
|
||||
**ONE** main HDR scene render pass (clears to `VOID_CLEAR_HDR`) and runs **ALL** passes'
|
||||
`render(pass)`. So: a field/offscreen pass encodes its own offscreen splat+blur render
|
||||
passes INSIDE `compute(encoder)`; it draws its membrane/quads/text in `render(pass)`.
|
||||
|
||||
**Render target format**: draw into `engine.sceneFormat` (= `'rgba16float'`, the offscreen
|
||||
HDR scene texture — NOT the swapchain). PostChain composites scene→swapchain afterward
|
||||
(mip bloom → Khronos tonemap → grain → vignette). **Text drawn into the scene gets bloom
|
||||
for free = it glows.** Render your MSDF text BEFORE post, into the scene texture.
|
||||
|
||||
**Params uniform**: `engine.paramsBuffer`, 16 f32, bound `@group(0) @binding(0)`. Struct
|
||||
layout (COMMON_WGSL): frame, loop_phase, node_count, edge_count, path_count, pulse,
|
||||
viewport_w, viewport_h, brightness, demo_id, time, capture_mode, live_kind, live_frame,
|
||||
live_energy, projection_days.
|
||||
|
||||
**Pick / interaction**: implement `pickAt(ndcX, ndcY): RoutePick | null` (CPU rect hit-test
|
||||
against your laid-out quads/glyph boxes). RouteStage converts pointer clientXY → NDC
|
||||
(y flipped) and calls each pass's `pickAt`. A control is a pickable quad, not a `<button>`.
|
||||
|
||||
**RouteSceneModel** (`$lib/observatory/route-scene.ts`): nodes/edges/events/receipts each
|
||||
carry a `Provenance {kind, id, scalar?}`. `assertProvenance(scene)` (call in `import.meta.env.DEV`)
|
||||
THROWS if any primitive lacks a real source. `scalars: Record<string, number>` for route
|
||||
metrics. `emptyScene(organ)` → `alive:false` honest empty (drawn on canvas, not DOM).
|
||||
|
||||
**Copy the proven field pipeline** from `reasoning/reasoning-theater-pass.ts`: additive splat
|
||||
→ fullscreen blur-H → blur-V → membrane, ALL render passes, rgba16float textures with usage
|
||||
`RENDER_ATTACHMENT | TEXTURE_BINDING` (NO STORAGE_BINDING). Own bind-group layouts +
|
||||
pipeline layouts in `ensurePipelines`; recreate field textures on resize in `ensureResources`.
|
||||
|
||||
---
|
||||
|
||||
## DAY-1 CRITICAL PATH (dependency chain — these are SEQUENTIAL, not parallel organs)
|
||||
|
||||
Nothing all-WebGPU renders text until the MSDF engine exists. Build in THIS order; each is
|
||||
its own card, live-GPU-audited by the conductor before the next starts:
|
||||
|
||||
1. **MSDF text engine** (`$lib/observatory/text/`): a checked-in MSDF atlas
|
||||
(JetBrains Mono, `static/msdf/` PNG + JSON — generate offline, never at runtime) +
|
||||
`msdf-atlas.ts` (loads atlas texture + glyph metrics) + `text-layer.ts` (a FramePass:
|
||||
CPU lays out strings → per-glyph instance buffer {glyphRect, atlasRect, worldAnchor,
|
||||
semanticColorRGBA, ageFrame, confidence}; GPU draws instanced quads, median-of-RGB MSDF
|
||||
distance → smoothstep AA, tints by semantic color, reveals glyph-by-glyph over ageFrame,
|
||||
renders into the scene pre-bloom) + `msdf-text.wgsl` (single render-only module; storage
|
||||
buffers `var<storage, read>`; no reserved-word fields) + a `layout` helper. ACCEPTANCE:
|
||||
a test route renders `hello · 5de3e41f · trust 51%` as glowing in-canvas text, pickable,
|
||||
materializing glyph-by-glyph, tinted by semantic color.
|
||||
2. **Zero-DOM RouteStage rebuild** (`RouteStage.svelte`): strip EVERY DOM node — the
|
||||
`<slot name="chrome">`, the `<button>` pause control, the telemetry/loading/error/empty
|
||||
`<div>`s. Result = one `<canvas>` (via ObservatoryCanvas) hosting L0 field + L1 organ +
|
||||
L2 MSDF text + L3 interaction. Pause control, telemetry, loading/error/empty are all MSDF
|
||||
text + pickable quads on the canvas. Pointer/keyboard events on the canvas → pickAt.
|
||||
3. **Interaction engine** (`$lib/observatory/interaction/`): cursor field (pointer = soft
|
||||
light, chemotaxis flinch), hover ignition (hover a pickable → it + real neighbors ignite,
|
||||
MSDF label materializes), click = incision + receipt (real actions play the wave AFTER the
|
||||
API succeeds — no optimistic fake pulses), pickable-quad controls, GPU cyan focus ring.
|
||||
|
||||
Only AFTER Day-1 lands do the flagship conversions (Day 2) and the sweep organs (Day 4,
|
||||
parallel swarm) run — each reusing this text + interaction layer.
|
||||
|
||||
---
|
||||
|
||||
## HARD RULES (a card is RED if it violates any)
|
||||
- NEVER touch: `MemoryCinema.svelte`, `src/lib/graph/cinema/*`,
|
||||
`observatory/types.ts` NodeState (64 bytes), the 8 existing observatory
|
||||
shaders, the Graph field. REUSE the engine, don't fork it.
|
||||
- Every visual primitive carries real `Provenance` (memory/event/receipt/pair/
|
||||
trace/pr/pattern/scalar). Run `assertProvenance(scene)` in dev. NO
|
||||
Math.random() as a semantic input. The Math.random() discipline test governs.
|
||||
- Reuse `cognitive-palette.ts`. magenta `#FF2DF7` = RSB retrograde causality
|
||||
ONLY. indigo `#7C6CFF` = bitemporal ONLY. scarlet/immune reds for
|
||||
contradiction/veto/suppression. blackwater `#020307` base, never purple.
|
||||
|
||||
## WEBGPU TRAPS (all found live in Organ 1/3 — DO NOT repeat)
|
||||
- T3: field ping-pong textures are `rgba16float` with usage `RENDER_ATTACHMENT
|
||||
| TEXTURE_BINDING` (NO STORAGE_BINDING). Do separable blur as FULLSCREEN
|
||||
RENDER PASSES, never `texture_storage_2d<...,write>` + textureStore (not
|
||||
portable). The whole field pipeline = render passes: splat(additive) ->
|
||||
blur-H -> blur-V -> membrane. Copy reasoning-theater-pass.ts.
|
||||
- T6: split WGSL into per-pipeline modules. A render pipeline's module must NOT
|
||||
declare `var<storage, read_write>` or write storage textures (compute-only).
|
||||
Render-stage storage buffers are `var<storage, read>` only.
|
||||
- WGSL reserved words: NEVER name a var OR STRUCT FIELD `active`, `meta`,
|
||||
`filter`, `common`, `sample`, `override`, `enable`, `const`, `handle`,
|
||||
`input`, `output`, `texture`, `binding`, `access`, `layout`. (`active` broke
|
||||
Organ 1, `meta` broke Blackbox — both found live.) The WGSL reserved list is
|
||||
long — pick descriptive safe names like `beat`/`info`/`data2`.
|
||||
- STRUCT-FIELD MATCH: every `struct.field` you reference in WGSL must EXIST in
|
||||
the struct (Organ 3's bug was `p.signals` vs a struct declaring `meta`). TS
|
||||
build does NOT catch this.
|
||||
- FramePass sequencing: the engine calls ALL `compute(encoder, frame)` then
|
||||
opens ONE main HDR scene pass and calls ALL `render(pass, frame)`. So a field
|
||||
pass encodes its own offscreen splat + blur render passes INSIDE
|
||||
`compute(encoder)` (it gets a `GPUCommandEncoder`), then draws the membrane in
|
||||
`render()`. Route field textures need their own `ensure(w,h)` on resize.
|
||||
- Pipeline-layout scoping (web-verified vs W3C spec): a pipeline's explicit
|
||||
layout need only cover the bindings the SELECTED entry point statically
|
||||
reaches — a shared multi-entry-point module is fine. Layout must be a SUPERSET
|
||||
of the used bindings. Watch texture sampleType `float` vs a filtering sampler
|
||||
(common creation error).
|
||||
- One-shot backend events (e.g. DeepReferenceCompleted) become LABELED
|
||||
deterministic replays, never fake streaming. A stage/element lights only if
|
||||
it has real output.
|
||||
- **NEVER touch**: `MemoryCinema.svelte`, `src/lib/graph/cinema/*`, `observatory/types.ts`
|
||||
NodeState (64 bytes), the 8 existing observatory shaders, the Graph field. REUSE the
|
||||
engine, don't fork it.
|
||||
- Every visual primitive carries real `Provenance`. Run `assertProvenance(scene)` in dev.
|
||||
NO `Math.random()` as a semantic input. The Math.random() discipline test governs — swap
|
||||
real data for random and the viewer can tell.
|
||||
- Reuse `cognitive-palette.ts`. blackwater `#020307` base, **never purple, ever**. cyan
|
||||
`#22C7DE` = the single interactive accent. magenta `#FF2DF7` = RSB retrograde causality
|
||||
ONLY. indigo `#7C6CFF` = bitemporal ONLY. scarlet reds = immune (contradiction/veto/suppression).
|
||||
|
||||
## LIFECYCLE CONTRACT (every organ ships all of it)
|
||||
loading / ready / stale / empty / error / reduced-motion / paused. WebGPU
|
||||
absent -> DOM/SVG fallback, NEVER a black canvas. Adapter-null -> honest metric
|
||||
snapshot. RouteStage already inherits pause + reduced-motion — reuse it.
|
||||
## WEBGPU TRAPS (all found live — DO NOT repeat)
|
||||
|
||||
## PER-CARD DELIVERABLE
|
||||
1. `<organ>/<organ>-scene.ts` — adapter: real API/event -> RouteSceneModel,
|
||||
run assertProvenance. Handle the honest empty state (scene.alive=false).
|
||||
2. `<organ>/<organ>-pass.ts` — the FramePass(es) implementing the hero (see the
|
||||
organ's spec in ROUND3-FINAL-PLAN §3/§4).
|
||||
3. Wire `src/routes/(app)/<organ>/+page.svelte`: mount RouteStage full-bleed
|
||||
with the pass; keep DOM detail/list panels for accessibility ON TOP; wire the
|
||||
primary click to a real receipt/action.
|
||||
- **T3 (field textures)**: `rgba16float`, usage `RENDER_ATTACHMENT | TEXTURE_BINDING` (NO
|
||||
STORAGE_BINDING). Separable blur = FULLSCREEN RENDER PASSES; NEVER `texture_storage_2d<...,
|
||||
write>` + textureStore (not portable on M-series). Copy reasoning-theater-pass.ts.
|
||||
- **T6 (module split)**: a render pipeline's WGSL module must NOT declare `var<storage,
|
||||
read_write>` or write storage textures (compute-only). Render-stage storage = `var<storage,
|
||||
read>`. Split modules per pipeline.
|
||||
- **Reserved words** — NEVER a var OR struct field named: `active`, `meta`, `filter`,
|
||||
`sample`, `texture`, `binding`, `common`, `override`, `enable`, `const`, `handle`, `input`,
|
||||
`output`, `access`, `layout`. (`active` broke Organ 1, `meta` broke Blackbox.) Use `beat`/
|
||||
`info`/`data2`.
|
||||
- **Struct-field match**: every `struct.field` referenced in WGSL must EXIST in the struct.
|
||||
TS build does NOT catch this.
|
||||
- **Pipeline-layout scoping**: a pipeline's explicit layout need only cover the SELECTED
|
||||
entry point's statically-reached bindings; a shared multi-entry module is fine. Watch
|
||||
texture `sampleType:'float'` vs a filtering sampler (creation error).
|
||||
- **One-shot events = labeled deterministic replay**, never fake streaming. An element lights
|
||||
only if it has real output.
|
||||
- **Capture**: snapshot copies `getCurrentTexture()` in the SAME task (WebGPU ignores
|
||||
`preserveDrawingBuffer`). Wordmark baked into the post chain, not DOM.
|
||||
|
||||
## GATE (do NOT self-assess — RUN these; the verifier will re-run them)
|
||||
- `pnpm --filter @vestige/dashboard check` -> 0 errors
|
||||
- `pnpm --filter @vestige/dashboard build` -> success
|
||||
Both must pass before you mark the card done. Note in your completion metadata:
|
||||
the files you created, and confirm you did NOT touch protected files.
|
||||
## LIFECYCLE (every organ, all in-canvas — NO DOM)
|
||||
|
||||
## NOTE ON LIVE-GPU BUGS
|
||||
check+build CANNOT catch invalid-pipeline runtime errors. After building, if you
|
||||
can, sanity-check your WGSL modules with `getCompilationInfo()`. The human
|
||||
conductor (Claude) will do the final live-GPU audit against the running app.
|
||||
loading (organ breathes dim + MSDF "replaying…") / ready / stale / empty (calm blackwater +
|
||||
honest MSDF line) / error (scarlet pulse + MSDF message) / reduced-motion (freeze ambient
|
||||
drift, KEEP discrete event pulses) / paused (MSDF pause glyph, pickable). WebGPU absent →
|
||||
canvas-drawn static message + last frame, never DOM, never black.
|
||||
|
||||
## GATE (do NOT self-assess — RUN these; the verifier re-runs them)
|
||||
|
||||
- `pnpm --filter @vestige/dashboard check` → **0 errors**
|
||||
- `pnpm --filter @vestige/dashboard build` → success
|
||||
- If you can, sanity-check WGSL with `getCompilationInfo()` before marking done.
|
||||
The human conductor (Claude/Opus) does the final live-GPU audit (Preview console +
|
||||
getCompilationInfo + screenshot on the running app) — check/build cannot catch invalid-
|
||||
pipeline runtime errors or "still DOM / still slop." Hold BOTH bars: renders-with-real-data
|
||||
AND looks-category-of-one AND zero-DOM.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import atlas from '../../../../static/msdf/jetbrains-mono.json';
|
||||
import { layoutText, type MsdfAtlasJson } from '../text/layout';
|
||||
|
||||
describe('MSDF text layout', () => {
|
||||
it('packs the atlas V flip for the top of A loudly', () => {
|
||||
const glyphs = layoutText('A', atlas as MsdfAtlasJson);
|
||||
expect(glyphs).toHaveLength(1);
|
||||
// atlasBounds.top for A is 373.5 in the checked-in 512px atlas.
|
||||
// WebGPU texture V is top-down, so the glyph top must sample 1 - top/512.
|
||||
expect(glyphs[0].v).toBeCloseTo(1 - 373.5 / 512, 4);
|
||||
expect(glyphs[0].v).toBeCloseTo(0.2705, 4);
|
||||
});
|
||||
|
||||
it('falls back to ASCII question mark for non-atlas characters', () => {
|
||||
const [fallback] = layoutText('·', atlas as MsdfAtlasJson);
|
||||
const [question] = layoutText('?', atlas as MsdfAtlasJson);
|
||||
expect(fallback).toEqual(question);
|
||||
});
|
||||
});
|
||||
146
apps/dashboard/src/lib/observatory/text/layout.ts
Normal file
146
apps/dashboard/src/lib/observatory/text/layout.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
export interface MsdfBounds {
|
||||
left: number;
|
||||
bottom: number;
|
||||
right: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export interface MsdfGlyph {
|
||||
unicode: number;
|
||||
advance?: number;
|
||||
planeBounds?: MsdfBounds;
|
||||
atlasBounds?: MsdfBounds;
|
||||
}
|
||||
|
||||
export interface MsdfAtlasJson {
|
||||
atlas: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
metrics?: {
|
||||
lineHeight?: number;
|
||||
};
|
||||
glyphs: MsdfGlyph[];
|
||||
}
|
||||
|
||||
export interface GlyphInstance {
|
||||
/** Quad origin in em/NDC-y units, baseline-relative, +Y up. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** Quad size in em/NDC-y units. */
|
||||
w: number;
|
||||
h: number;
|
||||
/** Atlas UV origin. v is already flipped from atlas yOrigin: bottom to GPU top-down V. */
|
||||
u: number;
|
||||
v: number;
|
||||
/** Atlas UV extent. vh is positive from flipped glyph top to flipped glyph bottom. */
|
||||
uw: number;
|
||||
vh: number;
|
||||
}
|
||||
|
||||
export interface LayoutTextOptions {
|
||||
/** Maximum line width in em units. Overlong lines are truncated with ASCII dots. */
|
||||
maxWidthEm?: number;
|
||||
/** Monospace advance in em units. The checked-in atlas uses 0.6 for every ASCII glyph. */
|
||||
advance?: number;
|
||||
/** Baseline-to-baseline distance in em units. Defaults to the atlas metric, then 1.32. */
|
||||
lineHeight?: number;
|
||||
}
|
||||
|
||||
const ASCII_MIN = 0x20;
|
||||
const ASCII_MAX = 0x7e;
|
||||
const FALLBACK_CODEPOINT = 0x3f; // '?'
|
||||
const DEFAULT_ADVANCE = 0.6;
|
||||
const DEFAULT_LINE_HEIGHT = 1.32;
|
||||
const ELLIPSIS = '...';
|
||||
|
||||
function glyphMapFor(atlas: MsdfAtlasJson): Map<number, MsdfGlyph> {
|
||||
return new Map(atlas.glyphs.map((glyph) => [glyph.unicode, glyph]));
|
||||
}
|
||||
|
||||
function asciiFallback(char: string): string {
|
||||
const codepoint = char.codePointAt(0) ?? FALLBACK_CODEPOINT;
|
||||
if (codepoint >= ASCII_MIN && codepoint <= ASCII_MAX) {
|
||||
return char;
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
function truncateLine(line: string, maxChars: number | undefined): string {
|
||||
if (maxChars === undefined || maxChars < 0) {
|
||||
return line;
|
||||
}
|
||||
if (maxChars === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const chars = Array.from(line, asciiFallback);
|
||||
if (chars.length <= maxChars) {
|
||||
return chars.join('');
|
||||
}
|
||||
if (maxChars <= ELLIPSIS.length) {
|
||||
return '.'.repeat(maxChars);
|
||||
}
|
||||
return `${chars.slice(0, maxChars - ELLIPSIS.length).join('')}${ELLIPSIS}`;
|
||||
}
|
||||
|
||||
function preparedLines(text: string, maxChars: number | undefined): string[] {
|
||||
return text.split('\n').map((line) => truncateLine(line, maxChars));
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout ASCII-only MSDF glyph instances for the checked-in JetBrains Mono atlas.
|
||||
*
|
||||
* Coordinates are baseline-relative with +Y up. Atlas UVs are packed with the required
|
||||
* V flip: atlas yOrigin is bottom, while GPU texture V is top-down. Newlines reset penX
|
||||
* and move the baseline down by lineHeight. Advance is always 0.6em by default, even
|
||||
* for spaces and glyphs without plane bounds.
|
||||
*/
|
||||
export function layoutText(text: string, atlas: MsdfAtlasJson, options: LayoutTextOptions = {}): GlyphInstance[] {
|
||||
const glyphs = glyphMapFor(atlas);
|
||||
const fallback = glyphs.get(FALLBACK_CODEPOINT);
|
||||
const atlasWidth = atlas.atlas.width;
|
||||
const atlasHeight = atlas.atlas.height;
|
||||
const advance = options.advance ?? DEFAULT_ADVANCE;
|
||||
const lineHeight = options.lineHeight ?? atlas.metrics?.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
const maxChars = options.maxWidthEm === undefined ? undefined : Math.max(0, Math.floor(options.maxWidthEm / advance));
|
||||
|
||||
const instances: GlyphInstance[] = [];
|
||||
let penY = 0;
|
||||
|
||||
for (const line of preparedLines(text, maxChars)) {
|
||||
let penX = 0;
|
||||
for (const rawChar of Array.from(line)) {
|
||||
const char = asciiFallback(rawChar);
|
||||
const codepoint = char.codePointAt(0) ?? FALLBACK_CODEPOINT;
|
||||
const glyph = glyphs.get(codepoint) ?? fallback;
|
||||
|
||||
if (glyph?.planeBounds && glyph.atlasBounds) {
|
||||
const plane = glyph.planeBounds;
|
||||
const atlasBounds = glyph.atlasBounds;
|
||||
const u = atlasBounds.left / atlasWidth;
|
||||
const v = 1 - atlasBounds.top / atlasHeight;
|
||||
const uw = (atlasBounds.right - atlasBounds.left) / atlasWidth;
|
||||
const vh = 1 - atlasBounds.bottom / atlasHeight - v;
|
||||
|
||||
instances.push({
|
||||
x: penX + plane.left,
|
||||
y: penY + plane.bottom,
|
||||
w: plane.right - plane.left,
|
||||
h: plane.top - plane.bottom,
|
||||
u,
|
||||
v,
|
||||
uw,
|
||||
vh
|
||||
});
|
||||
}
|
||||
|
||||
penX += advance;
|
||||
}
|
||||
penY -= lineHeight;
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
export const layoutMsdfText = layoutText;
|
||||
80
apps/dashboard/src/lib/observatory/text/msdf-atlas.ts
Normal file
80
apps/dashboard/src/lib/observatory/text/msdf-atlas.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { base } from '$app/paths';
|
||||
import type { MsdfGlyph } from './layout';
|
||||
|
||||
export type MsdfAtlasMetrics = {
|
||||
emSize?: number;
|
||||
lineHeight: number;
|
||||
ascender?: number;
|
||||
descender?: number;
|
||||
underlineY?: number;
|
||||
underlineThickness?: number;
|
||||
};
|
||||
|
||||
export type LoadedMsdfAtlas = {
|
||||
atlas: {
|
||||
type?: string;
|
||||
distanceRange: number;
|
||||
distanceRangeMiddle?: number;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
yOrigin?: string;
|
||||
};
|
||||
metrics: MsdfAtlasMetrics;
|
||||
glyphs: MsdfGlyph[];
|
||||
glyphMap: Map<number, MsdfGlyph>;
|
||||
texture: GPUTexture;
|
||||
textureView: GPUTextureView;
|
||||
sampler: GPUSampler;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
type RawAtlas = Omit<LoadedMsdfAtlas, 'glyphMap' | 'texture' | 'textureView' | 'sampler' | 'dispose'>;
|
||||
|
||||
export async function loadMsdfAtlas(device: GPUDevice): Promise<LoadedMsdfAtlas> {
|
||||
const jsonUrl = `${base}/msdf/jetbrains-mono.json`;
|
||||
const pngUrl = `${base}/msdf/jetbrains-mono.png`;
|
||||
|
||||
const jsonResponse = await fetch(jsonUrl);
|
||||
if (!jsonResponse.ok) throw new Error(`MSDF atlas JSON failed: ${jsonResponse.status} ${jsonUrl}`);
|
||||
const raw = (await jsonResponse.json()) as RawAtlas;
|
||||
if (raw.atlas?.yOrigin !== 'bottom') {
|
||||
throw new Error(`MSDF atlas yOrigin must be bottom, got ${raw.atlas?.yOrigin ?? 'missing'}`);
|
||||
}
|
||||
|
||||
const pngResponse = await fetch(pngUrl);
|
||||
if (!pngResponse.ok) throw new Error(`MSDF atlas PNG failed: ${pngResponse.status} ${pngUrl}`);
|
||||
const blob = await pngResponse.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const texture = device.createTexture({
|
||||
label: 'msdf-jetbrains-mono-rgba8unorm',
|
||||
size: [bitmap.width, bitmap.height, 1],
|
||||
format: 'rgba8unorm',
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
|
||||
});
|
||||
device.queue.copyExternalImageToTexture(
|
||||
{ source: bitmap },
|
||||
{ texture },
|
||||
{ width: bitmap.width, height: bitmap.height }
|
||||
);
|
||||
bitmap.close?.();
|
||||
|
||||
const sampler = device.createSampler({
|
||||
label: 'msdf-jetbrains-mono-linear-sampler',
|
||||
magFilter: 'linear',
|
||||
minFilter: 'linear',
|
||||
mipmapFilter: 'linear',
|
||||
addressModeU: 'clamp-to-edge',
|
||||
addressModeV: 'clamp-to-edge'
|
||||
});
|
||||
const textureView = texture.createView({ label: 'msdf-jetbrains-mono-view' });
|
||||
const glyphMap = new Map(raw.glyphs.map((glyph) => [glyph.unicode, glyph]));
|
||||
return {
|
||||
...raw,
|
||||
glyphMap,
|
||||
texture,
|
||||
textureView,
|
||||
sampler,
|
||||
dispose: () => texture.destroy()
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
export const MSDF_TEXT_WGSL = /* wgsl */ `
|
||||
struct Params {
|
||||
frame: f32,
|
||||
loop_phase: f32,
|
||||
node_count: f32,
|
||||
edge_count: f32,
|
||||
path_count: f32,
|
||||
pulse: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
struct Glyph {
|
||||
anchor_size: vec4f,
|
||||
quad_offset: vec4f,
|
||||
uv_rect: vec4f,
|
||||
info: vec4f,
|
||||
color: vec4f,
|
||||
};
|
||||
|
||||
struct VSOut {
|
||||
@builtin(position) clip: vec4f,
|
||||
@location(0) uv: vec2f,
|
||||
@location(1) @interpolate(flat) info: vec4f,
|
||||
@location(2) @interpolate(flat) color: vec4f,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read> glyphs: array<Glyph>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
@group(0) @binding(3) var atlas_tex: texture_2d<f32>;
|
||||
|
||||
const QUAD = array<vec2f, 6>(
|
||||
vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0),
|
||||
vec2f(0.0, 0.0), vec2f(1.0, 1.0), vec2f(0.0, 1.0)
|
||||
);
|
||||
|
||||
fn median3(c: vec3f) -> f32 {
|
||||
return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_text(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
|
||||
let glyph = glyphs[ii];
|
||||
let corner = QUAD[vi];
|
||||
let anchor = glyph.anchor_size.xy;
|
||||
let size = glyph.anchor_size.zw;
|
||||
let quad_offset = glyph.quad_offset.xy;
|
||||
let uv_min = glyph.uv_rect.xy;
|
||||
let uv_max = glyph.uv_rect.zw;
|
||||
let aspect = max(0.0001, params.viewport_w / max(1.0, params.viewport_h));
|
||||
var pos = anchor + quad_offset + corner * size;
|
||||
// Keep glyphs square in BOTH orientations: normalize by the longer axis.
|
||||
// Landscape (aspect>1): narrow x. Portrait (aspect<1): shrink y instead —
|
||||
// dividing x by aspect<1 would WIDEN x and push text off-screen.
|
||||
pos.x = pos.x / max(aspect, 1.0);
|
||||
pos.y = pos.y * min(aspect, 1.0);
|
||||
var out: VSOut;
|
||||
out.clip = vec4f(pos, 0.0, 1.0);
|
||||
out.uv = vec2f(mix(uv_min.x, uv_max.x, corner.x), mix(uv_max.y, uv_min.y, corner.y));
|
||||
out.info = glyph.info;
|
||||
out.color = glyph.color;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_text(in: VSOut) -> @location(0) vec4f {
|
||||
let atlas_px = vec2f(textureDimensions(atlas_tex, 0));
|
||||
let msdf = textureSample(atlas_tex, atlas_sampler, in.uv).rgb;
|
||||
let dist = median3(msdf);
|
||||
let uv_width = max(fwidth(in.uv), vec2f(1.0 / max(atlas_px.x, 1.0), 1.0 / max(atlas_px.y, 1.0)));
|
||||
let texels_per_px = max(length(uv_width * atlas_px), 0.0001);
|
||||
let screen_range = max(0.5, 4.0 / texels_per_px);
|
||||
let px_dist = screen_range * (dist - 0.5);
|
||||
let coverage = clamp(px_dist + 0.5, 0.0, 1.0);
|
||||
let reveal_span = max(1.0, in.info.y);
|
||||
let reveal = clamp((params.frame - in.info.x) / reveal_span, 0.0, 1.0);
|
||||
let alpha = coverage * in.color.a * reveal;
|
||||
if (alpha < 0.001) { discard; }
|
||||
let rgb = in.color.rgb * params.brightness;
|
||||
return vec4f(rgb * alpha, alpha);
|
||||
}
|
||||
`;
|
||||
243
apps/dashboard/src/lib/observatory/text/text-layer.ts
Normal file
243
apps/dashboard/src/lib/observatory/text/text-layer.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import type { ObservatoryEngine, FramePass } from '$lib/observatory/engine';
|
||||
import { rgb01 } from '$lib/observatory/cognitive-palette';
|
||||
import { layoutText, type GlyphInstance } from './layout';
|
||||
import { loadMsdfAtlas, type LoadedMsdfAtlas } from './msdf-atlas';
|
||||
import { MSDF_TEXT_WGSL } from './shaders/msdf-text.wgsl';
|
||||
|
||||
type RoutePick = { id: string; kind: string; index?: number; payload?: unknown };
|
||||
|
||||
export type TextLayerItem = {
|
||||
id?: string;
|
||||
kind?: string;
|
||||
text: string;
|
||||
/** Logical NDC anchor, before shader's aspect divide. y is baseline, +Y up. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** NDC-Y units per em. */
|
||||
size?: number;
|
||||
color?: [number, number, number, number];
|
||||
startFrame?: number;
|
||||
revealSpan?: number;
|
||||
maxWidthEm?: number;
|
||||
maxLines?: number;
|
||||
};
|
||||
|
||||
type TextRunRect = {
|
||||
id: string;
|
||||
kind: string;
|
||||
text: string;
|
||||
x0: number;
|
||||
x1: number;
|
||||
y0: number;
|
||||
y1: number;
|
||||
payload: TextLayerItem;
|
||||
};
|
||||
|
||||
const GLYPH_FLOATS = 20;
|
||||
const DEFAULT_COLOR: [number, number, number, number] = [...rgb01('#22C7DE'), 1];
|
||||
|
||||
export class TextLayerPass implements FramePass {
|
||||
private engine: ObservatoryEngine;
|
||||
private atlas: LoadedMsdfAtlas | null = null;
|
||||
private bindLayout: GPUBindGroupLayout | null = null;
|
||||
private pipeline: GPURenderPipeline | null = null;
|
||||
private glyphBuffer: GPUBuffer | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private glyphCapacity = 0;
|
||||
private glyphCount = 0;
|
||||
private pendingItems: TextLayerItem[] = [];
|
||||
private runs: TextRunRect[] = [];
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this.initInner();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async initInner(): Promise<void> {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.engine.paramsBuffer) return;
|
||||
this.atlas = await loadMsdfAtlas(device);
|
||||
this.ensurePipeline(device);
|
||||
if (this.pendingItems.length) this.uploadItems(this.pendingItems);
|
||||
}
|
||||
|
||||
setText(items: string | TextLayerItem | TextLayerItem[]): void {
|
||||
const normalized = typeof items === 'string' ? [{ text: items, x: -0.62, y: 0, size: 0.075 }] : Array.isArray(items) ? items : [items];
|
||||
this.pendingItems = normalized;
|
||||
this.uploadItems(normalized);
|
||||
}
|
||||
|
||||
private ensurePipeline(device: GPUDevice): void {
|
||||
if (this.pipeline || !this.engine.paramsBuffer) return;
|
||||
const module = device.createShaderModule({ label: 'msdf-text-wgsl', code: MSDF_TEXT_WGSL });
|
||||
this.bindLayout = device.createBindGroupLayout({
|
||||
label: 'msdf-text-bind-layout',
|
||||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
||||
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
|
||||
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
|
||||
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }
|
||||
]
|
||||
});
|
||||
const layout = device.createPipelineLayout({ label: 'msdf-text-pipeline-layout', bindGroupLayouts: [this.bindLayout] });
|
||||
const overBlend: GPUBlendState = {
|
||||
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
||||
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
|
||||
};
|
||||
this.pipeline = device.createRenderPipeline({
|
||||
label: 'msdf-text-pipeline',
|
||||
layout,
|
||||
vertex: { module, entryPoint: 'vs_text' },
|
||||
fragment: { module, entryPoint: 'fs_text', targets: [{ format: this.engine.sceneFormat, blend: overBlend }] },
|
||||
primitive: { topology: 'triangle-list' }
|
||||
});
|
||||
}
|
||||
|
||||
private uploadItems(items: TextLayerItem[]): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.engine.paramsBuffer || !this.atlas) return;
|
||||
this.ensurePipeline(device);
|
||||
const packed: number[] = [];
|
||||
const runs: TextRunRect[] = [];
|
||||
let glyphIndex = 0;
|
||||
|
||||
items.forEach((item, itemIndex) => {
|
||||
const size = item.size ?? 0.075;
|
||||
const laid = layoutText(item.text, this.atlas!, {
|
||||
maxWidthEm: item.maxWidthEm
|
||||
});
|
||||
const color = item.color ?? DEFAULT_COLOR;
|
||||
const runGlyphs = laid;
|
||||
let x0 = Number.POSITIVE_INFINITY;
|
||||
let x1 = Number.NEGATIVE_INFINITY;
|
||||
let y0 = Number.POSITIVE_INFINITY;
|
||||
let y1 = Number.NEGATIVE_INFINITY;
|
||||
for (const glyph of runGlyphs) {
|
||||
const gx0 = item.x + glyph.x * size;
|
||||
const gx1 = item.x + (glyph.x + glyph.w) * size;
|
||||
const gy0 = item.y + glyph.y * size;
|
||||
const gy1 = item.y + (glyph.y + glyph.h) * size;
|
||||
x0 = Math.min(x0, gx0);
|
||||
x1 = Math.max(x1, gx1);
|
||||
y0 = Math.min(y0, gy0);
|
||||
y1 = Math.max(y1, gy1);
|
||||
packGlyph(packed, item, glyph, color, size, glyphIndex++);
|
||||
}
|
||||
if (runGlyphs.length > 0) {
|
||||
runs.push({
|
||||
id: item.id ?? `msdf-text:${itemIndex}`,
|
||||
kind: item.kind ?? 'text',
|
||||
text: item.text,
|
||||
x0,
|
||||
x1,
|
||||
y0,
|
||||
y1,
|
||||
payload: item
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.runs = runs;
|
||||
this.glyphCount = packed.length / GLYPH_FLOATS;
|
||||
const floats = new Float32Array(packed.length || GLYPH_FLOATS);
|
||||
floats.set(packed);
|
||||
this.ensureGlyphBuffer(device, Math.max(1, this.glyphCount));
|
||||
if (!this.glyphBuffer || !this.bindLayout) return;
|
||||
device.queue.writeBuffer(this.glyphBuffer, 0, floats);
|
||||
this.bindGroup = device.createBindGroup({
|
||||
label: 'msdf-text-bind-group',
|
||||
layout: this.bindLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: this.glyphBuffer } },
|
||||
{ binding: 2, resource: this.atlas.sampler },
|
||||
{ binding: 3, resource: this.atlas.textureView }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private ensureGlyphBuffer(device: GPUDevice, glyphCount: number): void {
|
||||
if (this.glyphBuffer && this.glyphCapacity >= glyphCount) return;
|
||||
this.glyphBuffer?.destroy();
|
||||
this.glyphCapacity = Math.max(glyphCount, Math.ceil(this.glyphCapacity * 1.5), 32);
|
||||
this.glyphBuffer = device.createBuffer({
|
||||
label: 'msdf-text-glyphs',
|
||||
size: this.glyphCapacity * GLYPH_FLOATS * 4,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
}
|
||||
|
||||
render(pass: GPURenderPassEncoder): void {
|
||||
if (!this.pipeline || !this.bindGroup || this.glyphCount <= 0) return;
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
pass.draw(6, this.glyphCount);
|
||||
}
|
||||
|
||||
pickAt(ndcX: number, ndcY: number): RoutePick | null {
|
||||
const aspect = Math.max(0.0001, (this.engine.params[6] || 1) / Math.max(1, this.engine.params[7] || 1));
|
||||
// Mirror the shader's square-in-both-orientations transform (msdf-text.wgsl):
|
||||
// x /= max(aspect,1); y *= min(aspect,1).
|
||||
const xScale = Math.max(aspect, 1);
|
||||
const yScale = Math.min(aspect, 1);
|
||||
for (const run of this.runs) {
|
||||
const x0 = run.x0 / xScale;
|
||||
const x1 = run.x1 / xScale;
|
||||
const y0 = run.y0 * yScale;
|
||||
const y1 = run.y1 * yScale;
|
||||
if (ndcX >= x0 && ndcX <= x1 && ndcY >= y0 && ndcY <= y1) {
|
||||
return { id: run.id, kind: run.kind, payload: run.payload };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.glyphBuffer?.destroy();
|
||||
this.glyphBuffer = null;
|
||||
this.atlas?.dispose();
|
||||
this.atlas = null;
|
||||
this.bindGroup = null;
|
||||
this.pipeline = null;
|
||||
}
|
||||
}
|
||||
|
||||
function packGlyph(
|
||||
out: number[],
|
||||
item: TextLayerItem,
|
||||
glyph: GlyphInstance,
|
||||
color: [number, number, number, number],
|
||||
size: number,
|
||||
glyphIndex: number
|
||||
): void {
|
||||
const ageFrame = (item.startFrame ?? 0) + glyphIndex * 2;
|
||||
const revealSpan = item.revealSpan ?? 18;
|
||||
out.push(
|
||||
item.x,
|
||||
item.y,
|
||||
glyph.w * size,
|
||||
glyph.h * size,
|
||||
glyph.x * size,
|
||||
glyph.y * size,
|
||||
0,
|
||||
0,
|
||||
glyph.u,
|
||||
glyph.v,
|
||||
glyph.u + glyph.uw,
|
||||
glyph.v + glyph.vh,
|
||||
ageFrame,
|
||||
revealSpan,
|
||||
0,
|
||||
0,
|
||||
color[0],
|
||||
color[1],
|
||||
color[2],
|
||||
color[3]
|
||||
);
|
||||
}
|
||||
50
apps/dashboard/src/routes/(app)/_msdftest/+page.svelte
Normal file
50
apps/dashboard/src/routes/(app)/_msdftest/+page.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import ObservatoryCanvas from '$lib/components/ObservatoryCanvas.svelte';
|
||||
import type { ObservatoryEngine } from '$lib/observatory/engine';
|
||||
import { TextLayerPass } from '$lib/observatory/text/text-layer';
|
||||
|
||||
let hostEl: HTMLDivElement | null = $state(null);
|
||||
let textPass: TextLayerPass | null = null;
|
||||
|
||||
async function handleReady(engine: ObservatoryEngine) {
|
||||
const pass = new TextLayerPass(engine);
|
||||
textPass = pass;
|
||||
await pass.init();
|
||||
pass.setText({
|
||||
id: 'msdf-test-line',
|
||||
kind: 'msdf-test',
|
||||
text: 'hello | 5de3e41f | trust 51%',
|
||||
x: -0.62,
|
||||
y: 0.03,
|
||||
size: 0.105,
|
||||
color: [0.14, 0.78, 0.87, 1],
|
||||
startFrame: 0,
|
||||
revealSpan: 20,
|
||||
maxWidthEm: 18
|
||||
});
|
||||
engine.addPass(pass);
|
||||
engine.demoClock.reset();
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (!hostEl || !textPass) return;
|
||||
const rect = hostEl.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const ndcY = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
|
||||
const hit = textPass.pickAt(ndcX, ndcY);
|
||||
if (hit) console.info('[msdf-test] picked', hit.id);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
textPass?.dispose();
|
||||
textPass = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Transparent pointer host only; ObservatoryCanvas owns the single visible canvas. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div bind:this={hostEl} class="fixed inset-0 bg-[#020307]" onpointerdown={handlePointerDown}>
|
||||
<ObservatoryCanvas demo="recall-path" seed="msdf-test-v1" onready={handleReady} />
|
||||
</div>
|
||||
1
apps/dashboard/static/msdf/jetbrains-mono.json
Normal file
1
apps/dashboard/static/msdf/jetbrains-mono.json
Normal file
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/static/msdf/jetbrains-mono.png
Normal file
BIN
apps/dashboard/static/msdf/jetbrains-mono.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Loading…
Add table
Add a link
Reference in a new issue