feat: mile-marker posts travel with the road and carry the route log
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user