Gravitational lensing: SIS + NFW thin-lens with precomputed NFW image-finding LUT#365
Open
rulkens wants to merge 40 commits into
Open
Gravitational lensing: SIS + NFW thin-lens with precomputed NFW image-finding LUT#365rulkens wants to merge 40 commits into
rulkens wants to merge 40 commits into
Conversation
Deflect background galaxies around a foreground lens directly in the points vertex stage, where each source's distance from the camera and the lens is known. This makes the effect physically honest: only sources *behind* the lens are bent, and the deflection scales with D_ls/D_s — the kind of distance-dependence a screen-space post-process pinch filter can't express (it would bend the foreground and Milky Way too). The lens model is a singular isothermal sphere (SIS): constant Einstein deflection theta_E, source at separation beta imaged outward to theta = beta + theta_E, so sources near the line of sight pile into an Einstein ring. A Gaussian envelope (~3 theta_E) localises the effect so an idealised SIS doesn't shift the whole sky. New helper: shaders/lib/lensing.wesl. Prototype shortcuts (to be formalised in a spec/plan): - Lens centre is the camera orbit target (the thing you've zoomed into), not a mass-weighted cluster. - theta_E is a UI-exaggerated angle in degrees (real cluster Einstein radii are ~arcseconds, invisible at zoom). - Only the SIS primary image is rendered; the inner counter-image and NFW profiles are follow-ups. Wiring: - io.wesl Uniforms gains a lensing block appended at offset 176 (struct grows 176 -> 208 bytes); picker in-place writes are all < 176 so they stay valid and picking lenses with the same params. - pointRenderer packs lensCenterWorld/lensEnabled/lensThetaE; threaded via PointDrawSettings + pointSpritesPass. - Toggle + exaggerated Einstein-radius slider live in the Debug panel (debug settings cluster), off by default. Settings auto-wake the renderer via the existing WAKE_ROUTES. Tests updated for the new debug fields + PointDrawSettings; full suite green (2978 tests). WESL link validated via the real wesl-plugin under vitest (pointRenderer test imports points/vertex.wesl?static). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7a4Pwz8yTrDtRcwGtojZb
Brings the lifted-out picking work (PR #362) onto the gravitational-lensing spike. The only conflict was in pointRenderer.ts, where both sides reshaped the uniform pack: main extracted packing into packPointUniforms.ts as the single source of truth (176 bytes), while the spike extended the layout to 208 bytes with a lensing block at bytes 176..207. Resolution keeps main's structure (draw() returns the packed ArrayBuffer via packPointUniforms) and ports the lensing block into packPointUniforms.ts: UNIFORM_BYTES → 208, plus the lensCenterWorld/lensEnabled/lensThetaERad writes. Added a byte-offset guard test for the new block so the layout can't drift (the iOS-freeze footgun). PointDrawSettings, the pointSpritesPass call site, and the WGSL Uniforms struct already carried the block and auto-merged clean. 3033 tests pass; typecheck clean on both projects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nses Builds on the single-lens SIS prototype (34ebb91) into a physically grounded, multi-cluster effect: - Counter-image: each source draws as two quads (vertex_index 0..5 = primary, 6..11 = inner counter-image), filling the disk inside the Einstein ring that the primary alone left hollow. Draw widens to draw(12, N) only when lensing is on; off path is unchanged. - Physical magnification: each image's intensity scales by |mu| (primary 1 + d/b, counter d/b - 1), so the ring reads genuinely bright and the counter-images fade to nothing at the Einstein radius. Capped at the finite-source limit to protect the additive HDR target. - Multi-cluster lenses: every in-view cluster becomes a lens. The vertex stage loops over the lens array, SUMMING each foreground lens's deflection (weak-shear field + per-ring push) for the primary, and rendering the dominant lens (largest d/b) for the counter-image - sidestepping the 2^N multi-image blowup. Lenses pack into the points uniform tail (192 -> 448 bytes, no new bind group; the pick pass inherits them), capped at MAX_LENSES (16) for per-vertex ALU / iOS. - buildClusterLenses (pure util + tests): selects clusters in front of the camera, sets each Einstein radius to master-angle x significance (normalised-M500 mass proxy), keeps the most massive MAX_LENSES. Single-plane only (no inter-lens bending) and angular Einstein radius (observer-distance-independent, the physically faithful SIS behaviour). Full suite green (3042 tests); WESL links to valid WGSL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gate each SIS lens's deflection on the impact parameter b = D_l·sin β (perpendicular distance from the cluster centre to the source's line of sight) via a world-Mpc Gaussian envelope, on top of the existing angular one. The angular envelope's physical width is ~3·θ_E·D_l, which grows with the eye→lens distance, so a pulled-back camera lensed an ever-wider cone of background. Capping b at a fixed Mpc scale confines each lens to a fixed-width pencil along the line of sight: sources directly behind the cluster (b → 0) stay bent at any depth so the ring survives, off-axis sources are not, and a distant camera no longer widens the reach. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the NFW work from claude/skymap-gravitational-lensing-57c5cg into this branch. That commit was written against the single-lens prototype; here it is ported into the multi-cluster lensTerm architecture (summed deflection + dominant-lens counter-image + magnification + impact- parameter envelope) this branch has since grown. - lib/lensing.wesl: nfwShape() (Wright & Brainerd g(x)/x, peak-normalised) and a mode branch in lensTerm. NFW's x = beta*D_l/r_s IS the impact parameter in scale-radius units, so it self-localises exactly like the SIS impact-parameter envelope — the two designs compose cleanly. SIS keeps the angular + impact-parameter envelopes; NFW needs neither. - io.wesl / packPointUniforms: the two header pad slots before the lens array become lensMode (u32) + lensScaleRadius (f32). Struct stays 448 bytes; picker in-place offsets (all < 176) untouched. - PointDrawSettings: lensMode + lensScaleRadiusMpc apply to every in-view lens this frame (drop the single-lens lensCenterWorld/lensStrengthRad). - New LensMode type; debug settings gain lensMode + lensScaleRadiusMpc with slice actions, selectors, defaults; DebugPanel SIS/NFW radio + NFW-only scale-radius slider. Full suite green (3043 tests); typecheck + WESL link clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pull the gravitational-lensing fields (enabled, count, mode, scaleRadius, + the 16 lens vec4s) out of the points uniform tail into a SEPARATE, shared LensingUniforms bind group at @group(4), mirroring the existing focusUniform shared-bind-group pattern. Behaviour-neutral — no visual change. The points uniform shrinks back to 176 bytes; the lens data lives in one 272-byte buffer written once per frame in renderFrame, bound by the points AND pick pipelines (and, next, the MCPM volume raymarch — the reason for the extraction: the same buffer can deform the volume field). - New: LensingUniforms{Bgl,Buffer,Value} types, createLensingUniformsBgl (visibility VERTEX|FRAGMENT), createLensingUniformBuffer factory, pure packLensingUniforms (MAX_LENSES + LENSING_UNIFORM_BYTES move here), lib/lensingUniforms.wesl struct. - packPointUniforms / points/io.wesl revert to 176 bytes (lens tail gone). - PointDrawSettings drops lenses/lensMode/lensScaleRadiusMpc, gains lensingBindGroup; keeps lensEnabled (CPU 12-vs-6 draw gate). - points/vertex.wesl reads lensing.* from @group(4); pointRenderer + pickRenderer thread lensingBgl + bind the shared group at @group(4). - buildClusterLenses moves to renderFrame (writes the buffer once/frame); pointSpritesPass just passes the bind group. - initGpu creates lensingBgl + lensingUniform; engine.ts destroy chain. Full suite green (3045 tests); typecheck + WESL link clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The counter-image and magnification are the SIS closed-form result (constant deflection, dθ/dβ = 1). NFW's deflection varies, so reusing the SIS counter placed a spurious inner ghost — the 'weird second set of points'. Stopgap: in NFW mode cull the counter quad and render the primary only (the counter branch is now gated on SIS mode); SIS is unchanged and keeps its exact counter + magnification. The proper fix is specced in docs/superpowers/specs/2026-06-24-nfw-lensing-lut-image-finding-design.md: precompute the inverse NFW lens equation (y = x − s·nfwShape(x)) into a 2D (source-position × reduced-strength) texture LUT on the CPU, sample it once per vertex to place the correct multi-image set with accurate magnification — no per-vertex root-finding, and cheaper than today's analytic NFW. The LUT folds into the shared LensingUniforms bind group. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The LensingUniforms extraction added a 5th bind group to the points + pick
pipelines (uniforms, fade, source, focus, lensing) — but WebGPU caps a
pipeline at maxBindGroups = 4, so pipeline creation failed
('bindGroupLayoutCount (5) is larger than the maximum allowed (4)'), and
iOS/WebKit is stricter still. The points pipeline already used all four.
Fix: the shared lensing buffer rides binding 1 of the points-specific
@group(0) (uniforms at binding 0) instead of its own @group(4). This keeps
points + pick at 4 groups and stays out of the disk pipelines' @group(1)
focus binding (no ripple there). The buffer is still the shared, engine-
owned, write-once unit — LensingUniformBuffer now exposes `.buffer`, which
the point + pick renderers embed in their @group(0) bind group at
construction; the standalone `.bindGroup` remains for a pipeline with a free
group (the volume raymarch) to bind directly. Drops the per-draw
lensingBindGroup (PointDrawSettings) and the @group(4) wiring.
Full suite green (3045 tests); typecheck + WESL link clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes to the lensing render path: - Co-host the shared LensingUniforms buffer at @group(3) @binding(1) (alongside cluster focus) and revert @group(0) to camera-only. Group 0 is the shared camera group the secondary pick renderers (structure rings, Milky-Way) reuse by binding only groups 1+2, so adding lensing there broke their group-0 layout compatibility (the structure-marker-camera-bgl mismatch). Group 3 is the only group those pickers don't declare, so co-hosting lensing there ripples to nothing else. - Draw the counter-image quad (12 vs 6 vertices) only when the lens is on AND the profile is SIS. NFW has no closed-form counter image, so its second quad was always culled — pure wasted vertex work. The primary image is still deflected in NFW. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared-LensingUniforms extraction landed split, not unified: lensing co-hosts the @group(3) focus group for points + pick, with a standalone BGL reserved for the volume raymarch. Repoint the spec's architecture section to that reality — the LUT texture/sampler extend the focus group (@binding(2)/(3), VERTEX) for points+pick; the standalone group gains fragment-visible entries with the later volume phase. Also flag the rgba32float-vs-linear-sampler filtering caveat for the plan to resolve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Behavior-neutral refactor of the @group(3) bind group that now hosts cluster focus + gravitational lensing (and, next, the lensing LUT). The name "FocusUniforms" described binding 0's content but was used as the whole group's identity — it lied harder with each co-tenant. Rename the group-level surfaces to their boundary: SceneUniformsBgl, createSceneUniformsBgl, sceneBgl, sceneBindGroup. FocusUniforms (the struct/buffer at binding 0), createFocusUniformBuffer, and lib/focusUniforms.wesl keep the focus name — they genuinely are focus. Also un-braid the assembly: createFocusUniformBuffer did two jobs (allocate the focus buffer AND compose the multi-tenant bind group). It now owns only the focus buffer (exposes `buffer`); a new createSceneBindGroup assembles the group from the already-built focus + lensing buffers, so the next tenant (the LUT texture + sampler) is added there, not in a focus-named factory. state.gpu.sceneBindGroup holds the assembled group. Fixes stale post-@group(3)-move docs on EngineGpuHandles (lensing is read by points/pick via the scene group, not @group(4); the standalone lensingBgl is reserved for the volume raymarch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 tasks / 7 phases: CPU LUT generator, behaviour-neutral lensedPosition extraction, rgba16float LUT texture resource, scene-group wiring + NFW counter from the table (12-vertex gate for both modes), disk/quad coverage behaviour change with a user-confirmation smoke test, entanglement-radar pass. Supersedes the spec's stale focusUniforms file naming. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A galaxy large enough to resolve into a disk is near the camera (small D_s), so it is rarely behind a lens with meaningful deflection; the only concrete trigger is a crossfade snap, which the Phase-2 lensedPosition seam makes a trivial one-line-per-shader follow-up. Now 9 tasks / 6 phases; removed the manual disk smoke-test gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure CPU generator inverting the dimensionless NFW lens equation y = x - s*nfwShape(x) over a (y, s) grid via dense scan + bisection, emitting [xPrimary, muPrimary, xCounter, muCounter] per cell. nfwShape ported from lib/lensing.wesl; log-scaled s-axis (LOG_K=4) documented with its inverse for the shader. Six tests cover the s->0, super-critical, MU_MAX-clamp, and dropped-third-image limits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Behaviour-neutral: lift the inline multi-lens summation, dominant-lens pick, and SIS-counter assembly out of points/vertex.wesl into a shared lensedPosition(...) + LensedImage struct, so points and pick share one deflection model and the disks can adopt it later with a one-line call. The vs block collapses to a single call; downstream intensity/cull math unchanged. Proven by a ?static link test + a draw-count parity test (SIS 12, NFW 6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Upload the NfwLensLut into an N*M rgba16float texture_2d (f16-packed via the existing floatToF16) with a clamp-to-edge linear sampler; expose texture/view/ sampler + an idempotent destroy(). rgba16float+linear dodges the non-universal float32-filterable feature; never texture_1d (iOS drops the frame). Not yet bound to any pipeline. Four fake-device tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend createSceneUniformsBgl with the LUT texture (binding 2) + sampler (binding 3), VERTEX, and createSceneBindGroup to bind them; build the LUT once at startup in initGpu (dims from the new data/nfwLensLut.ts constants home) and thread its view + sampler into the scene bind group. New EngineGpuHandles.lensLutTexture follows the null->non-null->null lifecycle and is released in destroy(). Not yet sampled by any shader. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lensedPosition samples the precomputed LUT (texture+sampler passed through the fn boundary) for the dominant NFW lens: (y,s)=(beta,strengthEff)*D_l/r_s mapped to UV via the algebraic inverse of the generator's log-s map, signed xCounter placing the inner image. LensTerm gains dL + strengthEff so the query needs no second lensTerm call. SIS stays analytic. The vertex-count gate now doubles to 12 for both modes (NFW's counter is real). LOG_K exported + a TS<->WESL parity test guards the three LUT axis constants from drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Correct three stale @group(4) comments (lensingUniforms.ts, points/io.wesl, packPointUniforms.ts) to @group(3) @binding(1) — points/pick read the lensing buffer via the scene group, not a 5th group. - Guard NFW_SHAPE_PEAK + LENS_MU_MAX against TS<->WESL drift: export them and extend nfwLutConstants.parity.test.ts (a NFW_SHAPE_PEAK drift would silently mis-scale every NFW ring). - Drop the stale 'SIS-only' counter-quad comment (NFW draws 12 too) and soften the pick comment (only the primary lensed image is hit-testable at draw(6)). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The NFW dominant lens's primary used the source-plane summed deflection while its counter used the exact LUT inverse, so the two images were solved by different methods and visibly crossed (inner/outer rings overlapping, unlike SIS). Hoist the LUT sample above the image-kind branch so the dominant NFW lens's primary and counter read one inverse solution, swapping the primary's source-plane push for the LUT-exact image position inside the LUT's y-domain. Continuous at y = Y_MAX (xPrimary -> y, so the swap tends to the source-plane push there). SIS path and NFW-counter arithmetic are byte-identical (the LUT sample is only hoisted, not changed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
skymap | 6a90a46 | Commit Preview URL Branch Preview URL |
Jun 25 2026, 12:17 AM |
…per-lens r_s) Drive each cluster lens from its physical R500 (the deflection-at-infinity alpha_inf proportional to R500^2, the NFW r_s = R500/c500), exposed via one dimensionless strength multiplier: 0 = off, 1 = physical, log slider to ~1000x. Per-lens r_s grows the lensing uniform to two vec4 per lens; the global scaleRadius knob retires. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diagnosis of the ~50% points-pass regression under lensing: 6->12 vert widening + the per-vertex lens loop run on all ~30M invocations. R3 (precompute per-lens eye-relative geometry) folds into the physical cluster lensing plan; R2 (broad-phase cone reject), R4 (pay-as-you-go vert width), R1 (compute cull + indirect counter draw), and the two A/B probes captured here for follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed in Adds the 8-task TDD plan and folds the per-lens eye-relative geometry precompute (dirLens + dL baked into the uniform) into both the plan and the spec's uniform-layout section, so lensTerm drops the per-vertex subtract / length / normalize on ~30M invocations/frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…leRadiusMpc Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ngth Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ader scaleRadius Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ted geom in lensing.wesl Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…adiusMpc setting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_s write Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…528-byte LensData Final whole-branch review surfaced three doc-comments in files outside the implementation diff still describing the retired single-vec4 / global-scaleRadius uniform. Comment-only; the code already allocates LENSING_UNIFORM_BYTES. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Lensing, Flow, and Label-Effects tuning sections each carried their own labelled range-input row — two byte-identical local Sliders plus a hand-rolled variant. Lift the row into one DebugSlider (label + pre- formatted readout + numeric onChange) and rewire all three sections, which also makes Label Effects' sliders visually consistent with its siblings. Adds a focused render/onChange test for the shared component. Co-Authored-By: Claude Opus 4.8 (1M context) <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.
What this is
Vertex-stage gravitational lensing for the galaxy point cloud: each background
galaxy is deflected by foreground cluster lenses, rendered as a primary image
plus a counter image (12 verts/point when lensing is on). Two lens profiles —
SIS (analytic) and NFW (driven by a precomputed image-finding LUT).
The branch is large because it stacks several prior lensing commits; the
NFW image-finding LUT is the work from this session and the part most worth
reviewing closely.
Where to focus the review (this session's LUT work)
These commits, newest first:
e6d1194afix(lensing): drive NFW primary image from the LUT — theconsistency fix. Before this, the NFW primary used the source-plane summed
deflection while the counter used the exact LUT inverse, so the two images
were solved by different methods and visibly crossed (inner/outer rings
overlapping, unlike SIS). Now the LUT sample is hoisted above the image-kind
branch so the dominant NFW lens's primary and counter read one inverse
solution. The primary's source-plane push is swapped for the LUT-exact image
position only inside the LUT's y-domain (
y <= Y_MAX), continuous at theboundary (
xPrimary -> yasy -> Y_MAX). SIS + NFW-counter math unchanged.9622ece4drive the NFW counter image from the LUT — option (b) textureparams on
lensedPosition, the(y, s) -> UVinverse map, the 12-vert gatefor both modes, and the TS↔WESL constants parity test.
88e4d21bwire the NFW LUT into the @group(3) scene group — BGL bindings2/3 (
rgba16floattexture + filtering sampler, both VERTEX), bind-groupentries, LUT built at startup, lifecycle/teardown.
41b6317ecreateNfwLensLutTexture GPU resource —rgba16float+ linearsampler (dodges the non-universal
float32-filterablefeature), f16 pack.bd8f7bfeextract lensedPosition into lib/lensing.wesl — faithful 1:1lift, pre-wires the shared deflection model.
4372bdb0NFW image-finding LUT generator (CPU) —buildNfwLensLut:dense-scan + bisection root finder, log-scaled s-axis, channels
[xPrimary, muPrimary, xCounter, muCounter].Architecture
y = x − s·nfwShape(x)on the CPU into a 256×64rgba16floattexture, sampled in the points vertex stage — no per-vertex root-finding.
LOG_K = 4.0); the shader UV map is the algebraicinverse of the generator's forward map. TS↔WESL constant parity is test-guarded
(
nfwLutConstants.parity.test.ts) since?staticWESL linking injects no values.@group(3)(scene group) alongside focus andthe lensing uniform buffer — the only group secondary pick renderers don't declare.
Verification
npm run build— green (WESL links; the iOS-fatal-shader class is the reasonthe build gate matters here).
npm test— 3070 passing.npm run typecheck— clean.Outstanding before merge
satisfied — I have not eyeballed the NFW rings rendering. Worth a look on the
dev server before this lands.
docs/superpowers/plans/2026-06-24-nfw-lensing-lut.md(+-part2.md).Disk/quad lensing coverage was deferred (galaxies large enough to be disks are
near-camera and rarely lensed); the
lensedPositionextraction pre-wires it.🤖 Generated with Claude Code