feat: refine nightdrive world and authored car

Integrate the Blender grand tourer with a cohesive shader-driven road, terrain, atmosphere, lighting, and post-processing pass. Refine responsive drive dynamics and sign lifecycle behavior, and add durable visual, accessibility, and performance verification evidence.
This commit is contained in:
2026-09-02 23:16:25 +03:00
parent 7dae835ac5
commit bc18bc2043
73 changed files with 1555 additions and 293 deletions

View File

@@ -1,4 +1,6 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import grandTourerUrl from '@/assets/nightdrive-grand-tourer.glb?url';
import type { SceneResourceBag } from '@/scene/resources';
import type { SceneDetailMode } from '@/scene/types';
@@ -9,6 +11,7 @@ export interface CarWorld {
}
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 };
@@ -19,15 +22,54 @@ interface CarGroup {
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[];
}
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 function createCar(resources: SceneResourceBag): CarWorld {
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('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(0.9, 0.72, 0.82);
group.scale.set(1.1, 0.82, 0.98);
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x304e68,
@@ -222,8 +264,11 @@ export function createCar(resources: SceneResourceBag): CarWorld {
const underglow = new THREE.Mesh(underglowGeometry, underglowMaterial);
underglow.name = 'car-underglow';
underglow.rotation.x = -Math.PI / 2;
underglow.position.set(0, -0.5, 0.45);
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';
@@ -258,11 +303,11 @@ export function createCar(resources: SceneResourceBag): CarWorld {
secondary.visible = detail === 'full';
if (detail === 'reduced') {
baseY = REDUCED_BASE_Y;
group.scale.set(0.7, 0.56, 0.66);
group.scale.set(0.86, 0.66, 0.76);
group.position.z = 0.62;
} else {
baseY = FULL_BASE_Y;
group.scale.set(0.9, 0.72, 0.82);
group.scale.set(1.1, 0.82, 0.98);
group.position.z = 0.72;
}
group.position.y = baseY - squat;
@@ -292,10 +337,201 @@ export function createCar(resources: SceneResourceBag): CarWorld {
);
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 tailMaterials: AuthoredMaterial[] = [];
const cyanMaterials: AuthoredMaterial[] = [];
authoredModel.traverse((object) => {
if (object.name.endsWith('.001')) object.name = object.name.slice(0, -4);
if (!object.isMesh || !object.geometry || !object.material) return;
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(0x0b2738);
material.emissiveIntensity = 0.58;
}
if (material.isMeshStandardMaterial && object.name === 'rear-glass') {
material.emissive?.setHex(0x041725);
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);
}
`,
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) {
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 - squat + vibration;
group.rotation.x = -squat * 0.035;
group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018;
for (const material of tailMaterials) material.emissiveIntensity = 4.4 + response * 3.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);
}
`,
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);