feat: route log odometer and lit mile markers for profile and experience

This commit is contained in:
2026-10-11 06:15:36 +03:00
parent b6e22a1299
commit 19f3ce79e6
29 changed files with 932 additions and 26 deletions

View File

@@ -36,6 +36,8 @@ import {
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { createHeadlights } from '@/scene/world/headlights';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { createMilestones } from '@/scene/world/milestones';
import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
import { dawnStep, type DawnStep } from '@/journey/dawn';
import { dawnLightUniforms } from '@/scene/world/dawn-light';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
@@ -169,8 +171,13 @@ export async function createProceduralSceneGeneration(
// the star drain (uniform writes); the warm rims and the bloom change are
// high/medium fine-pointer only.
const dawnRims = maintenanceTier === 'full';
// MILE MARKERS: kilometre posts roll past through Experience and Profile.
// Low tier and touch draw them without beam, pulse or flash.
const milestones = createMilestones(context.resources, maintenanceTier === 'full');
scene.add(
maintenance.group,
milestones.mesh,
milestones.pool,
atmosphere.group,
road.group,
expressway.group,
@@ -351,6 +358,14 @@ export async function createProceduralSceneGeneration(
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
}
districts.setServiceState(service.dim, service.relight);
milestones.update(
snapshot.activeSectionId,
isMilestoneSection(snapshot.activeSectionId)
? sectionLocalProgress(snapshot, snapshot.activeSectionId)
: null,
musicPulse,
deltaSeconds,
);
// END OF SHIFT: parked at the destination, the night ends. One scalar
// drives it all; leaving rewinds it as fast as the outro returns.
const dawn = easeInOutCubic(outroProgress);

View File

@@ -0,0 +1,317 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import { isMilestoneSection, MILESTONE_COUNTS, passPoint, type MilestoneSection } from '@/journey/route-log';
import type { SceneResourceBag } from '@/scene/resources';
import { BEAM_PARS, beamUniforms } from './headlights';
/**
* MILE MARKERS, scene side. Each role (Experience) or screening fact
* (Profile) is a lit kilometre post on the shoulder of the side the reading
* panel leaves free. Its position is a pure function of section-local scroll
* progress, so scrolling back rolls the posts back. One InstancedMesh, one
* draw call: a dark post, a pale retro-reflective cap plate that catches the
* high beam, and a chapter-accent emissive stripe that breathes +8% with the
* bass. A post flashes for 300 ms as its plate crosses the activation line,
* which is the moment it reaches the camera: the post is at its largest, at
* the bottom edge of the frame, and the odometer ticks as it goes by. A second
* additive mesh lays a warm light pool on the shoulder under each post.
* Low tier and touch draw the posts without beam, pulse or flash.
*/
/**
* Where a post stands as its plate crosses the line. The camera sits at z ~25
* with the frame shifted toward the panel, so the free shoulder leaves the
* frame's side edge about 35 m ahead of the camera: the post goes by there,
* at its largest, as the odometer ticks.
*/
export const MILESTONE_PASS_Z = -12;
/** Metres of road per unit of travel: the next post is RUN / count metres up the road. */
export const MILESTONE_RUN = 200;
export const MILESTONE_FAR_Z = MILESTONE_PASS_Z - MILESTONE_RUN / 2;
export const MILESTONE_NEAR_Z = MILESTONE_PASS_Z + MILESTONE_RUN / 2;
/** The free shoulder: the Experience panel is on the left, Profile's on the right. */
export const MILESTONE_SIDE: Readonly<Record<MilestoneSection, number>> = { experience: 9.5, profile: -9.5 };
/* The world is drawn ~2.3x life size (the road is 20 m wide), so a 2.8 m post is 6.4 m here. */
const POST_W = 0.4;
const POST_H = 6.4;
const PLATE_W = 2.2;
const PLATE_H = 1.0;
/** The pool of light each post throws on the shoulder (x across the road, z along it). */
const POOL_W = 8;
const POOL_L = 14;
/** Plates turn this far toward the road (rad). */
const PLATE_YAW = 0.3;
const FLASH_SECONDS = 0.3;
/** One flash per post at most every 0.6 s, however fast the scroll (photosensitivity). */
const FLASH_GAP_SECONDS = 0.6;
const PULSE_GAIN = 0.08;
/** The cap plate's resting glow: it reads as lit retro-reflective sheeting at night. */
const PLATE_GLOW = 0.42;
const SECTIONS = Object.keys(MILESTONE_COUNTS) as MilestoneSection[];
const INSTANCES = SECTIONS.reduce((sum, id) => sum + MILESTONE_COUNTS[id], 0);
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
const smooth = (from: number, to: number, value: number): number => {
const t = clamp01((value - from) / (to - from));
return t * t * (3 - 2 * t);
};
/** Travel parameter of post i: 0.5 exactly when its plate is on the line. */
export const milestoneTravel = (p: number, index: number, count: number): number =>
p - passPoint(index, count) + 0.5;
export const milestoneZ = (p: number, index: number, count: number): number =>
THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, milestoneTravel(p, index, count));
/** The chapter fades the posts in and out at its edges. */
export const milestoneChapterFade = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p));
/** A post rises out of the haze far up the road and leaves once it is past the camera. */
export const milestoneRangeFade = (z: number): number => smooth(-190, -130, z) * (1 - smooth(10, 22, z));
export interface MilestonePost {
section: MilestoneSection;
index: number;
x: number;
z: number;
/** 0..1 screen-door cover: chapter edges and the far end of the run. */
presence: number;
}
/** Every post's place for this frame; posts of other chapters are absent (presence 0). */
export function milestoneLayout(section: SectionId, p: number | null): MilestonePost[] {
const posts: MilestonePost[] = [];
for (const id of SECTIONS) {
const count = MILESTONE_COUNTS[id];
for (let index = 0; index < count; index += 1) {
const t = p === null ? 0 : milestoneTravel(p, index, count);
const live = p !== null && id === section;
const z = THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, t);
posts.push({
section: id,
index,
x: MILESTONE_SIDE[id],
z,
presence: live ? milestoneChapterFade(p) * milestoneRangeFade(z) : 0,
});
}
}
return posts;
}
function box(width: number, height: number, depth: number, x: number, y: number, z: number, part: number) {
const geometry = new THREE.BoxGeometry(width, height, depth);
geometry.translate(x, y, z);
return { geometry, part };
}
/** Post, plate and stripes merged into one indexed geometry with a part id. */
function postGeometry() {
const pieces = [
box(POST_W, POST_H, POST_W, 0, POST_H / 2, 0, 0),
// The number plate, framed top and bottom by the chapter's accent.
box(PLATE_W, PLATE_H, 0.08, 0, POST_H + PLATE_H / 2 - 0.12, 0.18, 1),
box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H + PLATE_H - 0.06, 0.18, 2),
box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H - 0.18, 0.18, 2),
// Two reflector bands down the post, like a real kilometre post.
box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.62, 0, 2),
box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.3, 0, 2),
];
const positions: number[] = [];
const normals: number[] = [];
const parts: number[] = [];
const indices: number[] = [];
for (const { geometry, part } of pieces) {
const offset = positions.length / 3;
const position = geometry.getAttribute('position');
positions.push(...position.array);
normals.push(...geometry.getAttribute('normal').array);
for (let i = 0; i < position.count; i += 1) parts.push(part);
indices.push(...Array.from(geometry.getIndex().array as ArrayLike<number>, (i) => i + offset));
geometry.dispose();
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
merged.setIndex(indices);
return merged;
}
export interface MilestonesWorld {
readonly mesh: { visible: boolean; count: number };
/** The additive light pools on the shoulder, one per post. */
readonly pool: { visible: boolean; count: number };
/** Section-local progress (null outside the chapter) and the smoothed bass pulse (0..1). */
update(section: SectionId, p: number | null, pulse: number, deltaSeconds: number): void;
/** Test hook: the current flash level of each instance. */
flashes(): readonly number[];
}
export function createMilestones(resources: SceneResourceBag, full: boolean): MilestonesWorld {
const geometry = resources.own('geometry', postGeometry());
// x: flash 0..1, y: presence 0..1.
const state = new THREE.InstancedBufferAttribute(new Float32Array(INSTANCES * 2), 2);
state.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute('aState', state);
const accent = new THREE.Color(SIGN_ACCENTS.experience);
const pulseUniform = { value: 0 };
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uAccent: { value: accent }, uPulse: pulseUniform, ...(full ? beamUniforms : {}) },
vertexShader: /* glsl */ `
attribute float aPart;
attribute vec2 aState;
varying float vPart;
varying vec2 vState;
varying vec3 vWorld;
varying vec3 vNormal;
void main() {
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
vWorld = world.xyz;
vNormal = normalize(mat3(modelMatrix * instanceMatrix) * normal);
vPart = aPart;
vState = aState;
gl_Position = projectionMatrix * viewMatrix * world;
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uAccent;
uniform float uPulse;
varying float vPart;
varying vec2 vState;
varying vec3 vWorld;
varying vec3 vNormal;
${full ? BEAM_PARS : ''}
void main() {
// Screen-door fade, like the districts: never a translucent sheet.
vec2 cell = mod(floor(gl_FragCoord.xy), 4.0);
float threshold = (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0;
if (vState.y < threshold) discard;
vec3 n = normalize(vNormal);
vec3 toCamera = normalize(cameraPosition - vWorld);
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
float facing = max(0.0, dot(n, toCamera));
float flash = ${full ? 'vState.x' : '0.0'};
vec3 color;
if (vPart < 0.5) {
color = vec3(0.04, 0.034, 0.06) + vec3(0.09, 0.08, 0.13) * key + uAccent * 0.06 * facing;
} else if (vPart < 1.5) {
// Lit retro-reflective plate: brightest facing the camera, and in the beam.
// Tinted by the chapter accent so it never reads as one more street lamp.
color = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.7) * (${PLATE_GLOW.toFixed(2)} + 0.12 * key + 0.5 * facing * facing);
color += uAccent * 0.35;
${full ? 'color += vec3(1.0, 0.9, 0.76) * headlightBeam(vWorld) * (0.5 + 1.8 * facing);' : ''}
color += vec3(1.0, 0.96, 0.9) * flash * 1.4;
} else {
color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8);
}
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
fog: false,
}));
const mesh = new THREE.InstancedMesh(geometry, material, INSTANCES);
mesh.name = 'mile-markers';
mesh.frustumCulled = false;
mesh.visible = false;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// The light pool: an additive quad on the shoulder, pulled toward the road.
const poolGeometry = resources.own('geometry', new THREE.PlaneGeometry(POOL_W, POOL_L).rotateX(-Math.PI / 2));
poolGeometry.setAttribute('aState', state);
const poolMaterial = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uAccent: { value: accent }, uPulse: pulseUniform },
vertexShader: /* glsl */ `
attribute vec2 aState;
varying vec2 vUv;
varying vec2 vState;
void main() {
vUv = uv;
vState = aState;
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uAccent;
uniform float uPulse;
varying vec2 vUv;
varying vec2 vState;
void main() {
vec2 d = (vUv - 0.5) * 2.0;
float r = length(d);
float pool = pow(max(0.0, 1.0 - r), 1.8);
vec3 warm = vec3(1.0, 0.86, 0.62);
vec3 color = mix(warm, uAccent, 0.5) * pool * (2.4 + ${PULSE_GAIN.toFixed(2)} * 3.0 * uPulse + 3.0 * vState.x);
gl_FragColor = vec4(color * vState.y, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false,
}));
const pool = new THREE.InstancedMesh(poolGeometry, poolMaterial, INSTANCES);
pool.name = 'mile-marker-pools';
pool.frustumCulled = false;
pool.visible = false;
pool.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const poolMatrix = new THREE.Matrix4();
const poolPosition = new THREE.Vector3();
const noRotation = new THREE.Quaternion();
const flash = new Float32Array(INSTANCES);
const sinceFlash = new Float32Array(INSTANCES).fill(Number.POSITIVE_INFINITY);
const side = new Float32Array(INSTANCES);
let lastSection: SectionId | null = null;
const matrix = new THREE.Matrix4();
const rotation = new THREE.Quaternion();
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
return {
mesh,
pool,
flashes: () => Array.from(flash),
update(section, p, pulse, deltaSeconds) {
const posts = milestoneLayout(section, p);
const changed = section !== lastSection;
lastSection = section;
let any = false;
posts.forEach((post, i) => {
// The flash fires when the plate crosses the line, either way.
const crossing = p === null || post.presence === 0 ? 0 : Math.sign(p - passPoint(post.index, MILESTONE_COUNTS[post.section]));
sinceFlash[i]! += deltaSeconds;
if (full && !changed && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) {
flash[i] = 1;
sinceFlash[i] = 0;
} else {
flash[i] = Math.max(0, flash[i]! - deltaSeconds / FLASH_SECONDS);
}
if (crossing !== 0) side[i] = crossing;
if (post.presence === 0) side[i] = 0;
const visible = post.presence > 0;
any ||= visible;
position.set(post.x, 0, post.z);
rotation.setFromAxisAngle(up, -Math.sign(post.x) * PLATE_YAW);
scale.setScalar(visible ? 1 : 0);
mesh.setMatrixAt(i, matrix.compose(position, rotation, scale));
poolPosition.set(post.x - Math.sign(post.x) * 1.4, 0.05, post.z);
pool.setMatrixAt(i, poolMatrix.compose(poolPosition, noRotation, scale));
state.setXY(i, flash[i]!, post.presence);
});
mesh.visible = any;
pool.visible = any;
if (!any) return;
if (isMilestoneSection(section)) accent.set(SIGN_ACCENTS[section]);
pulseUniform.value = full ? clamp01(pulse) : 0;
mesh.instanceMatrix.needsUpdate = true;
pool.instanceMatrix.needsUpdate = true;
state.needsUpdate = true;
},
};
}