From b0bb9f69b079d6e9a066ca90fe9c434d865d68d7 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:32:44 +0200 Subject: [PATCH 1/8] perf(points): shrink VSOut by folding sizePx + isFallback + paCs/paSn Three independent VSOut cleanups in the same file pair: - sizePx (location 13): drop. The fragment used it only to compute the procedural-disk crossfade alpha multiplier. All inputs are per-instance constants, so the smoothstep moves to the vertex stage and folds into out.intensity. Fragment loses a smoothstep + saturate. - isFallback (location 7): drop. Used by realOnlyMode discard and by the magenta highlight tint. realOnlyMode now culls at the vertex stage (same trick as Malmquist mode 1), and the magenta multiplier bakes into out.tint. Fragment loses a per-pixel discard branch and a select. Side benefit: realOnly-gated galaxies are now also non-pickable, which fixes a pre-existing inconsistency where they were invisible but the pick fragment still wrote their identity. - paCs + paSn (locations 6, 15): pack into one vec2 paRotation at location 6. Same wire bytes, frees location 15. Net: VSOut 9 locations -> 6, 64 B -> 56 B. Fragment loses ~5 ALU ops per pixel; vertex picks up cheap per-instance work. Co-Authored-By: Claude Opus 4.7 --- .../gpu/shaders/points/colorFragment.wesl | 42 +++---------- src/services/gpu/shaders/points/io.wesl | 33 ++++------ src/services/gpu/shaders/points/vertex.wesl | 61 +++++++++++-------- 3 files changed, 56 insertions(+), 80 deletions(-) diff --git a/src/services/gpu/shaders/points/colorFragment.wesl b/src/services/gpu/shaders/points/colorFragment.wesl index 047fa607..32dc954e 100644 --- a/src/services/gpu/shaders/points/colorFragment.wesl +++ b/src/services/gpu/shaders/points/colorFragment.wesl @@ -48,8 +48,8 @@ fn fs(in: VSOut) -> @location(0) vec4 { // // The cs/sn pair is pre-computed in the vertex stage and flat- // interpolated, saving millions of trig calls per frame. - let cs = in.paCs; - let sn = in.paSn; + let cs = in.paRotation.x; + let sn = in.paRotation.y; let rotated = vec2( cs * in.uv.x - sn * in.uv.y, sn * in.uv.x + cs * in.uv.y, @@ -65,40 +65,18 @@ fn fs(in: VSOut) -> @location(0) vec4 { let r2 = dot(elliptic, elliptic); // ──────────────────────────────────────────────────────────────────────── - // Real-only mode: discard fallback fragments entirely. The user - // enabled this to see ONLY galaxies for which we have measured - // photometric orientation. - if (u.realOnlyMode == 1u && in.isFallback == 1u) { discard; } - - // ── Procedural-disk crossfade-OUT ──────────────────────────────────────── - // - // The thumbnail subsystem's procedural-disk pass fades IN across - // [u.pxFadeStart, u.pxFadeEnd]; we fade the points-pass OUT with the - // complementary curve. Sum of the two curves is 1.0 across the band, - // so the additive HDR contribution stays constant per galaxy through - // the transition. - let apparentDiameterPx = in.sizePx * 0.5; - let fadeT = saturate( - (apparentDiameterPx - u.pxFadeStart) / (u.pxFadeEnd - u.pxFadeStart), - ); - let pointAlphaMult = 1.0 - fadeT * fadeT * (3.0 - 2.0 * fadeT); - // Discard fragments outside the oriented ellipse. if (r2 > 1.0) { discard; } // Gaussian-like falloff: bright at centre (r²=0 → e⁰=1), fading to - // e⁻⁴ ≈ 0.018 at the edge (r²=1). The per-instance modulators - // (Schechter, angular reweight, depth fade) are folded into - // 'in.intensity' by the vertex stage — see vertex.wesl. - var alpha = exp(-r2 * 4.0) * pointAlphaMult; - - // Highlight fallback rows in magenta when the toggle is on. The 0.3 - // green keeps fallback galaxies recognisable as 'data-y' rather than - // turning them into pure UI accents. - let highlightActive = (u.highlightFallback == 1u) && (in.isFallback == 1u); - let tintFinal = select(in.tint, in.tint * vec3(1.0, 0.3, 1.0), highlightActive); - // Scale the colour by the per-point intensity. - let rgb = tintFinal * in.intensity; + // e⁻⁴ ≈ 0.018 at the edge (r²=1). All per-instance modulators + // (Schechter, angular reweight, depth fade, procedural-disk + // crossfade-out, magenta highlight) are folded into 'in.tint' and + // 'in.intensity' by the vertex stage — see vertex.wesl. Galaxies + // gated by realOnlyMode are culled at the vertex stage too, so this + // fragment never sees them. + var alpha = exp(-r2 * 4.0); + let rgb = in.tint * in.intensity; // ── Source fade-in ───────────────────────────────────────────────────────── // diff --git a/src/services/gpu/shaders/points/io.wesl b/src/services/gpu/shaders/points/io.wesl index f82260c7..2f5bc2da 100644 --- a/src/services/gpu/shaders/points/io.wesl +++ b/src/services/gpu/shaders/points/io.wesl @@ -169,9 +169,10 @@ struct Uniforms { // The thumbnail subsystem's procedural-disk pass fades IN across an // apparent-pixel-size band [pxFadeStart, pxFadeEnd]. Without a // complementary fade-OUT on the points pass, both passes would be - // fully present inside the band — a 'double-bright donut'. We feed - // the same two thresholds in here and multiply the per-fragment alpha - // by '1 - smoothstep(start, end, sizePx)' before output. + // fully present inside the band — a 'double-bright donut'. The + // vertex stage folds '1 - smoothstep(start, end, apparentDiameterPx)' + // into 'out.intensity', so the fragment reads no per-pixel state for + // the crossfade. pxFadeStart: f32, pxFadeEnd: f32, _padFade0: f32, @@ -219,7 +220,7 @@ struct PerVertex { // The synthetic-fallback source ships its axisRatio array filled with // NaN. WGSL's 'abs(NaN)' returns NaN and 'NaN < 0.0' is false, so // the vertex stage routes synthetic rows through the existing - // 'axisRatio > 0 is false' round-mask path with 'isFallback = 0u'. + // 'axisRatio > 0 is false' round-mask path with the fallback flag clear. @location(4) axisRatio: f32, // Position angle in degrees, [0, 180). East-of-north convention; we @@ -286,23 +287,13 @@ struct VSOut { @location(3) @interpolate(flat) instanceIdx: u32, // Forwarded 'abs(axisRatio)' so the fragment stage's elliptical mask - // uses the unsigned magnitude. Sign bit was the fallback flag (now - // extracted into 'isFallback'). Per-instance constant. + // uses the unsigned magnitude. Sign bit (fallback flag) is consumed + // by the vertex stage; only the magnitude reaches here. Per-instance. @location(5) @interpolate(flat) axisRatio: f32, - // Pre-computed cos/sin of the position-angle rotation. Computed once - // per primitive in 'vs' instead of per fragment, saving millions of - // trig calls per frame. - @location(6) @interpolate(flat) paCs: f32, - @location(15) @interpolate(flat) paSn: f32, - - // 1u when this row's orientation came from the deterministic fallback - // (sign bit of axisRatio was set at upload time); 0u for real - // measurements. - @location(7) @interpolate(flat) isFallback: u32, - - // Per-instance billboard radius in screen-space pixels. Used by the - // fragment stage to fade points-pass alpha across the procedural- - // disk crossfade band. All 6 vertices share the same value. - @location(13) @interpolate(flat) sizePx: f32, + // Pre-computed (cos, sin) of the position-angle rotation. Computed + // once per primitive in 'vs' instead of per fragment, saving millions + // of trig calls per frame. Packed into a vec2 at one location. + @location(6) @interpolate(flat) paRotation: vec2, + }; diff --git a/src/services/gpu/shaders/points/vertex.wesl b/src/services/gpu/shaders/points/vertex.wesl index 6da3710d..e60e5ecf 100644 --- a/src/services/gpu/shaders/points/vertex.wesl +++ b/src/services/gpu/shaders/points/vertex.wesl @@ -108,7 +108,16 @@ fn vs( // '@builtin(instance_index)' (local 0..count-1). let myPacked = packSelection(source.sourceCode, ii); - if (u.biasMode == 1u && absMag > u.absMagLimit) { + let isFallbackFlag = select(0u, 1u, p.axisRatio < 0.0); + + // Vertex-side cull: galaxies gated out by Malmquist mode 1 (volume- + // limited) or by realOnlyMode (hide fallback orientations) emit a + // degenerate clip position so all 6 vertices of the quad land outside + // the NDC cube and the rasteriser drops the primitive — no fragment + // work, no pick-texture write. + let malmquistGated = u.biasMode == 1u && absMag > u.absMagLimit; + let realOnlyGated = u.realOnlyMode == 1u && isFallbackFlag == 1u; + if (malmquistGated || realOnlyGated) { var earlyOut: VSOut; earlyOut.clip = vec4(2.0, 2.0, 2.0, 1.0); earlyOut.uv = corner; @@ -116,15 +125,10 @@ fn vs( earlyOut.intensity = 0.0; earlyOut.instanceIdx = myPacked; earlyOut.axisRatio = 1.0; - earlyOut.paCs = 1.0; - earlyOut.paSn = 0.0; - earlyOut.isFallback = 0u; - earlyOut.sizePx = 0.0; + earlyOut.paRotation = vec2(1.0, 0.0); return earlyOut; } - let isFallbackFlag = select(0u, 1u, p.axisRatio < 0.0); - // ── APPARENT-SIZE BILLBOARD RADIUS ─────────────────────────────────────── // // We want each galaxy's billboard to occupy its real angular footprint on @@ -183,23 +187,27 @@ fn vs( let isUnknownColour = p.colorIndex > 100.0; let restColorIndex = select(p.colorIndex - p.kPerZ * zRedshift, 1.05, isUnknownColour); - // Look up the colour for this point's *rest-frame* colour index. - out.tint = ramp(restColorIndex); + // Look up the colour for this point's *rest-frame* colour index, and + // bake the magenta highlight in when both u.highlightFallback and the + // fallback flag are set. Folding the tint multiplier here means the + // fragment reads in.tint directly with no per-pixel branch. + let highlightActive = u.highlightFallback == 1u && isFallbackFlag == 1u; + let highlightTint = select(vec3(1.0), vec3(1.0, 0.3, 1.0), highlightActive); + out.tint = ramp(restColorIndex) * highlightTint; // ── MAGNITUDE → INTENSITY, with every per-instance modulator folded in ── // // intensity = clamp((22 - magnitude) / 8, 0.05, 1.0) // mag 14 → 1.0, // mag 22 → 0.05 // × u.brightness // global slider - // × vMaxWeight (mode 2: 1/V_max) - // × schechterRatio (mode 3: Schechter LF) + // × vMaxWeight (mode 2: 1/V_max) + // × schechterRatio (mode 3: Schechter LF) // × angularReweight (mode 4: HEALPix) - // × depthFade (camera-distance falloff) + // × depthFade (camera-distance falloff) + // × crossfadeOut (procedural-disk handoff band) // - // Folding the four mode/distance multipliers in here means the fragment - // multiplies only per-pixel terms (Gaussian + crossfade + source fade). - // Mathematically identical to applying them per-pixel because all five - // factors are per-instance constants. + // All six factors are per-instance constants, so the fragment only + // multiplies per-pixel terms (Gaussian falloff + source fade). let vMaxAlpha = select(1.0, p.vMaxWeight, u.biasMode == 2u); let schechterMult = select(1.0, p.schechterRatio, u.biasMode == 3u); let angularMult = select(1.0, p.angularDensityWeight, u.biasMode == 4u); @@ -210,12 +218,20 @@ fn vs( let depthFadeRaw = 1.0 / (1.0 + camDistRel * camDistRel); let depthFadeMult = select(1.0, depthFadeRaw, u.depthFadeEnabled == 1u); + // Crossfade-out against the procedural-disk pass. ThumbnailRenderer + // fades IN across [pxFadeStart, pxFadeEnd] (apparent-pixel diameter); + // we fade OUT with the complementary smoothstep so the additive HDR + // contribution stays constant per galaxy through the band. + let apparentDiameterPx = sizePx * 0.5; + let crossfadeOut = 1.0 - smoothstep(u.pxFadeStart, u.pxFadeEnd, apparentDiameterPx); + out.intensity = clamp((22.0 - p.magnitude) / 8.0, 0.05, 1.0) * u.brightness * vMaxAlpha * schechterMult * angularMult - * depthFadeMult; + * depthFadeMult + * crossfadeOut; // Invisibility cull: galaxies whose folded intensity falls below this // threshold contribute imperceptibly to the additive HDR target, so @@ -234,9 +250,6 @@ fn vs( // The visual 'fs' ignores this field. out.instanceIdx = myPacked; - // Forward the fallback flag for the highlight + hide toggles in 'fs'. - out.isFallback = isFallbackFlag; - // Forward 'abs(axisRatio)' so the fragment stage's elliptical mask // uses the unsigned magnitude. Negative values would make the // existing 'axisRatio > 0.0' validity check trip on every fallback @@ -249,13 +262,7 @@ fn vs( // (CCW on sky) but our UV-y points down on screen, AND because // rotating the UV is the inverse of rotating the ellipse. let paRad = -p.positionAngleDeg * 3.14159265 / 180.0; - out.paCs = cos(paRad); - out.paSn = sin(paRad); - - // Forward the per-instance billboard radius in screen-pixels so the - // fragment stage can fade points-pass alpha across the procedural- - // disk crossfade band. - out.sizePx = sizePx; + out.paRotation = vec2(cos(paRad), sin(paRad)); return out; } From 144a402d0d844bb5449af7f97c4a8a3c6b06d6b3 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:45:07 +0200 Subject: [PATCH 2/8] perf(points): move kPerZ from per-instance buffer to per-survey uniform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kPerZ is the K-correction coefficient — a single linear factor per survey (SDSS=3.0, GLADE=1.0, 2MRS=0.0, Famous=0.0, Synthetic=3.0) baked into every row of the per-instance vertex buffer. Per-row storage paid 2.5M copies of the same handful of constants. Move kPerZ into SourceUniforms (the existing @group(2) per-survey uniform that already carried sourceCode + 12 B padding). Free pad slot at offset 4 absorbs the f32; no buffer-size or alignment churn. The vertex shader reads source.kPerZ instead of p.kPerZ. Verified consumer graph: kPerZ as a value is only consumed by the points pipeline. pickColourIndex's secondary caller (proceduralDiskSubsystem) already discards the kPerZ field — only the bake site used it, and that write now goes away. Sentinel-colour rows (colorIndex >= 100) previously wrote kPerZ = 0; the shader's select gates the K-correction off via the sentinel check, so the per-row value never mattered. After the move, all rows use the survey constant; sentinel rows still get the 1.05 substitution. Net: - Vertex buffer: 12 slots -> 11, 48 B -> 44 B per instance. At 2.5M galaxies that's ~10 MB saved on the GPU. - One fewer per-vertex attribute fetch. - Slot indices for axisRatio (6 -> 5), positionAngleDeg (7 -> 6), diameterKpc (8 -> 7), vMaxWeight (9 -> 8), schechterRatio (10 -> 9), angularDensityWeight (11 -> 10). Co-Authored-By: Claude Opus 4.7 --- .../BuildPointInterleavedBufferInput.d.ts | 2 +- .../BuildPointInterleavedBufferMode.d.ts | 6 +- src/@types/rendering/PointRenderer.d.ts | 6 +- .../bake/buildPointInterleavedBuffer.ts | 90 ++++++++---------- src/services/gpu/renderers/pointRenderer.ts | 95 +++++++++---------- .../gpu/shaders/lib/sourceUniforms.wesl | 19 ++-- src/services/gpu/shaders/points/io.wesl | 20 ++-- src/services/gpu/shaders/points/vertex.wesl | 10 +- .../bake/buildPointInterleavedBuffer.test.ts | 53 +++++------ .../gpu/renderers/pointRenderer.test.ts | 42 ++++---- 10 files changed, 159 insertions(+), 184 deletions(-) diff --git a/src/@types/engine/BuildPointInterleavedBufferInput.d.ts b/src/@types/engine/BuildPointInterleavedBufferInput.d.ts index f53139df..cf4779cc 100644 --- a/src/@types/engine/BuildPointInterleavedBufferInput.d.ts +++ b/src/@types/engine/BuildPointInterleavedBufferInput.d.ts @@ -9,7 +9,7 @@ export type BuildPointInterleavedBufferInput = { source: Source; /** * Whether to compute the per-galaxy Schechter ratios as part of this bake. - * Defaults to `'fast'` (slot 10 = 1.0). See `BuildPointInterleavedBufferMode` + * Defaults to `'fast'` (slot 9 = 1.0). See `BuildPointInterleavedBufferMode` * for the trade-off. Optional so existing callers (and the worker * structured-clone roundtrip) keep working without recompilation. */ diff --git a/src/@types/engine/BuildPointInterleavedBufferMode.d.ts b/src/@types/engine/BuildPointInterleavedBufferMode.d.ts index 59ad5391..abb82a73 100644 --- a/src/@types/engine/BuildPointInterleavedBufferMode.d.ts +++ b/src/@types/engine/BuildPointInterleavedBufferMode.d.ts @@ -2,14 +2,14 @@ * BuildPointInterleavedBufferMode — selector for the bake's * Schechter-ratio strategy. * - * - `'fast'` — slot 10 (per-vertex `schechterRatio`) is filled with 1.0, + * - `'fast'` — slot 9 (per-vertex `schechterRatio`) is filled with 1.0, * so the shader's bias-mode branch is a no-op when None / * VolumeLimited / V_max are selected. All three modes - * ignore slot 10 anyway, so this is correct AS LONG AS the + * ignore slot 9 anyway, so this is correct AS LONG AS the * user hasn't picked Schechter LF. This is the default at * upload time — the .bin lands fast (~2 s saved on a * fully-loaded deck). - * - `'with-schechter'` — slot 10 holds the real `min(1, sqrt(nRef/n(d)))` + * - `'with-schechter'` — slot 9 holds the real `min(1, sqrt(nRef/n(d)))` * ratio, computed via `computeSchechterRatios`. * Used either when an upload happens *while* * Schechter mode is already active, or as part diff --git a/src/@types/rendering/PointRenderer.d.ts b/src/@types/rendering/PointRenderer.d.ts index 8a45b919..c4635db4 100644 --- a/src/@types/rendering/PointRenderer.d.ts +++ b/src/@types/rendering/PointRenderer.d.ts @@ -42,11 +42,11 @@ export type PointRenderer = { setBiasUploadCallback(cb: ((source: Source, cloud: GalaxyCatalog) => void) | null): void; /** Install the unload-tail callback for the bias-correction subsystem. */ setBiasUnloadCallback(cb: ((source: Source) => void) | null): void; - /** Splice per-row Schechter ratios into slot 10 of the source's interleaved mirror. */ + /** Splice per-row Schechter ratios into slot 9 of the source's interleaved mirror. */ spliceSchechterRatios(source: Source, ratios: Float32Array): void; - /** Splice per-row HEALPix angular weights into slot 11. */ + /** Splice per-row HEALPix angular weights into slot 10. */ spliceAngularWeights(source: Source, weights: Float32Array): void; - /** Zero slots 10 + 11 for one source or every loaded source. */ + /** Zero slots 9 + 10 for one source or every loaded source. */ clearBiasOverlays(source?: Source): void; /** Total number of points across every loaded source. */ totalCount(): number; diff --git a/src/services/engine/bake/buildPointInterleavedBuffer.ts b/src/services/engine/bake/buildPointInterleavedBuffer.ts index 3395f7b3..6cbc1988 100644 --- a/src/services/engine/bake/buildPointInterleavedBuffer.ts +++ b/src/services/engine/bake/buildPointInterleavedBuffer.ts @@ -74,19 +74,18 @@ import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/B * slot 0,1,2 — position xyz (f32) * slot 3 — magnitude (f32) * slot 4 — colorIndex (f32) - * slot 5 — kPerZ (f32) - * slot 6 — axisRatio (f32) — sign bit carries isFallback - * slot 7 — positionAngleDeg (f32) - * slot 8 — diameterKpc (f32) - * slot 9 — vMaxWeight (f32) - * slot 10 — schechterRatio (f32) - * slot 11 — angularDensityWeight (f32) + * slot 5 — axisRatio (f32) — sign bit carries isFallback + * slot 6 — positionAngleDeg (f32) + * slot 7 — diameterKpc (f32) + * slot 8 — vMaxWeight (f32) + * slot 9 — schechterRatio (f32) + * slot 10 — angularDensityWeight (f32) * - * Total: 12 × 4 = 48 bytes per point (down from 52). The previous - * `globalInstanceIdx u32` slot is gone — the picker now writes - * `(sourceCode << 27) | localIdx + 1` directly via a per-draw uniform - * carrying the source code and the GPU's `@builtin(instance_index)` for - * the local index. No more priorCount running-sum bake. + * Total: 11 × 4 = 44 bytes per point. The previous kPerZ slot moved + * to the per-survey `SourceUniforms` uniform (k is constant per + * survey, so paying for it per-row was waste). Earlier still, a + * 4-byte `globalInstanceIdx u32` slot was also dropped when the picker + * switched to per-draw `(sourceCode << 27) | localIdx + 1` packing. * * The fallback-orientation flag (formerly the high bit of * `globalInstanceIdx`) now rides on the sign bit of `axisRatio`. Real @@ -95,7 +94,7 @@ import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/B * mask shape (`abs(axisRatio)`) and the flag (`axisRatio < 0`) in one * read. See the slot 6 comment in the writer loop below. * - * Slot 11 (`angularDensityWeight`) is left at 1.0 (multiplicative identity) + * Slot 10 (`angularDensityWeight`) is left at 1.0 (multiplicative identity) * by every default upload. Mode 4 of the Malmquist-bias correction — * HEALPix angular re-weighting — replaces these defaults via the lazy * `setBiasMode(BiasMode.AngularReweight)` flow (mirror of Schechter). Skipping the @@ -103,7 +102,7 @@ import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/B * HEALPix pass costs ~100 ms even at full deck, and the user only pays it * if they actually pick mode 4. */ -const SLOTS_PER_POINT = 12; +const SLOTS_PER_POINT = 11; /** Reference distance used to normalise the per-galaxy 1/V_max weight. */ const D_REF_MPC = 750; @@ -201,7 +200,7 @@ export function buildPointInterleavedBuffer( // ── Lazy Schechter ratios (mode = 'with-schechter' only) ──────────────── // // When the upload happens while bias mode is already 3, we compute the - // ratios up-front via the shared helper and splice them into slot 11 of + // ratios up-front via the shared helper and splice them into slot 9 of // each row below. Otherwise (the common case) every row gets 1.0. // // Calling the helper here — rather than open-coding the integral — keeps @@ -256,10 +255,7 @@ export function buildPointInterleavedBuffer( interleaved[o + 3] = Number.isFinite(g) ? g + magOffset : SDSS_TARGET_MEAN_MAG; interleaved[o + 4] = colour ? colour.colourIndex : NO_COLOUR_SENTINEL; - // Slot 5 — per-row K-correction coefficient. See pickColourIndex. - interleaved[o + 5] = colour ? colour.kPerZ : 0; - - // Slot 6 — axisRatio (galaxy disk b/a in (0, 1]) with the SIGN BIT + // Slot 5 — axisRatio (galaxy disk b/a in (0, 1]) with the SIGN BIT // carrying the fallback-orientation flag. Real measurements from // catalogs are always > 0; we negate the value when the row was // classified as fallback so the shader can recover both: @@ -267,24 +263,21 @@ export function buildPointInterleavedBuffer( // - the elliptical mask shape via `abs(axisRatio)` // - the fallback flag via `axisRatio < 0.0` // - // in a single per-instance attribute read. This replaces the prior - // high-bit-of-globalInstanceIdx encoding — that piggyback went away - // with the (source, localIdx) packing refactor that deleted the - // globalInstanceIdx slot entirely. Float sign-bit packing is well- - // defined for finite non-zero values and survives NaN (synthetic - // clouds use NaN axisRatio; the shader's existing `axisRatio > 0` - // mask correctly treats NaN as "no orientation" → circle, no - // fallback flag). + // in a single per-instance attribute read. Float sign-bit packing + // is well-defined for finite non-zero values and survives NaN + // (synthetic clouds use NaN axisRatio; the shader's existing + // `axisRatio > 0` mask correctly treats NaN as "no orientation" → + // circle, no fallback flag). const ab = cloud.axisRatio[i]!; - interleaved[o + 6] = isFallbackArr[i] === 1 ? -Math.abs(ab) : ab; + interleaved[o + 5] = isFallbackArr[i] === 1 ? -Math.abs(ab) : ab; - // Slots 7..8 — positionAngleDeg + diameterKpc copied through. Build + // Slots 6..7 — positionAngleDeg + diameterKpc copied through. Build // pipeline guarantees finite values for diameterKpc; positionAngleDeg // is real-or-fallback (also finite). - interleaved[o + 7] = cloud.positionAngleDeg[i]!; - interleaved[o + 8] = cloud.diameterKpc[i]!; + interleaved[o + 6] = cloud.positionAngleDeg[i]!; + interleaved[o + 7] = cloud.diameterKpc[i]!; - // Slot 9 — per-galaxy 1/V_max weight. Computed from the *raw* + // Slot 8 — per-galaxy 1/V_max weight. Computed from the *raw* // apparent magnitude (NOT `g + magOffset` — the per-survey // normalisation is a visualisation cosmetic, not a physical change to // the photometry) plus Cartesian distance. vMaxWeight handles NaN @@ -294,34 +287,29 @@ export function buildPointInterleavedBuffer( const dz = cloud.positions[i * 3 + 2]!; const dMpc = Math.hypot(dx, dy, dz); const absMag = absoluteFromApparent(g, dMpc); - interleaved[o + 9] = vMaxWeight({ + interleaved[o + 8] = vMaxWeight({ absMag, mLim: surveyMLim, dRefMpc: D_REF_MPC, }); - // Slot 10 — per-galaxy Schechter density-correction ratio. In fast + // Slot 9 — per-galaxy Schechter density-correction ratio. In fast // mode we leave it at the multiplicative identity (1.0); the shader's // `select(1.0, schechterRatio, biasMode == 3u)` ignores the slot for // modes 0/1/2 anyway, so this matches the rendered output bit-for-bit - // unless the user actually picks Schechter LF. - // - // When mode === 'with-schechter' the ratios were computed up-front by - // `computeSchechterRatios` (above); we just splice each row in here. - interleaved[o + 10] = schechterRatios !== null ? schechterRatios[i]! : 1.0; + // unless the user actually picks Schechter LF. When mode === + // 'with-schechter' the ratios were computed up-front by + // `computeSchechterRatios`; we just splice each row in here. + interleaved[o + 9] = schechterRatios !== null ? schechterRatios[i]! : 1.0; - // Slot 11 — per-galaxy HEALPix angular re-weight (BiasMode.AngularReweight, - // mode 4 of the Malmquist-bias correction). Default-write 1.0 (the - // multiplicative identity) so the shader's - // `select(1.0, angularDensityWeight, biasMode == 4u)` produces no change - // in the other four modes. The lazy bake path - // (`pointRenderer.setBiasMode(BiasMode.AngularReweight)`) splices real - // per-galaxy weights into this slot and re-uploads when the user toggles - // into mode 4. We don't add an eager `'with-angular'` mode here because - // the toggle isn't expected to be the default; if a survey arrives - // mid-mode-4 the renderer's `setBiasMode` re-runs the worker bake for - // the new source, picking up the now-stale 1.0s. - interleaved[o + 11] = 1.0; + // Slot 10 — per-galaxy HEALPix angular re-weight (BiasMode.AngularReweight, + // mode 4). Default-write 1.0 (multiplicative identity) so the + // shader's `select(1.0, angularDensityWeight, biasMode == 4u)` + // produces no change in the other modes. The lazy bake path + // (`pointRenderer.setBiasMode(BiasMode.AngularReweight)`) splices + // real per-galaxy weights in and re-uploads when the user toggles + // into mode 4. + interleaved[o + 10] = 1.0; } return { diff --git a/src/services/gpu/renderers/pointRenderer.ts b/src/services/gpu/renderers/pointRenderer.ts index 19a38414..617757b7 100644 --- a/src/services/gpu/renderers/pointRenderer.ts +++ b/src/services/gpu/renderers/pointRenderer.ts @@ -46,6 +46,7 @@ import type { PointDrawSettings } from '../../../@types/rendering/PointDrawSetti import type { PointRenderer } from '../../../@types/rendering/PointRenderer'; import type { GalaxyCatalog } from '../../../@types/data/GalaxyCatalog'; import { ALL_SOURCES, Source } from '../../../data/sources'; +import { colourIndexSpec } from '../../../data/colourIndex'; import type { BuildPointInterleavedBufferInput } from '../../../@types/engine/BuildPointInterleavedBufferInput'; import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/BuildPointInterleavedBufferResult'; @@ -130,81 +131,72 @@ import type { SourceUniformsBgl } from '../../../@types/rendering/SourceUniforms * parallel-upload race that the old global-ID baking suffered from is * structurally impossible. */ -const SLOTS_PER_POINT = 12; +const SLOTS_PER_POINT = 11; /** * Byte stride between consecutive per-instance records in the vertex buffer. * - * 12 slots × 4 bytes = 48 bytes. The pipeline's `arrayStride` must match + * 11 slots × 4 bytes = 44 bytes. The pipeline's `arrayStride` must match * this exactly; if it disagrees WebGPU will either validate-error or * silently read garbage. PickRenderer's pipeline declares the same - * 48-byte stride and the same attribute table, so the two pipelines stay + * 44-byte stride and the same attribute table, so the two pipelines stay * compatible with this single vertex buffer layout. */ -export const POINT_STRIDE = SLOTS_PER_POINT * 4; // 48 bytes - -/** - * Byte offset of the `kPerZ` slot inside one per-instance record. - * - * Sits at slot index 5 (offset 20). Per-row K-correction coefficient - * (see colourIndex.ts). The shader multiplies it by redshift `z` to - * obtain the per-point K shift. - */ -const K_PER_Z_BYTE_OFFSET = 20; +export const POINT_STRIDE = SLOTS_PER_POINT * 4; // 44 bytes /** * Byte offset of the `axisRatio` slot — the b/a ratio of the galaxy disk. * - * Sits at slot index 6 (offset 24). The fragment shader uses + * Sits at slot index 5 (offset 20). The fragment shader uses * `abs(axisRatio)` to squash the unit-circle UV mask into an ellipse; * the sign bit doubles as the fallback-orientation flag (real * measurements are positive; a negative value flags a fallback row). */ -const AXIS_RATIO_BYTE_OFFSET = 24; +const AXIS_RATIO_BYTE_OFFSET = 20; /** * Byte offset of the `positionAngleDeg` slot — the east-of-north position * angle of the galaxy disk's major axis, in degrees [0, 180). * - * Sits at slot index 7 (offset 28). The fragment shader rotates the + * Sits at slot index 6 (offset 24). The fragment shader rotates the * squashed ellipse around the billboard centre by this angle. */ -const POSITION_ANGLE_BYTE_OFFSET = 28; +const POSITION_ANGLE_BYTE_OFFSET = 24; /** * Byte offset of the `diameterKpc` slot — the per-galaxy physical disk * diameter in kiloparsecs. * - * Sits at slot index 8 (offset 32). The vertex shader uses it to + * Sits at slot index 7 (offset 28). The vertex shader uses it to * compute each billboard's apparent angular radius from * `(diameterKpc / 1000 / 2) / distance_Mpc`. */ -const DIAMETER_KPC_BYTE_OFFSET = 32; +const DIAMETER_KPC_BYTE_OFFSET = 28; /** * Byte offset of the `vMaxWeight` slot — the per-galaxy 1/V_max alpha * multiplier used by the Malmquist-bias correction's mode 2. * - * Sits at slot index 9 (offset 36). Baked at upload time from each + * Sits at slot index 8 (offset 32). Baked at upload time from each * galaxy's apparent magnitude, Cartesian distance and the survey's flux * limit. */ -const VMAX_WEIGHT_BYTE_OFFSET = 36; +const VMAX_WEIGHT_BYTE_OFFSET = 32; /** * Byte offset of the `schechterRatio` slot — the per-galaxy Schechter * density-correction ratio used by the Malmquist-bias correction's mode 3. * - * Sits at slot index 10 (offset 40). Default 1.0 in fast mode; real + * Sits at slot index 9 (offset 36). Default 1.0 in fast mode; real * ratios spliced in lazily when the user picks mode 3. */ -const SCHECHTER_RATIO_BYTE_OFFSET = 40; +const SCHECHTER_RATIO_BYTE_OFFSET = 36; /** * Byte offset of the `angularDensityWeight` slot — the per-galaxy HEALPix * angular re-weight used by the Malmquist-bias correction's mode 4. * - * Sits at slot index 11 (offset 44). Default-baked to 1.0 + * Sits at slot index 10 (offset 40). Default-baked to 1.0 * (multiplicative identity) at upload time so modes 0/1/2/3 see no * change. Real per-galaxy values are spliced in lazily by the * bias-correction subsystem (`biasCorrectionSubsystem.ts`) the first @@ -234,7 +226,7 @@ const SCHECHTER_RATIO_BYTE_OFFSET = 40; * per-source bake for the new source and splices in real weights when * it resolves — same lazy semantics as Schechter. */ -const ANGULAR_WEIGHT_BYTE_OFFSET = 44; +const ANGULAR_WEIGHT_BYTE_OFFSET = 40; /** * Vertex buffer attribute table — single source of truth shared with @@ -253,32 +245,31 @@ const ANGULAR_WEIGHT_BYTE_OFFSET = 44; * 0 position (vec3) * 1 magnitude (f32) * 2 colorIndex (f32) - * 3 kPerZ (f32) — per-row K-correction; vertex shader × redshift z - * 4 axisRatio (f32) — b/a; SIGN BIT = isFallback flag - * 5 positionAngleDeg (f32) — east-of-north major-axis angle, [0, 180) - * 6 diameterKpc (f32) — per-galaxy physical disk diameter - * 7 vMaxWeight (f32) — Malmquist mode 2 (1/V_max) multiplier - * 8 schechterRatio (f32) — Malmquist mode 3 (Schechter) ratio - * 9 angularDensityWeight (f32) — Malmquist mode 4 (HEALPix) re-weight + * 3 axisRatio (f32) — b/a; SIGN BIT = isFallback flag + * 4 positionAngleDeg (f32) — east-of-north major-axis angle, [0, 180) + * 5 diameterKpc (f32) — per-galaxy physical disk diameter + * 6 vMaxWeight (f32) — Malmquist mode 2 (1/V_max) multiplier + * 7 schechterRatio (f32) — Malmquist mode 3 (Schechter) ratio + * 8 angularDensityWeight (f32) — Malmquist mode 4 (HEALPix) re-weight * * The right-hand sides reference the named byte-offset constants above * so the JSDoc on each constant stays the canonical documentation for * its slot. Position / magnitude / colorIndex use literal offsets * (0 / 12 / 16) because they're never read by name elsewhere — only * the offsets that the bake or shader-side code needs to address by - * name get named constants. + * name get named constants. kPerZ was previously slot 3; it now lives + * in the per-survey SourceUniforms uniform (set once at upload time). */ export const POINT_VERTEX_ATTRIBUTES: readonly GPUVertexAttribute[] = [ { shaderLocation: 0, offset: 0, format: 'float32x3' }, { shaderLocation: 1, offset: 12, format: 'float32' }, { shaderLocation: 2, offset: 16, format: 'float32' }, - { shaderLocation: 3, offset: K_PER_Z_BYTE_OFFSET, format: 'float32' }, - { shaderLocation: 4, offset: AXIS_RATIO_BYTE_OFFSET, format: 'float32' }, - { shaderLocation: 5, offset: POSITION_ANGLE_BYTE_OFFSET, format: 'float32' }, - { shaderLocation: 6, offset: DIAMETER_KPC_BYTE_OFFSET, format: 'float32' }, - { shaderLocation: 7, offset: VMAX_WEIGHT_BYTE_OFFSET, format: 'float32' }, - { shaderLocation: 8, offset: SCHECHTER_RATIO_BYTE_OFFSET, format: 'float32' }, - { shaderLocation: 9, offset: ANGULAR_WEIGHT_BYTE_OFFSET, format: 'float32' }, + { shaderLocation: 3, offset: AXIS_RATIO_BYTE_OFFSET, format: 'float32' }, + { shaderLocation: 4, offset: POSITION_ANGLE_BYTE_OFFSET, format: 'float32' }, + { shaderLocation: 5, offset: DIAMETER_KPC_BYTE_OFFSET, format: 'float32' }, + { shaderLocation: 6, offset: VMAX_WEIGHT_BYTE_OFFSET, format: 'float32' }, + { shaderLocation: 7, offset: SCHECHTER_RATIO_BYTE_OFFSET, format: 'float32' }, + { shaderLocation: 8, offset: ANGULAR_WEIGHT_BYTE_OFFSET, format: 'float32' }, ]; // ─── Uniform buffer byte offsets (per-pass partial writes) ────────────────── @@ -530,7 +521,7 @@ type LoadedSource = { * Mirror of the interleaved Float32Array baked into `buffer` at upload * time. Held on the JS side so the bias-correction subsystem's splice * methods (`spliceSchechterRatios` / `spliceAngularWeights` / - * `clearBiasOverlays` below) can rewrite slot 10 / 11 of every row and + * `clearBiasOverlays` below) can rewrite slot 9 / 10 of every row and * re-upload the whole buffer with one `device.queue.writeBuffer` call. * * Why a single full re-upload rather than N sparse writes? WebGPU has @@ -854,7 +845,7 @@ export function createPointRenderer( // is active. If a bias mode is active when this upload finishes, // the subsystem fires a per-source bake via the // `biasUploadCallback` at the bottom of this method and splices the - // result into slot 10/11 once it resolves — same observable + // result into slot 9/10 once it resolves — same observable // behaviour as the pre-E.4 inline path, but the rendering and // bias-correction concerns are now cleanly separated. const result = await buildRunner({ @@ -901,10 +892,11 @@ export function createPointRenderer( entries: [{ binding: 0, resource: { buffer: fadeBuffer } }], }); - // SourceUniforms — 16 bytes, written ONCE here at upload time. The - // 5-bit Source enum value never changes for a given source, so a - // per-frame write would be wasted bytes. Pack sourceCode into the - // first 4 bytes and leave the rest zero. + // SourceUniforms — 16 bytes, written ONCE here at upload time. Both + // fields are per-survey constants (sourceCode and kPerZ never change + // for a given source), so a per-frame write would be wasted bytes. + // Layout: sourceCode (u32) at offset 0, kPerZ (f32) at offset 4, + // remaining 8 bytes are reserved padding. const sourceBuffer = device.createBuffer({ label: `points-source-uniform-${source}`, size: 16, @@ -912,6 +904,7 @@ export function createPointRenderer( }); const sourceScratch = new ArrayBuffer(16); new Uint32Array(sourceScratch)[0] = source >>> 0; + new Float32Array(sourceScratch)[1] = colourIndexSpec(source).kPerZ; device.queue.writeBuffer(sourceBuffer, 0, sourceScratch); const sourceBindGroup = device.createBindGroup({ label: `points-source-bg-${source}`, @@ -986,7 +979,7 @@ export function createPointRenderer( /** * Splice a tightly-packed Float32Array of per-row Schechter ratios - * (length must equal the source's `count`) into slot 10 of every row of + * (length must equal the source's `count`) into slot 9 of every row of * the entry's interleaved mirror, then re-upload the whole vertex * buffer. No mode tracking; the caller (subsystem) decides when to * call this. @@ -1000,14 +993,14 @@ export function createPointRenderer( ); } for (let i = 0; i < entry.count; i++) { - entry.interleaved[i * SLOTS_PER_POINT + 10] = ratios[i]!; + entry.interleaved[i * SLOTS_PER_POINT + 9] = ratios[i]!; } device.queue.writeBuffer(entry.buffer, 0, entry.interleaved); } /** * Splice a tightly-packed Float32Array of per-row HEALPix angular - * weights (length must equal the source's `count`) into slot 11 of + * weights (length must equal the source's `count`) into slot 10 of * every row of the entry's interleaved mirror, then re-upload. */ function spliceAngularWeights(source: Source, weights: Float32Array): void { @@ -1019,7 +1012,7 @@ export function createPointRenderer( ); } for (let i = 0; i < entry.count; i++) { - entry.interleaved[i * SLOTS_PER_POINT + 11] = weights[i]!; + entry.interleaved[i * SLOTS_PER_POINT + 10] = weights[i]!; } device.queue.writeBuffer(entry.buffer, 0, entry.interleaved); } @@ -1048,8 +1041,8 @@ export function createPointRenderer( : Array.from(clouds.values()); for (const entry of targets) { for (let i = 0; i < entry.count; i++) { + entry.interleaved[i * SLOTS_PER_POINT + 9] = 0; entry.interleaved[i * SLOTS_PER_POINT + 10] = 0; - entry.interleaved[i * SLOTS_PER_POINT + 11] = 0; } device.queue.writeBuffer(entry.buffer, 0, entry.interleaved); } diff --git a/src/services/gpu/shaders/lib/sourceUniforms.wesl b/src/services/gpu/shaders/lib/sourceUniforms.wesl index b0a4b596..e38fe3ca 100644 --- a/src/services/gpu/shaders/lib/sourceUniforms.wesl +++ b/src/services/gpu/shaders/lib/sourceUniforms.wesl @@ -36,13 +36,18 @@ struct SourceUniforms { // anyway. sourceCode: u32, - // Pad to 16-byte WebGPU-minimum uniform-buffer alignment. Never - // written from the CPU side; never read here. Named individually - // (rather than `_pad: vec3`) so a future field can repurpose - // any one slot without alignment churn — each pad is a free 4-byte - // u32 slot. (Wider types like vec2 or vec4 need 8 / 16-byte - // alignment respectively and would require resizing the struct.) - _pad0: u32, + // Per-survey K-correction coefficient (ramp-position units per unit + // redshift z). Read by the points vertex stage as + // 'colorIndex - kPerZ * z' to convert observed colour to rest-frame. + // Values live in 'data/colourIndex.ts' SPEC table and never change + // for a given survey, so this stays a one-shot upload-time write. + // Other consumers of SourceUniforms (cluster markers, etc.) leave + // this slot zero — their shaders don't read kPerZ. + kPerZ: f32, + + // Pad to 16-byte WebGPU-minimum uniform-buffer alignment. Each pad + // is a free 4-byte slot for a future field (sourceCode and kPerZ + // already claimed the first two). _pad1: u32, _pad2: u32, }; diff --git a/src/services/gpu/shaders/points/io.wesl b/src/services/gpu/shaders/points/io.wesl index 2f5bc2da..72fdb405 100644 --- a/src/services/gpu/shaders/points/io.wesl +++ b/src/services/gpu/shaders/points/io.wesl @@ -200,14 +200,6 @@ struct PerVertex { // 'no observed colour for this survey'. @location(2) colorIndex: f32, - // Per-row K-correction coefficient (units: per unit redshift z). - // Each survey has a different colour pair with different sensitivity - // to bandpass shift, so 'k' lives per-instance: - // - SDSS u−g → k ≈ 3.0 - // - GLADE B−J → k ≈ 1.0 - // - 2MRS J−K → k ≈ 0.0 - @location(3) kPerZ: f32, - // Galaxy minor/major axis ratio b/a in (0, 1] — with the SIGN BIT // carrying the fallback-orientation flag. Real measurements are // always positive; the JS-side bake negates the value for fallback @@ -221,33 +213,33 @@ struct PerVertex { // NaN. WGSL's 'abs(NaN)' returns NaN and 'NaN < 0.0' is false, so // the vertex stage routes synthetic rows through the existing // 'axisRatio > 0 is false' round-mask path with the fallback flag clear. - @location(4) axisRatio: f32, + @location(3) axisRatio: f32, // Position angle in degrees, [0, 180). East-of-north convention; we // negate before applying because UV-space y points down on screen. - @location(5) positionAngleDeg: f32, + @location(4) positionAngleDeg: f32, // Per-galaxy physical diameter in kiloparsecs. Drives the apparent- // size billboard radius. v4 binary format guarantees a finite // positive value (real measurement or 30-kpc fallback). - @location(6) diameterKpc: f32, + @location(5) diameterKpc: f32, // Per-galaxy 1/V_max weight for Malmquist-bias correction. Baked at // upload time as 'clamp((dRef / dMax(M, m_lim))³, 0, 1)'. Read by the // vertex shader's intensity computation, but ONLY when // 'u.biasMode == 2u'. - @location(7) vMaxWeight: f32, + @location(6) vMaxWeight: f32, // Per-galaxy Schechter density-correction ratio. Baked at upload time // as 'clamp(N_ref / n(d), 0, 10)' (originally a 200-step trapezoidal // integral evaluated per-fragment; now a single multiply). Read in // 'fs' only when 'u.biasMode == 3u'. - @location(8) schechterRatio: f32, + @location(7) schechterRatio: f32, // Per-galaxy HEALPix angular re-weight. Baked at mode-toggle time as // 'clamp(medianCount / localCount, 0.1, 10)' (default 1.0). Read in // 'fs' only when 'u.biasMode == 4u'. - @location(9) angularDensityWeight: f32, + @location(8) angularDensityWeight: f32, }; // ─── vertex-to-fragment interface ─────────────────────────────────── diff --git a/src/services/gpu/shaders/points/vertex.wesl b/src/services/gpu/shaders/points/vertex.wesl index e60e5ecf..94518f90 100644 --- a/src/services/gpu/shaders/points/vertex.wesl +++ b/src/services/gpu/shaders/points/vertex.wesl @@ -173,10 +173,10 @@ fn vs( // // colour_rest ≈ colour_obs − k · z // - // 'k' is the per-row 'p.kPerZ' attribute (baked at upload time per - // source). We derive z from the position vector via Hubble's law: - // |xyz| = c·z/H₀, so z = |xyz| / HUBBLE_DISTANCE_MPC. This matches - // how the CPU-side raDecZToCartesian generated these positions. + // 'k' is the per-survey 'source.kPerZ' uniform (set once at upload + // time from data/colourIndex.ts). We derive z from the position + // vector via Hubble's law: |xyz| = c·z/H₀, so z = |xyz| / + // HUBBLE_DISTANCE_MPC. Matches the CPU-side raDecZToCartesian. let HUBBLE_DISTANCE_MPC = 4282.749; // c / H₀ for H₀ = 70 km/s/Mpc let zRedshift = length(p.position) / HUBBLE_DISTANCE_MPC; @@ -185,7 +185,7 @@ fn vs( // and substitute a fixed mid-ramp colour (1.05 ≈ pale orange-white) // so sentinel galaxies have a stable visually-neutral tint. let isUnknownColour = p.colorIndex > 100.0; - let restColorIndex = select(p.colorIndex - p.kPerZ * zRedshift, 1.05, isUnknownColour); + let restColorIndex = select(p.colorIndex - source.kPerZ * zRedshift, 1.05, isUnknownColour); // Look up the colour for this point's *rest-frame* colour index, and // bake the magenta highlight in when both u.highlightFallback and the diff --git a/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts b/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts index d52aaa47..efff3a53 100644 --- a/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts +++ b/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts @@ -12,21 +12,20 @@ * checklist for the manual verification step that catches the worker * bundle's transitive imports. * - * ### Slot layout (post (source, localIdx) packing refactor) + * ### Slot layout * * slot 0,1,2 — position xyz * slot 3 — magnitude * slot 4 — colorIndex - * slot 5 — kPerZ - * slot 6 — axisRatio (sign bit = isFallback) - * slot 7 — positionAngleDeg - * slot 8 — diameterKpc - * slot 9 — vMaxWeight - * slot 10 — schechterRatio - * slot 11 — angularDensityWeight + * slot 5 — axisRatio (sign bit = isFallback) + * slot 6 — positionAngleDeg + * slot 7 — diameterKpc + * slot 8 — vMaxWeight + * slot 9 — schechterRatio + * slot 10 — angularDensityWeight * - * 12 slots × 4 bytes = 48 bytes per point. No more globalInstanceIdx - * slot — the picker now derives its packed identity from a per-source + * 11 slots × 4 bytes = 44 bytes per point. kPerZ moved to per-survey + * `SourceUniforms`; the picker reads instance identity from a per-source * uniform + the GPU's `@builtin(instance_index)`. */ @@ -52,10 +51,10 @@ function makeCloud(count: number): GalaxyCatalog { }; } -const SLOTS = 12; +const SLOTS = 11; describe('buildPointInterleavedBuffer', () => { - it('produces an interleaved Float32Array of the expected length (12 slots × 4 bytes)', () => { + it('produces an interleaved Float32Array of the expected length (11 slots × 4 bytes)', () => { const cloud = makeCloud(3); const result = buildPointInterleavedBuffer({ cloud, @@ -82,7 +81,7 @@ describe('buildPointInterleavedBuffer', () => { expect(interleaved[1 * SLOTS + 2]).toBeCloseTo(-15); }); - it('writes axisRatio at slot 6 with positive sign for non-fallback rows', () => { + it('writes axisRatio at slot 5 with positive sign for non-fallback rows', () => { // axisRatio = 0.7 is unlikely to match the deterministic // fallbackOrientation for these specific (objIDs, ra, dec) — so // the bake should keep the value positive (no sign-bit flip). @@ -93,7 +92,7 @@ describe('buildPointInterleavedBuffer', () => { source: Source.SDSS, }); for (let i = 0; i < 3; i++) { - const ab = interleaved[i * SLOTS + 6]!; + const ab = interleaved[i * SLOTS + 5]!; // Either positive (real measurement) or negative (fallback). These // particular rows shouldn't be classified as fallback (their // (b/a, PA) doesn't match the deterministic hash output for the @@ -130,7 +129,7 @@ describe('buildPointInterleavedBuffer', () => { expect(result.nRef).toBeGreaterThan(0); }); - it('writes vMaxWeight in slot 9; default fast mode leaves slot 10 at 1.0', () => { + it('writes vMaxWeight in slot 8; default fast mode leaves slot 9 at 1.0', () => { const cloud = makeCloud(1); // Place the galaxy at d = 100 Mpc with a typical SDSS-like apparent // magnitude. The exact weight value depends on vMaxWeight()'s formula @@ -141,20 +140,20 @@ describe('buildPointInterleavedBuffer', () => { cloud, source: Source.SDSS, }); - const vMax = interleaved[9]!; - const sch = interleaved[10]!; + const vMax = interleaved[8]!; + const sch = interleaved[9]!; expect(Number.isFinite(vMax)).toBe(true); expect(vMax).toBeGreaterThanOrEqual(0); expect(vMax).toBeLessThanOrEqual(1); - // Default mode is 'fast' → slot 10 is the multiplicative identity. + // Default mode is 'fast' → slot 9 is the multiplicative identity. // The shader's `select(1.0, schechterRatio, biasMode == 3u)` ignores // this slot in modes 0/1/2, so the visual is unchanged. expect(sch).toBe(1); }); - it('mode: fast writes 1.0 to schechterRatio (slot 10) for every row', () => { + it('mode: fast writes 1.0 to schechterRatio (slot 9) for every row', () => { // Build a multi-row cloud spread across distances and assert every - // row's slot 10 is exactly 1.0 — the multiplicative identity that + // row's slot 9 is exactly 1.0 — the multiplicative identity that // makes the shader's mode-3 multiplication a no-op. const cloud = makeCloud(5); for (let i = 0; i < 5; i++) { @@ -167,16 +166,16 @@ describe('buildPointInterleavedBuffer', () => { mode: 'fast', }); for (let i = 0; i < 5; i++) { - expect(interleaved[i * SLOTS + 10]).toBe(1); + expect(interleaved[i * SLOTS + 9]).toBe(1); } }); - it('mode: with-schechter writes the per-row symmetric-rebalance ratios in slot 10', () => { + it('mode: with-schechter writes the per-row symmetric-rebalance ratios in slot 9', () => { // Symmetric rebalance centers ratios on 1.0 (median pivot): far-field // boosts modestly (capped at 1.2×), near-field dims more aggressively // (down to 0.3×). We assert at least one row off 1.0 — this catches // a regression where the bake silently degrades to fast-mode (which - // writes 1.0 into slot 10 unconditionally). + // writes 1.0 into slot 9 unconditionally). const cloud = makeCloud(5); cloud.positions.set([20, 0, 0, 50, 0, 0, 100, 0, 0, 200, 0, 0, 500, 0, 0]); cloud.magG.set([16, 17, 18, 19, 20]); @@ -187,7 +186,7 @@ describe('buildPointInterleavedBuffer', () => { }); let sawNonUnity = false; for (let i = 0; i < 5; i++) { - const r = interleaved[i * SLOTS + 10]!; + const r = interleaved[i * SLOTS + 9]!; expect(Number.isFinite(r)).toBe(true); expect(r).toBeGreaterThanOrEqual(0.3 - 1e-6); expect(r).toBeLessThanOrEqual(1.2 + 1e-6); @@ -196,14 +195,14 @@ describe('buildPointInterleavedBuffer', () => { expect(sawNonUnity).toBe(true); }); - it('writes 1.0 into the angularDensityWeight slot (slot 11) by default', () => { + it('writes 1.0 into the angularDensityWeight slot (slot 10) by default', () => { const cloud = makeCloud(3); const { interleaved } = buildPointInterleavedBuffer({ cloud, source: Source.SDSS, }); for (let i = 0; i < 3; i++) { - expect(interleaved[i * SLOTS + 11]).toBe(1); + expect(interleaved[i * SLOTS + 10]).toBe(1); } }); @@ -239,7 +238,5 @@ describe('buildPointInterleavedBuffer', () => { source: Source.SDSS, }); expect(interleaved[4]).toBe(999); - // K-correction defaults to 0 when the colour is absent. - expect(interleaved[5]).toBe(0); }); }); diff --git a/tests/services/gpu/renderers/pointRenderer.test.ts b/tests/services/gpu/renderers/pointRenderer.test.ts index 24e85aa1..1ce23384 100644 --- a/tests/services/gpu/renderers/pointRenderer.test.ts +++ b/tests/services/gpu/renderers/pointRenderer.test.ts @@ -479,7 +479,7 @@ function makeCapturingDevice( } describe('PointRenderer.spliceSchechterRatios', () => { - it('writes ratios[i] into slot 10 of row i of the interleaved mirror', async () => { + it('writes ratios[i] into slot 9 of row i of the interleaved mirror', async () => { const writeCalls: { buffer: GPUBuffer; offset: number; data: ArrayBufferView }[] = []; const device = makeCapturingDevice(writeCalls); const renderer = createPointRenderer(device, 'rgba16float', makeStubFadeBgl(), makeStubSourceBgl()); @@ -492,10 +492,10 @@ describe('PointRenderer.spliceSchechterRatios', () => { const last = writeCalls[writeCalls.length - 1]!; const view = last.data as Float32Array; const f32 = new Float32Array(view.buffer, view.byteOffset, view.length); - // SLOTS_PER_POINT = 12; slot 10 = SCHECHTER_RATIO_BYTE_OFFSET / 4 = 10. - expect(f32[0 * 12 + 10]).toBeCloseTo(0.25); - expect(f32[1 * 12 + 10]).toBeCloseTo(0.5); - expect(f32[2 * 12 + 10]).toBeCloseTo(0.75); + // SLOTS_PER_POINT = 11; slot 9 = SCHECHTER_RATIO_BYTE_OFFSET / 4. + expect(f32[0 * 11 + 9]).toBeCloseTo(0.25); + expect(f32[1 * 11 + 9]).toBeCloseTo(0.5); + expect(f32[2 * 11 + 9]).toBeCloseTo(0.75); }); it('throws when ratios.length !== source count', async () => { @@ -514,7 +514,7 @@ describe('PointRenderer.spliceSchechterRatios', () => { }); describe('PointRenderer.spliceAngularWeights', () => { - it('writes weights[i] into slot 11 of row i', async () => { + it('writes weights[i] into slot 10 of row i', async () => { const writeCalls: { buffer: GPUBuffer; offset: number; data: ArrayBufferView }[] = []; const device = makeCapturingDevice(writeCalls); const renderer = createPointRenderer(device, 'rgba16float', makeStubFadeBgl(), makeStubSourceBgl()); @@ -526,9 +526,9 @@ describe('PointRenderer.spliceAngularWeights', () => { const last = writeCalls[writeCalls.length - 1]!; const view = last.data as Float32Array; const f32 = new Float32Array(view.buffer, view.byteOffset, view.length); - // slot 11 = ANGULAR_WEIGHT_BYTE_OFFSET / 4 = 11. - expect(f32[0 * 12 + 11]).toBeCloseTo(0.1); - expect(f32[1 * 12 + 11]).toBeCloseTo(0.9); + // slot 10 = ANGULAR_WEIGHT_BYTE_OFFSET / 4. + expect(f32[0 * 11 + 10]).toBeCloseTo(0.1); + expect(f32[1 * 11 + 10]).toBeCloseTo(0.9); }); it('throws when weights.length !== source count', async () => { @@ -541,13 +541,13 @@ describe('PointRenderer.spliceAngularWeights', () => { }); describe('PointRenderer.clearBiasOverlays', () => { - it('zeroes slots 10 and 11 for the named source', async () => { + it('zeroes slots 9 and 10 for the named source', async () => { const writeCalls: { buffer: GPUBuffer; offset: number; data: ArrayBufferView }[] = []; const device = makeCapturingDevice(writeCalls); const renderer = createPointRenderer(device, 'rgba16float', makeStubFadeBgl(), makeStubSourceBgl()); await renderer.upload(Source.SDSS, makeCloud(2)); - // Populate slots 10/11 first so we can assert clear actually clears. + // Populate slots 9/10 first so we can assert clear actually clears. renderer.spliceSchechterRatios(Source.SDSS, new Float32Array([0.5, 0.6])); renderer.spliceAngularWeights(Source.SDSS, new Float32Array([0.7, 0.8])); @@ -557,10 +557,10 @@ describe('PointRenderer.clearBiasOverlays', () => { const last = writeCalls[writeCalls.length - 1]!; const view = last.data as Float32Array; const f32 = new Float32Array(view.buffer, view.byteOffset, view.length); - expect(f32[0 * 12 + 10]).toBe(0); - expect(f32[0 * 12 + 11]).toBe(0); - expect(f32[1 * 12 + 10]).toBe(0); - expect(f32[1 * 12 + 11]).toBe(0); + expect(f32[0 * 11 + 9]).toBe(0); + expect(f32[0 * 11 + 10]).toBe(0); + expect(f32[1 * 11 + 9]).toBe(0); + expect(f32[1 * 11 + 10]).toBe(0); }); it('zeroes for every loaded source when called with no argument', async () => { @@ -743,16 +743,16 @@ describe('PointRenderer.draw — PointDrawSettings shape', () => { }); describe('POINT_VERTEX_ATTRIBUTES — shared layout export', () => { - it('has 10 attributes with the expected shader locations and formats', async () => { + it('has 9 attributes with the expected shader locations and formats', async () => { const { POINT_VERTEX_ATTRIBUTES, POINT_STRIDE, } = await import('../../../../src/services/gpu/renderers/pointRenderer'); - expect(POINT_STRIDE).toBe(48); - expect(POINT_VERTEX_ATTRIBUTES).toHaveLength(10); + expect(POINT_STRIDE).toBe(44); + expect(POINT_VERTEX_ATTRIBUTES).toHaveLength(9); - // Slot 0 is the only vec3; slots 1-9 are scalar f32s. Anyone editing + // Slot 0 is the only vec3; slots 1-8 are scalar f32s. Anyone editing // pointRenderer's table must update this expectation deliberately, // which is the point — a silent shape change here would break the // shared invariant with pickRenderer. @@ -762,8 +762,8 @@ describe('POINT_VERTEX_ATTRIBUTES — shared layout export', () => { format: 'float32x3', }); - const expectedOffsets = [12, 16, 20, 24, 28, 32, 36, 40, 44]; - for (let i = 1; i <= 9; i++) { + const expectedOffsets = [12, 16, 20, 24, 28, 32, 36, 40]; + for (let i = 1; i <= 8; i++) { expect(POINT_VERTEX_ATTRIBUTES[i]).toEqual({ shaderLocation: i, offset: expectedOffsets[i - 1], From 308271e53c26ce80faca9d85ab8fe65925db4d9b Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:53:08 +0200 Subject: [PATCH 3/8] perf(points): pack safeAB into tint.w, contiguous VSOut locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more VSOut tightenings: - Pre-compute the elliptical-mask coefficient (safeAB) at the vertex stage and pack into the unused alpha channel of tint. Fragment reads in.tint.w directly with zero per-pixel axis-ratio work — saves a select + max + sign-check per pixel. The axisRatio location goes away entirely. - Renumber paRotation from location 6 to location 4 so the VSOut locations are contiguous (0..4 with no gaps). Net: VSOut 6 locations -> 5, 56 B -> 52 B. Fragment shader keeps shrinking. Co-Authored-By: Claude Opus 4.7 --- .../gpu/shaders/points/colorFragment.wesl | 15 ++++-------- src/services/gpu/shaders/points/io.wesl | 20 ++++++++-------- src/services/gpu/shaders/points/vertex.wesl | 23 ++++++++----------- 3 files changed, 25 insertions(+), 33 deletions(-) diff --git a/src/services/gpu/shaders/points/colorFragment.wesl b/src/services/gpu/shaders/points/colorFragment.wesl index 32dc954e..e8d17724 100644 --- a/src/services/gpu/shaders/points/colorFragment.wesl +++ b/src/services/gpu/shaders/points/colorFragment.wesl @@ -47,21 +47,16 @@ fn fs(in: VSOut) -> @location(0) vec4 { // cutoff. // // The cs/sn pair is pre-computed in the vertex stage and flat- - // interpolated, saving millions of trig calls per frame. + // interpolated, saving millions of trig calls per frame. safeAB + // (the validated ellipse minor-axis ratio) rides in the alpha channel + // of in.tint, so the fragment does zero per-pixel axis-ratio work. let cs = in.paRotation.x; let sn = in.paRotation.y; let rotated = vec2( cs * in.uv.x - sn * in.uv.y, sn * in.uv.x + cs * in.uv.y, ); - // axisRatio is guaranteed > 0 by the build pipeline, BUT the synthetic- - // fallback source (loaded when every real .bin file fails to decode) - // ships its axisRatio array filled with NaN. 'NaN > 0.0' is false in - // WGSL, so this single comparison catches both NaN and zero/negative - // — invalid → safeAB = 1.0 → circular r2 = original dot(uv, uv). - let abIsValid = in.axisRatio > 0.0; - let safeAB = select(1.0, max(in.axisRatio, 0.05), abIsValid); - let elliptic = vec2(rotated.x, rotated.y / safeAB); + let elliptic = vec2(rotated.x, rotated.y / in.tint.w); let r2 = dot(elliptic, elliptic); // ──────────────────────────────────────────────────────────────────────── @@ -76,7 +71,7 @@ fn fs(in: VSOut) -> @location(0) vec4 { // gated by realOnlyMode are culled at the vertex stage too, so this // fragment never sees them. var alpha = exp(-r2 * 4.0); - let rgb = in.tint * in.intensity; + let rgb = in.tint.rgb * in.intensity; // ── Source fade-in ───────────────────────────────────────────────────────── // diff --git a/src/services/gpu/shaders/points/io.wesl b/src/services/gpu/shaders/points/io.wesl index 72fdb405..7bbf6d8b 100644 --- a/src/services/gpu/shaders/points/io.wesl +++ b/src/services/gpu/shaders/points/io.wesl @@ -261,10 +261,15 @@ struct VSOut { // circle/ellipse falloff. @location(0) uv: vec2, - // Pre-computed colour for this point (from the colourIndex ramp). - // Flat-interpolated — all 6 vertices of an instance share the value, - // so smooth interpolation would do work for an identical result. - @location(1) @interpolate(flat) tint: vec3, + // Per-instance colour + ellipse-mask coefficient packed together. + // .rgb = colourIndex-ramp lookup with magenta highlight folded in. + // .w = pre-computed safeAB (axis ratio clamped to [0.05, 1.0], + // defaulted to 1.0 for invalid/NaN inputs). Fragment uses .w + // directly to squash the elliptical mask. Packing the scalar + // into the unused alpha channel of the tint slot frees the + // location axisRatio used to occupy. + // Flat-interpolated — all 6 vertices of an instance share the value. + @location(1) @interpolate(flat) tint: vec4, // Per-instance brightness with every per-instance modulator folded in: // magnitude-based intensity × brightness slider × vMax (mode 2) × @@ -278,14 +283,9 @@ struct VSOut { // interpolated, and all 6 vertices of one instance share the same value. @location(3) @interpolate(flat) instanceIdx: u32, - // Forwarded 'abs(axisRatio)' so the fragment stage's elliptical mask - // uses the unsigned magnitude. Sign bit (fallback flag) is consumed - // by the vertex stage; only the magnitude reaches here. Per-instance. - @location(5) @interpolate(flat) axisRatio: f32, - // Pre-computed (cos, sin) of the position-angle rotation. Computed // once per primitive in 'vs' instead of per fragment, saving millions // of trig calls per frame. Packed into a vec2 at one location. - @location(6) @interpolate(flat) paRotation: vec2, + @location(4) @interpolate(flat) paRotation: vec2, }; diff --git a/src/services/gpu/shaders/points/vertex.wesl b/src/services/gpu/shaders/points/vertex.wesl index 94518f90..3956e0bd 100644 --- a/src/services/gpu/shaders/points/vertex.wesl +++ b/src/services/gpu/shaders/points/vertex.wesl @@ -121,10 +121,9 @@ fn vs( var earlyOut: VSOut; earlyOut.clip = vec4(2.0, 2.0, 2.0, 1.0); earlyOut.uv = corner; - earlyOut.tint = vec3(0.0); + earlyOut.tint = vec4(0.0, 0.0, 0.0, 1.0); earlyOut.intensity = 0.0; earlyOut.instanceIdx = myPacked; - earlyOut.axisRatio = 1.0; earlyOut.paRotation = vec2(1.0, 0.0); return earlyOut; } @@ -187,13 +186,17 @@ fn vs( let isUnknownColour = p.colorIndex > 100.0; let restColorIndex = select(p.colorIndex - source.kPerZ * zRedshift, 1.05, isUnknownColour); - // Look up the colour for this point's *rest-frame* colour index, and - // bake the magenta highlight in when both u.highlightFallback and the - // fallback flag are set. Folding the tint multiplier here means the - // fragment reads in.tint directly with no per-pixel branch. + // Look up the colour for this point's *rest-frame* colour index, fold + // the magenta highlight in when both u.highlightFallback and the + // fallback flag are set, and pack the pre-computed safeAB ellipse + // coefficient into the alpha channel. safeAB defaults to 1.0 (circle) + // when axisRatio is invalid or NaN — 'abs(NaN) > 0.0' is false, so + // the select branch is the correct gate for synthetic-fallback rows. let highlightActive = u.highlightFallback == 1u && isFallbackFlag == 1u; let highlightTint = select(vec3(1.0), vec3(1.0, 0.3, 1.0), highlightActive); - out.tint = ramp(restColorIndex) * highlightTint; + let absAR = abs(p.axisRatio); + let safeAB = select(1.0, max(absAR, 0.05), absAR > 0.0); + out.tint = vec4(ramp(restColorIndex) * highlightTint, safeAB); // ── MAGNITUDE → INTENSITY, with every per-instance modulator folded in ── // @@ -250,12 +253,6 @@ fn vs( // The visual 'fs' ignores this field. out.instanceIdx = myPacked; - // Forward 'abs(axisRatio)' so the fragment stage's elliptical mask - // uses the unsigned magnitude. Negative values would make the - // existing 'axisRatio > 0.0' validity check trip on every fallback - // row and collapse the ellipse mask to a circle. - out.axisRatio = abs(p.axisRatio); - // Pre-compute cos/sin of the position-angle rotation so the fragment // stage skips the trig and just reads these flat-interpolated values. // We negate the rotation here because astronomical PA is east-of-north From 20de5048198aba91d337c68e54c8091c113144c4 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 04:08:49 +0200 Subject: [PATCH 4/8] perf(points): fold intensity into tint.rgb, rename to shaded Drop the standalone intensity varying by pre-multiplying it into the rgb channels of the per-instance colour vec4 at the vertex stage. Fragment reads in.shaded.rgb directly with no per-pixel mul. Renamed tint -> shaded since the field no longer carries a 'tint' (modifier) but a fully-lit RGB premultiplied with intensity, plus the safeAB ellipse-mask coefficient packed into .w (unchanged by this commit). The invisibility cull now reads the local intensity scalar; behaviour unchanged. Net: VSOut 5 locations -> 4, 52 B -> 48 B. Co-Authored-By: Claude Opus 4.7 --- .../gpu/shaders/points/colorFragment.wesl | 12 +++---- src/services/gpu/shaders/points/io.wesl | 27 ++++++--------- src/services/gpu/shaders/points/vertex.wesl | 34 +++++++++---------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/src/services/gpu/shaders/points/colorFragment.wesl b/src/services/gpu/shaders/points/colorFragment.wesl index e8d17724..c4893eac 100644 --- a/src/services/gpu/shaders/points/colorFragment.wesl +++ b/src/services/gpu/shaders/points/colorFragment.wesl @@ -49,14 +49,14 @@ fn fs(in: VSOut) -> @location(0) vec4 { // The cs/sn pair is pre-computed in the vertex stage and flat- // interpolated, saving millions of trig calls per frame. safeAB // (the validated ellipse minor-axis ratio) rides in the alpha channel - // of in.tint, so the fragment does zero per-pixel axis-ratio work. + // of in.shaded, so the fragment does zero per-pixel axis-ratio work. let cs = in.paRotation.x; let sn = in.paRotation.y; let rotated = vec2( cs * in.uv.x - sn * in.uv.y, sn * in.uv.x + cs * in.uv.y, ); - let elliptic = vec2(rotated.x, rotated.y / in.tint.w); + let elliptic = vec2(rotated.x, rotated.y / in.shaded.w); let r2 = dot(elliptic, elliptic); // ──────────────────────────────────────────────────────────────────────── @@ -65,13 +65,13 @@ fn fs(in: VSOut) -> @location(0) vec4 { // Gaussian-like falloff: bright at centre (r²=0 → e⁰=1), fading to // e⁻⁴ ≈ 0.018 at the edge (r²=1). All per-instance modulators - // (Schechter, angular reweight, depth fade, procedural-disk - // crossfade-out, magenta highlight) are folded into 'in.tint' and - // 'in.intensity' by the vertex stage — see vertex.wesl. Galaxies + // (intensity scalar, Schechter, angular reweight, depth fade, + // procedural-disk crossfade-out, magenta highlight) are folded into + // 'in.shaded.rgb' by the vertex stage — see vertex.wesl. Galaxies // gated by realOnlyMode are culled at the vertex stage too, so this // fragment never sees them. var alpha = exp(-r2 * 4.0); - let rgb = in.tint.rgb * in.intensity; + let rgb = in.shaded.rgb; // ── Source fade-in ───────────────────────────────────────────────────────── // diff --git a/src/services/gpu/shaders/points/io.wesl b/src/services/gpu/shaders/points/io.wesl index 7bbf6d8b..2775ed24 100644 --- a/src/services/gpu/shaders/points/io.wesl +++ b/src/services/gpu/shaders/points/io.wesl @@ -171,7 +171,7 @@ struct Uniforms { // complementary fade-OUT on the points pass, both passes would be // fully present inside the band — a 'double-bright donut'. The // vertex stage folds '1 - smoothstep(start, end, apparentDiameterPx)' - // into 'out.intensity', so the fragment reads no per-pixel state for + // into 'out.shaded.rgb', so the fragment reads no per-pixel state for // the crossfade. pxFadeStart: f32, pxFadeEnd: f32, @@ -261,31 +261,26 @@ struct VSOut { // circle/ellipse falloff. @location(0) uv: vec2, - // Per-instance colour + ellipse-mask coefficient packed together. - // .rgb = colourIndex-ramp lookup with magenta highlight folded in. + // Fully-shaded per-instance appearance packed into one vec4. + // .rgb = ramp(restColorIndex) × magenta highlight × intensity, where + // intensity already folds in the magnitude clamp, brightness + // slider, Malmquist mode-2/3/4 weights, depth fade, and the + // procedural-disk crossfade-out. Fragment writes + // '(rgb * alpha, alpha)' directly with no further scaling. // .w = pre-computed safeAB (axis ratio clamped to [0.05, 1.0], // defaulted to 1.0 for invalid/NaN inputs). Fragment uses .w - // directly to squash the elliptical mask. Packing the scalar - // into the unused alpha channel of the tint slot frees the - // location axisRatio used to occupy. + // directly to squash the elliptical mask. // Flat-interpolated — all 6 vertices of an instance share the value. - @location(1) @interpolate(flat) tint: vec4, - - // Per-instance brightness with every per-instance modulator folded in: - // magnitude-based intensity × brightness slider × vMax (mode 2) × - // Schechter (mode 3) × angular reweight (mode 4) × depth-fade - // (camera-distance falloff). Fragment multiplies in only per-pixel - // terms (Gaussian falloff, procedural-disk fade, source fade). - @location(2) @interpolate(flat) intensity: f32, + @location(1) @interpolate(flat) shaded: vec4, // Packed (source, localIdx) identity used by 'fsPick' to write the // pick texture. Flat-interpolated because integers can't be linearly // interpolated, and all 6 vertices of one instance share the same value. - @location(3) @interpolate(flat) instanceIdx: u32, + @location(2) @interpolate(flat) instanceIdx: u32, // Pre-computed (cos, sin) of the position-angle rotation. Computed // once per primitive in 'vs' instead of per fragment, saving millions // of trig calls per frame. Packed into a vec2 at one location. - @location(4) @interpolate(flat) paRotation: vec2, + @location(3) @interpolate(flat) paRotation: vec2, }; diff --git a/src/services/gpu/shaders/points/vertex.wesl b/src/services/gpu/shaders/points/vertex.wesl index 3956e0bd..38249300 100644 --- a/src/services/gpu/shaders/points/vertex.wesl +++ b/src/services/gpu/shaders/points/vertex.wesl @@ -121,8 +121,7 @@ fn vs( var earlyOut: VSOut; earlyOut.clip = vec4(2.0, 2.0, 2.0, 1.0); earlyOut.uv = corner; - earlyOut.tint = vec4(0.0, 0.0, 0.0, 1.0); - earlyOut.intensity = 0.0; + earlyOut.shaded = vec4(0.0, 0.0, 0.0, 1.0); earlyOut.instanceIdx = myPacked; earlyOut.paRotation = vec2(1.0, 0.0); return earlyOut; @@ -186,18 +185,6 @@ fn vs( let isUnknownColour = p.colorIndex > 100.0; let restColorIndex = select(p.colorIndex - source.kPerZ * zRedshift, 1.05, isUnknownColour); - // Look up the colour for this point's *rest-frame* colour index, fold - // the magenta highlight in when both u.highlightFallback and the - // fallback flag are set, and pack the pre-computed safeAB ellipse - // coefficient into the alpha channel. safeAB defaults to 1.0 (circle) - // when axisRatio is invalid or NaN — 'abs(NaN) > 0.0' is false, so - // the select branch is the correct gate for synthetic-fallback rows. - let highlightActive = u.highlightFallback == 1u && isFallbackFlag == 1u; - let highlightTint = select(vec3(1.0), vec3(1.0, 0.3, 1.0), highlightActive); - let absAR = abs(p.axisRatio); - let safeAB = select(1.0, max(absAR, 0.05), absAR > 0.0); - out.tint = vec4(ramp(restColorIndex) * highlightTint, safeAB); - // ── MAGNITUDE → INTENSITY, with every per-instance modulator folded in ── // // intensity = clamp((22 - magnitude) / 8, 0.05, 1.0) // mag 14 → 1.0, @@ -209,8 +196,8 @@ fn vs( // × depthFade (camera-distance falloff) // × crossfadeOut (procedural-disk handoff band) // - // All six factors are per-instance constants, so the fragment only - // multiplies per-pixel terms (Gaussian falloff + source fade). + // All six factors are per-instance constants, so the fragment reads + // out.shaded.rgb directly — no per-pixel multiply. let vMaxAlpha = select(1.0, p.vMaxWeight, u.biasMode == 2u); let schechterMult = select(1.0, p.schechterRatio, u.biasMode == 3u); let angularMult = select(1.0, p.angularDensityWeight, u.biasMode == 4u); @@ -228,7 +215,7 @@ fn vs( let apparentDiameterPx = sizePx * 0.5; let crossfadeOut = 1.0 - smoothstep(u.pxFadeStart, u.pxFadeEnd, apparentDiameterPx); - out.intensity = clamp((22.0 - p.magnitude) / 8.0, 0.05, 1.0) + let intensity = clamp((22.0 - p.magnitude) / 8.0, 0.05, 1.0) * u.brightness * vMaxAlpha * schechterMult @@ -236,6 +223,17 @@ fn vs( * depthFadeMult * crossfadeOut; + // Bake the K-corrected ramp colour, the magenta highlight, and the + // intensity scalar straight into out.shaded.rgb. .w carries safeAB — + // pre-computed ellipse-mask coefficient, defaulted to 1.0 (circle) + // for invalid/NaN axisRatio (synthetic-fallback rows). 'abs(NaN) > + // 0.0' is false, so the select branch is the correct gate. + let highlightActive = u.highlightFallback == 1u && isFallbackFlag == 1u; + let highlightTint = select(vec3(1.0), vec3(1.0, 0.3, 1.0), highlightActive); + let absAR = abs(p.axisRatio); + let safeAB = select(1.0, max(absAR, 0.05), absAR > 0.0); + out.shaded = vec4(ramp(restColorIndex) * highlightTint * intensity, safeAB); + // Invisibility cull: galaxies whose folded intensity falls below this // threshold contribute imperceptibly to the additive HDR target, so // we emit a degenerate clip position (outside the [-1, 1] NDC cube) @@ -245,7 +243,7 @@ fn vs( // selected-but-culled galaxy still gets its UI ring from the dedicated // selectionRingPass, which runs independently of this pipeline. let INVISIBILITY_THRESHOLD = 0.005; - if (out.intensity < INVISIBILITY_THRESHOLD) { + if (intensity < INVISIBILITY_THRESHOLD) { out.clip = vec4(2.0, 2.0, 2.0, 1.0); } From 61a0dfe242531306204a3f621eca990a7b27030e Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 04:35:28 +0200 Subject: [PATCH 5/8] fix(colour): unify K-correction + sentinel between points and procDisks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same galaxy was rendering different hues in the points pass vs the procedural-disk impostor pass — two divergences: 1. Points applied K-correction in the vertex shader; procDisks didn't apply it at all. At non-trivial redshift, hues drifted apart. 2. The unknown-band fallback was 1.05 in the points shader and 1.0 in the procDisk subsystem. Different ramp positions = different hue. Move K-correction into pickColourIndex() so both consumers get the same rest-frame value with the shared UNKNOWN_COLOUR_RAMP_POSITION fallback. The shader drops its K-correction block (HUBBLE_DISTANCE_MPC + zRedshift + sentinel check + select) entirely. Function signature collapses from { colourIndex, kPerZ } | null to number. Neither caller distinguished null from "got data" — they both substituted the same fallback — so the nullable was paying for an option nobody exercised. Both call sites now read identically: const colourIndex = pickColourIndex(source, magU..magZ, dMpc); Side effects: - SourceUniforms.kPerZ slot reverts to padding (no longer read by GPU). - pointRenderer.ts drops the per-survey kPerZ write. - NO_COLOUR_SENTINEL constant goes away (1.05 is baked directly). Co-Authored-By: Claude Opus 4.7 --- src/data/colourIndex.ts | 62 +++++++++++++------ .../bake/buildPointInterleavedBuffer.ts | 33 +++++----- .../subsystems/proceduralDiskSubsystem.ts | 18 +++--- src/services/gpu/renderers/pointRenderer.ts | 11 ++-- .../gpu/shaders/lib/sourceUniforms.wesl | 17 ++--- .../gpu/shaders/points/colorFragment.wesl | 18 ++---- src/services/gpu/shaders/points/io.wesl | 6 +- src/services/gpu/shaders/points/vertex.wesl | 22 ++----- tests/data/colourIndex.test.ts | 56 ++++++++++------- .../bake/buildPointInterleavedBuffer.test.ts | 9 +-- 10 files changed, 129 insertions(+), 123 deletions(-) diff --git a/src/data/colourIndex.ts b/src/data/colourIndex.ts index 460c681d..1af1336e 100644 --- a/src/data/colourIndex.ts +++ b/src/data/colourIndex.ts @@ -15,6 +15,24 @@ import { Source, type SurveySource } from './sources'; import type { ColourIndexSpec } from '../@types/data/ColourIndexSpec'; +/** + * Hubble distance in Mpc — c / H₀ for H₀ = 70 km/s/Mpc. Converts + * Cartesian distance from origin to cosmological redshift via + * z = d / HUBBLE_DISTANCE_MPC, matching the small-z Hubble approximation + * the project's raDecZToCartesian uses to produce these positions. + */ +const HUBBLE_DISTANCE_MPC = 4282.749; + +/** + * Ramp-position fallback for rows whose colour cannot be computed (one + * or both bands missing). 1.05 is a deliberately pale, neutral position + * on the 0..2 ramp — close enough to the middle that sentinel rows + * don't draw the eye away from real colour gradients. Both the points + * bake and the procedural-disk subsystem substitute this value, so a + * galaxy without bands renders the same hue in both renderers. + */ +export const UNKNOWN_COLOUR_RAMP_POSITION = 1.05; + // POI source codes (Cluster, Supercluster, Void) have no photometry — // they're pick-encoding-only markers, not survey rows. The colour-index // spec is therefore keyed by `SurveySource` (excludes POIs), and the @@ -33,9 +51,16 @@ const SPEC: Record = { }; /** - * Look up which slot maps to which mag value, then compute the source- - * appropriate colour index and K coefficient. Returns null when either - * constituent band is NaN (so the caller knows to use the sentinel path). + * Look up which slot maps to which mag value, compute the source- + * appropriate colour index, K-correct it to rest-frame using the row's + * distance, and normalise to the 0..2 ramp range. Returns + * {@link UNKNOWN_COLOUR_RAMP_POSITION} when either constituent band is + * NaN, so callers never need a null-check or fallback constant. + * + * The K-correction is applied here (CPU side) so both consumers + * (`buildPointInterleavedBuffer` and `proceduralDiskSubsystem`) get the + * same rest-frame value, removing a visible hue mismatch between the + * points pass and the procedural-disk impostor for the same galaxy. */ export function pickColourIndex( source: Source, @@ -44,7 +69,8 @@ export function pickColourIndex( magR: number, magI: number, magZ: number, -): { colourIndex: number; kPerZ: number } | null { + dMpcFromOrigin: number, +): number { if (source === Source.Cluster || source === Source.Supercluster || source === Source.Void) { throw new Error(`pickColourIndex: POI source ${source} has no colour index`); } @@ -52,23 +78,21 @@ export function pickColourIndex( const slotMap = { u: magU, g: magG, r: magR, i: magI, z: magZ }; const a = slotMap[spec.slotA]; const b = slotMap[spec.slotB]; - if (!Number.isFinite(a) || !Number.isFinite(b)) return null; + if (!Number.isFinite(a) || !Number.isFinite(b)) return UNKNOWN_COLOUR_RAMP_POSITION; - // ── Normalise raw colour to the 0..2 ramp range ───────────────────────── - // - // The WGSL ramp expects its input in [0, 2]. We pre-bake the linear - // remap here so the shader doesn't need to know any per-source range - // numbers — it just reads a single f32 and indexes the ramp. - // - // kPerZ is passed through unchanged: it's already specified in - // normalised ramp-position units (see the SPEC type's docstring for - // why the literature mag/z values aren't used directly). + // Normalise raw observed colour to the 0..2 ramp range. The WGSL ramp + // expects its input there; pre-baking the linear remap means the + // shader reads a single f32 and indexes the ramp. const raw = a - b; - const colourIndex = Math.max( - 0, - Math.min(2, ((raw - spec.rangeMin) / (spec.rangeMax - spec.rangeMin)) * 2.0), - ); - return { colourIndex, kPerZ: spec.kPerZ }; + const observedCI = ((raw - spec.rangeMin) / (spec.rangeMax - spec.rangeMin)) * 2.0; + + // K-correction: colour_rest ≈ colour_obs − k · z. kPerZ is already in + // normalised ramp-position units, so the subtraction happens directly + // on the ramp coordinate. Re-clamp because the correction can push + // the value outside [0, 2]. + const z = dMpcFromOrigin / HUBBLE_DISTANCE_MPC; + const restCI = observedCI - spec.kPerZ * z; + return Math.max(0, Math.min(2, restCI)); } /** Public read of the spec table — used by `galaxyType.ts` and tests. */ diff --git a/src/services/engine/bake/buildPointInterleavedBuffer.ts b/src/services/engine/bake/buildPointInterleavedBuffer.ts index 6cbc1988..99adf73a 100644 --- a/src/services/engine/bake/buildPointInterleavedBuffer.ts +++ b/src/services/engine/bake/buildPointInterleavedBuffer.ts @@ -110,12 +110,6 @@ const D_REF_MPC = 750; /** Target post-shift mean magnitude for the per-survey magG normalisation. */ const SDSS_TARGET_MEAN_MAG = 18; -/** - * Sentinel value the WGSL fragment shader recognises as "no measured colour - * for this row". Mirrors the one in `pointRenderer.ts`. - */ -const NO_COLOUR_SENTINEL = 999; - // Type declarations moved to @types/engine/BuildPointInterleavedBuffer*.d.ts. /** @@ -241,20 +235,27 @@ export function buildPointInterleavedBuffer( const g = cloud.magG[i]!; - const colour = pickColourIndex( + // Distance from origin in Mpc — needed by both the K-correction + // baked into the colour-index lookup and by the vMaxWeight block + // below. Hoist once here to avoid a second hypot. + const dx = cloud.positions[i * 3 + 0]!; + const dy = cloud.positions[i * 3 + 1]!; + const dz = cloud.positions[i * 3 + 2]!; + const dMpc = Math.hypot(dx, dy, dz); + + // Apply the per-survey mag offset. NaN-G galaxies snap to the post- + // shift target so they render at average intensity instead of vanishing. + interleaved[o + 3] = Number.isFinite(g) ? g + magOffset : SDSS_TARGET_MEAN_MAG; + interleaved[o + 4] = pickColourIndex( source, cloud.magU[i]!, cloud.magG[i]!, cloud.magR[i]!, cloud.magI[i]!, cloud.magZ[i]!, + dMpc, ); - // Apply the per-survey mag offset. NaN-G galaxies snap to the post- - // shift target so they render at average intensity instead of vanishing. - interleaved[o + 3] = Number.isFinite(g) ? g + magOffset : SDSS_TARGET_MEAN_MAG; - interleaved[o + 4] = colour ? colour.colourIndex : NO_COLOUR_SENTINEL; - // Slot 5 — axisRatio (galaxy disk b/a in (0, 1]) with the SIGN BIT // carrying the fallback-orientation flag. Real measurements from // catalogs are always > 0; we negate the value when the row was @@ -280,12 +281,8 @@ export function buildPointInterleavedBuffer( // Slot 8 — per-galaxy 1/V_max weight. Computed from the *raw* // apparent magnitude (NOT `g + magOffset` — the per-survey // normalisation is a visualisation cosmetic, not a physical change to - // the photometry) plus Cartesian distance. vMaxWeight handles NaN - // inputs by returning 0. - const dx = cloud.positions[i * 3 + 0]!; - const dy = cloud.positions[i * 3 + 1]!; - const dz = cloud.positions[i * 3 + 2]!; - const dMpc = Math.hypot(dx, dy, dz); + // the photometry) plus Cartesian distance (already hoisted above). + // vMaxWeight handles NaN inputs by returning 0. const absMag = absoluteFromApparent(g, dMpc); interleaved[o + 8] = vMaxWeight({ absMag, diff --git a/src/services/engine/subsystems/proceduralDiskSubsystem.ts b/src/services/engine/subsystems/proceduralDiskSubsystem.ts index b4fccb58..0408aa82 100644 --- a/src/services/engine/subsystems/proceduralDiskSubsystem.ts +++ b/src/services/engine/subsystems/proceduralDiskSubsystem.ts @@ -166,15 +166,19 @@ export function createProceduralDiskSubsystem( const ar = cloud.axisRatio[i]!; const pa = cloud.positionAngleDeg[i]!; - const ci = pickColourIndex( + // Distance from origin (NOT from camera) — K-correction uses + // cosmological redshift z = d / Hubble distance, which is a + // function of the row's position, not the viewer's location. + const dMpcFromOrigin = Math.hypot(x, y, z); + const colourIndex = pickColourIndex( cloudSource, - cloud.magU[i] ?? NaN, - cloud.magG[i] ?? NaN, - cloud.magR[i] ?? NaN, - cloud.magI[i] ?? NaN, - cloud.magZ[i] ?? NaN, + cloud.magU[i]!, + cloud.magG[i]!, + cloud.magR[i]!, + cloud.magI[i]!, + cloud.magZ[i]!, + dMpcFromOrigin, ); - const colourIndex = ci !== null ? ci.colourIndex : 1.0; const emitted = maybeEmitProceduralDisk( px, diff --git a/src/services/gpu/renderers/pointRenderer.ts b/src/services/gpu/renderers/pointRenderer.ts index 617757b7..0f25ddea 100644 --- a/src/services/gpu/renderers/pointRenderer.ts +++ b/src/services/gpu/renderers/pointRenderer.ts @@ -46,7 +46,6 @@ import type { PointDrawSettings } from '../../../@types/rendering/PointDrawSetti import type { PointRenderer } from '../../../@types/rendering/PointRenderer'; import type { GalaxyCatalog } from '../../../@types/data/GalaxyCatalog'; import { ALL_SOURCES, Source } from '../../../data/sources'; -import { colourIndexSpec } from '../../../data/colourIndex'; import type { BuildPointInterleavedBufferInput } from '../../../@types/engine/BuildPointInterleavedBufferInput'; import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/BuildPointInterleavedBufferResult'; @@ -892,11 +891,10 @@ export function createPointRenderer( entries: [{ binding: 0, resource: { buffer: fadeBuffer } }], }); - // SourceUniforms — 16 bytes, written ONCE here at upload time. Both - // fields are per-survey constants (sourceCode and kPerZ never change - // for a given source), so a per-frame write would be wasted bytes. - // Layout: sourceCode (u32) at offset 0, kPerZ (f32) at offset 4, - // remaining 8 bytes are reserved padding. + // SourceUniforms — 16 bytes, written ONCE here at upload time. The + // 5-bit Source enum value never changes for a given source, so a + // per-frame write would be wasted bytes. Pack sourceCode into the + // first 4 bytes; the remaining 12 bytes are reserved padding. const sourceBuffer = device.createBuffer({ label: `points-source-uniform-${source}`, size: 16, @@ -904,7 +902,6 @@ export function createPointRenderer( }); const sourceScratch = new ArrayBuffer(16); new Uint32Array(sourceScratch)[0] = source >>> 0; - new Float32Array(sourceScratch)[1] = colourIndexSpec(source).kPerZ; device.queue.writeBuffer(sourceBuffer, 0, sourceScratch); const sourceBindGroup = device.createBindGroup({ label: `points-source-bg-${source}`, diff --git a/src/services/gpu/shaders/lib/sourceUniforms.wesl b/src/services/gpu/shaders/lib/sourceUniforms.wesl index e38fe3ca..535145f3 100644 --- a/src/services/gpu/shaders/lib/sourceUniforms.wesl +++ b/src/services/gpu/shaders/lib/sourceUniforms.wesl @@ -36,18 +36,11 @@ struct SourceUniforms { // anyway. sourceCode: u32, - // Per-survey K-correction coefficient (ramp-position units per unit - // redshift z). Read by the points vertex stage as - // 'colorIndex - kPerZ * z' to convert observed colour to rest-frame. - // Values live in 'data/colourIndex.ts' SPEC table and never change - // for a given survey, so this stays a one-shot upload-time write. - // Other consumers of SourceUniforms (cluster markers, etc.) leave - // this slot zero — their shaders don't read kPerZ. - kPerZ: f32, - - // Pad to 16-byte WebGPU-minimum uniform-buffer alignment. Each pad - // is a free 4-byte slot for a future field (sourceCode and kPerZ - // already claimed the first two). + // Pad to 16-byte WebGPU-minimum uniform-buffer alignment. Named + // individually (rather than '_pad: vec3') so a future field can + // repurpose any one slot without alignment churn — each pad is a + // free 4-byte slot. + _pad0: u32, _pad1: u32, _pad2: u32, }; diff --git a/src/services/gpu/shaders/points/colorFragment.wesl b/src/services/gpu/shaders/points/colorFragment.wesl index c4893eac..4678c26a 100644 --- a/src/services/gpu/shaders/points/colorFragment.wesl +++ b/src/services/gpu/shaders/points/colorFragment.wesl @@ -7,24 +7,18 @@ // texture. The selection ring is drawn by a dedicated pass and is not // this file's concern. // -// ## Why bindings appear here even though io.wesl already declared them +// ## Bindings // -// They didn't — io.wesl declares only the STRUCT layouts. WESL has -// no global state, so '@group/@binding' declarations are module-local. -// This file re-declares 'u' (at @group(0), same as vertex.wesl) and -// 'fade' (at @group(1), declared only here — the vertex stage doesn't -// read opacity). The struct layouts come from io.wesl and -// fadeUniforms.wesl respectively, so drift is structurally impossible. -// WGSL accepts the same '@group/@binding' pair appearing in multiple -// compiled modules so long as the layouts agree. +// Only 'fade' at @group(1) is bound here — every per-instance and +// per-frame value the fragment needs is already folded into the VSOut +// varyings by the vertex stage (see 'shaded' in io.wesl). The vertex +// stage still binds @group(0) Uniforms for its own use; the pipeline +// layout's @group(0) entry stays VERTEX-visibility-only. -import package::points::io::Uniforms; import package::points::io::VSOut; -import package::lib::math::saturate; import package::lib::fadeUniforms::FadeUniforms; import package::lib::fadeUniforms::applyFade; -@group(0) @binding(0) var u: Uniforms; @group(1) @binding(0) var fade: FadeUniforms; // ─── visual fragment stage ───────────────────────────────────────────── diff --git a/src/services/gpu/shaders/points/io.wesl b/src/services/gpu/shaders/points/io.wesl index 2775ed24..c774e6e6 100644 --- a/src/services/gpu/shaders/points/io.wesl +++ b/src/services/gpu/shaders/points/io.wesl @@ -195,9 +195,9 @@ struct PerVertex { // scale runs backwards). @location(1) magnitude: f32, - // Survey-specific colour index (e.g. SDSS g−r, GLADE B−J, 2MRS J−K). - // Negative → blue, positive → red. Sentinel value '>= 100' marks - // 'no observed colour for this survey'. + // Rest-frame colour-ramp position in [0, 2]. K-correction and the + // unknown-colour fallback (1.05 substitution) are already applied at + // bake time by 'pickColourIndex'; the shader just calls 'ramp(...)'. @location(2) colorIndex: f32, // Galaxy minor/major axis ratio b/a in (0, 1] — with the SIGN BIT diff --git a/src/services/gpu/shaders/points/vertex.wesl b/src/services/gpu/shaders/points/vertex.wesl index 38249300..9080f323 100644 --- a/src/services/gpu/shaders/points/vertex.wesl +++ b/src/services/gpu/shaders/points/vertex.wesl @@ -167,23 +167,9 @@ fn vs( // compute distance from the billboard centre. out.uv = corner; - // ── K-CORRECTION (observed → rest-frame colour) ────────────────────────── - // - // colour_rest ≈ colour_obs − k · z - // - // 'k' is the per-survey 'source.kPerZ' uniform (set once at upload - // time from data/colourIndex.ts). We derive z from the position - // vector via Hubble's law: |xyz| = c·z/H₀, so z = |xyz| / - // HUBBLE_DISTANCE_MPC. Matches the CPU-side raDecZToCartesian. - let HUBBLE_DISTANCE_MPC = 4282.749; // c / H₀ for H₀ = 70 km/s/Mpc - let zRedshift = length(p.position) / HUBBLE_DISTANCE_MPC; - - // Sentinel detection: colorIndex >= 100 marks 'no observed colour for - // this survey's preferred band pair'. We skip K-correction for those - // and substitute a fixed mid-ramp colour (1.05 ≈ pale orange-white) - // so sentinel galaxies have a stable visually-neutral tint. - let isUnknownColour = p.colorIndex > 100.0; - let restColorIndex = select(p.colorIndex - source.kPerZ * zRedshift, 1.05, isUnknownColour); + // p.colorIndex is the rest-frame, sentinel-substituted ramp position + // — K-correction and the unknown-colour fallback already happened + // CPU-side in pickColourIndex. The shader just looks the value up. // ── MAGNITUDE → INTENSITY, with every per-instance modulator folded in ── // @@ -232,7 +218,7 @@ fn vs( let highlightTint = select(vec3(1.0), vec3(1.0, 0.3, 1.0), highlightActive); let absAR = abs(p.axisRatio); let safeAB = select(1.0, max(absAR, 0.05), absAR > 0.0); - out.shaded = vec4(ramp(restColorIndex) * highlightTint * intensity, safeAB); + out.shaded = vec4(ramp(p.colorIndex) * highlightTint * intensity, safeAB); // Invisibility cull: galaxies whose folded intensity falls below this // threshold contribute imperceptibly to the additive HDR target, so diff --git a/tests/data/colourIndex.test.ts b/tests/data/colourIndex.test.ts index 4b3ccd55..50be9e66 100644 --- a/tests/data/colourIndex.test.ts +++ b/tests/data/colourIndex.test.ts @@ -1,43 +1,53 @@ import { describe, it, expect } from 'vitest'; -import { pickColourIndex } from '../../src/data/colourIndex'; +import { pickColourIndex, UNKNOWN_COLOUR_RAMP_POSITION } from '../../src/data/colourIndex'; import { Source } from '../../src/data/sources'; +// d = 0 collapses the K-correction term to zero so the assertions can +// focus on the observed-colour normalisation. Standalone K-correction +// behaviour is exercised by the dedicated test further down. +const D_ZERO = 0; + describe('pickColourIndex', () => { - it('SDSS uses u−g and SDSS K coefficient', () => { + it('SDSS uses u−g and normalises to ramp position', () => { // u=18.5, g=17.5 → u−g = 1.0 → normalised to (1.0-0.5)/(2.0-0.5)*2 ≈ 0.667 - // kPerZ passes through from the SPEC table unchanged — see the docstring - // on ColourIndexSpec for why we use the empirical normalised-units - // value rather than rescaling the literature mag/z value. - const result = pickColourIndex(Source.SDSS, 18.5, 17.5, NaN, NaN, NaN); - expect(result).not.toBeNull(); - expect(result!.colourIndex).toBeCloseTo(0.667, 2); - expect(result!.kPerZ).toBe(3.0); + const result = pickColourIndex(Source.SDSS, 18.5, 17.5, NaN, NaN, NaN, D_ZERO); + expect(result).toBeCloseTo(0.667, 2); }); - it('2MRS uses J−K (slot G − slot I) with zero K coefficient', () => { + it('2MRS uses J−K (slot G − slot I)', () => { // J=8.5, K=7.6 → J−K = 0.9 → (0.9-0.7)/(1.1-0.7)*2 = 1.0 - const result = pickColourIndex(Source.TwoMRS, NaN, 8.5, NaN, 7.6, NaN); - expect(result).not.toBeNull(); - expect(result!.colourIndex).toBeCloseTo(1.0, 2); - expect(result!.kPerZ).toBe(0.0); + const result = pickColourIndex(Source.TwoMRS, NaN, 8.5, NaN, 7.6, NaN, D_ZERO); + expect(result).toBeCloseTo(1.0, 2); }); - it('GLADE uses B−J (slot G − slot R) with modest K coefficient', () => { + it('GLADE uses B−J (slot G − slot R)', () => { // B=14.0, J=12.0 → B−J = 2.0 → (2.0-0.5)/(3.5-0.5)*2 = 1.0 - const result = pickColourIndex(Source.Glade, NaN, 14.0, 12.0, NaN, NaN); - expect(result).not.toBeNull(); - expect(result!.colourIndex).toBeCloseTo(1.0, 2); - expect(result!.kPerZ).toBe(1.0); + const result = pickColourIndex(Source.Glade, NaN, 14.0, 12.0, NaN, NaN, D_ZERO); + expect(result).toBeCloseTo(1.0, 2); }); it('clamps out-of-range colours to [0, 2]', () => { // Extreme blue SDSS galaxy: u−g = 0.0 (well below natural min of 0.5) - const result = pickColourIndex(Source.SDSS, 17.0, 17.0, NaN, NaN, NaN); - expect(result!.colourIndex).toBe(0); + const result = pickColourIndex(Source.SDSS, 17.0, 17.0, NaN, NaN, NaN, D_ZERO); + expect(result).toBe(0); }); - it('returns null when a constituent band is NaN', () => { + it('returns UNKNOWN_COLOUR_RAMP_POSITION when a constituent band is NaN', () => { // GLADE without B-band - expect(pickColourIndex(Source.Glade, NaN, NaN, 12.0, NaN, NaN)).toBeNull(); + expect(pickColourIndex(Source.Glade, NaN, NaN, 12.0, NaN, NaN, D_ZERO)).toBe( + UNKNOWN_COLOUR_RAMP_POSITION, + ); + }); + + it('applies K-correction at non-zero distance', () => { + // SDSS kPerZ = 3.0, Hubble distance ~4282.749 Mpc. + // At d = 428.2749 Mpc, z ≈ 0.1 → shift = 0.3 ramp-units. + // u−g = 1.0 → observed ramp = 0.667; rest-frame = 0.667 − 0.3 = 0.367. + const result = pickColourIndex(Source.SDSS, 18.5, 17.5, NaN, NaN, NaN, 428.2749); + expect(result).toBeCloseTo(0.367, 2); + }); + + it('exports UNKNOWN_COLOUR_RAMP_POSITION as the shared fallback', () => { + expect(UNKNOWN_COLOUR_RAMP_POSITION).toBe(1.05); }); }); diff --git a/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts b/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts index efff3a53..59a98102 100644 --- a/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts +++ b/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts @@ -224,10 +224,11 @@ describe('buildPointInterleavedBuffer', () => { expect(meanOut).toBeCloseTo(18, 5); }); - it('writes the colour-index sentinel (999) when the row lacks usable bands', () => { + it('writes the unknown-colour fallback (1.05) when the row lacks usable bands', () => { const cloud = makeCloud(1); - // SDSS picks u−g. Setting both bands to NaN forces pickColourIndex to - // return null, which the bake maps to the 999 sentinel. + // SDSS picks u−g. Setting both bands to NaN forces pickColourIndex + // to return null, which the bake maps to UNKNOWN_COLOUR_RAMP_POSITION + // (1.05) — the shared neutral-ramp value both renderers substitute. cloud.magU.set([NaN]); cloud.magG.set([NaN]); cloud.magR.set([NaN]); @@ -237,6 +238,6 @@ describe('buildPointInterleavedBuffer', () => { cloud, source: Source.SDSS, }); - expect(interleaved[4]).toBe(999); + expect(interleaved[4]).toBeCloseTo(1.05, 5); }); }); From bb6aa7378ad311153eeae58186dc615d9182ef6d Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 04:41:27 +0200 Subject: [PATCH 6/8] refactor(galaxy-size): share paddedRadiusMpc across all three pipelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4× thumbnail-footprint padding + 30-kpc synthetic-fallback floor + kpc->Mpc unit conversion was inlined at three sites: - buildPointInterleavedBuffer (points bake, was kpc — shader converted) - proceduralDiskSubsystem (full-extent in Mpc) - texturedImpostorSubsystem (full-extent in Mpc) A change to any of those constants had to land in all three in lockstep. Centralise into src/utils/galaxySize.ts as paddedRadiusMpc(diameterKpc). The two subsystems multiply by 2 at the call site for their full-quad- extent convention (vertex shader halves at corner expansion); the points bake uses the helper output directly as half-extent. While in the neighbourhood, switch the points pipeline to Mpc units to match every other shader: - Vertex buffer slot 7 was raw diameterKpc; shader applied '* 2 / 1000' to convert. Now pre-baked as padded radius in Mpc. - PerVertex field renamed diameterKpc -> radiusMpc. - Shader drops the safeDiameterKpc select + GALAXY_RADIUS_MPC compute and reads p.radiusMpc directly. Raw cloud.diameterKpc (the catalog's source-of-truth in kpc) is unchanged — only the GPU interleaved buffer's slot semantics shifted. Co-Authored-By: Claude Opus 4.7 --- .../bake/buildPointInterleavedBuffer.ts | 15 ++++--- .../subsystems/proceduralDiskSubsystem.ts | 5 ++- .../subsystems/texturedImpostorSubsystem.ts | 5 ++- src/services/gpu/renderers/pointRenderer.ts | 19 +++++---- src/services/gpu/shaders/points/io.wesl | 10 +++-- src/services/gpu/shaders/points/vertex.wesl | 21 ++++------ src/utils/galaxySize.ts | 41 +++++++++++++++++++ .../bake/buildPointInterleavedBuffer.test.ts | 2 +- 8 files changed, 83 insertions(+), 35 deletions(-) create mode 100644 src/utils/galaxySize.ts diff --git a/src/services/engine/bake/buildPointInterleavedBuffer.ts b/src/services/engine/bake/buildPointInterleavedBuffer.ts index 99adf73a..857451c2 100644 --- a/src/services/engine/bake/buildPointInterleavedBuffer.ts +++ b/src/services/engine/bake/buildPointInterleavedBuffer.ts @@ -48,6 +48,7 @@ */ import { pickColourIndex } from '../../../data/colourIndex'; +import { paddedRadiusMpc } from '../../../utils/galaxySize'; import { Source } from '../../../data/sources'; import { surveyFluxLimit, surveySchechter } from '../../../data/surveyFluxLimits'; import { fallbackOrientation } from '../../../utils/random/fallbackOrientation'; @@ -76,7 +77,7 @@ import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/B * slot 4 — colorIndex (f32) * slot 5 — axisRatio (f32) — sign bit carries isFallback * slot 6 — positionAngleDeg (f32) - * slot 7 — diameterKpc (f32) + * slot 7 — radiusMpc (f32) — padded billboard half-extent * slot 8 — vMaxWeight (f32) * slot 9 — schechterRatio (f32) * slot 10 — angularDensityWeight (f32) @@ -272,11 +273,15 @@ export function buildPointInterleavedBuffer( const ab = cloud.axisRatio[i]!; interleaved[o + 5] = isFallbackArr[i] === 1 ? -Math.abs(ab) : ab; - // Slots 6..7 — positionAngleDeg + diameterKpc copied through. Build - // pipeline guarantees finite values for diameterKpc; positionAngleDeg - // is real-or-fallback (also finite). + // Slot 6 — positionAngleDeg copied through. interleaved[o + 6] = cloud.positionAngleDeg[i]!; - interleaved[o + 7] = cloud.diameterKpc[i]!; + + // Slot 7 — padded billboard radius in Mpc, half-extent (the shader + // uses it directly as the world-space radius for the billboard + // quad). Shares the helper with the procedural-disk + textured- + // thumbnail pipelines so the load-fade handoff occupies an + // identical world-space footprint across all three. + interleaved[o + 7] = paddedRadiusMpc(cloud.diameterKpc[i]!); // Slot 8 — per-galaxy 1/V_max weight. Computed from the *raw* // apparent magnitude (NOT `g + magOffset` — the per-survey diff --git a/src/services/engine/subsystems/proceduralDiskSubsystem.ts b/src/services/engine/subsystems/proceduralDiskSubsystem.ts index 0408aa82..2ebfa637 100644 --- a/src/services/engine/subsystems/proceduralDiskSubsystem.ts +++ b/src/services/engine/subsystems/proceduralDiskSubsystem.ts @@ -32,6 +32,7 @@ import { Source } from '../../../data/sources'; import { pickColourIndex } from '../../../data/colourIndex'; +import { paddedRadiusMpc } from '../../../utils/galaxySize'; import type { GalaxyCatalog } from '../../../@types/data/GalaxyCatalog'; import type { OrbitCamera } from '../../../@types/camera/OrbitCamera'; import type { Destroyable } from '../../../@types/rendering/Destroyable'; @@ -162,7 +163,9 @@ export function createProceduralDiskSubsystem( if (px <= PROCEDURAL_DISK_FADE_START_PX) continue; - const sizeWorldMpc = (dKpcRow / 1000) * 4; + // posSize.w stores the FULL quad extent (vertex stage halves it + // at corner expansion), so double the shared radius helper. + const sizeWorldMpc = paddedRadiusMpc(dKpcRow) * 2; const ar = cloud.axisRatio[i]!; const pa = cloud.positionAngleDeg[i]!; diff --git a/src/services/engine/subsystems/texturedImpostorSubsystem.ts b/src/services/engine/subsystems/texturedImpostorSubsystem.ts index 65b4bff6..4d1ba71b 100644 --- a/src/services/engine/subsystems/texturedImpostorSubsystem.ts +++ b/src/services/engine/subsystems/texturedImpostorSubsystem.ts @@ -29,6 +29,7 @@ */ import { Source } from '../../../data/sources'; +import { paddedRadiusMpc } from '../../../utils/galaxySize'; import type { GalaxyCatalog } from '../../../@types/data/GalaxyCatalog'; import type { OrbitCamera } from '../../../@types/camera/OrbitCamera'; import type { Destroyable } from '../../../@types/rendering/Destroyable'; @@ -161,7 +162,9 @@ export function createTexturedImpostorSubsystem( if (cloudSource !== Source.Famous && px < APPARENT_SIZE_THRESHOLD_PX) continue; - const sizeWorldMpc = (dKpcRow / 1000) * 4; + // posSize.w stores the FULL quad extent (vertex stage halves it + // at corner expansion), so double the shared radius helper. + const sizeWorldMpc = paddedRadiusMpc(dKpcRow) * 2; const ar = cloud.axisRatio[i]!; const pa = cloud.positionAngleDeg[i]!; diff --git a/src/services/gpu/renderers/pointRenderer.ts b/src/services/gpu/renderers/pointRenderer.ts index 0f25ddea..e58f54cd 100644 --- a/src/services/gpu/renderers/pointRenderer.ts +++ b/src/services/gpu/renderers/pointRenderer.ts @@ -103,7 +103,7 @@ import type { SourceUniformsBgl } from '../../../@types/rendering/SourceUniforms * magnitude f32, colorIndex f32, * kPerZ f32, * axisRatio f32 (sign bit = isFallback flag), - * positionAngleDeg f32, diameterKpc f32, + * positionAngleDeg f32, radiusMpc f32, * vMaxWeight f32, schechterRatio f32, angularDensityWeight f32] * * Every slot is f32 from the GPU's perspective; the single bit of "is @@ -163,14 +163,15 @@ const AXIS_RATIO_BYTE_OFFSET = 20; const POSITION_ANGLE_BYTE_OFFSET = 24; /** - * Byte offset of the `diameterKpc` slot — the per-galaxy physical disk - * diameter in kiloparsecs. + * Byte offset of the `radiusMpc` slot — the per-galaxy padded billboard + * radius in Mpc. * - * Sits at slot index 7 (offset 28). The vertex shader uses it to - * compute each billboard's apparent angular radius from - * `(diameterKpc / 1000 / 2) / distance_Mpc`. + * Sits at slot index 7 (offset 28). Pre-baked at upload time as + * `max(diameterKpc, 30) * 2 / 1000`, which folds in the 4× + * thumbnail-footprint padding and the synthetic-fallback floor. The + * vertex shader divides directly by distance_Mpc to get angular radius. */ -const DIAMETER_KPC_BYTE_OFFSET = 28; +const RADIUS_MPC_BYTE_OFFSET = 28; /** * Byte offset of the `vMaxWeight` slot — the per-galaxy 1/V_max alpha @@ -246,7 +247,7 @@ const ANGULAR_WEIGHT_BYTE_OFFSET = 40; * 2 colorIndex (f32) * 3 axisRatio (f32) — b/a; SIGN BIT = isFallback flag * 4 positionAngleDeg (f32) — east-of-north major-axis angle, [0, 180) - * 5 diameterKpc (f32) — per-galaxy physical disk diameter + * 5 radiusMpc (f32) — padded billboard half-extent in Mpc (pre-baked) * 6 vMaxWeight (f32) — Malmquist mode 2 (1/V_max) multiplier * 7 schechterRatio (f32) — Malmquist mode 3 (Schechter) ratio * 8 angularDensityWeight (f32) — Malmquist mode 4 (HEALPix) re-weight @@ -265,7 +266,7 @@ export const POINT_VERTEX_ATTRIBUTES: readonly GPUVertexAttribute[] = [ { shaderLocation: 2, offset: 16, format: 'float32' }, { shaderLocation: 3, offset: AXIS_RATIO_BYTE_OFFSET, format: 'float32' }, { shaderLocation: 4, offset: POSITION_ANGLE_BYTE_OFFSET, format: 'float32' }, - { shaderLocation: 5, offset: DIAMETER_KPC_BYTE_OFFSET, format: 'float32' }, + { shaderLocation: 5, offset: RADIUS_MPC_BYTE_OFFSET, format: 'float32' }, { shaderLocation: 6, offset: VMAX_WEIGHT_BYTE_OFFSET, format: 'float32' }, { shaderLocation: 7, offset: SCHECHTER_RATIO_BYTE_OFFSET, format: 'float32' }, { shaderLocation: 8, offset: ANGULAR_WEIGHT_BYTE_OFFSET, format: 'float32' }, diff --git a/src/services/gpu/shaders/points/io.wesl b/src/services/gpu/shaders/points/io.wesl index c774e6e6..f1f58858 100644 --- a/src/services/gpu/shaders/points/io.wesl +++ b/src/services/gpu/shaders/points/io.wesl @@ -219,10 +219,12 @@ struct PerVertex { // negate before applying because UV-space y points down on screen. @location(4) positionAngleDeg: f32, - // Per-galaxy physical diameter in kiloparsecs. Drives the apparent- - // size billboard radius. v4 binary format guarantees a finite - // positive value (real measurement or 30-kpc fallback). - @location(5) diameterKpc: f32, + // Per-galaxy padded billboard radius in Mpc — equals + // 'max(diameterKpc, 30) * 2 / 1000' baked at upload time. The 4× + // thumbnail-footprint padding and the synthetic-fallback floor are + // already applied; the shader reads the value directly as the + // half-extent of the world-space quad. + @location(5) radiusMpc: f32, // Per-galaxy 1/V_max weight for Malmquist-bias correction. Baked at // upload time as 'clamp((dRef / dMax(M, m_lim))³, 0, 1)'. Read by the diff --git a/src/services/gpu/shaders/points/vertex.wesl b/src/services/gpu/shaders/points/vertex.wesl index 9080f323..0080678e 100644 --- a/src/services/gpu/shaders/points/vertex.wesl +++ b/src/services/gpu/shaders/points/vertex.wesl @@ -129,25 +129,18 @@ fn vs( // ── APPARENT-SIZE BILLBOARD RADIUS ─────────────────────────────────────── // - // We want each galaxy's billboard to occupy its real angular footprint on - // screen but never to vanish below 'u.pointSizePx' (the far-field 'still - // detectable as a glowing dot' floor). - // - // radius_Mpc = (diameterKpc / 2) * 4 / 1000 = diameterKpc * 2 / 1000 - // - // The 4× padding factor matches ThumbnailRenderer's - // 'sizeWorld = (diameterKpc / 1000) * 4', so the soft glowing dot and - // the textured thumbnail occupy the same world-space footprint and - // the load-fade transition is seamless. The 'select' clamps zero/NaN - // diameters back to the project-wide 30-kpc default. - let safeDiameterKpc = select(30.0, p.diameterKpc, p.diameterKpc > 0.0); - let GALAXY_RADIUS_MPC = safeDiameterKpc * 2.0 / 1000.0; + // Each galaxy's billboard occupies its real angular footprint on + // screen but never vanishes below 'u.pointSizePx' (the far-field + // 'still detectable as a glowing dot' floor). The per-instance + // 'p.radiusMpc' carries the already-padded world-space half-extent — + // bake site applied the 4× thumbnail-footprint padding and the + // synthetic-fallback floor, so the shader reads it directly. let toGalaxy = p.position - u.camPosWorld; let distanceMpc = length(toGalaxy); // Guard distanceMpc against 0 so we don't divide-by-zero when the camera // is parked exactly on a galaxy (test fixture path; not a real scenario). let safeDist = max(distanceMpc, 0.001); - let apparentPxRadius = (GALAXY_RADIUS_MPC / safeDist) * u.pxPerRad; + let apparentPxRadius = (p.radiusMpc / safeDist) * u.pxPerRad; let sizePx = max(u.pointSizePx, apparentPxRadius); // ── PIXEL-SIZE-IN-CLIP-SPACE CONVERSION ────────────────────────────────── diff --git a/src/utils/galaxySize.ts b/src/utils/galaxySize.ts new file mode 100644 index 00000000..4ba8ba4b --- /dev/null +++ b/src/utils/galaxySize.ts @@ -0,0 +1,41 @@ +/** + * Single source of truth for the padded billboard radius used by every + * galaxy-disk renderer (points soft glow, procedural disk, textured + * thumbnail). + * + * ## Why a shared helper + * + * The points bake, proceduralDiskSubsystem, and texturedImpostorSubsystem + * each used to compute the same '(diameterKpc * 2) / 1000' algebra + * inline — three sites, one constant 4× padding factor + one 30-kpc + * synthetic-fallback floor. A change to either (e.g. tightening the + * padding) had to be replicated three times in lockstep; a missed edit + * created a visible size mismatch at the load-fade crossfade boundary. + * + * Centralising the math also documents *what the 4× means*: each + * renderer's quad expands to the same world-space footprint, so the + * soft glow → procedural disk → textured thumbnail handoff happens at + * fixed pixel boundaries without any pipeline being visibly larger or + * smaller than the others at the crossfade. + * + * ## Return value convention + * + * Returns the padded *half-extent* (radius) in Mpc — the natural unit + * for the points pipeline's billboard math. Subsystems that store the + * *full quad extent* (procedural disk, textured thumbnail — both store + * 'posSize.w' as diameter so their vertex stage can halve at corner + * expansion) call this helper and double the result at the call site. + * The doubling is explicit at each site to make the convention switch + * visible rather than hidden inside the helper. + */ + +/** Synthetic-fallback floor (kpc) for galaxies with missing or zero diameter. */ +const SYNTHETIC_FALLBACK_DIAMETER_KPC = 30; + +/** Padding multiplier; matches the textured-thumbnail's world footprint. */ +const THUMBNAIL_FOOTPRINT_PADDING = 4; + +export function paddedRadiusMpc(diameterKpc: number): number { + const safeDKpc = diameterKpc > 0 ? diameterKpc : SYNTHETIC_FALLBACK_DIAMETER_KPC; + return ((safeDKpc / 2) * THUMBNAIL_FOOTPRINT_PADDING) / 1000; +} diff --git a/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts b/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts index 59a98102..d2df04e6 100644 --- a/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts +++ b/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts @@ -19,7 +19,7 @@ * slot 4 — colorIndex * slot 5 — axisRatio (sign bit = isFallback) * slot 6 — positionAngleDeg - * slot 7 — diameterKpc + * slot 7 — radiusMpc (padded half-extent) * slot 8 — vMaxWeight * slot 9 — schechterRatio * slot 10 — angularDensityWeight From f185f21616142583b6c4769b590fc31d922dcb6f Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 05:00:43 +0200 Subject: [PATCH 7/8] refactor(naming): align textured-disk pipeline naming end-to-end The textured-galaxy-thumbnail pipeline had inconsistent names along its chain: shaders/disks/ (folder), texturedDiskRenderer.ts (GPU consumer), texturedImpostorSubsystem.ts (engine driver). 'Impostor' is legitimate graphics jargon for a billboard-as-3D-approximation, but the three-way naming mismatch obscured the relationship between the layers. Rename: - shaders/disks/ -> shaders/texturedDisks/ Parallels the existing shaders/proceduralDisks/ sibling. - texturedImpostorSubsystem -> texturedDiskSubsystem Aligns with texturedDiskRenderer.ts and texturedDisks/ shaders. All identifiers (PascalCase + camelCase + plural field name) renamed across 32 files. WESL imports updated. Stale 'disks.wesl' cross- references in lib/* shader comments cleaned up. No behaviour change. Co-Authored-By: Claude Opus 4.7 --- src/@types/animation/OverlayId.d.ts | 4 ++-- src/@types/engine/ReadyEngineState.d.ts | 4 ++-- .../engine/frame/ReadyFrameContext.d.ts | 4 ++-- .../handles/EngineSubsystemHandles.d.ts | 6 ++--- .../subsystems/GalaxyAtlasSubsystem.d.ts | 6 ++--- ...system.d.ts => TexturedDiskSubsystem.d.ts} | 18 +++++++------- src/services/engine/engine.ts | 8 +++---- src/services/engine/frame/frameContext.ts | 4 ++-- src/services/engine/frame/passes/index.ts | 2 +- .../engine/frame/passes/texturedDisksPass.ts | 12 +++++----- src/services/engine/frame/runFrame.ts | 8 +++---- src/services/engine/helpers/engineReady.ts | 6 ++--- src/services/engine/phases/wireSlots.ts | 10 ++++---- .../engine/subsystems/galaxyAtlasSubsystem.ts | 4 ++-- ...rSubsystem.ts => texturedDiskSubsystem.ts} | 24 +++++++++---------- .../gpu/renderers/texturedDiskRenderer.ts | 4 ++-- src/services/gpu/shaders/lib/billboard.wesl | 4 ++-- src/services/gpu/shaders/lib/camera.wesl | 2 +- src/services/gpu/shaders/lib/masks.wesl | 2 +- src/services/gpu/shaders/lib/orientation.wesl | 10 ++++---- .../gpu/shaders/proceduralDisks/io.wesl | 4 ++-- .../gpu/shaders/proceduralDisks/vertex.wesl | 8 +++---- .../{disks => texturedDisks}/fragment.wesl | 4 ++-- .../shaders/{disks => texturedDisks}/io.wesl | 22 +++++++++-------- .../{disks => texturedDisks}/vertex.wesl | 10 ++++---- src/services/gpu/timing/TIMING_SLOT_NAMES.ts | 2 +- src/utils/galaxySize.ts | 2 +- tests/@types/engineState.test.ts | 4 ++-- .../engine/frame/encodeVolumes.test.ts | 2 +- .../engine/frame/frameContext.test.ts | 22 ++++++++--------- .../engine/frame/passes/passes.test.ts | 4 ++-- .../frame/passes/proceduralDisksPass.test.ts | 2 +- .../frame/passes/selectionRingPass.test.ts | 2 +- .../frame/passes/texturedDisksPass.test.ts | 14 +++++------ .../frame/passes/volumeUpsamplePass.test.ts | 2 +- .../services/engine/frame/renderFrame.test.ts | 8 +++---- .../engine/frame/renderFrame.timing.test.ts | 6 ++--- tests/services/engine/frame/runFrame.test.ts | 2 +- .../engine/helpers/engineReady.test.ts | 18 +++++++------- .../services/engine/phases/wireSlots.test.ts | 6 ++--- ....test.ts => texturedDiskSubsystem.test.ts} | 14 +++++------ tests/visual/galaxyImpostorBaseline.test.ts | 6 ++--- tests/visual/renderFrameSplitBaseline.test.ts | 6 ++--- 43 files changed, 157 insertions(+), 155 deletions(-) rename src/@types/engine/subsystems/{TexturedImpostorSubsystem.d.ts => TexturedDiskSubsystem.d.ts} (80%) rename src/services/engine/subsystems/{texturedImpostorSubsystem.ts => texturedDiskSubsystem.ts} (94%) rename src/services/gpu/shaders/{disks => texturedDisks}/fragment.wesl (96%) rename src/services/gpu/shaders/{disks => texturedDisks}/io.wesl (78%) rename src/services/gpu/shaders/{disks => texturedDisks}/vertex.wesl (96%) rename tests/services/engine/subsystems/{texturedImpostorSubsystem.test.ts => texturedDiskSubsystem.test.ts} (92%) diff --git a/src/@types/animation/OverlayId.d.ts b/src/@types/animation/OverlayId.d.ts index 6dffff10..8b13c708 100644 --- a/src/@types/animation/OverlayId.d.ts +++ b/src/@types/animation/OverlayId.d.ts @@ -11,6 +11,6 @@ * - milkyWay — single-quad procedural Milky Way impostor. * - proceduralDisks — LOD-1 procedural-disk pass (per-galaxy disk * impostor for the close-approach band). - * - texturedImpostors — LOD-2 textured-thumbnail quad pass. + * - texturedDisks — LOD-2 textured-thumbnail quad pass. */ -export type OverlayId = 'milkyWay' | 'proceduralDisks' | 'texturedImpostors'; +export type OverlayId = 'milkyWay' | 'proceduralDisks' | 'texturedDisks'; diff --git a/src/@types/engine/ReadyEngineState.d.ts b/src/@types/engine/ReadyEngineState.d.ts index f245d9ce..bc5c2fce 100644 --- a/src/@types/engine/ReadyEngineState.d.ts +++ b/src/@types/engine/ReadyEngineState.d.ts @@ -18,7 +18,7 @@ import type { PointRenderer } from '../rendering/PointRenderer'; import type { PickRenderer } from '../rendering/PickRenderer'; import type { PostProcess } from '../rendering/PostProcess'; import type { VolumeOffscreen } from '../rendering/VolumeOffscreen'; -import type { TexturedImpostorSubsystem } from './subsystems/TexturedImpostorSubsystem'; +import type { TexturedDiskSubsystem } from './subsystems/TexturedDiskSubsystem'; export type ReadyEngineState = EngineState & { cam: OrbitCamera; @@ -36,6 +36,6 @@ export type ReadyEngineState = EngineState & { volumeOffscreen: VolumeOffscreen; }; subsystems: EngineState['subsystems'] & { - texturedImpostors: TexturedImpostorSubsystem; + texturedDisks: TexturedDiskSubsystem; }; }; diff --git a/src/@types/engine/frame/ReadyFrameContext.d.ts b/src/@types/engine/frame/ReadyFrameContext.d.ts index 0e9ef92b..2dc7163c 100644 --- a/src/@types/engine/frame/ReadyFrameContext.d.ts +++ b/src/@types/engine/frame/ReadyFrameContext.d.ts @@ -53,7 +53,7 @@ import type { Vec3 } from '../../math/Vec3'; import type { PointRenderer } from '../../rendering/PointRenderer'; import type { PostProcess } from '../../rendering/PostProcess'; import type { VolumeOffscreen } from '../../rendering/VolumeOffscreen'; -import type { TexturedImpostorSubsystem } from '../subsystems/TexturedImpostorSubsystem'; +import type { TexturedDiskSubsystem } from '../subsystems/TexturedDiskSubsystem'; /** The ready case: every per-frame derived value is non-null. */ export type ReadyFrameContext = { @@ -85,5 +85,5 @@ export type ReadyFrameContext = { * `ctx.volumeOffscreen.view` without reaching back into `state`. */ volumeOffscreen: VolumeOffscreen; - texturedImpostors: TexturedImpostorSubsystem; + texturedDisks: TexturedDiskSubsystem; }; diff --git a/src/@types/engine/handles/EngineSubsystemHandles.d.ts b/src/@types/engine/handles/EngineSubsystemHandles.d.ts index de81aa00..6f1f9b93 100644 --- a/src/@types/engine/handles/EngineSubsystemHandles.d.ts +++ b/src/@types/engine/handles/EngineSubsystemHandles.d.ts @@ -21,7 +21,7 @@ * `scheduler`. None of these need a GPU device — their callbacks * queue work that the scheduler will pick up once the IIFE finishes. * - Lazy (inside the IIFE): `galaxyAtlas` / `proceduralDisks` / - * `texturedImpostors` (need the GPU device + the + * `texturedDisks` (need the GPU device + the * TexturedQuadRenderer / TexturedDiskRenderer pair), `clickResolver` * (needs the pick renderer), `inputBindings` (needs the scheduler so * it can wake the loop on input). These start as null. @@ -39,7 +39,7 @@ import type { GalaxyAtlasSubsystem } from '../subsystems/GalaxyAtlasSubsystem'; import type { ProceduralDiskSubsystem } from '../subsystems/ProceduralDiskSubsystem'; -import type { TexturedImpostorSubsystem } from '../subsystems/TexturedImpostorSubsystem'; +import type { TexturedDiskSubsystem } from '../subsystems/TexturedDiskSubsystem'; import type { SpaceMouseSubsystem } from '../subsystems/SpaceMouseSubsystem'; import type { SelectionSubsystem } from '../subsystems/SelectionSubsystem'; import type { BiasCorrectionSubsystem } from '../subsystems/BiasCorrectionSubsystem'; @@ -57,7 +57,7 @@ import type { Destroyable } from '../../rendering/Destroyable'; export type EngineSubsystemHandles = { galaxyAtlas: GalaxyAtlasSubsystem | null; proceduralDisks: ProceduralDiskSubsystem | null; - texturedImpostors: TexturedImpostorSubsystem | null; + texturedDisks: TexturedDiskSubsystem | null; spaceMouse: SpaceMouseSubsystem; tweens: TweenManager; clickResolver: ClickResolver | null; diff --git a/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts b/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts index e82b247d..f2157594 100644 --- a/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts +++ b/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts @@ -7,14 +7,14 @@ * The 2048² LRU atlas texture, the LRU clock, the priority-queued bitmap * fetcher, failure memoisation, and an eviction notification hook. It * has NO direct connection to per-frame catalog walking — that lives in - * `texturedImpostorSubsystem`, which calls into this atlas to allocate + * `texturedDiskSubsystem`, which calls into this atlas to allocate * slots and schedule fetches. * * ### Why a separate subsystem * * Pre-split, this state lived inline in `thumbnailSubsystem` alongside * per-frame planning + render dispatch. Splitting it out gives the LOD-2 - * planner (`texturedImpostorSubsystem`) one focused dependency to inject + * planner (`texturedDiskSubsystem`) one focused dependency to inject * — and gives future code that wants to read atlas state (debug HUD, * memory profilers) a typed surface to consume. * @@ -79,7 +79,7 @@ export type GalaxyAtlasSubsystem = Destroyable & { /** * Optional handler called when LRU evicts a slot. The - * `texturedImpostorSubsystem` subscribes to clear its bitmapReady / + * `texturedDiskSubsystem` subscribes to clear its bitmapReady / * bitmapFailed / bitmapReadyTime entries for the ousted key. */ setEvictHandler(handler: ((key: string) => void) | undefined): void; diff --git a/src/@types/engine/subsystems/TexturedImpostorSubsystem.d.ts b/src/@types/engine/subsystems/TexturedDiskSubsystem.d.ts similarity index 80% rename from src/@types/engine/subsystems/TexturedImpostorSubsystem.d.ts rename to src/@types/engine/subsystems/TexturedDiskSubsystem.d.ts index dc6f8c47..b99fa86d 100644 --- a/src/@types/engine/subsystems/TexturedImpostorSubsystem.d.ts +++ b/src/@types/engine/subsystems/TexturedDiskSubsystem.d.ts @@ -1,5 +1,5 @@ /** - * TexturedImpostorSubsystem — LOD-2 per-frame planner. + * TexturedDiskSubsystem — LOD-2 per-frame planner. * * Walks the catalog, applies the px ≥ 24 fetch gate, allocates atlas * slots through the injected `GalaxyAtlasSubsystem`, schedules fetches, @@ -25,7 +25,7 @@ import type { OrbitCamera } from '../../camera/OrbitCamera'; import type { FamousMetaEntry } from '../../loading/FamousMetaEntry'; import type { Source } from '../../../data/sources'; -export type TexturedImpostorFrameInput = { +export type TexturedDiskFrameInput = { readonly cam: OrbitCamera; readonly catalogs: ReadonlyMap; readonly visibleSourceMask: number; @@ -33,15 +33,15 @@ export type TexturedImpostorFrameInput = { readonly famousMeta: readonly FamousMetaEntry[]; }; -export type TexturedImpostorFrameOutput = { +export type TexturedDiskFrameOutput = { /** LOD-2 — galaxies with finite orientation, sorted back-to-front. */ readonly disks: readonly DiskInstance[]; }; -export type TexturedImpostorSubsystem = Destroyable & { - runFrame(input: TexturedImpostorFrameInput): TexturedImpostorFrameOutput; +export type TexturedDiskSubsystem = Destroyable & { + runFrame(input: TexturedDiskFrameInput): TexturedDiskFrameOutput; - readonly lastOutput: TexturedImpostorFrameOutput; + readonly lastOutput: TexturedDiskFrameOutput; /** * OR'd into the engine's render-on-demand predicate. True while any @@ -56,10 +56,10 @@ export type TexturedImpostorSubsystem = Destroyable & { * shape the legacy thumbnailSubsystem did, so the split-out tests can * inspect the post-extraction subsystem's bookkeeping the same way. */ -export type TexturedImpostorTestState = { +export type TexturedDiskTestState = { readonly bitmapReadyTime: ReadonlyMap; }; -export type TexturedImpostorSubsystemWithTestSeam = TexturedImpostorSubsystem & { - __testGetState(): TexturedImpostorTestState; +export type TexturedDiskSubsystemWithTestSeam = TexturedDiskSubsystem & { + __testGetState(): TexturedDiskTestState; }; diff --git a/src/services/engine/engine.ts b/src/services/engine/engine.ts index fbead137..a84dcd5f 100644 --- a/src/services/engine/engine.ts +++ b/src/services/engine/engine.ts @@ -517,7 +517,7 @@ export function createEngine(canvas: HTMLCanvasElement, cb: EngineCallbacks): En // All three null until `wireSlots` constructs them post-GPU init. galaxyAtlas: null, proceduralDisks: null, - texturedImpostors: null, + texturedDisks: null, // ── Tween manager ────────────────────────────────────────── // At most one camera tween at a time. Sites that mutate it: // - public handle's focusOn / focusOnHome / selectFamous @@ -1318,11 +1318,11 @@ export function createEngine(canvas: HTMLCanvasElement, cb: EngineCallbacks): En state.subsystems.labelDirector.destroy(); state.subsystems.pois.destroy(); // Teardown order across the three impostor subsystems matters: - // texturedImpostors subscribes to galaxyAtlas's eviction handler + // texturedDisks subscribes to galaxyAtlas's eviction handler // (so destroy it first), proceduralDisks is independent, and // galaxyAtlas releases its GPU texture last among the three. - state.subsystems.texturedImpostors?.destroy(); - state.subsystems.texturedImpostors = null; + state.subsystems.texturedDisks?.destroy(); + state.subsystems.texturedDisks = null; state.subsystems.proceduralDisks?.destroy(); state.subsystems.proceduralDisks = null; state.subsystems.galaxyAtlas?.destroy(); diff --git a/src/services/engine/frame/frameContext.ts b/src/services/engine/frame/frameContext.ts index f90b1406..639d741f 100644 --- a/src/services/engine/frame/frameContext.ts +++ b/src/services/engine/frame/frameContext.ts @@ -135,7 +135,7 @@ export function deriveFrameContext( const renderer = state.gpu.renderer; const postProcess = state.gpu.postProcess; const volumeOffscreen = state.gpu.volumeOffscreen; - const texturedImpostors = state.subsystems.texturedImpostors; + const texturedDisks = state.subsystems.texturedDisks; // Snapshot-derive everything the caller would otherwise compute // locally. `computeViewProj` was previously called in `runFrame`; @@ -161,6 +161,6 @@ export function deriveFrameContext( renderer, postProcess, volumeOffscreen, - texturedImpostors, + texturedDisks, }; } diff --git a/src/services/engine/frame/passes/index.ts b/src/services/engine/frame/passes/index.ts index 3fc7b4e9..5cbd42f7 100644 --- a/src/services/engine/frame/passes/index.ts +++ b/src/services/engine/frame/passes/index.ts @@ -42,7 +42,7 @@ * encoded galaxy has finite (axisRatio, PA) — the quad branch in the * impostor subsystem only ever fired for famous galaxies at <4 px, * where the point sprite handled them. See - * `texturedImpostorSubsystem.ts` for the full rationale. + * `texturedDiskSubsystem.ts` for the full rationale. * * Reordering passes is a one-line array shuffle with a clear * semantic. The DebugPanel `GpuTimingsSection` derives its row order diff --git a/src/services/engine/frame/passes/texturedDisksPass.ts b/src/services/engine/frame/passes/texturedDisksPass.ts index f26f8354..a02287ea 100644 --- a/src/services/engine/frame/passes/texturedDisksPass.ts +++ b/src/services/engine/frame/passes/texturedDisksPass.ts @@ -1,14 +1,14 @@ /** * texturedDisksPass — LOD-2 textured galaxy thumbnails (3D-oriented disks). * - * What's left of the former `texturedImpostorsPass` after the + * What's left of the former `texturedDisksPass` after the * 2026-05-18 quad-removal. Reads from - * `state.subsystems.texturedImpostors.lastOutput.disks` (populated + * `state.subsystems.texturedDisks.lastOutput.disks` (populated * upstream in `runFrame.ts`) and dispatches one draw call to * `texturedDiskRenderer`. The legacy screen-aligned quad fallback * was deleted because the build pipeline's deterministic orientation * fallback (`buildAllBins.ts`) ensures every encoded galaxy has finite - * (axisRatio, PA) — see `texturedImpostorSubsystem.ts` for the full + * (axisRatio, PA) — see `texturedDiskSubsystem.ts` for the full * rationale. */ @@ -18,11 +18,11 @@ export const texturedDisksPass: Pass = { name: 'textured-disks', enabled(state, _ctx, settings) { if (!settings.galaxyTexturesEnabled) return false; - if (state.subsystems.texturedImpostors === null) return false; - return state.subsystems.texturedImpostors.lastOutput.disks.length > 0; + if (state.subsystems.texturedDisks === null) return false; + return state.subsystems.texturedDisks.lastOutput.disks.length > 0; }, draw(pass, ctx, state, _settings, deps) { - const subsys = state.subsystems.texturedImpostors; + const subsys = state.subsystems.texturedDisks; if (subsys === null) return; const { disks } = subsys.lastOutput; if (disks.length === 0) return; diff --git a/src/services/engine/frame/runFrame.ts b/src/services/engine/frame/runFrame.ts index a7debd59..dfcf5ea8 100644 --- a/src/services/engine/frame/runFrame.ts +++ b/src/services/engine/frame/runFrame.ts @@ -235,7 +235,7 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): // // CPU-side step that populates the two LOD-aligned subsystems' // `lastOutput` arrays. The HDR_PASSES loop reads those arrays via - // the new proceduralDisksPass / texturedImpostorsPass entries; this + // the new proceduralDisksPass / texturedDisksPass entries; this // call site is the one place both walks happen each frame. The // atlas subsystem is mutated transitively by the textured-impostor // run (slot allocations + fetch enqueues); we don't call into it @@ -248,8 +248,8 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): pxPerRad: ctx.drawPxPerRad, }); } - if (state.subsystems.texturedImpostors !== null) { - state.subsystems.texturedImpostors.runFrame({ + if (state.subsystems.texturedDisks !== null) { + state.subsystems.texturedDisks.runFrame({ cam: ctx.cam, catalogs: state.sources.catalogs, visibleSourceMask: state.sources.drawMask, @@ -515,7 +515,7 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): state.settings.camera.autoRotate || state.subsystems.tweens.isActive() || state.subsystems.spaceMouse.hasAxes() || - (ready && state.subsystems.texturedImpostors.hasInFlightWork()) || + (ready && state.subsystems.texturedDisks.hasInFlightWork()) || // Survey + filament fade-in / fade-out: consult the FadeRegistry // — the registry owns every handle's animation clock after the // unified-fade migration (plan-03 for surveys, plan-04 for filaments). diff --git a/src/services/engine/helpers/engineReady.ts b/src/services/engine/helpers/engineReady.ts index 07952229..ce85db9e 100644 --- a/src/services/engine/helpers/engineReady.ts +++ b/src/services/engine/helpers/engineReady.ts @@ -8,7 +8,7 @@ * * - `runFrame.ts` had a 5-way `||` chain across `state.cam`, * `state.gpu.renderer`, `state.gpu.postProcess`, - * `state.gpu.pickRenderer`, and `state.subsystems.texturedImpostors` + * `state.gpu.pickRenderer`, and `state.subsystems.texturedDisks` * (later consolidated by D.1's `FrameContext`, but minus * `pickRenderer`). * - `runFrame.ts`'s "still-animating" predicate at the end of the @@ -58,7 +58,7 @@ * Unlike `filamentRenderer`, `pickRenderer`'s lifecycle matches the * other gate-included handles: it's constructed in `phases/wireInput.ts` * during the bootstrap IIFE and torn down in `destroy()` alongside - * `renderer`, `postProcess`, `volumeOffscreen`, and `texturedImpostors`. + * `renderer`, `postProcess`, `volumeOffscreen`, and `texturedDisks`. * Either all gate-included handles are present or none are — there is * no "engine ran but pickRenderer isn't built" state by design. * Including it here lets the per-frame pick branch drop its @@ -130,6 +130,6 @@ export function isEngineReady(state: EngineState): state is ReadyEngineState { // include it because it is never null when the engine is ready, not // because it's an optional resource. state.gpu.volumeOffscreen !== null && - state.subsystems.texturedImpostors !== null + state.subsystems.texturedDisks !== null ); } diff --git a/src/services/engine/phases/wireSlots.ts b/src/services/engine/phases/wireSlots.ts index d04878ec..7d5cd740 100644 --- a/src/services/engine/phases/wireSlots.ts +++ b/src/services/engine/phases/wireSlots.ts @@ -33,7 +33,7 @@ * - `state.sources.catalogs` — populated by the per-source slot commit * subscribers (wired in `initGpu`). * - `state.subsystems.loadProgress`, `state.subsystems.galaxyAtlas`, - * `state.subsystems.texturedImpostors`, `state.subsystems.proceduralDisks`. + * `state.subsystems.texturedDisks`, `state.subsystems.proceduralDisks`. * - `cb.onStatusChange({ kind: 'loading' })` synchronously; `kind: * 'ready'` fires from the per-arrival subscriber, not from this body. * @@ -54,7 +54,7 @@ import { createSyntheticVolumeSlots } from '../../loading/slots/syntheticVolumeS import { createLoadProgressEmitter } from '../subsystems/loadProgressAggregator'; import { createGalaxyAtlasSubsystem } from '../subsystems/galaxyAtlasSubsystem'; import { createProceduralDiskSubsystem } from '../subsystems/proceduralDiskSubsystem'; -import { createTexturedImpostorSubsystem } from '../subsystems/texturedImpostorSubsystem'; +import { createTexturedDiskSubsystem } from '../subsystems/texturedDiskSubsystem'; // Cosmography POI anchors wired unconditionally into the POI subsystem // below — the user-facing toggle is the SettingsPanel per-category // checkbox, not a URL gate. (Pre-2026-05-17 this was gated on @@ -268,7 +268,7 @@ export async function wireSlots(state: EngineState, deps: BootstrapDeps): Promis device, requestRender: () => state.subsystems.scheduler.requestRender(), }); - const texturedImpostors = createTexturedImpostorSubsystem({ + const texturedDisks = createTexturedDiskSubsystem({ device, atlas: galaxyAtlas, requestRender: () => state.subsystems.scheduler.requestRender(), @@ -283,7 +283,7 @@ export async function wireSlots(state: EngineState, deps: BootstrapDeps): Promis texturedDiskRenderer.bindAtlas(galaxyAtlas.getTextureView()); state.subsystems.galaxyAtlas = galaxyAtlas; - state.subsystems.texturedImpostors = texturedImpostors; + state.subsystems.texturedDisks = texturedDisks; state.subsystems.proceduralDisks = proceduralDisks; // Register the always-on overlay fade handles at opacity 1.0. The @@ -304,7 +304,7 @@ export async function wireSlots(state: EngineState, deps: BootstrapDeps): Promis state.settings.milkyWay.enabled ? 1 : 0, ); state.subsystems.fades.register({ kind: 'overlay', id: 'proceduralDisks' }, 1); - state.subsystems.fades.register({ kind: 'overlay', id: 'texturedImpostors' }, 1); + state.subsystems.fades.register({ kind: 'overlay', id: 'texturedDisks' }, 1); // Scalar-volume master gate. Registered at the current settings // value so a default-on session sees 1.0 from frame 1 (and the diff --git a/src/services/engine/subsystems/galaxyAtlasSubsystem.ts b/src/services/engine/subsystems/galaxyAtlasSubsystem.ts index c889c77e..7f9c9528 100644 --- a/src/services/engine/subsystems/galaxyAtlasSubsystem.ts +++ b/src/services/engine/subsystems/galaxyAtlasSubsystem.ts @@ -5,7 +5,7 @@ * impostor-subsystem split. Owns the 2048² GPU texture atlas, the LRU * clock, the priority-queued bitmap fetcher, the failure-memoisation * pair (handled here for "did the fetch land at all?" — separate from - * the load-fade bookkeeping which lives in `texturedImpostorSubsystem`), + * the load-fade bookkeeping which lives in `texturedDiskSubsystem`), * and the eviction notification hook. * * No catalog awareness; no per-frame planning; no GPU dispatch. This @@ -21,7 +21,7 @@ * * The first two are pure "did the fetch succeed?" state — exactly the * shape that lives here. The third is load-fade state and belongs in - * `texturedImpostorSubsystem`, which owns the fade-window decisions. + * `texturedDiskSubsystem`, which owns the fade-window decisions. * The eviction handler (`setEvictHandler`) is what lets the LOD-2 planner * keep its parallel `bitmapReadyTime` map in sync without re-implementing * the LRU clock. diff --git a/src/services/engine/subsystems/texturedImpostorSubsystem.ts b/src/services/engine/subsystems/texturedDiskSubsystem.ts similarity index 94% rename from src/services/engine/subsystems/texturedImpostorSubsystem.ts rename to src/services/engine/subsystems/texturedDiskSubsystem.ts index 4d1ba71b..a6fa695d 100644 --- a/src/services/engine/subsystems/texturedImpostorSubsystem.ts +++ b/src/services/engine/subsystems/texturedDiskSubsystem.ts @@ -1,5 +1,5 @@ /** - * texturedImpostorSubsystem — LOD-2 per-frame planner. + * texturedDiskSubsystem — LOD-2 per-frame planner. * * Extracted from `thumbnailSubsystem.ts` lines 487-993 as part of the * 2026-05-12 impostor-subsystem split. Walks the catalog, applies the @@ -36,10 +36,10 @@ import type { Destroyable } from '../../../@types/rendering/Destroyable'; import type { DiskInstance } from '../../../@types/rendering/DiskInstance'; import type { GalaxyAtlasSubsystem } from '../../../@types/engine/subsystems/GalaxyAtlasSubsystem'; import type { - TexturedImpostorFrameInput, - TexturedImpostorFrameOutput, - TexturedImpostorSubsystemWithTestSeam, -} from '../../../@types/engine/subsystems/TexturedImpostorSubsystem'; + TexturedDiskFrameInput, + TexturedDiskFrameOutput, + TexturedDiskSubsystemWithTestSeam, +} from '../../../@types/engine/subsystems/TexturedDiskSubsystem'; import type { FamousMetaEntry } from '../../../@types/loading/FamousMetaEntry'; import { fetchGalaxyBitmap } from '../../../utils/network/galaxyImageFetcher'; import { cartesianToRaDecZ } from '../../../utils/math'; @@ -60,7 +60,7 @@ export function galaxyCacheKey(ra: number, dec: number): string { return `${ra.toFixed(5)}_${dec.toFixed(5)}`; } -export type TexturedImpostorDeps = { +export type TexturedDiskDeps = { readonly device: GPUDevice; readonly atlas: GalaxyAtlasSubsystem; readonly requestRender: () => void; @@ -73,9 +73,9 @@ export type TexturedImpostorDeps = { readonly decimationFactor?: number; }; -export function createTexturedImpostorSubsystem( - deps: TexturedImpostorDeps, -): TexturedImpostorSubsystemWithTestSeam { +export function createTexturedDiskSubsystem( + deps: TexturedDiskDeps, +): TexturedDiskSubsystemWithTestSeam { const { atlas, requestRender } = deps; const fetcher = deps.fetcher ?? fetchGalaxyBitmap; const decimationFactor = Math.max(1, Math.floor(deps.decimationFactor ?? 8)); @@ -95,9 +95,9 @@ export function createTexturedImpostorSubsystem( let frameCounter = 0; let destroyed = false; - let lastOutput: TexturedImpostorFrameOutput = { disks: [] }; + let lastOutput: TexturedDiskFrameOutput = { disks: [] }; - function runFrame(input: TexturedImpostorFrameInput): TexturedImpostorFrameOutput { + function runFrame(input: TexturedDiskFrameInput): TexturedDiskFrameOutput { if (destroyed) return lastOutput; const { cam, catalogs, visibleSourceMask, pxPerRad, famousMeta } = input; @@ -277,7 +277,7 @@ export function createTexturedImpostorSubsystem( lastOutput = { disks: [] }; } - const subsystem: TexturedImpostorSubsystemWithTestSeam = { + const subsystem: TexturedDiskSubsystemWithTestSeam = { runFrame, get lastOutput() { return lastOutput; diff --git a/src/services/gpu/renderers/texturedDiskRenderer.ts b/src/services/gpu/renderers/texturedDiskRenderer.ts index 3acde5d5..fceeffec 100644 --- a/src/services/gpu/renderers/texturedDiskRenderer.ts +++ b/src/services/gpu/renderers/texturedDiskRenderer.ts @@ -47,8 +47,8 @@ import type { Renderer } from '../../../@types/rendering/Renderer'; import type { DiskInstance } from '../../../@types/rendering/DiskInstance'; import type { TexturedDiskRenderer } from '../../../@types/rendering/TexturedDiskRenderer'; import type { Vec3 } from '../../../@types/math/Vec3'; -import vsCode from '../shaders/disks/vertex.wesl?static'; -import fsCode from '../shaders/disks/fragment.wesl?static'; +import vsCode from '../shaders/texturedDisks/vertex.wesl?static'; +import fsCode from '../shaders/texturedDisks/fragment.wesl?static'; import { FLOATS_PER_INSTANCE, createInstancedQuadRenderer } from './instancedQuadRenderer'; export function createTexturedDiskRenderer(ctx: GpuContext, maxInstances = 256): TexturedDiskRenderer { diff --git a/src/services/gpu/shaders/lib/billboard.wesl b/src/services/gpu/shaders/lib/billboard.wesl index 8d94ef0e..d0c3520b 100644 --- a/src/services/gpu/shaders/lib/billboard.wesl +++ b/src/services/gpu/shaders/lib/billboard.wesl @@ -26,7 +26,7 @@ // north-up orientation tracks sky-north, not the camera). That's // fundamentally renderer-specific math — see the long doc-block // around 'NORTH_WORLD' / 'upClip' in 'quads.wesl'. -// - 'disks' and 'proceduralDisks' build their bases from the galaxy's +// - 'texturedDisks' and 'proceduralDisks' build their bases from the galaxy's // intrinsic position-angle and inclination (camera-INDEPENDENT — the // disk plane is a property of the galaxy in 3D space). That math // belongs in 'lib/orientation.wesl' (Task 6), not here. @@ -69,7 +69,7 @@ // orderings of the same unit square (both render an identical filled // quad under WebGPU's default 'cullMode: none'): // -// - 'quads' / 'disks' / 'proceduralDisks': +// - 'quads' / 'texturedDisks' / 'proceduralDisks': // BL, BR, TR, BL, TR, TL // - 'points': // BL, BR, TL, TL, BR, TR diff --git a/src/services/gpu/shaders/lib/camera.wesl b/src/services/gpu/shaders/lib/camera.wesl index ac21b6f3..7dd335a6 100644 --- a/src/services/gpu/shaders/lib/camera.wesl +++ b/src/services/gpu/shaders/lib/camera.wesl @@ -30,7 +30,7 @@ // 'cameraPosWorld', 'camPos') lives at WILDLY different byte // offsets across renderers — points puts six other f32/u32 // fields between 'viewport' and 'camPosWorld'; milkyWayImpostor -// has 'fadeAlpha' + 'iTime' in those same slots; quads/disks +// has 'fadeAlpha' + 'iTime' in those same slots; quads/texturedDisks // pad those slots out with explicit '_pad0/_pad1'. Forcing all // of them onto the same prefix would mean rewriting the // points/milkyWayImpostor uniform layouts purely to satisfy a diff --git a/src/services/gpu/shaders/lib/masks.wesl b/src/services/gpu/shaders/lib/masks.wesl index 923bc74e..ae2a73e4 100644 --- a/src/services/gpu/shaders/lib/masks.wesl +++ b/src/services/gpu/shaders/lib/masks.wesl @@ -56,7 +56,7 @@ fn circularMask(r: f32, inner: f32, outer: f32) -> f32 { // ── lumAlpha ───────────────────────────────────────────────────────── // // Luminance gate for sky-subtraction-lite. Used by texture-sampling -// fragment stages (disks, quads) to drop near-black sky pixels in +// fragment stages (texturedDisks, quads) to drop near-black sky pixels in // SDSS / DSS thumbnail JPEGs that ship with no alpha channel: passing // 'max(rgba.r, max(rgba.g, rgba.b))' through this gate maps near-zero // luminance to fully transparent and bright galaxy pixels to fully diff --git a/src/services/gpu/shaders/lib/orientation.wesl b/src/services/gpu/shaders/lib/orientation.wesl index 234f7d0b..a238eebf 100644 --- a/src/services/gpu/shaders/lib/orientation.wesl +++ b/src/services/gpu/shaders/lib/orientation.wesl @@ -1,5 +1,5 @@ // lib/orientation.wesl — disk-plane axis math from on-sky position angle -// + inclination, shared between 'disks.wesl' (textured thumbnails) and +// + inclination, shared between 'texturedDisks' (textured thumbnails) and // 'proceduralDisks.wesl' (impostor brightness profile). // // Both renderers draw a galaxy as a 3D-oriented quad whose in-plane @@ -14,12 +14,12 @@ // // CRITICAL: this math is camera-INDEPENDENT. The disk's orientation is // a property of the galaxy in 3D space, NOT of where the camera -// currently sits. An earlier revision of disks.wesl (now its long +// currently sits. An earlier revision of texturedDisks (now its long // header comment) built the basis from 'camPos - center', which made // orbiting the camera visibly rotate the disk plane — exactly the bug // world-space orientation was rewritten to fix. Do NOT add a // 'cameraPos' parameter to anything in this file; if you find yourself -// reaching for one, re-read disks.wesl's header. The plan for this +// reaching for one, re-read texturedDisks's header. The plan for this // task initially proposed 'diskAxes(posWS, cameraPos, ...)'; the // 'cameraPos' was dropped after grepping the call sites confirmed // neither consumer reads it. @@ -43,7 +43,7 @@ // 'axisRatio' and computing them inside? The 'sinI = sqrt(max(0.0, // 1.0 - cosI*cosI))' line is one statement and the consumers already // have the convention of clamping 'axisRatio' to a 0.05 floor before -// the trig (see disks.wesl line 120 + proceduralDisks.wesl line 119). +// the trig (see texturedDisks line 120 + proceduralDisks.wesl line 119). // Doing the clamp inside this lib would either silently re-clamp a // value the caller already clamped, or it would require a second // "raw vs clamped" parameter — both are noisier than just letting the @@ -83,7 +83,7 @@ // downstream normalize() amplifies floating-point noise. Two slightly // different threshold conventions existed pre-extraction: // -// - disks.wesl: 'abs(dot(northPole, losDir)) > 0.99' (~8° from pole), +// - texturedDisks: 'abs(dot(northPole, losDir)) > 0.99' (~8° from pole), // swap seed BEFORE the projection — wide, conservative. // - proceduralDisks.wesl: 'length(northTangentRaw) < 1e-4' (~exactly // at pole), swap result AFTER the projection — tight, only fires diff --git a/src/services/gpu/shaders/proceduralDisks/io.wesl b/src/services/gpu/shaders/proceduralDisks/io.wesl index c051e5f9..3d622772 100644 --- a/src/services/gpu/shaders/proceduralDisks/io.wesl +++ b/src/services/gpu/shaders/proceduralDisks/io.wesl @@ -1,7 +1,7 @@ // proceduralDisks/io.wesl — shared structs for the procedural galaxy // impostor pipeline. // -// Sibling pipeline to 'disks' (texture-based) and 'points' (screen- +// Sibling pipeline to 'texturedDisks' (atlas-textured impostor) and 'points' (screen- // aligned billboards). Renders every galaxy whose apparent size // exceeds 8 px (with a crossfade up to 14 px) as a 3D-oriented quad // shaded with a two-component brightness profile (Gaussian bulge + @@ -66,7 +66,7 @@ struct Uniforms { // 'normalize(pos)' (Earth → galaxy), not the camera, and the // procedural shader has no apparent-size scaling that would consult // 'pxPerRad'. Same intentional ABI-preservation pattern as - // disks.wesl. + // texturedDisks. camPosWorld: vec3, pxPerRad: f32, }; diff --git a/src/services/gpu/shaders/proceduralDisks/vertex.wesl b/src/services/gpu/shaders/proceduralDisks/vertex.wesl index 99ccda0b..3515c7e7 100644 --- a/src/services/gpu/shaders/proceduralDisks/vertex.wesl +++ b/src/services/gpu/shaders/proceduralDisks/vertex.wesl @@ -40,7 +40,7 @@ import package::lib::camera::worldToClip; // is intentionally NOT pulled into this lib. import package::lib::billboard::quadCorner; // Disk-plane axis math (PA + inclination → world-space major/minor basis) -// is shared with 'disks.wesl' via 'lib/orientation.wesl'. The inline +// is shared with 'texturedDisks/vertex.wesl' via 'lib/orientation.wesl'. The inline // derivation that used to live in this file's vs() body — the // los/north/east/major/minor chain plus the inclination tilt of the // minor axis out of the sky plane — is now the lib's 'diskAxes' fn, @@ -82,11 +82,11 @@ fn vs(@builtin(vertex_index) vid: u32, instance: InstanceIn) -> VsOut { // toward the perpendicular-to-major-axis sky direction. The minor // axis then lies in the plane perpendicular to (major × normal). // - // Implementation reuses the same algebra as disks.wesl — see that + // Implementation reuses the same algebra as texturedDisks/vertex.wesl — see that // file for the full step-by-step derivation including the sign // conventions for sky-east vs world-X. let pos = instance.posSize.xyz; - // Half the full-extent value in posSize.w to match disks.wesl line 104. + // Half the full-extent value in posSize.w to match texturedDisks/vertex.wesl line 104. // 'posSize.w' is the FULL quad extent in Mpc (set at the emission site // in thumbnailSubsystem.ts to '(diameterKpc/1000) * 4', the same // multiplier the points pass uses for GALAXY_RADIUS_MPC). Each @@ -96,7 +96,7 @@ fn vs(@builtin(vertex_index) vid: u32, instance: InstanceIn) -> VsOut { // points-pass billboard (which uses the same multiplier directly). let halfWorld = instance.posSize.w * 0.5; // Floor at 0.05 to avoid degenerate-edge-on disks collapsing the quad to a - // 1D line in the vertex stage; matches the disks.wesl convention. + // 1D line in the vertex stage; matches the texturedDisks/vertex.wesl convention. let axisRatio = max(instance.orientation.x, 0.05); let paRad = instance.orientation.y * 3.14159265 / 180.0; diff --git a/src/services/gpu/shaders/disks/fragment.wesl b/src/services/gpu/shaders/texturedDisks/fragment.wesl similarity index 96% rename from src/services/gpu/shaders/disks/fragment.wesl rename to src/services/gpu/shaders/texturedDisks/fragment.wesl index c05f4b7e..520708f1 100644 --- a/src/services/gpu/shaders/disks/fragment.wesl +++ b/src/services/gpu/shaders/texturedDisks/fragment.wesl @@ -1,4 +1,4 @@ -// disks/fragment.wesl — galaxy-disk fragment stage. +// texturedDisks/fragment.wesl — textured galaxy-disk fragment stage. // // Samples the texture atlas, applies a soft circular mask to round // the corners of the (square) UV rectangle, and gates alpha by @@ -29,7 +29,7 @@ // fragment module here intentionally doesn't declare the uniform, // keeping the binding's visibility VERTEX-only as it was before.) -import package::disks::io::VsOut; +import package::texturedDisks::io::VsOut; // Shared fragment-stage mask shapes — see 'lib/masks.wesl' for the // rationale (three smoothstep patterns recurred across four shaders, // naming the shapes makes the intent visible at the call site). diff --git a/src/services/gpu/shaders/disks/io.wesl b/src/services/gpu/shaders/texturedDisks/io.wesl similarity index 78% rename from src/services/gpu/shaders/disks/io.wesl rename to src/services/gpu/shaders/texturedDisks/io.wesl index 813c166c..53cb3f5b 100644 --- a/src/services/gpu/shaders/disks/io.wesl +++ b/src/services/gpu/shaders/texturedDisks/io.wesl @@ -1,8 +1,12 @@ -// disks/io.wesl — shared structs for the oriented galaxy-disk pipeline. +// texturedDisks/io.wesl — shared structs for the textured galaxy-disk +// (impostor-quad) pipeline. Sibling pipeline to 'proceduralDisks/' +// (procedural impostor for galaxies that don't yet have a thumbnail +// loaded) and 'points/' (screen-aligned billboards). This pipeline +// renders galaxies whose apparent pixel size exceeds the +// procedural-disk fade-in band as a 3D-oriented quad textured with the +// per-galaxy thumbnail atlas slot. // -// This file is the 'interface' module of the disks renderer family. It -// declares the structs that BOTH the vertex and fragment entry points -// need to agree on byte-for-byte: +// Declares the three structs every entry point needs to agree on: // // - 'Uniforms' — the @group(0) @binding(0) uniform buffer layout. // - 'InstanceIn' — the per-instance vertex attributes. @@ -10,12 +14,10 @@ // // ## Why a separate io module // -// Originally everything lived in a single 'disks.wesl'. Splitting it -// into io + vertex + fragment mirrors the points/ and milkyWay/ -// splits (tasks 13 and 14) so each stage compiles a strictly-smaller -// shader module from disjoint source. The vertex stage doesn't read -// the atlas texture; the fragment stage doesn't run the orientation -// math. +// io + vertex + fragment mirrors the points/ and milkyWay/ splits so +// each stage compiles a strictly-smaller shader module from disjoint +// source. The vertex stage doesn't read the atlas texture; the +// fragment stage doesn't run the orientation math. // // ## Why bindings live in the consuming files, not here // diff --git a/src/services/gpu/shaders/disks/vertex.wesl b/src/services/gpu/shaders/texturedDisks/vertex.wesl similarity index 96% rename from src/services/gpu/shaders/disks/vertex.wesl rename to src/services/gpu/shaders/texturedDisks/vertex.wesl index 1e7c96ea..d8687f15 100644 --- a/src/services/gpu/shaders/disks/vertex.wesl +++ b/src/services/gpu/shaders/texturedDisks/vertex.wesl @@ -1,5 +1,5 @@ -// disks/vertex.wesl — oriented galaxy-disk vertex stage (astronomically -// correct). +// texturedDisks/vertex.wesl — oriented textured galaxy-disk vertex +// stage (astronomically correct). // // Each instance is a 3D disk fixed in WORLD space. The galaxy's true // orientation is derived from its on-sky position angle (PA, east of @@ -44,9 +44,9 @@ // identical uniform declaration. Atlas-texture / sampler bindings // are fragment-only and stay in fragment.wesl. -import package::disks::io::Uniforms; -import package::disks::io::InstanceIn; -import package::disks::io::VsOut; +import package::texturedDisks::io::Uniforms; +import package::texturedDisks::io::InstanceIn; +import package::texturedDisks::io::VsOut; import package::lib::camera::worldToClip; // Shared unit-quad helpers from 'lib/billboard.wesl' — replace the // inline 'CORNERS' const + '(corner + 1) * 0.5' UV remap that used to diff --git a/src/services/gpu/timing/TIMING_SLOT_NAMES.ts b/src/services/gpu/timing/TIMING_SLOT_NAMES.ts index 5eb4524b..abc9bfb9 100644 --- a/src/services/gpu/timing/TIMING_SLOT_NAMES.ts +++ b/src/services/gpu/timing/TIMING_SLOT_NAMES.ts @@ -26,7 +26,7 @@ * * `textured-disks` inherits the legacy `textured-impostors` indices * (4, 5) so historical samples stay comparable across the - * 2026-05-18 quad-removal rename. See `texturedImpostorSubsystem.ts` + * 2026-05-18 quad-removal rename. See `texturedDiskSubsystem.ts` * for why the screen-aligned quad fallback was removed. * * ### Why a `Map` rather than a plain object diff --git a/src/utils/galaxySize.ts b/src/utils/galaxySize.ts index 4ba8ba4b..048a3ef3 100644 --- a/src/utils/galaxySize.ts +++ b/src/utils/galaxySize.ts @@ -5,7 +5,7 @@ * * ## Why a shared helper * - * The points bake, proceduralDiskSubsystem, and texturedImpostorSubsystem + * The points bake, proceduralDiskSubsystem, and texturedDiskSubsystem * each used to compute the same '(diameterKpc * 2) / 1000' algebra * inline — three sites, one constant 4× padding factor + one 30-kpc * synthetic-fallback floor. A change to either (e.g. tightening the diff --git a/tests/@types/engineState.test.ts b/tests/@types/engineState.test.ts index 04c17da1..edbe8598 100644 --- a/tests/@types/engineState.test.ts +++ b/tests/@types/engineState.test.ts @@ -168,7 +168,7 @@ describe('EngineState type', () => { subsystems: { galaxyAtlas: null, proceduralDisks: null, - texturedImpostors: null, + texturedDisks: null, loadProgress: null, spaceMouse: createSpaceMouseSubsystem({ cancelTween: () => {}, @@ -346,7 +346,7 @@ describe('EngineState type', () => { subsystems: { galaxyAtlas: null, proceduralDisks: null, - texturedImpostors: null, + texturedDisks: null, loadProgress: null, spaceMouse: createSpaceMouseSubsystem({ cancelTween: () => {}, diff --git a/tests/services/engine/frame/encodeVolumes.test.ts b/tests/services/engine/frame/encodeVolumes.test.ts index d32129d3..4a1ab6c9 100644 --- a/tests/services/engine/frame/encodeVolumes.test.ts +++ b/tests/services/engine/frame/encodeVolumes.test.ts @@ -50,7 +50,7 @@ function makeCtx(): ReadyFrameContext { renderer: {} as never, postProcess: { view: {} as GPUTextureView, resize: vi.fn(), draw: vi.fn(), destroy: vi.fn() } as never, volumeOffscreen: { view: offscreenView, resize: vi.fn(), destroy: vi.fn() }, - texturedImpostors: {} as never, + texturedDisks: {} as never, }; } diff --git a/tests/services/engine/frame/frameContext.test.ts b/tests/services/engine/frame/frameContext.test.ts index 8198603d..3978ca87 100644 --- a/tests/services/engine/frame/frameContext.test.ts +++ b/tests/services/engine/frame/frameContext.test.ts @@ -52,7 +52,7 @@ function makeCam(overrides: Partial = {}): OrbitCamera { /** * Build an `EngineState`-shaped fixture with the guard fields * (`cam`, `gpu.renderer`, `gpu.postProcess`, `gpu.pickRenderer`, - * `gpu.volumeOffscreen`, `subsystems.texturedImpostors`) populated by + * `gpu.volumeOffscreen`, `subsystems.texturedDisks`) populated by * default. Each test override can null any one of them to exercise the * not-ready branch. * @@ -67,7 +67,7 @@ function makeState(overrides: { postProcess?: unknown; pickRenderer?: unknown; volumeOffscreen?: unknown; - texturedImpostors?: unknown; + texturedDisks?: unknown; } = {}): EngineState { const cam = overrides.cam === undefined ? makeCam() : overrides.cam; const renderer = overrides.renderer === undefined ? ({} as unknown) : overrides.renderer; @@ -77,12 +77,12 @@ function makeState(overrides: { overrides.pickRenderer === undefined ? ({} as unknown) : overrides.pickRenderer; const volumeOffscreen = overrides.volumeOffscreen === undefined ? ({} as unknown) : overrides.volumeOffscreen; - const texturedImpostors = - overrides.texturedImpostors === undefined ? ({} as unknown) : overrides.texturedImpostors; + const texturedDisks = + overrides.texturedDisks === undefined ? ({} as unknown) : overrides.texturedDisks; return { cam, gpu: { renderer, postProcess, pickRenderer, volumeOffscreen }, - subsystems: { texturedImpostors }, + subsystems: { texturedDisks }, } as unknown as EngineState; } @@ -113,8 +113,8 @@ describe('deriveFrameContext — not-ready branch', () => { expect(ctx.isReady).toBe(false); }); - it('returns isReady:false when subsystems.texturedImpostors is null', () => { - const ctx = deriveFrameContext(makeState({ texturedImpostors: null }), makeCanvas()); + it('returns isReady:false when subsystems.texturedDisks is null', () => { + const ctx = deriveFrameContext(makeState({ texturedDisks: null }), makeCanvas()); expect(ctx.isReady).toBe(false); }); }); @@ -149,19 +149,19 @@ describe('deriveFrameContext — ready branch', () => { expect(ctx.vp.length).toBe(16); }); - it('forwards renderer, postProcess, texturedImpostors references onto the ready context', () => { + it('forwards renderer, postProcess, texturedDisks references onto the ready context', () => { const renderer = { tag: 'renderer' }; const postProcess = { tag: 'postProcess' }; - const texturedImpostors = { tag: 'texturedImpostors' }; + const texturedDisks = { tag: 'texturedDisks' }; const ctx = deriveFrameContext( - makeState({ renderer, postProcess, texturedImpostors }), + makeState({ renderer, postProcess, texturedDisks }), makeCanvas(), ); expect(ctx.isReady).toBe(true); if (!ctx.isReady) return; expect(ctx.renderer).toBe(renderer); expect(ctx.postProcess).toBe(postProcess); - expect(ctx.texturedImpostors).toBe(texturedImpostors); + expect(ctx.texturedDisks).toBe(texturedDisks); }); it('forwards volumeOffscreen reference onto the ready context', () => { diff --git a/tests/services/engine/frame/passes/passes.test.ts b/tests/services/engine/frame/passes/passes.test.ts index bc8c543b..b677a245 100644 --- a/tests/services/engine/frame/passes/passes.test.ts +++ b/tests/services/engine/frame/passes/passes.test.ts @@ -66,7 +66,7 @@ function makeCtx(overrides: Partial = {}): ReadyFrameContext const renderer = { draw: vi.fn() } as any; const postProcess = { view: {} as GPUTextureView, draw: vi.fn(), resize: vi.fn(), destroy: vi.fn() } as any; const volumeOffscreen = { view: {} as GPUTextureView, resize: vi.fn(), destroy: vi.fn() } as any; - const texturedImpostors = { runFrame: vi.fn(), lastOutput: { quads: [], disks: [] }, hasInFlightWork: () => false } as any; + const texturedDisks = { runFrame: vi.fn(), lastOutput: { quads: [], disks: [] }, hasInFlightWork: () => false } as any; return { isReady: true, cam, @@ -77,7 +77,7 @@ function makeCtx(overrides: Partial = {}): ReadyFrameContext renderer, postProcess, volumeOffscreen, - texturedImpostors, + texturedDisks, ...overrides, }; } diff --git a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts index d9bc8210..e9de1178 100644 --- a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts +++ b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts @@ -34,7 +34,7 @@ function makeCtx(overrides: Partial = {}): ReadyFrameContext renderer: { draw: vi.fn() } as any, postProcess: { view: {} as GPUTextureView, draw: vi.fn(), resize: vi.fn(), destroy: vi.fn() } as any, volumeOffscreen: { view: {} as GPUTextureView, resize: vi.fn(), destroy: vi.fn() } as any, - texturedImpostors: { runFrame: vi.fn(), lastOutput: { quads: [], disks: [] }, hasInFlightWork: () => false } as any, + texturedDisks: { runFrame: vi.fn(), lastOutput: { quads: [], disks: [] }, hasInFlightWork: () => false } as any, ...overrides, }; } diff --git a/tests/services/engine/frame/passes/selectionRingPass.test.ts b/tests/services/engine/frame/passes/selectionRingPass.test.ts index aa739a5a..7a467fe2 100644 --- a/tests/services/engine/frame/passes/selectionRingPass.test.ts +++ b/tests/services/engine/frame/passes/selectionRingPass.test.ts @@ -20,7 +20,7 @@ function makeCtx(): ReadyFrameContext { renderer: {} as never, postProcess: {} as never, volumeOffscreen: {} as never, - texturedImpostors: {} as never, + texturedDisks: {} as never, }; } diff --git a/tests/services/engine/frame/passes/texturedDisksPass.test.ts b/tests/services/engine/frame/passes/texturedDisksPass.test.ts index 3fc81ae0..df4153a9 100644 --- a/tests/services/engine/frame/passes/texturedDisksPass.test.ts +++ b/tests/services/engine/frame/passes/texturedDisksPass.test.ts @@ -38,7 +38,7 @@ function makeCtx(): ReadyFrameContext { destroy: vi.fn(), } as any, volumeOffscreen: { view: {} as GPUTextureView, resize: vi.fn(), destroy: vi.fn() } as any, - texturedImpostors: { + texturedDisks: { runFrame: vi.fn(), lastOutput: { disks: [] }, hasInFlightWork: () => false, @@ -71,7 +71,7 @@ describe('texturedDisksPass', () => { it('enabled() returns false when galaxyTexturesEnabled is false', () => { const state = { - subsystems: { texturedImpostors: { lastOutput: { disks: [{}], quads: [] } } }, + subsystems: { texturedDisks: { lastOutput: { disks: [{}], quads: [] } } }, } as unknown as EngineState; expect( texturedDisksPass.enabled(state, makeCtx(), makeSettings({ galaxyTexturesEnabled: false })), @@ -79,20 +79,20 @@ describe('texturedDisksPass', () => { }); it('enabled() returns false when subsystem is null', () => { - const state = { subsystems: { texturedImpostors: null } } as unknown as EngineState; + const state = { subsystems: { texturedDisks: null } } as unknown as EngineState; expect(texturedDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); }); it('enabled() returns false when disks array is empty', () => { const state = { - subsystems: { texturedImpostors: { lastOutput: { disks: [] } } }, + subsystems: { texturedDisks: { lastOutput: { disks: [] } } }, } as unknown as EngineState; expect(texturedDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); }); it('enabled() returns true when disks array is non-empty', () => { const state = { - subsystems: { texturedImpostors: { lastOutput: { disks: [{}] } } }, + subsystems: { texturedDisks: { lastOutput: { disks: [{}] } } }, } as unknown as EngineState; expect(texturedDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(true); }); @@ -100,7 +100,7 @@ describe('texturedDisksPass', () => { it('draw() invokes texturedDiskRenderer.draw', () => { const disks = [{ x: 1 }]; const state = { - subsystems: { texturedImpostors: { lastOutput: { disks } } }, + subsystems: { texturedDisks: { lastOutput: { disks } } }, } as unknown as EngineState; const deps = makeDeps(); texturedDisksPass.draw({} as GPURenderPassEncoder, makeCtx(), state, makeSettings(), deps); @@ -109,7 +109,7 @@ describe('texturedDisksPass', () => { it('draw() is a no-op when disks array is empty', () => { const state = { - subsystems: { texturedImpostors: { lastOutput: { disks: [] } } }, + subsystems: { texturedDisks: { lastOutput: { disks: [] } } }, } as unknown as EngineState; const deps = makeDeps(); texturedDisksPass.draw({} as GPURenderPassEncoder, makeCtx(), state, makeSettings(), deps); diff --git a/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts b/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts index 56a86bc9..e80a2f74 100644 --- a/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts +++ b/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts @@ -46,7 +46,7 @@ function makeCtx(offscreenView: GPUTextureView = {} as GPUTextureView): ReadyFra renderer: {} as never, postProcess: { view: {} as GPUTextureView, resize: vi.fn(), draw: vi.fn(), destroy: vi.fn() } as never, volumeOffscreen: { view: offscreenView, resize: vi.fn(), destroy: vi.fn() }, - texturedImpostors: {} as never, + texturedDisks: {} as never, }; } diff --git a/tests/services/engine/frame/renderFrame.test.ts b/tests/services/engine/frame/renderFrame.test.ts index 67073846..d729f2ef 100644 --- a/tests/services/engine/frame/renderFrame.test.ts +++ b/tests/services/engine/frame/renderFrame.test.ts @@ -284,7 +284,7 @@ function makeInput( renderer: pointRenderer, postProcess, volumeOffscreen, - texturedImpostors: thumbnails, + texturedDisks: thumbnails, }; return { @@ -327,7 +327,7 @@ function makeInput( // assertions continue to focus on point + milky-way ordering. subsystems: { proceduralDisks: null, - texturedImpostors: null, + texturedDisks: null, // filamentsPass.enabled now consults the FadeRegistry to // keep the pass alive through fade-out tails. Provide a // minimal opacityOf stub so the gate doesn't crash. @@ -456,11 +456,11 @@ describe('renderFrame', () => { // impostor-subsystem-split (Tasks 11/12). The combined `runFrame` call // that lived inside the legacy galaxyThumbnailsPass is gone — the LOD-1 // and LOD-2 plans are now produced by `proceduralDiskSubsystem.runFrame` - // and `texturedImpostorSubsystem.runFrame` upstream in `runFrame.ts`, + // and `texturedDiskSubsystem.runFrame` upstream in `runFrame.ts`, // and the three downstream passes (`proceduralDisksPass`, // `texturedQuadsPass`, `texturedDisksPass`) just issue the renderer // draws. The textured halves were split out of the former - // `texturedImpostorsPass` on 2026-05-18 so the debug panel can + // `texturedDisksPass` on 2026-05-18 so the debug panel can // toggle them independently. Per-pass coverage lives in the // matching `passes/Pass.test.ts` files. diff --git a/tests/services/engine/frame/renderFrame.timing.test.ts b/tests/services/engine/frame/renderFrame.timing.test.ts index 040e5672..08f1dd5f 100644 --- a/tests/services/engine/frame/renderFrame.timing.test.ts +++ b/tests/services/engine/frame/renderFrame.timing.test.ts @@ -203,9 +203,9 @@ function makeMinimalInputWithTiming(timingService: GpuTimingService): { drawPxPerRad: canvasHeight / (2 * Math.tan(cam.fovYRad / 2)), renderer: pointRenderer, postProcess, - // texturedImpostors slot is referenced from frameContext shape; + // texturedDisks slot is referenced from frameContext shape; // we'll null the matching subsystem on `state` so the pass skips. - texturedImpostors: null, + texturedDisks: null, } as never; const settings = { @@ -244,7 +244,7 @@ function makeMinimalInputWithTiming(timingService: GpuTimingService): { }, subsystems: { proceduralDisks: null, - texturedImpostors: null, + texturedDisks: null, // filamentsPass.enabled consults the FadeRegistry to keep the // pass alive through fade-out tails. This fixture wants the // pass GATED OFF (the test asserts only point-sprites + diff --git a/tests/services/engine/frame/runFrame.test.ts b/tests/services/engine/frame/runFrame.test.ts index 97412c7e..fa0004f0 100644 --- a/tests/services/engine/frame/runFrame.test.ts +++ b/tests/services/engine/frame/runFrame.test.ts @@ -96,7 +96,7 @@ function makeState(): EngineState { }, galaxyAtlas: null, proceduralDisks: null, - texturedImpostors: null, + texturedDisks: null, clickResolver: null, inputBindings: null, loadProgress: null, diff --git a/tests/services/engine/helpers/engineReady.test.ts b/tests/services/engine/helpers/engineReady.test.ts index b17fbb9a..a7ee923c 100644 --- a/tests/services/engine/helpers/engineReady.test.ts +++ b/tests/services/engine/helpers/engineReady.test.ts @@ -12,7 +12,7 @@ * compiler treats `state.cam`, `state.gpu.renderer`, * `state.gpu.postProcess`, `state.gpu.pickRenderer`, * `state.gpu.volumeOffscreen`, and - * `state.subsystems.texturedImpostors` as non-null without `!` or + * `state.subsystems.texturedDisks` as non-null without `!` or * `?.`. We assert this with `@ts-expect-error` over an * access that is intentionally rejected pre-narrowing, plus * a positive access post-narrowing that compiles cleanly. @@ -54,7 +54,7 @@ function makeState(overrides: { postProcess?: unknown; pickRenderer?: unknown; volumeOffscreen?: unknown; - texturedImpostors?: unknown; + texturedDisks?: unknown; } = {}): EngineState { const cam = overrides.cam === undefined ? ({} as unknown as OrbitCamera) : overrides.cam; const renderer = overrides.renderer === undefined ? ({} as unknown) : overrides.renderer; @@ -64,12 +64,12 @@ function makeState(overrides: { overrides.pickRenderer === undefined ? ({} as unknown) : overrides.pickRenderer; const volumeOffscreen = overrides.volumeOffscreen === undefined ? ({} as unknown) : overrides.volumeOffscreen; - const texturedImpostors = - overrides.texturedImpostors === undefined ? ({} as unknown) : overrides.texturedImpostors; + const texturedDisks = + overrides.texturedDisks === undefined ? ({} as unknown) : overrides.texturedDisks; return { cam, gpu: { renderer, postProcess, pickRenderer, volumeOffscreen }, - subsystems: { texturedImpostors }, + subsystems: { texturedDisks }, } as unknown as EngineState; } @@ -98,8 +98,8 @@ describe('isEngineReady — false branch', () => { expect(isEngineReady(makeState({ volumeOffscreen: null }))).toBe(false); }); - it('returns false when state.subsystems.texturedImpostors is null', () => { - expect(isEngineReady(makeState({ texturedImpostors: null }))).toBe(false); + it('returns false when state.subsystems.texturedDisks is null', () => { + expect(isEngineReady(makeState({ texturedDisks: null }))).toBe(false); }); }); @@ -122,7 +122,7 @@ describe('isEngineReady — true branch', () => { }); describe('isEngineReady — type narrowing', () => { - it('narrows state.cam, gpu handles, and texturedImpostors to non-null', () => { + it('narrows state.cam, gpu handles, and texturedDisks to non-null', () => { const state = makeState(); // Pre-narrowing, `state.cam` is `OrbitCamera | null`, so reading @@ -143,7 +143,7 @@ describe('isEngineReady — type narrowing', () => { void state.gpu.postProcess.draw; void state.gpu.pickRenderer.pick; void state.gpu.volumeOffscreen.view; - void state.subsystems.texturedImpostors.runFrame; + void state.subsystems.texturedDisks.runFrame; // Sanity: the runtime value is the same object, only the type // narrowing changed. This guards against a future diff --git a/tests/services/engine/phases/wireSlots.test.ts b/tests/services/engine/phases/wireSlots.test.ts index 0dbc57cb..7d84f5ae 100644 --- a/tests/services/engine/phases/wireSlots.test.ts +++ b/tests/services/engine/phases/wireSlots.test.ts @@ -136,8 +136,8 @@ vi.mock('../../../../src/services/engine/subsystems/proceduralDiskSubsystem', () PROCEDURAL_DISK_FADE_START_PX: 8, PROCEDURAL_DISK_FADE_END_PX: 14, })); -vi.mock('../../../../src/services/engine/subsystems/texturedImpostorSubsystem', () => ({ - createTexturedImpostorSubsystem: vi.fn(() => ({ +vi.mock('../../../../src/services/engine/subsystems/texturedDiskSubsystem', () => ({ + createTexturedDiskSubsystem: vi.fn(() => ({ runFrame: vi.fn(), lastOutput: { quads: [], disks: [] }, hasInFlightWork: vi.fn(() => false), @@ -291,7 +291,7 @@ function makeState( scheduler: { requestRender: vi.fn() } as never, galaxyAtlas: null, proceduralDisks: null, - texturedImpostors: null, + texturedDisks: null, loadProgress: null, // Post-Task-7 (2026-05-17): static cluster/supercluster/void // anchors are wired unconditionally — `wireSlots` now always diff --git a/tests/services/engine/subsystems/texturedImpostorSubsystem.test.ts b/tests/services/engine/subsystems/texturedDiskSubsystem.test.ts similarity index 92% rename from tests/services/engine/subsystems/texturedImpostorSubsystem.test.ts rename to tests/services/engine/subsystems/texturedDiskSubsystem.test.ts index 8083b311..3787250f 100644 --- a/tests/services/engine/subsystems/texturedImpostorSubsystem.test.ts +++ b/tests/services/engine/subsystems/texturedDiskSubsystem.test.ts @@ -1,5 +1,5 @@ /** - * texturedImpostorSubsystem — unit tests for the LOD-2 per-frame planner. + * texturedDiskSubsystem — unit tests for the LOD-2 per-frame planner. * * Coverage focus: * - allocates an atlas slot per visible-large-enough galaxy @@ -14,7 +14,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Source } from '../../../../src/data/sources'; import { createGalaxyAtlasSubsystem } from '../../../../src/services/engine/subsystems/galaxyAtlasSubsystem'; -import { createTexturedImpostorSubsystem } from '../../../../src/services/engine/subsystems/texturedImpostorSubsystem'; +import { createTexturedDiskSubsystem } from '../../../../src/services/engine/subsystems/texturedDiskSubsystem'; import type { GalaxyCatalog } from '../../../../src/@types/data/GalaxyCatalog'; import type { OrbitCamera } from '../../../../src/@types/camera/OrbitCamera'; @@ -85,7 +85,7 @@ function makeInput(catalogs: Map, mask = 0xffffffff) { }; } -describe('createTexturedImpostorSubsystem', () => { +describe('createTexturedDiskSubsystem', () => { let device: GPUDevice; beforeEach(() => { device = makeFakeDevice(); @@ -94,7 +94,7 @@ describe('createTexturedImpostorSubsystem', () => { it('emits a DiskInstance per finite-orientation galaxy once bitmap is ready', async () => { const fetcher = vi.fn(async () => makeFakeBitmap()); const atlas = createGalaxyAtlasSubsystem({ device, requestRender: () => {} }); - const sys = createTexturedImpostorSubsystem({ + const sys = createTexturedDiskSubsystem({ device, atlas, requestRender: () => {}, @@ -119,7 +119,7 @@ describe('createTexturedImpostorSubsystem', () => { // the disks-only branch. const fetcher = vi.fn(async () => makeFakeBitmap()); const atlas = createGalaxyAtlasSubsystem({ device, requestRender: () => {} }); - const sys = createTexturedImpostorSubsystem({ + const sys = createTexturedDiskSubsystem({ device, atlas, requestRender: () => {}, @@ -137,7 +137,7 @@ describe('createTexturedImpostorSubsystem', () => { const pending: Array<(b: ImageBitmap | null) => void> = []; const fetcher = vi.fn(() => new Promise((res) => pending.push(res))); const atlas = createGalaxyAtlasSubsystem({ device, requestRender: () => {} }); - const sys = createTexturedImpostorSubsystem({ + const sys = createTexturedDiskSubsystem({ device, atlas, requestRender: () => {}, @@ -155,7 +155,7 @@ describe('createTexturedImpostorSubsystem', () => { it('skips fetches for already-failed keys (retry-storm guard)', async () => { const fetcher = vi.fn(async () => null); const atlas = createGalaxyAtlasSubsystem({ device, requestRender: () => {} }); - const sys = createTexturedImpostorSubsystem({ + const sys = createTexturedDiskSubsystem({ device, atlas, requestRender: () => {}, diff --git a/tests/visual/galaxyImpostorBaseline.test.ts b/tests/visual/galaxyImpostorBaseline.test.ts index 72ef30a1..93f6e648 100644 --- a/tests/visual/galaxyImpostorBaseline.test.ts +++ b/tests/visual/galaxyImpostorBaseline.test.ts @@ -2,7 +2,7 @@ * Visual baseline — post-split galaxy-impostor draw-call sequence. * * Drives the three new subsystems (galaxyAtlas + proceduralDisk + - * texturedImpostor) through one runFrame each, then asserts the + * texturedDisk) through one runFrame each, then asserts the * resulting `lastOutput` arrays hash to the same baseline the pre-split * snapshot recorded in Task 1. * @@ -24,7 +24,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Source } from '../../src/data/sources'; import { createGalaxyAtlasSubsystem } from '../../src/services/engine/subsystems/galaxyAtlasSubsystem'; import { createProceduralDiskSubsystem } from '../../src/services/engine/subsystems/proceduralDiskSubsystem'; -import { createTexturedImpostorSubsystem } from '../../src/services/engine/subsystems/texturedImpostorSubsystem'; +import { createTexturedDiskSubsystem } from '../../src/services/engine/subsystems/texturedDiskSubsystem'; import type { GalaxyCatalog } from '../../src/@types/data/GalaxyCatalog'; import type { OrbitCamera } from '../../src/@types/camera/OrbitCamera'; @@ -110,7 +110,7 @@ describe('galaxy-impostor visual baseline (post-split)', () => { const device = makeFakeDevice(); const atlas = createGalaxyAtlasSubsystem({ device, requestRender: () => {} }); const procSys = createProceduralDiskSubsystem({ decimationFactor: 1 }); - const texSys = createTexturedImpostorSubsystem({ + const texSys = createTexturedDiskSubsystem({ device, atlas, requestRender: () => {}, diff --git a/tests/visual/renderFrameSplitBaseline.test.ts b/tests/visual/renderFrameSplitBaseline.test.ts index 41565deb..f7b3173e 100644 --- a/tests/visual/renderFrameSplitBaseline.test.ts +++ b/tests/visual/renderFrameSplitBaseline.test.ts @@ -289,7 +289,7 @@ describe('renderFrame visual baseline', () => { const proceduralDisksSubsystem = { lastOutput: { instances: [{ stub: true }] as unknown[] }, }; - const texturedImpostorsSubsystem = { + const texturedDisksSubsystem = { lastOutput: { disks: [{ stub: true }] as unknown[], }, @@ -306,7 +306,7 @@ describe('renderFrame visual baseline', () => { drawPxPerRad, renderer: pointRenderer, postProcess, - texturedImpostors: texturedImpostorsSubsystem, + texturedDisks: texturedDisksSubsystem, // volumeUpsamplePass.draw reads ctx.volumeOffscreen.view to pass // as the source texture to the upsample step. volumeOffscreen: { view: {} as GPUTextureView }, @@ -352,7 +352,7 @@ describe('renderFrame visual baseline', () => { }, subsystems: { proceduralDisks: proceduralDisksSubsystem, - texturedImpostors: texturedImpostorsSubsystem, + texturedDisks: texturedDisksSubsystem, fades: { register: vi.fn(), unregister: vi.fn(), From 85ba2eb29df631a22ea41e1f9b883edb49fe1b6a Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 05:06:10 +0200 Subject: [PATCH 8/8] refactor(naming): drop residual textured-impostor references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep of the leftovers the sed didn't catch (compound terms like 'textured-impostor' separator-style, plus historical narrative comments referring to the pre-rename layout). Generic uses of 'impostor' as a graphics term (e.g. proceduralDisks docblock describing what texturedDisks IS) are left intact — those are legitimate jargon, not subsystem references. While in the neighbourhood, trim a handful of historical comments ('post-split', 'Task 11/12', 'legacy textured-impostors slot', '2026-05-18 quad-removal') per the project's comment-style convention against history notes in code. Co-Authored-By: Claude Opus 4.7 --- src/@types/animation/FadeHandle.d.ts | 2 +- .../subsystems/GalaxyAtlasSubsystem.d.ts | 4 +-- .../engine/subsystems/PoiSubsystem.d.ts | 2 +- src/@types/gpu/timing/TimingSlotName.d.ts | 8 ++--- src/services/engine/frame/runFrame.ts | 17 +++++----- src/services/engine/phases/wireSlots.ts | 4 +-- src/services/gpu/timing/TIMING_SLOT_NAMES.ts | 5 --- .../engine/frame/passes/passes.test.ts | 20 ++++-------- .../gpu/timing/TIMING_SLOT_NAMES.test.ts | 3 -- .../gpu/timing/decodeTimestampBuffer.test.ts | 3 +- tests/visual/galaxyImpostorBaseline.test.ts | 32 +++++++++---------- 11 files changed, 39 insertions(+), 61 deletions(-) diff --git a/src/@types/animation/FadeHandle.d.ts b/src/@types/animation/FadeHandle.d.ts index 4079f353..a5ac1200 100644 --- a/src/@types/animation/FadeHandle.d.ts +++ b/src/@types/animation/FadeHandle.d.ts @@ -21,7 +21,7 @@ * galaxy names, scale bar). Discriminator: * `layer: LabelLayerId`. * - overlay — always-on GPU overlay (Milky Way, procedural - * disks, textured impostors). Registered at + * disks, textured disks). Registered at * opacity 1.0 via setImmediate. Discriminator: * `id: OverlayId`. * - volumesMaster — the master enable gate for the whole scalar- diff --git a/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts b/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts index f2157594..32f326e7 100644 --- a/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts +++ b/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts @@ -1,6 +1,6 @@ /** * GalaxyAtlasSubsystem — shared GPU texture atlas + bitmap-fetch queue - * for the LOD-2 (textured-impostor) galaxy path. + * for the LOD-2 (textured-disk) galaxy path. * * ### What this owns * @@ -68,7 +68,7 @@ export type GalaxyAtlasSubsystem = Destroyable & { isFailed(key: string): boolean; /** - * Number of in-flight fetches. Read by the textured-impostor + * Number of in-flight fetches. Read by the textured-disk * subsystem's `hasInFlightWork()` (which the engine's render-on-demand * predicate ORs in). */ diff --git a/src/@types/engine/subsystems/PoiSubsystem.d.ts b/src/@types/engine/subsystems/PoiSubsystem.d.ts index 5bd26fb0..c4f3c296 100644 --- a/src/@types/engine/subsystems/PoiSubsystem.d.ts +++ b/src/@types/engine/subsystems/PoiSubsystem.d.ts @@ -31,7 +31,7 @@ export type PoiSubsystem = LabelProducer & { * markers (halo + ring). Returns one descriptor per visible POI * after applying the apparent-size fade-in band AND the * max-apparent-radius fade-out. Famous-galaxy POIs always return - * empty (they render through the textured-impostor + label paths). + * empty (they render through the textured-disk + label paths). * * The producer never mutates engine state directly — the returned * array is fed to `state.gpu.clusterMarkerRenderer.setMarkers(...)` diff --git a/src/@types/gpu/timing/TimingSlotName.d.ts b/src/@types/gpu/timing/TimingSlotName.d.ts index 174d674e..9fd0ee9e 100644 --- a/src/@types/gpu/timing/TimingSlotName.d.ts +++ b/src/@types/gpu/timing/TimingSlotName.d.ts @@ -17,12 +17,8 @@ * future inhabitants without forcing a query-set resize. * * The strings match the `name` fields on `Pass` objects (e.g. - * `pointSpritesPass.name === 'point-sprites'`). Tests in Task 9 lean - * on that equality to assert each pass plumbs its timing descriptor. - * - * The `textured-disks` slot replaces the legacy `textured-impostors` - * slot (2026-05-18 split + 2026-05-18 quad-removal). Same slot - * indices (4, 5) so historical samples stay comparable. + * `pointSpritesPass.name === 'point-sprites'`). Tests lean on that + * equality to assert each pass plumbs its timing descriptor. */ export type TimingSlotName = diff --git a/src/services/engine/frame/runFrame.ts b/src/services/engine/frame/runFrame.ts index dfcf5ea8..1e966c0a 100644 --- a/src/services/engine/frame/runFrame.ts +++ b/src/services/engine/frame/runFrame.ts @@ -235,10 +235,10 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): // // CPU-side step that populates the two LOD-aligned subsystems' // `lastOutput` arrays. The HDR_PASSES loop reads those arrays via - // the new proceduralDisksPass / texturedDisksPass entries; this - // call site is the one place both walks happen each frame. The - // atlas subsystem is mutated transitively by the textured-impostor - // run (slot allocations + fetch enqueues); we don't call into it + // the proceduralDisksPass / texturedDisksPass entries; this call + // site is the one place both walks happen each frame. The atlas + // subsystem is mutated transitively by the textured-disk run + // (slot allocations + fetch enqueues); we don't call into it // directly here. if (state.subsystems.proceduralDisks !== null) { state.subsystems.proceduralDisks.runFrame({ @@ -320,11 +320,10 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): schechterMStar: state.bias.schechterMStar, schechterAlpha: state.bias.schechterAlpha, depthFadeEnabled: state.settings.points.depthFade, - // Task 8 of procedural-disk-impostor: feed the points-pass - // fragment shader the same crossfade band the procedural- - // disk pass fades IN over, so the two passes blend cleanly - // without a double-bright donut. Constants live in - // `thumbnailSubsystem.ts` as a single source of truth. + // Feed the points-pass vertex shader the same crossfade band + // the procedural-disk pass fades IN over, so the two passes + // blend cleanly without a double-bright donut. Constants live + // in `proceduralDiskSubsystem.ts` as a single source of truth. pxFadeStartPoints: PROCEDURAL_DISK_FADE_START_PX, pxFadeEndPoints: PROCEDURAL_DISK_FADE_END_PX, exposure: state.settings.tonemap.exposure, diff --git a/src/services/engine/phases/wireSlots.ts b/src/services/engine/phases/wireSlots.ts index 7d5cd740..ba24bd6f 100644 --- a/src/services/engine/phases/wireSlots.ts +++ b/src/services/engine/phases/wireSlots.ts @@ -10,7 +10,7 @@ * - Famous-meta + PGC-alias sidecar slots. * - Synthetic-volume DEV fixtures. * - The `allSlots` registry + load-progress emitter. - * - The galaxy-atlas / textured-impostor / procedural-disk subsystems. + * - The galaxy-atlas / textured-disk / procedural-disk subsystems. * * After mint, this phase fires `cb.onStatusChange({ kind: 'loading' })` * and kicks off the parallel survey loads. It does NOT block on arrivals @@ -262,7 +262,7 @@ export async function wireSlots(state: EngineState, deps: BootstrapDeps): Promis famousMetaSlot.load(); // Construct the three impostor subsystems in dependency order. The - // textured-impostor planner depends on the atlas (slot allocation + + // textured-disk planner depends on the atlas (slot allocation + // eviction subscription); the procedural-disk planner is independent. const galaxyAtlas = createGalaxyAtlasSubsystem({ device, diff --git a/src/services/gpu/timing/TIMING_SLOT_NAMES.ts b/src/services/gpu/timing/TIMING_SLOT_NAMES.ts index abc9bfb9..b4f7970b 100644 --- a/src/services/gpu/timing/TIMING_SLOT_NAMES.ts +++ b/src/services/gpu/timing/TIMING_SLOT_NAMES.ts @@ -24,11 +24,6 @@ * `beginRenderPass` for OVER-blend coherency, so they bill against a * single timing slot. * - * `textured-disks` inherits the legacy `textured-impostors` indices - * (4, 5) so historical samples stay comparable across the - * 2026-05-18 quad-removal rename. See `texturedDiskSubsystem.ts` - * for why the screen-aligned quad fallback was removed. - * * ### Why a `Map` rather than a plain object * * Iteration order matters: the decode loop in `gpuTimingService` diff --git a/tests/services/engine/frame/passes/passes.test.ts b/tests/services/engine/frame/passes/passes.test.ts index b677a245..adfc9353 100644 --- a/tests/services/engine/frame/passes/passes.test.ts +++ b/tests/services/engine/frame/passes/passes.test.ts @@ -146,16 +146,11 @@ describe('HDR_PASSES registry', () => { it('contains the seven HDR passes in canonical draw order', () => { // Order is load-bearing for HMR-stability of the encoder record; // see passes/index.ts module header. Marker-lines and labels - // moved out of HDR_PASSES to UI_PASSES (post-tone-map overlay) so - // they could escape the tone-map curve compression and avoid the - // OVER-blend coherency issue on tile-based GPUs. The former - // `textured-impostors` slot was briefly split into - // (`textured-quads`, `textured-disks`) on 2026-05-18 — the quad - // half was deleted the same day along with its renderer because - // the build-pipeline orientation fallback meant the quad branch - // never fired in practice. Cluster-markers (cluster-viz sub-plan - // 2 task 14) is the seventh additive entry, drawn last so the - // halo/ring overlay composites over the cosmic-web volume. + // live in UI_PASSES (post-tone-map overlay), not HDR_PASSES, so + // they escape the tone-map curve compression and dodge the + // OVER-blend coherency issue on tile-based GPUs. Cluster-markers + // is the seventh additive entry, drawn last so the halo/ring + // overlay composites over the cosmic-web volume. expect(HDR_PASSES).toHaveLength(7); expect(HDR_PASSES.map((p) => p.name)).toEqual([ 'point-sprites', @@ -220,11 +215,10 @@ describe('proceduralDisksPass.enabled', () => { }); }); -// Coverage for the split halves of the former `textured-impostors` -// pass lives in `texturedQuadsPass.test.ts` and +// Coverage for the `textured-disks` pass lives in // `texturedDisksPass.test.ts` (one test file per Pass module, matching // the convention used by every other entry in `passes/`). The -// HDR_PASSES registry check above pins both names in canonical order. +// HDR_PASSES registry check above pins the name in canonical order. describe('filamentsPass.enabled', () => { it('returns true when filamentsEnabled is true (renderer presence checked in draw)', () => { diff --git a/tests/services/gpu/timing/TIMING_SLOT_NAMES.test.ts b/tests/services/gpu/timing/TIMING_SLOT_NAMES.test.ts index 4090c775..95bcf882 100644 --- a/tests/services/gpu/timing/TIMING_SLOT_NAMES.test.ts +++ b/tests/services/gpu/timing/TIMING_SLOT_NAMES.test.ts @@ -15,9 +15,6 @@ describe('TIMING_SLOT_NAMES', () => { it('maps every spec-defined slot to the correct begin/end indices', () => { expect(TIMING_SLOT_NAMES.get('point-sprites')).toEqual([0, 1]); expect(TIMING_SLOT_NAMES.get('procedural-disks')).toEqual([2, 3]); - // `textured-disks` inherits the legacy `textured-impostors` indices - // (4, 5) so historical samples stay comparable across the - // 2026-05-18 quad-removal rename. expect(TIMING_SLOT_NAMES.get('textured-disks')).toEqual([4, 5]); expect(TIMING_SLOT_NAMES.get('filaments')).toEqual([6, 7]); expect(TIMING_SLOT_NAMES.get('scalar-volume')).toEqual([8, 9]); diff --git a/tests/services/gpu/timing/decodeTimestampBuffer.test.ts b/tests/services/gpu/timing/decodeTimestampBuffer.test.ts index f5da5735..e1167615 100644 --- a/tests/services/gpu/timing/decodeTimestampBuffer.test.ts +++ b/tests/services/gpu/timing/decodeTimestampBuffer.test.ts @@ -44,8 +44,7 @@ describe('decodeTimestampBuffer', () => { }); it('clamps negative deltas (end < begin) to 0', () => { - // Pair index 2 = textured-disks (u64 slots 4/5), inherited from - // the legacy `textured-impostors` slot. + // Pair index 2 = textured-disks (u64 slots 4/5). const buf = buildBuffer([[2, 5_000_000n, 1_000_000n]]); const out = decodeTimestampBuffer(buf, 1); diff --git a/tests/visual/galaxyImpostorBaseline.test.ts b/tests/visual/galaxyImpostorBaseline.test.ts index 93f6e648..d6348634 100644 --- a/tests/visual/galaxyImpostorBaseline.test.ts +++ b/tests/visual/galaxyImpostorBaseline.test.ts @@ -1,23 +1,21 @@ /** - * Visual baseline — post-split galaxy-impostor draw-call sequence. + * Visual baseline — galaxy-impostor draw-call sequence. * - * Drives the three new subsystems (galaxyAtlas + proceduralDisk + - * texturedDisk) through one runFrame each, then asserts the - * resulting `lastOutput` arrays hash to the same baseline the pre-split - * snapshot recorded in Task 1. + * Drives the three impostor-side subsystems (galaxyAtlas + + * proceduralDisk + texturedDisk) through one runFrame each, then + * asserts the resulting `lastOutput` arrays hash to a recorded baseline. + * Guards the per-galaxy emission order, the fade-alpha math, and the + * atlas-slot bookkeeping against silent regressions. * - * If this test fails after Task 11/12 cut over production: a planner's - * extraction diverged from the legacy semantics. Investigate before - * proceeding. + * ## NOTE on `performance.now()` mocking * - * NOTE on `performance.now()` mocking: the textured-impostor planner - * derives a per-galaxy `fadeAlpha` from `(now - bitmapReadyTime) / 400ms`. - * Without a fixed clock the elapsed wall time between bitmap-landing - * (inside the microtask drain after Frame 1) and `nowMs` read in Frame 2 - * varies across runs, perturbing the rounded hash. We mock `performance.now` - * with a synthetic clock advanced by exactly 50 ms between frames so the - * load-fade lerp lands deterministically on 50/400 = 0.125 — matching the - * pre-split baseline's recorded value byte-for-byte. + * The textured-disk planner derives a per-galaxy `fadeAlpha` from + * `(now - bitmapReadyTime) / 400ms`. Without a fixed clock, the elapsed + * wall time between bitmap-landing (inside the microtask drain after + * Frame 1) and `nowMs` read in Frame 2 varies across runs and perturbs + * the rounded hash. We mock `performance.now` with a synthetic clock + * advanced by exactly 50 ms between frames so the load-fade lerp lands + * deterministically on 50/400 = 0.125. */ import { describe, it, expect, vi } from 'vitest'; @@ -99,7 +97,7 @@ function hashInstances(instances: ReadonlyArray): string { return parts.join(';'); } -describe('galaxy-impostor visual baseline (post-split)', () => { +describe('galaxy-impostor visual baseline', () => { it('emits the same lastOutput sequence given a fixed fixture', async () => { // Synthetic clock — see module docstring for why this is required for // deterministic load-fade. Restored in `finally`.