diff --git a/docs/overnight/LOG.md b/docs/overnight/LOG.md index ec65c77..8d3f098 100644 --- a/docs/overnight/LOG.md +++ b/docs/overnight/LOG.md @@ -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). diff --git a/docs/overnight/round8/01-long-contact-sheet.webp b/docs/overnight/round8/01-long-contact-sheet.webp new file mode 100644 index 0000000..8e84220 Binary files /dev/null and b/docs/overnight/round8/01-long-contact-sheet.webp differ diff --git a/docs/overnight/round8/02-cold-frame-2.1s.webp b/docs/overnight/round8/02-cold-frame-2.1s.webp new file mode 100644 index 0000000..0a405ec Binary files /dev/null and b/docs/overnight/round8/02-cold-frame-2.1s.webp differ diff --git a/docs/overnight/round8/03-lamps-on-2.6s.webp b/docs/overnight/round8/03-lamps-on-2.6s.webp new file mode 100644 index 0000000..1e5df7c Binary files /dev/null and b/docs/overnight/round8/03-lamps-on-2.6s.webp differ diff --git a/docs/overnight/round8/04-rolling-sweep-3.3s.webp b/docs/overnight/round8/04-rolling-sweep-3.3s.webp new file mode 100644 index 0000000..e32f01c Binary files /dev/null and b/docs/overnight/round8/04-rolling-sweep-3.3s.webp differ diff --git a/docs/overnight/round8/05-short-form-returning-1.5s.webp b/docs/overnight/round8/05-short-form-returning-1.5s.webp new file mode 100644 index 0000000..00a144d Binary files /dev/null and b/docs/overnight/round8/05-short-form-returning-1.5s.webp differ diff --git a/docs/overnight/round8/06-early-scroll-cut-sheet.webp b/docs/overnight/round8/06-early-scroll-cut-sheet.webp new file mode 100644 index 0000000..d16d638 Binary files /dev/null and b/docs/overnight/round8/06-early-scroll-cut-sheet.webp differ diff --git a/docs/overnight/round8/07-phone-390-rolling-2.9s.webp b/docs/overnight/round8/07-phone-390-rolling-2.9s.webp new file mode 100644 index 0000000..fa2850b Binary files /dev/null and b/docs/overnight/round8/07-phone-390-rolling-2.9s.webp differ diff --git a/src/App.vue b/src/App.vue index c673b9f..c6a01a9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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 }>(); diff --git a/src/composables/useHeadlightBeam.ts b/src/composables/useHeadlightBeam.ts index 725b430..9ff5455 100644 --- a/src/composables/useHeadlightBeam.ts +++ b/src/composables/useHeadlightBeam.ts @@ -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(); }, diff --git a/src/journey/ignition-feed.ts b/src/journey/ignition-feed.ts new file mode 100644 index 0000000..fdb7c32 --- /dev/null +++ b/src/journey/ignition-feed.ts @@ -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; diff --git a/src/scene/world/car.ts b/src/scene/world/car.ts index 5fcafc1..8ed44b1 100644 --- a/src/scene/world/car.ts +++ b/src/scene/world/car.ts @@ -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; }, }; diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 7be55c1..d2e1ceb 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -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. diff --git a/src/scene/world/ignition.ts b/src/scene/world/ignition.ts new file mode 100644 index 0000000..96bcd92 --- /dev/null +++ b/src/scene/world/ignition.ts @@ -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; + }, + }; +} diff --git a/src/scene/world/road.ts b/src/scene/world/road.ts index 7341a6f..7e7cb72 100644 --- a/src/scene/world/road.ts +++ b/src/scene/world/road.ts @@ -20,7 +20,8 @@ export interface RoadWorld { palette?: Readonly, ): 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); }, }; } diff --git a/src/styles/ignition.css b/src/styles/ignition.css new file mode 100644 index 0000000..02bac09 --- /dev/null +++ b/src/styles/ignition.css @@ -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; } +} diff --git a/tests/browser/visual-regression.spec.ts b/tests/browser/visual-regression.spec.ts index 6de49b7..7e39595 100644 --- a/tests/browser/visual-regression.spec.ts +++ b/tests/browser/visual-regression.spec.ts @@ -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 ( diff --git a/tests/unit/ignition.test.ts b/tests/unit/ignition.test.ts new file mode 100644 index 0000000..00df53d --- /dev/null +++ b/tests/unit/ignition.test.ts @@ -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 } }; + 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 } }; + 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(); + }); +});