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/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/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/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..32f326e7 100644 --- a/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts +++ b/src/@types/engine/subsystems/GalaxyAtlasSubsystem.d.ts @@ -1,20 +1,20 @@ /** * 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 * * 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. * @@ -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). */ @@ -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/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/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/@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/@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/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 3395f7b3..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'; @@ -74,19 +75,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 — radiusMpc (f32) — padded billboard half-extent + * 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 +95,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 +103,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; @@ -111,12 +111,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. /** @@ -201,7 +195,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 @@ -242,24 +236,28 @@ 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 — 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,61 +265,53 @@ 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; + + // Slot 6 — positionAngleDeg copied through. + interleaved[o + 6] = cloud.positionAngleDeg[i]!; - // Slots 7..8 — 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]!; + // 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 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 - // 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 + 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/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..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 / texturedImpostorsPass 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({ @@ -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, @@ -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, @@ -515,7 +514,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..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 @@ -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 @@ -262,13 +262,13 @@ 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, 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/proceduralDiskSubsystem.ts b/src/services/engine/subsystems/proceduralDiskSubsystem.ts index b4fccb58..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,19 +163,25 @@ 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]!; - 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/engine/subsystems/texturedImpostorSubsystem.ts b/src/services/engine/subsystems/texturedDiskSubsystem.ts similarity index 92% rename from src/services/engine/subsystems/texturedImpostorSubsystem.ts rename to src/services/engine/subsystems/texturedDiskSubsystem.ts index 65b4bff6..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 @@ -29,16 +29,17 @@ */ 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'; 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'; @@ -59,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; @@ -72,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)); @@ -94,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; @@ -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]!; @@ -274,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/pointRenderer.ts b/src/services/gpu/renderers/pointRenderer.ts index 19a38414..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 @@ -130,81 +130,73 @@ 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. + * Byte offset of the `radiusMpc` slot — the per-galaxy padded billboard + * radius in Mpc. * - * Sits at slot index 8 (offset 32). 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 = 32; +const RADIUS_MPC_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 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 * * 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: 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' }, ]; // ─── 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({ @@ -904,7 +895,7 @@ export function createPointRenderer( // 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. + // first 4 bytes; the remaining 12 bytes are reserved padding. const sourceBuffer = device.createBuffer({ label: `points-source-uniform-${source}`, size: 16, @@ -986,7 +977,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 +991,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 +1010,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 +1039,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/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/lib/sourceUniforms.wesl b/src/services/gpu/shaders/lib/sourceUniforms.wesl index b0a4b596..535145f3 100644 --- a/src/services/gpu/shaders/lib/sourceUniforms.wesl +++ b/src/services/gpu/shaders/lib/sourceUniforms.wesl @@ -36,12 +36,10 @@ 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.) + // 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 047fa607..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 ───────────────────────────────────────────── @@ -47,58 +41,31 @@ 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. - let cs = in.paCs; - let sn = in.paSn; + // interpolated, saving millions of trig calls per frame. safeAB + // (the validated ellipse minor-axis ratio) rides in the alpha channel + // 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, ); - // 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.shaded.w); 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 + // (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.shaded.rgb; // ── Source fade-in ───────────────────────────────────────────────────────── // diff --git a/src/services/gpu/shaders/points/io.wesl b/src/services/gpu/shaders/points/io.wesl index f82260c7..f1f58858 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.shaded.rgb', so the fragment reads no per-pixel state for + // the crossfade. pxFadeStart: f32, pxFadeEnd: f32, _padFade0: f32, @@ -194,19 +195,11 @@ 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, - // 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 @@ -219,34 +212,36 @@ 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'. - @location(4) axisRatio: f32, + // 'axisRatio > 0 is false' round-mask path with the fallback flag clear. + @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, + // 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 // 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 ─────────────────────────────────── @@ -268,41 +263,26 @@ 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 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, + // 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. + // Flat-interpolated — all 6 vertices of an instance share the value. + @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, - - // 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. - @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, + @location(2) @interpolate(flat) instanceIdx: u32, - // 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, + // 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(3) @interpolate(flat) paRotation: vec2, - // 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, }; diff --git a/src/services/gpu/shaders/points/vertex.wesl b/src/services/gpu/shaders/points/vertex.wesl index 6da3710d..0080678e 100644 --- a/src/services/gpu/shaders/points/vertex.wesl +++ b/src/services/gpu/shaders/points/vertex.wesl @@ -108,44 +108,39 @@ 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; - earlyOut.tint = vec3(0.0); - earlyOut.intensity = 0.0; + earlyOut.shaded = vec4(0.0, 0.0, 0.0, 1.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 - // 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 ────────────────────────────────── @@ -165,41 +160,23 @@ 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-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. - 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 - p.kPerZ * zRedshift, 1.05, isUnknownColour); - - // Look up the colour for this point's *rest-frame* colour index. - out.tint = ramp(restColorIndex); + // 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 ── // // 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 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); @@ -210,12 +187,31 @@ fn vs( let depthFadeRaw = 1.0 / (1.0 + camDistRel * camDistRel); let depthFadeMult = select(1.0, depthFadeRaw, u.depthFadeEnabled == 1u); - out.intensity = clamp((22.0 - p.magnitude) / 8.0, 0.05, 1.0) + // 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); + + let intensity = clamp((22.0 - p.magnitude) / 8.0, 0.05, 1.0) * u.brightness * vMaxAlpha * schechterMult * angularMult - * depthFadeMult; + * 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(p.colorIndex) * highlightTint * intensity, safeAB); // Invisibility cull: galaxies whose folded intensity falls below this // threshold contribute imperceptibly to the additive HDR target, so @@ -226,7 +222,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); } @@ -234,28 +230,13 @@ 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 - // 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 // (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; } 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..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 `texturedImpostorSubsystem.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/src/utils/galaxySize.ts b/src/utils/galaxySize.ts new file mode 100644 index 00000000..048a3ef3 --- /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 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 + * 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/@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/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 d52aaa47..d2df04e6 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 — radiusMpc (padded half-extent) + * 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); } }); @@ -225,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]); @@ -238,8 +238,6 @@ describe('buildPointInterleavedBuffer', () => { cloud, source: Source.SDSS, }); - expect(interleaved[4]).toBe(999); - // K-correction defaults to 0 when the colour is absent. - expect(interleaved[5]).toBe(0); + expect(interleaved[4]).toBeCloseTo(1.05, 5); }); }); 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..adfc9353 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, }; } @@ -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/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/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], 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 72ef30a1..d6348634 100644 --- a/tests/visual/galaxyImpostorBaseline.test.ts +++ b/tests/visual/galaxyImpostorBaseline.test.ts @@ -1,30 +1,28 @@ /** - * Visual baseline — post-split galaxy-impostor draw-call sequence. + * Visual baseline — galaxy-impostor draw-call sequence. * - * Drives the three new subsystems (galaxyAtlas + proceduralDisk + - * texturedImpostor) 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'; 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'; @@ -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`. @@ -110,7 +108,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(),