363 lines
15 KiB
TypeScript
363 lines
15 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import * as THREE from 'three';
|
|
import type { EnvironmentChapter } from '@/journey/types';
|
|
import { renderQualityFor } from '@/scene/render-quality';
|
|
import { SceneResourceBag } from '@/scene/resources';
|
|
import {
|
|
CHAPTER_PALETTES,
|
|
createMutableChapterPalette,
|
|
writeChapterPalette,
|
|
} from '@/scene/world/palette';
|
|
import { createRoad } from '@/scene/world/road';
|
|
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', () => {
|
|
const high = renderQualityFor('high', 3, false);
|
|
const medium = renderQualityFor('medium', 3, false);
|
|
const low = renderQualityFor('low', 3, false);
|
|
|
|
expect(high.roadSegments).toBeGreaterThan(medium.roadSegments);
|
|
expect(medium.roadSegments).toBeGreaterThan(low.roadSegments);
|
|
expect(high.terrainSegments).toBeGreaterThan(medium.terrainSegments);
|
|
expect(medium.terrainSegments).toBeGreaterThan(low.terrainSegments);
|
|
expect(high.streakCount).toBeGreaterThan(low.streakCount);
|
|
expect(low.postEffects).toBe(false);
|
|
});
|
|
|
|
it('builds a shader road whose motion is entirely uniform-driven', () => {
|
|
const resources = new SceneResourceBag();
|
|
const road = createRoad(resources, renderQualityFor('high', 1, false));
|
|
const surface = road.group.getObjectByName('road-surface') as ShaderRenderable;
|
|
|
|
expect(surface).toBeInstanceOf(THREE.Mesh);
|
|
expect(surface.geometry.index).not.toBeNull();
|
|
expect(surface.geometry.getAttribute('position').count).toBeGreaterThan(1_000);
|
|
expect(surface.material.uniforms).toMatchObject({
|
|
uDistance: { value: 0 },
|
|
uSpeed: { value: 0 },
|
|
uAcceleration: { value: 0 },
|
|
});
|
|
expect(surface.material.fragmentShader).toContain('centerLane');
|
|
expect(surface.material.fragmentShader).toContain('outerEdge');
|
|
|
|
const positionArray = surface.geometry.getAttribute('position').array;
|
|
const before = resources.snapshot();
|
|
for (let index = 0; index < 300; index += 1) {
|
|
road.update(index * 0.5, 0.65, 0.4);
|
|
}
|
|
|
|
expect(resources.snapshot()).toEqual(before);
|
|
expect(surface.geometry.getAttribute('position').array).toBe(positionArray);
|
|
expect(surface.material.uniforms.uDistance?.value).toBe(149.5);
|
|
expect(surface.material.uniforms.uSpeed?.value).toBe(0.65);
|
|
expect(surface.material.uniforms.uAcceleration?.value).toBe(0.4);
|
|
resources.dispose();
|
|
});
|
|
|
|
it('builds one wide indexed line terrain with a flat road-center mask', () => {
|
|
const resources = new SceneResourceBag();
|
|
const terrain = createTerrain(resources, renderQualityFor('medium', 1, false));
|
|
const lines = terrain.group.getObjectByName('terrain-lines') as ShaderRenderable;
|
|
|
|
expect(lines).toBeInstanceOf(THREE.LineSegments);
|
|
expect(lines.geometry.index).not.toBeNull();
|
|
lines.geometry.computeBoundingBox();
|
|
expect(lines.geometry.boundingBox).not.toBeNull();
|
|
expect(
|
|
(lines.geometry.boundingBox?.max.x ?? 0) - (lines.geometry.boundingBox?.min.x ?? 0),
|
|
).toBeGreaterThan(120);
|
|
|
|
const roadMask = lines.geometry.getAttribute('roadMask');
|
|
expect(roadMask).toBeDefined();
|
|
const maskValues = Array.from(roadMask.array as ArrayLike<number>);
|
|
expect(Math.min(...maskValues)).toBe(0);
|
|
expect(Math.max(...maskValues)).toBeGreaterThan(0.99);
|
|
expect(lines.material.uniforms).toMatchObject({
|
|
uDistance: { value: 0 },
|
|
uSpeed: { value: 0 },
|
|
uRouteProgress: { value: 0 },
|
|
});
|
|
expect(lines.material.vertexShader).toContain('multiOctaveNoise');
|
|
|
|
const indexArray = lines.geometry.index?.array;
|
|
const positionArray = lines.geometry.getAttribute('position').array;
|
|
const lowColor = lines.material.uniforms.uLowColor?.value;
|
|
const highColor = lines.material.uniforms.uHighColor?.value;
|
|
const before = resources.snapshot();
|
|
for (let index = 0; index < 300; index += 1) {
|
|
terrain.update(index * 0.4, 0.7, 'builds', index / 299);
|
|
}
|
|
|
|
expect(resources.snapshot()).toEqual(before);
|
|
expect(lines.geometry.index?.array).toBe(indexArray);
|
|
expect(lines.geometry.getAttribute('position').array).toBe(positionArray);
|
|
expect(lines.material.uniforms.uLowColor?.value).toBe(lowColor);
|
|
expect(lines.material.uniforms.uHighColor?.value).toBe(highColor);
|
|
expect(lines.material.uniforms.uRouteProgress?.value).toBe(1);
|
|
resources.dispose();
|
|
});
|
|
|
|
it('writes deterministic chapter blends into stable color allocations', () => {
|
|
const target = createMutableChapterPalette('deep-night');
|
|
const colorReferences = [
|
|
target.sky,
|
|
target.fog,
|
|
target.terrainLow,
|
|
target.terrainHigh,
|
|
target.sun,
|
|
target.haze,
|
|
];
|
|
|
|
expect(Object.keys(CHAPTER_PALETTES)).toEqual([
|
|
'deep-night',
|
|
'systems',
|
|
'infrastructure',
|
|
'builds',
|
|
'pre-dawn',
|
|
'destination',
|
|
] satisfies EnvironmentChapter[]);
|
|
expect(writeChapterPalette(target, 'deep-night', 'destination', 0.5)).toBe(target);
|
|
const first = paletteSnapshot(target);
|
|
expect(writeChapterPalette(target, 'deep-night', 'destination', 0.5)).toBe(target);
|
|
expect(paletteSnapshot(target)).toEqual(first);
|
|
expect([
|
|
target.sky,
|
|
target.fog,
|
|
target.terrainLow,
|
|
target.terrainHigh,
|
|
target.sun,
|
|
target.haze,
|
|
]).toEqual(colorReferences);
|
|
expect(target.warmth).toBeGreaterThan(CHAPTER_PALETTES['deep-night'].warmth);
|
|
expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.warmth);
|
|
});
|
|
|
|
it('renders the sun as a scan-banded disc with a separately owned halo', () => {
|
|
const resources = new SceneResourceBag();
|
|
const sun = createSun(resources);
|
|
const disc = sun.group.getObjectByName('sun-disc') as ShaderRenderable;
|
|
const halo = sun.group.getObjectByName('sun-halo');
|
|
|
|
expect(disc).toBeInstanceOf(THREE.Mesh);
|
|
expect(halo).toBeInstanceOf(THREE.Sprite);
|
|
expect(disc.material.uniforms).toMatchObject({
|
|
uScanBands: { value: 14 },
|
|
uWarmth: { value: CHAPTER_PALETTES['deep-night'].warmth },
|
|
});
|
|
expect(disc.material.fragmentShader).toContain('scanBand');
|
|
expect(resources.snapshot().owned.texture).toBe(1);
|
|
|
|
const palette = createMutableChapterPalette('destination');
|
|
sun.update(palette, 1);
|
|
expect(disc.material.uniforms.uWarmth?.value).toBe(1);
|
|
expect(disc.material.uniforms.uRouteProgress?.value).toBe(1);
|
|
resources.dispose();
|
|
});
|
|
|
|
it('builds deterministic stars, haze layers, and quality-capped speed streaks', () => {
|
|
const highResources = new SceneResourceBag();
|
|
const lowResources = new SceneResourceBag();
|
|
const highQuality = renderQualityFor('high', 1, false);
|
|
const lowQuality = renderQualityFor('low', 1, true);
|
|
const high = createAtmosphere(highResources, highQuality);
|
|
const low = createAtmosphere(lowResources, lowQuality);
|
|
const highStars = high.group.getObjectByName('atmosphere-stars') as ShaderRenderable;
|
|
const lowStars = low.group.getObjectByName('atmosphere-stars') as ShaderRenderable;
|
|
const highStreaks = high.group.getObjectByName('atmosphere-streaks') as ShaderRenderable;
|
|
const lowStreaks = low.group.getObjectByName('atmosphere-streaks') as ShaderRenderable;
|
|
|
|
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);
|
|
expect(high.group.getObjectByName('horizon-haze-far')).toBeInstanceOf(THREE.Mesh);
|
|
|
|
const highStarPositions = Array.from(highStars.geometry.getAttribute('position').array);
|
|
const secondResources = new SceneResourceBag();
|
|
const secondHigh = createAtmosphere(secondResources, highQuality);
|
|
const secondStars = secondHigh.group.getObjectByName('atmosphere-stars') as ShaderRenderable;
|
|
expect(Array.from(secondStars.geometry.getAttribute('position').array)).toEqual(highStarPositions);
|
|
|
|
const palette = createMutableChapterPalette('builds');
|
|
const before = highResources.snapshot();
|
|
for (let index = 0; index < 300; index += 1) {
|
|
high.update(index, 0.7, 0.8, palette);
|
|
}
|
|
expect(highResources.snapshot()).toEqual(before);
|
|
expect(highStreaks.material.uniforms.uAcceleration?.value).toBe(0.8);
|
|
expect(highStreaks.material.uniforms.uVisibility?.value).toBeGreaterThan(0);
|
|
|
|
highResources.dispose();
|
|
lowResources.dispose();
|
|
secondResources.dispose();
|
|
});
|
|
|
|
it('disposes a composer and its passes exactly once', () => {
|
|
const resources = new SceneResourceBag();
|
|
const composer = {
|
|
addPass: vi.fn(),
|
|
setPixelRatio: vi.fn(),
|
|
setSize: vi.fn(),
|
|
render: vi.fn(),
|
|
dispose: vi.fn(),
|
|
};
|
|
const renderPass = { scene: null, camera: null, setSize: vi.fn(), dispose: vi.fn() };
|
|
const bloomPass = { setSize: vi.fn(), dispose: vi.fn() };
|
|
const processor = createPostProcessor(resources, {}, {
|
|
createComposer: () => composer,
|
|
createRenderPass: () => renderPass,
|
|
createBloomPass: () => bloomPass,
|
|
});
|
|
|
|
processor.resize(1_200, 800, 1.25);
|
|
processor.render({}, {});
|
|
processor.dispose();
|
|
processor.dispose();
|
|
resources.dispose();
|
|
|
|
expect(composer.addPass).toHaveBeenCalledTimes(2);
|
|
expect(composer.setPixelRatio).toHaveBeenCalledWith(1.25);
|
|
expect(composer.setSize).toHaveBeenCalledWith(1_200, 800);
|
|
expect(composer.render).toHaveBeenCalledOnce();
|
|
expect(renderPass.dispose).toHaveBeenCalledOnce();
|
|
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>) {
|
|
return {
|
|
sky: palette.sky.getHex(),
|
|
fog: palette.fog.getHex(),
|
|
terrainLow: palette.terrainLow.getHex(),
|
|
terrainHigh: palette.terrainHigh.getHex(),
|
|
sun: palette.sun.getHex(),
|
|
haze: palette.haze.getHex(),
|
|
warmth: palette.warmth,
|
|
terrainEnergy: palette.terrainEnergy,
|
|
starVisibility: palette.starVisibility,
|
|
streakEnergy: palette.streakEnergy,
|
|
};
|
|
}
|
|
|
|
interface ShaderRenderable {
|
|
geometry: {
|
|
index: { array: ArrayLike<number> } | null;
|
|
boundingBox: { min: { x: number }; max: { x: number } } | null;
|
|
computeBoundingBox(): void;
|
|
getAttribute(name: string): { count: number; array: ArrayLike<number> };
|
|
};
|
|
material: {
|
|
uniforms: Record<string, { value: unknown }>;
|
|
vertexShader: 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),
|
|
];
|
|
}
|