352 lines
14 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
};
|
|
}
|