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

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