diff --git a/docs/superpowers/plans/2026-05-07-wesl-conversion.md b/docs/superpowers/plans/2026-05-07-wesl-conversion.md
new file mode 100644
index 0000000..0ecd046
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-07-wesl-conversion.md
@@ -0,0 +1,1600 @@
+# WGSL → WESL Conversion + Shared Shader Library Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Convert the seven WGSL shaders under `src/services/gpu/shaders/` to WESL, extract a reusable `lib/` of shared modules, and uniformly split each shader into vertex/fragment/io files.
+
+**Architecture:** Build-time linking via `wesl-plugin` for Vite. Each renderer's TS file imports two pre-linked WGSL strings (one per stage) using the `?static` suffix. Library modules live under `src/services/gpu/shaders/lib/` as themed single-file modules (one file per logical group of related fns), e.g. `lib/math.wesl` holds saturate/rot2/sabs/toPolar/toRect/constants. WESL imports a function FROM a module rather than a function-as-module, so one-fn-per-file would force a verbose duplicated leaf in the import path (`lib::math::saturate::saturate`); grouping into one file matches the WESL idiom. Every shader-touching task ends with build + typecheck + full test suite + manual visual sanity check on the running dev server before commit, per the project's `wgsl-meticulous` convention.
+
+**Tech Stack:** TypeScript 5.x, Vite 5.x, raw WebGPU, WGSL, WESL (`wesl@^0.7.26`, `wesl-plugin@^0.6.74`), Vitest 1.x. No shader unit-test framework exists; verification = build green + 590+ existing tests stay green + visual identity check.
+
+**Spec:** `docs/superpowers/specs/2026-05-07-wesl-conversion-design.md`
+
+---
+
+## Pre-flight reference (read once before starting)
+
+**WESL `?static` suffix semantics.** Build-time linker. `import s from './foo.wesl?static'` returns a `string` containing flat WGSL with all `import ... ;` statements resolved into top-level functions/structs (mangled where collisions exist). Zero runtime cost. The legacy `?raw` import returns the file's bytes verbatim — the migration replaces every `?raw` with `?static` so the linker runs on what was previously a self-contained string.
+
+**WESL import path syntax.** Inside `.wesl` files, imports look like `import lib::math::saturate;` — colons, not slashes; no braces. After the import statement, `saturate` is a top-level identifier inside the importing file. Path resolution is relative to the configured root (this project: `src/services/gpu/shaders/`). Use `super::` for parent-relative paths (rare in this layout) and `as` for renaming on collision.
+
+**Sourcemaps caveat.** WGSL compile errors in Chrome will report line numbers in the **linked** WGSL output, not the source `.wesl`. Mitigation in this codebase: every shader module starts with a docblock identifying it (e.g. `// lib/math/saturate.wesl`), and `tonePass.ts` (and all renderers) log the linked WGSL alongside any `device.createShaderModule` failure in dev mode. Task 1 establishes that logging.
+
+**Project visual-verification rule.** Per `feedback_wgsl_meticulous.md`, no shader-touching task is marked complete until the implementer has visually compared the dev-server render to the previous render and confirmed identity. Tests are silent on shader correctness — visual is the only check.
+
+**WESL parser limitations discovered during Task 1 (2026-05-07).** Three concrete gotchas surfaced by the smoke test that affect every later task:
+
+1. **No backticks (`` ` ``) anywhere in shader source** — including inside `//` and `/* */` comments. The WESL parser tokenises the backtick character regardless of comment context and emits "expected a semicolon" errors. The didactic-comment style across the existing `.wgsl` files uses backticks heavily for inline code identifiers (335 occurrences across the 6 not-yet-converted shaders, 204 in `points.wgsl` alone). **Task 2's bulk rename must include a global `` ` `` → `'` substitution** in every shader file, applied as part of the same commit. The single-quote replacement preserves the visual intent (callout for an identifier) at the cost of the markdown-style aesthetic. If the WESL parser later fixes this, the substitution is mechanically reversible.
+
+2. **TypeScript subpath types via the tsconfig `types` array don't reliably resolve.** Adding `"wesl-plugin/suffixes"` to `compilerOptions.types` does not on its own make `import wgsl from './foo.wesl?static'` resolve to `string` under our `moduleResolution: "bundler"` setup. **A triple-slash reference in a project type file is required**, not optional. Task 1 ships `src/@types/wesl.d.ts` with `/// `; later tasks reference this file rather than re-creating it.
+
+3. **Vitest does NOT inherit Vite plugins from `vite.config.ts`.** Without explicit registration in `vitest.config.ts`, Vitest's SSR-transform pipeline tries to parse `.wesl` files as JavaScript and rolldown rejects them. Task 1 ships an updated `vitest.config.ts` that registers `wesl-plugin` directly. Later tasks should not modify this config unless adding new build extensions.
+
+4. **Self-package import prefix is the literal `package`, not the npm package name.** Verified during Task 3 (2026-05-07) — every snippet in this plan that reads `import skymap::lib::...` should be `import package::lib::...`. The wesl-plugin source (`PluginApi.ts`) calls `fileToModulePath(rootModuleName, "package", false)`, hard-coding the literal `"package"` as the root module's prefix; the official `wesl` README example uses the same form (`import package::colors::chartreuse;`). The npm `name` field (`"skymap"`) is reserved for cross-package imports if this project ever publishes a shader library to npm. **Read every later task's `skymap::...` snippet as `package::...` until those are amended in-place.**
+
+---
+
+## Task 1: Tooling bootstrap (wesl-plugin + Vite + types) and convert toneMap
+
+**Files:**
+- Modify: `package.json` (add deps)
+- Create: `wesl.toml` (repo root)
+- Modify: `tsconfig.json` (activate ambient `?static` types from `wesl-plugin/suffixes`)
+- Modify: `vite.config.ts`
+- Rename: `src/services/gpu/shaders/toneMap.wgsl` → `src/services/gpu/shaders/toneMap.wesl`
+- Modify: `src/services/gpu/toneMapPass.ts` (import suffix + dev-mode link logging)
+
+- [ ] **Step 1.1: Add devDependencies**
+
+```bash
+npm install --save-dev wesl@^0.7.26 wesl-plugin@^0.6.74
+```
+
+Versions verified against the npm registry on 2026-05-07: `wesl-plugin` is still on the 0.6.x track (the original draft assumed 0.7.x, which doesn't exist on npm yet). The matching `wesl` runtime is `0.7.26`. Note: in this implementation pass the controller has already run `npm install` for the agent, so this step is a no-op record of what was added. Expected: lockfile updated, no peer-dep warnings beyond what existed before.
+
+- [ ] **Step 1.2: Create `wesl.toml` at repo root**
+
+The actual TOML schema (verified against `node_modules/wesl-plugin/dist/PluginExtension-DTjKL6rt.d.mts` on 2026-05-07) has flat top-level keys — no `[package]` table, no `name` field. The package name used as the prefix in WESL `import` paths comes from npm's `package.json` `name` (already `"skymap"`), which keeps a single source of truth.
+
+```toml
+edition = "unstable_2025"
+include = ["**/*.wesl", "**/*.wgsl"]
+root = "src/services/gpu/shaders"
+```
+
+A short comment block in the file explains why we picked `?static` over `?link` — see the actual file for the full rationale.
+
+- [ ] **Step 1.3: Activate ambient types for `?static` imports**
+
+`wesl-plugin` ships its own ambient module declarations at the subpath `wesl-plugin/suffixes` (see `node_modules/wesl-plugin/src/defaultSuffixTypes.d.ts` — declares `*?static` as `string`, plus stubs for `?link`, `?simple_reflect`, `?bindingLayout`). There is **no need** to hand-write `src/@types/wesl.d.ts`. Activate the shipped types by adding `"wesl-plugin/suffixes"` to `compilerOptions.types` in `tsconfig.json` — that matches the project's existing pattern (the array already lists `"node"`, `"@webgpu/types"`, `"vite/client"`).
+
+```jsonc
+// tsconfig.json
+"types": ["node", "@webgpu/types", "vite/client", "wesl-plugin/suffixes"]
+```
+
+- [ ] **Step 1.4: Wire `wesl-plugin` into `vite.config.ts`**
+
+Read `vite.config.ts` first to see the existing plugin array. The actual API splits the Vite plugin entry point from the build extensions: import the Vite-specific factory from `wesl-plugin/vite` and the `staticBuildExtension` from the package root, then pass the extension to the factory.
+
+```ts
+import { staticBuildExtension } from 'wesl-plugin';
+import viteWesl from 'wesl-plugin/vite';
+// ...
+plugins: [viteWesl({ extensions: [staticBuildExtension] }), react()],
+```
+
+Plugin order shouldn't matter for correctness; alphabetise as fits the existing arrangement. Do not change any other plugin or config.
+
+- [ ] **Step 1.5: Rename `toneMap.wgsl` → `toneMap.wesl`**
+
+```bash
+git mv src/services/gpu/shaders/toneMap.wgsl src/services/gpu/shaders/toneMap.wesl
+```
+
+No content changes. WESL is a strict superset of WGSL.
+
+- [ ] **Step 1.6: Update `toneMapPass.ts` import + add dev-mode link logging**
+
+Read `src/services/gpu/toneMapPass.ts` to find the existing `?raw` import. Change:
+
+```ts
+import wgsl from './shaders/toneMap.wgsl?raw';
+```
+
+to:
+
+```ts
+import wgsl from './shaders/toneMap.wesl?static';
+```
+
+Then locate the `device.createShaderModule({ code: wgsl, ... })` call. Wrap shader compilation error logging so the linked WGSL is dumped in dev:
+
+```ts
+const module = device.createShaderModule({ code: wgsl, label: 'toneMap' });
+if (import.meta.env.DEV) {
+ module.getCompilationInfo().then((info) => {
+ if (info.messages.some((m) => m.type === 'error')) {
+ // Browser error line numbers refer to the linked WGSL output, not
+ // source .wesl files. Log the linked source so we can map line
+ // numbers back manually until wesl-plugin gains sourcemap support.
+ console.groupCollapsed('[toneMap] linked WGSL (for error line lookup)');
+ console.log(wgsl);
+ console.groupEnd();
+ }
+ });
+}
+```
+
+(If `toneMapPass.ts` already creates the module without a `label`, add the label too — it shows up in `getCompilationInfo` messages and helps identify which shader errored.)
+
+- [ ] **Step 1.7: Build + typecheck + test**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Expected: all green. The build output's bundle size for shaders should be the same byte count as before (toneMap has no imports yet, so the linker's output is the same WGSL).
+
+- [ ] **Step 1.8: Visual sanity check**
+
+Confirm the dev server is running (`npm run dev`). Open the browser. The tone-mapped scene should look identical to before — same gamma curve, same colors. If anything looks different, stop and investigate; the linker has changed something it shouldn't have.
+
+- [ ] **Step 1.9: Commit**
+
+```bash
+git add package.json package-lock.json wesl.toml tsconfig.json vite.config.ts \
+ src/services/gpu/shaders/toneMap.wgsl src/services/gpu/shaders/toneMap.wesl \
+ src/services/gpu/toneMapPass.ts
+git commit -m "$(cat <<'EOF'
+chore(shaders): bootstrap wesl-plugin tooling and convert toneMap
+
+Adds wesl + wesl-plugin (build-time linker) wired into Vite via the
+?static import suffix. Renames toneMap.wgsl → toneMap.wesl as the
+smoke-test shader; the linker output is identical WGSL until imports
+are added in later tasks. Dev-mode shader-compile errors now log the
+linked WGSL alongside the error, since wesl-plugin doesn't yet emit
+sourcemaps that survive into Chrome's WGSL compiler diagnostics.
+
+Co-Authored-By: Claude Opus 4.7
+EOF
+)"
+```
+
+---
+
+## Task 2: Bulk rename remaining 6 shaders to .wesl
+
+**Files:**
+- Rename: 6 shader files
+- Modify: 6 renderer TS files (one import line each)
+
+- [ ] **Step 2.1: Rename shader files**
+
+```bash
+cd src/services/gpu/shaders
+git mv disks.wgsl disks.wesl
+git mv filaments.wgsl filaments.wesl
+git mv milkyWayImpostor.wgsl milkyWayImpostor.wesl
+git mv points.wgsl points.wesl
+git mv proceduralDisks.wgsl proceduralDisks.wesl
+git mv quads.wgsl quads.wesl
+cd -
+```
+
+- [ ] **Step 2.1b: Strip backticks from shader comments**
+
+Per the WESL parser limitations documented in the pre-flight reference, every backtick (`` ` ``) inside the shader files must be replaced with a single quote. The didactic-comment style uses backticks for inline-code callouts; single quotes preserve the visual cue while making the WESL parser happy. Apply across all 6 renamed files (toneMap was handled in task 1):
+
+```bash
+for f in src/services/gpu/shaders/disks.wesl \
+ src/services/gpu/shaders/filaments.wesl \
+ src/services/gpu/shaders/milkyWayImpostor.wesl \
+ src/services/gpu/shaders/points.wesl \
+ src/services/gpu/shaders/proceduralDisks.wesl \
+ src/services/gpu/shaders/quads.wesl; do
+ # Use perl rather than sed for portable in-place editing without backup files.
+ perl -i -pe "s/\`/'/g" "$f"
+done
+```
+
+Verify zero backticks remain:
+
+```bash
+grep -c '`' src/services/gpu/shaders/*.wesl
+# Expected: every line ends with `:0`
+```
+
+This is the only content change in this task — every other byte of the shaders stays identical. Document the substitution in the commit message.
+
+- [ ] **Step 2.2: Update each renderer's import**
+
+For each of the 6 renderer TS files, change the `?raw` import to `?static` and update the file extension. Read each file first to find the exact line, then edit:
+
+| File | Old import | New import |
+|---|---|---|
+| `src/services/gpu/diskRenderer.ts` | `'./shaders/disks.wgsl?raw'` | `'./shaders/disks.wesl?static'` |
+| `src/services/gpu/filamentRenderer.ts` | `'./shaders/filaments.wgsl?raw'` | `'./shaders/filaments.wesl?static'` |
+| `src/services/gpu/milkyWayRenderer.ts` | `'./shaders/milkyWayImpostor.wgsl?raw'` | `'./shaders/milkyWayImpostor.wesl?static'` |
+| `src/services/gpu/pointRenderer.ts` | `'./shaders/points.wgsl?raw'` | `'./shaders/points.wesl?static'` |
+| `src/services/gpu/proceduralDiskRenderer.ts` | `'./shaders/proceduralDisks.wgsl?raw'` | `'./shaders/proceduralDisks.wesl?static'` |
+| `src/services/gpu/quadRenderer.ts` | `'./shaders/quads.wgsl?raw'` | `'./shaders/quads.wesl?static'` |
+| `src/services/gpu/pickRenderer.ts` | `'./shaders/points.wgsl?raw'` | `'./shaders/points.wesl?static'` |
+
+(Note: pickRenderer also imports `points.wgsl` — that's the second import to update. Total: 7 TS files modified, 6 shader files renamed.)
+
+- [ ] **Step 2.3: Build + typecheck + test**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Expected: all green. Each shader now goes through the WESL linker but still has zero imports, so output WGSL is byte-identical to source.
+
+- [ ] **Step 2.4: Visual sanity check**
+
+Reload the dev server. All renderers should produce identical visuals to before. Pan, zoom, rotate; toggle tier; click a galaxy to verify pickRenderer still works. Anything different = stop.
+
+- [ ] **Step 2.5: Commit**
+
+```bash
+git add -u
+git commit -m "$(cat <<'EOF'
+chore(shaders): rename remaining 6 shaders .wgsl → .wesl
+
+Bulk rename. Each renderer's ?raw import becomes ?static so the WESL
+linker runs on every shader. Output WGSL is byte-identical until
+imports are introduced in later tasks, save for one mechanical content
+change: backticks in comments are replaced with single quotes
+project-wide because the WESL parser tokenises ` regardless of comment
+context. The single-quote replacement preserves the visual intent of
+the inline-code callouts and is mechanically reversible if the parser
+later loosens up.
+
+Co-Authored-By: Claude Opus 4.7
+EOF
+)"
+```
+
+---
+
+## Task 3: Extract `lib/math.wesl` (math primitives module)
+
+> **Note (post-execution):** This task originally planned six single-function files under `lib/math/`, but WESL's import resolution treats the last segment of a path as the function name and the rest as the module path. With one-fn-per-file the working import becomes `import package::lib::math::saturate::saturate;` (duplicated leaf) instead of the cleaner `import package::lib::math::saturate;`. We collapsed the six files into a single `lib/math.wesl` with section-divider comments, which matches the WESL idiom. The task as committed creates one file (`lib/math.wesl`) instead of six and uses single-segment imports.
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/math/constants.wesl`
+- Create: `src/services/gpu/shaders/lib/math/rot2.wesl`
+- Create: `src/services/gpu/shaders/lib/math/sabs.wesl`
+- Create: `src/services/gpu/shaders/lib/math/saturate.wesl`
+- Create: `src/services/gpu/shaders/lib/math/toPolar.wesl`
+- Create: `src/services/gpu/shaders/lib/math/toRect.wesl`
+- Modify: `src/services/gpu/shaders/milkyWayImpostor.wesl` (replace inline `rot`, `sabs`, `toPolar`, `toRect`)
+- Modify: `src/services/gpu/shaders/points.wesl` (replace inline `clamp(x, 0, 1)` with `saturate`, where it appears)
+
+- [ ] **Step 3.1: Create the six math files**
+
+`src/services/gpu/shaders/lib/math/constants.wesl`:
+```wgsl
+// lib/math/constants.wesl — common scalar constants.
+//
+// Pulled out of points.wesl + milkyWayImpostor.wesl which had
+// hand-typed `3.14159...` and `2.30258...` literals. Keeping these
+// in one file gives us one place to add precision if we ever need
+// f64-equivalent constants for compute shaders.
+
+const PI: f32 = 3.14159265358979;
+const TAU: f32 = 6.28318530717958;
+const LOG10: f32 = 2.30258509299404; // ln(10), for converting log/ln
+```
+
+`src/services/gpu/shaders/lib/math/saturate.wesl`:
+```wgsl
+// lib/math/saturate.wesl — clamp(x, 0, 1).
+//
+// WGSL has no built-in `saturate`. The `clamp(x, 0.0, 1.0)` form
+// recurs ~20× across the shaders; this gives us a named primitive.
+
+fn saturate(x: f32) -> f32 {
+ return clamp(x, 0.0, 1.0);
+}
+```
+
+`src/services/gpu/shaders/lib/math/rot2.wesl`:
+```wgsl
+// lib/math/rot2.wesl — 2D rotation of a point around the origin.
+//
+// Pulled from milkyWayImpostor.wesl's inline `rot()`. Returned as
+// a fresh vec2 (no in-place mutation) so it composes cleanly in
+// expressions.
+
+fn rot2(p: vec2, a: f32) -> vec2 {
+ let c = cos(a);
+ let s = sin(a);
+ return vec2(c * p.x - s * p.y, s * p.x + c * p.y);
+}
+```
+
+`src/services/gpu/shaders/lib/math/sabs.wesl`:
+```wgsl
+// lib/math/sabs.wesl — smooth absolute value.
+//
+// `sabs(x, k)` approximates `abs(x)` but is C¹-continuous at x=0.
+// Larger `k` → sharper corner. Used by milkyWay's height function
+// to avoid kinks in the derivative of disk thickness.
+
+fn sabs(x: f32, k: f32) -> f32 {
+ return sqrt(x * x + k);
+}
+```
+
+`src/services/gpu/shaders/lib/math/toPolar.wesl`:
+```wgsl
+// lib/math/toPolar.wesl — Cartesian (x, y) → polar (r, θ).
+//
+// Returns vec2(r, theta) with theta in radians, range (-PI, PI].
+
+fn toPolar(p: vec2) -> vec2 {
+ return vec2(length(p), atan2(p.y, p.x));
+}
+```
+
+`src/services/gpu/shaders/lib/math/toRect.wesl`:
+```wgsl
+// lib/math/toRect.wesl — polar (r, θ) → Cartesian (x, y).
+//
+// Inverse of toPolar. p.x = r, p.y = theta.
+
+fn toRect(p: vec2) -> vec2 {
+ return vec2(p.x * cos(p.y), p.x * sin(p.y));
+}
+```
+
+- [ ] **Step 3.2: Replace `rot`, `sabs`, `toPolar`, `toRect` in `milkyWayImpostor.wesl`**
+
+Read `src/services/gpu/shaders/milkyWayImpostor.wesl`. At the top of the file (after any leading docblock), add:
+
+```wgsl
+import skymap::lib::math::rot2;
+import skymap::lib::math::sabs;
+import skymap::lib::math::toPolar;
+import skymap::lib::math::toRect;
+```
+
+Then **delete** the four inline function definitions:
+- `fn toPolar(p: vec2) -> vec2` (around line 330)
+- `fn toRect(p: vec2) -> vec2` (around line 334)
+- `fn rot(p: vec2, a: f32) -> vec2` (around line 367)
+- `fn sabs(x: f32, k: f32) -> f32` (around line 425)
+
+The function name `rot` becomes `rot2` everywhere it's called inside the file. Use a global find-replace within the file: `rot(` → `rot2(` (be precise — there's no other identifier matching that prefix in this shader, but verify with grep before replacing).
+
+```bash
+grep -n "rot(" src/services/gpu/shaders/milkyWayImpostor.wesl
+```
+
+Expected: matches are all the call sites of the deleted `rot` function. Replace each with `rot2(`.
+
+- [ ] **Step 3.3: Replace `clamp(x, 0.0, 1.0)` with `saturate(x)` in points.wesl**
+
+Read `src/services/gpu/shaders/points.wesl`. Add the import near the top:
+
+```wgsl
+import skymap::lib::math::saturate;
+```
+
+Find every occurrence of `clamp(, 0.0, 1.0)` and `clamp(, 0, 1)` in the file:
+
+```bash
+grep -n "clamp(" src/services/gpu/shaders/points.wesl
+```
+
+Replace each `clamp(, 0.0, 1.0)` with `saturate()` **only when** the second and third arguments are exactly `0.0, 1.0` or `0, 1`. Don't touch `clamp` calls with other bounds.
+
+(There may be ~5–10 such matches. The remaining `clamp` calls with non-[0,1] bounds stay as-is — `saturate` is specifically the [0,1] case.)
+
+- [ ] **Step 3.4: Build + typecheck + test**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Expected: all green.
+
+- [ ] **Step 3.5: Visual sanity check**
+
+Reload dev server. Milky Way impostor + points pass should be visually identical. Spend ~30s panning around, especially near the Milky Way (where `sabs`/`rot2` actually fire) and at distance from origin (where `saturate` calls in points.wesl gate the depth fade).
+
+- [ ] **Step 3.6: Commit**
+
+```bash
+git add src/services/gpu/shaders/lib/math/ \
+ src/services/gpu/shaders/milkyWayImpostor.wesl \
+ src/services/gpu/shaders/points.wesl
+git commit -m "$(cat <<'EOF'
+refactor(shaders): extract lib/math/ — saturate, rot2, sabs, toPolar, toRect, constants
+
+Six single-function modules under lib/math/, plus a constants file
+for PI/TAU/LOG10. Replaces inline definitions in milkyWayImpostor
+and the ~10 inline `clamp(x, 0, 1)` calls in points with named
+`saturate()`. No semantic change.
+
+Co-Authored-By: Claude Opus 4.7
+EOF
+)"
+```
+
+---
+
+## Task 4: Extract `lib/camera.wesl`
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/camera.wesl`
+- Modify: each renderer shader that today rolls its own view/proj math
+
+- [ ] **Step 4.1: Inventory existing camera-uniform layouts**
+
+Before extracting, read each renderer's `Uniforms` struct to identify which fields are camera-related (`viewProj`, `view`, `proj`, `cameraPos`, `kPerZ`, `viewportPx`, `dpr`, etc.) vs. renderer-specific (e.g. `globalBrightness` in points; `cloudOpacity` is fade-related and stays in cloudFade later). Note any field-order differences between renderers.
+
+```bash
+grep -n "^struct Uniforms" src/services/gpu/shaders/*.wesl
+# Then read each one — they're at:
+# disks.wesl:57, filaments.wesl:21, milkyWayImpostor.wesl:71,
+# points.wesl:68, proceduralDisks.wesl:18, quads.wesl:21, toneMap.wesl:24
+```
+
+Document the canonical `CameraUniforms` field order in the new module's docblock — this is the source of truth, all renderers must adopt this order.
+
+- [ ] **Step 4.2: Create `lib/camera.wesl`**
+
+```wgsl
+// lib/camera.wesl — shared camera uniform layout + projection helpers.
+//
+// CANONICAL FIELD ORDER. Bind groups across all renderers depend on
+// these offsets matching exactly between TS-side struct writes and
+// WGSL-side struct reads. Do NOT reorder fields without updating
+// every renderer's TypedArray fill on the CPU side.
+//
+// Layout (16-byte aligned, std140-compatible-ish):
+// offset 0: mat4x4 viewProj (64 B)
+// offset 64: mat4x4 view (64 B)
+// offset 128: mat4x4 proj (64 B)
+// offset 192: vec3 cameraPos + 4 B padding
+// offset 208: vec2 viewportPx + 8 B padding
+// offset 224: f32 kPerZ
+// offset 228: f32 dpr
+// offset 232: f32 timeSec (for animated effects; renderers that
+// don't need it leave it 0)
+// offset 236: f32 _pad
+// Total: 240 bytes.
+
+struct CameraUniforms {
+ viewProj: mat4x4,
+ view: mat4x4,
+ proj: mat4x4,
+ cameraPos: vec3,
+ viewportPx: vec2,
+ kPerZ: f32,
+ dpr: f32,
+ timeSec: f32,
+}
+
+// World-space → clip-space (homogeneous, w=1 input).
+fn worldToClip(cam: CameraUniforms, p: vec3) -> vec4 {
+ return cam.viewProj * vec4(p, 1.0);
+}
+
+// Eye-space depth (linear distance from camera along view direction).
+// Useful for size-vs-distance scaling that must be linear, not 1/w.
+fn worldEyeDepth(cam: CameraUniforms, p: vec3) -> f32 {
+ return length(cam.cameraPos - p);
+}
+
+// Pixel size (in NDC units) of a kPerZ-defined world unit at the given
+// eye-space depth. Inverse of: "1 NDC unit = how many pixels at this depth?"
+// Used by the billboard library for screen-space-sized point sprites.
+fn pixelSizeAt(cam: CameraUniforms, eyeDepth: f32) -> f32 {
+ return cam.kPerZ / max(eyeDepth, 0.001);
+}
+```
+
+(Verify the field count and offsets against what the TS side actually writes — read `src/services/engine/engine.ts` or wherever the camera uniform buffer is filled. Adjust `viewportPx` / `dpr` / `timeSec` presence based on real usage.)
+
+- [ ] **Step 4.3: Update each renderer shader**
+
+For each of the 7 shader files (`disks`, `filaments`, `milkyWayImpostor`, `points`, `proceduralDisks`, `quads`, `toneMap`):
+
+1. Add `import skymap::lib::camera::{ CameraUniforms, worldToClip, worldEyeDepth };` (and `pixelSizeAt` where used) to the top of the file.
+2. Refactor the renderer's `Uniforms` struct so its first field is `cam: CameraUniforms` and renderer-specific fields follow. **Or**, if the renderer has only camera fields, replace the `Uniforms` struct entirely with `CameraUniforms`.
+3. Replace inline `viewProj * vec4(p, 1.0)` with `worldToClip(u.cam, p)`.
+4. Replace inline `length(u.cameraPos - p)` (or equivalent) with `worldEyeDepth(u.cam, p)`.
+
+This is a per-renderer commit. **Do these as 7 sub-commits**, one per renderer, so each diff is reviewable in isolation.
+
+For **each** renderer, after the shader change, also update the TypeScript side that fills the uniform buffer. Read the renderer's TS file to locate where the `Float32Array`/`DataView` write sequence happens — add or reorder writes to match the new `CameraUniforms` layout. The byte total must match the WGSL struct exactly.
+
+The mechanical pattern per renderer:
+```
+edit shaders/.wesl # add import, restructure Uniforms struct, swap call sites
+edit Renderer.ts # update CPU-side uniform write to match new layout
+build + test + visual # gate
+git add + commit # per-renderer sub-commit
+```
+
+- [ ] **Step 4.4: Per-renderer sub-commit checklist**
+
+Repeat for each of: `disks`, `filaments`, `milkyWayImpostor`, `points`, `proceduralDisks`, `quads`, `toneMap`:
+
+```bash
+# After editing the .wesl + .ts pair for one renderer:
+npm run typecheck && npm run build && npm test
+# Visual check: reload dev server, focus on the affected renderer's output
+git add src/services/gpu/shaders/.wesl src/services/gpu/Renderer.ts
+git commit -m "refactor(shaders): adopt lib/camera.wesl in Renderer"
+```
+
+(Final sub-commit, after all 7 renderers, also git-adds `lib/camera.wesl` itself if not already committed.)
+
+- [ ] **Step 4.5: Final verification after all renderers converted**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Expected: all green. Visual: every renderer should look identical to pre-task. The most likely failure mode is a struct-alignment bug — wrong CPU-side write order produces garbage uniforms and renders nothing or wildly wrong colors.
+
+---
+
+## Task 5: Extract `lib/billboard.wesl`
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/billboard.wesl`
+- Modify: `points.wesl`, `quads.wesl`, `disks.wesl`, `proceduralDisks.wesl`
+
+- [ ] **Step 5.1: Inventory existing billboard expansion code**
+
+Each of the four billboard renderers has a near-identical block that:
+1. Receives `vid: u32` (0..3, the vertex index of a unit quad).
+2. Computes `cornerOffset = vec2((vid & 1u) == 0u ? -1.0 : 1.0, ...)` (or via a constant array).
+3. Scales by a per-instance pixel- or world-size.
+4. Adds the offset to the world-space center, projected via `viewProj`.
+
+Read each of the four files' `vs` entry points to locate the shared pattern.
+
+- [ ] **Step 5.2: Create `lib/billboard.wesl`**
+
+```wgsl
+// lib/billboard.wesl — view-aligned billboard expansion helpers.
+//
+// All four billboard renderers (points, quads, disks, proceduralDisks)
+// take a unit-quad's `@builtin(vertex_index) vid: u32` and need to:
+// 1. Map vid (0..3) → corner offset in [-1, +1]² (UV-style).
+// 2. Multiply by a per-instance size.
+// 3. Add to an instance's world-space center.
+//
+// The corner mapping uses a CCW triangle-strip order (vid=0 →
+// bottom-left, 1 → bottom-right, 2 → top-left, 3 → top-right) so a
+// 4-vertex `triangle-strip` topology renders the quad as two
+// triangles without an index buffer.
+
+import skymap::lib::camera::{ CameraUniforms, pixelSizeAt };
+
+// Map vertex index 0..3 to its [-1, +1]² corner offset.
+fn quadCorner(vid: u32) -> vec2 {
+ let x = select(1.0, -1.0, (vid & 1u) == 0u);
+ let y = select(1.0, -1.0, (vid & 2u) == 0u);
+ return vec2(x, y);
+}
+
+// Same mapping but as UV in [0, 1]², for fragment-shader UV coords.
+fn quadUv(vid: u32) -> vec2 {
+ let x = select(1.0, 0.0, (vid & 1u) == 0u);
+ let y = select(1.0, 0.0, (vid & 2u) == 0u);
+ return vec2(x, y);
+}
+
+// Expand a screen-space-sized billboard. `centerWS` is the instance
+// center in world space, `sizePx` is the desired diameter in pixels at
+// the current viewport, and the result is a clip-space position.
+//
+// Internally: project center to clip, then add the corner offset
+// scaled by pixelSizeAt(eyeDepth) so the quad's screen size is
+// constant regardless of distance.
+fn expandBillboardScreen(
+ cam: CameraUniforms,
+ centerWS: vec3,
+ sizePx: f32,
+ vid: u32,
+) -> vec4 {
+ let eyeDepth = length(cam.cameraPos - centerWS);
+ let centerClip = cam.viewProj * vec4(centerWS, 1.0);
+ let cornerNDC = quadCorner(vid) * (sizePx / cam.viewportPx) * centerClip.w;
+ return vec4(centerClip.xy + cornerNDC, centerClip.zw);
+}
+
+// Expand a world-space-sized billboard. `sizeWS` is the desired
+// diameter in world units, and the quad is view-aligned (faces the
+// camera). Used for galaxy thumbnails, where the on-sky size is
+// physically meaningful.
+fn expandBillboardWorld(
+ cam: CameraUniforms,
+ centerWS: vec3,
+ sizeWS: f32,
+ vid: u32,
+) -> vec4 {
+ // View-aligned basis: x = camera-right, y = camera-up.
+ // Extracted from the inverse-rotation columns of the view matrix.
+ let right = vec3(cam.view[0].x, cam.view[1].x, cam.view[2].x);
+ let up = vec3(cam.view[0].y, cam.view[1].y, cam.view[2].y);
+ let corner = quadCorner(vid) * sizeWS * 0.5;
+ let posWS = centerWS + right * corner.x + up * corner.y;
+ return cam.viewProj * vec4(posWS, 1.0);
+}
+```
+
+- [ ] **Step 5.3: Replace inline expansion in each billboard renderer**
+
+For each of `points.wesl`, `quads.wesl`, `disks.wesl`, `proceduralDisks.wesl`:
+
+1. Add the relevant imports:
+ ```wgsl
+ import skymap::lib::billboard::{ quadCorner, quadUv, expandBillboardScreen, expandBillboardWorld };
+ ```
+2. Inside the `vs` entry point, replace the manually-rolled corner+expansion math with the matching helper. Keep all other logic (color computation, fade, magnitude→intensity) untouched.
+3. If the existing code uses a custom corner ordering, verify the new `quadCorner`'s [-1,+1]² output produces the same vertex layout — otherwise the quad will wind backward and disappear under back-face culling.
+
+This is per-renderer. Sub-commit each:
+
+```bash
+npm run typecheck && npm run build && npm test
+# Visual: reload dev. Focus on the renderer just changed.
+git add src/services/gpu/shaders/.wesl
+git commit -m "refactor(shaders): adopt lib/billboard.wesl in "
+```
+
+(`disks.wesl` is the trickiest — its expansion uses the position-angle/inclination math, so leave the orientation parts untouched and only swap the corner-mapping primitives. `lib/orientation.wesl` in task 6 handles the rest.)
+
+- [ ] **Step 5.4: Final verification**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Visual: thoroughly check points, quads (galaxy thumbnails near close approach), disks, and proceduralDisks. The failure mode here is a corner-ordering bug — quads disappear or invert.
+
+---
+
+## Task 6: Extract `lib/orientation.wesl`
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/orientation.wesl`
+- Modify: `disks.wesl`, `proceduralDisks.wesl`
+
+- [ ] **Step 6.1: Read the duplicate code**
+
+```bash
+sed -n '155,170p' src/services/gpu/shaders/disks.wesl
+echo "---"
+sed -n '150,170p' src/services/gpu/shaders/proceduralDisks.wesl
+```
+
+Confirm the two blocks are byte-for-byte equivalent (modulo identifier renames and comment style). Capture any genuine difference here in the commit message — usually there's none.
+
+- [ ] **Step 6.2: Create `lib/orientation.wesl`**
+
+```wgsl
+// lib/orientation.wesl — galaxy disk orientation: position-angle +
+// inclination → world-space major/minor axes.
+//
+// Background: the catalog gives us each galaxy's position-angle (PA,
+// the angle from local north toward east, projected on the sky) and
+// either an axis ratio b/a or a directly-measured inclination i.
+// We need a 3D coordinate frame for the disk: a major axis on the
+// plane of the sky, and a minor axis tilted toward the line-of-sight.
+//
+// Derivation (also lives in disks.wesl + proceduralDisks.wesl as
+// commentary):
+// 1. north_proj, east_proj: tangent-plane basis at the galaxy
+// world position, north = +y projected onto the local sky tangent.
+// 2. major = north_proj * cos(PA) + east_proj * sin(PA)
+// 3. minor_in_sky = north_proj * (-sin(PA)) + east_proj * cos(PA)
+// 4. minor_3d = minor_in_sky * cos(i) + losDir * sin(i)
+// where losDir = unit vector from camera toward galaxy.
+//
+// Edge-on (axisRatio → 0, cosI → 0, sinI → 1) → minor_3d ≈ losDir.
+// Face-on (axisRatio → 1, cosI → 1, sinI → 0) → minor_3d ≈ minor_in_sky.
+
+struct DiskAxes {
+ major: vec3,
+ minor: vec3,
+}
+
+// Build the disk's world-space axes.
+// posWS: galaxy world position
+// cameraPos: camera world position (defines line-of-sight)
+// paRad: position angle in radians, from north toward east
+// cosI, sinI: cosine and sine of the inclination angle.
+// For a catalog axisRatio = b/a, cosI = axisRatio,
+// sinI = sqrt(1 - axisRatio²).
+fn diskAxes(
+ posWS: vec3,
+ cameraPos: vec3,
+ paRad: f32,
+ cosI: f32,
+ sinI: f32,
+) -> DiskAxes {
+ let losDir = normalize(posWS - cameraPos);
+
+ // Local tangent basis. North is global +y projected onto the plane
+ // perpendicular to losDir; east is north × losDir (right-handed).
+ let worldUp = vec3(0.0, 1.0, 0.0);
+ let northTangent = normalize(worldUp - losDir * dot(losDir, worldUp));
+ let eastTangent = cross(northTangent, losDir);
+
+ let cosPA = cos(paRad);
+ let sinPA = sin(paRad);
+
+ let majorSky = northTangent * cosPA + eastTangent * sinPA;
+ let perpMajorSky = northTangent * (-sinPA) + eastTangent * cosPA;
+ let minor3D = perpMajorSky * cosI + losDir * sinI;
+
+ return DiskAxes(majorSky, minor3D);
+}
+```
+
+(Verify the exact derivation against the existing block — there's a chance one renderer uses a slightly different sign convention. If so, document and unify.)
+
+- [ ] **Step 6.3: Replace the inline block in `disks.wesl`**
+
+Read `disks.wesl` to locate the existing block (around lines 155–170). Add the import:
+
+```wgsl
+import skymap::lib::orientation::{ DiskAxes, diskAxes };
+```
+
+Replace the ~12 lines of inline math with a single call:
+
+```wgsl
+let axes = diskAxes(instance.posWS, u.cam.cameraPos, instance.paRad, cosI, sinI);
+let majorAxis = axes.major;
+let minorAxis = axes.minor;
+```
+
+(Adjust local variable names to match what the existing `vs` body uses afterward.)
+
+- [ ] **Step 6.4: Replace the inline block in `proceduralDisks.wesl`**
+
+Same replacement, same import, same call shape.
+
+- [ ] **Step 6.5: Build + typecheck + test + visual + commit**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Visual: focus on disks and procedural disks at close approach. Any galaxy with a known orientation (M31, M81, NGC 891) should still tilt correctly. Edge-on galaxies should still appear edge-on.
+
+```bash
+git add src/services/gpu/shaders/lib/orientation.wesl \
+ src/services/gpu/shaders/disks.wesl \
+ src/services/gpu/shaders/proceduralDisks.wesl
+git commit -m "$(cat <<'EOF'
+refactor(shaders): extract lib/orientation.wesl
+
+Collapses the verbatim PA + inclination → 3D major/minor axis math
+duplicated between disks.wesl and proceduralDisks.wesl. The two
+blocks were byte-equal modulo identifier renames; both now call
+the shared diskAxes() helper.
+
+Co-Authored-By: Claude Opus 4.7
+EOF
+)"
+```
+
+---
+
+## Task 7: Extract `lib/colorIndex.wesl`
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/colorIndex.wesl`
+- Modify: `points.wesl`, `proceduralDisks.wesl`
+
+- [ ] **Step 7.1: Read the duplicate `ramp` function**
+
+```bash
+sed -n '650,705p' src/services/gpu/shaders/points.wesl
+echo "---"
+sed -n '210,220p' src/services/gpu/shaders/proceduralDisks.wesl
+```
+
+Confirm the two `fn ramp(t: f32) -> vec3` definitions are byte-equal (modulo formatting). The longer comment block above `points.wesl`'s ramp is documentation; preserve it on the new module.
+
+- [ ] **Step 7.2: Create `lib/colorIndex.wesl`**
+
+```wgsl
+// lib/colorIndex.wesl — color-index → RGB ramp.
+//
+// Maps a normalised color index t ∈ [0, 1] to a color, where t=0
+// represents the bluest galaxies and t=1 the reddest. The ramp is a
+// piecewise-linear interpolation through five anchor colors derived
+// from real galaxy spectra (UV-bright spirals → red ellipticals).
+//
+// The mapping from catalog (g - i) or (B - V) to t happens on the CPU
+// side (see src/data/colourIndex.ts) so this shader doesn't have to
+// know which photometric system any given galaxy came from.
+//
+// Future work: a B-V → blackbody-temperature → RGB path would be
+// physically more honest. Until then, this hand-tuned ramp matches
+// what NASA-style press images use, which gives users the "right"
+// expectation about galaxy color.
+
+fn ramp(t: f32) -> vec3 {
+ // [PASTE THE EXISTING RAMP BODY HERE — copy from points.wesl
+ // verbatim. The function is ~50 lines of piecewise mix() calls
+ // between five anchor colors.]
+}
+```
+
+(The implementer must paste the actual existing function body when extracting — do not re-derive the anchor colors from memory.)
+
+- [ ] **Step 7.3: Replace `ramp` in `points.wesl`**
+
+Add import:
+```wgsl
+import skymap::lib::colorIndex::ramp;
+```
+
+Delete the local `fn ramp` definition. Call sites (already named `ramp(...)`) need no change.
+
+- [ ] **Step 7.4: Replace `ramp` in `proceduralDisks.wesl`**
+
+Same pattern.
+
+- [ ] **Step 7.5: Build + visual + commit**
+
+```bash
+npm run typecheck && npm run build && npm test
+git add src/services/gpu/shaders/lib/colorIndex.wesl \
+ src/services/gpu/shaders/points.wesl \
+ src/services/gpu/shaders/proceduralDisks.wesl
+git commit -m "refactor(shaders): extract lib/colorIndex.wesl"
+```
+
+(Visual: galaxy color distribution should be unchanged. Easiest check: zoom out to a wide view and observe the red/blue ratio matches before.)
+
+---
+
+## Task 8: Extract `lib/cloudFade.wesl`
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/cloudFade.wesl`
+- Modify: `points.wesl`, `filaments.wesl`
+
+- [ ] **Step 8.1: Compare the duplicate `CloudUniforms` struct**
+
+```bash
+sed -n '290,322p' src/services/gpu/shaders/points.wesl
+echo "---"
+sed -n '37,47p' src/services/gpu/shaders/filaments.wesl
+```
+
+Note any differences. Document them in the commit message; if they diverge, either unify (preferred) or split into two named structs.
+
+- [ ] **Step 8.2: Create `lib/cloudFade.wesl`**
+
+```wgsl
+// lib/cloudFade.wesl — per-cloud fade uniform + apply helper.
+//
+// Each renderable point cloud has an `opacity` scalar in [0, 1] that
+// drives a smooth fade-in/out as a tier swap progresses. The CPU side
+// animates this between 0 and 1 using a smoothstep curve.
+//
+// The struct also includes a `cloudId` for picking-target encoding:
+// the pickRenderer writes (cloudId, instanceIdx) into r32uint so a
+// single readback distinguishes which cloud the user clicked.
+
+struct CloudUniforms {
+ opacity: f32,
+ cloudId: u32,
+ // pad to 16-byte alignment if needed by the bind-group layout
+ _pad0: f32,
+ _pad1: f32,
+}
+
+fn applyCloudFade(color: vec4, cloud: CloudUniforms) -> vec4 {
+ return vec4(color.rgb, color.a * cloud.opacity);
+}
+```
+
+(Match the actual TS-side write layout. If `CloudUniforms` has more fields in the live code than this draft shows, copy them in.)
+
+- [ ] **Step 8.3: Replace the inline struct + fade application in `points.wesl` and `filaments.wesl`**
+
+For each file:
+
+```wgsl
+import skymap::lib::cloudFade::{ CloudUniforms, applyCloudFade };
+```
+
+Delete the local `struct CloudUniforms`. The bind-group binding (e.g. `@group(2) @binding(0) var cloud: CloudUniforms;`) stays in the renderer file — only the type definition moves.
+
+Replace any inline `color * cloud.opacity` with `applyCloudFade(color, cloud)` where it appears as the final fade step.
+
+- [ ] **Step 8.4: Build + visual + commit**
+
+```bash
+npm run typecheck && npm run build && npm test
+git add src/services/gpu/shaders/lib/cloudFade.wesl \
+ src/services/gpu/shaders/points.wesl \
+ src/services/gpu/shaders/filaments.wesl
+git commit -m "refactor(shaders): extract lib/cloudFade.wesl"
+```
+
+Visual: tier-swap animations should fade smoothly as before. Pick a tier transition that exercises both points and filaments fading.
+
+---
+
+## Task 9: Extract `lib/masks.wesl`
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/masks.wesl`
+- Modify: `disks.wesl`, `quads.wesl`, `proceduralDisks.wesl`, `filaments.wesl`
+
+- [ ] **Step 9.1: Inventory the existing mask patterns**
+
+Three patterns recur across fragment shaders:
+
+| Pattern | Where | Purpose |
+|---|---|---|
+| `1.0 - smoothstep(inner, outer, r)` | disks `:191`, quads `:210`, proceduralDisks `:241` | Circular cutoff fade — soft edge of a disk/sprite |
+| `smoothstep(lo, hi, lum)` | disks `:195`, quads `:230` | Luminance-keyed alpha — dim pixels become transparent |
+| `smoothstep(0, fade, uv.y) * (1 - smoothstep(1-fade, 1, uv.y))` | filaments `:107` | Edge-band mask — fade in at 0 and out at 1 |
+
+- [ ] **Step 9.2: Create `lib/masks.wesl`**
+
+```wgsl
+// lib/masks.wesl — common fragment-stage mask shapes.
+
+// Soft circular cutoff: 1 inside `inner`, 0 outside `outer`, smooth between.
+// Used for disk/sprite edges. r is typically `length(uv - 0.5) * 2` or
+// `length(uv - center)` depending on the shader's UV convention.
+fn circularMask(r: f32, inner: f32, outer: f32) -> f32 {
+ return 1.0 - smoothstep(inner, outer, r);
+}
+
+// Luminance-keyed alpha: 0 below `lo`, 1 above `hi`, smooth between.
+// Lets the renderer fade out very dim pixels rather than rendering
+// them as gray noise.
+fn lumAlpha(lum: f32, lo: f32, hi: f32) -> f32 {
+ return smoothstep(lo, hi, lum);
+}
+
+// Edge-band mask along one UV axis. 0 at axis=0 and axis=1, 1 in the
+// middle, with `fade` controlling the falloff width at each end.
+// Used by filaments to taper line endpoints.
+fn edgeBandMask(axis: f32, fade: f32) -> f32 {
+ return smoothstep(0.0, fade, axis) * (1.0 - smoothstep(1.0 - fade, 1.0, axis));
+}
+```
+
+- [ ] **Step 9.3: Replace inline masks in each fragment shader**
+
+For each of `disks.wesl`, `quads.wesl`, `proceduralDisks.wesl`, `filaments.wesl`:
+
+1. Add `import skymap::lib::masks::{ circularMask, lumAlpha, edgeBandMask };` (only the names actually used).
+2. Replace each occurrence of the matching pattern with a call to the helper. Verify the parameters map to the helper's argument order — the existing inline forms might pass `outer, inner` instead of `inner, outer`.
+
+Per-shader sub-commit.
+
+- [ ] **Step 9.4: Final verification**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Visual: galaxy sprites should still have soft edges, dim pixels fade out as before, filament endpoints taper smoothly.
+
+---
+
+## Task 10: Extract `lib/astro.wesl`
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/astro.wesl`
+- Modify: `points.wesl`
+
+- [ ] **Step 10.1: Locate the formulas in `points.wesl`**
+
+```bash
+grep -n "5.0 \* (log\|pow(10" src/services/gpu/shaders/points.wesl
+```
+
+Two formulas:
+- Distance modulus at `points.wesl:762` — `absMag = appMag - 5*log10(d_Mpc) - 25`
+- Magnitude → intensity (search for `pow(10.0, -0.4`).
+
+- [ ] **Step 10.2: Create `lib/astro.wesl`**
+
+```wgsl
+// lib/astro.wesl — astronomical magnitude conversions.
+
+import skymap::lib::math::constants::LOG10;
+
+// Distance modulus: convert apparent magnitude + distance to absolute
+// magnitude. m - M = 5·log₁₀(d/10pc) — for d in Mpc this is
+// M = m - 5·log₁₀(d_Mpc) - 25
+fn distanceModulus(appMag: f32, distMpc: f32) -> f32 {
+ return appMag - 5.0 * (log(distMpc) / LOG10) - 25.0;
+}
+
+// Apparent magnitude → linear flux ratio. Pogson scale: each 5 mag
+// step is a factor of 100 in flux, so flux ratio = 10^(-0.4·m).
+// `m=0` returns 1.0; brighter (smaller m) returns >1, dimmer <1.
+fn appMagToIntensity(m: f32) -> f32 {
+ return pow(10.0, -0.4 * m);
+}
+```
+
+- [ ] **Step 10.3: Replace inline formulas in `points.wesl`**
+
+Add `import skymap::lib::astro::{ distanceModulus, appMagToIntensity };`.
+
+Replace the inline `appMag - 5.0 * (log(dMpc) / LOG10) - 25.0` with `distanceModulus(appMag, dMpc)`. Replace `pow(10.0, -0.4 * m)` with `appMagToIntensity(m)`.
+
+- [ ] **Step 10.4: Build + visual + commit**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Visual: galaxy brightnesses should be unchanged. Easiest check: examine a known-bright galaxy (M31) — its apparent size and intensity should match before.
+
+```bash
+git add src/services/gpu/shaders/lib/astro.wesl \
+ src/services/gpu/shaders/points.wesl
+git commit -m "refactor(shaders): extract lib/astro.wesl — distance modulus + magnitude→intensity"
+```
+
+---
+
+## Task 11: Extract `lib/tonemap.wesl`
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/tonemap.wesl`
+- Modify: `toneMap.wesl`
+
+- [ ] **Step 11.1: Read the existing tone-mapping curves**
+
+```bash
+sed -n '55,110p' src/services/gpu/shaders/toneMap.wesl
+```
+
+Five functions: `applyLinear`, `applyReinhard`, `applyAsinh`, `applyGamma2`, `applyAces`.
+
+- [ ] **Step 11.2: Create `lib/tonemap.wesl`**
+
+```wgsl
+// lib/tonemap.wesl — tone-mapping curves.
+//
+// Each function maps a linear-space HDR color to a [0, 1] LDR color
+// suitable for an sRGB display. Curves chosen to suit deep-space
+// imagery where the dynamic range spans many orders of magnitude.
+
+// Identity. Useful as a debug or "bypass" pass.
+fn applyLinear(c: vec3) -> vec3 {
+ // [PASTE EXISTING IMPL]
+}
+
+// Reinhard with white-point normalization. wsq = whitePoint².
+fn applyReinhard(c: vec3, wsq: f32) -> vec3 {
+ // [PASTE EXISTING IMPL]
+}
+
+// asinh(k·x)/asinh(k) — natural fit for stellar magnitudes.
+fn applyAsinh(c: vec3, k: f32) -> vec3 {
+ // [PASTE EXISTING IMPL]
+}
+
+// sqrt(saturate(c)) — quick gamma-2 approximation.
+fn applyGamma2(c: vec3) -> vec3 {
+ // [PASTE EXISTING IMPL]
+}
+
+// ACES filmic curve. Standard cinema/CG tone-map.
+fn applyAces(c: vec3) -> vec3 {
+ // [PASTE EXISTING IMPL]
+}
+```
+
+(Implementer pastes the actual function bodies. Don't re-derive ACES coefficients.)
+
+- [ ] **Step 11.3: Replace inline functions in `toneMap.wesl`**
+
+Add:
+```wgsl
+import skymap::lib::tonemap::{ applyLinear, applyReinhard, applyAsinh, applyGamma2, applyAces };
+```
+
+Delete the five inline `fn apply*` definitions. The fragment-stage `fs` function calls (already named `applyReinhard(...)` etc.) need no change.
+
+- [ ] **Step 11.4: Build + visual + commit**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Visual: tone-map dropdown in the dev panel should still cycle through Linear / Reinhard / Asinh / Gamma2 / ACES with the same curves as before. Set each one and compare to memory of the previous look.
+
+```bash
+git add src/services/gpu/shaders/lib/tonemap.wesl src/services/gpu/shaders/toneMap.wesl
+git commit -m "refactor(shaders): extract lib/tonemap.wesl"
+```
+
+---
+
+## Task 12: Extract `lib/util.wesl` (noise + raySphere + galactic + sRGB + pickEncode)
+
+**Files:**
+- Create: `src/services/gpu/shaders/lib/util.wesl`
+- Modify: `milkyWayImpostor.wesl`, `points.wesl` (the pick fragment), `toneMap.wesl`
+
+- [ ] **Step 12.1: Read the source functions**
+
+```bash
+# Noise + ray-sphere + galactic + stars (in milkyWay)
+grep -n "^fn " src/services/gpu/shaders/milkyWayImpostor.wesl
+# Pick encoding (in points)
+grep -n "vec4\|@location(0) vec4" src/services/gpu/shaders/points.wesl
+# sRGB conversion (in toneMap, currently as part of gamma2)
+grep -n "linearToSRGB\|srgbToLinear\|gamma" src/services/gpu/shaders/toneMap.wesl
+```
+
+- [ ] **Step 12.2: Create `lib/util.wesl`**
+
+```wgsl
+// lib/util.wesl — orphan utility functions awaiting promotion.
+//
+// Each function in this module is currently used by exactly one
+// shader. They live together to avoid a flurry of single-call-site
+// modules; when a second consumer appears for any of them, that
+// function graduates to its own file under lib//.wesl
+// (matching the lib/math/ pattern).
+
+// ── noise ─────────────────────────────────────────────────────────
+
+// Hash from 2D input to scalar in [0, 1). The constants come from the
+// classic `fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453)`
+// tradition; they're a hash, not a serious PRNG, but visually
+// good enough for shader noise.
+fn hash21(co: vec2) -> f32 {
+ // [PASTE existing rand() body from milkyWayImpostor.wesl]
+}
+
+// 2D value noise with bilinear interpolation. tm is a phase offset.
+fn valueNoise2(p: vec2, tm: f32) -> f32 {
+ // [PASTE existing noise1() body from milkyWayImpostor.wesl]
+}
+
+// ── geometry ──────────────────────────────────────────────────────
+
+// Ray-sphere intersection. Returns vec2(tEnter, tExit); both
+// negative if the ray misses or the sphere is behind the origin.
+fn raySphere(ro: vec3, rd: vec3, center: vec3, radius: f32) -> vec2 {
+ // [PASTE existing impl from milkyWayImpostor.wesl]
+}
+
+// ── galactic frame ────────────────────────────────────────────────
+
+// World-frame (equatorial-aligned) → galactic-frame rotation.
+fn worldToGalactic(v: vec3) -> vec3 {
+ // [PASTE existing impl from milkyWayImpostor.wesl]
+}
+
+// Galactic-frame → renderer-frame (the Milky Way impostor's
+// orientation in the scene).
+fn galacticToShader(g: vec3) -> vec3 {
+ // [PASTE existing impl from milkyWayImpostor.wesl]
+}
+
+// ── sRGB ──────────────────────────────────────────────────────────
+
+// Linear → sRGB gamma. Currently used implicitly by toneMap's
+// gamma-2 curve; isolating it makes the conversion available to
+// any future post-process pass.
+fn linearToSRGB(c: vec3) -> vec3 {
+ let cutoff = vec3(0.0031308);
+ let lo = 12.92 * c;
+ let hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
+ return select(hi, lo, c < cutoff);
+}
+
+fn srgbToLinear(c: vec3) -> vec3 {
+ let cutoff = vec3(0.04045);
+ let lo = c / 12.92;
+ let hi = pow((c + 0.055) / 1.055, vec3(2.4));
+ return select(hi, lo, c < cutoff);
+}
+
+// ── pick-target encoding ──────────────────────────────────────────
+
+// Encode a 32-bit instance ID into the r32uint pick-target format.
+// The fragment shader writes vec4; only the .r channel is read
+// back via copyTextureToBuffer. Keeping this in a function documents
+// the wire format for future readback code.
+fn encodePickId(idx: u32) -> vec4 {
+ return vec4(idx, 0u, 0u, 0u);
+}
+```
+
+- [ ] **Step 12.3: Replace call sites in `milkyWayImpostor.wesl`**
+
+Add:
+```wgsl
+import skymap::lib::util::{ hash21, valueNoise2, raySphere, worldToGalactic, galacticToShader };
+```
+
+Delete the local definitions of `rand`, `noise1`, `raySphere`, `worldToGalactic`, `galacticToShader`. Rename call sites: `rand(` → `hash21(`, `noise1(` → `valueNoise2(`. Verify with grep.
+
+- [ ] **Step 12.4: Replace pick encoding in `points.wesl`**
+
+In the `fsPick` function, replace the inline `vec4(globalInstanceIdx, 0u, 0u, 0u)` (or whatever the existing form is) with `encodePickId(globalInstanceIdx)`. Add the import.
+
+- [ ] **Step 12.5: Final verification**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Visual: Milky Way impostor (fragment shader is the heaviest user — noise + raySphere + galactic). Pan around it; the procedural galaxy should look identical. Click a galaxy → pickRenderer → ensure selection still works.
+
+```bash
+git add src/services/gpu/shaders/lib/util.wesl \
+ src/services/gpu/shaders/milkyWayImpostor.wesl \
+ src/services/gpu/shaders/points.wesl
+git commit -m "refactor(shaders): extract lib/util.wesl — noise, raySphere, galactic, sRGB, pickEncode"
+```
+
+---
+
+## Task 13: Split `points.wesl` into 4 files
+
+**Files:**
+- Create: `src/services/gpu/shaders/points.io.wesl`
+- Create: `src/services/gpu/shaders/points.vertex.wesl`
+- Create: `src/services/gpu/shaders/points.color.fragment.wesl`
+- Create: `src/services/gpu/shaders/points.pick.fragment.wesl`
+- Delete: `src/services/gpu/shaders/points.wesl`
+- Modify: `src/services/gpu/pointRenderer.ts`, `src/services/gpu/pickRenderer.ts`
+
+- [ ] **Step 13.1: Carve up the existing file**
+
+Read `src/services/gpu/shaders/points.wesl` to see what's there now (after tasks 3–12, it's smaller — most reusable code has been extracted to `lib/`). Identify three regions:
+
+1. **Shared types**: `struct Uniforms`, `struct CloudUniforms` (already imported), `struct PerVertex`, `struct VSOut`, plus any bind-group declarations.
+2. **Vertex stage**: `@vertex fn vs(...)` — used by both color and pick paths.
+3. **Color fragment**: `@fragment fn fs(in: VSOut) -> @location(0) vec4`.
+4. **Pick fragment**: `@fragment fn fsPick(in: VSOut) -> @location(0) vec4`.
+
+- [ ] **Step 13.2: Create the four new files**
+
+`points.io.wesl`:
+```wgsl
+// points.io.wesl — shared type declarations + bind groups for the
+// points/pick pair. Imported by all three companion files
+// (points.vertex.wesl, points.color.fragment.wesl, points.pick.fragment.wesl).
+//
+// Pulling these out of points.vertex.wesl (where they could otherwise
+// live) means both fragment files get them without re-declaring,
+// which prevents accidental drift in the V→F interpolant struct.
+
+import skymap::lib::camera::CameraUniforms;
+import skymap::lib::cloudFade::CloudUniforms;
+
+struct Uniforms {
+ cam: CameraUniforms,
+ // [paste any remaining renderer-specific fields here]
+}
+
+struct PerVertex {
+ // [paste from current points.wesl]
+}
+
+struct VSOut {
+ // [paste from current points.wesl]
+}
+
+// Bind groups (paste the @group / @binding declarations from the
+// current file). All three companion files reference these.
+@group(0) @binding(0) var u: Uniforms;
+@group(2) @binding(0) var cloud: CloudUniforms;
+// [etc.]
+```
+
+`points.vertex.wesl`:
+```wgsl
+import skymap::points::io::{ Uniforms, PerVertex, VSOut, u, cloud };
+import skymap::lib::camera::worldToClip;
+import skymap::lib::billboard::expandBillboardScreen;
+// [other imports the vs body uses, copied from the current top-of-file]
+
+@vertex
+fn vs(/* paste signature */) -> VSOut {
+ // [paste existing vs body verbatim]
+}
+```
+
+`points.color.fragment.wesl`:
+```wgsl
+import skymap::points::io::{ VSOut, u, cloud };
+import skymap::lib::cloudFade::applyCloudFade;
+// [other imports]
+
+@fragment
+fn fs(in: VSOut) -> @location(0) vec4 {
+ // [paste existing fs body verbatim]
+}
+```
+
+`points.pick.fragment.wesl`:
+```wgsl
+import skymap::points::io::VSOut;
+import skymap::lib::util::encodePickId;
+
+@fragment
+fn fsPick(in: VSOut) -> @location(0) vec4 {
+ // [paste existing fsPick body verbatim]
+}
+```
+
+(WESL imports of `var` bindings: verify the linker actually allows importing a binding declaration vs. requiring redeclaration. If not, the bind groups must live in each consuming file with identical `@group/@binding` numbers — a pattern WGSL itself supports without complaint as long as the numbers match.)
+
+- [ ] **Step 13.3: Delete the old `points.wesl`**
+
+```bash
+git rm src/services/gpu/shaders/points.wesl
+```
+
+- [ ] **Step 13.4: Update `pointRenderer.ts`**
+
+Read the current file. Find the `import wgsl from './shaders/points.wesl?static'` line, plus the `device.createShaderModule` and `device.createRenderPipeline` calls.
+
+Replace the single import with two:
+
+```ts
+import vsCode from './shaders/points.vertex.wesl?static';
+import fsCode from './shaders/points.color.fragment.wesl?static';
+```
+
+Update the pipeline construction to use two modules:
+
+```ts
+const vsModule = device.createShaderModule({ code: vsCode, label: 'points.vertex' });
+const fsModule = device.createShaderModule({ code: fsCode, label: 'points.color.fragment' });
+
+device.createRenderPipeline({
+ // ...existing layout/buffers/etc...
+ vertex: { module: vsModule, entryPoint: 'vs', buffers: [...] },
+ fragment: { module: fsModule, entryPoint: 'fs', targets: [...] },
+});
+```
+
+Apply the same dev-mode link-logging pattern used in task 1 to both modules.
+
+- [ ] **Step 13.5: Update `pickRenderer.ts`**
+
+Same pattern, but the fragment module imports the pick fragment file:
+
+```ts
+import vsCode from './shaders/points.vertex.wesl?static';
+import fsCode from './shaders/points.pick.fragment.wesl?static';
+```
+
+The vertex module is bit-identical to pointRenderer's — both renderers can either keep separate `createShaderModule` calls (simpler, no shared state) or coordinate to share one. **Use separate calls.** It's cheap and avoids cross-renderer coupling.
+
+- [ ] **Step 13.6: Final verification**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Visual: points pass renders identically. Click a galaxy — selection halo appears on the right galaxy (regression of the second-bug-class on the project's "things that have bitten us" list — selection-on-wrong-galaxy was caused by uniform-update races; this split eliminates that whole class).
+
+```bash
+git add -u
+git commit -m "$(cat <<'EOF'
+refactor(shaders): split points.wesl into vertex / color-fs / pick-fs / io
+
+Replaces the single 1485-line points.wesl with four files:
+- points.io.wesl — shared structs + bind-group declarations
+- points.vertex.wesl — @vertex fn vs (used by both renderers)
+- points.color.fragment.wesl — @fragment fn fs (pointRenderer)
+- points.pick.fragment.wesl — @fragment fn fsPick (pickRenderer)
+
+This replaces the planned `@if(PICK)` conditional-compilation
+approach: with a vertex/fragment file split, the pick path is
+just a different fragment module import — no preprocessor needed.
+
+Co-Authored-By: Claude Opus 4.7
+EOF
+)"
+```
+
+---
+
+## Task 14: Split `milkyWayImpostor.wesl` into 3 files
+
+**Files:**
+- Create: `src/services/gpu/shaders/milkyWayImpostor.io.wesl`
+- Create: `src/services/gpu/shaders/milkyWayImpostor.vertex.wesl`
+- Create: `src/services/gpu/shaders/milkyWayImpostor.fragment.wesl`
+- Delete: `src/services/gpu/shaders/milkyWayImpostor.wesl`
+- Modify: `src/services/gpu/milkyWayRenderer.ts`
+
+Same pattern as task 13, but only one fragment file.
+
+- [ ] **Step 14.1: Carve up the file**
+
+Read the post-task-12 `milkyWayImpostor.wesl`. It now has structs + vs entry point + fs entry point + the procedural-galaxy helpers (`stars`, `height`, `galaxyNormal`, `shadeGalaxyDisk`, `renderGalaxy`).
+
+Decision: the procedural-galaxy helpers (~5 functions, ~150 lines) are fragment-stage only and not reusable elsewhere. Keep them in the fragment file rather than inventing a fourth file. If a future shader wants `renderGalaxy`, it graduates to `lib/` then.
+
+- [ ] **Step 14.2: Create the three files**
+
+`milkyWayImpostor.io.wesl`:
+```wgsl
+import skymap::lib::camera::CameraUniforms;
+
+struct Uniforms {
+ cam: CameraUniforms,
+ // [other fields]
+}
+
+struct VsOut {
+ // [paste]
+}
+
+@group(0) @binding(0) var u: Uniforms;
+// [other bindings]
+```
+
+`milkyWayImpostor.vertex.wesl`:
+```wgsl
+import skymap::milkyWayImpostor::io::{ Uniforms, VsOut, u };
+import skymap::lib::camera::worldToClip;
+
+@vertex
+fn vs(@builtin(vertex_index) vid: u32) -> VsOut {
+ // [paste vs body]
+}
+```
+
+`milkyWayImpostor.fragment.wesl`:
+```wgsl
+import skymap::milkyWayImpostor::io::{ Uniforms, VsOut, u };
+import skymap::lib::util::{ raySphere, worldToGalactic, galacticToShader, hash21, valueNoise2 };
+import skymap::lib::math::{ rot2, sabs, toPolar, toRect };
+// [etc]
+
+// The procedural-galaxy helpers (stars, height, galaxyNormal, etc.)
+// stay here — they're fragment-stage only and only this shader uses
+// them. Promote to lib/ if a second consumer ever appears.
+
+fn stars(p_in: vec2) -> vec3 { /* paste */ }
+fn height(p: vec2, tm: f32) -> f32 { /* paste */ }
+fn galaxyNormal(p: vec2, tm: f32) -> vec3 { /* paste */ }
+fn shadeGalaxyDisk(/* ... */) -> vec3 { /* paste */ }
+fn renderGalaxy(ro: vec3, rd: vec3, tm: f32) -> vec3 { /* paste */ }
+
+@fragment
+fn fs(in: VsOut) -> @location(0) vec4 {
+ // [paste fs body]
+}
+```
+
+- [ ] **Step 14.3: Delete old file + update renderer**
+
+```bash
+git rm src/services/gpu/shaders/milkyWayImpostor.wesl
+```
+
+`milkyWayRenderer.ts`:
+```ts
+import vsCode from './shaders/milkyWayImpostor.vertex.wesl?static';
+import fsCode from './shaders/milkyWayImpostor.fragment.wesl?static';
+```
+
+Update pipeline construction to use two modules.
+
+- [ ] **Step 14.4: Build + visual + commit**
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Visual: zoom in on the Milky Way impostor — same procedural galaxy, same star field. Animate (the `tm` parameter) — the galaxy should wobble identically.
+
+```bash
+git add -u
+git commit -m "refactor(shaders): split milkyWayImpostor.wesl into vertex/fragment/io"
+```
+
+---
+
+## Task 15: Split remaining 5 shaders into 3 files each
+
+**Files:**
+- For each of `disks`, `filaments`, `proceduralDisks`, `quads`, `toneMap`:
+ - Create: `.io.wesl`, `.vertex.wesl`, `.fragment.wesl`
+ - Delete: `.wesl`
+ - Modify: `Renderer.ts` (or `toneMapPass.ts`)
+
+Same pattern as tasks 13–14, repeated for each small renderer. Each one is mechanical (these shaders are <300 lines each), so they're done as five sub-commits in one task.
+
+- [ ] **Step 15.1: Per-renderer template**
+
+For each renderer, in the order: `toneMap`, `filaments`, `disks`, `quads`, `proceduralDisks` (smallest to largest):
+
+1. Read the current `.wesl`. Identify: structs + bindings (→ io), `@vertex fn vs` (→ vertex), `@fragment fn fs` (→ fragment).
+2. Create `.io.wesl`, `.vertex.wesl`, `.fragment.wesl` per the templates from tasks 13–14.
+3. `git rm` the original `.wesl`.
+4. Update the renderer's TS file: replace the single `?static` import with two, and update the pipeline construction to use two `GPUShaderModule`s.
+5. Build + typecheck + test.
+6. Visual: focus on this renderer's output.
+7. Sub-commit:
+ ```bash
+ git add -u
+ git commit -m "refactor(shaders): split .wesl into vertex/fragment/io"
+ ```
+
+- [ ] **Step 15.2: Final verification across all renderers**
+
+After all five sub-commits:
+
+```bash
+npm run typecheck && npm run build && npm test
+```
+
+Comprehensive visual check: pan, zoom, rotate, click, tier-swap, tone-map curve cycle. Everything should look identical to before the entire 15-task plan started.
+
+- [ ] **Step 15.3: Open PR**
+
+```bash
+git push -u origin my-feature
+gh pr create --title "WGSL → WESL conversion + shared shader library" --body "$(cat <<'EOF'
+## Summary
+
+- Bootstraps `wesl-plugin` (build-time WESL→WGSL linker for Vite) and converts all 7 shaders from `.wgsl` to `.wesl`.
+- Extracts a `lib/` of shared shader modules: `math/` (saturate, rot2, sabs, toPolar, toRect, constants), camera, billboard, orientation, colorIndex, cloudFade, masks, astro, tonemap, util.
+- Uniformly splits every renderer shader into `.io.wesl` + `.vertex.wesl` + `.fragment.wesl`. `points` is special-cased with two fragment files (color + pick).
+- Replaces the planned `@if(PICK)` conditional-compilation path with a clean two-fragment-file split for the points/pick renderer pair.
+
+Spec: `docs/superpowers/specs/2026-05-07-wesl-conversion-design.md`
+Plan: `docs/superpowers/plans/2026-05-07-wesl-conversion.md`
+
+## Test plan
+
+- [x] `npm run typecheck` green
+- [x] `npm run build` green
+- [x] `npm test` green (590+ tests)
+- [x] Visual: every renderer output identical to pre-PR
+- [x] Visual: click-to-select still works (pickRenderer)
+- [x] Visual: tier-swap fades smoothly (cloudFade)
+- [x] Visual: tone-map dropdown cycles through all 5 curves correctly
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
+
+---
+
+## Self-review notes
+
+After all 15 tasks, verify against the spec:
+
+- [x] Section 1 (Goal) — all 7 shaders converted, lib/ extracted, vertex/fragment split done.
+- [x] Section 2 (Why WESL) — three duplications collapsed: ramp (task 7), CloudUniforms (task 8), orientation (task 6). Single-file scale addressed: tasks 13–15 split. One-file-two-entry-points addressed: task 13.
+- [x] Section 3 (Architecture) — every file in the spec's tree exists (or is deleted intentionally).
+- [x] Section 4 (Library modules) — every immediate-win module extracted in tasks 4–11, math primitives in task 3, util staging in task 12.
+- [x] Section 5 (Tooling) — wesl + wesl-plugin + wesl.toml + tsconfig types activation + Vite config in task 1.
+- [x] Section 6 (Migration plan) — 15 tasks, matching the 15-task spec section.
+- [x] Section 7 (Risks) — sourcemap-survival risk addressed by dev-mode link logging in task 1; struct-alignment risk addressed by canonical CameraUniforms layout in task 4; visual-verification gate present in every task.
diff --git a/docs/superpowers/specs/2026-05-07-wesl-conversion-design.md b/docs/superpowers/specs/2026-05-07-wesl-conversion-design.md
new file mode 100644
index 0000000..4321e50
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-07-wesl-conversion-design.md
@@ -0,0 +1,149 @@
+# WGSL → WESL Conversion + Shared Shader Library — Design
+
+**Status:** Draft (2026-05-07)
+**Owner:** @rulkens
+**Branch:** `my-feature` (worktree: `.worktrees/my-feature`)
+
+## Goal
+
+Convert the seven hand-rolled WGSL shaders under `src/services/gpu/shaders/` to WESL, the WebGPU Shading Extended Language, and use its module-import system to extract a reusable shader library under `lib/`. The aim is to eliminate verbatim copy-paste between renderers (`ramp()`, `CloudUniforms`, the position-angle/inclination axis math), shrink the giant `points.wgsl` (1485 lines) by hoisting reusable building blocks out of it, and replace the runtime entry-point juggling between `pointRenderer` and `pickRenderer` with a clean per-stage file split.
+
+Non-goals: rewriting any rendering algorithm, changing the binary point-cloud format, changing pipeline descriptors beyond what the file split mechanically requires, or introducing runtime feature-flag toggling. WESL's full toolbox (linker conditionals, generics) is on the table; we use only what serves the immediate refactor.
+
+## Why WESL (and why now)
+
+WGSL has no module system. Every shader is a single self-contained string compiled into a `GPUShaderModule`. That's fine for a small renderer, but our shader code shows three concrete tax effects of the missing modularity:
+
+1. **Verbatim duplication.** `fn ramp(t: f32) -> vec3` is identical between `points.wgsl:652` and `proceduralDisks.wgsl:211`. The position-angle + inclination → 3D major/minor axis math at `disks.wgsl:158-166` is bytes-equal to `proceduralDisks.wgsl:154-166`. `struct CloudUniforms` lives in both `points.wgsl:292` and `filaments.wgsl:39`. Each duplicate is a maintenance liability; "fix the bug in both places" is already a thing in this code.
+2. **One file, two entry points.** `points.wgsl` exposes both `fs` (color path, used by `pointRenderer`) and `fsPick` (pick-target path, used by `pickRenderer`). Both renderers `import wgsl from './shaders/points.wgsl?raw'` and select different `entryPoint:` strings on pipeline creation. The common code between them is real but the file is monolithic — there's no way to express "these two paths share this vertex stage but diverge at the fragment".
+3. **Single-file scale.** `points.wgsl` is 1485 lines and `milkyWayImpostor.wgsl` is 774. Both are dominated by reusable primitives (color ramps, billboard expansion, value noise, ray–sphere intersection, galactic-frame rotation) that are stuck inside the file because there's no way to import them.
+
+WESL is a strict superset of WGSL — every existing `.wgsl` file is already a valid `.wesl` file — so the conversion is incremental and reversible. The toolchain is `wesl-plugin` for Vite (build-time linker, sourcemap-aware, HMR-compatible). At build time WESL modules are linked into a final WGSL string per import; production gets a flat WGSL bundle, dev gets HMR-reloaded modules. Runtime cost: zero.
+
+## Architecture overview
+
+```
+src/services/gpu/shaders/
+├── lib/
+│ ├── math.wesl # PI/TAU/LOG10, saturate, rot2, sabs,
+│ │ # toPolar, toRect — small primitives
+│ │ # grouped one-per-section in one file
+│ │ # (WESL imports a fn from a module,
+│ │ # not a fn-as-module — so one-fn-per-
+│ │ # file forces a verbose duplicated
+│ │ # leaf, e.g. lib::math::saturate::saturate)
+│ ├── astro.wesl # distance modulus, mag→intensity
+│ ├── billboard.wesl # vid→corner, screen/world expansion
+│ ├── camera.wesl # CameraUniforms, worldToClip, depth
+│ ├── cloudFade.wesl # CloudUniforms + applyCloudFade
+│ ├── colorIndex.wesl # ramp(), color-index → RGB
+│ ├── masks.wesl # circularMask, lumAlpha, edgeBand
+│ ├── orientation.wesl # PA + inclination → 3D axes
+│ ├── tonemap.wesl # linear/reinhard/asinh/gamma2/aces
+│ └── util.wesl # noise, raySphere, galactic, sRGB,
+│ # pick-encode — staging area; promoted
+│ # to lib//.wesl when a
+│ # second consumer appears
+├── points.io.wesl # struct VSOut, struct Uniforms
+├── points.vertex.wesl # @vertex fn vs (shared color + pick)
+├── points.color.fragment.wesl # @fragment fn fs (pointRenderer)
+├── points.pick.fragment.wesl # @fragment fn fsPick (pickRenderer)
+├── milkyWayImpostor.io.wesl
+├── milkyWayImpostor.vertex.wesl
+├── milkyWayImpostor.fragment.wesl
+├── disks.io.wesl
+├── disks.vertex.wesl
+├── disks.fragment.wesl
+├── filaments.io.wesl
+├── filaments.vertex.wesl
+├── filaments.fragment.wesl
+├── proceduralDisks.io.wesl
+├── proceduralDisks.vertex.wesl
+├── proceduralDisks.fragment.wesl
+├── quads.io.wesl
+├── quads.vertex.wesl
+├── quads.fragment.wesl
+├── toneMap.io.wesl
+├── toneMap.vertex.wesl
+└── toneMap.fragment.wesl
+```
+
+The split rule is **uniform**: every shader is broken into a vertex file, a fragment file, and a `.io.wesl` file containing the V→F interpolant struct + uniform layouts that both stages import. `points` is a special case with two fragment variants (color + pick) sharing a vertex file. The uniformity costs slightly more files for the small shaders (`filaments`, `toneMap`, `disks`) where a single file would be navigable, but it pays off in predictability — every renderer's TS file imports the same shape (`.vertex.wesl?static` + `.fragment.wesl?static`), and the V→F interpolant contract for every shader has a single canonical source.
+
+## Library modules
+
+The `lib/` tree has three tiers, distinguished by whether they're solving real duplication today or staging future reuse.
+
+**Immediate-win modules** (each replaces existing duplicated code on extraction):
+
+- **`lib/camera.wesl`** — declares `CameraUniforms` (viewProj, view, proj, cameraPos, kPerZ, viewportPx) and helpers `worldToClip(p) -> vec4`, `worldEyeDepth(p) -> f32`, `pixelSizeAt(eyeDepth) -> f32`. Every renderer except `toneMap` currently rolls its own `viewProj * vec4(p, 1)` plus a per-shader copy of the kPerZ scaling logic. Consolidating fixes the second concrete bug class on the project's `things-that-have-bitten-us` list — the `queue.writeBuffer` race only happens because per-renderer uniform structs each have their own subtly different layouts to keep in sync.
+- **`lib/billboard.wesl`** — unit-quad `vid -> corner` expansion (used by `points`, `quads`, `disks`, `proceduralDisks`), plus `expandBillboardScreen(centerWS, sizePx, vid)` (kPerZ-scaled, screen-aligned) and `expandBillboardWorld(centerWS, sizeWS, vid)` (world-space-sized, view-aligned). Each billboard shader currently writes its own version of this, with subtle differences that have caused alignment bugs.
+- **`lib/orientation.wesl`** — given a galaxy's (positionWS, position-angle, inclination, axisRatio) plus the camera position, returns `(majorAxis3D, minorAxis3D)` in world space. The 9-line block at `disks.wgsl:158-166` and `proceduralDisks.wgsl:154-166` is byte-for-byte identical; this module is the first one extracted because the saving is unambiguous and the consolidation pays for the WESL setup work on its own.
+- **`lib/colorIndex.wesl`** — exports `ramp(t: f32) -> vec3`, the duplicated piecewise color-index→RGB function. Future expansion slot for B−V→temperature→RGB if/when we move to a physically-grounded color model.
+- **`lib/cloudFade.wesl`** — exports `CloudUniforms` and `applyCloudFade(opacity)`. Resolves the duplicate struct between `points` and `filaments`.
+- **`lib/masks.wesl`** — `circularMask(uv, inner, outer) -> f32`, `lumAlpha(lum, lo, hi) -> f32`, `edgeBandMask(uv, fade) -> f32`. Each existing fragment shader hand-rolls a `1 - smoothstep(0.45, 0.5, r)` or similar; consolidating makes it consistent and clarifies which renderer uses which mask shape.
+- **`lib/astro.wesl`** — `distanceModulus(appMag, dMpc) -> f32` (the `appMag - 5·log₁₀(d_Mpc) - 25` line currently inline at `points.wgsl:762`), `appMagToIntensity(m) -> f32` (the `pow(10, -0.4·m)` pattern), `LOG10` constant. Today's only consumer is `points`, but the formulas are the canonical astronomy primitives — pulling them into a single, comment-rich file makes them documentable and future-proof for any catalog/UI/debug shader that needs to convert between magnitude representations.
+- **`lib/tonemap.wesl`** — `applyLinear`, `applyReinhard`, `applyAsinh`, `applyGamma2`, `applyAces`. Currently lives inside `toneMap.wgsl`. Pulling them out makes them reusable for any future post-process pass (bloom, motion blur, debug-tonemap previews) without `toneMap.wesl` becoming a transitive import.
+
+**Math primitives** (each in its own file under `lib/math/`, per the project's house rule):
+
+- **`lib/math/saturate.wesl`** — `fn saturate(x: f32) -> f32 { return clamp(x, 0.0, 1.0); }`. Currently written inline as `clamp(x, 0.0, 1.0)` ~20× across the shaders.
+- **`lib/math/rot2.wesl`** — 2D rotation matrix builder; replaces the hand-rolled `cos·p.x − sin·p.y` and `sin·p.x + cos·p.y` lines that appear in `milkyWayImpostor.wgsl` and the position-angle code in `points.wgsl`.
+- **`lib/math/sabs.wesl`** — smooth absolute value with parameter `k`. Currently lives in `milkyWayImpostor.wgsl:425`. Generic enough to live with the other math primitives.
+- **`lib/math/toPolar.wesl`** / **`lib/math/toRect.wesl`** — Cartesian↔polar (vec2). Currently in `milkyWayImpostor.wgsl:330-336`.
+- **`lib/math/constants.wesl`** — `const PI = 3.14159...`, `const TAU = 6.28318...`, `const LOG10 = 2.30258...`. Tiny but eliminates the magic numbers that recur in points + milkyWay.
+
+The "one function per file" rule applies specifically to `lib/math/`. The other lib modules are themed cohesive units (camera *is* its uniform struct + its handful of helpers; splitting them into per-function files would obscure their interface), and they stay multi-function.
+
+**Future-proofing modules** (single call site today, generic utility — staged in `lib/util.wesl` until they earn their own file):
+
+`lib/util.wesl` consolidates the orphan utilities: `hash21(co)`, `valueNoise2(p)` (currently `rand`/`noise1` in milkyWay), `raySphere(ro, rd, center, r)` (currently in milkyWay), `worldToGalactic(v)` / `galacticToShader(g)` (galactic-frame rotations from milkyWay), `linearToSRGB` / `srgbToLinear` (currently implicit in `toneMap`'s gamma curve), and `encodePickId(idx)` / `decodePickId(v)` (currently inline in `points.wgsl:fsPick`). They live together until a real second consumer appears, at which point each graduates to its own `lib//.wesl` file (matching the `lib/math/` pattern). The util file is a staging area, not a permanent home.
+
+## Tooling
+
+- Add `wesl` and `wesl-plugin` as devDependencies (pinned to `0.7.x` — the package is sub-1.0 and we want predictable rebuilds). Wire `wesl-plugin` into `vite.config.ts`. The plugin registers a `?static` import suffix that runs the WESL linker at build time and returns the linked WGSL string — semantically equivalent to today's `?raw` import, but with imports resolved.
+- Add a `wesl.toml` at the repo root configuring the resolution root to `src/services/gpu/shaders/`, since the wesl-plugin default of `./shaders/` doesn't match this project's layout.
+- Add `src/@types/wesl.d.ts` mirroring the existing `wgsl.d.ts`, declaring `*.wesl?static` as resolving to `string`.
+- Rename `.wgsl` → `.wesl` across `src/services/gpu/shaders/`. Because WESL is a strict superset, no shader content changes are required for the rename itself — the build keeps producing identical pipelines until imports are added.
+- Each renderer's TS file changes one line: `import shader from './shaders/foo.wgsl?raw'` becomes `import shader from './shaders/foo.wesl?static'`. The shape (string) is unchanged. Renderers that split into vertex/fragment modules go from one import to two, and `device.createRenderPipeline` is updated to pass two `GPUShaderModule`s — which matches WebGPU's native pipeline shape (vertex and fragment have always been separate fields; today both happen to point at the same module).
+- Inside `.wesl` files, imports use WESL's `::` path syntax (not TypeScript brace syntax): `import package::lib::math::saturate;` makes `saturate` available as a top-level identifier. The leading `package::` is the literal placeholder for the project's own root package (verified in `wesl-plugin/src/PluginApi.ts` — `fileToModulePath(rootModuleName, "package", false)` — and matches the official `wesl` README example `import package::colors::chartreuse;`). Paths are resolved from the configured root (`src/services/gpu/shaders/`), so `package::lib::math::saturate` maps to `src/services/gpu/shaders/lib/math/saturate.wesl`. The npm package name (`skymap`) is **not** used as the prefix — that name is reserved for cross-package imports if this project ever publishes a shader library.
+
+## Migration plan (15 tasks)
+
+Each task is independently shippable. The build stays green throughout, the existing 590+ test suite stays green, and every shader-touching task ends with a manual visual sanity check on the running dev server before being marked complete (per the `wgsl-meticulous` project convention — shader edits never ship on confidence alone).
+
+1. **Tooling bootstrap.** Add `wesl` + `wesl-plugin` + Vite config + `wesl.toml` + `wesl.d.ts`. Convert `toneMap.wgsl` → `toneMap.wesl`, switch the `toneMapPass.ts` import from `?raw` to `?static`. Smoke-test: build, dev HMR, sourcemap line numbers in browser errors. Document the actual sourcemap behaviour in this commit so the rest of the plan can rely on it (per the research, expect sourcemaps **not** to survive into Chrome's WGSL compiler errors — mitigation is naming-discipline + a dev-mode log of the linked WGSL alongside any compile error).
+2. **Bulk rename.** The remaining 6 shaders renamed `.wgsl` → `.wesl`, all `?raw` imports switched to `?static`. No content changes. Visual diff: nothing.
+3. **Extract `lib/math/`.** Create the six single-function files. Replace inline `clamp(x, 0, 1)` with `saturate(x)` in shaders that already use it; replace the 2D rotation pattern in milkyWay with `rot2`. Constants pulled out into `constants.wesl`. Tests stay green; visual: identical.
+4. **Extract `lib/camera.wesl`.** Replace each renderer's hand-rolled view/proj math with imports. One sub-commit per renderer to keep diffs reviewable. The camera uniform layout changes per renderer because some have additional renderer-specific fields — those move into a renderer-local struct that *contains* `CameraUniforms` rather than duplicating its fields.
+5. **Extract `lib/billboard.wesl`.** Replace the unit-quad expansion + screen-space-sizing logic in `points`, `quads`, `disks`, `proceduralDisks`. Each replacement is mechanical; the win is removing the per-renderer subtle variations.
+6. **Extract `lib/orientation.wesl`.** Collapses the verbatim PA+inclination duplicate between `disks` and `proceduralDisks`. Smallest commit, biggest readability win.
+7. **Extract `lib/colorIndex.wesl`.** Collapses the `ramp()` duplicate between `points` and `proceduralDisks`.
+8. **Extract `lib/cloudFade.wesl`.** Collapses the `CloudUniforms` + `applyCloudFade` duplicate between `points` and `filaments`.
+9. **Extract `lib/masks.wesl`.** Pulls the circular / lum / edge-band masks out of `disks`, `quads`, `proceduralDisks`, `filaments`.
+10. **Extract `lib/astro.wesl`.** Pulls the distance-modulus and magnitude→intensity formulas out of `points` into a documented module.
+11. **Extract `lib/tonemap.wesl`.** The five tone-mapping functions move out of `toneMap.wesl`; the renderer entry shader becomes a thin import + entry-point file.
+12. **Extract `lib/util.wesl`.** Consolidates noise, ray-sphere, galactic-frame, sRGB, and pick-encode utilities pulled out of `milkyWayImpostor`, `toneMap`, and `points` (the pick path).
+13. **Split `points` into 4 files.** `points.io.wesl` (shared structs), `points.vertex.wesl` (shared `vs`), `points.color.fragment.wesl` (`fs` for `pointRenderer`), `points.pick.fragment.wesl` (`fsPick` for `pickRenderer`). `pointRenderer.ts` and `pickRenderer.ts` each import their respective vertex+fragment pair. This replaces the planned `@if(PICK)` approach with a cleaner two-file split — no conditional compilation needed.
+14. **Split `milkyWayImpostor` into 3 files.** `milkyWayImpostor.io.wesl`, `milkyWayImpostor.vertex.wesl`, `milkyWayImpostor.fragment.wesl`. The fragment file is where most of the existing 774 lines end up (procedural galaxy, ray-sphere, noise) — but with `lib/util.wesl` already extracted in task 12, the file is dominated by genuine renderer-specific code rather than reusable primitives.
+15. **Split remaining 5 shaders into 3 files each.** `disks`, `filaments`, `proceduralDisks`, `quads`, `toneMap` each get a `.io.wesl` + `.vertex.wesl` + `.fragment.wesl` triple. Each of the five splits is mechanical and small (the original files are 138–258 lines), so they're bundled into a single sweep with one sub-commit per renderer. Each renderer's TS file gains one extra `?static` import.
+
+## Risks
+
+**`wesl-plugin` maturity.** WESL is a young language and its Vite plugin is correspondingly young. Task 1 is the smoke test — if HMR, sourcemaps, or module resolution have rough edges that don't have a plugin-level fix, fall back to a small custom Vite plugin around `wesl-js` (the linker library, which is more stable than the all-in-one plugin). The fallback adds ~30 lines of plugin code to `vite.config.ts` but keeps the same build-time-link semantics.
+
+**Shader debugging line numbers.** Browser-side shader compilation errors will reference the linked WGSL output, not the source `.wesl` file. `wesl-plugin` advertises sourcemap support but it needs verification on Chrome's WebGPU compiler error path. If sourcemaps don't survive into browser error messages, mitigation is logging the linked WGSL alongside the error in dev mode — already a pattern this repo uses for catalog-format errors.
+
+**Subtle struct-layout drift.** When `CameraUniforms` moves from inline definitions across six renderers into `lib/camera.wesl`, any field-order divergence breaks bind groups silently — the GPU will read garbage instead of erroring. Mitigation is per-step diff review at the byte level, plus a one-time write-up of the canonical `CameraUniforms` field order in the module's docblock so that future changes happen in one place. The 590-test suite covers TS-side correctness but doesn't catch GPU-side struct-alignment bugs; visual sanity is the only check there.
+
+**Shader file is not unit-testable.** Tests are silent on shader correctness. Every shader-touching task is gated on a manual visual comparison ("does the rendered scene look identical to before?") on the running dev server, plus the standard test pass for the surrounding TS scaffolding. The `wgsl-meticulous` project memory enforces this.
+
+**Plan stays sequential, not parallel.** Tasks 4–12 each touch multiple renderers (each lib extraction sweeps across consumers) so they can't be parallelised by subagent. The throughput limit is one task per implementer per session, with visual review between. That's deliberate — the cost of a silent regression is high enough that batching gains aren't worth chasing.
+
+## Out of scope
+
+- Runtime feature-flag toggling (would require shipping `.wesl` source to the browser; we don't need it).
+- Any procedural code change inside a shader (this is a refactor, not a redesign — the rendered output is byte-identical at every step).
+- The `tools/` build pipeline (the catalog `.bin` format and the parsers under `tools/parsers/` are untouched).
+- Migration of any future shader stages (compute, mesh) — none exist today; if they do later, they slot into the same lib structure with no design change required.
+- A WESL coding-style guide or shared lint rules. The project's existing didactic-comments convention and `feedback_wgsl_meticulous` rule are sufficient guidance.
diff --git a/package-lock.json b/package-lock.json
index e7cb9ba..a641a80 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,6 +28,8 @@
"typescript": "6.0.3",
"vite": "8.0.10",
"vitest": "4.1.5",
+ "wesl": "0.7.26",
+ "wesl-plugin": "0.6.74",
"wrangler": "4.87.0"
},
"engines": {
@@ -1646,6 +1648,28 @@
"node": ">=18"
}
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -2349,6 +2373,19 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/ansi-align": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
@@ -4916,6 +4953,22 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/unplugin": {
+ "version": "2.3.11",
+ "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
+ "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "acorn": "^8.15.0",
+ "picomatch": "^4.0.3",
+ "webpack-virtual-modules": "^0.6.2"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
"node_modules/update-notifier": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz",
@@ -5119,6 +5172,68 @@
}
}
},
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wesl": {
+ "version": "0.7.26",
+ "resolved": "https://registry.npmjs.org/wesl/-/wesl-0.7.26.tgz",
+ "integrity": "sha512-61iTpol7jy9iXiIN4T5x/1UFRrVFN5KUUKuBY3iE0e4Cr1Si0RF+0KCLnSGa/QNr2ZfYTs6dwdqgpBLDIR6iDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wesl-plugin": {
+ "version": "0.6.74",
+ "resolved": "https://registry.npmjs.org/wesl-plugin/-/wesl-plugin-0.6.74.tgz",
+ "integrity": "sha512-0xStBCryNYLLRitqumIcYNW3YqQL81u+9aiiJqL6GDHIefNLKXrEJlroP8chdoiK0BYFTC9FRBigKo5adTWjlw==",
+ "dev": true,
+ "dependencies": {
+ "unplugin": "^2.3.5",
+ "wesl": "0.7.26",
+ "wesl-reflect": "0.0.5"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": "^3",
+ "@nuxt/schema": "^3",
+ "esbuild": "*",
+ "rollup": "^3",
+ "vite": ">=3",
+ "webpack": "^4 || ^5"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "@nuxt/schema": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "rollup": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ },
+ "webpack": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wesl-reflect": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/wesl-reflect/-/wesl-reflect-0.0.5.tgz",
+ "integrity": "sha512-HG4dU7Bw82paVdU0jZU49W6/aGIrHlGt9zNjopWQyS4gzHJnpUfdsNM+fbCObts8kLPN89B7QAjnZGZmgYz0mw==",
+ "dev": true,
+ "dependencies": {
+ "wesl": "0.7.26"
+ }
+ },
"node_modules/when-exit": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz",
diff --git a/package.json b/package.json
index 36746f4..7bdbe5f 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,8 @@
"typescript": "6.0.3",
"vite": "8.0.10",
"vitest": "4.1.5",
+ "wesl": "0.7.26",
+ "wesl-plugin": "0.6.74",
"wrangler": "4.87.0"
},
"dependencies": {
diff --git a/src/@types/wesl.d.ts b/src/@types/wesl.d.ts
new file mode 100644
index 0000000..c6c9d94
--- /dev/null
+++ b/src/@types/wesl.d.ts
@@ -0,0 +1,9 @@
+// Activate wesl-plugin's ambient declarations for `?static` etc.
+//
+// We import these via tsconfig.json `types: ["wesl-plugin/suffixes"]`, but
+// that subpath form isn't reliably resolved by every TypeScript version
+// when the compilerOptions are picked up by the editor / build separately.
+// A triple-slash reference here is the belt-and-braces fallback that
+// guarantees resolution from any compiler entry point.
+///
+export {};
diff --git a/src/services/gpu/cloudFade.ts b/src/services/gpu/cloudFade.ts
index 6a1d57c..aebd9a0 100644
--- a/src/services/gpu/cloudFade.ts
+++ b/src/services/gpu/cloudFade.ts
@@ -132,6 +132,7 @@ export class CloudFade {
startNowMs: number = performance.now(),
) {
this.buffer = device.createBuffer({
+ label: 'cloudFade-uniform-buffer',
// 16 bytes is WebGPU's minimum uniform-buffer alignment — even though
// we only need 4 bytes for the f32 opacity, allocating less is a
// validation error. The shader's `_pad0/1/2` fields consume the
@@ -140,6 +141,7 @@ export class CloudFade {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = device.createBindGroup({
+ label: 'cloudFade-bg',
layout: bindGroupLayout,
entries: [{ binding: 0, resource: { buffer: this.buffer } }],
});
diff --git a/src/services/gpu/diskRenderer.ts b/src/services/gpu/diskRenderer.ts
index de1bdac..950003d 100644
--- a/src/services/gpu/diskRenderer.ts
+++ b/src/services/gpu/diskRenderer.ts
@@ -28,7 +28,9 @@
import type { mat4 } from 'gl-matrix';
import type { GpuContext } from '../../@types';
-import diskWgsl from './shaders/disks.wgsl?raw';
+import vsCode from './shaders/disks/vertex.wesl?static';
+import fsCode from './shaders/disks/fragment.wesl?static';
+import { createShaderModuleWithDevLog } from './shaderCompileLogger';
export type DiskInstance = {
x: number;
@@ -53,20 +55,27 @@ const FLOATS_PER_INSTANCE = 12;
const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4;
/**
- * 96-byte uniform layout (matches the WGSL `Uniforms` struct in disks.wgsl):
+ * 96-byte uniform layout (matches the WESL `Uniforms` struct in
+ * disks.wesl, which now extends the shared `CameraUniforms` prefix from
+ * `lib/camera.wesl`):
*
- * bytes 0..63 : viewProj mat4x4 (16 floats = 64 B)
- * bytes 64..71 : viewport vec2 (2 floats = 8 B)
- * bytes 72..79 : _pad0/_pad1 f32 × 2 (8 B; pads next vec3 to 16-B boundary)
- * bytes 80..91 : camPos vec3 (3 floats = 12 B; vec3 needs 16-B alignment)
- * bytes 92..95 : _pad2 f32 (4 B; trailing pad in camPos's vec4 quantum)
+ * bytes 0..63 : cam.viewProj mat4x4 (16 floats = 64 B)
+ * bytes 64..71 : cam.viewportPx vec2 (2 floats = 8 B)
+ * bytes 72..79 : cam._pad0 / _pad1 f32 × 2 (8 B; pads next vec3 to 16-B boundary)
+ * bytes 80..91 : camPos vec3 (3 floats = 12 B; vec3 needs 16-B alignment)
+ * bytes 92..95 : _pad2 f32 (4 B; trailing pad in camPos's vec4 quantum)
*
- * Total: 96 bytes — multiple of 16 ✓. This mirrors the QuadRenderer's
- * revised layout (after the orbit-warp fix) so the two passes can share
- * the same conceptual binding even though their consumers differ:
- * QuadRenderer uses the trailing slot for `pxPerRad`, while DiskRenderer
- * doesn't need pixel-radius math (the disk geometry sizes itself in
- * world space) and leaves it as padding.
+ * Total: 96 bytes — multiple of 16 ✓. Byte-for-byte identical to the
+ * pre-CameraUniforms layout: the WESL refactor only renamed the prefix
+ * fields ('viewProj' → 'cam.viewProj', 'viewport' → 'cam.viewportPx',
+ * '_pad0/_pad1' → 'cam._pad0/_pad1') without moving any of the
+ * trailing renderer-specific bytes, so this CPU uploader didn't need to
+ * shift any offsets. This mirrors the QuadRenderer's revised layout
+ * (after the orbit-warp fix) so the two passes can share the same
+ * conceptual binding even though their consumers differ: QuadRenderer
+ * uses the trailing slot for `pxPerRad`, while DiskRenderer doesn't
+ * need pixel-radius math (the disk geometry sizes itself in world
+ * space) and leaves it as padding.
*/
const UNIFORM_BYTES = 96;
@@ -95,13 +104,17 @@ export class DiskRenderer {
],
});
- const module = this.device.createShaderModule({ label: 'disks-wgsl', code: diskWgsl });
+ const vsModule = createShaderModuleWithDevLog(this.device, vsCode, 'disks.vertex');
+ const fsModule = createShaderModuleWithDevLog(this.device, fsCode, 'disks.fragment');
this.pipeline = this.device.createRenderPipeline({
label: 'disk-pipeline',
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
+ layout: this.device.createPipelineLayout({
+ label: 'disks-pipeline-layout',
+ bindGroupLayouts: [this.bindGroupLayout],
+ }),
vertex: {
- module,
+ module: vsModule,
entryPoint: 'vs',
buffers: [
{
@@ -116,7 +129,7 @@ export class DiskRenderer {
],
},
fragment: {
- module,
+ module: fsModule,
entryPoint: 'fs',
targets: [
{
diff --git a/src/services/gpu/filamentRenderer.ts b/src/services/gpu/filamentRenderer.ts
index 4263f07..19cc56b 100644
--- a/src/services/gpu/filamentRenderer.ts
+++ b/src/services/gpu/filamentRenderer.ts
@@ -13,7 +13,7 @@
* indexBuffer (static) : 6 × uint16 → two-triangle quad
* quadVertexBuffer (static) : 4 × vec2 → corner UVs
* segmentInstanceBuffer : segmentCount × 8 × f32 → per-segment endpoints
- * uniformBuffer : 32 bytes (viewProj + viewport + halfWidth)
+ * uniformBuffer : 96 bytes (CameraUniforms prefix + halfWidth + intensityScale + tail pad)
*
* Public API:
* - new FilamentRenderer(device, format)
@@ -22,21 +22,31 @@
* - clear() → drops the instance buffer
* - destroy() → releases all GPU resources
*/
-import shaderSource from './shaders/filaments.wgsl?raw';
+import vsCode from './shaders/filaments/vertex.wesl?static';
+import fsCode from './shaders/filaments/fragment.wesl?static';
import type { FilamentCloud } from '../../@types/FilamentCloud';
import type { mat4 } from 'gl-matrix';
import { CloudFade } from './cloudFade';
+import { createShaderModuleWithDevLog } from './shaderCompileLogger';
const FLOATS_PER_SEGMENT = 8; // startxyz + startD + endxyz + endD
-// Uniform block layout (std140-ish, WGSL host-shareable):
-// viewProj mat4 = 64 bytes
-// viewport vec2 = 8 bytes
-// halfWidthPx f32 = 4 bytes
-// _pad f32 = 4 bytes (round to 16-byte alignment)
-// Total: 80 bytes. WebGPU rounds uniform-buffer sizes up to a multiple
-// of 16, so 80 is already aligned — no extra padding needed.
-const UNIFORM_BYTES = 80;
+// Uniform block layout, mirroring 'struct Uniforms' in
+// 'shaders/filaments.wesl'. The first 80 bytes are the shared
+// 'CameraUniforms' prefix from 'shaders/lib/camera.wesl'; the
+// renderer-specific scalars sit AFTER it in offsets 80..87. The
+// trailing 8B pad rounds up to a 16-byte multiple — WebGPU would
+// round the buffer size anyway, but writing the pad explicitly keeps
+// the JS-side layout obvious and grep-able.
+//
+// offset 0..63 : viewProj mat4x4 (CameraUniforms.viewProj)
+// offset 64..71 : viewportPx vec2 (CameraUniforms.viewportPx)
+// offset 72..79 : _pad0, _pad1 2 × f32 (CameraUniforms reserved)
+// offset 80..83 : halfWidthPx f32
+// offset 84..87 : intensityScale f32
+// offset 88..95 : _pad0, _pad1 2 × f32 (Uniforms tail pad)
+// Total: 96 bytes.
+const UNIFORM_BYTES = 96;
/**
* Build a flat per-segment instance array from a `FilamentCloud`. One
@@ -116,9 +126,11 @@ export class FilamentRenderer {
*/
hdrFormat: GPUTextureFormat,
) {
- const module = device.createShaderModule({ code: shaderSource });
+ const vsModule = createShaderModuleWithDevLog(device, vsCode, 'filaments.vertex');
+ const fsModule = createShaderModuleWithDevLog(device, fsCode, 'filaments.fragment');
this.uniformBuffer = device.createBuffer({
+ label: 'filaments-uniform-buffer',
size: UNIFORM_BYTES,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
@@ -126,6 +138,7 @@ export class FilamentRenderer {
// Static index buffer: two triangles forming the quad.
const indices = new Uint16Array([0, 1, 2, 1, 3, 2]);
this.indexBuffer = device.createBuffer({
+ label: 'filaments-index-buffer',
size: indices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
});
@@ -134,12 +147,14 @@ export class FilamentRenderer {
// Static quad-corner buffer: 4 vertices × vec2 = 32 bytes.
const quadCorners = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
this.quadVertexBuffer = device.createBuffer({
+ label: 'filaments-quad-vertex-buffer',
size: quadCorners.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(this.quadVertexBuffer, 0, quadCorners);
const bindGroupLayout = device.createBindGroupLayout({
+ label: 'filaments-bgl-uniforms',
entries: [
{
binding: 0,
@@ -155,6 +170,7 @@ export class FilamentRenderer {
// never needs to see the opacity). Stored on the instance so the
// lazily-created CloudFade can reuse it.
this.cloudFadeBindGroupLayout = device.createBindGroupLayout({
+ label: 'filaments-bgl-cloudFade',
entries: [
{
binding: 0,
@@ -165,16 +181,19 @@ export class FilamentRenderer {
});
this.bindGroup = device.createBindGroup({
+ label: 'filaments-bg-uniforms',
layout: bindGroupLayout,
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }],
});
this.pipeline = device.createRenderPipeline({
+ label: 'filaments-pipeline',
layout: device.createPipelineLayout({
+ label: 'filaments-pipeline-layout',
bindGroupLayouts: [bindGroupLayout, this.cloudFadeBindGroupLayout],
}),
vertex: {
- module,
+ module: vsModule,
entryPoint: 'vs',
buffers: [
// Per-quad-vertex: uv vec2
@@ -197,7 +216,7 @@ export class FilamentRenderer {
],
},
fragment: {
- module,
+ module: fsModule,
entryPoint: 'fs',
targets: [
{
@@ -231,6 +250,7 @@ export class FilamentRenderer {
}
this.instanceBuffer?.destroy();
this.instanceBuffer = this.device.createBuffer({
+ label: 'filaments-instance-buffer',
size: data.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
@@ -262,20 +282,25 @@ export class FilamentRenderer {
if (this.segmentCount === 0 || !this.instanceBuffer || !this.fade) return;
// Pack uniforms. See UNIFORM_BYTES comment above for the byte layout.
- // f32[0..15] viewProj (mat4)
- // f32[16..17] viewport (vec2)
- // f32[18] halfWidthPx
- // f32[19] intensityScale (was: padding; the slot is already
- // in the uniform buffer's footprint, repurposing it
- // for the user-facing intensity slider doesn't grow
- // the uniform's size or change its 16-byte alignment)
+ // f32[0..15] viewProj (mat4) — CameraUniforms.viewProj
+ // f32[16..17] viewportPx (vec2) — CameraUniforms.viewportPx
+ // f32[18..19] CameraUniforms reserved pad (left zero)
+ // f32[20] halfWidthPx — Uniforms.halfWidthPx (offset 80)
+ // f32[21] intensityScale — Uniforms.intensityScale (offset 84)
+ // f32[22..23] Uniforms tail pad (left zero)
+ //
+ // Adoption of the shared 'CameraUniforms' prefix moved the two
+ // scalars from f32-indices 18/19 down to 20/21. The two reserved
+ // pad slots in CameraUniforms (f32[18..19]) MUST stay zero —
+ // overwriting them silently shifts the WGSL view of every later
+ // member.
const buf = new ArrayBuffer(UNIFORM_BYTES);
const f32 = new Float32Array(buf);
f32.set(viewProj as Float32Array, 0);
f32[16] = viewportPx[0];
f32[17] = viewportPx[1];
- f32[18] = halfWidthPx;
- f32[19] = intensityScale;
+ f32[20] = halfWidthPx;
+ f32[21] = intensityScale;
this.device.queue.writeBuffer(this.uniformBuffer, 0, buf);
// Cloud-fade-in opacity for this frame. Steady-state (after the
diff --git a/src/services/gpu/milkyWayRenderer.ts b/src/services/gpu/milkyWayRenderer.ts
index 6d7b858..af5ae83 100644
--- a/src/services/gpu/milkyWayRenderer.ts
+++ b/src/services/gpu/milkyWayRenderer.ts
@@ -9,34 +9,53 @@
* just a six-vertex `draw(6, 1)` call.
*
* The GPU side is a hand port of a CC0 ShaderToy "Spiral galaxy"
- * fragment shader. See `shaders/milkyWayImpostor.wgsl` for the WGSL
- * source and the per-line port notes.
+ * fragment shader. See `shaders/milkyWay/{io,vertex,fragment}.wesl`
+ * for the WESL source and the per-line port notes — the procedural-
+ * galaxy helpers (stars, height, galaxyNormal, shadeGalaxyDisk,
+ * renderGalaxy) all live alongside `fs` in `fragment.wesl` because
+ * they're fragment-only.
*
* ### Uniform buffer ABI
*
- * 96 bytes total — padded to the same shape as the procedural-disk
- * uniform layout so future refactors that share a uniform-pack helper
- * across passes don't have to special-case this one:
+ * 112 bytes total — first 80 bytes are the shared `CameraUniforms`
+ * prefix from `lib/camera.wesl`, followed by the renderer-specific
+ * camera position + scalars + tail pad:
*
- * offset 0 | mat4x4 viewProj — vertex stage projects the
- * world-anchored billboard
- * offset 64 | vec2 viewport — UNUSED (ABI symmetry)
- * offset 72 | f32 fadeAlpha — distance-based alpha, [0..1]
- * offset 76 | f32 iTime — animation time (sec * 0.25)
- * offset 80 | vec3 cameraPosWorld — drives both the vertex
- * stage's view-aligned
- * billboard basis and the
- * fragment stage's
- * synthetic-camera ray
- * origin
- * offset 92 | f32 _pad — alignment padding to 96 B
+ * offset 0 | mat4x4 cam.viewProj — vertex stage projects the
+ * world-anchored billboard
+ * offset 64 | vec2 cam.viewportPx — UNUSED here (ABI symmetry
+ * with peer renderers)
+ * offset 72 | f32 cam._pad0 — reserved by CameraUniforms
+ * offset 76 | f32 cam._pad1 — reserved by CameraUniforms
+ * offset 80 | vec3 cameraPosWorld — drives both the vertex
+ * stage's view-aligned
+ * billboard basis and the
+ * fragment stage's
+ * synthetic-camera ray
+ * origin
+ * offset 92 | f32 fadeAlpha — distance-based alpha [0..1]
+ * offset 96 | f32 iTime — animation time (sec * 0.25)
+ * offset 100 | f32 × 3 _pad — round struct up to 112 B
*
- * **viewProj is now load-bearing.** Earlier this pass emitted directly
+ * #### Why the field order changed (vs the pre-WESL-conversion layout)
+ *
+ * The previous layout placed `fadeAlpha` + `iTime` at offsets 72/76,
+ * which collide with the `_pad0/_pad1` slots that `CameraUniforms`
+ * reserves. To embed `cam: CameraUniforms` as the first field we
+ * had to relocate the renderer-specific scalars after the cam block.
+ * `cameraPosWorld` (vec3, 16-byte alignment) lands naturally at
+ * offset 80 — the first 16-byte boundary after cam — and the two
+ * f32 scalars fall in at 92 / 96. CPU-side: `fadeAlpha` moved from
+ * f32 index 18 → 23, `iTime` moved from f32 index 19 → 24,
+ * `cameraPosWorld` stays at 20..22.
+ *
+ * **viewProj is load-bearing.** Earlier this pass emitted directly
* in clip-space (slot 0 was kept "for ABI symmetry") and the impostor
* was always full-screen regardless of camera distance. The
* world-anchored billboard fixes that — the vertex stage projects each
- * corner via viewProj so the quad's apparent angular size on screen
- * scales as `2 * atan(milkyWayHalfExtent / cameraDistance)`.
+ * corner via `worldToClip(u.cam, p)` so the quad's apparent angular
+ * size on screen scales as `2 * atan(milkyWayHalfExtent /
+ * cameraDistance)`.
*
* **cameraPosWorld is also load-bearing.** Earlier the fragment stage
* hard-coded `ro = vec3(0, 0.7, 2) * 0.75` for its synthetic camera
@@ -46,8 +65,8 @@
* drive the raymarched render — orbiting reveals different aspects of
* the spiral.
*
- * viewport stays unused: the fragment shader works in the impostor's
- * local UV directly, never in pixel coordinates.
+ * `viewportPx` stays unused: the fragment shader works in the
+ * impostor's local UV directly, never in pixel coordinates.
*
* ### Why no instance vertex buffer?
*
@@ -60,7 +79,16 @@
* `@builtin(vertex_index)`.
*/
-import wgsl from './shaders/milkyWayImpostor.wgsl?raw';
+// Two ?static imports mirror the points/* split (Task 13): each
+// pipeline stage compiles its own GPUShaderModule from a strictly-
+// smaller source. The vertex module pulls in 'lib/camera' for
+// 'worldToClip'; the fragment module pulls in 'lib/math' + 'lib/util'
+// for the procedural-galaxy helpers. Sharing modules across pipelines
+// would invite the WebGPU 'auto' bind-group-layout trap — sidestepped
+// here by giving each stage its own module from disjoint sources.
+import vsCode from './shaders/milkyWay/vertex.wesl?static';
+import fsCode from './shaders/milkyWay/fragment.wesl?static';
+import { createShaderModuleWithDevLog } from './shaderCompileLogger';
type Init = {
device: GPUDevice;
@@ -70,11 +98,12 @@ type Init = {
export class MilkyWayRenderer {
/**
* Public constant pinning the on-the-wire uniform buffer size. Must
- * match the WGSL `Uniforms` struct's std140-ish layout (mat4 + vec2 +
- * 2 f32 + 16 bytes padding = 96 bytes) byte-for-byte. Changing one
- * without the other yields silent uniform-read corruption.
+ * match the WESL `Uniforms` struct's std140-ish layout
+ * (`CameraUniforms` 80 B + vec3 cameraPosWorld 12 B + 2 × f32 8 B +
+ * 12 B tail pad = 112 bytes) byte-for-byte. Changing one without
+ * the other yields silent uniform-read corruption.
*/
- static readonly UNIFORM_BUFFER_SIZE = 96;
+ static readonly UNIFORM_BUFFER_SIZE = 112;
private device: GPUDevice;
private pipeline: GPURenderPipeline;
@@ -86,9 +115,11 @@ export class MilkyWayRenderer {
const { device, format } = init;
this.device = device;
- const module = device.createShaderModule({ code: wgsl });
+ const vsModule = createShaderModuleWithDevLog(device, vsCode, 'milkyWay.vertex');
+ const fsModule = createShaderModuleWithDevLog(device, fsCode, 'milkyWay.fragment');
this.bindGroupLayout = device.createBindGroupLayout({
+ label: 'milkyWay-bgl-uniforms',
entries: [
{
binding: 0,
@@ -99,24 +130,28 @@ export class MilkyWayRenderer {
});
this.uniformBuffer = device.createBuffer({
+ label: 'milkyWay-uniform-buffer',
size: MilkyWayRenderer.UNIFORM_BUFFER_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = device.createBindGroup({
+ label: 'milkyWay-bg-uniforms',
layout: this.bindGroupLayout,
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }],
});
const pipelineLayout = device.createPipelineLayout({
+ label: 'milkyWay-pipeline-layout',
bindGroupLayouts: [this.bindGroupLayout],
});
this.pipeline = device.createRenderPipeline({
+ label: 'milkyWay-pipeline',
layout: pipelineLayout,
- vertex: { module, entryPoint: 'vs' },
+ vertex: { module: vsModule, entryPoint: 'vs' },
fragment: {
- module,
+ module: fsModule,
entryPoint: 'fs',
targets: [
{
@@ -194,28 +229,41 @@ export class MilkyWayRenderer {
iTimeSec: number,
cameraPosWorld: [number, number, number],
): void {
- // Pack uniforms into a 96-byte ArrayBuffer matching the WGSL
+ // Pack uniforms into a 112-byte ArrayBuffer matching the WESL
// `Uniforms` struct layout. See the class doc-comment for the
- // offset table.
+ // full offset table.
const uniforms = new ArrayBuffer(MilkyWayRenderer.UNIFORM_BUFFER_SIZE);
const f32 = new Float32Array(uniforms);
- // mat4 viewProj (offsets 0..63 / floats 0..15)
+ // cam.viewProj — mat4 (offsets 0..63 / floats 0..15)
f32.set(viewProj, 0);
- // viewport (offsets 64..71 / floats 16..17)
+ // cam.viewportPx — vec2 (offsets 64..71 / floats 16..17). Unread
+ // by this pass but uploaded for ABI symmetry with the rest of the
+ // engine (every other renderer reads viewportPx for pxPerRad-style
+ // derivations).
f32[16] = viewport[0];
f32[17] = viewport[1];
- // fadeAlpha (offset 72 / float 18)
- f32[18] = fadeAlpha;
- // iTime (offset 76 / float 19)
- f32[19] = iTimeSec;
- // cameraPosWorld (offsets 80..91 / floats 20..22). vec3 alignment
- // is 16 bytes in the WGSL std140-ish layout, so the field starts
- // at offset 80 (the next multiple of 16 after 76+4=80). Float 23
- // is the trailing pad and stays zero — the ArrayBuffer init takes
- // care of it.
+ // cam._pad0/_pad1 (offsets 72..79 / floats 18..19) — reserved by
+ // CameraUniforms. Stays zero (ArrayBuffer init handles it).
+ // cameraPosWorld — vec3 (offsets 80..91 / floats 20..22). Float
+ // 22 is the third component of the vec3, NOT padding; the next
+ // 16-byte boundary is at offset 96, so the implicit padding sits
+ // at offset 92 in WGSL terms — but our layout repurposes that
+ // slot as the next field (fadeAlpha) since vec3 + f32 fits in a
+ // 16-byte chunk without extra alignment loss.
f32[20] = cameraPosWorld[0];
f32[21] = cameraPosWorld[1];
f32[22] = cameraPosWorld[2];
+ // fadeAlpha (offset 92 / float 23) — sits in the f32 slot
+ // immediately after the vec3, packing the vec3+f32 quad into
+ // bytes 80..95.
+ f32[23] = fadeAlpha;
+ // iTime (offset 96 / float 24). Note: this moved from float
+ // index 19 in the pre-CameraUniforms layout — the cam prefix
+ // now occupies 0..79 and the renderer-specific scalars sit
+ // after the cameraPosWorld vec3.
+ f32[24] = iTimeSec;
+ // Floats 25..27 are tail padding (offsets 100..111) rounding
+ // the struct size up to a 16-byte multiple. Stays zero.
this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
pass.setPipeline(this.pipeline);
diff --git a/src/services/gpu/pickRenderer.ts b/src/services/gpu/pickRenderer.ts
index 342989e..1c8c7ba 100644
--- a/src/services/gpu/pickRenderer.ts
+++ b/src/services/gpu/pickRenderer.ts
@@ -37,9 +37,9 @@
*
* The pick pipeline reuses the *same* vertex buffer and *same* uniform buffer as
* the visual pass. The caller must ensure that the visual pass has already
- * written its per-frame uniforms (viewProj, viewport, pointSizePx, brightness)
- * before calling `pick()` — the pick pass reads the same values without
- * re-uploading them. See the `pick()` JSDoc for the exact contract.
+ * written its per-frame uniforms (cam.viewProj, cam.viewportPx, pointSizePx,
+ * brightness, ...) before calling `pick()` — the pick pass reads the same
+ * values without re-uploading them. See the `pick()` JSDoc for the exact contract.
*
* ### Forgiveness radius
*
@@ -50,9 +50,19 @@
* @module
*/
-import shaderSrc from './shaders/points.wgsl?raw';
+// The points shader was split into four files (Task 13 of the WGSL→WESL
+// conversion plan): `points/io.wesl` (shared structs), `points/vertex.wesl`
+// (the `vs` entry point shared with PointRenderer), `points/colorFragment.wesl`
+// (PointRenderer's visual `fs`), and `points/pickFragment.wesl` (the
+// `fsPick` entry point — this renderer). The vertex source is textually
+// shared with PointRenderer, but we compile our OWN GPUShaderModule from
+// it; never share modules across pipelines (see the `auto` bind-group-
+// layout trap noted in pointRenderer.ts).
+import vsCode from './shaders/points/vertex.wesl?static';
+import pickFsCode from './shaders/points/pickFragment.wesl?static';
import type { Source } from '../../data/sources';
import type { PointRenderer } from './pointRenderer';
+import { createShaderModuleWithDevLog } from './shaderCompileLogger';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -246,12 +256,16 @@ export function createPickRenderer(
device: GPUDevice,
pointRenderer: PointRenderer,
): PickRenderer {
- // ── Shader module ──────────────────────────────────────────────────────────
+ // ── Shader modules ─────────────────────────────────────────────────────────
//
- // We reuse the same WGSL source as PointRenderer. The shader file contains
- // both the `fs` (visual) and `fsPick` (picking) fragment entry points.
- // Here we select `fsPick`.
- const module = device.createShaderModule({ code: shaderSrc });
+ // The vertex stage source is textually shared with PointRenderer, but we
+ // compile our OWN GPUShaderModule from it — never share modules across
+ // pipelines (see the `auto` bind-group-layout trap above). The fragment
+ // module compiles `pickFragment.wesl`, which contains only the `fsPick`
+ // entry point; the visual `fs` lives in a sibling file that this
+ // renderer never touches.
+ const vsModule = createShaderModuleWithDevLog(device, vsCode, 'pick.vertex');
+ const fsModule = createShaderModuleWithDevLog(device, pickFsCode, 'pick.pickFragment');
// ── Render pipeline ────────────────────────────────────────────────────────
//
@@ -266,10 +280,11 @@ export function createPickRenderer(
// `layout: 'auto'` reflects the bind group layout from the shader's @group/@binding
// declarations. The single binding is @group(0) @binding(0) — the Uniforms buffer.
const pipeline = device.createRenderPipeline({
+ label: 'pick-pipeline',
layout: 'auto',
vertex: {
- module,
+ module: vsModule,
entryPoint: 'vs',
// Vertex buffer layout — must exactly match PointRenderer's layout
@@ -307,7 +322,7 @@ export function createPickRenderer(
},
fragment: {
- module,
+ module: fsModule,
entryPoint: 'fsPick', // the picking fragment — writes instance index to r32uint
targets: [
@@ -344,6 +359,7 @@ export function createPickRenderer(
// 4-byte texel, we must allocate at least 256 bytes. We never map this
// buffer for writing — only MAP_READ is needed.
const stagingBuffer = device.createBuffer({
+ label: 'pick-staging-buffer',
size: 256,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
@@ -397,6 +413,7 @@ export function createPickRenderer(
// `RENDER_ATTACHMENT` — the render pass can write to it.
// `COPY_SRC` — we copy a single pixel out of it after the pass.
pickTexture = device.createTexture({
+ label: 'pick-target',
size: { width: w, height: h },
format: 'r32uint',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
@@ -409,6 +426,7 @@ export function createPickRenderer(
// Only `RENDER_ATTACHMENT` is needed — depth buffers are not typically read
// back to the CPU, so no `COPY_SRC` here.
depthTexture = device.createTexture({
+ label: 'pick-depth',
size: { width: w, height: h },
format: 'depth24plus',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
@@ -480,8 +498,13 @@ export function createPickRenderer(
// with the real selectedPacked, so we don't need to restore
// anything afterward.
//
- // Layout: mat4 viewProj (64) + viewport (8) + pointSizePx (4) +
- // brightness (4) → selectedPacked sits at byte offset 80.
+ // Layout (post-CameraUniforms refactor): the shared 80-byte
+ // 'CameraUniforms' prefix occupies bytes 0..79 (viewProj + viewportPx
+ // + two pad slots), so 'selectedPacked' sits at byte offset 80 — the
+ // SAME offset as before the refactor (pre-refactor was viewProj 64 +
+ // viewport 8 + pointSizePx 4 + brightness 4 = 80). The value at this
+ // offset is the packed (source, localIdx) u32, not an instance
+ // index — see PointRenderer.toGlobalIdx for the encoding.
const SELECTED_PACKED_OFFSET = 80;
const NONE_SENTINEL = new Uint32Array([0xffffffff]);
device.queue.writeBuffer(sharedUniformBuffer, SELECTED_PACKED_OFFSET, NONE_SENTINEL);
@@ -492,16 +515,24 @@ export function createPickRenderer(
// full rationale. Pads the visual `pointSizePx` floor by a few extra
// pixels so distant point-like galaxies become easier mouse targets
// without growing them on screen. Same in-place mutation pattern as
- // the SELECTED_INDEX write above — the next visual frame writes the
+ // the SELECTED_PACKED write above — the next visual frame writes the
// real `pointSizePx` back, so the visual pass is unaffected.
//
- // Layout reminder: pointSizePx sits at byte offset 72 (mat4 viewProj
- // = 64 + viewport vec2 = 8 → 72). Skipped entirely when the caller
- // didn't supply pointSizePx — preserves the legacy "pick whatever the
- // visual frame just wrote" contract for any test that constructs the
- // renderer in isolation.
+ // Layout reminder (post-CameraUniforms refactor): pointSizePx now sits
+ // at byte offset 88 (cam: CameraUniforms = 80 B prefix + selectedPacked
+ // u32 + instanceIdOffset u32 = 88). It used to live at offset 72, but
+ // adopting the shared 'CameraUniforms' prefix (which reserves bytes
+ // 72..79 as '_pad0/_pad1') forced 'pointSizePx' + 'brightness' to
+ // move into the existing 8-byte alignment slack between
+ // 'instanceIdOffset' and the vec3-aligned 'camPosWorld'. See the
+ // 'Uniforms layout' doc-block in points.wesl for the migration
+ // diagram and the matching f32-index update in pointRenderer.ts.
+ //
+ // Skipped entirely when the caller didn't supply pointSizePx —
+ // preserves the legacy "pick whatever the visual frame just wrote"
+ // contract for any test that constructs the renderer in isolation.
if (pointSizePx !== undefined) {
- const POINT_SIZE_OFFSET = 72;
+ const POINT_SIZE_OFFSET = 88;
const boostedSize = new Float32Array([pointSizePx + PICK_PADDING_PX]);
device.queue.writeBuffer(sharedUniformBuffer, POINT_SIZE_OFFSET, boostedSize);
}
@@ -550,6 +581,7 @@ export function createPickRenderer(
// next pick() call picks up the fresh handle without needing to
// invalidate this PickRenderer.
const bindGroup = device.createBindGroup({
+ label: 'pick-bg-uniforms',
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: sharedUniformBuffer } }],
});
@@ -567,6 +599,7 @@ export function createPickRenderer(
const cloudLayout = pipeline.getBindGroupLayout(1);
for (const src of sourceList) {
const cloudBindGroup = device.createBindGroup({
+ label: `pick-bg-cloudFade-${src.source}`,
layout: cloudLayout,
entries: [{ binding: 0, resource: { buffer: src.cloudFadeBuffer } }],
});
diff --git a/src/services/gpu/pointRenderer.ts b/src/services/gpu/pointRenderer.ts
index 6135aa5..28c9900 100644
--- a/src/services/gpu/pointRenderer.ts
+++ b/src/services/gpu/pointRenderer.ts
@@ -81,13 +81,26 @@ import { type ComputeSchechterRatiosInput } from '../engine/computeSchechterRati
import ComputeAngularWeightsWorker from '../engine/computeAngularWeights.worker?worker';
import { type ComputeAngularWeightsInput } from '../engine/computeAngularWeights';
-// `?raw` is a Vite-specific import suffix. It tells the bundler to import the
-// file's content as a plain string rather than attempting to execute it as
-// JavaScript. The WGSL source text ends up inlined in the JS bundle; at
-// runtime we hand it to `device.createShaderModule({ code: shaderSrc })`.
-// Without `?raw`, Vite would try to parse the .wgsl file as JS and fail.
-import shaderSrc from './shaders/points.wgsl?raw';
+// `?static` is wesl-plugin's Vite import suffix. It runs the WESL linker at
+// build time and hands us a plain WGSL string with all `import` statements
+// resolved into top-level functions. We forward that string straight to
+// `device.createShaderModule({ code: shaderSrc })`. The previous `?raw`
+// suffix bypassed the linker entirely and worked only because the legacy
+// .wgsl source was self-contained — once we extract shared modules under
+// `shaders/lib/`, `?static` is required.
+//
+// The points shader was split into four files (Task 13 of the WGSL→WESL
+// conversion plan): `points/io.wesl` (shared structs), `points/vertex.wesl`
+// (the `vs` entry point shared with PickRenderer), `points/colorFragment.wesl`
+// (the visual `fs` entry point — this renderer), and `points/pickFragment.wesl`
+// (PickRenderer's `fsPick`). Each pipeline now compiles its own vertex +
+// fragment GPUShaderModule from disjoint sources, eliminating a class of
+// selection-on-wrong-galaxy bugs that came from one shader module servicing
+// two pipelines with diverging fragment paths.
+import vsCode from './shaders/points/vertex.wesl?static';
+import colorFsCode from './shaders/points/colorFragment.wesl?static';
import { CloudFade } from './cloudFade';
+import { createShaderModuleWithDevLog } from './shaderCompileLogger';
// ─── Layout constants ─────────────────────────────────────────────────────────
@@ -249,13 +262,14 @@ export const SELECTED_PACKED_BYTE_OFFSET = 80;
*
* The struct contains (offsets are byte offsets from the start of the buffer):
*
- * bytes 0..63 : viewProj mat4x4 (16 floats = 64 bytes)
- * bytes 64..71 : viewport vec2 (2 floats) }
- * bytes 72..75 : pointSizePx f32 (1 float) } 16 bytes (one vec4 slot)
- * bytes 76..79 : brightness f32 (1 float) }
+ * bytes 0..63 : cam.viewProj mat4x4 (16 floats = 64 bytes) } CameraUniforms
+ * bytes 64..71 : cam.viewportPx vec2 (2 floats) } prefix from
+ * bytes 72..75 : cam._pad0 f32 (alignment slack) } lib/camera.wesl
+ * bytes 76..79 : cam._pad1 f32 (alignment slack) } (80 B total)
* bytes 80..83 : selectedPacked u32 ← (selectedSource << 27) | selectedLocalIdx, or 0xFFFFFFFF
* bytes 84..87 : sourceCode u32 ← per-draw source tag (5 bits used)
- * bytes 88..95 : _pad0/_pad1 u32×2 (written as 0) ← alignment for the next vec3 slot
+ * bytes 88..91 : pointSizePx f32 (moved here from offset 72 — see Uniforms doc-block)
+ * bytes 92..95 : brightness f32 (moved here from offset 76 — see Uniforms doc-block)
* bytes 96..107 : camPosWorld vec3 (3 floats) } 16 bytes (one vec4 slot)
* bytes 108..111: pxPerRad f32 (1 float) }
* bytes 112..115: highlightFallback u32 }
@@ -279,15 +293,20 @@ export const SELECTED_PACKED_BYTE_OFFSET = 80;
*
* WGSL uniform buffers follow rules similar to std140 (see WGSL spec §13,
* "Memory Layout"). Each member must be aligned to its alignment value:
- * `vec3` requires 16-byte alignment, which is why the `_pad0/_pad1`
- * pair sits between `sourceCode` and `camPosWorld` — without those
- * eight bytes, `camPosWorld` would land at offset 88, breaking alignment
- * and silently corrupting the camera position.
+ * `vec3` requires 16-byte alignment, which is why we still need 8
+ * bytes between `sourceCode` (offset 84) and `camPosWorld` (offset 96).
+ * The pre-CameraUniforms layout filled those 8 bytes with explicit
+ * `_pad0/_pad1` u32s; the post-refactor layout fills them with
+ * `pointSizePx` + `brightness` (formerly at offsets 72/76, which now
+ * belong to `CameraUniforms._pad0/_pad1`). Same number of bytes, same
+ * alignment — the displaced scalars simply moved into the existing pad slack.
*
- * The picker (`pickRenderer.ts`) writes `selectedPacked` (offset 80) +
- * `sourceCode` (offset 84) for every per-source draw — see its `pick()`
- * docblock for the per-source uniform-write pattern that lets the pick
- * pass see the same packed identity space the visual pass does.
+ * The picker (`pickRenderer.ts`) writes `selectedPacked` (offset 80,
+ * UNCHANGED across the refactor) + `sourceCode` (offset 84) for every
+ * per-source draw — see its `pick()` docblock for the per-source
+ * uniform-write pattern that lets the pick pass see the same packed
+ * identity space the visual pass does. It also writes `pointSizePx` at
+ * offset 88 (moved from offset 72 by the CameraUniforms refactor).
*
* Task 15 added the trailing 16-byte slot for the orientation-visibility
* toggles (`highlightFallback`, `realOnlyMode`). The two trailing u32
@@ -810,13 +829,22 @@ export class PointRenderer {
private device: GPUDevice,
format: GPUTextureFormat,
) {
- const module = device.createShaderModule({ code: shaderSrc });
+ // Two modules — one per stage — built from disjoint sources. The
+ // vertex source is shared (textually) with PickRenderer, but each
+ // renderer compiles its OWN GPUShaderModule from it; sharing modules
+ // across pipelines tempts you into the WebGPU 'auto' bind-group-layout
+ // trap (auto-derived layouts are pipeline-specific identities and
+ // sharing them across pipelines fails the 'group-equivalent'
+ // compatibility check at draw time).
+ const vsModule = createShaderModuleWithDevLog(device, vsCode, 'points.vertex');
+ const fsModule = createShaderModuleWithDevLog(device, colorFsCode, 'points.colorFragment');
this.pipeline = device.createRenderPipeline({
+ label: 'points-pipeline',
layout: 'auto',
vertex: {
- module,
+ module: vsModule,
entryPoint: 'vs',
buffers: [
{
@@ -861,7 +889,7 @@ export class PointRenderer {
},
fragment: {
- module,
+ module: fsModule,
entryPoint: 'fs',
targets: [
{
@@ -881,11 +909,13 @@ export class PointRenderer {
});
this.uniformBuffer_internal = device.createBuffer({
+ label: 'points-uniform-buffer',
size: UNIFORM_BYTES,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = device.createBindGroup({
+ label: 'points-bg-uniforms',
layout: this.pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer_internal } }],
});
@@ -1012,6 +1042,7 @@ export class PointRenderer {
}
const buffer = this.device.createBuffer({
+ label: `points-vertex-buffer-${source}`,
size: interleaved.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
@@ -1594,15 +1625,25 @@ export class PointRenderer {
const f32 = new Float32Array(buf);
const u32 = new Uint32Array(buf);
+ // Cam block (offsets 0..79) — viewProj + viewportPx + 2 reserved pads.
+ // f32[18] / f32[19] are the CameraUniforms '_pad0' / '_pad1' slots; the
+ // shared struct reserves them for vec3-alignment and they stay zero here.
f32.set(viewProj, 0);
- f32[16] = viewportPx[0];
- f32[17] = viewportPx[1];
- f32[18] = pointSizePx;
- f32[19] = brightness;
+ f32[16] = viewportPx[0]; // cam.viewportPx.x at byte offset 64
+ f32[17] = viewportPx[1]; // cam.viewportPx.y at byte offset 68
+ // f32[18], f32[19] (cam._pad0, cam._pad1) stay zero.
u32[20] = selectedPacked >>> 0; // selectedPacked at byte offset 80
- // u32[21..23] are pad bytes (sourceCode lives in the per-source
- // @group(1) cloud bind group, not @group(0)). Float32Array starts
- // zero-initialised so we don't need to write them explicitly.
+ // u32[21] (offset 84) is the @group(0) _pad0 — sourceCode lives in
+ // the per-source @group(1) cloud bind group, not @group(0).
+ // ArrayBuffer starts zero-initialised so we don't need to write it.
+ // pointSizePx + brightness moved into f32[22]/f32[23] from f32[18]/f32[19]
+ // when the shared CameraUniforms prefix took over the first 80 bytes —
+ // they recycle the existing 8-byte alignment slack between the
+ // @group(0)-unused slot at offset 84 and the vec3-aligned camPosWorld
+ // at offset 96. See the 'Uniforms layout' doc-block in points.wesl
+ // and the matching POINT_SIZE_OFFSET = 88 in pickRenderer.ts.
+ f32[22] = pointSizePx; // bytes 88..91
+ f32[23] = brightness; // bytes 92..95
f32[24] = camPosWorld[0]; // bytes 96..99
f32[25] = camPosWorld[1]; // bytes 100..103
f32[26] = camPosWorld[2]; // bytes 104..107
diff --git a/src/services/gpu/postProcess.ts b/src/services/gpu/postProcess.ts
index 8ec2077..7f0abb5 100644
--- a/src/services/gpu/postProcess.ts
+++ b/src/services/gpu/postProcess.ts
@@ -90,8 +90,16 @@
* monotonicity, asymptotic behaviour, and curve-specific shape.
*/
-import toneMapWgsl from './shaders/toneMap.wgsl?raw';
+// `?static` runs the WESL linker at build time and returns a flat WGSL
+// string. The tone-map pass is split into vertex + fragment source
+// files (mirroring the points/ and milkyWay/ splits) so each stage
+// compiles a strictly-smaller GPUShaderModule from disjoint source.
+// Both modules import their shared structs from `shaders/toneMap/io.wesl`
+// so the vertex-to-fragment interface stays byte-identical.
+import vsCode from './shaders/toneMap/vertex.wesl?static';
+import fsCode from './shaders/toneMap/fragment.wesl?static';
import { ToneMapCurve } from '../../data/toneMapCurve';
+import { createShaderModuleWithDevLog } from './shaderCompileLogger';
/**
* Plain `{ width, height }` pair, kept local to this module. We
@@ -198,6 +206,7 @@ export function createPostProcess(
function allocateHdr(s: Size): void {
if (hdrTexture) hdrTexture.destroy();
hdrTexture = device.createTexture({
+ label: 'hdr-target',
format: 'rgba16float',
size: { width: s.width, height: s.height },
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
@@ -208,7 +217,17 @@ export function createPostProcess(
allocateHdr(size);
// ── Tone-map pipeline (built once, lives until destroy) ───────────────
- const module = device.createShaderModule({ code: toneMapWgsl });
+ //
+ // `label` shows up in `getCompilationInfo` diagnostics and in
+ // browser-devtools error reports, which makes it much easier to tell
+ // *which* shader broke when several modules fail in the same frame.
+ // The helper additionally dumps the linked WGSL on compile errors in
+ // dev mode — see `shaderCompileLogger.ts` for the rationale (Chrome's
+ // WGSL compiler reports error line numbers against the linked output
+ // that wesl-plugin produces, so the only way to map them back to a
+ // source file is to read the linked string ourselves).
+ const vsModule = createShaderModuleWithDevLog(device, vsCode, 'toneMap.vertex');
+ const fsModule = createShaderModuleWithDevLog(device, fsCode, 'toneMap.fragment');
// Why nearest, not linear? The HDR texture is the same resolution
// as the swap chain (we resize it in lockstep) so the fullscreen
@@ -217,6 +236,7 @@ export function createPostProcess(
// work, and on some GPUs `linear` requires `'float32-filterable'`
// even on rgba16float. `nearest` is universally supported.
const sampler = device.createSampler({
+ label: 'toneMap-sampler',
magFilter: 'nearest',
minFilter: 'nearest',
});
@@ -224,11 +244,13 @@ export function createPostProcess(
// Uniform layout: [exposure: f32, whitepointSq: f32, asinhSoftness: f32,
// curve: u32] — 16 bytes total, naturally 16-byte aligned.
const uniformBuffer = device.createBuffer({
+ label: 'toneMap-uniform-buffer',
size: 16,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const bindGroupLayout = device.createBindGroupLayout({
+ label: 'toneMap-bgl',
entries: [
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
@@ -237,10 +259,14 @@ export function createPostProcess(
});
const pipeline = device.createRenderPipeline({
- layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
- vertex: { module, entryPoint: 'vs' },
+ label: 'toneMap-pipeline',
+ layout: device.createPipelineLayout({
+ label: 'toneMap-pipeline-layout',
+ bindGroupLayouts: [bindGroupLayout],
+ }),
+ vertex: { module: vsModule, entryPoint: 'vs' },
fragment: {
- module,
+ module: fsModule,
entryPoint: 'fs',
targets: [{ format: swapFormat }],
},
@@ -272,6 +298,7 @@ export function createPostProcess(
// bind a stale (destroyed) view. The cost is one allocation
// per frame; trivial compared to the actual fullscreen blit.
const bindGroup = device.createBindGroup({
+ label: 'toneMap-bg',
layout: bindGroupLayout,
entries: [
{ binding: 0, resource: hdrView! },
diff --git a/src/services/gpu/proceduralDiskRenderer.ts b/src/services/gpu/proceduralDiskRenderer.ts
index 0eef592..c812e83 100644
--- a/src/services/gpu/proceduralDiskRenderer.ts
+++ b/src/services/gpu/proceduralDiskRenderer.ts
@@ -11,8 +11,10 @@
* is just the JS-side pipeline wiring.
*/
-import wgsl from './shaders/proceduralDisks.wgsl?raw';
+import vsCode from './shaders/proceduralDisks/vertex.wesl?static';
+import fsCode from './shaders/proceduralDisks/fragment.wesl?static';
import type { ProceduralDiskInstance } from '../../@types/ProceduralDiskInstance';
+import { createShaderModuleWithDevLog } from './shaderCompileLogger';
const STRIDE_FLOATS = 12; // 3 vec4 per instance
const STRIDE_BYTES = STRIDE_FLOATS * 4;
@@ -37,9 +39,11 @@ export class ProceduralDiskRenderer {
const { device, format } = init;
this.device = device;
- const module = device.createShaderModule({ code: wgsl });
+ const vsModule = createShaderModuleWithDevLog(device, vsCode, 'proceduralDisks.vertex');
+ const fsModule = createShaderModuleWithDevLog(device, fsCode, 'proceduralDisks.fragment');
this.bindGroupLayout = device.createBindGroupLayout({
+ label: 'proceduralDisks-bgl-uniforms',
entries: [
{
binding: 0,
@@ -52,23 +56,27 @@ export class ProceduralDiskRenderer {
// Uniform layout matches diskRenderer / quadRenderer (mat4 + vec2 +
// 2 padding f32 + vec3 + f32) — 96 bytes.
this.uniformBuffer = device.createBuffer({
+ label: 'proceduralDisks-uniform-buffer',
size: 96,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = device.createBindGroup({
+ label: 'proceduralDisks-bg-uniforms',
layout: this.bindGroupLayout,
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }],
});
const pipelineLayout = device.createPipelineLayout({
+ label: 'proceduralDisks-pipeline-layout',
bindGroupLayouts: [this.bindGroupLayout],
});
this.pipeline = device.createRenderPipeline({
+ label: 'proceduralDisks-pipeline',
layout: pipelineLayout,
vertex: {
- module,
+ module: vsModule,
entryPoint: 'vs',
buffers: [
{
@@ -83,7 +91,7 @@ export class ProceduralDiskRenderer {
],
},
fragment: {
- module,
+ module: fsModule,
entryPoint: 'fs',
targets: [
{
@@ -133,6 +141,7 @@ export class ProceduralDiskRenderer {
this.vertexBuffer?.destroy();
const cap = Math.max(instances.length, 64);
this.vertexBuffer = this.device.createBuffer({
+ label: 'proceduralDisks-vertex-buffer',
size: cap * STRIDE_BYTES,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
diff --git a/src/services/gpu/quadRenderer.ts b/src/services/gpu/quadRenderer.ts
index 18f9ee9..59e1d34 100644
--- a/src/services/gpu/quadRenderer.ts
+++ b/src/services/gpu/quadRenderer.ts
@@ -15,7 +15,9 @@
import type { mat4 } from 'gl-matrix';
import type { GpuContext, QuadInstance } from '../../@types';
-import quadsWgsl from './shaders/quads.wgsl?raw';
+import vsCode from './shaders/quads/vertex.wesl?static';
+import fsCode from './shaders/quads/fragment.wesl?static';
+import { createShaderModuleWithDevLog } from './shaderCompileLogger';
/**
* Per-instance vertex attributes packed as 12 floats / 48 bytes:
@@ -35,15 +37,25 @@ const FLOATS_PER_INSTANCE = 12;
const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4;
/**
- * 96-byte uniform layout (matches the WGSL `Uniforms` struct in quads.wgsl):
+ * 96-byte uniform layout, mirroring `struct Uniforms` in
+ * `shaders/quads.wesl`. The first 80 bytes are the shared
+ * `CameraUniforms` prefix from `shaders/lib/camera.wesl`; the
+ * renderer-specific `camPosWorld + pxPerRad` pair sits AFTER it
+ * starting at offset 80.
*
- * bytes 0..63 : viewProj mat4x4 (16 floats = 64 B)
- * bytes 64..71 : viewport vec2 (2 floats = 8 B)
- * bytes 72..79 : _pad0/_pad1 f32 × 2 (8 B; padding so the next vec3 lands on a 16-B boundary)
- * bytes 80..91 : camPosWorld vec3 (3 floats = 12 B; vec3 needs 16-B alignment)
- * bytes 92..95 : pxPerRad f32 (1 float = 4 B; fits the trailing slot of camPosWorld's 16-B vec4 quantum)
+ * bytes 0..63 : viewProj mat4x4 (CameraUniforms.viewProj)
+ * bytes 64..71 : viewportPx vec2 (CameraUniforms.viewportPx)
+ * bytes 72..79 : _pad0, _pad1 2 × f32 (CameraUniforms reserved)
+ * bytes 80..91 : camPosWorld vec3 (vec3 needs 16-B alignment, which 80 already provides)
+ * bytes 92..95 : pxPerRad f32 (fills the trailing slot of camPosWorld's 16-B vec4 quantum)
*
- * Total: 96 bytes — multiple of 16 ✓.
+ * Total: 96 bytes — multiple of 16, no tail pad needed.
+ *
+ * Adopting `CameraUniforms` is a pure renaming at this layout: the
+ * shared prefix overlays the previous `viewProj + viewport + _pad0
+ * + _pad1` region byte-for-byte, so f32-indices for camPosWorld /
+ * pxPerRad stay at 20..23 — the CPU writes below are unchanged from
+ * before adoption.
*
* `camPosWorld` and `pxPerRad` are used by the vertex stage to compute
* each quad's apparent angular radius from its world-space distance to
@@ -82,13 +94,17 @@ export class QuadRenderer {
],
});
- const module = this.device.createShaderModule({ label: 'quads-wgsl', code: quadsWgsl });
+ const vsModule = createShaderModuleWithDevLog(this.device, vsCode, 'quads.vertex');
+ const fsModule = createShaderModuleWithDevLog(this.device, fsCode, 'quads.fragment');
this.pipeline = this.device.createRenderPipeline({
label: 'quad-pipeline',
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
+ layout: this.device.createPipelineLayout({
+ label: 'quads-pipeline-layout',
+ bindGroupLayouts: [this.bindGroupLayout],
+ }),
vertex: {
- module,
+ module: vsModule,
entryPoint: 'vs',
buffers: [
{
@@ -103,7 +119,7 @@ export class QuadRenderer {
],
},
fragment: {
- module,
+ module: fsModule,
entryPoint: 'fs',
targets: [
{
@@ -190,11 +206,20 @@ export class QuadRenderer {
if (instances.length === 0) return;
// Pack uniforms — see UNIFORM_BYTES doc-comment for the layout.
+ // f32[0..15] viewProj — CameraUniforms.viewProj
+ // f32[16..17] viewportPx — CameraUniforms.viewportPx
+ // f32[18..19] CameraUniforms reserved pad (left zero)
+ // f32[20..22] camPosWorld — Uniforms.camPosWorld (offset 80)
+ // f32[23] pxPerRad — Uniforms.pxPerRad (offset 92)
+ //
+ // The CameraUniforms reserved pad slots at f32[18..19] MUST stay
+ // zero — overwriting them silently shifts the WGSL view of every
+ // later member. `Float32Array` zero-initialises so we rely on
+ // that rather than writing explicit zeros.
const uni = new Float32Array(UNIFORM_BYTES / 4);
uni.set(viewProj as Float32Array, 0);
uni[16] = viewportPx[0];
uni[17] = viewportPx[1];
- // uni[18], uni[19] are the _pad0/_pad1 zero slots (left zero by Float32Array init).
uni[20] = camPosWorld[0]; // camPosWorld.x at byte offset 80
uni[21] = camPosWorld[1];
uni[22] = camPosWorld[2];
diff --git a/src/services/gpu/shaderCompileLogger.ts b/src/services/gpu/shaderCompileLogger.ts
new file mode 100644
index 0000000..303c729
--- /dev/null
+++ b/src/services/gpu/shaderCompileLogger.ts
@@ -0,0 +1,44 @@
+/**
+ * Helper for creating a `GPUShaderModule` that logs the linked WGSL
+ * source alongside any compile-time error in dev mode.
+ *
+ * Why this exists: under wesl-plugin's `?static` import, what reaches
+ * `device.createShaderModule` is a *linked* WGSL string with all WESL
+ * imports resolved into top-level functions. Chrome's WGSL compiler
+ * reports error line numbers against THAT linked string, not the
+ * source `.wesl` modules — so when a compile error fires, the only
+ * way to map "error at line 142" back to a source file is to read the
+ * linked WGSL ourselves.
+ *
+ * The pattern: gate the dump on `import.meta.env.DEV` so production
+ * bundles strip the branch and don't ship the shader source twice
+ * (once as the module, once as a console log). `getCompilationInfo`
+ * is a Promise; we don't await it so module creation stays
+ * synchronous and the caller can keep building its pipeline.
+ *
+ * Until wesl-plugin gains sourcemap support, every renderer should
+ * route shader-module creation through this helper. Removing it later
+ * is a one-line edit (drop the wrapper, call createShaderModule
+ * directly) — keeping it in a single file means there's exactly one
+ * place to update if upstream changes.
+ */
+export function createShaderModuleWithDevLog(
+ device: GPUDevice,
+ code: string,
+ label: string,
+): GPUShaderModule {
+ const module = device.createShaderModule({ code, label });
+ if (import.meta.env.DEV) {
+ void module.getCompilationInfo().then((info) => {
+ if (info.messages.some((m) => m.type === 'error')) {
+ // eslint-disable-next-line no-console
+ console.groupCollapsed(`[${label}] linked WGSL (for error line lookup)`);
+ // eslint-disable-next-line no-console
+ console.log(code);
+ // eslint-disable-next-line no-console
+ console.groupEnd();
+ }
+ });
+ }
+ return module;
+}
diff --git a/src/services/gpu/shaders/disks.wgsl b/src/services/gpu/shaders/disks.wgsl
deleted file mode 100644
index 4b7cba9..0000000
--- a/src/services/gpu/shaders/disks.wgsl
+++ /dev/null
@@ -1,202 +0,0 @@
-// disks.wgsl — oriented galaxy disks (astronomically correct).
-//
-// Each instance is a 3D disk fixed in WORLD space. The galaxy's true
-// orientation is derived from its on-sky position angle (PA, east of
-// north) and its inclination i where cos(i) = axisRatio (b/a). These
-// are intrinsic properties of the galaxy in 3D space — they do NOT
-// depend on the camera position. Foreshortening then falls out of the
-// perspective projection naturally: tilt the camera and the disk's
-// projected ellipse changes accordingly.
-//
-// ### Why this approach instead of "always face the camera"
-//
-// The first cut of this shader built a basis from `camPos - center` and
-// squashed it by axisRatio. That made the disk plane track the camera,
-// so axisRatio became a 2D screen-space squash — visually identical to
-// the points-shader's elliptical billboard mask, with no real 3D
-// foreshortening. Worse, near the celestial poles the seed vector
-// (used to break the up/right ambiguity) flipped abruptly when the
-// camera-relative normal crossed a threshold, snapping the basis.
-//
-// Building in world space fixes both: the disk has ONE orientation in
-// 3D regardless of camera, so orbiting reveals the true ellipse
-// foreshortening; and the only singularity is now galaxies physically
-// at the celestial poles (Dec ≈ ±90°), which is independent of camera
-// motion and easily handled with a fallback seed.
-//
-// ### Frame construction (right-handed, world-fixed)
-//
-// 1. losDir = normalize(center - origin)
-// Earth (the observer) sits at world origin. losDir is the
-// direction from Earth to the galaxy — the line of sight.
-// 2. north_proj = normalize(NORTH_POLE - dot(NORTH_POLE, losDir) *
-// losDir)
-// Project the celestial north pole vector onto the sky tangent
-// plane at the galaxy's position. Falls back to (0,1,0) when the
-// galaxy is within ~8° of the pole.
-// 3. east_proj = cross(north_proj, losDir)
-// Right-handed 3-axis at the galaxy: (north_proj, east_proj,
-// losDir).
-// 4. major = north_proj * cos(PA) + east_proj * sin(PA)
-// Position angle is east of north.
-// 5. minor_in_sky = cross(losDir, major)
-// In-sky perpendicular to the major axis.
-// 6. minor_3d = minor_in_sky * cosI + losDir * sinI
-// where cosI = axisRatio. Rotates the disk's true minor axis out
-// of the sky plane toward the observer by inclination angle i.
-// Face-on (axisRatio = 1, sinI = 0) → minor_3d = minor_in_sky →
-// disk lies entirely in the sky plane → projects as a circle.
-// Edge-on (axisRatio → 0, cosI → 0, sinI → 1) → minor_3d ≈ losDir
-// → disk is nearly parallel to the line of sight → projects as a
-// thin streak along the major axis.
-//
-// The disk basis is (major, minor_3d), both unit length and orthogonal.
-// Each corner is placed at center + (corner.x * major + corner.y *
-// minor_3d) * halfSize, then projected via viewProj.
-
-struct Uniforms {
- viewProj: mat4x4,
- viewport: vec2,
- _pad0: f32,
- _pad1: f32,
- // camPos is preserved in the layout for ABI continuity with the JS
- // upload path, but the world-fixed disk math doesn't read it: the
- // disk's orientation is an intrinsic galaxy property, independent of
- // where the camera sits. The camera contributes only via viewProj
- // (which is also a uniform, see above).
- camPos: vec3,
- _pad2: f32,
-};
-
-struct InstanceIn {
- @location(0) posSize: vec4,
- @location(1) uvRect: vec4,
- // x: axisRatio, y: positionAngleDeg, z: fadeAlpha (per-frame distance ×
- // load fade multiplier from the engine), w: reserved padding.
- @location(2) orient: vec4,
-};
-
-struct VsOut {
- @builtin(position) clipPos: vec4,
- @location(0) atlasUv: vec2,
- @location(1) cornerUv: vec2,
- // Per-instance fade multiplier in [0, 1].
- @location(2) fadeAlpha: f32,
-};
-
-@group(0) @binding(0) var u: Uniforms;
-@group(0) @binding(1) var atlasTex: texture_2d;
-@group(0) @binding(2) var atlasSmp: sampler;
-
-const CORNERS = array, 6>(
- vec2(-1.0, -1.0),
- vec2( 1.0, -1.0),
- vec2( 1.0, 1.0),
- vec2(-1.0, -1.0),
- vec2( 1.0, 1.0),
- vec2(-1.0, 1.0),
-);
-
-@vertex
-fn vs(@builtin(vertex_index) vid: u32, instance: InstanceIn) -> VsOut {
- let corner = CORNERS[vid];
- let center = instance.posSize.xyz;
- let halfSize = instance.posSize.w * 0.5;
- // Clamp axisRatio so an edge-on disk still produces a thin sliver
- // rather than a degenerate zero-area quad (which would z-fight or
- // disappear entirely under sub-pixel rounding).
- let axisRatio = max(instance.orient.x, 0.05);
- let paDeg = instance.orient.y;
- let paRad = paDeg * 3.14159265 / 180.0;
-
- // ── Line of sight ────────────────────────────────────────────────────
- //
- // Earth (the observer) is at world origin in this coordinate system —
- // the build pipeline's `raDecZToCartesian` places galaxies relative
- // to that point. losDir is therefore the direction from Earth to
- // the galaxy. Note: this is NOT the camera direction — the disk's
- // orientation must be camera-independent, otherwise orbiting would
- // make the disk visibly rotate (which it shouldn't).
- let losDir = normalize(center);
-
- // ── Sky tangent basis (north / east at the galaxy's position) ────────
- //
- // The celestial north pole is at Dec = +90°, which the build
- // pipeline maps to world-space (0, 0, 1). Project that vector onto
- // the plane perpendicular to losDir to get the in-sky "north"
- // direction at the galaxy's position.
- //
- // Singularity: when the galaxy is within ~8° of the celestial pole
- // (|losDir.z| > 0.99), `northPole - dot(...) * losDir` shrinks to
- // near-zero and normalize() amplifies floating-point noise. Fall
- // back to seeding with world-y in that case — for the handful of
- // real galaxies that close to the pole the resulting PA is still
- // well-defined, just measured against a different (consistent)
- // reference direction.
- let northPole = vec3(0.0, 0.0, 1.0);
- let nearPole = abs(dot(northPole, losDir)) > 0.99;
- let seed = select(northPole, vec3(0.0, 1.0, 0.0), nearPole);
- let north_proj = normalize(seed - dot(seed, losDir) * losDir);
- let east_proj = cross(north_proj, losDir);
-
- // ── Major axis on the sky ────────────────────────────────────────────
- //
- // Astronomical PA is measured east of north — increasing PA rotates
- // the major axis from north toward east.
- let cs = cos(paRad);
- let sn = sin(paRad);
- let major = north_proj * cs + east_proj * sn;
-
- // ── Tilt the disk's true minor axis out of the sky plane ─────────────
- //
- // For a face-on galaxy (axisRatio = 1, inclination i = 0°), the disk
- // minor axis lies entirely in the sky plane perpendicular to major.
- // For an edge-on galaxy (axisRatio = 0, i = 90°), the disk minor
- // axis points along the line of sight. Interpolate using cos(i) =
- // axisRatio:
- //
- // minor_3d = minor_in_sky · cos(i) + losDir · sin(i)
- //
- // This is the disk's REAL minor axis in 3D. When projected onto the
- // sky plane, its sky-projection length is cos(i) = axisRatio — which
- // matches the observed b/a, by definition.
- let minor_in_sky = cross(losDir, major);
- let cosI = axisRatio;
- let sinI = sqrt(max(0.0, 1.0 - cosI * cosI));
- let minor_3d = minor_in_sky * cosI + losDir * sinI;
-
- // Place the corner in world space using (major, minor_3d) as the
- // disk's basis. No squash needed — the basis vectors are already
- // unit length, and the inclination foreshortening will appear
- // automatically when the camera projects them.
- let world = center + (major * corner.x + minor_3d * corner.y) * halfSize;
- var out: VsOut;
- out.clipPos = u.viewProj * vec4(world, 1.0);
-
- let cornerUv = (corner + vec2(1.0, 1.0)) * 0.5;
- let uvLocal = vec2(cornerUv.x, 1.0 - cornerUv.y);
- out.atlasUv = mix(instance.uvRect.xy, instance.uvRect.zw, uvLocal);
- out.cornerUv = cornerUv;
- out.fadeAlpha = instance.orient.z;
- return out;
-}
-
-@fragment
-fn fs(in: VsOut) -> @location(0) vec4 {
- let rgba = textureSample(atlasTex, atlasSmp, in.atlasUv);
- // Soft circular mask — the disk geometry is already tilted in world
- // space, so the on-screen shape is a true ellipse from projection;
- // the mask just rounds the four corners of the (square) UV space.
- let r = length(in.cornerUv - vec2(0.5, 0.5));
- let mask = 1.0 - smoothstep(0.45, 0.5, r);
- // Brightness-derived alpha — same trick as quads.wgsl, lets the dark
- // sky in the cutout JPEG bleed transparent against the dot field.
- let lum = max(rgba.r, max(rgba.g, rgba.b));
- let lumAlpha = smoothstep(0.05, 0.30, lum);
- let alpha = lumAlpha * mask * in.fadeAlpha;
- // Discard near-transparent fragments so we don't waste blend
- // bandwidth on near-zero contributions. See `quads.wgsl` for
- // the longer note — same reasoning applies here.
- if (alpha < 0.01) { discard; }
- return vec4(rgba.rgb * alpha, alpha);
-}
diff --git a/src/services/gpu/shaders/disks/fragment.wesl b/src/services/gpu/shaders/disks/fragment.wesl
new file mode 100644
index 0000000..c05f4b7
--- /dev/null
+++ b/src/services/gpu/shaders/disks/fragment.wesl
@@ -0,0 +1,60 @@
+// disks/fragment.wesl — galaxy-disk fragment stage.
+//
+// Samples the texture atlas, applies a soft circular mask to round
+// the corners of the (square) UV rectangle, and gates alpha by
+// luminance so the dark sky in the SDSS / DSS cutout JPEGs bleeds
+// transparent against the catalogue dot field.
+//
+// The disk geometry is already tilted in world space by the vertex
+// stage, so the on-screen shape is a true ellipse from projection —
+// the mask just rounds the four corners of the (square) UV space, it
+// doesn't generate the ellipse itself.
+//
+// ## Why bindings are declared here, not in io.wesl
+//
+// Same WESL no-global-state argument as elsewhere. The atlas texture
+// + sampler bindings are fragment-only (the vertex stage never
+// samples), so they live here. The uniform binding is duplicated
+// from vertex.wesl because the fragment stage technically receives
+// the same @group(0) bind group at draw time even though the WGSL
+// fragment entry doesn't read 'u' — the diskRenderer's bind-group
+// layout still lists binding 0, so we keep the WGSL declaration in
+// sync to avoid a layout mismatch.
+//
+// (In the original single-file shader the uniform read happened in
+// the vertex stage only; binding 0 was tagged @group(0) once at
+// module scope and the WebGPU pipeline-layout introspection picked
+// it up as VERTEX-only. With two modules each declaring the binding
+// is conditional on whether the entry actually consumes it — the
+// fragment module here intentionally doesn't declare the uniform,
+// keeping the binding's visibility VERTEX-only as it was before.)
+
+import package::disks::io::VsOut;
+// Shared fragment-stage mask shapes — see 'lib/masks.wesl' for the
+// rationale (three smoothstep patterns recurred across four shaders,
+// naming the shapes makes the intent visible at the call site).
+import package::lib::masks::circularMask;
+import package::lib::masks::lumAlpha;
+
+@group(0) @binding(1) var atlasTex: texture_2d;
+@group(0) @binding(2) var atlasSmp: sampler;
+
+@fragment
+fn fs(in: VsOut) -> @location(0) vec4 {
+ let rgba = textureSample(atlasTex, atlasSmp, in.atlasUv);
+ // Soft circular mask — the disk geometry is already tilted in world
+ // space, so the on-screen shape is a true ellipse from projection;
+ // the mask just rounds the four corners of the (square) UV space.
+ let r = length(in.cornerUv - vec2(0.5, 0.5));
+ let mask = circularMask(r, 0.45, 0.5);
+ // Brightness-derived alpha — same trick as quads.wesl, lets the dark
+ // sky in the cutout JPEG bleed transparent against the dot field.
+ let lum = max(rgba.r, max(rgba.g, rgba.b));
+ let lumGate = lumAlpha(lum, 0.05, 0.30);
+ let alpha = lumGate * mask * in.fadeAlpha;
+ // Discard near-transparent fragments so we don't waste blend
+ // bandwidth on near-zero contributions. See 'quads.wesl' for
+ // the longer note — same reasoning applies here.
+ if (alpha < 0.01) { discard; }
+ return vec4(rgba.rgb * alpha, alpha);
+}
diff --git a/src/services/gpu/shaders/disks/io.wesl b/src/services/gpu/shaders/disks/io.wesl
new file mode 100644
index 0000000..813c166
--- /dev/null
+++ b/src/services/gpu/shaders/disks/io.wesl
@@ -0,0 +1,77 @@
+// disks/io.wesl — shared structs for the oriented galaxy-disk pipeline.
+//
+// This file is the 'interface' module of the disks renderer family. It
+// declares the structs that BOTH the vertex and fragment entry points
+// need to agree on byte-for-byte:
+//
+// - 'Uniforms' — the @group(0) @binding(0) uniform buffer layout.
+// - 'InstanceIn' — the per-instance vertex attributes.
+// - 'VsOut' — the vertex-to-fragment interface.
+//
+// ## Why a separate io module
+//
+// Originally everything lived in a single 'disks.wesl'. Splitting it
+// into io + vertex + fragment mirrors the points/ and milkyWay/
+// splits (tasks 13 and 14) so each stage compiles a strictly-smaller
+// shader module from disjoint source. The vertex stage doesn't read
+// the atlas texture; the fragment stage doesn't run the orientation
+// math.
+//
+// ## Why bindings live in the consuming files, not here
+//
+// WESL has no global state — bindings are module-local declarations,
+// not exportable symbols. Each consuming file redeclares the
+// bindings it needs (vertex.wesl: just the uniform; fragment.wesl:
+// the atlas texture + sampler) using the structs imported from this
+// authoritative module so the layout cannot drift.
+
+import package::lib::camera::CameraUniforms;
+
+// ── Uniforms layout (CameraUniforms-prefixed) ──────────────────────
+//
+// 96-byte uniform buffer matching the CPU-side UNIFORM_BYTES in
+// 'diskRenderer.ts'. The first 80 bytes are the shared
+// 'CameraUniforms' prefix (viewProj + viewportPx + 8B reserved pad);
+// the renderer's own 'camPos' vec3 sits at offset 80 followed by an
+// f32 tail pad to round up to a 16-byte boundary.
+//
+// 'camPos' is preserved in the layout for ABI continuity with the JS
+// upload path, but the world-fixed disk math doesn't read it: the
+// disk's orientation is an intrinsic galaxy property, independent of
+// where the camera sits. The camera contributes only via
+// 'cam.viewProj' (consumed by 'worldToClip').
+
+struct Uniforms {
+ cam: CameraUniforms,
+ // camPos is preserved in the layout for ABI continuity with the JS
+ // upload path, but the world-fixed disk math doesn't read it: the
+ // disk's orientation is an intrinsic galaxy property, independent of
+ // where the camera sits. The camera contributes only via
+ // 'cam.viewProj' (consumed by 'worldToClip').
+ camPos: vec3,
+ _pad2: f32,
+};
+
+// ── per-instance attributes ────────────────────────────────────────
+//
+// 48 bytes / 12 floats per instance (matches BYTES_PER_INSTANCE in
+// 'diskRenderer.ts'). Three vec4 slots: position+size, uvRect, and
+// the orientation pack (axisRatio, PA, fadeAlpha, reserved).
+
+struct InstanceIn {
+ @location(0) posSize: vec4,
+ @location(1) uvRect: vec4,
+ // x: axisRatio, y: positionAngleDeg, z: fadeAlpha (per-frame distance ×
+ // load fade multiplier from the engine), w: reserved padding.
+ @location(2) orient: vec4,
+};
+
+// ── vertex-to-fragment interface ────────────────────────────────────
+
+struct VsOut {
+ @builtin(position) clipPos: vec4,
+ @location(0) atlasUv: vec2,
+ @location(1) cornerUv: vec2,
+ // Per-instance fade multiplier in [0, 1].
+ @location(2) fadeAlpha: f32,
+};
diff --git a/src/services/gpu/shaders/disks/vertex.wesl b/src/services/gpu/shaders/disks/vertex.wesl
new file mode 100644
index 0000000..9d5a93f
--- /dev/null
+++ b/src/services/gpu/shaders/disks/vertex.wesl
@@ -0,0 +1,124 @@
+// disks/vertex.wesl — oriented galaxy-disk vertex stage (astronomically
+// correct).
+//
+// Each instance is a 3D disk fixed in WORLD space. The galaxy's true
+// orientation is derived from its on-sky position angle (PA, east of
+// north) and its inclination i where cos(i) = axisRatio (b/a). These
+// are intrinsic properties of the galaxy in 3D space — they do NOT
+// depend on the camera position. Foreshortening then falls out of
+// the perspective projection naturally: tilt the camera and the
+// disk's projected ellipse changes accordingly.
+//
+// ### Why this approach instead of 'always face the camera'
+//
+// The first cut of this shader built a basis from 'camPos - center'
+// and squashed it by axisRatio. That made the disk plane track the
+// camera, so axisRatio became a 2D screen-space squash — visually
+// identical to the points-shader's elliptical billboard mask, with
+// no real 3D foreshortening. Worse, near the celestial poles the
+// seed vector (used to break the up/right ambiguity) flipped
+// abruptly when the camera-relative normal crossed a threshold,
+// snapping the basis.
+//
+// Building in world space fixes both: the disk has ONE orientation
+// in 3D regardless of camera, so orbiting reveals the true ellipse
+// foreshortening; and the only singularity is now galaxies
+// physically at the celestial poles (Dec ≈ ±90°), which is
+// independent of camera motion and easily handled with a fallback
+// seed.
+//
+// ### Frame construction
+//
+// The disk-plane axis math (line-of-sight from Earth-at-origin →
+// sky-tangent (north, east) frame with pole fallback → PA-east-of-
+// north rotation → inclination tilt of the minor axis out of the
+// sky plane) lives in 'lib/orientation::diskAxes'. See that lib's
+// docblock for the camera-independence invariant and the ~8°-from-
+// pole degeneracy fallback.
+//
+// ## Why bindings are declared here, not in io.wesl
+//
+// Same WESL no-global-state argument as elsewhere: 'Uniforms' is
+// imported from io.wesl, the @group/@binding declaration lives in
+// the consuming file, and the layout matches fragment.wesl's
+// identical uniform declaration. Atlas-texture / sampler bindings
+// are fragment-only and stay in fragment.wesl.
+
+import package::disks::io::Uniforms;
+import package::disks::io::InstanceIn;
+import package::disks::io::VsOut;
+import package::lib::camera::worldToClip;
+// Shared unit-quad helpers from 'lib/billboard.wesl' — replace the
+// inline 'CORNERS' const + '(corner + 1) * 0.5' UV remap that used to
+// live in this file. The orientation-aligned disk-plane basis (PA +
+// inclination → 'major' / 'minor_3d' in 3D world space) stays
+// renderer-specific; only the vertex-index → corner / UV lookups are
+// shared. See the docblock at the top of 'lib/billboard.wesl' for why
+// the orientation math is intentionally NOT pulled into this lib.
+import package::lib::billboard::quadCorner;
+import package::lib::billboard::quadUv;
+// Disk-plane axis math (PA + inclination → world-space major/minor basis)
+// is shared with 'proceduralDisks.wesl' via 'lib/orientation.wesl'. The
+// inline derivation that used to live in this file's vs() body — the
+// los/north/east/major/minor chain — is now the lib's 'diskAxes' fn,
+// byte-equivalent for disks since the lib standardised on the wider
+// '|dot(north, los)| > 0.99' (~8°) pole-fallback threshold that disks
+// already used. See 'lib/orientation.wesl' for the camera-independence
+// invariant + the pole-degeneracy discussion.
+import package::lib::orientation::DiskAxes;
+import package::lib::orientation::diskAxes;
+
+@group(0) @binding(0) var u: Uniforms;
+
+@vertex
+fn vs(@builtin(vertex_index) vid: u32, instance: InstanceIn) -> VsOut {
+ // Unit-square corner offset in [-1, +1]² for this triangle-list
+ // vertex. Pulled from 'lib/billboard::quadCorner' so the (BL, BR,
+ // TR, BL, TR, TL) ordering is shared across all four billboard
+ // renderers — see the lib's docblock for the corner-ordering
+ // discussion. The corner here is in the disk's LOCAL 2D frame; the
+ // 3D placement happens via the (major, minor_3d) basis below.
+ let corner = quadCorner(vid);
+ let center = instance.posSize.xyz;
+ let halfSize = instance.posSize.w * 0.5;
+ // Clamp axisRatio so an edge-on disk still produces a thin sliver
+ // rather than a degenerate zero-area quad (which would z-fight or
+ // disappear entirely under sub-pixel rounding).
+ let axisRatio = max(instance.orient.x, 0.05);
+ let paDeg = instance.orient.y;
+ let paRad = paDeg * 3.14159265 / 180.0;
+
+ // ── Disk-plane basis (PA + inclination → world-space major / minor) ─
+ //
+ // The full derivation — line-of-sight from Earth-at-origin, sky-tangent
+ // (north, east) frame with pole fallback, PA-east-of-north rotation,
+ // and inclination tilt of the minor axis out of the sky plane — lives
+ // in 'lib/orientation.wesl'. See its docblock for the camera-
+ // independence invariant and the ~8°-from-pole degeneracy fallback.
+ // 'axisRatio' is clamped at the call site (above) BEFORE we feed it
+ // through as cosI, because the lib intentionally doesn't re-clamp.
+ let cosI = axisRatio;
+ let sinI = sqrt(max(0.0, 1.0 - cosI * cosI));
+ let axes = diskAxes(center, paRad, cosI, sinI);
+ let major = axes.major;
+ let minor_3d = axes.minor;
+
+ // Place the corner in world space using (major, minor_3d) as the
+ // disk's basis. No squash needed — the basis vectors are already
+ // unit length, and the inclination foreshortening will appear
+ // automatically when the camera projects them.
+ let world = center + (major * corner.x + minor_3d * corner.y) * halfSize;
+ var out: VsOut;
+ out.clipPos = worldToClip(u.cam, world);
+
+ // 'quadUv' returns the unit-square corner remapped to [0, 1]² (same
+ // vertex-index ordering as 'quadCorner' above). We then flip V so
+ // the texture isn't upside down — matches the atlas's top-down
+ // 'flipY: false' upload convention used by 'quads.wesl'.
+ let cornerUv = quadUv(vid);
+ let uvLocal = vec2(cornerUv.x, 1.0 - cornerUv.y);
+ out.atlasUv = mix(instance.uvRect.xy, instance.uvRect.zw, uvLocal);
+ out.cornerUv = cornerUv;
+ out.fadeAlpha = instance.orient.z;
+ return out;
+}
diff --git a/src/services/gpu/shaders/filaments.wgsl b/src/services/gpu/shaders/filaments.wgsl
deleted file mode 100644
index 9e921ad..0000000
--- a/src/services/gpu/shaders/filaments.wgsl
+++ /dev/null
@@ -1,152 +0,0 @@
-// filaments.wgsl — instanced-quad line shader for the cosmic-web skeleton.
-//
-// One instance per filament SEGMENT (consecutive vertex pair within a
-// strip). The instance attributes are the segment's two endpoints +
-// per-endpoint density. The vertex stage is invoked 6 times per
-// instance (two triangles forming a screen-aligned thick rectangle
-// between the two endpoints).
-//
-// Why instanced quads instead of native line topology? WebGPU's
-// `topology: 'line-list'` always renders 1-pixel-wide lines on most
-// platforms (no `setLineWidth` exists, by spec). For visible-from-
-// orbit cosmic-web filaments we want anti-aliased thick lines with a
-// soft edge falloff — only the instanced-quad trick gives us that.
-//
-// The unit-quad geometry is shared static data:
-// indices (constant, 6 per instance): 0 1 2 1 3 2
-// per-quad-vertex attribute (4 verts): uv = (0,0), (1,0), (0,1), (1,1)
-// uv.x picks startpoint vs endpoint; uv.y picks one side of the line vs
-// the other (mapped to ±half-width along the screen-space perpendicular).
-
-struct Uniforms {
- viewProj : mat4x4,
- viewport : vec2, // [w, h] in physical pixels
- halfWidthPx : f32, // line half-width in pixels
- // Per-frame uniform scale for the entire filament-pass output, [0..1].
- // Multiplied into the final pre-multiplied colour + alpha. Lives in
- // the slot that used to be `pad0` — the byte layout is unchanged.
- // Lets the user dim the cosmic-web overlay against the bright HDR
- // catalogue when high-σ skeletons (with their longer, denser ridges)
- // saturate to flat white under the tone-mapped pass.
- intensityScale : f32,
-};
-
-@group(0) @binding(0) var u : Uniforms;
-
-// Per-cloud fade-in (CloudFade — see src/services/gpu/cloudFade.ts). One
-// f32 opacity, written each frame from the JS side; multiplied into the
-// fragment alpha so a freshly-uploaded skeleton glides in over ~600 ms.
-struct CloudUniforms {
- opacity : f32,
- _pad0 : f32,
- _pad1 : f32,
- _pad2 : f32,
-};
-@group(1) @binding(0) var cloud : CloudUniforms;
-
-struct PerVertex {
- @location(0) uv : vec2, // (0..1, 0..1) — quad-corner UV
- @location(1) startPos : vec3, // segment start in world Mpc
- @location(2) startDensity : f32, // 0..1
- @location(3) endPos : vec3, // segment end in world Mpc
- @location(4) endDensity : f32, // 0..1
-};
-
-struct VSOut {
- @builtin(position) clip : vec4,
- @location(0) uv : vec2,
- @location(1) density : f32,
-};
-
-@vertex
-fn vs(in : PerVertex) -> VSOut {
- // Project both endpoints to clip space.
- let aClip = u.viewProj * vec4(in.startPos, 1.0);
- let bClip = u.viewProj * vec4(in.endPos, 1.0);
-
- // Choose which endpoint this corner uses (uv.x = 0 → start, 1 → end).
- let endpoint = select(aClip, bClip, in.uv.x > 0.5);
-
- // Compute the screen-space tangent and perpendicular for THIS segment.
- // We do the math in NDC then scale to pixels — clip-space requires the
- // perspective divide first.
- let aNdc = aClip.xy / aClip.w;
- let bNdc = bClip.xy / bClip.w;
- let tangent = normalize(bNdc - aNdc);
- let perp = vec2(-tangent.y, tangent.x);
-
- // pixel width → NDC offset: (px / halfViewport) is the NDC-space length
- // of one pixel. Multiplied by halfWidthPx gives the half-width in NDC.
- let halfWidthNdc = perp * (u.halfWidthPx / (u.viewport * 0.5));
-
- // uv.y in [0, 1] picks +halfWidth or -halfWidth.
- let sideSign = in.uv.y * 2.0 - 1.0;
- let offsetNdc = halfWidthNdc * sideSign;
-
- // Apply the offset to the chosen endpoint, then re-multiply by w to
- // restore clip space (perspective-correct interpolation).
- var out : VSOut;
- out.clip = vec4(
- endpoint.xy + offsetNdc * endpoint.w,
- endpoint.zw,
- );
- // Pass uv.y through for the fragment falloff; lerp density between
- // start/end based on uv.x.
- out.uv = in.uv;
- out.density = mix(in.startDensity, in.endDensity, in.uv.x);
- return out;
-}
-
-@fragment
-fn fs(in : VSOut) -> @location(0) vec4 {
- // Soft anti-aliased edge: uv.y ∈ [0, 1], peak at 0.5.
- // smoothstep(0, 0.1, x) and (1 - smoothstep(0.9, 1, x)) carve a soft
- // window around the centre. Multiplied together they give a
- // perpendicular-distance falloff that fades to 0 at the line's edges.
- let edgeFade =
- smoothstep(0.0, 0.1, in.uv.y) * (1.0 - smoothstep(0.9, 1.0, in.uv.y));
-
- // ── Density-aware brightness + tint ──────────────────────────────
- //
- // The per-vertex density attribute is min-max-normalised at build
- // time (see `skeletonToFilamentCloud` in `tools/parsers/ndskl.ts`),
- // so `in.density` ∈ [0, 1] across the whole catalogue: 0 = the
- // sparsest filament vertex, 1 = the densest. The vertex stage
- // already linearly interpolates `startDensity` ↔ `endDensity` along
- // the segment, so within a single filament the value rises smoothly
- // toward dense hub regions.
- //
- // Two simultaneous modulations:
- //
- // * `densityBoost` ramps alpha from a visible floor (0.2) at
- // low-density tendrils to full (1.0) at the brightest spine
- // vertices. The `pow(d, 0.6)` gamma-correction stretches the
- // low end of the curve — without it, a near-linear ramp would
- // crush the dim 0.1–0.4 range to invisibility against the
- // tone-mapped HDR background. 0.6 is empirical; the eye reads
- // the resulting falloff as smooth.
- //
- // * `tint` blends from a base soft purple at low density toward a
- // brighter, slightly more white-blue purple at high density.
- // This adds a second visual axis (hue, not just brightness) so
- // the cosmic-web spine pops without needing the alpha alone to
- // carry the contrast. The two endpoints have similar luminance
- // so the tint shift reads as colour temperature, not glare.
- //
- // Disclaimer: `density` here is the DTFE field value at the vertex,
- // NOT the per-filament robustness in σ (which is what DisPerSE's
- // persistence cut threshold uses). They're correlated — denser
- // ridges tend to be more persistent — but not identical. See the
- // "Phase 3" note in the DisPerSE plan for the proper σ-coded
- // visualisation, which would require capturing per-filament
- // robustness in the parser, bumping the FILA binary format to v2,
- // and adding a second per-segment vertex attribute.
- let densityBoost = mix(0.2, 1.0, pow(in.density, 0.6));
-
- let baseTint = vec3(0.55, 0.45, 0.85); // dim, cool-purple tendrils
- let hotTint = vec3(0.85, 0.75, 1.0); // bright, near-white-violet spine
- let tint = mix(baseTint, hotTint, in.density);
-
- let alpha = edgeFade * 0.6 * densityBoost * u.intensityScale * cloud.opacity;
- return vec4(tint * alpha, alpha); // pre-multiplied alpha
-}
diff --git a/src/services/gpu/shaders/filaments/fragment.wesl b/src/services/gpu/shaders/filaments/fragment.wesl
new file mode 100644
index 0000000..5386705
--- /dev/null
+++ b/src/services/gpu/shaders/filaments/fragment.wesl
@@ -0,0 +1,111 @@
+// filaments/fragment.wesl — soft anti-aliased line fragment stage.
+//
+// The fragment stage carries the cosmic-web visual identity: a
+// soft-edged glow whose brightness and tint vary along the segment
+// according to local density. Three modulations:
+//
+// - edgeBandMask(uv.y, 0.1) — a two-tailed smoothstep that produces
+// a 10%-on / 80%-flat / 10%-off shape across the line's
+// perpendicular axis. This is the screen-space anti-aliasing.
+// - densityBoost — gamma-stretched density 'pow(d, 0.6)' mapped
+// into [0.2, 1.0] so even sparse tendrils stay visible while
+// dense spines saturate.
+// - tint blend — cool-purple at low density to bright violet at
+// high density. Adds a hue axis on top of the brightness ramp.
+//
+// Then 'applyCloudFade' folds in the per-cloud opacity uniform that
+// lets the renderer ramp the entire skeleton in/out smoothly when a
+// new tier loads.
+//
+// ## Why bindings are declared here, not in io.wesl
+//
+// Same WESL no-global-state argument as elsewhere: 'Uniforms' is
+// imported from io.wesl, the @group/@binding declarations live here,
+// and the layout matches vertex.wesl's identical declaration.
+// 'CloudUniforms' lives in 'lib/cloudFade.wesl' — see that file's
+// docblock for why the layout is shared with points/.
+
+import package::filaments::io::Uniforms;
+import package::filaments::io::VSOut;
+import package::lib::cloudFade::CloudUniforms;
+import package::lib::cloudFade::applyCloudFade;
+// Shared fragment-stage mask shapes — see 'lib/masks.wesl' for the
+// rationale (three smoothstep patterns recurred across four shaders,
+// naming the shapes makes the intent visible at the call site).
+// 'edgeBandMask(axis, fade)' is the two-tailed soft-window pattern
+// previously spelled inline as 'smoothstep(0, fade, x) * (1 -
+// smoothstep(1-fade, 1, x))' below.
+import package::lib::masks::edgeBandMask;
+
+@group(0) @binding(0) var u : Uniforms;
+
+// Per-cloud fade-in (CloudFade — see src/services/gpu/cloudFade.ts).
+// 'CloudUniforms' is imported from 'lib/cloudFade.wesl' (shared with
+// points.wesl). The CPU-side 'CloudFade' class produces the same
+// 16-byte layout for every consumer, so the shared shader struct is
+// honest: filaments doesn't read 'sourceCode' today, but the bytes
+// are written either way and a future filament feature can opt in
+// without renaming. See the lib's docblock for the full rationale.
+@group(1) @binding(0) var cloud : CloudUniforms;
+
+@fragment
+fn fs(in : VSOut) -> @location(0) vec4 {
+ // Soft anti-aliased edge: uv.y ∈ [0, 1], peak at 0.5.
+ // 'edgeBandMask(axis, fade)' carves a soft window around the centre
+ // — fade=0.1 gives a 10%-on / 80%-flat / 10%-off shape. The two-tailed
+ // smoothstep pattern lives in 'lib/masks.wesl' for the longer
+ // rationale; this is the call site that motivated the helper's
+ // existence.
+ let edgeFade = edgeBandMask(in.uv.y, 0.1);
+
+ // ── Density-aware brightness + tint ──────────────────────────────
+ //
+ // The per-vertex density attribute is min-max-normalised at build
+ // time (see 'skeletonToFilamentCloud' in 'tools/parsers/ndskl.ts'),
+ // so 'in.density' ∈ [0, 1] across the whole catalogue: 0 = the
+ // sparsest filament vertex, 1 = the densest. The vertex stage
+ // already linearly interpolates 'startDensity' ↔ 'endDensity' along
+ // the segment, so within a single filament the value rises smoothly
+ // toward dense hub regions.
+ //
+ // Two simultaneous modulations:
+ //
+ // * 'densityBoost' ramps alpha from a visible floor (0.2) at
+ // low-density tendrils to full (1.0) at the brightest spine
+ // vertices. The 'pow(d, 0.6)' gamma-correction stretches the
+ // low end of the curve — without it, a near-linear ramp would
+ // crush the dim 0.1–0.4 range to invisibility against the
+ // tone-mapped HDR background. 0.6 is empirical; the eye reads
+ // the resulting falloff as smooth.
+ //
+ // * 'tint' blends from a base soft purple at low density toward a
+ // brighter, slightly more white-blue purple at high density.
+ // This adds a second visual axis (hue, not just brightness) so
+ // the cosmic-web spine pops without needing the alpha alone to
+ // carry the contrast. The two endpoints have similar luminance
+ // so the tint shift reads as colour temperature, not glare.
+ //
+ // Disclaimer: 'density' here is the DTFE field value at the vertex,
+ // NOT the per-filament robustness in σ (which is what DisPerSE's
+ // persistence cut threshold uses). They're correlated — denser
+ // ridges tend to be more persistent — but not identical. See the
+ // 'Phase 3' note in the DisPerSE plan for the proper σ-coded
+ // visualisation, which would require capturing per-filament
+ // robustness in the parser, bumping the FILA binary format to v2,
+ // and adding a second per-segment vertex attribute.
+ let densityBoost = mix(0.2, 1.0, pow(in.density, 0.6));
+
+ let baseTint = vec3(0.55, 0.45, 0.85); // dim, cool-purple tendrils
+ let hotTint = vec3(0.85, 0.75, 1.0); // bright, near-white-violet spine
+ let tint = mix(baseTint, hotTint, in.density);
+
+ // Per-cloud fade-in (opacity uniform written each frame from JS).
+ // 'applyCloudFade' (lib/cloudFade.wesl) is the documented place that
+ // says 'never multiply opacity into RGB' — it's a scalar helper that
+ // folds opacity into 'alpha' alongside the other modulators here.
+ let alpha = applyCloudFade(
+ edgeFade * 0.6 * densityBoost * u.intensityScale,
+ cloud.opacity,
+ );
+ return vec4(tint * alpha, alpha); // pre-multiplied alpha
+}
diff --git a/src/services/gpu/shaders/filaments/io.wesl b/src/services/gpu/shaders/filaments/io.wesl
new file mode 100644
index 0000000..2535624
--- /dev/null
+++ b/src/services/gpu/shaders/filaments/io.wesl
@@ -0,0 +1,82 @@
+// filaments/io.wesl — shared structs for the cosmic-web filament pipeline.
+//
+// This file is the 'interface' module of the filament renderer family.
+// It declares the structs that BOTH the vertex and fragment entry
+// points need to agree on byte-for-byte:
+//
+// - 'Uniforms' — the @group(0) @binding(0) uniform buffer layout.
+// - 'PerVertex' — per-quad-vertex + per-instance vertex attributes.
+// - 'VSOut' — the vertex-to-fragment interface.
+//
+// ## Why a separate io module
+//
+// Originally everything lived in a single 'filaments.wesl'. Splitting
+// it into io + vertex + fragment mirrors the points/ and milkyWay/
+// splits (tasks 13 and 14) so each stage compiles a strictly-smaller
+// shader module from disjoint source. The vertex stage doesn't need
+// the cloud-fade or mask helpers; the fragment stage doesn't need
+// 'worldToClip'.
+//
+// ## Why bindings live in the consuming files, not here
+//
+// WESL has no global state — '@group(N) @binding(M) var X'
+// is a module-local declaration, not an exportable symbol. Each
+// consuming file redeclares its bindings using the structs imported
+// from this single authoritative module so the layout numbers cannot
+// drift between vertex and fragment.
+
+import package::lib::camera::CameraUniforms;
+
+// ── Uniforms layout (CameraUniforms-prefixed) ──────────────────────
+//
+// The first 80 bytes are the shared 'CameraUniforms' prefix from
+// 'lib/camera.wesl' (viewProj at offset 0, viewportPx at offset 64,
+// two reserved-pad f32s at 72/76). The next two f32 slots at offsets
+// 80 and 84 hold this renderer's two scalar parameters; offsets
+// 88..95 are an explicit 8-byte tail pad to round the struct up to
+// a 16-byte multiple. Total: 96 bytes.
+//
+// CPU-side counterpart in 'filamentRenderer.ts' writes halfWidthPx
+// at f32-index 20 (byte 80) and intensityScale at f32-index 21
+// (byte 84) — see UNIFORM_BYTES there.
+
+struct Uniforms {
+ cam : CameraUniforms,
+ halfWidthPx : f32, // line half-width in pixels (offset 80)
+ // Per-frame uniform scale for the entire filament-pass output, [0..1].
+ // Multiplied into the final pre-multiplied colour + alpha. Lets the
+ // user dim the cosmic-web overlay against the bright HDR catalogue
+ // when high-σ skeletons (with their longer, denser ridges) saturate
+ // to flat white under the tone-mapped pass.
+ intensityScale : f32, // offset 84
+ _pad0 : f32, // offset 88 — see struct comment above
+ _pad1 : f32, // offset 92
+};
+
+// ── per-vertex attributes ──────────────────────────────────────────
+//
+// Two-buffer vertex layout: location 0 is per-quad-vertex (the unit-
+// quad UV), locations 1..4 are per-instance (the segment endpoints).
+// The renderer's vertex-buffer descriptor in filamentRenderer.ts has
+// to match this struct's location numbers exactly.
+
+struct PerVertex {
+ @location(0) uv : vec2, // (0..1, 0..1) — quad-corner UV
+ @location(1) startPos : vec3, // segment start in world Mpc
+ @location(2) startDensity : f32, // 0..1
+ @location(3) endPos : vec3, // segment end in world Mpc
+ @location(4) endDensity : f32, // 0..1
+};
+
+// ── vertex-to-fragment interface ────────────────────────────────────
+//
+// 'uv' carries through the per-quad-vertex coordinate so the fragment
+// stage can compute the soft edge falloff perpendicular to the
+// segment. 'density' is per-segment-endpoint linearly interpolated
+// across the line, driving the brightness + tint modulation.
+
+struct VSOut {
+ @builtin(position) clip : vec4,
+ @location(0) uv : vec2,
+ @location(1) density : f32,
+};
diff --git a/src/services/gpu/shaders/filaments/vertex.wesl b/src/services/gpu/shaders/filaments/vertex.wesl
new file mode 100644
index 0000000..4d4cac5
--- /dev/null
+++ b/src/services/gpu/shaders/filaments/vertex.wesl
@@ -0,0 +1,79 @@
+// filaments/vertex.wesl — instanced-quad line vertex stage.
+//
+// One instance per filament segment (consecutive vertex pair within a
+// strip). The instance attributes are the segment's two endpoints +
+// per-endpoint density. This vertex stage is invoked 6 times per
+// instance (two triangles forming a screen-aligned thick rectangle
+// between the two endpoints).
+//
+// The unit-quad geometry is shared static data:
+// indices (constant, 6 per instance): 0 1 2 1 3 2
+// per-quad-vertex attribute (4 verts): uv = (0,0), (1,0), (0,1), (1,1)
+// uv.x picks startpoint vs endpoint; uv.y picks one side of the line vs
+// the other (mapped to ±half-width along the screen-space perpendicular).
+//
+// Why instanced quads instead of native line topology? WebGPU's
+// 'topology: 'line-list'' always renders 1-pixel-wide lines on most
+// platforms (no 'setLineWidth' exists, by spec). For visible-from-
+// orbit cosmic-web filaments we want anti-aliased thick lines with a
+// soft edge falloff — only the instanced-quad trick gives us that.
+//
+// ## Why bindings are declared here, not in io.wesl
+//
+// WESL has no global state — the @group(0) @binding(0) declaration
+// below is a module-local thing that cannot be exported. We re-
+// declare it here using 'Uniforms' imported from io.wesl so the
+// layout matches fragment.wesl's identical declaration. The fragment
+// stage additionally declares @group(1) @binding(0) for the cloud-
+// fade uniform; that binding is fragment-only so it doesn't appear
+// here.
+
+import package::filaments::io::Uniforms;
+import package::filaments::io::PerVertex;
+import package::filaments::io::VSOut;
+import package::lib::camera::worldToClip;
+
+@group(0) @binding(0) var u : Uniforms;
+
+@vertex
+fn vs(in : PerVertex) -> VSOut {
+ // Project both endpoints to clip space via the shared helper. We
+ // could call 'worldToNdc' twice and drop the perspective divide
+ // separately, but we still need 'aClip.w' / 'bClip.w' below to
+ // restore clip space after the pixel-space offset, so doing the
+ // divide locally is cheaper than projecting twice.
+ let aClip = worldToClip(u.cam, in.startPos);
+ let bClip = worldToClip(u.cam, in.endPos);
+
+ // Choose which endpoint this corner uses (uv.x = 0 → start, 1 → end).
+ let endpoint = select(aClip, bClip, in.uv.x > 0.5);
+
+ // Compute the screen-space tangent and perpendicular for THIS segment.
+ // We do the math in NDC then scale to pixels — clip-space requires the
+ // perspective divide first.
+ let aNdc = aClip.xy / aClip.w;
+ let bNdc = bClip.xy / bClip.w;
+ let tangent = normalize(bNdc - aNdc);
+ let perp = vec2(-tangent.y, tangent.x);
+
+ // pixel width → NDC offset: (px / halfViewport) is the NDC-space length
+ // of one pixel. Multiplied by halfWidthPx gives the half-width in NDC.
+ let halfWidthNdc = perp * (u.halfWidthPx / (u.cam.viewportPx * 0.5));
+
+ // uv.y in [0, 1] picks +halfWidth or -halfWidth.
+ let sideSign = in.uv.y * 2.0 - 1.0;
+ let offsetNdc = halfWidthNdc * sideSign;
+
+ // Apply the offset to the chosen endpoint, then re-multiply by w to
+ // restore clip space (perspective-correct interpolation).
+ var out : VSOut;
+ out.clip = vec4(
+ endpoint.xy + offsetNdc * endpoint.w,
+ endpoint.zw,
+ );
+ // Pass uv.y through for the fragment falloff; lerp density between
+ // start/end based on uv.x.
+ out.uv = in.uv;
+ out.density = mix(in.startDensity, in.endDensity, in.uv.x);
+ return out;
+}
diff --git a/src/services/gpu/shaders/labels.wgsl b/src/services/gpu/shaders/labels.wgsl
deleted file mode 100644
index 5af39b9..0000000
--- a/src/services/gpu/shaders/labels.wgsl
+++ /dev/null
@@ -1,106 +0,0 @@
-// labels.wgsl — MSDF text rendering with hybrid (clamped) screen-space sizing.
-//
-// Per-glyph instance: one quad expanded from a unit corner attribute.
-// Per-label data lives in a storage buffer indexed by `labelIndex` so
-// all glyphs of one label share its world position, color, and fade.
-//
-// Sizing model: each label has a notional "world em size" (Mpc per em
-// of the source font). The vertex shader projects worldPos to clip
-// space, computes how many screen pixels one em occupies at that depth,
-// then clamps the result to [minPixelSize, maxPixelSize] before scaling
-// each glyph quad accordingly. This is the "hybrid: world-space with
-// min/max pixel clamp" mode from the design spec.
-
-struct Uniforms {
- viewProj : mat4x4,
- // viewport pixel dimensions in xy; .zw reserved for future use.
- viewport : vec4,
-};
-
-struct LabelData {
- // worldPos.xyz = anchor in Mpc; worldPos.w = worldEmMpc (em-size in Mpc)
- worldPos : vec4,
- // color.rgb premultiplied; color.a = base alpha (multiplied by fadeAlpha)
- color : vec4,
- // x = pixelSize (target em pixel height at natural viewing distance)
- // y = minPixelSize, z = maxPixelSize, w = fadeAlpha
- sizing : vec4,
-};
-
-@group(0) @binding(0) var u : Uniforms;
-@group(0) @binding(1) var labels : array