import * as THREE from 'three'; import type { SectionId } from '@/content/types'; import { SIGN_ACCENTS } from '@/journey/sign-routes'; import { CRANE_FROM_Z, CRANE_TO_Z, craneZ, createLamp, floodLight, floodWarmup, plateLit, spreaderDrop, stepLamp, STACK_TIERS, STACK_ZS, } from '@/journey/yard-progress'; import type { SceneResourceBag } from '@/scene/resources'; import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition'; type ThreeBufferGeometry = InstanceType; type ThreeGroup = InstanceType; type ThreeInstancedMesh = InstanceType; type ThreeInstancedBufferAttribute = InstanceType; /** * NIGHT FREIGHT, scene side. A container yard on the free left verge of the * Additional work chapter (the reading panel covers 15-95% of the width): four * stacks (four, three, two and one container, far to near) on a raised apron * along a pair of crane rails, two high-mast floods, and a rail-mounted gantry * crane that rides the chapter. The rails are a straight siding that runs from * the camera's pivot into the distance at the angle that keeps the whole yard * at the same screen x for the viewport aspect (RAIL_NDC: the middle of the * left margin), so every stop, plate and the crane stay inside the margin at * any aspect, and the stacks step up with distance so none hides the next. Placements are pure * functions of section-local progress (journey/yard-progress.ts). At each stop * the spreader lowers onto the stack, the top container's end plate lights in * the chapter accent and the two work lamps strike. The floods warm up from * sodium orange to blue-white HID over 1.5 s at chapter entry and cool on the * way out. Structure stays under the 0.85 bloom threshold (round 9); only the * lamps bloom, modestly. The crane fades out (screen door) while an underpass * deck is within reach, so it never crosses a deck or its piers. * Lite (low tier, touch): crane, stacks and plates; no floods, pools, lamps or pulse. * * The world is drawn ~2.3x life size: a 20 ft box is 14 x 5.6 x 6 m here. */ const LENS = CHAPTER_COMPOSITIONS['additional-work']; export const DESIGN_ASPECT = 1.6; /** Screen x of the rail centre line (NDC): the middle of the 15% left margin, clear of its edge at -0.7. */ const RAIL_NDC = -0.86; /** The yard floor (apron top): a quay above the verge, over most of the terrain swell. */ const YARD_Y = 5; const BOX_L = 14; const BOX_W = 5.6; const BOX_H = 6; /** Portal: legs at +- SPAN/2 around the rail line, girders at PORTAL_H, legs +- LEG_Z along the rails. */ const SPAN = 12; const PORTAL_H = 29; const LEG_Z = 5.5; const SPREADER_UP = PORTAL_H - 3; const FLOOD_X = -8.5; const FLOOD_ZS = [-124, -88] as const; const FLOOD_H = 36; const APRON = { halfWidth: SPAN / 2 + 5, depth: 12, from: CRANE_FROM_Z - 10, to: CRANE_TO_Z + 12 } as const; /** * The rail line's yaw for a viewport aspect: the angle whose projection lands * at RAIL_NDC once the chapter's lens shift (setViewOffset) is applied. */ export function railYaw(aspect: number = DESIGN_ASPECT): number { const tanHalfHeight = Math.tan(THREE.MathUtils.degToRad(LENS.fov / 2)); const portrait = aspect < 1.18; const raw = portrait ? -0.9 : RAIL_NDC - 2 * LENS.shift; return Math.atan(-raw * tanHalfHeight * aspect); } /** The crane hides (screen door) while a deck is this close along the road (m). */ const DECK_CLEAR = { from: 16, to: 34 } as const; const PULSE_GAIN = 0.08; const ORANGE = new THREE.Color(1.0, 0.42, 0.12); const HID = new THREE.Color(0.72, 0.84, 1.0); const TINTS = [[0.42, 0.13, 0.08], [0.08, 0.26, 0.3], [0.3, 0.31, 0.34], [0.44, 0.3, 0.08]] as const; type Box = readonly [w: number, h: number, d: number, x: number, y: number, z: number, part?: number]; /** Boxes merged into one indexed geometry, with a part id per vertex. */ function boxes(list: readonly Box[]): ThreeBufferGeometry { const positions: number[] = []; const normals: number[] = []; const parts: number[] = []; const indices: number[] = []; for (const [w, h, d, x, y, z, part = 0] of list) { const piece = new THREE.BoxGeometry(w, h, d).translate(x, y, z); const offset = positions.length / 3; const position = piece.getAttribute('position'); positions.push(...position.array); normals.push(...piece.getAttribute('normal').array); for (let i = 0; i < position.count; i += 1) parts.push(part); indices.push(...Array.from(piece.getIndex()!.array as ArrayLike, (i) => i + offset)); piece.dispose(); } const merged = new THREE.BufferGeometry(); merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1)); merged.setIndex(indices); return merged; } const legs = (y: number, h: number, t: number): Box[] => [-1, 1].flatMap((sx) => [-1, 1].map((sz) => [t, h, t, (sx * SPAN) / 2, y, sz * LEG_Z] as Box)); /** Portal frame around the rail line (x 0): legs, sills, girders, end ties, cab. */ const portalBoxes = (): Box[] => [ ...legs(PORTAL_H / 2, PORTAL_H, 1.3), ...[-1, 1].map((sx) => [1.6, 1.4, LEG_Z * 2 + 3, (sx * SPAN) / 2, 1.2, 0] as Box), ...[-1, 1].map((sx) => [1.3, 1.3, LEG_Z * 2, (sx * SPAN) / 2, PORTAL_H - 4, 0] as Box), ...[-1, 1].map((sz) => [SPAN + 3, 2, 1.5, 0, PORTAL_H, sz * LEG_Z] as Box), ...[-1, 1].map((sx) => [1.5, 2, LEG_Z * 2 + 1.5, (sx * SPAN) / 2, PORTAL_H, 0] as Box), [3, 3, 3.4, -SPAN / 2 + 2.3, PORTAL_H - 5, LEG_Z + 1.2], ]; const SCREEN_DOOR = /* glsl */ ` vec2 cell = mod(floor(gl_FragCoord.xy), 4.0); if (presence < (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0) discard; `; /** Dark steel and containers, lit by a key, the floods (uFlood) and, for plates, the accent. */ function solid(uniforms: Record, instanced: boolean) { return new THREE.ShaderMaterial({ uniforms, vertexShader: /* glsl */ ` attribute float aPart; ${instanced ? 'attribute vec2 aState;' : ''} varying float vPart; varying vec2 vState; varying vec3 vNormal; varying vec3 vWorld; void main() { mat4 model = modelMatrix${instanced ? ' * instanceMatrix' : ''}; vec4 world = model * vec4(position, 1.0); vWorld = world.xyz; vNormal = normalize(mat3(model) * normal); vPart = aPart; vState = ${instanced ? 'aState' : 'vec2(0.0)'}; gl_Position = projectionMatrix * viewMatrix * world; } `, fragmentShader: /* glsl */ ` uniform float uPresence; uniform vec3 uFlood; uniform vec3 uAccent; uniform float uPulse; varying float vPart; varying vec2 vState; varying vec3 vNormal; varying vec3 vWorld; void main() { float presence = uPresence; ${SCREEN_DOOR} vec3 n = normalize(vNormal); float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35)))); float floodSide = max(0.0, dot(n, normalize(vec3(-0.6, 0.6, 0.5)))); vec3 light = vec3(0.1, 0.1, 0.13) + vec3(0.16, 0.15, 0.2) * key + uFlood * (0.25 + 0.55 * floodSide); vec3 color; ${instanced ? /* glsl */ ` int tintIndex = int(vState.y + 0.5); vec3 tint = tintIndex == 0 ? vec3(${TINTS[0].join(', ')}) : tintIndex == 1 ? vec3(${TINTS[1].join(', ')}) : tintIndex == 2 ? vec3(${TINTS[2].join(', ')}) : vec3(${TINTS[3].join(', ')}); float rib = 0.82 + 0.18 * step(0.5, fract((abs(n.x) > 0.5 ? vWorld.z : vWorld.x) * 0.9)); color = tint * light * rib * 1.6; if (vPart > 0.5) { // The end plate: a door at rest, the chapter accent once the spreader has set down (< 0.85). color = mix(color * 0.7, uAccent * (0.2 + 0.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse)), vState.x); }` : /* glsl */ ` color = vec3(0.16, 0.17, 0.2) * light * 1.5 + uAccent * 0.025; if (vPart > 0.5) color = vec3(0.55, 0.42, 0.1) * light * 1.8; if (vPart > 1.5) { // The apron: poured concrete with a painted bay line every 14 m. float bay = step(0.985, fract(vWorld.z * 0.0714)) * step(0.5, n.y); color = vec3(0.09, 0.095, 0.12) * light * 1.4 + uAccent * (0.02 + 0.12 * bay); }`} gl_FragColor = vec4(min(color, vec3(0.82)), 1.0); #include #include } `, fog: false, }); } /** Additive lights: camera-facing glows (billboard) or ground pools; aGlow = rgb, level. */ function additive(billboard: boolean) { return new THREE.ShaderMaterial({ vertexShader: /* glsl */ ` attribute vec4 aGlow; varying vec2 vUv; varying vec4 vGlow; void main() { vUv = uv; vGlow = aGlow; ${billboard ? /* glsl */ ` float size = length(instanceMatrix[0].xyz); vec4 centre = viewMatrix * modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0); gl_Position = projectionMatrix * (centre + vec4(position.xy * size, 0.0, 0.0));` : ` gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);`} } `, fragmentShader: /* glsl */ ` varying vec2 vUv; varying vec4 vGlow; void main() { float r = 1.0 - min(1.0, length(vUv - 0.5) * 2.0); float shape = ${billboard ? 'pow(max(r, 0.0), 3.0) * 1.3 + pow(max(r, 0.0), 1.2) * 0.22' : 'pow(max(r, 0.0), 1.8) * 0.7'}; gl_FragColor = vec4(vGlow.rgb * shape * vGlow.w, 1.0); #include #include } `, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, }); } export interface YardInspection { visible: boolean; presence: number; cranePresence: number; /** Rail coordinate (metres from the lens along the rails, negative ahead) and its world z. */ craneZ: number; craneWorldZ: number; spreader: number; /** Stack-top plates fully lit (= stops passed). */ litPlates: number; lamp: number; strikes: number; flood: number; } export interface YardWorld { readonly group: ThreeGroup; /** * Section-local progress of 'additional-work' (null outside), bass pulse, * frame delta, the underpass deck z (null: none) and the viewport aspect. */ update(section: SectionId, p: number | null, pulse: number, deltaSeconds: number, deckZ?: number | null, aspect?: number): void; inspect(): YardInspection; } export function createYard(resources: SceneResourceBag, full: boolean): YardWorld { const own = (kind: 'geometry' | 'material', value: T): T => resources.own(kind, value); const accent = new THREE.Color(SIGN_ACCENTS['additional-work']); const flood = new THREE.Color(0, 0, 0); const pulse = { value: 0 }; const shared = { uFlood: { value: flood }, uAccent: { value: accent }, uPulse: pulse }; const yardPresence = { value: 0 }; const cranePresence = { value: 0 }; const group = new THREE.Group(); group.name = 'night-freight'; group.visible = false; // The group's -z axis is the rail line, pivoting on the chapter lens. group.position.set(LENS.x, 0, LENS.z); let yaw = Number.NaN; const place = (aspect: number) => { const next = railYaw(aspect); if (next === yaw) return; yaw = next; group.rotation.y = yaw; }; place(DESIGN_ASPECT); const yardMaterial = own('material', solid({ ...shared, uPresence: yardPresence }, false)); const craneMaterial = own('material', solid({ ...shared, uPresence: cranePresence }, false)); const mesh = (geometry: ThreeBufferGeometry, material = yardMaterial) => { const m = new THREE.Mesh(own('geometry', geometry), material); m.frustumCulled = false; return m; }; // Apron (part 2), rails and the two flood masts (heads are part 1: warm metal housings). const apronLength = APRON.to - APRON.from; group.add(mesh(boxes([ [APRON.halfWidth * 2, APRON.depth, apronLength, 0, YARD_Y - APRON.depth / 2, APRON.from + apronLength / 2, 2], ...[-1, 1].map((sx) => [0.9, 0.5, apronLength - 4, (sx * SPAN) / 2, YARD_Y + 0.25, APRON.from + apronLength / 2] as Box), ...FLOOD_ZS.flatMap((z): Box[] => [ [1.1, FLOOD_H, 1.1, FLOOD_X, YARD_Y + FLOOD_H / 2, z], [6, 0.5, 0.5, FLOOD_X + 1, YARD_Y + FLOOD_H - 0.6, z], [5.2, 2, 1.6, FLOOD_X + 1.2, YARD_Y + FLOOD_H + 0.6, z + 0.4, 1], ]), ]))); // The stacks: one InstancedMesh, the end plate (part 1) faces the camera. const stackGeometry = own('geometry', boxes([ [BOX_W, BOX_H - 0.1, BOX_L, 0, BOX_H / 2, 0], [BOX_W - 0.7, BOX_H - 0.8, 0.16, 0, BOX_H / 2, BOX_L / 2 + 0.06, 1], ])); const topIndex: number[] = []; let count = 0; STACK_TIERS.forEach((tiers) => { count += tiers; topIndex.push(count - 1); }); const stackState = new THREE.InstancedBufferAttribute(new Float32Array(count * 2), 2); stackState.setUsage(THREE.DynamicDrawUsage); stackGeometry.setAttribute('aState', stackState); const stacks = new THREE.InstancedMesh( stackGeometry, own('material', solid({ ...shared, uPresence: yardPresence }, true)), count, ); stacks.name = 'yard-stacks'; stacks.frustumCulled = false; const matrix = new THREE.Matrix4(); let i = 0; STACK_ZS.forEach((z, s) => { for (let tier = 0; tier < STACK_TIERS[s]!; tier += 1, i += 1) { // A little stacking slop so the column reads as boxes, not a slab. matrix.makeRotationY(((i * 7) % 5 - 2) * 0.012).setPosition(((i * 3) % 4 - 1.5) * 0.18, YARD_Y + tier * BOX_H, z); stacks.setMatrixAt(i, matrix); stackState.setXY(i, 0, (s + tier * 3) % 4); } }); group.add(stacks); // The crane: portal, trolley, hoist ropes (unit height, hung from the top) and spreader. const crane = new THREE.Group(); crane.position.y = YARD_Y; const ropes = mesh(boxes([-1, 1].flatMap((sx) => [-1, 1].map((sz) => [0.18, 1, 0.18, sx * 2, -0.5, sz * 4.5] as Box))), craneMaterial); ropes.position.y = PORTAL_H - 1; const spreader = mesh(boxes([ [BOX_W + 0.3, 0.7, BOX_L - 0.4, 0, 0.35, 0], [1.2, 1.2, BOX_L - 2, 0, 1.2, 0, 1], ]), craneMaterial); crane.add( mesh(boxes(portalBoxes()), craneMaterial), mesh(boxes([[5, 2.2, LEG_Z * 2 + 1, 0, PORTAL_H + 2, 0, 1]]), craneMaterial), ropes, spreader, ); group.add(crane); // Lamps (two work lamps, two flood heads) and two flood pools on the apron: full only. let glows: ThreeInstancedMesh | null = null; let pools: ThreeInstancedMesh | null = null; let glowState: ThreeInstancedBufferAttribute | null = null; let poolState: ThreeInstancedBufferAttribute | null = null; if (full) { const glowGeometry = own('geometry', new THREE.PlaneGeometry(1, 1)); glowState = new THREE.InstancedBufferAttribute(new Float32Array(16), 4); glowState.setUsage(THREE.DynamicDrawUsage); glowGeometry.setAttribute('aGlow', glowState); glows = new THREE.InstancedMesh(glowGeometry, own('material', additive(true)), 4); const poolGeometry = own('geometry', new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2)); poolState = new THREE.InstancedBufferAttribute(new Float32Array(8), 4); poolState.setUsage(THREE.DynamicDrawUsage); poolGeometry.setAttribute('aGlow', poolState); pools = new THREE.InstancedMesh(poolGeometry, own('material', additive(false)), 2); const position = new THREE.Vector3(); const scale = new THREE.Vector3(); const none = new THREE.Quaternion(); FLOOD_ZS.forEach((z, i) => { glows!.setMatrixAt(2 + i, matrix.compose(position.set(FLOOD_X + 1.2, YARD_Y + FLOOD_H + 0.2, z + 1.4), none, scale.setScalar(13))); pools!.setMatrixAt(i, matrix.compose(position.set(-1.5, YARD_Y + 0.06, z + 8), none, scale.set(24, 1, 40))); }); for (const m of [glows, pools]) { m.frustumCulled = false; m.instanceMatrix.setUsage(THREE.DynamicDrawUsage); group.add(m); } } const lamp = createLamp(); const lampMatrix = new THREE.Matrix4(); const hue = new THREE.Color(); let warm = 0; let state: YardInspection = { visible: false, presence: 0, cranePresence: 0, craneZ: craneZ(0), craneWorldZ: LENS.z + craneZ(0) * Math.cos(yaw), spreader: 0, litPlates: 0, lamp: 0, strikes: 0, flood: 0, }; return { group, inspect: () => ({ ...state }), update(section, p, beat, deltaSeconds, deckZ = null, aspect = DESIGN_ASPECT) { place(Number.isFinite(aspect) && aspect > 0 ? aspect : DESIGN_ASPECT); const live = section === 'additional-work' && p !== null && Number.isFinite(p) ? Math.min(1, Math.max(0, p)) : null; const edge = (from: number, to: number, v: number) => { const t = Math.min(1, Math.max(0, (v - from) / (to - from))); return t * t * (3 - 2 * t); }; const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(0.95, 1, live)); const z = craneZ(live ?? 0); const worldZ = LENS.z + z * Math.cos(yaw); const clear = deckZ === null || !Number.isFinite(deckZ) ? 1 : edge(DECK_CLEAR.from, DECK_CLEAR.to, Math.abs(deckZ - worldZ)); const drop = spreaderDrop(live); // The spreader lands on whichever stack the crane is over (the nearest by rail distance). let over = 0; STACK_ZS.forEach((stackZ, s) => { if (Math.abs(stackZ - z) < Math.abs(STACK_ZS[over]! - z)) over = s; }); const spreaderDown = STACK_TIERS[over]! * BOX_H + 0.45; const visible = presence > 0; yardPresence.value = presence; cranePresence.value = presence * clear; crane.position.z = z; crane.visible = cranePresence.value > 0; const spreaderY = SPREADER_UP + (spreaderDown - SPREADER_UP) * drop; spreader.position.y = spreaderY; ropes.scale.y = Math.max(0.1, PORTAL_H - 1 - spreaderY - 1.8); let litPlates = 0; STACK_ZS.forEach((_, s) => { const lit = plateLit(live, s); if (lit >= 1) litPlates += 1; stackState.setX(topIndex[s]!, lit); }); stackState.needsUpdate = true; warm = floodWarmup(warm, full && live !== null && live < 0.93, deltaSeconds); const light = floodLight(warm); flood.copy(ORANGE).lerp(HID, light.hue).multiplyScalar(full ? light.output * 0.5 : 0); pulse.value = full ? Math.min(1, Math.max(0, beat)) : 0; stepLamp(lamp, full && crane.visible && drop > 0.6 ? 1 : 0, deltaSeconds); if (glows !== null && pools !== null && glowState !== null && poolState !== null) { const workLamp = [1.0, 0.86, 0.66] as const; hue.copy(flood).multiplyScalar(1 / 0.5); [-1, 1].forEach((sx, i) => { lampMatrix.makeScale(5, 5, 5).setPosition(sx * (SPAN / 2 + 0.8), YARD_Y + PORTAL_H - 1.4, z + LEG_Z + 1.2); glows!.setMatrixAt(i, lampMatrix); glowState!.setXYZW(i, workLamp[0], workLamp[1], workLamp[2], (0.12 + 0.88 * lamp.level) * cranePresence.value); glowState!.setXYZW(2 + i, hue.r, hue.g, hue.b, presence); poolState!.setXYZW(i, hue.r, hue.g, hue.b, presence * (1 + PULSE_GAIN * pulse.value)); }); glows.instanceMatrix.needsUpdate = true; glowState.needsUpdate = true; poolState.needsUpdate = true; } group.visible = visible; state = { visible, presence, cranePresence: cranePresence.value, craneZ: z, craneWorldZ: worldZ, spreader: drop, litPlates, lamp: lamp.level, strikes: lamp.strikes, flood: warm, }; }, }; }