polish: scene nits — live-lens yard, square stacks, deck-clear billboard, two-layer lamps, flare reach, phone traffic and exposure

This commit is contained in:
2026-10-12 18:59:33 +03:00
parent 71465fa481
commit 3602eeb3d1
23 changed files with 671 additions and 73 deletions

View File

@@ -19,19 +19,25 @@ import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
type ThreeBufferGeometry = InstanceType<typeof THREE.BufferGeometry>;
type ThreeGroup = InstanceType<typeof THREE.Group>;
type ThreeCamera = InstanceType<typeof THREE.Camera>;
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
* stacks (three, 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
* any aspect, and the stacks step up with distance so none hides the next.
* With the live camera passed in, the rails are aimed from the camera's
* current lens every frame (position, fov and view offset mid-ease), so the
* yard holds its screen x while the lens settles into the chapter instead of
* sliding in with the world. The containers (and the spreader) stay square to
* the road whatever the rail yaw, so a stack never reads as leaning. 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
@@ -53,11 +59,13 @@ 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;
/* 14 m: a road-square 14 m box seen across a ~30 degree rail yaw is 11.9 m wide between the legs. */
const SPAN = 14;
const PORTAL_H = 29;
const LEG_Z = 5.5;
const SPREADER_UP = PORTAL_H - 3;
const FLOOD_X = -8.5;
/** Clear of the girder ends (SPAN + 3) / 2 as the crane passes the masts. */
const FLOOD_X = -10.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;
@@ -72,6 +80,8 @@ export function railYaw(aspect: number = DESIGN_ASPECT): number {
const raw = portrait ? -0.9 : RAIL_NDC - 2 * LENS.shift;
return Math.atan(-raw * tanHalfHeight * aspect);
}
/** Screen x (NDC, after the lens shift) the rails aim at on a portrait viewport (no lens shift there). */
const PORTRAIT_NDC = -0.9;
/** 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;
@@ -243,7 +253,15 @@ export interface YardWorld {
* 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;
update(
section: SectionId,
p: number | null,
pulse: number,
deltaSeconds: number,
deckZ?: number | null,
aspect?: number,
camera?: ThreeCamera | null,
): void;
inspect(): YardInspection;
}
@@ -261,13 +279,27 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
// 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);
let squaredYaw = Number.NaN;
const ray = new THREE.Vector3();
const setYaw = (next: number) => {
if (next === yaw) return;
yaw = next;
group.rotation.y = yaw;
};
place(DESIGN_ASPECT);
const place = (aspect: number, camera: ThreeCamera | null) => {
if (camera === null) {
group.position.set(LENS.x, 0, LENS.z);
setYaw(railYaw(aspect));
return;
}
// The live lens: the vertical plane through the camera and the ray at the
// rail's screen x projects to that x, whatever the ease is doing.
camera.updateMatrixWorld();
ray.set(aspect < 1.18 ? PORTRAIT_NDC : RAIL_NDC, 0, 0.5).unproject(camera).sub(camera.position);
group.position.set(camera.position.x, 0, camera.position.z);
if (ray.x * ray.x + ray.z * ray.z > 1e-9) setYaw(Math.atan2(-ray.x, -ray.z));
};
place(DESIGN_ASPECT, null);
const yardMaterial = own('material', solid({ ...shared, uPresence: yardPresence }, false));
const craneMaterial = own('material', solid({ ...shared, uPresence: cranePresence }, false));
const mesh = (geometry: ThreeBufferGeometry, material = yardMaterial) => {
@@ -310,15 +342,26 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
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);
}
});
/*
* The stacks counter-turn the rail yaw so every box is square to the road
* (end plate to the camera). Only the bottom tier keeps a hair of stacking
* slop, so the column reads as boxes and the upper tiers sit plumb.
*/
const squareStacks = () => {
if (squaredYaw === yaw) return;
squaredYaw = yaw;
let i = 0;
STACK_ZS.forEach((z, s) => {
for (let tier = 0; tier < STACK_TIERS[s]!; tier += 1, i += 1) {
const slop = tier === 0 ? ((i * 7) % 5 - 2) * 0.006 : 0;
matrix.makeRotationY(slop - yaw).setPosition(((s * 3) % 4 - 1.5) * 0.12, YARD_Y + tier * BOX_H, z);
stacks.setMatrixAt(i, matrix);
stackState.setXY(i, 0, (s + tier * 3) % 4);
}
});
stacks.instanceMatrix.needsUpdate = true;
};
squareStacks();
group.add(stacks);
// The crane: portal, trolley, hoist ropes (unit height, hung from the top) and spreader.
@@ -373,14 +416,19 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
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,
visible: false, presence: 0, cranePresence: 0, craneZ: craneZ(0), craneWorldZ: group.position.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);
update(section, p, beat, deltaSeconds, deckZ = null, aspect = DESIGN_ASPECT, camera = null) {
place(Number.isFinite(aspect) && aspect > 0 ? aspect : DESIGN_ASPECT, camera);
if (group.visible || section === 'additional-work') {
squareStacks();
spreader.rotation.y = -yaw;
ropes.rotation.y = -yaw;
}
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)));
@@ -388,7 +436,7 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
};
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 worldZ = group.position.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).