feat: cursor-steered headlights light the hero name, plates and world
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => (
|
||||
|
||||
214
src/composables/useHeadlightBeam.ts
Normal file
214
src/composables/useHeadlightBeam.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
174
src/journey/headlight-beam.ts
Normal file
174
src/journey/headlight-beam.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
206
src/scene/world/headlights.ts
Normal file
206
src/scene/world/headlights.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
109
src/styles/headlight.css
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user