Files
Portfolio/src/scene/world/maintenance-window.ts

352 lines
14 KiB
TypeScript

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(clamp(vUv.x, 0.0, 1.0), 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,
};
},
};
}