feat: reconstruct the chase-view grand tourer
This commit is contained in:
@@ -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;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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);
|
||||
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.18, z);
|
||||
group.add(wheel);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -213,8 +213,15 @@ describe('scene render quality', () => {
|
||||
expect(maxX - minX).toBeGreaterThan(0.22);
|
||||
expect(minY).toBeGreaterThan(-0.98);
|
||||
expect(maxY).toBeLessThan(-0.15);
|
||||
const silhouette = car.group.getObjectByName('rear-silhouette');
|
||||
const silhouette = car.group.getObjectByName('rear-silhouette') as {
|
||||
material: { color: { r: number; g: number; b: number } };
|
||||
};
|
||||
expect(silhouette).toBeDefined();
|
||||
expect(car.group.getObjectByName('rear-light-bar')).toBeDefined();
|
||||
expect(car.group.getObjectByName('rear-glass')).toBeDefined();
|
||||
expect(car.group.getObjectByName('rear-fascia')).toBeDefined();
|
||||
expect(car.group.getObjectByName('rear-wheel-left')).toBeDefined();
|
||||
expect(car.group.getObjectByName('rear-wheel-right')).toBeDefined();
|
||||
const silhouetteBounds = new THREE.Box3().setFromObject(silhouette);
|
||||
expect(silhouetteBounds.max.x - silhouetteBounds.min.x).toBeGreaterThan(4);
|
||||
expect(Math.max(
|
||||
@@ -222,6 +229,8 @@ describe('scene render quality', () => {
|
||||
silhouette.material.color.g,
|
||||
silhouette.material.color.b,
|
||||
)).toBeGreaterThan(0.12);
|
||||
car.setDetail('reduced');
|
||||
expect((car.group.getObjectByName('secondary-detail') as { visible: boolean }).visible).toBe(false);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ import { createTerrain } from '@/scene/world/terrain';
|
||||
import { createSun } from '@/scene/world/sun';
|
||||
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||
import { createPostProcessor } from '@/scene/post-processing';
|
||||
import { createCar } from '@/scene/world/car';
|
||||
import { createChaseCamera } from '@/scene/world/environment';
|
||||
|
||||
describe('procedural world models', () => {
|
||||
it('scales geometry and atmosphere work monotonically by live quality tier', () => {
|
||||
@@ -172,6 +174,10 @@ describe('procedural world models', () => {
|
||||
|
||||
expect(highStars.geometry.getAttribute('position').count).toBe(highQuality.starCount);
|
||||
expect(lowStars.geometry.getAttribute('position').count).toBe(lowQuality.starCount);
|
||||
expect(highStars.material.uniforms).toMatchObject({
|
||||
uPointScale: { value: 18 },
|
||||
uMaxPointSize: { value: 4.5 },
|
||||
});
|
||||
expect(highStreaks.geometry.getAttribute('position').count).toBe(highQuality.streakCount * 2);
|
||||
expect(lowStreaks.geometry.getAttribute('position').count).toBe(lowQuality.streakCount * 2);
|
||||
expect(high.group.getObjectByName('horizon-haze-near')).toBeInstanceOf(THREE.Mesh);
|
||||
@@ -228,6 +234,71 @@ describe('procedural world models', () => {
|
||||
expect(bloomPass.dispose).toHaveBeenCalledOnce();
|
||||
expect(composer.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps the grand tourer anatomy readable in desktop and portrait chase views', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const car = createCar(resources);
|
||||
for (const name of [
|
||||
'rear-silhouette',
|
||||
'rear-body-face',
|
||||
'rear-light-bar',
|
||||
'rear-glass',
|
||||
'rear-glass-panel',
|
||||
'rear-fascia',
|
||||
'rear-wheel-left',
|
||||
'rear-wheel-right',
|
||||
'car-underglow',
|
||||
]) {
|
||||
expect(car.group.getObjectByName(name), name).toBeDefined();
|
||||
}
|
||||
|
||||
const desktopCamera = createChaseCamera(16 / 9);
|
||||
expect((desktopCamera as { fov: number }).fov).toBe(70);
|
||||
const desktop = projectedBounds(car.group, desktopCamera);
|
||||
expect(desktop.maxX - desktop.minX).toBeGreaterThan(0.28);
|
||||
expect(desktop.minX).toBeGreaterThan(-0.96);
|
||||
expect(desktop.maxX).toBeLessThan(0.96);
|
||||
expect(desktop.minY).toBeGreaterThan(-0.99);
|
||||
expect(desktop.maxY).toBeLessThan(-0.05);
|
||||
|
||||
car.setDetail('reduced');
|
||||
const portrait = projectedBounds(car.group, createChaseCamera(390 / 844));
|
||||
expect(portrait.minX).toBeGreaterThan(-0.99);
|
||||
expect(portrait.maxX).toBeLessThan(0.99);
|
||||
expect(portrait.minY).toBeGreaterThan(-0.99);
|
||||
expect(portrait.maxY).toBeLessThan(0.16);
|
||||
expect((car.group.getObjectByName('secondary-detail') as { visible: boolean }).visible).toBe(false);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('damps squat and light response without allocating or changing the scene graph', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const car = createCar(resources);
|
||||
const light = car.group.getObjectByName('rear-light-bar') as {
|
||||
material: { emissiveIntensity: number };
|
||||
};
|
||||
const underglow = car.group.getObjectByName('car-underglow') as {
|
||||
material: { uniforms: Record<string, { value: number }> };
|
||||
};
|
||||
car.update(0.3, 0, 0);
|
||||
const restingY = (car.group as { position: { y: number } }).position.y;
|
||||
const restingLight = light.material.emissiveIntensity;
|
||||
const restingGlow = underglow.material.uniforms.uOpacity?.value ?? 0;
|
||||
const childCount = (car.group as { children: unknown[] }).children.length;
|
||||
const allocation = resources.snapshot();
|
||||
|
||||
for (let frame = 1; frame <= 60; frame += 1) {
|
||||
car.update(0.92, 1, frame / 60);
|
||||
}
|
||||
|
||||
expect((car.group as { position: { y: number } }).position.y).toBeLessThan(restingY);
|
||||
expect(light.material.emissiveIntensity).toBeGreaterThan(restingLight);
|
||||
expect(underglow.material.uniforms.uOpacity?.value).toBeGreaterThan(restingGlow);
|
||||
expect(Math.abs((car.group as { rotation: { z: number } }).rotation.z)).toBeLessThanOrEqual(0.006);
|
||||
expect((car.group as { children: unknown[] }).children).toHaveLength(childCount);
|
||||
expect(resources.snapshot()).toEqual(allocation);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function paletteSnapshot(palette: ReturnType<typeof createMutableChapterPalette>) {
|
||||
@@ -258,3 +329,34 @@ interface ShaderRenderable {
|
||||
fragmentShader: string;
|
||||
};
|
||||
}
|
||||
|
||||
function projectedBounds(group: unknown, camera: unknown) {
|
||||
const typedGroup = group as { updateMatrixWorld(force: boolean): void };
|
||||
const typedCamera = camera as { updateMatrixWorld(force: boolean): void };
|
||||
typedGroup.updateMatrixWorld(true);
|
||||
typedCamera.updateMatrixWorld(true);
|
||||
const bounds = new THREE.Box3().setFromObject(group);
|
||||
const projected = boxCorners(bounds).map((point) => point.project(camera));
|
||||
return {
|
||||
minX: Math.min(...projected.map((point) => point.x)),
|
||||
maxX: Math.max(...projected.map((point) => point.x)),
|
||||
minY: Math.min(...projected.map((point) => point.y)),
|
||||
maxY: Math.max(...projected.map((point) => point.y)),
|
||||
};
|
||||
}
|
||||
|
||||
function boxCorners(bounds: {
|
||||
min: { x: number; y: number; z: number };
|
||||
max: { x: number; y: number; z: number };
|
||||
}) {
|
||||
return [
|
||||
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z),
|
||||
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
|
||||
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z),
|
||||
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
|
||||
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z),
|
||||
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
|
||||
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z),
|
||||
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user