I warm the world's programs with renderer.compileAsync() at the end of the async build, with the composer's scene target bound so tone mapping and output colour space key the same programs the composer draws with, and with hidden subtrees revealed so the light count matches the frame-0 warm render. The wait is capped at 2.5 s; the warm render stays as the safety net for the post passes, texture uploads and anything a slow or extension-less driver left. Test builds report renderer.info.programs, and a browser spec pins zero program growth after frame 0. On the Intel ARL laptop the frame-0 task drops from ~1.74 s to ~0.34 s with a pixel-identical first frame; measurements in docs/overnight/compile-async-results.md.
640 lines
24 KiB
TypeScript
640 lines
24 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 { 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("updates the sun's palette and motion inputs without reallocating its disc and 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(resources.snapshot().owned.texture).toBe(1);
|
|
const allocation = resources.snapshot();
|
|
const color = disc.material.uniforms.uColor!.value as { getHex(): number };
|
|
const initialColor = color.getHex();
|
|
|
|
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);
|
|
expect(disc.material.uniforms.uTime?.value).toBe(3.2);
|
|
expect(disc.material.uniforms.uColor?.value).toBe(color);
|
|
expect(color.getHex()).not.toBe(initialColor);
|
|
expect(resources.snapshot()).toEqual(allocation);
|
|
resources.dispose();
|
|
});
|
|
|
|
it("builds deterministic stars, a palette-driven sky, 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,
|
|
);
|
|
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.frustumCulled).toBe(false);
|
|
expect(sky.material.depthTest).toBe(false);
|
|
const hazeColor = sky.material.uniforms.uHazeColor!.value as { getHex(): number };
|
|
const initialHaze = hazeColor.getHex();
|
|
expect(
|
|
(high.group.getObjectByName("distant-silhouettes") as { children: unknown[] })
|
|
.children,
|
|
).toHaveLength(3);
|
|
|
|
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(sky.material.uniforms.uHazeColor!.value).toBe(hazeColor);
|
|
expect(hazeColor.getHex()).toBe(palette.haze.getHex());
|
|
expect(hazeColor.getHex()).not.toBe(initialHaze);
|
|
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);
|
|
const restingBloom = { strength: bloomPass.strength, threshold: bloomPass.threshold };
|
|
processor.setDynamics(0.75);
|
|
expect(bloomPass.strength).toBeGreaterThan(restingBloom.strength);
|
|
expect(bloomPass.threshold).toBeLessThan(restingBloom.threshold);
|
|
processor.setDynamics(0);
|
|
expect(bloomPass.strength).toBe(restingBloom.strength);
|
|
expect(bloomPass.threshold).toBe(restingBloom.threshold);
|
|
processor.render({}, {});
|
|
// The shader warm-up binds the scene pass's target (EffectComposer's readBuffer).
|
|
expect(processor.sceneTarget()).toBe(composer.renderTarget2);
|
|
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();
|
|
});
|
|
|
|
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);
|
|
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");
|
|
// The live scene recenters and pulls the lens back on portrait displays.
|
|
// Use that settled framing fixture, not the initial desktop chase pose.
|
|
const portraitCamera = new THREE.PerspectiveCamera(61, 390 / 844, 0.1, 1200);
|
|
portraitCamera.position.set(0, 6.5, 31);
|
|
portraitCamera.lookAt(0, 3.5, -88);
|
|
portraitCamera.setViewOffset(390, 844, 0, -844 * 0.16, 390, 844);
|
|
const portrait = projectedBounds(car.group, portraitCamera);
|
|
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();
|
|
const body = car.group.getObjectByName("car-body") as {
|
|
material: { emissive: { getHex(): number }; emissiveIntensity: number };
|
|
};
|
|
expect(body.material.emissive.getHex()).not.toBe(0);
|
|
expect(body.material.emissiveIntensity).toBeGreaterThan(0);
|
|
expect(body.material.emissiveIntensity).toBeLessThan(1);
|
|
const material = body.material;
|
|
const allocation = resources.snapshot();
|
|
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(body.material).toBe(material);
|
|
expect(resources.snapshot()).toEqual(allocation);
|
|
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();
|
|
});
|
|
|
|
});
|
|
|
|
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),
|
|
];
|
|
}
|