Files
Portfolio/src/scene/world/yard.ts

453 lines
19 KiB
TypeScript

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<typeof THREE.BufferGeometry>;
type ThreeGroup = InstanceType<typeof THREE.Group>;
type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
type ThreeInstancedBufferAttribute = InstanceType<typeof THREE.InstancedBufferAttribute>;
/**
* 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<number>, (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<string, { value: unknown }>, 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 <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
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 <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
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 = <T extends { dispose(): void }>(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,
};
},
};
}