Files
Portfolio/src/scene/world/sign.ts

781 lines
23 KiB
TypeScript

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';
import { roadsideSignMetrics, usesRoadsideSign } from './sign-layout';
interface ProjectingCamera {
position: { x: number; y: number; z: number };
rotation: { y: number };
updateMatrixWorld(force: boolean): void;
}
const SIGN_MARKER_FONT =
'560 46px "Geist Mono Variable", ui-monospace, monospace';
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
export interface SignWorld {
group: SignGroup;
inspect(
camera: ProjectingCamera,
width: number,
height: number,
): SignPresentation | null;
compose(
camera: ProjectingCamera,
width: number,
height: number,
navigationBottom: number,
now: number,
carAnchor?: { x: number; z: number },
): void;
setViewportAspect(aspect: number): void;
setViewportSize(width: number, height: number): void;
update(sign: SignSnapshot | null, now?: number): 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;
maximumSize: number;
anisotropy: number;
fontReadyWhenDrawn: boolean;
};
}
export interface SignRenderOptions {
maximumAnisotropy?: number;
maximumTextureSize?: number;
}
interface SignGroup {
visible: boolean;
userData: Record<string, unknown>;
position: {
x: number;
y: number;
z: number;
set(x: number, y: number, z: number): void;
clone(): SignGroup['position'];
distanceTo(other: SignGroup['position']): number;
};
rotation: { y: number };
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 signRouteFor(id).label;
}
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;
const housingMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x654169,
emissive: 0x512547,
emissiveIntensity: 0.6,
roughness: 0.28,
metalness: 0.7,
transparent: true,
opacity: 0,
alphaTest: 0.02,
}),
);
const backplateMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x301f47,
roughness: 0.72,
metalness: 0.38,
transparent: true,
opacity: 0,
alphaTest: 0.02,
}),
);
const facePanelMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x11091d,
roughness: 0.9,
metalness: 0.12,
transparent: true,
opacity: 0,
alphaTest: 0.02,
}),
);
const supportMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x3c2752,
emissive: 0x38204e,
emissiveIntensity: 0.4,
roughness: 0.56,
metalness: 0.68,
transparent: true,
opacity: 0,
alphaTest: 0.02,
}),
);
const edgeMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0xa3488d,
emissive: 0xff79c0,
emissiveIntensity: 1.1,
roughness: 0.42,
metalness: 0.5,
transparent: true,
opacity: 0,
alphaTest: 0.02,
}),
);
const routeMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0xbc744d,
emissive: 0xffae6b,
emissiveIntensity: 0.8,
roughness: 0.48,
metalness: 0.42,
transparent: true,
opacity: 0,
alphaTest: 0.02,
}),
);
const housing = new THREE.Mesh(
resources.own('geometry', createSignBody(8.64, 2.72, 0.5, 0.24)),
housingMaterial,
);
housing.name = 'sign-housing';
housing.position.z = -0.08;
group.add(housing);
const backplate = new THREE.Mesh(
resources.own('geometry', createSignBody(8.3, 2.38, 0.2, 0.18)),
backplateMaterial,
);
backplate.name = 'sign-backplate';
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');
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;
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,
opacity: 0,
alphaTest: 0.02,
side: THREE.FrontSide,
map: texture,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1,
depthWrite: false,
toneMapped: false,
}),
);
const face = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
faceMaterial,
);
face.name = 'sign-face';
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.045, 1.56, 0.045),
);
const upperEdgeGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(7.6, 0.035, 0.045),
);
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
leftEdge.position.set(-3.9, 0, 0.57);
edgeLight.add(leftEdge);
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
upperEdge.position.set(0, 1.14, 0.57);
edgeLight.add(upperEdge);
const lowerEdge = new THREE.Mesh(upperEdgeGeometry, routeMaterial);
lowerEdge.position.set(0, -1.14, 0.57);
edgeLight.add(lowerEdge);
const finGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(0.055, 1.86, 0.48),
);
for (const side of [-1, 1])
for (let index = 0; index < 3; index++) {
const fin = new THREE.Mesh(finGeometry, supportMaterial);
fin.position.set(side * (4.26 + index * 0.12), 0, -0.04);
edgeLight.add(fin);
}
group.add(edgeLight);
const routeAccent = new THREE.Mesh(
resources.own('geometry', new THREE.BoxGeometry(1.08, 0.045, 0.055)),
routeMaterial,
);
routeAccent.name = 'sign-route-accent';
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(postGeometry, supportMaterial);
post.name = 'sign-post';
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(6.1, 0.13, 0.18)),
supportMaterial,
);
arm.position.set(0, -1.24, -0.38);
bracket.add(arm);
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 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';
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 sweepMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { uSweep: { value: -1 }, uOpacity: { value: 0 } },
vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader: `uniform float uSweep;uniform float uOpacity;varying vec2 vUv;
void main(){float beam=exp(-pow((vUv.x+vUv.y*0.14-uSweep)*12.0,2.0));
gl_FragColor=vec4(0.36,0.82,0.9,beam*uOpacity);}`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
const sweep = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
sweepMaterial,
);
sweep.name = 'sign-illumination-sweep';
sweep.position.z = 0.56;
group.add(sweep);
const fadeMaterials = [
[housingMaterial, 0.97],
[backplateMaterial, 1],
[facePanelMaterial, 1],
[supportMaterial, 0.94],
[edgeMaterial, 0.76],
[routeMaterial, 0.82],
[faceMaterial, 1],
] as const;
let target: SectionId | null = null;
let snapshot: SignSnapshot | null = null;
let viewport: SignViewportProfile = 'wide';
let activeCorridor: SignCorridor = 'right';
let viewportWidth = 1440;
let viewportHeight = 900;
let previousNow = 0;
let screenX = 0.5;
let screenY = 120;
let screenWidth = 340;
let initialized = false;
let roadsideMix = 0;
let previousPhase: SignSnapshot['phase'] | null = null;
let dwellDrift = 0;
let departureDrift = 0;
const physical = roadsideSignMetrics(900);
const point = new THREE.Vector3();
const ray = new THREE.Vector3();
const forward = new THREE.Vector3();
const worldRight = new THREE.Vector3();
const worldLeft = new THREE.Vector3();
const corner = new THREE.Vector3();
const corners = [
[-4, -1],
[4, -1],
[4, 1],
[-4, 1],
];
function plantMounting() {
const footY = -group.position.y / Math.max(0.001, group.scale.x);
const height = Math.max(0.36, -1.36 - footY);
for (const post of mountedPosts) {
post.position.y = (-1.36 + footY) * 0.5;
post.scale.set(1, height / 5.05, 1);
}
footing.position.y = footY;
}
function worldPoint(
out: typeof point,
x: number,
y: number,
depth: number,
camera: ProjectingCamera,
) {
out
.set((x / viewportWidth) * 2 - 1, 1 - (y / viewportHeight) * 2, 0.5)
.unproject(camera);
ray.copy(out).sub(camera.position).normalize();
out
.copy(camera.position)
.addScaledVector(ray, depth / Math.max(0.05, ray.dot(forward)));
}
return {
group,
inspect(camera, width, height) {
if (target === null) return null;
group.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
let left = Infinity,
top = Infinity,
right = -Infinity,
bottom = -Infinity;
for (const [x, y] of corners) {
corner.set(x, y, 0).applyMatrix4(face.matrixWorld).project(camera);
const px = ((corner.x + 1) * width) / 2,
py = ((1 - corner.y) * height) / 2;
left = Math.min(left, px);
right = Math.max(right, px);
top = Math.min(top, py);
bottom = Math.max(bottom, py);
}
return {
visible: group.visible,
corridor: activeCorridor,
viewportProfile: viewport,
bounds: {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
},
texture: {
width: canvas.width,
height: canvas.height,
maximumSize: maximumTextureSize,
anisotropy: texture.anisotropy,
fontReadyWhenDrawn: face.userData.fontReadyWhenDrawn === true,
},
};
},
setViewportAspect(aspect) {
viewport = aspect < 1.05 ? 'tall' : 'wide';
},
setViewportSize(width, height) {
viewportWidth = Math.max(1, width);
viewportHeight = Math.max(1, height);
viewport = signViewportProfileFor(width, height);
},
update(sign) {
snapshot = sign;
group.userData.phase = sign?.phase ?? 'inactive';
group.userData.target = sign?.targetId ?? null;
if (!sign) {
group.visible = false;
initialized = false;
previousPhase = null;
dwellDrift = 0;
return;
}
if (target !== sign.targetId) {
previousPhase = null;
dwellDrift = 0;
target = sign.targetId;
face.userData.textureUpdateCount += 1;
face.userData.fontReadyWhenDrawn =
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
if (context)
drawLabel(
context,
signRouteFor(sign.targetId),
canvas.width,
canvas.height,
);
texture.needsUpdate = true;
initialized = false;
}
},
compose(
camera,
width,
height,
navigationBottom,
now,
carAnchor = { x: 0, z: 0.72 },
) {
if (!snapshot) return;
viewportWidth = width;
viewportHeight = height;
camera.updateMatrixWorld(true);
forward
.set(0, 0, -1)
.transformDirection(
(camera as ProjectingCamera & { matrixWorld: unknown }).matrixWorld,
);
const route = signRouteFor(snapshot.targetId);
activeCorridor = route.corridor ?? 'right';
const side = activeCorridor.includes('left') ? -1 : 1;
const compact = width < 900;
const shortSide = height < 560 && width >= 768;
const lowerStrip = height < 560 && !shortSide;
const targetWidth = Math.min(
width - 44,
compact ? 288 : 356,
height < 650 ? 250 : 356,
);
const targetX = shortSide ? 0.82 : compact ? 0.5 : side < 0 ? 0.23 : 0.77;
const targetY = shortSide
? navigationBottom + 32 + (height - navigationBottom - 96) * 0.25
: lowerStrip
? height - 112
: navigationBottom + 12 + targetWidth / 3.18 / 2;
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
previousNow = now;
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
const roadside = usesRoadsideSign(width, height);
roadsideMix += ((roadside ? 1 : 0) - roadsideMix) * mix;
screenX += (targetX - screenX) * mix;
screenY += (targetY - screenY) * mix;
screenWidth += (targetWidth - screenWidth) * mix;
initialized = true;
const p = smoothstep(clamp01(snapshot.progress));
let arrival =
snapshot.phase === 'anticipate'
? 0
: snapshot.phase === 'approach'
? p
: 1;
const closeDepth = lowerStrip ? 12 : 18;
let depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
let cx = THREE.MathUtils.lerp(0.5 + side * 0.1, screenX, arrival) * width;
let cy = THREE.MathUtils.lerp(
height * 0.43,
screenY,
lowerStrip ? arrival * arrival * arrival : arrival,
);
let projectedWidth = screenWidth * THREE.MathUtils.lerp(0.1, 1, arrival);
if (snapshot.phase === 'depart') {
depth = THREE.MathUtils.lerp(closeDepth, lowerStrip ? 4 : 12, p);
cx += side * p * width * 0.3;
projectedWidth *= 1 + p * 0.3;
}
if (snapshot.phase !== 'depart') {
projectedWidth = Math.min(width - 44, projectedWidth);
cx = THREE.MathUtils.clamp(
cx,
projectedWidth / 2 + 14,
width - projectedWidth / 2 - 14,
);
}
worldPoint(point, cx, cy, depth, camera);
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
if (lowerStrip) point.y = Math.max(1.4, point.y);
// A wide-screen sign is a real roadside object: fixed lateral offset,
// fixed mast height, constant size, and travel only along the road axis.
// Camera movement changes its projection, never its world-space anchor.
const desiredPhysical = roadsideSignMetrics(height, snapshot.targetId);
for (const key of ['x', 'y', 'scale', 'dwellZ'] as const) {
physical[key] += (desiredPhysical[key] - physical[key]) * mix;
}
// Canonical dwell progress is always 1; visual drift must integrate time.
if (snapshot.phase === 'dwell')
dwellDrift = Math.min(3, dwellDrift + dt * 1.7);
if (snapshot.phase === 'depart' && previousPhase !== 'depart')
departureDrift = dwellDrift;
previousPhase = snapshot.phase;
const roadZ =
snapshot.phase === 'depart'
? THREE.MathUtils.lerp(physical.dwellZ + departureDrift, 48, p)
: THREE.MathUtils.lerp(-230, physical.dwellZ, arrival) + dwellDrift;
point.x = THREE.MathUtils.lerp(
point.x,
carAnchor.x + side * physical.x,
roadsideMix,
);
point.y = THREE.MathUtils.lerp(point.y, physical.y, roadsideMix);
point.z = THREE.MathUtils.lerp(point.z, carAnchor.z + roadZ, roadsideMix);
group.position.set(point.x, point.y, point.z);
group.scale.setScalar(
THREE.MathUtils.lerp(
worldRight.distanceTo(worldLeft) / 8.64,
physical.scale,
roadsideMix,
),
);
group.rotation.y = THREE.MathUtils.lerp(
camera.rotation.y - side * 0.065,
-side * 0.1,
roadsideMix,
);
plantMounting();
const opacity = clamp01(snapshot.opacity);
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
// The frame catches light first; one moving highlight resolves into a quiet face.
faceMaterial.opacity = opacity * smoothstep(Math.min(1, arrival * 1.8));
edgeMaterial.emissiveIntensity = 0.8 + Math.sin(arrival * Math.PI) * 1.4;
sweepMaterial.uniforms.uSweep.value = arrival * 1.5 - 0.2;
sweepMaterial.uniforms.uOpacity.value =
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
group.visible = opacity > 0;
group.userData.anchoring = roadside ? 'roadside' : 'compact-shelf';
},
};
}
function createSignBody(
width: number,
height: number,
depth: number,
cut: number,
) {
const geometry = new THREE.ExtrudeGeometry(
createClippedShape(width, height, cut),
{
depth,
bevelEnabled: true,
bevelSegments: 1,
bevelSize: 0.045,
bevelThickness: 0.045,
curveSegments: 1,
},
);
geometry.translate(0, 0, -depth * 0.5);
geometry.computeVertexNormals();
return geometry;
}
function createClippedShape(width: number, height: number, cut: number) {
const halfWidth = width * 0.5;
const halfHeight = height * 0.5;
const shape = new THREE.Shape();
shape.moveTo(-halfWidth + cut, -halfHeight);
shape.lineTo(halfWidth - cut, -halfHeight);
shape.lineTo(halfWidth, -halfHeight + cut);
shape.lineTo(halfWidth, halfHeight - cut);
shape.lineTo(halfWidth - cut, halfHeight);
shape.lineTo(-halfWidth + cut, halfHeight);
shape.lineTo(-halfWidth, halfHeight - cut);
shape.lineTo(-halfWidth, -halfHeight + cut);
shape.closePath();
return shape;
}
function drawLabel(
context: CanvasRenderingContext2D,
route: SignRouteSpec,
width: number,
height: number,
): void {
context.clearRect(0, 0, width, height);
context.save();
context.scale(width / 2_048, height / 512);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.globalAlpha = 0.16;
context.fillStyle = '#cfabcd';
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 = '#ff95cd';
context.fillRect(82, 72, 10, 368);
context.fillStyle = '#bbaae0';
context.font = SIGN_MARKER_FONT;
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillText(`ROUTE ${route.marker} / NEXT`, 142, 120);
context.globalAlpha = 0.6;
context.fillStyle = '#61416d';
context.fillRect(142, 174, 1_610, 2);
context.globalAlpha = 1;
context.fillStyle = '#fff2df';
const labelSize = fittedLabelSize(context, route.label, 1_520);
context.font = `650 ${labelSize}px "Geist Variable", sans-serif`;
context.fillText(route.label, 142, 326);
context.strokeStyle = '#ffb879';
context.lineWidth = 10;
context.lineCap = 'square';
context.lineJoin = 'miter';
context.beginPath();
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 = 180; size >= 128; size -= 4) {
context.font = `650 ${size}px "Geist Variable", sans-serif`;
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 {
return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0;
}
function smoothstep(value: number): number {
return value * value * (3 - 2 * value);
}