feat: ignition opening with cold start, lamps, crossfade and shader warm-up

This commit is contained in:
2026-10-11 09:54:48 +03:00
parent 33585f7e34
commit 7423f5d52e
18 changed files with 516 additions and 25 deletions

View File

@@ -21,6 +21,7 @@ import './styles/route-log.css';
import './styles/billboard.css';
import './styles/dashcam.css';
import './styles/relay.css';
import './styles/ignition.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>();
</script>

View File

@@ -1,5 +1,6 @@
import { onMounted, onUnmounted, watch, type Ref } from 'vue';
import { createHeadlightBeam, type BeamSample } from '@/journey/headlight-beam';
import { readIgnition } from '@/journey/ignition-feed';
import type { QualityTier } from '@/journey/motion-policy';
import type { SectionId } from '@/content/types';
@@ -83,8 +84,11 @@ export function useHeadlightBeam(sources: {
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`;
// Letters lit by the intro arc stay lit at dipped level (CSS). Before
// the arc, nothing is lit: the lamp's idle drift must not pre-light
// letters the title card then reveals and IGNITION's hold would darken.
const next = beam.swept(now) ? '9999px'
: sweepRequested ? `${Math.max(-1, px - hero.left).toFixed(0)}px` : '-1px';
if (next !== wipe) {
wipe = next;
hero.el.style.setProperty('--beam-wipe', next);
@@ -130,8 +134,13 @@ export function useHeadlightBeam(sources: {
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);
// Without a scene frame (a slow or failed scene), the name still lights;
// a scene mid-IGNITION plays it itself when the main beam comes on.
const fallback = (): void => {
if (readIgnition().phase < 2) fallbackTimer = window.setTimeout(fallback, 400);
else sweep();
};
fallbackTimer = window.setTimeout(fallback, 2_500);
} else {
delete root().dataset.beam;
if (raf) cancelAnimationFrame(raf);
@@ -204,9 +213,9 @@ export function useHeadlightBeam(sources: {
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. */
/** Called for every rendered scene frame: the first one with the main beam on (IGNITION phase 2) plays the sweep. */
sceneFrame: (): void => {
if (sweepRequested) return;
if (sweepRequested || readIgnition().phase < 2) return;
sweepRequested = true;
sweep();
},

View File

@@ -0,0 +1,30 @@
/**
* IGNITION, the slot the DOM and the scene share (initial chunk, kept tiny).
* The scene writes the phase of its first three seconds (0 cold, 1 dipped
* lamps, 2 rolling, 3 driving); useHeadlightBeam holds the hero sweep until
* the main beam comes on (phase >= 2). With no scene the phase stays 3, so
* nothing waits on it. Nothing here schedules work.
*/
export type IgnitionPhase = 0 | 1 | 2 | 3;
const feed = {
phase: 3 as IgnitionPhase,
/** A generation already started the ignition: later ones (tier swap, context restore) begin driving. */
played: false,
/**
* A returning visitor gets the short form. Read once, at module evaluation:
* the route memory writes this key as soon as the shell mounts, so a later
* read would see every visit as a return.
*/
returning: ((): boolean => {
try {
const raw = typeof window === 'undefined' ? null : window.localStorage.getItem('nightdrive:route');
const visited = raw === null ? null : (JSON.parse(raw) as { visited?: unknown }).visited;
return Array.isArray(visited) && visited.length > 0;
} catch {
return false;
}
})(),
};
export const readIgnition = (): typeof feed => feed;

View File

@@ -17,6 +17,8 @@ export interface CarWorld {
distance?: number,
/** 0 driving, 1 parked at the destination: lamps drop to parking level. */
engineOff?: number,
/** IGNITION: 0 cold (every lamp out) .. 1 lit; multiplies the lamp level. */
ignition?: number,
): void;
}
@@ -419,8 +421,9 @@ export function createCar(
return {
group,
setDetail,
update(speed, acceleration, elapsedSeconds, _distance, engineOff = 0) {
const lamps = 1 - clamp01(engineOff) * 0.62;
update(speed, acceleration, elapsedSeconds, _distance, engineOff = 0, ignition = 1) {
const lit = clamp01(ignition);
const lamps = (1 - clamp01(engineOff) * 0.62) * lit;
const safeSpeed = clamp01(speed);
const response = clamp01(acceleration);
const elapsed = Number.isFinite(elapsedSeconds)
@@ -603,10 +606,11 @@ function createAuthoredCar(
detail = nextDetail;
setScale(detail);
},
update(speed, acceleration, elapsedSeconds, distance, engineOff = 0) {
update(speed, acceleration, elapsedSeconds, distance, engineOff = 0, ignition = 1) {
// Parked at the destination: the tail bar and the cyan running lamps
// drop to parking level, the underglow almost out.
const lamps = 1 - clamp01(engineOff) * 0.62;
// drop to parking level, the underglow almost out. Cold (IGNITION): all out.
const lit = clamp01(ignition);
const lamps = (1 - clamp01(engineOff) * 0.62) * lit;
const safeSpeed = clamp01(speed);
const response = clamp01(acceleration);
const elapsed = Number.isFinite(elapsedSeconds)
@@ -645,7 +649,7 @@ function createAuthoredCar(
for (const material of cyanMaterials)
material.emissiveIntensity = (2.2 + response * 1.6) * lamps;
underglowUniforms.uOpacity.value =
(0.09 + safeSpeed * 0.04 + response * 0.075) * (1 - clamp01(engineOff) * 0.8);
(0.09 + safeSpeed * 0.04 + response * 0.075) * (1 - clamp01(engineOff) * 0.8) * lit;
contact.uniforms.uOpacity.value = 0.21 + response * 0.055;
},
};

View File

@@ -46,6 +46,8 @@ import { dawnLightUniforms } from '@/scene/world/dawn-light';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
import { readDashcam, takeDashcamCapture } from '@/journey/dashcam-feed';
import { createDashcamLens } from '@/scene/world/dashcam-lens';
import { createIgnition, IGNITION } from '@/scene/world/ignition';
import { readIgnition } from '@/journey/ignition-feed';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD =
@@ -104,7 +106,7 @@ export async function createProceduralSceneGeneration(
renderer.outputColorSpace = THREE.SRGBColorSpace;
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
renderer.toneMapping = THREE.CustomToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.toneMappingExposure = IGNITION.exposure;
const scene = new THREE.Scene();
scene.name = 'world-root';
@@ -257,6 +259,9 @@ export async function createProceduralSceneGeneration(
let dawnState: DawnStep | '' = '';
// DASHCAM: the pointer drifts the lens and the letterbox closes while open.
const dashcamLens = createDashcamLens();
// IGNITION: the first frame is cold; lamps, beam, roll and exposure come up
// over three seconds (src/scene/world/ignition.ts).
const ignition = createIgnition();
const resize = (): void => {
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
@@ -367,6 +372,23 @@ export async function createProceduralSceneGeneration(
// lifting off slows the spectrum to rest without rewinding its phase.
terrainSpectrumPhase =
(terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1;
// IGNITION: the first three seconds. A scroll, a navigation, a sign, the
// dashcam or a stuttering start (the crane's rule) cuts to driving. The
// clock starts on the frame after the first: the first frame compiles
// the shaders (hundreds of ms on a cold GPU) while the canvas dissolves
// in on the compositor, and that stall must not eat the cold hold. A
// later hitch stretches the timeline instead of skipping a beat.
const ignited = ignition.update(renderedFrames <= 1 ? 0 : Math.min(0.1, wallSeconds), {
cut: snapshot.scrollY > 40
|| snapshot.activeSectionId !== 'start'
|| snapshot.sign !== null
|| readDashcam().open
|| introSlowFrames >= 3,
short: readIgnition().returning,
});
const roll = ignited.roll;
renderer.toneMappingExposure = ignited.exposure;
if (renderedFrames === 0) canvas.dataset.lit = '';
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
// The 03:12 window: section-local scroll progress drives every beat.
const service = maintenance.update(
@@ -427,7 +449,7 @@ export async function createProceduralSceneGeneration(
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
dampPalette(currentPalette, targetPalette, deltaSeconds);
const driveDistance = travelDistance.advance(
deltaSeconds * (1 - engineOff),
deltaSeconds * (1 - engineOff) * roll,
snapshot.driveSpeed,
response.roadEnergy,
);
@@ -435,11 +457,11 @@ export async function createProceduralSceneGeneration(
0.18 + response.roadEnergy * 0.82,
0.18,
1,
) * (1 - engineOff);
) * (1 - engineOff) * roll;
road.update(
driveDistance,
visualSpeed,
response.roadEnergy,
response.roadEnergy * roll,
currentPalette,
);
expressway.update(
@@ -473,7 +495,7 @@ export async function createProceduralSceneGeneration(
skyline.update(currentPalette, elapsedSeconds);
traffic.update(
deltaSeconds,
(18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff),
(18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff) * roll,
elapsedSeconds,
);
sun.update(
@@ -490,9 +512,10 @@ export async function createProceduralSceneGeneration(
elapsedSeconds,
driveDistance,
engineOff,
ignited.lamps,
);
speedWake.update(
overdrive,
overdrive * roll,
elapsedSeconds,
car.group.position,
width < 1100 || height < 620 ? 2.32 : 2.78,
@@ -509,7 +532,7 @@ export async function createProceduralSceneGeneration(
atmosphere.update(
driveDistance,
visualSpeed,
response.streakEnergy,
response.streakEnergy * roll,
currentPalette,
atmosphericTime,
overdrive,
@@ -658,7 +681,8 @@ export async function createProceduralSceneGeneration(
introFrames += 1;
if (introFrames > 3 && wallSeconds > 0.1) introSlowFrames += 1;
if (introSlowFrames >= 3) introProgress = 1;
introProgress = Math.min(1, introProgress + deltaSeconds / INTRO_SECONDS);
// IGNITION: the crane holds its top position until the main beam is on.
if (ignited.phase >= 2) introProgress = Math.min(1, introProgress + deltaSeconds / INTRO_SECONDS);
}
outroProgress = THREE.MathUtils.clamp(
outroProgress
@@ -699,8 +723,11 @@ 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);
// IGNITION: the dipped level lights the road throw; the steerable main
// beam (spot, haze cone, lift) only comes in above it.
const mainBeam = THREE.MathUtils.clamp((ignited.lamps - IGNITION.dipped) / (1 - IGNITION.dipped), 0, 1);
const beam = headlights.update(snapshot.beam, snapshot, camera, (1 - engineOff) * mainBeam, deltaSeconds);
road.setBeam(beam.yaw, beam.lift, ignited.lamps);
const stage = signStage.update(width, height);
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
const gantry = sign.dockState();
@@ -756,6 +783,37 @@ export async function createProceduralSceneGeneration(
);
}
}
if (renderedFrames === 0) {
// IGNITION parks the wake, the streaks and the beam cone until the
// pull-away, and the first scroll brings the sign: each would compile
// its program as a 70-280 ms hitch inside the opening shot (three slow
// frames end the crane; one over 250 ms is a quality downgrade that
// rebuilds the scene). Draw everything once now, behind the poster,
// through the real pipeline: renderer.compile() keys its programs for
// the screen (sRGB output, tone mapping) and never matches the post
// processor's linear targets, so every program compiled twice. The
// drawing buffer is presented once per task: the cold frame below
// is what the canvas shows.
// Culling is off too: the car sits outside the crane's frustum.
const hidden: { visible: boolean }[] = [];
const culled: { frustumCulled: boolean }[] = [];
scene.traverse((object: { visible: boolean; frustumCulled: boolean }) => {
if (object.frustumCulled) {
object.frustumCulled = false;
culled.push(object);
}
if (object.visible) return;
object.visible = true;
hidden.push(object);
});
try {
if (postProcessor) postProcessor.render(scene, camera);
else renderer.render(scene, camera);
} finally {
for (const object of hidden) object.visible = false;
for (const object of culled) object.frustumCulled = true;
}
}
if (postProcessor) {
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
postProcessor.setLens({
@@ -791,6 +849,7 @@ export async function createProceduralSceneGeneration(
delete document.documentElement.dataset.interlude;
delete document.documentElement.dataset.interludeArmed;
delete document.documentElement.dataset.dawn;
ignition.dispose();
dawnLightUniforms.uDawnLight.value = 0;
// Quality changes reuse the canvas context. Leave texture upload flags at
// defaults before the next renderer creates its empty array textures.

155
src/scene/world/ignition.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* IGNITION: the first three seconds. The first rendered frame is cold (low
* exposure, lamps off, road still, camera held at the crane's top) and the
* car starts from there: dipped lamps at 0.6 s, main beam at 1.0 s, then the
* road rolls up to cruise and the exposure lifts until 3.0 s. Ramps only
* rise (no lamp ever switches off: photosensitivity). A returning visitor
* gets a short form (lamps already on, no hold); any scroll, navigation,
* sign, dashcam or slow-frame run cuts to the driving state in 0.4 s.
*
* ignitionAt() is the pure timeline; createIgnition() adds the cut, writes
* html[data-ignition] (cold | lamps | rolling, CSS keys the HUD sub-line and
* the canvas fade off it) and the phase the DOM reads (ignition-feed.ts).
*/
import { readIgnition, type IgnitionPhase } from '@/journey/ignition-feed';
export type IgnitionAttr = 'cold' | 'lamps' | 'rolling' | null;
export interface IgnitionLevels {
/** Car lamps and road throw: 0 cold, DIPPED at 0.6 s, 1 with the main beam. */
lamps: number;
/** 0 parked .. 1 cruising: multiplies road, wake, streak and traffic speed. */
roll: number;
/** renderer.toneMappingExposure. */
exposure: number;
phase: IgnitionPhase;
}
/** Tunables (seconds from the first rendered frame unless noted). */
export const IGNITION = {
coldExposure: 0.35,
exposure: 1.12,
dipAt: 0.6,
dipFor: 0.15,
dipped: 0.45,
beamAt: 1.0,
beamFor: 0.25,
rollAt: 1.0,
rollEnd: 3.0,
/** html[data-ignition] stays 'rolling' this long so the sub-line can fade (CSS 3.5 s). */
sublineFor: 3.5,
/** Returning visitors. */
shortRoll: 1.2,
shortExposure: 0.6,
cutFor: 0.4,
} as const;
const DRIVING: IgnitionLevels = { lamps: 1, roll: 1, exposure: IGNITION.exposure, phase: 3 };
const clamp01 = (v: number): number => (v > 0 ? (v < 1 ? v : 1) : 0);
const ramp = (t: number, from: number, span: number): number => clamp01((t - from) / span);
const easeInCubic = (t: number): number => t * t * t;
const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;
const smooth = (t: number): number => t * t * (3 - 2 * t);
const mix = (a: number, b: number, t: number): number => a + (b - a) * t;
/** The uninterrupted timeline at t seconds after the first rendered frame. */
export function ignitionAt(t: number, short = false): IgnitionLevels & { attr: IgnitionAttr } {
const s = Number.isFinite(t) ? Math.max(0, t) : 0;
const k = IGNITION;
if (short) {
const p = ramp(s, 0, k.shortRoll);
return {
lamps: 1,
roll: easeInCubic(p),
exposure: mix(k.shortExposure, k.exposure, smooth(p)),
phase: p < 1 ? 2 : 3,
attr: s < k.sublineFor ? 'rolling' : null,
};
}
const p = ramp(s, k.rollAt, k.rollEnd - k.rollAt);
return {
lamps: s < k.beamAt
? k.dipped * ramp(s, k.dipAt, k.dipFor)
: mix(k.dipped, 1, ramp(s, k.beamAt, k.beamFor)),
roll: easeInCubic(p),
exposure: mix(k.coldExposure, k.exposure, smooth(p)),
phase: s < k.dipAt ? 0 : s < k.beamAt ? 1 : s < k.rollEnd ? 2 : 3,
attr: s < k.dipAt ? 'cold' : s < k.beamAt ? 'lamps' : s < k.rollAt + k.sublineFor ? 'rolling' : null,
};
}
export interface Ignition {
update(dt: number, input: { cut: boolean; short: boolean }): IgnitionLevels;
dispose(): void;
}
export function createIgnition(): Ignition {
const feed = readIgnition();
const root = typeof document === 'undefined' ? null : document.documentElement;
// A tier swap or a context restore rebuilds the scene: it starts driving.
let done = feed.played;
let started = false;
let short = false;
let t = 0;
let cutFrom: IgnitionLevels | null = null;
let cutT = 0;
let attr: IgnitionAttr = null;
let out: IgnitionLevels = DRIVING;
const write = (next: IgnitionAttr, phase: IgnitionPhase): void => {
feed.phase = phase;
if (next === attr || !root) return;
attr = next;
if (next) root.dataset.ignition = next;
else delete root.dataset.ignition;
};
return {
update(dt, input) {
if (!started) {
started = true;
short = input.short;
// Landing mid-page (a hash, a restored scroll): no ignition at all.
if (input.cut) done = true;
if (!done) feed.played = true;
}
if (done) {
write(null, 3);
return (out = DRIVING);
}
const step = Number.isFinite(dt) ? Math.max(0, dt) : 0;
if (cutFrom === null && input.cut) {
cutFrom = out;
cutT = 0;
}
if (cutFrom !== null) {
cutT += step;
const e = easeOutCubic(clamp01(cutT / IGNITION.cutFor));
if (e >= 1) {
done = true;
write(null, 3);
return (out = DRIVING);
}
out = {
lamps: mix(cutFrom.lamps, 1, e),
roll: mix(cutFrom.roll, 1, e),
exposure: mix(cutFrom.exposure, IGNITION.exposure, e),
phase: 2,
};
write(attr, 2);
return out;
}
t += step;
const at = ignitionAt(t, short);
write(at.attr, at.phase);
if (at.attr === null) done = true;
return (out = { lamps: at.lamps, roll: at.roll, exposure: at.exposure, phase: at.phase });
},
dispose() {
feed.phase = 3;
if (root) delete root.dataset.ignition;
attr = null;
},
};
}

View File

@@ -20,7 +20,8 @@ export interface RoadWorld {
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;
/** throwLevel: IGNITION's lamp level for the headlight throw (default 1). */
setBeam(yaw: number, lift: number, throwLevel?: number): void;
}
interface WorldGroup {
@@ -61,6 +62,7 @@ export function createRoad(
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
uBeamYaw: { value: 0 },
uBeamLift: { value: 0 },
uThrow: { value: 1 },
...dawnLightUniforms,
};
const material = resources.own(
@@ -97,6 +99,7 @@ export function createRoad(
uniform vec3 uHazeColor;
uniform float uBeamYaw;
uniform float uBeamLift;
uniform float uThrow;
varying vec2 vRoadUv;
varying float vRoadX;
varying float vViewDistance;
@@ -206,7 +209,7 @@ export function createRoad(
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);
* (1.0 + 0.3 * uBeamLift) * uThrow;
// Pylon lamps reflect as long streaks towards the camera.
float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)});
@@ -252,9 +255,10 @@ export function createRoad(
uniforms.uHazeColor.value.copy(palette.haze);
}
},
setBeam(yaw, lift) {
setBeam(yaw, lift, throwLevel = 1) {
uniforms.uBeamYaw.value = THREE.MathUtils.clamp(finiteOrZero(yaw), -1, 1);
uniforms.uBeamLift.value = clamp01(lift);
uniforms.uThrow.value = clamp01(throwLevel);
},
};
}

37
src/styles/ignition.css Normal file
View File

@@ -0,0 +1,37 @@
/* ---------- IGNITION (the first three seconds) ----------
The poster holds until the scene has drawn its first (cold) frame, which
marks the canvas data-lit; the canvas then crossfades over the poster.
The scene writes html[data-ignition] = cold | lamps | rolling and removes
it at the end; the server HTML carries neither attribute. */
.journey-canvas {
opacity: 0;
transition: opacity 0.6s cubic-bezier(0.33, 0, 0.2, 1);
}
.journey-canvas[data-lit] { opacity: 1; }
/* The poster is the lit, cruising shot; the cold frame sits at a third of its
exposure. Pull the poster down over the same 0.6 s so the dissolve reads
as one exposure pull, not a bright image giving way to a dark one. */
html:is([data-ignition='cold'], [data-ignition='lamps']) .journey-poster {
filter: brightness(0.5) saturate(0.9);
transition: filter 0.6s cubic-bezier(0.33, 0, 0.2, 1);
}
/* Returning visitors (short form, rolling from the first frame). */
html[data-ignition='rolling'] .journey-canvas { transition-duration: 0.8s; }
@media (prefers-reduced-motion: reduce) {
.journey-canvas { transition: none; }
html:is([data-ignition='cold'], [data-ignition='lamps']) .journey-poster { filter: none; transition: none; }
}
/* The gantry sub-line is free at the start: dawn, the maintenance window and
the route log all leave it empty there, and any of them still wins. */
html:is([data-ignition='lamps'], [data-ignition='rolling']) .journey-hud__subline:empty { display: block; }
html[data-ignition='lamps'] .journey-hud__subline:empty::before { content: 'IGNITION'; }
html[data-ignition='rolling'] .journey-hud__subline:empty::before { content: 'SHIFT START · 02:58'; }
@media (prefers-reduced-motion: no-preference) {
/* 'rolling' lasts 3.5 s: hold, then fade over the last second. */
html[data-ignition='rolling'] .journey-hud__subline:empty { animation: ignition-subline 3.5s linear both; }
}
@keyframes ignition-subline {
0%, 71% { opacity: 1; }
100% { opacity: 0; }
}