feat(black-hole): Sgr A* lensed close-up — cubemap capture, geodesic lens pass, emission annulus - #645
Merged
Conversation
… on body slab Design settled Q1-Q11 (cubemap geodesic pass, Schwarzschild, faint EHT-style glow, 2 r_s floor, zero settings); parked pending the body-slab camera work. Backlog entry added under Rendering as blocked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
skymap | 0006507 | Commit Preview URL Branch Preview URL |
Sep 02 2026, 12:47 AM |
Spec written against the post-#634 body-slab architecture with ground prep P1-P3 (slab candidacy from body data, per-body standoff, fixed-size render targets) as a separate prep PR. Backlog item retired into the spec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ses) Review fixes over the draft: perf halt-condition wording corrected in plan and spec (regression/unbounded halts, not neutral), per-face context hand-off responsibility named in Task 12, LUT finite-distance approximation noted as gate-judged in Task 13. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
visibleSlabBodies now takes a flat `bodies: readonly SceneBody[]` list
instead of hardcoding `{earth, planets}` and concatenating internally
— the caller assembles the candidate list, so an AnchorPointBody (Sgr
A*) can join the roster on the same frustum/pixel-cull terms as a
planet. BODY_SLAB_CAPACITY sums the same three sources. Zero behaviour
change for every existing scene: the anchor's culls reject it at any
sane viewing distance, and the new SCENE_ANCHOR_POINT_BODIES array
mirrors SCENE_PLANETS so a second anchor is a data append, not a
call-site edit.
Also corrects the stale "DRAWS NOTHING" docblock claim in
AnchorPointBody.d.ts and sgr-a-star.ts — a future anchor may draw a
far-field glint and lensing-band geodesic pass via dedicated
ContentLayer rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…yer targets Adds the RenderTargetSpec.fixedSizePx field and the reconcile/allocate branch that honours it, ahead of the sky-cubemap target row that needs a fixed 2d-array texture regardless of canvas size (Phase B). No production row sets it yet; createRenderTargets gains a test-only extraRows injection seam so the branch is exercisable now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding: the test file's module-header comment still described
the pre-change `[earth, ...planets]` signature after task 1's
`{ bodies }` param rename — mirrors the src file's already-correct
header at visibleSlabBodies.ts:11.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
clampDistance gains an optional standoffRadii third param (default SURFACE_STANDOFF_RADII) so a focused body can override the Earth-tuned global zoom floor. AnchorPointBody carries the override; SGR_A_STAR sets it to 2.0 (Q10's descent floor). Threaded through the zoom path that actually reaches clampDistance for a focused body — zoomedDistance/zoomedPose, applyWheelZoom, and both attachOrbitControls zoom sites (pinch, wheel-during-gesture) — via a new pivotStandoffRadii(row) helper mirroring pivotRadiusMpc, read the same way in wireInput.ts. Every other body (no override) falls through to the existing default unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nRow Every existing zoom-path test hand-builds a SelectionRow with standoffRadii already set, so the 'standoffRadii' in body narrowing in extractSelectionRow.ts had no coverage against the real SCENE_BODIES seed — a typo'd property or a reversed condition would silently drop Sgr A*'s Q10 floor with the suite green. Adds a test against the real SGR_A_STAR seed plus an explicit undefined-field assertion for a body with no override, so a reversed condition fails on either side. Verified by temporarily reversing the condition: the new Sgr A* case goes red (undefined vs 2), confirming it catches the regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Framing getter
Task 2 threaded the per-body camera-standoff floor as a second parallel
getter lane beside pivotRadiusMpc through the orbit-controls input stack
(OrbitControlsOptions -> orbitControls -> applyWheelZoom -> zoomedPose ->
zoomedDistance -> clampDistance). Reworks that into a single
`pivotFraming: () => PivotFraming` getter carrying { radiusMpc, floorMpc }
in one bundle, per the user's rejection of the two-lane shape.
floorMpc is precomputed at the lane source (pivotRadiusMpc.ts's new
pivotFraming(), built beside the untouched pivotRadiusMpc() scalar export
that runFrame.ts/frameContext.ts/logCameraState.ts still consume directly),
so clampDistance drops back to a plain two-arg (distance, floorMpc) clamp
with no pivot-radius/standoff-ratio knowledge of its own. zoomedDistance
keeps reading the bundle's raw radiusMpc for its altitude-taper anchor
(h = distance - radiusMpc) alongside the precomputed floor — the two fields
diverge on purpose, which is why they ride one bundle instead of collapsing
into a single number. Deletes pivotStandoffRadii.ts (merged into
pivotFraming); Sgr A*'s 2 r_s override stays covered by
pivotRadiusMpc.test.ts's pivotFraming describe block plus a wireInput.ts
wiring test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 2b review (fix round 1): - SelectionRow.d.ts's standoffRadii comment still named the deleted pivotStandoffRadii helper; points at pivotFraming instead. - pivotRadiusMpc.ts's module header ran 12 lines, over the ≤10 budget; trimmed both doc comments to their load-bearing why (outside-lane consumers, the null-radius floor equivalence) and dropped restated detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace SGR_A_STAR_SCHWARZSCHILD_RADIUS_KM constant with mass-derived physics util schwarzschildRadiusM(). Enables mass-keyed derivations (deflection LUT, emission annulus) to share the same computation. - Add schwarzschildRadiusM(massSolar): m with G/c constants - Add SGR_A_STAR_MASS_SOLAR = 4.297e6 (GRAVITY Collab 2019) - sceneSgrAStar.ts: radiusM = schwarzschildRadiusM(SGR_A_STAR_MASS_SOLAR) - sStarOrbitInfo.ts: migrate pericentre/r_s card row to new source - Delete sgrAStarSchwarzschildRadiusKm.ts (0 remaining refs) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the scale-fade band that engages the black-hole lens pass on close approach to Sgr A*. The band inverts the direction of milkyWayApproachGc (full at close edge, 0 at far edge) and pins the edge ordering via a classifier test that would catch a backwards band. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Introduces BlackHoleRow type in src/@types/data/BlackHoleRow.d.ts defining the emission parameters for supermassive black holes (inner/outer Schwarzschild radii, inclination, position angle, flicker). Adds BLACK_HOLES append-only registry in src/data/blackHoles.ts with the Sgr A* entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address review findings (fix round 1): 1. Test: use spec's externally cited figure (12.69e6 km = 1.269e10 m, GRAVITY 2019) with explicit ~0.1% relative tolerance, not back-filled implementation output. Show reference source in test comment. 2. schwarzschildRadiusM.ts: consolidate inline comments to stay within budget (≤3 comment lines, half of 6 code lines). 3. Test tolerance comment: clarify ~0.1% relative (not 0.004% absolute). UI impact noted: S-star pericentre card shifts ~0.027%, Sgr A* radius from 12.69e6 km to 12.69337e6 km — expected outcome of using mass-derived constant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 10 of the Sgr A* black-hole render plan: the 144-byte uniform byte-layout contract between the (not-yet-written) lens WGSL pass and its CPU-side uploader. Follows the CameraUniforms shared-prefix pattern — 80-byte cam prefix, renderer-specific scalars at offset 80+, anchorPosRelCamM (the f64->f32 rebase seam) 16-byte aligned at 128. Adds a byte-offset parity test in the style of packAtmosphereUniforms.test.ts, driving the real packer with distinct sentinel values per slot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The black-hole lens's sky-capture half: a fixed-size 6-layer 2d-array `sky-cubemap` row (Task 3's `fixedSizePx` mechanism) plus a per-face synthetic camera derivation, following the `pickFrameContext.ts` precedent — re-deriving a full `ReadyFrameContext` so roster layers' frame-global reads (fovYRad, canvasSize, drawPxPerRad) stay correct rather than threading a swapped vp by hand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CPU-side bending-angle table (task 9 of the render-black-hole plan): buildSchwarzschildDeflectionLut integrates (du/dphi)^2 = 1/b^2 - u^2 + u^3 via bisection for the turning point plus composite Simpson's rule under a theta = arcsin(u/u0) substitution that regularises the integrable sqrt singularity at the photon sphere. Sub-critical impact parameters (capture) sample as Infinity. Reference values in the test were hand-derived independently via mpmath high-precision quadrature under a different (algebraic r-space) substitution, cross-checked against scipy's adaptive quadrature, never re-derived from the module's own formula. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding (fix round 1) on 0423762fa: the existing parity test only re-asserted the packer's own documented offsets against itself, never reading sgrAStarLensing.wesl — a field reorder there would sail through undetected. Adds sgrAStarLensingUniformsLayout.parity.test.ts, following the atmosphereUniformsLayout.parity.test.ts / nodeParamsLayout.test.ts precedent: parse the struct out of the .wesl file, derive its std140 offsets, then drive the real packer with a sentinel per field and assert each sentinel lands where the STRUCT says it should. Verified it catches a reorder by transiently swapping two fields in the .wesl and confirming the new test fails, then reverting. Kept the existing packSgrAStarLensingUniforms.test.ts (hardcoded offset table) as a second, independent leg — the same two-file split packAtmosphereUniforms.test.ts / atmosphereUniformsLayout.parity.test.ts already establishes in this directory, not redundant with the new WESL-driven check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s header, format - buildSchwarzschildDeflectionLut.ts: the module header and endpointValue comment both claimed the theta->pi/2 endpoint-limit derivation lived in the test file; it didn't. Point to the definition site instead and state the (Taylor-expansion) derivation inline in one comment there. - SchwarzschildDeflectionLut.d.ts: trim the header to the load-bearing core — units, grid-indexing convention, capture sentinel. - buildSchwarzschildDeflectionLut.test.ts: npx prettier --write (one line over print width). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mo camera's skyCubemapFaceContext reused the live projection's near (0.01 Mpc / 10 kpc), but the capture's own content — S-stars and the field around Sgr A* — sits at hundreds of AU, seven orders of magnitude inside that plane, clipping it all invisible. SKY_CAPTURE_NEAR_MPC (0.1 AU) is sized for the capture scene instead; far still rides the live projection so distant stars/galaxies survive. depth: null on the sky-cubemap row means this costs nothing but clipping range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Widens bodyGlintsLayer with a second, independent packed source for SCENE_ANCHOR_POINT_BODIES: a fixed warm-orange marker that crossfades out as the black-hole lens pass engages on close approach to Sgr A* (1 - fadeBand(sgrAStarLensing, distMpc)). An anchor never resolves to a mesh, so it rides its own brightness formula outside sceneBodyPartition's glint/mesh XOR, with no apparent-size or solar-system-backdrop fade — the marker is meant to read from anywhere, not just near the Sun. enabled's widening mirrors pickEnabled's existing Earth-caption pattern: checked before (not after) the solar-system backdrop gate, since the anchor's own visibility criterion is independent of it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Derives MAX_GLINTS from SCENE_PLANETS.length + SCENE_ANCHOR_POINT_BODIES.length + a small named margin (mirrors BODY_SLAB_CAPACITY's derivation pattern) instead of the hand-picked 24, which had silently saturated to exactly the current seed count with zero headroom. Inverts the bodyGlintsLayer.test.ts sceneBodyStates mock's default: the anchor now co-locates with the Sun (inert for every near-Sun fixture) instead of its real ~8 kpc position, so only the tests that actually exercise the far-field glint or whose own camera range crosses into sgrAStarLensing's fade zone need an explicit override — down from 4 reconciled tests to 4 explicit setups, all using mockImplementationOnce instead of the previous getMockImplementation / mockImplementation / try-finally save-restore. Also drops the dead SCENE_PLANETS import (comment-only references) and corrects sgr-a-star.ts's stale comment claiming the far-field glint would ride a dedicated ContentLayer row — it rides the existing bodyGlintsLayer instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iring skyCubemapCaptureSchedule is a pure round-robin scheduler for the Sgr A* lens's 6-face sky capture: full sweep on band entry, one face/frame otherwise, with a staleness/camera-move escape valve. frameProgram grows a fourth parameter (the faces to capture this frame) and emits one 'sky-cubemap' render step per face, gated on the lensing band alpha so zero steps are emitted outside it. renderFrame derives the schedule each frame from the band alpha (keyed on camera distance from the galactic-centre anchor, same as every other sgrAStarLensing consumer) and threads it through; the schedule's own cross-frame bookkeeping (lastCapturedAtMs, frameIndex, band-active edge, last-sweep camera position) lives on a new cameraRuntime.skyCubemapCapture Resource, following the same amortized-Resources pattern its sibling fields use. FrameStep's 'render' kind grows an optional `face` field, and a new renderStepTimingSlotName helper (slabs.ts) disambiguates the 6 capture steps' timing-slot names — they all share (target, slab), unlike a body row which gets its own slab index for free. Finding (not resolved here): no ContentLayer row targets 'sky-cubemap' today, so these steps currently select an empty group and draw nothing. The schedule, timing-slot derivation, and DebugPanel plumbing are ready; wiring the actual roster (point-sprites/star-points/star-catalog/ star-aggregates) into the capture target is follow-up work — see the Task 12 report for the full writeup of why "reuse the existing layers' draw calls" doesn't fall out for free under the current (target, slab) grouping model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 1 (31d1c94be) shipped the capture schedule and frameProgram wiring but never built the brief's "runtime hand-off" step: renderFrame deriving each scheduled face's skyCubemapFaceContext and passing it to the executor so a capture step resolves its OWN camera instead of the frame-wide one. That omission went undisclosed in the round-1 report, unlike the (correctly flagged) roster-target-string finding — a real compliance gap, not a judgment call. Fixed here: ExecuteFrameArgs grows an optional skyCubemapFaceContexts map; executeFrame's 'render' case resolves a face-carrying step's SlabView/ enabled/draw ctx from that map instead of the frame-wide ctx, skipping the step cleanly when a face has no context (mirrors skyCubemapFaceContext itself returning null pre-bootstrap). renderFrame derives the map each frame — one skyCubemapFaceContext call per scheduled face, eye = Sgr A*'s anchor, faceSizePx read off the sky-cubemap row's own declared size — and threads it through. The draw group these steps select is still empty (the separate, still-open roster-wiring finding from round 1); this only builds the ctx/view hand-off. Also trims skyCubemapCaptureSchedule.ts's comment budget (33/28 → 12/28 comment/code lines) per the review's minor finding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (Ruling 6) The black-hole lens's sky-cubemap capture steps (Task 12) all target 'sky-cubemap', so target-matching could never select a draw group for them — no roster layer's own `target` is 'sky-cubemap'. Add `ContentLayer.skyCapture?: true` and have executeFrame's capture-step branch select its group by that flag instead of `target`, keeping the existing slab match unchanged. Zero-cost guarantee holds: no layer carries the flag yet (see the next commit's report), so a capture step's group is still empty and the step still draws nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…shader Adds the sgr-a-star-lensing ContentLayer (Task 13): a 'body'-slab row drawing only on Sgr A*'s own body-m slab, gated on the fade band's alpha (Q6's zero-dispatch guarantee). The vertex stage builds a camera-facing billboard from the anchor's camera-relative position alone (the locked 144-byte uniform contract carries no camera basis or inverse-viewProj to unproject a screen ray from otherwise); the fragment classifies each ray as captured (black), escaping (a Task-9-LUT-deflected sample of the Task-11 sky cubemap), or crossing the accretion annulus (a bounded 48-step march with doppler + gravitational-shift + flicker modulation), composited premultiplied- OVER so a captured ray truly occludes the additive light behind it. Infinity encoding: the LUT's captured samples (b <= b_c) are replaced with a large finite sentinel before the r32float upload — raw IEEE Infinity round-trips through the texel exactly, but a fast-math shader compiler isn't guaranteed to preserve Infinity arithmetic (docs/RENDERER.md already tracks float edge-case flushing as a live landmine), so a threshold comparison well clear of both the sentinel and any real quadrature result is the robust encoding either way. Ground preparation this needed: RenderTargets.cubeViewOf (a dimension:'cube' view over a 6-layer row — viewOf's default 2d-array view can't bind as texture_cube) and lib/math.wesl's rotateAroundAxis (Rodrigues' formula, used for the deflection rotation and the disk plane's tilt/spin — no prior 3D-rotation primitive existed). Headless verification: `npm run perf -- --scenario galactic-centre` against this worktree's dev server reports an empty page-errors section (no Invalid ShaderModule/pipeline error) — the renderer's pipeline is constructed at engine bootstrap, so this catches a shader compile error even though the camera isn't in-band. Full npx vitest run and npm run typecheck are green. Known limitation (not this task's gate — Task 17's visual gate): frameProgram never emits an (hdr, BODY[k]) render step, only (foreground:0, BODY[k]) ones, so this layer is registered and its pipeline compiles but nothing currently invokes its draw(). Wiring that step is Task 14's job per its own forward reference in this layer's file-list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he LUT edge Fading at b = lutMax (50 r_s) blended a sky still deflected ~2.4-3.4 deg (~39-56 px at 100 AU / 1080p / 60 deg fov) into the unshifted roster - the double image at the blend ring. Now: (1) the deflection continues past the LUT as endpoint*(lutMax/b), exactly continuous at the handoff; (2) the fade ends at edgeFadeEndRs (new uniform in the former _pad4 slot), derived per frame as max(lutMax, min(2*drawPxPerRad, 0.6*distRs)) - where 2/b drops below one screen pixel, capped where billboard coverage tops out; (3) the quad sizes itself to cover that fade end (R = b*d/sqrt(d^2-b^2)); (4) the 48-step annulus march is skipped for b > outerRs + 4, output-identically, so the now-near-fullscreen escape region stays cheap. Band gating and the capture roster untouched. Derivation in audit-cubemap-alignment.md section 7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… pop-in The lens layer only draws once a body-m slab exists for sgr-a-star, and candidacy required the hole's own disc to clear the 1-px floor - at a dpr-2 1080p-class viewport that is ~346 AU, where bandAlpha is already ~0.4 (user observed the pop at 341 AU), and the threshold moves with viewport/ dpr. A band-bearing lens body's painted footprint is the quad (up to ~0.75x camera distance), not its r_s-scale disc, so visibleSlabBodies now keeps such a body - both culls bypassed - for the whole support of its SCALE_FADE_BANDS row (goneAt, 500 AU): the slab and the lens step are born exactly where alpha = 0 and onset rides the band ramp. Derivation in audit-cubemap-alignment.md section 8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User judgment against the live view: 512 read as too little. Bumps DEFAULT_SGR_A_STAR_LENSING_TUNING.cubemapResolutionPx (data/defaults.ts) to 1024 and updates its doc comment plus the sky-cubemap row's option-set comment in renderTargets.ts. The knob itself still offers 256/512/1024/2048; no other file carries a competing literal (initialState.ts and makeSettingsFixture.ts both spread DEFAULT_SGR_A_STAR_LENSING_TUNING, and no test pins 512 as the default). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ose-orbit shake Close to the hole the edge-fade end is floored at lutMax (50 r_s) >= the camera's distance in r_s, and vertex.wesl's f32 inversion of b = R*d/sqrt(R^2+d^2) bottomed out its 1e-6 discriminant floor - a 1e6-r_s quad at 20 r_s, whose 5e4x corner-to-centre varying ratio put ~3 mrad of barycentric interpolation noise on every reconstructed ray: sky and disc shaking together while orbiting (introduced by 576a636; the slab-candidacy fix is exonerated in the audit). The inversion now runs in f64 in lensQuadPlaneRadiusRs (new util), capped at 8x the anchor distance (atan(8) ~ 83 deg half-angle, past any viewport half-diagonal; cap reachable only where full coverage is geometrically impossible), and the finished radius rides the struct's last pad slot (quadPlaneRadiusRs, byte 172). Derivation in audit-cubemap-alignment.md section 9. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds AnchorPointBody.focusDistanceRadii, a per-body override (mirroring standoffRadii) that lets focusFraming's body arm skip bodyFocusDistance's FOV-dependent screen-fill formula and land at a fixed radius multiple instead. Sgr A* is seeded at 30.4 r_s — the arrival distance the user framed live, well outside the 2 r_s descent floor and deep inside the lensing fade band. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LMC/SMC/Andromeda draw as textured famous-galaxy thumbnails (texturedDisksLayer), but the roster never included them, so the captured cubemap lacked those galaxies and the lens quad covered the real originals with an incomplete capture. Flags texturedDisksLayer skyCapture: true. Its shared instancedQuadRenderer factory wrote one shared @group(0) camera uniform per draw() call — a capture sweep's several calls (different faces, one submit) would have raced on it, so the factory gains an opt-in per-view-slot buffer+bindGroup ring (InstancedQuadConfig.viewSlotCount, default 1, every other consumer unchanged) that texturedDiskRenderer opts into. The instance buffer itself needs no ring: the disk list is computed once per frame from the real camera, upstream of the capture sweep, so every call re-uploads identical bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Investigated the reported "Andromeda still doesn't appear lensed" defect against commit 2c63b9a, which flagged texturedDisksLayer into the capture roster on the theory that lastOutput.disks (computed once from the LIVE main camera, upstream of the 6-face capture sweep) might miss galaxies a capture face needs but the main view culls. diskPlannerWalk.runFrame reads only cam.position — never cam.target, yaw, or pitch — so its distance/apparent-size gates are direction- agnostic by construction; there is no frustum cull to reuse incorrectly. Verified against the real famous.bin: M31 (row 46, ~0.78 Mpc from both the Sun and the Sgr A* anchor) computes to ~48px at the default 60deg FOV, clearing every gate (4px disk floor, 24px famous-exempt threshold, 23 Mpc max-visible-distance bound) regardless of which way the camera faces. No DiskInstance field is camera-orientation-dependent either (world-space position/size/axisRatio/PA; fadeAlpha is px-based, not direction-based). Adds a regression test pinning this invariant: a Famous row placed on the opposite side of the camera from its look target still lands in lastOutput.disks. Confirmed it fails if the walk grows a direction cull (temporarily injected one, watched the test fail, reverted). No production code changed — the capture-reuse mechanism is already correct; the continued report points at a defect elsewhere in the lensing pipeline, outside this investigation's scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sky-cubemap roster spans two slabs, so frameProgram emits TWO capture steps per face (COSMO then NEAR0). executeFrame forced `loadOp: 'clear'` on every step carrying a `face`, so the NEAR0 step cleared away the galaxy points and textured disks the COSMO step had just drawn into that same face. Only the star layers survived into the cubemap — which is why M31 / LMC / SMC vanish once the lens fade band engages and the visible sky IS the capture. The blanket always-clear (ebbf79e) was the right fix for its own bug — `touched` tracks by target, and 'sky-cubemap' has six layers — but landed after the two-step split and so over-corrected. Capture steps now take their first-touch fact from a private `(target, face)`-keyed set, which clears once per face and loads within it. `ctx.renderedTargets` stays keyed by bare target id: it is a public consumer surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 2.21) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan's Task 15 called for deleting the debug-panel tuning cluster before merge and baking its values into BLACK_HOLES / shader consts. It ships instead: the Tier-2 fields have no owner other than settings.sgrAStarLensingTuning, the emission look is taste that keeps being retuned, and cubemapResolutionPx is a live VRAM/sharpness trade. The 176-byte uniform tail is therefore permanent. Softens every "TEMPORARY / removed at the removal step" comment into a statement of what the code is, retitles the panel section, and records the amendment in the spec's §Settings and the plan's Global Constraints + Task 15. Sizes the lens uniform buffer off SGR_A_STAR_LENSING_UNIFORM_FLOATS while the "176 bytes, temporarily" comment beside it goes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…band The row was an ordinary offscreen, so every device held 1024x1024x6 rgba16float (50 MB) from boot whether or not the camera ever went near Sgr A* — contrary to the spec's "zero cost outside ~500 AU" claim. A `RenderTargetSpec.allocateWhen` predicate now gates a row's existence on the same per-frame `reconcile` seam a moved size already rides; `sky-cubemap` keys it off the lensing-band flag on `skyCubemapCapture`, and `reconcile` releases the texture and its views when the band closes. `runFrame`'s reconcile runs before the frame's camera pose exists, so `renderFrame` reconciles on the band EDGE — the entry frame is also the frame that sweeps all six faces. `wasBandActive` becomes `bandActive` (it is now read as the row's allocation condition, not only as the edge's previous value), and the capture bookkeeping folds under `if (bandActive)` so nothing re-pins the eye on a frame that cannot capture. The lens renderer + LUT stay boot-eager: a one-off 2 KB texture and two pipelines, not 50 MB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spec's capture roster composites the Gaia stream "the same way star-upsample already does", but the aggregate stream wrote its raw linear glow straight into the face: the knee lives in star-upsample, which runs over the half-res offscreen and has no counterpart behind a capture face. Captured aggregate glows were therefore brighter and more saturated than the same stars in the direct view drawn beside them at the band crossfade. Un-braids the two things `StarDrawStream` was deciding at once: `stream` still picks the node set and the buffer pair, and a new `knee` arg picks the fragment entry point. The aggregate stream asks for the knee at deposit exactly when it draws into a capture face. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- packSgrAStarLensingUniforms takes a named bag: 17 of its 22 positional args were bare numbers, so a transposition would type-check and pass the offset parity test. - The lens flicker phase keys on the SIM clock (spec §Data), wrapped into [0, 2pi) in f64 — a paused clock now holds the flicker still, and the raw phase at a J2000 epoch is far past f32's resolution. - bodyGlintsLayer addresses Sgr A* directly instead of looping over SCENE_ANCHOR_POINT_BODIES with Sgr A*'s own band and tint; drops the `states.get(...)!` throw path, and corrects the backdropFade invariant comment the anchor widening falsified. - Per-layer GPU timing slots carry the capture face, and timedSlotRowsOf emits the matching rows, so a capture pass and the real (hdr, NEAR0) pass no longer overwrite each other's ?gpuTimings numbers. - renderFrame hoists the galactic-centre region lookup; skyCubemapFaceContext takes the frame's nowMs instead of sampling performance.now() itself; createViewSlotUniformRing throws a named error on an out-of-range slot; the dead draw-side bandAlpha early-out goes. - Tests: the scaleFadeBands case pins direction + monotonicity off the band itself instead of restating its edges, and a new case pins the instance-bytes invariant the un-ringed textured-disk instance buffer rests on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every file the review measured over the comment budget (module header <= 10
lines, comment lines <= half the code lines) is back inside it, except the two
byte-layout-contract files that take the camera.wesl allowance and the .d.ts
files.
What went: the regression narrative ("the old formula returned...", "fix round
3", "audit-cubemap-alignment.md SS7"), which the git log and the ledger already
hold; every `Ruling N` reference, restated as the decision itself; and the
`Task N` citations, whose one-line conclusions are inlined where load-bearing.
`.superpowers/sdd/` is a workspace directory, so those pointers would dangle
the moment it is cleaned up.
What stayed: every landmine — the Y-flip origin, the writeBuffer/submit race,
the f64 quad inversion, the per-face clear, the Infinity/sentinel encoding, the
texture_1d WebKit rejection, the denormal notes. Also corrects the captured
ray's march budget (its 24-step half of 48, not the full budget).
`lensQuadPlaneRadiusRs.ts` is the one file still over: 7 lines of code, and the
f32-in-the-vertex-stage landmine is what the file exists to record.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cleanup Re-review of the black-hole lensing fix round left six residuals; this closes them all: - renderTargets.ts's sky-cubemap `allocateWhen` gated release on a single hard threshold (the lensing band's goneAt, 500 AU), so a camera dithering across that edge destroyed + reallocated 50 MB + 7 views every frame. Now hysteretic: the row is entered on band-active as before, but once allocated it survives until the camera-to-anchor distance exceeds 1.5x goneAt (SKY_CUBEMAP_ROW_RELEASE_MARGIN), fed through the existing `allocateWhen` seam via a new `isAllocated` parameter and a per-frame `gcDistanceMpc` recorded on the capture runtime. - Plan text: Task 16 no longer waits on a "tuning-removal" step the Settings amendment already dropped; Task 17's visual gate picks up the Milky-Way quad-rim discontinuity and the capture-face aggregate knee, both new in this fix wave and never eyeballed; Task 16 now requires a VRAM line for the sky-cubemap row. - Dropped the remaining ledger-speak (`Ruling N` citations with no in-repo referent; `Task N` citations converted to timeless wording) across the sites the re-review enumerated. - starCatalogRenderer's two star pipelines are labelled by compression (kneed/linear) instead of by stream, matching how they're actually keyed. - Fixed the renderFrame.test.ts fixture comment (and its two copies) that claimed the sky-cubemap bookkeeping runs every frame regardless of the band — false since the m9 fix folded the schedule fields under `if (bandActive)`. Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oad, mirror tests Applies the triaged subset of the whole-branch deletion audit (.superpowers/sdd/2026-09-01-render-black-hole/deletion-audit-whole-branch.md): delete the packer test subsumed by its parity sibling, drop createRenderTargets' test-only extraRows seam now that the real sky-cubemap row covers it, retire the dead flickerTimescaleS uniform field (CPU-side phase precompute keeps the tuning value; the shader never read it), drop createViewSlotUniformRing's unused slotCount override, collapse the two subsumed LUT test cases and three default-parameter/literal-restatement test cases, fold BAND_SLAB_FLOOR_MPC from a one-entry map to a direct id compare, and un-export two symbols with no importers. Co-Authored-By: Claude Code <noreply@anthropic.com>
…estation) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rulkens
marked this pull request as draft
September 2, 2026 00:39
DoD audit READY: 8011 tests green, typecheck clean, T1–T17 ticked (T17 user-attested 2026-09-02), perf neutral outside the band / bounded inside, final review + deletion audit applied. Plan + spec relocated to completed/, SDD ledger archived beside the plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rulkens
marked this pull request as ready for review
September 2, 2026 00:43
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Sep 3, 2026
rulkens
added a commit
that referenced
this pull request
Sep 3, 2026
…r capture faces (#667) * fix(star-catalog): capture faces bypass the per-catalog node fade The Gaia node fade is keyed per catalog but prepareStarCut runs once per ctx, and the sky-cubemap capture faces run before the main view each frame with the same nowMs. Faces after the first saw dt = 0, so every node not in the previous call's active list was a newcomer frozen at opacity 0, and the faces' active-list swaps poisoned the main view's fade. A capture view (viewSlot !== 0) now draws its whole cut at opacity 1 and never touches the fade state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(black-hole): sky cubemap is a one-shot static bake Everything the capture roster draws is kpc away: a 1024² face is ~1.5 mrad per texel, and moving that content by a texel takes ~12 pc of camera travel against a 500 AU lens band. One sweep is texel-exact for the whole band, so the round-robin capture, the 2 s staleness valve, the pinned-eye tracking and the recaptureCameraMoveFraction knob are gone. The bake re-runs only when its inputs change: settings/selection/tier by reference, the row's allocated face size, or a source-visibility ramp in flight. star-points leaves the roster: the flag never drew (placeholder face pose distance trips its foreground gate) and the S-stars need finite-distance lensing, not an at-infinity cubemap. Consumes the static-bake backlog item and the two deletion-audit items parked at the #645 ship. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(black-hole): re-bake the sky cubemap while thumbnails are still landing Famous-galaxy disks arrive after the visibility ramp settles (atlas upload + 400 ms load fade), so the settle bake had no disks. The key's ramp flag becomes rosterSettling: visibility fades OR texturedDisks.hasInFlightWork(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(black-hole): bake memory is one settings reference Drop the bake key type and comparison module: the runtime remembers only the settings slice the last settled bake ran under. tier swaps dissolve through the fade registry, the resolution knob is a settings write whose reallocation precedes renderFrame, and a stale selection halo in the lensed sky is accepted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
rulkens
added a commit
that referenced
this pull request
Sep 9, 2026
…eState (#670) * refactor(engine): move skyCubemapCapture off cameraRuntime onto EngineState The sky-cubemap bake bookkeeping is render state, not camera state — nothing camera-side reads or writes it; #645 parked it on `cameraRuntime` only because that was the nearest bag of per-frame mutable memory. It now sits beside the other engine-owned bags, where its single writer (`renderFrame`) and single reader (the `sky-cubemap` row's `allocateWhen` in `renderTargets.ts`) find it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(engine): name the sky-cubemap band fields as last-frame memory `bandActive` → `lastBandActive`, `gcDistanceMpc` → `lastGcDistanceMpc`. Both are the previous frame's values, not live ones: `allocateWhen` runs inside `runFrame`'s reconcile, before this frame's pose and body states exist, so the previous frame is all it can see. Backlogs the un-braid that would let both derive instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(types): CameraRuntime header matches its fields The header claimed four Resources while listing more; the count was already stale before this branch removed a field. Dropped the number rather than pinning a new one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Sgr A* black-hole close-up: descend the scale ladder to the galactic centre and the
far-field glint crossfades (500 → 100 AU) into a Schwarzschild-lensed view — a ~5.2 r_s
shadow, an Einstein ring on the captured sky, and a faint EHT-style emission annulus
with doppler asymmetry. Annotations (S-star sprites, orbit trails, labels, rings) stay
crisp and unlensed on top. Focus arrival is 30.4 r_s; the camera floor is 2 r_s.
Spec:
docs/superpowers/specs/2026-09-01-render-black-hole-design.md·Plan:
docs/superpowers/plans/2026-09-01-render-black-hole.md·Decisions:
docs/grill-sessions/render-black-hole-2026-09-01.md.Ground prep (P1–P3) landed separately as #649.
How it works
buildSchwarzschildDeflectionLut): exact Schwarzschild bendingangle vs impact parameter, 512 texels, r32float; a weak-field 1/b tail beyond the
table keeps the sky continuous out to the quad edge.
sky-cubemap, 6 × 1024² rgba16float): captured live from apinned eye, one face per frame round-robin, full sweep on band entry and whenever
the camera moves > 3 % of its distance to Sgr A*. Roster = galaxy point sprites,
Gaia star catalog + aggregates (kneed like the main view), S-stars, textured famous
disks. Lazily allocated: 0 B outside the band, 50 MB inside, released beyond
750 AU with hysteresis.
separate per-slot uniform buffers, closing the
writeBuffer-at-submit race for anymulti-view frame (XR eyes ride the same seam later).
sgrAStarLensingWESL pair): per-pixel escape/captureclassification, bent-ray annulus march kinked at closest approach, subpixel-keyed
edge fade; quad radius solved CPU-side in f64 to avoid barycentric ray noise.
the lens step, and a post-lensing NEAR0 pass; outside it the step list is
byte-identical to before.
ruling — disc orientation, strength/tint, scale height, doppler, edge fade,
cubemap resolution (256–2048). Defaults are baked in
BLACK_HOLES.Perf (Task 16)
Ledger:
docs/superpowers/plans/completed/2026-09-01-render-black-hole.ledger.md.servers) — earth-surface and milky-way inside base's own spread; galactic-centre
+1.2 ms spread evenly across passes the branch never touches (volume, bloom), no
new slot.
sgr-a-star-lensscenario, 96 AU): 16.1 ms merged(~62 fps ceiling) vs ~10–11 outside; one face per frame (~3.1 ms COSMO + ~1.2 ms
stars at 1024²), lens pass 0.7 ms.
Reviews
Final whole-branch review (0 critical / 4 major / 16 minor, all fixed and
re-reviewed), deletion audit applied (−274 LOC net, branch judged lean).
Open / follow-ups
(
docs/backlog/2026-09-02-lens-crossfade-duplicate-points.md).and a Bruneton-style 2-D table replacing the march: future spec candidates.
Test plan
npm run typecheckclean,npx vitest run8011 greennpm run perfin-band and out-of-band free of pipeline/shader errorscubemap aligned with the direct sky; disc in front, lensed; focus arrival 30.4 r_s
· glint handoff · Milky-Way at the quad rim · capture-face aggregate knee)
🤖 Generated with Claude Code