feat: daybreak finale turns the contact arrival into a sunrise with a dawn chord
This commit is contained in:
@@ -54,6 +54,8 @@ export interface LensState {
|
||||
rain?: number;
|
||||
/** Seconds, for the drops' slow slide. */
|
||||
rainTime?: number;
|
||||
/** END OF SHIFT daybreak, 0..1: lifted blacks and a warmer white balance. */
|
||||
dawn?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +76,7 @@ export class LensFinishPass extends Pass {
|
||||
uVignette: { value: 0.42 },
|
||||
uRain: { value: 0 },
|
||||
uRainTime: { value: 0 },
|
||||
uDawn: { value: 0 },
|
||||
toneMappingExposure: { value: 1 },
|
||||
};
|
||||
|
||||
@@ -113,6 +116,7 @@ export class LensFinishPass extends Pass {
|
||||
uniform float uVignette;
|
||||
uniform float uRain;
|
||||
uniform float uRainTime;
|
||||
uniform float uDawn;
|
||||
varying vec2 vUv;
|
||||
#include <tonemapping_pars_fragment>
|
||||
#include <colorspace_pars_fragment>
|
||||
@@ -188,6 +192,9 @@ export class LensFinishPass extends Pass {
|
||||
}
|
||||
color = CustomToneMapping( color );
|
||||
color = sRGBTransferOETF( vec4( color, 1.0 ) ).rgb;
|
||||
// Daybreak: blacks lift toward a warm grey, white balance warms.
|
||||
color = color * ( 1.0 - 0.03 * uDawn ) + vec3( 0.03, 0.026, 0.024 ) * uDawn;
|
||||
color *= vec3( 1.0 + 0.04 * uDawn, 1.0, 1.0 - 0.04 * uDawn );
|
||||
float vignette = smoothstep( 0.18, 1.05, length( centered * vec2( 1.0, 0.86 ) ) * 1.42 );
|
||||
color *= 1.0 - uVignette * vignette;
|
||||
color += ( grainHash( gl_FragCoord.xy + fract( uTime * 7.31 ) * 311.0 ) - 0.5 ) * uGrain;
|
||||
@@ -214,6 +221,7 @@ export class LensFinishPass extends Pass {
|
||||
this.uniforms.uAberration.value = 0.0035 + energy * 0.006;
|
||||
this.uniforms.uRain.value = THREE.MathUtils.clamp(state.rain ?? 0, 0, 1);
|
||||
this.uniforms.uRainTime.value = state.rainTime ?? state.time;
|
||||
this.uniforms.uDawn.value = THREE.MathUtils.clamp(state.dawn ?? 0, 0, 1);
|
||||
if (this.uniforms.tStreak.value) {
|
||||
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
|
||||
export interface PostProcessor extends GpuRenderTargetReporter {
|
||||
resize(width: number, height: number, pixelRatio: number): void;
|
||||
setDynamics(acceleration: number): void;
|
||||
/** dawn (END OF SHIFT, 0..1) lowers the bloom threshold up to 10%. */
|
||||
setDynamics(acceleration: number, dawn?: number): void;
|
||||
setLens(state: LensState): void;
|
||||
render(scene: unknown, camera: unknown): void;
|
||||
dispose(): void;
|
||||
@@ -127,12 +128,13 @@ export function createPostProcessor(
|
||||
Math.max(1, Math.floor(finiteOr(height, 1))),
|
||||
);
|
||||
},
|
||||
setDynamics(acceleration) {
|
||||
setDynamics(acceleration, dawn = 0) {
|
||||
if (disposed) return;
|
||||
const energy = clamp(acceleration, 0, 1);
|
||||
const daybreak = clamp(dawn, 0, 1);
|
||||
bloomPass.strength = BLOOM_REST.strength + energy * 0.16;
|
||||
bloomPass.radius = BLOOM_REST.radius + energy * 0.06;
|
||||
bloomPass.threshold = BLOOM_REST.threshold - energy * 0.05;
|
||||
bloomPass.threshold = (BLOOM_REST.threshold - energy * 0.05) * (1 - 0.1 * daybreak);
|
||||
},
|
||||
setLens(state) {
|
||||
if (disposed) return;
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
|
||||
geometry.setAttribute('aFlight', new THREE.Float32BufferAttribute(timing, 2));
|
||||
geometry.setAttribute('aTravel', new THREE.Float32BufferAttribute(travel, 2));
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: { uTime: { value: -1 } },
|
||||
uniforms: { uTime: { value: -1 }, uFade: { value: 1 } },
|
||||
vertexShader: `
|
||||
uniform float uTime;
|
||||
attribute vec2 aFlight;
|
||||
@@ -51,6 +51,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform float uFade;
|
||||
varying vec2 vUv;
|
||||
varying float vLife;
|
||||
void main() {
|
||||
@@ -60,7 +61,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
|
||||
float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper;
|
||||
float head = (1.0 - smoothstep(0.0, 1.0,
|
||||
length(vec2((vUv.x - 0.965) * 30.0, across))));
|
||||
float alpha = vLife * max(glow * 0.8, head);
|
||||
float alpha = vLife * max(glow * 0.8, head) * uFade;
|
||||
if (alpha < 0.005) discard;
|
||||
vec3 light = mix(vec3(0.22, 0.62, 1.0), vec3(1.0, 0.94, 0.8), max(core, head));
|
||||
gl_FragColor = vec4(light * 1.8, alpha);
|
||||
@@ -80,12 +81,13 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
|
||||
mesh.frustumCulled = false;
|
||||
mesh.visible = false;
|
||||
let warmed = false;
|
||||
return { mesh, update(age: number, active: boolean) {
|
||||
return { mesh, update(age: number, active: boolean, fade = 1) {
|
||||
// One invisible startup draw warms the exact live pipeline (including
|
||||
// its post-processing color space), so arrival never compiles a variant.
|
||||
const playing = active && age >= 0 && age < 4;
|
||||
mesh.visible = playing || !warmed;
|
||||
if (mesh.visible) material.uniforms.uTime.value = playing ? age : -1;
|
||||
material.uniforms.uFade.value = Math.min(1, Math.max(0, fade));
|
||||
warmed = true;
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface AtmosphereWorld {
|
||||
palette: Readonly<MutableChapterPalette>,
|
||||
elapsedSeconds?: number,
|
||||
overdrive?: number,
|
||||
/** END OF SHIFT: 0 night, 1 daybreak. */
|
||||
dawn?: number,
|
||||
): void;
|
||||
}
|
||||
|
||||
@@ -57,6 +59,7 @@ export function createAtmosphere(
|
||||
uAspect: { value: 1 },
|
||||
uTime: { value: 0 },
|
||||
uOverdrive: { value: 0 },
|
||||
uDawn: { value: 0 },
|
||||
},
|
||||
vertexShader: `
|
||||
uniform vec3 uSkyColor;
|
||||
@@ -66,6 +69,7 @@ export function createAtmosphere(
|
||||
uniform float uAspect;
|
||||
uniform float uTime;
|
||||
uniform float uOverdrive;
|
||||
uniform float uDawn;
|
||||
varying vec3 vSkyColor;
|
||||
void main() {
|
||||
float verticalSky = smoothstep(0.08, 0.66, uv.y);
|
||||
@@ -91,6 +95,17 @@ export function createAtmosphere(
|
||||
vec3 aurora = mix(violet, rose, 0.5 + 0.5 * sin(uv.x * 4.2 + uTime * 0.018));
|
||||
color += aurora * veil * (0.10 + side * 0.11) * (1.0 + uOverdrive * 0.36);
|
||||
color += vec3(0.018, 0.075, 0.29) * innerVeil * side * 0.12;
|
||||
// END OF SHIFT: a warm band climbs off the horizon with the dawn, up
|
||||
// to 0.35 of the frame, dusty rose above pale amber; the veils fade.
|
||||
if (uDawn > 0.0) {
|
||||
float reach = 0.47 + 0.35 * uDawn;
|
||||
float band = 1.0 - smoothstep(0.4, reach, uv.y);
|
||||
band *= smoothstep(0.3, 0.44, uv.y);
|
||||
vec3 warm = mix(vec3(1.0, 0.72, 0.46), vec3(0.78, 0.45, 0.52), smoothstep(0.47, reach, uv.y));
|
||||
color = mix(color, warm * (0.78 + centerGlow * 0.22), band * uDawn * 0.62);
|
||||
color -= aurora * veil * (0.10 + side * 0.11) * uDawn;
|
||||
color = max(color, vec3(0.0));
|
||||
}
|
||||
float topFalloff = smoothstep(0.54, 1.0, uv.y);
|
||||
float vignette = smoothstep(0.36, 1.18, normalizedX);
|
||||
color *= 1.0 - topFalloff * 0.28 - vignette * 0.08;
|
||||
@@ -275,6 +290,7 @@ export function createAtmosphere(
|
||||
palette,
|
||||
elapsedSeconds = 0,
|
||||
overdrive = 0,
|
||||
dawn = 0,
|
||||
) {
|
||||
const safeAcceleration = clamp01(acceleration);
|
||||
const visibility =
|
||||
@@ -293,6 +309,7 @@ export function createAtmosphere(
|
||||
skyMaterial.uniforms.uWarmth.value = palette.warmth;
|
||||
skyMaterial.uniforms.uTime.value = finiteOrZero(elapsedSeconds);
|
||||
skyMaterial.uniforms.uOverdrive.value = clamp01(overdrive);
|
||||
skyMaterial.uniforms.uDawn.value = clamp01(dawn);
|
||||
for (let index = 0; index < silhouetteMaterials.length; index += 1) {
|
||||
silhouetteMaterials[index]?.color
|
||||
.copy(palette.fog)
|
||||
|
||||
27
src/scene/world/dawn-light.ts
Normal file
27
src/scene/world/dawn-light.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* END OF SHIFT: first light. A fixed low sun straight down the road throws a
|
||||
* warm rim on the terrain, the district facades and the asphalt as the dawn
|
||||
* rises. Shared uniform (one set for every generation), like the headlight
|
||||
* beam: the environment writes one float per frame, and 0 skips the term.
|
||||
*/
|
||||
export const dawnLightUniforms = {
|
||||
uDawnLight: { value: 0 },
|
||||
};
|
||||
|
||||
/** Toward the sun: low over the horizon, down the road (-z). */
|
||||
const SUN_DIRECTION = new THREE.Vector3(0, 0.2, -1).normalize();
|
||||
|
||||
/** GLSL: dawnRim(normal, toCamera) -> warm light to add (0 when uDawnLight is 0). */
|
||||
export const DAWN_PARS = /* glsl */ `
|
||||
uniform float uDawnLight;
|
||||
vec3 dawnRim(vec3 n, vec3 toCamera) {
|
||||
if (uDawnLight <= 0.0) return vec3(0.0);
|
||||
vec3 sunDir = vec3(${SUN_DIRECTION.x.toFixed(4)}, ${SUN_DIRECTION.y.toFixed(4)}, ${SUN_DIRECTION.z.toFixed(4)});
|
||||
float facing = abs(dot(n, sunDir));
|
||||
float rim = pow(1.0 - abs(dot(n, toCamera)), 2.0);
|
||||
float against = max(0.0, dot(-toCamera, sunDir));
|
||||
return vec3(1.0, 0.6, 0.36) * uDawnLight * (facing * 0.09 + rim * against * 0.24);
|
||||
}
|
||||
`;
|
||||
@@ -11,6 +11,7 @@ import type { SectionId } from '@/content/types';
|
||||
import { signAccentFor } from './sign';
|
||||
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
|
||||
import { BEAM_PARS, beamUniforms } from './headlights';
|
||||
import { DAWN_PARS, dawnLightUniforms } from './dawn-light';
|
||||
|
||||
/** Roadside districts: each chapter raises its own landscape out of the ground
|
||||
* (server rows for Infrastructure, a workshop and cranes for Builds, a container
|
||||
@@ -202,7 +203,7 @@ export function createDistricts(
|
||||
resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: { ...shared, ...service, ...beamUniforms, uRise: rise, uPieceHeight: height },
|
||||
uniforms: { ...shared, ...service, ...beamUniforms, ...dawnLightUniforms, uRise: rise, uPieceHeight: height },
|
||||
vertexShader: VERTEX,
|
||||
fragmentShader: `
|
||||
uniform vec3 uAccent;
|
||||
@@ -211,6 +212,7 @@ export function createDistricts(
|
||||
varying float vRise;
|
||||
varying float vHeight;
|
||||
${BEAM_PARS}
|
||||
${DAWN_PARS}
|
||||
${serviced ? SERVICE_PARS : ''}
|
||||
void main() {
|
||||
${FADES}
|
||||
@@ -238,6 +240,8 @@ export function createDistricts(
|
||||
// HIGH BEAM: the headlights rake the facades they face.
|
||||
vec3 toLamp = normalize(uBeamOrigin - vWorld);
|
||||
color += vec3(1.0, 0.84, 0.66) * headlightBeam(vWorld) * (0.25 + 0.75 * abs(dot(n, toLamp))) * 0.35;
|
||||
// END OF SHIFT: the facades catch first light from the low sun ahead.
|
||||
color += dawnRim(n, normalize(cameraPosition - vWorld)) * 1.4;
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
|
||||
@@ -25,6 +25,8 @@ import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
||||
import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
|
||||
import { createTravelDistance } from '@/scene/world/travel-distance';
|
||||
import {
|
||||
blendDaybreak,
|
||||
engineOffStarScale,
|
||||
createMutableChapterPalette,
|
||||
STORM_STAR_SCALE,
|
||||
STORM_TINT,
|
||||
@@ -34,6 +36,8 @@ import {
|
||||
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
|
||||
import { createHeadlights } from '@/scene/world/headlights';
|
||||
import { maintenanceProgress } from '@/journey/maintenance-progress';
|
||||
import { dawnStep, type DawnStep } from '@/journey/dawn';
|
||||
import { dawnLightUniforms } from '@/scene/world/dawn-light';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
@@ -161,6 +165,10 @@ export async function createProceduralSceneGeneration(
|
||||
|| (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)
|
||||
? 'lite'
|
||||
: 'full';
|
||||
// END OF SHIFT: the low tier and touch keep the palette, the sun lift and
|
||||
// the star drain (uniform writes); the warm rims and the bloom change are
|
||||
// high/medium fine-pointer only.
|
||||
const dawnRims = maintenanceTier === 'full';
|
||||
scene.add(
|
||||
maintenance.group,
|
||||
atmosphere.group,
|
||||
@@ -218,6 +226,7 @@ export async function createProceduralSceneGeneration(
|
||||
let interludeOn = false;
|
||||
let riseUntil = 0;
|
||||
let gantryState = '';
|
||||
let dawnState: DawnStep | '' = '';
|
||||
|
||||
const resize = (): void => {
|
||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
||||
@@ -342,14 +351,20 @@ export async function createProceduralSceneGeneration(
|
||||
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
|
||||
}
|
||||
districts.setServiceState(service.dim, service.relight);
|
||||
// Engine off: parked at the destination the sky drops about 10%, more
|
||||
// stars come out, and the drive winds down to a stop.
|
||||
// END OF SHIFT: parked at the destination, the night ends. One scalar
|
||||
// drives it all; leaving rewinds it as fast as the outro returns.
|
||||
const dawn = easeInOutCubic(outroProgress);
|
||||
blendDaybreak(targetPalette, THREE.MathUtils.smoothstep(dawn, 0.15, 0.95));
|
||||
// Engine off: parked at the destination the sky drops about 10%, the
|
||||
// stars come out (then drain as the dawn rises), and the drive winds
|
||||
// down to a stop.
|
||||
const engineOff = THREE.MathUtils.smoothstep(outroProgress, 0.12, 0.7);
|
||||
if (engineOff > 0) {
|
||||
targetPalette.sky.lerp(ENGINE_OFF_NIGHT, 0.1 * engineOff);
|
||||
targetPalette.fog.lerp(ENGINE_OFF_NIGHT, 0.1 * engineOff);
|
||||
targetPalette.starVisibility *= 1 + 0.8 * engineOff;
|
||||
targetPalette.starVisibility *= engineOffStarScale(engineOff, dawn);
|
||||
}
|
||||
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
|
||||
dampPalette(currentPalette, targetPalette, deltaSeconds);
|
||||
const driveDistance = travelDistance.advance(
|
||||
deltaSeconds * (1 - engineOff),
|
||||
@@ -394,7 +409,7 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
atmosphericTime += deltaSeconds;
|
||||
const elapsedSeconds = atmosphericTime;
|
||||
horizonAir.update(currentPalette, elapsedSeconds);
|
||||
horizonAir.update(currentPalette, elapsedSeconds, dawn);
|
||||
skyline.update(currentPalette, elapsedSeconds);
|
||||
traffic.update(
|
||||
deltaSeconds,
|
||||
@@ -407,6 +422,7 @@ export async function createProceduralSceneGeneration(
|
||||
elapsedSeconds,
|
||||
Math.min(1, response.bloomEnergy + beat * 0.35),
|
||||
snapshot.arrived,
|
||||
dawn,
|
||||
);
|
||||
car.update(
|
||||
visualSpeed,
|
||||
@@ -437,6 +453,7 @@ export async function createProceduralSceneGeneration(
|
||||
currentPalette,
|
||||
atmosphericTime,
|
||||
overdrive,
|
||||
dawn,
|
||||
);
|
||||
background.copy(currentPalette.sky);
|
||||
scene.fog.color.copy(currentPalette.fog);
|
||||
@@ -644,6 +661,12 @@ export async function createProceduralSceneGeneration(
|
||||
riseUntil = 0;
|
||||
delete document.documentElement.dataset.interlude;
|
||||
}
|
||||
// END OF SHIFT: CSS keys the credit and the letterbox tint off this.
|
||||
const nextDawnState = dawnStep(dawn);
|
||||
if (nextDawnState !== dawnState) {
|
||||
dawnState = nextDawnState;
|
||||
document.documentElement.dataset.dawn = nextDawnState;
|
||||
}
|
||||
if (TEST_BUILD && snapshot.sign !== null) {
|
||||
const presentation = sign.inspect(camera, width, height);
|
||||
if (presentation !== null) {
|
||||
@@ -663,13 +686,14 @@ export async function createProceduralSceneGeneration(
|
||||
}
|
||||
}
|
||||
if (postProcessor) {
|
||||
postProcessor.setDynamics(response.bloomEnergy);
|
||||
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
|
||||
postProcessor.setLens({
|
||||
time: elapsedSeconds,
|
||||
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
|
||||
energy: overdrive,
|
||||
rain: service.rain,
|
||||
rainTime: elapsedSeconds,
|
||||
dawn,
|
||||
});
|
||||
postProcessor.render(scene, camera);
|
||||
} else {
|
||||
@@ -683,6 +707,8 @@ export async function createProceduralSceneGeneration(
|
||||
delete document.documentElement.dataset.gantry;
|
||||
delete document.documentElement.dataset.interlude;
|
||||
delete document.documentElement.dataset.interludeArmed;
|
||||
delete document.documentElement.dataset.dawn;
|
||||
dawnLightUniforms.uDawnLight.value = 0;
|
||||
// Quality changes reuse the canvas context. Leave texture upload flags at
|
||||
// defaults before the next renderer creates its empty array textures.
|
||||
const gl = renderer.getContext();
|
||||
@@ -694,7 +720,8 @@ export async function createProceduralSceneGeneration(
|
||||
}
|
||||
|
||||
const INTRO_SECONDS = 4.2;
|
||||
const OUTRO_SECONDS = 5.5;
|
||||
/** Long enough for the dawn to read: the sun clears the skyline near 3.8 s. */
|
||||
const OUTRO_SECONDS = 7.0;
|
||||
const OUTRO_RETURN_SECONDS = 1.6;
|
||||
const OUTRO_REACH = 0.62;
|
||||
const ENGINE_OFF_NIGHT = new THREE.Color(0x000000);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function createHorizonAir(resources: SceneResourceBag) {
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uWarmth: { value: 0 },
|
||||
uDawn: { value: 0 },
|
||||
uColor: { value: new THREE.Color(0xc5646b) },
|
||||
};
|
||||
const material = resources.own(
|
||||
@@ -22,6 +23,7 @@ export function createHorizonAir(resources: SceneResourceBag) {
|
||||
fragmentShader: `
|
||||
uniform float uTime;
|
||||
uniform float uWarmth;
|
||||
uniform float uDawn;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
void main(){
|
||||
@@ -32,7 +34,7 @@ export function createHorizonAir(resources: SceneResourceBag) {
|
||||
float colorWave=0.5+0.5*sin(vUv.x*7.0+uTime*0.035);
|
||||
vec3 color=mix(uColor,vec3(0.27,0.045,0.75),colorWave*0.55);
|
||||
color=mix(color,vec3(0.05,0.52,0.75),smoothstep(0.54,0.82,vUv.y)*0.3);
|
||||
gl_FragColor=vec4(color,(ribbon*0.16+thin*0.032)*horizontal*(0.7+uWarmth*0.3));
|
||||
gl_FragColor=vec4(color,(ribbon*0.16+thin*0.032)*horizontal*(0.7+uWarmth*0.3)*(1.0+uDawn));
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
@@ -53,8 +55,10 @@ export function createHorizonAir(resources: SceneResourceBag) {
|
||||
}
|
||||
return {
|
||||
group,
|
||||
update(palette: Readonly<MutableChapterPalette>, time: number) {
|
||||
/** dawn: END OF SHIFT, the air masses catch first light (gain up to 2x). */
|
||||
update(palette: Readonly<MutableChapterPalette>, time: number, dawn = 0) {
|
||||
uniforms.uTime.value = time;
|
||||
uniforms.uDawn.value = Number.isFinite(dawn) ? Math.min(1, Math.max(0, dawn)) : 0;
|
||||
uniforms.uWarmth.value = palette.warmth;
|
||||
uniforms.uColor.value.copy(palette.sun);
|
||||
},
|
||||
|
||||
@@ -111,6 +111,60 @@ export const CHAPTER_PALETTES: Readonly<Record<EnvironmentChapter, ChapterPalett
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* END OF SHIFT: the destination's sky at daybreak. Not a journey chapter: the
|
||||
* environment blends the destination toward it as the outro's dawn rises.
|
||||
*/
|
||||
export const DAYBREAK_PALETTE: Readonly<ChapterPaletteDefinition> = {
|
||||
sky: 0x6b4a7a,
|
||||
fog: 0xb07a8c,
|
||||
terrainLow: 0xffb98a,
|
||||
terrainHigh: 0xffe1c2,
|
||||
sun: 0xffd49a,
|
||||
haze: 0xffb07a,
|
||||
warmth: 1.25,
|
||||
terrainEnergy: 0.4,
|
||||
starVisibility: 0,
|
||||
streakEnergy: 0,
|
||||
};
|
||||
|
||||
const daybreakColors = {
|
||||
sky: new THREE.Color(DAYBREAK_PALETTE.sky),
|
||||
fog: new THREE.Color(DAYBREAK_PALETTE.fog),
|
||||
terrainLow: new THREE.Color(DAYBREAK_PALETTE.terrainLow),
|
||||
terrainHigh: new THREE.Color(DAYBREAK_PALETTE.terrainHigh),
|
||||
sun: new THREE.Color(DAYBREAK_PALETTE.sun),
|
||||
haze: new THREE.Color(DAYBREAK_PALETTE.haze),
|
||||
};
|
||||
|
||||
/** Leans a written palette toward daybreak (0 untouched, 1 full daybreak). */
|
||||
export function blendDaybreak(
|
||||
target: MutableChapterPalette,
|
||||
amount: number,
|
||||
): MutableChapterPalette {
|
||||
const mix = clamp01(amount);
|
||||
if (mix === 0) return target;
|
||||
target.sky.lerp(daybreakColors.sky, mix);
|
||||
target.fog.lerp(daybreakColors.fog, mix);
|
||||
target.terrainLow.lerp(daybreakColors.terrainLow, mix);
|
||||
target.terrainHigh.lerp(daybreakColors.terrainHigh, mix);
|
||||
target.sun.lerp(daybreakColors.sun, mix);
|
||||
target.haze.lerp(daybreakColors.haze, mix);
|
||||
target.warmth = lerp(target.warmth, DAYBREAK_PALETTE.warmth, mix);
|
||||
target.terrainEnergy = lerp(target.terrainEnergy, DAYBREAK_PALETTE.terrainEnergy, mix);
|
||||
target.starVisibility = lerp(target.starVisibility, DAYBREAK_PALETTE.starVisibility, mix);
|
||||
target.streakEnergy = lerp(target.streakEnergy, DAYBREAK_PALETTE.streakEnergy, mix);
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Star visibility multiplier while the engine is off: the stars brighten as
|
||||
* the engine cuts (up to 1.8x), then drain back as the dawn rises.
|
||||
*/
|
||||
export function engineOffStarScale(engineOff: number, dawn: number): number {
|
||||
return 1 + 0.8 * clamp01(engineOff) * (1 - clamp01(dawn));
|
||||
}
|
||||
|
||||
/**
|
||||
* The 03:12 window's storm: a colder navy the Infrastructure sky leans toward
|
||||
* (about 55%) while the maintenance window is open. A tint, not a chapter.
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
import { DAWN_PARS, dawnLightUniforms } from '@/scene/world/dawn-light';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
@@ -60,6 +61,7 @@ export function createRoad(
|
||||
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
||||
uBeamYaw: { value: 0 },
|
||||
uBeamLift: { value: 0 },
|
||||
...dawnLightUniforms,
|
||||
};
|
||||
const material = resources.own(
|
||||
'material',
|
||||
@@ -100,6 +102,7 @@ export function createRoad(
|
||||
varying float vViewDistance;
|
||||
varying float vWorldZ;
|
||||
varying float vNearFade;
|
||||
${DAWN_PARS}
|
||||
|
||||
float hash21(vec2 point) {
|
||||
point = fract(point * vec2(123.34, 456.21));
|
||||
@@ -187,6 +190,12 @@ export function createRoad(
|
||||
float glare = exp(-pow(vRoadX / (1.1 + vViewDistance * 0.02), 2.0))
|
||||
* smoothstep(50.0, 220.0, vViewDistance) * (1.0 - smoothstep(360.0, 560.0, vViewDistance));
|
||||
color += vec3(1.0, 0.32, 0.22) * glare * pow(film, 2.0) * asphaltMask * 0.24;
|
||||
// END OF SHIFT: first light grazes the wet asphalt from the low sun
|
||||
// ahead, a warm sheen that strengthens toward the horizon.
|
||||
if (uDawnLight > 0.0) {
|
||||
color += vec3(1.0, 0.6, 0.36) * uDawnLight * asphaltMask * film
|
||||
* (0.025 + grazing * 0.06 + glare * 0.32);
|
||||
}
|
||||
|
||||
// Headlight throw: two warm cones ahead of the car merging into a pool.
|
||||
float ahead = -vWorldZ - 5.0;
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface SunWorld {
|
||||
elapsedSeconds: number,
|
||||
driveEnergy?: number,
|
||||
arrived?: boolean,
|
||||
/** END OF SHIFT: 0 night, 1 the sun has cleared the skyline. */
|
||||
dawn?: number,
|
||||
): void;
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
uTime: { value: 0 },
|
||||
uDriveEnergy: { value: 0 },
|
||||
uArrival: { value: 0 },
|
||||
uDawn: { value: 0 },
|
||||
};
|
||||
const geometry = resources.own(
|
||||
'geometry',
|
||||
@@ -64,6 +67,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
uniform float uTime;
|
||||
uniform float uDriveEnergy;
|
||||
uniform float uArrival;
|
||||
uniform float uDawn;
|
||||
varying vec2 vSunUv;
|
||||
|
||||
void main() {
|
||||
@@ -74,10 +78,11 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
float softEdge = 1.0 - smoothstep(0.496, 0.5, radius);
|
||||
float stripes = (0.65 - solar.y) * 17.0;
|
||||
float phase = fract(stripes);
|
||||
float gap = mix(0.12, 0.61, clamp(stripes / 11.0, 0.0, 1.0));
|
||||
// Daybreak closes the scan bands: the retro striped sun becomes a disc.
|
||||
float gap = mix(0.12, 0.61, clamp(stripes / 11.0, 0.0, 1.0)) * (1.0 - 0.7 * uDawn);
|
||||
float separated = smoothstep(gap, gap + 0.024, phase);
|
||||
float bandMask = solar.y > 0.65 ? 1.0 : separated;
|
||||
float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65);
|
||||
float bandMask = solar.y > 0.65 ? 1.0 : mix(separated, 1.0, smoothstep(0.75, 1.0, uDawn));
|
||||
float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65) * (1.0 - uDawn);
|
||||
float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
|
||||
* sin(solar.y * 31.0 - uTime * 0.032);
|
||||
vec3 vermilion = mix(vec3(1.0, 0.03, 0.4), vec3(1.0, 0.24, 0.12), uArrival);
|
||||
@@ -85,6 +90,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
vec3 crown = mix(vec3(1.0, 0.8, 0.12), vec3(1.0, 0.9, 0.6), uArrival);
|
||||
vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y));
|
||||
core = mix(core, crown, smoothstep(0.47, 1.0, solar.y));
|
||||
core = mix(core, mix(uColor, crown, 0.4), uDawn * 0.55);
|
||||
float filament = pow(abs(sin(solar.x * 39.0 + solar.y * 25.0 + convection * 2.4)), 16.0);
|
||||
core *= 0.87 + convection * 0.055 + filament * 0.07;
|
||||
float limb = pow(smoothstep(0.38, 0.498, radius), 4.0);
|
||||
@@ -204,8 +210,9 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
|
||||
return {
|
||||
group,
|
||||
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0, arrived = false) {
|
||||
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0, arrived = false, dawnLevel = 0) {
|
||||
const progress = clamp01(routeProgress);
|
||||
const dawn = clamp01(dawnLevel);
|
||||
const energy = clamp01(driveEnergy);
|
||||
const time = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
|
||||
if (arrived && !wasArrived) arrivalStartedAt = time;
|
||||
@@ -220,7 +227,8 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
// Only the rim repeats, using the existing visibility-aware scene clock.
|
||||
const ringPhase = (Math.max(0, arrivalAge - 0.5) % 3) / 3;
|
||||
const ringPulse = arrived ? Math.sin(Math.PI * ringPhase) ** 2 : 0;
|
||||
meteors.update(arrivalAge, arrived);
|
||||
// The meteors belong to the night: they fade out once dawn passes 0.7.
|
||||
meteors.update(arrivalAge, arrived, 1 - THREE.MathUtils.smoothstep(dawn, 0.7, 0.85));
|
||||
sunColor.copy(palette.sun).lerp(arrivalColor, arrivalGlow * 0.55);
|
||||
uniforms.uHazeColor.value.copy(palette.haze);
|
||||
haloMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22)
|
||||
@@ -232,6 +240,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
uniforms.uTime.value = time;
|
||||
uniforms.uDriveEnergy.value = energy;
|
||||
uniforms.uArrival.value = arrivalGlow;
|
||||
uniforms.uDawn.value = dawn;
|
||||
limbMaterial.uniforms.uEnergy.value = energy;
|
||||
const ringScale = ringPulse > 0 ? 1.9 : 1;
|
||||
limb.scale.setScalar(1.034 * ringScale);
|
||||
@@ -239,17 +248,29 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
limbMaterial.uniforms.uRingRadius.value = 0.49 + ringPhase * 0.36;
|
||||
limbMaterial.uniforms.uRingOpacity.value = ringPulse;
|
||||
limbMaterial.uniforms.uArrival.value = arrivalGlow;
|
||||
haloMaterial.opacity = 0.095 + palette.warmth * 0.03 + energy * 0.015 + arrivalGlow * 0.08 + arrivalPulse * 0.1;
|
||||
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
|
||||
halo.scale.set(480 + energy * 18 + arrivalGlow * 40 + arrivalPulse * 76, 360 + energy * 10 + arrivalGlow * 30 + arrivalPulse * 56, 1);
|
||||
disc.position.y = 79 + palette.warmth * 14;
|
||||
// Daybreak: the halo opens wider but thinner, and the horizon bleed eases
|
||||
// off, so the disc keeps an edge against the lightened sky.
|
||||
haloMaterial.opacity = 0.095 + palette.warmth * 0.03 + energy * 0.015 + arrivalGlow * 0.08 + arrivalPulse * 0.1
|
||||
- dawn * 0.04;
|
||||
bleedMaterial.opacity = (0.055 + palette.warmth * 0.03 + energy * 0.01) * (1 - dawn * 0.35);
|
||||
const haloGrowth = 1 + dawn * 0.18;
|
||||
halo.scale.set(
|
||||
(480 + energy * 18 + arrivalGlow * 40 + arrivalPulse * 76) * haloGrowth,
|
||||
(360 + energy * 10 + arrivalGlow * 30 + arrivalPulse * 56) * haloGrowth,
|
||||
1,
|
||||
);
|
||||
// The disc lifts 58 units with the dawn and clears the skyline near 0.6.
|
||||
disc.position.y = 79 + palette.warmth * 14 + dawn * DAWN_LIFT;
|
||||
limb.position.y = disc.position.y;
|
||||
halo.position.y = disc.position.y;
|
||||
bleed.position.y = 7 + palette.warmth * 3;
|
||||
bleed.position.y = 7 + palette.warmth * 3 + dawn * DAWN_LIFT * 0.3;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** How far the disc lifts at full dawn, in world units. */
|
||||
export const DAWN_LIFT = 58;
|
||||
|
||||
function createHaloTexture() {
|
||||
const size = 64;
|
||||
if (typeof document !== 'undefined') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as THREE from 'three';
|
||||
import { BEAM_PARS, beamUniforms } from './headlights';
|
||||
import { DAWN_PARS, dawnLightUniforms } from './dawn-light';
|
||||
import type { EnvironmentChapter } from '@/journey/types';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
@@ -236,7 +237,7 @@ export function createTerrain(
|
||||
const groundMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: { ...uniforms, ...beamUniforms },
|
||||
uniforms: { ...uniforms, ...beamUniforms, ...dawnLightUniforms },
|
||||
vertexShader: material.vertexShader.replace(
|
||||
'displaced.y += height + 0.028;',
|
||||
'displaced.y += height - 0.12;',
|
||||
@@ -251,6 +252,7 @@ export function createTerrain(
|
||||
varying vec3 vTerrainPosition;
|
||||
varying vec3 vGridColor;
|
||||
${BEAM_PARS}
|
||||
${DAWN_PARS}
|
||||
void main() {
|
||||
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
|
||||
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
|
||||
@@ -259,6 +261,8 @@ export function createTerrain(
|
||||
// HIGH BEAM: the ridges beside the road catch the headlights.
|
||||
float beam = headlightBeam(vTerrainPosition);
|
||||
color += vec3(1.0, 0.84, 0.66) * beam * (0.3 + 0.7 * abs(dot(normal, normalize(uBeamOrigin - vTerrainPosition)))) * 0.28;
|
||||
// END OF SHIFT: the ridges catch first light from the low sun ahead.
|
||||
color += dawnRim(normal, normalize(cameraPosition - vTerrainPosition));
|
||||
color = mix(uFogColor * 0.55, color, vDepthFade);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
|
||||
Reference in New Issue
Block a user