Skip to content

Lift picking out of the render frame (pointer-driven hover + click)#362

Merged
rulkens merged 15 commits into
mainfrom
refactor/pick-out-of-frame
Jun 22, 2026
Merged

Lift picking out of the render frame (pointer-driven hover + click)#362
rulkens merged 15 commits into
mainfrom
refactor/pick-out-of-frame

Conversation

@rulkens

@rulkens rulkens commented Jun 22, 2026

Copy link
Copy Markdown
Owner

What & why

GPU picking used to live inside the render frame and worked by corrupting the visual render's shared uniform bufferpickRenderer.recordPickPass scribbled three fields into pointRenderer.uniformBuffer and relied on the next visual frame to repair them. Consequences: a hover over a static scene re-rendered ~2.5M points (every pointermove called requestRender purely to un-corrupt the buffer), and both pick paths were wedged after queue.submit().

This PR makes picking an independent, pointer-driven concern. The frame hands picking exactly one thing — a CPU copy of the uniform bytes it last rendered with (state.picking.lastFrameUniformBytes) — and the pick renderer uploads that to its own buffer.

Core principle

Snapshot only what can't be re-derived live. The camera pose is derived once per frame (single-writer, clock advances once per frame), so a pick fired at pointer time can't legally re-derive viewProj — that's the only thing snapshotted. Targets/viewport/point-size stay derived live at pick time (the click path already proved this correct).

Changes

  • packPointUniforms — the 176-byte layout becomes one tested pure function (single source of truth; a layout mismatch is the class of bug that silently freezes iOS WebGPU). Byte-offset table test guards drift.
  • PickRenderer owns its buffer — uploads the snapshot + applies the 3 overrides on its own buffer; never touches pointRenderer.uniformBuffer (which is no longer public). The pick-debug overlay is fixed for free.
  • Frame-tail snapshot — the point sprites pass stashes draw's packed bytes into state.picking.lastFrameUniformBytes.
  • hoverPickDriver — pointer-driven scheduler: in-flight coalescer + trailing-edge re-fire, drag-gated (pointerDown), no rAF, no requestRender. Hover over a static scene no longer re-renders the scene.
  • Click re-pointed to the same snapshot (behaviour-identical, just sourced from the CPU copy).
  • runFrame slimmed — dead in-frame hover block deleted; pick-debug overlay extracted to drawPickDebugOverlay; retired latestMouseCss/lastPickedMouseCss/MousePos.

Verification

  • Full suite 3028 green, typecheck clean (both projects).
  • Decoupling regression test asserts the pick renderer never writes an external buffer.
  • Entanglement-radar audit: two-writer coupling gone, packPointUniforms sole packer, no re-braid, dep bags narrow.
  • Spec + plan included (docs ride this branch), relocated to completed/.

Out of scope (unchanged)

Pick encoding/resolvePick/selectionEncoding, the pick GPU pipeline/WGSL, touch/pen, double-click→focus.

Suggested manual smoke test

Hover a galaxy → InfoCard updates; click → selects; drag → no hover flicker/storm; toggle the pick-buffer debug overlay → still draws.

🤖 Generated with Claude Code

rulkens and others added 15 commits June 22, 2026 10:19
Hover + click picking stop corrupting the visual uniform buffer and
re-pointing on the next frame. Pick renderer owns its uniform buffer;
the frame stashes its packed PointUniforms image; both pick paths read
that snapshot and derive targets live. Drops the pointermove->full-frame
re-render. Single packPointUniforms source of truth guards byte-layout drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9 tasks (8 impl + entanglement-radar review). packPointUniforms keystone,
pick owns its buffer, frame-tail snapshot, pointer-driven hover driver,
click re-pointed, runFrame slimmed. Contract code per plan-style.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…f truth

draw() now calls the pure packer and returns the packed ArrayBuffer (null
when no catalogs). UNIFORM_BYTES lives in packPointUniforms.ts (re-exported
from pointRenderer to keep the public import path) to avoid a circular import.
Task 1 of docs/superpowers/plans/2026-06-22-pick-out-of-frame.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frame stashes its last packed uniform image here; the pick paths read it
so a pick reproduces the last frame's camera without re-running the
per-frame camera drivers. Initialised null. Task 2 of the pick-out-of-frame plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures draw()'s returned ArrayBuffer into state.picking.lastFrameUniformBytes
(null return preserves the prior value). The pass owns the stash so the
renderer stays ignorant of EngineState. Task 3 of the pick-out-of-frame plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uption)

The pick pass no longer scribbles on pointRenderer.uniformBuffer and relies
on the next frame to repair it. It allocates its own pickUniformBuffer, uploads
the caller-supplied last-frame image (state.picking.lastFrameUniformBytes), and
applies the 3 pick overrides there. createPickRenderer drops its pointRenderer
arg; PointRenderer no longer exposes uniformBuffer. pick/renderForDebug gain a
required uniformBytes; pointSizePx becomes required. Callers (runFrame, click)
thread the snapshot through. Decoupling regression test asserts the pick never
writes an external buffer. Task 4 of the pick-out-of-frame plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uler)

Standalone Option-1 driver: in-flight coalescer + trailing-edge re-fire to
catch the resting position. Reads only lastFrameUniformBytes off state; targets,
viewport, pointSize, timing are live thunks. No rAF, no requestRender (hover
feeds only the InfoCard, no scene halo). Task 5 of the pick-out-of-frame plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the hardcoded point-size default duplicate flagged in the Task 4
review; the sole caller always passes state.settings.galaxyCatalogs.sizePx.
Single source of truth. Task 6 of the pick-out-of-frame plan (the snapshot
read itself landed with Task 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… frame

Canvas pointermove now calls hoverPickDriver.onPointerMove instead of
scheduler.requestRender — a hover over a static scene no longer re-renders
~2.5M points. wireInput constructs the driver (live target/viewport/size/timing
thunks) after pickRenderer is ready. pointerleave/pointerdown keep their wakes.
Task 7 of the pick-out-of-frame plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…PickDebugOverlay

The in-frame hover-pick block is gone (the pointer-driven driver owns it).
The pick-debug overlay moves to its own helper (still frame-coupled — it
composites on the swap chain — but out of the orchestrator body), reading
the snapshot bytes with a null guard. Retired the now-unread latestMouseCss/
lastPickedMouseCss fields + MousePos type; updated the renderFrame docblock.
Task 8 of the pick-out-of-frame plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final-review regression: the in-frame hover block skipped picks while
pointerDown (orbiting, not hovering); the new driver's maybeFire lacked
that gate, so hover picks fired per-readback during a drag and dispatched
spurious hover selections. Re-add 'if (pointerDown) return' + a drag-skip
test; pointerDown is live state again. Sweeps the stale renderScheduler
docblock and corrects the spec sketch (the omission's origin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DoD audit READY: full suite 3028 green, typecheck clean both projects, all
57 checkboxes ticked, entanglement-radar all green (two-writer coupling gone,
packPointUniforms sole packer, no re-braid), final whole-branch review APPROVE
after the drag-gate regression fix. Relocates plan + spec to completed/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
skymap a085147 Commit Preview URL

Branch Preview URL
Jun 22 2026, 11:16 AM

@rulkens rulkens merged commit bf79f80 into main Jun 22, 2026
2 checks passed
@rulkens rulkens deleted the refactor/pick-out-of-frame branch June 22, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant