Merge branch 'polish/scene-nits' into redesign/overnight

This commit is contained in:
2026-10-12 19:00:08 +03:00
23 changed files with 671 additions and 73 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -25,7 +25,7 @@ export const CRANE_TO_Z = -62;
/** The stacks along the rails, one under each stop, far to near. */
export const STACK_ZS = [-134, -112, -94, -80] as const;
/** Containers per stack, far to near: the yard steps down toward the camera so every stack top and the crane stay in view. */
export const STACK_TIERS = [4, 3, 2, 1] as const;
export const STACK_TIERS = [3, 3, 2, 1] as const;
/** Work lamps: no strike ramps faster than this, and no more than one strike per gap. */
export const LAMP_RAMP_SECONDS = 0.35;
export const LAMP_STRIKE_GAP_SECONDS = 0.6;

View File

@@ -44,6 +44,18 @@ const settings: Readonly<Record<LiveQualityTier, Omit<RenderQualitySettings, 'pi
},
};
/**
* The low tier draws without bloom or the post stack, so the lamps lose their
* glow and the frame reads dim on phones: it renders one stop-fraction
* brighter instead.
*/
export const NO_POST_EXPOSURE_LIFT = 1.15;
/** Tone-mapping exposure for a tier: the base, lifted where there is no post. */
export function tierExposure(base: number, quality: Pick<RenderQualitySettings, 'postEffects'>): number {
return quality.postEffects ? base : base * NO_POST_EXPOSURE_LIFT;
}
export function renderQualityFor(
tier: LiveQualityTier,
devicePixelRatio: number,

View File

@@ -310,6 +310,14 @@ function boardGeometry(): { dispose(): void } {
// ---------------------------------------------------------------- world
/** The EXIT 05 deck has passed once it is this far toward the camera (the chase camera sits at z ~25). */
export const BILLBOARD_DECK_CLEAR_Z = 10;
/** True while the Selected builds underpass deck is on the road and still ahead of the camera. */
export function deckAhead(frame: Pick<BillboardFrame, 'deckZ' | 'deckExit'>): boolean {
const z = frame.deckZ;
return frame.deckExit === 'selected-builds' && typeof z === 'number' && Number.isFinite(z) && z < BILLBOARD_DECK_CLEAR_Z;
}
export interface BillboardFrame {
section: SectionId;
/** Selected builds chapter progress, null outside the chapter. */
@@ -324,6 +332,13 @@ export interface BillboardFrame {
focus: string | undefined;
/** Tall viewport (camera on the centre line): the board steps in and shrinks. */
narrow?: boolean;
/**
* The underpass deck on the road (z, null: none) and the chapter beyond it.
* While the EXIT 05 deck is still ahead of the camera the board does not
* start to rise, so the deck's pier never crosses the rising board's edge.
*/
deckZ?: number | null;
deckExit?: SectionId | null;
deltaSeconds: number;
}
@@ -519,7 +534,7 @@ export function createBillboard(resources: SceneResourceBag, options: BillboardO
// Up with the workshop, and already standing as the chapter heading
// approaches so the entry band shows it whole; down after the chapter.
const presence = frame.section === 'infrastructure' && frame.near ? 1 : buildsPresence(frame.section);
rise = stepDistrictRise(rise, presence, dt);
rise = stepDistrictRise(rise, deckAhead(frame) && rise < STANDING ? 0 : presence, dt);
if (frame.p !== null) lastP = frame.p;
else if (frame.section !== 'selected-builds') lastP = frame.section === 'additional-work' || frame.section === 'toolkit' || frame.section === 'contact' ? 1 : 0;
const narrow = frame.narrow === true;

View File

@@ -3,7 +3,7 @@ import type { JourneySnapshot } from '@/journey/types';
import { readMusicPulse } from '@/audio/music-pulse';
import type { SceneResourceBag } from '@/scene/resources';
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
import { renderQualityFor } from '@/scene/render-quality';
import { renderQualityFor, tierExposure } from '@/scene/render-quality';
import { createHorizonAir } from '@/scene/world/horizon-air';
import { createExpressway } from '@/scene/world/expressway';
import { createDistricts } from '@/scene/world/districts';
@@ -112,7 +112,7 @@ export async function createProceduralSceneGeneration(
renderer.outputColorSpace = THREE.SRGBColorSpace;
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
renderer.toneMapping = THREE.CustomToneMapping;
renderer.toneMappingExposure = IGNITION.exposure;
renderer.toneMappingExposure = tierExposure(IGNITION.exposure, quality);
const scene = new THREE.Scene();
scene.name = 'world-root';
@@ -428,7 +428,7 @@ export async function createProceduralSceneGeneration(
short: readIgnition().returning,
});
const roll = ignited.roll;
renderer.toneMappingExposure = ignited.exposure;
renderer.toneMappingExposure = tierExposure(ignited.exposure, quality);
if (renderedFrames === 0) canvas.dataset.lit = '';
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
// The 03:12 window: section-local scroll progress drives every beat.
@@ -444,16 +444,6 @@ export async function createProceduralSceneGeneration(
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
}
districts.setServiceState(service.dim, service.relight);
// The deck from the previous frame (underpass updates after the sign).
const yardDeck = underpass.inspect();
yard.update(
snapshot.activeSectionId,
sectionLocalProgress(snapshot, 'additional-work'),
musicPulse,
deltaSeconds,
yardDeck.visible ? yardDeck.z : null,
camera.aspect,
);
const relayNudge = relay.update(
snapshot.activeSectionId,
snapshot.activeSectionId === 'toolkit' ? sectionLocalProgress(snapshot, 'toolkit') : null,
@@ -463,6 +453,8 @@ export async function createProceduralSceneGeneration(
now,
camera.aspect,
);
// The deck from the previous frame (underpass updates after the sign and the lens).
const roadDeck = underpass.inspect();
const billboardNudge = billboard.update({
section: snapshot.activeSectionId,
p: snapshot.activeSectionId === 'selected-builds'
@@ -474,6 +466,8 @@ export async function createProceduralSceneGeneration(
energy: overdrive,
focus: document.documentElement.dataset.billboardFocus,
narrow: camera.aspect <= 1.18,
deckZ: roadDeck.visible ? roadDeck.z : null,
deckExit: roadDeck.exit,
deltaSeconds,
});
// END OF SHIFT: parked at the destination, the night ends. One scalar
@@ -842,6 +836,17 @@ export async function createProceduralSceneGeneration(
const stage = signStage.update(width, height);
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
const gantry = sign.dockState();
// NIGHT FREIGHT: after the lens is set, so the rails aim from the live
// camera (mid-ease too); the deck is the previous frame's (underpass updates next).
yard.update(
snapshot.activeSectionId,
sectionLocalProgress(snapshot, 'additional-work'),
musicPulse,
deltaSeconds,
roadDeck.visible ? roadDeck.z : null,
camera.aspect,
camera,
);
// UNDERPASS: after the sign so the deck holds behind it while it comes in.
const nextRoute = nextSectionId(snapshot.activeSectionId);
const passage = underpass.update(

View File

@@ -4,6 +4,16 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SectionId } from '@/content/types';
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
/**
* Pylon lamps are two layers: a core held below the tone curve's shoulder
* (so cyan stays cyan and magenta stays magenta instead of toning toward
* white) and a soft additive halo in the same saturated hue around it.
*/
export const PYLON_CORE_GAIN = 0.62;
export const PYLON_HALO_GAIN = 0.3;
/** Halo quad: this wide (m) and this much longer than the lamp strip. */
const PYLON_HALO = { width: 1.1, extra: 0.9 } as const;
/** A bounded kit of physical roadside objects. Instance origins wrap behind the
* camera; the entire object moves together, so long beams never tear at a seam. */
export function createExpressway(
@@ -24,7 +34,9 @@ export function createExpressway(
varying vec3 vNormal;
varying vec3 vWorld;
varying float vDepth;
varying vec2 vUv;
void main() {
vUv = uv;
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
vec4 p = instanceMatrix * vec4(position, 1.0);
p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z;
@@ -36,7 +48,7 @@ export function createExpressway(
gl_Position = projectionMatrix * view;
}
`;
function material(luminous: boolean, coral = false, fade: readonly [number, number] = [380, 660]) {
function material(luminous: boolean, coral = false, fade: readonly [number, number] = [380, 660], gain = 1, halo = false) {
return resources.own(
'material',
new THREE.ShaderMaterial({
@@ -48,13 +60,19 @@ export function createExpressway(
varying vec3 vNormal;
varying vec3 vWorld;
varying float vDepth;
varying vec2 vUv;
void main() {
float farFade = 1.0 - smoothstep(${fade[0].toFixed(1)}, ${fade[1].toFixed(1)}, vDepth);
float nearFade = smoothstep(0.0, 9.0, vDepth);
vec3 cyan = mix(vec3(1.0, 0.025, 0.34), vec3(0.02, 0.78, 0.93), step(0.0, vWorld.x));
vec3 coral = vec3(1.0, 0.18, 0.095);
float facing = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.5, 0.9, 0.6))));
vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5)` : 'vec3(0.028, 0.012, 0.046) + vec3(0.09, 0.055, 0.14) * pow(max(facing, 0.0), 2.0)'};
vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5) * ${gain.toFixed(3)}` : 'vec3(0.028, 0.012, 0.046) + vec3(0.09, 0.055, 0.14) * pow(max(facing, 0.0), 2.0)'};
${halo ? `
// Soft across the strip, eased in at its ends.
vec2 q = abs(vUv - 0.5) * 2.0;
float across = clamp(1.0 - q.x, 0.0, 1.0);
color *= across * across * (1.0 - smoothstep(0.82, 1.0, q.y));` : ''}
${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''}
if(farFade * nearFade < 0.02) discard;
gl_FragColor = vec4(color, farFade * nearFade);
@@ -70,11 +88,13 @@ export function createExpressway(
);
}
const graphite = material(false);
const cyan = material(true);
// Delineator reflectors fade out by ~240 m: beyond that a 0.4 m plate is a
// sub-pixel speck, and the specks read as random orange dots (owner).
const coral = material(true, true, [110, 240]);
const pylonCore = material(true, false, [380, 660], PYLON_CORE_GAIN);
const pylonHalo = material(true, false, [380, 660], PYLON_HALO_GAIN, true);
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
const dummy = new THREE.Object3D();
type Part = {
x: number;
@@ -89,10 +109,11 @@ export function createExpressway(
name: string,
parts: Part[],
surface: ReturnType<typeof material>,
shape: typeof box = box,
) {
const mesh = resources.own(
'other',
new THREE.InstancedMesh(box, surface, parts.length),
new THREE.InstancedMesh(shape, surface, parts.length),
);
mesh.name = name;
mesh.frustumCulled = false;
@@ -174,7 +195,13 @@ export function createExpressway(
feet.push({ x: side * 12.6, y: 0.24, z, sx: 1.1, sy: 0.48, sz: 1.5 });
}
batch('swept-signal-pylons', fins, graphite);
batch('recessed-pylon-lights', lights, cyan);
batch('recessed-pylon-lights', lights, pylonCore);
batch(
'pylon-light-halos',
lights.map((part) => ({ ...part, z: part.z + 0.05, sx: PYLON_HALO.width, sy: part.sy + PYLON_HALO.extra, sz: 1 })),
pylonHalo,
plane,
);
batch('pylon-foundations', feet, graphite);
// Far roadside monolith pairs give the waves an occasional architectural edge.
const landmarks: Part[] = [];

View File

@@ -48,6 +48,13 @@ const BEAM_WIDTH = 7;
export const BEAM_INTENSITY = 0.65;
const SWEEP_SECONDS = 6;
const PULSE_GAIN = 0.08;
/** Lit rung core (linear, pre-tonemap): luminance ~0.19, under the 0.85 bloom threshold and the ACES shoulder. */
export const RUNG_CORE = [0.34, 0.09, 0.75] as const;
/** Rung halo: saturated violet, additive, peak level (the core plus a full halo still tones violet). */
export const RUNG_HALO = [0.42, 0.1, 1.0] as const;
export const RUNG_HALO_GAIN = 0.32;
/** Halo quad size relative to the lamp bar (width, height): a soft ellipse around it. */
const HALO_SIZE = { width: 1.5, height: 4.2 } as const;
/**
* Where the masts stand, placed by where they land in the frame (toolkit
@@ -60,8 +67,10 @@ const PULSE_GAIN = 0.08;
* lands as a fraction of the half-height above the centre: in the upper
* third, clear of the nav bar. x and the scale follow from those and the
* viewport aspect, so the masts keep their place on narrow lenses too.
* Portrait (phones): the margins are slivers, so the masts tuck under the
* panel edge instead of leaving the frame.
* Portrait (phones): the margins are slivers, so four masts would be four
* slivers under the panel edge. Instead one large transmitter stands deep
* left of the road (PORTRAIT_STAND) and carries every group's rungs, stacked
* in bands bottom-up in group order; only its frame, beacon and beam draw.
*/
const LENS = CHAPTER_COMPOSITIONS.toolkit;
export const DESIGN_ASPECT = 1.6;
@@ -73,6 +82,9 @@ const SLOT_FRAMES = [
{ z: -130, fx: 0.875, fy: 0.56 },
] as const;
/** Portrait: the one transmitter, as a frame fraction like SLOT_FRAMES (left of the road, beacon high). */
const PORTRAIT_STAND = { z: -220, fx: 0.42, fy: 0.78, side: -1 } as const;
interface RelaySlot {
z: number;
side: number;
@@ -88,6 +100,17 @@ export function relaySlots(aspect: number = DESIGN_ASPECT): RelaySlot[] {
if (aspect === slotAspect) return slots;
slotAspect = aspect;
const portrait = aspect < 1.18;
if (portrait) {
const distance = LENS.z - PORTRAIT_STAND.z;
const stand: RelaySlot = {
z: PORTRAIT_STAND.z,
side: PORTRAIT_STAND.side,
x: PORTRAIT_STAND.side * PORTRAIT_STAND.fx * TAN_HALF_HEIGHT * aspect * distance,
scale: (LENS.y + PORTRAIT_STAND.fy * TAN_HALF_HEIGHT * distance) / (MAST_HEIGHT + ANTENNA),
};
slots = SLOT_FRAMES.map(() => stand);
return slots;
}
slots = SLOT_FRAMES.map((slot, i) => {
const side = i % 2 === 0 ? 1 : -1;
const distance = LENS.z - slot.z;
@@ -128,6 +151,8 @@ export interface RelayMast {
presence: number;
/** 0..1: the mast is online (beacon and beam). */
level: number;
/** The mast draws its own frame, beacon and beam (portrait: only the first; the others hang their rungs on it). */
shown: boolean;
rungs: RelayRung[];
}
@@ -138,11 +163,21 @@ export function relayLayout(
full = true,
aspect: number = DESIGN_ASPECT,
): RelayMast[] {
const single = aspect < 1.18;
const shared = single ? counts.reduce((sum, value) => sum + value, 0) : 0;
let before = 0;
return relaySlots(aspect).slice(0, counts.length).map((slot, mast) => {
const count = counts[mast] ?? 0;
const presence = p === null ? 0 : relayPresence(p);
const live = presence > 0;
// One transmitter: the groups' rungs stack in bands up the shared mast.
const first = before;
before += count;
const band = (rung: number) => (single
? (RUNG_SPAN * (first + rung + 0.5)) / Math.max(1, shared)
: (RUNG_SPAN * (rung + 0.5)) / count);
return {
shown: !single || mast === 0,
x: slot.x,
z: slot.z,
scale: slot.scale,
@@ -151,7 +186,7 @@ export function relayLayout(
presence,
level: !live ? 0 : full ? smooth(RELAY_STOPS[mast]!, RELAY_STOPS[mast]! + 0.03, p!) : 1,
rungs: Array.from({ length: count }, (_, rung) => {
const y = MAST_HEIGHT * (RUNG_FROM + (RUNG_SPAN * (rung + 0.5)) / count);
const y = MAST_HEIGHT * (RUNG_FROM + band(rung));
return { y: y * slot.scale, halfWidth: halfWidthAt(y) * slot.scale, lit: !live ? 0 : full ? rungLit(p, mast, rung, count) : 1 };
}),
};
@@ -301,19 +336,57 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
const rungGeometry = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
const rungState = stateAttribute(rungGeometry, MAX_RUNGS);
const rungMesh = instanced(rungGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ `
// Lit lamps are a hot, saturated violet (hotter than the bloom threshold, so they glow violet, not white).
vec3 lamp = vec3(0.62, 0.3, 1.0) * 2.1 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse);
// Lit lamps: a saturated violet core kept below the tone curve's shoulder
// (and the bloom threshold), so it reads violet, not lavender-white; the
// glow is the additive halo layer (relay-halos), not a blown core.
vec3 lamp = vec3(${RUNG_CORE.map((v) => v.toFixed(2)).join(', ')}) * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse);
vec3 dark = uAccent * 0.12 + vec3(0.02) * key;
// Hover flash: the rung turns ice-white and swells (see FLASH_SWELL).
color = mix(mix(dark, lamp, vState.x), vec3(0.78, 1.0, 1.0) * 2.4, vState.y);
`)), MAX_RUNGS, 'relay-rungs');
// The halo: one soft additive ellipse per rung, in front of the lamp bar.
const haloGeometry = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
const haloState = stateAttribute(haloGeometry, MAX_RUNGS);
const haloMesh = instanced(haloGeometry, resources.own('material', new THREE.ShaderMaterial({
uniforms: { uPulse: pulseUniform },
vertexShader: /* glsl */ `
attribute vec3 aState;
varying vec2 vUv;
varying float vLevel;
void main() {
vUv = uv;
// Lit and present; a hover flash does not swell the halo (the core flashes).
vLevel = aState.x * aState.z;
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform float uPulse;
varying vec2 vUv;
varying float vLevel;
void main() {
float r = length((vUv - 0.5) * 2.0);
float a = clamp(1.0 - r, 0.0, 1.0);
a *= a;
vec3 color = vec3(${RUNG_HALO.map((v) => v.toFixed(2)).join(', ')}) * a * vLevel * ${RUNG_HALO_GAIN.toFixed(2)} * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false,
})), MAX_RUNGS, 'relay-halos');
const beaconGeometry = resources.own('geometry', new THREE.OctahedronGeometry(1.6));
const beaconState = stateAttribute(beaconGeometry, SLOT_FRAMES.length);
const beaconMesh = instanced(beaconGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ `
color = vec3(1.0, 0.12, 0.08) * (0.12 + 2.2 * vState.x);
`)), SLOT_FRAMES.length, 'relay-beacons');
group.add(frames, rungMesh, beaconMesh);
group.add(frames, rungMesh, haloMesh, beaconMesh);
// The sky beam: a thin additive quad from the beacon, leaning in over the road and sweeping every 6 s.
let beamMesh: ThreeInstancedMesh | null = null;
@@ -407,8 +480,9 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
let any = false;
masts.forEach((mast, m) => {
const visible = mast.presence > 0;
const drawn = visible && mast.shown;
any ||= visible;
scale.setScalar(visible ? mast.scale : 0);
scale.setScalar(drawn ? mast.scale : 0);
rotation.identity();
position.set(mast.x, 0, mast.z);
frames.setMatrixAt(m, matrix.compose(position, rotation, scale));
@@ -416,7 +490,7 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
// A soft 0.5 Hz blink while online (lite: steady).
const blink = full ? Math.pow(0.5 + 0.5 * Math.cos(2 * Math.PI * BEACON_HZ * time + m * 1.3), 3) : 0.6;
beaconLevels[m] = visible ? mast.level * (0.2 + 0.8 * blink) : 0;
beaconLevels[m] = drawn ? mast.level * (0.2 + 0.8 * blink) : 0;
position.set(mast.x, (MAST_HEIGHT + ANTENNA) * mast.scale, mast.z);
beaconMesh.setMatrixAt(m, matrix.compose(position, rotation, scale));
beaconState.setXYZ(m, beaconLevels[m]!, 0, mast.presence);
@@ -424,7 +498,7 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
if (beamMesh !== null && beamState !== null) {
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
rotation.setFromAxisAngle(forward, sweep);
const shown = visible && mast.level > 0 ? mast.scale : 0;
const shown = drawn && mast.level > 0 ? mast.scale : 0;
beamMesh.setMatrixAt(m, matrix.compose(position, rotation, scale.set(BEAM_WIDTH * shown, BEAM_LENGTH * shown, 1)));
beamState.setXYZ(m, mast.level, 0, mast.presence);
rotation.identity();
@@ -446,18 +520,23 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
scale.set(visible ? rung.halfWidth * 2.3 * (1 + FLASH_SWELL.width * f) : 0, 0.9 * s * (1 + FLASH_SWELL.height * f), 0.6 * s);
rungMesh.setMatrixAt(index, matrix.compose(position, rotation, scale));
rungState.setXYZ(index, rung.lit, flash[index]!, mast.presence);
position.z += 0.3 * s;
scale.set(visible ? rung.halfWidth * 2.3 * HALO_SIZE.width : 0, 0.9 * s * HALO_SIZE.height, 1);
haloMesh.setMatrixAt(index, matrix.compose(position, rotation, scale));
haloState.setXYZ(index, rung.lit, 0, mast.presence);
index += 1;
}
});
total = index;
rungMesh.count = total;
haloMesh.count = total;
frames.count = masts.length;
beaconMesh.count = masts.length;
if (beamMesh !== null) beamMesh.count = masts.length;
group.visible = any;
if (any) {
pulseUniform.value = full ? clamp01(pulse) : 0;
for (const mesh of [frames, rungMesh, beaconMesh, beamMesh]) {
for (const mesh of [frames, rungMesh, haloMesh, beaconMesh, beamMesh]) {
if (mesh === null) continue;
mesh.instanceMatrix.needsUpdate = true;
(mesh.geometry.getAttribute('aState') as ThreeInstancedBufferAttribute).needsUpdate = true;

View File

@@ -74,6 +74,34 @@ export const TRAFFIC_CONVOY_NEAR_Z = -450;
const CONVOY_FOLLOWER_GAP = 150;
const CONVOY_GAP_MIN = 30;
const CONVOY_GAP_SPAN = 20;
/** The farthest a queued car's rear sits behind its truck's rear, past its own length (m). */
export const TRAFFIC_CONVOY_GAP_MAX = CONVOY_GAP_MIN + CONVOY_GAP_SPAN;
/**
* Lite (6-vehicle pool, phones): the convoy window is nearer, so a queued
* pair reaches the overtake band in ~10-17 s at cruise instead of ~25 s.
*/
export const TRAFFIC_LITE_CONVOY_FAR_Z = -420;
export const TRAFFIC_LITE_CONVOY_NEAR_Z = -330;
/**
* Lite: a pass is staged at each chapter entry (TrafficInput.cue) for up to
* TRAFFIC_LITE_STAGE_SECONDS, and whenever the road has gone this long with
* no lane change: a truck screen-doors in at the haze (if none is coming)
* and one right car more than the chapter wants may queue behind it. The
* first lane change ends the staging.
*/
export const TRAFFIC_LITE_DROUGHT_SECONDS = 10;
export const TRAFFIC_LITE_STAGE_SECONDS = 20;
/** Where a staged truck's rear enters: [Z, Z + SPAN], inside the lite convoy window. */
export const TRAFFIC_LITE_STAGE_Z = -410;
export const TRAFFIC_LITE_STAGE_SPAN = 40;
/** Lite staging: a right-lane truck further out than the window by this much does not count as on its way. */
export const TRAFFIC_LITE_STAGE_REACH = 140;
/** Lite staging: a truck on its way counts only with a passer that will be this close behind it... */
const TRAFFIC_LITE_STAGE_FOLLOW = 60;
/** ...by the time it is this far into the entry band. */
const TRAFFIC_LITE_STAGE_BAND = 30;
/** Lite staging ahead of a car already out there: no nearer than this (rear z; it screen-doors in). */
export const TRAFFIC_LITE_STAGE_NEAR_Z = -260;
/**
* A vehicle whose nose has passed this z has gone beneath the chase camera
* (z 23-26, y >= 5.6) and out of every chapter's frame; it is still simulated
@@ -245,6 +273,8 @@ export interface TrafficInput {
cameraX: number;
/** 0..1, parked at the destination. */
engineOff: number;
/** One frame: the reader arrived in a new chapter (lite stages a pass). */
cue?: boolean;
}
export interface TrafficFrame {
@@ -381,6 +411,9 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
let hurried = false;
let lastManoeuvre = Number.NEGATIVE_INFINITY;
let onsetIndex = 0;
/** Lite pass staging: the last lane change, and the end of a chapter-entry cue. */
let lastLaneChange = 0;
let stageUntil = Number.NEGATIVE_INFINITY;
const random = (): number => {
seed = (seed * 16807) % 2147483647;
@@ -409,6 +442,8 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
hurried = false;
lastManoeuvre = Number.NEGATIVE_INFINITY;
onsetIndex = 0;
lastLaneChange = 0;
stageUntil = Number.NEGATIVE_INFINITY;
onsets.fill(Number.NEGATIVE_INFINITY);
lastSpawn.fill(Number.NEGATIVE_INFINITY);
state.fill(TRAFFIC_PENDING);
@@ -514,6 +549,8 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
indicatorSince[i] = time;
}
counters.laneChanges += 1;
lastLaneChange = time;
stageUntil = Number.NEGATIVE_INFINITY;
}
/** A change already under way finishes at the swerve pace (progress is kept: no jump in x). */
@@ -592,24 +629,43 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
state[i] = TRAFFIC_VISIBLE;
}
/** Lite: a right-lane truck in the convoy window with no car queued behind it yet. */
function convoyOpen(): boolean {
for (let t = 0; t < n; t += 1) {
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || lane[t] !== TRAFFIC_LANE_RIGHT || lateral(t)) continue;
if (z[t]! >= TRAFFIC_LITE_CONVOY_NEAR_Z || z[t]! <= TRAFFIC_LITE_CONVOY_FAR_Z) continue;
let followed = false;
for (let j = 0; j < n; j += 1) {
if (j === t || !present(j) || kind[j] !== TRAFFIC_CAR || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
if (z[j]! > z[t]! && z[j]! - length(j) - z[t]! < CONVOY_FOLLOWER_GAP) followed = true;
}
if (!followed) return true;
}
return false;
}
/**
* Rear z (< 0) for a car queued behind an unfollowed right-lane truck in
* the convoy window; 1 while such a truck is still short of the window
* (worth waiting a few seconds for); 0 when there is none.
*/
function convoyRear(len: number): number {
const windowFar = config.lite ? TRAFFIC_LITE_CONVOY_FAR_Z : TRAFFIC_CONVOY_FAR_Z;
const windowNear = config.lite ? TRAFFIC_LITE_CONVOY_NEAR_Z : TRAFFIC_CONVOY_NEAR_Z;
let soon = 0;
for (let t = 0; t < n; t += 1) {
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || lane[t] !== TRAFFIC_LANE_RIGHT || lateral(t)) continue;
const zt = z[t]!;
if (zt >= TRAFFIC_CONVOY_NEAR_Z) continue;
if (zt <= TRAFFIC_CONVOY_FAR_Z) {
if (zt >= windowNear) continue;
if (zt <= windowFar) {
soon = 1;
continue;
}
let followed = false;
for (let j = 0; j < n; j += 1) {
if (j === t || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
// (Lite: a truck behind is no passer; its own pass is already under way.)
if (config.lite && kind[j] === TRAFFIC_TRUCK) continue;
if (z[j]! > zt && z[j]! - length(j) - zt < CONVOY_FOLLOWER_GAP) {
followed = true;
break;
@@ -656,7 +712,8 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
}
/** One group (lane, kind) spawns at most one vehicle per call. */
function spawn(group: number, laneIndex: number, k: number, want: number, initial: boolean): void {
/** `queueOnly`: the call carries a staging extra, which may only join a truck's queue. */
function spawn(group: number, laneIndex: number, k: number, want: number, initial: boolean, queueOnly = false): void {
if (countVisible(laneIndex, k) >= want) return;
if (!initial && time - lastSpawn[group]! < SPAWN_GAP_SECONDS) return;
const i = pendingOf(k);
@@ -684,6 +741,7 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
random() * 30 -
(k === TRAFFIC_TRUCK ? 15 : 0);
} else if (base * 1.08 > hero + 0.5) {
if (queueOnly && countVisible(laneIndex, k) >= want - 1) return;
rear = TRAFFIC_EXIT_NOSE_Z + len - 0.01;
} else if (k === TRAFFIC_CAR && laneIndex === TRAFFIC_LANE_RIGHT && (rear = convoyRear(len)) !== 0) {
// A truck is about to reach the window: wait for it (rear > 0)...
@@ -694,6 +752,9 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
v[i] = TRAFFIC_SPEEDS.truck;
lastSpawn[group] = time;
return;
} else if (queueOnly && countVisible(laneIndex, k) >= want - 1) {
// The staging extra is for the queue only: no ordinary entry on its account.
return;
} else if (laneBandEmpty(laneIndex)) {
// The far stretch is empty: screen-door in at the edge of the haze.
rear = TRAFFIC_ENTRY_NEAR_Z - random() * TRAFFIC_ENTRY_NEAR_SPAN;
@@ -710,16 +771,87 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
lastSpawn[group] = time;
}
/** Lite: a pass is wanted (chapter-entry cue armed, or a lane-change drought), and the road is running. */
function staging(): boolean {
if (!config.lite || engineOff > 0 || chapter === 'destination') return false;
return time < stageUntil || time - lastLaneChange > TRAFFIC_LITE_DROUGHT_SECONDS;
}
/** Vehicles present in (or bound for) the right lane, ahead of the camera. */
function rightCount(): number {
let count = 0;
for (let i = 0; i < n; i += 1) if (state[i] === TRAFFIC_VISIBLE && home[i] === TRAFFIC_LANE_RIGHT) count += 1;
return count;
}
/** Lite staging: a truck screen-doors in at the haze when none is on its way to the overtake band. */
function stageTruck(): void {
if (time - lastSpawn[1]! < SPAWN_GAP_SECONDS) return;
for (let t = 0; t < n; t += 1) {
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || laneOf(x[t]!) !== TRAFFIC_LANE_RIGHT) continue;
// One already in (or just short of) the convoy window will do; one deep in the haze is too late.
if (z[t]! >= CENTRE_ENTRY_Z || z[t]! <= TRAFFIC_LITE_CONVOY_FAR_Z - TRAFFIC_LITE_STAGE_REACH) continue;
// ...and only if someone will pass it: a car closing on it, or a free car to queue in the window.
if (z[t]! < TRAFFIC_LITE_CONVOY_NEAR_Z && pendingOf(TRAFFIC_CAR) >= 0) return;
for (let c = 0; c < n; c += 1) {
if (!present(c) || kind[c] !== TRAFFIC_CAR || laneOf(x[c]!) !== TRAFFIC_LANE_RIGHT || lateral(c)) continue;
if (z[c]! <= z[t]! || v0[c]! - v[t]! < ENTRY_LEADER_DELTA) continue;
// Closing in time: within reach of it by the time it is at the entry band.
const seconds = Math.max(0, CENTRE_ENTRY_Z - TRAFFIC_LITE_STAGE_BAND - z[t]!) / Math.max(1, hero - v[t]!);
if (z[c]! - length(c) - z[t]! - (v0[c]! - v[t]!) * seconds < TRAFFIC_LITE_STAGE_FOLLOW) return;
}
}
const i = pendingOf(TRAFFIC_TRUCK);
if (i < 0) return;
const { length: len, halfWidth: hw } = TRAFFIC_KINDS[TRAFFIC_TRUCK]!;
const lx = TRAFFIC_LANE_X[TRAFFIC_LANE_RIGHT]!;
let rear = TRAFFIC_LITE_STAGE_Z + random() * TRAFFIC_LITE_STAGE_SPAN;
if (pendingOf(TRAFFIC_CAR) < 0) {
// No car left to queue behind it: slot the truck just ahead of a right-lane car already out there.
let lead = -1;
for (let c = 0; c < n; c += 1) {
if (!present(c) || kind[c] !== TRAFFIC_CAR || laneOf(x[c]!) !== TRAFFIC_LANE_RIGHT || lateral(c)) continue;
const vc = v[c]!;
const ahead = z[c]! - length(c) - TRAFFIC_IDM.S0 - vc * TRAFFIC_IDM.T - Math.max(0, vc - TRAFFIC_SPEEDS.truck * 1.08) * 1.5 - 4;
if (ahead > TRAFFIC_LITE_STAGE_NEAR_Z || ahead < TRAFFIC_LITE_STAGE_Z - TRAFFIC_LITE_STAGE_REACH) continue;
if (lead < 0 || ahead > rear) {
lead = c;
rear = ahead;
}
}
if (lead < 0) return;
rear -= random() * 10;
}
if (!clearAt(i, lx - hw - TRAFFIC_LANE_JITTER, lx + hw + TRAFFIC_LANE_JITTER, rear, len, TRAFFIC_SPEEDS.truck * 1.08)) return;
// Leave the passer room to cut back in ahead of it (as returnRoom will ask), or the pass never starts.
const room = TRAFFIC_KINDS[TRAFFIC_CAR]!.length + 2 * (TRAFFIC_IDM.S0 + TRAFFIC_SPEEDS.rightCar * 1.08 * TRAFFIC_IDM.T);
for (let j = 0; j < n; j += 1) {
if (j === i || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
if (z[j]! < rear && rear - len - z[j]! < room) return;
}
place(i, TRAFFIC_LANE_RIGHT, rear, true);
lastSpawn[1] = time;
}
function spawnAll(initial: boolean): void {
const want = target();
// Lite staging may add one right-lane vehicle over the chapter's want (5 at most).
const stage = !initial && staging() && want.right > 0 && rightCount() <= TRAFFIC_LITE_CAP.right;
if (stage) stageTruck();
const extra = stage && rightCount() <= TRAFFIC_LITE_CAP.right ? 1 : 0;
// Groups: left cars, right trucks, right cars.
const rounds = initial ? 6 : 1;
for (let r = 0; r < rounds; r += 1) {
spawn(0, TRAFFIC_LANE_LEFT, TRAFFIC_CAR, want.left, initial);
// While staging, trucks enter only through stageTruck, and the staged truck's follower has first call on a free car.
if (stage) spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks + extra, initial, true);
// ...and the left lane waits for it while one is still to be queued.
if (!stage || !convoyOpen()) spawn(0, TRAFFIC_LANE_LEFT, TRAFFIC_CAR, want.left, initial);
if (!stage) {
spawn(1, TRAFFIC_LANE_RIGHT, TRAFFIC_TRUCK, want.trucks, initial);
spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks, initial);
}
}
}
function sortOrder(): void {
// Front (most negative rear z) first; pending vehicles last. Insertion sort, n <= 12.
@@ -995,6 +1127,7 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
if (Number.isFinite(input.engineOff)) engineOff = clamp(input.engineOff, 0, 1);
if (Object.prototype.hasOwnProperty.call(TRAFFIC_DENSITY, input.chapter)) chapter = input.chapter;
time += dt;
if (config.lite && input.cue === true) stageUntil = time + TRAFFIC_LITE_STAGE_SECONDS;
if (!primed) {
primed = true;
spawnAll(true);

View File

@@ -124,6 +124,24 @@ export const INDICATOR_GAIN = 2.4;
export const INDICATOR_SWELL = 1.6;
/** Tail lamps swell this much at full high-beam lift (the flash to pass). */
export const FLARE_SWELL = 0.3;
/**
* The flash to pass, by distance from the beam: a faint answer from 18 m, the
* full one only from ~125 m out, held to 250 m and gone by 420 m. Up close a
* flare would read as a bright patch (the lamps are already large there).
* Mirrors the GLSL in the lamp vertex shader and the truck under-run bar.
*/
export function flareReach(d: number): number {
const s = (a: number, b: number) => {
const t = Math.min(1, Math.max(0, (d - a) / (b - a)));
return t * t * (3 - 2 * t);
};
return (0.15 * s(18, 50) + 0.85 * s(90, 125)) * (1 - s(250, 420));
}
const REACH_GLSL = '(0.15 * smoothstep(18.0, 50.0, d) + 0.85 * smoothstep(90.0, 125.0, d)) * (1.0 - smoothstep(250.0, 420.0, d))';
/** A flared lamp brightens by at most this fraction of its own gain (in its own red or amber). */
export const FLARE_GAIN = 0.35;
/** Low tier (no bloom): the lamps carry more of their own glow. */
export const LITE_LAMP_GAIN = 1.3;
const FRONT_POOL_LENGTH = 14;
const PASS_Z = 8;
/** Camera-side vehicles dissolve over this z band as they pass beneath the lens. */
@@ -189,10 +207,11 @@ export function createTraffic(
float lift = smoothstep(0.78, 1.0, uBeamGain);
// Not up close (a near car's lamps would bloom into a bright patch).
float d = distance(centre, uBeamOrigin);
float reach = smoothstep(18.0, 50.0, d) * (1.0 - smoothstep(180.0, 420.0, d));
// Brightness stays where it was (the bloom pass spreads anything
// hotter into a white patch); the flash reads by colour and size.
vFlare = aGain.y * headlightCone(centre) * reach * (0.25 + 1.6 * lift);
float reach = ${REACH_GLSL};
// The lamp answers in its own colour and only a little brighter (the
// bloom pass spreads anything hotter into a white patch); the flash
// reads by size. The 0.7 at rest keeps the resting retro-reflection.
vFlare = aGain.y * headlightCone(centre) * reach * (0.7 + 1.6 * lift);
}
vec4 view = viewMatrix * world;
vDepth = -view.z;
@@ -210,9 +229,8 @@ export function createTraffic(
vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), clamp(vLevel, 0.0, 1.0));
color = mix(color, vec3(1.0, 0.46, 0.06), step(1.5, vLevel));
color = mix(color, vec3(1.0, 0.86, 0.6), step(2.5, vLevel));
// A reflector answering the beam whitens toward the headlight colour.
color = mix(color, vec3(1.0, 0.72, 0.58), clamp(vFlare, 0.0, 1.0) * 0.7);
gl_FragColor = vec4(color * glow * (vGain + vFlare) * far, 1.0);
// A reflector answering the beam flares in its own red or amber.
gl_FragColor = vec4(color * glow * vGain * (1.0 + ${FLARE_GAIN.toFixed(2)} * vFlare) * far, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`;
@@ -235,7 +253,7 @@ export function createTraffic(
vec2 c = vUv - 0.5;
float core = exp(-pow(abs(c.x) * 2.3, 6.0) - pow(abs(c.y) * 2.4, 6.0));
float r = length(c * vec2(1.0, 1.6));
float glow = core * 2.4 + exp(-r * r * 22.0) * 1.2 + exp(-r * r * 5.0) * 0.3;`);
float glow = (core * 2.4 + exp(-r * r * 22.0) * 1.2 + exp(-r * r * 5.0) * 0.3) * ${(lite ? LITE_LAMP_GAIN : 1).toFixed(2)};`);
// Every pow() base is clamped: interpolation can land a fragment a hair
// outside 0..1 (a quad edge-on or clipped by the near plane), and pow() of a
// negative base is NaN. One NaN pixel in the HDR target is spread by the
@@ -320,7 +338,8 @@ export function createTraffic(
// A flash to pass paints the whole rear face from 300 m in.
float lift = smoothstep(0.78, 1.0, uBeamGain);
float beamDistance = distance(vWorld, uBeamOrigin);
color += vec3(0.62, 0.5, 0.4) * rear * headlightCone(vWorld) * (1.0 - smoothstep(120.0, 320.0, beamDistance)) * lift * 0.16;
// Softer up close, so the flash never whitens a near car.
color += vec3(0.62, 0.5, 0.4) * rear * headlightCone(vWorld) * mix(0.35, 1.0, smoothstep(40.0, 110.0, beamDistance)) * (1.0 - smoothstep(200.0, 360.0, beamDistance)) * lift * 0.16;
${
kind === 'car'
? `// Rear window: glass reflects a lavender band of sky.
@@ -334,7 +353,8 @@ export function createTraffic(
float lampBand = exp(-pow(abs(vLocal.y - 1.3) / 0.2, 2.0));
// The under-run bar is a reflector strip: it answers the beam from far off.
float bar = rear * step(0.42, vLocal.y) * step(vLocal.y, 0.56);
color += vec3(1.0, 0.34, 0.22) * bar * headlightCone(vWorld) * smoothstep(18.0, 50.0, beamDistance) * (1.0 - smoothstep(180.0, 420.0, beamDistance)) * (0.2 + 1.4 * lift);`
float d = beamDistance;
color += vec3(1.0, 0.34, 0.22) * bar * headlightCone(vWorld) * ${REACH_GLSL} * (0.2 + 1.4 * lift);`
}
// The tail lamps spill red on the bumper around them.
color += vec3(0.5, 0.02, 0.04) * rear * lampBand * 0.35 * (1.0 + vBrake * 1.5);

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();
/*
* 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) {
// 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);
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).

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { publishBillboard, billboardStops, type BillboardProject } from '@/journey/billboard-progress';
import {
BILLBOARD_DECK_CLEAR_Z,
BILLBOARD_TIMING,
BILLBOARD_Z_FROM,
BILLBOARD_Z_TO,
@@ -9,6 +10,7 @@ import {
billboardZ,
composeScreen,
createBillboard,
deckAhead,
SCREEN_ASPECT,
STANDING,
textureSize,
@@ -156,6 +158,23 @@ describe('billboard world', () => {
expect(board.mesh.visible).toBe(false);
});
it('holds down until the EXIT 05 deck has passed the camera, then rises', () => {
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
// The deck comes down the road from the haze toward the camera (z ~25).
for (let z = -400; z < BILLBOARD_DECK_CLEAR_Z; z += 4) {
board.update(frame('infrastructure', null, { deckZ: z, deckExit: 'selected-builds' }));
expect(board.inspect().rise).toBe(0);
}
// Another chapter's deck, or no deck, never holds it.
expect(deckAhead({ deckZ: -80, deckExit: 'additional-work' })).toBe(false);
expect(deckAhead({ deckZ: null, deckExit: 'selected-builds' })).toBe(false);
const up = run(board, 10, frame('infrastructure', null, { deckZ: BILLBOARD_DECK_CLEAR_Z + 2, deckExit: 'selected-builds' }));
expect(up.at(-1)!.rise).toBe(1);
// Standing (reverse drive back under the deck): it stays up.
const back = run(board, 3, frame('infrastructure', null, { deckZ: -60, deckExit: 'selected-builds', direction: -1 }));
expect(back.at(-1)!.rise).toBe(1);
});
it('keeps the floods off while rising, then lights each project in order and reverses', async () => {
publishBillboard(PROJECTS, STOPS);
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { renderQualityFor } from '@/scene/render-quality';
import { createExpressway, PYLON_CORE_GAIN, PYLON_HALO_GAIN } from '@/scene/world/expressway';
interface Node {
name: string;
count?: number;
material?: { blending: number; fragmentShader?: string };
}
const children = (group: unknown) => (group as { children: Node[] }).children;
const luminance = ([r, g, b]: readonly number[]) => 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
const CYAN = [0.02, 0.78, 0.93] as const;
const MAGENTA = [1.0, 0.025, 0.34] as const;
describe('expressway pylon lamps', () => {
it('draws a two-layer lamp: a core and a soft additive halo for every pylon strip', () => {
const world = createExpressway(new SceneResourceBag(), renderQualityFor('high', 1, false));
const core = children(world.group).find((child) => child.name === 'recessed-pylon-lights')!;
const halo = children(world.group).find((child) => child.name === 'pylon-light-halos')!;
expect(halo.count).toBe(core.count);
expect(halo.material!.blending).toBe(core.material!.blending);
expect(halo.material!.fragmentShader).toContain('across');
});
it('keeps the core under the bloom threshold at full drive energy, so the hue survives the tone curve', () => {
// Core: hue * (1.3 + energy 0.5) * gain; core + halo peak stays under 0.85 luminance too.
for (const hue of [CYAN, MAGENTA]) {
const peak = 1.8 * PYLON_CORE_GAIN;
expect(luminance(hue.map((v) => v * peak))).toBeLessThan(0.85);
expect(luminance(hue.map((v) => v * 1.8 * (PYLON_CORE_GAIN + PYLON_HALO_GAIN)))).toBeLessThan(1.2);
}
expect(PYLON_HALO_GAIN).toBeLessThan(PYLON_CORE_GAIN);
});
});

View File

@@ -2,7 +2,15 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { BEAM_INTENSITY, createRelayMasts, RUNG_FLASH_GAP_SECONDS, relayLayout } from '@/scene/world/relay-masts';
import {
BEAM_INTENSITY,
createRelayMasts,
RUNG_CORE,
RUNG_FLASH_GAP_SECONDS,
RUNG_HALO,
RUNG_HALO_GAIN,
relayLayout,
} from '@/scene/world/relay-masts';
import { publishRelay } from '@/journey/relay-progress';
import { readSwitchboard } from '@/audio/music-pulse';
import { playRelayTone } from '@/audio/relay-tone';
@@ -54,6 +62,35 @@ describe('relay mast layout', () => {
}
});
it('portrait: one large transmitter carries the rungs of every group, bottom-up in group order', () => {
const masts = relayLayout(0.5, counts, false, 390 / 844);
expect(masts).toHaveLength(4);
expect(masts.filter((mast) => mast.shown)).toHaveLength(1);
const [stand] = masts;
// One stand, and it is big: well over twice the design mast.
masts.forEach((mast) => expect([mast.x, mast.z, mast.scale]).toEqual([stand!.x, stand!.z, stand!.scale]));
expect(stand!.height).toBeGreaterThan(2 * relayLayout(0.5, counts)[0]!.height);
// On screen, left of the road and inside the frame (portrait lens: no shift).
const tanHalfWidth = Math.tan((62 / 2) * (Math.PI / 180)) * (390 / 844);
const fx = stand!.x / (tanHalfWidth * (25 - stand!.z));
expect(fx).toBeLessThan(-0.2);
expect(fx).toBeGreaterThan(-0.7);
// Rungs: one per technology, strictly rising through the groups, inside the mast.
const ys = masts.flatMap((mast) => mast.rungs.map((rung) => rung.y));
expect(ys).toHaveLength(counts.reduce((sum, count) => sum + count, 0));
ys.slice(1).forEach((y, i) => expect(y).toBeGreaterThan(ys[i]!));
expect(ys.at(-1)!).toBeLessThan(stand!.height);
});
it('keeps lit rung cores under the tone shoulder and bloom threshold, violet not white', () => {
const luminance = (c: readonly number[]) => 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!;
expect(luminance(RUNG_CORE)).toBeLessThan(0.3);
expect(luminance(RUNG_CORE.map((v, i) => v + RUNG_HALO[i]! * RUNG_HALO_GAIN))).toBeLessThan(0.85);
// Saturated: blue dominates, green is a small fraction (lavender-white would be near-equal channels).
expect(RUNG_CORE[1] / RUNG_CORE[2]).toBeLessThan(0.2);
expect(RUNG_HALO[1] / RUNG_HALO[2]).toBeLessThan(0.2);
});
it('is absent outside the chapter and fades at its edges', () => {
expect(relayLayout(null, counts).every((mast) => mast.presence === 0)).toBe(true);
expect(relayLayout(0, counts).every((mast) => mast.presence === 0)).toBe(true);

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import * as THREE from 'three';
import { SceneResourceBag } from '@/scene/resources';
import { renderQualityFor } from '@/scene/render-quality';
import { NO_POST_EXPOSURE_LIFT, renderQualityFor, tierExposure } from '@/scene/render-quality';
import { createChaseCamera } from '@/scene/world/environment';
import { createCar } from '@/scene/world/car';
import { routeLabelFor } from '@/scene/world/sign';
@@ -187,6 +187,14 @@ describe('scene render quality', () => {
});
});
it('lifts exposure only on the tier without bloom or post, and modestly', () => {
expect(tierExposure(1.12, renderQualityFor('high', 1, false))).toBe(1.12);
expect(tierExposure(1.12, renderQualityFor('medium', 1, false))).toBe(1.12);
expect(tierExposure(1.12, renderQualityFor('low', 1, true))).toBeCloseTo(1.12 * NO_POST_EXPOSURE_LIFT, 6);
expect(NO_POST_EXPOSURE_LIFT).toBeGreaterThan(1);
expect(NO_POST_EXPOSURE_LIFT).toBeLessThanOrEqual(1.25);
});
it('caps mobile and tablet pixel ratio at 1.5 even for High', () => {
expect(renderQualityFor('high', 3, true).pixelRatio).toBe(1.5);
});

View File

@@ -15,6 +15,10 @@ import {
INDICATOR_HZ,
TRAFFIC_ASPHALT_HALF_WIDTH,
TRAFFIC_BEHIND,
TRAFFIC_CONVOY_GAP_MAX,
TRAFFIC_LITE_CONVOY_NEAR_Z,
TRAFFIC_LITE_DROUGHT_SECONDS,
TRAFFIC_LITE_STAGE_SECONDS,
TRAFFIC_DENSITY,
TRAFFIC_ENTRY_FAR_Z,
TRAFFIC_ENTRY_NEAR_Z,
@@ -122,6 +126,8 @@ describe('traffic model: long randomised drives', () => {
hero += (target - hero) * Math.min(1, dt * 3);
if (time >= nextChapter) {
step.chapter = CHAPTERS[Math.floor(random() * CHAPTERS.length)]!;
// A chapter entry: lite stages a pass (the invariants hold through it).
step.cue = true;
nextChapter = time + 20 + random() * 40;
if (random() < 0.4) sweepUntil = time + 0.5 + random();
}
@@ -134,6 +140,7 @@ describe('traffic model: long randomised drives', () => {
step.cameraX = Math.sin(time * 0.05) * 5.6;
step.dt = dt;
const f = model.step(step);
step.cue = false;
const problem = violation(f);
if (problem) throw new Error(`seed ${seed} t ${time.toFixed(2)}: ${problem}`);
const events = model.events();
@@ -489,7 +496,8 @@ describe('traffic model: the interaction is in frame', () => {
const f = model.step(step);
for (let i = 0; i < f.count; i += 1) {
const x = f.active[i] === TRAFFIC_VISIBLE ? f.x[i]! : 9;
if (prev[i]! > 3 && x <= 3) entries.push(f.z[i]!);
// A pull-out from the right lane (not a pooled slot reappearing in another lane).
if (prev[i]! > 3 && prev[i]! !== 9 && x <= 3) entries.push(f.z[i]!);
if (Math.abs(prev[i]!) <= 3 && Math.abs(x) > 3 && x !== 9) exits.push(f.z[i]!);
prev[i] = x;
}
@@ -532,8 +540,11 @@ describe('traffic model: the interaction is in frame', () => {
seen[i] = 1;
if (t === 0) continue;
if (f.z[i]! > TRAFFIC_ENTRY_FAR_Z + 1 && f.z[i]! < TRAFFIC_EXIT_NOSE_Z - 1) {
// Nearer than the far entry: screen-doored in, still in the haze.
expect(f.z[i]!, `vehicle ${i} at ${t.toFixed(1)} s`).toBeLessThan(TRAFFIC_ENTRY_NEAR_Z + 2);
// Nearer than the far entry: screen-doored in, at the haze. Lite
// queues its convoy car (and stages its truck) a little nearer:
// the rear of a car behind a truck at the window's near edge.
const liteNear = TRAFFIC_LITE_CONVOY_NEAR_Z + TRAFFIC_KINDS[0]!.length + TRAFFIC_CONVOY_GAP_MAX;
expect(f.z[i]!, `vehicle ${i} at ${t.toFixed(1)} s`).toBeLessThan(Math.max(TRAFFIC_ENTRY_NEAR_Z, liteNear) + 2);
expect(f.z[i]!).toBeGreaterThan(TRAFFIC_ENTRY_NEAR_Z - TRAFFIC_ENTRY_NEAR_SPAN - 50);
expect(f.dither[i]!).toBeGreaterThan(0.9);
fading[i] = t;
@@ -614,3 +625,79 @@ describe('flash detector', () => {
expect(fired.length).toBe(2);
});
});
describe('traffic model: lite passes (phones)', () => {
const LIVE: EnvironmentChapter[] = ['deep-night', 'systems', 'infrastructure', 'builds', 'pre-dawn'];
it('stages a pass at each chapter entry: a car pulls out within 30 s, 20 s on average', () => {
// A truck staged at the haze takes ~15 s to reach the 150-215 m entry band.
const waits: number[] = [];
for (const seed of [1, 5, 8, 13, 21]) {
const model = createTrafficModel({ pool: 6, seed, lite: true });
const step = input();
for (let t = 0; t < 5; t += 1 / 60) model.step(step);
for (let entry = 0; entry < 8; entry += 1) {
step.chapter = LIVE[(entry + seed) % LIVE.length]!;
step.cue = true;
const before = model.events().laneChanges;
let waited = 0;
while (model.events().laneChanges === before && waited < 40) {
const f = model.step(step);
step.cue = false;
waited += 1 / 60;
const problem = violation(f);
if (problem) throw new Error(`seed ${seed} entry ${entry}: ${problem}`);
}
expect(waited, `seed ${seed} entry ${entry} (${step.chapter})`).toBeLessThan(30);
waits.push(waited);
// Read the chapter for a while before the next entry.
for (let t = 0; t < 12; t += 1 / 60) model.step(step);
}
}
expect(waits.reduce((a, b) => a + b, 0) / waits.length).toBeLessThan(20);
expect(TRAFFIC_LITE_STAGE_SECONDS).toBeGreaterThanOrEqual(15);
});
it('never goes long without a pass at a steady cruise (the drought rule)', () => {
for (const seed of [2, 5, 8, 17, 3, 11, 29, 42]) {
const model = createTrafficModel({ pool: 6, seed, lite: true });
const step = input({ chapter: 'systems' });
let last = 0;
let longest = 0;
let changes = 0;
for (let t = 0; t < 600; t += 1 / 60) {
const f = model.step(step);
const problem = violation(f);
if (problem) throw new Error(`seed ${seed} t ${t.toFixed(1)}: ${problem}`);
if (model.events().laneChanges !== changes) {
changes = model.events().laneChanges;
longest = Math.max(longest, t - last);
last = t;
}
}
longest = Math.max(longest, 600 - last);
// Lane changes count the return too: drought, then ~15 s for a staged truck to reach the band, then the pass.
// (Without staging this runs 82-88 s on these seeds.)
expect(longest, `seed ${seed}`).toBeLessThan(55);
}
expect(TRAFFIC_LITE_DROUGHT_SECONDS).toBeLessThanOrEqual(15);
});
it('staging stays inside the low-tier caps and never with the engine off', () => {
const model = createTrafficModel({ pool: 6, seed: 6, lite: true });
const step = input({ chapter: 'infrastructure' });
let maxRight = 0;
for (let t = 0; t < 300; t += 1 / 60) {
step.cue = Math.floor(t * 60) % 600 === 0;
const f = model.step(step);
let right = 0;
for (let i = 0; i < f.count; i += 1) if (f.active[i] === TRAFFIC_VISIBLE && f.x[i]! > 0) right += 1;
maxRight = Math.max(maxRight, right);
}
expect(maxRight).toBeLessThanOrEqual(5);
const parked = createTrafficModel({ pool: 6, seed: 6, lite: true });
for (let t = 0; t < 60; t += 1 / 60) parked.step(input({ chapter: 'destination', engineOff: 1, heroSpeed: 0, cue: t < 0.02 }));
expect(parked.events().laneChanges).toBe(0);
});
});

View File

@@ -1,5 +1,10 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
FLARE_GAIN,
FLARE_SWELL,
flareReach,
HERO_CAR_HALF_WIDTH,
LAMPS_PER_VEHICLE,
TRAFFIC_BODY,
@@ -107,3 +112,25 @@ describe('expressway traffic stays clear of the hero car', () => {
expect(trafficPoolSize({ postEffects: false, postScale: 1 })).toBe(6);
});
});
describe('the flash to pass', () => {
it('peaks between 120 and 250 m, not within 100 m, and is gone past 420 m', () => {
const peak = Math.max(...Array.from({ length: 500 }, (_, d) => flareReach(d)));
expect(peak).toBe(1);
for (let d = 125; d <= 250; d += 5) expect(flareReach(d)).toBeGreaterThan(0.97);
expect(flareReach(120)).toBeGreaterThan(0.9);
for (let d = 0; d <= 100; d += 2) expect(flareReach(d)).toBeLessThan(0.35);
for (let d = 0; d <= 60; d += 2) expect(flareReach(d)).toBeLessThanOrEqual(0.15);
expect(flareReach(420)).toBe(0);
});
it('flares lamps in their own red and amber, only a little brighter', () => {
const source = readFileSync(resolve(process.cwd(), 'src/scene/world/traffic.ts'), 'utf8');
// No warm-white mix toward the headlight colour.
expect(source).not.toContain('vec3(1.0, 0.72, 0.58)');
expect(source).toContain('vGain * (1.0 + ${FLARE_GAIN.toFixed(2)} * vFlare)');
// At full lift (vFlare <= 0.7 + 1.6), a lamp gains at most ~0.8x of its own level.
expect(FLARE_GAIN * (0.7 + 1.6)).toBeLessThan(0.85);
expect(FLARE_SWELL).toBeLessThanOrEqual(0.3);
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { SceneResourceBag } from '@/scene/resources';
import { createYard, railYaw } from '@/scene/world/yard';
import { STACK_TIERS, STACK_ZS, YARD_STOPS, yardStopsPassed } from '@/journey/yard-progress';
@@ -21,7 +22,7 @@ const nodes = (group: unknown): Node[] => {
const instancedMeshes = (group: unknown) => nodes(group).filter((child) => child.isInstancedMesh === true);
describe('night freight yard', () => {
it('draws the stacks as one InstancedMesh of 4 + 3 + 2 + 1 containers', () => {
it('draws the stacks as one InstancedMesh of 3 + 3 + 2 + 1 containers', () => {
const yard = createYard(new SceneResourceBag(), true);
const stacks = instancedMeshes(yard.group).filter((mesh) => mesh.name === 'yard-stacks');
expect(stacks).toHaveLength(1);
@@ -97,6 +98,51 @@ describe('night freight yard', () => {
expect(yawAt(Number.NaN)).toBeCloseTo(railYaw(), 6);
});
it('aims the rails from the live lens, so the yard holds its screen x while the camera eases', () => {
const yard = createYard(new SceneResourceBag(), true);
const group = yard.group as unknown as InstanceType<typeof THREE.Group>;
const camera = new THREE.PerspectiveCamera(61, 1440 / 900, 0.1, 2000);
const railScreenX = (s: number) => {
group.updateMatrixWorld(true);
return new THREE.Vector3(0, 5, s).applyMatrix4(group.matrixWorld).project(camera).x;
};
// Mid-ease lenses: position, aim, fov and view offset all off their settled values.
const lenses = [
{ x: 6, z: 40, fov: 48, shift: 0.1 },
{ x: 0, z: 30, fov: 55, shift: -0.02 },
{ x: -3.2, z: 24, fov: 61, shift: -0.12 },
];
for (const lens of lenses) {
camera.fov = lens.fov;
camera.setViewOffset(1440, 900, -1440 * lens.shift, 0, 1440, 900);
camera.position.set(lens.x, 30, lens.z);
camera.lookAt(0, 18, -220);
yard.update('additional-work', 0.5, 0, 1 / 60, null, camera.aspect, camera);
for (const s of [-150, -110, -70]) expect(railScreenX(s)).toBeCloseTo(-0.86, 1);
}
});
it('keeps every container square to the road at any rail yaw', () => {
const yard = createYard(new SceneResourceBag(), true);
const stacks = instancedMeshes(yard.group).find((mesh) => mesh.name === 'yard-stacks') as unknown as InstanceType<typeof THREE.InstancedMesh>;
const group = yard.group as unknown as InstanceType<typeof THREE.Group>;
const local = new THREE.Matrix4();
const world = new THREE.Matrix4();
const axis = new THREE.Vector3();
for (const aspect of [1920 / 1080, 1440 / 900, 1024 / 700]) {
yard.update('additional-work', 0.5, 0, 1 / 60, null, aspect);
group.updateMatrixWorld(true);
for (let i = 0; i < stacks.count; i += 1) {
stacks.getMatrixAt(i, local);
world.multiplyMatrices(group.matrixWorld, local);
// The box's long axis (local z) runs down the road: no more than the
// bottom tier's hair of slop (0.75 degrees) off world z.
axis.set(0, 0, 1).transformDirection(world);
expect(Math.abs(Math.atan2(axis.x, axis.z))).toBeLessThan(0.013);
}
}
});
it('frees every geometry and material on dispose', () => {
const resources = new SceneResourceBag();
const yard = createYard(resources, true);