Triangle LED Hero
Analytic edge-glow triangle with LED emitters, floor radiance, and interactive color deploy. Canvas-scoped pointer input drives the lighting while an accessible mode selector chooses highlighted edges.
index.tsxcontrols.tsxtypes.tsrenderer.tsscene-renderer.tslight-sources-raw.tslight-sources-pass.tsled-buffer.tssettings.tshero-frame-state.tsdirect-triangle-raycast.tsvalue-noise.tstriangle-hit.tssim-sizing.tsshaders/light-sources.wgslshaders/led-emitters.wgslshaders/direct-triangle-raycast.wgslshaders/floor-noise.wgslshaders/color-utils.wgslshaders/geometry.wgslshaders/floor-falloff.wgslshaders/hash.wgslshaders/themes/dark/main-scene-floor.wgslshaders/themes/light/main-scene-floor.wgsl
import type { Bundle, Frame, FramePass, Gpu, StorageBuffer, Target } from 'vgpu';
import type { CascadeFitRect, RenderSize } from './settings';
import {
DARK_FLOOR_DEFAULTS,
DARK_POSTPROCESS_DEFAULTS,
HERO_CANVAS_MAX_CSS,
LEDS_PER_EDGE,
LIGHT_AO_DEFAULTS,
LIGHT_GLOW_DEFAULTS,
PROBE_DENSITY,
PROBE_DISCARD_DEFAULTS,
RADIANCE_DEBUG_DEFAULTS,
TUNABLE_DEFAULTS,
canonicalTriangleGeometry,
cascadeCount,
cascadeFitRect,
clampProbeDensity,
getHeroEdgeFadeFrac,
ledMeshGeometry,
resolveHeroSceneScale,
setHeroEdgeFadeFrac,
setHeroSceneScale,
triangleLedRadius,
triangleLedShapeDimensions,
type DarkFloorSettings,
type DarkPostprocessSettings,
type HeroStateSettings,
type LightAoSettings,
type LightGlowSettings,
type ProbeDiscardSettings,
type RadianceDebugSettings,
} from './settings';
import {
DIRECT_TRIANGLE_ABSORPTION,
DIRECT_TRIANGLE_FALLOFF_POWER,
DIRECT_TRIANGLE_HIT_THRESHOLD_PX,
DIRECT_TRIANGLE_INTENSITY_SCALE,
DIRECT_TRIANGLE_MIN_SOURCE_LUMA,
DIRECT_TRIANGLE_MIN_STEP_PX,
DIRECT_TRIANGLE_TARGET_SCALE,
directTriangleTargetSize,
} from './direct-triangle-raycast';
import {
buildLedGeometry,
computeLeds,
type LedGeometryState,
} from './led-buffer';
import {
createHeroFrameState,
type FrameTheme,
type ResolveFrameOptions,
} from './hero-frame-state';
import {
type BrushState,
type SceneTunables,
} from './light-sources-pass';
import {
canvasRenderSizing,
} from './sim-sizing';
import {
createLightSourcesRaw,
type LightSourcesRaw,
} from './light-sources-raw';
import raycastWgsl from './shaders/direct-triangle-raycast.wgsl';
import floorNoiseWgsl from './shaders/floor-noise.wgsl';
import darkFloorWgsl from './shaders/themes/dark/main-scene-floor.wgsl';
import lightFloorWgsl from './shaders/themes/light/main-scene-floor.wgsl';
import { bundle, draw, frame, sampler, storage, target } from "vgpu";
export interface HeroRendererCss {
width: number;
height: number;
dpr: number;
}
export interface HeroRenderFrameArgs {
/** Monotonically increasing seconds. Kept explicit so renderThumb can be deterministic. */
time: number;
dt?: number;
}
export interface HeroRenderer {
/** Encode the fixed frame graph into an existing facade frame. */
renderFrame(currentFrame: Frame, args: HeroRenderFrameArgs): void;
/** Surface or offscreen Target. The same floor pass path renders into either. */
setOutputTarget(colorTarget: Target): void;
/** Synchronous docs-only resize rebuild (may hitch one frame versus production's async holder). */
rebuild(css: HeroRendererCss): void;
setBrush(b: Partial<BrushState>): void;
setHero(state: Partial<HeroStateSettings>): void;
setRgbDeployActive(v: boolean): void;
readonly hero: Partial<HeroStateSettings>;
prewarm(): Promise<void>;
destroy(): void;
}
interface RendererParts {
simulationSize: RenderSize;
presentationSize: RenderSize;
pixelRatio: number;
probeDensity: number;
cascadeFit: CascadeFitRect;
leds: LedGeometryState;
ledStorage: DestroyableStorageBuffer;
lightSourcesRaw: LightSourcesRaw;
raycastTarget: Target;
raycastBundle: Bundle;
floorGate: FloorUniformGate;
floorUniformReady: boolean;
lightGlowUniformReady: boolean;
floorBundles?: FloorBundles;
}
type DestroyableStorageBuffer = StorageBuffer & {
readonly buffer?: { destroy(): void };
destroy?: () => void;
};
interface FloorBundles {
readonly target: Target;
readonly light: Bundle;
readonly dark: Bundle;
}
export function createHeroRenderer(
gpu: Gpu,
opts: { theme?: FrameTheme; css: HeroRendererCss; target?: Target },
): HeroRenderer {
const theme = opts.theme ?? 'dark';
const heroFrameState = createHeroFrameState();
const hero: Partial<HeroStateSettings> = {};
const brush: Partial<BrushState> = {};
const options: ResolveFrameOptions & { renderBlackOccluder?: boolean; showForegroundTriangle?: boolean } = {};
let rgbDeployActive = false;
let outputTarget = opts.target;
let destroyed = false;
const samplerState = sampler(gpu, { minFilter: 'linear', magFilter: 'linear' });
const noiseTarget = target(gpu, {
size: [500, 500],
format: 'rgba16float',
label: 'triangle-led-front-floor-noise',
});
const noiseDraw = draw(gpu, { shader: floorNoiseWgsl, vertices: 3 });
const lightFloorDraw = draw(gpu, { shader: lightFloorWgsl, vertices: 3 });
const darkFloorDraw = draw(gpu, { shader: darkFloorWgsl, vertices: 3 });
const raycastDraw = draw(gpu, { shader: raycastWgsl, vertices: 3 });
// Bake the time-invariant floor-noise target once. It is rebuilt only if this renderer is rebuilt.
frame(gpu, (currentFrame: Frame) => {
currentFrame.pass({ target: noiseTarget }, (pass: FramePass) => pass.draw(noiseDraw));
});
let parts = buildParts(opts.css);
if (outputTarget) recordFloorBundles(parts, outputTarget);
const api: HeroRenderer = {
hero,
renderFrame(currentFrame, args) {
if (destroyed) return;
if (!outputTarget) return;
const currentParts = parts;
const currentTheme = theme;
const resolved = heroFrameState.resolveFrame({
patch: brush,
tunables: TUNABLE_DEFAULTS,
options: { ...options, hero, hoverRgbDeployActive: rgbDeployActive },
probeDiscard: undefined,
theme: currentTheme,
time: args.time,
updateLedsFor: (ctx) => {
const transition = computeLeds(
currentParts.leds,
ctx.time,
ctx.tunables,
ctx.settings,
ctx.hoverDeploy,
ctx.brush,
ctx.theme,
);
// CPU LED upload mirrors SRC/core/renderer.ts:561-566. The facade storage buffer is owned
// here and consumed by Stage 2's light-sources pass.
currentParts.ledStorage.write(currentParts.leds.data.buffer as ArrayBuffer);
return transition;
},
});
// Frame graph order mirrors SRC/core/renderer.ts:595-618. Light sources,
// raycast, and floor are recorded into this facade frame so WebGPU inserts
// the required render_attachment -> texture_binding barriers in one submit.
currentParts.lightSourcesRaw.encode({
frame: currentFrame,
brush: resolved.brush,
time: args.time,
tunables: resolved.tunables,
renderBlackOccluder: options.renderBlackOccluder ?? true,
});
currentFrame.pass(
{ target: currentParts.raycastTarget, clear: [0, 0, 0, 1] },
(pass: FramePass) => pass.bundles(currentParts.raycastBundle),
);
if (currentTheme === 'light') {
lightFloorDraw.set({
lg: lightGlowUniformData(resolved.lightGlow, resolved.colorMix),
});
currentParts.lightGlowUniformReady = true;
}
const floorData = writeFloorUniformIfNeeded(
currentParts,
{
width: currentParts.presentationSize.width,
height: currentParts.presentationSize.height,
tunables: resolved.tunables,
probeDiscard: resolved.probeDiscard,
theme: currentTheme,
lightAo: resolved.lightAo,
radianceDebug: resolved.radianceDebug,
darkFloor: resolved.darkFloor,
darkPostprocess: resolved.darkPostprocess,
showForegroundTriangle: options.showForegroundTriangle ?? true,
},
);
if (floorData) {
lightFloorDraw.set({ cfg: floorData });
darkFloorDraw.set({ cfg: floorData });
currentParts.floorUniformReady = true;
}
const floorBundles = currentParts.floorBundles ?? recordFloorBundles(currentParts, outputTarget);
currentFrame.pass(
{ target: outputTarget, clear: [0, 0, 0, 1] },
(pass: FramePass) => pass.bundles(
currentTheme === 'light' ? floorBundles.light : floorBundles.dark,
),
);
},
setOutputTarget(colorTarget) {
outputTarget = colorTarget;
recordFloorBundles(parts, colorTarget);
},
rebuild(css) {
if (destroyed) return;
// setHeroSceneScale/setHeroEdgeFadeFrac are module globals shared by run() and renderThumb().
// Re-set them before every synchronous docs rebuild so geometry and floor packing agree.
const next = buildParts(css, parts.leds);
destroyParts(parts);
parts = next;
if (outputTarget) recordFloorBundles(parts, outputTarget);
},
setBrush(b) {
Object.assign(brush, b);
},
setHero(state) {
Object.assign(hero, state);
},
setRgbDeployActive(v) {
rgbDeployActive = v;
},
async prewarm() {
const colorTarget = outputTarget;
if (!colorTarget) return;
const ready = parts.lightSourcesRaw.ready;
await Promise.all([
lightFloorDraw.compile({ colors: [colorTarget.format] }),
darkFloorDraw.compile({ colors: [colorTarget.format] }),
raycastDraw.compile(parts.raycastTarget),
noiseDraw.compile(noiseTarget),
ready ?? Promise.resolve(),
]);
},
destroy() {
if (destroyed) return;
destroyed = true;
destroyParts(parts);
},
};
return api;
function recordFloorBundles(currentParts: RendererParts, colorTarget: Target): FloorBundles {
const lightSet: Record<string, unknown> = {
radiance_tex: currentParts.raycastTarget,
light_sources_tex: currentParts.lightSourcesRaw.texture,
linear_samp: samplerState,
};
const darkSet: Record<string, unknown> = {
radiance_tex: currentParts.raycastTarget,
light_sources_tex: currentParts.lightSourcesRaw.texture,
linear_samp: samplerState,
floor_noise_tex: noiseTarget,
};
if (!currentParts.floorUniformReady) {
const initialFloor = initialFloorUniformData();
lightSet.cfg = initialFloor;
darkSet.cfg = initialFloor;
currentParts.floorUniformReady = true;
}
if (!currentParts.lightGlowUniformReady) {
lightSet.lg = initialLightGlowUniformData();
currentParts.lightGlowUniformReady = true;
}
lightFloorDraw.set(lightSet);
darkFloorDraw.set(darkSet);
const floorBundles: FloorBundles = {
target: colorTarget,
light: bundle(gpu, { target: { colors: [colorTarget.format] }, label: 'triangle-led-front-light-floor' },
(recorded) => recorded.draw(lightFloorDraw),
),
dark: bundle(gpu, { target: { colors: [colorTarget.format] }, label: 'triangle-led-front-dark-floor' },
(recorded) => recorded.draw(darkFloorDraw),
),
};
currentParts.floorBundles = floorBundles;
return floorBundles;
}
function buildParts(css: HeroRendererCss, previousLeds?: LedGeometryState): RendererParts {
// Deliberate simplification for docs: resize rebuilds synchronously and may hitch one frame;
// production held the previous frame while rebuilding asynchronously. Steady-state bytes match.
setHeroSceneScale(resolveHeroSceneScale(1, css.height, false));
setHeroEdgeFadeFrac(0.2);
const sizing = canvasRenderSizing(
css.width,
css.height,
css.dpr,
PROBE_DENSITY,
undefined,
);
const simulationSize = normalizedSize(
sizing.simulationWidth,
sizing.simulationHeight,
);
const presentationSize = normalizedSize(css.width * css.dpr, css.height * css.dpr);
const pixelRatio = normalizedPixelRatio(sizing.pixelRatio);
const probeDensity = clampProbeDensity(sizing.probeDensity);
const count = cascadeCount(simulationSize);
void count; // Kept for parity with the source size derivation; direct raycast uses full fit.
const fit = cascadeFitRect(simulationSize, probeDensity);
const leds = buildLedGeometry(simulationSize, previousLeds);
const ledStorage = storage(gpu, 72 * 8 * 4) as DestroyableStorageBuffer;
ledStorage.write(leds.data.buffer as ArrayBuffer);
const raycastSize = directTriangleTargetSize(simulationSize);
const raycastTarget = target(gpu, {
size: [raycastSize.width, raycastSize.height],
format: 'rgba16float',
label: 'triangle-led-front-direct-triangle-raycast',
});
const triangle = canonicalTriangleGeometry(simulationSize);
const lightSourcesRaw = createLightSourcesRaw(gpu, {
size: [simulationSize.width, simulationSize.height] as const,
ledStorage,
triangle,
ledRadius: triangleLedRadius(simulationSize),
ledShape: triangleLedShapeDimensions(simulationSize, LEDS_PER_EDGE),
});
raycastDraw.set({
cfg: directTriangleRaycastUniformData(simulationSize),
light_sources_tex: lightSourcesRaw.texture,
});
const raycastBundle = bundle(gpu, { target: raycastTarget, label: 'triangle-led-front-raycast' },
(recorded) => recorded.draw(raycastDraw),
);
return {
simulationSize,
presentationSize,
pixelRatio,
probeDensity,
cascadeFit: fit,
leds,
ledStorage,
lightSourcesRaw,
raycastTarget,
raycastBundle,
floorGate: createFloorUniformGate(),
floorUniformReady: false,
lightGlowUniformReady: false,
};
}
function destroyParts(p: RendererParts): void {
p.lightSourcesRaw.destroy();
p.ledStorage.destroy?.();
p.ledStorage.buffer?.destroy();
const raycastTexture = p.raycastTarget?.color?.gpu ?? p.raycastTarget?.gpu;
raycastTexture?.destroy?.();
}
}
function directTriangleRaycastUniformData(simulationSize: RenderSize) {
const size = directTriangleTargetSize(simulationSize);
// Cast rays at the inset LED-mesh triangle (where the emitters actually are), NOT the
// canonical edge — otherwise ray hits land in the gap between the inset LEDs and the
// full-size occluder, sampling LED/background inconsistently and serrating the glow.
const triangle = ledMeshGeometry(simulationSize);
// 20 floats (5 vec4f), all static — written once below.
const tri_a_b = [triangle.top.x, triangle.top.y, triangle.left.x, triangle.left.y];
const tri_c_target = [triangle.right.x, triangle.right.y, size.width, size.height];
// size_steps.z/w (min ray step + edge hit threshold) are absolute sim px. On a short sim a
// fixed px is a LARGER fraction of the scene, so near-edge pixels skip the nearest LED hit
// (min-step cutoff) and instead pick up a more distant, different-hued LED — over-mixing hues
// → desaturation. Scale both by the sim height as a fraction of the desktop cap so they are a
// CONSTANT fraction of the scene. >= cap → factor 1 (desktop byte-identical), like the
// geometric falloff (target_info.y) and the per-px absorption (params.x) already do.
const pxStepScale =
Math.min(simulationSize.height, HERO_CANVAS_MAX_CSS) / HERO_CANVAS_MAX_CSS;
const size_steps = [
simulationSize.width,
simulationSize.height,
DIRECT_TRIANGLE_MIN_STEP_PX * pxStepScale,
DIRECT_TRIANGLE_HIT_THRESHOLD_PX * pxStepScale,
];
const params = [
// params.x: Beer-Lambert absorption per sim px (extinction-over-sim-height / height).
DIRECT_TRIANGLE_ABSORPTION / simulationSize.height,
DIRECT_TRIANGLE_FALLOFF_POWER,
DIRECT_TRIANGLE_INTENSITY_SCALE,
DIRECT_TRIANGLE_MIN_SOURCE_LUMA,
];
// target_info.y: geometric-falloff distance scale = reference height / sim height, so the
// pow(distance) falloff depends on distance as a FRACTION of the scene size (resolution-
// independent radiance) instead of raw sim px. Anchored to HERO_CANVAS_MAX_CSS so the
// desktop look is unchanged; shorter render targets no longer read brighter/whiter.
const target_info = [
DIRECT_TRIANGLE_TARGET_SCALE,
HERO_CANVAS_MAX_CSS / Math.max(simulationSize.height, 1),
0,
0,
];
return { tri_a_b, tri_c_target, size_steps, params, target_info };
}
interface FloorUniformInputs {
width: number;
height: number;
tunables: SceneTunables;
probeDiscard: ProbeDiscardSettings;
theme: 'dark' | 'light';
lightAo: LightAoSettings;
radianceDebug: RadianceDebugSettings;
darkFloor: DarkFloorSettings;
darkPostprocess: DarkPostprocessSettings;
showForegroundTriangle: boolean;
}
interface FloorUniformGate {
currentFloorWidth: number;
currentFloorHeight: number;
currentFloorTheme: 'dark' | 'light' | undefined;
currentDarkFloorAlbedo: number;
currentLightFloorAlbedo: number;
currentLedIntensity: number;
currentFloorLightAabbPadding: number;
currentLightAoRadiance: number;
currentLightAoContactOpacity: number;
currentLightAoContactSize: number;
currentLightAoContactFalloffPower: number;
currentLightAoHighlightPower: number;
currentLightAoHighlightStrength: number;
currentFloorRadianceDebugEnabled: boolean | undefined;
currentFloorRadianceDebugRed: number;
currentFloorRadianceDebugGreen: number;
currentFloorRadianceDebugBlue: number;
currentDarkFloorSdfFadeDistanceScale: number;
currentDarkFloorSdfFadeEdgePx: number;
currentDarkFloorNearFalloffPower: number;
currentDarkFloorNearFalloffDistanceScale: number;
currentDarkFloorNearFalloffIntensity: number;
currentDarkFloorNearFalloffMapMin: number;
currentDarkFloorNearFalloffMapMax: number;
currentDarkFloorTailIntensity: number;
currentDarkFloorTailMapMin: number;
currentDarkFloorTailMapMax: number;
currentDarkFloorTailPower: number;
currentDarkFloorMiddleFalloffPower: number;
currentDarkFloorMiddleFalloffIntensity: number;
currentDarkFloorNearFalloffEnabled: boolean | undefined;
currentDarkFloorMiddleFalloffEnabled: boolean | undefined;
currentDarkFloorFarFalloffEnabled: boolean | undefined;
currentDarkFloorNoiseEnabled: boolean | undefined;
currentDarkFloorRadianceJitterPx: number;
currentDarkFloorVibrancy: number;
currentDarkPostprocessContrast: number;
currentDarkPostprocessExposure: number;
currentShowForegroundTriangle: boolean | undefined;
}
function createFloorUniformGate(): FloorUniformGate {
return {
currentFloorWidth: Number.NaN,
currentFloorHeight: Number.NaN,
currentFloorTheme: undefined,
currentDarkFloorAlbedo: Number.NaN,
currentLightFloorAlbedo: Number.NaN,
currentLedIntensity: Number.NaN,
currentFloorLightAabbPadding: Number.NaN,
currentLightAoRadiance: Number.NaN,
currentLightAoContactOpacity: Number.NaN,
currentLightAoContactSize: Number.NaN,
currentLightAoContactFalloffPower: Number.NaN,
currentLightAoHighlightPower: Number.NaN,
currentLightAoHighlightStrength: Number.NaN,
currentFloorRadianceDebugEnabled: undefined,
currentFloorRadianceDebugRed: Number.NaN,
currentFloorRadianceDebugGreen: Number.NaN,
currentFloorRadianceDebugBlue: Number.NaN,
currentDarkFloorSdfFadeDistanceScale: Number.NaN,
currentDarkFloorSdfFadeEdgePx: Number.NaN,
currentDarkFloorNearFalloffPower: Number.NaN,
currentDarkFloorNearFalloffDistanceScale: Number.NaN,
currentDarkFloorNearFalloffIntensity: Number.NaN,
currentDarkFloorNearFalloffMapMin: Number.NaN,
currentDarkFloorNearFalloffMapMax: Number.NaN,
currentDarkFloorTailIntensity: Number.NaN,
currentDarkFloorTailMapMin: Number.NaN,
currentDarkFloorTailMapMax: Number.NaN,
currentDarkFloorTailPower: Number.NaN,
currentDarkFloorMiddleFalloffPower: Number.NaN,
currentDarkFloorMiddleFalloffIntensity: Number.NaN,
currentDarkFloorNearFalloffEnabled: undefined,
currentDarkFloorMiddleFalloffEnabled: undefined,
currentDarkFloorFarFalloffEnabled: undefined,
currentDarkFloorNoiseEnabled: undefined,
currentDarkFloorRadianceJitterPx: Number.NaN,
currentDarkFloorVibrancy: Number.NaN,
currentDarkPostprocessContrast: Number.NaN,
currentDarkPostprocessExposure: Number.NaN,
currentShowForegroundTriangle: undefined,
};
}
function writeFloorUniformIfNeeded(
parts: RendererParts,
inputs: FloorUniformInputs,
): ReturnType<typeof floorUniformData> | undefined {
const gate = parts.floorGate;
const {
width,
height,
tunables,
probeDiscard = PROBE_DISCARD_DEFAULTS,
theme = 'dark',
lightAo = LIGHT_AO_DEFAULTS,
radianceDebug = RADIANCE_DEBUG_DEFAULTS,
darkFloor = DARK_FLOOR_DEFAULTS,
darkPostprocess = DARK_POSTPROCESS_DEFAULTS,
showForegroundTriangle = true,
} = inputs;
const floorUniformChanged =
width !== gate.currentFloorWidth ||
height !== gate.currentFloorHeight ||
theme !== gate.currentFloorTheme ||
tunables.darkFloorAlbedo !== gate.currentDarkFloorAlbedo ||
tunables.lightFloorAlbedo !== gate.currentLightFloorAlbedo ||
tunables.ledIntensity !== gate.currentLedIntensity ||
probeDiscard.lightAabbPadding !== gate.currentFloorLightAabbPadding ||
lightAo.radiance !== gate.currentLightAoRadiance ||
lightAo.contactOpacity !== gate.currentLightAoContactOpacity ||
lightAo.contactSize !== gate.currentLightAoContactSize ||
lightAo.contactFalloffPower !== gate.currentLightAoContactFalloffPower ||
lightAo.highlightPower !== gate.currentLightAoHighlightPower ||
lightAo.highlightStrength !== gate.currentLightAoHighlightStrength ||
radianceDebug.enabled !== gate.currentFloorRadianceDebugEnabled ||
radianceDebug.redMultiplier !== gate.currentFloorRadianceDebugRed ||
radianceDebug.greenMultiplier !== gate.currentFloorRadianceDebugGreen ||
radianceDebug.blueMultiplier !== gate.currentFloorRadianceDebugBlue ||
darkFloor.sdfFadeDistanceScale !==
gate.currentDarkFloorSdfFadeDistanceScale ||
darkFloor.sdfFadeEdgePx !== gate.currentDarkFloorSdfFadeEdgePx ||
darkFloor.nearFalloffPower !== gate.currentDarkFloorNearFalloffPower ||
darkFloor.nearFalloffDistanceScale !==
gate.currentDarkFloorNearFalloffDistanceScale ||
darkFloor.nearFalloffIntensity !==
gate.currentDarkFloorNearFalloffIntensity ||
darkFloor.nearFalloffMapMin !== gate.currentDarkFloorNearFalloffMapMin ||
darkFloor.nearFalloffMapMax !== gate.currentDarkFloorNearFalloffMapMax ||
darkFloor.tailIntensity !== gate.currentDarkFloorTailIntensity ||
darkFloor.tailMapMin !== gate.currentDarkFloorTailMapMin ||
darkFloor.tailMapMax !== gate.currentDarkFloorTailMapMax ||
darkFloor.tailPower !== gate.currentDarkFloorTailPower ||
darkFloor.middleFalloffPower !== gate.currentDarkFloorMiddleFalloffPower ||
darkFloor.middleFalloffIntensity !==
gate.currentDarkFloorMiddleFalloffIntensity ||
darkFloor.nearFalloffEnabled !== gate.currentDarkFloorNearFalloffEnabled ||
darkFloor.middleFalloffEnabled !==
gate.currentDarkFloorMiddleFalloffEnabled ||
darkFloor.farFalloffEnabled !== gate.currentDarkFloorFarFalloffEnabled ||
darkFloor.noiseEnabled !== gate.currentDarkFloorNoiseEnabled ||
darkFloor.radianceJitterPx !== gate.currentDarkFloorRadianceJitterPx ||
darkFloor.vibrancy !== gate.currentDarkFloorVibrancy ||
darkPostprocess.contrast !== gate.currentDarkPostprocessContrast ||
darkPostprocess.exposure !== gate.currentDarkPostprocessExposure ||
showForegroundTriangle !== gate.currentShowForegroundTriangle;
if (!floorUniformChanged) return undefined;
const data = floorUniformData(
width,
height,
parts.simulationSize,
parts.presentationSize,
parts.pixelRatio,
parts.cascadeFit,
tunables,
probeDiscard,
theme,
lightAo,
radianceDebug,
darkFloor,
darkPostprocess,
showForegroundTriangle,
);
gate.currentFloorWidth = width;
gate.currentFloorHeight = height;
gate.currentFloorTheme = theme;
gate.currentDarkFloorAlbedo = tunables.darkFloorAlbedo;
gate.currentLightFloorAlbedo = tunables.lightFloorAlbedo;
gate.currentLedIntensity = tunables.ledIntensity;
gate.currentFloorLightAabbPadding = probeDiscard.lightAabbPadding;
gate.currentLightAoRadiance = lightAo.radiance;
gate.currentLightAoContactOpacity = lightAo.contactOpacity;
gate.currentLightAoContactSize = lightAo.contactSize;
gate.currentLightAoContactFalloffPower = lightAo.contactFalloffPower;
gate.currentLightAoHighlightPower = lightAo.highlightPower;
gate.currentLightAoHighlightStrength = lightAo.highlightStrength;
gate.currentFloorRadianceDebugEnabled = radianceDebug.enabled;
gate.currentFloorRadianceDebugRed = radianceDebug.redMultiplier;
gate.currentFloorRadianceDebugGreen = radianceDebug.greenMultiplier;
gate.currentFloorRadianceDebugBlue = radianceDebug.blueMultiplier;
gate.currentDarkFloorSdfFadeDistanceScale = darkFloor.sdfFadeDistanceScale;
gate.currentDarkFloorSdfFadeEdgePx = darkFloor.sdfFadeEdgePx;
gate.currentDarkFloorNearFalloffPower = darkFloor.nearFalloffPower;
gate.currentDarkFloorNearFalloffDistanceScale =
darkFloor.nearFalloffDistanceScale;
gate.currentDarkFloorNearFalloffIntensity = darkFloor.nearFalloffIntensity;
gate.currentDarkFloorNearFalloffMapMin = darkFloor.nearFalloffMapMin;
gate.currentDarkFloorNearFalloffMapMax = darkFloor.nearFalloffMapMax;
gate.currentDarkFloorTailIntensity = darkFloor.tailIntensity;
gate.currentDarkFloorTailMapMin = darkFloor.tailMapMin;
gate.currentDarkFloorTailMapMax = darkFloor.tailMapMax;
gate.currentDarkFloorTailPower = darkFloor.tailPower;
gate.currentDarkFloorMiddleFalloffPower = darkFloor.middleFalloffPower;
gate.currentDarkFloorMiddleFalloffIntensity =
darkFloor.middleFalloffIntensity;
gate.currentDarkFloorNearFalloffEnabled = darkFloor.nearFalloffEnabled;
gate.currentDarkFloorMiddleFalloffEnabled = darkFloor.middleFalloffEnabled;
gate.currentDarkFloorFarFalloffEnabled = darkFloor.farFalloffEnabled;
gate.currentDarkFloorNoiseEnabled = darkFloor.noiseEnabled;
gate.currentDarkFloorRadianceJitterPx = darkFloor.radianceJitterPx;
gate.currentDarkFloorVibrancy = darkFloor.vibrancy;
gate.currentDarkPostprocessContrast = darkPostprocess.contrast;
gate.currentDarkPostprocessExposure = darkPostprocess.exposure;
gate.currentShowForegroundTriangle = showForegroundTriangle;
return data;
}
function initialFloorUniformData() {
const zero = [0, 0, 0, 0];
return {
screen: zero,
light_sources: zero,
tunables: zero,
triangle: zero,
culling: zero,
radiance_fit: zero,
light_ao: zero,
radiance_debug: zero,
sim_transform: zero,
dark_floor: zero,
dark_glow: zero,
dark_near: zero,
dark_middle: zero,
dark_toggles: zero,
dark_circle: zero,
dark_noise: zero,
};
}
function initialLightGlowUniformData() {
const zero = [0, 0, 0, 0];
return {
near: zero,
middle: zero,
glow: zero,
params: zero,
toggles: zero,
ao: zero,
ao2: zero,
ao3: zero,
edgeRed: zero,
edgeGreen: zero,
edgeBlue: zero,
nearColor: zero,
middleColor: zero,
glowColor: zero,
paramsColor: zero,
togglesColor: zero,
};
}
// Packs the light-glow settings into the binding-4 uniform read by the light floor.
// Layout (16 × vec4f = 256 bytes) — keep in LOCKSTEP with the WGSL LightGlow struct + buffer size:
function rgbToOklab(c: { r: number; g: number; b: number }): {
r: number;
g: number;
b: number;
} {
const x = 0.412165612 * c.r + 0.536275208 * c.g + 0.0514575653 * c.b;
const y = 0.21185911 * c.r + 0.6807189584 * c.g + 0.107406579 * c.b;
const z = 0.0883097947 * c.r + 0.2818474174 * c.g + 0.6302613616 * c.b;
return { r: Math.cbrt(x), g: Math.cbrt(y), b: Math.cbrt(z) };
}
function lightGlowUniformData(g: LightGlowSettings, colorMix: number) {
const invOverlap = 1 / Math.max(g.edgeOverlap, 0.01);
const labR = rgbToOklab(g.edgeRedRgb);
const labG = rgbToOklab(g.edgeGreenRgb);
const labB = rgbToOklab(g.edgeBlueRgb);
return {
near: [g.nearDistanceScale, g.nearIntensity, g.nearMapMin, g.nearMapMax],
middle: [g.middlePower, g.middleIntensity, g.highlightNoise, 0],
glow: [g.farIntensity, colorMix, g.farMapMin, g.farMapMax],
params: [g.nearPower, g.middleOuterScale, g.farPower, g.fadeInner],
toggles: [
g.nearEnabled ? 1 : 0,
g.middleEnabled ? 1 : 0,
g.farEnabled ? 1 : 0,
g.contrast,
],
ao: [g.aoStrength, g.aoRadiusScale, g.aoPower, g.aoEnabled ? 1 : 0],
ao2: [g.ao2Strength, g.ao2RadiusScale, g.ao2Power, g.ao2Enabled ? 1 : 0],
ao3: [g.ao3Strength, g.ao3RadiusScale, g.ao3Power, g.ao3Enabled ? 1 : 0],
edgeRed: [labR.r, labR.g, labR.b, invOverlap],
edgeGreen: [labG.r, labG.g, labG.b, 0],
edgeBlue: [labB.r, labB.g, labB.b, 0],
nearColor: [
g.nearDistanceScaleColor,
g.nearIntensityColor,
g.nearMapMinColor,
g.nearMapMaxColor,
],
middleColor: [g.middlePowerColor, g.middleIntensityColor, 0, 0],
glowColor: [
g.farIntensityColor,
g.highlightDebug ? 1 : 0,
g.farMapMinColor,
g.farMapMaxColor,
],
paramsColor: [
g.nearPowerColor,
g.middleOuterScaleColor,
g.farPowerColor,
g.fadeInnerColor,
],
togglesColor: [
g.nearEnabledColor ? 1 : 0,
g.middleEnabledColor ? 1 : 0,
g.farEnabledColor ? 1 : 0,
g.contrastColor,
],
};
}
function floorUniformData(
width: number,
height: number,
simulationSize: RenderSize,
presentationSize: RenderSize,
pixelRatio: number,
cascadeFit: CascadeFitRect,
tunables: SceneTunables,
probeDiscard: ProbeDiscardSettings,
theme: 'dark' | 'light',
lightAo: LightAoSettings,
radianceDebug: RadianceDebugSettings,
darkFloor: DarkFloorSettings,
darkPostprocess: DarkPostprocessSettings,
showForegroundTriangle: boolean,
) {
const transform = presentationSimulationTransform(
simulationSize,
presentationSize,
);
const triangle = presentationTriangleParams(simulationSize, transform);
const isLightTheme = theme === 'light' ? 1 : 0;
const contactAoStrength = theme === 'light' ? lightAo.contactOpacity : 0;
const contactAoSize = theme === 'light' ? lightAo.contactSize : 0;
const floorAlbedo =
theme === 'light' ? tunables.lightFloorAlbedo : tunables.darkFloorAlbedo;
const referencePresentationHeight =
HERO_CANVAS_MAX_CSS * Math.max(pixelRatio, 1e-4);
const radianceJitterNorm =
Math.min(presentationSize.height, referencePresentationHeight) /
referencePresentationHeight;
return {
screen: [width, height, isLightTheme, pixelRatio],
light_sources: [
simulationSize.width,
simulationSize.height,
0,
contactAoStrength,
],
tunables: [tunables.ledIntensity, floorAlbedo, lightAo.radiance, contactAoSize],
triangle: [
triangle.centerX,
triangle.centerY,
triangle.circumradiusY,
triangle.halfSideX,
],
culling: [
probeDiscard.lightAabbPadding * triangle.scaleX,
probeDiscard.lightAabbPadding * triangle.scaleY,
showForegroundTriangle ? 1 : 0,
0,
],
radiance_fit: [
transform.originX + cascadeFit.originSceneX * transform.scale,
transform.originY + cascadeFit.originSceneY * transform.scale,
cascadeFit.widthScene * transform.scale,
cascadeFit.heightScene * transform.scale,
],
light_ao: [
lightAo.contactFalloffPower,
lightAo.highlightPower,
lightAo.highlightStrength,
0,
],
radiance_debug: [
radianceDebug.enabled ? 1 : 0,
radianceDebug.redMultiplier,
radianceDebug.greenMultiplier,
radianceDebug.blueMultiplier,
],
sim_transform: [
transform.originX,
transform.originY,
transform.scale,
getHeroEdgeFadeFrac(),
],
dark_floor: [
darkFloor.sdfFadeDistanceScale,
darkFloor.sdfFadeEdgePx,
darkFloor.nearFalloffPower,
darkFloor.tailPower,
],
dark_glow: [
darkFloor.tailIntensity,
darkFloor.vibrancy,
darkFloor.tailMapMin,
darkFloor.tailMapMax,
],
dark_near: [
darkFloor.nearFalloffDistanceScale,
darkFloor.nearFalloffIntensity,
darkFloor.nearFalloffMapMin,
darkFloor.nearFalloffMapMax,
],
dark_middle: [
darkFloor.middleFalloffPower,
darkFloor.middleFalloffIntensity,
darkFloor.radianceJitterPx,
radianceJitterNorm,
],
dark_toggles: [
darkFloor.nearFalloffEnabled ? 1 : 0,
darkFloor.middleFalloffEnabled ? 1 : 0,
darkFloor.farFalloffEnabled ? 1 : 0,
darkPostprocess.contrast,
],
dark_circle: [getHeroEdgeFadeFrac(), darkPostprocess.exposure, 0, 0],
dark_noise: [darkFloor.noiseEnabled ? 1 : 0, 0, 0, 0],
};
}
interface PresentationSimulationTransform {
originX: number;
originY: number;
scale: number;
}
function presentationSimulationTransform(
simulationSize: RenderSize,
presentationSize: RenderSize,
): PresentationSimulationTransform {
// FIT scale = presentationHeight / simHeight. The sim shares the canvas aspect (both
// dims floored by the same factor in canvasRenderSizing), so fitting by height is
// uniform and the origin stays ~0 (the scene still fills the canvas). When the sim is
// NOT floored (sim height == CSS height), presentationHeight/simHeight = (cssH·dpr)/cssH
// = dpr, identical to the old scale=pixelRatio — so unfloored/desktop is byte-identical.
const scale = Math.max(
0.001,
presentationSize.height / Math.max(1, simulationSize.height),
);
return {
originX: (presentationSize.width - simulationSize.width * scale) * 0.5,
originY: (presentationSize.height - simulationSize.height * scale) * 0.5,
scale,
};
}
function presentationTriangleParams(
simulationSize: RenderSize,
transform: PresentationSimulationTransform,
) {
const geometry = canonicalTriangleGeometry(simulationSize);
return {
centerX: transform.originX + geometry.center.x * transform.scale,
centerY: transform.originY + geometry.center.y * transform.scale,
circumradiusY: geometry.circumradius * transform.scale,
halfSideX: geometry.sideLength * 0.5 * transform.scale,
scaleX: transform.scale,
scaleY: transform.scale,
};
}
function normalizedSize(width: number, height: number): RenderSize {
return {
width: Math.max(1, Math.floor(width)),
height: Math.max(1, Math.floor(height)),
};
}
function normalizedPixelRatio(value: number | undefined): number {
return Math.max(
0.001,
typeof value === 'number' && Number.isFinite(value) ? value : 1,
);
}