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 #include }`, 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 }`, 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 }`, 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, }; }, }; }