Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bfade96
feat(fonts): rebake atlas at distanceRange 16 for outline+glow headroom
rulkens May 20, 2026
9392632
feat(labels): migrate Label.color to straight RGBA, premultiply on pack
rulkens May 20, 2026
5274f3d
fix(fonts): update font registry test for distanceRange 16 rebake
rulkens May 20, 2026
018d8e7
test(poi): tripwire that POI_STYLES.labelColor alpha stays 1
rulkens May 20, 2026
aeedf9d
feat(labels): declare optional outline + glow fields on Label type
rulkens May 20, 2026
5b1b2ae
feat(labels): grow per-label storage 48→96 bytes for outline+glow
rulkens May 20, 2026
3a8a432
feat(labels/shader): grow LabelData, expand quad for effect fringe
rulkens May 20, 2026
94ffac5
docs(labels/shader): drop date-stamped history note from vertex.wesl
rulkens May 20, 2026
eacd402
feat(labels/shader): three-band composite glow+outline+fill
rulkens May 20, 2026
e301ead
docs(labels/shader): restore MSDF + texture-array didactic context
rulkens May 20, 2026
d4abb53
feat(labels): add labelStyleOverride module for debug-panel tuning
rulkens May 20, 2026
2fa10b9
feat(labels): apply labelStyleOverride from youAreHere + POI producers
rulkens May 20, 2026
a7f33ed
feat(labels): version-bump override so director re-flushes on edit
rulkens May 20, 2026
950f63b
feat(debug): add LabelEffectsSection live-tuning controls
rulkens May 20, 2026
8863df6
fix(debug): clear label override on LabelEffectsSection unmount
rulkens May 20, 2026
110bf7b
fix(labels): wake render-on-demand loop on label-style override edit
rulkens May 20, 2026
53a39e4
fix(labels): widen atlas padding so glow doesn't bleed into neighbours
rulkens May 20, 2026
ac96559
feat(labels): bake drop-shadow outline into producer defaults
rulkens May 20, 2026
e5435e1
feat(debug): hide glow controls — MSDF composite artefacts at width
rulkens May 20, 2026
810ea34
refactor(labels): remove glow infrastructure, shrink storage 96 to 64…
rulkens May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,168 changes: 584 additions & 584 deletions public/fonts/cormorant.json

Large diffs are not rendered by default.

