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.
763 lines
22 KiB
TypeScript
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;
|
|
}
|