165 lines
6.3 KiB
TypeScript
165 lines
6.3 KiB
TypeScript
import { describe, expect, it } 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';
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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;
|
|
};
|
|
}
|