Files
Portfolio/tests/unit/world-models.test.ts

419 lines
17 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';
import { createSign, routeLabelFor } from '@/scene/world/sign';
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();
});
it('renders one physical roadside sign and updates its plain label once per target', () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
for (const name of [
'sign-backplate',
'sign-face',
'sign-frame',
'sign-post-left',
'sign-post-right',
]) {
expect(sign.group.getObjectByName(name), name).toBeDefined();
}
const face = sign.group.getObjectByName('sign-face') as {
userData: { textureUpdateCount: number };
material: { opacity: number };
};
const allocation = resources.snapshot();
sign.update(null);
expect((sign.group as { visible: boolean }).visible).toBe(false);
sign.update({
targetId: 'infrastructure',
phase: 'anticipate',
progress: 0,
opacity: 0,
});
expect((sign.group as { visible: boolean }).visible).toBe(false);
expect(face.userData.textureUpdateCount).toBe(1);
expect(routeLabelFor('infrastructure')).toBe('Infrastructure');
sign.update({
targetId: 'infrastructure',
phase: 'approach',
progress: 0.5,
opacity: 0.7,
});
expect((sign.group as { visible: boolean }).visible).toBe(true);
expect(face.userData.textureUpdateCount).toBe(1);
expect(face.material.opacity).toBeCloseTo(0.7, 6);
const desktopX = (sign.group as { position: { x: number } }).position.x;
sign.setViewportAspect(0.6);
sign.update({
targetId: 'selected-builds',
phase: 'dwell',
progress: 1,
opacity: 1,
});
expect(face.userData.textureUpdateCount).toBe(2);
expect(routeLabelFor('selected-builds')).toBe('Selected builds');
expect((sign.group as { position: { x: number } }).position.x).toBeLessThan(desktopX);
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),
];
}