feat: cursor-steered headlights light the hero name, plates and world

This commit is contained in:
2026-10-11 05:07:05 +03:00
parent 52b4a1ad14
commit c9e6ac0f29
41 changed files with 1196 additions and 9 deletions

View File

@@ -16,6 +16,7 @@ import './styles/gantry.css';
import './styles/cards.css';
import './styles/maintenance.css';
import './styles/print.css';
import './styles/headlight.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>();
</script>

View File

@@ -16,6 +16,7 @@ import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
import { useAudioEngine } from '@/composables/useAudioEngine';
import { useAudioIntent } from '@/composables/useAudioIntent';
import { useCardEntrances } from '@/composables/useCardEntrances';
import { useHeadlightBeam } from '@/composables/useHeadlightBeam';
import { useJourneySections } from '@/composables/useJourneySections';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
@@ -140,6 +141,12 @@ const pinnedPerformanceTier = requestedPerformanceTier === 'high' || requestedPe
? requestedPerformanceTier
: null;
const actualQuality = computed(() => pinnedPerformanceTier ?? adaptive.snapshot.value.actual);
/* HIGH BEAM: the cursor is the car's headlights, in the scene and on the page. */
const headlight = useHeadlightBeam({
motionAllowed: computed(() => motion.policy.value.allowSceneRequest && pageLifecycle.value === 'active'),
quality: actualQuality,
activeSectionId: () => journey.value.activeSectionId,
});
const activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId));
const serverDefaultPolicy: MotionPolicy = {
allowSceneRequest: false,
@@ -152,6 +159,7 @@ const displayedSceneState = computed(() => controlsReady.value ? sceneState.valu
let removePageListeners = (): void => {};
const sampleSceneFrame = (start: number, end: number): void => {
headlight.sceneFrame();
if (pinnedPerformanceTier === null) adaptive.sampleFrame(start, end);
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordSceneFrame(start, end);
};
@@ -253,6 +261,7 @@ onUnmounted(() => removePageListeners());
:scene-description="content.sceneDescription"
:credit="`${content.identity.name} · ${content.identity.role} · ${content.identity.location.city}, ${content.identity.location.country}`"
:sample-frame="sampleSceneFrame"
:sample-beam="headlight.sampleBeam"
@state-change="updateSceneState"
/>
<JourneyHud

View File

@@ -19,7 +19,7 @@ const emit = defineEmits<{
<template>
<section id="start" class="hero-journey" data-layout="hero" data-exit="01" aria-labelledby="start-heading">
<div class="hero-journey__content">
<h1 id="start-heading" class="hero-name" tabindex="-1"><span class="hero-name__text">{{ identity.name }}</span><NameParticles :allow-motion="allowMotion" :name="identity.name" /></h1>
<h1 id="start-heading" class="hero-name" tabindex="-1"><span class="hero-name__text" :data-name="identity.name">{{ identity.name }}</span><NameParticles :allow-motion="allowMotion" :name="identity.name" /></h1>
<p class="hero-journey__role">{{ identity.role }}</p>
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
<p class="hero-journey__availability">{{ identity.availability }}</p>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { heroBeam } from '@/composables/useHeadlightBeam';
const props = defineProps<{ allowMotion: boolean; name: string }>();
const root = ref<HTMLElement | null>(null);
@@ -27,12 +28,23 @@ const sparks = Array.from({ length: 100 }, () => ({
x: random(), y: random(), duration: random(), phase: random(), size: random(), drift: random(),
}));
const BEAM_SPARK_RADIUS = 180;
const paint = (): void => {
if (!context || width <= 0 || height <= 0) return;
const ctx = context;
ctx.clearRect(0, 0, width, height);
ctx.lineCap = 'round';
const fieldHeight = height - 16;
// HIGH BEAM: sparks inside the beam burn up to 1.8x brighter. The canvas
// sits 12 px left of and 8 px above the name (title-navigation.css).
const lit = heroBeam.on ? heroBeam.intensity * 0.8 : 0;
const boost = (x: number, y: number): number => {
if (lit <= 0) return 1;
const dx = x - 12 - heroBeam.x;
const dy = y - 8 - heroBeam.y;
return 1 + lit * Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) / BEAM_SPARK_RADIUS);
};
for (let layer = 0; layer < 2; layer++) {
ctx.globalAlpha = 0.7;
ctx.strokeStyle = layer ? '#ff87c8' : '#91e6ff';
@@ -52,14 +64,14 @@ const paint = (): void => {
const y = spark.y * fieldHeight + (phase - 0.5) * (spark.drift - 0.5) * 26;
ctx.beginPath();
if (index % 8 === 0) {
ctx.globalAlpha = life * 0.85;
ctx.globalAlpha = Math.min(1, life * 0.85 * boost(x, y));
ctx.strokeStyle = '#75aaff';
ctx.lineWidth = 0.9;
ctx.moveTo(x, y);
ctx.lineTo(x - 5 - spark.duration * 9, y);
ctx.stroke();
} else {
ctx.globalAlpha = life * (0.35 + spark.drift * 0.6);
ctx.globalAlpha = Math.min(1, life * (0.35 + spark.drift * 0.6) * boost(x, y));
ctx.fillStyle = '#f5f2ff';
ctx.arc(x, y, 0.5 + spark.size * 1.1, 0, Math.PI * 2);
ctx.fill();

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, ref, toRef, watch } from 'vue';
import { useSceneController, type SceneHostState } from '@/composables/useSceneController';
import type { JourneySnapshot } from '@/journey/types';
import type { BeamSample } from '@/journey/headlight-beam';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
import posterUrl from '@/assets/hero-chromatic.jpg';
@@ -18,6 +19,8 @@ const props = defineProps<{
/** Closing credit for the destination letterbox: real identity facts only. */
credit?: string;
sampleFrame?: (start: number, end: number) => unknown;
/** HIGH BEAM: the live headlight sample for the scene. */
sampleBeam?: (now: number) => BeamSample | null;
}>();
const emit = defineEmits<{
@@ -30,7 +33,7 @@ const posterFailed = ref(TEST_BUILD && isTestFault('F7-poster-failure'));
const journey = toRef(props, 'journey');
const policy = toRef(props, 'policy');
const quality = toRef(props, 'quality');
const scene = useSceneController({ canvas, journey, policy, quality, sampleFrame: props.sampleFrame });
const scene = useSceneController({ canvas, journey, policy, quality, sampleFrame: props.sampleFrame, sampleBeam: props.sampleBeam });
const sceneState = computed(() => scene.state.value);
const sceneGeneration = computed(() => scene.snapshot.value?.currentGeneration ?? null);
const showCanvas = computed(() => (

View File

@@ -0,0 +1,214 @@
import { onMounted, onUnmounted, watch, type Ref } from 'vue';
import { createHeadlightBeam, type BeamSample } from '@/journey/headlight-beam';
import type { QualityTier } from '@/journey/motion-policy';
import type { SectionId } from '@/content/types';
/** The beam relative to the hero name (px), read by NameParticles' canvas. */
export const heroBeam = { on: false, x: 0, y: 0, intensity: 0 };
interface Anchor { el: HTMLElement; left: number; top: number; scroll: number; ex: number; ey: number; i: number }
/**
* HIGH BEAM, DOM side. Fine pointers with motion allowed (not the low tier):
* pointermove stores the position, one rAF per frame samples the shared beam
* and writes element-relative --beam-ex/--beam-ey/--beam-i on the hero name
* and the current plate only (not on <html>: an inherited custom property on
* the root restyles the whole document every frame). No layout reads in the
* loop: rects are cached on resize, chapter change and content resize.
* Elsewhere the scene still gets the beam: the sweep, a slow sway, and taps.
*/
export function useHeadlightBeam(sources: {
motionAllowed: Readonly<Ref<boolean>>;
quality: Readonly<Ref<QualityTier>>;
activeSectionId: () => SectionId;
}) {
const beam = createHeadlightBeam();
const root = (): HTMLElement => document.documentElement;
const allowed = (): boolean => sources.motionAllowed.value && sources.quality.value !== 'static';
let mounted = false;
let fine = false;
let domOn = false;
let raf = 0;
let idleTimer = 0;
let fallbackTimer = 0;
let vw = 1;
let vh = 1;
let hero: Anchor | null = null;
let plate: Anchor | null = null;
let wipe = '';
let teardown = (): void => {};
const anchor = (el: HTMLElement | null, previous: Anchor | null): Anchor | null => {
if (previous && previous.el !== el) for (const name of ['--beam-ex', '--beam-ey', '--beam-i']) previous.el.style.removeProperty(name);
if (!el) return null;
const rect = el.getBoundingClientRect();
return { el, left: rect.left, top: rect.top, scroll: window.scrollY, ex: NaN, ey: NaN, i: NaN };
};
const measure = (): void => {
if (!mounted) return;
vw = Math.max(1, window.innerWidth);
vh = Math.max(1, window.innerHeight);
hero = anchor(document.querySelector<HTMLElement>('.hero-name'), hero);
const id = sources.activeSectionId();
plate = anchor(id === 'start' ? null : document.querySelector<HTMLElement>(`#${id} .chapter-body`), plate);
kick();
};
const place = (target: Anchor | null, px: number, py: number, intensity: number, scroll: number): void => {
if (!target) return;
const ex = px - target.left;
const ey = py - (target.top - (scroll - target.scroll));
const style = target.el.style;
// Negated so the NaN sentinel from anchor() always writes the first frame.
if (!(Math.abs(ex - target.ex) < 0.5 && Math.abs(ey - target.ey) < 0.5)) {
target.ex = ex;
target.ey = ey;
style.setProperty('--beam-ex', `${ex.toFixed(1)}px`);
style.setProperty('--beam-ey', `${ey.toFixed(1)}px`);
}
if (!(Math.abs(intensity - target.i) < 0.004)) {
target.i = intensity;
style.setProperty('--beam-i', intensity.toFixed(3));
}
};
const write = (sample: BeamSample, now: number): void => {
const px = (sample.x + 1) * 0.5 * vw;
const py = (sample.y + 1) * 0.5 * vh;
const scroll = window.scrollY;
place(hero, px, py, sample.intensity, scroll);
place(plate, px, py, sample.intensity, scroll);
if (hero) {
heroBeam.x = hero.ex;
heroBeam.y = hero.ey;
heroBeam.intensity = sample.intensity;
// Letters lit by the intro arc stay lit at dipped level (CSS).
const next = beam.swept(now) ? '9999px' : `${Math.max(-1, px - hero.left).toFixed(0)}px`;
if (next !== wipe) {
wipe = next;
hero.el.style.setProperty('--beam-wipe', next);
}
}
};
const frame = (now: number): void => {
raf = 0;
if (!domOn || document.hidden) return;
write(beam.sample(now), now);
if (!beam.settled(now)) {
raf = requestAnimationFrame(frame);
return;
}
const idleAt = beam.idleAt();
window.clearTimeout(idleTimer);
if (idleAt !== null && idleAt > now) idleTimer = window.setTimeout(kick, idleAt - now + 16);
};
function kick(): void {
if (raf === 0 && domOn && !document.hidden) raf = requestAnimationFrame(frame);
}
let sweepRequested = false;
const sweep = (): void => {
if (!allowed()) return;
sweepRequested = true;
measure();
const y = hero ? ((hero.top + hero.el.offsetHeight / 2) / vh) * 2 - 1 : 0;
if (beam.sweep(performance.now(), y)) kick();
};
const sync = (): void => {
if (!mounted) return;
const tierOk = sources.quality.value !== 'low';
const next = allowed() && fine && tierOk;
beam.setAutoSway(!(fine && tierOk));
if (next === domOn) return;
domOn = next;
heroBeam.on = next;
window.clearTimeout(fallbackTimer);
if (next) {
root().dataset.beam = 'on';
measure();
// Without a scene frame (a slow or failed scene), the name still lights.
fallbackTimer = window.setTimeout(sweep, 2_500);
} else {
delete root().dataset.beam;
if (raf) cancelAnimationFrame(raf);
raf = 0;
}
};
onMounted(() => {
mounted = true;
const query = window.matchMedia?.('(pointer: fine)');
fine = query?.matches ?? false;
const onPointer = (event: PointerEvent): void => {
if (!domOn) return;
beam.input((event.clientX / vw) * 2 - 1, (event.clientY / vh) * 2 - 1, event.pointerType, performance.now());
kick();
};
const onClick = (event: MouseEvent): void => {
const target = event.target;
if (!allowed() || !(target instanceof Element) || !target.closest('.hero-journey, .gantry-window')) return;
if (beam.flash(performance.now())) kick();
};
const onQuery = (): void => { fine = query?.matches ?? false; sync(); };
const onAnimationEnd = (event: AnimationEvent): void => {
if (event.target instanceof Element && event.target.matches('.hero-name')) measure();
};
const passive = { passive: true } as const;
const resize = new ResizeObserver(measure);
const main = document.getElementById('portfolio-content');
if (main) resize.observe(main);
window.addEventListener('pointermove', onPointer, passive);
window.addEventListener('resize', measure, passive);
window.addEventListener('scroll', kick, passive);
document.addEventListener('click', onClick, passive);
document.addEventListener('visibilitychange', kick, passive);
document.addEventListener('animationend', onAnimationEnd, passive);
query?.addEventListener?.('change', onQuery);
teardown = () => {
resize.disconnect();
window.removeEventListener('pointermove', onPointer);
window.removeEventListener('resize', measure);
window.removeEventListener('scroll', kick);
document.removeEventListener('click', onClick);
document.removeEventListener('visibilitychange', kick);
document.removeEventListener('animationend', onAnimationEnd);
query?.removeEventListener?.('change', onQuery);
};
sync();
});
const stopWatch = watch(() => [allowed(), sources.quality.value], sync);
const stopSection = watch(sources.activeSectionId, measure, { flush: 'post' });
onUnmounted(() => {
stopWatch();
stopSection();
teardown();
window.clearTimeout(idleTimer);
window.clearTimeout(fallbackTimer);
if (raf) cancelAnimationFrame(raf);
raf = 0;
domOn = false;
heroBeam.on = false;
delete root().dataset.beam;
hero?.el.style.removeProperty('--beam-wipe');
hero = anchor(null, hero);
plate = anchor(null, plate);
mounted = false;
});
return {
/** The scene's per-frame read; null when motion is not allowed. */
sampleBeam: (now: number): BeamSample | null => (allowed() ? beam.sample(now) : null),
/** Called for every rendered scene frame: the first one plays the sweep. */
sceneFrame: (): void => {
if (sweepRequested) return;
sweepRequested = true;
sweep();
},
};
}

View File

@@ -9,6 +9,7 @@ import {
} from 'vue';
import { createSceneController } from '@/scene/create-scene-controller';
import type { JourneySnapshot } from '@/journey/types';
import type { BeamSample } from '@/journey/headlight-beam';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import type { SceneController, SceneControllerSnapshot } from '@/scene/types';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
@@ -30,6 +31,7 @@ export interface SceneControllerSources {
policy: Readonly<Ref<MotionPolicy>>;
quality: Readonly<Ref<QualityTier>>;
sampleFrame?: ((start: number, end: number) => unknown) | undefined;
sampleBeam?: ((now: number) => BeamSample | null) | undefined;
}
export function useSceneController(sources: SceneControllerSources): SceneControllerBinding {
@@ -126,6 +128,7 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
},
motionAllowed: () => sources.policy.value.allowSceneRequest,
onFrameRendered: sources.sampleFrame,
sampleBeam: sources.sampleBeam,
onStateChange: (nextState) => {
queueMicrotask(() => {
if (controller !== next) return;

View File

@@ -0,0 +1,174 @@
/**
* HIGH BEAM: the cursor is the car's headlights. One pure, clock-driven beam
* shared by the DOM (the hero name, plate edges, sparks) and the lazy scene
* chunk (road cones, spotlight). Coordinates are normalised to the viewport:
* x -1 (left) .. +1 (right), y -1 (top) .. +1 (bottom); intensity 0..1.
*
* Photosensitivity (round 5): a flash is a gentle lift to high beam, never a
* strobe. At most one flash per FLASH_MIN_INTERVAL_MS (<= 1.67 Hz, under the
* 3 Hz ceiling), with eased rise and fall.
*/
export interface BeamSample {
readonly x: number;
readonly y: number;
readonly intensity: number;
}
/** Pointer follow, critically damped; the "smooth time" to reach the pointer. */
export const FOLLOW_TAU_MS = 180;
/** Without pointer input for this long, the lamps drift back to dead ahead. */
export const IDLE_AFTER_MS = 4_000;
export const IDLE_TAU_MS = 900;
export const SWEEP_MS = 1_600;
export const SWEEP_FROM = -1;
export const SWEEP_TO = 0.25;
export const FLASH_HOLD_MS = 420;
export const FLASH_MIN_INTERVAL_MS = 600;
/** Dipped beam: the resting intensity after the sweep. Flash lifts it to 1. */
export const REST_INTENSITY = 0.72;
export const INTENSITY_RISE_TAU_MS = 70;
export const INTENSITY_FALL_TAU_MS = 260;
/** Touch, coarse pointers and the low tier: a slow automatic sway instead. */
export const SWAY_HZ = 0.08;
export const SWAY_AMPLITUDE = 0.3;
const MAX_STEP_MS = 100;
export interface HeadlightBeam {
/** Pointer position (normalised). Touch input is ignored: a tap is a flash. */
input(x: number, y: number, pointerType: string, now: number): void;
sample(now: number): BeamSample;
/** Plays the intro arc once. Returns false when it already played. */
sweep(now: number, y?: number): boolean;
/** Lifts to high beam; false when rate-limited. */
flash(now: number): boolean;
setAutoSway(enabled: boolean): void;
/** True once the sweep has finished (or never needs to play again). */
swept(now: number): boolean;
/** At rest: nothing will change until input, a flash, or {@link idleAt}. */
settled(now: number): boolean;
/** When the idle return starts, or null when it is not pending. */
idleAt(): number | null;
}
const clamp = (value: number, min: number, max: number): number =>
Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : 0;
const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;
export function createHeadlightBeam(): HeadlightBeam {
let x = 0, y = 0, vx = 0, vy = 0, intensity = 0;
let targetX = 0, targetY = 0;
let lastInputAt: number | null = null;
let last: number | null = null;
let sweepStart: number | null = null;
let sweepY = 0;
let sweepDone = false;
let flashUntil = -Infinity;
let lastFlashAt = -Infinity;
let sway = false;
let swayOrigin = 0;
let cached: BeamSample = { x, y, intensity };
let dirty = false;
const sweeping = (now: number): boolean =>
sweepStart !== null && !sweepDone && now <= sweepStart + SWEEP_MS;
const finishSweep = (): void => {
if (sweepDone || sweepStart === null) return;
sweepDone = true;
x = SWEEP_TO; y = sweepY; vx = 0; vy = 0;
const end = sweepStart + SWEEP_MS;
// The arc's end counts as the last input: the lamps hold there, then the
// pointer (or the idle drift) takes over. A pointer that moved during
// the arc keeps its target.
if (lastInputAt === null || lastInputAt < sweepStart) {
targetX = SWEEP_TO; targetY = sweepY;
}
lastInputAt = Math.max(lastInputAt ?? end, end);
swayOrigin = end;
};
const intensityTarget = (now: number): number =>
now < flashUntil ? 1 : sweepDone ? REST_INTENSITY : 0;
/** Exact critically damped step (smooth time tau: omega = 2 / tau). */
const damp = (position: number, velocity: number, target: number, dt: number, tau: number): [number, number] => {
const omega = 2 / tau;
const offset = position - target;
const decay = Math.exp(-omega * dt);
const temp = (velocity + omega * offset) * dt;
return [target + (offset + temp) * decay, (velocity - omega * temp) * decay];
};
return {
input(nx, ny, pointerType, now) {
if (pointerType === 'touch') return;
targetX = clamp(nx, -1, 1);
targetY = clamp(ny, -1, 1);
lastInputAt = now;
},
sample(now) {
if (last !== null && now <= last && !dirty) return cached;
dirty = false;
const dt = last === null ? 0 : Math.min(MAX_STEP_MS, Math.max(0, now - last));
last = now;
if (sweepStart !== null && !sweepDone) {
if (sweeping(now)) {
const t = easeOutCubic(clamp((now - sweepStart) / SWEEP_MS, 0, 1));
x = SWEEP_FROM + (SWEEP_TO - SWEEP_FROM) * t;
y = sweepY;
intensity = t;
return (cached = { x, y, intensity });
}
finishSweep();
intensity = 1;
}
if (dt > 0) {
let tx = targetX, ty = targetY, tau = FOLLOW_TAU_MS;
if (sway) {
tx = SWAY_AMPLITUDE * Math.sin(2 * Math.PI * SWAY_HZ * ((now - swayOrigin) / 1000));
ty = 0;
tau = IDLE_TAU_MS;
} else if (lastInputAt === null || now - lastInputAt >= IDLE_AFTER_MS) {
tx = 0; ty = 0; tau = IDLE_TAU_MS;
}
[x, vx] = damp(x, vx, tx, dt, tau);
[y, vy] = damp(y, vy, ty, dt, tau);
const goal = intensityTarget(now);
const itau = goal > intensity ? INTENSITY_RISE_TAU_MS : INTENSITY_FALL_TAU_MS;
intensity = goal + (intensity - goal) * Math.exp(-dt / itau);
}
return (cached = { x, y, intensity });
},
sweep(now, sy = 0) {
if (sweepStart !== null) return false;
sweepStart = now;
sweepY = clamp(sy, -1, 1);
dirty = true;
return true;
},
flash(now) {
if (now - lastFlashAt < FLASH_MIN_INTERVAL_MS) return false;
lastFlashAt = now;
flashUntil = now + FLASH_HOLD_MS;
return true;
},
setAutoSway(enabled) {
if (enabled && !sway) swayOrigin = last ?? 0;
sway = enabled;
},
swept: (now) => sweepDone || (sweepStart !== null && now >= sweepStart + SWEEP_MS),
settled(now) {
if (sway || (sweepStart !== null && !sweepDone) || now < flashUntil) return false;
const idle = lastInputAt === null || now - lastInputAt >= IDLE_AFTER_MS;
const tx = idle ? 0 : targetX;
const ty = idle ? 0 : targetY;
return Math.abs(x - tx) < 5e-4 && Math.abs(y - ty) < 5e-4
&& Math.abs(vx) < 1e-6 && Math.abs(vy) < 1e-6
&& Math.abs(intensity - intensityTarget(now)) < 5e-4;
},
idleAt() {
if (lastInputAt === null || (targetX === 0 && targetY === 0)) return null;
return lastInputAt + IDLE_AFTER_MS;
},
};
}

View File

@@ -1,5 +1,6 @@
import type { SectionId } from "@/content/types";
import type { SignViewportProfile } from '@/journey/sign-routes';
import type { BeamSample } from '@/journey/headlight-beam';
export interface ScrollDriveSample {
scrollY: number;
@@ -78,4 +79,7 @@ export interface JourneySnapshot extends ScrollDriveSnapshot {
sign: SignSnapshot | null;
arrived: boolean;
sectionActivation: SectionActivationSnapshot;
/** HIGH BEAM: where the headlights point (normalised viewport), sampled per
* scene frame. Absent when no beam is driving the scene. */
beam?: BeamSample;
}

View File

@@ -167,6 +167,8 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
try {
const frameStartedAt = scheduler.now();
const active = current;
const beam = options.sampleBeam?.(now);
if (beam) latestJourney.beam = { x: finite(beam.x), y: finite(beam.y), intensity: finite(beam.intensity) };
const complete = active.runtime.render(latestJourney, now) !== false;
const frameEndedAt = scheduler.now();
if (complete && current === active) {
@@ -589,6 +591,13 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
? null
: finite(snapshot.sectionActivation.nextHeadingOffsetVh),
},
...(snapshot.beam === undefined ? {} : {
beam: {
x: finite(snapshot.beam.x),
y: finite(snapshot.beam.y),
intensity: finite(snapshot.beam.intensity),
},
}),
};
}

View File

@@ -1,4 +1,5 @@
import type { JourneySnapshot } from '@/journey/types';
import type { BeamSample } from '@/journey/headlight-beam';
import type { QualityTier } from '@/journey/motion-policy';
import type { SceneResourceBag } from '@/scene/resources';
@@ -55,6 +56,8 @@ export interface SceneControllerOptions {
page?: ScenePageEnvironment;
motionAllowed?: () => boolean;
onFrameRendered?: ((start: number, end: number) => unknown) | undefined;
/** HIGH BEAM: the live headlight sample, read once per rendered frame. */
sampleBeam?: ((now: number) => BeamSample | null) | undefined;
onStateChange?: ((state: SceneLifecycleState) => unknown) | undefined;
signal?: AbortSignal;
}

View File

@@ -10,6 +10,7 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SectionId } from '@/content/types';
import { signAccentFor } from './sign';
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
import { BEAM_PARS, beamUniforms } from './headlights';
/** Roadside districts: each chapter raises its own landscape out of the ground
* (server rows for Infrastructure, a workshop and cranes for Builds, a container
@@ -201,7 +202,7 @@ export function createDistricts(
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
uniforms: { ...shared, ...service, ...beamUniforms, uRise: rise, uPieceHeight: height },
vertexShader: VERTEX,
fragmentShader: `
uniform vec3 uAccent;
@@ -209,6 +210,7 @@ export function createDistricts(
varying float vDepth;
varying float vRise;
varying float vHeight;
${BEAM_PARS}
${serviced ? SERVICE_PARS : ''}
void main() {
${FADES}
@@ -233,6 +235,9 @@ export function createDistricts(
float power = mix(1.0, mix(0.1, 1.0, relit), dimmed);
color += mix(uAccent, uServiceColor, dimmed * relit) * (rim * 0.22 + crown * 0.05) * power;` : `
color += uAccent * (rim * 0.22 + crown * 0.05);`}
// HIGH BEAM: the headlights rake the facades they face.
vec3 toLamp = normalize(uBeamOrigin - vWorld);
color += vec3(1.0, 0.84, 0.66) * headlightBeam(vWorld) * (0.25 + 0.75 * abs(dot(n, toLamp))) * 0.35;
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>

View File

@@ -32,6 +32,7 @@ import {
type MutableChapterPalette,
} from '@/scene/world/palette';
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { createHeadlights } from '@/scene/world/headlights';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
@@ -145,6 +146,13 @@ export async function createProceduralSceneGeneration(
maximumTextureSize: renderer.capabilities.maxTextureSize,
});
const atmosphere = createAtmosphere(context.resources, quality);
// HIGH BEAM: the cursor steers the headlights (road cones on every tier).
const headlights = createHeadlights(
context.resources,
context.quality,
// A THREE.Group at runtime; CarGroup only types what car.ts uses.
car.group as unknown as Parameters<typeof createHeadlights>[2],
);
// The 03:12 window. Phones, coarse pointers and the low tier keep only the
// power-down, the re-light and the palette nudge: no rain, packets or lightning.
const maintenance = createMaintenanceWindow(context.resources);
@@ -168,6 +176,7 @@ export async function createProceduralSceneGeneration(
speedWake.group,
sign.group,
sign.truss,
...headlights.lights,
);
const postProcessor = quality.postEffects
@@ -612,6 +621,8 @@ export async function createProceduralSceneGeneration(
camera.fov = lensFov;
camera.updateProjectionMatrix();
}
const beam = headlights.update(snapshot.beam, snapshot, camera, 1 - engineOff, deltaSeconds);
road.setBeam(beam.yaw, beam.lift);
const stage = signStage.update(width, height);
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
const gantry = sign.dockState();

View File

@@ -0,0 +1,206 @@
import * as THREE from 'three';
import { REST_INTENSITY, type BeamSample } from '@/journey/headlight-beam';
import type { JourneySnapshot } from '@/journey/types';
import type { LiveQualityTier } from '@/scene/types';
import type { SceneResourceBag } from '@/scene/resources';
/* three ships untyped here (see src/scene/three.d.ts). */
type Vec3 = { x: number; y: number; z: number };
type Object3D = {
add(...objects: unknown[]): unknown;
updateWorldMatrix(parents: boolean, children: boolean): void;
worldToLocal<T>(vector: T): T;
matrixWorld: unknown;
};
type Camera = { position: Vec3 };
/**
* HIGH BEAM, scene side. The shared beam (src/journey/headlight-beam.ts)
* yaws the road cones on every tier; high and medium add one warm spotlight
* parented to the car (no shadows) and a matching term in the unlit district
* and terrain shaders; high adds an additive cone of haze while the road is
* open (no reading plate over it).
*/
export const BEAM_YAW_RAD = 0.35;
export const BEAM_PITCH_RAD = 0.12;
const SPOT_COLOR = 0xffd6a8;
const SPOT_ANGLE = 0.42;
const SPOT_PENUMBRA = 0.6;
const SPOT_DISTANCE = 70;
const SPOT_INTENSITY = 400;
const LAMP = new THREE.Vector3(0, 0.55, -2.4);
/** The resting aim: the cone axis meets the road about 25 m ahead. */
const BASE_PITCH = -0.05;
const CONE_LENGTH = 34;
const CONE_WIDTH = 10;
const CONE_OPACITY = 0.13;
/** Shared by the district and terrain shaders (one set for every generation). */
export const beamUniforms = {
uBeamOrigin: { value: new THREE.Vector3(0, 1, -2) },
uBeamDir: { value: new THREE.Vector3(0, -0.05, -1).normalize() },
uBeamGain: { value: 0 },
};
/** GLSL: the spotlight's footprint for unlit ShaderMaterials (0..~1). */
export const BEAM_PARS = /* glsl */ `
uniform vec3 uBeamOrigin;
uniform vec3 uBeamDir;
uniform float uBeamGain;
float headlightBeam(vec3 p) {
if (uBeamGain <= 0.0) return 0.0;
vec3 ray = p - uBeamOrigin;
float d = length(ray);
float cosA = dot(ray / max(d, 0.001), uBeamDir);
float cone = smoothstep(${Math.cos(SPOT_ANGLE).toFixed(4)}, ${Math.cos(SPOT_ANGLE * (1 - SPOT_PENUMBRA)).toFixed(4)}, cosA);
float reach = 1.0 - smoothstep(${(SPOT_DISTANCE * 0.45).toFixed(1)}, ${SPOT_DISTANCE.toFixed(1)}, d);
return uBeamGain * cone * reach * (14.0 / (14.0 + d));
}
`;
/** How much open road is on screen: 1 between chapters, 0 under a plate. */
export function roadOpenness(
snapshot: Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>,
): number {
if (snapshot.activeSectionId === 'start') return 1;
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return 0;
const span = next - active;
if (!(span > 0)) return 0;
const p = -active / span;
return 1 - THREE.MathUtils.smoothstep(p, 0.02, 0.12) * (1 - THREE.MathUtils.smoothstep(p, 0.8, 0.92));
}
/** Presence (0..1, ramps in with the sweep) and high-beam lift (0..1). */
export function beamLevels(beam: BeamSample | undefined): { presence: number; lift: number } {
if (!beam) return { presence: 0, lift: 0 };
const i = THREE.MathUtils.clamp(beam.intensity, 0, 1);
return {
presence: Math.min(1, i / REST_INTENSITY),
lift: Math.max(0, (i - REST_INTENSITY) / (1 - REST_INTENSITY)),
};
}
export interface HeadlightsWorld {
/** World-space lights (the spot and its target); add to the scene root. */
readonly lights: unknown[];
/** Returns the yaw (-1..1) and lift (0..1) for the road cones. */
update(
beam: BeamSample | undefined,
snapshot: Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>,
camera: Camera,
engineOn: number,
dt: number,
): { yaw: number; lift: number };
}
/**
* The spot lives in world space at the scene root, so hiding the car on a
* short viewport never changes the light count (no shader recompiles); the
* haze cone rides on the car.
*/
export function createHeadlights(
resources: SceneResourceBag,
tier: LiveQualityTier,
car: Object3D,
): HeadlightsWorld {
const lit = tier !== 'low';
const spot = lit ? new THREE.SpotLight(SPOT_COLOR, 0, SPOT_DISTANCE, SPOT_ANGLE, SPOT_PENUMBRA, 2) : null;
if (spot) {
spot.name = 'high-beam';
spot.castShadow = false;
spot.target.name = 'high-beam-target';
}
let cone: any = null;
let coneMaterial: any = null;
if (tier === 'high') {
const geometry = resources.own('geometry', new THREE.PlaneGeometry(CONE_WIDTH, CONE_LENGTH));
geometry.translate(0, CONE_LENGTH / 2, 0);
coneMaterial = resources.own('material', new THREE.MeshBasicMaterial({
map: resources.own('texture', coneTexture()),
color: SPOT_COLOR,
transparent: true,
opacity: 0,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
}));
cone = new THREE.Mesh(geometry, coneMaterial);
cone.name = 'high-beam-cone';
cone.visible = false;
cone.position.copy(LAMP);
cone.renderOrder = 2;
car.add(cone);
}
const direction = new THREE.Vector3();
const toCamera = new THREE.Vector3();
const across = new THREE.Vector3();
const basis = new THREE.Matrix4();
const origin = new THREE.Vector3();
let open = 1;
return {
lights: spot ? [spot, spot.target] : [],
update(beam, snapshot, camera, engineOn, dt) {
const { presence, lift } = beamLevels(beam);
const x = beam ? THREE.MathUtils.clamp(beam.x, -1, 1) : 0;
const y = beam ? THREE.MathUtils.clamp(beam.y, -1, 1) : 0;
const yaw = x * BEAM_YAW_RAD * presence;
const pitch = BASE_PITCH - y * BEAM_PITCH_RAD * presence;
direction.set(Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw) * Math.cos(pitch));
const strength = (beam ? THREE.MathUtils.clamp(beam.intensity, 0, 1) : 0) * engineOn;
if (spot) {
// World space, shared with the unlit districts and terrain.
car.updateWorldMatrix(true, false);
const worldOrigin = beamUniforms.uBeamOrigin.value.copy(LAMP).applyMatrix4(car.matrixWorld);
const worldDir = beamUniforms.uBeamDir.value.copy(direction).transformDirection(car.matrixWorld);
spot.intensity = SPOT_INTENSITY * strength;
spot.position.copy(worldOrigin);
spot.target.position.copy(worldOrigin).addScaledVector(worldDir, 30);
spot.target.updateMatrixWorld();
beamUniforms.uBeamGain.value = strength;
} else {
beamUniforms.uBeamGain.value = 0;
}
if (cone && coneMaterial) {
const target = roadOpenness(snapshot);
open += (target - open) * (1 - Math.exp(-Math.max(0, dt) / 0.3));
coneMaterial.opacity = CONE_OPACITY * strength * open;
cone.visible = coneMaterial.opacity > 0.002;
if (cone.visible) {
// Billboard about the beam axis: the haze always faces the lens.
car.worldToLocal(toCamera.copy(camera.position)).sub(origin.copy(LAMP));
toCamera.addScaledVector(direction, -toCamera.dot(direction));
if (toCamera.lengthSq() < 1e-6) toCamera.set(0, 1, 0);
toCamera.normalize();
across.crossVectors(direction, toCamera).normalize();
cone.quaternion.setFromRotationMatrix(basis.makeBasis(across, direction, toCamera));
}
}
return { yaw: x * presence, lift: lift * engineOn };
},
};
}
/** A small fan of haze: narrow at the lamp, widening and fading down the road. */
function coneTexture() {
const width = 32;
const height = 64;
const data = new Uint8Array(width * height * 4);
for (let row = 0; row < height; row++) {
const v = row / (height - 1);
const half = 0.07 + 0.4 * v;
const along = THREE.MathUtils.smoothstep(v, 0, 0.12) * (1 - v) ** 1.6;
for (let column = 0; column < width; column++) {
const u = column / (width - 1) - 0.5;
const value = Math.round(255 * along * Math.exp(-((u / half) ** 2) * 2.5));
data.set([value, value, value, 255], (row * width + column) * 4);
}
}
const texture = new THREE.DataTexture(data, width, height);
texture.needsUpdate = true;
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearFilter;
return texture;
}

View File

@@ -18,6 +18,8 @@ export interface RoadWorld {
acceleration: number,
palette?: Readonly<MutableChapterPalette>,
): void;
/** HIGH BEAM: yaw (beam x, -1..1, scaled by presence) and high-beam lift (0..1). */
setBeam(yaw: number, lift: number): void;
}
interface WorldGroup {
@@ -56,6 +58,8 @@ export function createRoad(
uLaneColor: { value: new THREE.Color(0xffb568) },
uFogColor: { value: new THREE.Color(initialPalette.fog) },
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
uBeamYaw: { value: 0 },
uBeamLift: { value: 0 },
};
const material = resources.own(
'material',
@@ -89,6 +93,8 @@ export function createRoad(
uniform vec3 uLaneColor;
uniform vec3 uFogColor;
uniform vec3 uHazeColor;
uniform float uBeamYaw;
uniform float uBeamLift;
varying vec2 vRoadUv;
varying float vRoadX;
varying float vViewDistance;
@@ -184,10 +190,14 @@ export function createRoad(
// Headlight throw: two warm cones ahead of the car merging into a pool.
float ahead = -vWorldZ - 5.0;
float throwZ = smoothstep(-3.0, 5.0, ahead) * (1.0 - smoothstep(26.0, 92.0, ahead));
// HIGH BEAM: the cones yaw with the pointer (3.4 units at the pool,
// growing with distance like a turned lamp) and reach further on a flash.
float throwZ = smoothstep(-3.0, 5.0, ahead) * (1.0 - smoothstep(26.0, mix(92.0, 120.0, uBeamLift), ahead));
float spread = 0.9 + max(0.0, ahead) * 0.07;
float cones = exp(-pow((vRoadX - 1.25) / spread, 2.0)) + exp(-pow((vRoadX + 1.25) / spread, 2.0));
color += vec3(1.0, 0.84, 0.66) * min(cones, 1.4) * throwZ * (0.07 + 0.16 * film) * asphaltMask;
float beamX = vRoadX - uBeamYaw * 3.4 * clamp(ahead / 26.0, 0.0, 4.6);
float cones = exp(-pow((beamX - 1.25) / spread, 2.0)) + exp(-pow((beamX + 1.25) / spread, 2.0));
color += vec3(1.0, 0.84, 0.66) * min(cones, 1.4) * throwZ * (0.07 + 0.16 * film) * asphaltMask
* (1.0 + 0.3 * uBeamLift);
// Pylon lamps reflect as long streaks towards the camera.
float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)});
@@ -233,6 +243,10 @@ export function createRoad(
uniforms.uHazeColor.value.copy(palette.haze);
}
},
setBeam(yaw, lift) {
uniforms.uBeamYaw.value = THREE.MathUtils.clamp(finiteOrZero(yaw), -1, 1);
uniforms.uBeamLift.value = clamp01(lift);
},
};
}

View File

@@ -1,4 +1,5 @@
import * as THREE from 'three';
import { BEAM_PARS, beamUniforms } from './headlights';
import type { EnvironmentChapter } from '@/journey/types';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources';
@@ -235,7 +236,7 @@ export function createTerrain(
const groundMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
uniforms: { ...uniforms, ...beamUniforms },
vertexShader: material.vertexShader.replace(
'displaced.y += height + 0.028;',
'displaced.y += height - 0.12;',
@@ -249,11 +250,15 @@ export function createTerrain(
varying float vDepthFade;
varying vec3 vTerrainPosition;
varying vec3 vGridColor;
${BEAM_PARS}
void main() {
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
vec3 color = mix(vec3(0.012, 0.003, 0.024), vGridColor * (0.065 + uSurge * 0.016), light);
color += uHighColor * pow(vHeight, 2.0) * light * 0.06;
// HIGH BEAM: the ridges beside the road catch the headlights.
float beam = headlightBeam(vTerrainPosition);
color += vec3(1.0, 0.84, 0.66) * beam * (0.3 + 0.7 * abs(dot(normal, normalize(uBeamOrigin - vTerrainPosition)))) * 0.28;
color = mix(uFogColor * 0.55, color, vDepthFade);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>

109
src/styles/headlight.css Normal file
View File

@@ -0,0 +1,109 @@
/* ==========================================================================
HIGH BEAM. The cursor is the car's headlights. useHeadlightBeam.ts sets
html[data-beam='on'] after mount (fine pointer, motion allowed, not the low
tier) and writes element-relative --beam-ex / --beam-ey (px), --beam-i
(0..1) and --beam-wipe on the hero name and the current plate. The server
document and every other visitor keep the fully lit name. Imported last.
========================================================================== */
:root {
/* Tunables. */
--beam-lamp: 255 205 150;
--beam-r: 2.6em; /* lit spot radius on the name */
--beam-rest: 0.8; /* letters the intro arc lit stay this lit */
--beam-plate-r: 300px; /* plate edge glow radius */
}
@media screen and (forced-colors: none) and (prefers-reduced-motion: no-preference) {
/* Dark outline: a 1px lamp-coloured stroke over a 10% fill. Static and
filter-free, so the lit copy below resolves against the h1. */
html[data-beam='on'] .hero-journey .hero-name__text {
position: static;
background: none;
filter: none;
color: rgb(255 214 168 / 10%);
-webkit-text-fill-color: rgb(255 214 168 / 10%);
-webkit-text-stroke: 1px rgb(var(--beam-lamp) / 55%);
text-shadow: 0 0 0.3em rgb(9 4 22 / 70%);
}
/* The lamp halo under the lit copy: a transparent copy of the name whose
text-shadow is the glow, masked by the spot alone. (A filter drop-shadow
on the lit copy paints the masked layer as a visible rectangle in
Chromium, so the glow lives here instead.) */
html[data-beam='on'] .hero-journey .hero-name__text::before,
html[data-beam='on'] .hero-journey .hero-name__text::after {
content: attr(data-name);
content: attr(data-name) / '';
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 100%;
pointer-events: none;
--beam-spot: radial-gradient(
circle var(--beam-r) at var(--beam-ex, -9999px) var(--beam-ey, -9999px),
rgb(0 0 0 / var(--beam-i, 0)) 0%,
rgb(0 0 0 / calc(var(--beam-i, 0) * 0.55)) 50%,
transparent 100%
);
}
html[data-beam='on'] .hero-journey .hero-name__text::before {
z-index: 0;
color: transparent;
-webkit-text-fill-color: transparent;
-webkit-text-stroke: 0;
text-shadow: 0 0 0.12em rgb(255 236 214 / 80%), 0 0 0.4em rgb(var(--beam-lamp) / 95%);
-webkit-mask-image: var(--beam-spot);
mask-image: var(--beam-spot);
}
/* The lit copy: the chrome fill from route.css, masked by the beam, plus
the letters the intro arc has already passed (at dipped level). */
html[data-beam='on'] .hero-journey .hero-name__text::after {
z-index: 1;
color: #ffe2c4;
background: linear-gradient(
180deg,
#fffaf2 8%,
#ffe2ef 34%,
#ff9fcd 48.5%,
#5b2591 49.5%,
#9b3fb8 54%,
#ff63ab 72%,
#ffb46e 96%
) 0 0 / 100% 0.86em repeat-y;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
-webkit-text-stroke: 1px rgb(255 236 214 / 70%);
text-shadow: none;
filter: none;
--beam-trail: linear-gradient(
90deg,
rgb(0 0 0 / var(--beam-rest)) calc(var(--beam-wipe, -9999px) - 0.5em),
transparent var(--beam-wipe, -9999px)
);
-webkit-mask-image: var(--beam-spot), var(--beam-trail);
mask-image: var(--beam-spot), var(--beam-trail);
}
/* The beam replaces the name's idle sheen. */
html[data-beam='on'] .hero-name-particles::after {
display: none;
}
/* Plates: a 2px edge that catches the same beam. */
html[data-beam='on'] .reading-stage .portfolio-section[data-current] .chapter-body::before {
content: '' / '';
position: absolute;
inset: -2px;
padding: 2px;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(
circle var(--beam-plate-r) at var(--beam-ex, -9999px) var(--beam-ey, -9999px),
rgb(var(--beam-lamp) / calc(var(--beam-i, 0) * 0.95)),
rgb(var(--beam-lamp) / calc(var(--beam-i, 0) * 0.3)) 45%,
transparent 100%
);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
}
}