feat: route log odometer and lit mile markers for profile and experience
This commit is contained in:
@@ -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);
|
||||
|
||||
317
src/scene/world/milestones.ts
Normal file
317
src/scene/world/milestones.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user