Merge branch 'polish/scene-nits' into redesign/overnight
BIN
docs/overnight/polish/nits-builds-toolkit-hero-before-after.webp
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
docs/overnight/polish/nits-flash-before-after.webp
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/overnight/polish/nits-yard-entry-before-after.webp
Normal file
|
After Width: | Height: | Size: 315 KiB |
BIN
docs/overnight/polish/nits-yard-reading-before-after.webp
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
docs/overnight/polish/phone-low-tier-before-after.webp
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
docs/overnight/polish/phone-poster-before-after.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
@@ -25,7 +25,7 @@ export const CRANE_TO_Z = -62;
|
|||||||
/** The stacks along the rails, one under each stop, far to near. */
|
/** The stacks along the rails, one under each stop, far to near. */
|
||||||
export const STACK_ZS = [-134, -112, -94, -80] as const;
|
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. */
|
/** 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. */
|
/** 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_RAMP_SECONDS = 0.35;
|
||||||
export const LAMP_STRIKE_GAP_SECONDS = 0.6;
|
export const LAMP_STRIKE_GAP_SECONDS = 0.6;
|
||||||
|
|||||||
@@ -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(
|
export function renderQualityFor(
|
||||||
tier: LiveQualityTier,
|
tier: LiveQualityTier,
|
||||||
devicePixelRatio: number,
|
devicePixelRatio: number,
|
||||||
|
|||||||
@@ -310,6 +310,14 @@ function boardGeometry(): { dispose(): void } {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- world
|
// ---------------------------------------------------------------- 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 {
|
export interface BillboardFrame {
|
||||||
section: SectionId;
|
section: SectionId;
|
||||||
/** Selected builds chapter progress, null outside the chapter. */
|
/** Selected builds chapter progress, null outside the chapter. */
|
||||||
@@ -324,6 +332,13 @@ export interface BillboardFrame {
|
|||||||
focus: string | undefined;
|
focus: string | undefined;
|
||||||
/** Tall viewport (camera on the centre line): the board steps in and shrinks. */
|
/** Tall viewport (camera on the centre line): the board steps in and shrinks. */
|
||||||
narrow?: boolean;
|
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;
|
deltaSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +534,7 @@ export function createBillboard(resources: SceneResourceBag, options: BillboardO
|
|||||||
// Up with the workshop, and already standing as the chapter heading
|
// Up with the workshop, and already standing as the chapter heading
|
||||||
// approaches so the entry band shows it whole; down after the chapter.
|
// approaches so the entry band shows it whole; down after the chapter.
|
||||||
const presence = frame.section === 'infrastructure' && frame.near ? 1 : buildsPresence(frame.section);
|
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;
|
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;
|
else if (frame.section !== 'selected-builds') lastP = frame.section === 'additional-work' || frame.section === 'toolkit' || frame.section === 'contact' ? 1 : 0;
|
||||||
const narrow = frame.narrow === true;
|
const narrow = frame.narrow === true;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { JourneySnapshot } from '@/journey/types';
|
|||||||
import { readMusicPulse } from '@/audio/music-pulse';
|
import { readMusicPulse } from '@/audio/music-pulse';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
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 { createHorizonAir } from '@/scene/world/horizon-air';
|
||||||
import { createExpressway } from '@/scene/world/expressway';
|
import { createExpressway } from '@/scene/world/expressway';
|
||||||
import { createDistricts } from '@/scene/world/districts';
|
import { createDistricts } from '@/scene/world/districts';
|
||||||
@@ -112,7 +112,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
|
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
|
||||||
renderer.toneMapping = THREE.CustomToneMapping;
|
renderer.toneMapping = THREE.CustomToneMapping;
|
||||||
renderer.toneMappingExposure = IGNITION.exposure;
|
renderer.toneMappingExposure = tierExposure(IGNITION.exposure, quality);
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.name = 'world-root';
|
scene.name = 'world-root';
|
||||||
@@ -428,7 +428,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
short: readIgnition().returning,
|
short: readIgnition().returning,
|
||||||
});
|
});
|
||||||
const roll = ignited.roll;
|
const roll = ignited.roll;
|
||||||
renderer.toneMappingExposure = ignited.exposure;
|
renderer.toneMappingExposure = tierExposure(ignited.exposure, quality);
|
||||||
if (renderedFrames === 0) canvas.dataset.lit = '';
|
if (renderedFrames === 0) canvas.dataset.lit = '';
|
||||||
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
|
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
|
||||||
// The 03:12 window: section-local scroll progress drives every beat.
|
// 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;
|
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
|
||||||
}
|
}
|
||||||
districts.setServiceState(service.dim, service.relight);
|
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(
|
const relayNudge = relay.update(
|
||||||
snapshot.activeSectionId,
|
snapshot.activeSectionId,
|
||||||
snapshot.activeSectionId === 'toolkit' ? sectionLocalProgress(snapshot, 'toolkit') : null,
|
snapshot.activeSectionId === 'toolkit' ? sectionLocalProgress(snapshot, 'toolkit') : null,
|
||||||
@@ -463,6 +453,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
now,
|
now,
|
||||||
camera.aspect,
|
camera.aspect,
|
||||||
);
|
);
|
||||||
|
// The deck from the previous frame (underpass updates after the sign and the lens).
|
||||||
|
const roadDeck = underpass.inspect();
|
||||||
const billboardNudge = billboard.update({
|
const billboardNudge = billboard.update({
|
||||||
section: snapshot.activeSectionId,
|
section: snapshot.activeSectionId,
|
||||||
p: snapshot.activeSectionId === 'selected-builds'
|
p: snapshot.activeSectionId === 'selected-builds'
|
||||||
@@ -474,6 +466,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
energy: overdrive,
|
energy: overdrive,
|
||||||
focus: document.documentElement.dataset.billboardFocus,
|
focus: document.documentElement.dataset.billboardFocus,
|
||||||
narrow: camera.aspect <= 1.18,
|
narrow: camera.aspect <= 1.18,
|
||||||
|
deckZ: roadDeck.visible ? roadDeck.z : null,
|
||||||
|
deckExit: roadDeck.exit,
|
||||||
deltaSeconds,
|
deltaSeconds,
|
||||||
});
|
});
|
||||||
// END OF SHIFT: parked at the destination, the night ends. One scalar
|
// 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);
|
const stage = signStage.update(width, height);
|
||||||
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
|
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
|
||||||
const gantry = sign.dockState();
|
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.
|
// UNDERPASS: after the sign so the deck holds behind it while it comes in.
|
||||||
const nextRoute = nextSectionId(snapshot.activeSectionId);
|
const nextRoute = nextSectionId(snapshot.activeSectionId);
|
||||||
const passage = underpass.update(
|
const passage = underpass.update(
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
|
|||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
|
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
|
/** 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. */
|
* camera; the entire object moves together, so long beams never tear at a seam. */
|
||||||
export function createExpressway(
|
export function createExpressway(
|
||||||
@@ -24,7 +34,9 @@ export function createExpressway(
|
|||||||
varying vec3 vNormal;
|
varying vec3 vNormal;
|
||||||
varying vec3 vWorld;
|
varying vec3 vWorld;
|
||||||
varying float vDepth;
|
varying float vDepth;
|
||||||
|
varying vec2 vUv;
|
||||||
void main() {
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||||
vec4 p = instanceMatrix * vec4(position, 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;
|
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;
|
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(
|
return resources.own(
|
||||||
'material',
|
'material',
|
||||||
new THREE.ShaderMaterial({
|
new THREE.ShaderMaterial({
|
||||||
@@ -48,13 +60,19 @@ export function createExpressway(
|
|||||||
varying vec3 vNormal;
|
varying vec3 vNormal;
|
||||||
varying vec3 vWorld;
|
varying vec3 vWorld;
|
||||||
varying float vDepth;
|
varying float vDepth;
|
||||||
|
varying vec2 vUv;
|
||||||
void main() {
|
void main() {
|
||||||
float farFade = 1.0 - smoothstep(${fade[0].toFixed(1)}, ${fade[1].toFixed(1)}, vDepth);
|
float farFade = 1.0 - smoothstep(${fade[0].toFixed(1)}, ${fade[1].toFixed(1)}, vDepth);
|
||||||
float nearFade = smoothstep(0.0, 9.0, 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 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);
|
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))));
|
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;' : ''}
|
${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''}
|
||||||
if(farFade * nearFade < 0.02) discard;
|
if(farFade * nearFade < 0.02) discard;
|
||||||
gl_FragColor = vec4(color, farFade * nearFade);
|
gl_FragColor = vec4(color, farFade * nearFade);
|
||||||
@@ -70,11 +88,13 @@ export function createExpressway(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const graphite = material(false);
|
const graphite = material(false);
|
||||||
const cyan = material(true);
|
|
||||||
// Delineator reflectors fade out by ~240 m: beyond that a 0.4 m plate is a
|
// 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).
|
// sub-pixel speck, and the specks read as random orange dots (owner).
|
||||||
const coral = material(true, true, [110, 240]);
|
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 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();
|
const dummy = new THREE.Object3D();
|
||||||
type Part = {
|
type Part = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -89,10 +109,11 @@ export function createExpressway(
|
|||||||
name: string,
|
name: string,
|
||||||
parts: Part[],
|
parts: Part[],
|
||||||
surface: ReturnType<typeof material>,
|
surface: ReturnType<typeof material>,
|
||||||
|
shape: typeof box = box,
|
||||||
) {
|
) {
|
||||||
const mesh = resources.own(
|
const mesh = resources.own(
|
||||||
'other',
|
'other',
|
||||||
new THREE.InstancedMesh(box, surface, parts.length),
|
new THREE.InstancedMesh(shape, surface, parts.length),
|
||||||
);
|
);
|
||||||
mesh.name = name;
|
mesh.name = name;
|
||||||
mesh.frustumCulled = false;
|
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 });
|
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('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);
|
batch('pylon-foundations', feet, graphite);
|
||||||
// Far roadside monolith pairs give the waves an occasional architectural edge.
|
// Far roadside monolith pairs give the waves an occasional architectural edge.
|
||||||
const landmarks: Part[] = [];
|
const landmarks: Part[] = [];
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ const BEAM_WIDTH = 7;
|
|||||||
export const BEAM_INTENSITY = 0.65;
|
export const BEAM_INTENSITY = 0.65;
|
||||||
const SWEEP_SECONDS = 6;
|
const SWEEP_SECONDS = 6;
|
||||||
const PULSE_GAIN = 0.08;
|
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
|
* 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
|
* 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
|
* 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.
|
* viewport aspect, so the masts keep their place on narrow lenses too.
|
||||||
* Portrait (phones): the margins are slivers, so the masts tuck under the
|
* Portrait (phones): the margins are slivers, so four masts would be four
|
||||||
* panel edge instead of leaving the frame.
|
* 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;
|
const LENS = CHAPTER_COMPOSITIONS.toolkit;
|
||||||
export const DESIGN_ASPECT = 1.6;
|
export const DESIGN_ASPECT = 1.6;
|
||||||
@@ -73,6 +82,9 @@ const SLOT_FRAMES = [
|
|||||||
{ z: -130, fx: 0.875, fy: 0.56 },
|
{ z: -130, fx: 0.875, fy: 0.56 },
|
||||||
] as const;
|
] 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 {
|
interface RelaySlot {
|
||||||
z: number;
|
z: number;
|
||||||
side: number;
|
side: number;
|
||||||
@@ -88,6 +100,17 @@ export function relaySlots(aspect: number = DESIGN_ASPECT): RelaySlot[] {
|
|||||||
if (aspect === slotAspect) return slots;
|
if (aspect === slotAspect) return slots;
|
||||||
slotAspect = aspect;
|
slotAspect = aspect;
|
||||||
const portrait = aspect < 1.18;
|
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) => {
|
slots = SLOT_FRAMES.map((slot, i) => {
|
||||||
const side = i % 2 === 0 ? 1 : -1;
|
const side = i % 2 === 0 ? 1 : -1;
|
||||||
const distance = LENS.z - slot.z;
|
const distance = LENS.z - slot.z;
|
||||||
@@ -128,6 +151,8 @@ export interface RelayMast {
|
|||||||
presence: number;
|
presence: number;
|
||||||
/** 0..1: the mast is online (beacon and beam). */
|
/** 0..1: the mast is online (beacon and beam). */
|
||||||
level: number;
|
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[];
|
rungs: RelayRung[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,11 +163,21 @@ export function relayLayout(
|
|||||||
full = true,
|
full = true,
|
||||||
aspect: number = DESIGN_ASPECT,
|
aspect: number = DESIGN_ASPECT,
|
||||||
): RelayMast[] {
|
): 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) => {
|
return relaySlots(aspect).slice(0, counts.length).map((slot, mast) => {
|
||||||
const count = counts[mast] ?? 0;
|
const count = counts[mast] ?? 0;
|
||||||
const presence = p === null ? 0 : relayPresence(p);
|
const presence = p === null ? 0 : relayPresence(p);
|
||||||
const live = presence > 0;
|
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 {
|
return {
|
||||||
|
shown: !single || mast === 0,
|
||||||
x: slot.x,
|
x: slot.x,
|
||||||
z: slot.z,
|
z: slot.z,
|
||||||
scale: slot.scale,
|
scale: slot.scale,
|
||||||
@@ -151,7 +186,7 @@ export function relayLayout(
|
|||||||
presence,
|
presence,
|
||||||
level: !live ? 0 : full ? smooth(RELAY_STOPS[mast]!, RELAY_STOPS[mast]! + 0.03, p!) : 1,
|
level: !live ? 0 : full ? smooth(RELAY_STOPS[mast]!, RELAY_STOPS[mast]! + 0.03, p!) : 1,
|
||||||
rungs: Array.from({ length: count }, (_, rung) => {
|
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 };
|
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 rungGeometry = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
|
||||||
const rungState = stateAttribute(rungGeometry, MAX_RUNGS);
|
const rungState = stateAttribute(rungGeometry, MAX_RUNGS);
|
||||||
const rungMesh = instanced(rungGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ `
|
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).
|
// Lit lamps: a saturated violet core kept below the tone curve's shoulder
|
||||||
vec3 lamp = vec3(0.62, 0.3, 1.0) * 2.1 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse);
|
// (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;
|
vec3 dark = uAccent * 0.12 + vec3(0.02) * key;
|
||||||
// Hover flash: the rung turns ice-white and swells (see FLASH_SWELL).
|
// 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);
|
color = mix(mix(dark, lamp, vState.x), vec3(0.78, 1.0, 1.0) * 2.4, vState.y);
|
||||||
`)), MAX_RUNGS, 'relay-rungs');
|
`)), 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 beaconGeometry = resources.own('geometry', new THREE.OctahedronGeometry(1.6));
|
||||||
const beaconState = stateAttribute(beaconGeometry, SLOT_FRAMES.length);
|
const beaconState = stateAttribute(beaconGeometry, SLOT_FRAMES.length);
|
||||||
const beaconMesh = instanced(beaconGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ `
|
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);
|
color = vec3(1.0, 0.12, 0.08) * (0.12 + 2.2 * vState.x);
|
||||||
`)), SLOT_FRAMES.length, 'relay-beacons');
|
`)), 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.
|
// 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;
|
let beamMesh: ThreeInstancedMesh | null = null;
|
||||||
@@ -407,8 +480,9 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
|
|||||||
let any = false;
|
let any = false;
|
||||||
masts.forEach((mast, m) => {
|
masts.forEach((mast, m) => {
|
||||||
const visible = mast.presence > 0;
|
const visible = mast.presence > 0;
|
||||||
|
const drawn = visible && mast.shown;
|
||||||
any ||= visible;
|
any ||= visible;
|
||||||
scale.setScalar(visible ? mast.scale : 0);
|
scale.setScalar(drawn ? mast.scale : 0);
|
||||||
rotation.identity();
|
rotation.identity();
|
||||||
position.set(mast.x, 0, mast.z);
|
position.set(mast.x, 0, mast.z);
|
||||||
frames.setMatrixAt(m, matrix.compose(position, rotation, scale));
|
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).
|
// 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;
|
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);
|
position.set(mast.x, (MAST_HEIGHT + ANTENNA) * mast.scale, mast.z);
|
||||||
beaconMesh.setMatrixAt(m, matrix.compose(position, rotation, scale));
|
beaconMesh.setMatrixAt(m, matrix.compose(position, rotation, scale));
|
||||||
beaconState.setXYZ(m, beaconLevels[m]!, 0, mast.presence);
|
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) {
|
if (beamMesh !== null && beamState !== null) {
|
||||||
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
|
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
|
||||||
rotation.setFromAxisAngle(forward, sweep);
|
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)));
|
beamMesh.setMatrixAt(m, matrix.compose(position, rotation, scale.set(BEAM_WIDTH * shown, BEAM_LENGTH * shown, 1)));
|
||||||
beamState.setXYZ(m, mast.level, 0, mast.presence);
|
beamState.setXYZ(m, mast.level, 0, mast.presence);
|
||||||
rotation.identity();
|
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);
|
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));
|
rungMesh.setMatrixAt(index, matrix.compose(position, rotation, scale));
|
||||||
rungState.setXYZ(index, rung.lit, flash[index]!, mast.presence);
|
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;
|
index += 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
total = index;
|
total = index;
|
||||||
rungMesh.count = total;
|
rungMesh.count = total;
|
||||||
|
haloMesh.count = total;
|
||||||
frames.count = masts.length;
|
frames.count = masts.length;
|
||||||
beaconMesh.count = masts.length;
|
beaconMesh.count = masts.length;
|
||||||
if (beamMesh !== null) beamMesh.count = masts.length;
|
if (beamMesh !== null) beamMesh.count = masts.length;
|
||||||
group.visible = any;
|
group.visible = any;
|
||||||
if (any) {
|
if (any) {
|
||||||
pulseUniform.value = full ? clamp01(pulse) : 0;
|
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;
|
if (mesh === null) continue;
|
||||||
mesh.instanceMatrix.needsUpdate = true;
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
(mesh.geometry.getAttribute('aState') as ThreeInstancedBufferAttribute).needsUpdate = true;
|
(mesh.geometry.getAttribute('aState') as ThreeInstancedBufferAttribute).needsUpdate = true;
|
||||||
|
|||||||
@@ -74,6 +74,34 @@ export const TRAFFIC_CONVOY_NEAR_Z = -450;
|
|||||||
const CONVOY_FOLLOWER_GAP = 150;
|
const CONVOY_FOLLOWER_GAP = 150;
|
||||||
const CONVOY_GAP_MIN = 30;
|
const CONVOY_GAP_MIN = 30;
|
||||||
const CONVOY_GAP_SPAN = 20;
|
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
|
* 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
|
* (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;
|
cameraX: number;
|
||||||
/** 0..1, parked at the destination. */
|
/** 0..1, parked at the destination. */
|
||||||
engineOff: number;
|
engineOff: number;
|
||||||
|
/** One frame: the reader arrived in a new chapter (lite stages a pass). */
|
||||||
|
cue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrafficFrame {
|
export interface TrafficFrame {
|
||||||
@@ -381,6 +411,9 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
|||||||
let hurried = false;
|
let hurried = false;
|
||||||
let lastManoeuvre = Number.NEGATIVE_INFINITY;
|
let lastManoeuvre = Number.NEGATIVE_INFINITY;
|
||||||
let onsetIndex = 0;
|
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 => {
|
const random = (): number => {
|
||||||
seed = (seed * 16807) % 2147483647;
|
seed = (seed * 16807) % 2147483647;
|
||||||
@@ -409,6 +442,8 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
|||||||
hurried = false;
|
hurried = false;
|
||||||
lastManoeuvre = Number.NEGATIVE_INFINITY;
|
lastManoeuvre = Number.NEGATIVE_INFINITY;
|
||||||
onsetIndex = 0;
|
onsetIndex = 0;
|
||||||
|
lastLaneChange = 0;
|
||||||
|
stageUntil = Number.NEGATIVE_INFINITY;
|
||||||
onsets.fill(Number.NEGATIVE_INFINITY);
|
onsets.fill(Number.NEGATIVE_INFINITY);
|
||||||
lastSpawn.fill(Number.NEGATIVE_INFINITY);
|
lastSpawn.fill(Number.NEGATIVE_INFINITY);
|
||||||
state.fill(TRAFFIC_PENDING);
|
state.fill(TRAFFIC_PENDING);
|
||||||
@@ -514,6 +549,8 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
|||||||
indicatorSince[i] = time;
|
indicatorSince[i] = time;
|
||||||
}
|
}
|
||||||
counters.laneChanges += 1;
|
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). */
|
/** 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;
|
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
|
* 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
|
* 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.
|
* (worth waiting a few seconds for); 0 when there is none.
|
||||||
*/
|
*/
|
||||||
function convoyRear(len: number): number {
|
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;
|
let soon = 0;
|
||||||
for (let t = 0; t < n; t += 1) {
|
for (let t = 0; t < n; t += 1) {
|
||||||
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || lane[t] !== TRAFFIC_LANE_RIGHT || lateral(t)) continue;
|
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || lane[t] !== TRAFFIC_LANE_RIGHT || lateral(t)) continue;
|
||||||
const zt = z[t]!;
|
const zt = z[t]!;
|
||||||
if (zt >= TRAFFIC_CONVOY_NEAR_Z) continue;
|
if (zt >= windowNear) continue;
|
||||||
if (zt <= TRAFFIC_CONVOY_FAR_Z) {
|
if (zt <= windowFar) {
|
||||||
soon = 1;
|
soon = 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let followed = false;
|
let followed = false;
|
||||||
for (let j = 0; j < n; j += 1) {
|
for (let j = 0; j < n; j += 1) {
|
||||||
if (j === t || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
|
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) {
|
if (z[j]! > zt && z[j]! - length(j) - zt < CONVOY_FOLLOWER_GAP) {
|
||||||
followed = true;
|
followed = true;
|
||||||
break;
|
break;
|
||||||
@@ -656,7 +712,8 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One group (lane, kind) spawns at most one vehicle per call. */
|
/** 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 (countVisible(laneIndex, k) >= want) return;
|
||||||
if (!initial && time - lastSpawn[group]! < SPAWN_GAP_SECONDS) return;
|
if (!initial && time - lastSpawn[group]! < SPAWN_GAP_SECONDS) return;
|
||||||
const i = pendingOf(k);
|
const i = pendingOf(k);
|
||||||
@@ -684,6 +741,7 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
|||||||
random() * 30 -
|
random() * 30 -
|
||||||
(k === TRAFFIC_TRUCK ? 15 : 0);
|
(k === TRAFFIC_TRUCK ? 15 : 0);
|
||||||
} else if (base * 1.08 > hero + 0.5) {
|
} else if (base * 1.08 > hero + 0.5) {
|
||||||
|
if (queueOnly && countVisible(laneIndex, k) >= want - 1) return;
|
||||||
rear = TRAFFIC_EXIT_NOSE_Z + len - 0.01;
|
rear = TRAFFIC_EXIT_NOSE_Z + len - 0.01;
|
||||||
} else if (k === TRAFFIC_CAR && laneIndex === TRAFFIC_LANE_RIGHT && (rear = convoyRear(len)) !== 0) {
|
} 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)...
|
// 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;
|
v[i] = TRAFFIC_SPEEDS.truck;
|
||||||
lastSpawn[group] = time;
|
lastSpawn[group] = time;
|
||||||
return;
|
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)) {
|
} else if (laneBandEmpty(laneIndex)) {
|
||||||
// The far stretch is empty: screen-door in at the edge of the haze.
|
// The far stretch is empty: screen-door in at the edge of the haze.
|
||||||
rear = TRAFFIC_ENTRY_NEAR_Z - random() * TRAFFIC_ENTRY_NEAR_SPAN;
|
rear = TRAFFIC_ENTRY_NEAR_Z - random() * TRAFFIC_ENTRY_NEAR_SPAN;
|
||||||
@@ -710,16 +771,87 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
|||||||
lastSpawn[group] = time;
|
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 {
|
function spawnAll(initial: boolean): void {
|
||||||
const want = target();
|
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.
|
// Groups: left cars, right trucks, right cars.
|
||||||
const rounds = initial ? 6 : 1;
|
const rounds = initial ? 6 : 1;
|
||||||
for (let r = 0; r < rounds; r += 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(1, TRAFFIC_LANE_RIGHT, TRAFFIC_TRUCK, want.trucks, initial);
|
||||||
spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks, initial);
|
spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks, initial);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sortOrder(): void {
|
function sortOrder(): void {
|
||||||
// Front (most negative rear z) first; pending vehicles last. Insertion sort, n <= 12.
|
// 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 (Number.isFinite(input.engineOff)) engineOff = clamp(input.engineOff, 0, 1);
|
||||||
if (Object.prototype.hasOwnProperty.call(TRAFFIC_DENSITY, input.chapter)) chapter = input.chapter;
|
if (Object.prototype.hasOwnProperty.call(TRAFFIC_DENSITY, input.chapter)) chapter = input.chapter;
|
||||||
time += dt;
|
time += dt;
|
||||||
|
if (config.lite && input.cue === true) stageUntil = time + TRAFFIC_LITE_STAGE_SECONDS;
|
||||||
if (!primed) {
|
if (!primed) {
|
||||||
primed = true;
|
primed = true;
|
||||||
spawnAll(true);
|
spawnAll(true);
|
||||||
|
|||||||
@@ -124,6 +124,24 @@ export const INDICATOR_GAIN = 2.4;
|
|||||||
export const INDICATOR_SWELL = 1.6;
|
export const INDICATOR_SWELL = 1.6;
|
||||||
/** Tail lamps swell this much at full high-beam lift (the flash to pass). */
|
/** Tail lamps swell this much at full high-beam lift (the flash to pass). */
|
||||||
export const FLARE_SWELL = 0.3;
|
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 FRONT_POOL_LENGTH = 14;
|
||||||
const PASS_Z = 8;
|
const PASS_Z = 8;
|
||||||
/** Camera-side vehicles dissolve over this z band as they pass beneath the lens. */
|
/** 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);
|
float lift = smoothstep(0.78, 1.0, uBeamGain);
|
||||||
// Not up close (a near car's lamps would bloom into a bright patch).
|
// Not up close (a near car's lamps would bloom into a bright patch).
|
||||||
float d = distance(centre, uBeamOrigin);
|
float d = distance(centre, uBeamOrigin);
|
||||||
float reach = smoothstep(18.0, 50.0, d) * (1.0 - smoothstep(180.0, 420.0, d));
|
float reach = ${REACH_GLSL};
|
||||||
// Brightness stays where it was (the bloom pass spreads anything
|
// The lamp answers in its own colour and only a little brighter (the
|
||||||
// hotter into a white patch); the flash reads by colour and size.
|
// bloom pass spreads anything hotter into a white patch); the flash
|
||||||
vFlare = aGain.y * headlightCone(centre) * reach * (0.25 + 1.6 * lift);
|
// 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;
|
vec4 view = viewMatrix * world;
|
||||||
vDepth = -view.z;
|
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));
|
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.46, 0.06), step(1.5, vLevel));
|
||||||
color = mix(color, vec3(1.0, 0.86, 0.6), step(2.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.
|
// A reflector answering the beam flares in its own red or amber.
|
||||||
color = mix(color, vec3(1.0, 0.72, 0.58), clamp(vFlare, 0.0, 1.0) * 0.7);
|
gl_FragColor = vec4(color * glow * vGain * (1.0 + ${FLARE_GAIN.toFixed(2)} * vFlare) * far, 1.0);
|
||||||
gl_FragColor = vec4(color * glow * (vGain + vFlare) * far, 1.0);
|
|
||||||
#include <tonemapping_fragment>
|
#include <tonemapping_fragment>
|
||||||
#include <colorspace_fragment>
|
#include <colorspace_fragment>
|
||||||
}`;
|
}`;
|
||||||
@@ -235,7 +253,7 @@ export function createTraffic(
|
|||||||
vec2 c = vUv - 0.5;
|
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 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 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
|
// 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
|
// 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
|
// 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.
|
// A flash to pass paints the whole rear face from 300 m in.
|
||||||
float lift = smoothstep(0.78, 1.0, uBeamGain);
|
float lift = smoothstep(0.78, 1.0, uBeamGain);
|
||||||
float beamDistance = distance(vWorld, uBeamOrigin);
|
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'
|
kind === 'car'
|
||||||
? `// Rear window: glass reflects a lavender band of sky.
|
? `// 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));
|
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.
|
// 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);
|
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.
|
// 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);
|
color += vec3(0.5, 0.02, 0.04) * rear * lampBand * 0.35 * (1.0 + vBrake * 1.5);
|
||||||
|
|||||||
@@ -19,19 +19,25 @@ import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
|||||||
|
|
||||||
type ThreeBufferGeometry = InstanceType<typeof THREE.BufferGeometry>;
|
type ThreeBufferGeometry = InstanceType<typeof THREE.BufferGeometry>;
|
||||||
type ThreeGroup = InstanceType<typeof THREE.Group>;
|
type ThreeGroup = InstanceType<typeof THREE.Group>;
|
||||||
|
type ThreeCamera = InstanceType<typeof THREE.Camera>;
|
||||||
type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
|
type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
|
||||||
type ThreeInstancedBufferAttribute = InstanceType<typeof THREE.InstancedBufferAttribute>;
|
type ThreeInstancedBufferAttribute = InstanceType<typeof THREE.InstancedBufferAttribute>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIGHT FREIGHT, scene side. A container yard on the free left verge of the
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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 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
|
* 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_W = 5.6;
|
||||||
const BOX_H = 6;
|
const BOX_H = 6;
|
||||||
/** Portal: legs at +- SPAN/2 around the rail line, girders at PORTAL_H, legs +- LEG_Z along the rails. */
|
/** 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 PORTAL_H = 29;
|
||||||
const LEG_Z = 5.5;
|
const LEG_Z = 5.5;
|
||||||
const SPREADER_UP = PORTAL_H - 3;
|
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_ZS = [-124, -88] as const;
|
||||||
const FLOOD_H = 36;
|
const FLOOD_H = 36;
|
||||||
const APRON = { halfWidth: SPAN / 2 + 5, depth: 12, from: CRANE_FROM_Z - 10, to: CRANE_TO_Z + 12 } as const;
|
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;
|
const raw = portrait ? -0.9 : RAIL_NDC - 2 * LENS.shift;
|
||||||
return Math.atan(-raw * tanHalfHeight * aspect);
|
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). */
|
/** 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 DECK_CLEAR = { from: 16, to: 34 } as const;
|
||||||
const PULSE_GAIN = 0.08;
|
const PULSE_GAIN = 0.08;
|
||||||
@@ -243,7 +253,15 @@ export interface YardWorld {
|
|||||||
* Section-local progress of 'additional-work' (null outside), bass pulse,
|
* Section-local progress of 'additional-work' (null outside), bass pulse,
|
||||||
* frame delta, the underpass deck z (null: none) and the viewport aspect.
|
* 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;
|
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.
|
// The group's -z axis is the rail line, pivoting on the chapter lens.
|
||||||
group.position.set(LENS.x, 0, LENS.z);
|
group.position.set(LENS.x, 0, LENS.z);
|
||||||
let yaw = Number.NaN;
|
let yaw = Number.NaN;
|
||||||
const place = (aspect: number) => {
|
let squaredYaw = Number.NaN;
|
||||||
const next = railYaw(aspect);
|
const ray = new THREE.Vector3();
|
||||||
|
const setYaw = (next: number) => {
|
||||||
if (next === yaw) return;
|
if (next === yaw) return;
|
||||||
yaw = next;
|
yaw = next;
|
||||||
group.rotation.y = yaw;
|
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 yardMaterial = own('material', solid({ ...shared, uPresence: yardPresence }, false));
|
||||||
const craneMaterial = own('material', solid({ ...shared, uPresence: cranePresence }, false));
|
const craneMaterial = own('material', solid({ ...shared, uPresence: cranePresence }, false));
|
||||||
const mesh = (geometry: ThreeBufferGeometry, material = yardMaterial) => {
|
const mesh = (geometry: ThreeBufferGeometry, material = yardMaterial) => {
|
||||||
@@ -310,15 +342,26 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
|
|||||||
stacks.name = 'yard-stacks';
|
stacks.name = 'yard-stacks';
|
||||||
stacks.frustumCulled = false;
|
stacks.frustumCulled = false;
|
||||||
const matrix = new THREE.Matrix4();
|
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;
|
let i = 0;
|
||||||
STACK_ZS.forEach((z, s) => {
|
STACK_ZS.forEach((z, s) => {
|
||||||
for (let tier = 0; tier < STACK_TIERS[s]!; tier += 1, i += 1) {
|
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.
|
const slop = tier === 0 ? ((i * 7) % 5 - 2) * 0.006 : 0;
|
||||||
matrix.makeRotationY(((i * 7) % 5 - 2) * 0.012).setPosition(((i * 3) % 4 - 1.5) * 0.18, YARD_Y + tier * BOX_H, z);
|
matrix.makeRotationY(slop - yaw).setPosition(((s * 3) % 4 - 1.5) * 0.12, YARD_Y + tier * BOX_H, z);
|
||||||
stacks.setMatrixAt(i, matrix);
|
stacks.setMatrixAt(i, matrix);
|
||||||
stackState.setXY(i, 0, (s + tier * 3) % 4);
|
stackState.setXY(i, 0, (s + tier * 3) % 4);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
stacks.instanceMatrix.needsUpdate = true;
|
||||||
|
};
|
||||||
|
squareStacks();
|
||||||
group.add(stacks);
|
group.add(stacks);
|
||||||
|
|
||||||
// The crane: portal, trolley, hoist ropes (unit height, hung from the top) and spreader.
|
// 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();
|
const hue = new THREE.Color();
|
||||||
let warm = 0;
|
let warm = 0;
|
||||||
let state: YardInspection = {
|
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 {
|
return {
|
||||||
group,
|
group,
|
||||||
inspect: () => ({ ...state }),
|
inspect: () => ({ ...state }),
|
||||||
update(section, p, beat, deltaSeconds, deckZ = null, aspect = DESIGN_ASPECT) {
|
update(section, p, beat, deltaSeconds, deckZ = null, aspect = DESIGN_ASPECT, camera = null) {
|
||||||
place(Number.isFinite(aspect) && aspect > 0 ? aspect : DESIGN_ASPECT);
|
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 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 edge = (from: number, to: number, v: number) => {
|
||||||
const t = Math.min(1, Math.max(0, (v - from) / (to - from)));
|
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 presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(0.95, 1, live));
|
||||||
const z = craneZ(live ?? 0);
|
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 clear = deckZ === null || !Number.isFinite(deckZ) ? 1 : edge(DECK_CLEAR.from, DECK_CLEAR.to, Math.abs(deckZ - worldZ));
|
||||||
const drop = spreaderDrop(live);
|
const drop = spreaderDrop(live);
|
||||||
// The spreader lands on whichever stack the crane is over (the nearest by rail distance).
|
// The spreader lands on whichever stack the crane is over (the nearest by rail distance).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||||||
import { SceneResourceBag } from '@/scene/resources';
|
import { SceneResourceBag } from '@/scene/resources';
|
||||||
import { publishBillboard, billboardStops, type BillboardProject } from '@/journey/billboard-progress';
|
import { publishBillboard, billboardStops, type BillboardProject } from '@/journey/billboard-progress';
|
||||||
import {
|
import {
|
||||||
|
BILLBOARD_DECK_CLEAR_Z,
|
||||||
BILLBOARD_TIMING,
|
BILLBOARD_TIMING,
|
||||||
BILLBOARD_Z_FROM,
|
BILLBOARD_Z_FROM,
|
||||||
BILLBOARD_Z_TO,
|
BILLBOARD_Z_TO,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
billboardZ,
|
billboardZ,
|
||||||
composeScreen,
|
composeScreen,
|
||||||
createBillboard,
|
createBillboard,
|
||||||
|
deckAhead,
|
||||||
SCREEN_ASPECT,
|
SCREEN_ASPECT,
|
||||||
STANDING,
|
STANDING,
|
||||||
textureSize,
|
textureSize,
|
||||||
@@ -156,6 +158,23 @@ describe('billboard world', () => {
|
|||||||
expect(board.mesh.visible).toBe(false);
|
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 () => {
|
it('keeps the floods off while rising, then lights each project in order and reverses', async () => {
|
||||||
publishBillboard(PROJECTS, STOPS);
|
publishBillboard(PROJECTS, STOPS);
|
||||||
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
|
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
|
||||||
|
|||||||
35
tests/unit/expressway-lamps.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,15 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { SceneResourceBag } from '@/scene/resources';
|
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 { publishRelay } from '@/journey/relay-progress';
|
||||||
import { readSwitchboard } from '@/audio/music-pulse';
|
import { readSwitchboard } from '@/audio/music-pulse';
|
||||||
import { playRelayTone } from '@/audio/relay-tone';
|
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', () => {
|
it('is absent outside the chapter and fades at its edges', () => {
|
||||||
expect(relayLayout(null, counts).every((mast) => mast.presence === 0)).toBe(true);
|
expect(relayLayout(null, counts).every((mast) => mast.presence === 0)).toBe(true);
|
||||||
expect(relayLayout(0, counts).every((mast) => mast.presence === 0)).toBe(true);
|
expect(relayLayout(0, counts).every((mast) => mast.presence === 0)).toBe(true);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { SceneResourceBag } from '@/scene/resources';
|
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 { createChaseCamera } from '@/scene/world/environment';
|
||||||
import { createCar } from '@/scene/world/car';
|
import { createCar } from '@/scene/world/car';
|
||||||
import { routeLabelFor } from '@/scene/world/sign';
|
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', () => {
|
it('caps mobile and tablet pixel ratio at 1.5 even for High', () => {
|
||||||
expect(renderQualityFor('high', 3, true).pixelRatio).toBe(1.5);
|
expect(renderQualityFor('high', 3, true).pixelRatio).toBe(1.5);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
INDICATOR_HZ,
|
INDICATOR_HZ,
|
||||||
TRAFFIC_ASPHALT_HALF_WIDTH,
|
TRAFFIC_ASPHALT_HALF_WIDTH,
|
||||||
TRAFFIC_BEHIND,
|
TRAFFIC_BEHIND,
|
||||||
|
TRAFFIC_CONVOY_GAP_MAX,
|
||||||
|
TRAFFIC_LITE_CONVOY_NEAR_Z,
|
||||||
|
TRAFFIC_LITE_DROUGHT_SECONDS,
|
||||||
|
TRAFFIC_LITE_STAGE_SECONDS,
|
||||||
TRAFFIC_DENSITY,
|
TRAFFIC_DENSITY,
|
||||||
TRAFFIC_ENTRY_FAR_Z,
|
TRAFFIC_ENTRY_FAR_Z,
|
||||||
TRAFFIC_ENTRY_NEAR_Z,
|
TRAFFIC_ENTRY_NEAR_Z,
|
||||||
@@ -122,6 +126,8 @@ describe('traffic model: long randomised drives', () => {
|
|||||||
hero += (target - hero) * Math.min(1, dt * 3);
|
hero += (target - hero) * Math.min(1, dt * 3);
|
||||||
if (time >= nextChapter) {
|
if (time >= nextChapter) {
|
||||||
step.chapter = CHAPTERS[Math.floor(random() * CHAPTERS.length)]!;
|
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;
|
nextChapter = time + 20 + random() * 40;
|
||||||
if (random() < 0.4) sweepUntil = time + 0.5 + random();
|
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.cameraX = Math.sin(time * 0.05) * 5.6;
|
||||||
step.dt = dt;
|
step.dt = dt;
|
||||||
const f = model.step(step);
|
const f = model.step(step);
|
||||||
|
step.cue = false;
|
||||||
const problem = violation(f);
|
const problem = violation(f);
|
||||||
if (problem) throw new Error(`seed ${seed} t ${time.toFixed(2)}: ${problem}`);
|
if (problem) throw new Error(`seed ${seed} t ${time.toFixed(2)}: ${problem}`);
|
||||||
const events = model.events();
|
const events = model.events();
|
||||||
@@ -489,7 +496,8 @@ describe('traffic model: the interaction is in frame', () => {
|
|||||||
const f = model.step(step);
|
const f = model.step(step);
|
||||||
for (let i = 0; i < f.count; i += 1) {
|
for (let i = 0; i < f.count; i += 1) {
|
||||||
const x = f.active[i] === TRAFFIC_VISIBLE ? f.x[i]! : 9;
|
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]!);
|
if (Math.abs(prev[i]!) <= 3 && Math.abs(x) > 3 && x !== 9) exits.push(f.z[i]!);
|
||||||
prev[i] = x;
|
prev[i] = x;
|
||||||
}
|
}
|
||||||
@@ -532,8 +540,11 @@ describe('traffic model: the interaction is in frame', () => {
|
|||||||
seen[i] = 1;
|
seen[i] = 1;
|
||||||
if (t === 0) continue;
|
if (t === 0) continue;
|
||||||
if (f.z[i]! > TRAFFIC_ENTRY_FAR_Z + 1 && f.z[i]! < TRAFFIC_EXIT_NOSE_Z - 1) {
|
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.
|
// Nearer than the far entry: screen-doored in, at the haze. Lite
|
||||||
expect(f.z[i]!, `vehicle ${i} at ${t.toFixed(1)} s`).toBeLessThan(TRAFFIC_ENTRY_NEAR_Z + 2);
|
// 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.z[i]!).toBeGreaterThan(TRAFFIC_ENTRY_NEAR_Z - TRAFFIC_ENTRY_NEAR_SPAN - 50);
|
||||||
expect(f.dither[i]!).toBeGreaterThan(0.9);
|
expect(f.dither[i]!).toBeGreaterThan(0.9);
|
||||||
fading[i] = t;
|
fading[i] = t;
|
||||||
@@ -614,3 +625,79 @@ describe('flash detector', () => {
|
|||||||
expect(fired.length).toBe(2);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
FLARE_GAIN,
|
||||||
|
FLARE_SWELL,
|
||||||
|
flareReach,
|
||||||
HERO_CAR_HALF_WIDTH,
|
HERO_CAR_HALF_WIDTH,
|
||||||
LAMPS_PER_VEHICLE,
|
LAMPS_PER_VEHICLE,
|
||||||
TRAFFIC_BODY,
|
TRAFFIC_BODY,
|
||||||
@@ -107,3 +112,25 @@ describe('expressway traffic stays clear of the hero car', () => {
|
|||||||
expect(trafficPoolSize({ postEffects: false, postScale: 1 })).toBe(6);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as THREE from 'three';
|
||||||
import { SceneResourceBag } from '@/scene/resources';
|
import { SceneResourceBag } from '@/scene/resources';
|
||||||
import { createYard, railYaw } from '@/scene/world/yard';
|
import { createYard, railYaw } from '@/scene/world/yard';
|
||||||
import { STACK_TIERS, STACK_ZS, YARD_STOPS, yardStopsPassed } from '@/journey/yard-progress';
|
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);
|
const instancedMeshes = (group: unknown) => nodes(group).filter((child) => child.isInstancedMesh === true);
|
||||||
|
|
||||||
describe('night freight yard', () => {
|
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 yard = createYard(new SceneResourceBag(), true);
|
||||||
const stacks = instancedMeshes(yard.group).filter((mesh) => mesh.name === 'yard-stacks');
|
const stacks = instancedMeshes(yard.group).filter((mesh) => mesh.name === 'yard-stacks');
|
||||||
expect(stacks).toHaveLength(1);
|
expect(stacks).toHaveLength(1);
|
||||||
@@ -97,6 +98,51 @@ describe('night freight yard', () => {
|
|||||||
expect(yawAt(Number.NaN)).toBeCloseTo(railYaw(), 6);
|
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', () => {
|
it('frees every geometry and material on dispose', () => {
|
||||||
const resources = new SceneResourceBag();
|
const resources = new SceneResourceBag();
|
||||||
const yard = createYard(resources, true);
|
const yard = createYard(resources, true);
|
||||||
|
|||||||