feat: polish nightdrive road sun and signage
This commit is contained in:
@@ -14,6 +14,23 @@ const routeLabels: Readonly<Record<SectionId, string>> = {
|
||||
contact: 'Contact',
|
||||
};
|
||||
|
||||
interface SignPose {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
export interface SignWorld {
|
||||
group: SignGroup;
|
||||
setViewportAspect(aspect: number): void;
|
||||
@@ -22,9 +39,16 @@ export interface SignWorld {
|
||||
|
||||
interface SignGroup {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void };
|
||||
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: { setScalar(scale: number): void };
|
||||
scale: { x: number; setScalar(scale: number): void };
|
||||
add(...objects: unknown[]): void;
|
||||
getObjectByName(name: string): unknown;
|
||||
}
|
||||
@@ -38,89 +62,159 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
||||
group.name = 'roadside-sign';
|
||||
group.visible = false;
|
||||
|
||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x07101a,
|
||||
roughness: 0.7,
|
||||
metalness: 0.42,
|
||||
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x071019,
|
||||
roughness: 0.58,
|
||||
metalness: 0.66,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const frameMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x2d6f79,
|
||||
emissive: 0x43cfdd,
|
||||
emissiveIntensity: 0.68,
|
||||
roughness: 0.38,
|
||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x111d27,
|
||||
roughness: 0.78,
|
||||
metalness: 0.34,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x142631,
|
||||
roughness: 0.52,
|
||||
metalness: 0.72,
|
||||
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 postMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x172b35,
|
||||
roughness: 0.64,
|
||||
metalness: 0.62,
|
||||
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x8b522d,
|
||||
emissive: 0xe49a58,
|
||||
emissiveIntensity: 0.72,
|
||||
roughness: 0.42,
|
||||
metalness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const housing = new THREE.Mesh(
|
||||
resources.own('geometry', createSignBody(7.5, 2.3, 0.34, 0.2)),
|
||||
housingMaterial,
|
||||
);
|
||||
housing.name = 'sign-housing';
|
||||
housing.position.z = -0.05;
|
||||
group.add(housing);
|
||||
|
||||
const backplate = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(8.2, 2.35, 0.24)),
|
||||
resources.own('geometry', createSignBody(7.2, 2.04, 0.18, 0.16)),
|
||||
backplateMaterial,
|
||||
);
|
||||
backplate.name = 'sign-backplate';
|
||||
backplate.position.z = 0.17;
|
||||
group.add(backplate);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1_024;
|
||||
canvas.height = 256;
|
||||
canvas.height = 288;
|
||||
const context = canvas.getContext('2d');
|
||||
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.generateMipmaps = false;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.anisotropy = 4;
|
||||
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
side: THREE.DoubleSide,
|
||||
side: THREE.FrontSide,
|
||||
map: texture,
|
||||
toneMapped: true,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
toneMapped: false,
|
||||
}));
|
||||
const face = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.PlaneGeometry(7.72, 1.87)),
|
||||
resources.own('geometry', new THREE.PlaneGeometry(6.92, 1.76)),
|
||||
faceMaterial,
|
||||
);
|
||||
face.name = 'sign-face';
|
||||
face.position.z = 0.132;
|
||||
face.position.z = 0.545;
|
||||
face.userData.textureUpdateCount = 0;
|
||||
group.add(face);
|
||||
|
||||
const frame = new THREE.Group();
|
||||
frame.name = 'sign-frame';
|
||||
const horizontalGeometry = resources.own('geometry', new THREE.BoxGeometry(8.42, 0.12, 0.32));
|
||||
const verticalGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 2.35, 0.32));
|
||||
for (const y of [-1.175, 1.175]) {
|
||||
const rail = new THREE.Mesh(horizontalGeometry, frameMaterial);
|
||||
rail.position.y = y;
|
||||
frame.add(rail);
|
||||
}
|
||||
for (const x of [-4.15, 4.15]) {
|
||||
const rail = new THREE.Mesh(verticalGeometry, frameMaterial);
|
||||
rail.position.x = x;
|
||||
frame.add(rail);
|
||||
}
|
||||
group.add(frame);
|
||||
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 leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
||||
leftEdge.position.set(-3.36, 0, 0.585);
|
||||
edgeLight.add(leftEdge);
|
||||
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
||||
upperEdge.position.set(-2.81, 0.88, 0.585);
|
||||
edgeLight.add(upperEdge);
|
||||
group.add(edgeLight);
|
||||
|
||||
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.17, 3.5, 0.17));
|
||||
for (const [x, side] of [[-2.9, 'left'], [2.9, 'right']] as const) {
|
||||
const post = new THREE.Mesh(postGeometry, postMaterial);
|
||||
post.name = `sign-post-${side}`;
|
||||
post.position.set(x, -2.86, -0.08);
|
||||
group.add(post);
|
||||
}
|
||||
const routeAccent = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(1.05, 0.055, 0.065)),
|
||||
routeMaterial,
|
||||
);
|
||||
routeAccent.name = 'sign-route-accent';
|
||||
routeAccent.position.set(2.77, -0.88, 0.585);
|
||||
group.add(routeAccent);
|
||||
|
||||
const fadeMaterials = [backplateMaterial, frameMaterial, postMaterial, faceMaterial];
|
||||
const post = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.14, 4.85, 8)),
|
||||
supportMaterial,
|
||||
);
|
||||
post.name = 'sign-post';
|
||||
post.position.set(2.35, -3.32, -0.14);
|
||||
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)),
|
||||
supportMaterial,
|
||||
);
|
||||
arm.position.set(1.18, -1.08, -0.13);
|
||||
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.rotation.z = -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,
|
||||
);
|
||||
footing.name = 'sign-footing';
|
||||
footing.position.set(2.35, -5.76, -0.14);
|
||||
group.add(footing);
|
||||
|
||||
const fadeMaterials = [
|
||||
[housingMaterial, 0.97],
|
||||
[backplateMaterial, 1],
|
||||
[supportMaterial, 0.94],
|
||||
[edgeMaterial, 0.76],
|
||||
[routeMaterial, 0.82],
|
||||
[faceMaterial, 1],
|
||||
] as const;
|
||||
const departureOrigin = new THREE.Vector3();
|
||||
let departureScale = 1;
|
||||
let target: SectionId | null = null;
|
||||
let narrowViewport = false;
|
||||
let pendingNarrowViewport = false;
|
||||
let activeNarrowViewport = false;
|
||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
||||
|
||||
const updateTexture = (id: SectionId): void => {
|
||||
if (target === id) return;
|
||||
@@ -131,36 +225,43 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
||||
};
|
||||
|
||||
const applyOpacity = (opacity: number): void => {
|
||||
for (const material of fadeMaterials) material.opacity = opacity;
|
||||
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
|
||||
};
|
||||
|
||||
const applyPose = (pose: SignPose): void => {
|
||||
group.position.set(pose.x, pose.y, pose.z);
|
||||
group.scale.setScalar(pose.scale);
|
||||
};
|
||||
|
||||
return {
|
||||
group,
|
||||
setViewportAspect(aspect) {
|
||||
narrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
|
||||
pendingNarrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
|
||||
if (!group.visible) activeNarrowViewport = pendingNarrowViewport;
|
||||
},
|
||||
update(sign) {
|
||||
if (!sign) {
|
||||
group.visible = false;
|
||||
applyOpacity(0);
|
||||
previousPhase = null;
|
||||
activeNarrowViewport = pendingNarrowViewport;
|
||||
return;
|
||||
}
|
||||
|
||||
updateTexture(sign.targetId);
|
||||
if (target !== null && target !== sign.targetId && group.visible) return;
|
||||
if (target !== sign.targetId) {
|
||||
activeNarrowViewport = pendingNarrowViewport;
|
||||
updateTexture(sign.targetId);
|
||||
}
|
||||
|
||||
const opacity = clamp01(sign.opacity);
|
||||
applyOpacity(opacity);
|
||||
group.visible = opacity > 0;
|
||||
const progress = smoothstep(clamp01(sign.progress));
|
||||
const dwell = narrowViewport
|
||||
? { x: 3.1, y: 3.25, z: -20, scale: 0.68 }
|
||||
: { x: 8.25, y: 3.1, z: -8.5, scale: 1 };
|
||||
const horizon = narrowViewport
|
||||
? { x: 4.2, y: 4.1, z: -88, scale: 0.2 }
|
||||
: { x: 12.4, y: 5.3, z: -92, scale: 0.24 };
|
||||
const { horizon, dwell } = activeNarrowViewport
|
||||
? NARROW_SIGN_POSES
|
||||
: WIDE_SIGN_POSES;
|
||||
|
||||
if (sign.phase === 'anticipate') {
|
||||
group.position.set(horizon.x, horizon.y, horizon.z);
|
||||
group.scale.setScalar(horizon.scale);
|
||||
applyPose(horizon);
|
||||
} else if (sign.phase === 'approach') {
|
||||
group.position.set(
|
||||
THREE.MathUtils.lerp(horizon.x, dwell.x, progress),
|
||||
@@ -169,21 +270,59 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
||||
);
|
||||
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
|
||||
} else if (sign.phase === 'dwell') {
|
||||
group.position.set(dwell.x, dwell.y, dwell.z);
|
||||
group.scale.setScalar(dwell.scale);
|
||||
applyPose(dwell);
|
||||
} else {
|
||||
if (previousPhase !== 'depart') {
|
||||
if (previousPhase === null) applyPose(dwell);
|
||||
departureOrigin.copy(group.position);
|
||||
departureScale = group.scale.x;
|
||||
}
|
||||
group.position.set(
|
||||
dwell.x + progress * (narrowViewport ? 1.4 : 3.2),
|
||||
dwell.y + progress * 0.7,
|
||||
THREE.MathUtils.lerp(dwell.z, 17, progress),
|
||||
departureOrigin.x + progress * (activeNarrowViewport ? 2.2 : 4.4),
|
||||
departureOrigin.y + progress * 0.62,
|
||||
THREE.MathUtils.lerp(departureOrigin.z, 28, progress),
|
||||
);
|
||||
group.scale.setScalar(dwell.scale * THREE.MathUtils.lerp(1, 1.08, progress));
|
||||
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 1.06, progress));
|
||||
}
|
||||
group.rotation.y = narrowViewport ? -0.08 : -0.16;
|
||||
|
||||
group.rotation.y = activeNarrowViewport ? -0.14 : -0.23;
|
||||
applyOpacity(opacity);
|
||||
group.visible = opacity > 0;
|
||||
previousPhase = sign.phase;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
label: string,
|
||||
@@ -191,22 +330,28 @@ function drawLabel(
|
||||
height: number,
|
||||
): void {
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = '#07111b';
|
||||
context.strokeStyle = '#56d4df';
|
||||
context.lineWidth = 4;
|
||||
context.beginPath();
|
||||
if (typeof context.roundRect === 'function') {
|
||||
context.roundRect(14, 14, width - 28, height - 28, 12);
|
||||
} else {
|
||||
context.rect(14, 14, width - 28, height - 28);
|
||||
}
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.fillStyle = '#eef7f8';
|
||||
context.font = '600 58px "Geist Mono Variable", ui-monospace, monospace';
|
||||
context.textAlign = 'center';
|
||||
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.textAlign = 'left';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText(label, width / 2, height / 2 + 2, width - 88);
|
||||
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.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.stroke();
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
|
||||
Reference in New Issue
Block a user