feat: ignition opening with cold start, lamps, crossfade and shader warm-up
This commit is contained in:
@@ -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
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
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
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user