feat: rebuild the nightdrive road and terrain
This commit is contained in:
@@ -155,9 +155,33 @@ describe('scene render quality', () => {
|
||||
const medium = renderQualityFor('medium', 3, false);
|
||||
const low = renderQualityFor('low', 3, false);
|
||||
|
||||
expect(high).toEqual({ pixelRatio: 1.5, terrainSegments: 96, particleCount: 384, postEffects: true });
|
||||
expect(medium).toEqual({ pixelRatio: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true });
|
||||
expect(low).toEqual({ pixelRatio: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false });
|
||||
expect(high).toEqual({
|
||||
pixelRatio: 1.5,
|
||||
roadSegments: 192,
|
||||
terrainSegments: 96,
|
||||
terrainOctaves: 3,
|
||||
particleCount: 384,
|
||||
streakCount: 48,
|
||||
postEffects: true,
|
||||
});
|
||||
expect(medium).toEqual({
|
||||
pixelRatio: 1.5,
|
||||
roadSegments: 144,
|
||||
terrainSegments: 64,
|
||||
terrainOctaves: 2,
|
||||
particleCount: 192,
|
||||
streakCount: 28,
|
||||
postEffects: true,
|
||||
});
|
||||
expect(low).toEqual({
|
||||
pixelRatio: 1.25,
|
||||
roadSegments: 96,
|
||||
terrainSegments: 32,
|
||||
terrainOctaves: 1,
|
||||
particleCount: 64,
|
||||
streakCount: 12,
|
||||
postEffects: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('caps mobile and tablet pixel ratio at 1.5 even for High', () => {
|
||||
|
||||
164
tests/unit/world-models.test.ts
Normal file
164
tests/unit/world-models.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user