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.
|
- **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.
|
- **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/.
|
- 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/billboard.css';
|
||||||
import './styles/dashcam.css';
|
import './styles/dashcam.css';
|
||||||
import './styles/relay.css';
|
import './styles/relay.css';
|
||||||
|
import './styles/ignition.css';
|
||||||
|
|
||||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { onMounted, onUnmounted, watch, type Ref } from 'vue';
|
import { onMounted, onUnmounted, watch, type Ref } from 'vue';
|
||||||
import { createHeadlightBeam, type BeamSample } from '@/journey/headlight-beam';
|
import { createHeadlightBeam, type BeamSample } from '@/journey/headlight-beam';
|
||||||
|
import { readIgnition } from '@/journey/ignition-feed';
|
||||||
import type { QualityTier } from '@/journey/motion-policy';
|
import type { QualityTier } from '@/journey/motion-policy';
|
||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
|
|
||||||
@@ -83,8 +84,11 @@ export function useHeadlightBeam(sources: {
|
|||||||
heroBeam.x = hero.ex;
|
heroBeam.x = hero.ex;
|
||||||
heroBeam.y = hero.ey;
|
heroBeam.y = hero.ey;
|
||||||
heroBeam.intensity = sample.intensity;
|
heroBeam.intensity = sample.intensity;
|
||||||
// Letters lit by the intro arc stay lit at dipped level (CSS).
|
// Letters lit by the intro arc stay lit at dipped level (CSS). Before
|
||||||
const next = beam.swept(now) ? '9999px' : `${Math.max(-1, px - hero.left).toFixed(0)}px`;
|
// 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) {
|
if (next !== wipe) {
|
||||||
wipe = next;
|
wipe = next;
|
||||||
hero.el.style.setProperty('--beam-wipe', next);
|
hero.el.style.setProperty('--beam-wipe', next);
|
||||||
@@ -130,8 +134,13 @@ export function useHeadlightBeam(sources: {
|
|||||||
if (next) {
|
if (next) {
|
||||||
root().dataset.beam = 'on';
|
root().dataset.beam = 'on';
|
||||||
measure();
|
measure();
|
||||||
// Without a scene frame (a slow or failed scene), the name still lights.
|
// Without a scene frame (a slow or failed scene), the name still lights;
|
||||||
fallbackTimer = window.setTimeout(sweep, 2_500);
|
// 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 {
|
} else {
|
||||||
delete root().dataset.beam;
|
delete root().dataset.beam;
|
||||||
if (raf) cancelAnimationFrame(raf);
|
if (raf) cancelAnimationFrame(raf);
|
||||||
@@ -204,9 +213,9 @@ export function useHeadlightBeam(sources: {
|
|||||||
return {
|
return {
|
||||||
/** The scene's per-frame read; null when motion is not allowed. */
|
/** The scene's per-frame read; null when motion is not allowed. */
|
||||||
sampleBeam: (now: number): BeamSample | null => (allowed() ? beam.sample(now) : null),
|
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 => {
|
sceneFrame: (): void => {
|
||||||
if (sweepRequested) return;
|
if (sweepRequested || readIgnition().phase < 2) return;
|
||||||
sweepRequested = true;
|
sweepRequested = true;
|
||||||
sweep();
|
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,
|
distance?: number,
|
||||||
/** 0 driving, 1 parked at the destination: lamps drop to parking level. */
|
/** 0 driving, 1 parked at the destination: lamps drop to parking level. */
|
||||||
engineOff?: number,
|
engineOff?: number,
|
||||||
|
/** IGNITION: 0 cold (every lamp out) .. 1 lit; multiplies the lamp level. */
|
||||||
|
ignition?: number,
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,8 +421,9 @@ export function createCar(
|
|||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
setDetail,
|
setDetail,
|
||||||
update(speed, acceleration, elapsedSeconds, _distance, engineOff = 0) {
|
update(speed, acceleration, elapsedSeconds, _distance, engineOff = 0, ignition = 1) {
|
||||||
const lamps = 1 - clamp01(engineOff) * 0.62;
|
const lit = clamp01(ignition);
|
||||||
|
const lamps = (1 - clamp01(engineOff) * 0.62) * lit;
|
||||||
const safeSpeed = clamp01(speed);
|
const safeSpeed = clamp01(speed);
|
||||||
const response = clamp01(acceleration);
|
const response = clamp01(acceleration);
|
||||||
const elapsed = Number.isFinite(elapsedSeconds)
|
const elapsed = Number.isFinite(elapsedSeconds)
|
||||||
@@ -603,10 +606,11 @@ function createAuthoredCar(
|
|||||||
detail = nextDetail;
|
detail = nextDetail;
|
||||||
setScale(detail);
|
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
|
// Parked at the destination: the tail bar and the cyan running lamps
|
||||||
// drop to parking level, the underglow almost out.
|
// drop to parking level, the underglow almost out. Cold (IGNITION): all out.
|
||||||
const lamps = 1 - clamp01(engineOff) * 0.62;
|
const lit = clamp01(ignition);
|
||||||
|
const lamps = (1 - clamp01(engineOff) * 0.62) * lit;
|
||||||
const safeSpeed = clamp01(speed);
|
const safeSpeed = clamp01(speed);
|
||||||
const response = clamp01(acceleration);
|
const response = clamp01(acceleration);
|
||||||
const elapsed = Number.isFinite(elapsedSeconds)
|
const elapsed = Number.isFinite(elapsedSeconds)
|
||||||
@@ -645,7 +649,7 @@ function createAuthoredCar(
|
|||||||
for (const material of cyanMaterials)
|
for (const material of cyanMaterials)
|
||||||
material.emissiveIntensity = (2.2 + response * 1.6) * lamps;
|
material.emissiveIntensity = (2.2 + response * 1.6) * lamps;
|
||||||
underglowUniforms.uOpacity.value =
|
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;
|
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 { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||||
import { readDashcam, takeDashcamCapture } from '@/journey/dashcam-feed';
|
import { readDashcam, takeDashcamCapture } from '@/journey/dashcam-feed';
|
||||||
import { createDashcamLens } from '@/scene/world/dashcam-lens';
|
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;
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||||
const TEST_BUILD =
|
const TEST_BUILD =
|
||||||
@@ -104,7 +106,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
|
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
|
||||||
renderer.toneMapping = THREE.CustomToneMapping;
|
renderer.toneMapping = THREE.CustomToneMapping;
|
||||||
renderer.toneMappingExposure = 1.12;
|
renderer.toneMappingExposure = IGNITION.exposure;
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.name = 'world-root';
|
scene.name = 'world-root';
|
||||||
@@ -257,6 +259,9 @@ export async function createProceduralSceneGeneration(
|
|||||||
let dawnState: DawnStep | '' = '';
|
let dawnState: DawnStep | '' = '';
|
||||||
// DASHCAM: the pointer drifts the lens and the letterbox closes while open.
|
// DASHCAM: the pointer drifts the lens and the letterbox closes while open.
|
||||||
const dashcamLens = createDashcamLens();
|
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 resize = (): void => {
|
||||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
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.
|
// lifting off slows the spectrum to rest without rewinding its phase.
|
||||||
terrainSpectrumPhase =
|
terrainSpectrumPhase =
|
||||||
(terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1;
|
(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);
|
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
|
||||||
// The 03:12 window: section-local scroll progress drives every beat.
|
// The 03:12 window: section-local scroll progress drives every beat.
|
||||||
const service = maintenance.update(
|
const service = maintenance.update(
|
||||||
@@ -427,7 +449,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
|
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
|
||||||
dampPalette(currentPalette, targetPalette, deltaSeconds);
|
dampPalette(currentPalette, targetPalette, deltaSeconds);
|
||||||
const driveDistance = travelDistance.advance(
|
const driveDistance = travelDistance.advance(
|
||||||
deltaSeconds * (1 - engineOff),
|
deltaSeconds * (1 - engineOff) * roll,
|
||||||
snapshot.driveSpeed,
|
snapshot.driveSpeed,
|
||||||
response.roadEnergy,
|
response.roadEnergy,
|
||||||
);
|
);
|
||||||
@@ -435,11 +457,11 @@ export async function createProceduralSceneGeneration(
|
|||||||
0.18 + response.roadEnergy * 0.82,
|
0.18 + response.roadEnergy * 0.82,
|
||||||
0.18,
|
0.18,
|
||||||
1,
|
1,
|
||||||
) * (1 - engineOff);
|
) * (1 - engineOff) * roll;
|
||||||
road.update(
|
road.update(
|
||||||
driveDistance,
|
driveDistance,
|
||||||
visualSpeed,
|
visualSpeed,
|
||||||
response.roadEnergy,
|
response.roadEnergy * roll,
|
||||||
currentPalette,
|
currentPalette,
|
||||||
);
|
);
|
||||||
expressway.update(
|
expressway.update(
|
||||||
@@ -473,7 +495,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
skyline.update(currentPalette, elapsedSeconds);
|
skyline.update(currentPalette, elapsedSeconds);
|
||||||
traffic.update(
|
traffic.update(
|
||||||
deltaSeconds,
|
deltaSeconds,
|
||||||
(18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff),
|
(18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff) * roll,
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
);
|
);
|
||||||
sun.update(
|
sun.update(
|
||||||
@@ -490,9 +512,10 @@ export async function createProceduralSceneGeneration(
|
|||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
driveDistance,
|
driveDistance,
|
||||||
engineOff,
|
engineOff,
|
||||||
|
ignited.lamps,
|
||||||
);
|
);
|
||||||
speedWake.update(
|
speedWake.update(
|
||||||
overdrive,
|
overdrive * roll,
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
car.group.position,
|
car.group.position,
|
||||||
width < 1100 || height < 620 ? 2.32 : 2.78,
|
width < 1100 || height < 620 ? 2.32 : 2.78,
|
||||||
@@ -509,7 +532,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
atmosphere.update(
|
atmosphere.update(
|
||||||
driveDistance,
|
driveDistance,
|
||||||
visualSpeed,
|
visualSpeed,
|
||||||
response.streakEnergy,
|
response.streakEnergy * roll,
|
||||||
currentPalette,
|
currentPalette,
|
||||||
atmosphericTime,
|
atmosphericTime,
|
||||||
overdrive,
|
overdrive,
|
||||||
@@ -658,7 +681,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
introFrames += 1;
|
introFrames += 1;
|
||||||
if (introFrames > 3 && wallSeconds > 0.1) introSlowFrames += 1;
|
if (introFrames > 3 && wallSeconds > 0.1) introSlowFrames += 1;
|
||||||
if (introSlowFrames >= 3) introProgress = 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 = THREE.MathUtils.clamp(
|
||||||
outroProgress
|
outroProgress
|
||||||
@@ -699,8 +723,11 @@ export async function createProceduralSceneGeneration(
|
|||||||
camera.fov = lensFov;
|
camera.fov = lensFov;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
const beam = headlights.update(snapshot.beam, snapshot, camera, 1 - engineOff, deltaSeconds);
|
// IGNITION: the dipped level lights the road throw; the steerable main
|
||||||
road.setBeam(beam.yaw, beam.lift);
|
// 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);
|
const stage = signStage.update(width, height);
|
||||||
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
|
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
|
||||||
const gantry = sign.dockState();
|
const gantry = sign.dockState();
|
||||||
@@ -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) {
|
if (postProcessor) {
|
||||||
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
|
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
|
||||||
postProcessor.setLens({
|
postProcessor.setLens({
|
||||||
@@ -791,6 +849,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
delete document.documentElement.dataset.interlude;
|
delete document.documentElement.dataset.interlude;
|
||||||
delete document.documentElement.dataset.interludeArmed;
|
delete document.documentElement.dataset.interludeArmed;
|
||||||
delete document.documentElement.dataset.dawn;
|
delete document.documentElement.dataset.dawn;
|
||||||
|
ignition.dispose();
|
||||||
dawnLightUniforms.uDawnLight.value = 0;
|
dawnLightUniforms.uDawnLight.value = 0;
|
||||||
// Quality changes reuse the canvas context. Leave texture upload flags at
|
// Quality changes reuse the canvas context. Leave texture upload flags at
|
||||||
// defaults before the next renderer creates its empty array textures.
|
// 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>,
|
palette?: Readonly<MutableChapterPalette>,
|
||||||
): void;
|
): void;
|
||||||
/** HIGH BEAM: yaw (beam x, -1..1, scaled by presence) and high-beam lift (0..1). */
|
/** 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 {
|
interface WorldGroup {
|
||||||
@@ -61,6 +62,7 @@ export function createRoad(
|
|||||||
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
||||||
uBeamYaw: { value: 0 },
|
uBeamYaw: { value: 0 },
|
||||||
uBeamLift: { value: 0 },
|
uBeamLift: { value: 0 },
|
||||||
|
uThrow: { value: 1 },
|
||||||
...dawnLightUniforms,
|
...dawnLightUniforms,
|
||||||
};
|
};
|
||||||
const material = resources.own(
|
const material = resources.own(
|
||||||
@@ -97,6 +99,7 @@ export function createRoad(
|
|||||||
uniform vec3 uHazeColor;
|
uniform vec3 uHazeColor;
|
||||||
uniform float uBeamYaw;
|
uniform float uBeamYaw;
|
||||||
uniform float uBeamLift;
|
uniform float uBeamLift;
|
||||||
|
uniform float uThrow;
|
||||||
varying vec2 vRoadUv;
|
varying vec2 vRoadUv;
|
||||||
varying float vRoadX;
|
varying float vRoadX;
|
||||||
varying float vViewDistance;
|
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 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));
|
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
|
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.
|
// Pylon lamps reflect as long streaks towards the camera.
|
||||||
float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)});
|
float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)});
|
||||||
@@ -252,9 +255,10 @@ export function createRoad(
|
|||||||
uniforms.uHazeColor.value.copy(palette.haze);
|
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.uBeamYaw.value = THREE.MathUtils.clamp(finiteOrZero(yaw), -1, 1);
|
||||||
uniforms.uBeamLift.value = clamp01(lift);
|
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",
|
"data-scene-state",
|
||||||
"running",
|
"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 (
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||