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 { 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 #include } `, 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 #include } `, 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 #include } `, 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; }