Files
Portfolio/src/scene/world/car.ts
LucaP 5d5935a1f8 feat: immerse the portfolio in a chromatic synthwave night
Carry rose, violet, coral, electric blue and cyan through the interface, road, terrain, signs and atmosphere. Add a Blender-authored paint edition and matching runtime fallback captures. Interpolate atmospheric color on a small sky lattice and enrich existing speed effects without another rendering pass.

Preserve approved copy, project media, metadata, navigation contracts and adaptive audio. Verified with typecheck, client/SSR builds, validated prerendering, artifact and transfer checks, responsive Chrome interaction, fallback checks and Intel-backed performance sampling.
2026-09-05 13:17:53 +03:00

763 lines
22 KiB
TypeScript

import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import grandTourerUrl from '@/assets/nightdrive-grand-tourer-chromatic.glb?url';
import type { SceneResourceBag } from '@/scene/resources';
import type { SceneDetailMode } from '@/scene/types';
export interface CarWorld {
group: CarGroup;
setDetail(detail: SceneDetailMode): void;
update(
speed: number,
acceleration: number,
elapsedSeconds: number,
distance?: number,
): void;
}
interface CarGroup {
name: string;
children: unknown[];
position: {
x: number;
y: number;
z: number;
set(x: number, y: number, z: number): void;
};
rotation: { x: number; y: number; z: number };
scale: { set(x: number, y: number, z: number): void };
visible: boolean;
add(...objects: unknown[]): void;
getObjectByName(name: string): unknown;
updateMatrixWorld(force: boolean): void;
}
interface AuthoredMaterial {
dispose(): void;
isMeshStandardMaterial?: boolean;
emissive?: { setHex(value: number): void };
emissiveIntensity: number;
[key: string]: unknown;
}
interface AuthoredObject {
name: string;
isMesh?: boolean;
geometry?: { dispose(): void };
material?: AuthoredMaterial | AuthoredMaterial[];
parent?: { name: string };
position: { y: number };
rotation: { x: number; z: number };
}
interface AuthoredCarGroup extends CarGroup {
removeFromParent(): void;
traverse(callback: (object: AuthoredObject) => void): void;
}
const FULL_BASE_Y = 0.58;
const REDUCED_BASE_Y = 0.42;
const GRAND_TOURER_URL = grandTourerUrl;
export async function loadGrandTourer(
signal: AbortSignal,
): Promise<AuthoredCarGroup> {
const response = await fetch(GRAND_TOURER_URL, { signal });
if (!response.ok) {
throw new Error(`Grand tourer asset failed to load (${response.status}).`);
}
const bytes = await response.arrayBuffer();
if (signal.aborted)
throw new DOMException('Scene build aborted', 'AbortError');
const gltf = await new GLTFLoader().parseAsync(bytes, GRAND_TOURER_URL);
if (signal.aborted)
throw new DOMException('Scene build aborted', 'AbortError');
const authored =
gltf.scene.getObjectByName('evolution-chase-car') ??
gltf.scene.getObjectByName('chase-car') ??
gltf.scene;
authored.removeFromParent();
return authored as AuthoredCarGroup;
}
export function createCar(
resources: SceneResourceBag,
authoredModel?: AuthoredCarGroup,
): CarWorld {
if (authoredModel) return createAuthoredCar(resources, authoredModel);
const group = new THREE.Group();
group.name = 'chase-car';
let baseY = FULL_BASE_Y;
group.position.set(0, baseY, 0.72);
group.scale.set(1.1, 0.82, 0.98);
const bodyMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0xa72c73,
emissive: 0x2c0822,
emissiveIntensity: 0.42,
roughness: 0.36,
metalness: 0.68,
}),
);
const shoulderMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x793fb2,
emissive: 0x211032,
emissiveIntensity: 0.36,
roughness: 0.3,
metalness: 0.74,
}),
);
const lowerBodyMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x111c29,
roughness: 0.48,
metalness: 0.54,
}),
);
const glassMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x071522,
emissive: 0x061522,
emissiveIntensity: 0.34,
roughness: 0.12,
metalness: 0.42,
transparent: true,
opacity: 0.92,
}),
);
const fasciaMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x080d15,
roughness: 0.52,
metalness: 0.42,
}),
);
const tailMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x8f0b2d,
emissive: 0xff174f,
emissiveIntensity: 1.25,
roughness: 0.26,
metalness: 0.18,
toneMapped: true,
}),
);
const cyanMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x2b9bab,
emissive: 0x44dceb,
emissiveIntensity: 0.7,
roughness: 0.34,
metalness: 0.32,
toneMapped: true,
}),
);
const tireMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x020407,
roughness: 0.94,
metalness: 0.04,
}),
);
const hubMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x354451,
roughness: 0.42,
metalness: 0.78,
}),
);
const rearProfile = createRearProfile();
const silhouetteGeometry = resources.own(
'geometry',
new THREE.ExtrudeGeometry(rearProfile, {
depth: 5.8,
steps: 1,
bevelEnabled: true,
bevelSegments: 1,
bevelSize: 0.11,
bevelThickness: 0.1,
}),
);
silhouetteGeometry.translate(0, 0, -2.9);
silhouetteGeometry.computeVertexNormals();
const silhouette = new THREE.Mesh(silhouetteGeometry, bodyMaterial);
silhouette.name = 'rear-silhouette';
group.add(silhouette);
const rearFaceMaterial = resources.own(
'material',
new THREE.MeshBasicMaterial({
color: 0x1b3246,
toneMapped: true,
}),
);
const rearFace = new THREE.Mesh(
resources.own('geometry', new THREE.ShapeGeometry(rearProfile)),
rearFaceMaterial,
);
rearFace.name = 'rear-body-face';
rearFace.position.z = 3.025;
group.add(rearFace);
const leftHaunch = new THREE.Mesh(
resources.own('geometry', createHaunchGeometry(-1)),
shoulderMaterial,
);
leftHaunch.name = 'left-rear-haunch';
group.add(leftHaunch);
const rightHaunch = new THREE.Mesh(
resources.own('geometry', createHaunchGeometry(1)),
shoulderMaterial,
);
rightHaunch.name = 'right-rear-haunch';
group.add(rightHaunch);
const glass = new THREE.Mesh(
resources.own('geometry', createCanopyGeometry()),
glassMaterial,
);
glass.name = 'rear-glass';
group.add(glass);
const rearGlassProfile = new THREE.Shape();
rearGlassProfile.moveTo(-1.58, 0.8);
rearGlassProfile.lineTo(-1.15, 1.41);
rearGlassProfile.lineTo(1.15, 1.41);
rearGlassProfile.lineTo(1.58, 0.8);
rearGlassProfile.closePath();
const rearGlassMaterial = resources.own(
'material',
new THREE.MeshBasicMaterial({
color: 0x0b2537,
transparent: true,
opacity: 0.94,
toneMapped: true,
}),
);
const rearGlass = new THREE.Mesh(
resources.own('geometry', new THREE.ShapeGeometry(rearGlassProfile)),
rearGlassMaterial,
);
rearGlass.name = 'rear-glass-panel';
rearGlass.position.z = 1.44;
group.add(rearGlass);
const fasciaGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(4.84, 0.56, 0.16),
);
const fascia = new THREE.Mesh(fasciaGeometry, fasciaMaterial);
fascia.name = 'rear-fascia';
fascia.position.set(0, 0.02, 3.07);
group.add(fascia);
const lightGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(4.55, 0.13, 0.09),
);
const lightBar = new THREE.Mesh(lightGeometry, tailMaterial);
lightBar.name = 'rear-light-bar';
lightBar.position.set(0, 0.46, 3.17);
group.add(lightBar);
const diffuserGeometry = resources.own('geometry', createDiffuserGeometry());
const diffuser = new THREE.Mesh(diffuserGeometry, lowerBodyMaterial);
diffuser.name = 'rear-diffuser';
diffuser.position.z = 3.08;
group.add(diffuser);
const wheelGeometry = resources.own(
'geometry',
new THREE.CylinderGeometry(0.66, 0.66, 0.5, 24),
);
const hubGeometry = resources.own(
'geometry',
new THREE.CylinderGeometry(0.31, 0.31, 0.515, 16),
);
for (const [x, side] of [
[-2.38, 'left'],
[2.38, 'right'],
] as const) {
const wheel = new THREE.Mesh(wheelGeometry, tireMaterial);
wheel.name = `rear-wheel-${side}`;
wheel.rotation.z = Math.PI / 2;
wheel.position.set(x, -0.16, 2.1);
group.add(wheel);
const hub = new THREE.Mesh(hubGeometry, hubMaterial);
hub.name = `rear-hub-${side}`;
hub.rotation.z = Math.PI / 2;
hub.position.copy(wheel.position);
group.add(hub);
}
const underglowUniforms = {
uColor: { value: new THREE.Color(0x49d7e5) },
uOpacity: { value: 0.12 },
};
const underglowGeometry = resources.own(
'geometry',
new THREE.PlaneGeometry(3.9, 4.2),
);
const underglowMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: underglowUniforms,
vertexShader: `
varying vec2 vGlowUv;
void main() {
vGlowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vGlowUv;
void main() {
vec2 centered = (vGlowUv - 0.5) * vec2(1.55, 1.0);
float glow = 1.0 - smoothstep(0.12, 0.72, length(centered));
gl_FragColor = vec4(uColor, glow * uOpacity);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
toneMapped: true,
}),
);
const underglow = new THREE.Mesh(underglowGeometry, underglowMaterial);
underglow.name = 'car-underglow';
underglow.rotation.x = -Math.PI / 2;
underglow.position.set(0, -0.67, 0.45);
group.add(underglow);
const contact = createContactShadow(resources, 4.5, 4.8, 0.22);
contact.mesh.position.set(0, -0.695, 0.52);
group.add(contact.mesh);
const secondary = new THREE.Group();
secondary.name = 'secondary-detail';
const trimGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(0.38, 0.075, 0.07),
);
for (const x of [-1.98, 1.98]) {
const trim = new THREE.Mesh(trimGeometry, cyanMaterial);
trim.position.set(x, -0.15, 3.13);
secondary.add(trim);
}
const deckGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(3.25, 0.075, 0.62),
);
const rearDeck = new THREE.Mesh(deckGeometry, shoulderMaterial);
rearDeck.position.set(0, 0.86, 2.15);
rearDeck.rotation.x = -0.08;
secondary.add(rearDeck);
const frontWheelGeometry = resources.own(
'geometry',
new THREE.CylinderGeometry(0.58, 0.58, 0.42, 18),
);
for (const x of [-2.18, 2.18]) {
const wheel = new THREE.Mesh(frontWheelGeometry, tireMaterial);
wheel.rotation.z = Math.PI / 2;
wheel.position.set(x, -0.14, -2.05);
secondary.add(wheel);
}
group.add(secondary);
let detail: SceneDetailMode = 'full';
let squat = 0;
let squatVelocity = 0;
let previousElapsed: number | null = null;
const setDetail = (nextDetail: SceneDetailMode): void => {
if (detail === nextDetail) return;
detail = nextDetail;
secondary.visible = detail === 'full';
if (detail === 'reduced') {
baseY = REDUCED_BASE_Y;
group.scale.set(0.86, 0.66, 0.76);
group.position.z = 0.62;
} else {
baseY = FULL_BASE_Y;
group.scale.set(1.1, 0.82, 0.98);
group.position.z = 0.72;
}
group.position.y = baseY - squat;
};
return {
group,
setDetail,
update(speed, acceleration, elapsedSeconds) {
const safeSpeed = clamp01(speed);
const response = clamp01(acceleration);
const elapsed = Number.isFinite(elapsedSeconds)
? Math.max(0, elapsedSeconds)
: 0;
const deltaSeconds =
previousElapsed === null
? 1 / 60
: THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
previousElapsed = elapsed;
const targetSquat = response * 0.088;
const springAcceleration =
(targetSquat - squat) * 34 - squatVelocity * 10;
squatVelocity += springAcceleration * deltaSeconds;
squat += squatVelocity * deltaSeconds;
const vibration =
Math.sin(elapsed * (8.2 + safeSpeed * 2.4)) *
(0.002 + safeSpeed * 0.003);
group.position.y = baseY - squat + vibration;
group.rotation.x = -squat * 0.045;
group.rotation.z =
Math.sin(elapsed * 0.78) * safeSpeed * 0.0024 +
Math.sin(elapsed * 1.31) * response * 0.0014;
tailMaterial.emissiveIntensity = 1.25 + response * 1.35;
underglowUniforms.uOpacity.value =
0.1 + safeSpeed * 0.055 + response * 0.07;
contact.uniforms.uOpacity.value = 0.2 + response * 0.06;
},
};
}
function createAuthoredCar(
resources: SceneResourceBag,
authoredModel: AuthoredCarGroup,
): CarWorld {
const group = new THREE.Group();
group.name = 'chase-car';
authoredModel.name = 'grand-tourer-model';
authoredModel.rotation.y = Math.PI;
group.add(authoredModel);
const wheels: Array<{ object: AuthoredObject; radius: number }> = [];
const bodyParts: Array<{ object: AuthoredObject; y: number }> = [];
const tailMaterials: AuthoredMaterial[] = [];
const cyanMaterials: AuthoredMaterial[] = [];
authoredModel.traverse((object) => {
if (object.name.startsWith('evolution-'))
object.name = object.name.slice('evolution-'.length);
// Three removes Blender's suffix dot when sanitizing imported node names.
object.name = object.name.replace(/\.?\d{3}$/u, '');
if (/^wheel-(front|rear)-(left|right)$/u.test(object.name)) {
wheels.push({
object,
radius: object.name.includes('front') ? 0.42 : 0.46,
});
}
if (!object.isMesh || !object.geometry || !object.material) return;
if (!object.parent?.name.startsWith('wheel-'))
bodyParts.push({ object, y: object.position.y });
resources.own('geometry', object.geometry);
const materials = Array.isArray(object.material)
? object.material
: [object.material];
for (const material of materials) {
resources.own('material', material);
ownMaterialTextures(resources, material);
if (
material.isMeshStandardMaterial &&
(object.name === 'car-body' || object.name === 'body-haunches')
) {
material.emissive?.setHex(0x491034);
material.emissiveIntensity = 0.16;
}
if (material.isMeshStandardMaterial && object.name === 'rear-glass') {
material.emissive?.setHex(0x0f143d);
material.emissiveIntensity = 0.31;
}
if (object.name === 'rear-light-bar' && material.isMeshStandardMaterial) {
tailMaterials.push(material);
}
if (
object.name === 'car-underglow-source' &&
material.isMeshStandardMaterial
) {
cyanMaterials.push(material);
}
}
});
const underglowUniforms = {
uColor: { value: new THREE.Color(0x47ddec) },
uOpacity: { value: 0.12 },
};
const underglow = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(1.72, 3.82)),
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: underglowUniforms,
vertexShader: `
varying vec2 vGlowUv;
void main() {
vGlowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vGlowUv;
void main() {
vec2 centered = (vGlowUv - 0.5) * vec2(1.7, 1.0);
float glow = 1.0 - smoothstep(0.08, 0.74, length(centered));
gl_FragColor = vec4(uColor * 1.35, glow * uOpacity);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
toneMapped: true,
}),
),
);
underglow.name = 'car-underglow';
underglow.rotation.x = -Math.PI / 2;
underglow.position.set(0, 0.012, 0.08);
group.add(underglow);
const contact = createContactShadow(resources, 1.98, 3.95, 0.24);
contact.mesh.position.set(0, 0.004, 0.14);
group.add(contact.mesh);
let detail: SceneDetailMode = 'full';
let baseY = 0.02;
let squat = 0;
let squatVelocity = 0;
let previousElapsed: number | null = null;
const setScale = (nextDetail: SceneDetailMode): void => {
if (nextDetail === 'reduced') {
baseY = -0.02;
group.scale.set(2.32, 2.32, 2.32);
group.position.z = 0.62;
} else {
baseY = 0.02;
group.scale.set(2.78, 2.78, 2.78);
group.position.z = 0.72;
}
group.position.y = baseY - squat;
};
setScale(detail);
return {
group,
setDetail(nextDetail) {
if (detail === nextDetail) return;
detail = nextDetail;
setScale(detail);
},
update(speed, acceleration, elapsedSeconds, distance) {
const safeSpeed = clamp01(speed);
const response = clamp01(acceleration);
const elapsed = Number.isFinite(elapsedSeconds)
? Math.max(0, elapsedSeconds)
: 0;
const deltaSeconds =
previousElapsed === null
? 1 / 60
: THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
previousElapsed = elapsed;
const targetSquat = response * 0.04;
const springAcceleration =
(targetSquat - squat) * 34 - squatVelocity * 10;
squatVelocity += springAcceleration * deltaSeconds;
squat += squatVelocity * deltaSeconds;
const vibration =
Math.sin(elapsed * (7.4 + safeSpeed * 2.2)) *
(0.0014 + safeSpeed * 0.0022);
group.position.y = baseY + vibration * 0.2;
for (const part of bodyParts) {
part.object.position.y = part.y - squat * 0.42;
part.object.rotation.x = -squat * 0.11;
part.object.rotation.z = Math.sin(elapsed * 0.65) * safeSpeed * 0.0015;
}
if (distance !== undefined && Number.isFinite(distance)) {
const scale = detail === 'full' ? 2.78 : 2.32;
for (const wheel of wheels)
wheel.object.rotation.x =
(distance / (wheel.radius * scale)) % (Math.PI * 2);
}
group.rotation.x = 0;
group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018;
for (const material of tailMaterials)
material.emissiveIntensity = 2.2 + response * 1.2;
for (const material of cyanMaterials)
material.emissiveIntensity = 2.2 + response * 1.6;
underglowUniforms.uOpacity.value =
0.09 + safeSpeed * 0.04 + response * 0.075;
contact.uniforms.uOpacity.value = 0.21 + response * 0.055;
},
};
}
function createContactShadow(
resources: SceneResourceBag,
width: number,
depth: number,
opacity: number,
) {
const uniforms = {
uOpacity: { value: opacity },
};
const material = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader: `
varying vec2 vShadowUv;
void main() {
vShadowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uOpacity;
varying vec2 vShadowUv;
void main() {
vec2 centered = (vShadowUv - 0.5) * vec2(1.65, 1.0);
float shadow = 1.0 - smoothstep(0.16, 0.72, length(centered));
gl_FragColor = vec4(vec3(0.002, 0.004, 0.008), shadow * uOpacity);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.NormalBlending,
toneMapped: true,
}),
);
const mesh = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(width, depth)),
material,
);
mesh.name = 'car-contact-shadow';
mesh.rotation.x = -Math.PI / 2;
mesh.renderOrder = 2;
return { mesh, uniforms };
}
function ownMaterialTextures(
resources: SceneResourceBag,
material: AuthoredMaterial,
): void {
for (const key of [
'map',
'emissiveMap',
'normalMap',
'roughnessMap',
'metalnessMap',
'aoMap',
]) {
const texture = material[key] as
{ isTexture?: boolean; dispose(): void } | null | undefined;
if (texture?.isTexture) resources.own('texture', texture);
}
}
function createRearProfile() {
const shape = new THREE.Shape();
shape.moveTo(-2.7, -0.42);
shape.lineTo(-2.63, 0.2);
shape.lineTo(-2.32, 0.64);
shape.lineTo(-1.56, 0.96);
shape.lineTo(1.56, 0.96);
shape.lineTo(2.32, 0.64);
shape.lineTo(2.63, 0.2);
shape.lineTo(2.7, -0.42);
shape.closePath();
return shape;
}
function createHaunchGeometry(side: -1 | 1) {
const inner = 0.95 * side;
const outer = 2.62 * side;
const frontOuter = 2.25 * side;
const positions = new Float32Array([
inner,
0.48,
-1.75,
frontOuter,
0.38,
-1.75,
inner,
0.92,
-1.75,
outer,
0.68,
-1.75,
inner,
0.48,
2.62,
outer,
0.33,
2.62,
inner,
0.92,
2.62,
outer,
0.73,
2.62,
]);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex([
0, 1, 2, 2, 1, 3, 4, 6, 5, 6, 7, 5, 0, 4, 1, 1, 4, 5, 2, 3, 6, 3, 7, 6, 1,
5, 3, 3, 5, 7, 0, 2, 4, 2, 6, 4,
]);
geometry.computeVertexNormals();
return geometry;
}
function createCanopyGeometry() {
const positions = new Float32Array([
-1.18, 0.78, -1.9, 1.18, 0.78, -1.9, -0.86, 1.82, -1.65, 0.86, 1.82, -1.65,
-1.62, 0.78, 1.42, 1.62, 0.78, 1.42, -1.18, 1.42, 1.06, 1.18, 1.42, 1.06,
]);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex([
0, 1, 2, 2, 1, 3, 4, 6, 5, 6, 7, 5, 0, 2, 4, 2, 6, 4, 1, 5, 3, 3, 5, 7, 2,
3, 6, 3, 7, 6, 0, 4, 1, 1, 4, 5,
]);
geometry.computeVertexNormals();
return geometry;
}
function createDiffuserGeometry() {
const shape = new THREE.Shape();
shape.moveTo(-2.28, -0.42);
shape.lineTo(-1.8, 0.04);
shape.lineTo(-0.62, -0.12);
shape.lineTo(0, 0.08);
shape.lineTo(0.62, -0.12);
shape.lineTo(1.8, 0.04);
shape.lineTo(2.28, -0.42);
shape.closePath();
return new THREE.ShapeGeometry(shape);
}
function clamp01(value: number): number {
return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0;
}