From 4b5a2a95873c23bba55df3628a61bfb2741179c6 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Thu, 23 Jul 2026 16:15:38 +0200 Subject: [PATCH 01/12] feat(galaxies): drive point brightness from physical surface brightness Galaxies rendered as flat LDR disks that never crossed the bloom threshold. Replace the apparent-magnitude clamp with a physically grounded surface-brightness amplitude so intrinsically bright/compact galaxies emit into HDR and bloom, while diffuse ones stay dim. - Bake per-galaxy sbAmp = 10^(-0.4*(M_abs - meanAbsMag_perCatalog)) / (diameterKpc/30)^2 into a new interleaved slot (13->14 slots). - Vertex intensity = sbAmp * sbScale * sbBoost * fluxFalloff, where fluxFalloff = pow(resolvedFrac, falloffStrength) gated by the depth toggle recovers inverse-square on approach while resolved galaxies hold constant surface brightness. - Wire three live calibration knobs (sbScale, sbMax, falloffStrength) through the store to the points Uniforms (176->192 bytes) with sliders in Settings > Galaxies > Advanced. - Repurpose the now-dead per-source intensityFloor field into an sbBoost multiplier; lift Milliquas 3x. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GalaxyCatalogSourceEntry.d.ts | 14 ++--- src/@types/rendering/PointDrawSettings.d.ts | 6 ++ src/@types/settings/EngineSettingsState.d.ts | 21 +++++++ .../SettingsPanel/GalaxiesSection.tsx | 56 +++++++++++++++++++ .../containers/GalaxiesSectionContainer.tsx | 24 ++++++++ src/data/defaults.ts | 29 ++++++++++ src/data/sources/desiDeep.ts | 7 +-- src/data/sources/desiSgw.ts | 7 +-- src/data/sources/desiWedge.ts | 7 +-- src/data/sources/famous-galaxy.ts | 3 +- src/data/sources/glade.ts | 3 +- src/data/sources/milliquas.ts | 11 +--- src/data/sources/sdss.ts | 3 +- src/data/sources/synthetic.ts | 8 +-- src/data/sources/twomrs.ts | 3 +- .../bake/buildPointInterleavedBuffer.ts | 42 +++++++++++++- .../engine/frame/passes/pointSpritesLayer.ts | 3 + .../engine/helpers/pickUniformBytesOf.ts | 5 +- .../renderers/galaxyCatalog/catalogStore.ts | 6 +- .../galaxyCatalog/pointVertexLayout.ts | 26 ++++++--- .../gpu/shaders/galaxyCatalog/points/io.wesl | 26 ++++++++- .../shaders/galaxyCatalog/points/vertex.wesl | 50 +++++++++-------- .../gpu/shaders/lib/sourceUniforms.wesl | 12 +--- src/state/settings/initialState.ts | 6 ++ src/state/settings/selectors.ts | 24 ++++++++ src/state/settings/settingsSlice.ts | 20 +++++++ src/utils/gpu/packPointUniforms.ts | 22 ++++++-- .../SettingsPanel/GalaxiesSection.test.ts | 6 ++ .../bake/buildPointInterleavedBuffer.test.ts | 7 ++- .../engine/helpers/pickUniformBytesOf.test.ts | 6 ++ .../galaxyCatalog/catalogStore.test.ts | 20 +++---- .../galaxyCatalog/pointRenderer.test.ts | 6 ++ .../galaxyCatalog/pointVertexLayout.test.ts | 7 ++- .../shaders/milkyWayPickUniformParity.test.ts | 5 +- tests/state/settings/makeSettingsFixture.ts | 6 ++ tests/utils/gpu/packPointUniforms.test.ts | 32 +++++++++-- 36 files changed, 424 insertions(+), 115 deletions(-) diff --git a/src/@types/data/galaxyCatalog/GalaxyCatalogSourceEntry.d.ts b/src/@types/data/galaxyCatalog/GalaxyCatalogSourceEntry.d.ts index 354b804d1..692f00b44 100644 --- a/src/@types/data/galaxyCatalog/GalaxyCatalogSourceEntry.d.ts +++ b/src/@types/data/galaxyCatalog/GalaxyCatalogSourceEntry.d.ts @@ -81,16 +81,12 @@ export type GalaxyCatalogSourceEntry = SourceEntryBase & { */ readonly fluxSupplementMagLimit?: number; /** - * Per-source floor of the points intensity formula - * `clamp((22 − magnitude) / 8, intensityFloor, 1)`. Plumbed through - * `SourceUniforms` to `points/vertex.wesl`. Replaces a prior hardcoded - * 0.05 floor; per-source tuning lets sparse far-field catalogs - * (Milliquas, where most rows sit past mag 22) carry a higher floor - * to stay visible, while bulk galaxy catalogs take a lower floor to - * reduce per-galaxy contribution to the additive HDR target in dense - * regions (and thus tame center saturation under the Reinhard tonemap). + * Per-source multiplier on the physical surface-brightness intensity + * (1.0 = no boost). Plumbed through `SourceUniforms` to + * `points/vertex.wesl`. Raise it to lift intrinsically-faint catalogs + * (e.g. Milliquas quasars) that the physical model renders dim. */ - readonly intensityFloor: number; + readonly sbBoost: number; /** * Per-source half-distance (Mpc) of the depth-fade curve * `1 / (1 + (d / falloffHalfMpc)²)`. Plumbed through `SourceUniforms` diff --git a/src/@types/rendering/PointDrawSettings.d.ts b/src/@types/rendering/PointDrawSettings.d.ts index 0b736c1e1..915759c0b 100644 --- a/src/@types/rendering/PointDrawSettings.d.ts +++ b/src/@types/rendering/PointDrawSettings.d.ts @@ -34,6 +34,12 @@ export type PointDrawSettings = { absMagLimit: number; /** Whether the points pass applies depth-based alpha fade. */ depthFadeEnabled: boolean; + /** Overall physical-SB → HDR gain — multiplies each galaxy's baked `sbAmp` into the additive HDR field (the `galaxySbScale` uniform). */ + sbScale: number; + /** Bloom ceiling — the max baked surface-brightness amplitude a compact galaxy can emit; the vertex stage clamps `sbAmp` to it (the `galaxySbMax` uniform). */ + sbMax: number; + /** Readability-falloff exponent on the resolved-fraction falloff, gated by `depthFadeEnabled` (the `galaxyFalloffStrength` uniform). */ + falloffStrength: number; /** Procedural-disk crossfade band — pixel threshold below which points render full-alpha. */ pxFadeStart: number; /** Procedural-disk crossfade band — pixel threshold above which points render zero-alpha (hand-off to disk pass). */ diff --git a/src/@types/settings/EngineSettingsState.d.ts b/src/@types/settings/EngineSettingsState.d.ts index 60ec85c82..f71ac6a51 100644 --- a/src/@types/settings/EngineSettingsState.d.ts +++ b/src/@types/settings/EngineSettingsState.d.ts @@ -90,6 +90,27 @@ export type EngineSettingsState = { depthFade: boolean; highlightFallback: boolean; realOnly: boolean; + /** + * Overall physical-SB → HDR gain — multiplies each galaxy's baked + * surface-brightness amplitude into the additive HDR field. The live + * successor to the old hardcoded `GALAXY_SB_SCALE` shader const; rides the + * points `Uniforms` struct as `galaxySbScale`. Default `DEFAULT_GALAXY_SB_SCALE`. + */ + sbScale: number; + /** + * Bloom ceiling — the maximum baked surface-brightness amplitude a compact + * galaxy can emit. The vertex stage clamps `sbAmp` to it live (`galaxySbMax` + * uniform), replacing the old bake-time clamp (now only a float-safety + * guard). Default `DEFAULT_GALAXY_SB_MAX`. + */ + sbMax: number; + /** + * Readability-falloff exponent `k` on the resolved-fraction falloff + * `pow(resolvedFrac, k)`, gated by `depthFade`. k = 2 is the full physical + * inverse-square; lower k keeps the deep field visible. Rides the points + * uniform as `galaxyFalloffStrength`. Default `DEFAULT_GALAXY_FALLOFF_STRENGTH`. + */ + falloffStrength: number; items: Record; }; diff --git a/src/components/SettingsPanel/GalaxiesSection.tsx b/src/components/SettingsPanel/GalaxiesSection.tsx index 328525ced..3603bed45 100644 --- a/src/components/SettingsPanel/GalaxiesSection.tsx +++ b/src/components/SettingsPanel/GalaxiesSection.tsx @@ -81,6 +81,18 @@ type GalaxiesSectionProps = { absMagLimit: number; /** Called when the user moves the M_lim slider. */ onAbsMagLimitChange: (absMag: number) => void; + /** Overall physical-SB → HDR gain — the "Galaxy brightness" knob. */ + sbScale: number; + /** Called when the user moves the galaxy-brightness slider. */ + onSbScaleChange: (v: number) => void; + /** Bloom ceiling — the max surface-brightness amplitude a compact galaxy can emit. */ + sbMax: number; + /** Called when the user moves the bloom-ceiling slider. */ + onSbMaxChange: (v: number) => void; + /** Readability-falloff exponent — the "Distance falloff" knob. */ + falloffStrength: number; + /** Called when the user moves the distance-falloff slider. */ + onFalloffStrengthChange: (v: number) => void; }; // ── GalaxiesSection ──────────────────────────────────────────────────────────── @@ -103,6 +115,12 @@ function GalaxiesSection({ onBiasModeChange, absMagLimit, onAbsMagLimitChange, + sbScale, + onSbScaleChange, + sbMax, + onSbMaxChange, + falloffStrength, + onFalloffStrengthChange, }: GalaxiesSectionProps) { // ── Master tri-state derivation ────────────────────────────────────────────── // Tri-state master = how many TOGGLEABLE_SOURCES are currently enabled. @@ -176,6 +194,44 @@ function GalaxiesSection({ /> + {/* Galaxy surface-brightness calibration knobs — overall HDR gain, + bloom ceiling, and the resolved-fraction readability falloff. */} +
+ `${v.toFixed(1)}×`} + /> +
+ +
+ v.toFixed(0)} + /> +
+ +
+ v.toFixed(2)} + /> +
+ {/* Depth fade — galaxy-only tunable. */}
diff --git a/src/components/containers/GalaxiesSectionContainer.tsx b/src/components/containers/GalaxiesSectionContainer.tsx index df40c5e65..a98ba1bff 100644 --- a/src/components/containers/GalaxiesSectionContainer.tsx +++ b/src/components/containers/GalaxiesSectionContainer.tsx @@ -34,6 +34,9 @@ import { selectDepthFade, selectBiasMode, selectAbsMagLimit, + selectGalaxySbScale, + selectGalaxySbMax, + selectGalaxyFalloffStrength, } from '../../state/settings/selectors'; import { selectSourceCounts } from '../../state/engine/selectors'; import { @@ -42,6 +45,9 @@ import { setDepthFade, setBiasMode, setAbsMagLimit, + setGalaxySbScale, + setGalaxySbMax, + setGalaxyFalloffStrength, } from '../../state/settings/settingsSlice'; import { galaxyCatalogIdOf } from '../../utils/galaxyCatalogIdOf'; import type { SourceType } from '../../@types/data/SourceType'; @@ -60,6 +66,9 @@ function GalaxiesSectionContainer(): React.ReactElement { const depthFadeEnabled = useAppSelector(selectDepthFade); const biasMode = useAppSelector(selectBiasMode); const absMagLimit = useAppSelector(selectAbsMagLimit); + const sbScale = useAppSelector(selectGalaxySbScale); + const sbMax = useAppSelector(selectGalaxySbMax); + const falloffStrength = useAppSelector(selectGalaxyFalloffStrength); const onToggleSource = useCallback( (source: SourceType, enabled: boolean) => @@ -87,6 +96,15 @@ function GalaxiesSectionContainer(): React.ReactElement { [dispatch], ); + const onSbScaleChange = useCallback((v: number) => dispatch(setGalaxySbScale(v)), [dispatch]); + + const onSbMaxChange = useCallback((v: number) => dispatch(setGalaxySbMax(v)), [dispatch]); + + const onFalloffStrengthChange = useCallback( + (v: number) => dispatch(setGalaxyFalloffStrength(v)), + [dispatch], + ); + return ( ); } diff --git a/src/data/defaults.ts b/src/data/defaults.ts index 4fae9e72a..bc2757db0 100644 --- a/src/data/defaults.ts +++ b/src/data/defaults.ts @@ -173,6 +173,35 @@ export const DEFAULT_BRIGHTNESS = 1.0; /** Auto-rotate (yaw drift) defaults OFF — most users want a static frame to explore. */ export const DEFAULT_AUTO_ROTATE = false; +/** + * Default overall physical-SB → HDR gain for galaxy point billboards — seeds + * `settings.galaxyCatalogs.sbScale`. Multiplies each galaxy's baked + * surface-brightness amplitude (`sbAmp`) into the additive HDR field. Replaces + * the old hardcoded `GALAXY_SB_SCALE` shader const; 5.0 places the per-catalog + * mean galaxy's resolved core relative to the 2.0 bloom threshold. Live-tunable + * (UI range 0.5–30) so the galaxy look can be re-eye-tuned without a rebuild. + */ +export const DEFAULT_GALAXY_SB_SCALE = 5.0; + +/** + * Default bloom ceiling for galaxy point billboards — seeds + * `settings.galaxyCatalogs.sbMax`. The maximum baked surface-brightness + * amplitude a compact galaxy can emit; the vertex stage clamps `sbAmp` to it + * live. Moved out of the bake-time clamp (now only a float-safety guard) so the + * ceiling is a live knob; 30.0 reproduces the old bake clamp. UI range 1–100. + */ +export const DEFAULT_GALAXY_SB_MAX = 30.0; + +/** + * Default readability-falloff exponent for galaxy point billboards — seeds + * `settings.galaxyCatalogs.falloffStrength`. The exponent `k` on the + * resolved-fraction falloff `pow(resolvedFrac, k)`: k = 2 is the full physical + * inverse-square (unresolved galaxies dim as (angular / floor)²), lower k keeps + * the deep field visible. 0.7 is eye-tuned; UI range 0–2. Gated by the + * depth-fade toggle (off holds flat constant surface brightness). + */ +export const DEFAULT_GALAXY_FALLOFF_STRENGTH = 0.7; + // ── Galaxy thumbnails / orientation toggles ───────────────────────────────── /** diff --git a/src/data/sources/desiDeep.ts b/src/data/sources/desiDeep.ts index 1fe9e7d72..f58a286b8 100644 --- a/src/data/sources/desiDeep.ts +++ b/src/data/sources/desiDeep.ts @@ -67,11 +67,8 @@ export const DESI_DEEP_ENTRY = { // narrow pencil-beam, not a bulk all-sky catalog), so there's no need to // subsample per tier. tierTargets: {}, - // Seeded from Milliquas: a sparse far-field catalog whose rows sit at - // faint apparent magnitudes needs a higher floor than the bulk galaxy - // catalogs' 0.02 default, or the whole source pins to invisible. Visual - // tuning knob, not a measured value. - intensityFloor: 0.15, + // Per-source SB boost — 1.0 = no boost. + sbBoost: 1.0, // Disable distance fade (an effectively-infinite half-distance): the // z ≈ 3.5 tail reaching past 7000 Mpc is the entire point of this source, // and the default ~1000 Mpc fade half-distance would extinguish it long diff --git a/src/data/sources/desiSgw.ts b/src/data/sources/desiSgw.ts index de6c67c20..872e5b58b 100644 --- a/src/data/sources/desiSgw.ts +++ b/src/data/sources/desiSgw.ts @@ -57,11 +57,8 @@ export const DESI_SGW_ENTRY = { // bounded volume, not a bulk all-sky catalog), so there's no need to // subsample per tier. tierTargets: {}, - // Seeded from the other DESI patches: a curated far-field catalog whose rows - // can sit at faint apparent magnitudes needs a higher floor than the bulk - // galaxy catalogs' 0.02 default, or the whole source pins to invisible. - // Visual tuning knob, not a measured value. - intensityFloor: 0.15, + // Per-source SB boost — 1.0 = no boost. + sbBoost: 1.0, // Disable distance fade (an effectively-infinite half-distance): the wall // spans ~165 Mpc of depth, and the default ~1000 Mpc fade half-distance would // dim its far edge relative to its near edge — the whole structure should diff --git a/src/data/sources/desiWedge.ts b/src/data/sources/desiWedge.ts index 533d5af8d..da6126a93 100644 --- a/src/data/sources/desiWedge.ts +++ b/src/data/sources/desiWedge.ts @@ -61,11 +61,8 @@ export const DESI_WEDGE_ENTRY = { // dec-band fan through one footprint arm, not a bulk all-sky catalog), so // there's no need to subsample per tier. tierTargets: {}, - // Seeded from the deep cone: a sparse far-field catalog whose rows sit at - // faint apparent magnitudes needs a higher floor than the bulk galaxy - // catalogs' 0.02 default, or the whole source pins to invisible. Visual - // tuning knob, not a measured value. - intensityFloor: 0.15, + // Per-source SB boost — 1.0 = no boost. + sbBoost: 1.0, // Disable distance fade (an effectively-infinite half-distance): the // z ≈ 3.5 tail reaching past 7000 Mpc is the entire point of this source, // and the default ~1000 Mpc fade half-distance would extinguish it long diff --git a/src/data/sources/famous-galaxy.ts b/src/data/sources/famous-galaxy.ts index 6099906eb..5a51fcc72 100644 --- a/src/data/sources/famous-galaxy.ts +++ b/src/data/sources/famous-galaxy.ts @@ -32,6 +32,7 @@ export const FAMOUS_GALAXY_ENTRY = { iauPrefix: 'Famous', // ~150 rows total — never subsampled; one file shared across tiers. tierTargets: {}, - intensityFloor: 0.02, + // Per-source SB boost — 1.0 = no boost. + sbBoost: 1.0, falloffHalfMpc: 1000, } as const satisfies GalaxyCatalogSourceEntry; diff --git a/src/data/sources/glade.ts b/src/data/sources/glade.ts index a96b36b21..368c47f13 100644 --- a/src/data/sources/glade.ts +++ b/src/data/sources/glade.ts @@ -31,6 +31,7 @@ export const GLADE_ENTRY = { // the apparently-bright nearby galaxies back, restoring the local volume // shell-free. ~+3,500 local at medium; see selectTierRecords. fluxSupplementMagLimit: 15, - intensityFloor: 0.02, + // Per-source SB boost — 1.0 = no boost. + sbBoost: 1.0, falloffHalfMpc: 1000, } as const satisfies GalaxyCatalogSourceEntry; diff --git a/src/data/sources/milliquas.ts b/src/data/sources/milliquas.ts index 24519176f..571fa7a73 100644 --- a/src/data/sources/milliquas.ts +++ b/src/data/sources/milliquas.ts @@ -50,13 +50,8 @@ export const MILLIQUAS_ENTRY = { // small caps at ~60k brightest for the mobile GPU budget; medium caps at // ~200k brightest; large is uncapped. tierTargets: { small: 60_000, medium: 200_000 }, - // Quasars sit at apparent mag 18–22+; with the bulk-galaxy catalog floor of - // 0.02 most rows would pin to it and look identical. A higher floor - // (0.15) keeps the faint tail distinguishable. The 1000-Mpc fade - // half-distance attenuates the catalog to ~0.04 at d=5 Gpc — kills - // the high-z quasars the whole catalog exists to show — so we set - // an effectively-infinite half-distance to disable distance fade - // for this source while keeping the toggle architecture intact. - intensityFloor: 0.15, + // SB boost — Milliquas quasars are intrinsically faint in the physical + // model; lift them. + sbBoost: 3.0, falloffHalfMpc: 1e30, } as const satisfies GalaxyCatalogSourceEntry; diff --git a/src/data/sources/sdss.ts b/src/data/sources/sdss.ts index 8a554f385..a11f0531e 100644 --- a/src/data/sources/sdss.ts +++ b/src/data/sources/sdss.ts @@ -29,6 +29,7 @@ export const SDSS_ENTRY = { // them (3,469 local in large → 1 in medium). The g-band flux floor recovers // them. Only affects medium; small is excluded entirely. fluxSupplementMagLimit: 15, - intensityFloor: 0.02, + // Per-source SB boost — 1.0 = no boost. + sbBoost: 1.0, falloffHalfMpc: 1000, } as const satisfies GalaxyCatalogSourceEntry; diff --git a/src/data/sources/synthetic.ts b/src/data/sources/synthetic.ts index 101c20886..dc0358013 100644 --- a/src/data/sources/synthetic.ts +++ b/src/data/sources/synthetic.ts @@ -21,11 +21,7 @@ export const SYNTHETIC_ENTRY = { schechter: { mStar: -21.18, alpha: -1.16, phiStar: 0.0093 }, iauPrefix: 'Synth', tierTargets: {}, // no caps anywhere — synthetic is procedurally sized - // Synthetic is the "no real data, show *something*" fallback — must be - // aggressively visible. Match Milliquas: higher floor + no depth fade. - // Bulk-galaxy catalog defaults (floor=0.02 / falloff=1000) at radius 1000 Mpc - // attenuate the cloud to a near-black haze against the additive HDR - // target — the symptom the fallback exists to prevent in the first place. - intensityFloor: 0.15, + // Per-source SB boost — 1.0 = no boost. + sbBoost: 1.0, falloffHalfMpc: 1e30, } as const satisfies GalaxyCatalogSourceEntry; diff --git a/src/data/sources/twomrs.ts b/src/data/sources/twomrs.ts index f6d55f07b..a9c98de6c 100644 --- a/src/data/sources/twomrs.ts +++ b/src/data/sources/twomrs.ts @@ -26,6 +26,7 @@ export const TWOMRS_ENTRY = { iauPrefix: '2MASX', // ~44k rows total — small enough to ship intact at every tier; no caps. tierTargets: {}, - intensityFloor: 0.02, + // Per-source SB boost — 1.0 = no boost. + sbBoost: 1.0, falloffHalfMpc: 1000, } as const satisfies GalaxyCatalogSourceEntry; diff --git a/src/services/engine/bake/buildPointInterleavedBuffer.ts b/src/services/engine/bake/buildPointInterleavedBuffer.ts index f1fb6a712..a06c4fe3d 100644 --- a/src/services/engine/bake/buildPointInterleavedBuffer.ts +++ b/src/services/engine/bake/buildPointInterleavedBuffer.ts @@ -78,8 +78,9 @@ import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/B * slot 10 — schechterRatio (f32) * slot 11 — angularDensityWeight (f32) * slot 12 — absMag (f32) — from the offset-normalised slot-3 magnitude + * slot 13 — sbAmp (f32) — physical surface-brightness amplitude * - * Total: 13 × 4 = 52 bytes per point. Per-galaxy catalog constants stay out of + * Total: 14 × 4 = 56 bytes per point. Per-galaxy catalog constants stay out of * the per-row layout: the K-correction kPerZ lives in the per-galaxy-catalog * `SourceUniforms` uniform (k is constant per galaxy catalog, so paying for it * per-row would be waste), and instance identity is composed per draw @@ -107,7 +108,7 @@ import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/B * once here is the classic space-for-ALU trade — +8 bytes/row against the * hottest per-frame loop in the app. */ -const SLOTS_PER_POINT = 13; +const SLOTS_PER_POINT = 14; /** Reference distance used to normalise the per-galaxy 1/V_max weight. */ const D_REF_MPC = 750; @@ -115,6 +116,17 @@ const D_REF_MPC = 750; /** Target post-shift mean magnitude for the per-galaxy-catalog magG normalisation. */ const SDSS_TARGET_MEAN_MAG = 18; +/** Reference physical diameter (kpc) for the surface-brightness zero-point — the L-star / Milky-Way scale. */ +const SB_REF_DIAMETER_KPC = 30; +/** + * Float-safety guard on the baked surface-brightness amplitude — only there to + * keep a pathologically compact/bright galaxy's `sbAmp` finite and in range. The + * live bloom ceiling now lives in the shader's `galaxySbMax` uniform (seeded + * from `DEFAULT_GALAXY_SB_MAX`), so this cap is deliberately far above the + * slider range and no longer limits the visible amplitude. + */ +const SB_AMP_MAX = 100000; + /** * Bake one point cloud's per-vertex GPU bytes. Pure: no `this`, no DOM, no * module-level state. Safe to call from a Worker. @@ -154,15 +166,27 @@ export function buildPointInterleavedBuffer( // and most 2MRS galaxies render at maximum intensity with zero contrast. let magSum = 0; let magCount = 0; + let absMagSum = 0; + let absMagCount = 0; for (let i = 0; i < cloud.count; i++) { const m = cloud.magG[i]!; if (Number.isFinite(m)) { magSum += m; magCount++; } + const x = cloud.positions[i * 3 + 0]!; + const y = cloud.positions[i * 3 + 1]!; + const z = cloud.positions[i * 3 + 2]!; + const dMpc = Math.hypot(x, y, z); + const M = absoluteFromApparent(m, dMpc); + if (Number.isFinite(M)) { + absMagSum += M; + absMagCount++; + } } const sourceMean = magCount > 0 ? magSum / magCount : SDSS_TARGET_MEAN_MAG; const magOffset = SDSS_TARGET_MEAN_MAG - sourceMean; + const meanAbsMag = absMagCount > 0 ? absMagSum / absMagCount : -20.5; // ── Malmquist 1/V_max weight inputs ────────────────────────────────────── // @@ -326,6 +350,20 @@ export function buildPointInterleavedBuffer( // is slot 3, so the baked value must fold the same per-catalog mean // shift or every mode-1 threshold would move by `magOffset`. interleaved[o + 12] = interleaved[o + 3]! - 5 * Math.log10(dMpc) - 25; + + // Slot 13 — physical surface-brightness amplitude. Relative luminosity + // (vs the per-catalog mean absolute magnitude) over (diameter / 30 kpc)^2. + // This is the intrinsic per-pixel radiance the vertex stage scales into + // HDR: intrinsically bright / compact galaxies emit above the bloom + // threshold; diffuse ones stay dim. Uses the RAW physical absMag (same as + // vMax), not the cosmetic offset-normalised slot-3/slot-12 value. + const diamKpc = cloud.diameterKpc[i]! > 0 ? cloud.diameterKpc[i]! : SB_REF_DIAMETER_KPC; + const diamRatio = diamKpc / SB_REF_DIAMETER_KPC; + const lumRel = Math.pow(10, -0.4 * (absMag - meanAbsMag)); + const sbAmpRaw = lumRel / (diamRatio * diamRatio); + interleaved[o + 13] = Number.isFinite(sbAmpRaw) + ? Math.min(Math.max(sbAmpRaw, 0), SB_AMP_MAX) + : 1.0; } return { diff --git a/src/services/engine/frame/passes/pointSpritesLayer.ts b/src/services/engine/frame/passes/pointSpritesLayer.ts index 34daffd3b..51c03bfed 100644 --- a/src/services/engine/frame/passes/pointSpritesLayer.ts +++ b/src/services/engine/frame/passes/pointSpritesLayer.ts @@ -116,6 +116,9 @@ export const pointSpritesLayer: ContentLayer = { biasMode: state.settings.bias.mode, absMagLimit: state.settings.bias.absMagLimit, depthFadeEnabled: state.settings.galaxyCatalogs.depthFade, + sbScale: state.settings.galaxyCatalogs.sbScale, + sbMax: state.settings.galaxyCatalogs.sbMax, + falloffStrength: state.settings.galaxyCatalogs.falloffStrength, // The points-layer fragment fades alpha to zero across the same // apparent-pixel-size band the procedural-disk layer fades IN over. // Both thresholds come from one source of truth so they can't drift diff --git a/src/services/engine/helpers/pickUniformBytesOf.ts b/src/services/engine/helpers/pickUniformBytesOf.ts index 494dc807f..3320eb225 100644 --- a/src/services/engine/helpers/pickUniformBytesOf.ts +++ b/src/services/engine/helpers/pickUniformBytesOf.ts @@ -1,7 +1,7 @@ /** * pickUniformBytesOf — rebuild the point pick uniform as a value, at pick time. * - * The pick pass needs the same 176-byte `Uniforms` image the visual points pass + * The pick pass needs the same 192-byte `Uniforms` image the visual points pass * uploads, minus the fields it always overrides. Building it as a side effect of * the visual `draw()` would braid pick-camera availability into "the points pass * has already drawn this frame" — a frame-ordering coupling — and mirror one @@ -74,6 +74,9 @@ export function pickUniformBytesOf( biasMode: bias.mode, absMagLimit: bias.absMagLimit, depthFadeEnabled: g.depthFade, + sbScale: g.sbScale, + sbMax: g.sbMax, + falloffStrength: g.falloffStrength, // Same fade-band source of truth the visual pass reads (kept in one place // so points fade-out and disks fade-in bands can't drift apart). pxFadeStart: PROCEDURAL_DISK_FADE_START_PX, diff --git a/src/services/gpu/renderers/galaxyCatalog/catalogStore.ts b/src/services/gpu/renderers/galaxyCatalog/catalogStore.ts index a1984aeb8..c487f8948 100644 --- a/src/services/gpu/renderers/galaxyCatalog/catalogStore.ts +++ b/src/services/gpu/renderers/galaxyCatalog/catalogStore.ts @@ -270,7 +270,7 @@ export function createCatalogStore(init: { async function upload(id: GalaxyCatalogId, galaxyCatalog: GalaxyCatalog): Promise { // The store only handles galaxy catalog sources; the registry // entry for this id carries the numeric source code, per-source - // intensityFloor + falloffHalfMpc, and the discriminant we narrow on. + // sbBoost + falloffHalfMpc, and the discriminant we narrow on. const source = CODE_OF_ID.get(id); if (source === undefined) { throw new Error(`catalogStore cannot upload unknown galaxy catalog id '${id}'`); @@ -331,7 +331,7 @@ export function createCatalogStore(init: { entries: [{ binding: 0, resource: { buffer: fadeBuffer } }], }); - // SourceUniforms: 5-bit sourceCode + per-source intensityFloor + + // SourceUniforms: 5-bit sourceCode + per-source sbBoost + // per-source falloffHalfMpc + 4 B pad. Written once here; the // values are constant per source so per-frame writes would be // wasted bytes. See lib/sourceUniforms.wesl for the struct layout @@ -345,7 +345,7 @@ export function createCatalogStore(init: { const sourceU32 = new Uint32Array(sourceScratch); const sourceF32 = new Float32Array(sourceScratch); sourceU32[0] = source >>> 0; - sourceF32[1] = entry.intensityFloor; + sourceF32[1] = entry.sbBoost; sourceF32[2] = entry.falloffHalfMpc; device.queue.writeBuffer(sourceBuffer, 0, sourceScratch); const sourceBindGroup = device.createBindGroup({ diff --git a/src/services/gpu/renderers/galaxyCatalog/pointVertexLayout.ts b/src/services/gpu/renderers/galaxyCatalog/pointVertexLayout.ts index 54a125be9..eb9d505f3 100644 --- a/src/services/gpu/renderers/galaxyCatalog/pointVertexLayout.ts +++ b/src/services/gpu/renderers/galaxyCatalog/pointVertexLayout.ts @@ -25,7 +25,7 @@ * [x, y, z, magnitude, colorIndex, * axisRatio (sign bit = isFallback flag), * paCos, paSin, radiusMpc, - * vMaxWeight, schechterRatio, angularDensityWeight, absMag] + * vMaxWeight, schechterRatio, angularDensityWeight, absMag, sbAmp] * * Every slot is f32; the fallback-orientation bit rides on the sign of * axisRatio. Identity comes from `(sourceCode << 27) | instance_index` @@ -35,14 +35,14 @@ * vertex stage skips a cos+sin and a log10+sqrt per invocation — see the * layout docblock in `buildPointInterleavedBuffer.ts` for the trade. */ -export const SLOTS_PER_POINT = 13; +export const SLOTS_PER_POINT = 14; /** - * Byte stride between per-instance records — 13 × 4 = 52. Both + * Byte stride between per-instance records — 14 × 4 = 56. Both * pipelines (point + pick) declare this stride; mismatched values * either validate-error or silently read garbage. */ -export const POINT_STRIDE = SLOTS_PER_POINT * 4; // 52 bytes +export const POINT_STRIDE = SLOTS_PER_POINT * 4; // 56 bytes /** Slot 5: galaxy b/a ratio. `abs(axisRatio)` for the ellipse mask; sign bit flags a fallback orientation. */ const AXIS_RATIO_BYTE_OFFSET = 20; @@ -88,6 +88,9 @@ const ANGULAR_WEIGHT_BYTE_OFFSET = 44; */ const ABS_MAG_BYTE_OFFSET = 48; +/** Slot 13: physical surface-brightness amplitude (see buildPointInterleavedBuffer slot 13). */ +const SB_AMP_BYTE_OFFSET = 52; + /** * Vertex buffer attribute table — single source of truth, imported * verbatim by `PickRenderer` so both pipelines stay layout-locked. @@ -102,6 +105,7 @@ const ABS_MAG_BYTE_OFFSET = 48; * 7 schechterRatio * 8 angularDensityWeight * 9 absMag + * 10 sbAmp * * Named offset constants only exist for slots that other code reads by * name (bake / shader); position/magnitude/colorIndex use literal @@ -118,6 +122,7 @@ export const POINT_VERTEX_ATTRIBUTES: readonly GPUVertexAttribute[] = [ { shaderLocation: 7, offset: SCHECHTER_RATIO_BYTE_OFFSET, format: 'float32' }, { shaderLocation: 8, offset: ANGULAR_WEIGHT_BYTE_OFFSET, format: 'float32' }, { shaderLocation: 9, offset: ABS_MAG_BYTE_OFFSET, format: 'float32' }, + { shaderLocation: 10, offset: SB_AMP_BYTE_OFFSET, format: 'float32' }, ]; // ─── Uniform buffer byte offsets (per-pass partial writes) ────────────────── @@ -170,9 +175,16 @@ export const PICK_PASS_BYTE_OFFSET = 168; * bytes 160..163: pxFadeStart f32 (procedural-disk band low) } * bytes 164..167: pxFadeEnd f32 (procedural-disk band high) } 16 bytes * bytes 168..171: pickPass u32 (0 = visual, 1 = pick) } - * bytes 172..175: _padFade1 f32 (written as 0) } + * bytes 172..175: galaxySbScale f32 (overall SB → HDR gain) } + * bytes 176..179: galaxySbMax f32 (bloom-ceiling clamp on sbAmp) } 16 bytes + * bytes 180..183: galaxyFalloffStrength f32 (resolved-fraction exponent) } + * bytes 184..191: _padU0 / _padU1 f32×2 (written as 0) } + * + * Byte 172 was the former `_padFade1` pad word, repurposed to `galaxySbScale` + * when the three galaxy surface-brightness calibration knobs became live + * uniforms; the two trailing pad words round the struct out to 192. * - * Total: 176 bytes — a multiple of 16 ✓ + * Total: 192 bytes — a multiple of 16 ✓ * * WGSL uniform buffers follow rules similar to std140 (WGSL spec §13, * "Memory Layout"). `vec3` requires 16-byte alignment, which is why @@ -194,7 +206,7 @@ export const PICK_PASS_BYTE_OFFSET = 168; * uniforms. They stay in the layout only to keep `pickPass`'s byte offset * stable; the WGSL struct still declares them but no shader reads them. * - * The value (176) is defined in `src/utils/gpu/packPointUniforms.ts` and + * The value (192) is defined in `src/utils/gpu/packPointUniforms.ts` and * re-exported from here so callers that already import the layout don't * need a second import path. */ diff --git a/src/services/gpu/shaders/galaxyCatalog/points/io.wesl b/src/services/gpu/shaders/galaxyCatalog/points/io.wesl index 3b159a412..eae2be66c 100644 --- a/src/services/gpu/shaders/galaxyCatalog/points/io.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/points/io.wesl @@ -80,8 +80,10 @@ import package::lib::camera::CameraUniforms; // offset 120 : depthFadeEnabled (u32) // offset 124 : _pad4 (u32) // offset 128..159 : Malmquist block (biasMode, absMagLimit, ...) -// offset 160..175 : pxFadeStart, pxFadeEnd, _padFade0, _padFade1 -// total: 176 bytes. +// offset 160..171 : pxFadeStart, pxFadeEnd, pickPass +// offset 172..183 : galaxySbScale, galaxySbMax, galaxyFalloffStrength +// offset 184..191 : _padU0, _padU1 (two pad words) +// total: 192 bytes. // // The full historical narrative (why fields moved during the // CameraUniforms-prefix adoption, why pointSizePx + brightness now sit @@ -185,7 +187,17 @@ struct Uniforms { // without those gates, galaxies handed off to a procedural disk // would not be pickable (the disk renderer has no pick pipeline). pickPass: u32, - _padFade1: f32, + + // Physical-SB live calibration knobs (galaxy-look spike). galaxySbScale is + // the overall HDR gain; galaxySbMax caps a compact galaxy's emitted + // amplitude (bloom ceiling); galaxyFalloffStrength is the exponent on the + // resolved-fraction readability falloff. Two pad words round the struct to + // 192 bytes. + galaxySbScale: f32, + galaxySbMax: f32, + galaxyFalloffStrength: f32, + _padU0: f32, + _padU1: f32, }; // ─── per-instance vertex attributes ────────────────────────────────── @@ -264,6 +276,14 @@ struct PerVertex { // length(position))' spent a log10 + sqrt per invocation recomputing // a constant. @location(9) absMag: f32, + + // Physical surface-brightness amplitude, baked at upload: relative + // luminosity (vs the per-catalog mean absolute magnitude) over + // (diameter / 30 kpc)^2. Drives the HDR intensity so intrinsically bright + // / compact galaxies emit above the bloom threshold and diffuse ones stay + // dim. Surface brightness is distance-independent; the inverse-square + // behaviour on approach comes from the flux-conservation term in vs. + @location(10) sbAmp: f32, }; // ─── vertex-to-fragment interface ─────────────────────────────────── diff --git a/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl b/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl index 543346429..42864f9cb 100644 --- a/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl @@ -58,7 +58,7 @@ import package::lib::focusUniforms::focusAlphaMultiplier; // ── @group(2) — per-source SourceUniforms ────────────────────────── // // Each loaded galaxy catalog has its OWN @group(2) bind group whose buffer -// carries SourceUniforms's 16-byte struct (sourceCode + intensityFloor +// carries SourceUniforms's 16-byte struct (sourceCode + sbBoost // + falloffHalfMpc + 4 bytes pad — see lib/sourceUniforms.wesl). // Per-source bind groups dodge the 'queue.writeBuffer' race entirely // (different uniform buffers per source means writes to one don't @@ -173,31 +173,28 @@ fn vs( // — 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 ── + // ── SURFACE BRIGHTNESS → 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 + // intensity = min(sbAmp, u.galaxySbMax) // physical SB base, bloom-ceiling clamped + // × u.galaxySbScale // overall HDR gain (live knob) + // × source.sbBoost // per-source lift (e.g. faint Milliquas quasars) + // × fluxFalloff // readability falloff, pow(resolvedFrac, k) + // × u.brightness // global slider // × vMaxWeight (mode 2: 1/V_max) // × schechterRatio (mode 3: Schechter LF) // × angularReweight (mode 4: HEALPix) - // × depthFade (camera-distance falloff) // × crossfadeOut (procedural-disk handoff band) + // × focusMult (cluster-focus dim) // - // All six factors are per-instance constants, so the fragment reads - // out.shaded.rgb directly — no per-pixel multiply. + // The old apparent-magnitude clamp is gone: p.sbAmp is the baked physical + // surface-brightness amplitude and u.galaxySbScale is the live HDR gain that + // replaced the hardcoded GALAXY_SB_SCALE const. All 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); - // Depth-fade: 1 / (1 + (camDist/FALLOFF_HALF)²), gated by depthFadeEnabled. - // Half-distance is per-source ('source.falloffHalfMpc') so sparse far-field - // catalogs (Milliquas) can effectively disable the fade by carrying a very - // large value while bulk galaxy catalogs keep the original ~1000 Mpc tuning. - let camDistRel = distanceMpc / source.falloffHalfMpc; - let depthFadeRaw = 1.0 / (1.0 + camDistRel * camDistRel); - let depthFadeMult = select(1.0, depthFadeRaw, u.depthFadeEnabled == 1u); - // Crossfade-out against the procedural-disk pass. ThumbnailRenderer // fades IN across [pxFadeStart, pxFadeEnd] (apparent-pixel diameter); // we fade OUT with the complementary smoothstep so the additive HDR @@ -211,10 +208,6 @@ fn vs( inPickPass, ); - // Per-source intensity floor ('source.intensityFloor'). Lower floors - // reduce per-galaxy contribution to the additive HDR target in dense - // regions (tames center saturation); higher floors keep faint objects - // visible (Milliquas at high z). // Cluster-focus dim: non-members of the focused POI fade toward 0.08, // scaled by the smoothstep blend. Collapses to exactly 1.0 when no POI // is focused (blend == 0) AND for members (mix(1.0, 1.0, blend)), so a @@ -224,12 +217,25 @@ fn vs( // non-member that drops under the perceptibility floor is culled rather // than left to pile up additively in a dense field. let focusMult = focusAlphaMultiplier(p.position, focus); - let intensity = clamp((22.0 - p.magnitude) / 8.0, source.intensityFloor, 1.0) + // Physical surface-brightness intensity. p.sbAmp is the baked intrinsic + // amplitude; u.galaxySbMax caps how bright a compact galaxy can emit (the + // live bloom-ceiling knob); u.galaxySbScale is the overall HDR gain. + let sbAmpClamped = min(p.sbAmp, u.galaxySbMax); + // Readability falloff, gated by the depth-fade toggle: pow(resolvedFrac, k) + // with k = u.galaxyFalloffStrength. k = 2 is the full physical inverse-square + // (unresolved galaxies dim as (angular / floor)^2); lower k keeps the deep + // field visible; toggle OFF holds flat constant surface brightness. + let resolvedFrac = clamp(apparentPxRadius / u.pointSizePx, 0.0, 1.0); + let fluxFalloff = select(1.0, pow(resolvedFrac, u.galaxyFalloffStrength), u.depthFadeEnabled == 1u); + // source.sbBoost is the per-source lift, applied after the SB-scale and + // bloom-ceiling clamp so it can brighten even a galaxy already at u.galaxySbMax. + let intensity = sbAmpClamped * u.galaxySbScale + * source.sbBoost + * fluxFalloff * u.brightness * vMaxAlpha * schechterMult * angularMult - * depthFadeMult * crossfadeOut * focusMult; diff --git a/src/services/gpu/shaders/lib/sourceUniforms.wesl b/src/services/gpu/shaders/lib/sourceUniforms.wesl index b2a10214d..183f8f63c 100644 --- a/src/services/gpu/shaders/lib/sourceUniforms.wesl +++ b/src/services/gpu/shaders/lib/sourceUniforms.wesl @@ -36,15 +36,9 @@ struct SourceUniforms { // anyway. sourceCode: u32, - // Per-source floor of the points intensity formula - // 'clamp((22 - mag) / 8, intensityFloor, 1)'. Replaces the prior - // hardcoded 0.05. A lower floor (e.g. 0.02 for GLADE) reduces the - // per-galaxy contribution that floored-faint galaxies make to the - // additive HDR target, which tames center saturation in dense - // regions. A higher floor (e.g. 0.15 for Milliquas) keeps faint - // quasars visible since quasar apparent mags often sit past 22 and - // would otherwise pin to the same floor as faint galaxies. - intensityFloor: f32, + // Per-source multiplier on the physical surface-brightness intensity + // (1.0 = no boost); >1 lifts faint sources. + sbBoost: f32, // Per-source half-distance of the depth-fade curve // '1 / (1 + (d / falloffHalfMpc)^2)'. Replaces the prior hardcoded diff --git a/src/state/settings/initialState.ts b/src/state/settings/initialState.ts index 267c3d5c4..90d7f7d45 100644 --- a/src/state/settings/initialState.ts +++ b/src/state/settings/initialState.ts @@ -30,6 +30,9 @@ import { DEFAULT_BLOOM_STRENGTH, DEFAULT_BLOOM_THRESHOLD, DEFAULT_GALAXY_TEXTURES_ENABLED, + DEFAULT_GALAXY_SB_SCALE, + DEFAULT_GALAXY_SB_MAX, + DEFAULT_GALAXY_FALLOFF_STRENGTH, DEFAULT_FAMOUS_STARS_ENABLED, DEFAULT_MILKY_WAY_ENABLED, DEFAULT_MILKY_WAY_LABEL_ENABLED, @@ -99,6 +102,9 @@ export function buildInitialSettings(): EngineSettingsState { depthFade: DEFAULT_DEPTH_FADE_ENABLED, highlightFallback: DEFAULT_HIGHLIGHT_FALLBACK, realOnly: DEFAULT_REAL_ONLY_MODE, + sbScale: DEFAULT_GALAXY_SB_SCALE, + sbMax: DEFAULT_GALAXY_SB_MAX, + falloffStrength: DEFAULT_GALAXY_FALLOFF_STRENGTH, items: Object.fromEntries( SOURCE_ENTRIES.filter((e) => e.type === 'galaxyCatalog').map((e) => [ e.id, diff --git a/src/state/settings/selectors.ts b/src/state/settings/selectors.ts index ce08bc80d..5fa065f0c 100644 --- a/src/state/settings/selectors.ts +++ b/src/state/settings/selectors.ts @@ -85,6 +85,30 @@ export const selectHighlightFallback = (state: RootState): boolean => export const selectRealOnly = (state: RootState): boolean => selectSettings(state).galaxyCatalogs.realOnly; +/** + * Overall physical-SB → HDR gain — the "Galaxy brightness" knob. A primitive + * read, so no memoization. The points draw layer writes it into the + * `galaxySbScale` uniform each frame. + */ +export const selectGalaxySbScale = (state: RootState): number => + selectSettings(state).galaxyCatalogs.sbScale; + +/** + * Bloom ceiling — the "Bloom ceiling" knob. A primitive read, so no + * memoization. The max baked surface-brightness amplitude a galaxy can emit; + * the vertex stage clamps `sbAmp` to it via the `galaxySbMax` uniform. + */ +export const selectGalaxySbMax = (state: RootState): number => + selectSettings(state).galaxyCatalogs.sbMax; + +/** + * Readability-falloff exponent — the "Distance falloff" knob. A primitive read, + * so no memoization. The exponent on the resolved-fraction falloff, gated by + * the depth-fade toggle; rides the `galaxyFalloffStrength` uniform. + */ +export const selectGalaxyFalloffStrength = (state: RootState): number => + selectSettings(state).galaxyCatalogs.falloffStrength; + export const selectGalaxyCatalogItems = ( state: RootState, ): Record => selectSettings(state).galaxyCatalogs.items; diff --git a/src/state/settings/settingsSlice.ts b/src/state/settings/settingsSlice.ts index 1831a8783..fb1796add 100644 --- a/src/state/settings/settingsSlice.ts +++ b/src/state/settings/settingsSlice.ts @@ -82,6 +82,23 @@ const settingsSlice = createSlice({ setRealOnly: (settings, action: PayloadAction) => { settings.galaxyCatalogs.realOnly = action.payload; }, + // Overall physical-SB → HDR gain, twin of setGalaxyCatalogSize. Rides the + // points uniform as `galaxySbScale`; the live successor to the old + // hardcoded `GALAXY_SB_SCALE` shader const. + setGalaxySbScale: (settings, action: PayloadAction) => { + settings.galaxyCatalogs.sbScale = action.payload; + }, + // Bloom ceiling — the max baked surface-brightness amplitude a compact + // galaxy can emit. The vertex stage clamps `sbAmp` to it live + // (`galaxySbMax` uniform), replacing the old bake-time clamp. + setGalaxySbMax: (settings, action: PayloadAction) => { + settings.galaxyCatalogs.sbMax = action.payload; + }, + // Readability-falloff exponent on the resolved-fraction falloff, gated by + // the depth-fade toggle. Rides the points uniform as `galaxyFalloffStrength`. + setGalaxyFalloffStrength: (settings, action: PayloadAction) => { + settings.galaxyCatalogs.falloffStrength = action.payload; + }, setGalaxyCatalogVisible: ( settings, action: PayloadAction<{ id: GalaxyCatalogId; enabled: boolean }>, @@ -439,6 +456,9 @@ export const { setDepthFade, setHighlightFallback, setRealOnly, + setGalaxySbScale, + setGalaxySbMax, + setGalaxyFalloffStrength, setGalaxyCatalogVisible, setGalaxyCatalogLabelEnabled, setExposure, diff --git a/src/utils/gpu/packPointUniforms.ts b/src/utils/gpu/packPointUniforms.ts index 6f9a605a4..796ac3c1a 100644 --- a/src/utils/gpu/packPointUniforms.ts +++ b/src/utils/gpu/packPointUniforms.ts @@ -1,5 +1,5 @@ /** - * packPointUniforms — pure packer for the 176-byte `Uniforms` struct. + * packPointUniforms — pure packer for the 192-byte `Uniforms` struct. * * Single source of truth for the visual-pass byte layout. Both the point * renderer's `draw()` and (via the returned buffer) the pick renderer's @@ -32,10 +32,12 @@ import type { PointDrawSettings } from '../../@types/rendering/PointDrawSettings * `pointRenderer.ts` re-exports this so existing call-sites that already * import from `pointRenderer` don't need a new import path. * - * 176 = (16 + 4 + 4 + 4 + 4 + 8 + 4) × 4 bytes. See the `UNIFORM_BYTES` - * docblock in `pointRenderer.ts` for the full slot-by-slot layout. + * 192 = (16 + 4 + 4 + 4 + 4 + 8 + 4 + 4) × 4 bytes. See the `UNIFORM_BYTES` + * docblock in `pointRenderer.ts` for the full slot-by-slot layout. The final + * 4-float block carries the galaxy surface-brightness calibration knobs + * (galaxySbScale / galaxySbMax / galaxyFalloffStrength) + one pad word. */ -export const UNIFORM_BYTES = 16 * 4 + 4 * 4 + 4 * 4 + 4 * 4 + 4 * 4 + 8 * 4 + 4 * 4; // 176 bytes +export const UNIFORM_BYTES = 16 * 4 + 4 * 4 + 4 * 4 + 4 * 4 + 4 * 4 + 8 * 4 + 4 * 4 + 4 * 4; // 192 bytes /** * Allocate and pack a `Uniforms` buffer for the visual point-sprite pass. @@ -72,6 +74,9 @@ export function packPointUniforms( depthFadeEnabled, pxFadeStart, pxFadeEnd, + sbScale, + sbMax, + falloffStrength, } = settings; // Pad slots are zero-initialised by `new ArrayBuffer` and never written. @@ -115,7 +120,14 @@ export function packPointUniforms( f32[40] = pxFadeStart; // byte 160 f32[41] = pxFadeEnd; // byte 164 u32[42] = pickPass >>> 0; // byte 168 pickPass (u32) - // f32[43] (_padFade1, byte 172) stays zero. + + // Galaxy surface-brightness calibration knobs. Slot 43 (byte 172) was the + // former `_padFade1` pad word, repurposed to `galaxySbScale`; slots 46/47 + // (bytes 184..191) stay zero pad to round the struct to 192 bytes. + f32[43] = sbScale; // byte 172 galaxySbScale + f32[44] = sbMax; // byte 176 galaxySbMax + f32[45] = falloffStrength; // byte 180 galaxyFalloffStrength + // f32[46..47] (bytes 184..191) stay zero pad → 192 total. return buf; } diff --git a/tests/components/SettingsPanel/GalaxiesSection.test.ts b/tests/components/SettingsPanel/GalaxiesSection.test.ts index 1bbb5f893..6c1664119 100644 --- a/tests/components/SettingsPanel/GalaxiesSection.test.ts +++ b/tests/components/SettingsPanel/GalaxiesSection.test.ts @@ -52,6 +52,12 @@ function baseProps() { onBiasModeChange: vi.fn<(mode: BiasModeT) => void>(), absMagLimit: -19, onAbsMagLimitChange: vi.fn<(absMag: number) => void>(), + sbScale: 8, + onSbScaleChange: vi.fn<(v: number) => void>(), + sbMax: 30, + onSbMaxChange: vi.fn<(v: number) => void>(), + falloffStrength: 0.8, + onFalloffStrengthChange: vi.fn<(v: number) => void>(), }; } diff --git a/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts b/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts index ad4396f6b..36e7a4a77 100644 --- a/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts +++ b/tests/services/engine/bake/buildPointInterleavedBuffer.test.ts @@ -24,8 +24,9 @@ * slot 10 — schechterRatio * slot 11 — angularDensityWeight * slot 12 — absMag (from the offset-normalised slot-3 magnitude) + * slot 13 — sbAmp (physical surface-brightness amplitude) * - * 13 slots × 4 bytes = 52 bytes per point. kPerZ moved to per-galaxy-catalog + * 14 slots × 4 bytes = 56 bytes per point. kPerZ moved to per-galaxy-catalog * `SourceUniforms`; the picker reads instance identity from a per-source * uniform + the GPU's `@builtin(instance_index)`. */ @@ -55,10 +56,10 @@ function makeCloud(count: number): GalaxyCatalog { }; } -const SLOTS = 13; +const SLOTS = 14; describe('buildPointInterleavedBuffer', () => { - it('produces an interleaved Float32Array of the expected length (13 slots × 4 bytes)', () => { + it('produces an interleaved Float32Array of the expected length (14 slots × 4 bytes)', () => { const cloud = makeCloud(3); const result = buildPointInterleavedBuffer({ cloud, diff --git a/tests/services/engine/helpers/pickUniformBytesOf.test.ts b/tests/services/engine/helpers/pickUniformBytesOf.test.ts index 4387e2ef6..2d4955704 100644 --- a/tests/services/engine/helpers/pickUniformBytesOf.test.ts +++ b/tests/services/engine/helpers/pickUniformBytesOf.test.ts @@ -61,6 +61,9 @@ const STATE = { highlightFallback: true, realOnly: false, depthFade: true, + sbScale: 8, + sbMax: 30, + falloffStrength: 0.8, }, bias: { mode: 1, absMagLimit: -18.25 }, }, @@ -84,6 +87,9 @@ describe('pickUniformBytesOf', () => { biasMode: STATE.settings.bias.mode, absMagLimit: STATE.settings.bias.absMagLimit, depthFadeEnabled: STATE.settings.galaxyCatalogs.depthFade, + sbScale: STATE.settings.galaxyCatalogs.sbScale, + sbMax: STATE.settings.galaxyCatalogs.sbMax, + falloffStrength: STATE.settings.galaxyCatalogs.falloffStrength, pxFadeStart: PROCEDURAL_DISK_FADE_START_PX, pxFadeEnd: PROCEDURAL_DISK_FADE_END_PX, }); // pickPass defaults to 0 — the override below sets it to 1 diff --git a/tests/services/gpu/renderers/galaxyCatalog/catalogStore.test.ts b/tests/services/gpu/renderers/galaxyCatalog/catalogStore.test.ts index a05510cbb..d56182f92 100644 --- a/tests/services/gpu/renderers/galaxyCatalog/catalogStore.test.ts +++ b/tests/services/gpu/renderers/galaxyCatalog/catalogStore.test.ts @@ -470,10 +470,10 @@ describe('catalogStore.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 = 13; slot 10 = SCHECHTER_RATIO_BYTE_OFFSET / 4. - expect(f32[0 * 13 + 10]).toBeCloseTo(0.25); - expect(f32[1 * 13 + 10]).toBeCloseTo(0.5); - expect(f32[2 * 13 + 10]).toBeCloseTo(0.75); + // SLOTS_PER_POINT = 14; slot 10 = SCHECHTER_RATIO_BYTE_OFFSET / 4. + expect(f32[0 * 14 + 10]).toBeCloseTo(0.25); + expect(f32[1 * 14 + 10]).toBeCloseTo(0.5); + expect(f32[2 * 14 + 10]).toBeCloseTo(0.75); }); it('throws when ratios.length !== source count', async () => { @@ -518,8 +518,8 @@ describe('catalogStore.spliceAngularWeights', () => { const view = last.data as Float32Array; const f32 = new Float32Array(view.buffer, view.byteOffset, view.length); // slot 11 = ANGULAR_WEIGHT_BYTE_OFFSET / 4. - expect(f32[0 * 13 + 11]).toBeCloseTo(0.1); - expect(f32[1 * 13 + 11]).toBeCloseTo(0.9); + expect(f32[0 * 14 + 11]).toBeCloseTo(0.1); + expect(f32[1 * 14 + 11]).toBeCloseTo(0.9); }); it('throws when weights.length !== source count', async () => { @@ -556,10 +556,10 @@ describe('catalogStore.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 * 13 + 10]).toBe(0); - expect(f32[0 * 13 + 11]).toBe(0); - expect(f32[1 * 13 + 10]).toBe(0); - expect(f32[1 * 13 + 11]).toBe(0); + expect(f32[0 * 14 + 10]).toBe(0); + expect(f32[0 * 14 + 11]).toBe(0); + expect(f32[1 * 14 + 10]).toBe(0); + expect(f32[1 * 14 + 11]).toBe(0); }); it('zeroes for every loaded source when called with no argument', async () => { diff --git a/tests/services/gpu/renderers/galaxyCatalog/pointRenderer.test.ts b/tests/services/gpu/renderers/galaxyCatalog/pointRenderer.test.ts index f2586b96f..b76cfce81 100644 --- a/tests/services/gpu/renderers/galaxyCatalog/pointRenderer.test.ts +++ b/tests/services/gpu/renderers/galaxyCatalog/pointRenderer.test.ts @@ -347,6 +347,9 @@ describe('PointRenderer.draw — PointDrawSettings shape', () => { biasMode: 0, absMagLimit: 0, depthFadeEnabled: false, + sbScale: 8, + sbMax: 30, + falloffStrength: 0.8, pxFadeStart: 0, pxFadeEnd: 0, focusBindGroup: FOCUS_BIND_GROUP, @@ -393,6 +396,9 @@ describe('PointRenderer.draw — PointDrawSettings shape', () => { biasMode: 0, absMagLimit: 0, depthFadeEnabled: false, + sbScale: 8, + sbMax: 30, + falloffStrength: 0.8, pxFadeStart: 0, pxFadeEnd: 0, focusBindGroup: FOCUS_BIND_GROUP, diff --git a/tests/services/gpu/renderers/galaxyCatalog/pointVertexLayout.test.ts b/tests/services/gpu/renderers/galaxyCatalog/pointVertexLayout.test.ts index a3d559816..5071e83fe 100644 --- a/tests/services/gpu/renderers/galaxyCatalog/pointVertexLayout.test.ts +++ b/tests/services/gpu/renderers/galaxyCatalog/pointVertexLayout.test.ts @@ -10,12 +10,12 @@ import { describe, it, expect } from 'vitest'; describe('POINT_VERTEX_ATTRIBUTES — shared layout export', () => { - it('has 10 attributes with the expected shader locations and formats', async () => { + it('has 11 attributes with the expected shader locations and formats', async () => { const { POINT_VERTEX_ATTRIBUTES, POINT_STRIDE } = await import('../../../../../src/services/gpu/renderers/galaxyCatalog/pointVertexLayout'); - expect(POINT_STRIDE).toBe(52); - expect(POINT_VERTEX_ATTRIBUTES).toHaveLength(10); + expect(POINT_STRIDE).toBe(56); + expect(POINT_VERTEX_ATTRIBUTES).toHaveLength(11); // Location 0 is the position vec3, location 4 is the baked (paCos, // paSin) vec2; everything else is a scalar f32. Anyone editing @@ -42,6 +42,7 @@ describe('POINT_VERTEX_ATTRIBUTES — shared layout export', () => { { location: 7, offset: 40 }, // schechterRatio { location: 8, offset: 44 }, // angularDensityWeight { location: 9, offset: 48 }, // absMag + { location: 10, offset: 52 }, // sbAmp ]; for (const { location, offset } of scalarExpectations) { expect(POINT_VERTEX_ATTRIBUTES[location]).toEqual({ diff --git a/tests/services/gpu/shaders/milkyWayPickUniformParity.test.ts b/tests/services/gpu/shaders/milkyWayPickUniformParity.test.ts index 181d6ed93..5843b2e2a 100644 --- a/tests/services/gpu/shaders/milkyWayPickUniformParity.test.ts +++ b/tests/services/gpu/shaders/milkyWayPickUniformParity.test.ts @@ -149,6 +149,9 @@ function packSentinels(): ArrayBuffer { biasMode: 0, absMagLimit: 0, depthFadeEnabled: false, + sbScale: 8, + sbMax: 30, + falloffStrength: 0.8, pxFadeStart: 0, pxFadeEnd: 0, focusBindGroup: {} as unknown as GPUBindGroup, @@ -212,7 +215,7 @@ describe('milkyWayPick/io.wesl Uniforms ↔ packPointUniforms layout parity', () expect(at('pxPerRad')).toBe(observedF32Offset(buf, SENTINEL.pxPerRad)); }); - it('reads exactly the documented 112-byte prefix, within the 176-byte buffer', () => { + it('reads exactly the documented 112-byte prefix, within the 192-byte buffer', () => { // The mirror must stay a PREFIX: its total extent is what the MW draw // reads through the caller's bind group, and WGSL only permits the // bound buffer to be LARGER than the declared struct — never smaller. diff --git a/tests/state/settings/makeSettingsFixture.ts b/tests/state/settings/makeSettingsFixture.ts index 477b314ff..9cb4d40a4 100644 --- a/tests/state/settings/makeSettingsFixture.ts +++ b/tests/state/settings/makeSettingsFixture.ts @@ -51,6 +51,9 @@ import { DEFAULT_EXPOSURE, DEFAULT_FAMOUS_STARS_ENABLED, DEFAULT_FLOW, + DEFAULT_GALAXY_FALLOFF_STRENGTH, + DEFAULT_GALAXY_SB_MAX, + DEFAULT_GALAXY_SB_SCALE, DEFAULT_GALAXY_TEXTURES_ENABLED, DEFAULT_HIGHLIGHT_FALLBACK, DEFAULT_ORIENTATION, @@ -94,6 +97,9 @@ export function makeSettingsFixture( depthFade: DEFAULT_DEPTH_FADE_ENABLED, highlightFallback: DEFAULT_HIGHLIGHT_FALLBACK, realOnly: DEFAULT_REAL_ONLY_MODE, + sbScale: DEFAULT_GALAXY_SB_SCALE, + sbMax: DEFAULT_GALAXY_SB_MAX, + falloffStrength: DEFAULT_GALAXY_FALLOFF_STRENGTH, items: Object.fromEntries( GALAXY_CATALOG_IDS.map((id) => [id, { enabled: true, labelEnabled: true }]), ) as Record, diff --git a/tests/utils/gpu/packPointUniforms.test.ts b/tests/utils/gpu/packPointUniforms.test.ts index 0ae9aba52..dedf4b543 100644 --- a/tests/utils/gpu/packPointUniforms.test.ts +++ b/tests/utils/gpu/packPointUniforms.test.ts @@ -58,6 +58,9 @@ const SETTINGS: PointDrawSettings = { depthFadeEnabled: true, pxFadeStart: 4, pxFadeEnd: 8, + sbScale: 8, + sbMax: 30, + falloffStrength: 0.8, focusBindGroup: FOCUS_BIND_GROUP, // packPointUniforms does not call this; fadeOpacityOf is a per-draw-loop // concern owned by the renderer. The pure packer receives the settings @@ -68,12 +71,12 @@ const SETTINGS: PointDrawSettings = { // ─── Tests ──────────────────────────────────────────────────────────────────── describe('packPointUniforms — byteLength', () => { - it('returns a buffer of exactly UNIFORM_BYTES (176)', () => { + it('returns a buffer of exactly UNIFORM_BYTES (192)', () => { // The size is the single source of truth (exported UNIFORM_BYTES); a // mismatch here means the alloc and the layout constant are out of sync. const buf = packPointUniforms(VIEW_PROJ, VIEWPORT_PX, SETTINGS); expect(buf.byteLength).toBe(UNIFORM_BYTES); - expect(buf.byteLength).toBe(176); + expect(buf.byteLength).toBe(192); }); }); @@ -245,10 +248,31 @@ describe('packPointUniforms — procedural-disk crossfade + pickPass (bytes 160. const buf = packPointUniforms(VIEW_PROJ, VIEWPORT_PX, SETTINGS, 1); expect(new Uint32Array(buf)[42]).toBe(1); }); +}); + +describe('packPointUniforms — galaxy SB calibration knobs (bytes 172..191)', () => { + it('writes galaxySbScale at byte 172 (float index 43, the old _padFade1 slot)', () => { + const buf = packPointUniforms(VIEW_PROJ, VIEWPORT_PX, SETTINGS); + const f32 = new Float32Array(buf); + expect(f32[43]).toBeCloseTo(SETTINGS.sbScale); + }); + + it('writes galaxySbMax at byte 176 (float index 44)', () => { + const buf = packPointUniforms(VIEW_PROJ, VIEWPORT_PX, SETTINGS); + const f32 = new Float32Array(buf); + expect(f32[44]).toBeCloseTo(SETTINGS.sbMax); + }); + + it('writes galaxyFalloffStrength at byte 180 (float index 45)', () => { + const buf = packPointUniforms(VIEW_PROJ, VIEWPORT_PX, SETTINGS); + const f32 = new Float32Array(buf); + expect(f32[45]).toBeCloseTo(SETTINGS.falloffStrength); + }); - it('leaves _padFade1 at byte 172 (float index 43) as zero', () => { + it('leaves the two trailing pad words (bytes 184..191, indices 46/47) as zero', () => { const buf = packPointUniforms(VIEW_PROJ, VIEWPORT_PX, SETTINGS); const f32 = new Float32Array(buf); - expect(f32[43]).toBe(0); + expect(f32[46]).toBe(0); + expect(f32[47]).toBe(0); }); }); From 995ef4fdb0f72f800946712ab08c9e8cf4d764e8 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 16:11:39 +0200 Subject: [PATCH 02/12] feat(galaxies): mirror physical surface brightness into procedural disks Stage 2 of the galaxy-look spike. The close-approach procedural disk handed off from a physically-lit point sprite to a flat-brightness disk (peak ~1.0, never bloomed). Now both passes derive brightness from the same physical surface-brightness model, so bright galaxies bloom in the disk view too and the point->disk crossfade holds constant brightness. - Extract the SB formula into shared pure helpers galaxySbAmp + galaxyMeanAbsMag; the bake and the disk planner both use them so the amplitude can't drift between passes. - Store the per-catalog SB zero-point on GalaxyCatalog.meanAbsMag (optional; populated at decode/synthetic/empty/transfer, recomputed on decode so no .bin format bump). The bake reads it; the disk planner reads catalog.meanAbsMag directly, so no store map or frame-input threading of the zero-point. - Disk planner computes each instance's effective amplitude min(sbAmp, sbMax) * sbScale * sbBoost * brightness from the live settings each frame and packs it into the instance's free extras.w slot; the fragment multiplies its profile by it. No disk-uniform growth, so the Advanced sliders stay live. - Tighten the core profile: bulge sigma 0.4->0.28, weights 0.6/0.4 -> 0.7/0.3, so the SB amplitude reads as a concentrated bloomy nucleus over a softer disk. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/galaxyCatalog/GalaxyCatalog.d.ts | 18 +++++++ .../subsystems/ProceduralDiskSubsystem.d.ts | 16 +++++-- .../rendering/ProceduralDiskInstance.d.ts | 11 +++++ src/data/galaxyCatalog/galaxyCatalogFormat.ts | 9 +++- .../galaxyCatalog/galaxyCatalogTransfer.ts | 3 ++ src/data/galaxyCatalog/synthetic.ts | 5 +- .../bake/buildPointInterleavedBuffer.ts | 47 +++++++------------ src/services/engine/frame/runFrame.ts | 7 ++- .../subsystems/proceduralDiskSubsystem.ts | 19 +++++++- .../galaxyCatalog/proceduralDiskRenderer.ts | 9 +++- .../proceduralDisks/fragment.wesl | 22 ++++++--- .../galaxyCatalog/proceduralDisks/io.wesl | 8 +++- .../galaxyCatalog/proceduralDisks/vertex.wesl | 4 ++ src/utils/galaxy/galaxyMeanAbsMag.ts | 39 +++++++++++++++ src/utils/galaxy/galaxySbAmp.ts | 42 +++++++++++++++++ .../render/disk/maybeEmitProceduralDisk.ts | 2 + .../diskPlannerWalk.integration.test.ts | 24 ++++++++-- .../engine/subsystems/diskWalkHarness.ts | 4 +- .../proceduralDiskSubsystem.test.ts | 6 +++ .../texturedDiskSubsystem.calibration.test.ts | 5 ++ .../proceduralDiskRenderer.test.ts | 1 + tests/utils/galaxy/galaxyMeanAbsMag.test.ts | 42 +++++++++++++++++ tests/utils/galaxy/galaxySbAmp.test.ts | 38 +++++++++++++++ .../disk/maybeEmitProceduralDisk.test.ts | 11 +++++ tests/visual/galaxyImpostorBaseline.test.ts | 14 +++++- 25 files changed, 350 insertions(+), 56 deletions(-) create mode 100644 src/utils/galaxy/galaxyMeanAbsMag.ts create mode 100644 src/utils/galaxy/galaxySbAmp.ts create mode 100644 tests/utils/galaxy/galaxyMeanAbsMag.test.ts create mode 100644 tests/utils/galaxy/galaxySbAmp.test.ts diff --git a/src/@types/data/galaxyCatalog/GalaxyCatalog.d.ts b/src/@types/data/galaxyCatalog/GalaxyCatalog.d.ts index a651d0171..99965323c 100644 --- a/src/@types/data/galaxyCatalog/GalaxyCatalog.d.ts +++ b/src/@types/data/galaxyCatalog/GalaxyCatalog.d.ts @@ -200,4 +200,22 @@ export type GalaxyCatalog = { * `milliquasParentSurveyPrefix(byte)` returns `null`. */ parentSurveyByte: Uint8Array; + + /** + * Per-catalog mean absolute magnitude — the surface-brightness + * zero-point `galaxySbAmp` normalises against (see + * `utils/galaxy/galaxySbAmp.ts` and `utils/galaxy/galaxyMeanAbsMag.ts`). + * + * Populated by every runtime construction path: `decodeGalaxyCatalog`, + * `generateSyntheticCloud`, `emptyGalaxyCatalog`, and + * `cloneGalaxyCatalogForTransfer`. Optional ONLY so lightweight test + * fixtures may omit it — consumers that need a value fall back + * themselves (the point bake recomputes via `galaxyMeanAbsMag`; the + * disk planner falls back to -20.5). + * + * Derived, NOT stored in the `.bin` — `decodeGalaxyCatalog` recomputes + * it from the decoded `magG` + `positions` on every load, so adding + * this field did not bump the binary format version. + */ + meanAbsMag?: number; }; diff --git a/src/@types/engine/subsystems/ProceduralDiskSubsystem.d.ts b/src/@types/engine/subsystems/ProceduralDiskSubsystem.d.ts index 6c37a39b4..80b83611c 100644 --- a/src/@types/engine/subsystems/ProceduralDiskSubsystem.d.ts +++ b/src/@types/engine/subsystems/ProceduralDiskSubsystem.d.ts @@ -28,11 +28,19 @@ import type { DiskRowVisitor } from './DiskRowVisitor'; import type { DiskWalkInput } from './DiskWalkInput'; /** - * The procedural body needs nothing beyond the geometry-bearing subset the - * shared walk computes from, so its frame input IS the walk input (the - * textured body is the one with extras like `famousMeta` / `nowMs`). + * The procedural body's frame input EXTENDS the shared walk input (mirrors + * how the textured body extends it) with the three live surface-brightness + * sliders — Settings -> Galaxies -> Advanced's `sbScale` / `sbMax` / + * `brightness`. These have to arrive per-frame so the sliders stay live; + * the per-catalog zero-point they're applied against (`meanAbsMag`) is + * NOT threaded here — the planner reads it straight off each row's + * `catalog.meanAbsMag`, since it's already holding that catalog. */ -export type ProceduralDiskFrameInput = DiskWalkInput; +export type ProceduralDiskFrameInput = DiskWalkInput & { + readonly sbScale: number; + readonly sbMax: number; + readonly brightness: number; +}; export type ProceduralDiskFrameOutput = { /** Back-to-front sorted; consumer ships this array directly to the renderer. */ diff --git a/src/@types/rendering/ProceduralDiskInstance.d.ts b/src/@types/rendering/ProceduralDiskInstance.d.ts index e80d63ab1..25a673a4d 100644 --- a/src/@types/rendering/ProceduralDiskInstance.d.ts +++ b/src/@types/rendering/ProceduralDiskInstance.d.ts @@ -76,4 +76,15 @@ export type ProceduralDiskInstance = { sourceCode: SourceType; /** Per-source catalog row index — the localIdx half of the packed pick id. */ localIdx: number; + /** + * The EFFECTIVE per-instance surface-brightness amplitude the fragment + * multiplies its profile by: + * `min(rawSbAmp, sbMax) * sbScale * sbBoost * brightness`. Pre-scaled on + * the CPU each frame (rather than uploading the raw amplitude + the + * three slider scalars separately) so the live sliders stay responsive + * with no disk-uniform change needed. Packed into `extras.w` (float slot + * 11) — it rides an existing padding float, so the 48-byte stride is + * unchanged. + */ + sbAmp: number; }; diff --git a/src/data/galaxyCatalog/galaxyCatalogFormat.ts b/src/data/galaxyCatalog/galaxyCatalogFormat.ts index 4a63ca2c2..dc6280e2c 100644 --- a/src/data/galaxyCatalog/galaxyCatalogFormat.ts +++ b/src/data/galaxyCatalog/galaxyCatalogFormat.ts @@ -59,6 +59,7 @@ */ import type { GalaxyCatalog } from '../../@types/data/galaxyCatalog/GalaxyCatalog'; +import { galaxyMeanAbsMag } from '../../utils/galaxy/galaxyMeanAbsMag'; const MAGIC = 0x504d4b53; const VERSION = 6; @@ -198,7 +199,7 @@ export function decodeGalaxyCatalog(buf: ArrayBuffer): GalaxyCatalog { // The remaining 6 padding bytes are ignored on decode. } - return { + const catalog: GalaxyCatalog = { count, objIDs, positions, @@ -214,6 +215,11 @@ export function decodeGalaxyCatalog(buf: ArrayBuffer): GalaxyCatalog { parentSurveyByte, spectroscopicZ, }; + // Derived, not stored on disk — recomputed here (rather than encoded) + // so adding this field never bumps the binary format version. Computed + // AFTER the object above so the helper sees the finished typed arrays. + catalog.meanAbsMag = galaxyMeanAbsMag(catalog); + return catalog; } export function emptyGalaxyCatalog(): GalaxyCatalog { @@ -232,5 +238,6 @@ export function emptyGalaxyCatalog(): GalaxyCatalog { classByte: new Uint8Array(0), parentSurveyByte: new Uint8Array(0), spectroscopicZ: new Float32Array(0), + meanAbsMag: -20.5, // count-0 fallback — same sentinel galaxyMeanAbsMag returns for count===0. }; } diff --git a/src/data/galaxyCatalog/galaxyCatalogTransfer.ts b/src/data/galaxyCatalog/galaxyCatalogTransfer.ts index 08bfcbbce..eededc0d1 100644 --- a/src/data/galaxyCatalog/galaxyCatalogTransfer.ts +++ b/src/data/galaxyCatalog/galaxyCatalogTransfer.ts @@ -60,6 +60,9 @@ export function cloneGalaxyCatalogForTransfer(catalog: GalaxyCatalog): ClonedGal classByte: new Uint8Array(catalog.classByte.buffer.slice(0)), parentSurveyByte: new Uint8Array(catalog.parentSurveyByte.buffer.slice(0)), spectroscopicZ: new Float32Array(catalog.spectroscopicZ.buffer.slice(0)), + // Scalar, not a typed array — rides along by value, no buffer to slice + // or add to the transfer list. + meanAbsMag: catalog.meanAbsMag, }; const transfer: Transferable[] = [ copy.objIDs.buffer, diff --git a/src/data/galaxyCatalog/synthetic.ts b/src/data/galaxyCatalog/synthetic.ts index 87b85e7b3..9221ee4f7 100644 --- a/src/data/galaxyCatalog/synthetic.ts +++ b/src/data/galaxyCatalog/synthetic.ts @@ -30,6 +30,7 @@ import type { GalaxyCatalog } from '../../@types/data/galaxyCatalog/GalaxyCatalog'; import { mulberry32 } from '../../utils/random/mulberry32'; import { uniformInSphere } from '../../utils/random/uniformInSphere'; +import { galaxyMeanAbsMag } from '../../utils/galaxy/galaxyMeanAbsMag'; // ─── Cloud generator ───────────────────────────────────────────────────────── @@ -185,7 +186,7 @@ export function generateSyntheticCloud(count: number, seed = 42): GalaxyCatalog // the build pipeline applies when a real catalog record has no size measurement. const diameterKpc = new Float32Array(count).fill(30); - return { + const cloud: GalaxyCatalog = { count, objIDs, positions, @@ -203,4 +204,6 @@ export function generateSyntheticCloud(count: number, seed = 42): GalaxyCatalog parentSurveyByte: new Uint8Array(count), spectroscopicZ: new Float32Array(count), }; + cloud.meanAbsMag = galaxyMeanAbsMag(cloud); + return cloud; } diff --git a/src/services/engine/bake/buildPointInterleavedBuffer.ts b/src/services/engine/bake/buildPointInterleavedBuffer.ts index a06c4fe3d..7b2cbf506 100644 --- a/src/services/engine/bake/buildPointInterleavedBuffer.ts +++ b/src/services/engine/bake/buildPointInterleavedBuffer.ts @@ -55,6 +55,8 @@ import { vMaxWeight, } from '../../../utils/math'; import { computeSchechterRatios } from './computeSchechterRatios'; +import { galaxySbAmp } from '../../../utils/galaxy/galaxySbAmp'; +import { galaxyMeanAbsMag } from '../../../utils/galaxy/galaxyMeanAbsMag'; import type { BuildPointInterleavedBufferMode } from '../../../@types/engine/BuildPointInterleavedBufferMode'; import type { BuildPointInterleavedBufferInput } from '../../../@types/engine/BuildPointInterleavedBufferInput'; import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/BuildPointInterleavedBufferResult'; @@ -116,17 +118,6 @@ const D_REF_MPC = 750; /** Target post-shift mean magnitude for the per-galaxy-catalog magG normalisation. */ const SDSS_TARGET_MEAN_MAG = 18; -/** Reference physical diameter (kpc) for the surface-brightness zero-point — the L-star / Milky-Way scale. */ -const SB_REF_DIAMETER_KPC = 30; -/** - * Float-safety guard on the baked surface-brightness amplitude — only there to - * keep a pathologically compact/bright galaxy's `sbAmp` finite and in range. The - * live bloom ceiling now lives in the shader's `galaxySbMax` uniform (seeded - * from `DEFAULT_GALAXY_SB_MAX`), so this cap is deliberately far above the - * slider range and no longer limits the visible amplitude. - */ -const SB_AMP_MAX = 100000; - /** * Bake one point cloud's per-vertex GPU bytes. Pure: no `this`, no DOM, no * module-level state. Safe to call from a Worker. @@ -166,27 +157,24 @@ export function buildPointInterleavedBuffer( // and most 2MRS galaxies render at maximum intensity with zero contrast. let magSum = 0; let magCount = 0; - let absMagSum = 0; - let absMagCount = 0; for (let i = 0; i < cloud.count; i++) { const m = cloud.magG[i]!; if (Number.isFinite(m)) { magSum += m; magCount++; } - const x = cloud.positions[i * 3 + 0]!; - const y = cloud.positions[i * 3 + 1]!; - const z = cloud.positions[i * 3 + 2]!; - const dMpc = Math.hypot(x, y, z); - const M = absoluteFromApparent(m, dMpc); - if (Number.isFinite(M)) { - absMagSum += M; - absMagCount++; - } } const sourceMean = magCount > 0 ? magSum / magCount : SDSS_TARGET_MEAN_MAG; const magOffset = SDSS_TARGET_MEAN_MAG - sourceMean; - const meanAbsMag = absMagCount > 0 ? absMagSum / absMagCount : -20.5; + + // Surface-brightness zero-point — a DIFFERENT quantity from the magOffset + // above (that one is a cosmetic per-catalog display shift; this one is + // the physical mean absolute magnitude `galaxySbAmp` normalises against). + // Shared with the disk-planner mirror of this bake via `cloud.meanAbsMag` + // when the catalog carries one (the real decode/synthetic paths always + // populate it); recomputed here as a fallback for lightweight test + // fixtures that omit the optional field. + const meanAbsMag = cloud.meanAbsMag ?? galaxyMeanAbsMag(cloud); // ── Malmquist 1/V_max weight inputs ────────────────────────────────────── // @@ -356,14 +344,11 @@ export function buildPointInterleavedBuffer( // This is the intrinsic per-pixel radiance the vertex stage scales into // HDR: intrinsically bright / compact galaxies emit above the bloom // threshold; diffuse ones stay dim. Uses the RAW physical absMag (same as - // vMax), not the cosmetic offset-normalised slot-3/slot-12 value. - const diamKpc = cloud.diameterKpc[i]! > 0 ? cloud.diameterKpc[i]! : SB_REF_DIAMETER_KPC; - const diamRatio = diamKpc / SB_REF_DIAMETER_KPC; - const lumRel = Math.pow(10, -0.4 * (absMag - meanAbsMag)); - const sbAmpRaw = lumRel / (diamRatio * diamRatio); - interleaved[o + 13] = Number.isFinite(sbAmpRaw) - ? Math.min(Math.max(sbAmpRaw, 0), SB_AMP_MAX) - : 1.0; + // vMax), not the cosmetic offset-normalised slot-3/slot-12 value. The + // procedural-disk pass (`proceduralDiskSubsystem.ts`) recomputes this + // SAME amplitude via the shared `galaxySbAmp` helper so the point↔disk + // crossfade holds constant brightness. + interleaved[o + 13] = galaxySbAmp(absMag, meanAbsMag, cloud.diameterKpc[i]!); } return { diff --git a/src/services/engine/frame/runFrame.ts b/src/services/engine/frame/runFrame.ts index 5d65c11b1..7ca10daf1 100644 --- a/src/services/engine/frame/runFrame.ts +++ b/src/services/engine/frame/runFrame.ts @@ -534,7 +534,12 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): }; diskPlannerWalk.runFrame( sharedInput, - proceduralDisks.beginFrame(sharedInput), + proceduralDisks.beginFrame({ + ...sharedInput, + sbScale: state.settings.galaxyCatalogs.sbScale, + sbMax: state.settings.galaxyCatalogs.sbMax, + brightness: state.settings.galaxyCatalogs.brightness, + }), texturedDisks.beginFrame({ ...sharedInput, famousMeta: state.data.galaxies.famousMeta, diff --git a/src/services/engine/subsystems/proceduralDiskSubsystem.ts b/src/services/engine/subsystems/proceduralDiskSubsystem.ts index 1b7457fe0..c3058d645 100644 --- a/src/services/engine/subsystems/proceduralDiskSubsystem.ts +++ b/src/services/engine/subsystems/proceduralDiskSubsystem.ts @@ -18,9 +18,10 @@ * this planner imports both. */ -import { Source } from '../../../data/sources'; +import { Source, SOURCE_REGISTRY } from '../../../data/sources'; import { pickColourIndex } from '../../../data/galaxyCatalog/colourIndex'; -import { cartesianToRaDec, smoothstep } from '../../../utils/math'; +import { absoluteFromApparent, cartesianToRaDec, smoothstep } from '../../../utils/math'; +import { galaxySbAmp } from '../../../utils/galaxy/galaxySbAmp'; import { diskQuadExtentMpc } from '../../../utils/render/disk/diskQuadExtentMpc'; import { purgeStrideWindow } from '../../../utils/render/disk/purgeStrideWindow'; import { byDistanceToCamera } from '../../../utils/render/disk/byDistanceToCamera'; @@ -74,6 +75,7 @@ export function createProceduralDiskSubsystem( function beginFrame(input: ProceduralDiskFrameInput): DiskRowVisitor { const camPosition = input.cam.position; + const { sbScale, sbMax, brightness } = input; const proceduralDisks: ProceduralDiskInstance[] = []; // Hoisted per source by beginSource so onRow does no map lookup — the @@ -117,6 +119,18 @@ export function createProceduralDiskSubsystem( dMpcFromOrigin, ); + // The disk recomputes the SAME physical surface brightness the + // point bake baked (shared `galaxySbAmp` helper + the same + // per-catalog `meanAbsMag`), pre-scaled by the live sliders, so + // the point -> disk crossfade holds constant brightness and + // intrinsically bright galaxies bloom in the disk view too. + const meanAbsMag = catalog.meanAbsMag ?? -20.5; + const absMag = absoluteFromApparent(catalog.magG[i]!, dMpcFromOrigin); + const rawSb = galaxySbAmp(absMag, meanAbsMag, dKpcRow); + const regEntry = SOURCE_REGISTRY[source]; + const sbBoost = regEntry.type === 'galaxyCatalog' ? regEntry.sbBoost : 1; + const sbAmp = Math.min(rawSb, sbMax) * sbScale * sbBoost * brightness; + const emitted = maybeEmitProceduralDisk( px, ar, @@ -126,6 +140,7 @@ export function createProceduralDiskSubsystem( z, sizeWorldMpc, colourIndex, + sbAmp, PROCEDURAL_DISK_FADE_START_PX, PROCEDURAL_DISK_FADE_END_PX, source, diff --git a/src/services/gpu/renderers/galaxyCatalog/proceduralDiskRenderer.ts b/src/services/gpu/renderers/galaxyCatalog/proceduralDiskRenderer.ts index b53d99dee..e1e915e8d 100644 --- a/src/services/gpu/renderers/galaxyCatalog/proceduralDiskRenderer.ts +++ b/src/services/gpu/renderers/galaxyCatalog/proceduralDiskRenderer.ts @@ -11,7 +11,7 @@ * * posSize vec4 xyz, sizeWorldMpc * orientation vec4 axisRatio, positionAngleDeg, _, _ - * extras vec4 colourIndex, crossfadeAlpha, procFadeOut, _ + * extras vec4 colourIndex, crossfadeAlpha, procFadeOut, sbAmp * hiResSlot vec4 _, _, _, _ (shared 64-byte stride; the procedural * shader ignores slots 12..15 — they * belong to texturedDiskRenderer) @@ -277,7 +277,12 @@ export function createProceduralDiskRenderer(init: Init): ProceduralDiskRenderer packed[o + 8] = ins.colourIndex; packed[o + 9] = ins.crossfadeAlpha; packed[o + 10] = ins.procFadeOut; - packed[o + 11] = 0; + // Slot 11 (extras.w) — effective surface-brightness amplitude, already + // scaled by the live sliders + per-source sbBoost (see + // ProceduralDiskInstance.d.ts). The pick fragment shares this same + // 'packed' array but ignores extras.w, so writing it here is harmless + // for the pick pass. + packed[o + 11] = ins.sbAmp; // Slots 12..15 are the shared-factory's hi-res-LOD vec4 (owned by // texturedDiskRenderer). Explicit zeros so a future migration to // a reused scratch buffer can't leak stale bytes into the GPU diff --git a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl index 9bdc0d3a5..08bfdf392 100644 --- a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl @@ -4,9 +4,19 @@ // apparent edge) and shades a two-component galaxy brightness // profile: // -// - Gaussian bulge (σ = 0.4): bright inner core. +// - Gaussian bulge (sigma = 0.28): bright, tight inner core. // - Exponential disk (scale = 0.5): softer halo. // +// The profile's output is scaled by 'in.sbAmp', the physical +// surface-brightness amplitude computed once per row in +// 'proceduralDiskSubsystem.ts' via the SAME 'galaxySbAmp' helper the +// point-sprite bake uses (see 'buildPointInterleavedBuffer.ts'). Sharing +// the helper and the per-catalog 'meanAbsMag' zero-point means an +// intrinsically bright or compact galaxy pushes this pass's HDR core +// above the bloom threshold exactly the way its companion point sprite +// does, and the two passes agree on brightness at the 8-14 px crossfade +// instead of only agreeing on hue. +// // Hue comes entirely from the per-galaxy colour-index ramp — the // same ramp the points pass uses, so a galaxy's procedural-disk // colour matches its companion point exactly. Earlier versions @@ -15,7 +25,7 @@ // points pass (warmer at the centre, cooler at the rim) so it's // been removed. Only the brightness profile remains. // -// Final alpha is the combined brightness × crossfadeAlpha so the +// Final alpha is the combined brightness x crossfadeAlpha so the // impostor fades in cleanly across the 8-14 px transition band. // // ## Why no uniform binding declared here @@ -40,10 +50,10 @@ import package::lib::colorIndex::ramp; // explicit. import package::lib::masks::circularMask; -const BULGE_SIGMA = 0.4; +const BULGE_SIGMA = 0.28; const DISK_SCALE = 0.5; -const BULGE_WEIGHT = 0.6; -const DISK_WEIGHT = 0.4; +const BULGE_WEIGHT = 0.7; +const DISK_WEIGHT = 0.3; // 'ramp(t)' lives in 'lib/colorIndex.wesl' — imported above. Sharing // the function with the points pass guarantees procedural-disk @@ -58,7 +68,7 @@ fn fs(in: VsOut) -> @location(0) vec4 { let bulge = exp(-(r * r) / (2.0 * BULGE_SIGMA * BULGE_SIGMA)); let disk = exp(-r / DISK_SCALE); - let intensity = bulge * BULGE_WEIGHT + disk * DISK_WEIGHT; + let intensity = (bulge * BULGE_WEIGHT + disk * DISK_WEIGHT) * in.sbAmp; // Colour: ramp hue only, no per-component tint shifts. See the // fragment-stage header comment above for why the warm-bulge / cool- diff --git a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/io.wesl b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/io.wesl index 4e7e8ecde..617cce767 100644 --- a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/io.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/io.wesl @@ -76,7 +76,7 @@ struct Uniforms { struct InstanceIn { @location(0) posSize: vec4, // x, y, z, sizeWorldMpc @location(1) orientation: vec4, // axisRatio, positionAngleDeg, pickIdBits, _ - @location(2) extras: vec4, // colourIndex, crossfadeAlpha, procFadeOut, _ + @location(2) extras: vec4, // colourIndex, crossfadeAlpha, procFadeOut, sbAmp }; // ── vertex-to-fragment interface ──────────────────────────────────── @@ -112,4 +112,10 @@ struct VsOut { // identity. Only the pick fragment declares and reads 'pickId'; the visual // fragment does not, so the value is simply ignored there. @location(5) @interpolate(flat) pickId: u32, + // Per-instance effective surface-brightness amplitude (already scaled by + // the live sbScale / sbMax / sbBoost / brightness sliders on the CPU). + // Flat because it's constant across a quad's fragments; the fragment + // stage multiplies its brightness profile by this so intrinsically + // bright galaxies push their HDR core above the bloom threshold. + @location(6) @interpolate(flat) sbAmp: f32, }; diff --git a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/vertex.wesl b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/vertex.wesl index 7e1ffee92..39afbed6e 100644 --- a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/vertex.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/vertex.wesl @@ -151,5 +151,9 @@ fn vs(@builtin(vertex_index) vid: u32, instance: InstanceIn) -> VsOut { // the TS side in slot 6 = orientation.z. The visual fragment ignores this // varying; the pick fragment reads it to write the r32uint pick texture. out.pickId = bitcast(instance.orientation.z); + // Effective surface-brightness amplitude, pre-scaled on the CPU by the + // live sliders; forwarded unchanged so the fragment stage can multiply + // its brightness profile by it. + out.sbAmp = instance.extras.w; return out; } diff --git a/src/utils/galaxy/galaxyMeanAbsMag.ts b/src/utils/galaxy/galaxyMeanAbsMag.ts new file mode 100644 index 000000000..c7dc1ca88 --- /dev/null +++ b/src/utils/galaxy/galaxyMeanAbsMag.ts @@ -0,0 +1,39 @@ +import type { GalaxyCatalog } from '../../@types/data/galaxyCatalog/GalaxyCatalog'; +import { absoluteFromApparent } from '../math/absoluteFromApparent'; + +/** + * galaxyMeanAbsMag — the per-catalog surface-brightness zero-point. + * + * Why per-catalog rather than one project-wide constant? Our three real + * catalogs each store a different photometric band in `magG` — SDSS is + * true g-band, 2MRS is near-infrared J, GLADE is B-band — so their raw + * apparent magnitudes live on different numeric scales entirely (2MRS + * galaxies read ~10 mag "brighter" in J than SDSS reads in g for a + * similar physical galaxy). A single global mean-absolute-magnitude + * reference would therefore render one catalog's galaxies systematically + * too bright or too dim relative to the others. Computing the mean once + * per catalog and normalising each row against ITS OWN catalog's mean + * cancels the band offset, so `galaxySbAmp` output stays comparable + * across catalogs even though the underlying photometry isn't. + * + * `-20.5` is the same "no data" fallback the bake already uses (roughly + * an L* absolute magnitude) — returned for an empty catalog so callers + * never have to special-case count === 0. + */ +export function galaxyMeanAbsMag(cloud: GalaxyCatalog): number { + let sum = 0; + let count = 0; + for (let i = 0; i < cloud.count; i++) { + const m = cloud.magG[i]!; + const x = cloud.positions[i * 3 + 0]!; + const y = cloud.positions[i * 3 + 1]!; + const z = cloud.positions[i * 3 + 2]!; + const dMpc = Math.hypot(x, y, z); + const M = absoluteFromApparent(m, dMpc); + if (Number.isFinite(M)) { + sum += M; + count++; + } + } + return count > 0 ? sum / count : -20.5; +} diff --git a/src/utils/galaxy/galaxySbAmp.ts b/src/utils/galaxy/galaxySbAmp.ts new file mode 100644 index 000000000..132718b88 --- /dev/null +++ b/src/utils/galaxy/galaxySbAmp.ts @@ -0,0 +1,42 @@ +/** + * galaxySbAmp — physical surface-brightness amplitude for one galaxy. + * + * "Surface brightness" here means relative luminosity spread over the + * galaxy's projected area, NOT the raw apparent magnitude. Two galaxies at + * wildly different distances can have identical apparent brightness (one + * is intrinsically faint and close, the other intrinsically bright and + * far), but they should NOT bloom the same way — bloom is a property of + * how much light lands per screen pixel, which for a fixed apparent size + * scales with intrinsic luminosity, and for a fixed intrinsic luminosity + * scales inversely with projected area. Hence `lumRel / diamRatio²`: the + * amplitude rises for galaxies that are intrinsically brighter than their + * catalog's mean AND falls for galaxies that spread that luminosity over + * a larger apparent disk. + * + * `SB_REF_DIAMETER_KPC = 30` is the zero-point for the area term — the + * project's DEFAULT_GALAXY_DIAMETER_KPC, roughly an L-star / Milky-Way- + * scale disk. It's a normalisation choice, not a physical constant: any fixed + * reference diameter works as long as the point bake (Stage 1) and this + * disk-pass mirror use the SAME one, which is why it's duplicated as a + * literal here rather than threaded through as a parameter — the value + * has to match `buildPointInterleavedBuffer.ts`'s bake exactly for the + * point↔disk crossfade to hold constant brightness. + * + * `SB_AMP_MAX` is a float-safety clamp only — it exists to keep a + * pathologically bright-and-compact galaxy's amplitude finite, not to cap + * the visible brightness (that's the caller's job via the live `sbMax` + * slider, which sits orders of magnitude below this ceiling). Without it, + * a division by a near-zero diameter could produce Infinity and poison + * downstream min/max clamps. + */ + +const SB_REF_DIAMETER_KPC = 30; +const SB_AMP_MAX = 100000; + +export function galaxySbAmp(absMag: number, meanAbsMag: number, diameterKpc: number): number { + const diamKpc = diameterKpc > 0 ? diameterKpc : SB_REF_DIAMETER_KPC; + const diamRatio = diamKpc / SB_REF_DIAMETER_KPC; + const lumRel = Math.pow(10, -0.4 * (absMag - meanAbsMag)); + const raw = lumRel / (diamRatio * diamRatio); + return Number.isFinite(raw) ? Math.min(Math.max(raw, 0), SB_AMP_MAX) : 1.0; +} diff --git a/src/utils/render/disk/maybeEmitProceduralDisk.ts b/src/utils/render/disk/maybeEmitProceduralDisk.ts index 48d3af26f..f235d3399 100644 --- a/src/utils/render/disk/maybeEmitProceduralDisk.ts +++ b/src/utils/render/disk/maybeEmitProceduralDisk.ts @@ -21,6 +21,7 @@ export function maybeEmitProceduralDisk( z: number, sizeWorldMpc: number, colourIndex: number, + sbAmp: number, fadeStartPx: number, fadeEndPx: number, sourceCode: SourceType, @@ -41,5 +42,6 @@ export function maybeEmitProceduralDisk( procFadeOut: 1.0, sourceCode, localIdx, + sbAmp, }; } diff --git a/tests/services/engine/subsystems/diskPlannerWalk.integration.test.ts b/tests/services/engine/subsystems/diskPlannerWalk.integration.test.ts index 1103db350..33b4583c9 100644 --- a/tests/services/engine/subsystems/diskPlannerWalk.integration.test.ts +++ b/tests/services/engine/subsystems/diskPlannerWalk.integration.test.ts @@ -125,7 +125,17 @@ function pxPerRadFor(cam: OrbitCamera): number { function makeInput(catalogs: Map, mask = 0xffffffff) { const cam = makeCam(); - return { cam, catalogs, visibleSourceMask: mask, pxPerRad: pxPerRadFor(cam) }; + return { + cam, + catalogs, + visibleSourceMask: mask, + pxPerRad: pxPerRadFor(cam), + // Live surface-brightness sliders — arbitrary plausible defaults; this + // suite is about parity between merged/solo walks, not brightness math. + sbScale: 5, + sbMax: 30, + brightness: 1, + }; } const tick = (): Promise => new Promise((r) => setTimeout(r, 0)); @@ -253,7 +263,11 @@ describe('diskPlannerWalk drives both bodies', () => { // ── Frame 1: sticky maps are empty, so procedural's lastOutput IS this // frame's window; textured enqueues a fetch per freshly-visited row. - walk.runFrame(input, proc.beginFrame(input), tex.beginFrame({ ...input, famousMeta: [], nowMs: 0 })); + walk.runFrame( + input, + proc.beginFrame(input), + tex.beginFrame({ ...input, famousMeta: [], nowMs: 0 }), + ); const procWindow1 = new Set(proc.lastOutput.instances.map((d) => d.localIdx)); const texWindow1 = texWindow(); expect(procWindow1).toEqual(new Set([0, 1, 2])); @@ -264,7 +278,11 @@ describe('diskPlannerWalk drives both bodies', () => { // ── Frame 2: cursor advances. Procedural's sticky map still holds window 1, // so the NEWLY-added indices (lastOutput minus frame-1 window) are frame 2's // window; textured enqueues only the freshly-visited rows. - walk.runFrame(input, proc.beginFrame(input), tex.beginFrame({ ...input, famousMeta: [], nowMs: 0 })); + walk.runFrame( + input, + proc.beginFrame(input), + tex.beginFrame({ ...input, famousMeta: [], nowMs: 0 }), + ); const procAll2 = new Set(proc.lastOutput.instances.map((d) => d.localIdx)); const procWindow2 = new Set([...procAll2].filter((i) => !procWindow1.has(i))); const texWindow2 = texWindow(); diff --git a/tests/services/engine/subsystems/diskWalkHarness.ts b/tests/services/engine/subsystems/diskWalkHarness.ts index 45c9f7670..fc0c27c5f 100644 --- a/tests/services/engine/subsystems/diskWalkHarness.ts +++ b/tests/services/engine/subsystems/diskWalkHarness.ts @@ -13,8 +13,8 @@ import type { DiskPlannerWalk } from '../../../../src/@types/engine/subsystems/DiskPlannerWalk'; import type { DiskRowVisitor } from '../../../../src/@types/engine/subsystems/DiskRowVisitor'; -import type { DiskWalkInput } from '../../../../src/@types/engine/subsystems/DiskWalkInput'; import type { + ProceduralDiskFrameInput, ProceduralDiskFrameOutput, ProceduralDiskSubsystem, } from '../../../../src/@types/engine/subsystems/ProceduralDiskSubsystem'; @@ -44,7 +44,7 @@ export function noopDiskRowVisitor(): DiskRowVisitor { export function runProceduralSolo( walk: DiskPlannerWalk, sys: ProceduralDiskSubsystem, - input: DiskWalkInput, + input: ProceduralDiskFrameInput, ): ProceduralDiskFrameOutput { walk.runFrame(input, sys.beginFrame(input), noopDiskRowVisitor()); return sys.lastOutput; diff --git a/tests/services/engine/subsystems/proceduralDiskSubsystem.test.ts b/tests/services/engine/subsystems/proceduralDiskSubsystem.test.ts index 34af1a898..24ed0e443 100644 --- a/tests/services/engine/subsystems/proceduralDiskSubsystem.test.ts +++ b/tests/services/engine/subsystems/proceduralDiskSubsystem.test.ts @@ -80,6 +80,12 @@ function makeInput(catalogs: Map, mask = 0xffffffff) catalogs, visibleSourceMask: mask, pxPerRad: 720 / (2 * Math.tan(cam.fovYRad / 2)), + // Live surface-brightness sliders — arbitrary but plausible defaults; + // these tests care about gating/sticky-map/crossfade behaviour, not + // the exact brightness math (covered by galaxySbAmp.test.ts). + sbScale: 5, + sbMax: 30, + brightness: 1, }; } diff --git a/tests/services/engine/subsystems/texturedDiskSubsystem.calibration.test.ts b/tests/services/engine/subsystems/texturedDiskSubsystem.calibration.test.ts index 17e976dd5..008183049 100644 --- a/tests/services/engine/subsystems/texturedDiskSubsystem.calibration.test.ts +++ b/tests/services/engine/subsystems/texturedDiskSubsystem.calibration.test.ts @@ -104,6 +104,11 @@ function makeInput( pxPerRad: 720 / (2 * Math.tan(cam.fovYRad / 2)), famousMeta, nowMs: 0, + // Live surface-brightness sliders, needed by the procedural body's + // frame input; the textured body ignores them. + sbScale: 5, + sbMax: 30, + brightness: 1, }; } diff --git a/tests/services/gpu/renderers/galaxyCatalog/proceduralDiskRenderer.test.ts b/tests/services/gpu/renderers/galaxyCatalog/proceduralDiskRenderer.test.ts index eb99792b7..fa61f8332 100644 --- a/tests/services/gpu/renderers/galaxyCatalog/proceduralDiskRenderer.test.ts +++ b/tests/services/gpu/renderers/galaxyCatalog/proceduralDiskRenderer.test.ts @@ -102,6 +102,7 @@ function fakeProceduralInstance( procFadeOut: 1, sourceCode: 0, localIdx: 0, + sbAmp: 1, ...overrides, }; } diff --git a/tests/utils/galaxy/galaxyMeanAbsMag.test.ts b/tests/utils/galaxy/galaxyMeanAbsMag.test.ts new file mode 100644 index 000000000..611f5130c --- /dev/null +++ b/tests/utils/galaxy/galaxyMeanAbsMag.test.ts @@ -0,0 +1,42 @@ +/** + * galaxyMeanAbsMag — pins the per-catalog surface-brightness zero-point. + * + * Coverage focus: the arithmetic-mean-of-absolute-magnitude computation + * itself (easy to get wrong via a sum/count off-by-one or a wrong + * distance formula), and the empty-catalog fallback other consumers rely + * on (`galaxySbAmp` callers, `emptyGalaxyCatalog`). + */ + +import { describe, it, expect } from 'vitest'; +import { galaxyMeanAbsMag } from '../../../src/utils/galaxy/galaxyMeanAbsMag'; +import { absoluteFromApparent } from '../../../src/utils/math/absoluteFromApparent'; +import type { GalaxyCatalog } from '../../../src/@types/data/galaxyCatalog/GalaxyCatalog'; + +describe('galaxyMeanAbsMag', () => { + it('returns the arithmetic mean of absoluteFromApparent over every row', () => { + // Distances chosen so hypot(x,0,0) = x — trivial distance-from-origin. + const cloud = { + count: 3, + magG: new Float32Array([18, 15, 20]), + positions: new Float32Array([10, 0, 0, 50, 0, 0, 100, 0, 0]), + } as unknown as GalaxyCatalog; + + const expectedMean = + (absoluteFromApparent(18, 10) + + absoluteFromApparent(15, 50) + + absoluteFromApparent(20, 100)) / + 3; + + expect(galaxyMeanAbsMag(cloud)).toBeCloseTo(expectedMean, 6); + }); + + it('returns -20.5 for an empty catalog', () => { + const cloud = { + count: 0, + magG: new Float32Array(0), + positions: new Float32Array(0), + } as unknown as GalaxyCatalog; + + expect(galaxyMeanAbsMag(cloud)).toBe(-20.5); + }); +}); diff --git a/tests/utils/galaxy/galaxySbAmp.test.ts b/tests/utils/galaxy/galaxySbAmp.test.ts new file mode 100644 index 000000000..00c43d343 --- /dev/null +++ b/tests/utils/galaxy/galaxySbAmp.test.ts @@ -0,0 +1,38 @@ +/** + * galaxySbAmp — pins the physical surface-brightness amplitude formula. + * + * Coverage focus: the two dials that actually change the rendered + * brightness (relative luminosity vs. projected area), plus the + * diameter-fallback branch a real catalog with a missing measurement hits. + */ + +import { describe, it, expect } from 'vitest'; +import { galaxySbAmp } from '../../../src/utils/galaxy/galaxySbAmp'; + +describe('galaxySbAmp', () => { + it('gives a brighter (more negative) absMag a larger amplitude at fixed diameter', () => { + const dim = galaxySbAmp(-19, -20.5, 30); + const bright = galaxySbAmp(-22, -20.5, 30); + expect(bright).toBeGreaterThan(dim); + }); + + it('gives a smaller diameter a larger amplitude at fixed absMag', () => { + const large = galaxySbAmp(-20.5, -20.5, 60); + const small = galaxySbAmp(-20.5, -20.5, 15); + expect(small).toBeGreaterThan(large); + }); + + it('treats a zero or negative diameter as the 30 kpc reference fallback', () => { + const viaFallback = galaxySbAmp(-21, -20.5, 0); + const viaExplicit30 = galaxySbAmp(-21, -20.5, 30); + expect(viaFallback).toBe(viaExplicit30); + + const viaNegative = galaxySbAmp(-21, -20.5, -5); + expect(viaNegative).toBe(viaExplicit30); + }); + + it('returns exactly 1.0 when absMag equals meanAbsMag and diameter is the 30 kpc reference', () => { + // lumRel = 10^0 = 1, diamRatio = 1 → raw = 1. + expect(galaxySbAmp(-20.5, -20.5, 30)).toBeCloseTo(1, 6); + }); +}); diff --git a/tests/utils/render/disk/maybeEmitProceduralDisk.test.ts b/tests/utils/render/disk/maybeEmitProceduralDisk.test.ts index eda5b94c4..245381a5d 100644 --- a/tests/utils/render/disk/maybeEmitProceduralDisk.test.ts +++ b/tests/utils/render/disk/maybeEmitProceduralDisk.test.ts @@ -45,6 +45,7 @@ describe('maybeEmitProceduralDisk', () => { base.z, base.sizeWorldMpc, base.colourIndex, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, @@ -66,6 +67,7 @@ describe('maybeEmitProceduralDisk', () => { base.z, base.sizeWorldMpc, base.colourIndex, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, @@ -84,6 +86,7 @@ describe('maybeEmitProceduralDisk', () => { base.z, base.sizeWorldMpc, base.colourIndex, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, @@ -102,6 +105,7 @@ describe('maybeEmitProceduralDisk', () => { base.z, base.sizeWorldMpc, base.colourIndex, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, @@ -120,6 +124,7 @@ describe('maybeEmitProceduralDisk', () => { base.z, base.sizeWorldMpc, base.colourIndex, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, @@ -139,6 +144,7 @@ describe('maybeEmitProceduralDisk', () => { base.z, base.sizeWorldMpc, base.colourIndex, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, @@ -156,6 +162,7 @@ describe('maybeEmitProceduralDisk', () => { base.z, base.sizeWorldMpc, base.colourIndex, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, @@ -177,6 +184,7 @@ describe('maybeEmitProceduralDisk', () => { base.z, base.sizeWorldMpc, base.colourIndex, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, @@ -195,6 +203,7 @@ describe('maybeEmitProceduralDisk', () => { 33, 0.05, 1.7, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, @@ -208,6 +217,7 @@ describe('maybeEmitProceduralDisk', () => { expect(r!.axisRatio).toBe(0.42); expect(r!.positionAngleDeg).toBe(137); expect(r!.colourIndex).toBe(1.7); + expect(r!.sbAmp).toBe(1.0); }); it('defaults procFadeOut to 1.0 — no fade-out against the textured-disk pass', () => { @@ -225,6 +235,7 @@ describe('maybeEmitProceduralDisk', () => { base.z, base.sizeWorldMpc, base.colourIndex, + 1.0, // sbAmp base.fadeStartPx, base.fadeEndPx, 0, diff --git a/tests/visual/galaxyImpostorBaseline.test.ts b/tests/visual/galaxyImpostorBaseline.test.ts index 1e53b7a70..5cc126c4a 100644 --- a/tests/visual/galaxyImpostorBaseline.test.ts +++ b/tests/visual/galaxyImpostorBaseline.test.ts @@ -132,7 +132,17 @@ describe('galaxy-impostor visual baseline', () => { // frame loop makes. `nowFakeAtFrame` is captured per frame so the textured // body's stamped clock matches the advancing synthetic clock. const driveFrame = (nowMs: number): void => { - const sharedInput = { cam, catalogs, visibleSourceMask: 0xffffffff, pxPerRad }; + const sharedInput = { + cam, + catalogs, + visibleSourceMask: 0xffffffff, + pxPerRad, + // Live surface-brightness sliders — arbitrary plausible defaults; + // this baseline is about emission/crossfade geometry, not brightness. + sbScale: 5, + sbMax: 30, + brightness: 1, + }; walk.runFrame( sharedInput, procSys.beginFrame(sharedInput), @@ -163,7 +173,7 @@ describe('galaxy-impostor visual baseline', () => { { "procDisks": { "count": 8, - "hash": "axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=7|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.007|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=6|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.006|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=5|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.005|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=4|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.004|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=3|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.003|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=2|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.002|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=1|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.001|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=0|positionAngleDeg=45|procFadeOut=1|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0|z=0", + "hash": "axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=7|positionAngleDeg=45|procFadeOut=1|sbAmp=0.000114|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.007|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=6|positionAngleDeg=45|procFadeOut=1|sbAmp=0.000114|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.006|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=5|positionAngleDeg=45|procFadeOut=1|sbAmp=0.000114|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.005|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=4|positionAngleDeg=45|procFadeOut=1|sbAmp=0.000114|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.004|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=3|positionAngleDeg=45|procFadeOut=1|sbAmp=0.000114|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.003|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=2|positionAngleDeg=45|procFadeOut=1|sbAmp=0.000114|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.002|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=1|positionAngleDeg=45|procFadeOut=1|sbAmp=0.000114|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0.001|z=0;axisRatio=0.7|colourIndex=0|crossfadeAlpha=1|localIdx=0|positionAngleDeg=45|procFadeOut=1|sbAmp=0.000114|sizeWorldMpc=0.2|sourceCode=1|x=10|y=0|z=0", }, "texDisks": { "count": 8, From 391285f7bf739b5ae77faf7206419567b9b51a56 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 17:09:08 +0200 Subject: [PATCH 03/12] feat(galaxies): boost procedural-disk core contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disk read as a flat blob: bulge sigma 0.28 with 0.7/0.3 weights left the mid-disk floor only ~4x below the centre, so core and halo bloomed together. Tighten the bulge to sigma 0.18 and shift the weights to 0.85/0.15. The weights still sum to 1.0, so the CENTRE peak stays at 1.0 x sbAmp — the value that already matches the companion point sprite, so this cannot reintroduce a brightness pop at the crossfade. Contrast comes entirely from draining the halo and tightening the bulge, dropping the mid-disk floor to ~14x below the peak: the nucleus clears the bloom threshold while the halo stays under it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../galaxyCatalog/proceduralDisks/fragment.wesl | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl index 08bfdf392..9409dc64a 100644 --- a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl @@ -4,9 +4,18 @@ // apparent edge) and shades a two-component galaxy brightness // profile: // -// - Gaussian bulge (sigma = 0.28): bright, tight inner core. +// - Gaussian bulge (sigma = 0.18): bright, tight inner core. // - Exponential disk (scale = 0.5): softer halo. // +// The bulge/disk weights (0.85 / 0.15) sum to 1.0, so the CENTRE peak +// stays at 1.0 x sbAmp — the value that already matches the companion +// point sprite at the crossfade, so retuning contrast here does not +// reintroduce a brightness pop at the handoff. Contrast is boosted by +// draining weight OUT of the halo and tightening the bulge, which drops +// the mid/outer-disk floor while leaving the peak fixed: the bright core +// clears the bloom threshold while the dim halo stays below it, so the +// nucleus blooms and the disk does not. +// // The profile's output is scaled by 'in.sbAmp', the physical // surface-brightness amplitude computed once per row in // 'proceduralDiskSubsystem.ts' via the SAME 'galaxySbAmp' helper the @@ -50,10 +59,10 @@ import package::lib::colorIndex::ramp; // explicit. import package::lib::masks::circularMask; -const BULGE_SIGMA = 0.28; +const BULGE_SIGMA = 0.18; const DISK_SCALE = 0.5; -const BULGE_WEIGHT = 0.7; -const DISK_WEIGHT = 0.3; +const BULGE_WEIGHT = 0.85; +const DISK_WEIGHT = 0.15; // 'ramp(t)' lives in 'lib/colorIndex.wesl' — imported above. Sharing // the function with the points pass guarantees procedural-disk From 8b9e701a42867a89f98c8ba77ea2f6ccf7d9272b Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 17:09:08 +0200 Subject: [PATCH 04/12] fix(galaxies): trim Famous surface-brightness boost to 0.45 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Famous galaxies rendered blown out on close approach. The cause is a systematic in the surface-brightness model, not missing photometry — 77 of the 80 seed rows carry a real magB. galaxySbAmp divides a catalog-RELATIVE luminosity (normalised against the catalog's own meanAbsMag) by an ABSOLUTE size reference (fixed 30 kpc). Famous has a median diameter of 25.4 kpc, so the size term alone inflates every row by 1/0.847^2 = 1.39x; with the log-space-mean- then-exponentiate skew the measured median raw lands at 2.14 against a nominal 1.0, tailing to 11.7 (NGC 4449). Every row then clears the 2.0 bloom threshold, so the whole catalog blooms at once. The sbMax ceiling never engages — 0 of 80 rows reach it. 0.45 ~= 1/2.14 re-centres the catalog's median on the amplitude a typical survey galaxy gets while preserving the internal spread. This is a per-source trim; normalising the size term per-catalog would fix the relative-vs-absolute mismatch generally, at the cost of re-tuning every other catalog. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/data/sources/famous-galaxy.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/data/sources/famous-galaxy.ts b/src/data/sources/famous-galaxy.ts index 5a51fcc72..b01db9784 100644 --- a/src/data/sources/famous-galaxy.ts +++ b/src/data/sources/famous-galaxy.ts @@ -33,6 +33,25 @@ export const FAMOUS_GALAXY_ENTRY = { // ~150 rows total — never subsampled; one file shared across tiers. tierTargets: {}, // Per-source SB boost — 1.0 = no boost. - sbBoost: 1.0, + // + // Famous runs HOT without a trim, and the cause is a systematic in the + // surface-brightness model rather than missing photometry (77 of the 80 + // seed rows carry a real magB). `galaxySbAmp` divides a catalog-RELATIVE + // luminosity (normalised against this catalog's own meanAbsMag) by an + // ABSOLUTE size reference (a fixed 30 kpc). Famous galaxies have a median + // diameter of 25.4 kpc, so that size term alone inflates every row by + // 1/0.847^2 = 1.39x; combined with the log-space-mean-then-exponentiate + // skew the measured median `raw` lands at 2.14 against a nominal 1.0, with + // a tail to 11.7 (NGC 4449). Since every row then clears the 2.0 bloom + // threshold, the whole catalog blooms at once and reads as blown out — + // and the sbMax ceiling never engages (0 of 80 rows reach it). + // + // 0.45 ~= 1/2.14 re-centres the catalog's median on the same amplitude a + // typical survey galaxy gets, preserving the internal spread (M32 still + // outshines a big diffuse spiral). This is a per-source trim, NOT a fix + // for the relative-vs-absolute mismatch — normalising the size term + // per-catalog would address that generally, at the cost of re-tuning + // every other catalog's brightness. + sbBoost: 0.45, falloffHalfMpc: 1000, } as const satisfies GalaxyCatalogSourceEntry; From 812da56f96909992505476feb40df7bcee4d8520 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 17:15:40 +0200 Subject: [PATCH 05/12] docs(backlog): capture the galaxy surface-brightness model as needs-design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The look spike grounded galaxy brightness in a physical surface-brightness amplitude, but the normalisation is not actually physical: galaxySbAmp divides a catalog-RELATIVE luminosity (against that catalog's own meanAbsMag) by an ABSOLUTE 30 kpc size reference. Records the three defects with derivations so the reasoning isn't lost: meanAbsMag conflating a band zero-point with real luminosity differences; Famous's sbBoost 0.45 being fitted to a symptom rather than photometry; and GLADE's diameter being Tully(1988)-derived from its own B magnitude, making SB ∝ L^-0.245 — a re-parameterisation of the magnitude carrying no independent surface-brightness information. Also notes the honest cheap option: keep the tuning, drop the "physically grounded" claim. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/BACKLOG.md | 1 + ...6-07-24-galaxy-surface-brightness-model.md | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 docs/backlog/2026-07-24-galaxy-surface-brightness-model.md diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 2d8830dc8..2ee37e382 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -55,6 +55,7 @@ Items with a **→ details** link have a full write-up in [`backlog/`](backlog/) - [ ] **Bloom perf — instrument first** `needs-design` — bloom is ~5 ms / 23% on solar-system; THREE levers now measured-dead (5→3, bloom0 1/3, fold-into-tonemap — the last a wash on a clean interleaved A/B, spikes 2026-07-22), and the whole pyramid is one timing slot so the cost is unlocalised. Split the slot per-sub-pass before any more attempts. → [details](backlog/2026-07-21-bloom-mip-count-perf.md) - [ ] **Fold star-upsample into hdr→swap** `needs-design` — delete the standalone fullscreen composite (~1.0 ms real) by sampling the aggregate target in the tonemap shader; bloom-ordering question open. → [details](backlog/2026-07-21-fold-star-upsample-into-tonemap.md) - [ ] **Real star apparent magnitudes from Earth** `needs-design` — relative photometry is already physical; calibrate the display mapping so the Earth vantage matches the real night sky (interacts with bloom + the brightness slider). → [details](backlog/2026-07-22-star-apparent-magnitude-realism.md) +- [ ] **Physically-honest galaxy surface brightness** `needs-design` — `galaxySbAmp` divides a catalog-relative luminosity by an absolute 30 kpc size reference; Famous is fudged with `sbBoost 0.45` and GLADE's SB is Tully-derived from its own B mag (no real information). → [details](backlog/2026-07-24-galaxy-surface-brightness-model.md) - [ ] **Bright star clump at ~5.9 kpc** `deferred` — flux verified conserved; residual over-exposure is display policy (mid-anchor slider + summed knee shipped; retune or tone-map shoulder next). → [details](backlog/2026-07-17-star-clump-brightness-5-9kpc.md) - [ ] **Foreground body draw/drawPick share a per-frame resolved set** `deferred` — mirrored partition/cull invocations can desync under future edits; star partition runs up to 4×/frame at deep zoom. → [details](backlog/2026-07-17-foreground-body-resolved-set.md) - [ ] **Star drawBudget small-tier mobile cap + iOS device pass** `deferred` — lower `hardCap` for `tier === 'small'` in `gaia-stars.ts`, tuned on a real device; verify the new vertex-stage storage bindings under WebKit's stricter WebGPU in the same pass. diff --git a/docs/backlog/2026-07-24-galaxy-surface-brightness-model.md b/docs/backlog/2026-07-24-galaxy-surface-brightness-model.md new file mode 100644 index 000000000..f97f3373b --- /dev/null +++ b/docs/backlog/2026-07-24-galaxy-surface-brightness-model.md @@ -0,0 +1,57 @@ +# Physically-honest galaxy surface-brightness model + +**Status:** needs-design (2026-07-24) + +## Ask + +The galaxy point/disk passes are driven by a physical surface-brightness amplitude (`sbAmp`, shipped in the 2026-07 look spike). The _form_ is right but the _normalisation_ is not: it divides a catalog-**relative** luminosity by an **absolute** size reference. Replace it with a physically defensible model, or decide deliberately to keep the current tuning and stop calling it physical. + +## Current state + +The amplitude is one shared helper consumed by both passes, so any fix lands in one place: + +- `src/utils/galaxy/galaxySbAmp.ts:36-41` — `lumRel = 10^(-0.4·(absMag − meanAbsMag))`, `raw = lumRel / (diameterKpc/30)²`. +- `src/utils/galaxy/galaxyMeanAbsMag.ts:23-38` — per-catalog mean absolute magnitude, `-20.5` fallback. +- Point pass: baked into slot 13 (`buildPointInterleavedBuffer.ts:177,351`), consumed at `shaders/galaxyCatalog/points/vertex.wesl:223-234`. +- Disk pass: recomputed CPU-side per frame (`proceduralDiskSubsystem.ts:127-132`) and packed into `extras.w`. +- Live knobs: `DEFAULT_GALAXY_SB_SCALE = 5.0`, `SB_MAX = 30.0`, `FALLOFF_STRENGTH = 0.7` (`src/data/defaults.ts:184,193,203`). + +**What is already physical:** the `L/D²` form. Surface brightness genuinely is distance-independent — `SB = M + 5·log₁₀(D) + const`, the distance cancels exactly. Keep this. + +## Three defects + +### 1. `meanAbsMag` conflates a calibration constant with real physics + +Subtracting each catalog's own mean absolute magnitude erases two different things at once: + +- the **photometric band zero-point** (SDSS g / 2MRS J / GLADE B / Famous B) — a genuine per-catalog constant that _should_ be corrected; +- the catalog's **real luminosity distribution** — a genuine physical difference that should _not_ be erased. + +Famous galaxies really are more luminous and higher surface brightness. Normalising that away and then re-introducing an absolute 30 kpc size term is what manufactures the mismatch. Secondary effect: the mean is arithmetic in log space, then exponentiated, so `lumRel` has median ≈ 1 but mean > 1 (Jensen) — a bright-tail skew that disappears with a fixed reference. + +### 2. `sbBoost` is the right shape, derived the wrong way + +A per-source multiplicative constant is _exactly_ how a band zero-point offset should be absorbed, so the knob sits in the correct slot. But `famous-galaxy.ts:55` is `0.45` because it cancels an observed median (measured `raw` median 2.14 vs nominal 1.0, tail to 11.7 for NGC 4449), not because of any photometric transformation. Famous's median diameter is 25.4 kpc against the fixed 30 kpc reference, so the size term alone inflates every row by `1/0.847² = 1.39×`. Note the `sbMax` ceiling never engaged — 0 of 80 rows reached it. + +### 3. GLADE's surface brightness is synthetic — this caps how physical the model can get + +GLADE carries no measured size. `tools/parsers/glade.ts:384-404` derives the diameter from the B magnitude via Tully (1988), implemented at `src/utils/math/galaxyDiameterKpc.ts:45-47`: + +``` +logR = −0.249·(M_B + 21) + 1.366 ⇒ D ∝ L^0.62 ⇒ SB = L/D² ∝ L^−0.245 +``` + +So for the largest catalog, `sbAmp` is a deterministic re-parameterisation of the B magnitude carrying **no independent surface-brightness information** — and with an inverted slope, where more luminous galaxies render slightly _dimmer_ per pixel. A 40× luminosity range yields only ~2.5× SB variation. + +Catalogs with genuinely measured sizes, where SB is real: SDSS (`petroR50_r`, `sdssCsv.ts:278`), 2MRS (`twoMrs.ts:311`), Famous (HyperLEDA `logd25`, `expandFamousFromCatalogs.ts:459`). + +## What needs decided + +- **Band unification.** Replace `meanAbsMag` with a per-catalog **band zero-point constant** plus a single **global absolute reference magnitude**. Needs a real transformation per source (B→g, J→g, …), each requiring a colour term — Famous has B−V, 2MRS has J−K, GLADE is B-only. Decide the target band and what to do where the colour term is missing. +- **What to do about GLADE.** Options: flag its SB as synthetic and render it at flat surface brightness; source measured diameters (HyperLEDA `logd25` by PGC — coverage is partial, see `project_hyperleda_partial_cache`); or accept the degeneracy and document it. +- **Re-tuning cost.** Dropping the per-catalog mean shifts _every_ catalog's brightness, so this needs a full visual pass and probably new `sbScale` defaults. Famous would become legitimately brighter than the field, at which point "too bright" is an exposure/tone-map question, not a data one — it interacts with the HDR bloom threshold and the brightness slider the same way [star apparent-magnitude realism](2026-07-22-star-apparent-magnitude-realism.md) does. +- **Whether it's worth it.** A defensible alternative is to keep the current tuning, delete the "physically grounded" claim from the comments, and treat `sbAmp` as a legibility heuristic. Cheaper and honest. + +## Validation + +Compare rendered SB against published values for galaxies with real measurements (M31, M87, Sombrero sit around 20–22 mag/arcsec² for disks) from a fixed pose; confirm the point↔disk crossfade still holds constant brightness (`galaxyImpostorBaseline.test.ts` hashes the disk instances); check Milliquas and the DESI cones, which have their own `sbBoost` lifts, don't invert. From 6e39e804a8f9cb19db8ba4ac21cd477b7780fc65 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 17:26:34 +0200 Subject: [PATCH 06/12] fix(milliquas): treat a literal 0 magnitude as missing, not measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Circinus and the Milliquas copy of Centaurus A rendered as blown-out white blobs. The cause is upstream data, not the brightness model. Milliquas marks an absent magnitude with a literal `0`, not a blank. The real Circinus row reads `Rmag="10.93" Bmag=" 0 "` — R measured, B absent — but the parser only guarded blanks, so parseFloat('0') came through as a real magnitude. 2169 rows carried magG=0. Zero is catastrophic rather than merely wrong: at Circinus' 4.28 Mpc an m=0 back-solves to M=-28.2, which galaxySbAmp reads as 243x a typical galaxy's luminosity. Clamped at sbMax it still renders at sbAmp 450 against a catalog median of 15 — 30x too bright. Treating 0 as missing is safe for this catalog specifically: the brightest known AGN (3C 273) sits at ~12.9, so no row has a legitimate magnitude near zero. NaN is the honest sentinel and downstream consumers already handle it. Regression test appends the real Circinus row to the fixture verbatim. Takes effect on the next Milliquas bin rebuild. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/fixtures/milliquas/sample.txt | 1 + tests/parsers/milliquas.test.ts | 14 ++++++++++++++ tools/parsers/milliquas.ts | 17 +++++++++++++++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/milliquas/sample.txt b/tests/fixtures/milliquas/sample.txt index 1c51da5b3..58004f506 100644 --- a/tests/fixtures/milliquas/sample.txt +++ b/tests/fixtures/milliquas/sample.txt @@ -19,3 +19,4 @@ 8.7500000 -20.3333300 SDSS J003500-202000 K 19.95 20.50 g - - 3.120 SDSS17 SDSS17 201.3651300 -43.0191100 NGC 5128 AR2 6.84 7.84 1 1 0.002 NED NED SUMSS J132527-430100 SUMSS J132440-425800 SUMSS J132615-431500 220.0000000 5.0000000 FAKE_ZERO_REDSHIFT Q 20.10 20.55 - - -0.001 FAKE FAKE +213.2915180 -65.3390920 Circinus NX 10.93 0 n x 0.001 1158 1360 CXO J141310.0-652029 diff --git a/tests/parsers/milliquas.test.ts b/tests/parsers/milliquas.test.ts index b9e6f66e3..09d65491d 100644 --- a/tests/parsers/milliquas.test.ts +++ b/tests/parsers/milliquas.test.ts @@ -38,6 +38,20 @@ describe('parseMilliquas', () => { expect(skipped.zNonPositive).toBeGreaterThanOrEqual(2); }); + it('treats a literal 0 magnitude as missing, not as a real measurement', () => { + // Milliquas marks an absent magnitude with `0`, not a blank. The real + // Circinus row (appended to the fixture verbatim) reads Rmag=10.93 with + // Bmag=0 — R measured, B absent. Letting the 0 through is not merely + // imprecise: at Circinus' 4 Mpc an m=0 back-solves to M=-28, which the + // surface-brightness model reads as ~240x a typical galaxy and renders + // as a blown-out white blob. Guard the sentinel, keep the real R. + const { records } = parseMilliquas(raw); + const circinus = records.find((r) => Math.abs(r.ra - 213.2915) < 1e-3); + expect(circinus).toBeDefined(); + expect(circinus!.magR).toBeCloseTo(10.93, 2); + expect(circinus!.magG).toBeNaN(); + }); + it('rejects 0.1-rounded photo-z candidate rows', () => { const { skipped } = parseMilliquas(raw); expect(skipped.photoZRounded).toBeGreaterThan(0); diff --git a/tools/parsers/milliquas.ts b/tools/parsers/milliquas.ts index 6c1bbbf6e..45719c103 100644 --- a/tools/parsers/milliquas.ts +++ b/tools/parsers/milliquas.ts @@ -175,8 +175,21 @@ export function parseMilliquas(rawText: string): MilliquasParseResult { continue; } - const magR = rmagStr === '' ? NaN : parseFloat(rmagStr); - const magG = bmagStr === '' ? NaN : parseFloat(bmagStr); + // Milliquas marks a missing magnitude with a literal `0`, NOT a blank — + // the Circinus row reads `Rmag="10.93" Bmag=" 0 "`, meaning "R measured, + // B absent". A blank-only guard lets `parseFloat('0')` through as a real + // magnitude, and zero is catastrophic downstream rather than merely + // wrong: a 4 Mpc galaxy at m=0 back-solves to M=-28, which the + // surface-brightness model reads as ~240x a typical galaxy's luminosity. + // That is what made Circinus and the Milliquas copy of Centaurus A + // render as blown-out white blobs (2169 rows carried magG=0). + // + // Treating 0 as "missing" is safe for THIS catalog specifically: the + // brightest known AGN (3C 273) sits at ~12.9, so no Milliquas row has a + // legitimate magnitude anywhere near zero. NaN is the honest sentinel — + // downstream consumers already treat it as "no measurement". + const magR = rmagStr === '' || parseFloat(rmagStr) === 0 ? NaN : parseFloat(rmagStr); + const magG = bmagStr === '' || parseFloat(bmagStr) === 0 ? NaN : parseFloat(bmagStr); const nameTrimmed = nameRaw.trimEnd().trimStart(); const classByte = classByteFromType(typeRaw); From 67f9d6bc34b7773eb761c271b20d577e0d7919a4 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 18:05:22 +0200 Subject: [PATCH 07/12] fix(catalog): run Milliquas through the famous-seed dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milliquas records were added straight into the per-source bucket after dropFamousMatches had already run on the crossMatch output — which Milliquas is deliberately absent from — so they silently skipped the famous dedup entirely. A famous galaxy with an active nucleus therefore rendered twice: once as its curated entry, once as a Milliquas point on top. Centaurus A was the visible case. The existing "Milliquas bypasses crossMatch on purpose" rationale is sound but covers only crossMatch (an AGN core and its host are different objects, and crossMatch dedups on RA/Dec/redshift). Skipping the famous dedup was an unexamined side effect of where the injection happened. Measured cost: 20 of ~943k Milliquas rows sit within 30" of a famous-seed position, and only ONE is genuinely a different object — a ~1 Gpc background quasar behind the Antennae. The other 19 are the host's own nucleus, scattered in distance only by Milliquas' coarse 3-decimal redshift (z=0.001 quantises to 4.28 Mpc, 0.002 to 8.56). Accepted trade; a redshift-agreement test would save the quasar if it ever matters. Drop count is logged rather than silent. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/catalog/buildAllBins.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tools/catalog/buildAllBins.ts b/tools/catalog/buildAllBins.ts index a65e5c5b6..4c55b3e85 100644 --- a/tools/catalog/buildAllBins.ts +++ b/tools/catalog/buildAllBins.ts @@ -767,10 +767,33 @@ async function runCli(): Promise { // survey it draws from (SDSS, Veron, NED, …), so a second dedup // pass would just spend CPU re-discovering the empty intersection. // - // Add the records straight into the per-source bucket so the per-tier - // write loop below treats them like any other survey. + // The crossMatch bypass above does NOT extend to the famous-seed dedup. + // Milliquas used to be added straight to the bucket here, which meant it + // silently skipped `dropFamousMatches` (that runs on the crossMatch output, + // which Milliquas is deliberately absent from) — so a famous galaxy with an + // active nucleus rendered twice: once as its curated entry, once as a + // Milliquas point on top. Centaurus A was the visible case. + // + // Measured cost of closing the gap: 20 of ~943k Milliquas rows sit within + // 30" of a famous-seed position, and only ONE is genuinely a different + // object — a ~1 Gpc background quasar shining through the Antennae. The + // rest are the host's own nucleus, scattered in distance only by Milliquas' + // coarse 3-decimal redshift (z=0.001 quantises to 4.28 Mpc, 0.002 to 8.56, + // …). Losing one background AGN to de-duplicate 19 is the accepted trade; + // a redshift-agreement test like `crossMatch` uses would save it, at the + // cost of a second matching pass for 19 rows. if (milliquasResult.records.length > 0) { - bySource.set(Source.Milliquas, milliquasResult.records); + const { kept: milliquasKept, dropped: milliquasFamousDropped } = dropFamousMatches( + milliquasResult.records, + famousPositions, + 30, + ); + if (milliquasFamousDropped > 0) { + process.stderr.write( + ` famous-seed dedup: dropped ${milliquasFamousDropped.toLocaleString()} Milliquas rows that match a famous-seed position\n`, + ); + } + bySource.set(Source.Milliquas, milliquasKept); } // Per-source dedup report. Subtracting kept from input gives the number From e9a352c52f6e4dee1d4b408bcc15a42a8f298efd Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 18:19:40 +0200 Subject: [PATCH 08/12] feat(galaxies): warm-core / cool-rim colour gradient in both galaxy passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Galaxies were flat-tinted: one ramp lookup per galaxy, the same hue from core to rim. Real galaxies are not — the bulge is old red stars, the arms young blue OB stars, a g-r difference of roughly 0.4. The gradient is expressed in COLOUR-INDEX space and re-uses the existing shared 'ramp', rather than as an ad-hoc RGB tint. That distinction is what makes it safe on a catalog: a red elliptical stays red and merely gains internal structure, instead of being force-tinted blue at its rim. The split is symmetric, so a galaxy's integrated hue stays near its catalogued colour index, and 'ramp' saturating at the extremes makes the gradient self-limiting for already-extreme colours. An earlier attempt at warm-bulge/cool-disk tinting was reverted because it lived only in the procedural-disk pass and so diverged visibly from the points pass across the 8-14 px crossfade. Both passes now read the same 'rampCore'/'rampRim' pair from 'lib/colorIndex.wesl', so that divergence is structurally impossible rather than merely avoided. The two passes spell the reconstruction differently for cost reasons, not semantic ones. The procedural-disk pass has few instances and evaluates both ramps per fragment. The points pass covers most of the screen at the large tiers and is fragment/blend-bound, so it hoists both ramp lookups to the vertex stage and forwards the rim-minus-core delta as a flat varying; the fragment reconstructs with one fused multiply-add. Both are exact lerps between the same two endpoints, and the endpoints are per-instance constants, so the passes agree value-for-value. Known approximation: the point sprite's billboard is padded relative to the galaxy's true extent while the disk impostor's r = 1 is the disk edge, so the gradient is radially compressed on the point relative to the disk. The passes cross-fade rather than swap, so this reads as the gradient gaining definition on approach. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../galaxyCatalog/points/colorFragment.wesl | 21 ++++++- .../gpu/shaders/galaxyCatalog/points/io.wesl | 41 +++++++++++-- .../shaders/galaxyCatalog/points/vertex.wesl | 24 ++++++-- .../proceduralDisks/fragment.wesl | 47 ++++++++------ src/services/gpu/shaders/lib/colorIndex.wesl | 61 +++++++++++++++++++ 5 files changed, 163 insertions(+), 31 deletions(-) diff --git a/src/services/gpu/shaders/galaxyCatalog/points/colorFragment.wesl b/src/services/gpu/shaders/galaxyCatalog/points/colorFragment.wesl index 2e5f95479..d690461d9 100644 --- a/src/services/gpu/shaders/galaxyCatalog/points/colorFragment.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/points/colorFragment.wesl @@ -65,7 +65,26 @@ fn fs(in: VSOut) -> @location(0) vec4 { // gated by realOnlyMode are culled at the vertex stage too, so this // fragment never sees them. var alpha = exp(-r2 * 4.0); - let rgb = in.shaded.rgb; + // Warm-core / cool-rim gradient reconstruction. 'in.shaded.rgb' is the + // CORE endpoint colour and 'in.rimDelta' is the pre-differenced + // (rim - core) delta — both baked per-instance in the vertex stage + // (see io.wesl's 'rimDelta' field comment for why the ramp evaluation + // is hoisted there instead of living here). 'r2' is the ELLIPTIC + // squared radius from the transform above — already axis-ratio + // corrected — so 'sqrt(r2)' is the normalised galactocentric radius + // in the galaxy's own plane, the same quantity the procedural-disk + // pass reads as 'length(in.uv)'. + // + // The match is not exact, and this is a known, accepted approximation + // rather than a claim of agreement: the point sprite's billboard is + // padded relative to the galaxy's true apparent extent (see + // 'PerVertex.radiusMpc' in io.wesl), while the disk impostor's 'r = 1' + // sits at the disk's true edge. So the same gradient reads somewhat + // radially compressed on the point relative to the disk. Because the + // two passes are cross-faded over the 8-14 px band rather than + // swapped instantly, this reads as the gradient gaining definition on + // approach rather than as a seam. + let rgb = in.shaded.rgb + in.rimDelta * sqrt(r2); // ── Source fade-in ───────────────────────────────────────────────────────── // diff --git a/src/services/gpu/shaders/galaxyCatalog/points/io.wesl b/src/services/gpu/shaders/galaxyCatalog/points/io.wesl index eae2be66c..90afa2d50 100644 --- a/src/services/gpu/shaders/galaxyCatalog/points/io.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/points/io.wesl @@ -306,11 +306,13 @@ struct VSOut { @location(0) uv: vec2, // 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. + // .rgb = rampCore(restColorIndex) × magenta highlight × intensity — + // the CORE endpoint of the warm-core / cool-rim gradient (see + // 'rimDelta' below for the rim endpoint). intensity already + // folds in the magnitude clamp, brightness slider, Malmquist + // mode-2/3/4 weights, depth fade, and the procedural-disk + // crossfade-out. 'colorFragment.wesl' adds in + // 'rimDelta * sqrt(r2)' before writing '(rgb * alpha, alpha)'. // .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. @@ -326,4 +328,33 @@ struct VSOut { // 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, + + // Warm-core / cool-rim colour gradient, pre-differenced: + // '(rampRim(colorIndex) - rampCore(colorIndex)) * highlightTint * + // intensity' — i.e. the already-fully-modulated RGB difference + // between the disk rim and the core. 'colorFragment.wesl' reconstructs + // the per-fragment colour as 'shaded.rgb + rimDelta * sqrt(r2)', a + // single fused multiply-add with no per-fragment 'ramp' call. + // + // Why hoist both ramp evaluations to the vertex stage instead of + // calling 'rampCore'/'rampRim' per fragment the way the procedural- + // disk pass does: the points pass is fragment/blend-bound (its + // billboards cover most of the screen at the large tiers — see + // docs/RENDERER.md), so evaluating the colour ramp twice per COVERED + // PIXEL would be a real, measurable cost. The vertex stage runs 3 + // times per instance (once per triangle corner) rather than once per + // covered pixel, so paying for both ramp evaluations there is nearly + // free by comparison. The procedural-disk pass has far fewer + // instances on screen at once, so it evaluates 'rampCore'/'rampRim' + // directly per fragment (see 'proceduralDisks/fragment.wesl') without + // needing this hoist. + // + // The two spellings agree exactly: 'colorIndex' is a per-instance + // constant (flat across all 3 vertices and every fragment of one + // galaxy's billboard), so 'coreRgb + (rimRgb - coreRgb) * r' is just + // the algebraic identity for 'mix(coreRgb, rimRgb, r)' — nothing about + // WHERE the two endpoint colours were evaluated changes that. Hoisting + // to the vertex stage moves the ramp calls earlier, it doesn't change + // the math the fragment ultimately performs. + @location(4) @interpolate(flat) rimDelta: vec3, }; diff --git a/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl b/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl index 42864f9cb..7c8bebdcb 100644 --- a/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl @@ -32,7 +32,8 @@ import package::lib::camera::worldToClip; import package::lib::billboard::triCorner; import package::lib::billboard::expandBillboardScreen; import package::lib::sourceUniforms::SourceUniforms; -import package::lib::colorIndex::ramp; +import package::lib::colorIndex::rampCore; +import package::lib::colorIndex::rampRim; import package::lib::selectionEncoding::packSelection; import package::lib::focusUniforms::FocusUniforms; import package::lib::focusUniforms::focusAlphaMultiplier; @@ -131,6 +132,7 @@ fn vs( earlyOut.shaded = vec4(0.0, 0.0, 0.0, 1.0); earlyOut.instanceIdx = myPacked; earlyOut.paRotation = vec2(1.0, 0.0); + earlyOut.rimDelta = vec3(0.0); return earlyOut; } @@ -240,15 +242,25 @@ fn vs( * focusMult; // 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. + // intensity scalar straight into out.shaded.rgb and out.rimDelta. + // 'out.shaded.rgb' carries the CORE endpoint of the warm-core / + // cool-rim gradient (see 'lib/colorIndex.wesl'); 'out.rimDelta' + // carries the already-fully-modulated RIM-minus-CORE difference, so + // the fragment reconstructs the gradient with one fused multiply-add + // instead of evaluating the ramp itself (see the 'rimDelta' field + // comment in io.wesl for why that hoist is worth it here but not in + // the procedural-disk pass). .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); + let coreRgb = rampCore(p.colorIndex) * highlightTint * intensity; + let rimRgb = rampRim(p.colorIndex) * highlightTint * intensity; + out.shaded = vec4(coreRgb, safeAB); + out.rimDelta = rimRgb - coreRgb; // Invisibility cull: galaxies whose folded intensity falls below this // threshold contribute imperceptibly to the additive HDR target, so diff --git a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl index 9409dc64a..3774ea01b 100644 --- a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl @@ -26,13 +26,21 @@ // does, and the two passes agree on brightness at the 8-14 px crossfade // instead of only agreeing on hue. // -// Hue comes entirely from the per-galaxy colour-index ramp — the -// same ramp the points pass uses, so a galaxy's procedural-disk -// colour matches its companion point exactly. Earlier versions -// added warm-bulge / cool-disk tint shifts on top of the ramp -// colour; that made the procedural disk visibly diverge from the -// points pass (warmer at the centre, cooler at the rim) so it's -// been removed. Only the brightness profile remains. +// Hue is a warm-core / cool-rim gradient built from the SHARED +// 'rampCore' / 'rampRim' pair in 'lib/colorIndex.wesl' — the same two +// functions the points pass uses to fill 'VSOut.rimDelta' (see +// 'points/vertex.wesl'). An earlier version of this pass applied its +// own warm-bulge / cool-disk tint on top of the plain ramp colour, and +// that shift was independent of what the points pass did — the two +// passes visibly diverged at the crossfade boundary (warmer at centre, +// cooler at rim on one pass but not the other), so the per-pass tint +// was removed and only the flat ramp colour survived. Routing the +// gradient through 'rampCore'/'rampRim' brings the tint back without +// reintroducing that failure mode: both passes now derive their +// endpoint colours from the exact same two function calls over the +// exact same 't', so there is no second copy of the gradient math left +// to drift out of sync. Only the brightness profile is pass-local; the +// colour math is fully shared. // // Final alpha is the combined brightness x crossfadeAlpha so the // impostor fades in cleanly across the 8-14 px transition band. @@ -48,7 +56,8 @@ // actually references end up emitted. import package::galaxyCatalog::proceduralDisks::io::VsOut; -import package::lib::colorIndex::ramp; +import package::lib::colorIndex::rampCore; +import package::lib::colorIndex::rampRim; // 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). The @@ -64,11 +73,12 @@ const DISK_SCALE = 0.5; const BULGE_WEIGHT = 0.85; const DISK_WEIGHT = 0.15; -// 'ramp(t)' lives in 'lib/colorIndex.wesl' — imported above. Sharing -// the function with the points pass guarantees procedural-disk -// colour matches points colour exactly at any colour-index value, so -// the disk impostor fade-in doesn't introduce a visible cross-LOD -// hue shift. +// 'rampCore(t)' / 'rampRim(t)' live in 'lib/colorIndex.wesl' — imported +// above. Sharing the pair with the points pass guarantees the +// procedural-disk gradient's two endpoint colours match the points +// pass's endpoint colours exactly at any colour-index value, so the +// disk impostor fade-in doesn't introduce a visible cross-LOD hue +// shift. @fragment fn fs(in: VsOut) -> @location(0) vec4 { @@ -79,12 +89,11 @@ fn fs(in: VsOut) -> @location(0) vec4 { let disk = exp(-r / DISK_SCALE); let intensity = (bulge * BULGE_WEIGHT + disk * DISK_WEIGHT) * in.sbAmp; - // Colour: ramp hue only, no per-component tint shifts. See the - // fragment-stage header comment above for why the warm-bulge / cool- - // disk tints were removed (they made this pass visibly diverge from - // the points pass at the crossfade boundary). - let base = ramp(in.colourIndex); - let tinted = base; + // Colour: warm-core / cool-rim gradient in colour-index space, mixed + // by normalised radius 'r' (0 at centre, 1 at the disk edge). See the + // fragment-stage header comment above for why this reads from the + // shared 'rampCore'/'rampRim' pair rather than an ad-hoc per-pass tint. + let tinted = mix(rampCore(in.colourIndex), rampRim(in.colourIndex), r); // Soft edge fade. We're outputting LINEAR colour into an rgba16float // HDR target; the compositor's tone-map curve converts to sRGB later diff --git a/src/services/gpu/shaders/lib/colorIndex.wesl b/src/services/gpu/shaders/lib/colorIndex.wesl index 1c02f2427..b38a37828 100644 --- a/src/services/gpu/shaders/lib/colorIndex.wesl +++ b/src/services/gpu/shaders/lib/colorIndex.wesl @@ -49,6 +49,50 @@ // argument order. So 'select(blueWhite, whiteRed, t > 1.0)' returns // blueWhite for t ≤ 1 and whiteRed for t > 1. Easy to invert; worth // the comment. +// +// ── warm-core / cool-rim gradient ──────────────────────────────────── +// +// This is the 'future colour-related helper' the module header above +// anticipates: a real galaxy isn't one flat colour end to end — the +// bulge is old, red, metal-rich stars, while the spiral arms carry +// young, blue O/B stars still lit by recent star formation. 'rampCore' +// and 'rampRim' give the two passes that draw a galaxy (points, +// procedural-disk) a shared pair of endpoint colours to mix between, +// so the radial gradient is expressed the SAME way 'ramp' itself is +// expressed: as a shift in colour-INDEX space, not as an ad-hoc RGB +// tint layered on top. The alternative — nudging RGB channels directly +// at the core or rim — was tried and reverted (see the procedural-disk +// fragment shader's header) precisely because an RGB tint doesn't +// respect the ramp's shape: it can shift an intrinsically red +// elliptical toward blue at the rim, which is physically backwards. +// Shifting the ramp's INPUT instead guarantees a red galaxy just gets +// a *redder* core and a *less-red* rim — it never crosses into a hue +// the galaxy's catalogued colour index doesn't support. +// +// Why both passes calling 'rampCore'/'rampRim' (rather than each +// re-deriving '±DISK_TINT_SPREAD * 0.5' locally) matters: it's the same +// argument the module header already makes for 'ramp' itself — two +// independent copies of an offset constant are an editing trap, and +// centralising the two derived lookups here makes cross-LOD drift +// between the points pass and the procedural-disk pass structurally +// impossible rather than merely policed by convention. +// +// Why 0.4: real SDSS g−r colour indices run roughly 0.75 in a galaxy's +// bulge and 0.35 in its spiral arms — a difference of about 0.4. The +// split is symmetric around the catalogued colour index (±0.2, i.e. +// ±DISK_TINT_SPREAD * 0.5) rather than skewed toward one endpoint, so +// the galaxy's INTEGRATED hue — what you'd get averaging core and rim +// — stays close to its catalogued g−r value; the gradient adds internal +// structure without changing the galaxy's overall colour identity. +// +// 'ramp' saturates its input at both ends (t ≤ 0 is fully blue, t > 2 +// is fully red — see the piecewise description above), so for a +// colour index already near either extreme, 'rampCore' and 'rampRim' +// converge to the same clamped colour and the gradient visibly +// flattens out. That's intended, not a bug: a t=0 quasar has no +// redder-bulge structure to reveal, so there's nothing physical for +// the gradient to express, and self-limiting at the ramp's clamps +// keeps the two endpoints from ever producing an out-of-range tint. import package::lib::math::saturate; @@ -66,3 +110,20 @@ fn ramp(t: f32) -> vec3 { // Remember: select(falseVal, trueVal, condition). return select(blueWhite, whiteRed, t > 1.0); } + +// Total core-to-rim spread in colour-index units — see the header +// section above ('warm-core / cool-rim gradient') for why 0.4 and why +// the split below is symmetric around 't' rather than one-sided. +const DISK_TINT_SPREAD = 0.4; + +// The warm endpoint: shifts 't' toward red (higher colour index) by +// half the spread, matching a galaxy's old-star bulge. +fn rampCore(t: f32) -> vec3 { + return ramp(t + DISK_TINT_SPREAD * 0.5); +} + +// The cool endpoint: shifts 't' toward blue (lower colour index) by +// half the spread, matching a galaxy's young-star spiral arms. +fn rampRim(t: f32) -> vec3 { + return ramp(t - DISK_TINT_SPREAD * 0.5); +} From c6dad6d2c8fced92ce8a65467332200c9f0b7d84 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 18:42:47 +0200 Subject: [PATCH 09/12] perf(galaxies): fold the colour gradient back into the existing varying budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The radial gradient forwarded a precomputed rim-minus-core delta from the points vertex stage, which needed a fifth @location on VSOut. That cost +1.5 ms on the point-sprites slot (~21% at the full-survey scenario, 60 frames, alternating A/B) against a per-fragment saving of one sqrt and one fused multiply-add. Varying count is a real budget here: each @location is a per-fragment parameter fetch from tile memory paid for every covered pixel, and this pass is fragment/blend-bound with billboards covering most of the screen at the large tiers. Two changes remove the slot. 'ramp' is piecewise linear in its saturated parameter, so mixing two lookups that land on the same segment equals one lookup at the mixed argument: mix(ramp(t+d), ramp(t-d), r) == ramp(t + d - 2*d*r). The rampCore/rampRim pair collapses to a single 'rampRadial(t, r)'. No pass needs to forward an endpoint delta any more, and the procedural-disk pass drops from two ramp evaluations per fragment to one. VSOut then repacks rather than extends. A WGSL @location is a 4-component slot, so a lone vec2 wastes two components. Grouping by what actually consumes the values together yields 'ellipse' (paCos, paSin, safeAB — exactly the three inputs to the elliptical-mask transform) and 'instanceTint' (the per-instance modulator plus the ramp input it scales). VSOut is back to 4 locations, its count before the gradient existed. The ramp evaluation necessarily moves to the fragment stage: its argument depends on the fragment's radius, so it could not live in the vertex stage under any spelling. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../galaxyCatalog/pointVertexLayout.ts | 2 +- .../galaxyCatalog/points/colorFragment.wesl | 67 +++++----- .../gpu/shaders/galaxyCatalog/points/io.wesl | 114 +++++++++++------- .../shaders/galaxyCatalog/points/vertex.wesl | 59 ++++----- .../proceduralDisks/fragment.wesl | 47 ++++---- src/services/gpu/shaders/lib/colorIndex.wesl | 113 ++++++++++------- 6 files changed, 228 insertions(+), 174 deletions(-) diff --git a/src/services/gpu/renderers/galaxyCatalog/pointVertexLayout.ts b/src/services/gpu/renderers/galaxyCatalog/pointVertexLayout.ts index eb9d505f3..e6d7c8d8a 100644 --- a/src/services/gpu/renderers/galaxyCatalog/pointVertexLayout.ts +++ b/src/services/gpu/renderers/galaxyCatalog/pointVertexLayout.ts @@ -49,7 +49,7 @@ const AXIS_RATIO_BYTE_OFFSET = 20; /** * Slots 6/7: cos/sin of the negated east-of-north position angle — - * the exact pair the shader forwards as `paRotation`, pre-baked. + * the exact pair the shader forwards as `ellipse.xy`, pre-baked. */ const PA_COS_SIN_BYTE_OFFSET = 24; diff --git a/src/services/gpu/shaders/galaxyCatalog/points/colorFragment.wesl b/src/services/gpu/shaders/galaxyCatalog/points/colorFragment.wesl index d690461d9..9bd733096 100644 --- a/src/services/gpu/shaders/galaxyCatalog/points/colorFragment.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/points/colorFragment.wesl @@ -11,11 +11,14 @@ // // 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. +// varyings by the vertex stage (see 'instanceTint' in io.wesl), except +// the colour ramp itself, which this fragment evaluates directly via +// 'rampRadial'. 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::galaxyCatalog::points::io::VSOut; +import package::lib::colorIndex::rampRadial; import package::lib::fadeUniforms::FadeUniforms; import package::lib::fadeUniforms::applyFade; @@ -42,15 +45,15 @@ fn fs(in: VSOut) -> @location(0) vec4 { // // The cs/sn pair is pre-computed in the vertex stage and flat- // interpolated, saving millions of trig calls per frame. safeAB - // (the validated ellipse minor-axis ratio) rides in the alpha channel - // of in.shaded, so the fragment does zero per-pixel axis-ratio work. - let cs = in.paRotation.x; - let sn = in.paRotation.y; + // (the validated ellipse minor-axis ratio) rides alongside them in + // 'in.ellipse.z', so the fragment does zero per-pixel axis-ratio work. + let cs = in.ellipse.x; + let sn = in.ellipse.y; let rotated = vec2( cs * in.uv.x - sn * in.uv.y, sn * in.uv.x + cs * in.uv.y, ); - let elliptic = vec2(rotated.x, rotated.y / in.shaded.w); + let elliptic = vec2(rotated.x, rotated.y / in.ellipse.z); let r2 = dot(elliptic, elliptic); // ──────────────────────────────────────────────────────────────────────── @@ -61,30 +64,34 @@ fn fs(in: VSOut) -> @location(0) vec4 { // 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. + // 'in.instanceTint.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); - // Warm-core / cool-rim gradient reconstruction. 'in.shaded.rgb' is the - // CORE endpoint colour and 'in.rimDelta' is the pre-differenced - // (rim - core) delta — both baked per-instance in the vertex stage - // (see io.wesl's 'rimDelta' field comment for why the ramp evaluation - // is hoisted there instead of living here). 'r2' is the ELLIPTIC - // squared radius from the transform above — already axis-ratio - // corrected — so 'sqrt(r2)' is the normalised galactocentric radius - // in the galaxy's own plane, the same quantity the procedural-disk - // pass reads as 'length(in.uv)'. + // Warm-core / cool-rim gradient. 'r2' is the ELLIPTIC squared radius + // from the transform above — already axis-ratio corrected — so + // 'sqrt(r2)' is the normalised galactocentric radius in the galaxy's + // own plane, the same quantity the procedural-disk pass reads as + // 'length(in.uv)'. 'rampRadial' takes that radius alongside the + // galaxy's colour index (forwarded unchanged in 'in.instanceTint.w') + // and returns the per-fragment ramp colour directly — see + // 'lib/colorIndex.wesl' for why a single radius-shifted lookup is the + // form used, and io.wesl's 'VSOut has no fifth @location' comment for + // why that call lives here instead of in the vertex stage. The + // result is then scaled by the per-instance modulator to get the + // final colour. // - // The match is not exact, and this is a known, accepted approximation - // rather than a claim of agreement: the point sprite's billboard is - // padded relative to the galaxy's true apparent extent (see - // 'PerVertex.radiusMpc' in io.wesl), while the disk impostor's 'r = 1' - // sits at the disk's true edge. So the same gradient reads somewhat - // radially compressed on the point relative to the disk. Because the - // two passes are cross-faded over the 8-14 px band rather than - // swapped instantly, this reads as the gradient gaining definition on - // approach rather than as a seam. - let rgb = in.shaded.rgb + in.rimDelta * sqrt(r2); + // The match to the procedural-disk pass's own gradient is not exact, + // and this is a known, accepted approximation rather than a claim of + // agreement: the point sprite's billboard is padded relative to the + // galaxy's true apparent extent (see 'PerVertex.radiusMpc' in + // io.wesl), while the disk impostor's 'r = 1' sits at the disk's true + // edge. So the same gradient reads somewhat radially compressed on + // the point relative to the disk. Because the two passes are + // cross-faded over the 8-14 px band rather than swapped instantly, + // this reads as the gradient gaining definition on approach rather + // than as a seam. + let rgb = in.instanceTint.rgb * rampRadial(in.instanceTint.w, sqrt(r2)); // ── Source fade-in ───────────────────────────────────────────────────────── // diff --git a/src/services/gpu/shaders/galaxyCatalog/points/io.wesl b/src/services/gpu/shaders/galaxyCatalog/points/io.wesl index 90afa2d50..f40e0bba8 100644 --- a/src/services/gpu/shaders/galaxyCatalog/points/io.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/points/io.wesl @@ -176,8 +176,8 @@ struct Uniforms { // complementary fade-OUT on the points pass, both passes would be // fully present inside the band — a 'double-bright donut'. The // vertex stage folds '1 - smoothstep(start, end, apparentDiameterPx)' - // into 'out.shaded.rgb', so the fragment reads no per-pixel state for - // the crossfade. + // into 'out.instanceTint.rgb', so the fragment reads no per-pixel + // state for the crossfade. pxFadeStart: f32, pxFadeEnd: f32, // 0 = visual pass, 1 = pick pass. Set in-place by pickRenderer @@ -305,56 +305,80 @@ struct VSOut { // circle/ellipse falloff. @location(0) uv: vec2, - // Fully-shaded per-instance appearance packed into one vec4. - // .rgb = rampCore(restColorIndex) × magenta highlight × intensity — - // the CORE endpoint of the warm-core / cool-rim gradient (see - // 'rimDelta' below for the rim endpoint). intensity already - // folds in the magnitude clamp, brightness slider, Malmquist - // mode-2/3/4 weights, depth fade, and the procedural-disk - // crossfade-out. 'colorFragment.wesl' adds in - // 'rimDelta * sqrt(r2)' before writing '(rgb * alpha, alpha)'. - // .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. + // Per-instance colour modulator, packed into one vec4. + // .rgb = highlightTint × intensity — the magenta-highlight tint and + // the fully-folded brightness scalar ONLY. The ramp colour + // itself is NOT baked in here: 'colorFragment.wesl' evaluates + // 'rampRadial' per fragment (see 'lib/colorIndex.wesl'), because + // the ramp's argument depends on the fragment's radius within + // the billboard and so cannot be a flat per-instance constant. + // intensity already folds in the magnitude clamp, brightness + // slider, Malmquist mode-2/3/4 weights, depth fade, and the + // procedural-disk crossfade-out. + // .w = colorIndex — the galaxy's rest-frame, K-corrected colour + // index, forwarded unchanged from 'PerVertex.colorIndex' so the + // fragment stage has the ramp's 't' argument without needing + // its own attribute slot. // Flat-interpolated — all 6 vertices of an instance share the value. - @location(1) @interpolate(flat) shaded: vec4, + @location(1) @interpolate(flat) instanceTint: 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(2) @interpolate(flat) instanceIdx: u32, - // Pre-computed (cos, sin) of the position-angle rotation. Computed - // once per primitive in 'vs' instead of per fragment, saving millions - // of trig calls per frame. Packed into a vec2 at one location. - @location(3) @interpolate(flat) paRotation: vec2, - - // Warm-core / cool-rim colour gradient, pre-differenced: - // '(rampRim(colorIndex) - rampCore(colorIndex)) * highlightTint * - // intensity' — i.e. the already-fully-modulated RGB difference - // between the disk rim and the core. 'colorFragment.wesl' reconstructs - // the per-fragment colour as 'shaded.rgb + rimDelta * sqrt(r2)', a - // single fused multiply-add with no per-fragment 'ramp' call. + // Per-instance ellipse-mask parameters, packed into one vec3 at one + // @location. A WGSL '@location' is always a 4-component slot on the + // wire — a lone vec2 or a lone f32 at its own location still consumes + // a full interpolator, wasting the remaining 2 or 3 components. + // Packing all three ellipse-mask values into one vec3 therefore costs + // exactly one location — the same as a vec2 alone would — while + // giving the fragment everything the mask transform needs. // - // Why hoist both ramp evaluations to the vertex stage instead of - // calling 'rampCore'/'rampRim' per fragment the way the procedural- - // disk pass does: the points pass is fragment/blend-bound (its - // billboards cover most of the screen at the large tiers — see - // docs/RENDERER.md), so evaluating the colour ramp twice per COVERED - // PIXEL would be a real, measurable cost. The vertex stage runs 3 - // times per instance (once per triangle corner) rather than once per - // covered pixel, so paying for both ramp evaluations there is nearly - // free by comparison. The procedural-disk pass has far fewer - // instances on screen at once, so it evaluates 'rampCore'/'rampRim' - // directly per fragment (see 'proceduralDisks/fragment.wesl') without - // needing this hoist. + // .xy = 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. + // .z = safeAB, the validated ellipse minor-axis ratio (clamped to + // [0.05, 1.0], defaulted to 1.0 for invalid/NaN inputs). // - // The two spellings agree exactly: 'colorIndex' is a per-instance - // constant (flat across all 3 vertices and every fragment of one - // galaxy's billboard), so 'coreRgb + (rimRgb - coreRgb) * r' is just - // the algebraic identity for 'mix(coreRgb, rimRgb, r)' — nothing about - // WHERE the two endpoint colours were evaluated changes that. Hoisting - // to the vertex stage moves the ramp calls earlier, it doesn't change - // the math the fragment ultimately performs. - @location(4) @interpolate(flat) rimDelta: vec3, + // The grouping isn't arbitrary packing for its own sake: all three + // values are consumed together, in the same few lines of the + // elliptical-mask transform in 'colorFragment.wesl' (rotate the UV by + // (cos, sin), then divide by the minor-axis ratio) — they're one + // conceptual unit ('the ellipse this billboard is masked to'), so one + // field name covering all three reads truer than two unrelated fields + // that happened to share a struct. + @location(3) @interpolate(flat) ellipse: vec3, }; + +// ── Why VSOut has no fifth @location for the colour gradient ───────── +// +// A radial warm-core / cool-rim colour gradient (see 'lib/colorIndex. +// wesl') needs the fragment to know both the galaxy's colour index and +// its position within the billboard — naively that argues for +// forwarding two pre-evaluated endpoint colours and mixing by radius, +// which would need a fifth @location. +// +// That would cost real, measured ground: a fifth @location measured +// +1.5 ms on this pass (~21%) at the full-survey scenario, against a +// per-fragment saving of one sqrt and one fused multiply-add — each +// additional @location is a per-fragment parameter fetch from tile +// memory paid for every covered pixel, and this pass is +// fragment/blend-bound with billboards covering most of the screen at +// the large tiers (see docs/RENDERER.md). +// +// The fix is not a workaround, it's the correct simplification: 'ramp' +// is piecewise linear in its saturated parameter, so mixing two +// endpoint lookups is algebraically identical to ONE lookup at the +// mixed argument (the identity is spelled out in 'lib/colorIndex.wesl'). +// Given that, the fragment can call 'rampRadial(colorIndex, r)' itself +// with no precomputed endpoints at all — it only needs 'colorIndex' +// (now riding in 'instanceTint.w', a slot that was already allocated +// and had room) and 'r' (already computed from 'uv' for the elliptical +// mask). Evaluating the ramp per fragment costs one 'ramp' call per +// covered pixel — MUCH cheaper than a whole interpolator slot, and +// unavoidable regardless: the argument depends on the fragment's +// radius, so this evaluation could never have stayed hoisted in the +// vertex stage even if varying budget were free. 'VSOut' therefore +// still has exactly the 4 locations (0-3) it had before the gradient +// existed. diff --git a/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl b/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl index 7c8bebdcb..bce388742 100644 --- a/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/points/vertex.wesl @@ -32,8 +32,6 @@ import package::lib::camera::worldToClip; import package::lib::billboard::triCorner; import package::lib::billboard::expandBillboardScreen; import package::lib::sourceUniforms::SourceUniforms; -import package::lib::colorIndex::rampCore; -import package::lib::colorIndex::rampRim; import package::lib::selectionEncoding::packSelection; import package::lib::focusUniforms::FocusUniforms; import package::lib::focusUniforms::focusAlphaMultiplier; @@ -129,10 +127,9 @@ fn vs( var earlyOut: VSOut; earlyOut.clip = vec4(2.0, 2.0, 2.0, 1.0); earlyOut.uv = corner; - earlyOut.shaded = vec4(0.0, 0.0, 0.0, 1.0); + earlyOut.instanceTint = vec4(0.0, 0.0, 0.0, 0.0); earlyOut.instanceIdx = myPacked; - earlyOut.paRotation = vec2(1.0, 0.0); - earlyOut.rimDelta = vec3(0.0); + earlyOut.ellipse = vec3(1.0, 0.0, 1.0); return earlyOut; } @@ -191,8 +188,9 @@ fn vs( // The old apparent-magnitude clamp is gone: p.sbAmp is the baked physical // surface-brightness amplitude and u.galaxySbScale is the live HDR gain that // replaced the hardcoded GALAXY_SB_SCALE const. All factors are per-instance - // constants, so the fragment reads out.shaded.rgb directly — no per-pixel - // multiply. + // constants, so the fragment reads out.instanceTint.rgb directly — no + // per-pixel multiply for the brightness modulator (the ramp colour it + // multiplies against IS evaluated per-fragment; see colorFragment.wesl). 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); @@ -241,26 +239,28 @@ fn vs( * crossfadeOut * focusMult; - // Bake the K-corrected ramp colour, the magenta highlight, and the - // intensity scalar straight into out.shaded.rgb and out.rimDelta. - // 'out.shaded.rgb' carries the CORE endpoint of the warm-core / - // cool-rim gradient (see 'lib/colorIndex.wesl'); 'out.rimDelta' - // carries the already-fully-modulated RIM-minus-CORE difference, so - // the fragment reconstructs the gradient with one fused multiply-add - // instead of evaluating the ramp itself (see the 'rimDelta' field - // comment in io.wesl for why that hoist is worth it here but not in - // the procedural-disk pass). .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. + // Bake the per-instance colour MODULATOR (magenta highlight x + // intensity) into out.instanceTint.rgb, and forward the galaxy's + // colour index unchanged in out.instanceTint.w. The ramp colour + // itself is NOT evaluated here — 'colorFragment.wesl' calls + // 'rampRadial(instanceTint.w, r)' per fragment instead, because the + // warm-core / cool-rim gradient's argument depends on the fragment's + // radius within the billboard and so cannot be baked as a flat + // per-instance constant (see the 'VSOut has no fifth @location' + // comment in io.wesl for why moving the ramp call to the fragment + // stage is the right trade rather than a regression). + // + // safeAB — the pre-computed ellipse-mask coefficient — is 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. It's computed here (rather than in the ellipse + // packing below) because it's derived from 'p.axisRatio', which is + // already in scope at this point in the function. 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); - let coreRgb = rampCore(p.colorIndex) * highlightTint * intensity; - let rimRgb = rampRim(p.colorIndex) * highlightTint * intensity; - out.shaded = vec4(coreRgb, safeAB); - out.rimDelta = rimRgb - coreRgb; + out.instanceTint = vec4(highlightTint * intensity, p.colorIndex); // Invisibility cull: galaxies whose folded intensity falls below this // threshold contribute imperceptibly to the additive HDR target, so @@ -290,11 +290,14 @@ fn vs( // The visual 'fs' ignores this field. out.instanceIdx = myPacked; - // Forward the pre-baked (cos, sin) of the position-angle rotation — - // computed once at upload in 'buildPointInterleavedBuffer' (negation - // for the east-of-north → screen-UV convention already folded in), so - // neither this stage nor the fragment pays any trig. - out.paRotation = p.paCosSin; + // Pack the ellipse-mask parameters: the pre-baked (cos, sin) of the + // position-angle rotation — computed once at upload in + // 'buildPointInterleavedBuffer' (negation for the east-of-north → + // screen-UV convention already folded in), so neither this stage nor + // the fragment pays any trig — alongside 'safeAB' (computed above + // from 'p.axisRatio'). See the 'ellipse' field comment in io.wesl for + // why these three values share one @location. + out.ellipse = vec3(p.paCosSin, safeAB); return out; } diff --git a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl index 3774ea01b..4444831f6 100644 --- a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl @@ -26,21 +26,18 @@ // does, and the two passes agree on brightness at the 8-14 px crossfade // instead of only agreeing on hue. // -// Hue is a warm-core / cool-rim gradient built from the SHARED -// 'rampCore' / 'rampRim' pair in 'lib/colorIndex.wesl' — the same two -// functions the points pass uses to fill 'VSOut.rimDelta' (see -// 'points/vertex.wesl'). An earlier version of this pass applied its -// own warm-bulge / cool-disk tint on top of the plain ramp colour, and -// that shift was independent of what the points pass did — the two -// passes visibly diverged at the crossfade boundary (warmer at centre, -// cooler at rim on one pass but not the other), so the per-pass tint -// was removed and only the flat ramp colour survived. Routing the -// gradient through 'rampCore'/'rampRim' brings the tint back without -// reintroducing that failure mode: both passes now derive their -// endpoint colours from the exact same two function calls over the -// exact same 't', so there is no second copy of the gradient math left -// to drift out of sync. Only the brightness profile is pass-local; the -// colour math is fully shared. +// Hue is a warm-core / cool-rim gradient from the SHARED 'rampRadial' +// function in 'lib/colorIndex.wesl' — the same function the points +// pass calls in 'colorFragment.wesl'. Sharing the function is +// load-bearing, not incidental: if this pass computed its own radial +// tint independently of the points pass, the two would disagree across +// the 8-14 px crossfade and the galaxy would visibly shift hue (warmer +// at centre / cooler at rim on one pass but not the other) as the +// impostor faded in. Deriving both passes' per-radius colour from the +// same call over the same 't' and 'r' makes that class of divergence +// structurally impossible, rather than merely avoided by convention. +// Only the brightness profile is pass-local; the colour math is fully +// shared. // // Final alpha is the combined brightness x crossfadeAlpha so the // impostor fades in cleanly across the 8-14 px transition band. @@ -56,8 +53,7 @@ // actually references end up emitted. import package::galaxyCatalog::proceduralDisks::io::VsOut; -import package::lib::colorIndex::rampCore; -import package::lib::colorIndex::rampRim; +import package::lib::colorIndex::rampRadial; // 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). The @@ -73,12 +69,11 @@ const DISK_SCALE = 0.5; const BULGE_WEIGHT = 0.85; const DISK_WEIGHT = 0.15; -// 'rampCore(t)' / 'rampRim(t)' live in 'lib/colorIndex.wesl' — imported -// above. Sharing the pair with the points pass guarantees the -// procedural-disk gradient's two endpoint colours match the points -// pass's endpoint colours exactly at any colour-index value, so the -// disk impostor fade-in doesn't introduce a visible cross-LOD hue -// shift. +// 'rampRadial(t, r)' lives in 'lib/colorIndex.wesl' — imported above. +// Sharing it with the points pass guarantees the procedural-disk +// gradient matches the points pass's gradient exactly at any given +// colour index and radius, so the disk impostor fade-in doesn't +// introduce a visible cross-LOD hue shift. @fragment fn fs(in: VsOut) -> @location(0) vec4 { @@ -89,11 +84,11 @@ fn fs(in: VsOut) -> @location(0) vec4 { let disk = exp(-r / DISK_SCALE); let intensity = (bulge * BULGE_WEIGHT + disk * DISK_WEIGHT) * in.sbAmp; - // Colour: warm-core / cool-rim gradient in colour-index space, mixed + // Colour: warm-core / cool-rim gradient in colour-index space, shifted // by normalised radius 'r' (0 at centre, 1 at the disk edge). See the // fragment-stage header comment above for why this reads from the - // shared 'rampCore'/'rampRim' pair rather than an ad-hoc per-pass tint. - let tinted = mix(rampCore(in.colourIndex), rampRim(in.colourIndex), r); + // shared 'rampRadial' function rather than an ad-hoc per-pass tint. + let tinted = rampRadial(in.colourIndex, r); // Soft edge fade. We're outputting LINEAR colour into an rgba16float // HDR target; the compositor's tone-map curve converts to sRGB later diff --git a/src/services/gpu/shaders/lib/colorIndex.wesl b/src/services/gpu/shaders/lib/colorIndex.wesl index b38a37828..73c8592d6 100644 --- a/src/services/gpu/shaders/lib/colorIndex.wesl +++ b/src/services/gpu/shaders/lib/colorIndex.wesl @@ -55,44 +55,67 @@ // This is the 'future colour-related helper' the module header above // anticipates: a real galaxy isn't one flat colour end to end — the // bulge is old, red, metal-rich stars, while the spiral arms carry -// young, blue O/B stars still lit by recent star formation. 'rampCore' -// and 'rampRim' give the two passes that draw a galaxy (points, -// procedural-disk) a shared pair of endpoint colours to mix between, -// so the radial gradient is expressed the SAME way 'ramp' itself is -// expressed: as a shift in colour-INDEX space, not as an ad-hoc RGB -// tint layered on top. The alternative — nudging RGB channels directly -// at the core or rim — was tried and reverted (see the procedural-disk -// fragment shader's header) precisely because an RGB tint doesn't -// respect the ramp's shape: it can shift an intrinsically red -// elliptical toward blue at the rim, which is physically backwards. -// Shifting the ramp's INPUT instead guarantees a red galaxy just gets -// a *redder* core and a *less-red* rim — it never crosses into a hue -// the galaxy's catalogued colour index doesn't support. +// young, blue O/B stars still lit by recent star formation. 'rampRadial' +// gives the two passes that draw a galaxy (points, procedural-disk) a +// single shared function of catalogued colour index 't' and normalised +// galactocentric radius 'r' (0 at the core, 1 at the rim), so the +// gradient is expressed the SAME way 'ramp' itself is expressed: as a +// shift in colour-INDEX space, not as an ad-hoc RGB tint layered on +// top. The alternative — nudging RGB channels directly at the core or +// rim — was tried and reverted (see the procedural-disk fragment +// shader's header) precisely because an RGB tint doesn't respect the +// ramp's shape: it can shift an intrinsically red elliptical toward +// blue at the rim, which is physically backwards. Shifting the ramp's +// INPUT instead guarantees a red galaxy just gets a *redder* core and +// a *less-red* rim — it never crosses into a hue the galaxy's +// catalogued colour index doesn't support. // -// Why both passes calling 'rampCore'/'rampRim' (rather than each -// re-deriving '±DISK_TINT_SPREAD * 0.5' locally) matters: it's the same -// argument the module header already makes for 'ramp' itself — two -// independent copies of an offset constant are an editing trap, and -// centralising the two derived lookups here makes cross-LOD drift -// between the points pass and the procedural-disk pass structurally -// impossible rather than merely policed by convention. +// 'rampRadial's argument runs from 't + 0.2' at the core (redder, +// old-star bulge) down to 't - 0.2' at the rim (bluer, young-star +// arms) — i.e. 't + DISK_TINT_SPREAD * (0.5 - r)'. Why a SINGLE lookup +// rather than two endpoint lookups mixed together (the alternative +// form, 'mix(rampCore(t), rampRim(t), r)'): 'ramp' is piecewise LINEAR +// in its saturated parameter 's', so mixing two lookups that land on +// the same linear segment is exactly equal to one lookup at the mixed +// argument — 'mix(ramp(t+d), ramp(t-d), r) == ramp(t + d - 2*d*r)'. +// Collapsing the two calls into one halves the per-fragment ramp cost. +// Evaluating the ramp once, at the already radius-shifted argument, +// also means no pass needs to precompute and forward an endpoint +// delta to its fragment stage — which is what keeps the points pass +// within its existing varying budget instead of carrying a dedicated +// interpolator slot for it (see 'points/io.wesl'). // -// Why 0.4: real SDSS g−r colour indices run roughly 0.75 in a galaxy's -// bulge and 0.35 in its spiral arms — a difference of about 0.4. The -// split is symmetric around the catalogued colour index (±0.2, i.e. -// ±DISK_TINT_SPREAD * 0.5) rather than skewed toward one endpoint, so -// the galaxy's INTEGRATED hue — what you'd get averaging core and rim -// — stays close to its catalogued g−r value; the gradient adds internal -// structure without changing the galaxy's overall colour identity. +// The one place this identity is inexact: 'ramp' switches segments at +// 't = 1' (see the piecewise description above), so for a galaxy whose +// catalogued colour index sits within 'DISK_TINT_SPREAD * 0.5' of 1.0, +// the shifted argument can cross that seam partway across the disk +// while the two-lookup form would have blended linearly across it +// instead. This is not a defect to route around: the one-lookup form +// is the BETTER-behaved of the two here, because it moves continuously +// along the ramp's own shape rather than linearly interpolating across +// a join between two different linear segments — a mix across the seam +// is the less physically-motivated of the two, not the more accurate +// one. So the single-lookup formulation is a straightforward +// improvement, not a compromise traded for fewer varyings. +// +// Why 0.4 ('DISK_TINT_SPREAD'): real SDSS g−r colour indices run +// roughly 0.75 in a galaxy's bulge and 0.35 in its spiral arms — a +// difference of about 0.4. The split is symmetric around the +// catalogued colour index (±0.2, i.e. ±DISK_TINT_SPREAD * 0.5) rather +// than skewed toward one endpoint, so the galaxy's INTEGRATED hue — +// what you'd get averaging core and rim — stays close to its +// catalogued g−r value; the gradient adds internal structure without +// changing the galaxy's overall colour identity. // // 'ramp' saturates its input at both ends (t ≤ 0 is fully blue, t > 2 // is fully red — see the piecewise description above), so for a -// colour index already near either extreme, 'rampCore' and 'rampRim' -// converge to the same clamped colour and the gradient visibly -// flattens out. That's intended, not a bug: a t=0 quasar has no -// redder-bulge structure to reveal, so there's nothing physical for -// the gradient to express, and self-limiting at the ramp's clamps -// keeps the two endpoints from ever producing an out-of-range tint. +// colour index already near either extreme, the core-shifted and +// rim-shifted arguments converge to the same clamped colour and the +// gradient visibly flattens out. That's intended, not a bug: a t=0 +// quasar has no redder-bulge structure to reveal, so there's nothing +// physical for the gradient to express, and self-limiting at the +// ramp's clamps keeps the shifted argument from ever producing an +// out-of-range tint. import package::lib::math::saturate; @@ -113,17 +136,19 @@ fn ramp(t: f32) -> vec3 { // Total core-to-rim spread in colour-index units — see the header // section above ('warm-core / cool-rim gradient') for why 0.4 and why -// the split below is symmetric around 't' rather than one-sided. +// the shift below is symmetric around 't' rather than one-sided. const DISK_TINT_SPREAD = 0.4; -// The warm endpoint: shifts 't' toward red (higher colour index) by -// half the spread, matching a galaxy's old-star bulge. -fn rampCore(t: f32) -> vec3 { - return ramp(t + DISK_TINT_SPREAD * 0.5); -} - -// The cool endpoint: shifts 't' toward blue (lower colour index) by -// half the spread, matching a galaxy's young-star spiral arms. -fn rampRim(t: f32) -> vec3 { - return ramp(t - DISK_TINT_SPREAD * 0.5); +// Warm-core / cool-rim galaxy tint, collapsed to one ramp lookup. +// 't' is the galaxy's catalogued (rest-frame, K-corrected) colour +// index; 'r' is the normalised galactocentric radius in the galaxy's +// own plane, 0 at the core and 1 at the rim. At r=0 the argument is +// 't + DISK_TINT_SPREAD * 0.5' (redder, matching the old-star bulge); +// at r=1 it's 't - DISK_TINT_SPREAD * 0.5' (bluer, matching the +// young-star spiral arms); values in between move linearly along the +// ramp's own argument rather than blending two separately-looked-up +// colours. See the header section above for the algebraic identity +// this rests on and the one place it's inexact. +fn rampRadial(t: f32, r: f32) -> vec3 { + return ramp(t + DISK_TINT_SPREAD * (0.5 - r)); } From a5eac688230b52575fcdc1e56fa296076aecc84e Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 18:49:18 +0200 Subject: [PATCH 10/12] fix(galaxies): make the colour ramp continuous and ground the gradient in measurements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to lib/colorIndex.wesl. ## The ramp had a hard seam at t = 1 Both halves shared 's = saturate(t * 0.5)', so the blue-to-white half only reached HALFWAY to white at t = 1 while the white-to-red half STARTED halfway to red. The function jumped by (+0.30, -0.05, -0.35) there and never passed through its own white anchor, despite the header calling white a shared join point and the transition continuous. Each half now spans its own unit interval, 'saturate(t)' and 'saturate(t - 1)', so both meet at white exactly. This stayed invisible for as long as each galaxy sampled the ramp at a single point: the step only separated one galaxy from another. The radial colour gradient sweeps one galaxy's argument across a range as the fragment radius varies, which turned the step into a hard-edged ring inside a single galaxy. A ramp sampled once per galaxy can hide a discontinuity; one swept per fragment cannot. Independent confirmation this was a bug and not a deliberate palette: UNKNOWN_COLOUR_RAMP_POSITION is documented as "a deliberately pale, neutral position", and 1.05 only renders pale under a continuous ramp. It rendered bright orange before. Expect a global recolouring. The endpoints are unchanged, but the mid-range becomes markedly whiter, and the large unknown-colour population at 1.05 goes from orange to pale cream. ## The spread was a guess; now it is derived DISK_TINT_SPREAD drops from 0.4 to 0.2. The old value rested on an unsourced claim about bulge and arm colours. Three independent measurements of the centre-to-edge gradient, all bluer-outward: - ~20,000 face-on SDSS-DR4 spirals: -0.006 mag/kpc in g-r, so about 0.09 mag over a ~15 kpc disk radius. - SDSS early-types: d(g-r)/d log r of -0.11 to -0.16 mag/dex, about 0.11 to 0.16 mag over the ~1 dex the visible disk spans. - Bulge-versus-disc component colours: about 0.23 mag in g-r. Taking 0.15 mag. The constant is in RAMP-POSITION units, not magnitudes: colourIndex.ts remaps each source's raw colour onto 0..2, so a magnitude is worth 2/(rangeMax-rangeMin) ramp units, which is per-source — 2MRS 5.00, DESI 2.86, SDSS and Famous 1.33, Milliquas 1.00, GLADE 0.67. At the median factor, 0.15 mag gives 0.2. Known simplification, recorded in the file: one shared ramp-space constant against per-source conversion factors means the effective physical gradient differs by catalog, roughly 0.04 mag for 2MRS up to 0.30 mag for GLADE. Making it exact needs a per-source spread derived from each colourSpec range and that band's own measured gradient. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/gpu/shaders/lib/colorIndex.wesl | 81 ++++++++++++++------ 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/src/services/gpu/shaders/lib/colorIndex.wesl b/src/services/gpu/shaders/lib/colorIndex.wesl index 73c8592d6..ccdeee213 100644 --- a/src/services/gpu/shaders/lib/colorIndex.wesl +++ b/src/services/gpu/shaders/lib/colorIndex.wesl @@ -36,9 +36,11 @@ // 1 < t ≤ 2 → whiteRed blend, parameter in (0.5, 1] // t > 2 → fully red (M-type stars, red galaxies) // -// Both blends share the same 's = saturate(t * 0.5)' parameter so the -// transition is continuous and uses the same 0→1 interpolation range -// in each half. Anchor colours: +// The blue-white half is parameterised by 'saturate(t)' over t in +// [0, 1]; the white-red half by 'saturate(t - 1)' over t in [1, 2]. +// Each half therefore spans its own full 0→1 interpolation range, and +// both reach the shared white anchor exactly at t = 1, which is what +// makes the ramp continuous there. Anchor colours: // // blue = (0.4, 0.6, 1.0) — hot blue // white = (1.0, 0.95, 0.8) — warm white (shared join point) @@ -50,6 +52,14 @@ // blueWhite for t ≤ 1 and whiteRed for t > 1. Easy to invert; worth // the comment. // +// Continuity at the join is load-bearing, not cosmetic: 'rampRadial' +// sweeps a single galaxy's argument across a range of 't' as the +// fragment radius varies, so any step at t = 1 would paint a +// hard-edged ring inside one galaxy, right where the core-to-rim +// gradient happens to straddle the join. A ramp sampled once per +// galaxy (the plain 'ramp(t)' call) can hide a step in a single flat +// colour; a ramp swept per fragment cannot. +// // ── warm-core / cool-rim gradient ──────────────────────────────────── // // This is the 'future colour-related helper' the module header above @@ -70,8 +80,8 @@ // a *less-red* rim — it never crosses into a hue the galaxy's // catalogued colour index doesn't support. // -// 'rampRadial's argument runs from 't + 0.2' at the core (redder, -// old-star bulge) down to 't - 0.2' at the rim (bluer, young-star +// 'rampRadial's argument runs from 't + 0.1' at the core (redder, +// old-star bulge) down to 't - 0.1' at the rim (bluer, young-star // arms) — i.e. 't + DISK_TINT_SPREAD * (0.5 - r)'. Why a SINGLE lookup // rather than two endpoint lookups mixed together (the alternative // form, 'mix(rampCore(t), rampRim(t), r)'): 'ramp' is piecewise LINEAR @@ -98,14 +108,42 @@ // one. So the single-lookup formulation is a straightforward // improvement, not a compromise traded for fewer varyings. // -// Why 0.4 ('DISK_TINT_SPREAD'): real SDSS g−r colour indices run -// roughly 0.75 in a galaxy's bulge and 0.35 in its spiral arms — a -// difference of about 0.4. The split is symmetric around the -// catalogued colour index (±0.2, i.e. ±DISK_TINT_SPREAD * 0.5) rather -// than skewed toward one endpoint, so the galaxy's INTEGRATED hue — -// what you'd get averaging core and rim — stays close to its -// catalogued g−r value; the gradient adds internal structure without -// changing the galaxy's overall colour identity. +// Why 0.2 ('DISK_TINT_SPREAD'): the constant lives in RAMP-POSITION +// units, not magnitudes. 'src/data/galaxyCatalog/colourIndex.ts' linearly +// remaps each source's own raw colour onto this 0..2 ramp via +// '((raw - rangeMin) / (rangeMax - rangeMin)) * 2', so one magnitude of +// real colour is worth '2 / (rangeMax - rangeMin)' ramp units — and +// that factor is PER SOURCE (different bands, different dynamic range). +// +// The centre-to-edge colour gradient this is meant to reproduce is +// measured, bluer outward, from three independent lines of evidence: +// - ~20,000 face-on SDSS-DR4 spirals give a mean disk gradient of +// -0.006 mag/kpc in g−r; over a ~15 kpc disk radius that's about +// 0.09 mag. +// - SDSS early-type galaxies give d(g−r)/d log r of about -0.11 to +// -0.16 mag per dex; the visible disk spans roughly one dex, so +// about 0.11 to 0.16 mag. +// - Bulge-versus-disc component colours differ by about 0.23 mag in +// g−r (bulge 0.91, disc 0.68), with large scatter. +// Taking ~0.15 mag as the centre-to-edge figure and converting through +// the per-source factors currently in play (2MRS 5.00, the three DESI +// sources 2.86, SDSS and Famous 1.33, Milliquas 1.00, GLADE 0.67 ramp +// units per magnitude): 0.15 mag at the median factor (1.33) gives 0.2. +// +// This is a known simplification, not an exact-per-source result: +// because a single ramp-space constant is shared by every source while +// the conversion factor is per-source, the effective physical gradient +// this produces differs between catalogs — roughly 0.04 mag for 2MRS +// up to 0.30 mag for GLADE. Making this exact would mean deriving the +// spread per source from its own 'colourSpec' range and its own band's +// measured gradient, which is a larger change than this constant. +// +// The split is symmetric around the catalogued colour index (±0.1, +// i.e. ±DISK_TINT_SPREAD * 0.5) rather than skewed toward one endpoint, +// so the galaxy's INTEGRATED hue — what you'd get averaging core and +// rim — stays close to its catalogued colour value; the gradient adds +// internal structure without changing the galaxy's overall colour +// identity. // // 'ramp' saturates its input at both ends (t ≤ 0 is fully blue, t > 2 // is fully red — see the piecewise description above), so for a @@ -120,14 +158,13 @@ import package::lib::math::saturate; fn ramp(t: f32) -> vec3 { - // s goes 0→1 as t goes 0→2; saturate stops it at 0 for negatives and 1 for t>2. - let s = saturate(t * 0.5); - - // Blue-to-white: hot blue (quasars, O/B stars) fading to a warm white. - let blueWhite = mix(vec3(0.4, 0.6, 1.0), vec3(1.0, 0.95, 0.8), s); + // Blue-to-white: hot blue (quasars, O/B stars) fading to a warm white, + // over its own t in [0, 1]. + let blueWhite = mix(vec3(0.4, 0.6, 1.0), vec3(1.0, 0.95, 0.8), saturate(t)); - // White-to-red: warm white fading to cool red (M-type stars, red galaxies). - let whiteRed = mix(vec3(1.0, 0.95, 0.8), vec3(1.0, 0.5, 0.3), s); + // White-to-red: warm white fading to cool red (M-type stars, red galaxies), + // over its own t in [1, 2]. + let whiteRed = mix(vec3(1.0, 0.95, 0.8), vec3(1.0, 0.5, 0.3), saturate(t - 1.0)); // Pick the right half of the ramp: blue-white for t ≤ 1, white-red for t > 1. // Remember: select(falseVal, trueVal, condition). @@ -135,9 +172,9 @@ fn ramp(t: f32) -> vec3 { } // Total core-to-rim spread in colour-index units — see the header -// section above ('warm-core / cool-rim gradient') for why 0.4 and why +// section above ('warm-core / cool-rim gradient') for why 0.2 and why // the shift below is symmetric around 't' rather than one-sided. -const DISK_TINT_SPREAD = 0.4; +const DISK_TINT_SPREAD = 0.2; // Warm-core / cool-rim galaxy tint, collapsed to one ramp lookup. // 't' is the galaxy's catalogued (rest-frame, K-corrected) colour From 9276381cd21ce4b9b359d4732e53ee903f815e3c Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 19:51:24 +0200 Subject: [PATCH 11/12] fix(catalog): reject sentinel magnitudes and make the SB zero-point robust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDSS medium rendered as a couple of dots — most of its galaxies invisible, though picking still resolved them. The galaxies were being rasterised; they were roughly 25x too dim. SDSS marks missing photometry with the numeric sentinel -9999, not NaN and not an empty field, and the CSV parser only rejected isNaN. The sentinel reached magG, where absoluteFromApparent(-9999, d) returns about -10040 — finite, so the zero-point's Number.isFinite guard accepted it. 55 such rows were enough. They dragged the per-catalog zero-point by -3.49 mag in sdss-medium (157,640 rows) and -1.11 mag in sdss-large (498,268 rows) — the same 55 rows over a third as many galaxies, which is why medium vanished while large merely read dim. Since galaxySbAmp normalises every galaxy against that zero-point, sdss-medium's median sbAmp was 0.038 where DEFAULT_GALAXY_SB_SCALE's own docstring states the invariant that a median row sits near 1.0. Measured, 0.05% of sdss-medium rows exceeded intensity 0.5 against GLADE medium's 43%. The rows survived every filter by design accident: subsampleByAbsMag ranks ascending by absolute magnitude, so -10040 sorted as the 55 BRIGHTEST galaxies and was guaranteed into every tier. Two complementary fixes rather than one. isPlausibleMagnitude rejects anything outside -30 < mag < 40 at the parser. It is an inclusive-range test, not an equality check against known sentinels: catalogs use several (-9999, -999, 99.99) and enumerating them means editing the predicate every time a source is added. Applied to the SDSS, GLADE, 2MRS, Hipparcos and Milliquas parsers. It also subsumes 2MRS's exact `=== 99.999` compare, which a truncated 99.99 would have slipped past. Milliquas keeps its own literal-0 rule composed on top, since 0 is a plausible magnitude in general and only Milliquas means "absent" by it. The DESI, famous-seed and HyperLEDA parsers already guard correctly and are untouched. galaxyMeanAbsMag becomes galaxyMedianAbsMag and takes a median. A mean can be moved arbitrarily far by a single wild value; a median is robust to any future contamination class without this file needing to know which sentinels exist — that knowledge belongs in the parser. On real data the two agree: sdss-medium's median is -20.82 against a sentinel-free mean of -20.880. The field holding it is renamed to medianAbsMag to keep the concept from being half-renamed. The fixes are not redundant. The parser repairs the data and the tier ranking; the median makes the statistic unwreckable by anything that slips through. Only the median takes effect on already-built bins — the parser fix needs a build-tiers run. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-07-24-galaxy-surface-brightness-model.md | 14 ++-- .../data/galaxyCatalog/GalaxyCatalog.d.ts | 12 ++-- .../subsystems/ProceduralDiskSubsystem.d.ts | 4 +- src/data/galaxyCatalog/galaxyCatalogFormat.ts | 6 +- .../galaxyCatalog/galaxyCatalogTransfer.ts | 2 +- src/data/galaxyCatalog/synthetic.ts | 4 +- src/data/sources/famous-galaxy.ts | 4 +- .../bake/buildPointInterleavedBuffer.ts | 16 ++--- .../subsystems/proceduralDiskSubsystem.ts | 6 +- .../proceduralDisks/fragment.wesl | 2 +- src/utils/galaxy/galaxyMeanAbsMag.ts | 39 ---------- src/utils/galaxy/galaxyMedianAbsMag.ts | 71 +++++++++++++++++++ src/utils/galaxy/galaxySbAmp.ts | 6 +- tests/parsers/sdssCsv.test.ts | 24 +++++-- .../utils/math/isPlausibleMagnitude.test.ts | 23 ++++++ tests/utils/galaxy/galaxyMeanAbsMag.test.ts | 42 ----------- tests/utils/galaxy/galaxyMedianAbsMag.test.ts | 63 ++++++++++++++++ tests/utils/galaxy/galaxySbAmp.test.ts | 2 +- tools/parsers/glade.ts | 37 +++++++--- tools/parsers/hipparcos2.ts | 7 +- tools/parsers/milliquas.ts | 47 ++++++++---- tools/parsers/sdssCsv.ts | 23 +++--- tools/parsers/twoMrs.ts | 36 +++++----- tools/utils/math/isPlausibleMagnitude.ts | 33 +++++++++ 24 files changed, 350 insertions(+), 173 deletions(-) delete mode 100644 src/utils/galaxy/galaxyMeanAbsMag.ts create mode 100644 src/utils/galaxy/galaxyMedianAbsMag.ts create mode 100644 tests/tools/utils/math/isPlausibleMagnitude.test.ts delete mode 100644 tests/utils/galaxy/galaxyMeanAbsMag.test.ts create mode 100644 tests/utils/galaxy/galaxyMedianAbsMag.test.ts create mode 100644 tools/utils/math/isPlausibleMagnitude.ts diff --git a/docs/backlog/2026-07-24-galaxy-surface-brightness-model.md b/docs/backlog/2026-07-24-galaxy-surface-brightness-model.md index f97f3373b..3ebd1b901 100644 --- a/docs/backlog/2026-07-24-galaxy-surface-brightness-model.md +++ b/docs/backlog/2026-07-24-galaxy-surface-brightness-model.md @@ -10,8 +10,8 @@ The galaxy point/disk passes are driven by a physical surface-brightness amplitu The amplitude is one shared helper consumed by both passes, so any fix lands in one place: -- `src/utils/galaxy/galaxySbAmp.ts:36-41` — `lumRel = 10^(-0.4·(absMag − meanAbsMag))`, `raw = lumRel / (diameterKpc/30)²`. -- `src/utils/galaxy/galaxyMeanAbsMag.ts:23-38` — per-catalog mean absolute magnitude, `-20.5` fallback. +- `src/utils/galaxy/galaxySbAmp.ts:36-41` — `lumRel = 10^(-0.4·(absMag − medianAbsMag))`, `raw = lumRel / (diameterKpc/30)²`. +- `src/utils/galaxy/galaxyMedianAbsMag.ts:43-71` — per-catalog median absolute magnitude, `-20.5` fallback. - Point pass: baked into slot 13 (`buildPointInterleavedBuffer.ts:177,351`), consumed at `shaders/galaxyCatalog/points/vertex.wesl:223-234`. - Disk pass: recomputed CPU-side per frame (`proceduralDiskSubsystem.ts:127-132`) and packed into `extras.w`. - Live knobs: `DEFAULT_GALAXY_SB_SCALE = 5.0`, `SB_MAX = 30.0`, `FALLOFF_STRENGTH = 0.7` (`src/data/defaults.ts:184,193,203`). @@ -20,14 +20,14 @@ The amplitude is one shared helper consumed by both passes, so any fix lands in ## Three defects -### 1. `meanAbsMag` conflates a calibration constant with real physics +### 1. `medianAbsMag` conflates a calibration constant with real physics -Subtracting each catalog's own mean absolute magnitude erases two different things at once: +Subtracting each catalog's own median absolute magnitude erases two different things at once: - the **photometric band zero-point** (SDSS g / 2MRS J / GLADE B / Famous B) — a genuine per-catalog constant that _should_ be corrected; - the catalog's **real luminosity distribution** — a genuine physical difference that should _not_ be erased. -Famous galaxies really are more luminous and higher surface brightness. Normalising that away and then re-introducing an absolute 30 kpc size term is what manufactures the mismatch. Secondary effect: the mean is arithmetic in log space, then exponentiated, so `lumRel` has median ≈ 1 but mean > 1 (Jensen) — a bright-tail skew that disappears with a fixed reference. +Famous galaxies really are more luminous and higher surface brightness. Normalising that away and then re-introducing an absolute 30 kpc size term is what manufactures the mismatch. Secondary effect: the zero-point is a central statistic in log space, then exponentiated, so `lumRel` has median ≈ 1 but mean > 1 (Jensen) — a bright-tail skew that disappears with a fixed reference. ### 2. `sbBoost` is the right shape, derived the wrong way @@ -47,9 +47,9 @@ Catalogs with genuinely measured sizes, where SB is real: SDSS (`petroR50_r`, `s ## What needs decided -- **Band unification.** Replace `meanAbsMag` with a per-catalog **band zero-point constant** plus a single **global absolute reference magnitude**. Needs a real transformation per source (B→g, J→g, …), each requiring a colour term — Famous has B−V, 2MRS has J−K, GLADE is B-only. Decide the target band and what to do where the colour term is missing. +- **Band unification.** Replace `medianAbsMag` with a per-catalog **band zero-point constant** plus a single **global absolute reference magnitude**. Needs a real transformation per source (B→g, J→g, …), each requiring a colour term — Famous has B−V, 2MRS has J−K, GLADE is B-only. Decide the target band and what to do where the colour term is missing. - **What to do about GLADE.** Options: flag its SB as synthetic and render it at flat surface brightness; source measured diameters (HyperLEDA `logd25` by PGC — coverage is partial, see `project_hyperleda_partial_cache`); or accept the degeneracy and document it. -- **Re-tuning cost.** Dropping the per-catalog mean shifts _every_ catalog's brightness, so this needs a full visual pass and probably new `sbScale` defaults. Famous would become legitimately brighter than the field, at which point "too bright" is an exposure/tone-map question, not a data one — it interacts with the HDR bloom threshold and the brightness slider the same way [star apparent-magnitude realism](2026-07-22-star-apparent-magnitude-realism.md) does. +- **Re-tuning cost.** Dropping the per-catalog median shifts _every_ catalog's brightness, so this needs a full visual pass and probably new `sbScale` defaults. Famous would become legitimately brighter than the field, at which point "too bright" is an exposure/tone-map question, not a data one — it interacts with the HDR bloom threshold and the brightness slider the same way [star apparent-magnitude realism](2026-07-22-star-apparent-magnitude-realism.md) does. - **Whether it's worth it.** A defensible alternative is to keep the current tuning, delete the "physically grounded" claim from the comments, and treat `sbAmp` as a legibility heuristic. Cheaper and honest. ## Validation diff --git a/src/@types/data/galaxyCatalog/GalaxyCatalog.d.ts b/src/@types/data/galaxyCatalog/GalaxyCatalog.d.ts index 3efcfbe8a..eed4bebdc 100644 --- a/src/@types/data/galaxyCatalog/GalaxyCatalog.d.ts +++ b/src/@types/data/galaxyCatalog/GalaxyCatalog.d.ts @@ -202,22 +202,22 @@ export type GalaxyCatalog = { parentSurveyByte: Uint8Array; /** - * Per-catalog mean absolute magnitude — the surface-brightness + * Per-catalog MEDIAN absolute magnitude — the surface-brightness * zero-point `galaxySbAmp` normalises against (see - * `utils/galaxy/galaxySbAmp.ts` and `utils/galaxy/galaxyMeanAbsMag.ts`). + * `utils/galaxy/galaxySbAmp.ts` and `utils/galaxy/galaxyMedianAbsMag.ts`). * * Populated by every runtime construction path: `decodeGalaxyCatalog`, * `generateSyntheticCloud`, `emptyGalaxyCatalog`, and * `cloneGalaxyCatalogForTransfer`. Optional ONLY so lightweight test * fixtures may omit it — consumers that need a value fall back - * themselves (the point bake recomputes via `galaxyMeanAbsMag`; the + * themselves (the point bake recomputes via `galaxyMedianAbsMag`; the * disk planner falls back to -20.5). * * Derived, NOT stored in the `.bin` — `decodeGalaxyCatalog` recomputes - * it from the decoded `magG` + `positions` on every load, so adding - * this field did not bump the binary format version. + * it from the decoded `magG` + `positions` on every load, so the field + * costs no binary format version. */ - meanAbsMag?: number; + medianAbsMag?: number; /** * Per-galaxy "orientation is a deterministic fallback" flag — length === diff --git a/src/@types/engine/subsystems/ProceduralDiskSubsystem.d.ts b/src/@types/engine/subsystems/ProceduralDiskSubsystem.d.ts index 80b83611c..3d5267300 100644 --- a/src/@types/engine/subsystems/ProceduralDiskSubsystem.d.ts +++ b/src/@types/engine/subsystems/ProceduralDiskSubsystem.d.ts @@ -32,9 +32,9 @@ import type { DiskWalkInput } from './DiskWalkInput'; * how the textured body extends it) with the three live surface-brightness * sliders — Settings -> Galaxies -> Advanced's `sbScale` / `sbMax` / * `brightness`. These have to arrive per-frame so the sliders stay live; - * the per-catalog zero-point they're applied against (`meanAbsMag`) is + * the per-catalog zero-point they're applied against (`medianAbsMag`) is * NOT threaded here — the planner reads it straight off each row's - * `catalog.meanAbsMag`, since it's already holding that catalog. + * `catalog.medianAbsMag`, since it's already holding that catalog. */ export type ProceduralDiskFrameInput = DiskWalkInput & { readonly sbScale: number; diff --git a/src/data/galaxyCatalog/galaxyCatalogFormat.ts b/src/data/galaxyCatalog/galaxyCatalogFormat.ts index 02d9e6fb4..ff4d60ea1 100644 --- a/src/data/galaxyCatalog/galaxyCatalogFormat.ts +++ b/src/data/galaxyCatalog/galaxyCatalogFormat.ts @@ -66,7 +66,7 @@ */ import type { GalaxyCatalog } from '../../@types/data/galaxyCatalog/GalaxyCatalog'; -import { galaxyMeanAbsMag } from '../../utils/galaxy/galaxyMeanAbsMag'; +import { galaxyMedianAbsMag } from '../../utils/galaxy/galaxyMedianAbsMag'; const MAGIC = 0x504d4b53; const VERSION = 8; @@ -243,7 +243,7 @@ export function decodeGalaxyCatalog(buf: ArrayBuffer): GalaxyCatalog { // Derived, not stored on disk — recomputed here (rather than encoded) // so adding this field never bumps the binary format version. Computed // AFTER the object above so the helper sees the finished typed arrays. - catalog.meanAbsMag = galaxyMeanAbsMag(catalog); + catalog.medianAbsMag = galaxyMedianAbsMag(catalog); return catalog; } @@ -263,7 +263,7 @@ export function emptyGalaxyCatalog(): GalaxyCatalog { classByte: new Uint8Array(0), parentSurveyByte: new Uint8Array(0), spectroscopicZ: new Float32Array(0), - meanAbsMag: -20.5, // count-0 fallback — same sentinel galaxyMeanAbsMag returns for count===0. + medianAbsMag: -20.5, // count-0 fallback — same sentinel galaxyMedianAbsMag returns for count===0. orientationIsFallback: new Uint8Array(0), diameterIsFallback: new Uint8Array(0), }; diff --git a/src/data/galaxyCatalog/galaxyCatalogTransfer.ts b/src/data/galaxyCatalog/galaxyCatalogTransfer.ts index bd1da9879..38d493fe5 100644 --- a/src/data/galaxyCatalog/galaxyCatalogTransfer.ts +++ b/src/data/galaxyCatalog/galaxyCatalogTransfer.ts @@ -62,7 +62,7 @@ export function cloneGalaxyCatalogForTransfer(catalog: GalaxyCatalog): ClonedGal spectroscopicZ: new Float32Array(catalog.spectroscopicZ.buffer.slice(0)), // Scalar, not a typed array — rides along by value, no buffer to slice // or add to the transfer list. - meanAbsMag: catalog.meanAbsMag, + medianAbsMag: catalog.medianAbsMag, orientationIsFallback: new Uint8Array(catalog.orientationIsFallback.buffer.slice(0)), diameterIsFallback: new Uint8Array(catalog.diameterIsFallback.buffer.slice(0)), }; diff --git a/src/data/galaxyCatalog/synthetic.ts b/src/data/galaxyCatalog/synthetic.ts index 68b7d7953..01bf30139 100644 --- a/src/data/galaxyCatalog/synthetic.ts +++ b/src/data/galaxyCatalog/synthetic.ts @@ -30,7 +30,7 @@ import type { GalaxyCatalog } from '../../@types/data/galaxyCatalog/GalaxyCatalog'; import { mulberry32 } from '../../utils/random/mulberry32'; import { uniformInSphere } from '../../utils/random/uniformInSphere'; -import { galaxyMeanAbsMag } from '../../utils/galaxy/galaxyMeanAbsMag'; +import { galaxyMedianAbsMag } from '../../utils/galaxy/galaxyMedianAbsMag'; // ─── Cloud generator ───────────────────────────────────────────────────────── @@ -212,6 +212,6 @@ export function generateSyntheticCloud(count: number, seed = 42): GalaxyCatalog // flagged 0. Uint8Array default-fills with 0. diameterIsFallback: new Uint8Array(count), }; - cloud.meanAbsMag = galaxyMeanAbsMag(cloud); + cloud.medianAbsMag = galaxyMedianAbsMag(cloud); return cloud; } diff --git a/src/data/sources/famous-galaxy.ts b/src/data/sources/famous-galaxy.ts index b01db9784..a738653a4 100644 --- a/src/data/sources/famous-galaxy.ts +++ b/src/data/sources/famous-galaxy.ts @@ -37,10 +37,10 @@ export const FAMOUS_GALAXY_ENTRY = { // Famous runs HOT without a trim, and the cause is a systematic in the // surface-brightness model rather than missing photometry (77 of the 80 // seed rows carry a real magB). `galaxySbAmp` divides a catalog-RELATIVE - // luminosity (normalised against this catalog's own meanAbsMag) by an + // luminosity (normalised against this catalog's own medianAbsMag) by an // ABSOLUTE size reference (a fixed 30 kpc). Famous galaxies have a median // diameter of 25.4 kpc, so that size term alone inflates every row by - // 1/0.847^2 = 1.39x; combined with the log-space-mean-then-exponentiate + // 1/0.847^2 = 1.39x; combined with the log-space-average-then-exponentiate // skew the measured median `raw` lands at 2.14 against a nominal 1.0, with // a tail to 11.7 (NGC 4449). Since every row then clears the 2.0 bloom // threshold, the whole catalog blooms at once and reads as blown out — diff --git a/src/services/engine/bake/buildPointInterleavedBuffer.ts b/src/services/engine/bake/buildPointInterleavedBuffer.ts index 7c5921df6..02b82e9ab 100644 --- a/src/services/engine/bake/buildPointInterleavedBuffer.ts +++ b/src/services/engine/bake/buildPointInterleavedBuffer.ts @@ -50,7 +50,7 @@ import { import { absoluteFromApparent, expectedNumberDensity, vMaxWeight } from '../../../utils/math'; import { computeSchechterRatios } from './computeSchechterRatios'; import { galaxySbAmp } from '../../../utils/galaxy/galaxySbAmp'; -import { galaxyMeanAbsMag } from '../../../utils/galaxy/galaxyMeanAbsMag'; +import { galaxyMedianAbsMag } from '../../../utils/galaxy/galaxyMedianAbsMag'; import type { BuildPointInterleavedBufferMode } from '../../../@types/engine/BuildPointInterleavedBufferMode'; import type { BuildPointInterleavedBufferInput } from '../../../@types/engine/BuildPointInterleavedBufferInput'; import type { BuildPointInterleavedBufferResult } from '../../../@types/engine/BuildPointInterleavedBufferResult'; @@ -169,12 +169,12 @@ export function buildPointInterleavedBuffer( // Surface-brightness zero-point — a DIFFERENT quantity from the magOffset // above (that one is a cosmetic per-catalog display shift; this one is - // the physical mean absolute magnitude `galaxySbAmp` normalises against). - // Shared with the disk-planner mirror of this bake via `cloud.meanAbsMag` - // when the catalog carries one (the real decode/synthetic paths always - // populate it); recomputed here as a fallback for lightweight test - // fixtures that omit the optional field. - const meanAbsMag = cloud.meanAbsMag ?? galaxyMeanAbsMag(cloud); + // the physical median absolute magnitude `galaxySbAmp` normalises + // against). Shared with the disk-planner mirror of this bake via + // `cloud.medianAbsMag` when the catalog carries one (the real + // decode/synthetic paths always populate it); recomputed here as a + // fallback for lightweight test fixtures that omit the optional field. + const medianAbsMag = cloud.medianAbsMag ?? galaxyMedianAbsMag(cloud); // ── Malmquist 1/V_max weight inputs ────────────────────────────────────── // @@ -347,7 +347,7 @@ export function buildPointInterleavedBuffer( // procedural-disk pass (`proceduralDiskSubsystem.ts`) recomputes this // SAME amplitude via the shared `galaxySbAmp` helper so the point↔disk // crossfade holds constant brightness. - interleaved[o + 13] = galaxySbAmp(absMag, meanAbsMag, cloud.diameterKpc[i]!); + interleaved[o + 13] = galaxySbAmp(absMag, medianAbsMag, cloud.diameterKpc[i]!); } return { diff --git a/src/services/engine/subsystems/proceduralDiskSubsystem.ts b/src/services/engine/subsystems/proceduralDiskSubsystem.ts index c3058d645..5bd93fa5a 100644 --- a/src/services/engine/subsystems/proceduralDiskSubsystem.ts +++ b/src/services/engine/subsystems/proceduralDiskSubsystem.ts @@ -121,12 +121,12 @@ export function createProceduralDiskSubsystem( // The disk recomputes the SAME physical surface brightness the // point bake baked (shared `galaxySbAmp` helper + the same - // per-catalog `meanAbsMag`), pre-scaled by the live sliders, so + // per-catalog `medianAbsMag`), pre-scaled by the live sliders, so // the point -> disk crossfade holds constant brightness and // intrinsically bright galaxies bloom in the disk view too. - const meanAbsMag = catalog.meanAbsMag ?? -20.5; + const medianAbsMag = catalog.medianAbsMag ?? -20.5; const absMag = absoluteFromApparent(catalog.magG[i]!, dMpcFromOrigin); - const rawSb = galaxySbAmp(absMag, meanAbsMag, dKpcRow); + const rawSb = galaxySbAmp(absMag, medianAbsMag, dKpcRow); const regEntry = SOURCE_REGISTRY[source]; const sbBoost = regEntry.type === 'galaxyCatalog' ? regEntry.sbBoost : 1; const sbAmp = Math.min(rawSb, sbMax) * sbScale * sbBoost * brightness; diff --git a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl index 4444831f6..b6bd61e1f 100644 --- a/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl +++ b/src/services/gpu/shaders/galaxyCatalog/proceduralDisks/fragment.wesl @@ -20,7 +20,7 @@ // surface-brightness amplitude computed once per row in // 'proceduralDiskSubsystem.ts' via the SAME 'galaxySbAmp' helper the // point-sprite bake uses (see 'buildPointInterleavedBuffer.ts'). Sharing -// the helper and the per-catalog 'meanAbsMag' zero-point means an +// the helper and the per-catalog 'medianAbsMag' zero-point means an // intrinsically bright or compact galaxy pushes this pass's HDR core // above the bloom threshold exactly the way its companion point sprite // does, and the two passes agree on brightness at the 8-14 px crossfade diff --git a/src/utils/galaxy/galaxyMeanAbsMag.ts b/src/utils/galaxy/galaxyMeanAbsMag.ts deleted file mode 100644 index c7dc1ca88..000000000 --- a/src/utils/galaxy/galaxyMeanAbsMag.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { GalaxyCatalog } from '../../@types/data/galaxyCatalog/GalaxyCatalog'; -import { absoluteFromApparent } from '../math/absoluteFromApparent'; - -/** - * galaxyMeanAbsMag — the per-catalog surface-brightness zero-point. - * - * Why per-catalog rather than one project-wide constant? Our three real - * catalogs each store a different photometric band in `magG` — SDSS is - * true g-band, 2MRS is near-infrared J, GLADE is B-band — so their raw - * apparent magnitudes live on different numeric scales entirely (2MRS - * galaxies read ~10 mag "brighter" in J than SDSS reads in g for a - * similar physical galaxy). A single global mean-absolute-magnitude - * reference would therefore render one catalog's galaxies systematically - * too bright or too dim relative to the others. Computing the mean once - * per catalog and normalising each row against ITS OWN catalog's mean - * cancels the band offset, so `galaxySbAmp` output stays comparable - * across catalogs even though the underlying photometry isn't. - * - * `-20.5` is the same "no data" fallback the bake already uses (roughly - * an L* absolute magnitude) — returned for an empty catalog so callers - * never have to special-case count === 0. - */ -export function galaxyMeanAbsMag(cloud: GalaxyCatalog): number { - let sum = 0; - let count = 0; - for (let i = 0; i < cloud.count; i++) { - const m = cloud.magG[i]!; - const x = cloud.positions[i * 3 + 0]!; - const y = cloud.positions[i * 3 + 1]!; - const z = cloud.positions[i * 3 + 2]!; - const dMpc = Math.hypot(x, y, z); - const M = absoluteFromApparent(m, dMpc); - if (Number.isFinite(M)) { - sum += M; - count++; - } - } - return count > 0 ? sum / count : -20.5; -} diff --git a/src/utils/galaxy/galaxyMedianAbsMag.ts b/src/utils/galaxy/galaxyMedianAbsMag.ts new file mode 100644 index 000000000..502353a17 --- /dev/null +++ b/src/utils/galaxy/galaxyMedianAbsMag.ts @@ -0,0 +1,71 @@ +import type { GalaxyCatalog } from '../../@types/data/galaxyCatalog/GalaxyCatalog'; +import { absoluteFromApparent } from '../math/absoluteFromApparent'; + +/** + * galaxyMedianAbsMag — the per-catalog surface-brightness zero-point. + * + * Why per-catalog rather than one project-wide constant? Our three real + * catalogs each store a different photometric band in `magG` — SDSS is + * true g-band, 2MRS is near-infrared J, GLADE is B-band — so their raw + * apparent magnitudes live on different numeric scales entirely (2MRS + * galaxies read ~10 mag "brighter" in J than SDSS reads in g for a + * similar physical galaxy). A single global absolute-magnitude reference + * would therefore render one catalog's galaxies systematically too bright + * or too dim relative to the others. Computing the zero-point once per + * catalog and normalising each row against ITS OWN catalog's value + * cancels the band offset, so `galaxySbAmp` output stays comparable + * across catalogs even though the underlying photometry isn't. + * + * Why the MEDIAN and not the mean, and not a mean-with-a-sentinel-filter? + * A mean is a fragile statistic for a job whose only purpose is to be a + * stable zero-point: one row with a wild magnitude drags it arbitrarily + * far, and the drag is invisible because the result is still a plausible + * number. SDSS' `-9999` "no photometry" sentinel back-solves to an + * absolute magnitude near -10040, which is FINITE, so it sails past every + * guard here; 55 such rows in a 157k-row catalog moved the zero-point by + * -3.5 mag, which `galaxySbAmp` turns into every galaxy in the catalog + * rendering ~25x too dim. The median simply cannot be moved that way: it + * is robust to any future contamination class, with no knowledge here of + * what shape the contamination takes. On clean data the two agree — + * sdss-medium reads -20.82 median against a -20.880 sentinel-free mean. + * + * Sentinel knowledge belongs in the parsers (`isPlausibleMagnitude`), + * which repair the data itself and the tier ranking that reads it; this + * file deliberately does NOT duplicate that list. The two are + * complementary, not redundant: the parser stops bad data existing, the + * median stops bad data mattering if any ever slips through — a new + * source, a hand-built fixture, a corrupted download. + * + * `-20.5` is the same "no data" fallback the bake already uses (roughly + * an L* absolute magnitude) — returned for an empty catalog so callers + * never have to special-case count === 0. + */ +export function galaxyMedianAbsMag(cloud: GalaxyCatalog): number { + // Collect into a Float64Array sized for the worst case and sort the + // populated prefix. Building the array then sorting is O(n log n) on up + // to a few million rows, which runs once per catalog load — a selection + // algorithm would be asymptotically better but the typed-array sort is + // native code, and this has never shown up next to the decode it follows. + const values = new Float64Array(cloud.count); + let count = 0; + for (let i = 0; i < cloud.count; i++) { + const m = cloud.magG[i]!; + const x = cloud.positions[i * 3 + 0]!; + const y = cloud.positions[i * 3 + 1]!; + const z = cloud.positions[i * 3 + 2]!; + const dMpc = Math.hypot(x, y, z); + const M = absoluteFromApparent(m, dMpc); + if (Number.isFinite(M)) { + values[count] = M; + count++; + } + } + if (count === 0) return -20.5; + + const sorted = values.subarray(0, count).sort(); + const mid = count >> 1; + // Even counts average the two central samples — the textbook definition. + // Averaging two neighbours cannot reintroduce outlier sensitivity: both + // are already central order statistics. + return count % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2; +} diff --git a/src/utils/galaxy/galaxySbAmp.ts b/src/utils/galaxy/galaxySbAmp.ts index 132718b88..f18f2482d 100644 --- a/src/utils/galaxy/galaxySbAmp.ts +++ b/src/utils/galaxy/galaxySbAmp.ts @@ -10,7 +10,7 @@ * scales with intrinsic luminosity, and for a fixed intrinsic luminosity * scales inversely with projected area. Hence `lumRel / diamRatio²`: the * amplitude rises for galaxies that are intrinsically brighter than their - * catalog's mean AND falls for galaxies that spread that luminosity over + * catalog's median AND falls for galaxies that spread that luminosity over * a larger apparent disk. * * `SB_REF_DIAMETER_KPC = 30` is the zero-point for the area term — the @@ -33,10 +33,10 @@ const SB_REF_DIAMETER_KPC = 30; const SB_AMP_MAX = 100000; -export function galaxySbAmp(absMag: number, meanAbsMag: number, diameterKpc: number): number { +export function galaxySbAmp(absMag: number, medianAbsMag: number, diameterKpc: number): number { const diamKpc = diameterKpc > 0 ? diameterKpc : SB_REF_DIAMETER_KPC; const diamRatio = diamKpc / SB_REF_DIAMETER_KPC; - const lumRel = Math.pow(10, -0.4 * (absMag - meanAbsMag)); + const lumRel = Math.pow(10, -0.4 * (absMag - medianAbsMag)); const raw = lumRel / (diamRatio * diamRatio); return Number.isFinite(raw) ? Math.min(Math.max(raw, 0), SB_AMP_MAX) : 1.0; } diff --git a/tests/parsers/sdssCsv.test.ts b/tests/parsers/sdssCsv.test.ts index 2c3e50cac..c06662c1e 100644 --- a/tests/parsers/sdssCsv.test.ts +++ b/tests/parsers/sdssCsv.test.ts @@ -22,16 +22,19 @@ const SAMPLE_CSV = [ '1237648720693788794,180.5,12.34,0.0512,19.21,18.05,17.40,17.10,16.92,0.5,30,0.7,30,0.5', // Invalid row: empty modelMag_u — should be skipped, not throw. '1237648720693788795,200.0,-5.0,0.10,,18.50,17.80,17.50,17.30,0.5,30,0.7,30,0.5', + // Invalid row: SDSS' `-9999` "no photometry" sentinel in modelMag_g. It + // parses as a finite number, so only the plausibility gate catches it. + '1237648720693788796,210.0,-6.0,0.10,19.10,-9999,17.80,17.50,17.30,0.5,30,0.7,30,0.5', ].join('\n'); describe('parseSdssCsv', () => { - it('parses the valid row, skips the invalid one, ignores comments', () => { + it('parses the valid row, skips the invalid ones, ignores comments', () => { const { records, skipped } = parseSdssCsv(SAMPLE_CSV); - // One row in (the second valid by header but missing magU), one row out - // (the first), one row skipped. + // Three data rows: one fully valid, one missing magU, one carrying the + // -9999 sentinel. Only the first survives. expect(records.length).toBe(1); - expect(skipped).toBe(1); + expect(skipped).toBe(2); const r = records[0]!; // Source tag must be SDSS — this is what lets the merger and the GPU @@ -53,6 +56,19 @@ describe('parseSdssCsv', () => { expect(r.magZ).toBeCloseTo(16.92, 6); }); + it('never emits the -9999 "no photometry" sentinel as a magnitude', () => { + // The sentinel is finite, so every downstream `Number.isFinite` guard + // accepts it: `absoluteFromApparent(-9999, d)` is about -10040, which + // poisons the catalog's surface-brightness zero-point and sorts to the + // very front of the brightness-ranked tier sub-sample. + const { records } = parseSdssCsv(SAMPLE_CSV); + for (const r of records) { + for (const mag of [r.magU, r.magG, r.magR, r.magI, r.magZ]) { + expect(mag).toBeGreaterThan(-30); + } + } + }); + it('parses orientation columns and blends exp+deV via fracDeV_r', () => { const csv = [ 'objID,ra,dec,z,modelMag_u,modelMag_g,modelMag_r,modelMag_i,modelMag_z,expAB_r,expPhi_r,deVAB_r,deVPhi_r,fracDeV_r', diff --git a/tests/tools/utils/math/isPlausibleMagnitude.test.ts b/tests/tools/utils/math/isPlausibleMagnitude.test.ts new file mode 100644 index 000000000..884af7b5e --- /dev/null +++ b/tests/tools/utils/math/isPlausibleMagnitude.test.ts @@ -0,0 +1,23 @@ +/** + * isPlausibleMagnitude — the shared sentinel gate every catalog parser + * runs its magnitude columns through. + * + * The one thing worth pinning is the behaviour the predicate exists for: + * a finite in-band sentinel is rejected while real photometry — from the + * faint survey end to the brightest object anyone measures — is kept. A + * regression here is silent everywhere else: `-9999` is finite, so no + * downstream guard catches it. + */ + +import { describe, it, expect } from 'vitest'; +import { isPlausibleMagnitude } from '../../../../tools/utils/math/isPlausibleMagnitude'; + +describe('isPlausibleMagnitude', () => { + it('rejects the -9999 sentinel while keeping real photometry', () => { + expect(isPlausibleMagnitude(-9999)).toBe(false); + // A typical SDSS main-sample galaxy. + expect(isPlausibleMagnitude(17.9)).toBe(true); + // The Sun — brighter than anything in any catalog here, and still in. + expect(isPlausibleMagnitude(-26.7)).toBe(true); + }); +}); diff --git a/tests/utils/galaxy/galaxyMeanAbsMag.test.ts b/tests/utils/galaxy/galaxyMeanAbsMag.test.ts deleted file mode 100644 index 611f5130c..000000000 --- a/tests/utils/galaxy/galaxyMeanAbsMag.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * galaxyMeanAbsMag — pins the per-catalog surface-brightness zero-point. - * - * Coverage focus: the arithmetic-mean-of-absolute-magnitude computation - * itself (easy to get wrong via a sum/count off-by-one or a wrong - * distance formula), and the empty-catalog fallback other consumers rely - * on (`galaxySbAmp` callers, `emptyGalaxyCatalog`). - */ - -import { describe, it, expect } from 'vitest'; -import { galaxyMeanAbsMag } from '../../../src/utils/galaxy/galaxyMeanAbsMag'; -import { absoluteFromApparent } from '../../../src/utils/math/absoluteFromApparent'; -import type { GalaxyCatalog } from '../../../src/@types/data/galaxyCatalog/GalaxyCatalog'; - -describe('galaxyMeanAbsMag', () => { - it('returns the arithmetic mean of absoluteFromApparent over every row', () => { - // Distances chosen so hypot(x,0,0) = x — trivial distance-from-origin. - const cloud = { - count: 3, - magG: new Float32Array([18, 15, 20]), - positions: new Float32Array([10, 0, 0, 50, 0, 0, 100, 0, 0]), - } as unknown as GalaxyCatalog; - - const expectedMean = - (absoluteFromApparent(18, 10) + - absoluteFromApparent(15, 50) + - absoluteFromApparent(20, 100)) / - 3; - - expect(galaxyMeanAbsMag(cloud)).toBeCloseTo(expectedMean, 6); - }); - - it('returns -20.5 for an empty catalog', () => { - const cloud = { - count: 0, - magG: new Float32Array(0), - positions: new Float32Array(0), - } as unknown as GalaxyCatalog; - - expect(galaxyMeanAbsMag(cloud)).toBe(-20.5); - }); -}); diff --git a/tests/utils/galaxy/galaxyMedianAbsMag.test.ts b/tests/utils/galaxy/galaxyMedianAbsMag.test.ts new file mode 100644 index 000000000..8c56aea71 --- /dev/null +++ b/tests/utils/galaxy/galaxyMedianAbsMag.test.ts @@ -0,0 +1,63 @@ +/** + * galaxyMedianAbsMag — pins the per-catalog surface-brightness zero-point. + * + * Coverage focus: that the zero-point is the MEDIAN order statistic (the + * mean is a different number for these fixtures, so a regression back to + * summing would fail here), that a single absurd row cannot move it — the + * property the whole choice of statistic exists for — and the + * empty-catalog fallback other consumers rely on (`galaxySbAmp` callers, + * `emptyGalaxyCatalog`). + */ + +import { describe, it, expect } from 'vitest'; +import { galaxyMedianAbsMag } from '../../../src/utils/galaxy/galaxyMedianAbsMag'; +import { absoluteFromApparent } from '../../../src/utils/math/absoluteFromApparent'; +import type { GalaxyCatalog } from '../../../src/@types/data/galaxyCatalog/GalaxyCatalog'; + +describe('galaxyMedianAbsMag', () => { + it('returns the median of absoluteFromApparent over every row', () => { + // Distances chosen so hypot(x,0,0) = x — trivial distance-from-origin. + const cloud = { + count: 3, + magG: new Float32Array([18, 15, 20]), + positions: new Float32Array([10, 0, 0, 50, 0, 0, 100, 0, 0]), + } as unknown as GalaxyCatalog; + + // Absolute magnitudes are -12, -18.49 and -15; the middle one is the + // third row's. The arithmetic mean of the same three is -15.16, so this + // assertion actually discriminates median from mean. + expect(galaxyMedianAbsMag(cloud)).toBeCloseTo(absoluteFromApparent(20, 100), 6); + }); + + it('is not moved materially by a single absurd magnitude', () => { + // The regression case: SDSS marks missing photometry with `-9999`, which + // back-solves to a FINITE absolute magnitude near -10000 and so passes + // every finiteness guard. Under an arithmetic mean one such row in six + // drags the zero-point by ~1670 mag; the median must barely notice. + const clean = { + count: 5, + magG: new Float32Array([18, 18.2, 18.4, 18.6, 18.8]), + positions: new Float32Array([100, 0, 0, 100, 0, 0, 100, 0, 0, 100, 0, 0, 100, 0, 0]), + } as unknown as GalaxyCatalog; + + const poisoned = { + count: 6, + magG: new Float32Array([18, 18.2, 18.4, 18.6, 18.8, -9999]), + positions: new Float32Array([ + 100, 0, 0, 100, 0, 0, 100, 0, 0, 100, 0, 0, 100, 0, 0, 100, 0, 0, + ]), + } as unknown as GalaxyCatalog; + + expect(galaxyMedianAbsMag(poisoned)).toBeCloseTo(galaxyMedianAbsMag(clean), 0); + }); + + it('returns -20.5 for an empty catalog', () => { + const cloud = { + count: 0, + magG: new Float32Array(0), + positions: new Float32Array(0), + } as unknown as GalaxyCatalog; + + expect(galaxyMedianAbsMag(cloud)).toBe(-20.5); + }); +}); diff --git a/tests/utils/galaxy/galaxySbAmp.test.ts b/tests/utils/galaxy/galaxySbAmp.test.ts index 00c43d343..16d753c24 100644 --- a/tests/utils/galaxy/galaxySbAmp.test.ts +++ b/tests/utils/galaxy/galaxySbAmp.test.ts @@ -31,7 +31,7 @@ describe('galaxySbAmp', () => { expect(viaNegative).toBe(viaExplicit30); }); - it('returns exactly 1.0 when absMag equals meanAbsMag and diameter is the 30 kpc reference', () => { + it('returns exactly 1.0 when absMag equals medianAbsMag and diameter is the 30 kpc reference', () => { // lumRel = 10^0 = 1, diamRatio = 1 → raw = 1. expect(galaxySbAmp(-20.5, -20.5, 30)).toBeCloseTo(1, 6); }); diff --git a/tools/parsers/glade.ts b/tools/parsers/glade.ts index 75012cf43..82866e5a1 100644 --- a/tools/parsers/glade.ts +++ b/tools/parsers/glade.ts @@ -99,6 +99,7 @@ import { galaxyDiameterKpc } from '../../src/utils/math/galaxyDiameterKpc'; import { DEFAULT_GALAXY_DIAMETER_KPC } from '../../src/utils/math/defaultGalaxyDiameterKpc'; import { absoluteMagnitude } from '../../src/utils/math/absoluteMagnitude'; import { redshiftToDistanceMpc } from '../../src/utils/math/redshiftToDistanceMpc'; +import { isPlausibleMagnitude } from '../utils/math/isPlausibleMagnitude'; /** * Map from PGC string (no padding, no leading zeros stripped) to HyperLEDA's @@ -212,6 +213,23 @@ function parseFloatOrNaN(s: string): number { return Number.isFinite(v) ? v : NaN; } +/** + * Magnitude columns get a second gate on top of `parseFloatOrNaN`. + * + * GLADE is a compilation catalog: its photometry is copied verbatim from + * HyperLEDA, 2MASS, SDSS and friends, each of which has its own numeric + * "no measurement" sentinel (`-9999`, `99.99`, …). Any of those that + * survives the dash check parses as a finite number and would then be + * treated as a real, absurdly bright or absurdly faint galaxy. Routing the + * four magnitude columns through `isPlausibleMagnitude` collapses the whole + * family to NaN, the sentinel the rest of the pipeline already understands. + * Redshift keeps the plain parser — it has its own positivity rule below. + */ +function parseMagOrNaN(s: string): number { + const v = parseFloatOrNaN(s); + return isPlausibleMagnitude(v) ? v : NaN; +} + /** * Options controlling row-level filtering of GLADE rows. * @@ -363,15 +381,16 @@ export function parseGladeLine( const z = parseFloatOrNaN(line.slice(173, 191)); if (!Number.isFinite(z) || z <= 0) return null; - // Photometry. All four fields use the dash-sentinel convention; any - // missing band collapses to NaN and propagates correctly through the - // renderer's colour computation. We deliberately do *not* skip the - // row when a band is missing — partial photometry is still useful - // for sorting by apparent brightness in one of the other bands. - const bmag = parseFloatOrNaN(line.slice(192, 198)); // bytes 193-198 - const jmag = parseFloatOrNaN(line.slice(214, 220)); // bytes 215-220 - const hmag = parseFloatOrNaN(line.slice(227, 233)); // bytes 228-233 - const kmag = parseFloatOrNaN(line.slice(240, 246)); // bytes 241-246 + // Photometry. All four fields use the dash-sentinel convention, plus the + // numeric-sentinel gate in `parseMagOrNaN`; any missing band collapses to + // NaN and propagates correctly through the renderer's colour computation. + // We deliberately do *not* skip the row when a band is missing — partial + // photometry is still useful for sorting by apparent brightness in one of + // the other bands. + const bmag = parseMagOrNaN(line.slice(192, 198)); // bytes 193-198 + const jmag = parseMagOrNaN(line.slice(214, 220)); // bytes 215-220 + const hmag = parseMagOrNaN(line.slice(227, 233)); // bytes 228-233 + const kmag = parseMagOrNaN(line.slice(240, 246)); // bytes 241-246 // PGC sits in bytes 1-7 (0-based: 0-7). Empty/sentinel rows (`---`, `0`) are // common — those rows just won't find a match in the cache and will fall diff --git a/tools/parsers/hipparcos2.ts b/tools/parsers/hipparcos2.ts index 87b8c1881..030bb8de0 100644 --- a/tools/parsers/hipparcos2.ts +++ b/tools/parsers/hipparcos2.ts @@ -52,6 +52,7 @@ */ import { nonCommentLines, slot } from './common'; +import { isPlausibleMagnitude } from '../utils/math/isPlausibleMagnitude'; /** Radians → degrees. Named so the conversion at the boundary is greppable. */ const RAD_TO_DEG = 180 / Math.PI; @@ -112,12 +113,16 @@ export function parseHipparcos2(rawText: string): Hip2Result { const bvStr = slot(line, 153, 158); const bv = bvStr === '' ? NaN : parseFloat(bvStr); + // Hpmag goes through the shared plausibility predicate rather than a + // bare finiteness check: VizieR-distributed reductions substitute a + // numeric sentinel when a column is unmeasured, and a finite -9999 would + // otherwise become the brightest star in the sky. if ( !Number.isFinite(hip) || !Number.isFinite(raRad) || !Number.isFinite(deRad) || !Number.isFinite(plxMas) || - !Number.isFinite(hpMag) + !isPlausibleMagnitude(hpMag) ) { // A required numeric field failed to parse — no usable row. skipped++; diff --git a/tools/parsers/milliquas.ts b/tools/parsers/milliquas.ts index 45719c103..f29c825ca 100644 --- a/tools/parsers/milliquas.ts +++ b/tools/parsers/milliquas.ts @@ -36,6 +36,7 @@ import { MILLIQUAS_CLASS_BYTE, MILLIQUAS_PARENT_SURVEY_BYTE, } from '../../src/data/galaxyCatalog/sourceClass'; +import { isPlausibleMagnitude } from '../utils/math/isPlausibleMagnitude'; import { nonCommentLines, type ParsedRecord } from './common'; // ─── Byte ranges (1-based inclusive, as published in the upstream ReadMe) ── @@ -119,6 +120,35 @@ function parentSurveyByteFromName(nameTrimmed: string): number { return 0; } +/** + * Read one Milliquas magnitude cell, returning NaN for "no measurement". + * + * Two rules compose here, and they are deliberately kept apart. + * + * The general one is `isPlausibleMagnitude`, shared with every other + * parser: it rejects blanks and the numeric sentinels catalogs inherit from + * their upstream sources. + * + * The Milliquas-specific one is the literal `0`. This catalog marks a + * missing magnitude with `0`, NOT a blank — the Circinus row reads + * `Rmag="10.93" Bmag=" 0 "`, meaning "R measured, B absent". Zero is + * catastrophic downstream rather than merely wrong: a 4 Mpc galaxy at m=0 + * back-solves to M=-28, which the surface-brightness model reads as ~240x a + * typical galaxy's luminosity. That is what made Circinus and the Milliquas + * copy of Centaurus A render as blown-out white blobs (2169 rows carried + * magG=0). + * + * The zero rule stays local because it is false in general — zero is a + * perfectly good magnitude for a bright star (Vega is 0.03) — and true only + * for THIS catalog: the brightest known AGN (3C 273) sits at ~12.9, so no + * Milliquas row has a legitimate magnitude anywhere near zero. + */ +function milliquasMagOrNaN(cell: string): number { + const v = parseFloat(cell); + if (v === 0) return NaN; + return isPlausibleMagnitude(v) ? v : NaN; +} + export type MilliquasParseResult = { records: ParsedRecord[]; skipped: { @@ -175,21 +205,8 @@ export function parseMilliquas(rawText: string): MilliquasParseResult { continue; } - // Milliquas marks a missing magnitude with a literal `0`, NOT a blank — - // the Circinus row reads `Rmag="10.93" Bmag=" 0 "`, meaning "R measured, - // B absent". A blank-only guard lets `parseFloat('0')` through as a real - // magnitude, and zero is catastrophic downstream rather than merely - // wrong: a 4 Mpc galaxy at m=0 back-solves to M=-28, which the - // surface-brightness model reads as ~240x a typical galaxy's luminosity. - // That is what made Circinus and the Milliquas copy of Centaurus A - // render as blown-out white blobs (2169 rows carried magG=0). - // - // Treating 0 as "missing" is safe for THIS catalog specifically: the - // brightest known AGN (3C 273) sits at ~12.9, so no Milliquas row has a - // legitimate magnitude anywhere near zero. NaN is the honest sentinel — - // downstream consumers already treat it as "no measurement". - const magR = rmagStr === '' || parseFloat(rmagStr) === 0 ? NaN : parseFloat(rmagStr); - const magG = bmagStr === '' || parseFloat(bmagStr) === 0 ? NaN : parseFloat(bmagStr); + const magR = milliquasMagOrNaN(rmagStr); + const magG = milliquasMagOrNaN(bmagStr); const nameTrimmed = nameRaw.trimEnd().trimStart(); const classByte = classByteFromType(typeRaw); diff --git a/tools/parsers/sdssCsv.ts b/tools/parsers/sdssCsv.ts index 3d74f8714..3c8c9c8e9 100644 --- a/tools/parsers/sdssCsv.ts +++ b/tools/parsers/sdssCsv.ts @@ -35,6 +35,7 @@ import { Source } from '../../src/data/sources'; import { arcsecToKpc } from '../../src/utils/math/arcsecToKpc'; import { redshiftToDistanceMpc } from '../../src/utils/math/redshiftToDistanceMpc'; +import { isPlausibleMagnitude } from '../utils/math/isPlausibleMagnitude'; import { nonCommentLines, type ParsedRecord } from './common'; /** @@ -107,9 +108,10 @@ function blendSdssShape( * - `z <= 0` — a star, a QSO at z = 0, or a catalogue error. Galaxies * have strictly positive cosmological redshifts; a non-positive z * means the row isn't usefully placeable in 3D space. - * - Any of the five magnitude columns is empty or fails to parse as a - * finite number. Without all five bands we can't compute K-corrected - * rest-frame colours in the shader. + * - Any of the five magnitude columns is empty, fails to parse as a + * finite number, or carries SDSS' `-9999` "no photometry" sentinel + * (see `isPlausibleMagnitude`). Without all five bands we can't + * compute K-corrected rest-frame colours in the shader. * - `objID` is empty, non-numeric, or zero. SDSS uses 0 as a sentinel * for "no object", so a 0 ID is by definition not a real galaxy. * @@ -201,16 +203,21 @@ export function parseSdssCsv(rawText: string): SdssCsvResult { const magI = parseFloat(cells[COL_MAG_I] ?? ''); const magZ = parseFloat(cells[COL_MAG_Z] ?? ''); + // Magnitudes are validated with `isPlausibleMagnitude`, not `isNaN`: + // SDSS marks missing photometry with the in-band sentinel `-9999`, + // which parses as a finite number and survives every NaN check the + // pipeline applies afterwards. See the predicate's docstring for why + // it is a range test rather than an equality check on the sentinel. if ( z <= 0 || isNaN(ra) || isNaN(dec) || isNaN(z) || - isNaN(magU) || - isNaN(magG) || - isNaN(magR) || - isNaN(magI) || - isNaN(magZ) + !isPlausibleMagnitude(magU) || + !isPlausibleMagnitude(magG) || + !isPlausibleMagnitude(magR) || + !isPlausibleMagnitude(magI) || + !isPlausibleMagnitude(magZ) ) { skipped++; continue; diff --git a/tools/parsers/twoMrs.ts b/tools/parsers/twoMrs.ts index 197dbbb6a..74b2a0ad9 100644 --- a/tools/parsers/twoMrs.ts +++ b/tools/parsers/twoMrs.ts @@ -54,12 +54,14 @@ * catalogs like 2MRS. We use only `Number.isFinite(cz)` here — no * positivity check. * - * - **Kcmag or Hcmag non-finite → skip.** These two bands carry the + * - **Kcmag or Hcmag missing → skip.** These two bands carry the * 2MRS flux limit and are present for essentially every row; a * missing K or H signals the row was added for redshift bookkeeping - * but lacks usable photometry, which makes it un-renderable. + * but lacks usable photometry, which makes it un-renderable. "Missing" + * means non-finite OR outside `isPlausibleMagnitude`, since 2MRS spells + * absence as a number (99.999) rather than a blank. * - * - **Jcmag = 99.999 → store as NaN.** 2MRS uses 99.999 as a sentinel + * - **Implausible Jcmag → store as NaN.** 2MRS uses 99.999 as a sentinel * for "no J measurement available" (a small fraction of rows are K/H * detections only). NaN propagates correctly through the renderer's * colour computation, so this is just normal "missing band" behaviour. @@ -71,6 +73,7 @@ import { Source } from '../../src/data/sources'; import { nonCommentLines, type ParsedRecord } from './common'; import { arcsecToKpc } from '../../src/utils/math/arcsecToKpc'; +import { isPlausibleMagnitude } from '../utils/math/isPlausibleMagnitude'; /** * Speed of light in km/s, used to convert heliocentric velocity cz into @@ -80,13 +83,6 @@ import { arcsecToKpc } from '../../src/utils/math/arcsecToKpc'; */ const C_KM_S = 299792.458; -/** - * 2MRS' documented "no J-band measurement" sentinel. Recording it as a - * named constant (rather than inlining `99.999` next to the comparison) - * makes the magic number greppable and hard to mis-read as a magnitude. - */ -const J_MISSING_SENTINEL = 99.999; - /** * Minimum line length required for a row to even be considered. The cz * column ends at byte 178, so any line shorter than that has no chance @@ -229,11 +225,15 @@ export function parseTwoMrs(rawText: string, xsc: XscShapeMap = new Map()): TwoM const czStr = line.slice(173, 178).trim(); const cz = czStr === '' ? NaN : parseFloat(czStr); + // K and H are gated by `isPlausibleMagnitude` rather than plain + // finiteness: 2MRS writes its "no measurement" sentinel as a number + // (99.999), so a finiteness check alone would let a row with no usable + // photometry through carrying a magnitude 88 mag off the sample limit. if ( !Number.isFinite(ra) || !Number.isFinite(dec) || - !Number.isFinite(kc) || - !Number.isFinite(hc) || + !isPlausibleMagnitude(kc) || + !isPlausibleMagnitude(hc) || !Number.isFinite(cz) ) { // Note: we deliberately do NOT check `cz > 0` here. Local Group @@ -276,10 +276,14 @@ export function parseTwoMrs(rawText: string, xsc: XscShapeMap = new Map()): TwoM continue; } - // Translate Jcmag's published sentinel to NaN so downstream consumers - // can use the same "missing band" idiom regardless of which survey - // the record came from. - const jc = jcRaw === J_MISSING_SENTINEL ? NaN : jcRaw; + // Translate Jcmag's published sentinel (99.999) to NaN so downstream + // consumers can use the same "missing band" idiom regardless of which + // survey the record came from. The shared range predicate does that + // without naming the constant: 99.999 is nowhere near a magnitude any + // instrument reports, and matching the family rather than the exact + // literal also catches a truncated `99.99` or a `-9999` inherited from + // an upstream cross-match. + const jc = isPlausibleMagnitude(jcRaw) ? jcRaw : NaN; // Riso (log10 of isophotal RADIUS in arcsec, K=20 mag/arcsec² isophote) // sits at bytes 142-146 (1-based inclusive, half-open 141..146). About diff --git a/tools/utils/math/isPlausibleMagnitude.ts b/tools/utils/math/isPlausibleMagnitude.ts new file mode 100644 index 000000000..57283b60d --- /dev/null +++ b/tools/utils/math/isPlausibleMagnitude.ts @@ -0,0 +1,33 @@ +/** + * isPlausibleMagnitude — "is this number a photometric measurement at all?" + * + * Every survey we parse marks missing photometry with an in-band numeric + * sentinel rather than a blank cell: SDSS writes `-9999`, other VizieR + * tables write `-999` or `99.99`, 2MRS publishes `99.999`. A parser that + * only rejects NaN happily hands those straight through as magnitudes, and + * they are not merely wrong — they are catastrophically wrong in the + * direction the renderer amplifies. `absoluteFromApparent(-9999, d)` is a + * perfectly FINITE −10040, so every downstream `Number.isFinite` guard + * accepts it, the per-catalog zero-point collapses by several magnitudes, + * and the tier sub-sampler (which ranks ascending by absolute magnitude) + * promotes the sentinels to "brightest galaxies in the survey". + * + * Why an inclusive-range test and not an equality check against the known + * sentinels? Because enumerating them means editing this function every + * time a new source appears, and every miss is silent. A range test states + * the actual physical claim instead: the brightest object in any catalog + * here is far brighter than nothing at −27 (the Sun, the brightest thing a + * human ever measures, is −26.7) and the faintest survey limits sit near + * 25. `-30 < mag < 40` brackets that with an order of magnitude of slack in + * both directions, so anything outside it is a sentinel or a parse error — + * never a measurement. A value that is genuinely borderline-implausible but + * inside the window stays the catalog's business, not this predicate's. + * + * Catalog-SPECIFIC missing-value rules do not belong here. Milliquas uses a + * literal `0` for "band absent", which is a fact about Milliquas: zero is a + * perfectly plausible magnitude in general (Vega is 0.03). That guard stays + * in `tools/parsers/milliquas.ts` and composes with this one. + */ +export function isPlausibleMagnitude(mag: number): boolean { + return mag > -30 && mag < 40; +} From a06954c8cf6043513f32dcb6f5640788cf746abf Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Fri, 24 Jul 2026 20:40:04 +0200 Subject: [PATCH 12/12] docs(backlog): file the two follow-ups surfaced by the galaxy look spike Per-source colour-gradient spread: DISK_TINT_SPREAD is one shared constant in ramp-position units, but ramp units per magnitude are fixed per source by its own colourSpec range, so one knob renders a 7.5x spread in the physical core-to-rim gradient (~0.04 mag for 2MRS to ~0.30 mag for GLADE). Famous-seed redshift-distance fallback: M90 bakes at 1.47 Mpc because HyperLEDA's mod0 misses its error gate by 0.05 mag and the chain falls to v3k/70, invalid for a Virgo infall member whose CMB-frame velocity is mostly peculiar motion. The wrong distance drags diameterKpc with it, so the galaxy mispositions, undersizes, and mis-brightens at once. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/BACKLOG.md | 2 + ...-famous-seed-redshift-distance-fallback.md | 131 ++++++++++++++++++ ...07-24-per-source-colour-gradient-spread.md | 49 +++++++ 3 files changed, 182 insertions(+) create mode 100644 docs/backlog/2026-07-24-famous-seed-redshift-distance-fallback.md create mode 100644 docs/backlog/2026-07-24-per-source-colour-gradient-spread.md diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index e6d848989..bbbb4b86b 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -56,6 +56,8 @@ Items with a **→ details** link have a full write-up in [`backlog/`](backlog/) - [ ] **Fold star-upsample into hdr→swap** `needs-design` — delete the standalone fullscreen composite (~1.0 ms real) by sampling the aggregate target in the tonemap shader; bloom-ordering question open. → [details](backlog/2026-07-21-fold-star-upsample-into-tonemap.md) - [ ] **Real star apparent magnitudes from Earth** `needs-design` — relative photometry is already physical; calibrate the display mapping so the Earth vantage matches the real night sky (interacts with bloom + the brightness slider). → [details](backlog/2026-07-22-star-apparent-magnitude-realism.md) - [ ] **Physically-honest galaxy surface brightness** `needs-design` — `galaxySbAmp` divides a catalog-relative luminosity by an absolute 30 kpc size reference; Famous is fudged with `sbBoost 0.45` and GLADE's SB is Tully-derived from its own B mag (no real information). → [details](backlog/2026-07-24-galaxy-surface-brightness-model.md) +- [ ] **Per-source colour-gradient spread** `needs-design` — the shared `DISK_TINT_SPREAD` ramp-space constant renders a different physical core-to-rim gradient per catalog (≈0.04–0.30 mag); derive it per source instead. → [details](backlog/2026-07-24-per-source-colour-gradient-spread.md) +- [ ] **Famous-seed redshift-distance fallback breaks on infall members** `needs-design` — M90 bakes at 1.47 Mpc via `v3k/70`, a class bug wherever peculiar velocity swamps Hubble flow. → [details](backlog/2026-07-24-famous-seed-redshift-distance-fallback.md) - [ ] **Bright star clump at ~5.9 kpc** `deferred` — flux verified conserved; residual over-exposure is display policy (mid-anchor slider + summed knee shipped; retune or tone-map shoulder next). → [details](backlog/2026-07-17-star-clump-brightness-5-9kpc.md) - [ ] **Foreground body draw/drawPick share a per-frame resolved set** `deferred` — mirrored partition/cull invocations can desync under future edits; star partition runs up to 4×/frame at deep zoom. → [details](backlog/2026-07-17-foreground-body-resolved-set.md) - [ ] **Star drawBudget small-tier mobile cap + iOS device pass** `deferred` — lower `hardCap` for `tier === 'small'` in `gaia-stars.ts`, tuned on a real device; verify the new vertex-stage storage bindings under WebKit's stricter WebGPU in the same pass. diff --git a/docs/backlog/2026-07-24-famous-seed-redshift-distance-fallback.md b/docs/backlog/2026-07-24-famous-seed-redshift-distance-fallback.md new file mode 100644 index 000000000..201b5a9ff --- /dev/null +++ b/docs/backlog/2026-07-24-famous-seed-redshift-distance-fallback.md @@ -0,0 +1,131 @@ +# Famous-seed redshift-distance fallback breaks on cluster infall members + +**Status:** needs-design (2026-07-24) + +## Ask + +M90 (NGC 4569) renders at `distanceMpc: 1.4714285714285715`, inside the Local +Group, when it is a Virgo Cluster member at roughly 16-17 Mpc. Every other +Virgo member in the famous seed sits at 11-20 Mpc. The wrong distance also +drags the wrong `diameterKpc` with it, so the point mispositions and +mis-sizes at once. Decide how the famous-seed pipeline should handle a galaxy +whose peculiar velocity swamps its Hubble-flow signal, since M90 is the +visible instance of a class, not a one-off. + +## Current state + +`tools/famous/expandFamousFromCatalogs.ts:384-392` (`distanceMpcFromHyperLeda`) +chains two HyperLEDA fields: + +1. `mod0` (true distance modulus) if `e_mod0 < MAX_MOD0_ERROR` (`0.3`, + `expandFamousFromCatalogs.ts:112`): `d_Mpc = 10^((mod0-25)/5)`. +2. else `v3k` (CMB-frame velocity) if positive: `d_Mpc = v3k / H0_KM_S_MPC` + (`H0_KM_S_MPC = 70`, `expandFamousFromCatalogs.ts:106`). + +The cached HyperLEDA row for NGC4569 (`data/raw/hyperleda/hyperleda_famous_cache.tsv:70`) +carries `mod0 = 30.37`, `e_mod0 = 0.35`, `v3k = 103`. `0.35 > 0.3` rejects +`mod0`, so the chain falls to `v3k / 70 = 103 / 70 = 1.4714285714285714…`, +which lands in the seed verbatim +(`data/seeds/famous_galaxies.seed.json:1275`, alongside `diameterKpc: +3.9035999332040086`). M89 and M91, the seed's immediate neighbours, sit at +`distanceMpc: 15.68…` and `15.62…` (`famous_galaxies.seed.json:1259,1292`), +both of which cleared the `mod0` gate. + +The rejected `mod0` is worth pricing: `10^((30.37-25)/5) = 11.9` Mpc. So the +error gate discards a measurement that misses its threshold by 0.05 mag and +would have placed M90 within a factor of 1.4 of the cluster, in favour of an +estimator that misses by a factor of 11. The gate scores each estimator's +own stated uncertainty in isolation and never compares the two candidates +against each other, so it cannot notice that its fallback disagrees with the +value it just rejected by an order of magnitude. + +The chain's own doc comment (`expandFamousFromCatalogs.ts:378-382`) already +names the failure mode for the opposite sign: Local Group galaxies with +`v3k < 0` are excluded by the `> 0` check because they're the ones falling +_toward_ us, and the comment assumes they always have a good `mod0` instead. +M90 is the counter-example the comment doesn't cover: it's a Virgo _infall_ +member, close enough that its CMB-frame velocity (103 km/s, mostly peculiar +motion toward the cluster, not Hubble flow) is small and positive, so it +clears the `> 0` check and reads as a 1.5 Mpc Local Group object instead of a +16 Mpc cluster member with a large negative peculiar velocity relative to the +Hubble flow at its true distance. `v3k / H0` is invalid precisely where +peculiar velocity is comparable to Hubble-flow velocity: the Virgo infall +region generally, not something specific to this one galaxy. + +**Blast radius beyond position.** `diameterKpc` is angular size times +distance, so the wrong distance also produces a diameter about 10x too +small. Both then feed the galaxy surface-brightness model +(`docs/backlog/2026-07-24-galaxy-surface-brightness-model.md`): +`src/utils/galaxy/galaxySbAmp.ts:36-41` divides by `(diameterKpc / +SB_REF_DIAMETER_KPC)^2`, and the absolute magnitude driving it is +`absoluteFromApparent(magB, distanceMpc)` +(`src/services/engine/bake/buildPointInterleavedBuffer.ts:308`, using +`magB` off the seed entry). Distance also sets the raw Cartesian position: +`tools/famous/buildFamous.ts:45-51` (`entryToXyz`) converts `(ra, dec, +distanceMpc)` directly to `(x, y, z)`. M90 currently renders in the wrong +place, undersized, and at the wrong brightness, all from the one bad +distance. + +**Re-running the pipeline reproduces it, so a hand-edit doesn't stick.** +`npm run famous-seed-from-leda -- NGC4569` (`tools/famous/famousSeedFromHyperleda.ts`) +calls the same `mergeIntoFamousEntry` (`expandFamousFromCatalogs.ts:445`) that +`expand-famous` uses, and its merge rule explicitly overwrites +`distanceMpc`/`diameterKpc`/`axisRatio`/`positionAngleDeg`/mags on every run, +preserving only `id`/`names`/`description` +(`expandFamousFromCatalogs.ts:434-440`). A hand-corrected `distanceMpc` in +`famous_galaxies.seed.json` is reverted the next time `expand-famous` runs +over the seed. + +**A precedent mechanism exists, but only on the survey path.** +`data/seeds/local_volume_distances.seed.json` is a curated +redshift-independent distance override for exactly this failure class: +entries already carry `method: "Tully-Fisher, Cosmicflows-3 (cz sign is bad +catalog data)"` and `method: "Virgo cluster mean"` +(`local_volume_distances.seed.json:24,36`). It's keyed by 2MASS `massId` +(not PGC, per its own header comment, `tools/catalog/loadLocalVolumeDistanceSeed.ts:15-17`) +and loaded into the survey bin build via +`loadLocalVolumeDistanceSeed()` (`tools/catalog/buildAllBins.ts:637`). The +famous seed path has no equivalent override, which is why M90 has nowhere +to be corrected that survives a re-run. + +A related but narrower mechanism is already documented for a different +sub-case: `.claude/skills/add-famous/SKILL.md:151-166` tells a human curator +to co-locate an _interacting pair_ at a CF4 group distance +(`table3.dat.gz` `DMav`) when the pair's members land at mismatched HyperLEDA +depths. That procedure is scoped to pairs sharing a tidal bridge; it doesn't +address a lone infall member like M90, though the CF4 lookup it describes is +the same kind of redshift-independent source a general fix would need. + +## Options + +- **A distance-override seed for the famous path**, mirroring + `local_volume_distances.seed.json`: a small curated file (keyed by PGC or + NGC id) that `expand-famous`/`mergeIntoFamousEntry` consults before falling + to `v3k`, and refuses to overwrite. Reuses a proven pattern, keeps the + correction auditable with a `method` string per entry. Costs a second + override file plus a merge-priority rule in the builder. +- **A guard in `distanceMpcFromHyperLeda`** that rejects the `v3k` fallback + below some velocity floor and falls through to a CF4 group distance + instead of `null`. Fixes the whole class, not one row, and the + `add-famous` skill already half-describes the CF4 `table3` `DMav` lookup + for pairs, so the machinery to reuse is partly built. Needs a defensible + velocity floor (Virgo's velocity dispersion is a few hundred km/s, so a + fixed cut has to clear that without also rejecting genuinely nearby, slow + Hubble-flow galaxies) and a CF4-by-PGC lookup on a path (famous) that + doesn't have one today. +- **Make the chain comparative rather than sequential.** Where both `mod0` + and `v3k` resolve, take the `mod0` value even past its error gate when the + two disagree by more than the gate could explain, on the reasoning that a + noisy direct measurement beats a systematically invalid proxy. Smallest + change of the three and it needs no new data source, but it only narrows + the failure rather than closing it: a galaxy with no `mod0` at all still + falls through to `v3k`. +- **Accept per-entry manual curation and document the trap.** Cheapest, but + as shown above it doesn't survive the next `expand-famous` run unless + paired with some preservation rule for the corrected field; without that + it's not really an option, just a temporary state. + +Landing any of these still requires `npm run build-famous` +(`tools/famous/buildFamous.ts`) and `npm run sync-r2-secure`: `famous.bin` +(`buildFamous.ts:220`) reaches production through R2, not git +(`docs/DEPLOY.md:16`). diff --git a/docs/backlog/2026-07-24-per-source-colour-gradient-spread.md b/docs/backlog/2026-07-24-per-source-colour-gradient-spread.md new file mode 100644 index 000000000..54ee01fc1 --- /dev/null +++ b/docs/backlog/2026-07-24-per-source-colour-gradient-spread.md @@ -0,0 +1,49 @@ +# Per-source colour-gradient spread + +**Status:** needs-design (2026-07-24) + +## Ask + +`DISK_TINT_SPREAD` is one shared ramp-space constant driving the warm-core / +cool-rim colour gradient for every galaxy catalog. Because the ramp-units-per- +magnitude conversion is per-source, the same constant produces a different +_physical_ colour gradient in each catalog. Derive the spread per source +instead of sharing one constant. + +## Current state + +- `src/services/gpu/shaders/lib/colorIndex.wesl:177,189-191` — `rampRadial(t, r)` + calls `ramp(t + DISK_TINT_SPREAD * (0.5 - r))` with `const DISK_TINT_SPREAD = +0.2`, shared by both galaxy passes (points, procedural-disk). +- `src/data/galaxyCatalog/colourIndex.ts:66` — `pickColourIndex` remaps each + source's raw colour onto the ramp's 0..2 range via + `((raw - rangeMin) / (rangeMax - rangeMin)) * 2`, so one magnitude of real + colour is worth `2 / (rangeMax - rangeMin)` ramp units, and that factor is + fixed per source by its own `colourSpec`. +- Per-source factors, read from each `colourSpec` in `src/data/sources/*.ts`: + 2MRS 5.00 (`twomrs.ts:20`, range 0.7–1.1), the three DESI sources 2.86 + (`desiDeep.ts:47`, `desiSgw.ts:43`, `desiWedge.ts:43`, range 0.35–1.05), SDSS + and Famous 1.33 (`sdss.ts:18`, `famous-galaxy.ts:26`, range 0.5–2.0), + Milliquas 1.00 (`milliquas.ts:35`, range 0.0–2.0), GLADE 0.67 + (`glade.ts:20`, range 0.5–3.5, the widest). +- The 0.2 value rests on a measured centre-to-edge gradient of ≈0.15 + mag (SDSS-DR4 spiral disk gradients + early-type d(g−r)/d log r + bulge−disc + colour offset), converted through the median per-source factor (1.33): + 0.15 × 1.33 ≈ 0.2. One shared constant applied against a per-source factor + means the effective physical gradient this produces today ranges from + ≈0.04 mag for 2MRS up to ≈0.30 mag for GLADE — a 7.5× spread in the actual + colour shift a viewer sees, despite every catalog using the same knob. + +## What needs decided + +- Replace the shared `DISK_TINT_SPREAD` with a per-source spread, derived + from each `colourSpec`'s own `rangeMax − rangeMin` and the measured + gradient for that source, so the physical gradient each catalog renders is + consistent rather than the ramp-space constant. +- The 0.15 mag figure is a g−r gradient. Applying it uniformly to 2MRS's J−K + or SDSS's u−g is itself an approximation — a per-band measured gradient + (where the literature has one) is the more honest input than reusing the + single g−r figure across every band. +- Where a per-band gradient isn't available (2MRS's J−K, GLADE's B−J, + Milliquas's B−R), decide whether to fall back to the g−r figure explicitly, + or flag those catalogs' gradients as approximate.