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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user