From 5a0ccf299ca3fe0689ee557fd995dce9d1670c84 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 00:23:52 +0200 Subject: [PATCH 01/11] docs(plan): pick resolved disks from the procedural-disk pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan to move resolved-galaxy disk picking off the point sprite and onto the procedural-disk pipeline (which already draws the exact disk the debug ring traces), and simplify the point pick back to a plain dot — removing the pick-ellipse varyings + extra worldToClip from the hot point vertex stage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-08-pick-disk-from-procedural.md | 433 ++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md diff --git a/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md b/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md new file mode 100644 index 00000000..f0a4f99f --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md @@ -0,0 +1,433 @@ +# 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. + +- [ ] 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.) +- [ ] Run it (`npm test -- proceduralDiskSubsystem`) — fails (fields absent). +- [ ] Extend `maybeEmitProceduralDisk` + the call site + the type. +- [ ] 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). +- [ ] `npm test -- proceduralDiskSubsystem` → new + existing tests pass. +- [ ] `npm run typecheck` clean. +- [ ] 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. + +- [ ] 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`. +- [ ] 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). +- [ ] Run it — fails (slot 6 is zero). +- [ ] Implement the `Uint32Array`-view pack + the owned pick instance buffer. +- [ ] 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). +- [ ] `npm test -- proceduralDiskRenderer` → green. +- [ ] 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. + +- [ ] Add the three shader edits / file. +- [ ] `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. +- [ ] 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`): + +- [ ] 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. +- [ ] 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`). +- [ ] Run the new tests — fail. +- [ ] Implement the pipeline + method + wiring + type field. +- [ ] `npm test -- proceduralDiskRenderer pickRenderer` and `npm run typecheck` + → green. `npm run build` → links. +- [ ] 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:** + +- [ ] 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. +- [ ] `npm run build` → links (the removed varyings must be gone from BOTH the + vertex output and every fragment input, or the WGSL compiler errors). +- [ ] `npm test` → full suite green. +- [ ] 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`). Ask the user to confirm in the + running dev server. +- [ ] 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. + +- [ ] Run the `entanglement-radar` skill over the full diff of this branch. +- [ ] 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). +- [ ] Address any knot it names, or record why it's acceptable, in the review + output (no new file — report inline). + +--- + +## 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. From 7800258a2c524633f9d2dbd1f3f6a0b7f68c6257 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 00:28:18 +0200 Subject: [PATCH 02/11] feat(picking): carry (source, localIdx) identity on procedural disk instances Task 1 of the disk-pick plan. The procedural disk renderer already draws the disk the debug ring traces, so it will become the pick surface. First step: each emitted instance must know which galaxy it is. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rendering/ProceduralDiskInstance.d.ts | 10 +++++++++ .../subsystems/proceduralDiskSubsystem.ts | 6 +++++ .../engine/proceduralDiskEmission.test.ts | 22 +++++++++++++++++++ .../proceduralDiskSubsystem.test.ts | 19 ++++++++++++++++ .../renderers/proceduralDiskRenderer.test.ts | 2 ++ 5 files changed, 59 insertions(+) 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/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/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/proceduralDiskRenderer.test.ts b/tests/services/gpu/renderers/proceduralDiskRenderer.test.ts index 32b48f86..f2abdc93 100644 --- a/tests/services/gpu/renderers/proceduralDiskRenderer.test.ts +++ b/tests/services/gpu/renderers/proceduralDiskRenderer.test.ts @@ -89,6 +89,8 @@ function fakeProceduralInstance(overrides: Partial = {}) colourIndex: 0.7, crossfadeAlpha: 0.5, procFadeOut: 1, + sourceCode: 0, + localIdx: 0, ...overrides, }; } From e36d99e681547e1cff1af11e3c86bd1b34719e91 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 00:48:53 +0200 Subject: [PATCH 03/11] feat(picking): pack pick id into procedural disk slot 6 + own a pick buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 of the disk-pick plan. draw() now writes packSelection(source, localIdx) into the free instance slot 6 as raw u32 bits (via a Uint32Array view over the same buffer — localIdx can exceed 2^24, so storing it as a float would corrupt large ids). The renderer also owns a grow-on-demand pick instance buffer filled byte-identically each frame; Task 4's pickDisks pass draws from it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gpu/renderers/proceduralDiskRenderer.ts | 51 ++++++++++++++++++- .../renderers/proceduralDiskRenderer.test.ts | 47 ++++++++++++++--- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/services/gpu/renderers/proceduralDiskRenderer.ts b/src/services/gpu/renderers/proceduralDiskRenderer.ts index 519338b9..803cc42b 100644 --- a/src/services/gpu/renderers/proceduralDiskRenderer.ts +++ b/src/services/gpu/renderers/proceduralDiskRenderer.ts @@ -42,7 +42,8 @@ import type { ProceduralDiskRenderer } from '../../../@types/rendering/Procedura 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, createInstancedQuadRenderer } from './instancedQuadRenderer'; +import { packSelection } from '../../../data/selectionEncoding'; type Init = { device: GPUDevice; @@ -71,6 +72,22 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer uniformVisibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, }); + // Pick instance buffer — owned by this renderer, separate from the + // visual instance buffer inside 'inner' (which is private to the + // factory). Task 4 will add the 'pickDisks' method that binds this + // buffer to the pick pipeline. For now we allocate, grow, fill, and + // record the count so the infrastructure is ready without leaking any + // pick-pipeline scope into this task's diff. + // + // Why a second buffer rather than reusing the visual one: the visual + // buffer is private to instancedQuadRenderer and not exposed. 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; + function draw( pass: GPURenderPassEncoder, viewProj: Float32Array, @@ -86,6 +103,13 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer // 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 +119,7 @@ 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; + packedU32[o + 6] = packSelection(ins.sourceCode, ins.localIdx); packed[o + 7] = 0; packed[o + 8] = ins.colourIndex; packed[o + 9] = ins.crossfadeAlpha; @@ -122,6 +146,29 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer pxPerRad, focusBindGroup, }); + + // ── Pick instance buffer (mirror of the visual upload) ───────────── + // + // We own a second GPU buffer holding the same byte-identical packed + // data. Task 4 will bind this to the pick pipeline's vertex slot; + // allocated and grown here so the infrastructure exists without + // requiring Task 4's pipeline code in this diff. + // + // 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 the pick pass to issue the instanced draw } const renderer: ProceduralDiskRenderer = { diff --git a/tests/services/gpu/renderers/proceduralDiskRenderer.test.ts b/tests/services/gpu/renderers/proceduralDiskRenderer.test.ts index f2abdc93..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, @@ -95,6 +96,39 @@ function fakeProceduralInstance(overrides: Partial = {}) }; } +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(); @@ -114,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. From 3526e6f07cd63b4f65324649a88d1dbe9abce37a Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 00:55:39 +0200 Subject: [PATCH 04/11] feat(picking): add the procedural-disk pick fragment + pickId varying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 of the disk-pick plan. The vertex stage bitcasts the packed id from slot 6 (orientation.z) into a flat pickId varying; the new pickFragment.wesl discards outside the unit-circle disk — exactly mirroring the visual fragment's edge, so the pick surface is the disk the debug ring traces — and writes pickId + PICK_SENTINEL_OFFSET into the r32uint pick target. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gpu/shaders/proceduralDisks/io.wesl | 8 +++- .../shaders/proceduralDisks/pickFragment.wesl | 45 +++++++++++++++++++ .../gpu/shaders/proceduralDisks/vertex.wesl | 5 +++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/services/gpu/shaders/proceduralDisks/pickFragment.wesl 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; } From 65a926c22872e94f9b1699abdf87634d6c10ecb0 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 01:11:01 +0200 Subject: [PATCH 05/11] feat(picking): pick resolved galaxy disks from the procedural-disk pass Task 4 of the disk-pick plan. The procedural-disk renderer gains its own r32uint pick pipeline (own @group(0) camera BGL + uniform, reusing the shared focus BGL so it's layout-compatible) and a pickDisks(pass) method that replays the camera state draw() last saw and draws the owned pick instance buffer. pickRenderer.recordPickPass calls it alongside the ring pick; shared depth means the front-most of a galaxy's point dot and disk wins, and both carry the same packed id so overlap is harmless. An empty frame zeroes the replayed count so a vanished disk can't be re-picked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rendering/ProceduralDiskRenderer.d.ts | 7 + src/services/engine/phases/wireInput.ts | 1 + src/services/gpu/renderers/pickRenderer.ts | 14 ++ .../gpu/renderers/proceduralDiskRenderer.ts | 199 ++++++++++++++++-- .../renderers/pickRenderer.diskPick.test.ts | 156 ++++++++++++++ 5 files changed, 360 insertions(+), 17 deletions(-) create mode 100644 tests/services/gpu/renderers/pickRenderer.diskPick.test.ts 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/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/gpu/renderers/pickRenderer.ts b/src/services/gpu/renderers/pickRenderer.ts index f96492de..35b8ca17 100644 --- a/src/services/gpu/renderers/pickRenderer.ts +++ b/src/services/gpu/renderers/pickRenderer.ts @@ -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 803cc42b..1170f636 100644 --- a/src/services/gpu/renderers/proceduralDiskRenderer.ts +++ b/src/services/gpu/renderers/proceduralDiskRenderer.ts @@ -37,13 +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, BYTES_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; @@ -72,22 +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). Task 4 will add the 'pickDisks' method that binds this - // buffer to the pick pipeline. For now we allocate, grow, fill, and - // record the count so the infrastructure is ready without leaking any - // pick-pipeline scope into this task's diff. - // - // Why a second buffer rather than reusing the visual one: the visual - // buffer is private to instancedQuadRenderer and not exposed. 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. + // 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, @@ -97,7 +207,14 @@ 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. @@ -147,12 +264,20 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer 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. Task 4 will bind this to the pick pipeline's vertex slot; - // allocated and grown here so the infrastructure exists without - // requiring Task 4's pipeline code in this diff. + // 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 @@ -168,13 +293,53 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer pickInstanceBufferCapacity = instances.length; } init.device.queue.writeBuffer(pickInstanceBuffer, 0, packed); - lastPickInstanceCount = instances.length; // consumed by the pick pass to issue the instanced draw + 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/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(); + }); +}); From 459a35f190144b39e30010335a8ed222eb8a26b9 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 01:23:08 +0200 Subject: [PATCH 06/11] perf(picking): simplify the point pick back to a plain dot Task 5 of the disk-pick plan. With the procedural-disk pass now owning resolved-disk picking, the point pick no longer reconstructs the disk ellipse. Strips the isDiskHandoff / pickMajorBillboard / pickMinorBillboard varyings and the diskAxes import off the hot, shared point vertex stage (two worldToClip calls and three flat varyings gone from a ~2.5M-invocation stage), clamps the pick-pass billboard to the pointSizePx dot floor, and reduces the point pick fragment to a plain dot(uv,uv) > 1.0 discard. The point pass picks dots; the procedural pass picks disks. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/@types/settings/EngineSettingsState.d.ts | 6 +- src/services/gpu/renderers/pickRenderer.ts | 4 +- src/services/gpu/shaders/points/io.wesl | 25 +------- .../gpu/shaders/points/pickFragment.wesl | 55 +++++------------- src/services/gpu/shaders/points/vertex.wesl | 57 +++---------------- 5 files changed, 28 insertions(+), 119 deletions(-) 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/gpu/renderers/pickRenderer.ts b/src/services/gpu/renderers/pickRenderer.ts index 35b8ca17..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 */ 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; } From e428c95adee182770bcbb391ce421fdac536065b Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 01:23:15 +0200 Subject: [PATCH 07/11] test: refresh impostor baseline snapshot for the pick-id fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The procDisks hash serializes every ProceduralDiskInstance field, so the sourceCode/localIdx identity added for disk picking now appears in it. Pure snapshot refresh — no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/visual/galaxyImpostorBaseline.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 3c19576c26700ae57c064c71e1d0b54ea994c215 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 01:28:49 +0200 Subject: [PATCH 08/11] docs(picking): cross-reference slot 6 to the shader at the pack site Entanglement-radar found the slot-6 pack site documented bits-vs-float but not what slot 6 IS. Add a one-line pointer to proceduralDisks/io.wesl orientation.z so a future edit can't treat the slot as free and silently corrupt pick ids. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/gpu/renderers/proceduralDiskRenderer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/gpu/renderers/proceduralDiskRenderer.ts b/src/services/gpu/renderers/proceduralDiskRenderer.ts index 1170f636..1a63f1d3 100644 --- a/src/services/gpu/renderers/proceduralDiskRenderer.ts +++ b/src/services/gpu/renderers/proceduralDiskRenderer.ts @@ -236,6 +236,8 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer packed[o + 3] = ins.sizeWorldMpc; packed[o + 4] = ins.axisRatio; packed[o + 5] = ins.positionAngleDeg; + // 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; From c703bde6109e361b89d3852d8124c33f48a1355c Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 01:29:09 +0200 Subject: [PATCH 09/11] docs(plan): tick completed tasks for the disk-pick plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-08-pick-disk-from-procedural.md | 65 ++++++++++--------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md b/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md index f0a4f99f..4a6bf26c 100644 --- a/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md +++ b/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md @@ -142,19 +142,19 @@ 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. -- [ ] Add a test `emits the (source, localIdx) identity for each instance`: +- [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.) -- [ ] Run it (`npm test -- proceduralDiskSubsystem`) — fails (fields absent). -- [ ] Extend `maybeEmitProceduralDisk` + the call site + the type. -- [ ] Update the existing `fakeProceduralInstance` factory in +- [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). -- [ ] `npm test -- proceduralDiskSubsystem` → new + existing tests pass. -- [ ] `npm run typecheck` clean. -- [ ] Commit. +- [x] `npm test -- proceduralDiskSubsystem` → new + existing tests pass. +- [x] `npm run typecheck` clean. +- [x] Commit. --- @@ -183,23 +183,23 @@ 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. -- [ ] Add a test `pack writes the packed pick id into slot 6 as u32 bits`: +- [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`. -- [ ] Note in the test that `1_000_000` proves the float-vs-bits distinction +- [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). -- [ ] Run it — fails (slot 6 is zero). -- [ ] Implement the `Uint32Array`-view pack + the owned pick instance buffer. -- [ ] Keep the existing "slots 12..15 are zero pad" test green (slot 6 is no +- [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). -- [ ] `npm test -- proceduralDiskRenderer` → green. -- [ ] Commit. +- [x] `npm test -- proceduralDiskRenderer` → green. +- [x] Commit. --- @@ -243,12 +243,12 @@ pick edge is the disk edge), then writes the offset packed id: - Declare NO bindings (the fragment reads only `VsOut` fields) — same rationale as the existing `proceduralDisks/fragment.wesl` header. -- [ ] Add the three shader edits / file. -- [ ] `npm run build` → `tsc --noEmit` + vite build succeed (the wesl-plugin +- [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. -- [ ] Commit. +- [x] Commit. --- @@ -313,20 +313,20 @@ fragment from the new `pickFragment.wesl`): **Test contract** (mirror the type-level style of `pickRenderer.poi.test.ts`, which can't stand up a live `GPUDevice`): -- [ ] Add a test asserting `createPickRenderer`'s 8th positional +- [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. -- [ ] Add a renderer-level unit test (stub-device style from +- [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`). -- [ ] Run the new tests — fail. -- [ ] Implement the pipeline + method + wiring + type field. -- [ ] `npm test -- proceduralDiskRenderer pickRenderer` and `npm run typecheck` +- [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. -- [ ] Commit. +- [x] Commit. --- @@ -387,19 +387,19 @@ floor and resolved disks are picked by the procedural pass. **Tests:** -- [ ] Search `tests/` for any assertion referencing `isDiskHandoff`, +- [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. -- [ ] `npm run build` → links (the removed varyings must be gone from BOTH the +- [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). -- [ ] `npm test` → full suite green. +- [x] `npm test` → full suite green. - [ ] 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`). Ask the user to confirm in the running dev server. -- [ ] Commit. +- [x] Commit. --- @@ -411,14 +411,17 @@ The project bakes a simplicity review into every plan. The de-complecting claim **"the point pass picks dots; the procedural pass picks disks"** — confirm the diff delivers it without introducing a new knot. -- [ ] Run the `entanglement-radar` skill over the full diff of this branch. -- [ ] Specifically verify: no shader branches on a galaxy's LOD regime in the pick +- [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). -- [ ] Address any knot it names, or record why it's acceptable, in the review - output (no new file — report inline). +- [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. --- From 1f7dfff87513c4192c5ce5e41ad008b49be70b03 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 01:53:31 +0200 Subject: [PATCH 10/11] docs(plan): tick the visual-verification checkbox (user-confirmed) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-08-pick-disk-from-procedural.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md b/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md index 4a6bf26c..ec0cd4dd 100644 --- a/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md +++ b/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md @@ -395,10 +395,10 @@ floor and resolved disks are picked by the procedural pass. - [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. -- [ ] Final behaviour is visual: with the pick-debug overlay +- [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`). Ask the user to confirm in the - running dev server. + 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. --- From bf997d983ce763aadee18ae00d34cee1be53ff33 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Mon, 8 Jun 2026 01:56:15 +0200 Subject: [PATCH 11/11] docs(plan): mark pick-disk-from-procedural complete DoD audit READY: 2449 tests pass, typecheck clean, 31/31 checkboxes ticked, no new unowned TODOs, test parity up (new pickRenderer.diskPick suite), visual smoke test user-confirmed (pick region hugs the disk-radius ring). Deferred: textured-disk picking for curated-famous galaxies (documented in Out of scope). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/{ => completed}/2026-06-08-pick-disk-from-procedural.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/superpowers/plans/{ => completed}/2026-06-08-pick-disk-from-procedural.md (100%) diff --git a/docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md b/docs/superpowers/plans/completed/2026-06-08-pick-disk-from-procedural.md similarity index 100% rename from docs/superpowers/plans/2026-06-08-pick-disk-from-procedural.md rename to docs/superpowers/plans/completed/2026-06-08-pick-disk-from-procedural.md