734 lines
26 KiB
TypeScript
734 lines
26 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, createWorldFog } from "@/scene/world/environment";
|
|
import {
|
|
HORIZON_FOG_END,
|
|
HORIZON_FOG_START,
|
|
} from "@/scene/world/metrics";
|
|
import { createSign, routeLabelFor } from "@/scene/world/sign";
|
|
import { createDriveDynamics } from "@/scene/drive-dynamics";
|
|
import { createTravelDistance } from "@/scene/world/travel-distance";
|
|
|
|
describe("procedural world models", () => {
|
|
it("uses the same linear horizon interval for scene-owned materials", () => {
|
|
const fog = createWorldFog(0x07101c);
|
|
|
|
expect(fog).toBeInstanceOf(THREE.Fog);
|
|
expect(fog.near).toBe(HORIZON_FOG_START);
|
|
expect(fog.far).toBe(HORIZON_FOG_END);
|
|
});
|
|
|
|
it("owns one monotonic forward-distance clock across cruise, input release, and delayed frames", () => {
|
|
const travel = createTravelDistance();
|
|
|
|
const cruise = travel.advance(0.1, 0.18, 0);
|
|
const accelerated = travel.advance(0.1, 1, 1);
|
|
const coast = travel.advance(0.1, 0.18, 0);
|
|
const delayed = travel.advance(4, 0.18, 0);
|
|
const malformed = travel.advance(Number.NaN, -1, Number.POSITIVE_INFINITY);
|
|
|
|
expect(cruise).toBeGreaterThan(0);
|
|
expect(accelerated).toBeGreaterThan(cruise);
|
|
expect(coast).toBeGreaterThan(accelerated);
|
|
expect(delayed).toBeGreaterThan(coast);
|
|
expect(delayed - coast).toBeCloseTo(cruise, 10);
|
|
expect(malformed).toBe(delayed);
|
|
expect(travel.snapshot()).toBe(delayed);
|
|
});
|
|
|
|
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,
|
|
);
|
|
surface.geometry.computeBoundingBox();
|
|
expect(surface.geometry.boundingBox?.min.z).toBeLessThan(-700);
|
|
expect(surface.material.uniforms).toMatchObject({
|
|
uDistance: { value: 0 },
|
|
uSpeed: { value: 0 },
|
|
uAcceleration: { value: 0 },
|
|
uFogColor: { value: expect.any(THREE.Color) },
|
|
});
|
|
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-grid",
|
|
) 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?.min.z).toBeLessThan(-700);
|
|
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 },
|
|
uFlowDistance: { value: 0 },
|
|
uRouteProgress: { value: 0 },
|
|
uRelief: { value: expect.any(Number) },
|
|
uFogColor: { value: expect.any(THREE.Color) },
|
|
});
|
|
expect(lines.material.vertexShader).toContain("multiOctaveNoise");
|
|
expect(lines.material.vertexShader).toContain("mod(point, 256.0)");
|
|
|
|
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("horizon-sun") as ShaderRenderable;
|
|
const halo = sun.group.getObjectByName("horizon-halo");
|
|
const limb = sun.group.getObjectByName("sun-limb-glow");
|
|
const bleed = sun.group.getObjectByName("horizon-light-bleed");
|
|
|
|
expect(disc).toBeInstanceOf(THREE.Mesh);
|
|
expect(halo).toBeInstanceOf(THREE.Sprite);
|
|
expect(limb).toBeInstanceOf(THREE.Mesh);
|
|
expect(bleed).toBeInstanceOf(THREE.Sprite);
|
|
expect((disc as unknown as { position: { z: number } }).position.z).toBeLessThan(-650);
|
|
expect(disc.material.uniforms).toMatchObject({
|
|
uScanBands: { value: 11 },
|
|
uWarmth: { value: CHAPTER_PALETTES["deep-night"].warmth },
|
|
uTime: { value: 0 },
|
|
uDriveEnergy: { value: 0 },
|
|
});
|
|
expect(disc.material.fragmentShader).toContain("scanBand");
|
|
expect(resources.snapshot().owned.texture).toBe(1);
|
|
|
|
const palette = createMutableChapterPalette("destination");
|
|
sun.update(palette, 1, 3.2, 1);
|
|
expect(disc.material.uniforms.uWarmth?.value).toBe(1);
|
|
expect(disc.material.uniforms.uRouteProgress?.value).toBe(1);
|
|
expect(disc.material.uniforms.uDriveEnergy?.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(
|
|
"speed-streaks",
|
|
) as ShaderRenderable;
|
|
const lowStreaks = low.group.getObjectByName(
|
|
"speed-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")).toBeInstanceOf(
|
|
THREE.Group,
|
|
);
|
|
const sky = high.group.getObjectByName("atmosphere-sky") as ShaderRenderable & {
|
|
frustumCulled: boolean;
|
|
};
|
|
sky.geometry.computeBoundingBox();
|
|
expect(sky.geometry.boundingBox?.min.x).toBeCloseTo(-1, 6);
|
|
expect(sky.geometry.boundingBox?.max.x).toBeCloseTo(1, 6);
|
|
expect(sky.material.vertexShader).toContain(
|
|
"gl_Position = vec4(position.xy, 1.0, 1.0)",
|
|
);
|
|
expect(sky.frustumCulled).toBe(false);
|
|
expect(sky.material.depthTest).toBe(false);
|
|
const haze = high.group.getObjectByName(
|
|
"horizon-haze-near",
|
|
) as ShaderRenderable;
|
|
expect(haze.material.vertexShader).toContain(
|
|
"gl_Position = vec4(position.xy, 1.0, 1.0)",
|
|
);
|
|
expect(
|
|
(high.group.getObjectByName("distant-silhouettes") as { children: unknown[] })
|
|
.children,
|
|
).toHaveLength(3);
|
|
expect(high.group.getObjectByName("roadside-anchors")).toBeInstanceOf(
|
|
THREE.InstancedMesh,
|
|
);
|
|
|
|
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);
|
|
|
|
high.update(100, 1, 1, palette);
|
|
const acceleratedPhase = highStreaks.material.uniforms.uDistance?.value as number;
|
|
high.update(102, 0.18, 0, palette);
|
|
const coastPhase = highStreaks.material.uniforms.uDistance?.value as number;
|
|
expect(coastPhase).toBeGreaterThan(acceleratedPhase);
|
|
|
|
high.setViewportAspect(32 / 9);
|
|
expect(sky.material.uniforms.uAspect?.value).toBeCloseTo(32 / 9, 6);
|
|
|
|
highResources.dispose();
|
|
lowResources.dispose();
|
|
secondResources.dispose();
|
|
});
|
|
|
|
it("keeps independent inertial response curves for the road, chase rig, streaks, bloom, and suspension", () => {
|
|
const dynamics = createDriveDynamics();
|
|
const stableSnapshot = dynamics.snapshot();
|
|
|
|
dynamics.update(1, 1, 0.1);
|
|
const attack = { ...dynamics.snapshot() };
|
|
expect(dynamics.snapshot()).toBe(stableSnapshot);
|
|
expect(attack.roadEnergy).toBeGreaterThan(attack.cameraEnergy);
|
|
expect(attack.cameraEnergy).toBeGreaterThan(attack.bloomEnergy);
|
|
expect(attack.streakEnergy).toBeGreaterThan(0);
|
|
expect(attack.suspensionEnergy).toBeGreaterThan(0);
|
|
expect(attack.directionBias).toBeGreaterThan(0);
|
|
|
|
dynamics.update(0, 0, 0.1);
|
|
const coast = dynamics.snapshot();
|
|
expect(coast.roadEnergy).toBeLessThan(attack.roadEnergy);
|
|
expect(coast.streakEnergy / attack.streakEnergy).toBeGreaterThan(
|
|
coast.roadEnergy / attack.roadEnergy,
|
|
);
|
|
expect(coast.bloomEnergy).toBeGreaterThan(0);
|
|
|
|
dynamics.update(1, -1, 10);
|
|
expect(Object.values(dynamics.snapshot()).every(Number.isFinite)).toBe(true);
|
|
expect(dynamics.snapshot().directionBias).toBeLessThan(1);
|
|
});
|
|
|
|
it("disposes a composer and its passes exactly once", () => {
|
|
const resources = new SceneResourceBag();
|
|
const target = (width: number, height: number, depthBuffer: boolean) => ({
|
|
width,
|
|
height,
|
|
depthBuffer,
|
|
samples: 1,
|
|
dispose: vi.fn(),
|
|
});
|
|
const composer = {
|
|
addPass: vi.fn(),
|
|
setPixelRatio: vi.fn(),
|
|
setSize: vi.fn(),
|
|
render: vi.fn(),
|
|
dispose: vi.fn(),
|
|
renderTarget1: target(1_500, 1_000, true),
|
|
renderTarget2: target(1_500, 1_000, true),
|
|
};
|
|
const renderPass = {
|
|
scene: null,
|
|
camera: null,
|
|
setSize: vi.fn(),
|
|
dispose: vi.fn(),
|
|
};
|
|
const bloomPass = {
|
|
strength: 0.54,
|
|
radius: 0.42,
|
|
threshold: 0.88,
|
|
setSize: vi.fn(),
|
|
dispose: vi.fn(),
|
|
renderTargetBright: target(750, 500, false),
|
|
renderTargetsHorizontal: [target(375, 250, false)],
|
|
renderTargetsVertical: [target(375, 250, false)],
|
|
};
|
|
const outputPass = {
|
|
setSize: vi.fn(),
|
|
dispose: vi.fn(),
|
|
};
|
|
const processor = createPostProcessor(
|
|
resources,
|
|
{},
|
|
{
|
|
createComposer: () => composer,
|
|
createRenderPass: () => renderPass,
|
|
createBloomPass: () => bloomPass,
|
|
createOutputPass: () => outputPass,
|
|
},
|
|
);
|
|
|
|
processor.resize(1_200, 800, 1.25);
|
|
processor.setDynamics(0.75);
|
|
processor.render({}, {});
|
|
const allocations = resources.gpuBudgetSnapshot({
|
|
tier: "high",
|
|
defaultFramebuffer: {
|
|
width: 1_500,
|
|
height: 1_000,
|
|
bytesPerPixel: 4,
|
|
attachments: 2,
|
|
samples: 4,
|
|
},
|
|
});
|
|
expect(
|
|
allocations.renderTargets.map((allocation) => allocation.id),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
"composer-target-1",
|
|
"composer-target-2",
|
|
"bloom-bright",
|
|
"bloom-horizontal-0",
|
|
"bloom-vertical-0",
|
|
]),
|
|
);
|
|
processor.dispose();
|
|
processor.dispose();
|
|
resources.dispose();
|
|
|
|
expect(composer.addPass).toHaveBeenCalledTimes(3);
|
|
expect(composer.addPass).toHaveBeenLastCalledWith(outputPass);
|
|
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(outputPass.dispose).toHaveBeenCalledOnce();
|
|
expect(composer.dispose).toHaveBeenCalledOnce();
|
|
expect(bloomPass.strength).toBeGreaterThan(0.54);
|
|
});
|
|
|
|
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",
|
|
"car-contact-shadow",
|
|
]) {
|
|
expect(car.group.getObjectByName(name), name).toBeDefined();
|
|
}
|
|
|
|
const desktopCamera = createChaseCamera(16 / 9);
|
|
expect((desktopCamera as { fov: number }).fov).toBe(66);
|
|
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("wraps the authored GLB hierarchy without rebuilding it during animation", () => {
|
|
const resources = new SceneResourceBag();
|
|
const authored = new THREE.Group();
|
|
authored.name = "chase-car";
|
|
for (const name of [
|
|
"car-body",
|
|
"rear-glass",
|
|
"rear-light-bar",
|
|
"rear-diffuser",
|
|
"car-underglow-source",
|
|
]) {
|
|
const material = name === "rear-light-bar"
|
|
? new THREE.MeshStandardMaterial({ emissive: 0xff174f, emissiveIntensity: 8 })
|
|
: new THREE.MeshStandardMaterial();
|
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material);
|
|
mesh.name = name;
|
|
authored.add(mesh);
|
|
}
|
|
|
|
const car = createCar(resources, authored);
|
|
const childCount = (car.group as { children: unknown[] }).children.length;
|
|
expect(
|
|
(car.group.getObjectByName("grand-tourer-model") as { rotation: { y: number } })
|
|
.rotation.y,
|
|
).toBeCloseTo(Math.PI, 6);
|
|
expect(car.group.getObjectByName("car-body")).toBeDefined();
|
|
expect(
|
|
(car.group.getObjectByName("car-body") as {
|
|
material: { emissive: { getHex(): number } };
|
|
}).material.emissive.getHex(),
|
|
).toBe(0x0b2738);
|
|
expect(car.group.getObjectByName("rear-glass")).toBeDefined();
|
|
expect(car.group.getObjectByName("rear-light-bar")).toBeDefined();
|
|
expect(car.group.getObjectByName("car-underglow")).toBeDefined();
|
|
expect(car.group.getObjectByName("car-contact-shadow")).toBeDefined();
|
|
|
|
for (let frame = 0; frame < 60; frame += 1) {
|
|
car.update(0.8, 0.7, frame / 60);
|
|
}
|
|
expect((car.group as { children: unknown[] }).children).toHaveLength(childCount);
|
|
expect(resources.snapshot().owned.geometry).toBeGreaterThanOrEqual(6);
|
|
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 mounted roadside sign without mutating a visible label", () => {
|
|
const resources = new SceneResourceBag();
|
|
const sign = createSign(resources);
|
|
for (const name of [
|
|
"sign-backplate",
|
|
"sign-face",
|
|
"sign-housing",
|
|
"sign-edge-light",
|
|
"sign-post",
|
|
"sign-bracket",
|
|
"sign-footing",
|
|
]) {
|
|
expect(sign.group.getObjectByName(name), name).toBeDefined();
|
|
}
|
|
const face = sign.group.getObjectByName("sign-face") as {
|
|
userData: { textureUpdateCount: number };
|
|
material: { opacity: number };
|
|
geometry: { getAttribute(name: string): { array: ArrayLike<number> } };
|
|
position: { z: number };
|
|
};
|
|
const backplate = sign.group.getObjectByName("sign-backplate") as {
|
|
geometry: {
|
|
boundingBox: { max: { z: number } } | null;
|
|
computeBoundingBox(): void;
|
|
};
|
|
position: { z: number };
|
|
};
|
|
backplate.geometry.computeBoundingBox();
|
|
const backplateFront = backplate.position.z + (backplate.geometry.boundingBox?.max.z ?? 0);
|
|
expect(face.position.z - backplateFront).toBeGreaterThan(0.05);
|
|
const faceUvs = Array.from(face.geometry.getAttribute("uv").array);
|
|
expect(Math.min(...faceUvs)).toBeGreaterThanOrEqual(0);
|
|
expect(Math.max(...faceUvs)).toBeLessThanOrEqual(1);
|
|
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 visiblePosition = sign.group.position.clone();
|
|
const visibleScale = sign.group.scale.x;
|
|
sign.setViewportAspect(0.6);
|
|
sign.update({
|
|
targetId: "selected-builds",
|
|
phase: "dwell",
|
|
progress: 1,
|
|
opacity: 1,
|
|
});
|
|
expect(face.userData.textureUpdateCount).toBe(1);
|
|
expect(routeLabelFor("selected-builds")).toBe("Selected builds");
|
|
expect(sign.group.position).toEqual(visiblePosition);
|
|
expect(sign.group.scale.x).toBeCloseTo(visibleScale, 6);
|
|
|
|
sign.update(null);
|
|
sign.update({
|
|
targetId: "selected-builds",
|
|
phase: "anticipate",
|
|
progress: 0,
|
|
opacity: 0,
|
|
});
|
|
expect(face.userData.textureUpdateCount).toBe(2);
|
|
expect(resources.snapshot()).toEqual(allocation);
|
|
resources.dispose();
|
|
});
|
|
|
|
it("begins sign departure from the rendered approach pose without a teleport", () => {
|
|
const resources = new SceneResourceBag();
|
|
const sign = createSign(resources);
|
|
sign.setViewportAspect(16 / 9);
|
|
sign.update({
|
|
targetId: "profile",
|
|
phase: "approach",
|
|
progress: 0.62,
|
|
opacity: 1,
|
|
});
|
|
const approachPosition = sign.group.position.clone();
|
|
const approachScale = sign.group.scale.x;
|
|
|
|
sign.update({
|
|
targetId: "profile",
|
|
phase: "depart",
|
|
progress: 0,
|
|
opacity: 1,
|
|
});
|
|
|
|
expect(sign.group.position.distanceTo(approachPosition)).toBeLessThan(0.001);
|
|
expect(sign.group.scale.x).toBeCloseTo(approachScale, 6);
|
|
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; z: number }; max: { x: number; z: number } } | null;
|
|
computeBoundingBox(): void;
|
|
getAttribute(name: string): { count: number; array: ArrayLike<number> };
|
|
};
|
|
material: {
|
|
uniforms: Record<string, { value: unknown }>;
|
|
vertexShader: string;
|
|
fragmentShader: string;
|
|
depthTest: boolean;
|
|
};
|
|
}
|
|
|
|
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),
|
|
];
|
|
}
|