fix: make nightdrive signage reliable and legible

This commit is contained in:
2026-09-03 02:34:15 +03:00
parent a03d4d8dba
commit cce018d3cb
39 changed files with 4635 additions and 196 deletions

View File

@@ -91,6 +91,7 @@ export function useJourneySections(
if (headings.length === 0) return false;
const scrollingElement = document.scrollingElement ?? document.documentElement;
const viewportWidth = visualViewport?.width ?? document.documentElement.clientWidth;
const viewportHeight = visualViewport?.height ?? document.documentElement.clientHeight;
const viewportOffsetTop = visualViewport?.offsetTop ?? 0;
const activationY = activationLineDocumentY({
@@ -123,6 +124,7 @@ export function useJourneySections(
direction: driveSnapshot.direction,
acceleration: driveSnapshot.acceleration,
deltaY: driveSnapshot.scrollY - previousScrollY,
viewportWidth,
viewportHeight,
crossedBoundaries,
candidateId,

View File

@@ -4,33 +4,21 @@ import type {
SignControllerSample,
SignSnapshot,
} from '@/journey/types';
const ANTICIPATE_MS = 300;
const APPROACH_MS = 900;
const APPROACH_REDUCTION_MS = 250;
const DWELL_MS = 1_400;
const DEPART_MS = 700;
const COOLDOWN_MS = 350;
const ELIGIBLE_DISTANCE_VH = 1.2;
const JUMP_DISTANCE_VH = 0.75;
const CONCEALED_OPACITY = 0.02;
const SECTION_ORDER: readonly SectionId[] = [
'start',
'profile',
'experience',
'infrastructure',
'selected-builds',
'additional-work',
'toolkit',
'contact',
];
import {
SIGN_LIFECYCLE,
SIGN_ROUTE_ORDER,
signRouteFor,
signViewportProfileFor,
type SignViewportProfile,
} from '@/journey/sign-routes';
interface VisibleState {
kind: 'visible';
targetId: SectionId;
latchedAt: number;
approachDuration: number;
departureRequested: boolean;
viewportProfile: SignViewportProfile;
dwellStartedAt: number | null;
departStartedAt: number | null;
generation: number;
}
@@ -51,56 +39,44 @@ const isFiniteSample = (input: SignControllerSample): boolean =>
Number.isFinite(input.now) &&
Number.isFinite(input.acceleration) &&
Number.isFinite(input.deltaY) &&
(input.viewportWidth === undefined || (
Number.isFinite(input.viewportWidth) && input.viewportWidth > 0
)) &&
Number.isFinite(input.viewportHeight) &&
Number.isFinite(input.crossedBoundaries) &&
input.viewportHeight > 0 &&
(input.candidateDistanceVh === null || Number.isFinite(input.candidateDistanceVh));
const canLatch = (input: SignControllerSample): input is SignControllerSample & {
candidateId: SectionId;
candidateDistanceVh: number;
} =>
input.motionAllowed &&
input.source === 'passive-scroll' &&
input.direction === 1 &&
input.deltaY > 0 &&
input.deltaY <= input.viewportHeight * JUMP_DISTANCE_VH &&
input.crossedBoundaries <= 1 &&
input.candidateId !== null &&
input.candidateId !== input.activeSectionId &&
input.candidateDistanceVh !== null &&
input.candidateDistanceVh > 0 &&
input.candidateDistanceVh <= ELIGIBLE_DISTANCE_VH;
export function createSignController(): SignController {
let state: State = { kind: 'idle' };
let lastNow: number | null = null;
let generation = 0;
let pendingTargetId: SectionId | null = null;
const completedTargets = new Set<SectionId>();
const reset = (now?: number): void => {
generation += 1;
state = { kind: 'idle' };
pendingTargetId = null;
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
};
const enterCooldown = (now: number): void => {
generation += 1;
state = { kind: 'cooldown', until: now + COOLDOWN_MS };
};
const latch = (
input: SignControllerSample & {
candidateId: SectionId;
candidateDistanceVh: number;
},
targetId: SectionId,
now: number,
acceleration: number,
viewportWidth: number,
viewportHeight: number,
): VisibleState => {
generation += 1;
return {
kind: 'visible',
targetId: input.candidateId,
latchedAt: input.now,
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
departureRequested: false,
targetId,
latchedAt: now,
approachDuration: SIGN_LIFECYCLE.approachMs
- SIGN_LIFECYCLE.approachReductionMs * clamp01(acceleration),
viewportProfile: signViewportProfileFor(viewportWidth, viewportHeight),
dwellStartedAt: null,
departStartedAt: null,
generation,
};
@@ -115,27 +91,25 @@ export function createSignController(): SignController {
const requestDeparture = (
visible: VisibleState,
current: SignSnapshot,
now: number,
): void => {
const dwellCompleteAt = visible.latchedAt
+ ANTICIPATE_MS
+ visible.approachDuration
+ DWELL_MS;
if (current.phase === 'dwell' && now < dwellCompleteAt) {
visible.departureRequested = true;
return;
}
if (
visible.dwellStartedAt === null
|| now < visible.dwellStartedAt + SIGN_LIFECYCLE.dwellMs
) return;
beginDeparture(visible, now);
};
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
if (visible.departStartedAt !== null) {
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
const progress = clamp01((now - visible.departStartedAt) / SIGN_LIFECYCLE.departMs);
if (progress >= 1) {
if (visible.dwellStartedAt !== null) completedTargets.add(visible.targetId);
state = {
kind: 'cooldown',
until: visible.departStartedAt + DEPART_MS + COOLDOWN_MS,
until: visible.departStartedAt
+ SIGN_LIFECYCLE.departMs
+ SIGN_LIFECYCLE.cooldownMs,
};
return null;
}
@@ -144,20 +118,22 @@ export function createSignController(): SignController {
phase: 'depart',
progress,
opacity: 1 - smoothstep(0.1, 1, progress),
viewportProfile: visible.viewportProfile,
};
}
const elapsed = Math.max(0, now - visible.latchedAt);
if (elapsed < ANTICIPATE_MS) {
if (elapsed < SIGN_LIFECYCLE.anticipateMs) {
return {
targetId: visible.targetId,
phase: 'anticipate',
progress: clamp01(elapsed / ANTICIPATE_MS),
progress: clamp01(elapsed / SIGN_LIFECYCLE.anticipateMs),
opacity: 0,
viewportProfile: visible.viewportProfile,
};
}
const approachElapsed = elapsed - ANTICIPATE_MS;
const approachElapsed = elapsed - SIGN_LIFECYCLE.anticipateMs;
if (approachElapsed < visible.approachDuration) {
const progress = clamp01(approachElapsed / visible.approachDuration);
return {
@@ -165,17 +141,46 @@ export function createSignController(): SignController {
phase: 'approach',
progress,
opacity: smoothstep(0, 0.42, progress),
viewportProfile: visible.viewportProfile,
};
}
if (visible.dwellStartedAt === null) visible.dwellStartedAt = now;
return {
targetId: visible.targetId,
phase: 'dwell',
progress: 1,
opacity: 1,
viewportProfile: visible.viewportProfile,
};
};
const canonicalCandidate = (input: SignControllerSample): SectionId | null => {
if (
input.direction !== 1
|| input.deltaY <= 0
|| input.source !== 'passive-scroll'
|| !input.motionAllowed
) return null;
if (input.crossedBoundaries > 0) {
const route = signRouteFor(input.activeSectionId);
return route.eligible && !completedTargets.has(route.id) ? route.id : null;
}
if (
input.candidateId === null
|| input.candidateId === input.activeSectionId
|| input.candidateDistanceVh === null
|| input.candidateDistanceVh <= 0
) return null;
const route = signRouteFor(input.candidateId);
return route.eligible
&& input.candidateDistanceVh <= route.triggerMaximumVh
&& !completedTargets.has(route.id)
? route.id
: null;
};
const sample = (input: SignControllerSample): SignSnapshot | null => {
if (!isFiniteSample(input) || (lastNow !== null && input.now < lastNow)) {
reset();
@@ -184,85 +189,113 @@ export function createSignController(): SignController {
lastNow = input.now;
if (!input.motionAllowed || input.source !== 'passive-scroll') {
if (input.source !== 'passive-scroll') {
const route = signRouteFor(input.activeSectionId);
if (route.eligible) completedTargets.add(route.id);
}
reset(input.now);
return null;
}
const candidate = canonicalCandidate(input);
const discontinuous =
input.crossedBoundaries > 1 ||
Math.abs(input.deltaY) > input.viewportHeight * JUMP_DISTANCE_VH;
if (discontinuous) {
if (state.kind !== 'visible') return null;
const current = snapshotVisible(state, input.now);
if (current === null || current.opacity <= CONCEALED_OPACITY) {
enterCooldown(input.now);
return null;
}
requestDeparture(state, current, input.now);
return snapshotVisible(state, input.now);
}
input.crossedBoundaries > 1
|| Math.abs(input.deltaY) > input.viewportHeight * 0.75;
if (state.kind === 'cooldown') {
if (candidate !== null) pendingTargetId = candidate;
if (input.now < state.until) return null;
state = { kind: 'idle' };
if (pendingTargetId !== null && !completedTargets.has(pendingTargetId)) {
const targetId = pendingTargetId;
pendingTargetId = null;
state = latch(
targetId,
input.now,
input.acceleration,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
);
return snapshotVisible(state, input.now);
}
}
if (state.kind === 'idle') {
if (!canLatch(input)) return null;
state = latch(input);
if (candidate === null) return null;
state = latch(
candidate,
input.now,
input.acceleration,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
);
return snapshotVisible(state, input.now);
}
if (candidate !== null && candidate !== state.targetId) {
pendingTargetId = candidate;
}
const current = snapshotVisible(state, input.now);
if (current === null) return null;
if (
current.opacity <= CONCEALED_OPACITY
&& canLatch(input)
&& input.candidateId !== state.targetId
current.opacity <= SIGN_LIFECYCLE.concealedOpacity
&& candidate !== null
&& candidate !== state.targetId
) {
state = latch(input);
pendingTargetId = null;
state = latch(
candidate,
input.now,
input.acceleration,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
);
return snapshotVisible(state, input.now);
}
const activeIndex = SECTION_ORDER.indexOf(input.activeSectionId);
const targetIndex = SECTION_ORDER.indexOf(state.targetId);
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
if (targetPassed && state.departStartedAt === null) {
if (current.opacity <= CONCEALED_OPACITY) {
enterCooldown(input.now);
if (discontinuous && state.departStartedAt === null) {
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
state = { kind: 'idle' };
return null;
}
requestDeparture(state, current, input.now);
requestDeparture(state, input.now);
return snapshotVisible(state, input.now);
}
const activeIndex = SIGN_ROUTE_ORDER.indexOf(input.activeSectionId);
const targetIndex = SIGN_ROUTE_ORDER.indexOf(state.targetId);
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
if (targetPassed && state.departStartedAt === null) {
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
state = { kind: 'idle' };
return null;
}
requestDeparture(state, input.now);
return snapshotVisible(state, input.now);
}
if (input.direction < 0) {
if (current.opacity <= CONCEALED_OPACITY) {
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
state = { kind: 'idle' };
return null;
}
if (state.departStartedAt === null) requestDeparture(state, current, input.now);
if (state.departStartedAt === null) requestDeparture(state, input.now);
}
if (state.departStartedAt === null) {
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
const dwellComplete = input.now >= dwellStartedAt + DWELL_MS;
if (
dwellComplete
&& (state.departureRequested || input.activeSectionId === state.targetId)
) {
beginDeparture(state, input.now);
}
const dwellStartedAt = state.dwellStartedAt;
const dwellComplete = dwellStartedAt !== null
&& input.now >= dwellStartedAt + SIGN_LIFECYCLE.dwellMs;
if (dwellComplete) beginDeparture(state, input.now);
}
return snapshotVisible(state, input.now);
};
const needsFrame = (): boolean => {
if (state.kind !== 'visible' || lastNow === null) return false;
if (state.departStartedAt !== null) return true;
return lastNow < state.latchedAt + ANTICIPATE_MS + state.approachDuration + DWELL_MS;
if (state.kind === 'cooldown') return pendingTargetId !== null;
return state.kind === 'visible' && lastNow !== null;
};
return { sample, reset, needsFrame };

View File

@@ -0,0 +1,75 @@
import type { SectionId } from '@/content/types';
export type SignCorridor = 'left' | 'right' | 'outer-left' | 'outer-right';
export type SignViewportProfile = 'wide' | 'tall' | 'short';
export interface SignRouteSpec {
id: SectionId;
label: string;
marker: string;
eligible: boolean;
corridor: SignCorridor | null;
triggerMaximumVh: number;
}
export const SIGN_LIFECYCLE = Object.freeze({
anticipateMs: 300,
approachMs: 900,
approachReductionMs: 250,
dwellMs: 1_400,
departMs: 700,
cooldownMs: 350,
concealedOpacity: 0.02,
});
export const SIGN_ROUTE_ORDER: readonly SectionId[] = [
'start',
'profile',
'experience',
'infrastructure',
'selected-builds',
'additional-work',
'toolkit',
'contact',
];
const routeSpecs: Readonly<Record<SectionId, SignRouteSpec>> = Object.freeze({
start: {
id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0,
},
profile: {
id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
},
experience: {
id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
},
infrastructure: {
id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
},
'selected-builds': {
id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
},
'additional-work': {
id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
},
toolkit: {
id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55,
},
contact: {
id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
},
});
export function signRouteFor(id: SectionId): SignRouteSpec {
return routeSpecs[id];
}
export function signViewportProfileFor(
width: number,
height: number,
): SignViewportProfile {
const safeWidth = Number.isFinite(width) ? Math.max(1, width) : 1;
const safeHeight = Number.isFinite(height) ? Math.max(1, height) : 1;
const aspect = safeWidth / safeHeight;
return safeHeight < 560 && aspect >= 1 ? 'short' : aspect < 1.05 ? 'tall' : 'wide';
}

View File

@@ -1,4 +1,5 @@
import type { SectionId } from "@/content/types";
import type { SignViewportProfile } from '@/journey/sign-routes';
export interface ScrollDriveSample {
scrollY: number;
@@ -38,6 +39,7 @@ export interface SignSnapshot {
phase: SignPhase;
progress: number;
opacity: number;
viewportProfile?: SignViewportProfile;
}
export interface SignControllerSample {
@@ -46,6 +48,7 @@ export interface SignControllerSample {
direction: -1 | 0 | 1;
acceleration: number;
deltaY: number;
viewportWidth?: number;
viewportHeight: number;
crossedBoundaries: number;
candidateId: SectionId | null;

View File

@@ -574,6 +574,9 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
phase: snapshot.sign.phase,
progress: finite(snapshot.sign.progress),
opacity: finite(snapshot.sign.opacity),
...(snapshot.sign.viewportProfile === undefined
? {}
: { viewportProfile: snapshot.sign.viewportProfile }),
} : null,
arrived: Boolean(snapshot.arrived),
};

View File

@@ -7,7 +7,7 @@ import { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun';
import { createCar, loadGrandTourer } from '@/scene/world/car';
import { createSign } from '@/scene/world/sign';
import { createSign, prepareSignTypography } from '@/scene/world/sign';
import { createAtmosphere } from '@/scene/world/atmosphere';
import { createPostProcessor } from '@/scene/post-processing';
import { createDriveDynamics } from '@/scene/drive-dynamics';
@@ -97,7 +97,10 @@ export async function createProceduralSceneGeneration(
const authoredCar = await loadGrandTourer(context.signal);
const car = createCar(context.resources, authoredCar);
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
const sign = createSign(context.resources);
await prepareSignTypography(context.signal);
const sign = createSign(context.resources, {
maximumAnisotropy: renderer.capabilities.getMaxAnisotropy(),
});
const atmosphere = createAtmosphere(context.resources, quality);
scene.add(
atmosphere.group,
@@ -152,7 +155,7 @@ export async function createProceduralSceneGeneration(
}
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
car.group.visible = height >= 340;
sign.setViewportAspect(camera.aspect);
sign.setViewportSize(width, height);
atmosphere.setViewportAspect(camera.aspect);
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
if (TEST_BUILD && context.quality !== 'medium') {
@@ -296,6 +299,21 @@ export async function createProceduralSceneGeneration(
camera.fov = cameraState.fov;
camera.updateProjectionMatrix();
}
if (TEST_BUILD && snapshot.sign !== null) {
const presentation = sign.inspect(camera, width, height);
if (presentation !== null) {
window.dispatchEvent(new CustomEvent('portfolio-sign-frame', {
detail: {
now,
...snapshot.sign,
activeSectionId: snapshot.activeSectionId,
direction: snapshot.direction,
...presentation,
rendererPixelRatio: renderer.getPixelRatio(),
},
}));
}
}
if (postProcessor) {
postProcessor.setDynamics(response.bloomEnergy);
postProcessor.render(scene, camera);

View File

@@ -1,19 +1,15 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import type { SignSnapshot } from '@/journey/types';
import {
signRouteFor,
signViewportProfileFor,
type SignCorridor,
type SignRouteSpec,
type SignViewportProfile,
} from '@/journey/sign-routes';
import type { SceneResourceBag } from '@/scene/resources';
const routeLabels: Readonly<Record<SectionId, string>> = {
start: 'Start',
profile: 'Profile',
experience: 'Experience',
infrastructure: 'Infrastructure',
'selected-builds': 'Selected builds',
'additional-work': 'Additional work',
toolkit: 'Toolkit',
contact: 'Contact',
};
interface SignPose {
x: number;
y: number;
@@ -21,22 +17,111 @@ interface SignPose {
scale: number;
}
const NARROW_SIGN_POSES = {
horizon: { x: 5.2, y: 6.8, z: -142, scale: 0.15 },
dwell: { x: 5.8, y: 4.86, z: -20, scale: 0.82 },
} as const;
interface SignPoseSet {
horizon: SignPose;
dwell: SignPose;
}
const WIDE_SIGN_POSES = {
horizon: { x: 9.1, y: 7.4, z: -160, scale: 0.18 },
dwell: { x: 10.7, y: 6.64, z: -14, scale: 1.12 },
} as const;
interface ProjectingCamera {
updateMatrixWorld(force: boolean): void;
}
const SIGN_MARKER_FONT = '560 46px "Geist Mono Variable", ui-monospace, monospace';
const SIGN_LABEL_FONT = '660 164px "Geist Mono Variable", ui-monospace, monospace';
const SIGN_POSES: Readonly<Record<SignViewportProfile, Readonly<Record<SignCorridor, SignPoseSet>>>> = {
wide: {
left: {
horizon: { x: -9.6, y: 7.2, z: -156, scale: 0.2 },
dwell: { x: -11.6, y: 7.9, z: -16, scale: 1.25 },
},
right: {
horizon: { x: 10.2, y: 7.2, z: -156, scale: 0.2 },
dwell: { x: 12.4, y: 7.9, z: -16, scale: 1.25 },
},
'outer-left': {
horizon: { x: -17.5, y: 7.4, z: -168, scale: 0.2 },
dwell: { x: -21.5, y: 8.55, z: -24, scale: 1.35 },
},
'outer-right': {
horizon: { x: 18.5, y: 7.4, z: -168, scale: 0.2 },
dwell: { x: 22.5, y: 8.55, z: -24, scale: 1.35 },
},
},
tall: {
left: {
horizon: { x: -4.2, y: 6.4, z: -136, scale: 0.16 },
dwell: { x: -5.2, y: 8.1, z: -24, scale: 1.3 },
},
right: {
horizon: { x: 4.2, y: 6.4, z: -136, scale: 0.16 },
dwell: { x: 5.2, y: 8.1, z: -24, scale: 1.3 },
},
'outer-left': {
horizon: { x: -5.8, y: 6.7, z: -148, scale: 0.16 },
dwell: { x: -7.2, y: 8.72, z: -30, scale: 1.4 },
},
'outer-right': {
horizon: { x: 5.8, y: 6.7, z: -148, scale: 0.16 },
dwell: { x: 7.2, y: 8.72, z: -30, scale: 1.4 },
},
},
short: {
left: {
horizon: { x: -7.8, y: 6.6, z: -144, scale: 0.18 },
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.55 },
},
right: {
horizon: { x: 7.8, y: 6.6, z: -144, scale: 0.18 },
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.55 },
},
'outer-left': {
horizon: { x: -11.2, y: 6.8, z: -154, scale: 0.18 },
dwell: { x: -12.6, y: 5.8, z: -32, scale: 2.25 },
},
'outer-right': {
horizon: { x: 11.2, y: 6.8, z: -154, scale: 0.18 },
dwell: { x: 12.6, y: 5.8, z: -32, scale: 2.25 },
},
},
};
export interface SignWorld {
group: SignGroup;
inspect(
camera: ProjectingCamera,
width: number,
height: number,
): SignPresentation | null;
setViewportAspect(aspect: number): void;
setViewportSize(width: number, height: number): void;
update(sign: SignSnapshot | null): void;
}
export interface SignPresentation {
visible: boolean;
corridor: SignCorridor;
viewportProfile: SignViewportProfile;
bounds: {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
};
texture: {
width: number;
height: number;
anisotropy: number;
fontReadyWhenDrawn: boolean;
};
}
export interface SignRenderOptions {
maximumAnisotropy?: number;
}
interface SignGroup {
visible: boolean;
position: {
@@ -51,13 +136,28 @@ interface SignGroup {
scale: { x: number; setScalar(scale: number): void };
add(...objects: unknown[]): void;
getObjectByName(name: string): unknown;
updateMatrixWorld(force: boolean): void;
}
export function routeLabelFor(id: SectionId): string {
return routeLabels[id];
return signRouteFor(id).label;
}
export function createSign(resources: SceneResourceBag): SignWorld {
export async function prepareSignTypography(signal: AbortSignal): Promise<void> {
const fonts = document.fonts;
if (fonts === undefined) return;
await Promise.all([
fonts.load(SIGN_MARKER_FONT),
fonts.load(SIGN_LABEL_FONT),
fonts.ready,
]);
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
}
export function createSign(
resources: SceneResourceBag,
options: SignRenderOptions = {},
): SignWorld {
const group = new THREE.Group();
group.name = 'roadside-sign';
group.visible = false;
@@ -103,7 +203,7 @@ export function createSign(resources: SceneResourceBag): SignWorld {
}));
const housing = new THREE.Mesh(
resources.own('geometry', createSignBody(7.5, 2.3, 0.34, 0.2)),
resources.own('geometry', createSignBody(8.55, 2.65, 0.42, 0.22)),
housingMaterial,
);
housing.name = 'sign-housing';
@@ -111,7 +211,7 @@ export function createSign(resources: SceneResourceBag): SignWorld {
group.add(housing);
const backplate = new THREE.Mesh(
resources.own('geometry', createSignBody(7.2, 2.04, 0.18, 0.16)),
resources.own('geometry', createSignBody(8.24, 2.34, 0.2, 0.18)),
backplateMaterial,
);
backplate.name = 'sign-backplate';
@@ -119,15 +219,18 @@ export function createSign(resources: SceneResourceBag): SignWorld {
group.add(backplate);
const canvas = document.createElement('canvas');
canvas.width = 1_024;
canvas.height = 288;
canvas.width = 2_048;
canvas.height = 512;
const context = canvas.getContext('2d');
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.anisotropy = 4;
texture.anisotropy = Math.min(
8,
Math.max(1, Math.floor(options.maximumAnisotropy ?? 8)),
);
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
@@ -140,55 +243,56 @@ export function createSign(resources: SceneResourceBag): SignWorld {
toneMapped: false,
}));
const face = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(6.92, 1.76)),
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
faceMaterial,
);
face.name = 'sign-face';
face.position.z = 0.545;
face.position.z = 0.625;
face.userData.textureUpdateCount = 0;
face.userData.fontReadyWhenDrawn = false;
group.add(face);
const edgeLight = new THREE.Group();
edgeLight.name = 'sign-edge-light';
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.055, 1.42, 0.055));
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.16, 0.045, 0.055));
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.055, 1.62, 0.055));
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.32, 0.045, 0.055));
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
leftEdge.position.set(-3.36, 0, 0.585);
leftEdge.position.set(-3.9, 0, 0.665);
edgeLight.add(leftEdge);
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
upperEdge.position.set(-2.81, 0.88, 0.585);
upperEdge.position.set(-3.28, 1, 0.665);
edgeLight.add(upperEdge);
group.add(edgeLight);
const routeAccent = new THREE.Mesh(
resources.own('geometry', new THREE.BoxGeometry(1.05, 0.055, 0.065)),
resources.own('geometry', new THREE.BoxGeometry(1.18, 0.055, 0.065)),
routeMaterial,
);
routeAccent.name = 'sign-route-accent';
routeAccent.position.set(2.77, -0.88, 0.585);
routeAccent.position.set(3.35, -1, 0.665);
group.add(routeAccent);
const post = new THREE.Mesh(
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.14, 4.85, 8)),
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.145, 5.05, 8)),
supportMaterial,
);
post.name = 'sign-post';
post.position.set(2.35, -3.32, -0.14);
post.position.set(2.75, -3.58, -0.16);
group.add(post);
const bracket = new THREE.Group();
bracket.name = 'sign-bracket';
const arm = new THREE.Mesh(
resources.own('geometry', new THREE.BoxGeometry(2.5, 0.11, 0.15)),
resources.own('geometry', new THREE.BoxGeometry(2.95, 0.11, 0.15)),
supportMaterial,
);
arm.position.set(1.18, -1.08, -0.13);
arm.position.set(1.4, -1.24, -0.15);
bracket.add(arm);
const brace = new THREE.Mesh(
resources.own('geometry', new THREE.BoxGeometry(0.1, 1.25, 0.12)),
supportMaterial,
);
brace.position.set(1.72, -1.56, -0.13);
brace.position.set(2.05, -1.74, -0.15);
brace.rotation.z = -0.72;
bracket.add(brace);
group.add(bracket);
@@ -198,7 +302,7 @@ export function createSign(resources: SceneResourceBag): SignWorld {
supportMaterial,
);
footing.name = 'sign-footing';
footing.position.set(2.35, -5.76, -0.14);
footing.position.set(2.75, -6.18, -0.16);
group.add(footing);
const fadeMaterials = [
@@ -212,15 +316,24 @@ export function createSign(resources: SceneResourceBag): SignWorld {
const departureOrigin = new THREE.Vector3();
let departureScale = 1;
let target: SectionId | null = null;
let pendingNarrowViewport = false;
let activeNarrowViewport = false;
let pendingViewport: SignViewportProfile = 'wide';
let activeViewport: SignViewportProfile = 'wide';
let activeCorridor: SignCorridor = 'right';
let previousPhase: SignSnapshot['phase'] | null = null;
const faceCornerCoordinates = [
[-4, -1, 0],
[4, -1, 0],
[4, 1, 0],
[-4, 1, 0],
] as const;
const projectedCorner = new THREE.Vector3();
const updateTexture = (id: SectionId): void => {
if (target === id) return;
target = id;
face.userData.textureUpdateCount += 1;
if (context) drawLabel(context, routeLabelFor(id), canvas.width, canvas.height);
face.userData.fontReadyWhenDrawn = document.fonts?.check(SIGN_LABEL_FONT) ?? true;
if (context) drawLabel(context, signRouteFor(id), canvas.width, canvas.height);
texture.needsUpdate = true;
};
@@ -231,34 +344,92 @@ export function createSign(resources: SceneResourceBag): SignWorld {
const applyPose = (pose: SignPose): void => {
group.position.set(pose.x, pose.y, pose.z);
group.scale.setScalar(pose.scale);
plantMounting(pose.y, pose.scale);
};
const plantMounting = (groupY: number, groupScale: number): void => {
const safeScale = Math.max(0.001, groupScale);
const footingY = -groupY / safeScale;
const postTopY = -1.36;
const postHeight = Math.max(0.36, postTopY - footingY);
post.position.y = (postTopY + footingY) * 0.5;
post.scale.set(1 / safeScale, postHeight / 5.05, 1 / safeScale);
footing.position.y = footingY;
footing.scale.setScalar(1 / Math.max(1, safeScale));
};
return {
group,
inspect(camera, width, height) {
if (target === null || width <= 0 || height <= 0) return null;
group.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
let left = Number.POSITIVE_INFINITY;
let top = Number.POSITIVE_INFINITY;
let right = Number.NEGATIVE_INFINITY;
let bottom = Number.NEGATIVE_INFINITY;
for (const [xPosition, yPosition, zPosition] of faceCornerCoordinates) {
projectedCorner
.set(xPosition, yPosition, zPosition)
.applyMatrix4(face.matrixWorld)
.project(camera);
const x = (projectedCorner.x + 1) * 0.5 * width;
const y = (1 - projectedCorner.y) * 0.5 * height;
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x);
bottom = Math.max(bottom, y);
}
return {
visible: group.visible,
corridor: activeCorridor,
viewportProfile: activeViewport,
bounds: {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
},
texture: {
width: canvas.width,
height: canvas.height,
anisotropy: texture.anisotropy,
fontReadyWhenDrawn: face.userData.fontReadyWhenDrawn === true,
},
};
},
setViewportAspect(aspect) {
pendingNarrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
if (!group.visible) activeNarrowViewport = pendingNarrowViewport;
pendingViewport = !Number.isFinite(aspect) || aspect < 0.85 ? 'tall' : 'wide';
if (previousPhase === null) activeViewport = pendingViewport;
},
setViewportSize(width, height) {
pendingViewport = signViewportProfileFor(width, height);
if (previousPhase === null) activeViewport = pendingViewport;
},
update(sign) {
if (!sign) {
group.visible = false;
applyOpacity(0);
previousPhase = null;
activeNarrowViewport = pendingNarrowViewport;
activeViewport = pendingViewport;
return;
}
if (target !== null && target !== sign.targetId && group.visible) return;
if (target !== sign.targetId) {
activeNarrowViewport = pendingNarrowViewport;
activeViewport = sign.viewportProfile ?? pendingViewport;
updateTexture(sign.targetId);
}
const opacity = clamp01(sign.opacity);
const progress = smoothstep(clamp01(sign.progress));
const { horizon, dwell } = activeNarrowViewport
? NARROW_SIGN_POSES
: WIDE_SIGN_POSES;
const route = signRouteFor(sign.targetId);
const corridor = route.corridor ?? 'right';
activeCorridor = corridor;
const { horizon, dwell } = SIGN_POSES[activeViewport][corridor];
const side = corridor.includes('left') ? -1 : 1;
if (sign.phase === 'anticipate') {
applyPose(horizon);
@@ -269,6 +440,7 @@ export function createSign(resources: SceneResourceBag): SignWorld {
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
);
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
plantMounting(group.position.y, group.scale.x);
} else if (sign.phase === 'dwell') {
applyPose(dwell);
} else {
@@ -277,15 +449,18 @@ export function createSign(resources: SceneResourceBag): SignWorld {
departureOrigin.copy(group.position);
departureScale = group.scale.x;
}
const lateralExit = activeViewport === 'wide'
? 14
: activeViewport === 'short' ? 10 : 8;
group.position.set(
departureOrigin.x + progress * (activeNarrowViewport ? 2.2 : 4.4),
departureOrigin.y + progress * 0.62,
THREE.MathUtils.lerp(departureOrigin.z, 28, progress),
departureOrigin.x + progress * side * lateralExit,
departureOrigin.y + progress * 0.72,
departureOrigin.z - progress * 32,
);
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 1.06, progress));
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 0.82, progress));
}
group.rotation.y = activeNarrowViewport ? -0.14 : -0.23;
group.rotation.y = -side * (activeViewport === 'wide' ? 0.2 : 0.12);
applyOpacity(opacity);
group.visible = opacity > 0;
previousPhase = sign.phase;
@@ -325,32 +500,32 @@ function createClippedShape(width: number, height: number, cut: number) {
function drawLabel(
context: CanvasRenderingContext2D,
label: string,
route: SignRouteSpec,
width: number,
height: number,
): void {
context.clearRect(0, 0, width, height);
context.fillStyle = '#07111a';
context.fillRect(0, 0, width, height);
context.fillStyle = '#55d4df';
context.fillRect(42, 36, 8, height - 72);
context.fillStyle = '#91aab3';
context.font = '500 23px "Geist Mono Variable", ui-monospace, monospace';
context.fillStyle = '#4cbac4';
context.fillRect(86, 72, 12, height - 144);
context.fillStyle = '#879da4';
context.font = SIGN_MARKER_FONT;
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillText('NEXT / ROUTE', 82, 72);
context.fillStyle = '#eef7f8';
context.font = '650 74px "Geist Mono Variable", ui-monospace, monospace';
context.fillText(label, 82, 170, width - 210);
context.strokeStyle = '#d99758';
context.lineWidth = 7;
context.fillText(`ROUTE ${route.marker} / NEXT`, 150, 126);
context.fillStyle = '#c8d8da';
context.font = SIGN_LABEL_FONT;
context.fillText(route.label, 150, 326, width - 420);
context.strokeStyle = '#cf8e54';
context.lineWidth = 12;
context.lineCap = 'square';
context.beginPath();
context.moveTo(width - 116, 156);
context.lineTo(width - 74, 156);
context.lineTo(width - 92, 138);
context.moveTo(width - 74, 156);
context.lineTo(width - 92, 174);
context.moveTo(width - 250, 304);
context.lineTo(width - 158, 304);
context.lineTo(width - 198, 264);
context.moveTo(width - 158, 304);
context.lineTo(width - 198, 344);
context.stroke();
}