feat: daybreak finale turns the contact arrival into a sunrise with a dawn chord
This commit is contained in:
@@ -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
138
src/audio/dawn-resolve.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
21
src/journey/dawn.ts
Normal 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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
27
src/scene/world/dawn-light.ts
Normal file
27
src/scene/world/dawn-light.ts
Normal 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);
|
||||
}
|
||||
`;
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
clip-path: inset(0 -1em 0 0);
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 1ms linear 1.9s,
|
||||
clip-path 1.5s steps(36, end) 1.9s;
|
||||
/* 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 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) {
|
||||
|
||||
Reference in New Issue
Block a user