Binary file modified public/fonts/cormorant.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 28 additions & 1 deletion src/@types/rendering/Label.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,35 @@ export type Label = {
* producers have migrated to `worldEmMpc`.
*/
readonly pixelSize: number;
/** RGBA premultiplied, defaults to [1,1,1,1]. */
/**
* Straight (non-premultiplied) RGBA fill colour, default `[1, 1, 1, 1]`.
*
* ## Convention
*
* Spell the colour the natural way — `[1, 0, 0, 0.5]` is
* "half-transparent red". The renderer's pack loop multiplies
* `rgb * a` on write before uploading to the GPU storage buffer; the
* fragment shader composites in premultiplied space. Producers
* therefore never have to think about premultiplication.
*
* The outline/glow colour fields below follow the same straight-RGBA
* convention — uniformity across the colour API surface is the whole
* point of carrying out this migration alongside the effects work.
*/
readonly color?: Vec4;
/**
* Outside-stroke outline colour (straight RGBA — renderer
* premultiplies on write). Default `[0, 0, 0, 0]`, which combined
* with `outlineEmFrac = 0` collapses the outline band to zero
* contribution. Composited OVER the fill in premultiplied space.
*/
readonly outlineColor?: Vec4;
/**
* Outline width as a fraction of the projected em height. Default
* `0`. Em-fraction so the outline scales naturally with the label's
* perspective-driven sizing clamp.
*/
readonly outlineEmFrac?: number;
/**
* Floor clamp on the projected em height in screen pixels (default 8).
* When the perspective projection of `worldEmMpc` falls below this
Expand Down
3 changes: 3 additions & 0 deletions src/components/DebugPanel/DebugPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { AssetLoadingSection } from './AssetLoadingSection';
import { GpuTimingsSection } from './GpuTimingsSection';
import { RenderTogglesSection } from './RenderTogglesSection';
import { DataQualitySection } from './DataQualitySection';
import { LabelEffectsSection } from './LabelEffectsSection';

export type DebugPanelProps = {
slots: ReadonlyMap<string, AssetSlot<unknown, unknown>>;
Expand Down Expand Up @@ -82,6 +83,8 @@ export function DebugPanel({
onHighlightFallbackChange={onHighlightFallbackChange}
onRealOnlyModeChange={onRealOnlyModeChange}
/>
<div style={{ marginTop: 6 }} />
<LabelEffectsSection />
</div>
);
}
80 changes: 80 additions & 0 deletions src/components/DebugPanel/LabelEffectsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* LabelEffectsSection — live-tuning controls for the label outline.
*
* Pick a target category, tune outline colour + width, then bake the
* values into `POI_STYLES.<cat>` or `youAreHereSubsystem.ts`. The
* override is a temporary hook, not a storage location.
*
* `setLabelStyleOverride` runs in `useEffect`, not during render —
* side effects during render trigger strict-mode double-fires.
*/

import { useEffect, useState, type ReactElement } from 'react';
import {
setLabelStyleOverride,
clearLabelStyleOverride,
type LabelStyleOverrideTarget,
} from '../../services/engine/labelStyleOverride';
import type { Vec4 } from '../../@types/math/Vec4';

const CATEGORIES: readonly LabelStyleOverrideTarget[] = [
'youAreHere',
'cluster',
'supercluster',
'famousGalaxy',
'void',
];

function hexToRgb(hex: string): [number, number, number] {
const m = /^#?([0-9a-f]{6})$/i.exec(hex);
if (!m) return [1, 1, 1];
const n = parseInt(m[1]!, 16);
return [((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255];
}

export function LabelEffectsSection(): ReactElement {
const [target, setTarget] = useState<LabelStyleOverrideTarget | ''>('');
const [outlineHex, setOutlineHex] = useState('#000000');
const [outlineAlpha, setOutlineAlpha] = useState(0.1);
const [outlineEmFrac, setOutlineEmFrac] = useState(0.16);

// Cleanup clears the override on unmount so closing the panel
// mid-tune restores producer-default styling.
useEffect(() => {
if (target === '') {
clearLabelStyleOverride();
return;
}
const [or, og, ob] = hexToRgb(outlineHex);
const outlineColor: Vec4 = [or, og, ob, outlineAlpha];
setLabelStyleOverride({ targetCategory: target, outlineColor, outlineEmFrac });
return () => clearLabelStyleOverride();
}, [target, outlineHex, outlineAlpha, outlineEmFrac]);

const labelStyle = { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } as const;
return (
<details>
<summary style={{ fontWeight: 'bold', cursor: 'pointer' }}>Label Effects</summary>
<div style={{ marginTop: 4, display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={labelStyle}>
<span style={{ width: 70 }}>Target</span>
<select value={target} onChange={(e) => setTarget(e.target.value as LabelStyleOverrideTarget | '')}>
<option value="">(off)</option>
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</label>
<label style={labelStyle}>
<span style={{ width: 70 }}>Outline</span>
<input type="color" value={outlineHex} onChange={(e) => setOutlineHex(e.target.value)} />
<input type="range" min={0} max={1} step={0.01} value={outlineAlpha} onChange={(e) => setOutlineAlpha(parseFloat(e.target.value))} />
<span style={{ width: 30 }}>{outlineAlpha.toFixed(2)}</span>
</label>
<label style={labelStyle}>
<span style={{ width: 70 }}>Out width</span>
<input type="range" min={0} max={0.28} step={0.005} value={outlineEmFrac} onChange={(e) => setOutlineEmFrac(parseFloat(e.target.value))} />
<span style={{ width: 40 }}>{outlineEmFrac.toFixed(3)}</span>
</label>
</div>
</details>
);
}
29 changes: 22 additions & 7 deletions src/data/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,29 @@ export const ATLAS_PX = 512;

/**
* MSDF distance range in pixels. Controls how wide the signed-distance
* field around each glyph edge extends. The fragment shader's
* `fwidth`-based smoothstep band is exactly one pixel wide for any
* scale, regardless of this value — but a too-small range produces
* visible banding at extreme upscales and a too-large range wastes
* atlas pixels. 4 is the msdf-bmfont-xml default and reads cleanly
* from 12 px (`Label.minPixelSize`) up to 64 px (`maxPixelSize`).
* field around each glyph edge extends, i.e. the maximum off-edge
* distance the atlas can faithfully encode. The body-fill fragment
* shader's `fwidth`-based smoothstep band is exactly one pixel wide
* for any scale regardless of this value — but outline and glow
* effects sample the SDF *past* the glyph contour, and any distance
* beyond `±DISTANCE_RANGE_PX / 2` clamps at the texel boundary,
* cutting off the falloff tail.
*
* 16 is sized for the per-label outline + glow pass. Glow extents
* scale with `maxPixelSize` (60 px) and reach ~12 px past the glyph
* edge in the worst case; add ~2 px of outline and we need at least
* 14 px of encoded headroom on either side. Choosing 16 (so ±8 px
* on each side) keeps ~25% margin past the worst-case effect extent
* while still fitting the 95-glyph charset into the 512² atlas.
*
* The previous value 4 (msdf-bmfont-xml's default) was sized only
* for the smoothstep body-fill band and clamped the soft glow tail
* to a hard step a couple of pixels past the contour. Shader-side
* SDF-units math (e.g. `widthInSdfUnits = (emFrac * ATLAS_EM_PX) /
* DISTANCE_RANGE_PX`) bakes this constant in too, so changing it
* requires regenerating the atlas via `npm run build-fonts`.
*/
export const DISTANCE_RANGE_PX = 4;
export const DISTANCE_RANGE_PX = 16;

/**
* Em-size of glyphs in atlas pixels at the source SDF resolution.
Expand Down
11 changes: 11 additions & 0 deletions src/services/engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import { createSelectionSubsystem } from './subsystems/selectionSubsystem';
import { createBiasCorrectionSubsystem } from './subsystems/biasCorrectionSubsystem';
import { createYouAreHereSubsystem } from './subsystems/youAreHereSubsystem';
import { createLabelDirectorSubsystem } from './subsystems/labelDirectorSubsystem';
import { registerLabelStyleOverrideWake } from './labelStyleOverride';
import { createPoiSubsystem } from './subsystems/poiSubsystem';
import { createFpsCounter } from './subsystems/fpsCounter';
import { HDR_PASSES, UI_PASSES } from './frame/passes';
Expand Down Expand Up @@ -698,6 +699,16 @@ export function createEngine(canvas: HTMLCanvasElement, cb: EngineCallbacks): En
state.subsystems.labelDirector.registerProducer(state.subsystems.youAreHere);
state.subsystems.labelDirector.registerProducer(state.subsystems.pois);

// ── Wake on label-style override edits ────────────────────────────────
//
// The DebugPanel's LabelEffectsSection writes to `labelStyleOverride`,
// which bumps a version counter that the label director reads from its
// signature hash. But render-on-demand only consults that hash inside
// an active frame — slider edits at idle would sit invisible until the
// user nudged the camera. Registering scheduler.requestRender here
// closes the loop: every set/clear wakes the loop on the next tick.
registerLabelStyleOverrideWake(() => state.subsystems.scheduler.requestRender());

// ── Cleanup function returned by `attachOrbitControls` ─────────────────
// Orbit-controls attachment lives outside `inputBindings` because it
// needs a fully-constructed OrbitCamera which doesn't exist at
Expand Down
108 changes: 108 additions & 0 deletions src/services/engine/labelStyleOverride.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* labelStyleOverride — process-wide, single-slot live-tuning hook for
* the DebugPanel's LabelEffectsSection.
*
* ### Why module-scoped mutable state?
*
* The override is a developer-only debug hook: while the DebugPanel
* has it on, every label-emitting subsystem consults the current
* value at frame-build time and substitutes the override's outline +
* glow fields for its own producer defaults. React state in the
* panel component is the wrong shape because the engine's per-frame
* code runs outside React's render loop and would need a ref or
* useEffect to read the current values; a plain module-scoped object
* is read directly by every producer with zero ceremony.
*
* ### Why a single slot, not a per-category record?
*
* The workflow is "select category, tune, bake into POI_STYLES, move
* to next category". A per-category record would invite the user to
* leave overrides stale across category switches; the single slot
* makes the active target unambiguous.
*
* ### Why default targetCategory = null?
*
* Production startup should never accidentally apply an override.
* The DebugPanel only exists in DEV builds or when ?debug is in the
* URL, so a non-DEV runtime never even calls `setLabelStyleOverride`.
* Defaulting to null means "no producer matches" and the override is
* completely inert until a developer opens the panel and picks a
* category.
*/

import type { Vec4 } from '../../@types/math/Vec4';
import type { PoiCategory } from './subsystems/poiSubsystem';

/**
* The set of label-emitting categories the override can target.
* Mirrors the dropdown in `LabelEffectsSection.tsx` — keep in sync.
*/
export type LabelStyleOverrideTarget = 'youAreHere' | PoiCategory;

/**
* Read-only snapshot of the current override. `targetCategory` is
* null when the override is inactive.
*/
export type LabelStyleOverride = {
readonly targetCategory: LabelStyleOverrideTarget | null;
readonly outlineColor: Vec4;
readonly outlineEmFrac: number;
};

// The single mutable slot. Reassigned (not mutated in place) by
// `setLabelStyleOverride` so any consumer that captured the prior
// reference sees a stable snapshot for the duration of one frame.
let current: LabelStyleOverride = {
targetCategory: null,
outlineColor: [0, 0, 0, 0],
outlineEmFrac: 0,
};

// Monotonic version counter — incremented on every set/clear. The
// label director includes this in its signature hash so an override
// edit triggers a re-flush even when the merged label set is
// id+fadeAlpha-stable. Cheaper than a listener channel and impossible
// to leak (no subscribers to forget to dispose).
let version = 0;

// Wake callback — the engine bootstrap registers a closure that calls
// `scheduler.requestRender()`. Without this, the version bump only
// causes a re-flush IF a frame happens to run; render-on-demand sits
// idle until the user nudges the mouse. Registration is module-scoped
// because the override has no constructor seam to receive deps.
let wake: (() => void) | null = null;

export function getLabelStyleOverride(): LabelStyleOverride {
return current;
}

export function getLabelStyleOverrideVersion(): number {
return version;
}

export function setLabelStyleOverride(next: LabelStyleOverride): void {
current = next;
version++;
wake?.();
}

export function clearLabelStyleOverride(): void {
current = {
targetCategory: null,
outlineColor: [0, 0, 0, 0],
outlineEmFrac: 0,
};
version++;
wake?.();
}

/**
* Register a wake callback fired on every override set/clear. The
* engine's bootstrap wires this to `scheduler.requestRender()` so a
* DebugPanel slider edit wakes the render-on-demand loop in addition
* to bumping the director's signature hash. Tests can leave this
* unregistered — the version counter still works.
*/
export function registerLabelStyleOverrideWake(fn: () => void): void {
wake = fn;
}
17 changes: 15 additions & 2 deletions src/services/engine/subsystems/labelDirectorSubsystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type { EngineState } from '../../../@types/engine/state/EngineState';
import type { Destroyable } from '../../../@types/rendering/Destroyable';
import type { LabelProducer } from '../../../@types/engine/subsystems/LabelProducer';
import type { LabelDirectorSubsystem } from '../../../@types/engine/subsystems/LabelDirectorSubsystem';
import { getLabelStyleOverrideVersion } from '../labelStyleOverride';

export function createLabelDirectorSubsystem(): LabelDirectorSubsystem {
let labelRenderer: LabelRenderer | null = null;
Expand All @@ -64,7 +65,10 @@ export function createLabelDirectorSubsystem(): LabelDirectorSubsystem {
}

function signatureOf(labels: readonly Label[], lines: readonly MarkerLine[]): string {
// Cheap stable signature: per-entry `id:fadeAlpha`, joined.
// Cheap stable signature: per-entry `id:fadeAlpha`, joined, plus a
// trailing `;O:<version>` term that tracks the labelStyleOverride
// module's monotonic version counter.
//
// Re-upload triggers when ids/count change OR when any entry's
// `fadeAlpha` differs from the prior frame. Including `fadeAlpha`
// matters because the `youAreHereSubsystem` keeps the same `id`
Expand All @@ -75,6 +79,15 @@ export function createLabelDirectorSubsystem(): LabelDirectorSubsystem {
// appears at e.g. 0.1 alpha and never brightens as the camera
// closes in.)
//
// The override-version term forces a re-flush whenever the
// DebugPanel's LabelEffectsSection mutates `labelStyleOverride`.
// Producers consult the override at frame-build time to swap in
// outline+glow fields, but the producer's resulting Label objects
// still carry the same `id` and `fadeAlpha`, so without this term
// the director would short-circuit and a slider edit would have no
// visible effect until something else (camera motion, fade) bumped
// the signature.
//
// We deliberately DON'T include world positions or colours — the
// glyph layout in `labelRenderer.setLabels` is the expensive
// step we're protecting; static-position producers (youAreHere,
Expand All @@ -86,7 +99,7 @@ export function createLabelDirectorSubsystem(): LabelDirectorSubsystem {
// from POI name which is part of the id space.
const lIds = labels.map((l) => `${l.id}:${l.fadeAlpha ?? 1}`).join('|');
const mIds = lines.map((m) => `${m.id}:${m.fadeAlpha ?? 1}`).join('|');
return `L:${labels.length}:${lIds};M:${lines.length}:${mIds}`;
return `L:${labels.length}:${lIds};M:${lines.length}:${mIds};O:${getLabelStyleOverrideVersion()}`;
}

function runFrame(state: EngineState, ctx: ReadyFrameContext): void {
Expand Down
Loading
Loading