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

@@ -15,3 +15,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
- **Known weakness:** the reading panel covers the scene during p 0.1-0.75, so district power-down, packets and offsite are barely visible; relight uses the normal accent so the payoff reads as "back to normal". Audio rain bed skipped. Do not revisit before round 5. - **Known weakness:** the reading panel covers the scene during p 0.1-0.75, so district power-down, packets and offsite are barely visible; relight uses the normal accent so the payoff reads as "back to normal". Audio rain bed skipped. Do not revisit before round 5.
- Screenshots: docs/overnight/round1/ (before-* vs p*-1440/390). - Screenshots: docs/overnight/round1/ (before-* vs p*-1440/390).
## Round 2: High Beam (hero, road, plates)
- **What:** the cursor steers the headlights. Fine-pointer hero name first paints as an outline, a 1.6 s sweep lights it, then a pointer-following halo lights the letters, plate edges and sparks; in the world the road cones yaw, terrain/districts catch the light, a warm SpotLight rakes the truss (high/medium), and a haze cone shows in the gaps between chapters. Click hero/gantry window flashes (rate-limited). Idle 4 s returns to centre. Touch/low tier: slow sway and tap flash only. Reduced motion: no effect, name stays lit.
- **Why:** first thing a juror does is move the mouse; the hero had no pointer response and the scene none at all (direction.md signature moment 3, never built). Lives where round 1 said drama should: hero, gaps, panel edges.
- **Build:** src/journey/headlight-beam.ts, useHeadlightBeam.ts, scene/world/headlights.ts, styles/headlight.css; beam terms in road, districts, terrain shaders. Initial JS +1.3 KB gz (113.0), 732 tests green.
- **Verdict: KEEP with fixes.** Fable found the DOM half dead on delivery (NaN sentinel), fixed it, removed a drop-shadow rectangle artefact, raised the resting level. Frame times unchanged (p95 16.7 ms on hardware GL).
- **Known weaknesses:** the outline-to-lit sweep beat is short (~300 ms visible); haze cone slightly flat/white; spark boost barely perceptible.
- Screenshots: docs/overnight/round2/.

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ const emit = defineEmits<{
<template> <template>
<section id="start" class="hero-journey" data-layout="hero" data-exit="01" aria-labelledby="start-heading"> <section id="start" class="hero-journey" data-layout="hero" data-exit="01" aria-labelledby="start-heading">
<div class="hero-journey__content"> <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__role">{{ identity.role }}</p>
<p class="hero-journey__value">{{ identity.valueProposition }}</p> <p class="hero-journey__value">{{ identity.valueProposition }}</p>
<p class="hero-journey__availability">{{ identity.availability }}</p> <p class="hero-journey__availability">{{ identity.availability }}</p>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { heroBeam } from '@/composables/useHeadlightBeam';
const props = defineProps<{ allowMotion: boolean; name: string }>(); const props = defineProps<{ allowMotion: boolean; name: string }>();
const root = ref<HTMLElement | null>(null); 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(), x: random(), y: random(), duration: random(), phase: random(), size: random(), drift: random(),
})); }));
const BEAM_SPARK_RADIUS = 180;
const paint = (): void => { const paint = (): void => {
if (!context || width <= 0 || height <= 0) return; if (!context || width <= 0 || height <= 0) return;
const ctx = context; const ctx = context;
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
ctx.lineCap = 'round'; ctx.lineCap = 'round';
const fieldHeight = height - 16; 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++) { for (let layer = 0; layer < 2; layer++) {
ctx.globalAlpha = 0.7; ctx.globalAlpha = 0.7;
ctx.strokeStyle = layer ? '#ff87c8' : '#91e6ff'; 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; const y = spark.y * fieldHeight + (phase - 0.5) * (spark.drift - 0.5) * 26;
ctx.beginPath(); ctx.beginPath();
if (index % 8 === 0) { if (index % 8 === 0) {
ctx.globalAlpha = life * 0.85; ctx.globalAlpha = Math.min(1, life * 0.85 * boost(x, y));
ctx.strokeStyle = '#75aaff'; ctx.strokeStyle = '#75aaff';
ctx.lineWidth = 0.9; ctx.lineWidth = 0.9;
ctx.moveTo(x, y); ctx.moveTo(x, y);
ctx.lineTo(x - 5 - spark.duration * 9, y); ctx.lineTo(x - 5 - spark.duration * 9, y);
ctx.stroke(); ctx.stroke();
} else { } 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.fillStyle = '#f5f2ff';
ctx.arc(x, y, 0.5 + spark.size * 1.1, 0, Math.PI * 2); ctx.arc(x, y, 0.5 + spark.size * 1.1, 0, Math.PI * 2);
ctx.fill(); ctx.fill();

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, ref, toRef, watch } from 'vue'; import { computed, onMounted, ref, toRef, watch } from 'vue';
import { useSceneController, type SceneHostState } from '@/composables/useSceneController'; import { useSceneController, type SceneHostState } from '@/composables/useSceneController';
import type { JourneySnapshot } from '@/journey/types'; import type { JourneySnapshot } from '@/journey/types';
import type { BeamSample } from '@/journey/headlight-beam';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy'; import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
import posterUrl from '@/assets/hero-chromatic.jpg'; import posterUrl from '@/assets/hero-chromatic.jpg';
@@ -18,6 +19,8 @@ const props = defineProps<{
/** Closing credit for the destination letterbox: real identity facts only. */ /** Closing credit for the destination letterbox: real identity facts only. */
credit?: string; credit?: string;
sampleFrame?: (start: number, end: number) => unknown; sampleFrame?: (start: number, end: number) => unknown;
/** HIGH BEAM: the live headlight sample for the scene. */
sampleBeam?: (now: number) => BeamSample | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -30,7 +33,7 @@ const posterFailed = ref(TEST_BUILD && isTestFault('F7-poster-failure'));
const journey = toRef(props, 'journey'); const journey = toRef(props, 'journey');
const policy = toRef(props, 'policy'); const policy = toRef(props, 'policy');
const quality = toRef(props, 'quality'); 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 sceneState = computed(() => scene.state.value);
const sceneGeneration = computed(() => scene.snapshot.value?.currentGeneration ?? null); const sceneGeneration = computed(() => scene.snapshot.value?.currentGeneration ?? null);
const showCanvas = computed(() => ( 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'; } from 'vue';
import { createSceneController } from '@/scene/create-scene-controller'; import { createSceneController } from '@/scene/create-scene-controller';
import type { JourneySnapshot } from '@/journey/types'; import type { JourneySnapshot } from '@/journey/types';
import type { BeamSample } from '@/journey/headlight-beam';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy'; import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import type { SceneController, SceneControllerSnapshot } from '@/scene/types'; import type { SceneController, SceneControllerSnapshot } from '@/scene/types';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
@@ -30,6 +31,7 @@ export interface SceneControllerSources {
policy: Readonly<Ref<MotionPolicy>>; policy: Readonly<Ref<MotionPolicy>>;
quality: Readonly<Ref<QualityTier>>; quality: Readonly<Ref<QualityTier>>;
sampleFrame?: ((start: number, end: number) => unknown) | undefined; sampleFrame?: ((start: number, end: number) => unknown) | undefined;
sampleBeam?: ((now: number) => BeamSample | null) | undefined;
} }
export function useSceneController(sources: SceneControllerSources): SceneControllerBinding { export function useSceneController(sources: SceneControllerSources): SceneControllerBinding {
@@ -126,6 +128,7 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
}, },
motionAllowed: () => sources.policy.value.allowSceneRequest, motionAllowed: () => sources.policy.value.allowSceneRequest,
onFrameRendered: sources.sampleFrame, onFrameRendered: sources.sampleFrame,
sampleBeam: sources.sampleBeam,
onStateChange: (nextState) => { onStateChange: (nextState) => {
queueMicrotask(() => { queueMicrotask(() => {
if (controller !== next) return; 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 { SectionId } from "@/content/types";
import type { SignViewportProfile } from '@/journey/sign-routes'; import type { SignViewportProfile } from '@/journey/sign-routes';
import type { BeamSample } from '@/journey/headlight-beam';
export interface ScrollDriveSample { export interface ScrollDriveSample {
scrollY: number; scrollY: number;
@@ -78,4 +79,7 @@ export interface JourneySnapshot extends ScrollDriveSnapshot {
sign: SignSnapshot | null; sign: SignSnapshot | null;
arrived: boolean; arrived: boolean;
sectionActivation: SectionActivationSnapshot; 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 { try {
const frameStartedAt = scheduler.now(); const frameStartedAt = scheduler.now();
const active = current; 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 complete = active.runtime.render(latestJourney, now) !== false;
const frameEndedAt = scheduler.now(); const frameEndedAt = scheduler.now();
if (complete && current === active) { if (complete && current === active) {
@@ -589,6 +591,13 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
? null ? null
: finite(snapshot.sectionActivation.nextHeadingOffsetVh), : 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 { JourneySnapshot } from '@/journey/types';
import type { BeamSample } from '@/journey/headlight-beam';
import type { QualityTier } from '@/journey/motion-policy'; import type { QualityTier } from '@/journey/motion-policy';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
@@ -55,6 +56,8 @@ export interface SceneControllerOptions {
page?: ScenePageEnvironment; page?: ScenePageEnvironment;
motionAllowed?: () => boolean; motionAllowed?: () => boolean;
onFrameRendered?: ((start: number, end: number) => unknown) | undefined; 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; onStateChange?: ((state: SceneLifecycleState) => unknown) | undefined;
signal?: AbortSignal; signal?: AbortSignal;
} }

View File

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

View File

@@ -32,6 +32,7 @@ import {
type MutableChapterPalette, type MutableChapterPalette,
} from '@/scene/world/palette'; } from '@/scene/world/palette';
import { createMaintenanceWindow } from '@/scene/world/maintenance-window'; import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { createHeadlights } from '@/scene/world/headlights';
import { maintenanceProgress } from '@/journey/maintenance-progress'; import { maintenanceProgress } from '@/journey/maintenance-progress';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
@@ -145,6 +146,13 @@ export async function createProceduralSceneGeneration(
maximumTextureSize: renderer.capabilities.maxTextureSize, maximumTextureSize: renderer.capabilities.maxTextureSize,
}); });
const atmosphere = createAtmosphere(context.resources, quality); 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 // 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. // power-down, the re-light and the palette nudge: no rain, packets or lightning.
const maintenance = createMaintenanceWindow(context.resources); const maintenance = createMaintenanceWindow(context.resources);
@@ -168,6 +176,7 @@ export async function createProceduralSceneGeneration(
speedWake.group, speedWake.group,
sign.group, sign.group,
sign.truss, sign.truss,
...headlights.lights,
); );
const postProcessor = quality.postEffects const postProcessor = quality.postEffects
@@ -612,6 +621,8 @@ export async function createProceduralSceneGeneration(
camera.fov = lensFov; camera.fov = lensFov;
camera.updateProjectionMatrix(); 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); const stage = signStage.update(width, height);
sign.compose(camera, width, height, stage.dock, now, stage.tilt); sign.compose(camera, width, height, stage.dock, now, stage.tilt);
const gantry = sign.dockState(); 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, acceleration: number,
palette?: Readonly<MutableChapterPalette>, palette?: Readonly<MutableChapterPalette>,
): void; ): 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 { interface WorldGroup {
@@ -56,6 +58,8 @@ export function createRoad(
uLaneColor: { value: new THREE.Color(0xffb568) }, uLaneColor: { value: new THREE.Color(0xffb568) },
uFogColor: { value: new THREE.Color(initialPalette.fog) }, uFogColor: { value: new THREE.Color(initialPalette.fog) },
uHazeColor: { value: new THREE.Color(initialPalette.haze) }, uHazeColor: { value: new THREE.Color(initialPalette.haze) },
uBeamYaw: { value: 0 },
uBeamLift: { value: 0 },
}; };
const material = resources.own( const material = resources.own(
'material', 'material',
@@ -89,6 +93,8 @@ export function createRoad(
uniform vec3 uLaneColor; uniform vec3 uLaneColor;
uniform vec3 uFogColor; uniform vec3 uFogColor;
uniform vec3 uHazeColor; uniform vec3 uHazeColor;
uniform float uBeamYaw;
uniform float uBeamLift;
varying vec2 vRoadUv; varying vec2 vRoadUv;
varying float vRoadX; varying float vRoadX;
varying float vViewDistance; varying float vViewDistance;
@@ -184,10 +190,14 @@ export function createRoad(
// Headlight throw: two warm cones ahead of the car merging into a pool. // Headlight throw: two warm cones ahead of the car merging into a pool.
float ahead = -vWorldZ - 5.0; 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 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)); float beamX = vRoadX - uBeamYaw * 3.4 * clamp(ahead / 26.0, 0.0, 4.6);
color += vec3(1.0, 0.84, 0.66) * min(cones, 1.4) * throwZ * (0.07 + 0.16 * film) * asphaltMask; 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. // Pylon lamps reflect as long streaks towards the camera.
float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)}); float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)});
@@ -233,6 +243,10 @@ export function createRoad(
uniforms.uHazeColor.value.copy(palette.haze); 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 * as THREE from 'three';
import { BEAM_PARS, beamUniforms } from './headlights';
import type { EnvironmentChapter } from '@/journey/types'; import type { EnvironmentChapter } from '@/journey/types';
import type { RenderQualitySettings } from '@/scene/render-quality'; import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
@@ -235,7 +236,7 @@ export function createTerrain(
const groundMaterial = resources.own( const groundMaterial = resources.own(
'material', 'material',
new THREE.ShaderMaterial({ new THREE.ShaderMaterial({
uniforms, uniforms: { ...uniforms, ...beamUniforms },
vertexShader: material.vertexShader.replace( vertexShader: material.vertexShader.replace(
'displaced.y += height + 0.028;', 'displaced.y += height + 0.028;',
'displaced.y += height - 0.12;', 'displaced.y += height - 0.12;',
@@ -249,11 +250,15 @@ export function createTerrain(
varying float vDepthFade; varying float vDepthFade;
varying vec3 vTerrainPosition; varying vec3 vTerrainPosition;
varying vec3 vGridColor; varying vec3 vGridColor;
${BEAM_PARS}
void main() { void main() {
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition))); vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3)))); 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); 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; 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); color = mix(uFogColor * 0.55, color, vDepthFade);
gl_FragColor = vec4(color, 1.0); gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment> #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);
}
}

View File

@@ -162,3 +162,17 @@ describe('the 03:12 window on the server', () => {
expect(subline?.textContent).toBe(''); expect(subline?.textContent).toBe('');
}); });
}); });
describe('HIGH BEAM on the server', () => {
it('ships no beam state: the name renders fully lit and its text is unchanged', async () => {
const html = await render('/', validInputs);
expect(html).not.toContain('data-beam');
expect(html).not.toContain('--beam-');
const document = await renderedDocument();
expect(document.documentElement.hasAttribute('data-beam')).toBe(false);
expect(document.querySelector('#start h1')?.textContent).toBe('Luca Pîslaru');
const name = document.querySelector('.hero-name__text');
expect(name?.getAttribute('data-name')).toBe('Luca Pîslaru');
expect(name?.textContent).toBe('Luca Pîslaru');
});
});

View File

@@ -0,0 +1,245 @@
import { describe, expect, it } from 'vitest';
import {
createHeadlightBeam,
FLASH_HOLD_MS,
FLASH_MIN_INTERVAL_MS,
IDLE_AFTER_MS,
REST_INTENSITY,
SWAY_AMPLITUDE,
SWEEP_FROM,
SWEEP_MS,
SWEEP_TO,
type HeadlightBeam,
} from '@/journey/headlight-beam';
/** Samples every 16 ms from `from` up to and including `to`. */
const run = (beam: HeadlightBeam, from: number, to: number) => {
let sample = beam.sample(from);
for (let now = from + 16; now <= to; now += 16) sample = beam.sample(now);
return beam.sample(to);
};
const swept = (start = 0): HeadlightBeam => {
const beam = createHeadlightBeam();
beam.sample(start);
beam.sweep(start);
run(beam, start, start + SWEEP_MS + 2_000);
return beam;
};
describe('headlight beam: smoothing', () => {
it('points dead ahead and dark before anything happens', () => {
const beam = createHeadlightBeam();
expect(beam.sample(0)).toEqual({ x: 0, y: 0, intensity: 0 });
expect(run(beam, 0, 1_000)).toEqual({ x: 0, y: 0, intensity: 0 });
});
it('follows the pointer critically damped: no overshoot, close within half a second', () => {
const beam = createHeadlightBeam();
beam.sample(0);
beam.input(0.8, -0.4, 'mouse', 0);
let previous = 0;
let peak = 0;
for (let now = 16; now <= 600; now += 16) {
const { x } = beam.sample(now);
expect(x).toBeGreaterThanOrEqual(previous - 1e-9);
previous = x;
peak = Math.max(peak, x);
if (now === 176) {
// About the smooth time (180 ms): more than a third, not there yet.
expect(x).toBeGreaterThan(0.8 * 0.35);
expect(x).toBeLessThan(0.8 * 0.8);
}
}
expect(peak).toBeLessThanOrEqual(0.8 + 1e-9);
const settled = beam.sample(600);
expect(settled.x).toBeCloseTo(0.8, 1);
expect(settled.y).toBeCloseTo(-0.4, 1);
});
it('clamps input to the viewport and ignores touch (a tap is a flash)', () => {
const beam = createHeadlightBeam();
beam.sample(0);
beam.input(0.5, 0, 'touch', 0);
expect(run(beam, 0, 800).x).toBe(0);
beam.input(4, -9, 'pen', 800);
const sample = run(beam, 800, 2_400);
expect(sample.x).toBeCloseTo(1, 2);
expect(sample.y).toBeCloseTo(-1, 2);
});
it('survives a long frame gap without jumping past the target', () => {
const beam = createHeadlightBeam();
beam.sample(0);
beam.input(1, 0, 'mouse', 0);
// One 3 s gap (a backgrounded tab) is integrated as a single short step.
const sample = beam.sample(3_000);
expect(sample.x).toBeGreaterThan(0);
expect(sample.x).toBeLessThanOrEqual(1);
});
it('returns the same sample for the same frame time', () => {
const beam = createHeadlightBeam();
beam.sample(0);
beam.input(1, 0, 'mouse', 0);
const a = beam.sample(50);
expect(beam.sample(50)).toBe(a);
expect(beam.sample(40)).toBe(a);
});
});
describe('headlight beam: idle return', () => {
it('holds the pointer, then drifts back to dead ahead after 4 s idle', () => {
const beam = createHeadlightBeam();
beam.sample(0);
beam.input(0.9, 0.5, 'mouse', 0);
expect(run(beam, 0, IDLE_AFTER_MS - 50).x).toBeCloseTo(0.9, 2);
expect(beam.idleAt()).toBe(IDLE_AFTER_MS);
// Slower than the follow: a third of a second in, it has barely moved.
const early = run(beam, IDLE_AFTER_MS - 50, IDLE_AFTER_MS + 300);
expect(early.x).toBeGreaterThan(0.6);
const back = run(beam, IDLE_AFTER_MS + 300, IDLE_AFTER_MS + 5_000);
expect(back.x).toBeCloseTo(0, 2);
expect(back.y).toBeCloseTo(0, 2);
expect(beam.settled(IDLE_AFTER_MS + 5_000)).toBe(true);
});
it('a new input cancels the idle drift', () => {
const beam = createHeadlightBeam();
beam.sample(0);
beam.input(0.9, 0, 'mouse', 0);
run(beam, 0, IDLE_AFTER_MS + 400);
beam.input(-0.5, 0, 'mouse', IDLE_AFTER_MS + 400);
expect(run(beam, IDLE_AFTER_MS + 400, IDLE_AFTER_MS + 1_400).x).toBeCloseTo(-0.5, 2);
});
});
describe('headlight beam: intro sweep', () => {
it('arcs from -1 to +0.25 over 1.6 s, ease-out cubic, intensity 0 to 1', () => {
const beam = createHeadlightBeam();
beam.sample(0);
expect(beam.sweep(100, -0.4)).toBe(true);
const start = beam.sample(100);
expect(start.x).toBeCloseTo(SWEEP_FROM, 6);
expect(start.intensity).toBeCloseTo(0, 6);
expect(start.y).toBeCloseTo(-0.4, 6);
// Ease-out: well past halfway in x at half time.
const half = beam.sample(100 + SWEEP_MS / 2);
const eased = 1 - 0.5 ** 3;
expect(half.x).toBeCloseTo(SWEEP_FROM + (SWEEP_TO - SWEEP_FROM) * eased, 6);
expect(half.intensity).toBeCloseTo(eased, 6);
expect(beam.swept(100 + SWEEP_MS - 1)).toBe(false);
const end = beam.sample(100 + SWEEP_MS);
expect(end.x).toBeCloseTo(SWEEP_TO, 6);
expect(end.intensity).toBeCloseTo(1, 6);
expect(beam.swept(100 + SWEEP_MS)).toBe(true);
});
it('is monotonic in x and plays only once', () => {
const beam = createHeadlightBeam();
beam.sample(0);
beam.sweep(0);
let previous = -Infinity;
for (let now = 0; now <= SWEEP_MS; now += 16) {
const { x } = beam.sample(now);
expect(x).toBeGreaterThanOrEqual(previous);
previous = x;
}
expect(beam.sweep(SWEEP_MS + 10)).toBe(false);
});
it('holds the arc end, then settles at dipped beam until the idle drift', () => {
const beam = swept();
const rest = beam.sample(SWEEP_MS + 2_000);
expect(rest.x).toBeCloseTo(SWEEP_TO, 3);
expect(rest.intensity).toBeCloseTo(REST_INTENSITY, 3);
expect(run(beam, SWEEP_MS + 2_000, SWEEP_MS + IDLE_AFTER_MS + 6_000).x).toBeCloseTo(0, 2);
});
it('hands over to a pointer that moved during the sweep', () => {
const beam = createHeadlightBeam();
beam.sample(0);
beam.sweep(0);
beam.input(-0.6, 0.2, 'mouse', 400);
expect(beam.sample(800).x).toBeLessThan(SWEEP_TO);
const after = run(beam, 800, SWEEP_MS + 1_000);
expect(after.x).toBeCloseTo(-0.6, 2);
});
});
describe('headlight beam: flash', () => {
it('lifts to full intensity for 420 ms, then falls back to dipped beam', () => {
const beam = swept();
const t0 = SWEEP_MS + 2_000;
expect(beam.flash(t0)).toBe(true);
const lifted = run(beam, t0, t0 + 300);
expect(lifted.intensity).toBeGreaterThan(0.98);
const after = run(beam, t0 + 300, t0 + FLASH_HOLD_MS + 1_500);
expect(after.intensity).toBeCloseTo(REST_INTENSITY, 2);
});
it('eases in rather than stepping (no hard flash)', () => {
const beam = swept();
const t0 = SWEEP_MS + 2_000;
beam.flash(t0);
const first = beam.sample(t0 + 16);
expect(first.intensity - REST_INTENSITY).toBeLessThan((1 - REST_INTENSITY) * 0.3);
});
it('is rate-limited to one per 600 ms: never more than 3 per second', () => {
expect(FLASH_MIN_INTERVAL_MS).toBeGreaterThanOrEqual(1000 / 3);
const beam = swept();
const t0 = 10_000;
const accepted: number[] = [];
// A visitor hammering the hero: 10 clicks a second for 5 seconds.
for (let now = t0; now < t0 + 5_000; now += 100) {
if (beam.flash(now)) accepted.push(now);
}
for (let i = 1; i < accepted.length; i++) {
expect(accepted[i]! - accepted[i - 1]!).toBeGreaterThanOrEqual(FLASH_MIN_INTERVAL_MS);
}
for (let start = t0; start < t0 + 5_000; start += 100) {
expect(accepted.filter((at) => at >= start && at < start + 1_000).length).toBeLessThanOrEqual(2);
}
expect(accepted.length).toBeGreaterThan(0);
});
it('the intensity crosses its midpoint at most 3 times a second under hammering', () => {
const beam = swept();
const t0 = 10_000;
const mid = (1 + REST_INTENSITY) / 2;
let above = false;
const crossings: number[] = [];
for (let now = t0; now < t0 + 6_000; now += 8) {
if (now % 100 === 0) beam.flash(now);
const high = beam.sample(now).intensity > mid;
if (high && !above) crossings.push(now);
above = high;
}
for (let start = t0; start < t0 + 5_000; start += 50) {
expect(crossings.filter((at) => at >= start && at < start + 1_000).length).toBeLessThanOrEqual(3);
}
});
});
describe('headlight beam: auto-sway (touch, coarse, low tier)', () => {
it('sways slowly within +-0.3 and never settles', () => {
const beam = swept();
beam.setAutoSway(true);
let min = Infinity;
let max = -Infinity;
const t0 = SWEEP_MS + 2_000;
for (let now = t0; now <= t0 + 25_000; now += 32) {
const { x } = beam.sample(now);
min = Math.min(min, x);
max = Math.max(max, x);
}
expect(max).toBeLessThanOrEqual(SWEEP_AMPLITUDE_GUARD);
expect(min).toBeGreaterThanOrEqual(-SWEEP_AMPLITUDE_GUARD);
expect(max - min).toBeGreaterThan(SWAY_AMPLITUDE);
expect(beam.settled(t0 + 25_000)).toBe(false);
});
});
/** The sway target is +-0.3; the arc end (0.25) is inside it too. */
const SWEEP_AMPLITUDE_GUARD = SWAY_AMPLITUDE + 1e-6;

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { SceneResourceBag } from '@/scene/resources';
import { renderQualityFor } from '@/scene/render-quality';
import { createRoad } from '@/scene/world/road';
import {
beamLevels,
beamUniforms,
createHeadlights,
roadOpenness,
} from '@/scene/world/headlights';
import { REST_INTENSITY } from '@/journey/headlight-beam';
const activation = (active: number, next: number | null) => ({
layoutRevision: 1,
activeHeadingOffsetVh: active,
nextHeadingOffsetVh: next,
});
const car = () => {
const group = new THREE.Group();
group.position.set(0, 0.4, 0.72);
return group;
};
const camera = () => {
const lens = new THREE.PerspectiveCamera(40, 1.6, 0.1, 1000);
lens.position.set(4, 4.5, 15);
return lens;
};
/* three ships untyped here (see src/scene/three.d.ts). */
interface Spot {
castShadow: boolean;
angle: number;
distance: number;
intensity: number;
color: { getHex(): number };
position: { x: number; z: number };
target: { position: { x: number; z: number } };
}
interface Uniforms { material: { uniforms: Record<string, { value: number }> } }
const isSpot = (light: unknown): light is Spot => light instanceof THREE.SpotLight;
const open = { activeSectionId: 'start' as const, sectionActivation: activation(0, 100) };
describe('high beam: scene', () => {
it('opens the road at the hero and between plates, closes it under a plate', () => {
expect(roadOpenness(open)).toBe(1);
// A chapter heading just reached the top: its plate is on screen.
expect(roadOpenness({ activeSectionId: 'experience', sectionActivation: activation(-40, 60) })).toBeLessThan(0.05);
// Just past the heading, and again in the gap before the next one.
expect(roadOpenness({ activeSectionId: 'experience', sectionActivation: activation(0, 100) })).toBe(1);
expect(roadOpenness({ activeSectionId: 'experience', sectionActivation: activation(-95, 5) })).toBeGreaterThan(0.95);
// The destination has no next heading: the plate holds the screen.
expect(roadOpenness({ activeSectionId: 'contact', sectionActivation: activation(-20, null) })).toBe(0);
});
it('ramps presence with the sweep and lifts only above dipped beam', () => {
expect(beamLevels(undefined)).toEqual({ presence: 0, lift: 0 });
expect(beamLevels({ x: 0, y: 0, intensity: REST_INTENSITY / 2 }).presence).toBeCloseTo(0.5);
expect(beamLevels({ x: 0, y: 0, intensity: REST_INTENSITY })).toEqual({ presence: 1, lift: 0 });
expect(beamLevels({ x: 0, y: 0, intensity: 1 }).lift).toBeCloseTo(1);
});
it('high adds one shadowless warm spotlight and a haze cone; medium the spot only; low neither', () => {
for (const tier of ['high', 'medium', 'low'] as const) {
const resources = new SceneResourceBag();
const parent = car();
const headlights = createHeadlights(resources, tier, parent);
const spots = headlights.lights.filter(isSpot);
expect(spots).toHaveLength(tier === 'low' ? 0 : 1);
for (const spot of spots) {
expect(spot.castShadow).toBe(false);
expect(spot.angle).toBeCloseTo(0.42);
expect(spot.distance).toBe(70);
expect(spot.color.getHex()).toBe(0xffd6a8);
}
expect(parent.getObjectByName('high-beam-cone') !== undefined).toBe(tier === 'high');
resources.dispose();
}
});
it('aims the spot and the shader beam with the pointer, dark until the sweep', () => {
const resources = new SceneResourceBag();
const parent = car();
const headlights = createHeadlights(resources, 'high', parent);
const spot = headlights.lights.find(isSpot)!;
const lens = camera();
headlights.update(undefined, open, lens, 1, 0.016);
expect(spot.intensity).toBe(0);
expect(beamUniforms.uBeamGain.value).toBe(0);
const right = headlights.update({ x: 1, y: 0, intensity: REST_INTENSITY }, open, lens, 1, 0.016);
expect(right.yaw).toBeCloseTo(1);
expect(right.lift).toBe(0);
expect(spot.intensity).toBeGreaterThan(0);
expect(spot.target.position.x).toBeGreaterThan(spot.position.x);
expect(spot.target.position.z).toBeLessThan(spot.position.z);
expect(beamUniforms.uBeamDir.value.x).toBeGreaterThan(0);
const left = headlights.update({ x: -1, y: 0, intensity: 1 }, open, lens, 1, 0.016);
expect(left.yaw).toBeCloseTo(-1);
expect(left.lift).toBeCloseTo(1);
expect(spot.target.position.x).toBeLessThan(spot.position.x);
// Parked at the destination: the lamps are off.
headlights.update({ x: 0, y: 0, intensity: 1 }, open, lens, 0, 0.016);
expect(spot.intensity).toBe(0);
resources.dispose();
});
it('fades the haze cone out under a reading plate', () => {
const resources = new SceneResourceBag();
const parent = car();
const headlights = createHeadlights(resources, 'high', parent);
const cone = parent.getObjectByName('high-beam-cone');
const lens = camera();
const beam = { x: 0.3, y: 0, intensity: REST_INTENSITY };
headlights.update(beam, open, lens, 1, 0.016);
expect(cone.visible).toBe(true);
const plate = { activeSectionId: 'experience' as const, sectionActivation: activation(-40, 60) };
for (let frame = 0; frame < 120; frame++) headlights.update(beam, plate, lens, 1, 0.016);
expect(cone.visible).toBe(false);
resources.dispose();
});
it('the road takes the yaw and the lift as clamped uniforms', () => {
const resources = new SceneResourceBag();
const road = createRoad(resources, renderQualityFor('high', 1, false));
road.setBeam(4, -2);
const surface = road.group.getObjectByName('road-surface') as Uniforms;
expect(surface.material.uniforms.uBeamYaw?.value).toBe(1);
expect(surface.material.uniforms.uBeamLift?.value).toBe(0);
resources.dispose();
});
});