polish: scene nits — live-lens yard, square stacks, deck-clear billboard, two-layer lamps, flare reach, phone traffic and exposure
This commit is contained in:
Binary file not shown.
|
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. */
|
||||
export const STACK_ZS = [-134, -112, -94, -80] as const;
|
||||
/** Containers per stack, far to near: the yard steps down toward the camera so every stack top and the crane stay in view. */
|
||||
export const STACK_TIERS = [4, 3, 2, 1] as const;
|
||||
export const STACK_TIERS = [3, 3, 2, 1] as const;
|
||||
/** Work lamps: no strike ramps faster than this, and no more than one strike per gap. */
|
||||
export const LAMP_RAMP_SECONDS = 0.35;
|
||||
export const LAMP_STRIKE_GAP_SECONDS = 0.6;
|
||||
|
||||
@@ -44,6 +44,18 @@ const settings: Readonly<Record<LiveQualityTier, Omit<RenderQualitySettings, 'pi
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The low tier draws without bloom or the post stack, so the lamps lose their
|
||||
* glow and the frame reads dim on phones: it renders one stop-fraction
|
||||
* brighter instead.
|
||||
*/
|
||||
export const NO_POST_EXPOSURE_LIFT = 1.15;
|
||||
|
||||
/** Tone-mapping exposure for a tier: the base, lifted where there is no post. */
|
||||
export function tierExposure(base: number, quality: Pick<RenderQualitySettings, 'postEffects'>): number {
|
||||
return quality.postEffects ? base : base * NO_POST_EXPOSURE_LIFT;
|
||||
}
|
||||
|
||||
export function renderQualityFor(
|
||||
tier: LiveQualityTier,
|
||||
devicePixelRatio: number,
|
||||
|
||||
@@ -310,6 +310,14 @@ function boardGeometry(): { dispose(): void } {
|
||||
|
||||
// ---------------------------------------------------------------- world
|
||||
|
||||
/** The EXIT 05 deck has passed once it is this far toward the camera (the chase camera sits at z ~25). */
|
||||
export const BILLBOARD_DECK_CLEAR_Z = 10;
|
||||
/** True while the Selected builds underpass deck is on the road and still ahead of the camera. */
|
||||
export function deckAhead(frame: Pick<BillboardFrame, 'deckZ' | 'deckExit'>): boolean {
|
||||
const z = frame.deckZ;
|
||||
return frame.deckExit === 'selected-builds' && typeof z === 'number' && Number.isFinite(z) && z < BILLBOARD_DECK_CLEAR_Z;
|
||||
}
|
||||
|
||||
export interface BillboardFrame {
|
||||
section: SectionId;
|
||||
/** Selected builds chapter progress, null outside the chapter. */
|
||||
@@ -324,6 +332,13 @@ export interface BillboardFrame {
|
||||
focus: string | undefined;
|
||||
/** Tall viewport (camera on the centre line): the board steps in and shrinks. */
|
||||
narrow?: boolean;
|
||||
/**
|
||||
* The underpass deck on the road (z, null: none) and the chapter beyond it.
|
||||
* While the EXIT 05 deck is still ahead of the camera the board does not
|
||||
* start to rise, so the deck's pier never crosses the rising board's edge.
|
||||
*/
|
||||
deckZ?: number | null;
|
||||
deckExit?: SectionId | null;
|
||||
deltaSeconds: number;
|
||||
}
|
||||
|
||||
@@ -519,7 +534,7 @@ export function createBillboard(resources: SceneResourceBag, options: BillboardO
|
||||
// Up with the workshop, and already standing as the chapter heading
|
||||
// approaches so the entry band shows it whole; down after the chapter.
|
||||
const presence = frame.section === 'infrastructure' && frame.near ? 1 : buildsPresence(frame.section);
|
||||
rise = stepDistrictRise(rise, presence, dt);
|
||||
rise = stepDistrictRise(rise, deckAhead(frame) && rise < STANDING ? 0 : presence, dt);
|
||||
if (frame.p !== null) lastP = frame.p;
|
||||
else if (frame.section !== 'selected-builds') lastP = frame.section === 'additional-work' || frame.section === 'toolkit' || frame.section === 'contact' ? 1 : 0;
|
||||
const narrow = frame.narrow === true;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { JourneySnapshot } from '@/journey/types';
|
||||
import { readMusicPulse } from '@/audio/music-pulse';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
||||
import { renderQualityFor } from '@/scene/render-quality';
|
||||
import { renderQualityFor, tierExposure } from '@/scene/render-quality';
|
||||
import { createHorizonAir } from '@/scene/world/horizon-air';
|
||||
import { createExpressway } from '@/scene/world/expressway';
|
||||
import { createDistricts } from '@/scene/world/districts';
|
||||
@@ -112,7 +112,7 @@ export async function createProceduralSceneGeneration(
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
|
||||
renderer.toneMapping = THREE.CustomToneMapping;
|
||||
renderer.toneMappingExposure = IGNITION.exposure;
|
||||
renderer.toneMappingExposure = tierExposure(IGNITION.exposure, quality);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.name = 'world-root';
|
||||
@@ -428,7 +428,7 @@ export async function createProceduralSceneGeneration(
|
||||
short: readIgnition().returning,
|
||||
});
|
||||
const roll = ignited.roll;
|
||||
renderer.toneMappingExposure = ignited.exposure;
|
||||
renderer.toneMappingExposure = tierExposure(ignited.exposure, quality);
|
||||
if (renderedFrames === 0) canvas.dataset.lit = '';
|
||||
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
|
||||
// The 03:12 window: section-local scroll progress drives every beat.
|
||||
@@ -444,16 +444,6 @@ export async function createProceduralSceneGeneration(
|
||||
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
|
||||
}
|
||||
districts.setServiceState(service.dim, service.relight);
|
||||
// The deck from the previous frame (underpass updates after the sign).
|
||||
const yardDeck = underpass.inspect();
|
||||
yard.update(
|
||||
snapshot.activeSectionId,
|
||||
sectionLocalProgress(snapshot, 'additional-work'),
|
||||
musicPulse,
|
||||
deltaSeconds,
|
||||
yardDeck.visible ? yardDeck.z : null,
|
||||
camera.aspect,
|
||||
);
|
||||
const relayNudge = relay.update(
|
||||
snapshot.activeSectionId,
|
||||
snapshot.activeSectionId === 'toolkit' ? sectionLocalProgress(snapshot, 'toolkit') : null,
|
||||
@@ -463,6 +453,8 @@ export async function createProceduralSceneGeneration(
|
||||
now,
|
||||
camera.aspect,
|
||||
);
|
||||
// The deck from the previous frame (underpass updates after the sign and the lens).
|
||||
const roadDeck = underpass.inspect();
|
||||
const billboardNudge = billboard.update({
|
||||
section: snapshot.activeSectionId,
|
||||
p: snapshot.activeSectionId === 'selected-builds'
|
||||
@@ -474,6 +466,8 @@ export async function createProceduralSceneGeneration(
|
||||
energy: overdrive,
|
||||
focus: document.documentElement.dataset.billboardFocus,
|
||||
narrow: camera.aspect <= 1.18,
|
||||
deckZ: roadDeck.visible ? roadDeck.z : null,
|
||||
deckExit: roadDeck.exit,
|
||||
deltaSeconds,
|
||||
});
|
||||
// END OF SHIFT: parked at the destination, the night ends. One scalar
|
||||
@@ -842,6 +836,17 @@ export async function createProceduralSceneGeneration(
|
||||
const stage = signStage.update(width, height);
|
||||
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
|
||||
const gantry = sign.dockState();
|
||||
// NIGHT FREIGHT: after the lens is set, so the rails aim from the live
|
||||
// camera (mid-ease too); the deck is the previous frame's (underpass updates next).
|
||||
yard.update(
|
||||
snapshot.activeSectionId,
|
||||
sectionLocalProgress(snapshot, 'additional-work'),
|
||||
musicPulse,
|
||||
deltaSeconds,
|
||||
roadDeck.visible ? roadDeck.z : null,
|
||||
camera.aspect,
|
||||
camera,
|
||||
);
|
||||
// UNDERPASS: after the sign so the deck holds behind it while it comes in.
|
||||
const nextRoute = nextSectionId(snapshot.activeSectionId);
|
||||
const passage = underpass.update(
|
||||
|
||||
@@ -4,6 +4,16 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
|
||||
|
||||
/**
|
||||
* Pylon lamps are two layers: a core held below the tone curve's shoulder
|
||||
* (so cyan stays cyan and magenta stays magenta instead of toning toward
|
||||
* white) and a soft additive halo in the same saturated hue around it.
|
||||
*/
|
||||
export const PYLON_CORE_GAIN = 0.62;
|
||||
export const PYLON_HALO_GAIN = 0.3;
|
||||
/** Halo quad: this wide (m) and this much longer than the lamp strip. */
|
||||
const PYLON_HALO = { width: 1.1, extra: 0.9 } as const;
|
||||
|
||||
/** A bounded kit of physical roadside objects. Instance origins wrap behind the
|
||||
* camera; the entire object moves together, so long beams never tear at a seam. */
|
||||
export function createExpressway(
|
||||
@@ -24,7 +34,9 @@ export function createExpressway(
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorld;
|
||||
varying float vDepth;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||
vec4 p = instanceMatrix * vec4(position, 1.0);
|
||||
p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z;
|
||||
@@ -36,7 +48,7 @@ export function createExpressway(
|
||||
gl_Position = projectionMatrix * view;
|
||||
}
|
||||
`;
|
||||
function material(luminous: boolean, coral = false, fade: readonly [number, number] = [380, 660]) {
|
||||
function material(luminous: boolean, coral = false, fade: readonly [number, number] = [380, 660], gain = 1, halo = false) {
|
||||
return resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
@@ -48,13 +60,19 @@ export function createExpressway(
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorld;
|
||||
varying float vDepth;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
float farFade = 1.0 - smoothstep(${fade[0].toFixed(1)}, ${fade[1].toFixed(1)}, vDepth);
|
||||
float nearFade = smoothstep(0.0, 9.0, vDepth);
|
||||
vec3 cyan = mix(vec3(1.0, 0.025, 0.34), vec3(0.02, 0.78, 0.93), step(0.0, vWorld.x));
|
||||
vec3 coral = vec3(1.0, 0.18, 0.095);
|
||||
float facing = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.5, 0.9, 0.6))));
|
||||
vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5)` : 'vec3(0.028, 0.012, 0.046) + vec3(0.09, 0.055, 0.14) * pow(max(facing, 0.0), 2.0)'};
|
||||
vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5) * ${gain.toFixed(3)}` : 'vec3(0.028, 0.012, 0.046) + vec3(0.09, 0.055, 0.14) * pow(max(facing, 0.0), 2.0)'};
|
||||
${halo ? `
|
||||
// Soft across the strip, eased in at its ends.
|
||||
vec2 q = abs(vUv - 0.5) * 2.0;
|
||||
float across = clamp(1.0 - q.x, 0.0, 1.0);
|
||||
color *= across * across * (1.0 - smoothstep(0.82, 1.0, q.y));` : ''}
|
||||
${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''}
|
||||
if(farFade * nearFade < 0.02) discard;
|
||||
gl_FragColor = vec4(color, farFade * nearFade);
|
||||
@@ -70,11 +88,13 @@ export function createExpressway(
|
||||
);
|
||||
}
|
||||
const graphite = material(false);
|
||||
const cyan = material(true);
|
||||
// Delineator reflectors fade out by ~240 m: beyond that a 0.4 m plate is a
|
||||
// sub-pixel speck, and the specks read as random orange dots (owner).
|
||||
const coral = material(true, true, [110, 240]);
|
||||
const pylonCore = material(true, false, [380, 660], PYLON_CORE_GAIN);
|
||||
const pylonHalo = material(true, false, [380, 660], PYLON_HALO_GAIN, true);
|
||||
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
|
||||
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
|
||||
const dummy = new THREE.Object3D();
|
||||
type Part = {
|
||||
x: number;
|
||||
@@ -89,10 +109,11 @@ export function createExpressway(
|
||||
name: string,
|
||||
parts: Part[],
|
||||
surface: ReturnType<typeof material>,
|
||||
shape: typeof box = box,
|
||||
) {
|
||||
const mesh = resources.own(
|
||||
'other',
|
||||
new THREE.InstancedMesh(box, surface, parts.length),
|
||||
new THREE.InstancedMesh(shape, surface, parts.length),
|
||||
);
|
||||
mesh.name = name;
|
||||
mesh.frustumCulled = false;
|
||||
@@ -174,7 +195,13 @@ export function createExpressway(
|
||||
feet.push({ x: side * 12.6, y: 0.24, z, sx: 1.1, sy: 0.48, sz: 1.5 });
|
||||
}
|
||||
batch('swept-signal-pylons', fins, graphite);
|
||||
batch('recessed-pylon-lights', lights, cyan);
|
||||
batch('recessed-pylon-lights', lights, pylonCore);
|
||||
batch(
|
||||
'pylon-light-halos',
|
||||
lights.map((part) => ({ ...part, z: part.z + 0.05, sx: PYLON_HALO.width, sy: part.sy + PYLON_HALO.extra, sz: 1 })),
|
||||
pylonHalo,
|
||||
plane,
|
||||
);
|
||||
batch('pylon-foundations', feet, graphite);
|
||||
// Far roadside monolith pairs give the waves an occasional architectural edge.
|
||||
const landmarks: Part[] = [];
|
||||
|
||||
@@ -48,6 +48,13 @@ const BEAM_WIDTH = 7;
|
||||
export const BEAM_INTENSITY = 0.65;
|
||||
const SWEEP_SECONDS = 6;
|
||||
const PULSE_GAIN = 0.08;
|
||||
/** Lit rung core (linear, pre-tonemap): luminance ~0.19, under the 0.85 bloom threshold and the ACES shoulder. */
|
||||
export const RUNG_CORE = [0.34, 0.09, 0.75] as const;
|
||||
/** Rung halo: saturated violet, additive, peak level (the core plus a full halo still tones violet). */
|
||||
export const RUNG_HALO = [0.42, 0.1, 1.0] as const;
|
||||
export const RUNG_HALO_GAIN = 0.32;
|
||||
/** Halo quad size relative to the lamp bar (width, height): a soft ellipse around it. */
|
||||
const HALO_SIZE = { width: 1.5, height: 4.2 } as const;
|
||||
|
||||
/**
|
||||
* Where the masts stand, placed by where they land in the frame (toolkit
|
||||
@@ -60,8 +67,10 @@ const PULSE_GAIN = 0.08;
|
||||
* lands as a fraction of the half-height above the centre: in the upper
|
||||
* third, clear of the nav bar. x and the scale follow from those and the
|
||||
* viewport aspect, so the masts keep their place on narrow lenses too.
|
||||
* Portrait (phones): the margins are slivers, so the masts tuck under the
|
||||
* panel edge instead of leaving the frame.
|
||||
* Portrait (phones): the margins are slivers, so four masts would be four
|
||||
* slivers under the panel edge. Instead one large transmitter stands deep
|
||||
* left of the road (PORTRAIT_STAND) and carries every group's rungs, stacked
|
||||
* in bands bottom-up in group order; only its frame, beacon and beam draw.
|
||||
*/
|
||||
const LENS = CHAPTER_COMPOSITIONS.toolkit;
|
||||
export const DESIGN_ASPECT = 1.6;
|
||||
@@ -73,6 +82,9 @@ const SLOT_FRAMES = [
|
||||
{ z: -130, fx: 0.875, fy: 0.56 },
|
||||
] as const;
|
||||
|
||||
/** Portrait: the one transmitter, as a frame fraction like SLOT_FRAMES (left of the road, beacon high). */
|
||||
const PORTRAIT_STAND = { z: -220, fx: 0.42, fy: 0.78, side: -1 } as const;
|
||||
|
||||
interface RelaySlot {
|
||||
z: number;
|
||||
side: number;
|
||||
@@ -88,6 +100,17 @@ export function relaySlots(aspect: number = DESIGN_ASPECT): RelaySlot[] {
|
||||
if (aspect === slotAspect) return slots;
|
||||
slotAspect = aspect;
|
||||
const portrait = aspect < 1.18;
|
||||
if (portrait) {
|
||||
const distance = LENS.z - PORTRAIT_STAND.z;
|
||||
const stand: RelaySlot = {
|
||||
z: PORTRAIT_STAND.z,
|
||||
side: PORTRAIT_STAND.side,
|
||||
x: PORTRAIT_STAND.side * PORTRAIT_STAND.fx * TAN_HALF_HEIGHT * aspect * distance,
|
||||
scale: (LENS.y + PORTRAIT_STAND.fy * TAN_HALF_HEIGHT * distance) / (MAST_HEIGHT + ANTENNA),
|
||||
};
|
||||
slots = SLOT_FRAMES.map(() => stand);
|
||||
return slots;
|
||||
}
|
||||
slots = SLOT_FRAMES.map((slot, i) => {
|
||||
const side = i % 2 === 0 ? 1 : -1;
|
||||
const distance = LENS.z - slot.z;
|
||||
@@ -128,6 +151,8 @@ export interface RelayMast {
|
||||
presence: number;
|
||||
/** 0..1: the mast is online (beacon and beam). */
|
||||
level: number;
|
||||
/** The mast draws its own frame, beacon and beam (portrait: only the first; the others hang their rungs on it). */
|
||||
shown: boolean;
|
||||
rungs: RelayRung[];
|
||||
}
|
||||
|
||||
@@ -138,11 +163,21 @@ export function relayLayout(
|
||||
full = true,
|
||||
aspect: number = DESIGN_ASPECT,
|
||||
): RelayMast[] {
|
||||
const single = aspect < 1.18;
|
||||
const shared = single ? counts.reduce((sum, value) => sum + value, 0) : 0;
|
||||
let before = 0;
|
||||
return relaySlots(aspect).slice(0, counts.length).map((slot, mast) => {
|
||||
const count = counts[mast] ?? 0;
|
||||
const presence = p === null ? 0 : relayPresence(p);
|
||||
const live = presence > 0;
|
||||
// One transmitter: the groups' rungs stack in bands up the shared mast.
|
||||
const first = before;
|
||||
before += count;
|
||||
const band = (rung: number) => (single
|
||||
? (RUNG_SPAN * (first + rung + 0.5)) / Math.max(1, shared)
|
||||
: (RUNG_SPAN * (rung + 0.5)) / count);
|
||||
return {
|
||||
shown: !single || mast === 0,
|
||||
x: slot.x,
|
||||
z: slot.z,
|
||||
scale: slot.scale,
|
||||
@@ -151,7 +186,7 @@ export function relayLayout(
|
||||
presence,
|
||||
level: !live ? 0 : full ? smooth(RELAY_STOPS[mast]!, RELAY_STOPS[mast]! + 0.03, p!) : 1,
|
||||
rungs: Array.from({ length: count }, (_, rung) => {
|
||||
const y = MAST_HEIGHT * (RUNG_FROM + (RUNG_SPAN * (rung + 0.5)) / count);
|
||||
const y = MAST_HEIGHT * (RUNG_FROM + band(rung));
|
||||
return { y: y * slot.scale, halfWidth: halfWidthAt(y) * slot.scale, lit: !live ? 0 : full ? rungLit(p, mast, rung, count) : 1 };
|
||||
}),
|
||||
};
|
||||
@@ -301,19 +336,57 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
|
||||
const rungGeometry = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
|
||||
const rungState = stateAttribute(rungGeometry, MAX_RUNGS);
|
||||
const rungMesh = instanced(rungGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ `
|
||||
// Lit lamps are a hot, saturated violet (hotter than the bloom threshold, so they glow violet, not white).
|
||||
vec3 lamp = vec3(0.62, 0.3, 1.0) * 2.1 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse);
|
||||
// Lit lamps: a saturated violet core kept below the tone curve's shoulder
|
||||
// (and the bloom threshold), so it reads violet, not lavender-white; the
|
||||
// glow is the additive halo layer (relay-halos), not a blown core.
|
||||
vec3 lamp = vec3(${RUNG_CORE.map((v) => v.toFixed(2)).join(', ')}) * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse);
|
||||
vec3 dark = uAccent * 0.12 + vec3(0.02) * key;
|
||||
// Hover flash: the rung turns ice-white and swells (see FLASH_SWELL).
|
||||
color = mix(mix(dark, lamp, vState.x), vec3(0.78, 1.0, 1.0) * 2.4, vState.y);
|
||||
`)), MAX_RUNGS, 'relay-rungs');
|
||||
|
||||
// The halo: one soft additive ellipse per rung, in front of the lamp bar.
|
||||
const haloGeometry = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
|
||||
const haloState = stateAttribute(haloGeometry, MAX_RUNGS);
|
||||
const haloMesh = instanced(haloGeometry, resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: { uPulse: pulseUniform },
|
||||
vertexShader: /* glsl */ `
|
||||
attribute vec3 aState;
|
||||
varying vec2 vUv;
|
||||
varying float vLevel;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
// Lit and present; a hover flash does not swell the halo (the core flashes).
|
||||
vLevel = aState.x * aState.z;
|
||||
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform float uPulse;
|
||||
varying vec2 vUv;
|
||||
varying float vLevel;
|
||||
void main() {
|
||||
float r = length((vUv - 0.5) * 2.0);
|
||||
float a = clamp(1.0 - r, 0.0, 1.0);
|
||||
a *= a;
|
||||
vec3 color = vec3(${RUNG_HALO.map((v) => v.toFixed(2)).join(', ')}) * a * vLevel * ${RUNG_HALO_GAIN.toFixed(2)} * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
fog: false,
|
||||
})), MAX_RUNGS, 'relay-halos');
|
||||
|
||||
const beaconGeometry = resources.own('geometry', new THREE.OctahedronGeometry(1.6));
|
||||
const beaconState = stateAttribute(beaconGeometry, SLOT_FRAMES.length);
|
||||
const beaconMesh = instanced(beaconGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ `
|
||||
color = vec3(1.0, 0.12, 0.08) * (0.12 + 2.2 * vState.x);
|
||||
`)), SLOT_FRAMES.length, 'relay-beacons');
|
||||
group.add(frames, rungMesh, beaconMesh);
|
||||
group.add(frames, rungMesh, haloMesh, beaconMesh);
|
||||
|
||||
// The sky beam: a thin additive quad from the beacon, leaning in over the road and sweeping every 6 s.
|
||||
let beamMesh: ThreeInstancedMesh | null = null;
|
||||
@@ -407,8 +480,9 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
|
||||
let any = false;
|
||||
masts.forEach((mast, m) => {
|
||||
const visible = mast.presence > 0;
|
||||
const drawn = visible && mast.shown;
|
||||
any ||= visible;
|
||||
scale.setScalar(visible ? mast.scale : 0);
|
||||
scale.setScalar(drawn ? mast.scale : 0);
|
||||
rotation.identity();
|
||||
position.set(mast.x, 0, mast.z);
|
||||
frames.setMatrixAt(m, matrix.compose(position, rotation, scale));
|
||||
@@ -416,7 +490,7 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
|
||||
|
||||
// A soft 0.5 Hz blink while online (lite: steady).
|
||||
const blink = full ? Math.pow(0.5 + 0.5 * Math.cos(2 * Math.PI * BEACON_HZ * time + m * 1.3), 3) : 0.6;
|
||||
beaconLevels[m] = visible ? mast.level * (0.2 + 0.8 * blink) : 0;
|
||||
beaconLevels[m] = drawn ? mast.level * (0.2 + 0.8 * blink) : 0;
|
||||
position.set(mast.x, (MAST_HEIGHT + ANTENNA) * mast.scale, mast.z);
|
||||
beaconMesh.setMatrixAt(m, matrix.compose(position, rotation, scale));
|
||||
beaconState.setXYZ(m, beaconLevels[m]!, 0, mast.presence);
|
||||
@@ -424,7 +498,7 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
|
||||
if (beamMesh !== null && beamState !== null) {
|
||||
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
|
||||
rotation.setFromAxisAngle(forward, sweep);
|
||||
const shown = visible && mast.level > 0 ? mast.scale : 0;
|
||||
const shown = drawn && mast.level > 0 ? mast.scale : 0;
|
||||
beamMesh.setMatrixAt(m, matrix.compose(position, rotation, scale.set(BEAM_WIDTH * shown, BEAM_LENGTH * shown, 1)));
|
||||
beamState.setXYZ(m, mast.level, 0, mast.presence);
|
||||
rotation.identity();
|
||||
@@ -446,18 +520,23 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
|
||||
scale.set(visible ? rung.halfWidth * 2.3 * (1 + FLASH_SWELL.width * f) : 0, 0.9 * s * (1 + FLASH_SWELL.height * f), 0.6 * s);
|
||||
rungMesh.setMatrixAt(index, matrix.compose(position, rotation, scale));
|
||||
rungState.setXYZ(index, rung.lit, flash[index]!, mast.presence);
|
||||
position.z += 0.3 * s;
|
||||
scale.set(visible ? rung.halfWidth * 2.3 * HALO_SIZE.width : 0, 0.9 * s * HALO_SIZE.height, 1);
|
||||
haloMesh.setMatrixAt(index, matrix.compose(position, rotation, scale));
|
||||
haloState.setXYZ(index, rung.lit, 0, mast.presence);
|
||||
index += 1;
|
||||
}
|
||||
});
|
||||
total = index;
|
||||
rungMesh.count = total;
|
||||
haloMesh.count = total;
|
||||
frames.count = masts.length;
|
||||
beaconMesh.count = masts.length;
|
||||
if (beamMesh !== null) beamMesh.count = masts.length;
|
||||
group.visible = any;
|
||||
if (any) {
|
||||
pulseUniform.value = full ? clamp01(pulse) : 0;
|
||||
for (const mesh of [frames, rungMesh, beaconMesh, beamMesh]) {
|
||||
for (const mesh of [frames, rungMesh, haloMesh, beaconMesh, beamMesh]) {
|
||||
if (mesh === null) continue;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
(mesh.geometry.getAttribute('aState') as ThreeInstancedBufferAttribute).needsUpdate = true;
|
||||
|
||||
@@ -74,6 +74,34 @@ export const TRAFFIC_CONVOY_NEAR_Z = -450;
|
||||
const CONVOY_FOLLOWER_GAP = 150;
|
||||
const CONVOY_GAP_MIN = 30;
|
||||
const CONVOY_GAP_SPAN = 20;
|
||||
/** The farthest a queued car's rear sits behind its truck's rear, past its own length (m). */
|
||||
export const TRAFFIC_CONVOY_GAP_MAX = CONVOY_GAP_MIN + CONVOY_GAP_SPAN;
|
||||
/**
|
||||
* Lite (6-vehicle pool, phones): the convoy window is nearer, so a queued
|
||||
* pair reaches the overtake band in ~10-17 s at cruise instead of ~25 s.
|
||||
*/
|
||||
export const TRAFFIC_LITE_CONVOY_FAR_Z = -420;
|
||||
export const TRAFFIC_LITE_CONVOY_NEAR_Z = -330;
|
||||
/**
|
||||
* Lite: a pass is staged at each chapter entry (TrafficInput.cue) for up to
|
||||
* TRAFFIC_LITE_STAGE_SECONDS, and whenever the road has gone this long with
|
||||
* no lane change: a truck screen-doors in at the haze (if none is coming)
|
||||
* and one right car more than the chapter wants may queue behind it. The
|
||||
* first lane change ends the staging.
|
||||
*/
|
||||
export const TRAFFIC_LITE_DROUGHT_SECONDS = 10;
|
||||
export const TRAFFIC_LITE_STAGE_SECONDS = 20;
|
||||
/** Where a staged truck's rear enters: [Z, Z + SPAN], inside the lite convoy window. */
|
||||
export const TRAFFIC_LITE_STAGE_Z = -410;
|
||||
export const TRAFFIC_LITE_STAGE_SPAN = 40;
|
||||
/** Lite staging: a right-lane truck further out than the window by this much does not count as on its way. */
|
||||
export const TRAFFIC_LITE_STAGE_REACH = 140;
|
||||
/** Lite staging: a truck on its way counts only with a passer that will be this close behind it... */
|
||||
const TRAFFIC_LITE_STAGE_FOLLOW = 60;
|
||||
/** ...by the time it is this far into the entry band. */
|
||||
const TRAFFIC_LITE_STAGE_BAND = 30;
|
||||
/** Lite staging ahead of a car already out there: no nearer than this (rear z; it screen-doors in). */
|
||||
export const TRAFFIC_LITE_STAGE_NEAR_Z = -260;
|
||||
/**
|
||||
* A vehicle whose nose has passed this z has gone beneath the chase camera
|
||||
* (z 23-26, y >= 5.6) and out of every chapter's frame; it is still simulated
|
||||
@@ -245,6 +273,8 @@ export interface TrafficInput {
|
||||
cameraX: number;
|
||||
/** 0..1, parked at the destination. */
|
||||
engineOff: number;
|
||||
/** One frame: the reader arrived in a new chapter (lite stages a pass). */
|
||||
cue?: boolean;
|
||||
}
|
||||
|
||||
export interface TrafficFrame {
|
||||
@@ -381,6 +411,9 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
||||
let hurried = false;
|
||||
let lastManoeuvre = Number.NEGATIVE_INFINITY;
|
||||
let onsetIndex = 0;
|
||||
/** Lite pass staging: the last lane change, and the end of a chapter-entry cue. */
|
||||
let lastLaneChange = 0;
|
||||
let stageUntil = Number.NEGATIVE_INFINITY;
|
||||
|
||||
const random = (): number => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
@@ -409,6 +442,8 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
||||
hurried = false;
|
||||
lastManoeuvre = Number.NEGATIVE_INFINITY;
|
||||
onsetIndex = 0;
|
||||
lastLaneChange = 0;
|
||||
stageUntil = Number.NEGATIVE_INFINITY;
|
||||
onsets.fill(Number.NEGATIVE_INFINITY);
|
||||
lastSpawn.fill(Number.NEGATIVE_INFINITY);
|
||||
state.fill(TRAFFIC_PENDING);
|
||||
@@ -514,6 +549,8 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
||||
indicatorSince[i] = time;
|
||||
}
|
||||
counters.laneChanges += 1;
|
||||
lastLaneChange = time;
|
||||
stageUntil = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
/** A change already under way finishes at the swerve pace (progress is kept: no jump in x). */
|
||||
@@ -592,24 +629,43 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
||||
state[i] = TRAFFIC_VISIBLE;
|
||||
}
|
||||
|
||||
/** Lite: a right-lane truck in the convoy window with no car queued behind it yet. */
|
||||
function convoyOpen(): boolean {
|
||||
for (let t = 0; t < n; t += 1) {
|
||||
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || lane[t] !== TRAFFIC_LANE_RIGHT || lateral(t)) continue;
|
||||
if (z[t]! >= TRAFFIC_LITE_CONVOY_NEAR_Z || z[t]! <= TRAFFIC_LITE_CONVOY_FAR_Z) continue;
|
||||
let followed = false;
|
||||
for (let j = 0; j < n; j += 1) {
|
||||
if (j === t || !present(j) || kind[j] !== TRAFFIC_CAR || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
|
||||
if (z[j]! > z[t]! && z[j]! - length(j) - z[t]! < CONVOY_FOLLOWER_GAP) followed = true;
|
||||
}
|
||||
if (!followed) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rear z (< 0) for a car queued behind an unfollowed right-lane truck in
|
||||
* the convoy window; 1 while such a truck is still short of the window
|
||||
* (worth waiting a few seconds for); 0 when there is none.
|
||||
*/
|
||||
function convoyRear(len: number): number {
|
||||
const windowFar = config.lite ? TRAFFIC_LITE_CONVOY_FAR_Z : TRAFFIC_CONVOY_FAR_Z;
|
||||
const windowNear = config.lite ? TRAFFIC_LITE_CONVOY_NEAR_Z : TRAFFIC_CONVOY_NEAR_Z;
|
||||
let soon = 0;
|
||||
for (let t = 0; t < n; t += 1) {
|
||||
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || lane[t] !== TRAFFIC_LANE_RIGHT || lateral(t)) continue;
|
||||
const zt = z[t]!;
|
||||
if (zt >= TRAFFIC_CONVOY_NEAR_Z) continue;
|
||||
if (zt <= TRAFFIC_CONVOY_FAR_Z) {
|
||||
if (zt >= windowNear) continue;
|
||||
if (zt <= windowFar) {
|
||||
soon = 1;
|
||||
continue;
|
||||
}
|
||||
let followed = false;
|
||||
for (let j = 0; j < n; j += 1) {
|
||||
if (j === t || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
|
||||
// (Lite: a truck behind is no passer; its own pass is already under way.)
|
||||
if (config.lite && kind[j] === TRAFFIC_TRUCK) continue;
|
||||
if (z[j]! > zt && z[j]! - length(j) - zt < CONVOY_FOLLOWER_GAP) {
|
||||
followed = true;
|
||||
break;
|
||||
@@ -656,7 +712,8 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
||||
}
|
||||
|
||||
/** One group (lane, kind) spawns at most one vehicle per call. */
|
||||
function spawn(group: number, laneIndex: number, k: number, want: number, initial: boolean): void {
|
||||
/** `queueOnly`: the call carries a staging extra, which may only join a truck's queue. */
|
||||
function spawn(group: number, laneIndex: number, k: number, want: number, initial: boolean, queueOnly = false): void {
|
||||
if (countVisible(laneIndex, k) >= want) return;
|
||||
if (!initial && time - lastSpawn[group]! < SPAWN_GAP_SECONDS) return;
|
||||
const i = pendingOf(k);
|
||||
@@ -684,6 +741,7 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
||||
random() * 30 -
|
||||
(k === TRAFFIC_TRUCK ? 15 : 0);
|
||||
} else if (base * 1.08 > hero + 0.5) {
|
||||
if (queueOnly && countVisible(laneIndex, k) >= want - 1) return;
|
||||
rear = TRAFFIC_EXIT_NOSE_Z + len - 0.01;
|
||||
} else if (k === TRAFFIC_CAR && laneIndex === TRAFFIC_LANE_RIGHT && (rear = convoyRear(len)) !== 0) {
|
||||
// A truck is about to reach the window: wait for it (rear > 0)...
|
||||
@@ -694,6 +752,9 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
||||
v[i] = TRAFFIC_SPEEDS.truck;
|
||||
lastSpawn[group] = time;
|
||||
return;
|
||||
} else if (queueOnly && countVisible(laneIndex, k) >= want - 1) {
|
||||
// The staging extra is for the queue only: no ordinary entry on its account.
|
||||
return;
|
||||
} else if (laneBandEmpty(laneIndex)) {
|
||||
// The far stretch is empty: screen-door in at the edge of the haze.
|
||||
rear = TRAFFIC_ENTRY_NEAR_Z - random() * TRAFFIC_ENTRY_NEAR_SPAN;
|
||||
@@ -710,14 +771,85 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
||||
lastSpawn[group] = time;
|
||||
}
|
||||
|
||||
/** Lite: a pass is wanted (chapter-entry cue armed, or a lane-change drought), and the road is running. */
|
||||
function staging(): boolean {
|
||||
if (!config.lite || engineOff > 0 || chapter === 'destination') return false;
|
||||
return time < stageUntil || time - lastLaneChange > TRAFFIC_LITE_DROUGHT_SECONDS;
|
||||
}
|
||||
|
||||
/** Vehicles present in (or bound for) the right lane, ahead of the camera. */
|
||||
function rightCount(): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < n; i += 1) if (state[i] === TRAFFIC_VISIBLE && home[i] === TRAFFIC_LANE_RIGHT) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Lite staging: a truck screen-doors in at the haze when none is on its way to the overtake band. */
|
||||
function stageTruck(): void {
|
||||
if (time - lastSpawn[1]! < SPAWN_GAP_SECONDS) return;
|
||||
for (let t = 0; t < n; t += 1) {
|
||||
if (!present(t) || kind[t] !== TRAFFIC_TRUCK || laneOf(x[t]!) !== TRAFFIC_LANE_RIGHT) continue;
|
||||
// One already in (or just short of) the convoy window will do; one deep in the haze is too late.
|
||||
if (z[t]! >= CENTRE_ENTRY_Z || z[t]! <= TRAFFIC_LITE_CONVOY_FAR_Z - TRAFFIC_LITE_STAGE_REACH) continue;
|
||||
// ...and only if someone will pass it: a car closing on it, or a free car to queue in the window.
|
||||
if (z[t]! < TRAFFIC_LITE_CONVOY_NEAR_Z && pendingOf(TRAFFIC_CAR) >= 0) return;
|
||||
for (let c = 0; c < n; c += 1) {
|
||||
if (!present(c) || kind[c] !== TRAFFIC_CAR || laneOf(x[c]!) !== TRAFFIC_LANE_RIGHT || lateral(c)) continue;
|
||||
if (z[c]! <= z[t]! || v0[c]! - v[t]! < ENTRY_LEADER_DELTA) continue;
|
||||
// Closing in time: within reach of it by the time it is at the entry band.
|
||||
const seconds = Math.max(0, CENTRE_ENTRY_Z - TRAFFIC_LITE_STAGE_BAND - z[t]!) / Math.max(1, hero - v[t]!);
|
||||
if (z[c]! - length(c) - z[t]! - (v0[c]! - v[t]!) * seconds < TRAFFIC_LITE_STAGE_FOLLOW) return;
|
||||
}
|
||||
}
|
||||
const i = pendingOf(TRAFFIC_TRUCK);
|
||||
if (i < 0) return;
|
||||
const { length: len, halfWidth: hw } = TRAFFIC_KINDS[TRAFFIC_TRUCK]!;
|
||||
const lx = TRAFFIC_LANE_X[TRAFFIC_LANE_RIGHT]!;
|
||||
let rear = TRAFFIC_LITE_STAGE_Z + random() * TRAFFIC_LITE_STAGE_SPAN;
|
||||
if (pendingOf(TRAFFIC_CAR) < 0) {
|
||||
// No car left to queue behind it: slot the truck just ahead of a right-lane car already out there.
|
||||
let lead = -1;
|
||||
for (let c = 0; c < n; c += 1) {
|
||||
if (!present(c) || kind[c] !== TRAFFIC_CAR || laneOf(x[c]!) !== TRAFFIC_LANE_RIGHT || lateral(c)) continue;
|
||||
const vc = v[c]!;
|
||||
const ahead = z[c]! - length(c) - TRAFFIC_IDM.S0 - vc * TRAFFIC_IDM.T - Math.max(0, vc - TRAFFIC_SPEEDS.truck * 1.08) * 1.5 - 4;
|
||||
if (ahead > TRAFFIC_LITE_STAGE_NEAR_Z || ahead < TRAFFIC_LITE_STAGE_Z - TRAFFIC_LITE_STAGE_REACH) continue;
|
||||
if (lead < 0 || ahead > rear) {
|
||||
lead = c;
|
||||
rear = ahead;
|
||||
}
|
||||
}
|
||||
if (lead < 0) return;
|
||||
rear -= random() * 10;
|
||||
}
|
||||
if (!clearAt(i, lx - hw - TRAFFIC_LANE_JITTER, lx + hw + TRAFFIC_LANE_JITTER, rear, len, TRAFFIC_SPEEDS.truck * 1.08)) return;
|
||||
// Leave the passer room to cut back in ahead of it (as returnRoom will ask), or the pass never starts.
|
||||
const room = TRAFFIC_KINDS[TRAFFIC_CAR]!.length + 2 * (TRAFFIC_IDM.S0 + TRAFFIC_SPEEDS.rightCar * 1.08 * TRAFFIC_IDM.T);
|
||||
for (let j = 0; j < n; j += 1) {
|
||||
if (j === i || !present(j) || laneOf(x[j]!) !== TRAFFIC_LANE_RIGHT) continue;
|
||||
if (z[j]! < rear && rear - len - z[j]! < room) return;
|
||||
}
|
||||
place(i, TRAFFIC_LANE_RIGHT, rear, true);
|
||||
lastSpawn[1] = time;
|
||||
}
|
||||
|
||||
function spawnAll(initial: boolean): void {
|
||||
const want = target();
|
||||
// Lite staging may add one right-lane vehicle over the chapter's want (5 at most).
|
||||
const stage = !initial && staging() && want.right > 0 && rightCount() <= TRAFFIC_LITE_CAP.right;
|
||||
if (stage) stageTruck();
|
||||
const extra = stage && rightCount() <= TRAFFIC_LITE_CAP.right ? 1 : 0;
|
||||
// Groups: left cars, right trucks, right cars.
|
||||
const rounds = initial ? 6 : 1;
|
||||
for (let r = 0; r < rounds; r += 1) {
|
||||
spawn(0, TRAFFIC_LANE_LEFT, TRAFFIC_CAR, want.left, initial);
|
||||
spawn(1, TRAFFIC_LANE_RIGHT, TRAFFIC_TRUCK, want.trucks, initial);
|
||||
spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks, initial);
|
||||
// While staging, trucks enter only through stageTruck, and the staged truck's follower has first call on a free car.
|
||||
if (stage) spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks + extra, initial, true);
|
||||
// ...and the left lane waits for it while one is still to be queued.
|
||||
if (!stage || !convoyOpen()) spawn(0, TRAFFIC_LANE_LEFT, TRAFFIC_CAR, want.left, initial);
|
||||
if (!stage) {
|
||||
spawn(1, TRAFFIC_LANE_RIGHT, TRAFFIC_TRUCK, want.trucks, initial);
|
||||
spawn(2, TRAFFIC_LANE_RIGHT, TRAFFIC_CAR, want.right - want.trucks, initial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,6 +1127,7 @@ export function createTrafficModel(config: TrafficConfig): TrafficModel {
|
||||
if (Number.isFinite(input.engineOff)) engineOff = clamp(input.engineOff, 0, 1);
|
||||
if (Object.prototype.hasOwnProperty.call(TRAFFIC_DENSITY, input.chapter)) chapter = input.chapter;
|
||||
time += dt;
|
||||
if (config.lite && input.cue === true) stageUntil = time + TRAFFIC_LITE_STAGE_SECONDS;
|
||||
if (!primed) {
|
||||
primed = true;
|
||||
spawnAll(true);
|
||||
|
||||
@@ -124,6 +124,24 @@ export const INDICATOR_GAIN = 2.4;
|
||||
export const INDICATOR_SWELL = 1.6;
|
||||
/** Tail lamps swell this much at full high-beam lift (the flash to pass). */
|
||||
export const FLARE_SWELL = 0.3;
|
||||
/**
|
||||
* The flash to pass, by distance from the beam: a faint answer from 18 m, the
|
||||
* full one only from ~125 m out, held to 250 m and gone by 420 m. Up close a
|
||||
* flare would read as a bright patch (the lamps are already large there).
|
||||
* Mirrors the GLSL in the lamp vertex shader and the truck under-run bar.
|
||||
*/
|
||||
export function flareReach(d: number): number {
|
||||
const s = (a: number, b: number) => {
|
||||
const t = Math.min(1, Math.max(0, (d - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
return (0.15 * s(18, 50) + 0.85 * s(90, 125)) * (1 - s(250, 420));
|
||||
}
|
||||
const REACH_GLSL = '(0.15 * smoothstep(18.0, 50.0, d) + 0.85 * smoothstep(90.0, 125.0, d)) * (1.0 - smoothstep(250.0, 420.0, d))';
|
||||
/** A flared lamp brightens by at most this fraction of its own gain (in its own red or amber). */
|
||||
export const FLARE_GAIN = 0.35;
|
||||
/** Low tier (no bloom): the lamps carry more of their own glow. */
|
||||
export const LITE_LAMP_GAIN = 1.3;
|
||||
const FRONT_POOL_LENGTH = 14;
|
||||
const PASS_Z = 8;
|
||||
/** Camera-side vehicles dissolve over this z band as they pass beneath the lens. */
|
||||
@@ -189,10 +207,11 @@ export function createTraffic(
|
||||
float lift = smoothstep(0.78, 1.0, uBeamGain);
|
||||
// Not up close (a near car's lamps would bloom into a bright patch).
|
||||
float d = distance(centre, uBeamOrigin);
|
||||
float reach = smoothstep(18.0, 50.0, d) * (1.0 - smoothstep(180.0, 420.0, d));
|
||||
// Brightness stays where it was (the bloom pass spreads anything
|
||||
// hotter into a white patch); the flash reads by colour and size.
|
||||
vFlare = aGain.y * headlightCone(centre) * reach * (0.25 + 1.6 * lift);
|
||||
float reach = ${REACH_GLSL};
|
||||
// The lamp answers in its own colour and only a little brighter (the
|
||||
// bloom pass spreads anything hotter into a white patch); the flash
|
||||
// reads by size. The 0.7 at rest keeps the resting retro-reflection.
|
||||
vFlare = aGain.y * headlightCone(centre) * reach * (0.7 + 1.6 * lift);
|
||||
}
|
||||
vec4 view = viewMatrix * world;
|
||||
vDepth = -view.z;
|
||||
@@ -210,9 +229,8 @@ export function createTraffic(
|
||||
vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), clamp(vLevel, 0.0, 1.0));
|
||||
color = mix(color, vec3(1.0, 0.46, 0.06), step(1.5, vLevel));
|
||||
color = mix(color, vec3(1.0, 0.86, 0.6), step(2.5, vLevel));
|
||||
// A reflector answering the beam whitens toward the headlight colour.
|
||||
color = mix(color, vec3(1.0, 0.72, 0.58), clamp(vFlare, 0.0, 1.0) * 0.7);
|
||||
gl_FragColor = vec4(color * glow * (vGain + vFlare) * far, 1.0);
|
||||
// A reflector answering the beam flares in its own red or amber.
|
||||
gl_FragColor = vec4(color * glow * vGain * (1.0 + ${FLARE_GAIN.toFixed(2)} * vFlare) * far, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}`;
|
||||
@@ -235,7 +253,7 @@ export function createTraffic(
|
||||
vec2 c = vUv - 0.5;
|
||||
float core = exp(-pow(abs(c.x) * 2.3, 6.0) - pow(abs(c.y) * 2.4, 6.0));
|
||||
float r = length(c * vec2(1.0, 1.6));
|
||||
float glow = core * 2.4 + exp(-r * r * 22.0) * 1.2 + exp(-r * r * 5.0) * 0.3;`);
|
||||
float glow = (core * 2.4 + exp(-r * r * 22.0) * 1.2 + exp(-r * r * 5.0) * 0.3) * ${(lite ? LITE_LAMP_GAIN : 1).toFixed(2)};`);
|
||||
// Every pow() base is clamped: interpolation can land a fragment a hair
|
||||
// outside 0..1 (a quad edge-on or clipped by the near plane), and pow() of a
|
||||
// negative base is NaN. One NaN pixel in the HDR target is spread by the
|
||||
@@ -320,7 +338,8 @@ export function createTraffic(
|
||||
// A flash to pass paints the whole rear face from 300 m in.
|
||||
float lift = smoothstep(0.78, 1.0, uBeamGain);
|
||||
float beamDistance = distance(vWorld, uBeamOrigin);
|
||||
color += vec3(0.62, 0.5, 0.4) * rear * headlightCone(vWorld) * (1.0 - smoothstep(120.0, 320.0, beamDistance)) * lift * 0.16;
|
||||
// Softer up close, so the flash never whitens a near car.
|
||||
color += vec3(0.62, 0.5, 0.4) * rear * headlightCone(vWorld) * mix(0.35, 1.0, smoothstep(40.0, 110.0, beamDistance)) * (1.0 - smoothstep(200.0, 360.0, beamDistance)) * lift * 0.16;
|
||||
${
|
||||
kind === 'car'
|
||||
? `// Rear window: glass reflects a lavender band of sky.
|
||||
@@ -334,7 +353,8 @@ export function createTraffic(
|
||||
float lampBand = exp(-pow(abs(vLocal.y - 1.3) / 0.2, 2.0));
|
||||
// The under-run bar is a reflector strip: it answers the beam from far off.
|
||||
float bar = rear * step(0.42, vLocal.y) * step(vLocal.y, 0.56);
|
||||
color += vec3(1.0, 0.34, 0.22) * bar * headlightCone(vWorld) * smoothstep(18.0, 50.0, beamDistance) * (1.0 - smoothstep(180.0, 420.0, beamDistance)) * (0.2 + 1.4 * lift);`
|
||||
float d = beamDistance;
|
||||
color += vec3(1.0, 0.34, 0.22) * bar * headlightCone(vWorld) * ${REACH_GLSL} * (0.2 + 1.4 * lift);`
|
||||
}
|
||||
// The tail lamps spill red on the bumper around them.
|
||||
color += vec3(0.5, 0.02, 0.04) * rear * lampBand * 0.35 * (1.0 + vBrake * 1.5);
|
||||
|
||||
@@ -19,19 +19,25 @@ import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
||||
|
||||
type ThreeBufferGeometry = InstanceType<typeof THREE.BufferGeometry>;
|
||||
type ThreeGroup = InstanceType<typeof THREE.Group>;
|
||||
type ThreeCamera = InstanceType<typeof THREE.Camera>;
|
||||
type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
|
||||
type ThreeInstancedBufferAttribute = InstanceType<typeof THREE.InstancedBufferAttribute>;
|
||||
|
||||
/**
|
||||
* NIGHT FREIGHT, scene side. A container yard on the free left verge of the
|
||||
* Additional work chapter (the reading panel covers 15-95% of the width): four
|
||||
* stacks (four, three, two and one container, far to near) on a raised apron
|
||||
* stacks (three, three, two and one container, far to near) on a raised apron
|
||||
* along a pair of crane rails, two high-mast floods, and a rail-mounted gantry
|
||||
* crane that rides the chapter. The rails are a straight siding that runs from
|
||||
* the camera's pivot into the distance at the angle that keeps the whole yard
|
||||
* at the same screen x for the viewport aspect (RAIL_NDC: the middle of the
|
||||
* left margin), so every stop, plate and the crane stay inside the margin at
|
||||
* any aspect, and the stacks step up with distance so none hides the next. Placements are pure
|
||||
* any aspect, and the stacks step up with distance so none hides the next.
|
||||
* With the live camera passed in, the rails are aimed from the camera's
|
||||
* current lens every frame (position, fov and view offset mid-ease), so the
|
||||
* yard holds its screen x while the lens settles into the chapter instead of
|
||||
* sliding in with the world. The containers (and the spreader) stay square to
|
||||
* the road whatever the rail yaw, so a stack never reads as leaning. Placements are pure
|
||||
* functions of section-local progress (journey/yard-progress.ts). At each stop
|
||||
* the spreader lowers onto the stack, the top container's end plate lights in
|
||||
* the chapter accent and the two work lamps strike. The floods warm up from
|
||||
@@ -53,11 +59,13 @@ const BOX_L = 14;
|
||||
const BOX_W = 5.6;
|
||||
const BOX_H = 6;
|
||||
/** Portal: legs at +- SPAN/2 around the rail line, girders at PORTAL_H, legs +- LEG_Z along the rails. */
|
||||
const SPAN = 12;
|
||||
/* 14 m: a road-square 14 m box seen across a ~30 degree rail yaw is 11.9 m wide between the legs. */
|
||||
const SPAN = 14;
|
||||
const PORTAL_H = 29;
|
||||
const LEG_Z = 5.5;
|
||||
const SPREADER_UP = PORTAL_H - 3;
|
||||
const FLOOD_X = -8.5;
|
||||
/** Clear of the girder ends (SPAN + 3) / 2 as the crane passes the masts. */
|
||||
const FLOOD_X = -10.5;
|
||||
const FLOOD_ZS = [-124, -88] as const;
|
||||
const FLOOD_H = 36;
|
||||
const APRON = { halfWidth: SPAN / 2 + 5, depth: 12, from: CRANE_FROM_Z - 10, to: CRANE_TO_Z + 12 } as const;
|
||||
@@ -72,6 +80,8 @@ export function railYaw(aspect: number = DESIGN_ASPECT): number {
|
||||
const raw = portrait ? -0.9 : RAIL_NDC - 2 * LENS.shift;
|
||||
return Math.atan(-raw * tanHalfHeight * aspect);
|
||||
}
|
||||
/** Screen x (NDC, after the lens shift) the rails aim at on a portrait viewport (no lens shift there). */
|
||||
const PORTRAIT_NDC = -0.9;
|
||||
/** The crane hides (screen door) while a deck is this close along the road (m). */
|
||||
const DECK_CLEAR = { from: 16, to: 34 } as const;
|
||||
const PULSE_GAIN = 0.08;
|
||||
@@ -243,7 +253,15 @@ export interface YardWorld {
|
||||
* Section-local progress of 'additional-work' (null outside), bass pulse,
|
||||
* frame delta, the underpass deck z (null: none) and the viewport aspect.
|
||||
*/
|
||||
update(section: SectionId, p: number | null, pulse: number, deltaSeconds: number, deckZ?: number | null, aspect?: number): void;
|
||||
update(
|
||||
section: SectionId,
|
||||
p: number | null,
|
||||
pulse: number,
|
||||
deltaSeconds: number,
|
||||
deckZ?: number | null,
|
||||
aspect?: number,
|
||||
camera?: ThreeCamera | null,
|
||||
): void;
|
||||
inspect(): YardInspection;
|
||||
}
|
||||
|
||||
@@ -261,13 +279,27 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
|
||||
// The group's -z axis is the rail line, pivoting on the chapter lens.
|
||||
group.position.set(LENS.x, 0, LENS.z);
|
||||
let yaw = Number.NaN;
|
||||
const place = (aspect: number) => {
|
||||
const next = railYaw(aspect);
|
||||
let squaredYaw = Number.NaN;
|
||||
const ray = new THREE.Vector3();
|
||||
const setYaw = (next: number) => {
|
||||
if (next === yaw) return;
|
||||
yaw = next;
|
||||
group.rotation.y = yaw;
|
||||
};
|
||||
place(DESIGN_ASPECT);
|
||||
const place = (aspect: number, camera: ThreeCamera | null) => {
|
||||
if (camera === null) {
|
||||
group.position.set(LENS.x, 0, LENS.z);
|
||||
setYaw(railYaw(aspect));
|
||||
return;
|
||||
}
|
||||
// The live lens: the vertical plane through the camera and the ray at the
|
||||
// rail's screen x projects to that x, whatever the ease is doing.
|
||||
camera.updateMatrixWorld();
|
||||
ray.set(aspect < 1.18 ? PORTRAIT_NDC : RAIL_NDC, 0, 0.5).unproject(camera).sub(camera.position);
|
||||
group.position.set(camera.position.x, 0, camera.position.z);
|
||||
if (ray.x * ray.x + ray.z * ray.z > 1e-9) setYaw(Math.atan2(-ray.x, -ray.z));
|
||||
};
|
||||
place(DESIGN_ASPECT, null);
|
||||
const yardMaterial = own('material', solid({ ...shared, uPresence: yardPresence }, false));
|
||||
const craneMaterial = own('material', solid({ ...shared, uPresence: cranePresence }, false));
|
||||
const mesh = (geometry: ThreeBufferGeometry, material = yardMaterial) => {
|
||||
@@ -310,15 +342,26 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
|
||||
stacks.name = 'yard-stacks';
|
||||
stacks.frustumCulled = false;
|
||||
const matrix = new THREE.Matrix4();
|
||||
let i = 0;
|
||||
STACK_ZS.forEach((z, s) => {
|
||||
for (let tier = 0; tier < STACK_TIERS[s]!; tier += 1, i += 1) {
|
||||
// A little stacking slop so the column reads as boxes, not a slab.
|
||||
matrix.makeRotationY(((i * 7) % 5 - 2) * 0.012).setPosition(((i * 3) % 4 - 1.5) * 0.18, YARD_Y + tier * BOX_H, z);
|
||||
stacks.setMatrixAt(i, matrix);
|
||||
stackState.setXY(i, 0, (s + tier * 3) % 4);
|
||||
}
|
||||
});
|
||||
/*
|
||||
* The stacks counter-turn the rail yaw so every box is square to the road
|
||||
* (end plate to the camera). Only the bottom tier keeps a hair of stacking
|
||||
* slop, so the column reads as boxes and the upper tiers sit plumb.
|
||||
*/
|
||||
const squareStacks = () => {
|
||||
if (squaredYaw === yaw) return;
|
||||
squaredYaw = yaw;
|
||||
let i = 0;
|
||||
STACK_ZS.forEach((z, s) => {
|
||||
for (let tier = 0; tier < STACK_TIERS[s]!; tier += 1, i += 1) {
|
||||
const slop = tier === 0 ? ((i * 7) % 5 - 2) * 0.006 : 0;
|
||||
matrix.makeRotationY(slop - yaw).setPosition(((s * 3) % 4 - 1.5) * 0.12, YARD_Y + tier * BOX_H, z);
|
||||
stacks.setMatrixAt(i, matrix);
|
||||
stackState.setXY(i, 0, (s + tier * 3) % 4);
|
||||
}
|
||||
});
|
||||
stacks.instanceMatrix.needsUpdate = true;
|
||||
};
|
||||
squareStacks();
|
||||
group.add(stacks);
|
||||
|
||||
// The crane: portal, trolley, hoist ropes (unit height, hung from the top) and spreader.
|
||||
@@ -373,14 +416,19 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
|
||||
const hue = new THREE.Color();
|
||||
let warm = 0;
|
||||
let state: YardInspection = {
|
||||
visible: false, presence: 0, cranePresence: 0, craneZ: craneZ(0), craneWorldZ: LENS.z + craneZ(0) * Math.cos(yaw), spreader: 0, litPlates: 0, lamp: 0, strikes: 0, flood: 0,
|
||||
visible: false, presence: 0, cranePresence: 0, craneZ: craneZ(0), craneWorldZ: group.position.z + craneZ(0) * Math.cos(yaw), spreader: 0, litPlates: 0, lamp: 0, strikes: 0, flood: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
group,
|
||||
inspect: () => ({ ...state }),
|
||||
update(section, p, beat, deltaSeconds, deckZ = null, aspect = DESIGN_ASPECT) {
|
||||
place(Number.isFinite(aspect) && aspect > 0 ? aspect : DESIGN_ASPECT);
|
||||
update(section, p, beat, deltaSeconds, deckZ = null, aspect = DESIGN_ASPECT, camera = null) {
|
||||
place(Number.isFinite(aspect) && aspect > 0 ? aspect : DESIGN_ASPECT, camera);
|
||||
if (group.visible || section === 'additional-work') {
|
||||
squareStacks();
|
||||
spreader.rotation.y = -yaw;
|
||||
ropes.rotation.y = -yaw;
|
||||
}
|
||||
const live = section === 'additional-work' && p !== null && Number.isFinite(p) ? Math.min(1, Math.max(0, p)) : null;
|
||||
const edge = (from: number, to: number, v: number) => {
|
||||
const t = Math.min(1, Math.max(0, (v - from) / (to - from)));
|
||||
@@ -388,7 +436,7 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
|
||||
};
|
||||
const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(0.95, 1, live));
|
||||
const z = craneZ(live ?? 0);
|
||||
const worldZ = LENS.z + z * Math.cos(yaw);
|
||||
const worldZ = group.position.z + z * Math.cos(yaw);
|
||||
const clear = deckZ === null || !Number.isFinite(deckZ) ? 1 : edge(DECK_CLEAR.from, DECK_CLEAR.to, Math.abs(deckZ - worldZ));
|
||||
const drop = spreaderDrop(live);
|
||||
// The spreader lands on whichever stack the crane is over (the nearest by rail distance).
|
||||
|
||||
Reference in New Issue
Block a user