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