feat: daybreak finale turns the contact arrival into a sunrise with a dawn chord
@@ -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.
|
- **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/.
|
- 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/.
|
||||||
|
|
||||||
|
|||||||
BIN
docs/overnight/round3/before-contact.webp
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
docs/overnight/round3/before-fix-t80.webp
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/overnight/round3/mobile-390.webp
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/overnight/round3/reduced-motion.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/overnight/round3/rewind.webp
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
docs/overnight/round3/t00.webp
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
docs/overnight/round3/t15.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
docs/overnight/round3/t30.webp
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
docs/overnight/round3/t45.webp
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/overnight/round3/t60.webp
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/overnight/round3/t80.webp
Normal file
|
After Width: | Height: | Size: 103 KiB |
@@ -27,6 +27,8 @@ export interface AdaptiveMixer {
|
|||||||
readonly bedInput: GainNode;
|
readonly bedInput: GainNode;
|
||||||
readonly driveLiftInput: GainNode;
|
readonly driveLiftInput: GainNode;
|
||||||
readonly driveTextureInput: GainNode;
|
readonly driveTextureInput: GainNode;
|
||||||
|
/** END OF SHIFT: the dawn chord, summed after the mood controls. */
|
||||||
|
readonly dawnInput: GainNode;
|
||||||
setChapterIntent(intent: ChapterIntent): void;
|
setChapterIntent(intent: ChapterIntent): void;
|
||||||
setDriveEnergy(sample: DriveEnergySample): void;
|
setDriveEnergy(sample: DriveEnergySample): void;
|
||||||
setTransportAnchor(anchorTime: number | null): void;
|
setTransportAnchor(anchorTime: number | null): void;
|
||||||
@@ -57,6 +59,9 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination)
|
|||||||
const driveTextureInput = context.createGain();
|
const driveTextureInput = context.createGain();
|
||||||
const textureSend = context.createGain();
|
const textureSend = context.createGain();
|
||||||
const sum = 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 highShelf = context.createBiquadFilter();
|
||||||
|
|
||||||
const initialProfile = MIX_PROFILES.open;
|
const initialProfile = MIX_PROFILES.open;
|
||||||
@@ -77,6 +82,7 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination)
|
|||||||
driveLiftCap.connect(sum);
|
driveLiftCap.connect(sum);
|
||||||
driveTextureInput.connect(textureSend);
|
driveTextureInput.connect(textureSend);
|
||||||
textureSend.connect(sum);
|
textureSend.connect(sum);
|
||||||
|
dawnInput.connect(sum);
|
||||||
sum.connect(destination);
|
sum.connect(destination);
|
||||||
|
|
||||||
const ownedNodes: readonly AudioNode[] = [
|
const ownedNodes: readonly AudioNode[] = [
|
||||||
@@ -88,6 +94,7 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination)
|
|||||||
driveLiftCap,
|
driveLiftCap,
|
||||||
driveTextureInput,
|
driveTextureInput,
|
||||||
textureSend,
|
textureSend,
|
||||||
|
dawnInput,
|
||||||
sum,
|
sum,
|
||||||
];
|
];
|
||||||
let transportAnchor: number | null = null;
|
let transportAnchor: number | null = null;
|
||||||
@@ -197,6 +204,7 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination)
|
|||||||
bedInput,
|
bedInput,
|
||||||
driveLiftInput,
|
driveLiftInput,
|
||||||
driveTextureInput,
|
driveTextureInput,
|
||||||
|
dawnInput,
|
||||||
setChapterIntent(intent) {
|
setChapterIntent(intent) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const profile = MIX_PROFILES[intent.mood];
|
const profile = MIX_PROFILES[intent.mood];
|
||||||
|
|||||||
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 DriveTexture,
|
||||||
type DriveTextureFactory,
|
type DriveTextureFactory,
|
||||||
} from '@/audio/drive-texture';
|
} from '@/audio/drive-texture';
|
||||||
|
import {
|
||||||
|
createDawnResolve,
|
||||||
|
type DawnResolve,
|
||||||
|
type DawnResolveFactory,
|
||||||
|
} from '@/audio/dawn-resolve';
|
||||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||||
import type {
|
import type {
|
||||||
AudioContextLifecycleState,
|
AudioContextLifecycleState,
|
||||||
@@ -73,6 +78,8 @@ export interface SoundtrackEngineDependencies {
|
|||||||
): Promise<AudioBuffer>;
|
): Promise<AudioBuffer>;
|
||||||
createMixer: AdaptiveMixerFactory;
|
createMixer: AdaptiveMixerFactory;
|
||||||
createDriveTexture: DriveTextureFactory;
|
createDriveTexture: DriveTextureFactory;
|
||||||
|
/** END OF SHIFT: the held dawn chord at the destination (optional voice). */
|
||||||
|
createDawnResolve?: DawnResolveFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSoundtrackEngine(
|
export function createSoundtrackEngine(
|
||||||
@@ -90,6 +97,12 @@ export function createSoundtrackEngine(
|
|||||||
} catch {
|
} catch {
|
||||||
driveTexture = null;
|
driveTexture = null;
|
||||||
}
|
}
|
||||||
|
let dawn: DawnResolve | null = null;
|
||||||
|
try {
|
||||||
|
dawn = dependencies.createDawnResolve?.(context, mixer.dawnInput) ?? null;
|
||||||
|
} catch {
|
||||||
|
dawn = null;
|
||||||
|
}
|
||||||
|
|
||||||
const ownedSources = new Set<OwnedSource>();
|
const ownedSources = new Set<OwnedSource>();
|
||||||
const stateListeners = new Set<(state: AudioContextLifecycleState) => void>();
|
const stateListeners = new Set<(state: AudioContextLifecycleState) => void>();
|
||||||
@@ -516,6 +529,8 @@ export function createSoundtrackEngine(
|
|||||||
mixer.setDriveLiftAvailableAt(null);
|
mixer.setDriveLiftAvailableAt(null);
|
||||||
driveTexture?.dispose();
|
driveTexture?.dispose();
|
||||||
driveTexture = null;
|
driveTexture = null;
|
||||||
|
dawn?.dispose();
|
||||||
|
dawn = null;
|
||||||
mixer.dispose();
|
mixer.dispose();
|
||||||
for (const source of [...ownedSources]) source.cancel();
|
for (const source of [...ownedSources]) source.cancel();
|
||||||
ownedSources.clear();
|
ownedSources.clear();
|
||||||
@@ -560,6 +575,8 @@ export function createSoundtrackEngine(
|
|||||||
driveTexture?.passBy?.();
|
driveTexture?.passBy?.();
|
||||||
// The last exit is the destination: the engine winds down there.
|
// The last exit is the destination: the engine winds down there.
|
||||||
driveTexture?.park?.(intent.sectionId === 'contact');
|
driveTexture?.park?.(intent.sectionId === 'contact');
|
||||||
|
// ...and the shift ends: the music resolves into a held dawn chord.
|
||||||
|
dawn?.setDawn(intent.sectionId === 'contact');
|
||||||
}
|
}
|
||||||
latestChapter = intent;
|
latestChapter = intent;
|
||||||
mixer.setChapterIntent(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.
|
// The mixer feeds a pass-through tap so the scene can read the bass level.
|
||||||
createMixer: (context, destination) => createAdaptiveMixer(context, createPulseTap(context, destination)),
|
createMixer: (context, destination) => createAdaptiveMixer(context, createPulseTap(context, destination)),
|
||||||
createDriveTexture,
|
createDriveTexture,
|
||||||
|
createDawnResolve,
|
||||||
});
|
});
|
||||||
let resumeCount = 0;
|
let resumeCount = 0;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
|||||||
import { useRouteMemory } from '@/composables/useRouteMemory';
|
import { useRouteMemory } from '@/composables/useRouteMemory';
|
||||||
import { assetUrl } from '@/content/asset-url';
|
import { assetUrl } from '@/content/asset-url';
|
||||||
import { signRouteFor } from '@/journey/sign-routes';
|
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 { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types';
|
||||||
import type { SceneHostState } from '@/composables/useSceneController';
|
import type { SceneHostState } from '@/composables/useSceneController';
|
||||||
import type { MotionPolicy } from '@/journey/motion-policy';
|
import type { MotionPolicy } from '@/journey/motion-policy';
|
||||||
@@ -129,6 +130,11 @@ const audio = useAudioEngine({
|
|||||||
const sceneState = shallowRef<SceneHostState>('idle');
|
const sceneState = shallowRef<SceneHostState>('idle');
|
||||||
const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active');
|
const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active');
|
||||||
const controlsReady = shallowRef(false);
|
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({
|
const adaptive = useAdaptiveQuality({
|
||||||
maximum: computed(() => motion.policy.value.maximumTier),
|
maximum: computed(() => motion.policy.value.maximumTier),
|
||||||
sceneState,
|
sceneState,
|
||||||
@@ -270,7 +276,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
:exit="exitFor(journey.activeSectionId)"
|
:exit="exitFor(journey.activeSectionId)"
|
||||||
:next-label="nextLabelFor(journey.activeSectionId)"
|
:next-label="nextLabelFor(journey.activeSectionId)"
|
||||||
:next-exit="nextExitFor(journey.activeSectionId)"
|
:next-exit="nextExitFor(journey.activeSectionId)"
|
||||||
:subline="maintenanceSubline"
|
:subline="hudSubline"
|
||||||
/>
|
/>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<SiteNavigation
|
<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__route" :data-next="next"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
|
||||||
<span class="journey-hud__section">{{ sectionLabel }}</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>
|
<span class="journey-hud__subline">{{ subline }}</span>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useSceneController, type SceneHostState } from '@/composables/useSceneController';
|
||||||
import type { JourneySnapshot } from '@/journey/types';
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
import type { BeamSample } from '@/journey/headlight-beam';
|
import type { BeamSample } from '@/journey/headlight-beam';
|
||||||
@@ -47,6 +47,17 @@ const showCanvas = computed(() => (
|
|||||||
|
|
||||||
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
|
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
|
||||||
onMounted(() => { clientReady.value = 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');
|
if (posterFailed.value) recordTestFaultEvent('poster-failure');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
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;
|
rain?: number;
|
||||||
/** Seconds, for the drops' slow slide. */
|
/** Seconds, for the drops' slow slide. */
|
||||||
rainTime?: number;
|
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 },
|
uVignette: { value: 0.42 },
|
||||||
uRain: { value: 0 },
|
uRain: { value: 0 },
|
||||||
uRainTime: { value: 0 },
|
uRainTime: { value: 0 },
|
||||||
|
uDawn: { value: 0 },
|
||||||
toneMappingExposure: { value: 1 },
|
toneMappingExposure: { value: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,6 +116,7 @@ export class LensFinishPass extends Pass {
|
|||||||
uniform float uVignette;
|
uniform float uVignette;
|
||||||
uniform float uRain;
|
uniform float uRain;
|
||||||
uniform float uRainTime;
|
uniform float uRainTime;
|
||||||
|
uniform float uDawn;
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
#include <tonemapping_pars_fragment>
|
#include <tonemapping_pars_fragment>
|
||||||
#include <colorspace_pars_fragment>
|
#include <colorspace_pars_fragment>
|
||||||
@@ -188,6 +192,9 @@ export class LensFinishPass extends Pass {
|
|||||||
}
|
}
|
||||||
color = CustomToneMapping( color );
|
color = CustomToneMapping( color );
|
||||||
color = sRGBTransferOETF( vec4( color, 1.0 ) ).rgb;
|
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 );
|
float vignette = smoothstep( 0.18, 1.05, length( centered * vec2( 1.0, 0.86 ) ) * 1.42 );
|
||||||
color *= 1.0 - uVignette * vignette;
|
color *= 1.0 - uVignette * vignette;
|
||||||
color += ( grainHash( gl_FragCoord.xy + fract( uTime * 7.31 ) * 311.0 ) - 0.5 ) * uGrain;
|
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.uAberration.value = 0.0035 + energy * 0.006;
|
||||||
this.uniforms.uRain.value = THREE.MathUtils.clamp(state.rain ?? 0, 0, 1);
|
this.uniforms.uRain.value = THREE.MathUtils.clamp(state.rain ?? 0, 0, 1);
|
||||||
this.uniforms.uRainTime.value = state.rainTime ?? state.time;
|
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) {
|
if (this.uniforms.tStreak.value) {
|
||||||
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
|
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import type {
|
|||||||
|
|
||||||
export interface PostProcessor extends GpuRenderTargetReporter {
|
export interface PostProcessor extends GpuRenderTargetReporter {
|
||||||
resize(width: number, height: number, pixelRatio: number): void;
|
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;
|
setLens(state: LensState): void;
|
||||||
render(scene: unknown, camera: unknown): void;
|
render(scene: unknown, camera: unknown): void;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -127,12 +128,13 @@ export function createPostProcessor(
|
|||||||
Math.max(1, Math.floor(finiteOr(height, 1))),
|
Math.max(1, Math.floor(finiteOr(height, 1))),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
setDynamics(acceleration) {
|
setDynamics(acceleration, dawn = 0) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const energy = clamp(acceleration, 0, 1);
|
const energy = clamp(acceleration, 0, 1);
|
||||||
|
const daybreak = clamp(dawn, 0, 1);
|
||||||
bloomPass.strength = BLOOM_REST.strength + energy * 0.16;
|
bloomPass.strength = BLOOM_REST.strength + energy * 0.16;
|
||||||
bloomPass.radius = BLOOM_REST.radius + energy * 0.06;
|
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) {
|
setLens(state) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
|
|||||||
geometry.setAttribute('aFlight', new THREE.Float32BufferAttribute(timing, 2));
|
geometry.setAttribute('aFlight', new THREE.Float32BufferAttribute(timing, 2));
|
||||||
geometry.setAttribute('aTravel', new THREE.Float32BufferAttribute(travel, 2));
|
geometry.setAttribute('aTravel', new THREE.Float32BufferAttribute(travel, 2));
|
||||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||||
uniforms: { uTime: { value: -1 } },
|
uniforms: { uTime: { value: -1 }, uFade: { value: 1 } },
|
||||||
vertexShader: `
|
vertexShader: `
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
attribute vec2 aFlight;
|
attribute vec2 aFlight;
|
||||||
@@ -51,6 +51,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
fragmentShader: `
|
fragmentShader: `
|
||||||
|
uniform float uFade;
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
varying float vLife;
|
varying float vLife;
|
||||||
void main() {
|
void main() {
|
||||||
@@ -60,7 +61,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
|
|||||||
float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper;
|
float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper;
|
||||||
float head = (1.0 - smoothstep(0.0, 1.0,
|
float head = (1.0 - smoothstep(0.0, 1.0,
|
||||||
length(vec2((vUv.x - 0.965) * 30.0, across))));
|
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;
|
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));
|
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);
|
gl_FragColor = vec4(light * 1.8, alpha);
|
||||||
@@ -80,12 +81,13 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
|
|||||||
mesh.frustumCulled = false;
|
mesh.frustumCulled = false;
|
||||||
mesh.visible = false;
|
mesh.visible = false;
|
||||||
let warmed = 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
|
// One invisible startup draw warms the exact live pipeline (including
|
||||||
// its post-processing color space), so arrival never compiles a variant.
|
// its post-processing color space), so arrival never compiles a variant.
|
||||||
const playing = active && age >= 0 && age < 4;
|
const playing = active && age >= 0 && age < 4;
|
||||||
mesh.visible = playing || !warmed;
|
mesh.visible = playing || !warmed;
|
||||||
if (mesh.visible) material.uniforms.uTime.value = playing ? age : -1;
|
if (mesh.visible) material.uniforms.uTime.value = playing ? age : -1;
|
||||||
|
material.uniforms.uFade.value = Math.min(1, Math.max(0, fade));
|
||||||
warmed = true;
|
warmed = true;
|
||||||
} };
|
} };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface AtmosphereWorld {
|
|||||||
palette: Readonly<MutableChapterPalette>,
|
palette: Readonly<MutableChapterPalette>,
|
||||||
elapsedSeconds?: number,
|
elapsedSeconds?: number,
|
||||||
overdrive?: number,
|
overdrive?: number,
|
||||||
|
/** END OF SHIFT: 0 night, 1 daybreak. */
|
||||||
|
dawn?: number,
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ export function createAtmosphere(
|
|||||||
uAspect: { value: 1 },
|
uAspect: { value: 1 },
|
||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
uOverdrive: { value: 0 },
|
uOverdrive: { value: 0 },
|
||||||
|
uDawn: { value: 0 },
|
||||||
},
|
},
|
||||||
vertexShader: `
|
vertexShader: `
|
||||||
uniform vec3 uSkyColor;
|
uniform vec3 uSkyColor;
|
||||||
@@ -66,6 +69,7 @@ export function createAtmosphere(
|
|||||||
uniform float uAspect;
|
uniform float uAspect;
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
uniform float uOverdrive;
|
uniform float uOverdrive;
|
||||||
|
uniform float uDawn;
|
||||||
varying vec3 vSkyColor;
|
varying vec3 vSkyColor;
|
||||||
void main() {
|
void main() {
|
||||||
float verticalSky = smoothstep(0.08, 0.66, uv.y);
|
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));
|
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 += aurora * veil * (0.10 + side * 0.11) * (1.0 + uOverdrive * 0.36);
|
||||||
color += vec3(0.018, 0.075, 0.29) * innerVeil * side * 0.12;
|
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 topFalloff = smoothstep(0.54, 1.0, uv.y);
|
||||||
float vignette = smoothstep(0.36, 1.18, normalizedX);
|
float vignette = smoothstep(0.36, 1.18, normalizedX);
|
||||||
color *= 1.0 - topFalloff * 0.28 - vignette * 0.08;
|
color *= 1.0 - topFalloff * 0.28 - vignette * 0.08;
|
||||||
@@ -275,6 +290,7 @@ export function createAtmosphere(
|
|||||||
palette,
|
palette,
|
||||||
elapsedSeconds = 0,
|
elapsedSeconds = 0,
|
||||||
overdrive = 0,
|
overdrive = 0,
|
||||||
|
dawn = 0,
|
||||||
) {
|
) {
|
||||||
const safeAcceleration = clamp01(acceleration);
|
const safeAcceleration = clamp01(acceleration);
|
||||||
const visibility =
|
const visibility =
|
||||||
@@ -293,6 +309,7 @@ export function createAtmosphere(
|
|||||||
skyMaterial.uniforms.uWarmth.value = palette.warmth;
|
skyMaterial.uniforms.uWarmth.value = palette.warmth;
|
||||||
skyMaterial.uniforms.uTime.value = finiteOrZero(elapsedSeconds);
|
skyMaterial.uniforms.uTime.value = finiteOrZero(elapsedSeconds);
|
||||||
skyMaterial.uniforms.uOverdrive.value = clamp01(overdrive);
|
skyMaterial.uniforms.uOverdrive.value = clamp01(overdrive);
|
||||||
|
skyMaterial.uniforms.uDawn.value = clamp01(dawn);
|
||||||
for (let index = 0; index < silhouetteMaterials.length; index += 1) {
|
for (let index = 0; index < silhouetteMaterials.length; index += 1) {
|
||||||
silhouetteMaterials[index]?.color
|
silhouetteMaterials[index]?.color
|
||||||
.copy(palette.fog)
|
.copy(palette.fog)
|
||||||
|
|||||||
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 { signAccentFor } from './sign';
|
||||||
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
|
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
|
||||||
import { BEAM_PARS, beamUniforms } from './headlights';
|
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
|
/** Roadside districts: each chapter raises its own landscape out of the ground
|
||||||
* (server rows for Infrastructure, a workshop and cranes for Builds, a container
|
* (server rows for Infrastructure, a workshop and cranes for Builds, a container
|
||||||
@@ -202,7 +203,7 @@ export function createDistricts(
|
|||||||
resources.own(
|
resources.own(
|
||||||
'material',
|
'material',
|
||||||
new THREE.ShaderMaterial({
|
new THREE.ShaderMaterial({
|
||||||
uniforms: { ...shared, ...service, ...beamUniforms, uRise: rise, uPieceHeight: height },
|
uniforms: { ...shared, ...service, ...beamUniforms, ...dawnLightUniforms, uRise: rise, uPieceHeight: height },
|
||||||
vertexShader: VERTEX,
|
vertexShader: VERTEX,
|
||||||
fragmentShader: `
|
fragmentShader: `
|
||||||
uniform vec3 uAccent;
|
uniform vec3 uAccent;
|
||||||
@@ -211,6 +212,7 @@ export function createDistricts(
|
|||||||
varying float vRise;
|
varying float vRise;
|
||||||
varying float vHeight;
|
varying float vHeight;
|
||||||
${BEAM_PARS}
|
${BEAM_PARS}
|
||||||
|
${DAWN_PARS}
|
||||||
${serviced ? SERVICE_PARS : ''}
|
${serviced ? SERVICE_PARS : ''}
|
||||||
void main() {
|
void main() {
|
||||||
${FADES}
|
${FADES}
|
||||||
@@ -238,6 +240,8 @@ export function createDistricts(
|
|||||||
// HIGH BEAM: the headlights rake the facades they face.
|
// HIGH BEAM: the headlights rake the facades they face.
|
||||||
vec3 toLamp = normalize(uBeamOrigin - vWorld);
|
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;
|
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);
|
gl_FragColor = vec4(color, 1.0);
|
||||||
#include <tonemapping_fragment>
|
#include <tonemapping_fragment>
|
||||||
#include <colorspace_fragment>
|
#include <colorspace_fragment>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
|||||||
import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
|
import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
|
||||||
import { createTravelDistance } from '@/scene/world/travel-distance';
|
import { createTravelDistance } from '@/scene/world/travel-distance';
|
||||||
import {
|
import {
|
||||||
|
blendDaybreak,
|
||||||
|
engineOffStarScale,
|
||||||
createMutableChapterPalette,
|
createMutableChapterPalette,
|
||||||
STORM_STAR_SCALE,
|
STORM_STAR_SCALE,
|
||||||
STORM_TINT,
|
STORM_TINT,
|
||||||
@@ -34,6 +36,8 @@ import {
|
|||||||
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
|
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
|
||||||
import { createHeadlights } from '@/scene/world/headlights';
|
import { createHeadlights } from '@/scene/world/headlights';
|
||||||
import { maintenanceProgress } from '@/journey/maintenance-progress';
|
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';
|
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||||
|
|
||||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||||
@@ -161,6 +165,10 @@ export async function createProceduralSceneGeneration(
|
|||||||
|| (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)
|
|| (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)
|
||||||
? 'lite'
|
? 'lite'
|
||||||
: 'full';
|
: '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(
|
scene.add(
|
||||||
maintenance.group,
|
maintenance.group,
|
||||||
atmosphere.group,
|
atmosphere.group,
|
||||||
@@ -218,6 +226,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
let interludeOn = false;
|
let interludeOn = false;
|
||||||
let riseUntil = 0;
|
let riseUntil = 0;
|
||||||
let gantryState = '';
|
let gantryState = '';
|
||||||
|
let dawnState: DawnStep | '' = '';
|
||||||
|
|
||||||
const resize = (): void => {
|
const resize = (): void => {
|
||||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
||||||
@@ -342,14 +351,20 @@ export async function createProceduralSceneGeneration(
|
|||||||
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
|
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
|
||||||
}
|
}
|
||||||
districts.setServiceState(service.dim, service.relight);
|
districts.setServiceState(service.dim, service.relight);
|
||||||
// Engine off: parked at the destination the sky drops about 10%, more
|
// END OF SHIFT: parked at the destination, the night ends. One scalar
|
||||||
// stars come out, and the drive winds down to a stop.
|
// 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);
|
const engineOff = THREE.MathUtils.smoothstep(outroProgress, 0.12, 0.7);
|
||||||
if (engineOff > 0) {
|
if (engineOff > 0) {
|
||||||
targetPalette.sky.lerp(ENGINE_OFF_NIGHT, 0.1 * engineOff);
|
targetPalette.sky.lerp(ENGINE_OFF_NIGHT, 0.1 * engineOff);
|
||||||
targetPalette.fog.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);
|
dampPalette(currentPalette, targetPalette, deltaSeconds);
|
||||||
const driveDistance = travelDistance.advance(
|
const driveDistance = travelDistance.advance(
|
||||||
deltaSeconds * (1 - engineOff),
|
deltaSeconds * (1 - engineOff),
|
||||||
@@ -394,7 +409,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
);
|
);
|
||||||
atmosphericTime += deltaSeconds;
|
atmosphericTime += deltaSeconds;
|
||||||
const elapsedSeconds = atmosphericTime;
|
const elapsedSeconds = atmosphericTime;
|
||||||
horizonAir.update(currentPalette, elapsedSeconds);
|
horizonAir.update(currentPalette, elapsedSeconds, dawn);
|
||||||
skyline.update(currentPalette, elapsedSeconds);
|
skyline.update(currentPalette, elapsedSeconds);
|
||||||
traffic.update(
|
traffic.update(
|
||||||
deltaSeconds,
|
deltaSeconds,
|
||||||
@@ -407,6 +422,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
Math.min(1, response.bloomEnergy + beat * 0.35),
|
Math.min(1, response.bloomEnergy + beat * 0.35),
|
||||||
snapshot.arrived,
|
snapshot.arrived,
|
||||||
|
dawn,
|
||||||
);
|
);
|
||||||
car.update(
|
car.update(
|
||||||
visualSpeed,
|
visualSpeed,
|
||||||
@@ -437,6 +453,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
currentPalette,
|
currentPalette,
|
||||||
atmosphericTime,
|
atmosphericTime,
|
||||||
overdrive,
|
overdrive,
|
||||||
|
dawn,
|
||||||
);
|
);
|
||||||
background.copy(currentPalette.sky);
|
background.copy(currentPalette.sky);
|
||||||
scene.fog.color.copy(currentPalette.fog);
|
scene.fog.color.copy(currentPalette.fog);
|
||||||
@@ -644,6 +661,12 @@ export async function createProceduralSceneGeneration(
|
|||||||
riseUntil = 0;
|
riseUntil = 0;
|
||||||
delete document.documentElement.dataset.interlude;
|
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) {
|
if (TEST_BUILD && snapshot.sign !== null) {
|
||||||
const presentation = sign.inspect(camera, width, height);
|
const presentation = sign.inspect(camera, width, height);
|
||||||
if (presentation !== null) {
|
if (presentation !== null) {
|
||||||
@@ -663,13 +686,14 @@ export async function createProceduralSceneGeneration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (postProcessor) {
|
if (postProcessor) {
|
||||||
postProcessor.setDynamics(response.bloomEnergy);
|
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
|
||||||
postProcessor.setLens({
|
postProcessor.setLens({
|
||||||
time: elapsedSeconds,
|
time: elapsedSeconds,
|
||||||
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
|
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
|
||||||
energy: overdrive,
|
energy: overdrive,
|
||||||
rain: service.rain,
|
rain: service.rain,
|
||||||
rainTime: elapsedSeconds,
|
rainTime: elapsedSeconds,
|
||||||
|
dawn,
|
||||||
});
|
});
|
||||||
postProcessor.render(scene, camera);
|
postProcessor.render(scene, camera);
|
||||||
} else {
|
} else {
|
||||||
@@ -683,6 +707,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
delete document.documentElement.dataset.gantry;
|
delete document.documentElement.dataset.gantry;
|
||||||
delete document.documentElement.dataset.interlude;
|
delete document.documentElement.dataset.interlude;
|
||||||
delete document.documentElement.dataset.interludeArmed;
|
delete document.documentElement.dataset.interludeArmed;
|
||||||
|
delete document.documentElement.dataset.dawn;
|
||||||
|
dawnLightUniforms.uDawnLight.value = 0;
|
||||||
// Quality changes reuse the canvas context. Leave texture upload flags at
|
// Quality changes reuse the canvas context. Leave texture upload flags at
|
||||||
// defaults before the next renderer creates its empty array textures.
|
// defaults before the next renderer creates its empty array textures.
|
||||||
const gl = renderer.getContext();
|
const gl = renderer.getContext();
|
||||||
@@ -694,7 +720,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INTRO_SECONDS = 4.2;
|
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_RETURN_SECONDS = 1.6;
|
||||||
const OUTRO_REACH = 0.62;
|
const OUTRO_REACH = 0.62;
|
||||||
const ENGINE_OFF_NIGHT = new THREE.Color(0x000000);
|
const ENGINE_OFF_NIGHT = new THREE.Color(0x000000);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function createHorizonAir(resources: SceneResourceBag) {
|
|||||||
const uniforms = {
|
const uniforms = {
|
||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
uWarmth: { value: 0 },
|
uWarmth: { value: 0 },
|
||||||
|
uDawn: { value: 0 },
|
||||||
uColor: { value: new THREE.Color(0xc5646b) },
|
uColor: { value: new THREE.Color(0xc5646b) },
|
||||||
};
|
};
|
||||||
const material = resources.own(
|
const material = resources.own(
|
||||||
@@ -22,6 +23,7 @@ export function createHorizonAir(resources: SceneResourceBag) {
|
|||||||
fragmentShader: `
|
fragmentShader: `
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
uniform float uWarmth;
|
uniform float uWarmth;
|
||||||
|
uniform float uDawn;
|
||||||
uniform vec3 uColor;
|
uniform vec3 uColor;
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
void main(){
|
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);
|
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);
|
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);
|
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 <tonemapping_fragment>
|
||||||
#include <colorspace_fragment>
|
#include <colorspace_fragment>
|
||||||
}
|
}
|
||||||
@@ -53,8 +55,10 @@ export function createHorizonAir(resources: SceneResourceBag) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
group,
|
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.uTime.value = time;
|
||||||
|
uniforms.uDawn.value = Number.isFinite(dawn) ? Math.min(1, Math.max(0, dawn)) : 0;
|
||||||
uniforms.uWarmth.value = palette.warmth;
|
uniforms.uWarmth.value = palette.warmth;
|
||||||
uniforms.uColor.value.copy(palette.sun);
|
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
|
* 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.
|
* (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 { SceneResourceBag } from '@/scene/resources';
|
||||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||||
|
import { DAWN_PARS, dawnLightUniforms } from '@/scene/world/dawn-light';
|
||||||
import {
|
import {
|
||||||
HORIZON_FOG_END,
|
HORIZON_FOG_END,
|
||||||
HORIZON_FOG_START,
|
HORIZON_FOG_START,
|
||||||
@@ -60,6 +61,7 @@ export function createRoad(
|
|||||||
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
||||||
uBeamYaw: { value: 0 },
|
uBeamYaw: { value: 0 },
|
||||||
uBeamLift: { value: 0 },
|
uBeamLift: { value: 0 },
|
||||||
|
...dawnLightUniforms,
|
||||||
};
|
};
|
||||||
const material = resources.own(
|
const material = resources.own(
|
||||||
'material',
|
'material',
|
||||||
@@ -100,6 +102,7 @@ export function createRoad(
|
|||||||
varying float vViewDistance;
|
varying float vViewDistance;
|
||||||
varying float vWorldZ;
|
varying float vWorldZ;
|
||||||
varying float vNearFade;
|
varying float vNearFade;
|
||||||
|
${DAWN_PARS}
|
||||||
|
|
||||||
float hash21(vec2 point) {
|
float hash21(vec2 point) {
|
||||||
point = fract(point * vec2(123.34, 456.21));
|
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))
|
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));
|
* 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;
|
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.
|
// Headlight throw: two warm cones ahead of the car merging into a pool.
|
||||||
float ahead = -vWorldZ - 5.0;
|
float ahead = -vWorldZ - 5.0;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface SunWorld {
|
|||||||
elapsedSeconds: number,
|
elapsedSeconds: number,
|
||||||
driveEnergy?: number,
|
driveEnergy?: number,
|
||||||
arrived?: boolean,
|
arrived?: boolean,
|
||||||
|
/** END OF SHIFT: 0 night, 1 the sun has cleared the skyline. */
|
||||||
|
dawn?: number,
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
|||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
uDriveEnergy: { value: 0 },
|
uDriveEnergy: { value: 0 },
|
||||||
uArrival: { value: 0 },
|
uArrival: { value: 0 },
|
||||||
|
uDawn: { value: 0 },
|
||||||
};
|
};
|
||||||
const geometry = resources.own(
|
const geometry = resources.own(
|
||||||
'geometry',
|
'geometry',
|
||||||
@@ -64,6 +67,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
|||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
uniform float uDriveEnergy;
|
uniform float uDriveEnergy;
|
||||||
uniform float uArrival;
|
uniform float uArrival;
|
||||||
|
uniform float uDawn;
|
||||||
varying vec2 vSunUv;
|
varying vec2 vSunUv;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -74,10 +78,11 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
|||||||
float softEdge = 1.0 - smoothstep(0.496, 0.5, radius);
|
float softEdge = 1.0 - smoothstep(0.496, 0.5, radius);
|
||||||
float stripes = (0.65 - solar.y) * 17.0;
|
float stripes = (0.65 - solar.y) * 17.0;
|
||||||
float phase = fract(stripes);
|
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 separated = smoothstep(gap, gap + 0.024, phase);
|
||||||
float bandMask = solar.y > 0.65 ? 1.0 : separated;
|
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);
|
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))
|
float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
|
||||||
* sin(solar.y * 31.0 - uTime * 0.032);
|
* 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);
|
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 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));
|
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, 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);
|
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;
|
core *= 0.87 + convection * 0.055 + filament * 0.07;
|
||||||
float limb = pow(smoothstep(0.38, 0.498, radius), 4.0);
|
float limb = pow(smoothstep(0.38, 0.498, radius), 4.0);
|
||||||
@@ -204,8 +210,9 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0, arrived = false) {
|
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0, arrived = false, dawnLevel = 0) {
|
||||||
const progress = clamp01(routeProgress);
|
const progress = clamp01(routeProgress);
|
||||||
|
const dawn = clamp01(dawnLevel);
|
||||||
const energy = clamp01(driveEnergy);
|
const energy = clamp01(driveEnergy);
|
||||||
const time = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
|
const time = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
|
||||||
if (arrived && !wasArrived) arrivalStartedAt = time;
|
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.
|
// Only the rim repeats, using the existing visibility-aware scene clock.
|
||||||
const ringPhase = (Math.max(0, arrivalAge - 0.5) % 3) / 3;
|
const ringPhase = (Math.max(0, arrivalAge - 0.5) % 3) / 3;
|
||||||
const ringPulse = arrived ? Math.sin(Math.PI * ringPhase) ** 2 : 0;
|
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);
|
sunColor.copy(palette.sun).lerp(arrivalColor, arrivalGlow * 0.55);
|
||||||
uniforms.uHazeColor.value.copy(palette.haze);
|
uniforms.uHazeColor.value.copy(palette.haze);
|
||||||
haloMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22)
|
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.uTime.value = time;
|
||||||
uniforms.uDriveEnergy.value = energy;
|
uniforms.uDriveEnergy.value = energy;
|
||||||
uniforms.uArrival.value = arrivalGlow;
|
uniforms.uArrival.value = arrivalGlow;
|
||||||
|
uniforms.uDawn.value = dawn;
|
||||||
limbMaterial.uniforms.uEnergy.value = energy;
|
limbMaterial.uniforms.uEnergy.value = energy;
|
||||||
const ringScale = ringPulse > 0 ? 1.9 : 1;
|
const ringScale = ringPulse > 0 ? 1.9 : 1;
|
||||||
limb.scale.setScalar(1.034 * ringScale);
|
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.uRingRadius.value = 0.49 + ringPhase * 0.36;
|
||||||
limbMaterial.uniforms.uRingOpacity.value = ringPulse;
|
limbMaterial.uniforms.uRingOpacity.value = ringPulse;
|
||||||
limbMaterial.uniforms.uArrival.value = arrivalGlow;
|
limbMaterial.uniforms.uArrival.value = arrivalGlow;
|
||||||
haloMaterial.opacity = 0.095 + palette.warmth * 0.03 + energy * 0.015 + arrivalGlow * 0.08 + arrivalPulse * 0.1;
|
// Daybreak: the halo opens wider but thinner, and the horizon bleed eases
|
||||||
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
|
// off, so the disc keeps an edge against the lightened sky.
|
||||||
halo.scale.set(480 + energy * 18 + arrivalGlow * 40 + arrivalPulse * 76, 360 + energy * 10 + arrivalGlow * 30 + arrivalPulse * 56, 1);
|
haloMaterial.opacity = 0.095 + palette.warmth * 0.03 + energy * 0.015 + arrivalGlow * 0.08 + arrivalPulse * 0.1
|
||||||
disc.position.y = 79 + palette.warmth * 14;
|
- 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;
|
limb.position.y = disc.position.y;
|
||||||
halo.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() {
|
function createHaloTexture() {
|
||||||
const size = 64;
|
const size = 64;
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { BEAM_PARS, beamUniforms } from './headlights';
|
import { BEAM_PARS, beamUniforms } from './headlights';
|
||||||
|
import { DAWN_PARS, dawnLightUniforms } from './dawn-light';
|
||||||
import type { EnvironmentChapter } from '@/journey/types';
|
import type { EnvironmentChapter } from '@/journey/types';
|
||||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
@@ -236,7 +237,7 @@ export function createTerrain(
|
|||||||
const groundMaterial = resources.own(
|
const groundMaterial = resources.own(
|
||||||
'material',
|
'material',
|
||||||
new THREE.ShaderMaterial({
|
new THREE.ShaderMaterial({
|
||||||
uniforms: { ...uniforms, ...beamUniforms },
|
uniforms: { ...uniforms, ...beamUniforms, ...dawnLightUniforms },
|
||||||
vertexShader: material.vertexShader.replace(
|
vertexShader: material.vertexShader.replace(
|
||||||
'displaced.y += height + 0.028;',
|
'displaced.y += height + 0.028;',
|
||||||
'displaced.y += height - 0.12;',
|
'displaced.y += height - 0.12;',
|
||||||
@@ -251,6 +252,7 @@ export function createTerrain(
|
|||||||
varying vec3 vTerrainPosition;
|
varying vec3 vTerrainPosition;
|
||||||
varying vec3 vGridColor;
|
varying vec3 vGridColor;
|
||||||
${BEAM_PARS}
|
${BEAM_PARS}
|
||||||
|
${DAWN_PARS}
|
||||||
void main() {
|
void main() {
|
||||||
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
|
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
|
||||||
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
|
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.
|
// HIGH BEAM: the ridges beside the road catch the headlights.
|
||||||
float beam = headlightBeam(vTerrainPosition);
|
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;
|
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);
|
color = mix(uFogColor * 0.55, color, vDepthFade);
|
||||||
gl_FragColor = vec4(color, 1.0);
|
gl_FragColor = vec4(color, 1.0);
|
||||||
#include <tonemapping_fragment>
|
#include <tonemapping_fragment>
|
||||||
|
|||||||
@@ -216,6 +216,21 @@
|
|||||||
color: var(--amber);
|
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 {
|
.drive-controls {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: var(--z-popover);
|
z-index: var(--z-popover);
|
||||||
@@ -519,8 +534,15 @@
|
|||||||
background: #020108;
|
background: #020108;
|
||||||
content: '';
|
content: '';
|
||||||
transform: scaleY(0);
|
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 {
|
.journey-world::before {
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -552,18 +574,45 @@
|
|||||||
clip-path: inset(0 100% 0 0);
|
clip-path: inset(0 100% 0 0);
|
||||||
opacity: 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);
|
clip-path: inset(0 -1em 0 0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition:
|
transition:
|
||||||
opacity 1ms linear 1.9s,
|
opacity 1ms linear 0.2s,
|
||||||
clip-path 1.5s steps(36, end) 1.9s;
|
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. */
|
/* Reduced motion: the bars and the dimming only; the credit simply shows. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@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[data-arrived='true'] .journey-poster { filter: brightness(0.9); }
|
||||||
.journey-world::after {
|
.journey-world::after {
|
||||||
inset-block-end: 0;
|
inset-block-end: 0;
|
||||||
@@ -586,10 +635,6 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: normal;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.journey-world::before,
|
.journey-world::before,
|
||||||
@@ -606,6 +651,7 @@
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.journey-world[data-arrived='true'] .journey-poster { filter: saturate(0.86) brightness(0.83); }
|
.journey-world[data-arrived='true'] .journey-poster { filter: saturate(0.86) brightness(0.83); }
|
||||||
|
.journey-poster { transition: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -17,6 +17,38 @@ function recordFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('adaptive mixer', () => {
|
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', () => {
|
it('ramps only the four approved mood controls over 1.4 seconds', () => {
|
||||||
const harness = createFakeAudioHarness();
|
const harness = createFakeAudioHarness();
|
||||||
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
const mixer = createAdaptiveMixer(harness.context, harness.destination);
|
||||||
@@ -129,7 +161,8 @@ describe('adaptive mixer', () => {
|
|||||||
mixer.dispose();
|
mixer.dispose();
|
||||||
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) {
|
for (const node of harness.gainNodes) {
|
||||||
expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1);
|
expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1);
|
||||||
}
|
}
|
||||||
|
|||||||
88
tests/unit/dawn-resolve.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
tests/unit/daybreak.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/audio/soundtrack-transport';
|
} from '@/audio/soundtrack-transport';
|
||||||
import type { AdaptiveMixer } from '@/audio/adaptive-mixer';
|
import type { AdaptiveMixer } from '@/audio/adaptive-mixer';
|
||||||
import type { DriveTexture } from '@/audio/drive-texture';
|
import type { DriveTexture } from '@/audio/drive-texture';
|
||||||
|
import type { DawnResolve } from '@/audio/dawn-resolve';
|
||||||
import type {
|
import type {
|
||||||
AudioInitialState,
|
AudioInitialState,
|
||||||
SoundtrackSources,
|
SoundtrackSources,
|
||||||
@@ -41,6 +42,7 @@ interface EngineHarnessOptions {
|
|||||||
readonly rejectDriveLift?: boolean;
|
readonly rejectDriveLift?: boolean;
|
||||||
readonly rejectDriveTexture?: boolean;
|
readonly rejectDriveTexture?: boolean;
|
||||||
readonly deferDriveLift?: boolean;
|
readonly deferDriveLift?: boolean;
|
||||||
|
readonly rejectDawn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Deferred<T> {
|
interface Deferred<T> {
|
||||||
@@ -71,10 +73,12 @@ function createEngineHarness(
|
|||||||
const bedInput = harness.context.createGain();
|
const bedInput = harness.context.createGain();
|
||||||
const driveLiftInput = harness.context.createGain();
|
const driveLiftInput = harness.context.createGain();
|
||||||
const driveTextureInput = harness.context.createGain();
|
const driveTextureInput = harness.context.createGain();
|
||||||
|
const dawnInput = harness.context.createGain();
|
||||||
const mixer = {
|
const mixer = {
|
||||||
bedInput,
|
bedInput,
|
||||||
driveLiftInput,
|
driveLiftInput,
|
||||||
driveTextureInput,
|
driveTextureInput,
|
||||||
|
dawnInput,
|
||||||
setChapterIntent: vi.fn(),
|
setChapterIntent: vi.fn(),
|
||||||
setDriveEnergy: vi.fn(),
|
setDriveEnergy: vi.fn(),
|
||||||
setTransportAnchor: vi.fn(),
|
setTransportAnchor: vi.fn(),
|
||||||
@@ -85,6 +89,14 @@ function createEngineHarness(
|
|||||||
setEnergy: vi.fn(),
|
setEnergy: vi.fn(),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
} satisfies DriveTexture;
|
} 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 createMixer = vi.fn(() => mixer);
|
||||||
const createDriveTexture = vi.fn(() => {
|
const createDriveTexture = vi.fn(() => {
|
||||||
if (options.rejectDriveTexture) throw new Error('drive texture unavailable');
|
if (options.rejectDriveTexture) throw new Error('drive texture unavailable');
|
||||||
@@ -116,6 +128,7 @@ function createEngineHarness(
|
|||||||
loadBuffer,
|
loadBuffer,
|
||||||
createMixer,
|
createMixer,
|
||||||
createDriveTexture,
|
createDriveTexture,
|
||||||
|
createDawnResolve,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sourcesFor = (url: string): FakeSourceRecord[] => {
|
const sourcesFor = (url: string): FakeSourceRecord[] => {
|
||||||
@@ -144,6 +157,8 @@ function createEngineHarness(
|
|||||||
harness,
|
harness,
|
||||||
mixer,
|
mixer,
|
||||||
texture,
|
texture,
|
||||||
|
dawn,
|
||||||
|
createDawnResolve,
|
||||||
loadCalls,
|
loadCalls,
|
||||||
sourcesFor,
|
sourcesFor,
|
||||||
sourceFor,
|
sourceFor,
|
||||||
@@ -348,7 +363,35 @@ describe('soundtrack engine', () => {
|
|||||||
expect(source.disconnectCount).toBe(1);
|
expect(source.disconnectCount).toBe(1);
|
||||||
}
|
}
|
||||||
expect(test.texture.dispose).toHaveBeenCalledOnce();
|
expect(test.texture.dispose).toHaveBeenCalledOnce();
|
||||||
|
expect(test.dawn.dispose).toHaveBeenCalledOnce();
|
||||||
expect(test.mixer.dispose).toHaveBeenCalledOnce();
|
expect(test.mixer.dispose).toHaveBeenCalledOnce();
|
||||||
expect(test.harness.closeCount).toBe(1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { SceneResourceBag } from '@/scene/resources';
|
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';
|
import { createMutableChapterPalette } from '@/scene/world/palette';
|
||||||
|
|
||||||
interface Halo {
|
interface Halo {
|
||||||
@@ -107,6 +107,39 @@ describe('destination sun', () => {
|
|||||||
resources.dispose();
|
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', () => {
|
it('does not pulse from route progress alone and cancels when leaving Contact', () => {
|
||||||
const resources = new SceneResourceBag();
|
const resources = new SceneResourceBag();
|
||||||
const sun = createSun(resources);
|
const sun = createSun(resources);
|
||||||
|
|||||||