feat: reconstruct the chase-view grand tourer

This commit is contained in:
2026-09-02 15:06:14 +03:00
parent 5c52de9134
commit e4c832356b
5 changed files with 475 additions and 81 deletions

View File

@@ -70,19 +70,27 @@ export function createAtmosphere(
uniforms: {
uVisibility: { value: night.starVisibility },
uDistance: { value: 0 },
uPointScale: { value: 18 },
uMaxPointSize: { value: 4.5 },
uColor: { value: new THREE.Color(0xb9ecf2) },
},
vertexShader: `
attribute float starSize;
attribute float brightness;
uniform float uDistance;
uniform float uPointScale;
uniform float uMaxPointSize;
varying float vBrightness;
void main() {
vec3 star = position;
star.x += sin(position.z * 0.017 + uDistance * 0.004) * 0.08;
vec4 viewPosition = modelViewMatrix * vec4(star, 1.0);
vBrightness = brightness;
gl_PointSize = starSize * (250.0 / max(1.0, -viewPosition.z));
gl_PointSize = clamp(
starSize * (uPointScale / max(1.0, -viewPosition.z)),
0.75,
uMaxPointSize
);
gl_Position = projectionMatrix * viewPosition;
}
`,

View File

@@ -1,108 +1,380 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
export function createCar(resources: SceneResourceBag) {
export interface CarWorld {
group: CarGroup;
setDetail(detail: 'full' | 'reduced'): void;
update(speed: number, acceleration: number, elapsedSeconds: number): void;
}
interface CarGroup {
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;
}
const FULL_BASE_Y = 0.58;
const REDUCED_BASE_Y = 0.42;
export function createCar(resources: SceneResourceBag): CarWorld {
const group = new THREE.Group();
group.position.set(0, 0.69, 0.5);
group.scale.set(0.9, 0.65, 0.78);
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);
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x202938,
roughness: 0.28,
metalness: 0.78,
color: 0x304e68,
emissive: 0x091725,
emissiveIntensity: 0.42,
roughness: 0.36,
metalness: 0.68,
}));
const shoulderMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x365b77,
emissive: 0x0a1b2a,
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: 0x10273a,
color: 0x071522,
emissive: 0x061522,
emissiveIntensity: 0.34,
roughness: 0.12,
metalness: 0.35,
}));
const tailMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff315f }));
const silhouetteMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x405875 }));
const fasciaMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x151c28,
roughness: 0.42,
metalness: 0.62,
}));
const underglowMaterial = resources.own('material', new THREE.MeshBasicMaterial({
color: 0x28d8ef,
metalness: 0.42,
transparent: true,
opacity: 0.42,
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 lowerGeometry = resources.own('geometry', new THREE.BoxGeometry(4.6, 0.55, 6.6));
const lower = new THREE.Mesh(lowerGeometry, bodyMaterial);
lower.scale.set(1, 1, 0.92);
group.add(lower);
const cabinGeometry = resources.own('geometry', new THREE.CylinderGeometry(1.25, 1.72, 2.9, 4));
const cabin = new THREE.Mesh(cabinGeometry, glassMaterial);
cabin.rotation.set(Math.PI / 2, Math.PI / 4, 0);
cabin.scale.set(1, 1, 0.62);
cabin.position.set(0, 0.64, -0.35);
group.add(cabin);
const lightGeometry = resources.own('geometry', new THREE.BoxGeometry(3.9, 0.12, 0.08));
const rearLight = new THREE.Mesh(lightGeometry, tailMaterial);
rearLight.position.set(0, 0.04, 3.08);
group.add(rearLight);
const rearProfile = new THREE.Shape();
rearProfile.moveTo(-2.5, -0.32);
rearProfile.lineTo(-2.32, 0.28);
rearProfile.lineTo(-1.42, 0.72);
rearProfile.lineTo(1.42, 0.72);
rearProfile.lineTo(2.32, 0.28);
rearProfile.lineTo(2.5, -0.32);
rearProfile.closePath();
const silhouetteGeometry = resources.own('geometry', new THREE.ShapeGeometry(rearProfile));
const silhouette = new THREE.Mesh(silhouetteGeometry, silhouetteMaterial);
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';
silhouette.position.z = 2.98;
group.add(silhouette);
const fasciaGeometry = resources.own('geometry', new THREE.BoxGeometry(4.3, 0.38, 0.22));
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.position.set(0, -0.12, 2.94);
fascia.name = 'rear-fascia';
fascia.position.set(0, 0.02, 3.07);
group.add(fascia);
const shoulderGeometry = resources.own('geometry', new THREE.BoxGeometry(5.05, 0.18, 1.1));
const shoulders = new THREE.Mesh(shoulderGeometry, bodyMaterial);
shoulders.position.set(0, 0.18, 2.48);
group.add(shoulders);
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 accentMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x28d8ef }));
const accentGeometry = resources.own('geometry', new THREE.BoxGeometry(0.36, 0.1, 0.08));
for (const x of [-2.02, 2.02]) {
const accent = new THREE.Mesh(accentGeometry, accentMaterial);
accent.position.set(x, -0.15, 3.11);
group.add(accent);
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 glowGeometry = resources.own('geometry', new THREE.PlaneGeometry(4.2, 6.2));
const underglow = new THREE.Mesh(glowGeometry, underglowMaterial);
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);
}
`,
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.y = -0.31;
underglow.position.set(0, -0.5, 0.45);
group.add(underglow);
const wheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.62, 0.62, 0.42, 18));
const wheelMaterial = resources.own('material', new THREE.MeshStandardMaterial({ color: 0x030407, roughness: 0.92 }));
for (const x of [-2.05, 2.05]) {
for (const z of [-2.15, 2.15]) {
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
wheel.rotation.z = Math.PI / 2;
wheel.position.set(x, -0.18, z);
group.add(wheel);
}
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: 'full' | 'reduced' = 'full';
let squat = 0;
let squatVelocity = 0;
let previousElapsed: number | null = null;
const setDetail = (nextDetail: 'full' | 'reduced'): void => {
if (detail === nextDetail) return;
detail = nextDetail;
secondary.visible = detail === 'full';
if (detail === 'reduced') {
baseY = REDUCED_BASE_Y;
group.scale.set(0.7, 0.56, 0.66);
group.position.z = 0.62;
} else {
baseY = FULL_BASE_Y;
group.scale.set(0.9, 0.72, 0.82);
group.position.z = 0.72;
}
group.position.y = baseY - squat;
};
return {
group,
update(speed: number, acceleration: number, elapsedSeconds: number) {
const response = Math.min(1, Math.max(0, acceleration));
group.position.y = 0.69 + Math.sin(elapsedSeconds * (2.2 + speed)) * (0.012 + response * 0.018);
group.rotation.z = Math.sin(elapsedSeconds * 1.4) * 0.003 * response;
tailMaterial.color.setRGB(1, 0.12 + response * 0.08, 0.28 + response * 0.08);
underglowMaterial.opacity = 0.32 + Math.min(0.3, speed * 0.2);
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;
},
};
}
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;
}

View File

@@ -21,7 +21,7 @@ declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export function createChaseCamera(aspect = 1) {
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 320);
const camera = new THREE.PerspectiveCamera(70, aspect, 0.1, 320);
camera.position.set(0, 4.8, 12.5);
camera.lookAt(0, 1.6, -22);
return camera;
@@ -76,6 +76,7 @@ export async function createProceduralSceneGeneration(
const terrain = createTerrain(context.resources, quality);
const sun = createSun(context.resources);
const car = createCar(context.resources);
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
const sign = createSign(context.resources);
const atmosphere = createAtmosphere(context.resources, quality);
scene.add(
@@ -107,6 +108,8 @@ export async function createProceduralSceneGeneration(
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
car.group.visible = height >= 340;
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
if (TEST_BUILD && context.quality !== 'medium') {
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
@@ -171,7 +174,7 @@ export async function createProceduralSceneGeneration(
background.copy(currentPalette.sky);
scene.fog.color.copy(currentPalette.fog);
scene.fog.density = 0.0105 + (1 - currentPalette.warmth) * 0.0025;
const fov = 52 + Math.min(7, snapshot.acceleration * 4.5);
const fov = 70 + Math.min(5, snapshot.acceleration * 4.2);
if (camera.fov !== fov) {
camera.fov = fov;
camera.updateProjectionMatrix();