fix: make nightdrive signage reliable and legible
This commit is contained in:
@@ -100,6 +100,7 @@ export async function createProceduralSceneGeneration(
|
||||
await prepareSignTypography(context.signal);
|
||||
const sign = createSign(context.resources, {
|
||||
maximumAnisotropy: renderer.capabilities.getMaxAnisotropy(),
|
||||
maximumTextureSize: renderer.capabilities.maxTextureSize,
|
||||
});
|
||||
const atmosphere = createAtmosphere(context.resources, quality);
|
||||
scene.add(
|
||||
@@ -228,7 +229,7 @@ export async function createProceduralSceneGeneration(
|
||||
response.bloomEnergy,
|
||||
);
|
||||
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
|
||||
sign.update(snapshot.sign);
|
||||
sign.update(snapshot.sign, now);
|
||||
atmosphere.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
@@ -310,6 +311,7 @@ export async function createProceduralSceneGeneration(
|
||||
direction: snapshot.direction,
|
||||
...presentation,
|
||||
rendererPixelRatio: renderer.getPixelRatio(),
|
||||
viewport: { width, height },
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as THREE from 'three';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import type { SignSnapshot } from '@/journey/types';
|
||||
import {
|
||||
SIGN_LIFECYCLE,
|
||||
signRouteFor,
|
||||
signViewportProfileFor,
|
||||
type SignCorridor,
|
||||
@@ -28,64 +29,77 @@ interface ProjectingCamera {
|
||||
|
||||
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 SHORT_CORRIDOR_REFERENCE_ASPECT = 1.58;
|
||||
const SHORT_CORRIDOR_BASE_RELIEF = 20;
|
||||
const SHORT_CORRIDOR_RELIEF_PER_ASPECT = 20;
|
||||
const SHORT_CORRIDOR_MINIMUM_RELIEF = 12;
|
||||
const SHORT_CORRIDOR_MAXIMUM_RELIEF = 34;
|
||||
const RESPONSIVE_REFLOW_MS = 320;
|
||||
|
||||
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 },
|
||||
dwell: { x: -11.9, y: 7.95, z: -16, scale: 1.36 },
|
||||
},
|
||||
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 },
|
||||
dwell: { x: 12.8, y: 7.95, z: -16, scale: 1.36 },
|
||||
},
|
||||
'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 },
|
||||
horizon: { x: -21.5, y: 7.4, z: -168, scale: 0.2 },
|
||||
dwell: { x: -30.4, y: 8.65, z: -24, scale: 1.46 },
|
||||
},
|
||||
'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 },
|
||||
horizon: { x: 26.5, y: 7.4, z: -168, scale: 0.2 },
|
||||
dwell: { x: 41.8, y: 8.65, z: -24, scale: 1.46 },
|
||||
},
|
||||
},
|
||||
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 },
|
||||
dwell: { x: -5.4, y: 8.15, z: -24, scale: 1.4 },
|
||||
},
|
||||
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 },
|
||||
dwell: { x: 5.4, y: 8.15, z: -24, scale: 1.4 },
|
||||
},
|
||||
'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 },
|
||||
dwell: { x: -7.6, y: 8.8, z: -30, scale: 1.48 },
|
||||
},
|
||||
'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 },
|
||||
dwell: { x: 7.6, y: 8.8, z: -30, scale: 1.48 },
|
||||
},
|
||||
},
|
||||
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 },
|
||||
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.62 },
|
||||
},
|
||||
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 },
|
||||
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.62 },
|
||||
},
|
||||
'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 },
|
||||
horizon: { x: -14.2, y: 6.8, z: -154, scale: 0.18 },
|
||||
dwell: { x: -26.5, y: 5.8, z: -32, scale: 2.3 },
|
||||
},
|
||||
'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 },
|
||||
horizon: { x: 14.2, y: 6.8, z: -154, scale: 0.18 },
|
||||
dwell: { x: 26.5, y: 5.8, z: -32, scale: 2.3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PORTRAIT_NAVIGATION_DWELL: Readonly<Record<SignCorridor, SignPose>> = {
|
||||
left: { x: -2.9, y: 3, z: 8, scale: 0.25 },
|
||||
right: { x: 2.9, y: 3, z: 8, scale: 0.25 },
|
||||
'outer-left': { x: -2.9, y: 3, z: 8, scale: 0.25 },
|
||||
'outer-right': { x: 2.9, y: 3, z: 8, scale: 0.25 },
|
||||
};
|
||||
|
||||
export interface SignWorld {
|
||||
group: SignGroup;
|
||||
inspect(
|
||||
@@ -95,7 +109,7 @@ export interface SignWorld {
|
||||
): SignPresentation | null;
|
||||
setViewportAspect(aspect: number): void;
|
||||
setViewportSize(width: number, height: number): void;
|
||||
update(sign: SignSnapshot | null): void;
|
||||
update(sign: SignSnapshot | null, now?: number): void;
|
||||
}
|
||||
|
||||
export interface SignPresentation {
|
||||
@@ -113,6 +127,7 @@ export interface SignPresentation {
|
||||
texture: {
|
||||
width: number;
|
||||
height: number;
|
||||
maximumSize: number;
|
||||
anisotropy: number;
|
||||
fontReadyWhenDrawn: boolean;
|
||||
};
|
||||
@@ -120,6 +135,7 @@ export interface SignPresentation {
|
||||
|
||||
export interface SignRenderOptions {
|
||||
maximumAnisotropy?: number;
|
||||
maximumTextureSize?: number;
|
||||
}
|
||||
|
||||
interface SignGroup {
|
||||
@@ -163,67 +179,84 @@ export function createSign(
|
||||
group.visible = false;
|
||||
|
||||
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x071019,
|
||||
roughness: 0.58,
|
||||
metalness: 0.66,
|
||||
color: 0x08131c,
|
||||
roughness: 0.5,
|
||||
metalness: 0.7,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x111d27,
|
||||
roughness: 0.78,
|
||||
metalness: 0.34,
|
||||
color: 0x172731,
|
||||
roughness: 0.72,
|
||||
metalness: 0.38,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const facePanelMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x061018,
|
||||
roughness: 0.9,
|
||||
metalness: 0.12,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x142631,
|
||||
roughness: 0.52,
|
||||
metalness: 0.72,
|
||||
color: 0x21343e,
|
||||
roughness: 0.56,
|
||||
metalness: 0.68,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x3a8892,
|
||||
emissive: 0x53d2dc,
|
||||
emissiveIntensity: 1.15,
|
||||
roughness: 0.34,
|
||||
metalness: 0.58,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x8b522d,
|
||||
emissive: 0xe49a58,
|
||||
emissiveIntensity: 0.72,
|
||||
color: 0x39747a,
|
||||
emissive: 0x55b9c0,
|
||||
emissiveIntensity: 0.42,
|
||||
roughness: 0.42,
|
||||
metalness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x8e6446,
|
||||
emissive: 0xb9723f,
|
||||
emissiveIntensity: 0.28,
|
||||
roughness: 0.48,
|
||||
metalness: 0.42,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const housing = new THREE.Mesh(
|
||||
resources.own('geometry', createSignBody(8.55, 2.65, 0.42, 0.22)),
|
||||
resources.own('geometry', createSignBody(8.64, 2.72, 0.5, 0.24)),
|
||||
housingMaterial,
|
||||
);
|
||||
housing.name = 'sign-housing';
|
||||
housing.position.z = -0.05;
|
||||
housing.position.z = -0.08;
|
||||
group.add(housing);
|
||||
|
||||
const backplate = new THREE.Mesh(
|
||||
resources.own('geometry', createSignBody(8.24, 2.34, 0.2, 0.18)),
|
||||
resources.own('geometry', createSignBody(8.3, 2.38, 0.2, 0.18)),
|
||||
backplateMaterial,
|
||||
);
|
||||
backplate.name = 'sign-backplate';
|
||||
backplate.position.z = 0.17;
|
||||
backplate.position.z = 0.15;
|
||||
group.add(backplate);
|
||||
|
||||
const facePanel = new THREE.Mesh(
|
||||
resources.own('geometry', createSignBody(8.08, 2.08, 0.1, 0.14)),
|
||||
facePanelMaterial,
|
||||
);
|
||||
facePanel.name = 'sign-face-panel';
|
||||
facePanel.position.z = 0.43;
|
||||
group.add(facePanel);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2_048;
|
||||
canvas.height = 512;
|
||||
const maximumTextureSize = normalizeTextureLimit(options.maximumTextureSize);
|
||||
canvas.width = Math.min(2_048, powerOfTwoAtMost(maximumTextureSize));
|
||||
canvas.height = Math.max(32, canvas.width / 4);
|
||||
const context = canvas.getContext('2d');
|
||||
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.premultiplyAlpha = true;
|
||||
texture.generateMipmaps = true;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
@@ -240,6 +273,7 @@ export function createSign(
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
}));
|
||||
const face = new THREE.Mesh(
|
||||
@@ -247,79 +281,123 @@ export function createSign(
|
||||
faceMaterial,
|
||||
);
|
||||
face.name = 'sign-face';
|
||||
face.position.z = 0.625;
|
||||
face.position.z = 0.54;
|
||||
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.62, 0.055));
|
||||
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.32, 0.045, 0.055));
|
||||
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.045, 1.56, 0.045));
|
||||
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.18, 0.04, 0.045));
|
||||
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
||||
leftEdge.position.set(-3.9, 0, 0.665);
|
||||
leftEdge.position.set(-3.9, 0, 0.57);
|
||||
edgeLight.add(leftEdge);
|
||||
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
||||
upperEdge.position.set(-3.28, 1, 0.665);
|
||||
upperEdge.position.set(-3.33, 1, 0.57);
|
||||
edgeLight.add(upperEdge);
|
||||
group.add(edgeLight);
|
||||
|
||||
const routeAccent = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(1.18, 0.055, 0.065)),
|
||||
resources.own('geometry', new THREE.BoxGeometry(1.08, 0.045, 0.055)),
|
||||
routeMaterial,
|
||||
);
|
||||
routeAccent.name = 'sign-route-accent';
|
||||
routeAccent.position.set(3.35, -1, 0.665);
|
||||
routeAccent.position.set(3.38, -1, 0.57);
|
||||
group.add(routeAccent);
|
||||
|
||||
const backRails = new THREE.Group();
|
||||
backRails.name = 'sign-back-rails';
|
||||
const backRailGeometry = resources.own('geometry', new THREE.BoxGeometry(7.15, 0.13, 0.16));
|
||||
for (const y of [-0.68, 0.68]) {
|
||||
const rail = new THREE.Mesh(backRailGeometry, supportMaterial);
|
||||
rail.position.set(0, y, -0.38);
|
||||
backRails.add(rail);
|
||||
}
|
||||
group.add(backRails);
|
||||
|
||||
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.22, 5.05, 0.26));
|
||||
const post = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.145, 5.05, 8)),
|
||||
postGeometry,
|
||||
supportMaterial,
|
||||
);
|
||||
post.name = 'sign-post';
|
||||
post.position.set(2.75, -3.58, -0.16);
|
||||
post.position.set(2.62, -3.58, -0.38);
|
||||
group.add(post);
|
||||
|
||||
const secondaryPost = new THREE.Mesh(postGeometry, supportMaterial);
|
||||
secondaryPost.name = 'sign-post-secondary';
|
||||
secondaryPost.position.set(-2.62, -3.58, -0.38);
|
||||
group.add(secondaryPost);
|
||||
const mountedPosts = [post, secondaryPost] as const;
|
||||
|
||||
const bracket = new THREE.Group();
|
||||
bracket.name = 'sign-bracket';
|
||||
const arm = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(2.95, 0.11, 0.15)),
|
||||
resources.own('geometry', new THREE.BoxGeometry(6.1, 0.13, 0.18)),
|
||||
supportMaterial,
|
||||
);
|
||||
arm.position.set(1.4, -1.24, -0.15);
|
||||
arm.position.set(0, -1.24, -0.38);
|
||||
bracket.add(arm);
|
||||
const brace = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(0.1, 1.25, 0.12)),
|
||||
supportMaterial,
|
||||
);
|
||||
brace.position.set(2.05, -1.74, -0.15);
|
||||
brace.rotation.z = -0.72;
|
||||
bracket.add(brace);
|
||||
const braceGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 1.18, 0.14));
|
||||
for (const side of [-1, 1]) {
|
||||
const brace = new THREE.Mesh(braceGeometry, supportMaterial);
|
||||
brace.position.set(side * 2.18, -1.68, -0.38);
|
||||
brace.rotation.z = side * -0.72;
|
||||
bracket.add(brace);
|
||||
}
|
||||
group.add(bracket);
|
||||
|
||||
const footing = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.CylinderGeometry(0.28, 0.34, 0.34, 8)),
|
||||
supportMaterial,
|
||||
);
|
||||
const fasteners = new THREE.Group();
|
||||
fasteners.name = 'sign-fasteners';
|
||||
const fastenerGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8));
|
||||
for (const x of [-3.72, 3.72]) {
|
||||
for (const y of [-0.78, 0.78]) {
|
||||
const fastener = new THREE.Mesh(fastenerGeometry, supportMaterial);
|
||||
fastener.position.set(x, y, 0.57);
|
||||
fastener.rotation.x = Math.PI * 0.5;
|
||||
fasteners.add(fastener);
|
||||
}
|
||||
}
|
||||
group.add(fasteners);
|
||||
|
||||
const footing = new THREE.Group();
|
||||
footing.name = 'sign-footing';
|
||||
footing.position.set(2.75, -6.18, -0.16);
|
||||
const footingGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8));
|
||||
for (const x of [-2.62, 2.62]) {
|
||||
const base = new THREE.Mesh(footingGeometry, supportMaterial);
|
||||
base.position.x = x;
|
||||
footing.add(base);
|
||||
}
|
||||
footing.position.set(0, -6.18, -0.38);
|
||||
group.add(footing);
|
||||
|
||||
const fadeMaterials = [
|
||||
[housingMaterial, 0.97],
|
||||
[backplateMaterial, 1],
|
||||
[facePanelMaterial, 1],
|
||||
[supportMaterial, 0.94],
|
||||
[edgeMaterial, 0.76],
|
||||
[routeMaterial, 0.82],
|
||||
[faceMaterial, 1],
|
||||
] as const;
|
||||
const departureOrigin = new THREE.Vector3();
|
||||
const poseTransitionOrigin = new THREE.Vector3();
|
||||
const poseTransitionDestination = new THREE.Vector3();
|
||||
let departureScale = 1;
|
||||
let poseTransitionOriginScale = 1;
|
||||
let poseTransitionOriginRotationY = 0;
|
||||
let poseTransitionPending = false;
|
||||
let poseTransitionStartedAt: number | null = null;
|
||||
let target: SectionId | null = null;
|
||||
let pendingViewport: SignViewportProfile = 'wide';
|
||||
let activeViewport: SignViewportProfile = 'wide';
|
||||
let pendingAspect = 16 / 9;
|
||||
let activeAspect = 16 / 9;
|
||||
let activeNavigationArrival = false;
|
||||
let activeCorridor: SignCorridor = 'right';
|
||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
||||
let renderedOpacity = 0;
|
||||
const faceCornerCoordinates = [
|
||||
[-4, -1, 0],
|
||||
[4, -1, 0],
|
||||
@@ -341,8 +419,34 @@ export function createSign(
|
||||
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
|
||||
};
|
||||
|
||||
const applyPose = (pose: SignPose): void => {
|
||||
group.position.set(pose.x, pose.y, pose.z);
|
||||
const shortCorridorRelief = (): number => activeViewport === 'short'
|
||||
? THREE.MathUtils.clamp(
|
||||
SHORT_CORRIDOR_BASE_RELIEF
|
||||
+ (activeAspect - SHORT_CORRIDOR_REFERENCE_ASPECT)
|
||||
* SHORT_CORRIDOR_RELIEF_PER_ASPECT,
|
||||
SHORT_CORRIDOR_MINIMUM_RELIEF,
|
||||
SHORT_CORRIDOR_MAXIMUM_RELIEF,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const resolvedPoseX = (
|
||||
pose: SignPose,
|
||||
side: number,
|
||||
weight: number,
|
||||
routeOffsetX = 0,
|
||||
): number => pose.x + side * shortCorridorRelief() * weight + routeOffsetX * weight;
|
||||
|
||||
const applyPose = (
|
||||
pose: SignPose,
|
||||
side: number,
|
||||
lateralWeight: number,
|
||||
routeOffsetX = 0,
|
||||
): void => {
|
||||
group.position.set(
|
||||
resolvedPoseX(pose, side, lateralWeight, routeOffsetX),
|
||||
pose.y,
|
||||
pose.z,
|
||||
);
|
||||
group.scale.setScalar(pose.scale);
|
||||
plantMounting(pose.y, pose.scale);
|
||||
};
|
||||
@@ -352,10 +456,38 @@ export function createSign(
|
||||
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);
|
||||
for (const mountedPost of mountedPosts) {
|
||||
mountedPost.position.y = (postTopY + footingY) * 0.5;
|
||||
mountedPost.scale.set(1, postHeight / 5.05, 1);
|
||||
}
|
||||
footing.position.y = footingY;
|
||||
footing.scale.setScalar(1 / Math.max(1, safeScale));
|
||||
footing.scale.setScalar(1);
|
||||
};
|
||||
|
||||
const stagePoseTransition = (): void => {
|
||||
if (previousPhase === null || previousPhase === 'depart') return;
|
||||
poseTransitionOrigin.copy(group.position);
|
||||
poseTransitionOriginScale = group.scale.x;
|
||||
poseTransitionOriginRotationY = group.rotation.y;
|
||||
poseTransitionPending = true;
|
||||
poseTransitionStartedAt = null;
|
||||
};
|
||||
|
||||
const stageResponsiveViewport = (
|
||||
viewport: SignViewportProfile,
|
||||
aspect: number,
|
||||
): void => {
|
||||
const changed = viewport !== pendingViewport
|
||||
|| Math.abs(aspect - pendingAspect) > 0.001;
|
||||
if (changed) stagePoseTransition();
|
||||
pendingViewport = viewport;
|
||||
pendingAspect = aspect;
|
||||
if (previousPhase === null) {
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -395,32 +527,66 @@ export function createSign(
|
||||
texture: {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
maximumSize: maximumTextureSize,
|
||||
anisotropy: texture.anisotropy,
|
||||
fontReadyWhenDrawn: face.userData.fontReadyWhenDrawn === true,
|
||||
},
|
||||
};
|
||||
},
|
||||
setViewportAspect(aspect) {
|
||||
pendingViewport = !Number.isFinite(aspect) || aspect < 0.85 ? 'tall' : 'wide';
|
||||
if (previousPhase === null) activeViewport = pendingViewport;
|
||||
const nextAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
|
||||
stageResponsiveViewport(nextAspect < 0.85 ? 'tall' : 'wide', nextAspect);
|
||||
},
|
||||
setViewportSize(width, height) {
|
||||
pendingViewport = signViewportProfileFor(width, height);
|
||||
if (previousPhase === null) activeViewport = pendingViewport;
|
||||
const nextAspect = Number.isFinite(width) && Number.isFinite(height) && height > 0
|
||||
? Math.max(1, width) / height
|
||||
: 1;
|
||||
stageResponsiveViewport(signViewportProfileFor(width, height), nextAspect);
|
||||
},
|
||||
update(sign) {
|
||||
update(sign, now) {
|
||||
if (!sign) {
|
||||
group.visible = false;
|
||||
applyOpacity(0);
|
||||
renderedOpacity = 0;
|
||||
previousPhase = null;
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeNavigationArrival = false;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (target !== null && target !== sign.targetId && group.visible) return;
|
||||
if (
|
||||
target !== null
|
||||
&& target !== sign.targetId
|
||||
&& renderedOpacity > SIGN_LIFECYCLE.concealedOpacity
|
||||
) return;
|
||||
const navigationArrival = sign.navigationArrival === true;
|
||||
if (target !== sign.targetId) {
|
||||
activeViewport = sign.viewportProfile ?? pendingViewport;
|
||||
// The renderer owns projection. A quality-tier change can rebuild this world
|
||||
// while a lifecycle is already visible, so its latched profile may describe
|
||||
// the generation that was just disposed rather than this canvas.
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeNavigationArrival = navigationArrival;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
updateTexture(sign.targetId);
|
||||
} else if (navigationArrival !== activeNavigationArrival) {
|
||||
stagePoseTransition();
|
||||
activeNavigationArrival = navigationArrival;
|
||||
}
|
||||
|
||||
if (poseTransitionPending && sign.phase === 'depart') {
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
} else if (poseTransitionPending) {
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
if (poseTransitionStartedAt === null) {
|
||||
poseTransitionStartedAt = Number.isFinite(now) ? now! : 0;
|
||||
}
|
||||
}
|
||||
|
||||
const opacity = clamp01(sign.opacity);
|
||||
@@ -428,41 +594,83 @@ export function createSign(
|
||||
const route = signRouteFor(sign.targetId);
|
||||
const corridor = route.corridor ?? 'right';
|
||||
activeCorridor = corridor;
|
||||
const { horizon, dwell } = SIGN_POSES[activeViewport][corridor];
|
||||
const poseSet = SIGN_POSES[activeViewport][corridor];
|
||||
const horizon = poseSet.horizon;
|
||||
const dwell = activeViewport === 'tall' && activeNavigationArrival
|
||||
? PORTRAIT_NAVIGATION_DWELL[corridor]
|
||||
: poseSet.dwell;
|
||||
const side = corridor.includes('left') ? -1 : 1;
|
||||
const routeOffsetX = route.dwellOffsetX ?? 0;
|
||||
|
||||
if (sign.phase === 'anticipate') {
|
||||
applyPose(horizon);
|
||||
applyPose(horizon, side, 0.35);
|
||||
} else if (sign.phase === 'approach') {
|
||||
group.position.set(
|
||||
THREE.MathUtils.lerp(horizon.x, dwell.x, progress),
|
||||
THREE.MathUtils.lerp(
|
||||
resolvedPoseX(horizon, side, 0.35),
|
||||
resolvedPoseX(dwell, side, 1, routeOffsetX),
|
||||
progress,
|
||||
),
|
||||
THREE.MathUtils.lerp(horizon.y, dwell.y, progress),
|
||||
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);
|
||||
applyPose(dwell, side, 1, routeOffsetX);
|
||||
} else {
|
||||
if (previousPhase !== 'depart') {
|
||||
if (previousPhase === null) applyPose(dwell);
|
||||
if (previousPhase === null) applyPose(dwell, side, 1, routeOffsetX);
|
||||
departureOrigin.copy(group.position);
|
||||
departureScale = group.scale.x;
|
||||
}
|
||||
const outerCorridor = corridor.startsWith('outer');
|
||||
const lateralExit = activeViewport === 'wide'
|
||||
? 14
|
||||
: activeViewport === 'short' ? 10 : 8;
|
||||
? outerCorridor ? 2.6 : 3.4
|
||||
: activeViewport === 'short' ? 2.2 : 2.4;
|
||||
group.position.set(
|
||||
departureOrigin.x + progress * side * lateralExit,
|
||||
departureOrigin.y + progress * 0.72,
|
||||
departureOrigin.z - progress * 32,
|
||||
departureOrigin.y,
|
||||
THREE.MathUtils.lerp(departureOrigin.z, 18, progress),
|
||||
);
|
||||
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 0.82, progress));
|
||||
group.scale.setScalar(departureScale);
|
||||
plantMounting(group.position.y, group.scale.x);
|
||||
}
|
||||
|
||||
group.rotation.y = -side * (activeViewport === 'wide' ? 0.2 : 0.12);
|
||||
const desiredRotationY = -side * (activeViewport === 'wide' ? 0.14 : 0.1);
|
||||
if (poseTransitionPending) {
|
||||
const elapsed = Number.isFinite(now)
|
||||
? Math.max(0, now! - (poseTransitionStartedAt ?? now!))
|
||||
: RESPONSIVE_REFLOW_MS;
|
||||
const mix = smoothstep(clamp01(elapsed / RESPONSIVE_REFLOW_MS));
|
||||
poseTransitionDestination.copy(group.position);
|
||||
const poseTransitionDestinationScale = group.scale.x;
|
||||
group.position.set(
|
||||
THREE.MathUtils.lerp(poseTransitionOrigin.x, poseTransitionDestination.x, mix),
|
||||
THREE.MathUtils.lerp(poseTransitionOrigin.y, poseTransitionDestination.y, mix),
|
||||
THREE.MathUtils.lerp(poseTransitionOrigin.z, poseTransitionDestination.z, mix),
|
||||
);
|
||||
group.scale.setScalar(THREE.MathUtils.lerp(
|
||||
poseTransitionOriginScale,
|
||||
poseTransitionDestinationScale,
|
||||
mix,
|
||||
));
|
||||
group.rotation.y = THREE.MathUtils.lerp(
|
||||
poseTransitionOriginRotationY,
|
||||
desiredRotationY,
|
||||
mix,
|
||||
);
|
||||
plantMounting(group.position.y, group.scale.x);
|
||||
if (mix >= 1) {
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
}
|
||||
} else {
|
||||
group.rotation.y = desiredRotationY;
|
||||
}
|
||||
applyOpacity(opacity);
|
||||
group.visible = opacity > 0;
|
||||
renderedOpacity = opacity;
|
||||
previousPhase = sign.phase;
|
||||
},
|
||||
};
|
||||
@@ -505,28 +713,75 @@ function drawLabel(
|
||||
height: number,
|
||||
): void {
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = '#07111a';
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.fillStyle = '#4cbac4';
|
||||
context.fillRect(86, 72, 12, height - 144);
|
||||
context.fillStyle = '#879da4';
|
||||
context.save();
|
||||
context.scale(width / 2_048, height / 512);
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
|
||||
context.globalAlpha = 0.16;
|
||||
context.fillStyle = '#8aa1a6';
|
||||
for (const [x, y, scratchWidth] of [
|
||||
[312, 84, 142],
|
||||
[742, 424, 96],
|
||||
[1_116, 66, 188],
|
||||
[1_488, 446, 112],
|
||||
] as const) {
|
||||
context.fillRect(x, y, scratchWidth, 2);
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = '#52bbc2';
|
||||
context.fillRect(82, 72, 10, 368);
|
||||
context.fillStyle = '#91a6a9';
|
||||
context.font = SIGN_MARKER_FONT;
|
||||
context.textAlign = 'left';
|
||||
context.textBaseline = 'middle';
|
||||
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.fillText(`ROUTE ${route.marker} / NEXT`, 142, 120);
|
||||
|
||||
context.globalAlpha = 0.6;
|
||||
context.fillStyle = '#2f4650';
|
||||
context.fillRect(142, 174, 1_610, 2);
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = '#d7e4e2';
|
||||
const labelSize = fittedLabelSize(context, route.label, 1_520);
|
||||
context.font = `660 ${labelSize}px "Geist Mono Variable", ui-monospace, monospace`;
|
||||
context.fillText(route.label, 142, 326);
|
||||
|
||||
context.strokeStyle = '#cb9160';
|
||||
context.lineWidth = 10;
|
||||
context.lineCap = 'square';
|
||||
context.lineJoin = 'miter';
|
||||
context.beginPath();
|
||||
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.moveTo(1_776, 304);
|
||||
context.lineTo(1_890, 304);
|
||||
context.lineTo(1_846, 260);
|
||||
context.moveTo(1_890, 304);
|
||||
context.lineTo(1_846, 348);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function fittedLabelSize(
|
||||
context: CanvasRenderingContext2D,
|
||||
label: string,
|
||||
maximumWidth: number,
|
||||
): number {
|
||||
for (let size = 164; size >= 112; size -= 4) {
|
||||
context.font = `660 ${size}px "Geist Mono Variable", ui-monospace, monospace`;
|
||||
if (context.measureText(label).width <= maximumWidth) return size;
|
||||
}
|
||||
return 112;
|
||||
}
|
||||
|
||||
function normalizeTextureLimit(value: number | undefined): number {
|
||||
return value !== undefined && Number.isFinite(value)
|
||||
? Math.max(32, Math.floor(value))
|
||||
: 2_048;
|
||||
}
|
||||
|
||||
function powerOfTwoAtMost(value: number): number {
|
||||
return 2 ** Math.floor(Math.log2(Math.max(1, value)));
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
|
||||
Reference in New Issue
Block a user