feat: mile-marker posts travel with the road and carry the route log

This commit is contained in:
2026-10-11 15:48:49 +03:00
parent 1326cee886
commit 388289f2fe
11 changed files with 783 additions and 267 deletions

View File

@@ -183,8 +183,8 @@ 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.
// MILE MARKERS: kilometre posts stream past on the free shoulder through
// Profile and Experience. Low tier and touch: no beam, pools, pulse or glint.
const milestones = createMilestones(context.resources, maintenanceTier === 'full');
// DRIVE-IN: the Selected builds billboard. Low tier and touch: one steady
// flood, no pulse, flicker or hover zoom, 512 px screens.
@@ -425,14 +425,6 @@ 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,
);
// The deck from the previous frame (underpass updates after the sign).
const yardDeck = underpass.inspect();
yard.update(
@@ -487,6 +479,17 @@ export async function createProceduralSceneGeneration(
snapshot.driveSpeed,
response.roadEnergy,
);
// MILE MARKERS ride the road: the posts move with the travel distance,
// the section-local progress only picks which entry the next one carries.
milestones.update({
section: snapshot.activeSectionId,
p: isMilestoneSection(snapshot.activeSectionId)
? sectionLocalProgress(snapshot, snapshot.activeSectionId)
: null,
distance: driveDistance,
pulse: musicPulse,
deltaSeconds,
});
const visualSpeed = THREE.MathUtils.clamp(
0.18 + response.roadEnergy * 0.82,
0.18,

View File

@@ -1,62 +1,98 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import type { PortfolioContent, SectionId } from '@/content/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import { isMilestoneSection, MILESTONE_COUNTS, passPoint, type MilestoneSection } from '@/journey/route-log';
import {
fitCaption,
isMilestoneSection,
MILESTONE_COUNTS,
milestoneIndex,
milestonesFor,
passPoint,
type Milestone,
type MilestoneSection,
} from '@/journey/route-log';
import type { SceneResourceBag } from '@/scene/resources';
import { BEAM_PARS, beamUniforms } from './headlights';
import { WORLD_NEAR_Z } from './metrics';
/**
* 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.
* MILE MARKERS, scene side. Kilometre posts are road furniture: they stand
* on a fixed lattice along the road and travel with the drive's distance,
* like the rails, delineators and pylons, so they keep streaming past while
* the reader is still. Through Profile and Experience the free shoulder (the
* side the reading panel leaves open) carries one every MILESTONE_SPACING
* metres: small dark plates with a lit kilometre number. The route log rides
* on that lattice. When a post comes out of the haze far up the road, it is
* given to the next log entry if it would reach the camera about when the
* entry's plate crosses the activation line (predicted from the scroll rate
* and the road speed), or straight away when the reader has stopped on an
* entry it has not passed yet. That feature post gets the large accent plate
* (LOG nn/nn, the dates, the organisation), a lamp that warms up as it
* approaches, a light pool that sweeps over the lane as it goes by, a soft
* glint at the pass line and a +8% swell with the bass. Scrolling back never
* runs the road backwards: the entries rewind and their posts come up the
* road again. Posts fade in and out (screen door) at the chapter changes and
* in the haze. Low tier and touch: no beam, pools, pulse or glint.
*/
/** Where a post goes by: the free shoulder leaves the frame about here. */
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;
/** Metres between two posts, and how many stand on the road at once. */
export const MILESTONE_SPACING = 60;
export const MILESTONE_SLOTS = 4;
/** A post leaves (wraps) here, behind the camera, and is born at the far end. */
export const MILESTONE_NEAR_Z = WORLD_NEAR_Z;
export const MILESTONE_FAR_Z = MILESTONE_NEAR_Z - MILESTONE_SPACING * MILESTONE_SLOTS;
/** Only a post this far up the road (still in the haze) can take a log entry. */
export const MILESTONE_ASSIGN_Z = -165;
/** 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;
/** The plate hangs on an arm this far over the road (the post stays on the shoulder),
* so it stays in frame on the narrow side and clears traffic (4 m) by 2 m. */
const ARM = 4.2;
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;
/** The plain kilometre plate is drawn at this scale of the feature plate. */
const PLAIN_PLATE = 0.62;
/** The pool of light a feature post throws across the shoulder and the lane. */
const POOL_W = 13;
const POOL_L = 18;
/** 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;
/** Smoothed section-local progress per second above which a crossing does not
* flash: a navigator jump or a fling sweeps several posts past in well under
* a second, and their flashes stacked into one bright bloom frame on arrival.
* A wheel tick lands in one frame, so the rate is smoothed: it rises over
* ~80 ms (a sweep is caught within three frames) and falls over ~400 ms. */
const GLINT_SECONDS = 0.3;
/** At most one glint every 0.6 s, whatever the speed (photosensitivity). */
const GLINT_GAP_SECONDS = 0.6;
/** Smoothed section-local progress per second above which nothing glints and
* no entry is handed out: a navigator jump or a fling. A wheel tick lands in
* one frame, so the rate is smoothed: it rises over ~80 ms and falls over ~400 ms. */
export const MILESTONE_FLASH_MAX_RATE = 0.8;
const RATE_RISE_SECONDS = 0.08;
const RATE_FALL_SECONDS = 0.4;
/** Below this |progress/s| the reader counts as stopped on an entry. */
const IDLE_RATE = 0.012;
/** An entry is due once its activation falls within this share of a post's arrival. */
const DUE_TOLERANCE = 0.06;
/** An entry can come round again once the reader is this many shares from it. */
const REARM_SHARES = 0.6;
/** Chapter fades are linear ramps (screen door), so no frame jumps by more than ~7%. */
const PRESENCE_IN_SECONDS = 0.35;
const PRESENCE_OUT_SECONDS = 0.25;
/** The reader counts as stopped after this long below IDLE_RATE. */
const STILL_SECONDS = 0.4;
/** No entry is handed out this long after a chapter change or a sweep: the
* scroll rate is not a reading pace yet. */
const SETTLE_CHANGE_SECONDS = 0.3;
const SETTLE_SWEEP_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);
/** Atlas: 2 x 4 feature cells (512 x 232) over a strip of ten digits. */
const CELL_W = 512;
const CELL_H = 232;
const ATLAS = 1024;
const DIGITS_Y = CELL_H * 4;
const CELL_BASE: Readonly<Record<MilestoneSection, number>> = { profile: 0, experience: MILESTONE_COUNTS.profile };
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
const smooth = (from: number, to: number, value: number): number => {
@@ -64,69 +100,279 @@ const smooth = (from: number, to: number, value: number): number => {
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));
/** Slot k's place on the lattice at drive distance d: its z, and the post's
* running number (one per post, rising along the route). */
export function milestoneSlot(distance: number, slot: number): { z: number; id: number } {
const run = MILESTONE_SPACING * MILESTONE_SLOTS;
const along = distance + slot * MILESTONE_SPACING;
const lap = Math.floor(along / run);
return { z: MILESTONE_FAR_Z + (along - lap * run), id: lap * MILESTONE_SLOTS - slot };
}
/** 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 const milestoneRangeFade = (z: number): number => smooth(-198, -140, z) * (1 - smooth(12, 28, z));
/** The lamp of a feature post warms up as it comes down the road. */
export const milestoneLamp = (z: number): number => smooth(-190, -50, z) * (1 - smooth(16, 30, z));
/** Kilometre number on a plain plate (three digits). */
export const milestoneNumber = (id: number): number => (((id + 128) % 1000) + 1000) % 1000;
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. */
x: number;
/** The post's chapter, or null while it is unclaimed (hidden). */
section: MilestoneSection | null;
/** The log entry it carries, -1 for a plain kilometre post. */
entry: number;
number: number;
/** 0..1 screen-door cover: chapter fades and the haze. */
presence: number;
lamp: number;
glint: 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,
});
export interface MilestoneFrame {
section: SectionId;
/** Section-local progress, null outside Profile and Experience. */
p: number | null;
/** The drive's travel distance (metres of road, never decreasing). */
distance: number;
/** Smoothed bass pulse, 0..1. */
pulse: number;
deltaSeconds: number;
}
interface Slot {
id: number;
section: MilestoneSection | null;
entry: number;
chapter: number;
z: number;
}
/**
* The lattice and the route log, without three.js: which post carries which
* entry, its chapter fade, lamp and glint. Pure state, one step per frame.
*/
export function createMilestoneRoad(full: boolean) {
const slots: Slot[] = Array.from({ length: MILESTONE_SLOTS }, () => ({
id: Number.NaN, section: null, entry: -1, chapter: 0, z: MILESTONE_FAR_Z,
}));
const glints = new Float32Array(MILESTONE_SLOTS);
const armed = new Set<number>();
let armedFor: MilestoneSection | null = null;
let lastDistance: number | null = null;
let lastProgress: number | null = null;
let lastSection: SectionId | null = null;
let speed = 0;
let rate = 0;
let drift = 0;
let still = 0;
let settle = 0;
let sinceGlint = Number.POSITIVE_INFINITY;
// Entries carried by posts on the road, per frame (refilled, never reallocated).
const inFlight = new Set<number>();
// One record per slot, rewritten in place every frame.
const posts: MilestonePost[] = slots.map(() => ({
z: MILESTONE_FAR_Z, x: 0, section: null, entry: -1, number: 0, presence: 0, lamp: 0, glint: 0,
}));
const due = (slot: Slot, section: MilestoneSection, p: number): number => {
if (settle > 0) return -1;
const count = MILESTONE_COUNTS[section];
const share = 1 / count;
const open = (i: number): boolean => armed.has(i) && !inFlight.has(i);
// Where the reader will be when this post reaches the pass line.
const arrival = clamp01(p + drift * Math.min(30, (MILESTONE_PASS_Z - slot.z) / Math.max(speed, 1)));
const lo = Math.min(p, arrival) - DUE_TOLERANCE * share;
const hi = Math.max(p, arrival) + DUE_TOLERANCE * share;
let best = -1;
for (let i = 0; i < count; i += 1) {
const point = passPoint(i, count);
// At most the next entry either way: a prediction is only a reading pace.
if (open(i) && point >= lo && point <= hi && Math.abs(point - p) <= share
&& (best < 0 || Math.abs(point - arrival) < Math.abs(passPoint(best, count) - arrival))) best = i;
}
}
return posts;
if (best >= 0) return best;
// Stopped on an entry whose post has not come by: send it now.
const here = milestoneIndex(p, count);
return still >= STILL_SECONDS && open(here) ? here : -1;
};
return {
posts: (): readonly MilestonePost[] => posts,
step({ section, p, distance, pulse, deltaSeconds }: MilestoneFrame): readonly MilestonePost[] {
const dt = Math.max(deltaSeconds, 1 / 240);
// A non-finite distance (a bad frame) keeps the road where it was.
if (!Number.isFinite(distance)) distance = lastDistance ?? 0;
const travelled = lastDistance === null ? 0 : Math.max(0, distance - lastDistance);
lastDistance = distance;
speed += (travelled / dt - speed) * Math.min(1, dt / 0.3);
const changed = section !== lastSection;
lastSection = section;
const delta = p !== null && lastProgress !== null && !changed ? p - lastProgress : 0;
lastProgress = p;
const instant = Math.abs(delta) / dt;
rate += (instant - rate) * Math.min(1, dt / (instant > rate ? RATE_RISE_SECONDS : RATE_FALL_SECONDS));
drift += (delta / dt - drift) * Math.min(1, dt / 0.25);
still = p !== null && !changed && instant < IDLE_RATE && Math.abs(drift) < IDLE_RATE ? still + deltaSeconds : 0;
const sweeping = rate > MILESTONE_FLASH_MAX_RATE;
settle = Math.max(changed ? SETTLE_CHANGE_SECONDS : sweeping ? SETTLE_SWEEP_SECONDS : 0, settle - deltaSeconds);
const live = p !== null && isMilestoneSection(section) ? section : null;
// Every entry is armed again once the reader is well away from it.
if (live !== armedFor) {
armed.clear();
armedFor = live;
}
inFlight.clear();
for (const s of slots) if (s.section === live && s.entry >= 0) inFlight.add(s.entry);
if (live !== null && p !== null) {
const count = MILESTONE_COUNTS[live];
for (let i = 0; i < count; i += 1) {
if (!inFlight.has(i) && Math.abs(p - passPoint(i, count)) > REARM_SHARES / count) armed.add(i);
}
// Entering mid-entry (a jump, a reload): that entry is due too.
if (changed || sweeping) armed.add(milestoneIndex(p, count));
}
sinceGlint += deltaSeconds;
for (let k = 0; k < slots.length; k += 1) {
const slot = slots[k]!;
const { z, id } = milestoneSlot(distance, k);
const crossed = slot.z < MILESTONE_PASS_Z && z >= MILESTONE_PASS_Z && slot.id === id;
if (id !== slot.id) {
// A new post at the far end of the lattice.
slot.id = id;
slot.section = live;
slot.entry = -1;
slot.chapter = live === null ? 0 : 1;
}
slot.z = z;
// An unclaimed or faded post that is still ahead joins the current chapter.
if (slot.section !== live && slot.chapter < 0.01 && z < MILESTONE_PASS_Z) {
slot.section = live;
slot.entry = -1;
}
const target = live !== null && slot.section === live ? 1 : 0;
const ramp = deltaSeconds / (target > slot.chapter ? PRESENCE_IN_SECONDS : PRESENCE_OUT_SECONDS);
slot.chapter = target > slot.chapter ? Math.min(target, slot.chapter + ramp) : Math.max(target, slot.chapter - ramp);
if (live !== null && p !== null && slot.section === live && slot.entry < 0 && z < MILESTONE_ASSIGN_Z) {
slot.entry = due(slot, live, p);
if (slot.entry >= 0) {
armed.delete(slot.entry);
inFlight.add(slot.entry);
}
}
const presence = slot.section === null ? 0 : slot.chapter * milestoneRangeFade(z);
const feature = slot.entry >= 0 && presence > 0;
if (full && feature && crossed && !sweeping && sinceGlint >= GLINT_GAP_SECONDS) {
glints[k] = 1;
sinceGlint = 0;
} else {
glints[k] = Math.max(0, glints[k]! - deltaSeconds / GLINT_SECONDS);
}
const swell = full ? 1 + PULSE_GAIN * clamp01(pulse) : 1;
const post = posts[k]!;
post.z = z;
post.x = slot.section === null ? 0 : MILESTONE_SIDE[slot.section];
post.section = slot.section;
post.entry = slot.entry;
post.number = milestoneNumber(id);
post.presence = presence;
post.lamp = feature ? milestoneLamp(z) * swell : 0;
post.glint = feature ? glints[k]! : 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);
/**
* The log entries, read once from the page's inert input snapshot (the same
* JSON the app hydrates from), so the plates need nothing from the initial
* bundle. Without it the plates still carry LOG nn/nn.
*/
const entries = new Map<MilestoneSection, readonly Milestone[]>();
export function milestoneEntries(section: MilestoneSection, source?: Document): readonly Milestone[] {
const cached = source === undefined ? entries.get(section) : undefined;
if (cached !== undefined) return cached;
let items: readonly Milestone[] = [];
try {
const text = (source ?? document).getElementById('portfolio-inputs')?.textContent ?? '';
if (text !== '') items = milestonesFor((JSON.parse(text) as { content: PortfolioContent }).content, section);
} catch {
items = [];
}
if (source === undefined) entries.set(section, items);
return items;
}
/** One feature plate: the log index on an accent band, the dates, the organisation. */
function drawFeaturePlate(context: CanvasRenderingContext2D, cell: number, section: MilestoneSection, item: Milestone, count: number) {
const x = (cell % 2) * CELL_W;
const y = Math.floor(cell / 2) * CELL_H;
const accent = SIGN_ACCENTS[section];
const fit = (text: string, size: number, weight: number, room: number): void => {
let px = size;
do {
context.font = `${weight} ${px}px "Geist Mono Variable", ui-monospace, monospace`;
px -= 2;
} while (px > 18 && context.measureText(text).width > room);
};
context.clearRect(x, y, CELL_W, CELL_H);
context.fillStyle = '#0c0815';
context.fillRect(x, y, CELL_W, CELL_H);
context.strokeStyle = accent;
context.lineWidth = 8;
context.strokeRect(x + 6, y + 6, CELL_W - 12, CELL_H - 12);
context.fillStyle = accent;
context.fillRect(x + 6, y + 6, CELL_W - 12, 50);
context.textBaseline = 'middle';
context.fillStyle = '#120a1d';
fit(`LOG ${String(item.index + 1).padStart(2, '0')}/${String(count).padStart(2, '0')}`, 34, 760, 300);
context.textAlign = 'left';
context.fillText(`LOG ${String(item.index + 1).padStart(2, '0')}/${String(count).padStart(2, '0')}`, x + 24, y + 33);
context.textAlign = 'right';
context.fillText(section === 'experience' ? 'EXP' : 'PRF', x + CELL_W - 24, y + 33);
context.textAlign = 'center';
const code = fitCaption(item.caption, 14);
const main = item.compactDate !== '' ? item.compactDate : code;
context.fillStyle = '#f1e8ff';
fit(main, 62, 720, CELL_W - 56);
context.fillText(main, x + CELL_W / 2, y + (main === code ? 140 : 118));
if (main !== code && code !== '') {
context.fillStyle = accent;
fit(code, 42, 680, CELL_W - 64);
context.fillText(code, x + CELL_W / 2, y + 186);
}
}
type Geometry = InstanceType<typeof THREE.BufferGeometry>;
function piece(geometry: Geometry, x: number, y: number, z: number, part: number): { geometry: Geometry; part: number } {
geometry.translate(x, y, z);
return { geometry, part };
}
/** Post, plate and stripes merged into one indexed geometry with a part id. */
/** Post, plate, bands and lamp merged into one indexed geometry with a part id:
* 0 post, 1 plate face (atlas uv), 2 accent bands, 3 lamp, 4 plate body, 5 arm
* (a unit-length box along +x, the shader sends it toward the road). */
function postGeometry() {
const plateY = POST_H + PLATE_H / 2 - 0.12;
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),
piece(new THREE.BoxGeometry(POST_W, POST_H, POST_W), 0, POST_H / 2, 0, 0),
piece(new THREE.BoxGeometry(PLATE_W + 0.12, PLATE_H + 0.12, 0.1), 0, plateY, 0.14, 4),
piece(new THREE.PlaneGeometry(PLATE_W, PLATE_H), 0, plateY, 0.2, 1),
// 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),
piece(new THREE.BoxGeometry(POST_W + 0.05, 0.42, POST_W + 0.05), 0, POST_H * 0.62, 0, 2),
piece(new THREE.BoxGeometry(POST_W + 0.05, 0.42, POST_W + 0.05), 0, POST_H * 0.3, 0, 2),
// A lamp head capping the post, lit on feature posts only.
piece(new THREE.BoxGeometry(0.62, 0.42, 0.62), 0, POST_H + PLATE_H + 0.24, 0.08, 3),
piece(new THREE.BoxGeometry(1, 0.22, 0.2), 0.5, POST_H + 0.02, 0.06, 5),
];
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const parts: number[] = [];
const indices: number[] = [];
for (const { geometry, part } of pieces) {
@@ -134,13 +380,15 @@ function postGeometry() {
const position = geometry.getAttribute('position');
positions.push(...position.array);
normals.push(...geometry.getAttribute('normal').array);
uvs.push(...geometry.getAttribute('uv').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));
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('uv', new THREE.Float32BufferAttribute(uvs, 2));
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
merged.setIndex(indices);
return merged;
@@ -148,45 +396,100 @@ function postGeometry() {
export interface MilestonesWorld {
readonly mesh: { visible: boolean; count: number };
/** The additive light pools on the shoulder, one per post. */
/** The additive light pools of feature posts. */
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[];
update(frame: MilestoneFrame): void;
/** Test hook: this frame's posts. */
posts(): readonly MilestonePost[];
}
export function createMilestones(resources: SceneResourceBag, full: boolean): MilestonesWorld {
const road = createMilestoneRoad(full);
const geometry = resources.own('geometry', postGeometry());
// x: flash 0..1, y: presence 0..1.
const state = new THREE.InstancedBufferAttribute(new Float32Array(INSTANCES * 2), 2);
// x: glint, y: presence, z: lamp, w: chapter (0 profile, 1 experience).
const state = new THREE.InstancedBufferAttribute(new Float32Array(MILESTONE_SLOTS * 4), 4);
state.setUsage(THREE.DynamicDrawUsage);
// x: atlas cell of a feature plate (-1 plain), y: kilometre number.
const plate = new THREE.InstancedBufferAttribute(new Float32Array(MILESTONE_SLOTS * 2), 2);
plate.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute('aState', state);
const accent = new THREE.Color(SIGN_ACCENTS.experience);
const pulseUniform = { value: 0 };
geometry.setAttribute('aPlate', plate);
const canvas = typeof document === 'undefined' ? null : document.createElement('canvas');
const context = canvas === null ? null : (canvas.getContext('2d') as CanvasRenderingContext2D | null);
if (canvas !== null) {
canvas.width = ATLAS;
canvas.height = ATLAS;
}
const atlas = resources.own('texture', new THREE.CanvasTexture(canvas as HTMLCanvasElement));
atlas.colorSpace = THREE.SRGBColorSpace;
atlas.anisotropy = 4;
if (context !== null) {
context.fillStyle = '#ffffff';
context.font = '700 84px "Geist Mono Variable", ui-monospace, monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
for (let digit = 0; digit < 10; digit += 1) {
context.fillText(String(digit), (digit + 0.5) * (ATLAS / 10), DIGITS_Y + (ATLAS - DIGITS_Y) / 2 + 4);
}
atlas.needsUpdate = true;
}
const lettered = new Map<number, string>();
const letter = (section: MilestoneSection, entry: number): number => {
const cell = CELL_BASE[section] + entry;
const items = milestoneEntries(section);
const item = items[entry] ?? { index: entry, dateLabel: '', compactDate: '', caption: '' };
const key = `${item.index}|${item.compactDate}|${item.caption}|${items.length}`;
if (context !== null && lettered.get(cell) !== key) {
lettered.set(cell, key);
drawFeaturePlate(context, cell, section, item, Math.max(items.length, MILESTONE_COUNTS[section]));
atlas.needsUpdate = true;
}
return cell;
};
const accents = [new THREE.Color(SIGN_ACCENTS.profile), new THREE.Color(SIGN_ACCENTS.experience)];
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uAccent: { value: accent }, uPulse: pulseUniform, ...(full ? beamUniforms : {}) },
uniforms: { uAccents: { value: accents }, uAtlas: { value: atlas }, ...(full ? beamUniforms : {}) },
vertexShader: /* glsl */ `
attribute float aPart;
attribute vec2 aState;
attribute vec4 aState;
attribute vec2 aPlate;
varying float vPart;
varying vec2 vState;
varying vec4 vState;
varying vec2 vPlate;
varying vec2 vUv;
varying vec3 vWorld;
varying vec3 vNormal;
void main() {
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
vec3 local = position;
// A plain kilometre post: a smaller plate and no lamp head.
if (aPlate.x < 0.0) {
float plateY = ${(POST_H + PLATE_H / 2 - 0.12).toFixed(3)};
if (aPart == 1.0 || aPart == 4.0) local.xy = vec2(local.x * ${PLAIN_PLATE.toFixed(2)}, plateY - 0.2 + (local.y - plateY) * ${PLAIN_PLATE.toFixed(2)});
if (aPart == 3.0) local *= 0.0;
}
// The plate hangs on an arm over the road, on the side the road lies.
float toRoad = aState.w > 0.5 ? -1.0 : 1.0;
if (aPart == 5.0) local.x *= ${ARM.toFixed(2)} * toRoad;
if (aPart == 1.0 || aPart == 4.0) local.x += ${ARM.toFixed(2)} * toRoad;
vec4 world = modelMatrix * instanceMatrix * vec4(local, 1.0);
vWorld = world.xyz;
vNormal = normalize(mat3(modelMatrix * instanceMatrix) * normal);
vPart = aPart;
vState = aState;
vPlate = aPlate;
vUv = uv;
gl_Position = projectionMatrix * viewMatrix * world;
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uAccent;
uniform float uPulse;
uniform vec3 uAccents[2];
uniform sampler2D uAtlas;
varying float vPart;
varying vec2 vState;
varying vec4 vState;
varying vec2 vPlate;
varying vec2 vUv;
varying vec3 vWorld;
varying vec3 vNormal;
${full ? BEAM_PARS : ''}
@@ -195,27 +498,50 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
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 accent = vState.w > 0.5 ? uAccents[1] : uAccents[0];
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'};
float lamp = vState.z;
float glint = vState.x;
float beam = ${full ? 'headlightBeam(vWorld)' : '0.0'};
bool feature = vPlate.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;
if (vPart < 0.5 || vPart > 3.5) {
// Graphite post and plate body, rimmed by the chapter colour.
color = vec3(0.025, 0.02, 0.04) + vec3(0.05, 0.045, 0.08) * key + accent * 0.05 * facing;
color += vec3(1.0, 0.78, 0.5) * 0.05 * lamp;
} 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.
// Kept below the tonemapper's shoulder, so the plate stays the
// chapter colour instead of clipping to white; the beam and the
// flash lift it in the accent's own hue.
vec3 sheet = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.82);
color = sheet * (${PLATE_GLOW.toFixed(2)} + 0.1 * key + 0.32 * facing * facing);
color += uAccent * 0.18;
${full ? 'color += sheet * headlightBeam(vWorld) * (0.3 + 0.8 * facing);' : ''}
color += mix(uAccent, vec3(1.0, 0.96, 0.9), 0.35) * flash * 0.9;
vec3 ink;
float glyph;
if (feature) {
vec2 at = vec2(mod(vPlate.x, 2.0) * 0.5 + vUv.x * 0.5,
1.0 - (floor(vPlate.x / 2.0) + 1.0 - vUv.y) * ${(CELL_H / ATLAS).toFixed(5)});
ink = texture2D(uAtlas, at).rgb;
glyph = 1.0;
// Lit from within as its lamp warms up; the beam lifts it like sheeting.
color = ink * (0.32 + 0.4 * lamp + 0.28 * glint + 0.22 * beam * facing);
} else {
// Plain plate: dark sheeting, an accent rule and three lit digits.
float edge = step(min(min(vUv.x, 1.0 - vUv.x) * ${(PLATE_W / PLATE_H).toFixed(2)}, min(vUv.y, 1.0 - vUv.y)), 0.07);
vec2 d = (vUv - vec2(0.2, 0.2)) / vec2(0.6, 0.6);
float glyphs = 0.0;
if (d.x > 0.0 && d.x < 1.0 && d.y > 0.0 && d.y < 1.0) {
float slot = floor(d.x * 3.0);
float value = slot < 0.5 ? floor(vPlate.y / 100.0) : slot < 1.5 ? mod(floor(vPlate.y / 10.0), 10.0) : mod(vPlate.y, 10.0);
vec2 at = vec2((value + fract(d.x * 3.0)) / 10.0, d.y * ${((ATLAS - DIGITS_Y) / ATLAS).toFixed(5)});
glyphs = texture2D(uAtlas, at).r;
}
vec3 sheet = mix(vec3(0.9, 0.9, 1.0), accent, 0.45);
color = vec3(0.03, 0.025, 0.05) + accent * 0.42 * edge + sheet * glyphs * (0.34 + 0.4 * beam * facing);
}
} else if (vPart < 2.5) {
color = accent * ((feature ? 0.6 : 0.36) + 0.25 * lamp + 0.3 * glint + 0.25 * beam);
} else {
color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8);
// The lamp head: warm, under the bloom threshold until it is close.
vec3 warm = mix(vec3(1.0, 0.8, 0.55), accent, 0.25);
color = vec3(0.05, 0.04, 0.07) + warm * (1.7 * lamp + 0.6 * glint) * (0.75 + 0.25 * facing);
}
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
@@ -224,21 +550,21 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
`,
fog: false,
}));
const mesh = new THREE.InstancedMesh(geometry, material, INSTANCES);
const mesh = new THREE.InstancedMesh(geometry, material, MILESTONE_SLOTS);
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.
// The light pool of a feature post: an additive quad over the shoulder and the lane.
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 },
uniforms: { uAccents: { value: accents } },
vertexShader: /* glsl */ `
attribute vec2 aState;
attribute vec4 aState;
varying vec2 vUv;
varying vec2 vState;
varying vec4 vState;
void main() {
vUv = uv;
vState = aState;
@@ -246,16 +572,14 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uAccent;
uniform float uPulse;
uniform vec3 uAccents[2];
varying vec2 vUv;
varying vec2 vState;
varying vec4 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);
float pool = pow(max(0.0, 1.0 - length(d)), 1.7);
vec3 accent = vState.w > 0.5 ? uAccents[1] : uAccents[0];
vec3 color = mix(vec3(1.0, 0.84, 0.6), accent, 0.6) * pool * (0.7 * vState.z + 0.35 * vState.x);
gl_FragColor = vec4(color * vState.y, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
@@ -266,23 +590,15 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
depthWrite: false,
fog: false,
}));
const pool = new THREE.InstancedMesh(poolGeometry, poolMaterial, INSTANCES);
const pool = new THREE.InstancedMesh(poolGeometry, poolMaterial, MILESTONE_SLOTS);
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;
let lastProgress: number | null = null;
let rate = 0;
const matrix = new THREE.Matrix4();
const rotation = new THREE.Quaternion();
const noRotation = new THREE.Quaternion();
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
@@ -290,47 +606,36 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
return {
mesh,
pool,
flashes: () => Array.from(flash),
update(section, p, pulse, deltaSeconds) {
const posts = milestoneLayout(section, p);
const changed = section !== lastSection;
lastSection = section;
const dt = Math.max(deltaSeconds, 1 / 240);
const instant = p !== null && lastProgress !== null && !changed ? Math.abs(p - lastProgress) / dt : 0;
rate += (instant - rate) * Math.min(1, dt / (instant > rate ? RATE_RISE_SECONDS : RATE_FALL_SECONDS));
const sweeping = rate > MILESTONE_FLASH_MAX_RATE;
lastProgress = p;
posts: () => road.posts(),
update(frame) {
const posts = road.step(frame);
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 && !sweeping && 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;
let lit = false;
for (let i = 0; i < posts.length; i += 1) {
const post = posts[i]!;
const visible = post.presence > 0 && post.section !== null;
any ||= visible;
const side = Math.sign(post.x) || 1;
position.set(post.x, 0, post.z);
rotation.setFromAxisAngle(up, -Math.sign(post.x) * PLATE_YAW);
rotation.setFromAxisAngle(up, -side * 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);
});
const pooled = visible && full && post.lamp > 0.01;
lit ||= pooled;
position.set(post.x - side * 4.2, 0.05, post.z);
scale.setScalar(pooled ? 1 : 0);
pool.setMatrixAt(i, matrix.compose(position, noRotation, scale));
const cell = visible && post.entry >= 0 && post.section !== null ? letter(post.section, post.entry) : -1;
state.setXYZW(i, post.glint, post.presence, post.lamp, post.section === 'experience' ? 1 : 0);
plate.setXY(i, cell, post.number);
}
mesh.visible = any;
pool.visible = any;
pool.visible = lit;
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;
plate.needsUpdate = true;
},
};
}