diff --git a/docs/superpowers/plans/completed/2026-06-08-pick-disk-from-procedural.md b/docs/superpowers/plans/completed/2026-06-08-pick-disk-from-procedural.md
new file mode 100644
index 00000000..ec0cd4dd
--- /dev/null
+++ b/docs/superpowers/plans/completed/2026-06-08-pick-disk-from-procedural.md
@@ -0,0 +1,436 @@
+# Pick resolved galaxy disks from the procedural-disk pipeline
+
+## Goal
+
+Make the pick surface for a resolved galaxy match its on-screen disk (and the
+disk-radius debug ring) **exactly**, by picking from the procedural-disk
+pipeline instead of reconstructing the ellipse on the point sprite. Then
+simplify the point-sprite pick back to a plain dot.
+
+### Why (background)
+
+Picking currently rides entirely on the POINT sprite billboard:
+`pickRenderer.ts` re-renders the point pipeline into an `r32uint` texture. For a
+galaxy that has crossed into the resolved-disk regime, the point pick fragment
+(`src/services/gpu/shaders/points/pickFragment.wesl`) tries to reconstruct the
+galaxy disk ellipse on a screen-aligned billboard from two varyings
+(`pickMajorBillboard`, `pickMinorBillboard`) plus an `isDiskHandoff` flag set in
+`points/vertex.wesl`, with the basis projected from `radiusMpc`. This is:
+
+- **a perf cost on the hot, shared point vertex stage** — two extra `worldToClip`
+ calls and two extra flat varyings on a stage that runs for ~2.5M point sprites
+ per frame; and
+- **buggy** — it draws a square/oversized circle that doesn't match the ring and
+ doesn't foreshorten.
+
+The realization: the procedural-disk renderer already draws the exact disk with
+correct world-space geometry, and the debug ring uses the identical math. By
+construction, **ring == procedural disk edge == desired pick surface**:
+
+- `src/services/gpu/shaders/proceduralDisks/vertex.wesl` builds the quad from
+ `halfWorld = posSize.w * 0.5` and `diskAxes(pos, paRad, cosI=max(axisRatio,0.05), sinI)`.
+- `proceduralDiskSubsystem.ts:187` sets `sizeWorldMpc = paddedRadiusMpc(dKpcRow) * 2`,
+ so `halfWorld == paddedRadiusMpc`.
+- `proceduralDisks/fragment.wesl:57` discards at `length(in.uv) > 1.0` — the
+ visible disk edge is the inscribed unit-circle ellipse.
+- `diskRadiusRingPass.ts:66` draws the ring at `paddedRadiusMpc(diameterKpc)` with
+ the same `diskAxes`.
+
+So the fix is to add a pick fragment to the procedural-disk pipeline that reuses
+its existing vertex stage and discards at `length(uv) > 1.0`, writing the packed
+identity; then strip the ellipse machinery off the point pick and clamp it to a
+plain dot.
+
+## Architecture
+
+- **Procedural disks own resolved-disk picking.** Add a pick fragment to the
+ procedural-disk pipeline (reusing its vertex stage), thread the packed
+ `(source, localIdx)` identity onto each procedural instance, expose a
+ `pickDisks(pass)` method on the procedural-disk renderer, and call it from
+ `pickRenderer.recordPickPass` — the exact pattern already used for
+ `structureMarkerRenderer.pickRing(pass)`.
+- **Points own dot picking.** In the pick pass the point billboard clamps to the
+ dot-floor size and the point pick fragment becomes a plain `dot(uv,uv) <= 1.0`
+ circle. The ellipse reconstruction (`pickMajorBillboard` / `pickMinorBillboard`
+ / `isDiskHandoff`) is deleted from `points/io.wesl`, `points/vertex.wesl`, and
+ `points/pickFragment.wesl`.
+- **Shared depth means front-most wins.** The point dot and the disk for the SAME
+ galaxy carry the SAME packed id, so any overlap between the two passes is
+ harmless.
+
+The de-complecting in one line: **the point pass picks dots; the procedural pass
+picks disks.** No shader branches on which regime a galaxy is in.
+
+## Tech Stack
+
+- TypeScript + Vite, raw WebGPU + WESL (wesl-plugin `?static` linker).
+- Tests: vitest (`npm test`). Typecheck: `npm run typecheck`. Shader linking is
+ verified by `npm run build`.
+- Project conventions (these override defaults): `type` aliases never `interface`;
+ one type per file under `src/@types//`; single-function utility files
+ named after the export; comments timeless + terse (no dates / PR refs); commit
+ messages end with a `Co-Authored-By` trailer only (never `--author`); stage
+ specific paths (never `git add -A`); work happens in the
+ `worktree-pick-disk-from-procedural` worktree.
+
+## For agentic workers
+
+**REQUIRED SUB-SKILL: `superpowers:test-driven-development`** — every task below is
+written as failing-test-first. When editing `.wesl`, also follow the
+`wesl-shaders` skill (no backticks in comments; `package::` import prefix; WGSL
+struct layout). Run bash sequentially (a permission denial cancels a parallel
+batch) and use Read/Grep tools rather than `sed`/`awk`/`grep` via Bash.
+
+### Contract reference (verified against current code)
+
+- `cloudSource` in `proceduralDiskSubsystem.ts` IS the numeric `SourceType`
+ (`Source.SDSS` = 1, etc. — `sources.ts:44`), so the pick source code is
+ `cloudSource` directly. `i` (the inner-loop catalog row index) is `localIdx`.
+- Packed id: `packSelection(sourceCode, localIdx)` returns a `u32`
+ (`src/data/selectionEncoding.ts:67`). The pick fragment writes
+ `packed + PICK_SENTINEL_OFFSET` (offset = 1).
+- WESL mirror: `lib/selectionEncoding.wesl` exports `fn packSelection` and
+ `const PICK_SENTINEL_OFFSET: u32 = 1u` — import these, don't open-code.
+- Procedural instance stride: 64 bytes / 16 floats
+ (`instancedQuadRenderer.ts` `FLOATS_PER_INSTANCE = 16`). Slots written by
+ `proceduralDiskRenderer.draw`: 0–3 posSize, 4 axisRatio, 5 positionAngleDeg,
+ **6 = 0, 7 = 0**, 8 colourIndex, 9 crossfadeAlpha, 10 procFadeOut, 11 = 0,
+ 12–15 = 0 (texturedDisk's hi-res slot). **Slot 6 is free** — we claim it for the
+ packed id.
+- `localIdx` can exceed 2^24 and is therefore NOT exactly representable as an
+ `f32`. The renderer must write the **u32 bits** into slot 6 via a `Uint32Array`
+ view over the same `ArrayBuffer` as the `Float32Array` pack buffer — NOT store
+ the integer as a float value. The shader reads it back with
+ `bitcast(instance.orientation.z)`.
+- `createInstancedQuadRenderer` builds ONE visual pipeline + instance buffer and
+ exposes only `draw` (plus optional atlas binders). It does NOT expose its
+ pipeline, instance buffer, or BGL to the consumer. The procedural renderer
+ therefore cannot reuse the factory's private instance buffer for a second
+ (pick) pipeline. **Decision:** `proceduralDiskRenderer` builds its OWN pick
+ pipeline + retains its OWN copy of the per-frame instance bytes/count (a small
+ second GPU buffer it owns), uploaded from the same packed `Float32Array` the
+ visual `draw` already produces. (Mirrors how `structureMarkerRenderer` owns its
+ instance buffer and feeds both visible + pick pipelines from it.)
+- `createPickRenderer` (`pickRenderer.ts:65`) already takes an optional trailing
+ `structureMarkerRenderer`; we add an analogous optional
+ `proceduralDiskRenderer`. Construction site: `phases/wireInput.ts:54`.
+
+---
+
+### Task 1 — Carry `sourceCode` / `localIdx` on the procedural instance
+
+**Files:**
+`src/@types/rendering/ProceduralDiskInstance.d.ts` (modify),
+`src/services/engine/subsystems/proceduralDiskSubsystem.ts` (modify),
+`tests/services/engine/subsystems/proceduralDiskSubsystem.test.ts` (modify).
+
+**Type contract** — append two readonly-style fields to `ProceduralDiskInstance`:
+
+```ts
+export type ProceduralDiskInstance = {
+ // ...existing fields...
+ /** Source code (numeric SourceType) of the galaxy this disk represents. */
+ sourceCode: number;
+ /** Per-source catalog row index — the localIdx half of the packed pick id. */
+ localIdx: number;
+};
+```
+
+`maybeEmitProceduralDisk` is the pure builder of the instance — extend its
+signature to accept `sourceCode` and `localIdx` and set them on the returned
+object. The subsystem inner loop passes `cloudSource` and `i`. The famous-WebP
+override site (`{ ...emitted, procFadeOut: ... }`) already spreads `emitted`, so
+the new fields flow through unchanged.
+
+- [x] Add a test `emits the (source, localIdx) identity for each instance`:
+ build a 4-row `makeDenseCloud` under `Source.SDSS` with `decimationFactor: 1`,
+ run one frame, and assert every emitted instance has `sourceCode === Source.SDSS`
+ and that the set of `localIdx` values equals `{0,1,2,3}`. (Note: the back-to-front
+ sort reorders instances, so assert on the set, not positional order.)
+- [x] Run it (`npm test -- proceduralDiskSubsystem`) — fails (fields absent).
+- [x] Extend `maybeEmitProceduralDisk` + the call site + the type.
+- [x] Update the existing `fakeProceduralInstance` factory in
+ `tests/services/gpu/renderers/proceduralDiskRenderer.test.ts` to include the two
+ new fields (so its `ProceduralDiskInstance` literal still type-checks).
+- [x] `npm test -- proceduralDiskSubsystem` → new + existing tests pass.
+- [x] `npm run typecheck` clean.
+- [x] Commit.
+
+---
+
+### Task 2 — Pack the u32 id into instance slot 6 + retain the buffer for picking
+
+**Files:**
+`src/services/gpu/renderers/proceduralDiskRenderer.ts` (modify),
+`tests/services/gpu/renderers/proceduralDiskRenderer.test.ts` (modify).
+
+**Packing contract:** in `draw`'s pack loop, slot 6 (`o + 6`) currently holds a
+literal `0`. Replace it with the packed pick id written as **u32 bits**, via a
+`Uint32Array` view aliasing the SAME `ArrayBuffer` as the `Float32Array packed`:
+
+- `const packed = new Float32Array(instances.length * FLOATS_PER_INSTANCE)`
+- `const packedU32 = new Uint32Array(packed.buffer)`
+- per instance: `packedU32[o + 6] = packSelection(ins.sourceCode, ins.localIdx)`
+
+Slot 6 is byte offset `6 * 4 = 24` within each 64-byte instance — i.e. the
+`.z` component of the location-1 `vec4` (`orientation`) the shader reads.
+
+**Retention contract:** the renderer must keep the last-uploaded instance bytes +
+count available to a pick pass. Since the visual pipeline + instance buffer are
+private to the `instancedQuadRenderer` factory, `proceduralDiskRenderer` allocates
+and owns a SECOND grow-on-demand GPU buffer (the "pick instance buffer"), writes
+the same `packed` bytes into it inside `draw`, and records the instance count.
+`pickDisks` (Task 4) draws from this owned buffer. Keep it byte-identical to the
+visual buffer so the shared vertex stage reads the same data.
+
+- [x] Add a test `pack writes the packed pick id into slot 6 as u32 bits`:
+ construct the renderer with the stub device, draw two instances with distinct
+ `sourceCode`/`localIdx` (e.g. `{sourceCode: 1, localIdx: 7}` and
+ `{sourceCode: 3, localIdx: 1_000_000}`), grab the instance `writeBuffer` payload,
+ reinterpret it as `Uint32Array` (via `new Uint32Array(payload.buffer)`), and
+ assert `u32[6] === packSelection(1, 7)` and `u32[16 + 6] === packSelection(3, 1_000_000)`.
+ Import `packSelection` from `src/data/selectionEncoding`.
+- [x] Note in the test that `1_000_000` proves the float-vs-bits distinction
+ matters (`1_000_000` IS f32-representable but a value like `0x07ffffff` would
+ not round-trip if stored as a float — keep the assertion on the exact bits).
+- [x] Run it — fails (slot 6 is zero).
+- [x] Implement the `Uint32Array`-view pack + the owned pick instance buffer.
+- [x] Keep the existing "slots 12..15 are zero pad" test green (slot 6 is no
+ longer asserted-zero there; if that test asserts slot 6 == 0, update it to the
+ new packed expectation rather than deleting the assertion).
+- [x] `npm test -- proceduralDiskRenderer` → green.
+- [x] Commit.
+
+---
+
+### Task 3 — Procedural pick shader (VsOut.pickId + bitcast + pickFragment.wesl)
+
+**Files:**
+`src/services/gpu/shaders/proceduralDisks/io.wesl` (modify),
+`src/services/gpu/shaders/proceduralDisks/vertex.wesl` (modify),
+`src/services/gpu/shaders/proceduralDisks/pickFragment.wesl` (new).
+
+**io.wesl** — add a flat `pickId` to `VsOut`:
+
+```wgsl
+@location(5) @interpolate(flat) pickId: u32,
+```
+
+(Locations 0–4 are taken; use 5. The visual `fs` declares fewer inputs, so the
+WGSL linker drops the unused varying on the visual pipeline.)
+
+**vertex.wesl** — slot 6 is `instance.orientation.z`. Set the varying:
+
+```wgsl
+out.pickId = bitcast(instance.orientation.z);
+```
+
+Import `packSelection` is NOT needed here (the id was packed CPU-side). No new
+`worldToClip` calls — this is a single bitcast on a stage that already runs for
+only a few resolved disks per frame, so adding the varying is acceptable (the perf
+concern is the POINT stage, not this one).
+
+**pickFragment.wesl** (new) — discards outside the unit-circle ellipse (matching
+the visual fragment's `length(in.uv) > 1.0`, with NO forgiveness margin so the
+pick edge is the disk edge), then writes the offset packed id:
+
+- imports: `package::proceduralDisks::io::VsOut`,
+ `package::lib::selectionEncoding::PICK_SENTINEL_OFFSET`.
+- body contract: `if (length(in.uv) > 1.0) { discard; }` then
+ `return vec4(in.pickId + PICK_SENTINEL_OFFSET, 0u, 0u, 0u);`
+- entry point name: `fsPick` (match the pick-fragment naming the renderer will
+ reference in Task 4).
+- Declare NO bindings (the fragment reads only `VsOut` fields) — same rationale as
+ the existing `proceduralDisks/fragment.wesl` header.
+
+- [x] Add the three shader edits / file.
+- [x] `npm run build` → `tsc --noEmit` + vite build succeed (the wesl-plugin
+ links `pickFragment.wesl`; a parse error or unresolved `package::` import fails
+ here). This is the acceptance gate for the task — there is no runtime test for
+ shader source.
+- [x] Commit.
+
+---
+
+### Task 4 — Procedural pick pipeline + `pickDisks(pass)`, wired into the pick pass
+
+**Files:**
+`src/@types/rendering/ProceduralDiskRenderer.d.ts` (modify),
+`src/services/gpu/renderers/proceduralDiskRenderer.ts` (modify),
+`src/services/gpu/renderers/pickRenderer.ts` (modify),
+`src/services/engine/phases/wireInput.ts` (modify),
+`tests/services/gpu/renderers/pickRenderer.poi.test.ts` (modify or add sibling).
+
+**Renderer API contract** — add to `ProceduralDiskRenderer`:
+
+```ts
+/**
+ * Draw the retained procedural-disk instances into the active pick
+ * render pass using the r32uint pick pipeline. No-op until `draw` has
+ * uploaded at least one instance this frame. Caller (pickRenderer) has
+ * already bound @group(0) camera + @group(1) focus state.
+ */
+pickDisks(pass: GPURenderPassEncoder): void;
+```
+
+**Pick pipeline contract** (built inside `createProceduralDiskRenderer` against a
+SEPARATE `GPUShaderModule` pair — vertex from `proceduralDisks/vertex.wesl`,
+fragment from the new `pickFragment.wesl`):
+
+- Reuse the SAME explicit pipeline layout the visual pipeline uses
+ (`[bindGroupLayout(@group0 uniforms), focusBgl(@group1)]`) so the caller's bound
+ groups are layout-compatible. The factory builds those layouts privately, so
+ the renderer needs access to them — pass the procedural renderer the
+ `focusBgl` it already receives in `Init`, and build a matching `@group(0)`
+ uniform BGL + uniform buffer locally for the pick pipeline (the pick pipeline
+ needs the camera `viewProj` to project the quad, same as the visual one).
+ *Sequence the implementation to read the visual pipeline's binding numbers
+ first and mirror them exactly; if the visual layout cannot be reused cleanly,
+ STOP and report rather than inventing a divergent layout.*
+- Fragment target `{ format: 'r32uint' }`, no blend.
+- `depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' }`
+ — same as the galaxy + ring pick pipelines, so it shares depth with them inside
+ the one pick pass (front-most wins).
+- `pickDisks` writes the pick uniform buffer (viewProj/viewport/camPos/pxPerRad —
+ the same values `draw` was last given; cache them in `draw`), sets the pick
+ pipeline + the camera bind group + the focus bind group the caller passes, binds
+ the owned pick instance buffer, and emits `draw(6, count)`. If count is 0, no-op.
+
+**Wiring contract** — `pickRenderer.ts`:
+
+- Add an optional trailing `proceduralDiskRenderer?: ProceduralDiskRenderer`
+ parameter to `createPickRenderer` (AFTER the existing optional
+ `structureMarkerRenderer` — appending keeps the existing positional contract the
+ `pickRenderer.poi.test.ts` type test pins).
+- In `recordPickPass`, after the `structureMarkerRenderer?.pickRing(pass)` call and
+ before `pass.end()`, add `proceduralDiskRenderer?.pickDisks(pass)`.
+ Document inline: shared depth means a closer point dot or disk claims the pixel;
+ the disk and its companion point carry the SAME packed id, so overlap is
+ harmless.
+- `wireInput.ts:54` passes `state.gpu.proceduralDiskRenderer ?? undefined` as the
+ new trailing arg.
+
+**Test contract** (mirror the type-level style of `pickRenderer.poi.test.ts`,
+which can't stand up a live `GPUDevice`):
+
+- [x] Add a test asserting `createPickRenderer`'s 8th positional
+ (`proceduralDiskRenderer`, index 7) exists and is assignable from `undefined`
+ (optional), in the same `Parameters` style as the
+ existing POI test. This pins the append-not-reorder contract.
+- [x] Add a renderer-level unit test (stub-device style from
+ `proceduralDiskRenderer.test.ts`): after a `draw` with N instances, calling
+ `pickDisks(stubPass)` issues `stubPass.draw` with vertex count 6 and instance
+ count N; and calling `pickDisks` on a fresh renderer (no prior `draw`) is a
+ no-op (no `setPipeline`/`draw`).
+- [x] Run the new tests — fail.
+- [x] Implement the pipeline + method + wiring + type field.
+- [x] `npm test -- proceduralDiskRenderer pickRenderer` and `npm run typecheck`
+ → green. `npm run build` → links.
+- [x] Commit.
+
+---
+
+### Task 5 — Simplify the point pick to a plain dot
+
+**Files:**
+`src/services/gpu/shaders/points/vertex.wesl` (modify),
+`src/services/gpu/shaders/points/io.wesl` (modify),
+`src/services/gpu/shaders/points/pickFragment.wesl` (modify),
+`src/@types/settings/EngineSettingsState.d.ts` (modify),
+`src/services/gpu/renderers/pickRenderer.ts` (doc comment only).
+
+**vertex.wesl:**
+
+- In the pick pass, clamp the billboard to the dot floor. Currently
+ `let sizePx = max(u.pointSizePx, apparentPxRadius);` (line ~152). The pick pass
+ must NOT inflate to the apparent-size circle. Contract: when `u.pickPass == 1u`,
+ use `u.pointSizePx` as the half-extent; otherwise keep
+ `max(u.pointSizePx, apparentPxRadius)`. (The procedural pass now owns
+ resolved-disk picking, so the point pass only needs to claim a small dot.)
+- Delete the entire `if (inPickPass && isDiskHandoff) { ... } else { ... }` block
+ (lines ~292–324) that computes `pickMajorBillboard` / `pickMinorBillboard`.
+- Delete the `isDiskHandoff` local + `out.isDiskHandoff = ...` write (lines
+ ~283–284).
+- Remove the now-unused `import package::lib::orientation::diskAxes;` (line 38) —
+ verify no other reference to `diskAxes` remains in this file after the deletions
+ (grep confirms the only uses are the deleted block).
+- Leave the `inPickPass` local in place — it is still used by `crossfadeOut`, the
+ invisibility cull, and the focus pick-exclusion.
+
+**io.wesl** — remove from `VSOut` the three fields no fragment reads anymore:
+`isDiskHandoff` (`@location(4)`), `pickMajorBillboard` (`@location(5)`),
+`pickMinorBillboard` (`@location(6)`). After removal, `paRotation` stays at
+`@location(3)` (the visual fragment uses it); no renumbering of the remaining
+locations is required.
+
+**pickFragment.wesl** — replace the whole `isDiskHandoff` branch + the
+`r2 > 2.25` (1.5x forgiveness) test with a single plain circle test:
+
+```wgsl
+if (dot(in.uv, in.uv) > 1.0) { discard; }
+```
+
+Then keep the existing
+`return vec4(in.instanceIdx + PICK_SENTINEL_OFFSET, 0u, 0u, 0u);`. Update the
+file header comment to describe the plain-dot behaviour (drop the disk-handoff /
+forgiveness-ellipse narrative).
+
+**EngineSettingsState.d.ts** — the `showPickBuffer` doc block (lines ~163–168)
+mentions "the 1.5x forgiveness ellipse/circle baked into pickFragment.wesl". Update
+it to describe the new behaviour: the point pass picks a `pointSizePx`-clamped dot,
+and resolved galaxy disks are picked by the procedural-disk pass at the disk edge.
+
+**pickRenderer.ts** — the module header (lines ~17–19) says "`pickFragment.wesl`'s
+1.5x forgiveness radius makes each pick billboard a bit larger than its visible
+disk." Replace with a note that the point pick is a plain dot clamped to the size
+floor and resolved disks are picked by the procedural pass.
+
+**Tests:**
+
+- [x] Search `tests/` for any assertion referencing `isDiskHandoff`,
+ `pickMajorBillboard`, `pickMinorBillboard`, or the `2.25` forgiveness constant.
+ If present, update those tests to the plain-dot contract; if absent, note that
+ point-pick shape is verified visually (no unit test stands up the GPU pick
+ pass). The acceptance is `npm run build` (shader links) + `npm test` green.
+- [x] `npm run build` → links (the removed varyings must be gone from BOTH the
+ vertex output and every fragment input, or the WGSL compiler errors).
+- [x] `npm test` → full suite green.
+- [x] Final behaviour is visual: with the pick-debug overlay
+ (`debug.showPickBuffer`) on, the picked region for a resolved galaxy matches the
+ disk-radius ring (`debug.showDiskRadiusRing`). Confirmed by the user in the
+ running dev server — pick region hugs the ring in size and tilt.
+- [x] Commit.
+
+---
+
+### Final task — Entanglement-radar pass over the diff
+
+**Files:** none (review only).
+
+The project bakes a simplicity review into every plan. The de-complecting claim is
+**"the point pass picks dots; the procedural pass picks disks"** — confirm the diff
+delivers it without introducing a new knot.
+
+- [x] Run the `entanglement-radar` skill over the full diff of this branch.
+- [x] Specifically verify: no shader branches on a galaxy's LOD regime in the pick
+ path anymore; the packed-id slot choice (slot 6) is documented at both the pack
+ site and the shader read; the procedural renderer's owned pick instance buffer is
+ not a stale mirror of the factory's visual buffer (it is re-uploaded every frame
+ from the same `packed` bytes, so there is no second source of truth).
+- [x] Address any knot it names, or record why it's acceptable, in the review
+ output (no new file — report inline). Radar verdict: de-complecting holds (no
+ regime branches in the pick path; pick buffer is fresh-not-mirror; cached camera
+ is legitimate same-frame replay). One small finding — slot-6 pack site lacked a
+ shader cross-reference — fixed in 3c19576c.
+
+---
+
+## Out of scope (follow-up, not a task here)
+
+Famous galaxies with a loaded curated WebP render via the **textured** disk with
+**calibrated** tilt/size (`texturedDiskSubsystem.ts` uses `effectiveTilt` +
+`calibratedDiskSizeWorld`), and their ring uses `effectiveTilt` too. Matching
+THEIR ring exactly needs the same pick fragment on the **textured-disk** pass.
+This plan lands procedural-disk picking first — it covers every non-curated galaxy
+(the reported "all galaxies" case). Textured-disk picking is a fast follow once
+this is green.
diff --git a/src/@types/rendering/ProceduralDiskInstance.d.ts b/src/@types/rendering/ProceduralDiskInstance.d.ts
index 34909b33..e80d63ab 100644
--- a/src/@types/rendering/ProceduralDiskInstance.d.ts
+++ b/src/@types/rendering/ProceduralDiskInstance.d.ts
@@ -1,3 +1,5 @@
+import type { SourceType } from '../data/SourceType';
+
/**
* ProceduralDiskInstance — one entry in the procedural-disk pass's
* per-instance vertex buffer. Mirrors the texture-based `DiskInstance`
@@ -66,4 +68,12 @@ export type ProceduralDiskInstance = {
* docblock above. Default 1.0 (no fade-out).
*/
procFadeOut: number;
+ /**
+ * Source + row identity of the galaxy this disk represents. CPU-only:
+ * these two fields are not in the nine-float vertex layout above — a
+ * later step packs them into a dedicated pick vertex slot.
+ */
+ sourceCode: SourceType;
+ /** Per-source catalog row index — the localIdx half of the packed pick id. */
+ localIdx: number;
};
diff --git a/src/@types/rendering/ProceduralDiskRenderer.d.ts b/src/@types/rendering/ProceduralDiskRenderer.d.ts
index c79d4c31..ce297a88 100644
--- a/src/@types/rendering/ProceduralDiskRenderer.d.ts
+++ b/src/@types/rendering/ProceduralDiskRenderer.d.ts
@@ -27,6 +27,13 @@ export type ProceduralDiskRenderer = {
focusBindGroup: GPUBindGroup,
instances: ReadonlyArray,
): void;
+ /**
+ * Draw the retained procedural-disk instances into the active pick
+ * render pass using the r32uint pick pipeline. No-op until 'draw' has
+ * uploaded at least one instance this frame. Caller (pickRenderer) has
+ * already bound the shared camera + focus state on the pick pass.
+ */
+ pickDisks(pass: GPURenderPassEncoder): void;
/** Release the uniform + per-instance vertex buffers. */
destroy(): void;
};
diff --git a/src/@types/settings/EngineSettingsState.d.ts b/src/@types/settings/EngineSettingsState.d.ts
index 966feb4d..25f9c095 100644
--- a/src/@types/settings/EngineSettingsState.d.ts
+++ b/src/@types/settings/EngineSettingsState.d.ts
@@ -163,9 +163,9 @@ export type EngineSettingsState = {
* - `showPickBuffer` — colour-maps the r32uint pick texture and
* composites it over the tone-mapped frame. Lets a developer
* see which billboard the hover/click resolver actually claims
- * at each pixel (including the +PICK_PADDING_PX boost and the
- * 1.5× forgiveness ellipse/circle baked into pickFragment.wesl).
- * Gated behind the DebugPanel.
+ * at each pixel. Point billboards show a `pointSizePx`-clamped
+ * dot; resolved galaxy disks are picked by the procedural-disk
+ * pass at the disk edge. Gated behind the DebugPanel.
* - `showDiskRadiusRing` — outlines each famous-galaxy thumbnail's
* disk-radius footprint so the developer can calibrate the
* placement against the underlying billboard. Gated behind the
diff --git a/src/services/engine/phases/wireInput.ts b/src/services/engine/phases/wireInput.ts
index cb61b59a..d0cc5193 100644
--- a/src/services/engine/phases/wireInput.ts
+++ b/src/services/engine/phases/wireInput.ts
@@ -61,6 +61,7 @@ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promis
// of a focused structure from hit-testing (vertex shader culls them).
state.gpu.focusUniform!.bindGroup,
state.gpu.structureMarkerRenderer ?? undefined,
+ state.gpu.proceduralDiskRenderer ?? undefined,
);
state.gpu.pickRenderer = pickRenderer;
// The resolver hands back the freshly-decoded `(source, localIdx)`
diff --git a/src/services/engine/subsystems/proceduralDiskSubsystem.ts b/src/services/engine/subsystems/proceduralDiskSubsystem.ts
index 1d18dc3e..43cf0a43 100644
--- a/src/services/engine/subsystems/proceduralDiskSubsystem.ts
+++ b/src/services/engine/subsystems/proceduralDiskSubsystem.ts
@@ -74,6 +74,8 @@ export function maybeEmitProceduralDisk(
colourIndex: number,
fadeStartPx: number,
fadeEndPx: number,
+ sourceCode: SourceType,
+ localIdx: number,
): ProceduralDiskInstance | null {
if (px <= fadeStartPx) return null;
if (!Number.isFinite(ar) || !Number.isFinite(pa)) return null;
@@ -89,6 +91,8 @@ export function maybeEmitProceduralDisk(
colourIndex,
crossfadeAlpha,
procFadeOut: 1.0,
+ sourceCode,
+ localIdx,
};
}
@@ -213,6 +217,8 @@ export function createProceduralDiskSubsystem(
colourIndex,
PROCEDURAL_DISK_FADE_START_PX,
PROCEDURAL_DISK_FADE_END_PX,
+ cloudSource,
+ i,
);
if (emitted) {
// Famous-WebP crossfade-OUT. For Famous-source galaxies
diff --git a/src/services/gpu/renderers/pickRenderer.ts b/src/services/gpu/renderers/pickRenderer.ts
index f96492de..ef8fd65a 100644
--- a/src/services/gpu/renderers/pickRenderer.ts
+++ b/src/services/gpu/renderers/pickRenderer.ts
@@ -14,8 +14,8 @@
* The pick pipeline reads `PointRenderer`'s vertex + uniform buffers
* directly — callers must run the visual pass first so this frame's
* viewProj/viewport/etc are already written when `pick()` fires.
- * `pickFragment.wesl`'s 1.5× forgiveness radius makes each pick
- * billboard a bit larger than its visible disk.
+ * Point billboards pick a `pointSizePx`-clamped dot; resolved galaxy
+ * disks are picked by the procedural-disk pass at the disk edge.
*
* @module
*/
@@ -34,6 +34,7 @@ import type { FadeUniformsBgl } from '../../../@types/rendering/FadeUniformsBgl'
import type { SourceUniformsBgl } from '../../../@types/rendering/SourceUniformsBgl';
import type { FocusUniformsBgl } from '../../../@types/rendering/FocusUniformsBgl';
import type { StructureMarkerRenderer } from '../../../@types/rendering/StructureMarkerRenderer';
+import type { ProceduralDiskRenderer } from '../../../@types/rendering/ProceduralDiskRenderer';
import {
POINT_STRIDE,
POINT_VERTEX_ATTRIBUTES,
@@ -81,6 +82,12 @@ export function createPickRenderer(
// Optional so tests can construct the picker in isolation; passing
// `undefined` yields a galaxy-only pick pass.
structureMarkerRenderer?: StructureMarkerRenderer,
+ // Optional procedural-disk pick provider. When present, the pick
+ // pass calls `proceduralDiskRenderer.pickDisks(pass)` so resolved
+ // galaxies (in the 8 px+ band) are pickable via their disk surface
+ // rather than only their companion point billboard. Optional so
+ // tests and pre-init paths can omit it.
+ proceduralDiskRenderer?: ProceduralDiskRenderer,
): PickRenderer {
const vsModule = createShaderModuleWithDevLog(device, vsCode, 'pick.vertex');
const fsModule = createShaderModuleWithDevLog(device, pickFsCode, 'pick.pickFragment');
@@ -337,6 +344,13 @@ export function createPickRenderer(
structureMarkerRenderer.pickRing(pass);
}
+ // Procedural-disk pick: shared depth means a closer point dot or
+ // disk claims the pixel; the disk and its companion point carry the
+ // SAME packed id, so overlap is harmless.
+ if (proceduralDiskRenderer) {
+ proceduralDiskRenderer.pickDisks(pass);
+ }
+
pass.end();
return pt;
}
diff --git a/src/services/gpu/renderers/proceduralDiskRenderer.ts b/src/services/gpu/renderers/proceduralDiskRenderer.ts
index 519338b9..1a63f1d3 100644
--- a/src/services/gpu/renderers/proceduralDiskRenderer.ts
+++ b/src/services/gpu/renderers/proceduralDiskRenderer.ts
@@ -37,12 +37,15 @@
import vsCode from '../shaders/proceduralDisks/vertex.wesl?static';
import fsCode from '../shaders/proceduralDisks/fragment.wesl?static';
+import pickFsCode from '../shaders/proceduralDisks/pickFragment.wesl?static';
import type { ProceduralDiskInstance } from '../../../@types/rendering/ProceduralDiskInstance';
import type { ProceduralDiskRenderer } from '../../../@types/rendering/ProceduralDiskRenderer';
import type { Renderer } from '../../../@types/rendering/Renderer';
import type { Vec3 } from '../../../@types/math/Vec3';
import type { FocusUniformsBgl } from '../../../@types/rendering/FocusUniformsBgl';
-import { FLOATS_PER_INSTANCE, createInstancedQuadRenderer } from './instancedQuadRenderer';
+import { FLOATS_PER_INSTANCE, BYTES_PER_INSTANCE, UNIFORM_BYTES, createInstancedQuadRenderer } from './instancedQuadRenderer';
+import { packSelection } from '../../../data/selectionEncoding';
+import { createShaderModuleWithDevLog } from '../shaderCompileLogger';
type Init = {
device: GPUDevice;
@@ -71,6 +74,130 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer
uniformVisibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
});
+ // ── Pick pipeline ────────────────────────────────────────────────────
+ //
+ // The visual pipeline is owned by the instancedQuadRenderer factory and
+ // its BGL is NOT exposed. We own a SECOND pipeline (and its own camera
+ // BGL + uniform buffer) for the pick pass — same "own-everything"
+ // pattern as structureMarkerRenderer.
+ //
+ // Pipeline layout:
+ // @group(0) — own camera uniform (viewProj + viewport + camPos +
+ // pxPerRad), same 96-byte shape as the visual pipeline's
+ // @group(0). Visibility VERTEX | FRAGMENT matches the
+ // `uniformVisibility` flag passed to the visual pipeline
+ // above (see the module-header rationale for widening to
+ // FRAGMENT even though the vertex stage is the only reader).
+ // @group(1) — focusBgl (shared with the visual pipeline; identical
+ // layout identity).
+ //
+ // The vertex source is the SAME 'vertex.wesl' the visual pipeline uses
+ // (same @group declarations), but compiled into a SEPARATE
+ // GPUShaderModule so `layout:'auto'` isolation is never an issue. The
+ // fragment source is the new 'pickFragment.wesl' (entry 'fsPick').
+ const pickCameraBgl = init.device.createBindGroupLayout({
+ label: 'proceduralDisks-pick-camera-bgl',
+ entries: [
+ {
+ binding: 0,
+ // VERTEX | FRAGMENT mirrors the visual pipeline's uniformVisibility
+ // setting — keeps BGL identity stable if anyone ever compares the
+ // two for compatibility. The pick fragment doesn't read 'u', but
+ // the declaration must match what the vertex module declares.
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
+ buffer: { type: 'uniform' },
+ },
+ ],
+ });
+
+ const pickUniformBuffer = init.device.createBuffer({
+ label: 'proceduralDisks-pick-uniforms',
+ size: UNIFORM_BYTES,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+
+ const pickUniformBindGroup = init.device.createBindGroup({
+ label: 'proceduralDisks-pick-camera-bg',
+ layout: pickCameraBgl,
+ entries: [{ binding: 0, resource: { buffer: pickUniformBuffer } }],
+ });
+
+ const pickVsModule = createShaderModuleWithDevLog(
+ init.device,
+ vsCode,
+ 'proceduralDisks.pick.vs',
+ );
+ const pickFsModule = createShaderModuleWithDevLog(
+ init.device,
+ pickFsCode,
+ 'proceduralDisks.pick.fs',
+ );
+
+ const pickPipeline = init.device.createRenderPipeline({
+ label: 'proceduralDisks-pick-pipeline',
+ layout: init.device.createPipelineLayout({
+ label: 'proceduralDisks-pick-pipeline-layout',
+ bindGroupLayouts: [pickCameraBgl, init.focusBgl],
+ }),
+ vertex: {
+ module: pickVsModule,
+ entryPoint: 'vs',
+ // Exact same 64-byte / 16-float instance layout the visual pipeline
+ // uses. The pick pipeline reads the same per-instance data from the
+ // owned pick instance buffer.
+ buffers: [
+ {
+ arrayStride: BYTES_PER_INSTANCE,
+ stepMode: 'instance',
+ attributes: [
+ { shaderLocation: 0, offset: 0, format: 'float32x4' },
+ { shaderLocation: 1, offset: 16, format: 'float32x4' },
+ { shaderLocation: 2, offset: 32, format: 'float32x4' },
+ { shaderLocation: 3, offset: 48, format: 'float32x4' },
+ ],
+ },
+ ],
+ },
+ fragment: {
+ module: pickFsModule,
+ entryPoint: 'fsPick',
+ // r32uint: no blend — integer formats don't support blending.
+ targets: [{ format: 'r32uint' }],
+ },
+ primitive: { topology: 'triangle-list' },
+ // Depth test matches the galaxy and ring pick pipelines: front-most
+ // wins, depth write enabled. The depth attachment is shared across
+ // all pick draws in the same pass encoder.
+ depthStencil: {
+ format: 'depth24plus',
+ depthWriteEnabled: true,
+ depthCompare: 'less',
+ },
+ });
+
+ // Reusable pick uniform scratch — same shape as the visual pipeline's
+ // uniformScratch in instancedQuadRenderer.
+ const pickUniformScratch = new Float32Array(UNIFORM_BYTES / 4);
+
+ // Pick instance buffer — owned by this renderer, separate from the
+ // visual instance buffer inside 'inner' (which is private to the
+ // factory). Unlike structureMarkerRenderer, which rebinds one shared
+ // buffer across its visible and pick pipelines, this renderer must
+ // allocate a second, byte-identical buffer — the factory gives us no
+ // other handle.
+ let pickInstanceBuffer: GPUBuffer | null = null;
+ let pickInstanceBufferCapacity = 0; // measured in instances
+ let lastPickInstanceCount = 0;
+
+ // Cached camera values from the last draw() call. The pick pass runs
+ // AFTER draw() in the same frame, so these are always the current
+ // frame's camera state when pickDisks() is called.
+ let cachedViewProj: Float32Array = new Float32Array(16);
+ let cachedViewport: [number, number] = [0, 0];
+ let cachedCamPosWorld: Readonly = [0, 0, 0];
+ let cachedPxPerRad = 0;
+ let cachedFocusBindGroup: GPUBindGroup | null = null;
+
function draw(
pass: GPURenderPassEncoder,
viewProj: Float32Array,
@@ -80,12 +207,26 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer
focusBindGroup: GPUBindGroup,
instances: ReadonlyArray,
): void {
- if (instances.length === 0) return;
+ if (instances.length === 0) {
+ // Zero the cached count so pickDisks() no-ops — it replays
+ // lastPickInstanceCount from the previous frame, so a frame that
+ // drops to 0 disks must clear it or pickDisks() would re-draw the
+ // prior frame's disks into the pick texture.
+ lastPickInstanceCount = 0;
+ return;
+ }
// Fresh allocation per frame. The typical-frame size for the
// procedural pass is a few KB; GC churn isn't load-bearing today.
// A reusable scratch buffer can be added if profiling flags it.
const packed = new Float32Array(instances.length * FLOATS_PER_INSTANCE);
+ // Alias the same ArrayBuffer as u32 so we can write pick ids into
+ // float slots without float-precision loss. localIdx can exceed 2^24,
+ // which isn't exactly representable as f32; writing the raw u32 bits
+ // preserves all 27 localIdx bits. The shader reads them back with
+ // bitcast. The alternative — storing localIdx as a plain f32 —
+ // would silently corrupt ids above ~16 M.
+ const packedU32 = new Uint32Array(packed.buffer);
for (let i = 0; i < instances.length; i++) {
const o = i * FLOATS_PER_INSTANCE;
const ins = instances[i]!;
@@ -95,7 +236,9 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer
packed[o + 3] = ins.sizeWorldMpc;
packed[o + 4] = ins.axisRatio;
packed[o + 5] = ins.positionAngleDeg;
- packed[o + 6] = 0;
+ // Slot 6 is the shader's 'orientation.z' (see proceduralDisks/io.wesl) —
+ // the pick pass bitcasts it back to the packed (source, localIdx) id.
+ packedU32[o + 6] = packSelection(ins.sourceCode, ins.localIdx);
packed[o + 7] = 0;
packed[o + 8] = ins.colourIndex;
packed[o + 9] = ins.crossfadeAlpha;
@@ -122,12 +265,83 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer
pxPerRad,
focusBindGroup,
});
+
+ // Cache camera state for pickDisks() — the pick pass runs after this
+ // draw() in the same frame; caching here avoids re-passing arguments.
+ cachedViewProj = viewProj;
+ cachedViewport = viewport;
+ cachedCamPosWorld = camPosWorld;
+ cachedPxPerRad = pxPerRad;
+ cachedFocusBindGroup = focusBindGroup;
+
+ // ── Pick instance buffer (mirror of the visual upload) ─────────────
+ //
+ // We own a second GPU buffer holding the same byte-identical packed
+ // data. The pickDisks() method binds this to the pick pipeline's
+ // vertex slot. Separate from the visual buffer because the factory
+ // keeps its instance buffer private.
+ //
+ // Why VERTEX | COPY_DST: instance buffers consumed by a draw call
+ // must carry VERTEX; COPY_DST is required by writeBuffer. Mirrors
+ // the usage flags on the visual instance buffer inside
+ // instancedQuadRenderer (see its grow block).
+ if (pickInstanceBuffer === null || pickInstanceBufferCapacity < instances.length) {
+ pickInstanceBuffer?.destroy();
+ pickInstanceBuffer = init.device.createBuffer({
+ label: 'proceduralDisks-pick-instances',
+ size: instances.length * BYTES_PER_INSTANCE,
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
+ });
+ pickInstanceBufferCapacity = instances.length;
+ }
+ init.device.queue.writeBuffer(pickInstanceBuffer, 0, packed);
+ lastPickInstanceCount = instances.length; // consumed by pickDisks() to issue the instanced draw
+ }
+
+ function pickDisks(pass: GPURenderPassEncoder): void {
+ // No-op until draw() has uploaded at least one instance this frame
+ // (so we have live camera values + a populated pick instance buffer).
+ if (lastPickInstanceCount === 0 || pickInstanceBuffer === null) return;
+ if (cachedFocusBindGroup === null) return;
+
+ // Write the pick uniform buffer with this frame's camera values.
+ // Same 96-byte layout as the visual pipeline's uniformScratch:
+ // f32[ 0..15] viewProj f32[16..17] viewport f32[18..19] reserved
+ // f32[20..22] camPosWorld f32[23] pxPerRad
+ pickUniformScratch.set(cachedViewProj, 0);
+ pickUniformScratch[16] = cachedViewport[0];
+ pickUniformScratch[17] = cachedViewport[1];
+ pickUniformScratch[18] = 0;
+ pickUniformScratch[19] = 0;
+ pickUniformScratch[20] = cachedCamPosWorld[0];
+ pickUniformScratch[21] = cachedCamPosWorld[1];
+ pickUniformScratch[22] = cachedCamPosWorld[2];
+ pickUniformScratch[23] = cachedPxPerRad;
+ init.device.queue.writeBuffer(pickUniformBuffer, 0, pickUniformScratch);
+
+ pass.setPipeline(pickPipeline);
+ // @group(0): own pick camera uniforms (viewProj / viewport / camPos / pxPerRad).
+ pass.setBindGroup(0, pickUniformBindGroup);
+ // @group(1): shared focus uniform. Shared depth state means a closer
+ // point dot or disk claims the pixel; the disk and its companion point
+ // carry the SAME packed id, so overlap is harmless.
+ pass.setBindGroup(1, cachedFocusBindGroup);
+ pass.setVertexBuffer(0, pickInstanceBuffer);
+ pass.draw(6, lastPickInstanceCount);
+ }
+
+ function destroy(): void {
+ inner.destroy();
+ pickUniformBuffer.destroy();
+ pickInstanceBuffer?.destroy();
+ pickInstanceBuffer = null;
}
const renderer: ProceduralDiskRenderer = {
label: 'proceduralDiskRenderer',
draw,
- destroy: inner.destroy,
+ pickDisks,
+ destroy,
};
// 'satisfies Renderer' confirms the shared label+destroy contract
// without widening the static type seen by consumers.
diff --git a/src/services/gpu/shaders/points/io.wesl b/src/services/gpu/shaders/points/io.wesl
index 057edbbe..367f4931 100644
--- a/src/services/gpu/shaders/points/io.wesl
+++ b/src/services/gpu/shaders/points/io.wesl
@@ -258,7 +258,7 @@ struct PerVertex {
// every triangle of every billboard. WGSL permits a fragment shader to
// declare FEWER inputs than the vertex shader outputs (unused fields
// are silently dropped by the linker), so 'fsPick' only reads
-// 'in.uv' + 'in.instanceIdx' even though the struct carries far more.
+// 'in.uv' + 'in.instanceIdx'.
struct VSOut {
// Clip-space position, used by the rasteriser for perspective divide
@@ -290,27 +290,4 @@ struct VSOut {
// once per primitive in 'vs' instead of per fragment, saving millions
// of trig calls per frame. Packed into a vec2 at one location.
@location(3) @interpolate(flat) paRotation: vec2,
-
- // 1u once 'apparentDiameterPx >= u.pxFadeStart' — the sprite has
- // entered the procedural-disk handoff band and the visual disk has
- // started rendering on top. pickFragment branches on this: 0u →
- // generous circular mask for faint far-field dots; 1u → projected
- // ellipse matching the visual disk, so the pick area follows the
- // disk shape and doesn't swallow clicks for galaxies behind it.
- // Consumed only by pickFragment; visual fs declares no input here
- // and the WGSL linker drops the unused varying.
- @location(4) @interpolate(flat) isDiskHandoff: u32,
-
- // Projected disk basis in billboard-relative coordinates, written
- // by the vertex stage when 'inPickPass && isDiskHandoff'. Each is
- // the screen-space delta from the billboard centre to the outer
- // edge along the world-space major / minor axis (built via
- // 'lib/orientation::diskAxes'), divided by the billboard's
- // half-extent in pixels. pickFragment builds M = [major|minor],
- // inverts it, and applies to 'uv' to test the ellipse — matches
- // the procedural-disk shape on screen for any camera pose.
- // Zero-filled when the regime doesn't apply (visual pass, or
- // far-field dot pick); the fragment doesn't read them in that case.
- @location(5) @interpolate(flat) pickMajorBillboard: vec2,
- @location(6) @interpolate(flat) pickMinorBillboard: vec2,
};
diff --git a/src/services/gpu/shaders/points/pickFragment.wesl b/src/services/gpu/shaders/points/pickFragment.wesl
index 0f24fba5..a090aa2e 100644
--- a/src/services/gpu/shaders/points/pickFragment.wesl
+++ b/src/services/gpu/shaders/points/pickFragment.wesl
@@ -9,19 +9,22 @@
//
// ## What the pick pass does
//
-// We re-render the scene into a tiny offscreen 'r32uint' texture where
-// each fragment writes the *1-based* packed identity of the billboard
-// covering it. The JS side reads back a single pixel from this texture
-// under the cursor and decodes the packed value into '(source, localIdx)'.
+// Re-renders the scene into a tiny offscreen 'r32uint' texture where
+// each fragment writes the packed identity of the billboard covering it.
+// JS reads back the texel under the cursor and decodes it; 0 is the
+// cleared-background sentinel.
//
-// Why offset by 1: the texture is cleared to 0 before the pass. If we
-// wrote 'instanceIdx' directly, instance 0 with sourceCode 0 would be
-// indistinguishable from cleared background. Adding 1 keeps 0 as the
-// unambiguous 'no hit' sentinel.
+// ## Why this fragment is a plain dot
+//
+// The point pick is clamped to 'u.pointSizePx' (the dot floor) in the
+// vertex stage — the procedural-disk pass now owns resolved-galaxy
+// picking at the disk edge, so inflating the billboard to the apparent-
+// size circle would create a large invisible click area on top of the
+// disk. A plain unit-circle test is correct and cheap.
//
// ## Why this fragment declares NO bindings
//
-// 'fsPick' only reads VSOut fields (in.uv + in.instanceIdx) — no
+// 'fsPick' only reads VSOut fields ('in.uv' + 'in.instanceIdx') — no
// uniforms touched here. The SHARED vertex stage in 'vertex.wesl'
// declares '@group(0)' (per-frame Uniforms) and '@group(2)' (per-
// source SourceUniforms); the pick pipeline binds those plus a
@@ -38,39 +41,11 @@ import package::lib::selectionEncoding::PICK_SENTINEL_OFFSET;
// 'r32uint' render target declared on the JS side. No blend state on
// the pipeline because integer formats can't be blended; depth test
// resolves overlapping points instead.
-//
-// Mask choice is set by 'in.isDiskHandoff' (see io.wesl::VSOut):
-// - 0u: circular mask in raw UV (far-field dot regime).
-// - 1u: projected ellipse from the vertex stage's world-space disk
-// basis ('pickMajorBillboard' / 'pickMinorBillboard'), matching
-// the procedural-disk renderer's shape on screen for any camera
-// pose. An earlier screen-aligned PA rotation gave the wrong
-// orientation whenever the camera wasn't on the Earth-origin axis.
-// Both masks discard at r² > 2.25 — a 1.5× forgiveness margin on the
-// visual fs's r² > 1.0 cutoff.
@fragment
fn fsPick(in: VSOut) -> @location(0) vec4 {
- var r2 = dot(in.uv, in.uv);
-
- if (in.isDiskHandoff == 1u) {
- // M = [major | minor] maps disk-local coords (a, b) — where the
- // visible ellipse is the unit circle a² + b² ≤ 1 — into the
- // billboard's UV space. Inverting and applying to 'uv' recovers
- // (a, b); we test that against the 1.5× forgiveness margin.
- // safeAB clamps cosI at 0.05 so the basis isn't singular.
- let major = in.pickMajorBillboard;
- let minor = in.pickMinorBillboard;
- let det = major.x * minor.y - major.y * minor.x;
- let invDet = 1.0 / det;
- let local = vec2(
- ( minor.y * in.uv.x - minor.x * in.uv.y) * invDet,
- (-major.y * in.uv.x + major.x * in.uv.y) * invDet,
- );
- r2 = dot(local, local);
- }
-
- if (r2 > 2.25) { discard; }
+ // Plain dot: discard fragments outside the unit circle.
+ if (dot(in.uv, in.uv) > 1.0) { discard; }
// Write '(sourceCode << 27 | instance_index) + 1' so background
// pixels (cleared to 0) are distinguishable from a real hit. The +1
@@ -81,7 +56,7 @@ fn fsPick(in: VSOut) -> @location(0) vec4 {
// 'in.instanceIdx' was assembled in the vertex stage from
// '(source.sourceCode << 27u) | @builtin(instance_index)'. The
// packing gives every survey a structurally-disjoint identity range
- // (top 5 bits = source code, bottom 27 = local index ≤ 134M), so
+ // (top 5 bits = source code, bottom 27 = local index <= 134M), so
// two galaxies in different surveys can never collide on the same
// pick value.
//
diff --git a/src/services/gpu/shaders/points/vertex.wesl b/src/services/gpu/shaders/points/vertex.wesl
index 9e4385bd..37f9bced 100644
--- a/src/services/gpu/shaders/points/vertex.wesl
+++ b/src/services/gpu/shaders/points/vertex.wesl
@@ -35,7 +35,6 @@ import package::lib::sourceUniforms::SourceUniforms;
import package::lib::colorIndex::ramp;
import package::lib::astro::distanceModulus;
import package::lib::selectionEncoding::packSelection;
-import package::lib::orientation::diskAxes;
import package::lib::focusUniforms::FocusUniforms;
import package::lib::focusUniforms::focusAlphaMultiplier;
@@ -149,7 +148,11 @@ fn vs(
// is parked exactly on a galaxy (test fixture path; not a real scenario).
let safeDist = max(distanceMpc, 0.001);
let apparentPxRadius = (p.radiusMpc / safeDist) * u.pxPerRad;
- let sizePx = max(u.pointSizePx, apparentPxRadius);
+ // In the pick pass, clamp the billboard to the dot floor: resolved
+ // galaxy disks are now picked by the procedural-disk pass at the disk
+ // edge, so the point pick only needs to claim a small dot.
+ let inPickPass = u.pickPass == 1u;
+ let sizePx = select(max(u.pointSizePx, apparentPxRadius), u.pointSizePx, inPickPass);
// ── PIXEL-SIZE-IN-CLIP-SPACE CONVERSION ──────────────────────────────────
//
@@ -199,10 +202,9 @@ fn vs(
// fades IN across [pxFadeStart, pxFadeEnd] (apparent-pixel diameter);
// we fade OUT with the complementary smoothstep so the additive HDR
// contribution stays constant per galaxy through the band. The pick
- // pass skips this fade so disk-sized galaxies still write into the
- // pick texture (the procedural-disk renderer has no pick pipeline).
+ // pass skips this fade so dimmed-by-crossfade galaxies remain
+ // pickable via the clamped dot billboard.
let apparentDiameterPx = sizePx * 0.5;
- let inPickPass = u.pickPass == 1u;
let crossfadeOut = select(
1.0 - smoothstep(u.pxFadeStart, u.pxFadeEnd, apparentDiameterPx),
1.0,
@@ -278,50 +280,5 @@ fn vs(
let paRad = -p.positionAngleDeg * 3.14159265 / 180.0;
out.paRotation = vec2(cos(paRad), sin(paRad));
- // Tell fsPick which mask shape to use. See io.wesl::VSOut for what
- // each value means.
- let isDiskHandoff = apparentDiameterPx >= u.pxFadeStart;
- out.isDiskHandoff = select(0u, 1u, isDiskHandoff);
-
- // Projected disk basis for the pick-mode ellipse mask. Only emit
- // the projection work when we're actually in the pick pass AND in
- // the disk-handoff regime; both gates avoid wasted clip-space
- // projections on the millions of far-field point sprites the visual
- // pass draws every frame. See io.wesl::VSOut for the math the
- // fragment side performs on these two vectors.
- if (inPickPass && isDiskHandoff) {
- // Clamp cosI at 0.05 (matches procedural-disk renderer) so an
- // edge-on disk's minor axis doesn't collapse to a degenerate line
- // and the fragment's matrix inverse stays well-conditioned.
- let cosI = max(safeAB, 0.05);
- let sinI = sqrt(max(0.0, 1.0 - cosI * cosI));
- // Positive paRad (east-of-north on the celestial sky) — opposite
- // sign from the point sprite's screen-aligned 'paRotation', which
- // negates because it rotates UV in the inverse direction. Here
- // we want the actual world-space axis.
- let paRadWS = p.positionAngleDeg * 3.14159265 / 180.0;
- let axes = diskAxes(p.position, paRadWS, cosI, sinI);
- let majorWS = axes.major;
- let minorWS = axes.minor;
-
- // Project the disk's outer-edge tips (centre ± radiusMpc · axis)
- // and take the NDC delta from the projected centre. Multiply by
- // viewportPx · 0.5 to convert NDC → pixels, then divide by the
- // billboard's half-extent in pixels so the fragment can test
- // against unit UV.
- let majorTipClip = worldToClip(u.cam, p.position + p.radiusMpc * majorWS);
- let minorTipClip = worldToClip(u.cam, p.position + p.radiusMpc * minorWS);
- let baseNDC = vec2(center.x, center.y) / center.w;
- let majorNDC = vec2(majorTipClip.x, majorTipClip.y) / majorTipClip.w;
- let minorNDC = vec2(minorTipClip.x, minorTipClip.y) / minorTipClip.w;
- let halfViewport = u.cam.viewportPx * 0.5;
- let billboardHalfPx = max(sizePx * 0.5, 1.0);
- out.pickMajorBillboard = ((majorNDC - baseNDC) * halfViewport) / billboardHalfPx;
- out.pickMinorBillboard = ((minorNDC - baseNDC) * halfViewport) / billboardHalfPx;
- } else {
- out.pickMajorBillboard = vec2(0.0, 0.0);
- out.pickMinorBillboard = vec2(0.0, 0.0);
- }
-
return out;
}
diff --git a/src/services/gpu/shaders/proceduralDisks/io.wesl b/src/services/gpu/shaders/proceduralDisks/io.wesl
index 8f56280b..ed7ffd27 100644
--- a/src/services/gpu/shaders/proceduralDisks/io.wesl
+++ b/src/services/gpu/shaders/proceduralDisks/io.wesl
@@ -75,7 +75,7 @@ struct Uniforms {
struct InstanceIn {
@location(0) posSize: vec4, // x, y, z, sizeWorldMpc
- @location(1) orientation: vec4, // axisRatio, positionAngleDeg, _, _
+ @location(1) orientation: vec4, // axisRatio, positionAngleDeg, pickIdBits, _
@location(2) extras: vec4, // colourIndex, crossfadeAlpha, procFadeOut, _
};
@@ -106,4 +106,10 @@ struct VsOut {
// focused POI (and for everything at rest), 0.08 for non-members, ramped
// by the focus blend. Computed once per instance in the vertex stage.
@location(4) @interpolate(flat) focusDim: f32,
+ // CPU-packed pick identity: '(sourceCode << 27) | localIdx', stored as
+ // raw bits in instance slot 6 (orientation.z) and recovered here via
+ // bitcast. Flat because every fragment of one quad shares the same galaxy
+ // identity. Only the pick fragment declares and reads 'pickId'; the visual
+ // fragment does not, so the value is simply ignored there.
+ @location(5) @interpolate(flat) pickId: u32,
};
diff --git a/src/services/gpu/shaders/proceduralDisks/pickFragment.wesl b/src/services/gpu/shaders/proceduralDisks/pickFragment.wesl
new file mode 100644
index 00000000..e90265bf
--- /dev/null
+++ b/src/services/gpu/shaders/proceduralDisks/pickFragment.wesl
@@ -0,0 +1,45 @@
+// proceduralDisks/pickFragment.wesl — offscreen r32uint picking fragment
+// for the procedural-disk impostor pipeline.
+//
+// Pick sibling of 'fragment.wesl'. Where the visual fragment shades a
+// two-component brightness profile into the rgba16float HDR target, this
+// one writes the packed galaxy identity into the r32uint pick texture so
+// the JS hover/click path can map a cursor pixel back to (source, localIdx).
+//
+// ## Shape contract
+//
+// The discard condition 'length(in.uv) > 1.0' is identical to the one in
+// 'fragment.wesl'. This makes the pick surface exactly the disk the debug
+// ring traces — no forgiveness margin, no shrink. A cursor pixel that hits
+// a visible disk fragment will always hit the pick surface too, and vice
+// versa.
+//
+// ## Why the +offset
+//
+// The r32uint pick texture is cleared to 0 before the pass. Writing the
+// raw packed id would make the galaxy with sourceCode = 0 AND localIdx = 0
+// indistinguishable from an unwritten pixel. Adding PICK_SENTINEL_OFFSET (=
+// 1) shifts the entire range up by one so 0 remains the unambiguous 'no
+// hit' sentinel. The JS decoder subtracts the offset before unpacking.
+//
+// ## No bindings declared here
+//
+// 'fsPick' only reads VsOut fields — no uniforms consumed. The vertex stage
+// in 'vertex.wesl' owns all @group declarations; the pick pipeline on the
+// JS side must still bind the same groups (WebGPU pipeline layout requires
+// it), but this fragment module doesn't redeclare them. Same pattern as
+// 'points/pickFragment.wesl'.
+
+import package::proceduralDisks::io::VsOut;
+import package::lib::selectionEncoding::PICK_SENTINEL_OFFSET;
+
+@fragment
+fn fsPick(in: VsOut) -> @location(0) vec4 {
+ // Exact unit-circle discard — matches the visual fragment's 'r > 1.0'
+ // test so the pick edge equals the visible disk edge.
+ if (length(in.uv) > 1.0) { discard; }
+
+ // Write the 1-based packed identity into the r channel. g/b/a are unused;
+ // the JS pick decoder reads only r. The +offset keeps 0 as 'no hit'.
+ return vec4(in.pickId + PICK_SENTINEL_OFFSET, 0u, 0u, 0u);
+}
diff --git a/src/services/gpu/shaders/proceduralDisks/vertex.wesl b/src/services/gpu/shaders/proceduralDisks/vertex.wesl
index be3d5852..b019f476 100644
--- a/src/services/gpu/shaders/proceduralDisks/vertex.wesl
+++ b/src/services/gpu/shaders/proceduralDisks/vertex.wesl
@@ -146,5 +146,10 @@ fn vs(@builtin(vertex_index) vid: u32, instance: InstanceIn) -> VsOut {
// Cluster-focus dim, evaluated once per instance at the galaxy centre
// (constant across the quad's corners). At rest (blend=0) this is 1.0.
out.focusDim = focusAlphaMultiplier(pos, focus);
+ // Slot 6 carries the CPU-packed pick id as raw f32 bits; bitcast
+ // recovers the original u32 '(sourceCode << 27) | localIdx' written by
+ // the TS side in slot 6 = orientation.z. The visual fragment ignores this
+ // varying; the pick fragment reads it to write the r32uint pick texture.
+ out.pickId = bitcast(instance.orientation.z);
return out;
}
diff --git a/tests/services/engine/proceduralDiskEmission.test.ts b/tests/services/engine/proceduralDiskEmission.test.ts
index d00eaff1..ce4921cd 100644
--- a/tests/services/engine/proceduralDiskEmission.test.ts
+++ b/tests/services/engine/proceduralDiskEmission.test.ts
@@ -47,6 +47,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(r).toBeNull();
});
@@ -66,6 +68,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(r).toBeNull();
});
@@ -82,6 +86,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(r).toBeNull();
});
@@ -98,6 +104,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(r).toBeNull();
});
@@ -114,6 +122,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(r).not.toBeNull();
expect(r!.crossfadeAlpha).toBeCloseTo(0, 3);
@@ -131,6 +141,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(atEnd!.crossfadeAlpha).toBeCloseTo(1, 6);
@@ -146,6 +158,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(farPast!.crossfadeAlpha).toBeCloseTo(1, 6);
});
@@ -167,6 +181,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(r!.crossfadeAlpha).toBeCloseTo(0.5, 6);
});
@@ -186,6 +202,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(r!.crossfadeAlpha).toBeCloseTo(0.15625, 6);
});
@@ -202,6 +220,8 @@ describe('maybeEmitProceduralDisk', () => {
1.7,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(r).not.toBeNull();
expect(r!.x).toBe(11);
@@ -230,6 +250,8 @@ describe('maybeEmitProceduralDisk', () => {
base.colourIndex,
base.fadeStartPx,
base.fadeEndPx,
+ 0,
+ 0,
);
expect(r!.procFadeOut).toBe(1.0);
});
diff --git a/tests/services/engine/subsystems/proceduralDiskSubsystem.test.ts b/tests/services/engine/subsystems/proceduralDiskSubsystem.test.ts
index dcef941e..cbc07810 100644
--- a/tests/services/engine/subsystems/proceduralDiskSubsystem.test.ts
+++ b/tests/services/engine/subsystems/proceduralDiskSubsystem.test.ts
@@ -117,6 +117,25 @@ describe('createProceduralDiskSubsystem', () => {
expect(sys.lastOutput.instances.length).toBe(2);
});
+ it('emits the (source, localIdx) identity for each instance', () => {
+ // decimationFactor:1 visits all rows in a single frame.
+ // The 4-row cloud uses the same camera/size setup as the existing
+ // 'emits one ProceduralDiskInstance per galaxy above 8 px' test —
+ // known to produce 4 emitted instances.
+ const sys = createProceduralDiskSubsystem({ decimationFactor: 1 });
+ const clouds = new Map([[Source.SDSS, makeDenseCloud(4)]]);
+ const out = sys.runFrame(makeInput(clouds));
+ expect(out.instances.length).toBe(4);
+ // Every instance must carry the SDSS source code.
+ for (const ins of out.instances) {
+ expect(ins.sourceCode).toBe(Source.SDSS);
+ }
+ // Back-to-front sort reorders instances, so check the SET of localIdx
+ // values rather than positional order.
+ const localIdxSet = new Set(out.instances.map((ins) => ins.localIdx));
+ expect(localIdxSet).toEqual(new Set([0, 1, 2, 3]));
+ });
+
describe('famous-WebP crossfade (procFadeOut override)', () => {
/**
* Minimal atlas stub. The subsystem only ever calls `isLoaded(key)` on
diff --git a/tests/services/gpu/renderers/pickRenderer.diskPick.test.ts b/tests/services/gpu/renderers/pickRenderer.diskPick.test.ts
new file mode 100644
index 00000000..87994fb5
--- /dev/null
+++ b/tests/services/gpu/renderers/pickRenderer.diskPick.test.ts
@@ -0,0 +1,156 @@
+/**
+ * pickRenderer.diskPick.test — type-level and stub-device contract for the
+ * procedural-disk pick integration.
+ *
+ * Two concerns:
+ *
+ * 1. Signature shape — `proceduralDiskRenderer` must be the 8th positional
+ * (index 7) and optional. Pins append-not-reorder: if a future edit
+ * moves it before `structureMarkerRenderer` (index 6) or makes it
+ * required, the type assertion below fails at type-check time.
+ *
+ * 2. `pickDisks` behaviour — after a `draw` with N instances, `pickDisks`
+ * issues `pass.draw(6, N)`; on a fresh renderer (no prior draw) it is a
+ * no-op (no setPipeline / draw).
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { createPickRenderer } from '../../../../src/services/gpu/renderers/pickRenderer';
+import { createProceduralDiskRenderer } from '../../../../src/services/gpu/renderers/proceduralDiskRenderer';
+import type { ProceduralDiskInstance } from '../../../../src/@types/rendering/ProceduralDiskInstance';
+
+// ── 1. Signature pin ────────────────────────────────────────────────────────
+
+describe('createPickRenderer disk-pick integration', () => {
+ it('keeps proceduralDiskRenderer optional as the 8th positional (index 7)', () => {
+ // Compile-time: the 8th parameter must exist and be assignable from
+ // `undefined` (declared with `?`). Removing it, making it required,
+ // or reordering it before structureMarkerRenderer all break this.
+ type Sig = Parameters;
+ const _check = (...args: Sig): void => {
+ const eighth: Sig[7] = args[7];
+ const _undef: typeof eighth = undefined;
+ void _undef;
+ };
+ expect(_check).toBeTypeOf('function');
+ });
+});
+
+// ── 2. pickDisks behaviour ──────────────────────────────────────────────────
+
+function makePickStubInit() {
+ const device = {
+ createShaderModule: vi.fn(() => ({
+ getCompilationInfo: () => Promise.resolve({ messages: [] }),
+ })),
+ createRenderPipeline: vi.fn(() => ({ getBindGroupLayout: () => ({}) })),
+ createPipelineLayout: vi.fn(() => ({})),
+ createBindGroupLayout: vi.fn(() => ({})),
+ createBuffer: vi.fn(() => ({ destroy: vi.fn() })),
+ createBindGroup: vi.fn(() => ({})),
+ createSampler: vi.fn(() => ({})),
+ queue: {
+ writeBuffer: vi.fn(),
+ submit: vi.fn(),
+ },
+ } as unknown as GPUDevice;
+
+ return {
+ init: {
+ device,
+ context: null as unknown as GPUCanvasContext,
+ format: 'rgba16float' as GPUTextureFormat,
+ canvas: null as unknown as HTMLCanvasElement,
+ focusBgl: {} as unknown as import('../../../../src/@types/rendering/FocusUniformsBgl').FocusUniformsBgl,
+ },
+ };
+}
+
+function makeStubPass() {
+ return {
+ setPipeline: vi.fn(),
+ setBindGroup: vi.fn(),
+ setVertexBuffer: vi.fn(),
+ draw: vi.fn(),
+ } as unknown as GPURenderPassEncoder;
+}
+
+const FOCUS_BG = {} as unknown as GPUBindGroup;
+
+function fakeInstance(overrides: Partial = {}): ProceduralDiskInstance {
+ return {
+ x: 1, y: 2, z: 3,
+ sizeWorldMpc: 0.05,
+ axisRatio: 0.6,
+ positionAngleDeg: 45,
+ colourIndex: 0.7,
+ crossfadeAlpha: 0.5,
+ procFadeOut: 1,
+ sourceCode: 0,
+ localIdx: 0,
+ ...overrides,
+ };
+}
+
+describe('proceduralDiskRenderer.pickDisks', () => {
+ it('issues draw(6, N) after draw() with N instances', () => {
+ const { init } = makePickStubInit();
+ const renderer = createProceduralDiskRenderer(init);
+
+ const visPass = makeStubPass();
+ const instances: ProceduralDiskInstance[] = [
+ fakeInstance({ sourceCode: 1, localIdx: 42 }),
+ fakeInstance({ sourceCode: 2, localIdx: 99 }),
+ fakeInstance({ sourceCode: 3, localIdx: 7 }),
+ ];
+ renderer.draw(visPass, new Float32Array(16), [800, 600], [0, 0, 0], 100, FOCUS_BG, instances);
+
+ const pickPass = makeStubPass();
+ renderer.pickDisks(pickPass);
+
+ // Must have set the pick pipeline.
+ expect(pickPass.setPipeline).toHaveBeenCalledTimes(1);
+ // Must have drawn 6 vertices × 3 instances.
+ expect(pickPass.draw).toHaveBeenCalledWith(6, 3);
+ });
+
+ it('is a no-op on a fresh renderer with no prior draw', () => {
+ const { init } = makePickStubInit();
+ const renderer = createProceduralDiskRenderer(init);
+
+ const pickPass = makeStubPass();
+ renderer.pickDisks(pickPass);
+
+ // Nothing should have been called — lastPickInstanceCount is 0.
+ expect(pickPass.setPipeline).not.toHaveBeenCalled();
+ expect(pickPass.draw).not.toHaveBeenCalled();
+ });
+
+ it('is a no-op after draw() is called with an empty instances array', () => {
+ // Regression: draw() with 0 instances must zero lastPickInstanceCount.
+ // Without the fix, the prior frame's count persists and pickDisks()
+ // re-draws the previous frame's disks into the pick texture.
+ const { init } = makePickStubInit();
+ const renderer = createProceduralDiskRenderer(init);
+
+ // First draw: 3 instances. pickDisks confirms something was drawn.
+ const visPass1 = makeStubPass();
+ const instances: ProceduralDiskInstance[] = [
+ fakeInstance({ sourceCode: 1, localIdx: 10 }),
+ fakeInstance({ sourceCode: 1, localIdx: 11 }),
+ fakeInstance({ sourceCode: 1, localIdx: 12 }),
+ ];
+ renderer.draw(visPass1, new Float32Array(16), [800, 600], [0, 0, 0], 100, FOCUS_BG, instances);
+ const pickPass1 = makeStubPass();
+ renderer.pickDisks(pickPass1);
+ expect(pickPass1.draw).toHaveBeenCalledWith(6, 3); // sanity
+
+ // Second draw: empty. pickDisks on a fresh pass must be a no-op.
+ const visPass2 = makeStubPass();
+ renderer.draw(visPass2, new Float32Array(16), [800, 600], [0, 0, 0], 100, FOCUS_BG, []);
+ const pickPass2 = makeStubPass();
+ renderer.pickDisks(pickPass2);
+ expect(pickPass2.setPipeline).not.toHaveBeenCalled();
+ expect(pickPass2.draw).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/services/gpu/renderers/proceduralDiskRenderer.test.ts b/tests/services/gpu/renderers/proceduralDiskRenderer.test.ts
index 32b48f86..9f254f26 100644
--- a/tests/services/gpu/renderers/proceduralDiskRenderer.test.ts
+++ b/tests/services/gpu/renderers/proceduralDiskRenderer.test.ts
@@ -20,6 +20,7 @@
import { describe, it, expect, vi } from 'vitest';
import { createProceduralDiskRenderer } from '../../../../src/services/gpu/renderers/proceduralDiskRenderer';
import { FLOATS_PER_INSTANCE } from '../../../../src/services/gpu/renderers/instancedQuadRenderer';
+import { packSelection } from '../../../../src/data/selectionEncoding';
import type { ProceduralDiskInstance } from '../../../../src/@types/rendering/ProceduralDiskInstance';
function makeStubInit() {
@@ -35,9 +36,9 @@ function makeStubInit() {
createBindGroup: vi.fn(() => ({})),
createSampler: vi.fn(() => ({})),
queue: {
- // The renderer's instance upload is the second writeBuffer call
- // (uniforms first, instance bytes second). We snapshot every call
- // and let the assertion pick out the instance payload.
+ // Three writeBuffer calls per frame: uniforms [0], visual instances [1],
+ // pick-buffer mirror [2]. We snapshot every call so assertions can
+ // address each by index.
writeBuffer: vi.fn(
(
_buf: GPUBuffer,
@@ -89,10 +90,45 @@ function fakeProceduralInstance(overrides: Partial = {})
colourIndex: 0.7,
crossfadeAlpha: 0.5,
procFadeOut: 1,
+ sourceCode: 0,
+ localIdx: 0,
...overrides,
};
}
+describe('proceduralDiskRenderer pack loop (Task R2)', () => {
+ it('pack writes the packed pick id into slot 6 as u32 bits', () => {
+ // 1_000_000 exercises the float-vs-bits distinction: Math.fround(1_000_000)
+ // === 1_000_000, but a non-round value like 0x07fffffe would not round-trip
+ // as f32 and would corrupt the id if written via packed[o+6] = value.
+ const { init, writeBufferCalls } = makeStubInit();
+ const renderer = createProceduralDiskRenderer(init);
+
+ const pass = {
+ setPipeline: vi.fn(),
+ setBindGroup: vi.fn(),
+ setVertexBuffer: vi.fn(),
+ draw: vi.fn(),
+ } as unknown as GPURenderPassEncoder;
+
+ const instances: ProceduralDiskInstance[] = [
+ fakeProceduralInstance({ sourceCode: 1, localIdx: 7 }),
+ fakeProceduralInstance({ sourceCode: 3, localIdx: 1_000_000 }),
+ ];
+
+ renderer.draw(pass, new Float32Array(16), [800, 600], [0, 0, 0], 100, FOCUS_BIND_GROUP, instances);
+
+ // Visual instance payload is always writeBufferCalls[1] (uniforms first,
+ // visual instances second, pick mirror third).
+ const visualPayload = writeBufferCalls[1]!.data;
+ // Reinterpret the same bytes as u32 to inspect the bitcast-written slot 6.
+ const u32 = new Uint32Array(visualPayload.buffer);
+
+ expect(u32[6]).toBe(packSelection(1, 7));
+ expect(u32[FLOATS_PER_INSTANCE + 6]).toBe(packSelection(3, 1_000_000));
+ });
+});
+
describe('proceduralDiskRenderer pack loop (Task R1)', () => {
it('pack writes 16 floats per instance — last 4 are zero (procedural shader does not read them)', () => {
const { init, writeBufferCalls } = makeStubInit();
@@ -112,9 +148,10 @@ describe('proceduralDiskRenderer pack loop (Task R1)', () => {
renderer.draw(pass, new Float32Array(16), [800, 600], [0, 0, 0], 100, FOCUS_BIND_GROUP, instances);
- // The factory calls writeBuffer twice per draw: uniforms first, then
- // the instance payload. The instance payload is the one we need.
- expect(writeBufferCalls.length).toBe(2);
+ // draw emits three writeBuffer calls per frame: uniforms first, then
+ // the visual instance payload, then the pick instance buffer mirror.
+ // The visual instance payload is always at index 1.
+ expect(writeBufferCalls.length).toBe(3);
const instancePayload = writeBufferCalls[1]!.data;
// 16 floats per instance × 2 instances = 32 floats.
diff --git a/tests/visual/galaxyImpostorBaseline.test.ts b/tests/visual/galaxyImpostorBaseline.test.ts
index f4404ac8..20424102 100644
--- a/tests/visual/galaxyImpostorBaseline.test.ts
+++ b/tests/visual/galaxyImpostorBaseline.test.ts
@@ -153,7 +153,7 @@ describe('galaxy-impostor visual baseline', () => {
{
"procDisks": {
"count": 8,
- "hash": "axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|x=10|y=0.007|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|x=10|y=0.006|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|x=10|y=0.005|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|x=10|y=0.004|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|x=10|y=0.003|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|x=10|y=0.002|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|x=10|y=0.001|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|x=10|y=0|z=0",
+ "hash": "axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=7|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.007|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=6|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.006|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=5|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.005|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=4|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.004|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=3|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.003|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=2|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.002|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.001|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=0|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0|z=0",
},
"texDisks": {
"count": 8,