feat: reconstruct the chase-view grand tourer
This commit is contained in:
@@ -70,19 +70,27 @@ export function createAtmosphere(
|
|||||||
uniforms: {
|
uniforms: {
|
||||||
uVisibility: { value: night.starVisibility },
|
uVisibility: { value: night.starVisibility },
|
||||||
uDistance: { value: 0 },
|
uDistance: { value: 0 },
|
||||||
|
uPointScale: { value: 18 },
|
||||||
|
uMaxPointSize: { value: 4.5 },
|
||||||
uColor: { value: new THREE.Color(0xb9ecf2) },
|
uColor: { value: new THREE.Color(0xb9ecf2) },
|
||||||
},
|
},
|
||||||
vertexShader: `
|
vertexShader: `
|
||||||
attribute float starSize;
|
attribute float starSize;
|
||||||
attribute float brightness;
|
attribute float brightness;
|
||||||
uniform float uDistance;
|
uniform float uDistance;
|
||||||
|
uniform float uPointScale;
|
||||||
|
uniform float uMaxPointSize;
|
||||||
varying float vBrightness;
|
varying float vBrightness;
|
||||||
void main() {
|
void main() {
|
||||||
vec3 star = position;
|
vec3 star = position;
|
||||||
star.x += sin(position.z * 0.017 + uDistance * 0.004) * 0.08;
|
star.x += sin(position.z * 0.017 + uDistance * 0.004) * 0.08;
|
||||||
vec4 viewPosition = modelViewMatrix * vec4(star, 1.0);
|
vec4 viewPosition = modelViewMatrix * vec4(star, 1.0);
|
||||||
vBrightness = brightness;
|
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;
|
gl_Position = projectionMatrix * viewPosition;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -1,108 +1,380 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
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();
|
const group = new THREE.Group();
|
||||||
group.position.set(0, 0.69, 0.5);
|
group.name = 'chase-car';
|
||||||
group.scale.set(0.9, 0.65, 0.78);
|
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({
|
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
color: 0x202938,
|
color: 0x304e68,
|
||||||
roughness: 0.28,
|
emissive: 0x091725,
|
||||||
metalness: 0.78,
|
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({
|
const glassMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
color: 0x10273a,
|
color: 0x071522,
|
||||||
|
emissive: 0x061522,
|
||||||
|
emissiveIntensity: 0.34,
|
||||||
roughness: 0.12,
|
roughness: 0.12,
|
||||||
metalness: 0.35,
|
metalness: 0.42,
|
||||||
}));
|
|
||||||
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,
|
|
||||||
transparent: true,
|
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 rearProfile = createRearProfile();
|
||||||
const lower = new THREE.Mesh(lowerGeometry, bodyMaterial);
|
const silhouetteGeometry = resources.own('geometry', new THREE.ExtrudeGeometry(rearProfile, {
|
||||||
lower.scale.set(1, 1, 0.92);
|
depth: 5.8,
|
||||||
group.add(lower);
|
steps: 1,
|
||||||
|
bevelEnabled: true,
|
||||||
const cabinGeometry = resources.own('geometry', new THREE.CylinderGeometry(1.25, 1.72, 2.9, 4));
|
bevelSegments: 1,
|
||||||
const cabin = new THREE.Mesh(cabinGeometry, glassMaterial);
|
bevelSize: 0.11,
|
||||||
cabin.rotation.set(Math.PI / 2, Math.PI / 4, 0);
|
bevelThickness: 0.1,
|
||||||
cabin.scale.set(1, 1, 0.62);
|
}));
|
||||||
cabin.position.set(0, 0.64, -0.35);
|
silhouetteGeometry.translate(0, 0, -2.9);
|
||||||
group.add(cabin);
|
silhouetteGeometry.computeVertexNormals();
|
||||||
|
const silhouette = new THREE.Mesh(silhouetteGeometry, bodyMaterial);
|
||||||
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);
|
|
||||||
silhouette.name = 'rear-silhouette';
|
silhouette.name = 'rear-silhouette';
|
||||||
silhouette.position.z = 2.98;
|
|
||||||
group.add(silhouette);
|
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);
|
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);
|
group.add(fascia);
|
||||||
|
|
||||||
const shoulderGeometry = resources.own('geometry', new THREE.BoxGeometry(5.05, 0.18, 1.1));
|
const lightGeometry = resources.own('geometry', new THREE.BoxGeometry(4.55, 0.13, 0.09));
|
||||||
const shoulders = new THREE.Mesh(shoulderGeometry, bodyMaterial);
|
const lightBar = new THREE.Mesh(lightGeometry, tailMaterial);
|
||||||
shoulders.position.set(0, 0.18, 2.48);
|
lightBar.name = 'rear-light-bar';
|
||||||
group.add(shoulders);
|
lightBar.position.set(0, 0.46, 3.17);
|
||||||
|
group.add(lightBar);
|
||||||
|
|
||||||
const accentMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x28d8ef }));
|
const diffuserGeometry = resources.own('geometry', createDiffuserGeometry());
|
||||||
const accentGeometry = resources.own('geometry', new THREE.BoxGeometry(0.36, 0.1, 0.08));
|
const diffuser = new THREE.Mesh(diffuserGeometry, lowerBodyMaterial);
|
||||||
for (const x of [-2.02, 2.02]) {
|
diffuser.name = 'rear-diffuser';
|
||||||
const accent = new THREE.Mesh(accentGeometry, accentMaterial);
|
diffuser.position.z = 3.08;
|
||||||
accent.position.set(x, -0.15, 3.11);
|
group.add(diffuser);
|
||||||
group.add(accent);
|
|
||||||
|
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 underglowUniforms = {
|
||||||
const underglow = new THREE.Mesh(glowGeometry, underglowMaterial);
|
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.rotation.x = -Math.PI / 2;
|
||||||
underglow.position.y = -0.31;
|
underglow.position.set(0, -0.5, 0.45);
|
||||||
group.add(underglow);
|
group.add(underglow);
|
||||||
|
|
||||||
const wheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.62, 0.62, 0.42, 18));
|
const secondary = new THREE.Group();
|
||||||
const wheelMaterial = resources.own('material', new THREE.MeshStandardMaterial({ color: 0x030407, roughness: 0.92 }));
|
secondary.name = 'secondary-detail';
|
||||||
for (const x of [-2.05, 2.05]) {
|
const trimGeometry = resources.own('geometry', new THREE.BoxGeometry(0.38, 0.075, 0.07));
|
||||||
for (const z of [-2.15, 2.15]) {
|
for (const x of [-1.98, 1.98]) {
|
||||||
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
|
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.rotation.z = Math.PI / 2;
|
||||||
wheel.position.set(x, -0.18, z);
|
wheel.position.set(x, -0.14, -2.05);
|
||||||
group.add(wheel);
|
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 {
|
return {
|
||||||
group,
|
group,
|
||||||
update(speed: number, acceleration: number, elapsedSeconds: number) {
|
setDetail,
|
||||||
const response = Math.min(1, Math.max(0, acceleration));
|
update(speed, acceleration, elapsedSeconds) {
|
||||||
group.position.y = 0.69 + Math.sin(elapsedSeconds * (2.2 + speed)) * (0.012 + response * 0.018);
|
const safeSpeed = clamp01(speed);
|
||||||
group.rotation.z = Math.sin(elapsedSeconds * 1.4) * 0.003 * response;
|
const response = clamp01(acceleration);
|
||||||
tailMaterial.color.setRGB(1, 0.12 + response * 0.08, 0.28 + response * 0.08);
|
const elapsed = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0;
|
||||||
underglowMaterial.opacity = 0.32 + Math.min(0.3, speed * 0.2);
|
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__;
|
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||||
|
|
||||||
export function createChaseCamera(aspect = 1) {
|
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.position.set(0, 4.8, 12.5);
|
||||||
camera.lookAt(0, 1.6, -22);
|
camera.lookAt(0, 1.6, -22);
|
||||||
return camera;
|
return camera;
|
||||||
@@ -76,6 +76,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
const terrain = createTerrain(context.resources, quality);
|
const terrain = createTerrain(context.resources, quality);
|
||||||
const sun = createSun(context.resources);
|
const sun = createSun(context.resources);
|
||||||
const car = createCar(context.resources);
|
const car = createCar(context.resources);
|
||||||
|
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
|
||||||
const sign = createSign(context.resources);
|
const sign = createSign(context.resources);
|
||||||
const atmosphere = createAtmosphere(context.resources, quality);
|
const atmosphere = createAtmosphere(context.resources, quality);
|
||||||
scene.add(
|
scene.add(
|
||||||
@@ -107,6 +108,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
renderer.setSize(width, height, false);
|
renderer.setSize(width, height, false);
|
||||||
camera.aspect = width / height;
|
camera.aspect = width / height;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
|
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
|
||||||
|
car.group.visible = height >= 340;
|
||||||
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
|
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
|
||||||
if (TEST_BUILD && context.quality !== 'medium') {
|
if (TEST_BUILD && context.quality !== 'medium') {
|
||||||
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
|
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
|
||||||
@@ -171,7 +174,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
background.copy(currentPalette.sky);
|
background.copy(currentPalette.sky);
|
||||||
scene.fog.color.copy(currentPalette.fog);
|
scene.fog.color.copy(currentPalette.fog);
|
||||||
scene.fog.density = 0.0105 + (1 - currentPalette.warmth) * 0.0025;
|
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) {
|
if (camera.fov !== fov) {
|
||||||
camera.fov = fov;
|
camera.fov = fov;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
|
|||||||
@@ -213,8 +213,15 @@ describe('scene render quality', () => {
|
|||||||
expect(maxX - minX).toBeGreaterThan(0.22);
|
expect(maxX - minX).toBeGreaterThan(0.22);
|
||||||
expect(minY).toBeGreaterThan(-0.98);
|
expect(minY).toBeGreaterThan(-0.98);
|
||||||
expect(maxY).toBeLessThan(-0.15);
|
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(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);
|
const silhouetteBounds = new THREE.Box3().setFromObject(silhouette);
|
||||||
expect(silhouetteBounds.max.x - silhouetteBounds.min.x).toBeGreaterThan(4);
|
expect(silhouetteBounds.max.x - silhouetteBounds.min.x).toBeGreaterThan(4);
|
||||||
expect(Math.max(
|
expect(Math.max(
|
||||||
@@ -222,6 +229,8 @@ describe('scene render quality', () => {
|
|||||||
silhouette.material.color.g,
|
silhouette.material.color.g,
|
||||||
silhouette.material.color.b,
|
silhouette.material.color.b,
|
||||||
)).toBeGreaterThan(0.12);
|
)).toBeGreaterThan(0.12);
|
||||||
|
car.setDetail('reduced');
|
||||||
|
expect((car.group.getObjectByName('secondary-detail') as { visible: boolean }).visible).toBe(false);
|
||||||
resources.dispose();
|
resources.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { createTerrain } from '@/scene/world/terrain';
|
|||||||
import { createSun } from '@/scene/world/sun';
|
import { createSun } from '@/scene/world/sun';
|
||||||
import { createAtmosphere } from '@/scene/world/atmosphere';
|
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||||
import { createPostProcessor } from '@/scene/post-processing';
|
import { createPostProcessor } from '@/scene/post-processing';
|
||||||
|
import { createCar } from '@/scene/world/car';
|
||||||
|
import { createChaseCamera } from '@/scene/world/environment';
|
||||||
|
|
||||||
describe('procedural world models', () => {
|
describe('procedural world models', () => {
|
||||||
it('scales geometry and atmosphere work monotonically by live quality tier', () => {
|
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(highStars.geometry.getAttribute('position').count).toBe(highQuality.starCount);
|
||||||
expect(lowStars.geometry.getAttribute('position').count).toBe(lowQuality.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(highStreaks.geometry.getAttribute('position').count).toBe(highQuality.streakCount * 2);
|
||||||
expect(lowStreaks.geometry.getAttribute('position').count).toBe(lowQuality.streakCount * 2);
|
expect(lowStreaks.geometry.getAttribute('position').count).toBe(lowQuality.streakCount * 2);
|
||||||
expect(high.group.getObjectByName('horizon-haze-near')).toBeInstanceOf(THREE.Mesh);
|
expect(high.group.getObjectByName('horizon-haze-near')).toBeInstanceOf(THREE.Mesh);
|
||||||
@@ -228,6 +234,71 @@ describe('procedural world models', () => {
|
|||||||
expect(bloomPass.dispose).toHaveBeenCalledOnce();
|
expect(bloomPass.dispose).toHaveBeenCalledOnce();
|
||||||
expect(composer.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>) {
|
function paletteSnapshot(palette: ReturnType<typeof createMutableChapterPalette>) {
|
||||||
@@ -258,3 +329,34 @@ interface ShaderRenderable {
|
|||||||
fragmentShader: string;
|
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