feat: scroll-driven maintenance window set-piece in the infrastructure chapter

This commit is contained in:
2026-10-11 04:25:28 +03:00
parent bd9bd692b9
commit 52b4a1ad14
45 changed files with 1063 additions and 10 deletions

View File

@@ -50,6 +50,10 @@ export interface LensState {
letterbox: number;
/** Drive energy: widens aberration and streaks a little. */
energy: number;
/** Windshield rain (the 03:12 window), 0..1. */
rain?: number;
/** Seconds, for the drops' slow slide. */
rainTime?: number;
}
/**
@@ -68,6 +72,8 @@ export class LensFinishPass extends Pass {
uAberration: { value: 0.0035 },
uGrain: { value: 0.03 },
uVignette: { value: 0.42 },
uRain: { value: 0 },
uRainTime: { value: 0 },
toneMappingExposure: { value: 1 },
};
@@ -105,6 +111,8 @@ export class LensFinishPass extends Pass {
uniform float uAberration;
uniform float uGrain;
uniform float uVignette;
uniform float uRain;
uniform float uRainTime;
varying vec2 vUv;
#include <tonemapping_pars_fragment>
#include <colorspace_pars_fragment>
@@ -115,15 +123,58 @@ export class LensFinishPass extends Pass {
return fract( ( p3.x + p3.y ) * p3.z );
}
vec3 rainHash( vec2 p ) {
vec3 q = fract( vec3( p.xyx ) * vec3( 0.1031, 0.1030, 0.0973 ) );
q += dot( q, q.yxz + 33.33 );
return fract( ( q.xxy + q.yzz ) * q.zyx );
}
// Rain on the windshield: one layer of drops on a grid, each with its
// own slow life (fade in, maybe slide, fade out over about 8 s), so
// nothing pops. A drop refracts the frame through itself; a sliding
// drop leaves a smeared trail above it. xy: sample offset, z: trail.
vec3 rainLayer( vec2 uv, float scale, float t, float density ) {
vec2 grid = vec2( uv.x * uAspect, uv.y ) * scale;
vec2 id = floor( grid );
vec2 f = fract( grid ) - 0.5;
vec3 h = rainHash( id + scale );
float present = smoothstep( h.x, h.x + 0.08, density );
if ( present <= 0.0 ) return vec3( 0.0 );
float life = fract( t * ( 0.09 + h.z * 0.05 ) + h.y );
float seen = smoothstep( 0.0, 0.18, life ) * ( 1.0 - smoothstep( 0.72, 1.0, life ) ) * present;
float slides = step( 0.55, h.z );
vec2 center = vec2( ( h.y - 0.5 ) * 0.5, 0.32 - slides * life * 0.62 );
float radius = 0.07 + h.z * 0.09;
vec2 d = ( f - center ) * vec2( 1.0, 0.85 );
float drop = ( 1.0 - smoothstep( radius * 0.55, radius, length( d ) ) ) * seen;
float trail = slides * seen
* ( 1.0 - smoothstep( 0.0, radius * 0.45, abs( f.x - center.x ) ) )
* step( center.y, f.y ) * ( 1.0 - smoothstep( center.y, 0.5, f.y ) );
return vec3( -d * drop * 2.4 / scale, trail * 0.6 );
}
void main() {
vec2 sampleUv = vUv;
float smear = 0.0;
if ( uRain > 0.001 ) {
vec3 near = rainLayer( vUv, 7.0, uRainTime, uRain * 0.75 );
vec3 far = rainLayer( vUv + 0.37, 13.0, uRainTime * 1.3, uRain * 0.6 );
sampleUv += ( near.xy + far.xy * 0.6 ) * vec2( 1.0 / uAspect, 1.0 );
smear = clamp( near.z + far.z * 0.5, 0.0, 1.0 ) * uRain;
}
vec2 centered = vUv - 0.5;
float radius2 = dot( centered * vec2( uAspect, 1.0 ), centered * vec2( uAspect, 1.0 ) );
vec2 shift = centered * radius2 * uAberration;
vec3 color = vec3(
texture2D( tDiffuse, vUv - shift ).r,
texture2D( tDiffuse, vUv ).g,
texture2D( tDiffuse, vUv + shift ).b
texture2D( tDiffuse, sampleUv - shift ).r,
texture2D( tDiffuse, sampleUv ).g,
texture2D( tDiffuse, sampleUv + shift ).b
);
if ( smear > 0.002 ) {
vec3 streaked = ( texture2D( tDiffuse, sampleUv + vec2( 0.0, 0.008 ) ).rgb
+ texture2D( tDiffuse, sampleUv + vec2( 0.0, 0.018 ) ).rgb ) * 0.5;
color = mix( color, streaked, smear * 0.8 );
}
if ( uStreak > 0.0 ) {
vec3 streak = vec3( 0.0 );
for ( int i = 1; i <= 7; i++ ) {
@@ -161,6 +212,8 @@ export class LensFinishPass extends Pass {
this.uniforms.uTime.value = state.time;
this.uniforms.uLetterbox.value = THREE.MathUtils.clamp(state.letterbox, 0, 0.2);
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;
if (this.uniforms.tStreak.value) {
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
}

View File

@@ -105,9 +105,11 @@ const VERTEX = `
varying float vDepth;
varying float vRise;
varying float vHeight;
varying float vStagger;
void main() {
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
float stagger = fract(sin(dot(origin.xz, vec2(12.9898, 78.233))) * 43758.5453);
vStagger = stagger;
float rise = clamp(uRise * 1.45 - stagger * 0.45, 0.0, 1.0);
rise = rise * rise * (3.0 - 2.0 * rise);
vRise = rise;
@@ -128,6 +130,32 @@ const FADES = `
if (fade < 0.02) discard;
`;
/** Pieces the 03:12 window powers down and re-lights (the Infrastructure district). */
const SERVICED: ReadonlySet<DistrictPiece> = new Set(['rack-row', 'mast']);
/** The infrastructure accent the service re-lights in. */
export const SERVICE_ACCENT = 0x8be9ed;
/* The 03:12 window. Each piece (a rack row, a mast) powers down to about 10%
on its own staggered beat, then re-lights lamp row by lamp row, bottom up,
in the infrastructure accent. Scroll-driven, so it never flickers. */
const SERVICE_PARS = `
uniform float uServiceDim;
uniform float uServiceRelight;
uniform float uPieceHeight;
uniform vec3 uServiceColor;
varying float vStagger;
float serviceRowDim() {
return clamp(uServiceDim * 1.6 - vStagger * 0.6, 0.0, 1.0);
}
float serviceRelit() {
float rows = 7.0;
float row = floor(clamp(vHeight / max(0.001, uPieceHeight), 0.0, 0.999) * rows);
float front = uServiceRelight * 1.3 - vStagger * 0.3;
float at = (row + 0.5) / rows;
return smoothstep(at - 0.05, at + 0.05, front);
}
`;
export interface DistrictsWorld {
group: { name: string; add(...objects: unknown[]): void };
update(
@@ -139,6 +167,8 @@ export interface DistrictsWorld {
): void;
/** Pieces currently raised (for tests and diagnostics). */
raised(): DistrictPiece[];
/** The 03:12 window: power-down (0..1) and re-light (0..1). Uniform writes only. */
setServiceState(dim: number, relight: number): void;
}
export function createDistricts(
@@ -154,6 +184,11 @@ export function createDistricts(
uTime: { value: 0 },
uAccent: { value: new THREE.Color(signAccentFor('start')) },
};
const service = {
uServiceDim: { value: 0 },
uServiceRelight: { value: 0 },
uServiceColor: { value: new THREE.Color(SERVICE_ACCENT) },
};
const targetAccent = new THREE.Color(signAccentFor('start'));
const state = new Map<
DistrictPiece,
@@ -162,11 +197,11 @@ export function createDistricts(
const pieces = Object.keys(DISTRICT_PRESENCE) as DistrictPiece[];
for (const piece of pieces) state.set(piece, { rise: { value: 0 }, meshes: [] });
const bodyMaterial = (rise: { value: number }) =>
const bodyMaterial = (rise: { value: number }, serviced: boolean, height: { value: number }) =>
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...shared, uRise: rise },
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
vertexShader: VERTEX,
fragmentShader: `
uniform vec3 uAccent;
@@ -174,6 +209,7 @@ export function createDistricts(
varying float vDepth;
varying float vRise;
varying float vHeight;
${serviced ? SERVICE_PARS : ''}
void main() {
${FADES}
// Opaque dark steel, like the sign truss: fades dissolve through a
@@ -191,7 +227,12 @@ export function createDistricts(
// the tops of the pieces carry a thin lit band.
float rim = pow(1.0 - abs(dot(n, normalize(cameraPosition - vWorld))), 4.0);
float crown = smoothstep(0.92, 1.0, n.y) * smoothstep(1.0, 6.0, vHeight);
color += uAccent * (rim * 0.22 + crown * 0.05);
${serviced ? `
float dimmed = serviceRowDim();
float relit = serviceRelit();
float power = mix(1.0, mix(0.1, 1.0, relit), dimmed);
color += mix(uAccent, uServiceColor, dimmed * relit) * (rim * 0.22 + crown * 0.05) * power;` : `
color += uAccent * (rim * 0.22 + crown * 0.05);`}
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
@@ -202,11 +243,11 @@ export function createDistricts(
side: THREE.DoubleSide,
}),
);
const lightMaterial = (rise: { value: number }, accentMix: number) =>
const lightMaterial = (rise: { value: number }, accentMix: number, serviced: boolean, height: { value: number }) =>
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...shared, uRise: rise },
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
vertexShader: VERTEX,
fragmentShader: `
uniform vec3 uAccent;
@@ -215,6 +256,8 @@ export function createDistricts(
varying vec3 vWorld;
varying float vDepth;
varying float vRise;
varying float vHeight;
${serviced ? SERVICE_PARS : ''}
void main() {
${FADES}
// Neon tubes warm up as the district finishes rising. A smooth
@@ -226,6 +269,11 @@ export function createDistricts(
// Close passes dim, so overhead neon never floods the bloom pass.
float near = mix(0.3, 1.0, smoothstep(5.0, 32.0, vDepth));
vec3 color = tone * (1.1 + uEnergy * 0.4) * near;
${serviced ? `
float dimmed = serviceRowDim();
float relit = serviceRelit();
color = mix(color, uServiceColor * (1.1 + uEnergy * 0.4) * near, dimmed * relit * 0.85);
color *= mix(1.0, mix(0.1, 1.0, relit), dimmed);` : ''}
gl_FragColor = vec4(color * on * fade, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
@@ -247,9 +295,16 @@ export function createDistricts(
if (!entry) return;
const placements = placementsFor(piece, quality.postEffects);
const accentMix = piece === 'arch' ? 1 : 0.35;
const serviced = SERVICED.has(piece);
const height = { value: 1 };
if (serviced) {
const bounds = (body as Geometry & { computeBoundingBox?(): void; boundingBox?: { max: { y: number } } | null });
bounds.computeBoundingBox?.();
height.value = Math.max(0.5, bounds.boundingBox?.max.y ?? 1);
}
for (const [geometry, material] of [
[body, bodyMaterial(entry.rise)],
[light, lightMaterial(entry.rise, accentMix)],
[body, bodyMaterial(entry.rise, serviced, height)],
[light, lightMaterial(entry.rise, accentMix, serviced, height)],
] as const) {
resources.own('geometry', geometry);
const mesh = resources.own(
@@ -318,5 +373,9 @@ export function createDistricts(
},
raised: () =>
pieces.filter((piece) => (DISTRICT_PRESENCE[piece][current] ?? 0) > 0),
setServiceState(dim, relight) {
service.uServiceDim.value = Number.isFinite(dim) ? Math.min(1, Math.max(0, dim)) : 0;
service.uServiceRelight.value = Number.isFinite(relight) ? Math.min(1, Math.max(0, relight)) : 0;
},
};
}

View File

@@ -26,9 +26,13 @@ import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
import { createTravelDistance } from '@/scene/world/travel-distance';
import {
createMutableChapterPalette,
STORM_STAR_SCALE,
STORM_TINT,
writeChapterPalette,
type MutableChapterPalette,
} from '@/scene/world/palette';
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
@@ -141,7 +145,16 @@ export async function createProceduralSceneGeneration(
maximumTextureSize: renderer.capabilities.maxTextureSize,
});
const atmosphere = createAtmosphere(context.resources, quality);
// The 03:12 window. Phones, coarse pointers and the low tier keep only the
// power-down, the re-light and the palette nudge: no rain, packets or lightning.
const maintenance = createMaintenanceWindow(context.resources);
const maintenanceTier =
context.quality === 'low'
|| (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)
? 'lite'
: 'full';
scene.add(
maintenance.group,
atmosphere.group,
road.group,
expressway.group,
@@ -307,6 +320,19 @@ export async function createProceduralSceneGeneration(
terrainSpectrumPhase =
(terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1;
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
// The 03:12 window: section-local scroll progress drives every beat.
const service = maintenance.update(
maintenanceProgress(snapshot),
snapshot.direction,
deltaSeconds,
maintenanceTier,
);
if (service.storm > 0) {
targetPalette.sky.lerp(STORM_SKY, 0.55 * service.storm);
targetPalette.fog.lerp(STORM_SKY, 0.4 * service.storm);
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.
const engineOff = THREE.MathUtils.smoothstep(outroProgress, 0.12, 0.7);
@@ -388,6 +414,13 @@ export async function createProceduralSceneGeneration(
);
sign.setResting(snapshot.activeSectionId);
sign.update(snapshot.sign, now, snapshot.driveSpeed);
// Distant lightning lifts the sky a little, toward a still-dark lilac.
if (service.flash > 0) {
flashRestore.sky.copy(currentPalette.sky);
flashRestore.fog.copy(currentPalette.fog);
currentPalette.sky.lerp(LIGHTNING_SKY, service.flash * 0.7);
currentPalette.fog.lerp(LIGHTNING_SKY, service.flash * 0.3);
}
atmosphere.update(
driveDistance,
visualSpeed,
@@ -402,6 +435,10 @@ export async function createProceduralSceneGeneration(
.copy(currentPalette.haze)
.lerp(currentPalette.terrainHigh, 0.26);
ambient.groundColor.copy(currentPalette.fog);
if (service.flash > 0) {
currentPalette.sky.copy(flashRestore.sky);
currentPalette.fog.copy(flashRestore.fog);
}
ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
carKeyLight.color.setHex(0xb2d5ff);
carKeyLight.intensity = 32 + response.bloomEnergy * 3.6 + overdrive * 8;
@@ -620,6 +657,8 @@ export async function createProceduralSceneGeneration(
time: elapsedSeconds,
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
energy: overdrive,
rain: service.rain,
rainTime: elapsedSeconds,
});
postProcessor.render(scene, camera);
} else {
@@ -648,6 +687,9 @@ const OUTRO_SECONDS = 5.5;
const OUTRO_RETURN_SECONDS = 1.6;
const OUTRO_REACH = 0.62;
const ENGINE_OFF_NIGHT = new THREE.Color(0x000000);
const STORM_SKY = new THREE.Color(STORM_TINT);
const LIGHTNING_SKY = new THREE.Color(0x5a5ea8);
const flashRestore = { sky: new THREE.Color(), fog: new THREE.Color() };
/** Plates rise back over 720 ms with up to three 90 ms staggers. */
const INTERLUDE_RISE_MS = 800;

View File

@@ -0,0 +1,351 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-progress';
/**
* THE 03:12 WINDOW, the Infrastructure chapter's set-piece. A maintenance
* window opens over the city: one district powers down lamp by lamp, amber
* backup packets stream to an offsite silhouette on the far right, rain
* starts on the windshield, then the service re-lights in the infrastructure
* accent. Everything is a pure function of section-local scroll progress, so
* scrolling back up replays it in reverse. Only the distant lightning keeps
* its own (bounded) clock.
*
* Photosensitivity (round 5): lightning is at most one soft pulse per 3.2 s
* (0.31 Hz, never two within 2.8 s), its peak is capped and it lights only a
* distant patch of sky. Nothing here blinks.
*/
export interface MaintenanceFrame {
step: MaintenanceStep;
/** District emissive power-down, 0..1 (rows stagger in the shader). */
dim: number;
/** Bottom-up re-light in the infrastructure accent, 0..1. */
relight: number;
/** Windshield rain, 0..0.7. */
rain: number;
/** 0..1 outbound backup packets, 1..2 the verified return trip. */
packetPhase: number;
/** How lit the offsite silhouette is, 0..1. */
offsite: number;
/** Storm palette nudge (cold navy, fewer stars), 0..1. */
storm: number;
/** Whether distant lightning may strike, 0..1. */
lightning: number;
}
export const MAINTENANCE_RAIN_PEAK = 0.7;
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
const smooth = (from: number, to: number, value: number): number => {
const t = clamp01((value - from) / (to - from));
return t * t * (3 - 2 * t);
};
/** The window as a pure function of section-local progress p (0..1). */
export function maintenanceTimeline(p: number): MaintenanceFrame {
const x = Number.isFinite(p) ? clamp01(p) : 0;
return {
step: maintenanceStep(x),
dim: smooth(0.1, 0.35, x),
relight: smooth(0.35, 0.65, x),
rain: MAINTENANCE_RAIN_PEAK * smooth(0.1, 0.3, x) * (1 - smooth(0.7, 0.9, x)),
packetPhase: clamp01((x - 0.12) / 0.21) + clamp01((x - 0.67) / 0.21),
offsite: smooth(0.14, 0.36, x),
storm: smooth(0, 0.06, x) * (1 - smooth(0.68, 0.92, x)),
lightning: smooth(0, 0.03, x) * (1 - smooth(0.62, 0.7, x)),
};
}
export const LIGHTNING = Object.freeze({
/** Seconds between strikes. 1 / 3.2 s = 0.31 Hz. */
minGap: 3.2,
maxGap: 7.6,
/** A strike never lands this soon after the window opens. */
settle: 1.4,
rise: 0.07,
decay: 0.5,
/** Peak flash, as a fraction of the (already dim) flash colour. */
peak: 0.6,
});
/** Distant lightning: one soft pulse at a time, seeded so tests are exact. */
export function createLightning(seed = 0x0312) {
let state = seed >>> 0 || 1;
const random = (): number => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
let clock = 0;
let lastStrike = -Infinity;
let next: number = LIGHTNING.settle;
return {
sample(dt: number, enable: number): number {
clock += Math.max(0, Number.isFinite(dt) ? dt : 0);
if (enable < 0.5) next = Math.max(next, clock + LIGHTNING.settle);
else if (clock >= next && clock - lastStrike >= LIGHTNING.minGap) {
lastStrike = clock;
next = clock + LIGHTNING.minGap + random() * (LIGHTNING.maxGap - LIGHTNING.minGap);
}
const age = clock - lastStrike;
const envelope = age < LIGHTNING.rise
? age / LIGHTNING.rise
: 1 - smooth(LIGHTNING.rise, LIGHTNING.rise + LIGHTNING.decay, age);
return LIGHTNING.peak * envelope * clamp01(enable);
},
};
}
export type MaintenanceTier = 'full' | 'lite';
export interface MaintenanceState extends MaintenanceFrame {
/** 1 while the Infrastructure chapter is current, easing to 0 after. */
presence: number;
/** Current lightning flash, 0..LIGHTNING.peak. */
flash: number;
reversing: boolean;
}
/** Far right of the infrastructure framing, on the horizon. */
export const OFFSITE = Object.freeze({ x: 250, y: 0, z: -540 });
const PACKET_COUNT = 24;
export function createMaintenanceWindow(resources: SceneResourceBag) {
const group = new THREE.Group();
group.name = 'maintenance-window';
// ---- Offsite: a low campus silhouette whose windows light as copies land.
const offsiteUniforms = { uLit: { value: 0 }, uPresence: { value: 0 }, uFlash: { value: 0 } };
const boxGeometry = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
boxGeometry.translate(0, 0.5, 0);
const offsiteMaterial = resources.own('material', new THREE.ShaderMaterial({
uniforms: offsiteUniforms,
vertexShader: /* glsl */ `
varying vec3 vWorld;
varying vec3 vNormal2;
void main() {
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
vWorld = world.xyz;
vNormal2 = normal;
gl_Position = projectionMatrix * viewMatrix * world;
}`,
fragmentShader: /* glsl */ `
uniform float uLit;
uniform float uPresence;
uniform float uFlash;
varying vec3 vWorld;
varying vec3 vNormal2;
float hash(vec2 p) { return fract(sin(dot(p, vec2(41.3, 289.1))) * 15731.743); }
void main() {
vec3 color = vec3(0.016, 0.014, 0.032) + vec3(0.02, 0.022, 0.05) * max(0.0, vNormal2.y);
// Window rows on the faces that look back at the road.
vec2 grid = vec2(vWorld.x * 0.42 + vWorld.z * 0.42, vWorld.y * 0.5);
vec2 cell = floor(grid);
vec2 f = fract(grid);
float pane = step(0.25, f.x) * step(f.x, 0.75) * step(0.3, f.y) * step(f.y, 0.72)
* (1.0 - step(0.5, abs(vNormal2.y)));
float order = hash(cell);
float lit = smoothstep(order - 0.05, order + 0.05, uLit * 1.05) * uPresence;
vec3 amber = vec3(1.0, 0.62, 0.26);
vec3 accent = vec3(0.545, 0.914, 0.929);
color += mix(amber, accent, step(0.7, hash(cell * 1.7))) * pane * lit * 1.3;
// Lightning behind it only rims the roofs.
color += vec3(0.32, 0.36, 0.62) * uFlash * smoothstep(0.6, 1.0, vNormal2.y);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
fog: false,
}));
const campus = [
[0, 0, 44, 20, 24],
[-15, 5, 10, 46, 10],
[11, -6, 12, 34, 12],
[28, 8, 16, 14, 16],
[-32, -3, 14, 11, 14],
] as const;
const offsite = resources.own('other', new THREE.InstancedMesh(boxGeometry, offsiteMaterial, campus.length));
offsite.name = 'maintenance-offsite';
const dummy = new THREE.Object3D();
campus.forEach(([dx, dz, w, h, d], index) => {
dummy.position.set(OFFSITE.x + dx, OFFSITE.y, OFFSITE.z + dz);
dummy.rotation.set(0, -0.35, 0);
dummy.scale.set(w, h, d);
dummy.updateMatrix();
offsite.setMatrixAt(index, dummy.matrix);
});
offsite.instanceMatrix.needsUpdate = true;
offsite.frustumCulled = false;
offsite.visible = false;
// ---- Distant lightning: a soft patch of sky behind the offsite.
const glowUniforms = { uFlash: { value: 0 } };
const glow = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(420, 200)),
resources.own('material', new THREE.ShaderMaterial({
uniforms: glowUniforms,
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: /* glsl */ `
uniform float uFlash;
varying vec2 vUv;
void main() {
vec2 c = (vUv - vec2(0.55, 0.42)) * vec2(1.0, 1.9);
float cloud = exp(-dot(c, c) * 7.0);
gl_FragColor = vec4(vec3(0.42, 0.46, 0.9) * cloud * uFlash, 1.0);
#include <colorspace_fragment>
}`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false,
})),
);
glow.name = 'maintenance-lightning';
glow.position.set(OFFSITE.x - 20, 70, OFFSITE.z - 80);
glow.frustumCulled = false;
glow.visible = false;
// ---- Backup packets: 24 amber streaks, the flight lives in the shader.
const packetUniforms = {
uOut: { value: 0 },
uBack: { value: 0 },
uPresence: { value: 0 },
uTarget: { value: new THREE.Vector3(OFFSITE.x, OFFSITE.y + 30, OFFSITE.z) },
};
const packetGeometry = resources.own('geometry', new THREE.InstancedBufferGeometry());
packetGeometry.setAttribute('position', new THREE.Float32BufferAttribute([0, -1, 0, 1, -1, 0, 1, 1, 0, 0, 1, 0], 3));
packetGeometry.setIndex([0, 1, 2, 0, 2, 3]);
const sources: number[] = [];
const seeds: number[] = [];
for (let i = 0; i < PACKET_COUNT; i += 1) {
const side = i % 2 ? 1 : -1;
const row = Math.floor(i / 2);
sources.push(side * (14.6 + (row % 3) * 2.3), 3 + ((i * 7) % 5), -48 - row * 11);
seeds.push(((i * 0.618) % 1), 34 + ((i * 13) % 7) * 6, 0.32 + ((i * 5) % 3) * 0.08);
}
packetGeometry.setAttribute('aSource', new THREE.InstancedBufferAttribute(new Float32Array(sources), 3));
packetGeometry.setAttribute('aSeed', new THREE.InstancedBufferAttribute(new Float32Array(seeds), 3));
packetGeometry.instanceCount = PACKET_COUNT;
const packets = new THREE.Mesh(packetGeometry, resources.own('material', new THREE.ShaderMaterial({
uniforms: packetUniforms,
vertexShader: /* glsl */ `
uniform float uOut;
uniform float uBack;
uniform float uPresence;
uniform vec3 uTarget;
attribute vec3 aSource;
attribute vec3 aSeed;
varying vec2 vUv;
varying float vAlpha;
varying float vReturn;
float travel(float phase) { return clamp(phase * 1.6 - aSeed.x * 0.6, 0.0, 1.0); }
vec3 along(float e) {
float s = e * e * (3.0 - 2.0 * e);
vec3 p = mix(aSource, uTarget, s);
p.y += sin(3.14159 * s) * aSeed.y;
return p;
}
void main() {
float returning = step(0.0001, uBack);
float o = travel(uOut);
float b = travel(uBack);
float local = mix(o, b, returning);
float e = mix(o, 1.0 - b, returning);
vAlpha = smoothstep(0.0, 0.05, local) * (1.0 - smoothstep(0.88, 1.0, local)) * uPresence;
vReturn = returning;
vUv = position.xy;
vec4 head = modelViewMatrix * vec4(along(e), 1.0);
vec4 tail = modelViewMatrix * vec4(along(clamp(e - mix(0.04, -0.04, returning), 0.0, 1.0)), 1.0);
vec2 dir = head.xy - tail.xy;
float len = length(dir);
dir = len > 0.0001 ? dir / len : vec2(1.0, 0.0);
float width = aSeed.z * max(1.0, -head.z * 0.0045);
vec4 v = mix(tail, head, position.x);
v.xy += vec2(-dir.y, dir.x) * position.y * width + dir * (position.x - 0.5) * width * 2.0;
gl_Position = vAlpha < 0.001 ? vec4(2.0, 2.0, 2.0, 1.0) : projectionMatrix * v;
}`,
fragmentShader: /* glsl */ `
varying vec2 vUv;
varying float vAlpha;
varying float vReturn;
void main() {
float across = abs(vUv.y);
float body = (1.0 - smoothstep(0.0, 1.0, across)) * pow(vUv.x, 1.6);
float head = 1.0 - smoothstep(0.0, 1.0, length(vec2((vUv.x - 0.9) * 5.0, across)));
float alpha = max(body * 0.7, head) * vAlpha;
if (alpha < 0.004) discard;
vec3 tone = mix(vec3(1.0, 0.56, 0.16), vec3(0.545, 0.914, 0.929), vReturn);
gl_FragColor = vec4(tone * 1.9 * alpha, alpha);
#include <colorspace_fragment>
}`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
toneMapped: false,
})));
packets.name = 'maintenance-packets';
packets.frustumCulled = false;
packets.visible = false;
group.add(glow, offsite, packets);
const lightning = createLightning();
let smoothed = 0;
let presence = 0;
let reversing = false;
let previous: number | null = null;
// One hidden-but-drawn frame compiles the programs before the chapter.
let warm = true;
return {
group,
update(p: number | null, direction: -1 | 0 | 1, dt: number, tier: MaintenanceTier): MaintenanceState {
const step = Math.max(0, Math.min(0.1, Number.isFinite(dt) ? dt : 0));
const inWindow = p !== null && Number.isFinite(p);
if (inWindow) {
if (presence < 0.02) smoothed = p;
smoothed += (p - smoothed) * (1 - Math.exp(-step / 0.16));
if (previous !== null && Math.abs(p - previous) > 0.0001) reversing = p < previous;
else if (direction !== 0) reversing = direction < 0;
previous = p;
} else {
previous = null;
reversing = false;
}
presence += ((inWindow ? 1 : 0) - presence) * (1 - Math.exp(-step / 0.5));
if (presence < 0.002 && !inWindow) presence = 0;
const frame = maintenanceTimeline(smoothed);
const full = tier === 'full';
const flash = full ? lightning.sample(step, frame.lightning * presence) : 0;
const live = presence > 0;
offsite.visible = full && (warm || live);
glow.visible = full && (warm || flash > 0.0005);
packets.visible = full && (warm || (live && frame.packetPhase > 0 && frame.packetPhase < 2));
warm = false;
offsiteUniforms.uLit.value = frame.offsite;
offsiteUniforms.uPresence.value = presence;
offsiteUniforms.uFlash.value = flash;
glowUniforms.uFlash.value = flash;
packetUniforms.uOut.value = Math.min(1, frame.packetPhase);
packetUniforms.uBack.value = Math.max(0, frame.packetPhase - 1);
packetUniforms.uPresence.value = presence;
return {
...frame,
dim: frame.dim * presence,
rain: full ? frame.rain * presence : 0,
storm: frame.storm * presence,
lightning: full ? frame.lightning * presence : 0,
presence,
flash,
reversing,
};
},
};
}

View File

@@ -111,6 +111,14 @@ export const CHAPTER_PALETTES: Readonly<Record<EnvironmentChapter, ChapterPalett
},
};
/**
* 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.
*/
export const STORM_TINT = 0x060c26;
/** Star visibility while the storm is in. */
export const STORM_STAR_SCALE = 0.4;
const paletteColors = Object.fromEntries(
Object.entries(CHAPTER_PALETTES).map(([chapter, palette]) => [chapter, {
sky: new THREE.Color(palette.sky),