diff --git a/src/scene/world/atmosphere.ts b/src/scene/world/atmosphere.ts index 99fa90b..19e22aa 100644 --- a/src/scene/world/atmosphere.ts +++ b/src/scene/world/atmosphere.ts @@ -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; } `, diff --git a/src/scene/world/car.ts b/src/scene/world/car.ts index cc28281..4815dc6 100644 --- a/src/scene/world/car.ts +++ b/src/scene/world/car.ts @@ -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; +} diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index fa3a286..2cfe5b4 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -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(); diff --git a/tests/unit/scene-resources.test.ts b/tests/unit/scene-resources.test.ts index 81c1da8..d349c4b 100644 --- a/tests/unit/scene-resources.test.ts +++ b/tests/unit/scene-resources.test.ts @@ -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(); }); }); diff --git a/tests/unit/world-models.test.ts b/tests/unit/world-models.test.ts index d9f48d9..9cc659a 100644 --- a/tests/unit/world-models.test.ts +++ b/tests/unit/world-models.test.ts @@ -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 }; + }; + 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) { @@ -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), + ]; +}