feat: daybreak finale turns the contact arrival into a sunrise with a dawn chord

This commit is contained in:
2026-10-11 05:32:08 +03:00
parent c9e6ac0f29
commit b6e22a1299
37 changed files with 756 additions and 44 deletions

View File

@@ -23,3 +23,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
- **Known weaknesses:** the outline-to-lit sweep beat is short (~300 ms visible); haze cone slightly flat/white; spark boost barely perceptible.
- Screenshots: docs/overnight/round2/.
## Round 3: End of Shift (Contact daybreak finale)
- **What:** arriving at Contact turns the night into dawn over 7 s: sky mauve/peach, stars drain, sun lifts and becomes a solid gold disc, warm rim light on road/terrain/districts, warm grade and bloom, bars warm, credit types at sunrise, HUD "END OF SHIFT · 05:48" (the joke: round 1's window ended 03:51), and a synthesized four-voice dawn pad (new mixer `dawnInput`) swells. Scrolling back rewinds in ~1.6 s. html[data-dawn] steps night/first-light/sunrise/day. Reduced motion/static: CSS-only daybreak gradient.
- **Why:** the ending was engine-off but still night with a sun that never moved; the finale is the last impression, and the plumbing (sun, meteors, outroProgress, palette) was already there.
- **Build:** src/journey/dawn.ts, scene/world/dawn-light.ts, audio/dawn-resolve.ts; edits in palette, environment, sun, atmosphere, horizon-air, road/terrain/districts, cinematic-grade, post-processing, adaptive-mixer, soundtrack-engine, world.css. 746 tests green, initial JS +0.7 KB gz, scene chunk +1.2 KB, outro frame times unchanged on hardware GL.
- **Verdict: KEEP with fixes** (Fable): the sun/halo stack washed out to a white blob, so the sun is now gold with a smaller halo; the "DESTINATION" eyebrow was invisible on the peach sky, darkened at sunrise.
- **Known weaknesses:** the "SHIFT OVER" label swap is invisible (HUD arrival label collapsed); the bottom credit is mostly hidden by the Contact panel (pre-existing); horizon still bright at full day; reduced-motion version reads as dusk rather than sunrise; audio checked by code review only.
- Screenshots: docs/overnight/round3/.

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -27,6 +27,8 @@ export interface AdaptiveMixer {
readonly bedInput: GainNode;
readonly driveLiftInput: GainNode;
readonly driveTextureInput: GainNode;
/** END OF SHIFT: the dawn chord, summed after the mood controls. */
readonly dawnInput: GainNode;
setChapterIntent(intent: ChapterIntent): void;
setDriveEnergy(sample: DriveEnergySample): void;
setTransportAnchor(anchorTime: number | null): void;
@@ -57,6 +59,9 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination)
const driveTextureInput = context.createGain();
const textureSend = context.createGain();
const sum = context.createGain();
// The dawn chord joins the sum directly: the output gain (mute, volume) and
// the pulse tap govern it, and the chapter moods leave it alone.
const dawnInput = context.createGain();
const highShelf = context.createBiquadFilter();
const initialProfile = MIX_PROFILES.open;
@@ -77,6 +82,7 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination)
driveLiftCap.connect(sum);
driveTextureInput.connect(textureSend);
textureSend.connect(sum);
dawnInput.connect(sum);
sum.connect(destination);
const ownedNodes: readonly AudioNode[] = [
@@ -88,6 +94,7 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination)
driveLiftCap,
driveTextureInput,
textureSend,
dawnInput,
sum,
];
let transportAnchor: number | null = null;
@@ -197,6 +204,7 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination)
bedInput,
driveLiftInput,
driveTextureInput,
dawnInput,
setChapterIntent(intent) {
if (disposed) return;
const profile = MIX_PROFILES[intent.mood];

138
src/audio/dawn-resolve.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* END OF SHIFT: the music resolves into a held dawn chord at the destination.
* A four-voice sine pad (A2, E3, A3, E4), each note a pair of sines detuned
* +/-4 cents, opens through a low-pass from 900 to 2600 Hz while it swells in,
* with a filtered-noise breath under it. Preallocated and silent until
* arrival, like the drive texture, so arriving schedules automation only.
*/
export const DAWN_CHORD_HZ = [110, 164.8, 220, 329.6] as const;
export const DAWN_DETUNE_CENTS = 4;
export const DAWN_PAD_GAIN = 0.09;
export const DAWN_SWELL_SECONDS = 5;
export const DAWN_OPEN_SECONDS = 6;
export const DAWN_RELEASE_SECONDS = 1.6;
const FILTER_CLOSED_HZ = 900;
const FILTER_OPEN_HZ = 2_600;
/** Upper notes sit a little under the root so the chord stays soft. */
const VOICE_LEVELS = [0.5, 0.36, 0.3, 0.18] as const;
const BREATH_PEAK = 0.022;
const BREATH_REST = 0.006;
const BREATH_PEAK_SECONDS = 2.4;
const NOISE_SECONDS = 2;
const NOISE_SEED = 0x44415752;
export interface DawnResolve {
/** Parked at the destination (true) or driving again (false). */
setDawn(on: boolean): void;
dispose(): void;
}
export type DawnResolveFactory = (
context: AudioContext,
destination: AudioNode,
) => DawnResolve;
function holdParameter(parameter: AudioParam, atTime: number): void {
if (typeof parameter.cancelAndHoldAtTime === 'function') {
parameter.cancelAndHoldAtTime(atTime);
return;
}
parameter.cancelScheduledValues(atTime);
parameter.setValueAtTime(parameter.value, atTime);
}
function createBreathBuffer(context: BaseAudioContext): AudioBuffer {
const length = Math.max(1, Math.round(context.sampleRate * NOISE_SECONDS));
const buffer = context.createBuffer(1, length, context.sampleRate);
const samples = buffer.getChannelData(0);
let state = NOISE_SEED;
for (let index = 0; index < samples.length; index += 1) {
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;
samples[index] = state / 0xffff_ffff * 2 - 1;
}
return buffer;
}
export const createDawnResolve: DawnResolveFactory = (context, destination) => {
const now = context.currentTime;
const padGain = context.createGain();
padGain.gain.setValueAtTime(0, now);
const padFilter = context.createBiquadFilter();
padFilter.type = 'lowpass';
padFilter.frequency.setValueAtTime(FILTER_CLOSED_HZ, now);
padFilter.Q.value = 0.6;
padFilter.connect(padGain);
padGain.connect(destination);
const sources: AudioScheduledSourceNode[] = [];
const ownedNodes: AudioNode[] = [padFilter, padGain];
DAWN_CHORD_HZ.forEach((frequency, voice) => {
for (const cents of [-DAWN_DETUNE_CENTS, DAWN_DETUNE_CENTS]) {
const oscillator = context.createOscillator();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(frequency, now);
oscillator.detune.setValueAtTime(cents, now);
const level = context.createGain();
level.gain.setValueAtTime(VOICE_LEVELS[voice] ?? 0.2, now);
oscillator.connect(level);
level.connect(padFilter);
sources.push(oscillator);
ownedNodes.push(oscillator, level);
}
});
const breathSource = context.createBufferSource();
breathSource.buffer = createBreathBuffer(context);
breathSource.loop = true;
const breathBand = context.createBiquadFilter();
breathBand.type = 'bandpass';
breathBand.frequency.setValueAtTime(1_400, now);
breathBand.Q.value = 0.7;
const breathGain = context.createGain();
breathGain.gain.setValueAtTime(0, now);
breathSource.connect(breathBand);
breathBand.connect(breathGain);
breathGain.connect(destination);
sources.push(breathSource);
ownedNodes.push(breathSource, breathBand, breathGain);
for (const source of sources) source.start();
let on = false;
let disposed = false;
return {
setDawn(next) {
if (disposed || next === on) return;
on = next;
const at = context.currentTime;
for (const parameter of [padGain.gain, padFilter.frequency, breathGain.gain, breathBand.frequency]) {
holdParameter(parameter, at);
}
if (on) {
padGain.gain.linearRampToValueAtTime(DAWN_PAD_GAIN, at + DAWN_SWELL_SECONDS);
padFilter.frequency.exponentialRampToValueAtTime(FILTER_OPEN_HZ, at + DAWN_OPEN_SECONDS);
breathGain.gain.linearRampToValueAtTime(BREATH_PEAK, at + BREATH_PEAK_SECONDS);
breathGain.gain.linearRampToValueAtTime(BREATH_REST, at + DAWN_OPEN_SECONDS);
breathBand.frequency.exponentialRampToValueAtTime(3_200, at + DAWN_OPEN_SECONDS);
return;
}
padGain.gain.linearRampToValueAtTime(0, at + DAWN_RELEASE_SECONDS);
padFilter.frequency.exponentialRampToValueAtTime(FILTER_CLOSED_HZ, at + DAWN_RELEASE_SECONDS);
breathGain.gain.linearRampToValueAtTime(0, at + DAWN_RELEASE_SECONDS);
breathBand.frequency.exponentialRampToValueAtTime(1_400, at + DAWN_RELEASE_SECONDS);
},
dispose() {
if (disposed) return;
disposed = true;
for (const source of sources) {
try {
source.stop();
} catch {
// A source that has already ended needs no further teardown.
}
}
for (const node of ownedNodes) node.disconnect();
},
};
};

View File

@@ -27,6 +27,11 @@ import {
type DriveTexture,
type DriveTextureFactory,
} from '@/audio/drive-texture';
import {
createDawnResolve,
type DawnResolve,
type DawnResolveFactory,
} from '@/audio/dawn-resolve';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
import type {
AudioContextLifecycleState,
@@ -73,6 +78,8 @@ export interface SoundtrackEngineDependencies {
): Promise<AudioBuffer>;
createMixer: AdaptiveMixerFactory;
createDriveTexture: DriveTextureFactory;
/** END OF SHIFT: the held dawn chord at the destination (optional voice). */
createDawnResolve?: DawnResolveFactory;
}
export function createSoundtrackEngine(
@@ -90,6 +97,12 @@ export function createSoundtrackEngine(
} catch {
driveTexture = null;
}
let dawn: DawnResolve | null = null;
try {
dawn = dependencies.createDawnResolve?.(context, mixer.dawnInput) ?? null;
} catch {
dawn = null;
}
const ownedSources = new Set<OwnedSource>();
const stateListeners = new Set<(state: AudioContextLifecycleState) => void>();
@@ -516,6 +529,8 @@ export function createSoundtrackEngine(
mixer.setDriveLiftAvailableAt(null);
driveTexture?.dispose();
driveTexture = null;
dawn?.dispose();
dawn = null;
mixer.dispose();
for (const source of [...ownedSources]) source.cancel();
ownedSources.clear();
@@ -560,6 +575,8 @@ export function createSoundtrackEngine(
driveTexture?.passBy?.();
// The last exit is the destination: the engine winds down there.
driveTexture?.park?.(intent.sectionId === 'contact');
// ...and the shift ends: the music resolves into a held dawn chord.
dawn?.setDawn(intent.sectionId === 'contact');
}
latestChapter = intent;
mixer.setChapterIntent(intent);
@@ -607,6 +624,7 @@ export function createBrowserSoundtrackEngine(
// The mixer feeds a pass-through tap so the scene can read the bass level.
createMixer: (context, destination) => createAdaptiveMixer(context, createPulseTap(context, destination)),
createDriveTexture,
createDawnResolve,
});
let resumeCount = 0;
return {

View File

@@ -24,6 +24,7 @@ import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { useRouteMemory } from '@/composables/useRouteMemory';
import { assetUrl } from '@/content/asset-url';
import { signRouteFor } from '@/journey/sign-routes';
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types';
import type { SceneHostState } from '@/composables/useSceneController';
import type { MotionPolicy } from '@/journey/motion-policy';
@@ -129,6 +130,11 @@ const audio = useAudioEngine({
const sceneState = shallowRef<SceneHostState>('idle');
const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active');
const controlsReady = shallowRef(false);
/* END OF SHIFT: parked at the destination the plate's sub-line reads the
shift's end. Client-only, like the 03:12 window, so the server HTML hydrates
unchanged. */
const hudSubline = computed(() => maintenanceSubline.value
|| (controlsReady.value && journey.value.arrived ? END_OF_SHIFT_SUBLINE : ''));
const adaptive = useAdaptiveQuality({
maximum: computed(() => motion.policy.value.maximumTier),
sceneState,
@@ -270,7 +276,7 @@ onUnmounted(() => removePageListeners());
:exit="exitFor(journey.activeSectionId)"
:next-label="nextLabelFor(journey.activeSectionId)"
:next-exit="nextExitFor(journey.activeSectionId)"
:subline="maintenanceSubline"
:subline="hudSubline"
/>
<header class="site-header">
<SiteNavigation

View File

@@ -41,7 +41,8 @@ const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
>
<span class="journey-hud__route" :data-next="next"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
<span class="journey-hud__section">{{ sectionLabel }}</span>
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
<!-- END OF SHIFT: html[data-dawn='day'] swaps the label for data-day (CSS). -->
<strong v-if="journey.arrived" class="journey-hud__arrival" data-day="SHIFT OVER">ARRIVED</strong>
<span class="journey-hud__subline">{{ subline }}</span>
</aside>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, toRef, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue';
import { useSceneController, type SceneHostState } from '@/composables/useSceneController';
import type { JourneySnapshot } from '@/journey/types';
import type { BeamSample } from '@/journey/headlight-beam';
@@ -47,6 +47,17 @@ const showCanvas = computed(() => (
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
onMounted(() => { clientReady.value = true; });
/* END OF SHIFT without the scene (reduced motion, static, a failed scene):
there is no dawn to wait for, so arrival goes straight to day. While the
canvas runs, the render loop owns html[data-dawn]. Client-only. */
watch(
() => [clientReady.value && !showCanvas.value, props.journey.arrived, sceneState.value] as const,
([cssOnly, arrived]) => {
if (cssOnly) document.documentElement.dataset.dawn = arrived ? 'day' : 'night';
},
{ flush: 'post' },
);
onUnmounted(() => { delete document.documentElement.dataset.dawn; });
if (posterFailed.value) recordTestFaultEvent('poster-failure');
</script>

21
src/journey/dawn.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* END OF SHIFT: arriving at the destination ends the night. One scalar, dawn
* (0..1), eases in over the outro; the scene derives the sky, the sun and the
* grade from it, and the DOM gets a stepped html[data-dawn] so CSS can key the
* credit and the letterbox tint off it. Shared by the DOM and the lazy scene
* chunk, so it stays tiny and pure.
*/
export type DawnStep = 'night' | 'first-light' | 'sunrise' | 'day';
/** Lower bounds of each step after night. */
export const DAWN_STEPS = { 'first-light': 0.3, sunrise: 0.6, day: 0.95 } as const;
/** The gantry plate's sub-line while parked at the destination. */
export const END_OF_SHIFT_SUBLINE = 'END OF SHIFT · 05:48';
export function dawnStep(dawn: number): DawnStep {
if (!(dawn >= DAWN_STEPS['first-light'])) return 'night';
if (dawn < DAWN_STEPS.sunrise) return 'first-light';
if (dawn < DAWN_STEPS.day) return 'sunrise';
return 'day';
}

View File

@@ -54,6 +54,8 @@ export interface LensState {
rain?: number;
/** Seconds, for the drops' slow slide. */
rainTime?: number;
/** END OF SHIFT daybreak, 0..1: lifted blacks and a warmer white balance. */
dawn?: number;
}
/**
@@ -74,6 +76,7 @@ export class LensFinishPass extends Pass {
uVignette: { value: 0.42 },
uRain: { value: 0 },
uRainTime: { value: 0 },
uDawn: { value: 0 },
toneMappingExposure: { value: 1 },
};
@@ -113,6 +116,7 @@ export class LensFinishPass extends Pass {
uniform float uVignette;
uniform float uRain;
uniform float uRainTime;
uniform float uDawn;
varying vec2 vUv;
#include <tonemapping_pars_fragment>
#include <colorspace_pars_fragment>
@@ -188,6 +192,9 @@ export class LensFinishPass extends Pass {
}
color = CustomToneMapping( color );
color = sRGBTransferOETF( vec4( color, 1.0 ) ).rgb;
// Daybreak: blacks lift toward a warm grey, white balance warms.
color = color * ( 1.0 - 0.03 * uDawn ) + vec3( 0.03, 0.026, 0.024 ) * uDawn;
color *= vec3( 1.0 + 0.04 * uDawn, 1.0, 1.0 - 0.04 * uDawn );
float vignette = smoothstep( 0.18, 1.05, length( centered * vec2( 1.0, 0.86 ) ) * 1.42 );
color *= 1.0 - uVignette * vignette;
color += ( grainHash( gl_FragCoord.xy + fract( uTime * 7.31 ) * 311.0 ) - 0.5 ) * uGrain;
@@ -214,6 +221,7 @@ export class LensFinishPass extends Pass {
this.uniforms.uAberration.value = 0.0035 + energy * 0.006;
this.uniforms.uRain.value = THREE.MathUtils.clamp(state.rain ?? 0, 0, 1);
this.uniforms.uRainTime.value = state.rainTime ?? state.time;
this.uniforms.uDawn.value = THREE.MathUtils.clamp(state.dawn ?? 0, 0, 1);
if (this.uniforms.tStreak.value) {
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
}

View File

@@ -11,7 +11,8 @@ import type {
export interface PostProcessor extends GpuRenderTargetReporter {
resize(width: number, height: number, pixelRatio: number): void;
setDynamics(acceleration: number): void;
/** dawn (END OF SHIFT, 0..1) lowers the bloom threshold up to 10%. */
setDynamics(acceleration: number, dawn?: number): void;
setLens(state: LensState): void;
render(scene: unknown, camera: unknown): void;
dispose(): void;
@@ -127,12 +128,13 @@ export function createPostProcessor(
Math.max(1, Math.floor(finiteOr(height, 1))),
);
},
setDynamics(acceleration) {
setDynamics(acceleration, dawn = 0) {
if (disposed) return;
const energy = clamp(acceleration, 0, 1);
const daybreak = clamp(dawn, 0, 1);
bloomPass.strength = BLOOM_REST.strength + energy * 0.16;
bloomPass.radius = BLOOM_REST.radius + energy * 0.06;
bloomPass.threshold = BLOOM_REST.threshold - energy * 0.05;
bloomPass.threshold = (BLOOM_REST.threshold - energy * 0.05) * (1 - 0.1 * daybreak);
},
setLens(state) {
if (disposed) return;

View File

@@ -35,7 +35,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
geometry.setAttribute('aFlight', new THREE.Float32BufferAttribute(timing, 2));
geometry.setAttribute('aTravel', new THREE.Float32BufferAttribute(travel, 2));
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uTime: { value: -1 } },
uniforms: { uTime: { value: -1 }, uFade: { value: 1 } },
vertexShader: `
uniform float uTime;
attribute vec2 aFlight;
@@ -51,6 +51,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
}
`,
fragmentShader: `
uniform float uFade;
varying vec2 vUv;
varying float vLife;
void main() {
@@ -60,7 +61,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper;
float head = (1.0 - smoothstep(0.0, 1.0,
length(vec2((vUv.x - 0.965) * 30.0, across))));
float alpha = vLife * max(glow * 0.8, head);
float alpha = vLife * max(glow * 0.8, head) * uFade;
if (alpha < 0.005) discard;
vec3 light = mix(vec3(0.22, 0.62, 1.0), vec3(1.0, 0.94, 0.8), max(core, head));
gl_FragColor = vec4(light * 1.8, alpha);
@@ -80,12 +81,13 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
mesh.frustumCulled = false;
mesh.visible = false;
let warmed = false;
return { mesh, update(age: number, active: boolean) {
return { mesh, update(age: number, active: boolean, fade = 1) {
// One invisible startup draw warms the exact live pipeline (including
// its post-processing color space), so arrival never compiles a variant.
const playing = active && age >= 0 && age < 4;
mesh.visible = playing || !warmed;
if (mesh.visible) material.uniforms.uTime.value = playing ? age : -1;
material.uniforms.uFade.value = Math.min(1, Math.max(0, fade));
warmed = true;
} };
}

View File

@@ -24,6 +24,8 @@ export interface AtmosphereWorld {
palette: Readonly<MutableChapterPalette>,
elapsedSeconds?: number,
overdrive?: number,
/** END OF SHIFT: 0 night, 1 daybreak. */
dawn?: number,
): void;
}
@@ -57,6 +59,7 @@ export function createAtmosphere(
uAspect: { value: 1 },
uTime: { value: 0 },
uOverdrive: { value: 0 },
uDawn: { value: 0 },
},
vertexShader: `
uniform vec3 uSkyColor;
@@ -66,6 +69,7 @@ export function createAtmosphere(
uniform float uAspect;
uniform float uTime;
uniform float uOverdrive;
uniform float uDawn;
varying vec3 vSkyColor;
void main() {
float verticalSky = smoothstep(0.08, 0.66, uv.y);
@@ -91,6 +95,17 @@ export function createAtmosphere(
vec3 aurora = mix(violet, rose, 0.5 + 0.5 * sin(uv.x * 4.2 + uTime * 0.018));
color += aurora * veil * (0.10 + side * 0.11) * (1.0 + uOverdrive * 0.36);
color += vec3(0.018, 0.075, 0.29) * innerVeil * side * 0.12;
// END OF SHIFT: a warm band climbs off the horizon with the dawn, up
// to 0.35 of the frame, dusty rose above pale amber; the veils fade.
if (uDawn > 0.0) {
float reach = 0.47 + 0.35 * uDawn;
float band = 1.0 - smoothstep(0.4, reach, uv.y);
band *= smoothstep(0.3, 0.44, uv.y);
vec3 warm = mix(vec3(1.0, 0.72, 0.46), vec3(0.78, 0.45, 0.52), smoothstep(0.47, reach, uv.y));
color = mix(color, warm * (0.78 + centerGlow * 0.22), band * uDawn * 0.62);
color -= aurora * veil * (0.10 + side * 0.11) * uDawn;
color = max(color, vec3(0.0));
}
float topFalloff = smoothstep(0.54, 1.0, uv.y);
float vignette = smoothstep(0.36, 1.18, normalizedX);
color *= 1.0 - topFalloff * 0.28 - vignette * 0.08;
@@ -275,6 +290,7 @@ export function createAtmosphere(
palette,
elapsedSeconds = 0,
overdrive = 0,
dawn = 0,
) {
const safeAcceleration = clamp01(acceleration);
const visibility =
@@ -293,6 +309,7 @@ export function createAtmosphere(
skyMaterial.uniforms.uWarmth.value = palette.warmth;
skyMaterial.uniforms.uTime.value = finiteOrZero(elapsedSeconds);
skyMaterial.uniforms.uOverdrive.value = clamp01(overdrive);
skyMaterial.uniforms.uDawn.value = clamp01(dawn);
for (let index = 0; index < silhouetteMaterials.length; index += 1) {
silhouetteMaterials[index]?.color
.copy(palette.fog)

View File

@@ -0,0 +1,27 @@
import * as THREE from 'three';
/**
* END OF SHIFT: first light. A fixed low sun straight down the road throws a
* warm rim on the terrain, the district facades and the asphalt as the dawn
* rises. Shared uniform (one set for every generation), like the headlight
* beam: the environment writes one float per frame, and 0 skips the term.
*/
export const dawnLightUniforms = {
uDawnLight: { value: 0 },
};
/** Toward the sun: low over the horizon, down the road (-z). */
const SUN_DIRECTION = new THREE.Vector3(0, 0.2, -1).normalize();
/** GLSL: dawnRim(normal, toCamera) -> warm light to add (0 when uDawnLight is 0). */
export const DAWN_PARS = /* glsl */ `
uniform float uDawnLight;
vec3 dawnRim(vec3 n, vec3 toCamera) {
if (uDawnLight <= 0.0) return vec3(0.0);
vec3 sunDir = vec3(${SUN_DIRECTION.x.toFixed(4)}, ${SUN_DIRECTION.y.toFixed(4)}, ${SUN_DIRECTION.z.toFixed(4)});
float facing = abs(dot(n, sunDir));
float rim = pow(1.0 - abs(dot(n, toCamera)), 2.0);
float against = max(0.0, dot(-toCamera, sunDir));
return vec3(1.0, 0.6, 0.36) * uDawnLight * (facing * 0.09 + rim * against * 0.24);
}
`;

View File

@@ -11,6 +11,7 @@ import type { SectionId } from '@/content/types';
import { signAccentFor } from './sign';
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
import { BEAM_PARS, beamUniforms } from './headlights';
import { DAWN_PARS, dawnLightUniforms } from './dawn-light';
/** Roadside districts: each chapter raises its own landscape out of the ground
* (server rows for Infrastructure, a workshop and cranes for Builds, a container
@@ -202,7 +203,7 @@ export function createDistricts(
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...shared, ...service, ...beamUniforms, uRise: rise, uPieceHeight: height },
uniforms: { ...shared, ...service, ...beamUniforms, ...dawnLightUniforms, uRise: rise, uPieceHeight: height },
vertexShader: VERTEX,
fragmentShader: `
uniform vec3 uAccent;
@@ -211,6 +212,7 @@ export function createDistricts(
varying float vRise;
varying float vHeight;
${BEAM_PARS}
${DAWN_PARS}
${serviced ? SERVICE_PARS : ''}
void main() {
${FADES}
@@ -238,6 +240,8 @@ export function createDistricts(
// HIGH BEAM: the headlights rake the facades they face.
vec3 toLamp = normalize(uBeamOrigin - vWorld);
color += vec3(1.0, 0.84, 0.66) * headlightBeam(vWorld) * (0.25 + 0.75 * abs(dot(n, toLamp))) * 0.35;
// END OF SHIFT: the facades catch first light from the low sun ahead.
color += dawnRim(n, normalize(cameraPosition - vWorld)) * 1.4;
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>

View File

@@ -25,6 +25,8 @@ import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
import { createTravelDistance } from '@/scene/world/travel-distance';
import {
blendDaybreak,
engineOffStarScale,
createMutableChapterPalette,
STORM_STAR_SCALE,
STORM_TINT,
@@ -34,6 +36,8 @@ import {
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { createHeadlights } from '@/scene/world/headlights';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { dawnStep, type DawnStep } from '@/journey/dawn';
import { dawnLightUniforms } from '@/scene/world/dawn-light';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
@@ -161,6 +165,10 @@ export async function createProceduralSceneGeneration(
|| (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)
? 'lite'
: 'full';
// END OF SHIFT: the low tier and touch keep the palette, the sun lift and
// the star drain (uniform writes); the warm rims and the bloom change are
// high/medium fine-pointer only.
const dawnRims = maintenanceTier === 'full';
scene.add(
maintenance.group,
atmosphere.group,
@@ -218,6 +226,7 @@ export async function createProceduralSceneGeneration(
let interludeOn = false;
let riseUntil = 0;
let gantryState = '';
let dawnState: DawnStep | '' = '';
const resize = (): void => {
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
@@ -342,14 +351,20 @@ export async function createProceduralSceneGeneration(
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
}
districts.setServiceState(service.dim, service.relight);
// Engine off: parked at the destination the sky drops about 10%, more
// stars come out, and the drive winds down to a stop.
// END OF SHIFT: parked at the destination, the night ends. One scalar
// drives it all; leaving rewinds it as fast as the outro returns.
const dawn = easeInOutCubic(outroProgress);
blendDaybreak(targetPalette, THREE.MathUtils.smoothstep(dawn, 0.15, 0.95));
// Engine off: parked at the destination the sky drops about 10%, the
// stars come out (then drain as the dawn rises), and the drive winds
// down to a stop.
const engineOff = THREE.MathUtils.smoothstep(outroProgress, 0.12, 0.7);
if (engineOff > 0) {
targetPalette.sky.lerp(ENGINE_OFF_NIGHT, 0.1 * engineOff);
targetPalette.fog.lerp(ENGINE_OFF_NIGHT, 0.1 * engineOff);
targetPalette.starVisibility *= 1 + 0.8 * engineOff;
targetPalette.starVisibility *= engineOffStarScale(engineOff, dawn);
}
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
dampPalette(currentPalette, targetPalette, deltaSeconds);
const driveDistance = travelDistance.advance(
deltaSeconds * (1 - engineOff),
@@ -394,7 +409,7 @@ export async function createProceduralSceneGeneration(
);
atmosphericTime += deltaSeconds;
const elapsedSeconds = atmosphericTime;
horizonAir.update(currentPalette, elapsedSeconds);
horizonAir.update(currentPalette, elapsedSeconds, dawn);
skyline.update(currentPalette, elapsedSeconds);
traffic.update(
deltaSeconds,
@@ -407,6 +422,7 @@ export async function createProceduralSceneGeneration(
elapsedSeconds,
Math.min(1, response.bloomEnergy + beat * 0.35),
snapshot.arrived,
dawn,
);
car.update(
visualSpeed,
@@ -437,6 +453,7 @@ export async function createProceduralSceneGeneration(
currentPalette,
atmosphericTime,
overdrive,
dawn,
);
background.copy(currentPalette.sky);
scene.fog.color.copy(currentPalette.fog);
@@ -644,6 +661,12 @@ export async function createProceduralSceneGeneration(
riseUntil = 0;
delete document.documentElement.dataset.interlude;
}
// END OF SHIFT: CSS keys the credit and the letterbox tint off this.
const nextDawnState = dawnStep(dawn);
if (nextDawnState !== dawnState) {
dawnState = nextDawnState;
document.documentElement.dataset.dawn = nextDawnState;
}
if (TEST_BUILD && snapshot.sign !== null) {
const presentation = sign.inspect(camera, width, height);
if (presentation !== null) {
@@ -663,13 +686,14 @@ export async function createProceduralSceneGeneration(
}
}
if (postProcessor) {
postProcessor.setDynamics(response.bloomEnergy);
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
postProcessor.setLens({
time: elapsedSeconds,
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
energy: overdrive,
rain: service.rain,
rainTime: elapsedSeconds,
dawn,
});
postProcessor.render(scene, camera);
} else {
@@ -683,6 +707,8 @@ export async function createProceduralSceneGeneration(
delete document.documentElement.dataset.gantry;
delete document.documentElement.dataset.interlude;
delete document.documentElement.dataset.interludeArmed;
delete document.documentElement.dataset.dawn;
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.
const gl = renderer.getContext();
@@ -694,7 +720,8 @@ export async function createProceduralSceneGeneration(
}
const INTRO_SECONDS = 4.2;
const OUTRO_SECONDS = 5.5;
/** Long enough for the dawn to read: the sun clears the skyline near 3.8 s. */
const OUTRO_SECONDS = 7.0;
const OUTRO_RETURN_SECONDS = 1.6;
const OUTRO_REACH = 0.62;
const ENGINE_OFF_NIGHT = new THREE.Color(0x000000);

View File

@@ -9,6 +9,7 @@ export function createHorizonAir(resources: SceneResourceBag) {
const uniforms = {
uTime: { value: 0 },
uWarmth: { value: 0 },
uDawn: { value: 0 },
uColor: { value: new THREE.Color(0xc5646b) },
};
const material = resources.own(
@@ -22,6 +23,7 @@ export function createHorizonAir(resources: SceneResourceBag) {
fragmentShader: `
uniform float uTime;
uniform float uWarmth;
uniform float uDawn;
uniform vec3 uColor;
varying vec2 vUv;
void main(){
@@ -32,7 +34,7 @@ export function createHorizonAir(resources: SceneResourceBag) {
float colorWave=0.5+0.5*sin(vUv.x*7.0+uTime*0.035);
vec3 color=mix(uColor,vec3(0.27,0.045,0.75),colorWave*0.55);
color=mix(color,vec3(0.05,0.52,0.75),smoothstep(0.54,0.82,vUv.y)*0.3);
gl_FragColor=vec4(color,(ribbon*0.16+thin*0.032)*horizontal*(0.7+uWarmth*0.3));
gl_FragColor=vec4(color,(ribbon*0.16+thin*0.032)*horizontal*(0.7+uWarmth*0.3)*(1.0+uDawn));
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
@@ -53,8 +55,10 @@ export function createHorizonAir(resources: SceneResourceBag) {
}
return {
group,
update(palette: Readonly<MutableChapterPalette>, time: number) {
/** dawn: END OF SHIFT, the air masses catch first light (gain up to 2x). */
update(palette: Readonly<MutableChapterPalette>, time: number, dawn = 0) {
uniforms.uTime.value = time;
uniforms.uDawn.value = Number.isFinite(dawn) ? Math.min(1, Math.max(0, dawn)) : 0;
uniforms.uWarmth.value = palette.warmth;
uniforms.uColor.value.copy(palette.sun);
},

View File

@@ -111,6 +111,60 @@ export const CHAPTER_PALETTES: Readonly<Record<EnvironmentChapter, ChapterPalett
},
};
/**
* END OF SHIFT: the destination's sky at daybreak. Not a journey chapter: the
* environment blends the destination toward it as the outro's dawn rises.
*/
export const DAYBREAK_PALETTE: Readonly<ChapterPaletteDefinition> = {
sky: 0x6b4a7a,
fog: 0xb07a8c,
terrainLow: 0xffb98a,
terrainHigh: 0xffe1c2,
sun: 0xffd49a,
haze: 0xffb07a,
warmth: 1.25,
terrainEnergy: 0.4,
starVisibility: 0,
streakEnergy: 0,
};
const daybreakColors = {
sky: new THREE.Color(DAYBREAK_PALETTE.sky),
fog: new THREE.Color(DAYBREAK_PALETTE.fog),
terrainLow: new THREE.Color(DAYBREAK_PALETTE.terrainLow),
terrainHigh: new THREE.Color(DAYBREAK_PALETTE.terrainHigh),
sun: new THREE.Color(DAYBREAK_PALETTE.sun),
haze: new THREE.Color(DAYBREAK_PALETTE.haze),
};
/** Leans a written palette toward daybreak (0 untouched, 1 full daybreak). */
export function blendDaybreak(
target: MutableChapterPalette,
amount: number,
): MutableChapterPalette {
const mix = clamp01(amount);
if (mix === 0) return target;
target.sky.lerp(daybreakColors.sky, mix);
target.fog.lerp(daybreakColors.fog, mix);
target.terrainLow.lerp(daybreakColors.terrainLow, mix);
target.terrainHigh.lerp(daybreakColors.terrainHigh, mix);
target.sun.lerp(daybreakColors.sun, mix);
target.haze.lerp(daybreakColors.haze, mix);
target.warmth = lerp(target.warmth, DAYBREAK_PALETTE.warmth, mix);
target.terrainEnergy = lerp(target.terrainEnergy, DAYBREAK_PALETTE.terrainEnergy, mix);
target.starVisibility = lerp(target.starVisibility, DAYBREAK_PALETTE.starVisibility, mix);
target.streakEnergy = lerp(target.streakEnergy, DAYBREAK_PALETTE.streakEnergy, mix);
return target;
}
/**
* Star visibility multiplier while the engine is off: the stars brighten as
* the engine cuts (up to 1.8x), then drain back as the dawn rises.
*/
export function engineOffStarScale(engineOff: number, dawn: number): number {
return 1 + 0.8 * clamp01(engineOff) * (1 - clamp01(dawn));
}
/**
* The 03:12 window's storm: a colder navy the Infrastructure sky leans toward
* (about 55%) while the maintenance window is open. A tint, not a chapter.

View File

@@ -3,6 +3,7 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources';
import type { MutableChapterPalette } from '@/scene/world/palette';
import { CHAPTER_PALETTES } from '@/scene/world/palette';
import { DAWN_PARS, dawnLightUniforms } from '@/scene/world/dawn-light';
import {
HORIZON_FOG_END,
HORIZON_FOG_START,
@@ -60,6 +61,7 @@ export function createRoad(
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
uBeamYaw: { value: 0 },
uBeamLift: { value: 0 },
...dawnLightUniforms,
};
const material = resources.own(
'material',
@@ -100,6 +102,7 @@ export function createRoad(
varying float vViewDistance;
varying float vWorldZ;
varying float vNearFade;
${DAWN_PARS}
float hash21(vec2 point) {
point = fract(point * vec2(123.34, 456.21));
@@ -187,6 +190,12 @@ export function createRoad(
float glare = exp(-pow(vRoadX / (1.1 + vViewDistance * 0.02), 2.0))
* smoothstep(50.0, 220.0, vViewDistance) * (1.0 - smoothstep(360.0, 560.0, vViewDistance));
color += vec3(1.0, 0.32, 0.22) * glare * pow(film, 2.0) * asphaltMask * 0.24;
// END OF SHIFT: first light grazes the wet asphalt from the low sun
// ahead, a warm sheen that strengthens toward the horizon.
if (uDawnLight > 0.0) {
color += vec3(1.0, 0.6, 0.36) * uDawnLight * asphaltMask * film
* (0.025 + grazing * 0.06 + glare * 0.32);
}
// Headlight throw: two warm cones ahead of the car merging into a pool.
float ahead = -vWorldZ - 5.0;

View File

@@ -13,6 +13,8 @@ export interface SunWorld {
elapsedSeconds: number,
driveEnergy?: number,
arrived?: boolean,
/** END OF SHIFT: 0 night, 1 the sun has cleared the skyline. */
dawn?: number,
): void;
}
@@ -38,6 +40,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
uTime: { value: 0 },
uDriveEnergy: { value: 0 },
uArrival: { value: 0 },
uDawn: { value: 0 },
};
const geometry = resources.own(
'geometry',
@@ -64,6 +67,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
uniform float uTime;
uniform float uDriveEnergy;
uniform float uArrival;
uniform float uDawn;
varying vec2 vSunUv;
void main() {
@@ -74,10 +78,11 @@ export function createSun(resources: SceneResourceBag): SunWorld {
float softEdge = 1.0 - smoothstep(0.496, 0.5, radius);
float stripes = (0.65 - solar.y) * 17.0;
float phase = fract(stripes);
float gap = mix(0.12, 0.61, clamp(stripes / 11.0, 0.0, 1.0));
// Daybreak closes the scan bands: the retro striped sun becomes a disc.
float gap = mix(0.12, 0.61, clamp(stripes / 11.0, 0.0, 1.0)) * (1.0 - 0.7 * uDawn);
float separated = smoothstep(gap, gap + 0.024, phase);
float bandMask = solar.y > 0.65 ? 1.0 : separated;
float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65);
float bandMask = solar.y > 0.65 ? 1.0 : mix(separated, 1.0, smoothstep(0.75, 1.0, uDawn));
float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65) * (1.0 - uDawn);
float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
* sin(solar.y * 31.0 - uTime * 0.032);
vec3 vermilion = mix(vec3(1.0, 0.03, 0.4), vec3(1.0, 0.24, 0.12), uArrival);
@@ -85,6 +90,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
vec3 crown = mix(vec3(1.0, 0.8, 0.12), vec3(1.0, 0.9, 0.6), uArrival);
vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y));
core = mix(core, crown, smoothstep(0.47, 1.0, solar.y));
core = mix(core, mix(uColor, crown, 0.4), uDawn * 0.55);
float filament = pow(abs(sin(solar.x * 39.0 + solar.y * 25.0 + convection * 2.4)), 16.0);
core *= 0.87 + convection * 0.055 + filament * 0.07;
float limb = pow(smoothstep(0.38, 0.498, radius), 4.0);
@@ -204,8 +210,9 @@ export function createSun(resources: SceneResourceBag): SunWorld {
return {
group,
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0, arrived = false) {
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0, arrived = false, dawnLevel = 0) {
const progress = clamp01(routeProgress);
const dawn = clamp01(dawnLevel);
const energy = clamp01(driveEnergy);
const time = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
if (arrived && !wasArrived) arrivalStartedAt = time;
@@ -220,7 +227,8 @@ export function createSun(resources: SceneResourceBag): SunWorld {
// Only the rim repeats, using the existing visibility-aware scene clock.
const ringPhase = (Math.max(0, arrivalAge - 0.5) % 3) / 3;
const ringPulse = arrived ? Math.sin(Math.PI * ringPhase) ** 2 : 0;
meteors.update(arrivalAge, arrived);
// The meteors belong to the night: they fade out once dawn passes 0.7.
meteors.update(arrivalAge, arrived, 1 - THREE.MathUtils.smoothstep(dawn, 0.7, 0.85));
sunColor.copy(palette.sun).lerp(arrivalColor, arrivalGlow * 0.55);
uniforms.uHazeColor.value.copy(palette.haze);
haloMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22)
@@ -232,6 +240,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
uniforms.uTime.value = time;
uniforms.uDriveEnergy.value = energy;
uniforms.uArrival.value = arrivalGlow;
uniforms.uDawn.value = dawn;
limbMaterial.uniforms.uEnergy.value = energy;
const ringScale = ringPulse > 0 ? 1.9 : 1;
limb.scale.setScalar(1.034 * ringScale);
@@ -239,17 +248,29 @@ export function createSun(resources: SceneResourceBag): SunWorld {
limbMaterial.uniforms.uRingRadius.value = 0.49 + ringPhase * 0.36;
limbMaterial.uniforms.uRingOpacity.value = ringPulse;
limbMaterial.uniforms.uArrival.value = arrivalGlow;
haloMaterial.opacity = 0.095 + palette.warmth * 0.03 + energy * 0.015 + arrivalGlow * 0.08 + arrivalPulse * 0.1;
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
halo.scale.set(480 + energy * 18 + arrivalGlow * 40 + arrivalPulse * 76, 360 + energy * 10 + arrivalGlow * 30 + arrivalPulse * 56, 1);
disc.position.y = 79 + palette.warmth * 14;
// Daybreak: the halo opens wider but thinner, and the horizon bleed eases
// off, so the disc keeps an edge against the lightened sky.
haloMaterial.opacity = 0.095 + palette.warmth * 0.03 + energy * 0.015 + arrivalGlow * 0.08 + arrivalPulse * 0.1
- dawn * 0.04;
bleedMaterial.opacity = (0.055 + palette.warmth * 0.03 + energy * 0.01) * (1 - dawn * 0.35);
const haloGrowth = 1 + dawn * 0.18;
halo.scale.set(
(480 + energy * 18 + arrivalGlow * 40 + arrivalPulse * 76) * haloGrowth,
(360 + energy * 10 + arrivalGlow * 30 + arrivalPulse * 56) * haloGrowth,
1,
);
// The disc lifts 58 units with the dawn and clears the skyline near 0.6.
disc.position.y = 79 + palette.warmth * 14 + dawn * DAWN_LIFT;
limb.position.y = disc.position.y;
halo.position.y = disc.position.y;
bleed.position.y = 7 + palette.warmth * 3;
bleed.position.y = 7 + palette.warmth * 3 + dawn * DAWN_LIFT * 0.3;
},
};
}
/** How far the disc lifts at full dawn, in world units. */
export const DAWN_LIFT = 58;
function createHaloTexture() {
const size = 64;
if (typeof document !== 'undefined') {

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import { BEAM_PARS, beamUniforms } from './headlights';
import { DAWN_PARS, dawnLightUniforms } from './dawn-light';
import type { EnvironmentChapter } from '@/journey/types';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources';
@@ -236,7 +237,7 @@ export function createTerrain(
const groundMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...uniforms, ...beamUniforms },
uniforms: { ...uniforms, ...beamUniforms, ...dawnLightUniforms },
vertexShader: material.vertexShader.replace(
'displaced.y += height + 0.028;',
'displaced.y += height - 0.12;',
@@ -251,6 +252,7 @@ export function createTerrain(
varying vec3 vTerrainPosition;
varying vec3 vGridColor;
${BEAM_PARS}
${DAWN_PARS}
void main() {
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
@@ -259,6 +261,8 @@ export function createTerrain(
// HIGH BEAM: the ridges beside the road catch the headlights.
float beam = headlightBeam(vTerrainPosition);
color += vec3(1.0, 0.84, 0.66) * beam * (0.3 + 0.7 * abs(dot(normal, normalize(uBeamOrigin - vTerrainPosition)))) * 0.28;
// END OF SHIFT: the ridges catch first light from the low sun ahead.
color += dawnRim(normal, normalize(cameraPosition - vTerrainPosition));
color = mix(uFogColor * 0.55, color, vDepthFade);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>

View File

@@ -216,6 +216,21 @@
color: var(--amber);
}
/* END OF SHIFT: the destination eyebrow sits on the lightened sky, so it
darkens with the dawn instead of keeping the chapter accent. */
#contact .section-eyebrow { transition: color 1.2s ease; }
html:is([data-dawn='sunrise'], [data-dawn='day']) #contact .section-eyebrow {
color: #3b1a33;
text-shadow: 0 1px 12px rgb(255 226 196 / 45%);
}
/* END OF SHIFT: at full daylight the label reads SHIFT OVER (a string swap). */
html[data-dawn='day'] .journey-hud__arrival { font-size: 0; }
html[data-dawn='day'] .journey-hud__arrival::after {
content: attr(data-day) / '';
font-size: 0.72rem;
}
.drive-controls {
position: fixed;
z-index: var(--z-popover);
@@ -519,8 +534,15 @@
background: #020108;
content: '';
transform: scaleY(0);
transition: transform 1.6s cubic-bezier(0.65, 0, 0.35, 1);
transition:
transform 1.6s cubic-bezier(0.65, 0, 0.35, 1),
background-color 2.4s ease;
}
/* END OF SHIFT: the bars take on the dawn, black to a warm aubergine. */
html[data-dawn='first-light'] .journey-world::before,
html[data-dawn='first-light'] .journey-world::after { background-color: #0e0810; }
html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::before,
html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::after { background-color: #1a0f14; }
.journey-world::before {
inset-block-start: 0;
display: grid;
@@ -552,18 +574,45 @@
clip-path: inset(0 100% 0 0);
opacity: 0;
}
.journey-world[data-arrived='true'] .journey-world__credit::before {
/* END OF SHIFT: the credit types as the sun clears the skyline
(html[data-dawn] reaches sunrise about 3.8 s after arrival). Until the
scene reports a dawn (still loading), it keeps a fixed 3.4 s delay. */
@media (prefers-reduced-motion: no-preference) {
html:is([data-dawn='sunrise'], [data-dawn='day'], :not([data-dawn])) .journey-world[data-arrived='true'] .journey-world__credit::before {
clip-path: inset(0 -1em 0 0);
opacity: 1;
transition:
opacity 1ms linear 1.9s,
clip-path 1.5s steps(36, end) 1.9s;
opacity 1ms linear 0.2s,
clip-path 1.5s steps(36, end) 0.2s;
}
}
/* Phones: the credit may wrap, so it fades in rather than types. */
@media (prefers-reduced-motion: no-preference) and (max-width: 767px) {
html:is([data-dawn='sunrise'], [data-dawn='day'], :not([data-dawn])) .journey-world[data-arrived='true'] .journey-world__credit::before {
clip-path: none;
transition: opacity 0.8s ease 0.2s;
}
}
@media (prefers-reduced-motion: no-preference) {
html:not([data-dawn]) .journey-world[data-arrived='true'] .journey-world__credit::before { transition-delay: 3.4s; }
/* No scene, no dawn to wait for: the bars' own timing. */
html .journey-world[data-static-fallback='true'][data-arrived='true'] .journey-world__credit::before { transition-delay: 1.9s; }
}
/* Reduced motion: the bars and the dimming only; the credit simply shows. */
@media (prefers-reduced-motion: reduce) {
.journey-world[data-arrived='true'] .journey-world__credit::before { transition: none; }
.journey-world[data-arrived='true'] .journey-world__credit::before {
clip-path: inset(0 -1em 0 0);
opacity: 1;
transition: none;
}
}
.journey-poster { transition: filter 1.6s ease; }
.journey-poster { transition: filter 1.6s ease, opacity 1.6s ease; }
/* END OF SHIFT without the scene: the world crossfades to the daybreak sky
(indigo, dusty rose, pale amber) under the dimmed night poster. */
.journey-world[data-static-fallback='true'][data-arrived='true'] {
background: linear-gradient(180deg, #6b4a7a 0%, #b07a8c 44%, #ffb98a 58%, #ffe1c2 61%, #1a0f14 100%);
}
.journey-world[data-static-fallback='true'][data-arrived='true'] .journey-poster { opacity: 0.45; }
.journey-world[data-arrived='true'] .journey-poster { filter: brightness(0.9); }
.journey-world::after {
inset-block-end: 0;
@@ -586,10 +635,6 @@
text-align: center;
white-space: normal;
}
.journey-world[data-arrived='true'] .journey-world__credit::before {
clip-path: none;
transition: opacity 0.8s ease 1.9s;
}
}
@media (prefers-reduced-motion: reduce) {
.journey-world::before,
@@ -606,6 +651,7 @@
}
@media (prefers-reduced-motion: reduce) {
.journey-world[data-arrived='true'] .journey-poster { filter: saturate(0.86) brightness(0.83); }
.journey-poster { transition: none; }
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -17,6 +17,38 @@ function recordFor(
}
describe('adaptive mixer', () => {
it('sums the dawn input after the mood controls, untouched by chapter moods', () => {
const harness = createFakeAudioHarness();
const connections = new Map<unknown, unknown[]>();
const createGain = harness.context.createGain.bind(harness.context);
harness.context.createGain = () => {
const node = createGain();
const connect = node.connect.bind(node) as (target: AudioNode) => AudioNode;
node.connect = ((target: AudioNode) => {
connections.set(node, [...(connections.get(node) ?? []), target]);
return connect(target);
}) as GainNode['connect'];
return node;
};
const mixer = createAdaptiveMixer(harness.context, harness.destination);
const sum = connections.get(mixer.dawnInput)?.[0];
expect(sum).toBeDefined();
// The same summing node feeds the destination (mute, volume, pulse tap).
expect(connections.get(sum)).toEqual([harness.destination]);
const dawnRecord = recordFor(harness, mixer.dawnInput.gain);
harness.setCurrentTime(2);
mixer.setChapterIntent({
sectionId: 'contact',
mood: 'resolve',
atStart: false,
entryPhrase: 6,
generation: 1,
});
expect(dawnRecord.events).toEqual([]);
mixer.dispose();
expect((mixer.dawnInput as GainNode & { disconnectCount: number }).disconnectCount).toBe(1);
});
it('ramps only the four approved mood controls over 1.4 seconds', () => {
const harness = createFakeAudioHarness();
const mixer = createAdaptiveMixer(harness.context, harness.destination);
@@ -129,7 +161,8 @@ describe('adaptive mixer', () => {
mixer.dispose();
mixer.dispose();
expect(harness.gainNodes).toHaveLength(8);
// Nine with the END OF SHIFT dawn input.
expect(harness.gainNodes).toHaveLength(9);
for (const node of harness.gainNodes) {
expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1);
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import {
createDawnResolve,
DAWN_CHORD_HZ,
DAWN_DETUNE_CENTS,
DAWN_OPEN_SECONDS,
DAWN_PAD_GAIN,
DAWN_RELEASE_SECONDS,
DAWN_SWELL_SECONDS,
} from '@/audio/dawn-resolve';
import { createFakeAudioHarness, type FakeAudioHarness } from '../helpers/fake-audio-context';
const events = (harness: FakeAudioHarness) => harness.params.flatMap((record) => record.events);
describe('dawn resolve', () => {
it('preallocates a silent four-note pad, each note a +/-4 cent pair, plus one breath source', () => {
const harness = createFakeAudioHarness();
createDawnResolve(harness.context, harness.destination);
const oscillators = harness.sources.filter((record) => record.kind === 'oscillator');
const buffers = harness.sources.filter((record) => record.kind === 'buffer');
expect(oscillators).toHaveLength(DAWN_CHORD_HZ.length * 2);
expect(buffers).toHaveLength(1);
const notes = oscillators.map((record) => {
const node = record.node as OscillatorNode;
return [node.frequency.value, node.detune.value];
});
for (const frequency of [110, 164.8, 220, 329.6]) {
expect(notes).toContainEqual([frequency, -DAWN_DETUNE_CENTS]);
expect(notes).toContainEqual([frequency, DAWN_DETUNE_CENTS]);
}
for (const source of harness.sources) expect(source.starts).toHaveLength(1);
// Silent until arrival: no ramp is scheduled at construction.
expect(events(harness).filter((event) => event.kind === 'linear' || event.kind === 'exponential'))
.toEqual([]);
});
it('swells in over 5 s while the low-pass opens 900 -> 2600 Hz over 6 s, without new nodes', () => {
const harness = createFakeAudioHarness();
const dawn = createDawnResolve(harness.context, harness.destination);
const sourceCount = harness.sources.length;
const gainCount = harness.gainNodes.length;
harness.setCurrentTime(10);
dawn.setDawn(true);
const scheduled = events(harness);
expect(scheduled).toContainEqual({ kind: 'linear', value: DAWN_PAD_GAIN, at: 10 + DAWN_SWELL_SECONDS });
expect(scheduled).toContainEqual({ kind: 'exponential', value: 2_600, at: 10 + DAWN_OPEN_SECONDS });
expect(DAWN_PAD_GAIN).toBe(0.09);
expect(harness.sources).toHaveLength(sourceCount);
expect(harness.gainNodes).toHaveLength(gainCount);
});
it('releases over 1.6 s when driving away and ignores repeated states', () => {
const harness = createFakeAudioHarness();
const dawn = createDawnResolve(harness.context, harness.destination);
dawn.setDawn(false);
expect(events(harness).filter((event) => event.kind === 'linear')).toEqual([]);
harness.setCurrentTime(1);
dawn.setDawn(true);
const afterOn = events(harness).length;
dawn.setDawn(true);
expect(events(harness)).toHaveLength(afterOn);
harness.setCurrentTime(4);
dawn.setDawn(false);
// Events are grouped per parameter, so pick the release out by its start time.
const release = events(harness).filter((event) => event.at >= 4);
expect(release).toContainEqual({ kind: 'linear', value: 0, at: 4 + DAWN_RELEASE_SECONDS });
expect(release).toContainEqual({ kind: 'exponential', value: 900, at: 4 + DAWN_RELEASE_SECONDS });
// Every ramp holds its parameter first, so a release mid-swell never jumps.
expect(release.filter((event) => event.kind === 'hold' && event.at === 4)).toHaveLength(4);
});
it('stops and disconnects every source exactly once and goes quiet after dispose', () => {
const harness = createFakeAudioHarness();
const dawn = createDawnResolve(harness.context, harness.destination);
dawn.dispose();
dawn.dispose();
for (const source of harness.sources) {
expect(source.stops).toHaveLength(1);
expect(source.disconnectCount).toBe(1);
}
for (const node of harness.gainNodes) {
expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1);
}
const before = events(harness).length;
dawn.setDawn(true);
expect(events(harness)).toHaveLength(before);
});
});

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { DAWN_STEPS, dawnStep, END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
import {
blendDaybreak,
CHAPTER_PALETTES,
createMutableChapterPalette,
DAYBREAK_PALETTE,
engineOffStarScale,
} from '@/scene/world/palette';
describe('dawn step mapping (html[data-dawn])', () => {
it('steps night -> first-light -> sunrise -> day at 0.3 / 0.6 / 0.95', () => {
expect(DAWN_STEPS).toEqual({ 'first-light': 0.3, sunrise: 0.6, day: 0.95 });
const cases: Array<[number, string]> = [
[0, 'night'],
[0.29, 'night'],
[0.3, 'first-light'],
[0.59, 'first-light'],
[0.6, 'sunrise'],
[0.949, 'sunrise'],
[0.95, 'day'],
[1, 'day'],
[-1, 'night'],
[Number.NaN, 'night'],
];
for (const [dawn, step] of cases) expect(dawnStep(dawn)).toBe(step);
});
it('reads the end-of-shift sub-line', () => {
expect(END_OF_SHIFT_SUBLINE).toBe('END OF SHIFT · 05:48');
});
});
describe('daybreak palette', () => {
it('is not a journey chapter', () => {
expect(Object.keys(CHAPTER_PALETTES)).not.toContain('daybreak');
});
it('leaves the destination untouched at 0 and becomes daybreak at 1', () => {
const untouched = createMutableChapterPalette('destination');
blendDaybreak(untouched, 0);
expect(untouched.sky.getHex()).toBe(CHAPTER_PALETTES.destination.sky);
expect(untouched.starVisibility).toBe(CHAPTER_PALETTES.destination.starVisibility);
const full = createMutableChapterPalette('destination');
blendDaybreak(full, 1);
for (const key of ['sky', 'fog', 'terrainLow', 'terrainHigh', 'sun', 'haze'] as const) {
expect(full[key].getHex()).toBe(DAYBREAK_PALETTE[key]);
}
expect(full.warmth).toBe(1.25);
expect(full.terrainEnergy).toBe(0.4);
expect(full.starVisibility).toBe(0);
expect(full.streakEnergy).toBe(0);
});
it('blends partway and clamps out-of-range amounts', () => {
const half = createMutableChapterPalette('destination');
blendDaybreak(half, 0.5);
expect(half.warmth).toBeCloseTo((1 + 1.25) / 2);
expect(half.starVisibility).toBeCloseTo(0.08);
const over = createMutableChapterPalette('destination');
blendDaybreak(over, 3);
expect(over.sky.getHex()).toBe(DAYBREAK_PALETTE.sky);
const nan = createMutableChapterPalette('destination');
blendDaybreak(nan, Number.NaN);
expect(nan.sky.getHex()).toBe(CHAPTER_PALETTES.destination.sky);
});
it('boosts the stars as the engine cuts, then drains them as the dawn rises', () => {
expect(engineOffStarScale(0, 0)).toBe(1);
expect(engineOffStarScale(1, 0)).toBeCloseTo(1.8);
expect(engineOffStarScale(1, 0.5)).toBeCloseTo(1.4);
expect(engineOffStarScale(1, 1)).toBe(1);
// Combined with the daybreak blend, the stars are gone by full dawn.
const palette = createMutableChapterPalette('destination');
blendDaybreak(palette, 1);
expect(palette.starVisibility * engineOffStarScale(1, 1)).toBe(0);
});
});

View File

@@ -12,6 +12,7 @@ import {
} from '@/audio/soundtrack-transport';
import type { AdaptiveMixer } from '@/audio/adaptive-mixer';
import type { DriveTexture } from '@/audio/drive-texture';
import type { DawnResolve } from '@/audio/dawn-resolve';
import type {
AudioInitialState,
SoundtrackSources,
@@ -41,6 +42,7 @@ interface EngineHarnessOptions {
readonly rejectDriveLift?: boolean;
readonly rejectDriveTexture?: boolean;
readonly deferDriveLift?: boolean;
readonly rejectDawn?: boolean;
}
interface Deferred<T> {
@@ -71,10 +73,12 @@ function createEngineHarness(
const bedInput = harness.context.createGain();
const driveLiftInput = harness.context.createGain();
const driveTextureInput = harness.context.createGain();
const dawnInput = harness.context.createGain();
const mixer = {
bedInput,
driveLiftInput,
driveTextureInput,
dawnInput,
setChapterIntent: vi.fn(),
setDriveEnergy: vi.fn(),
setTransportAnchor: vi.fn(),
@@ -85,6 +89,14 @@ function createEngineHarness(
setEnergy: vi.fn(),
dispose: vi.fn(),
} satisfies DriveTexture;
const dawn = {
setDawn: vi.fn(),
dispose: vi.fn(),
} satisfies DawnResolve;
const createDawnResolve = vi.fn((_context: AudioContext, _destination: AudioNode) => {
if (options.rejectDawn) throw new Error('dawn chord unavailable');
return dawn;
});
const createMixer = vi.fn(() => mixer);
const createDriveTexture = vi.fn(() => {
if (options.rejectDriveTexture) throw new Error('drive texture unavailable');
@@ -116,6 +128,7 @@ function createEngineHarness(
loadBuffer,
createMixer,
createDriveTexture,
createDawnResolve,
});
const sourcesFor = (url: string): FakeSourceRecord[] => {
@@ -144,6 +157,8 @@ function createEngineHarness(
harness,
mixer,
texture,
dawn,
createDawnResolve,
loadCalls,
sourcesFor,
sourceFor,
@@ -348,7 +363,35 @@ describe('soundtrack engine', () => {
expect(source.disconnectCount).toBe(1);
}
expect(test.texture.dispose).toHaveBeenCalledOnce();
expect(test.dawn.dispose).toHaveBeenCalledOnce();
expect(test.mixer.dispose).toHaveBeenCalledOnce();
expect(test.harness.closeCount).toBe(1);
});
it('routes the dawn chord into the mixer and resolves it only at the destination', () => {
const test = createEngineHarness();
expect(test.createDawnResolve).toHaveBeenCalledWith(test.harness.context, test.mixer.dawnInput);
expect(test.dawn.setDawn).not.toHaveBeenCalled();
test.graph.setChapterIntent(resolveChapterIntent('toolkit', 1));
expect(test.dawn.setDawn).toHaveBeenLastCalledWith(false);
test.graph.setChapterIntent(resolveChapterIntent('contact', 2));
expect(test.dawn.setDawn).toHaveBeenLastCalledWith(true);
// A repeated intent for the same chapter does not re-trigger the chord.
const calls = test.dawn.setDawn.mock.calls.length;
test.graph.setChapterIntent(resolveChapterIntent('contact', 3));
expect(test.dawn.setDawn).toHaveBeenCalledTimes(calls);
test.graph.setChapterIntent(resolveChapterIntent('toolkit', 4));
expect(test.dawn.setDawn).toHaveBeenLastCalledWith(false);
});
it('keeps playing when the dawn chord cannot be built, and ignores it after close', async () => {
const test = createEngineHarness(initialState, { rejectDawn: true });
expect(() => test.graph.setChapterIntent(resolveChapterIntent('contact', 1))).not.toThrow();
expect(test.dawn.setDawn).not.toHaveBeenCalled();
const closed = createEngineHarness();
await closed.graph.close();
closed.graph.setChapterIntent(resolveChapterIntent('contact', 1));
expect(closed.dawn.setDawn).not.toHaveBeenCalled();
expect(closed.dawn.dispose).toHaveBeenCalledOnce();
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { createSun } from '@/scene/world/sun';
import { createSun, DAWN_LIFT } from '@/scene/world/sun';
import { createMutableChapterPalette } from '@/scene/world/palette';
interface Halo {
@@ -107,6 +107,39 @@ describe('destination sun', () => {
resources.dispose();
});
it('lifts the disc, widens the halo and fades the meteors as the dawn rises, without allocating', () => {
const resources = new SceneResourceBag();
const sun = createSun(resources);
const palette = createMutableChapterPalette('destination');
const disc = sun.group.getObjectByName('horizon-sun') as unknown as {
position: { y: number };
material: { uniforms: Record<string, { value: number }> };
};
const limb = sun.group.getObjectByName('sun-limb-glow') as unknown as { position: { y: number } };
const halo = sun.group.getObjectByName('horizon-halo') as unknown as Halo & { position: { y: number } };
const meteors = sun.group.getObjectByName('arrival-meteors') as unknown as {
material: { uniforms: { uFade: { value: number } } };
};
sun.update(palette, 1, 10, 0, true, 0);
sun.update(palette, 1, 20, 0, true, 0);
const night = { y: disc.position.y, width: halo.scale.x };
expect(meteors.material.uniforms.uFade.value).toBe(1);
const allocation = resources.snapshot();
sun.update(palette, 1, 20, 0, true, 0.7);
expect(meteors.material.uniforms.uFade.value).toBe(1);
sun.update(palette, 1, 20, 0, true, 1);
expect(disc.position.y).toBeCloseTo(night.y + DAWN_LIFT);
expect(DAWN_LIFT).toBe(58);
expect(limb.position.y).toBe(disc.position.y);
expect(halo.position.y).toBe(disc.position.y);
expect(halo.scale.x / night.width).toBeCloseTo(1.18);
expect(disc.material.uniforms.uDawn!.value).toBe(1);
expect(meteors.material.uniforms.uFade.value).toBe(0);
expect(resources.snapshot()).toEqual(allocation);
resources.dispose();
});
it('does not pulse from route progress alone and cancels when leaving Contact', () => {
const resources = new SceneResourceBag();
const sun = createSun(resources);