/** * The environment rig: lifecycle, caching and the contract it must not break. * * There is no GL context under `node --test`, so the renderer here is a fake — * but a fake of a very specific kind. `PMREMGenerator` never touches WebGL * directly: it allocates plain `WebGLRenderTarget` objects, builds plain * meshes, and reaches the GPU only through `renderer.render`, * `renderer.setRenderTarget` and a handful of state accessors. Stubbing those * out runs the *real* generator, the real target allocation and the real * blur chain, and leaves only the pixels unwritten. So these tests exercise the * actual code path the browser takes, which is the difference between testing * the rig and testing a mock of it. * * The one assertion that needs the fake to be more than a no-op is the dispose * check: the render targets the rig allocates are private to it, so they are * captured on their way through `setRenderTarget` and identified afterwards by * the texture the scene ended up holding. */ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; import * as THREE from "three"; import { createEnvironmentRig } from "../../engine/environmentRig.ts"; import type { LightingState } from "../../engine/types.ts"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); // ---- A renderer that allocates but does not draw --------------------------- interface FakeRenderer { renders: number; targets: Set; failNext: boolean; as(): THREE.WebGLRenderer; } function fakeRenderer(): FakeRenderer { const targets = new Set(); const state = { renders: 0, targets, failNext: false, as(): THREE.WebGLRenderer { return stub as unknown as THREE.WebGLRenderer; }, }; const stub = { autoClear: true, toneMapping: THREE.NoToneMapping, xr: { enabled: false }, state: { buffers: { depth: { getReversed: () => false } } }, getRenderTarget: () => null, getActiveCubeFace: () => 0, getActiveMipmapLevel: () => 0, getClearColor: (target: THREE.Color) => target, getClearAlpha: () => 1, setClearColor: () => {}, setClearAlpha: () => {}, clearDepth: () => {}, compile: () => {}, setRenderTarget(target: THREE.WebGLRenderTarget | null) { if (target) targets.add(target); }, render() { if (state.failNext) { state.failNext = false; throw new Error("simulated context loss"); } state.renders++; }, }; return state; } // ---- Lighting states ------------------------------------------------------- function lightingState(overrides: Partial = {}): LightingState { return { sun: { direction: [0.31, 0.86, 0.4], color: 0xfff3e0, intensity: 2.35 }, hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 0.92 }, ambient: { color: 0xffffff, intensity: 0.24 }, sky: { top: 0x8fb8d8, horizon: 0xd9e6ee }, fog: { color: 0xd9e6ee, near: 210, far: 460 }, ...overrides, }; } /** The render target the scene's environment texture came out of. */ function targetOf(fake: FakeRenderer, scene: THREE.Scene): THREE.WebGLRenderTarget { for (const target of fake.targets) { if (target.texture === scene.environment) return target; } throw new Error("no allocated render target owns the scene's environment texture"); } // ---- Lifecycle ------------------------------------------------------------- for (const kind of ["office", "city"] as const) { test(`apply(..., "${kind}") leaves a texture on the scene and dispose frees it`, () => { const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const scene = new THREE.Scene(); rig.apply(scene, lightingState(), kind); assert.ok( scene.environment instanceof THREE.Texture, "apply must leave a real texture on the scene, not a placeholder", ); assert.equal(scene.environmentIntensity, 1); assert.ok(fake.renders > 0, "the PMREM chain never rendered"); // The PMREM target, identified by the texture the scene is holding. const target = targetOf(fake, scene); let freed = false; target.addEventListener("dispose", () => { freed = true; }); rig.dispose(); assert.equal(scene.environment, null, "dispose must take the environment back off the scene"); assert.ok(freed, "dispose must free the PMREM render target, not just drop the reference"); }); } test("apply adds nothing to the scene graph", () => { const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const scene = new THREE.Scene(); scene.add(new THREE.Object3D()); rig.apply(scene, lightingState(), "office"); rig.apply(scene, lightingState(), "city"); // The environment is a property, not a child. A rig that parented its probe // room into the caller's scene would light the office with a nine-quad box // floating inside it. assert.equal(scene.children.length, 1); rig.dispose(); }); test("dispose clears every scene the rig ever wrote to", () => { const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const a = new THREE.Scene(); const b = new THREE.Scene(); rig.apply(a, lightingState(), "city"); rig.apply(b, lightingState(), "city"); assert.ok(a.environment); assert.ok(b.environment); rig.dispose(); assert.equal(a.environment, null); assert.equal(b.environment, null); }); test("apply after dispose is inert rather than fatal", () => { const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const scene = new THREE.Scene(); rig.dispose(); rig.apply(scene, lightingState(), "city"); assert.equal(scene.environment, null); rig.dispose(); }); // ---- Caching --------------------------------------------------------------- test("an unchanged lighting state does not rebuild", () => { const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const scene = new THREE.Scene(); rig.apply(scene, lightingState(), "city"); const first = scene.environment; const after = fake.renders; for (let i = 0; i < 20; i++) rig.apply(scene, lightingState(), "city"); assert.equal(scene.environment, first, "the environment texture was replaced for no reason"); assert.equal(fake.renders, after, "the PMREM chain ran again for an unchanged sky"); rig.dispose(); }); test("a change below the quantisation step does not rebuild", () => { const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const scene = new THREE.Scene(); rig.apply(scene, lightingState(), "city"); const first = scene.environment; // `Atmosphere` interpolates continuously, so every field of a LightingState // moves a fraction every frame. Rebuilding on that would run the whole chain // sixty times a second to produce sixty indistinguishable environments. rig.apply( scene, lightingState({ sun: { direction: [0.3104, 0.8601, 0.4002], color: 0xfff3e1, intensity: 2.352 }, }), "city", ); assert.equal(scene.environment, first); rig.dispose(); }); test("a real change of hour rebuilds, and frees what it replaced", () => { const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const scene = new THREE.Scene(); rig.apply(scene, lightingState(), "city"); const noon = targetOf(fake, scene); let freed = false; noon.addEventListener("dispose", () => { freed = true; }); rig.apply( scene, lightingState({ sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 }, sky: { top: 0x2a4275, horizon: 0x9a6a63 }, }), "city", ); assert.notEqual(scene.environment, noon.texture, "sunset must not reflect noon's sky"); assert.ok(freed, "the replaced PMREM target leaked"); rig.dispose(); }); test("city and office are cached separately and do not evict each other", () => { const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const city = new THREE.Scene(); const office = new THREE.Scene(); rig.apply(city, lightingState(), "city"); const cityEnv = city.environment; rig.apply(office, lightingState(), "office"); const officeEnv = office.environment; assert.notEqual(cityEnv, officeEnv, "a room and a sky must not be the same environment"); const before = fake.renders; rig.apply(city, lightingState(), "city"); assert.equal(city.environment, cityEnv); assert.equal(fake.renders, before, "the office build evicted the city's cached environment"); rig.dispose(); }); test("rebuilding one kind does not repoint scenes on the other", () => { // A page holds both at once — CONTRACT.md §1 keeps the city alive and paused // while an office is on screen — so the loop that repoints scenes after a // rebuild has to know which kind each scene is on. Getting this wrong lights // an office through a wall with the city's sunset. const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const city = new THREE.Scene(); const office = new THREE.Scene(); rig.apply(city, lightingState(), "city"); rig.apply(office, lightingState(), "office"); const officeEnv = office.environment; rig.apply( city, lightingState({ sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 }, sky: { top: 0x2a4275, horizon: 0x9a6a63 }, }), "city", ); assert.equal(office.environment, officeEnv, "the office was handed the city's new sky"); assert.notEqual(city.environment, officeEnv); rig.dispose(); }); test("a scene left holding a freed target is repointed at the replacement", () => { // The other half of the same loop: two city scenes, one rebuild, and the one // that did not ask must not be left pointing at a disposed render target. const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const a = new THREE.Scene(); const b = new THREE.Scene(); rig.apply(a, lightingState(), "city"); rig.apply(b, lightingState(), "city"); rig.apply( a, lightingState({ sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 } }), "city", ); assert.equal(b.environment, a.environment, "the second city scene kept a freed texture"); rig.dispose(); }); // ---- Degradation ----------------------------------------------------------- test("a renderer that throws costs the environment, not the frame", () => { const fake = fakeRenderer(); const rig = createEnvironmentRig(fake.as()); const scene = new THREE.Scene(); const warnings: unknown[] = []; const warn = console.warn; console.warn = (...args: unknown[]) => warnings.push(args); try { fake.failNext = true; rig.apply(scene, lightingState(), "city"); } finally { console.warn = warn; } assert.equal(scene.environment, null, "a failed build must not leave a half-made environment"); assert.equal(warnings.length, 1, "a missing environment is undiagnosable from the picture alone"); // And it recovers: the next apply tries again rather than latching off. rig.apply(scene, lightingState(), "city"); const recovered: unknown = scene.environment; assert.ok(recovered instanceof THREE.Texture); rig.dispose(); }); // ---- CONTRACT §4 ----------------------------------------------------------- test("the rig constructs no light of any kind", () => { // The literal form of the rule, checked against the source, because the whole // value of CONTRACT.md §4 is that there is exactly one light owner and this is // the module most likely to be tempted into becoming a second one. const source = readFileSync(path.join(ROOT, "src/engine/environmentRig.ts"), "utf8"); const lights = source.match( /new\s+THREE\.(Directional|Point|Spot|Rect(Area)?|Hemisphere|Ambient|Light\b)/g, ); assert.equal(lights, null, `environmentRig constructs lights: ${lights?.join(", ")}`); // And no import of three's fixed studio room either — the office probe has to // stay derived from the LightingState, or the reflections stop knowing what // time it is. (The prose above the code discusses `RoomEnvironment` by name, // hence matching the import statement rather than the word.) assert.doesNotMatch(source, /^\s*import[^\n]*RoomEnvironment/m); });