feat: ignition opening with cold start, lamps, crossfade and shader warm-up
@@ -63,3 +63,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
|
||||
- **Verdict: KEEP with fixes** (Fable, hardware GL): near/far pairs overlapped and the near-right mast clipped, so layout is now aspect-aware with the far mast closer to centre; lamps were white and rungs grey, now hot violet above the bloom threshold; beams brightened and widened with a eased base; hover flash made visible.
|
||||
- **Known weaknesses:** lamp cores still tonemap to lavender-white (violet lives in the halo); masts are slivers on phones and modest at 1024 wide; hover flash is a peripheral beat; chirp verified by unit test only.
|
||||
- Screenshots: docs/overnight/round7/.
|
||||
|
||||
## Round 8: IGNITION (the first three seconds)
|
||||
- **What:** the site opens as a film: the first rendered frame is cold (exposure 0.35, lamps off, road stopped) and dissolves over the SSR poster in 0.6 s; dipped lamps at +0.6 s, main beam at +1.0 s with the hero-name sweep; from +1.0 to +3.0 s the road and streaks ease to cruise while the exposure rises and the crane starts; the HUD subline reads IGNITION, then SHIFT START · 02:58, fading out by +4.5 s. html[data-ignition] walks cold → lamps → rolling and is removed. Returning visitors (localStorage nightdrive:route) get the short form; a scroll, nav, dashcam or section change inside 3 s cuts to driving in 0.4 s; deep links skip it; reduced motion, static and data-saver keep the poster only.
|
||||
- **Why:** the previous opening was a poster that silently became a lit, cruising scene: a loading state. An ignition beat is a reason for the dark first frame and makes the pull-away the first thing the visitor watches.
|
||||
- **Build:** scene/world/ignition.ts, journey/ignition-feed.ts, styles/ignition.css; environment.ts, useHeadlightBeam.ts, car.ts, road.ts. 854 tests (74 files), initial JS 118.23 KB gz (1.8 KB left), CSS 26.73 KB gz, scene chunk 215 KB gz.
|
||||
- **Verdict: SHIP** (Fable, hardware GL 1440x900, fresh context): beats land on the spec clock (lamps +0.60, rolling +1.00, sweep +1.05, attribute removed +4.5 s); the dissolve is one exposure pull (the poster darkens with the canvas fade, frame luminance 47.7 → 28 with no step over 15 per 200 ms); rAF p50 16.7 / p95 16.8 ms after the first frame. Fixes made during review: (1) the ignition clock no longer counts the first-frame shader stall, so the cold hold is a real 0.6 s; (2) the poster dims with the dissolve instead of a bright image giving way to a dark one; (3) the hero name is unlit until the sweep (the lamp's idle drift pre-lit letters the hold then darkened); (4) every program is compiled in frame 0 behind the poster by a warm render of the whole scene (hidden objects visible, culling off) through the real post-processing pipeline. renderer.compile() keys programs for the screen and never matched the composer's linear targets, so the car, the sign and the streaks each compiled on first sight: 230-280 ms stalls on the first scroll, which tripped the 250 ms catastrophic-frame downgrade and rebuilt the scene mid-opening (a 350 ms freeze and a brightness pop). After the fix no long task follows the first frame in any scenario; the first scroll cuts cleanly in 0.4 s.
|
||||
- **Known weaknesses:** the first frame now lands ~1.7-1.9 s after navigation on a cold GPU (all ~60 programs compile behind the poster in one 1.2 s task; the poster holds, nothing is blank); clicking a nav link inside the opening fires the pre-existing exit-sign flare, one bright frame (mean 80/255) on arrival; the phone opening is quieter (no hero sweep on coarse pointers); the short form's road start is a small 5-point dip in luminance before cruise.
|
||||
- Screenshots: docs/overnight/round8/ (01 long contact sheet 1.5-6.0 s, 02 cold frame, 03 lamps on, 04 rolling + sweep, 05 short form, 06 early-scroll cut sheet, 07 phone 390x844).
|
||||
|
||||
BIN
docs/overnight/round8/01-long-contact-sheet.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/overnight/round8/02-cold-frame-2.1s.webp
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
docs/overnight/round8/03-lamps-on-2.6s.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/overnight/round8/04-rolling-sweep-3.3s.webp
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
docs/overnight/round8/05-short-form-returning-1.5s.webp
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
docs/overnight/round8/06-early-scroll-cut-sheet.webp
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/overnight/round8/07-phone-390-rolling-2.9s.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
30
src/journey/ignition-feed.ts
Normal 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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -55,6 +55,13 @@ const waitForRenderedPortfolio = async (
|
||||
"data-scene-state",
|
||||
"running",
|
||||
);
|
||||
// IGNITION: the first frame is cold and the canvas crossfades in; wait for
|
||||
// the first frame (data-lit), the end of the ignition and the fade.
|
||||
await expect(page.locator(".journey-canvas[data-lit]")).toHaveCount(1);
|
||||
await expect(page.locator("html:not([data-ignition])")).toHaveCount(1, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.locator(".journey-canvas")).toHaveCSS("opacity", "1");
|
||||
};
|
||||
|
||||
const captureViewport = async (
|
||||
|
||||
177
tests/unit/ignition.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createIgnition, IGNITION, ignitionAt } from '@/scene/world/ignition';
|
||||
import { readIgnition } from '@/journey/ignition-feed';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { createCar } from '@/scene/world/car';
|
||||
import { createRoad } from '@/scene/world/road';
|
||||
import { renderQualityFor } from '@/scene/render-quality';
|
||||
|
||||
const FRAME = 1 / 60;
|
||||
const root = (): HTMLElement => document.documentElement;
|
||||
const run = (seconds: number, step: (t: number) => { cut: boolean; short: boolean }, ignition = createIgnition()) => {
|
||||
const frames = [];
|
||||
for (let t = 0; t <= seconds + 1e-9; t += FRAME) frames.push({ t, ...ignition.update(t === 0 ? 0 : FRAME, step(t)), attr: root().dataset.ignition });
|
||||
return { frames, ignition };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const feed = readIgnition();
|
||||
feed.played = false;
|
||||
feed.phase = 3;
|
||||
delete root().dataset.ignition;
|
||||
});
|
||||
afterEach(() => { delete root().dataset.ignition; });
|
||||
|
||||
describe('ignition timeline', () => {
|
||||
it('starts cold: lamps out, road still, low exposure', () => {
|
||||
expect(ignitionAt(0)).toEqual({ lamps: 0, roll: 0, exposure: IGNITION.coldExposure, phase: 0, attr: 'cold' });
|
||||
});
|
||||
|
||||
it('dips the lamps at 0.6 s, then the main beam at 1.0 s', () => {
|
||||
const at06 = ignitionAt(0.6);
|
||||
expect(at06.lamps).toBe(0);
|
||||
expect(at06.phase).toBe(1);
|
||||
expect(at06.attr).toBe('lamps');
|
||||
expect(ignitionAt(0.6 + IGNITION.dipFor).lamps).toBeCloseTo(IGNITION.dipped);
|
||||
const at10 = ignitionAt(1.0);
|
||||
expect(at10.lamps).toBeCloseTo(IGNITION.dipped);
|
||||
expect(at10.roll).toBe(0);
|
||||
expect(at10.exposure).toBeCloseTo(IGNITION.coldExposure);
|
||||
expect(at10.phase).toBe(2);
|
||||
expect(at10.attr).toBe('rolling');
|
||||
expect(ignitionAt(1.0 + IGNITION.beamFor).lamps).toBe(1);
|
||||
});
|
||||
|
||||
it('rolls to cruise and full exposure by 3.0 s, easing in', () => {
|
||||
expect(ignitionAt(2.0).roll).toBeCloseTo(0.125);
|
||||
const at30 = ignitionAt(3.0);
|
||||
expect(at30).toMatchObject({ lamps: 1, roll: 1, exposure: IGNITION.exposure, phase: 3, attr: 'rolling' });
|
||||
expect(ignitionAt(1.0 + IGNITION.sublineFor).attr).toBeNull();
|
||||
});
|
||||
|
||||
it('only ever raises the lamps, the roll and the exposure', () => {
|
||||
let previous = ignitionAt(0);
|
||||
for (let t = 0; t <= 5; t += 0.005) {
|
||||
const next = ignitionAt(t);
|
||||
expect(next.lamps).toBeGreaterThanOrEqual(previous.lamps);
|
||||
expect(next.roll).toBeGreaterThanOrEqual(previous.roll);
|
||||
expect(next.exposure).toBeGreaterThanOrEqual(previous.exposure - 1e-12);
|
||||
expect(next.phase).toBeGreaterThanOrEqual(previous.phase);
|
||||
previous = next;
|
||||
}
|
||||
});
|
||||
|
||||
it('short form (returning visitor): lamps on, no hold, rolling from the first frame', () => {
|
||||
const first = ignitionAt(0, true);
|
||||
expect(first).toMatchObject({ lamps: 1, roll: 0, phase: 2, attr: 'rolling' });
|
||||
expect(first.exposure).toBeGreaterThan(IGNITION.coldExposure);
|
||||
expect(ignitionAt(IGNITION.shortRoll, true)).toMatchObject({ roll: 1, exposure: IGNITION.exposure, phase: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createIgnition', () => {
|
||||
it('steps html[data-ignition] cold, lamps, rolling, then removes it, phases in order', () => {
|
||||
const { frames } = run(5, () => ({ cut: false, short: false }));
|
||||
const attrs = frames.map((f) => f.attr ?? null).filter((a, i, all) => i === 0 || a !== all[i - 1]);
|
||||
expect(attrs).toEqual(['cold', 'lamps', 'rolling', null]);
|
||||
const phases = frames.map((f) => f.phase).filter((p, i, all) => i === 0 || p !== all[i - 1]);
|
||||
expect(phases).toEqual([0, 1, 2, 3]);
|
||||
expect(readIgnition().phase).toBe(3);
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
for (let i = 1; i < frames.length; i++) expect(frames[i]!.lamps).toBeGreaterThanOrEqual(frames[i - 1]!.lamps);
|
||||
});
|
||||
|
||||
it('publishes the phase for the DOM (the hero sweep waits for 2)', () => {
|
||||
const ignition = createIgnition();
|
||||
ignition.update(0, { cut: false, short: false });
|
||||
expect(readIgnition().phase).toBe(0);
|
||||
for (let t = 0; t < 1.05; t += FRAME) ignition.update(FRAME, { cut: false, short: false });
|
||||
expect(readIgnition().phase).toBe(2);
|
||||
ignition.dispose();
|
||||
expect(readIgnition().phase).toBe(3);
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a scroll before 3 s cuts to the driving state within 0.4 s, lamps never dropping', () => {
|
||||
const { frames } = run(2, (t) => ({ cut: t >= 0.7, short: false }));
|
||||
const cutAt = frames.findIndex((f) => f.t >= 0.7);
|
||||
const driving = frames.findIndex((f) => f.phase === 3);
|
||||
expect((driving - cutAt) * FRAME).toBeLessThanOrEqual(IGNITION.cutFor + FRAME * 1.5);
|
||||
expect(frames[driving]).toMatchObject({ lamps: 1, roll: 1, exposure: IGNITION.exposure });
|
||||
expect(frames[cutAt]!.phase).toBe(2);
|
||||
for (let i = 1; i < frames.length; i++) expect(frames[i]!.lamps).toBeGreaterThanOrEqual(frames[i - 1]!.lamps);
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
});
|
||||
|
||||
it('landing mid-page skips the ignition entirely', () => {
|
||||
const ignition = createIgnition();
|
||||
expect(ignition.update(0, { cut: true, short: false })).toMatchObject({ lamps: 1, roll: 1, phase: 3 });
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
});
|
||||
|
||||
it('short form skips the hold', () => {
|
||||
const ignition = createIgnition();
|
||||
const first = ignition.update(0, { cut: false, short: true });
|
||||
expect(first).toMatchObject({ lamps: 1, phase: 2 });
|
||||
expect(root().dataset.ignition).toBe('rolling');
|
||||
});
|
||||
|
||||
it('a rebuilt generation (tier swap, context restore) starts driving', () => {
|
||||
const first = createIgnition();
|
||||
first.update(0, { cut: false, short: false });
|
||||
first.dispose();
|
||||
const second = createIgnition();
|
||||
expect(second.update(0, { cut: false, short: false })).toMatchObject({ lamps: 1, roll: 1, phase: 3 });
|
||||
expect(root().dataset.ignition).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ignition feed', () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem('nightdrive:route');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('reads a returning visitor once, before the route memory writes', async () => {
|
||||
window.localStorage.setItem('nightdrive:route', JSON.stringify({ visited: ['start', 'profile'], last: 'profile' }));
|
||||
vi.resetModules();
|
||||
const returning = (await import('@/journey/ignition-feed')).readIgnition().returning;
|
||||
expect(returning).toBe(true);
|
||||
window.localStorage.removeItem('nightdrive:route');
|
||||
vi.resetModules();
|
||||
expect((await import('@/journey/ignition-feed')).readIgnition().returning).toBe(false);
|
||||
});
|
||||
|
||||
it('treats unreadable storage as a first visit', async () => {
|
||||
window.localStorage.setItem('nightdrive:route', '{not json');
|
||||
vi.resetModules();
|
||||
expect((await import('@/journey/ignition-feed')).readIgnition().returning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ignition lamp factor', () => {
|
||||
it('the car is dark when cold and lit at 1', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const car = createCar(resources);
|
||||
const light = car.group.getObjectByName('rear-light-bar') as unknown as { material: { emissiveIntensity: number } };
|
||||
const glow = car.group.getObjectByName('car-underglow') as unknown as { material: { uniforms: Record<string, { value: number }> } };
|
||||
car.update(0, 0, 0, 0, 0, 0);
|
||||
expect(light.material.emissiveIntensity).toBe(0);
|
||||
expect(glow.material.uniforms.uOpacity!.value).toBe(0);
|
||||
car.update(0, 0, 0.1, 0, 0, 1);
|
||||
expect(light.material.emissiveIntensity).toBeGreaterThan(1);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('the road throw follows the lamps (default fully lit)', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const road = createRoad(resources, renderQualityFor('high', 1, false));
|
||||
const surface = road.group.getObjectByName('road-surface') as unknown as { material: { uniforms: Record<string, { value: number }> } };
|
||||
expect(surface.material.uniforms.uThrow!.value).toBe(1);
|
||||
road.setBeam(0, 0, 0);
|
||||
expect(surface.material.uniforms.uThrow!.value).toBe(0);
|
||||
road.setBeam(0, 0);
|
||||
expect(surface.material.uniforms.uThrow!.value).toBe(1);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||