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

@@ -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();
}