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();

View File

@@ -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();
});
});

View File

@@ -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),
];
}