Skip to content

feat(black-hole): Sgr A* lensed close-up — cubemap capture, geodesic lens pass, emission annulus - #645

Merged
rulkens merged 62 commits into
mainfrom
worktree-render-black-hole
Sep 2, 2026
Merged

rulkens merged 62 commits into
mainfrom
worktree-render-black-hole

Conversation

@rulkens

@rulkens rulkens commented Sep 1, 2026 •

Copy link
Copy Markdown
Owner

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

  • Deflection LUT (buildSchwarzschildDeflectionLut): exact Schwarzschild bending
    angle 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 (sky-cubemap, 6 × 1024² rgba16float): captured live from a
    pinned 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.
  • viewSlot uniform rings: capture faces and the main view record draws against
    separate per-slot uniform buffers, closing the writeBuffer-at-submit race for any
    multi-view frame (XR eyes ride the same seam later).
  • Lens pass (sgrAStarLensing WESL pair): per-pixel escape/capture
    classification, bent-ray annulus march kinked at closest approach, subpixel-keyed
    edge fade; quad radius solved CPU-side in f64 to avoid barycentric ray noise.
  • Band-gated step split: inside the band the frame program emits capture steps,
    the lens step, and a post-lensing NEAR0 pass; outside it the step list is
    byte-identical to before.
  • Debug tuning section ("Sgr A* lens tuning" in the debug panel) ships by
    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.

  • Outside the band: neutral. Paired A/B against the base commit (alternating
    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.
  • Inside the band (new sgr-a-star-lens scenario, 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.
  • VRAM: 0 B outside / 50 MB in-band at 1024 / 201 MB only at the 2048 knob.

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

  • Backlogged: lens crossfade shows subtle duplicate points across the band ramp
    (docs/backlog/2026-09-02-lens-crossfade-duplicate-points.md).
  • Deferred physics refinements (doppler exponent, Novikov-Thorne ISCO, kink placement)
    and a Bruneton-style 2-D table replacing the march: future spec candidates.

Test plan

  • npm run typecheck clean, npx vitest run 8011 green
  • Headless npm run perf in-band and out-of-band free of pipeline/shader errors
  • Live: M31/LMC/SMC survive the direct→lensed handoff; no pop-in at 500 AU;
    cubemap aligned with the direct sky; disc in front, lensed; focus arrival 30.4 r_s
  • Task 17 checklist (user-attested 2026-09-02) (shadow ~5.2 r_s · Einstein ring · doppler side · band crossfade
    · glint handoff · Milky-Way at the quad rim · capture-face aggregate knee)

🤖 Generated with Claude Code

… 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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 1, 2026 •

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 0006507 Commit Preview URL

Branch Preview URL
Sep 02 2026, 12:47 AM

rulkens and others added 8 commits September 1, 2026 02:56
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>
rulkens and others added 19 commits September 1, 2026 12:35
…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>
…-render-black-hole

Lane files resolved by taking main's tree: main's squash of #649 already
contains the pivotFraming-x-#648 union, and this branch carried no src/tests
content beyond what #649 shipped.

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>
rulkens and others added 19 commits September 1, 2026 19:29
…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 rulkens changed the title docs: Sgr A* black-hole close-up design captured, parked on body slab feat(black-hole): Sgr A* lensed close-up — cubemap capture, geodesic lens pass, emission annulus Sep 2, 2026
@rulkens
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
rulkens marked this pull request as ready for review September 2, 2026 00:43
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rulkens
rulkens merged commit 83798fc into main Sep 2, 2026
2 checks passed
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>
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