feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Test home for the `render` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 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<THREE.WebGLRenderTarget>;
|
||||
failNext: boolean;
|
||||
as(): THREE.WebGLRenderer;
|
||||
}
|
||||
|
||||
function fakeRenderer(): FakeRenderer {
|
||||
const targets = new Set<THREE.WebGLRenderTarget>();
|
||||
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> = {}): 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);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* The material roles, and the two new texture channels they bind.
|
||||
*
|
||||
* Four roles land here that three other workstreams are waiting on —
|
||||
* `deviceShell`, `deviceMesh`, `deviceIndicator`, `screenContent` — plus the
|
||||
* `alphaMap` wiring that turns `foliage` from a flat green shard into a leaf.
|
||||
* The assertions are deliberately about *bindings* rather than about numbers:
|
||||
* a roughness value is a judgement and will be re-tuned, but a `screenContent`
|
||||
* that has stopped carrying an `emissiveMap` is a screen that has gone back to
|
||||
* being a lamp, and a `foliage` with no `alphaMap` is the worst-looking asset in
|
||||
* the product returning.
|
||||
*
|
||||
* The registry is fed a stub `TextureBin` throughout. `node --test` has no
|
||||
* canvas, so the real bin correctly returns `null` for everything (see its own
|
||||
* comment about the zero-config boot), and a null map cannot demonstrate that
|
||||
* the map was bound to the right slot. The stub hands back real
|
||||
* `THREE.Texture`s named after what was asked for, so every assertion below is
|
||||
* about the code in `MaterialRegistry.create`, which is the code under test.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { MaterialRegistry, type SurfaceRole } from "../../assets/materials.ts";
|
||||
import {
|
||||
DEFAULT_INTERIOR_PALETTE,
|
||||
LIGHTNESS_HEADROOM,
|
||||
ROLE_SHIFTS,
|
||||
derivePalette,
|
||||
} from "../../assets/palette.ts";
|
||||
import {
|
||||
NORMAL_MAP_KINDS,
|
||||
SCREEN_UI_VARIANTS,
|
||||
TextureBin,
|
||||
type TextureKind,
|
||||
} from "../../assets/textures.ts";
|
||||
|
||||
/** A bin that draws nothing but answers as though it had. */
|
||||
class StubBin extends TextureBin {
|
||||
readonly asked: string[] = [];
|
||||
|
||||
override get(kind: TextureKind, variant = 0): THREE.Texture | null {
|
||||
this.asked.push(`${kind}#${variant}`);
|
||||
const texture = new THREE.Texture();
|
||||
texture.name = `${kind}#${variant}`;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubbed for the same reason `get` is, with one difference that matters: the
|
||||
* real `normal()` needs no canvas and would work here, but it would also build
|
||||
* six 512² fields per registry. What is under test is the *binding*, so the
|
||||
* stub keeps the one property an assertion can hang off — relief exists for
|
||||
* exactly the kinds that have a recipe and for no others.
|
||||
*/
|
||||
override normal(kind: TextureKind): THREE.Texture | null {
|
||||
if (!NORMAL_MAP_KINDS.includes(kind)) return null;
|
||||
this.asked.push(`${kind}!normal`);
|
||||
const texture = new THREE.Texture();
|
||||
texture.name = `${kind}!normal`;
|
||||
return texture;
|
||||
}
|
||||
|
||||
override variants(kind: TextureKind): number {
|
||||
return kind === "screenUI" ? SCREEN_UI_VARIANTS : 1;
|
||||
}
|
||||
}
|
||||
|
||||
function registry(quality: "low" | "medium" | "high" = "high"): {
|
||||
materials: MaterialRegistry;
|
||||
bin: StubBin;
|
||||
} {
|
||||
const bin = new StubBin(quality);
|
||||
return { materials: new MaterialRegistry({ quality, textures: bin }), bin };
|
||||
}
|
||||
|
||||
const NEW_ROLES: SurfaceRole[] = ["deviceShell", "deviceMesh", "deviceIndicator", "screenContent"];
|
||||
|
||||
// ---- The published names ---------------------------------------------------
|
||||
|
||||
test("the registry exposes the four device roles", () => {
|
||||
const { materials } = registry();
|
||||
for (const role of NEW_ROLES) {
|
||||
const material = materials.get(role);
|
||||
assert.ok(material instanceof THREE.Material, `${role} did not resolve to a material`);
|
||||
assert.equal(material.name, role);
|
||||
// One material per role per registry — the sharing that is half the
|
||||
// draw-call budget.
|
||||
assert.equal(materials.get(role), material);
|
||||
}
|
||||
});
|
||||
|
||||
test("every new role carries a palette derivation", () => {
|
||||
// The compiler enforces this for `ROLE_SHIFTS`, but not that the shift
|
||||
// actually produced a colour, and a role missing from the derived palette
|
||||
// would silently construct a material with `color: undefined` (black).
|
||||
for (const role of NEW_ROLES) {
|
||||
assert.ok(role in ROLE_SHIFTS, `${role} has no shift`);
|
||||
const color = DEFAULT_INTERIOR_PALETTE[role];
|
||||
assert.equal(typeof color, "number");
|
||||
assert.ok(color >= 0 && color <= 0xffffff);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- What each new role is -------------------------------------------------
|
||||
|
||||
test("the device shell and grille are metal, which they can now afford to be", () => {
|
||||
const { materials } = registry();
|
||||
const shell = materials.get("deviceShell") as THREE.MeshStandardMaterial;
|
||||
const mesh = materials.get("deviceMesh") as THREE.MeshStandardMaterial;
|
||||
|
||||
assert.ok(shell.metalness > 0.15, "a device body with no metalness reads as painted plastic");
|
||||
assert.ok(mesh.metalness > 0.6, "a speaker grille is metal");
|
||||
// Double-sided: you see through a grille to the inside of the housing, and
|
||||
// that is most of what makes a speaker look like a speaker.
|
||||
assert.equal(mesh.side, THREE.DoubleSide);
|
||||
assert.equal(shell.side, THREE.FrontSide);
|
||||
});
|
||||
|
||||
test("the indicator emits, and a tint carries into the emission", () => {
|
||||
const { materials } = registry();
|
||||
const led = materials.get("deviceIndicator") as THREE.MeshStandardMaterial;
|
||||
assert.equal(led.emissiveIntensity, 1, "an LED is a light source, not a lit surface");
|
||||
assert.notEqual(led.emissive.getHex(), 0x000000);
|
||||
|
||||
// The device render layer tints this per state. That path must reach the
|
||||
// emissive term, or a powered mic and an unpowered one glow the same colour.
|
||||
const hot = materials.tinted("deviceIndicator", 0xff3a1e) as THREE.MeshStandardMaterial;
|
||||
assert.notEqual(hot, led);
|
||||
assert.equal(hot.color.getHex(THREE.SRGBColorSpace), 0xff3a1e);
|
||||
assert.equal(hot.emissive.getHex(THREE.SRGBColorSpace), 0xff3a1e);
|
||||
// Cached: a hundred indicators in three states are three materials.
|
||||
assert.equal(materials.tinted("deviceIndicator", 0xff3a1e), hot);
|
||||
});
|
||||
|
||||
test("screen content is lit through its own map, not flat across the panel", () => {
|
||||
const { materials } = registry();
|
||||
const screen = materials.get("screenContent") as THREE.MeshStandardMaterial;
|
||||
|
||||
assert.ok(screen.map, "screenContent must carry the drawn interface as its colour map");
|
||||
assert.equal(screen.map?.name, "screenUI#0");
|
||||
assert.ok(screen.emissiveMap, "screenContent must emit through the map, or it is a light box");
|
||||
assert.equal(screen.emissiveMap, screen.map, "the emissive map must be the same drawing");
|
||||
// White emissive, so the drawn colours are not tinted a second time on top of
|
||||
// `color` already tinting them.
|
||||
assert.equal(screen.emissive.getHex(), 0xffffff);
|
||||
assert.ok(screen.emissiveIntensity > 0.5);
|
||||
|
||||
// And the palette gets out of the map's way: this is the one non-neutral
|
||||
// texture in the library, so a mid-grey role colour would multiply it to mud.
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
new THREE.Color(DEFAULT_INTERIOR_PALETTE.screenContent).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
assert.ok(hsl.l > 0.8, `screenContent is L=${hsl.l.toFixed(2)}, too dark to pass a drawing through`);
|
||||
});
|
||||
|
||||
test("screen variants are separate cached materials, and variant 0 is the base", () => {
|
||||
const { materials } = registry();
|
||||
const base = materials.get("screenContent");
|
||||
|
||||
assert.equal(materials.variant("screenContent", 0), base, "variant 0 must not mint a duplicate");
|
||||
|
||||
const seen = new Set<THREE.Material>();
|
||||
for (let i = 0; i < SCREEN_UI_VARIANTS; i++) seen.add(materials.variant("screenContent", i));
|
||||
assert.equal(seen.size, SCREEN_UI_VARIANTS, "layouts collapsed onto the same material");
|
||||
|
||||
// Cached, and wrapping rather than throwing: `furnish.ts` batches per kind, so
|
||||
// a caller hands this a running prop index and must not have to bound it.
|
||||
assert.equal(
|
||||
materials.variant("screenContent", 2),
|
||||
materials.variant("screenContent", 2 + SCREEN_UI_VARIANTS),
|
||||
);
|
||||
assert.equal(materials.variant("screenContent", -1), materials.variant("screenContent", SCREEN_UI_VARIANTS - 1));
|
||||
|
||||
// A role with one layout ignores the index entirely.
|
||||
assert.equal(materials.variant("carpet", 3), materials.get("carpet"));
|
||||
});
|
||||
|
||||
// ---- The alpha channel -----------------------------------------------------
|
||||
|
||||
test("foliage is a cutout, not a rectangle", () => {
|
||||
const { materials } = registry();
|
||||
const leaf = materials.get("foliage") as THREE.MeshStandardMaterial;
|
||||
|
||||
assert.ok(leaf.alphaMap, "foliage without an alphaMap is the flat green shard on the live site");
|
||||
assert.equal(leaf.alphaMap?.name, "leafAlpha#0");
|
||||
assert.ok(leaf.alphaTest > 0.2 && leaf.alphaTest < 0.8, `alphaTest ${leaf.alphaTest} is at an edge of the range`);
|
||||
// Cutout, not blend: a leaf still writes depth, sorts as solid geometry and
|
||||
// casts a correctly-shaped shadow.
|
||||
assert.equal(leaf.transparent, false);
|
||||
assert.equal(leaf.depthWrite, true);
|
||||
assert.equal(leaf.side, THREE.DoubleSide);
|
||||
});
|
||||
|
||||
test("a role with no alpha texture is not given an alphaTest", () => {
|
||||
const { materials } = registry();
|
||||
const carpet = materials.get("carpet") as THREE.MeshStandardMaterial;
|
||||
assert.equal(carpet.alphaMap, null);
|
||||
// A threshold with no map to test against compiles a branch into the shader
|
||||
// for a comparison that always passes.
|
||||
assert.equal(carpet.alphaTest, 0);
|
||||
});
|
||||
|
||||
test("the leaf keeps its shape when it is ghosted", () => {
|
||||
const { materials } = registry();
|
||||
const ghost = materials.ghostOf("foliage");
|
||||
// The colour map is decoration and is dropped on purpose; the coverage map is
|
||||
// *shape*, and a ghost with no cutout is the shard again at 18% opacity.
|
||||
assert.equal(ghost.map, null);
|
||||
assert.ok(ghost.alphaMap, "ghostOf must not drop the coverage map with the colour map");
|
||||
assert.equal(ghost.transparent, true);
|
||||
});
|
||||
|
||||
test("low quality still cuts the leaf out", () => {
|
||||
// `low` means no *shading* maps. An alpha cutout is one fetch and a discard,
|
||||
// and the alternative at low quality is not a cheaper plant, it is a shard.
|
||||
const { materials } = registry("low");
|
||||
const leaf = materials.get("foliage") as THREE.MeshLambertMaterial;
|
||||
assert.ok(leaf instanceof THREE.MeshLambertMaterial, "low quality must stay Lambert");
|
||||
assert.ok(leaf.alphaMap);
|
||||
assert.ok(leaf.alphaTest > 0);
|
||||
});
|
||||
|
||||
// ---- The widened lightness band --------------------------------------------
|
||||
|
||||
test("the band was widened, and the darkest roles actually went darker", () => {
|
||||
assert.ok(LIGHTNESS_HEADROOM > 0.14, "the headroom was not relaxed");
|
||||
|
||||
const palette = derivePalette();
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
const lightnessOf = (role: SurfaceRole) => {
|
||||
new THREE.Color(palette[role]).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
return hsl.l;
|
||||
};
|
||||
|
||||
// Under the old 0.14 headroom the floor of the band was L≈0.248 and these
|
||||
// three were all clamped to it, which is why a screen bezel was a mid-grey.
|
||||
for (const role of ["screenBezel", "deviceShell", "deviceMesh"] as SurfaceRole[]) {
|
||||
const l = lightnessOf(role);
|
||||
assert.ok(l < 0.248, `${role} is L=${l.toFixed(3)}, still clamped up into mid-grey`);
|
||||
assert.ok(l > 0.05, `${role} is L=${l.toFixed(3)}, past charcoal into black`);
|
||||
}
|
||||
|
||||
// Saturation gets no concession, and widening the lightness band must not
|
||||
// have quietly widened that too: every role stays inside the city's own
|
||||
// saturation range.
|
||||
const city = Object.values(palette).map((hex) => {
|
||||
new THREE.Color(hex).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
return hsl.s;
|
||||
});
|
||||
assert.ok(Math.max(...city) <= 0.6, "an interior role has become more chromatic than the city");
|
||||
});
|
||||
|
||||
// ---- Glass -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The one role that changes material *class* with quality.
|
||||
*
|
||||
* A 22%-opacity blend is a grey film. Transmission is glass: it refracts what
|
||||
* is behind it, keeps a specular highlight and an environment reflection on top,
|
||||
* and turns `roughness` into frosting instead of into a matte grey. What must
|
||||
* survive the change is the note at the role itself — glass writes no depth —
|
||||
* because three.js's own advice for a transmissive material is the opposite,
|
||||
* and an office is a box of glass boxes where the first sheet the sorter reaches
|
||||
* would erase the two behind it.
|
||||
*/
|
||||
test("glazing at medium and high is physical glass that still writes no depth", () => {
|
||||
for (const quality of ["medium", "high"] as const) {
|
||||
const { materials } = registry(quality);
|
||||
const glass = materials.get("glazing");
|
||||
assert.ok(
|
||||
glass instanceof THREE.MeshPhysicalMaterial,
|
||||
`glazing at ${quality} is ${glass.type}, not physical glass`,
|
||||
);
|
||||
const physical = glass as THREE.MeshPhysicalMaterial;
|
||||
assert.ok(physical.transmission > 0, "glazing has no transmission");
|
||||
assert.equal(physical.ior, 1.5, "glazing is no longer soda-lime glass");
|
||||
assert.ok(physical.thickness > 0, "glazing has no thickness to refract through");
|
||||
// The line the spec asked to be kept, kept.
|
||||
assert.equal(physical.depthWrite, false, "glass started writing depth");
|
||||
// Transmission carries the see-through; blending it as well would leave the
|
||||
// sheet four fifths invisible and refracting the fifth that was left.
|
||||
assert.equal(physical.transparent, false);
|
||||
assert.equal(physical.opacity, 1);
|
||||
// three.js scales transmission by `1 - metalness`, so any metalness at all
|
||||
// is that fraction of the glass quietly turned back into a mirror.
|
||||
assert.equal(physical.metalness, 0);
|
||||
assert.equal(physical.side, THREE.DoubleSide);
|
||||
}
|
||||
});
|
||||
|
||||
test("glazing at low falls back to a blended sheet rather than to nothing", () => {
|
||||
const { materials } = registry("low");
|
||||
const glass = materials.get("glazing");
|
||||
// `low` is the integrated-GPU setting and a transmission pass is a full
|
||||
// render-target copy, so the blend has to stay reachable — and it is still a
|
||||
// window, so it still must not write depth.
|
||||
assert.ok(glass instanceof THREE.MeshLambertMaterial);
|
||||
assert.equal(glass.transparent, true);
|
||||
assert.ok(glass.opacity < 0.5);
|
||||
assert.equal(glass.depthWrite, false);
|
||||
});
|
||||
|
||||
test("a ghosted sheet of glass stops refracting", () => {
|
||||
const { materials } = registry("high");
|
||||
const ghost = materials.ghostOf("glazing") as THREE.MeshPhysicalMaterial;
|
||||
// The occlusion fade is a hint and is rebuilt as the camera moves; putting it
|
||||
// through the transmission pass buys nothing and costs a target copy.
|
||||
assert.equal(ghost.transmission, 0);
|
||||
assert.equal(ghost.transparent, true);
|
||||
assert.equal(ghost.depthWrite, false);
|
||||
});
|
||||
|
||||
// ---- The relief channel ----------------------------------------------------
|
||||
|
||||
test("every role with a texture also carries its relief", () => {
|
||||
const { materials } = registry("high");
|
||||
// Six kinds have relief; a whiteboard and a screen are flat. A role gets the
|
||||
// relief of its own texture or nothing — there is no third option, and no role
|
||||
// opts in separately.
|
||||
const expected: [SurfaceRole, boolean][] = [
|
||||
["carpet", true],
|
||||
["woodFloor", true],
|
||||
["tile", true],
|
||||
["ceilingTile", true],
|
||||
["plaster", true],
|
||||
["chairFabric", true],
|
||||
["whiteboard", false],
|
||||
["screenContent", false],
|
||||
["metalTrim", false],
|
||||
["deviceShell", false],
|
||||
];
|
||||
for (const [role, hasRelief] of expected) {
|
||||
const material = materials.get(role) as THREE.MeshStandardMaterial;
|
||||
if (hasRelief) {
|
||||
assert.ok(material.normalMap, `${role} lost its normal map`);
|
||||
assert.match(material.normalMap.name, /!normal$/);
|
||||
} else {
|
||||
assert.equal(material.normalMap, null, `${role} gained relief it has no surface for`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("relief follows a tint and a variant, because it is the same surface", () => {
|
||||
const { materials } = registry("high");
|
||||
const tinted = materials.tinted("carpet", 0x884422) as THREE.MeshStandardMaterial;
|
||||
assert.ok(tinted.normalMap, "a recoloured carpet lost its pile");
|
||||
const variant = materials.variant("screenContent", 3) as THREE.MeshStandardMaterial;
|
||||
assert.equal(variant.normalMap, null, "a screen layout grew relief");
|
||||
});
|
||||
|
||||
test("low quality binds no relief at all", () => {
|
||||
const { materials } = registry("low");
|
||||
const carpet = materials.get("carpet") as THREE.MeshLambertMaterial;
|
||||
// `low` exists to compile the cheap shader. A normal map is a fetch and a
|
||||
// matrix multiply per fragment, which is exactly the cost it is refusing.
|
||||
assert.ok(carpet instanceof THREE.MeshLambertMaterial);
|
||||
assert.equal(carpet.normalMap, null);
|
||||
});
|
||||
|
||||
test("a ghost drops the relief with the colour, for the same reason", () => {
|
||||
const { materials } = registry("high");
|
||||
const ghost = materials.ghostOf("carpet") as THREE.MeshStandardMaterial;
|
||||
assert.equal(ghost.map, null);
|
||||
assert.equal(ghost.normalMap, null, "an 82%-transparent surface is still being bumped");
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The draw-call reclaim in `engine/structures.ts`.
|
||||
*
|
||||
* These are budget tests, and they are here because the budget is the reason
|
||||
* anything else in this build can be made to look better. The city measured 616
|
||||
* draw calls against a cap of 650 while the office spent 8% of its triangle
|
||||
* allowance: indoors quality is nearly free, outdoors it is not, and every call
|
||||
* this module gives back is one the exterior Model X and the aircraft get to
|
||||
* spend. `scripts/performance-budget.mjs` is the real gate, but it needs a
|
||||
* built bundle, a browser and eleven seconds a cell — these run in
|
||||
* milliseconds and fail on the line that caused the regression.
|
||||
*
|
||||
* Two invariants, and they are the two ways this file has gone wrong before:
|
||||
*
|
||||
* 1. **A material is per colour, not per call site.** `roadRibbon` used to
|
||||
* close over `new THREE.MeshLambertMaterial({ color })`, so twelve
|
||||
* identical asphalt decks were twelve materials — and two meshes that do
|
||||
* not share a material can never be merged, whatever else you do.
|
||||
* 2. **Geometry is merged per bucket.** A suspension bridge used to arrive as
|
||||
* about thirty-four meshes of one colour.
|
||||
*
|
||||
* There is a third thing the tests below quietly guard, and it is the one that
|
||||
* fails silently: `mergeGeometries` returns `null` when the attribute sets
|
||||
* disagree, so a ribbon without UVs sitting in a bucket beside a tube that has
|
||||
* them loses the whole bucket. Asserting on merged vertex counts is what catches
|
||||
* that, because a dropped bucket looks exactly like a very efficient one.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createBridge, createBridges, createRoads } from "../../engine/structures.ts";
|
||||
import type { Bridge, City, Road } from "../../engine/types.ts";
|
||||
import type { World } from "../../engine/world.ts";
|
||||
|
||||
/**
|
||||
* The smallest thing `structures.ts` will accept: a flat projection, ground at
|
||||
* zero, and metres straight through.
|
||||
*
|
||||
* A real `World` builds a heightfield, which is 0.53M lattice points and a
|
||||
* couple of seconds — none of which any assertion here depends on.
|
||||
*/
|
||||
function flatWorld(city: Partial<City>): World {
|
||||
return {
|
||||
city: { roads: [], bridges: [], inlandWater: [], ...city } as unknown as City,
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng + 122) * 20, -(lat - 37) * 20];
|
||||
},
|
||||
groundAt(): number {
|
||||
return 0;
|
||||
},
|
||||
metres(value: number): number {
|
||||
return value / 100;
|
||||
},
|
||||
} as unknown as World;
|
||||
}
|
||||
|
||||
const GOLDEN_GATE: Bridge = {
|
||||
name: "golden-gate",
|
||||
path: [
|
||||
[37.806, -122.4756],
|
||||
[37.8199, -122.4783],
|
||||
[37.8324, -122.4796],
|
||||
],
|
||||
towers: [
|
||||
[37.8104, -122.4767],
|
||||
[37.8249, -122.4787],
|
||||
],
|
||||
deckHeight: 67,
|
||||
towerHeight: 227,
|
||||
sag: 0.45,
|
||||
color: 0xc0553b,
|
||||
};
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function materialsIn(root: THREE.Object3D): Set<THREE.Material> {
|
||||
const set = new Set<THREE.Material>();
|
||||
for (const mesh of meshes(root)) {
|
||||
if (Array.isArray(mesh.material)) for (const material of mesh.material) set.add(material);
|
||||
else set.add(mesh.material);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// ---- Bridges ---------------------------------------------------------------
|
||||
|
||||
test("a suspension bridge is one material and one draw call", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
|
||||
// The spec's number is six; a bridge is painted one colour throughout, so
|
||||
// anything above one is a part that was left out of the bucket.
|
||||
const distinct = materialsIn(bridge);
|
||||
assert.ok(distinct.size <= 6, `the bridge holds ${distinct.size} materials`);
|
||||
assert.equal(distinct.size, 1, `the bridge holds ${distinct.size} materials, not one`);
|
||||
assert.equal(meshes(bridge).length, 1, "the bridge did not merge into one mesh");
|
||||
});
|
||||
|
||||
test("merging kept every part of the bridge", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
const merged = meshes(bridge)[0];
|
||||
assert.ok(merged);
|
||||
|
||||
// The arithmetic, because a bucket that failed to merge comes out as one
|
||||
// *span* of geometry and otherwise looks entirely healthy: a 3-point deck tube
|
||||
// is 7 × 5 = 35 vertices, two towers and four braces are 24 each = 144, three
|
||||
// cable spans at 25 × 6 = 450, and the hangers are 24 boxes of 24 less
|
||||
// whichever ones the deck-clearance test culls — call it 1,000 at the floor.
|
||||
const vertices = merged.geometry.getAttribute("position").count;
|
||||
assert.ok(vertices > 1_000, `the bridge merged down to ${vertices} vertices`);
|
||||
|
||||
// The merge only happens because every part carries the same attributes.
|
||||
for (const name of ["position", "normal", "uv"]) {
|
||||
assert.ok(merged.geometry.getAttribute(name), `the merged bridge has no ${name}`);
|
||||
}
|
||||
assert.ok(merged.geometry.getIndex(), "the merged bridge lost its index");
|
||||
|
||||
// A 227 m tower is the tallest thing on the board; it has to cast.
|
||||
assert.equal(merged.castShadow, true);
|
||||
});
|
||||
|
||||
test("the bridge is still shaped like a bridge after the merge", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
const merged = meshes(bridge)[0];
|
||||
assert.ok(merged);
|
||||
merged.geometry.computeBoundingBox();
|
||||
const box = merged.geometry.boundingBox;
|
||||
assert.ok(box);
|
||||
|
||||
// Towers to 2.27 units, deck at 0.67, cables sagging between. Baking the
|
||||
// transforms into the geometry is where a merge goes wrong — a part that lost
|
||||
// its translation collapses onto the origin and the box stops matching.
|
||||
assert.ok(Math.abs(box.max.y - 2.27) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
|
||||
assert.ok(box.min.y > 0, "something sank below the water line");
|
||||
assert.ok(box.max.x - box.min.x > 0.4, "the bridge has no span");
|
||||
});
|
||||
|
||||
test("two bridges are two draw calls, not sixty-eight", () => {
|
||||
const second: Bridge = { ...GOLDEN_GATE, name: "bay-bridge", color: 0x9aa6ad };
|
||||
const group = createBridges(flatWorld({ bridges: [GOLDEN_GATE, second] }));
|
||||
assert.equal(meshes(group).length, 2);
|
||||
// Different colours, so genuinely two materials. Each bridge builds its own
|
||||
// batch, which is deliberate: the cache cannot outlive the build, because
|
||||
// `createScene().dispose()` walks the scene disposing every material it finds
|
||||
// and a shared cache would hand the next board a disposed one.
|
||||
assert.equal(materialsIn(group).size, 2);
|
||||
});
|
||||
|
||||
// ---- Roads -----------------------------------------------------------------
|
||||
|
||||
test("identical roads share one material and one mesh", () => {
|
||||
const street: Road = {
|
||||
kind: "street",
|
||||
width: 0.1,
|
||||
path: [
|
||||
[37.7, -122.4],
|
||||
[37.75, -122.42],
|
||||
[37.8, -122.45],
|
||||
],
|
||||
};
|
||||
const group = createRoads(flatWorld({ roads: [street, street, street] }));
|
||||
|
||||
// Three streets, one colour: one draw call. Before the cache this was three
|
||||
// materials and three meshes, and it scaled with the pack.
|
||||
assert.equal(materialsIn(group).size, 1);
|
||||
assert.equal(meshes(group).length, 1);
|
||||
|
||||
const merged = meshes(group)[0];
|
||||
assert.ok(merged);
|
||||
// All three really are in there — three drapes of the same path.
|
||||
const vertices = merged.geometry.getAttribute("position").count;
|
||||
assert.ok(vertices > 100, `three roads merged to ${vertices} vertices`);
|
||||
assert.ok(merged.geometry.getAttribute("uv"), "the road deck lost the UVs merging depends on");
|
||||
});
|
||||
|
||||
test("a freeway keeps its median stroke as a second material", () => {
|
||||
const freeway: Road = {
|
||||
kind: "freeway",
|
||||
width: 0.14,
|
||||
path: [
|
||||
[37.7, -122.4],
|
||||
[37.9, -122.45],
|
||||
],
|
||||
};
|
||||
const group = createRoads(flatWorld({ roads: [freeway] }));
|
||||
// Two colours is two calls, and that is the floor rather than a regression:
|
||||
// the stroke is a different colour from the deck it sits on.
|
||||
assert.equal(meshes(group).length, 2);
|
||||
assert.equal(materialsIn(group).size, 2);
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* The two new texture kinds, and the bin that parameterises them.
|
||||
*
|
||||
* Drawing is done against a recording 2D context rather than a real one. There
|
||||
* is no canvas under `node --test`, and a real rasteriser would only let these
|
||||
* tests assert about pixels — which is a picture, which is exactly the thing
|
||||
* nobody should be asserting equality on. What *can* be pinned, and matters, is
|
||||
* the structure: that six genuinely different layouts exist rather than one
|
||||
* drawn six times, that the same variant is byte-identical run to run (the
|
||||
* drawings are seeded, and a `Math.random` slipping in would make an office
|
||||
* different on every reload), that the leaf stays inside the quad it is cut out
|
||||
* of, and that each kind gets the wrap mode and colour space its *use* demands.
|
||||
*
|
||||
* That last one is the subtle failure this file is really guarding. An
|
||||
* `alphaMap` sampled through an sRGB decode shifts every coverage value — a
|
||||
* cutout authored at 0.5 arrives at 0.21 — and the symptom is half a leaf, at
|
||||
* runtime, with nothing in the source looking wrong.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
NORMAL_MAP_KINDS,
|
||||
SCREEN_UI_VARIANTS,
|
||||
TextureBin,
|
||||
type TextureKind,
|
||||
} from "../../assets/textures.ts";
|
||||
|
||||
// ---- A 2D context that records instead of rasterising -----------------------
|
||||
|
||||
/** One entry per drawing, in the order the canvases were created. */
|
||||
const logs: string[][] = [];
|
||||
|
||||
interface FakeCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext(id: string): unknown;
|
||||
}
|
||||
|
||||
function installFakeDocument(): void {
|
||||
const document = {
|
||||
createElement(tag: string): FakeCanvas {
|
||||
if (tag !== "canvas") throw new Error(`unexpected element ${tag}`);
|
||||
const canvas: FakeCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext(): unknown {
|
||||
const log: string[] = [];
|
||||
logs.push(log);
|
||||
return makeContext(canvas, log);
|
||||
},
|
||||
};
|
||||
return canvas;
|
||||
},
|
||||
};
|
||||
(globalThis as { document?: unknown }).document = document;
|
||||
}
|
||||
|
||||
function makeContext(canvas: FakeCanvas, log: string[]): unknown {
|
||||
const record = (name: string, ...args: number[]) => {
|
||||
log.push(`${name}(${args.map((n) => n.toFixed(3)).join(",")})`);
|
||||
};
|
||||
const style = (name: string, value: unknown) => {
|
||||
log.push(`${name}=${String(value)}`);
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
_fillStyle: "",
|
||||
set fillStyle(v: unknown) {
|
||||
style("fillStyle", v);
|
||||
ctx._fillStyle = String(v);
|
||||
},
|
||||
get fillStyle() {
|
||||
return ctx._fillStyle;
|
||||
},
|
||||
set strokeStyle(v: unknown) {
|
||||
style("strokeStyle", v);
|
||||
},
|
||||
set lineWidth(v: number) {
|
||||
style("lineWidth", v);
|
||||
},
|
||||
set lineCap(v: string) {
|
||||
style("lineCap", v);
|
||||
},
|
||||
set globalCompositeOperation(v: string) {
|
||||
style("composite", v);
|
||||
},
|
||||
fillRect: (x: number, y: number, w: number, h: number) => record("fillRect", x, y, w, h),
|
||||
beginPath: () => log.push("beginPath()"),
|
||||
closePath: () => log.push("closePath()"),
|
||||
moveTo: (x: number, y: number) => record("moveTo", x, y),
|
||||
lineTo: (x: number, y: number) => record("lineTo", x, y),
|
||||
arc: (x: number, y: number, r: number) => record("arc", x, y, r),
|
||||
arcTo: (x1: number, y1: number, x2: number, y2: number, r: number) =>
|
||||
record("arcTo", x1, y1, x2, y2, r),
|
||||
bezierCurveTo: (a: number, b: number, c: number, d: number, e: number, f: number) =>
|
||||
record("bezierCurveTo", a, b, c, d, e, f),
|
||||
fill: () => log.push("fill()"),
|
||||
stroke: () => log.push("stroke()"),
|
||||
getImageData: (_x: number, _y: number, w: number, h: number) => {
|
||||
log.push(`getImageData(${w},${h})`);
|
||||
return { data: new Uint8ClampedArray(w * h * 4).fill(255), width: w, height: h };
|
||||
},
|
||||
putImageData: () => log.push("putImageData()"),
|
||||
};
|
||||
void canvas;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
installFakeDocument();
|
||||
|
||||
/** Draw one texture in isolation and hand back the texture and its op log. */
|
||||
function draw(
|
||||
kind: TextureKind,
|
||||
variant = 0,
|
||||
quality: "low" | "medium" | "high" = "high",
|
||||
): { texture: THREE.Texture | null; log: string[]; canvas: FakeCanvas | null } {
|
||||
const before = logs.length;
|
||||
const bin = new TextureBin(quality);
|
||||
const texture = bin.get(kind, variant);
|
||||
const log = logs[before] ?? [];
|
||||
const canvas = (texture?.image as FakeCanvas | undefined) ?? null;
|
||||
return { texture, log, canvas };
|
||||
}
|
||||
|
||||
// ---- screenUI ---------------------------------------------------------------
|
||||
|
||||
test("screenUI publishes at least the four variants the assets workstream needs", () => {
|
||||
assert.ok(SCREEN_UI_VARIANTS >= 4, `only ${SCREEN_UI_VARIANTS} screen layouts`);
|
||||
assert.equal(new TextureBin("high").variants("screenUI"), SCREEN_UI_VARIANTS);
|
||||
// Everything that tiles has exactly one.
|
||||
assert.equal(new TextureBin("high").variants("carpetLoop"), 1);
|
||||
});
|
||||
|
||||
test("every screen variant is a genuinely different drawing", () => {
|
||||
const signatures = new Set<string>();
|
||||
for (let v = 0; v < SCREEN_UI_VARIANTS; v++) {
|
||||
const { log } = draw("screenUI", v);
|
||||
assert.ok(log.length > 60, `variant ${v} drew only ${log.length} operations`);
|
||||
signatures.add(log.join("|"));
|
||||
}
|
||||
// A single layout drawn six times with a different seed would still differ,
|
||||
// so this is the weaker half of the claim; the stronger half is that the
|
||||
// module has six distinct layout functions, which the count below pins.
|
||||
assert.equal(signatures.size, SCREEN_UI_VARIANTS, "two screen variants draw the same picture");
|
||||
});
|
||||
|
||||
test("a screen variant is the same picture every time it is drawn", () => {
|
||||
// The drawings are seeded from the variant index. A `Math.random` anywhere in
|
||||
// this path would give a studio a different set of monitors on every reload,
|
||||
// and would break the byte-identical-for-a-seed property the asset library
|
||||
// promises everywhere else.
|
||||
for (const v of [0, 3, 5]) {
|
||||
const first = draw("screenUI", v).log.join("|");
|
||||
const second = draw("screenUI", v).log.join("|");
|
||||
assert.equal(first, second, `screen variant ${v} is not deterministic`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a screen is drawn in the proportions of a screen", () => {
|
||||
const { canvas } = draw("screenUI", 1);
|
||||
assert.ok(canvas);
|
||||
const aspect = (canvas?.width ?? 0) / (canvas?.height ?? 1);
|
||||
assert.ok(Math.abs(aspect - 16 / 9) < 0.02, `screen canvas aspect ${aspect.toFixed(3)}`);
|
||||
});
|
||||
|
||||
test("a screen clamps and carries colour", () => {
|
||||
const { texture } = draw("screenUI", 2);
|
||||
assert.ok(texture);
|
||||
// One image on one quad. Repeat wrapping here means a UV that overshoots by a
|
||||
// hair draws the right edge of the interface against the left one.
|
||||
assert.equal(texture?.wrapS, THREE.ClampToEdgeWrapping);
|
||||
assert.equal(texture?.wrapT, THREE.ClampToEdgeWrapping);
|
||||
// Content, not coverage: this is the one drawing in the library with its own
|
||||
// colour, and it is authored in sRGB.
|
||||
assert.equal(texture?.colorSpace, THREE.SRGBColorSpace);
|
||||
assert.equal(texture?.name, "screenUI#2");
|
||||
});
|
||||
|
||||
test("a screen draws no text and no logo", () => {
|
||||
// ARCHITECTURE.md §3.1: a screen drawing a recognisable interface is a screen
|
||||
// drawing somebody's trademark. There is no `fillText` in the fake context at
|
||||
// all, so a drawing that reached for one would throw — this asserts the
|
||||
// intent explicitly so the next person does not add one.
|
||||
for (let v = 0; v < SCREEN_UI_VARIANTS; v++) {
|
||||
const { log } = draw("screenUI", v);
|
||||
assert.ok(!log.some((op) => op.startsWith("fillText") || op.startsWith("drawImage")));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- leafAlpha --------------------------------------------------------------
|
||||
|
||||
test("the leaf is coverage, not colour", () => {
|
||||
const { texture } = draw("leafAlpha");
|
||||
assert.ok(texture);
|
||||
// An sRGB decode on an alphaMap shifts every coverage value: a cutout drawn
|
||||
// at 0.5 arrives at 0.21 and `alphaTest` eats half the leaf.
|
||||
assert.equal(texture?.colorSpace, THREE.NoColorSpace);
|
||||
assert.equal(texture?.wrapS, THREE.ClampToEdgeWrapping);
|
||||
assert.equal(texture?.wrapT, THREE.ClampToEdgeWrapping);
|
||||
});
|
||||
|
||||
test("the leaf is drawn as a shape, black ground first", () => {
|
||||
const { log } = draw("leafAlpha");
|
||||
const firstFill = log.findIndex((op) => op.startsWith("fillRect"));
|
||||
assert.ok(firstFill > 0);
|
||||
assert.equal(log[firstFill - 1], "fillStyle=#000000", "the ground under the cutout must be empty");
|
||||
assert.ok(
|
||||
log.some((op) => op.startsWith("bezierCurveTo")),
|
||||
"a leaf outline drawn without curves is a rectangle with a different name",
|
||||
);
|
||||
// The serrations are bitten out and the mode is put back, or every drawing
|
||||
// after this one on the same context would erase instead of paint.
|
||||
const cut = log.indexOf("composite=destination-out");
|
||||
const restore = log.indexOf("composite=source-over");
|
||||
assert.ok(cut > 0, "no serrations were cut");
|
||||
assert.ok(restore > cut, "the composite mode was left in destination-out");
|
||||
});
|
||||
|
||||
test("the leaf stays inside the quad it is cut out of", () => {
|
||||
const { log, canvas } = draw("leafAlpha");
|
||||
const w = canvas?.width ?? 0;
|
||||
const h = canvas?.height ?? 0;
|
||||
assert.ok(w > 0 && h > 0);
|
||||
|
||||
const numbers = (op: string): number[] =>
|
||||
(op.slice(op.indexOf("(") + 1, -1).match(/-?\d+\.\d+/g) ?? []).map(Number);
|
||||
|
||||
for (const op of log) {
|
||||
if (op.startsWith("arc(")) {
|
||||
const [x = 0, y = 0, r = 0] = numbers(op);
|
||||
assert.ok(x - r >= -0.5 && x + r <= w + 0.5, `serration off the left/right edge: ${op}`);
|
||||
assert.ok(y - r >= -0.5 && y + r <= h + 0.5, `serration off the top/bottom edge: ${op}`);
|
||||
} else if (op.startsWith("bezierCurveTo") || op.startsWith("moveTo")) {
|
||||
const values = numbers(op);
|
||||
for (let i = 0; i < values.length; i += 2) {
|
||||
assert.ok((values[i] ?? 0) >= -0.5 && (values[i] ?? 0) <= w + 0.5, `x out of bounds: ${op}`);
|
||||
assert.ok(
|
||||
(values[i + 1] ?? 0) >= -0.5 && (values[i + 1] ?? 0) <= h + 0.5,
|
||||
`y out of bounds: ${op}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the leaf is cut even at low quality, where every other map is skipped", () => {
|
||||
// `low` means no *shading* maps, and it is the setting that makes an office
|
||||
// open on an integrated GPU. Skipping the cutout there does not buy a cheaper
|
||||
// plant, it buys the flat green shard back.
|
||||
const leaf = draw("leafAlpha", 0, "low");
|
||||
assert.ok(leaf.texture, "leafAlpha must survive low quality");
|
||||
assert.ok((leaf.canvas?.width ?? 0) >= 128);
|
||||
|
||||
const carpet = draw("carpetLoop", 0, "low");
|
||||
assert.equal(carpet.texture, null, "low quality must still skip the shading maps");
|
||||
});
|
||||
|
||||
// ---- The bin ----------------------------------------------------------------
|
||||
|
||||
test("the bin draws each kind and variant at most once", () => {
|
||||
const bin = new TextureBin("high");
|
||||
const before = logs.length;
|
||||
const a = bin.get("screenUI", 1);
|
||||
const b = bin.get("screenUI", 1);
|
||||
assert.equal(a, b);
|
||||
assert.equal(logs.length - before, 1, "the same variant was drawn twice");
|
||||
|
||||
const c = bin.get("screenUI", 4);
|
||||
assert.notEqual(a, c);
|
||||
assert.equal(logs.length - before, 2);
|
||||
|
||||
// Wrapping, so a caller can hand it a running prop index.
|
||||
assert.equal(bin.get("screenUI", 1 + SCREEN_UI_VARIANTS), a);
|
||||
assert.equal(logs.length - before, 2);
|
||||
bin.dispose();
|
||||
});
|
||||
|
||||
test("a variant index on a kind that has none is ignored", () => {
|
||||
const bin = new TextureBin("high");
|
||||
const before = logs.length;
|
||||
assert.equal(bin.get("carpetLoop", 0), bin.get("carpetLoop", 7));
|
||||
assert.equal(logs.length - before, 1);
|
||||
bin.dispose();
|
||||
});
|
||||
|
||||
test("the tiling kinds are unchanged: square, repeating, sRGB", () => {
|
||||
for (const kind of [
|
||||
"carpetLoop",
|
||||
"woodPlank",
|
||||
"polishedConcrete",
|
||||
"ceilingTile",
|
||||
"plasterPaint",
|
||||
"fabricWeave",
|
||||
"tileGrid",
|
||||
"whiteboard",
|
||||
] as TextureKind[]) {
|
||||
const { texture, canvas } = draw(kind);
|
||||
assert.ok(texture, `${kind} did not draw`);
|
||||
assert.equal(canvas?.width, canvas?.height, `${kind} is no longer square`);
|
||||
assert.equal(texture?.wrapS, THREE.RepeatWrapping, `${kind} stopped tiling`);
|
||||
assert.equal(texture?.wrapT, THREE.RepeatWrapping, `${kind} stopped tiling`);
|
||||
assert.equal(texture?.colorSpace, THREE.SRGBColorSpace);
|
||||
assert.equal(texture?.name, kind, `${kind} gained a variant suffix it did not ask for`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The relief channel ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* These read pixels, which the tests above deliberately refuse to do — and the
|
||||
* difference is that a normal map is not a picture. It is a field of measured
|
||||
* directions with a defined encoding, and the three things that can be wrong
|
||||
* with it are all arithmetic: it can be un-normalised, it can be flipped in one
|
||||
* axis (a floor that lights as though it were embossed inside out), or it can
|
||||
* have a discontinuity at the tile seam. All three are checkable, none of them
|
||||
* is a judgement about how a carpet ought to look, and none of them shows up in
|
||||
* a screenshot until a low sun rakes across the floor.
|
||||
*
|
||||
* They also run *without* the fake canvas: relief comes off an authored height
|
||||
* field rather than off the drawing, which is what lets it exist under
|
||||
* `node --test` at all.
|
||||
*/
|
||||
|
||||
/** Every kind the spec asked for relief on, in the order the spec named them. */
|
||||
const RELIEF_KINDS: TextureKind[] = [
|
||||
"carpetLoop",
|
||||
"woodPlank",
|
||||
"fabricWeave",
|
||||
"plasterPaint",
|
||||
"tileGrid",
|
||||
"ceilingTile",
|
||||
];
|
||||
|
||||
function normalTexture(kind: TextureKind, quality: "low" | "medium" | "high" = "high") {
|
||||
const texture = new TextureBin(quality).draw(kind, "normal");
|
||||
assert.ok(texture, `${kind} has no normal map`);
|
||||
const image = texture.image as { width: number; height: number; data: Uint8Array };
|
||||
return { texture, ...image };
|
||||
}
|
||||
|
||||
test("every kind the spec named has relief, and nothing else does", () => {
|
||||
assert.deepEqual([...NORMAL_MAP_KINDS].sort(), [...RELIEF_KINDS].sort());
|
||||
const bin = new TextureBin("high");
|
||||
// A whiteboard, a display and an alpha cutout are flat. Binding a normal map
|
||||
// to them would be inventing texture that is not on the object.
|
||||
for (const kind of ["polishedConcrete", "whiteboard", "screenUI", "leafAlpha"] as TextureKind[]) {
|
||||
assert.equal(bin.normal(kind), null, `${kind} grew a normal map`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the centre of every relief map is flat, within a texel of tolerance", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, data } = normalTexture(kind);
|
||||
const i = ((width / 2) * width + width / 2) * 4;
|
||||
const [r, g, b] = [data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0];
|
||||
// The middle of the tile is the middle of a plank, the bottom of a grout
|
||||
// line or the crest of a carpet row depending on the kind — a stationary
|
||||
// point of the height field in every case, so the surface there points
|
||||
// straight up and encodes as (128, 128, 255).
|
||||
assert.ok(Math.abs(r - 128) <= 6, `${kind} centre R is ${r}`);
|
||||
assert.ok(Math.abs(g - 128) <= 6, `${kind} centre G is ${g}`);
|
||||
assert.ok(Math.abs(b - 255) <= 6, `${kind} centre B is ${b}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every texel of every relief map is a unit vector pointing out of the surface", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, height, data } = normalTexture(kind);
|
||||
let worst = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const x = ((data[i * 4] ?? 0) / 255) * 2 - 1;
|
||||
const y = ((data[i * 4 + 1] ?? 0) / 255) * 2 - 1;
|
||||
const z = ((data[i * 4 + 2] ?? 0) / 255) * 2 - 1;
|
||||
// Out of the surface, never into it: a negative Z is a normal facing away
|
||||
// from the viewer, which shades as a hole.
|
||||
assert.ok(z > 0, `${kind} has a texel whose normal points into the surface`);
|
||||
worst = Math.max(worst, Math.abs(Math.hypot(x, y, z) - 1));
|
||||
assert.equal(data[i * 4 + 3], 255, `${kind} has a non-opaque texel`);
|
||||
}
|
||||
// 1/255 per channel of quantisation, tripled and rounded up.
|
||||
assert.ok(worst < 0.02, `${kind} normals are off unit length by ${worst.toFixed(4)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("relief has something in it — a flat normal map is a wasted texture unit", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, height, data } = normalTexture(kind);
|
||||
let peak = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
peak = Math.max(
|
||||
peak,
|
||||
Math.abs((data[i * 4] ?? 0) - 128),
|
||||
Math.abs((data[i * 4 + 1] ?? 0) - 128),
|
||||
);
|
||||
}
|
||||
assert.ok(peak >= 8, `${kind} relief peaks at ${peak}/128 and reads as flat`);
|
||||
}
|
||||
});
|
||||
|
||||
test("relief wraps at the tile seam", () => {
|
||||
// The colour maps are built to tile; a normal map that does not would put a
|
||||
// hard lighting crease every two metres across a floor, which is worse than no
|
||||
// relief at all because it moves with the sun.
|
||||
//
|
||||
// The comparison is against the *local* step, not against zero. Column 0 and
|
||||
// column 511 are one texel apart under wrapping, and in the wall of a grout
|
||||
// line one texel is a big step — legitimately. What would not be legitimate is
|
||||
// the step across the seam being larger than the steps either side of it,
|
||||
// which is exactly what a field sampled at `(x + 0.5) / size` instead of
|
||||
// `x / size` produces.
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, height, data } = normalTexture(kind);
|
||||
const texel = (x: number, y: number): number[] => {
|
||||
const i = (y * width + x) * 4;
|
||||
return [data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0];
|
||||
};
|
||||
const spread = (a: number[], b: number[]): number =>
|
||||
Math.max(...a.map((value, c) => Math.abs(value - (b[c] ?? 0))));
|
||||
|
||||
for (let y = 0; y < height; y += 17) {
|
||||
const seam = spread(texel(width - 1, y), texel(0, y));
|
||||
const local = Math.max(
|
||||
spread(texel(0, y), texel(1, y)),
|
||||
spread(texel(width - 2, y), texel(width - 1, y)),
|
||||
);
|
||||
assert.ok(seam <= local + 4, `${kind} row ${y}: seam step ${seam} vs local ${local}`);
|
||||
}
|
||||
for (let x = 0; x < width; x += 17) {
|
||||
const seam = spread(texel(x, height - 1), texel(x, 0));
|
||||
const local = Math.max(
|
||||
spread(texel(x, 0), texel(x, 1)),
|
||||
spread(texel(x, height - 2), texel(x, height - 1)),
|
||||
);
|
||||
assert.ok(seam <= local + 4, `${kind} column ${x}: seam step ${seam} vs local ${local}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("relief is resolution-independent, so medium and high light the same", () => {
|
||||
// The gradient is taken per unit UV rather than per texel. Get that wrong and
|
||||
// the same floor is twice as steep at `medium` as at `high`, which is a
|
||||
// quality setting that changes the art rather than the cost.
|
||||
//
|
||||
// `fabricWeave` is excluded, and the exclusion is the finding rather than a
|
||||
// fudge: its drawing rules 128 threads across the tile, which is two texels at
|
||||
// 256² — below what a half-resolution map can carry at all. The weave
|
||||
// therefore genuinely disappears from the relief at `medium`, the same way the
|
||||
// 1-pixel thread lines alias out of the colour map at `medium`. That is a
|
||||
// graceful loss of detail, which is what a quality setting is for; every other
|
||||
// kind's features are coarse enough to survive both and are held to within a
|
||||
// few percent.
|
||||
for (const kind of RELIEF_KINDS.filter((kind) => kind !== "fabricWeave")) {
|
||||
const strength = (quality: "medium" | "high") => {
|
||||
const { width, height, data } = normalTexture(kind, quality);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
sum += Math.hypot((data[i * 4] ?? 0) - 128, (data[i * 4 + 1] ?? 0) - 128);
|
||||
}
|
||||
return sum / (width * height);
|
||||
};
|
||||
const medium = strength("medium");
|
||||
const high = strength("high");
|
||||
// A third, not a few percent: a grout line is a couple of texels wide even
|
||||
// at 512 and softens measurably at 256. The bug this is really guarding
|
||||
// against — differentiating per texel instead of per unit UV — is a factor
|
||||
// of two, and no amount of softening reaches that.
|
||||
assert.ok(
|
||||
Math.abs(medium - high) <= Math.max(0.3, high * 0.35),
|
||||
`${kind} relief is ${medium.toFixed(2)} at medium and ${high.toFixed(2)} at high`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("relief is set up as a sampled map, not as raw data", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { texture } = normalTexture(kind);
|
||||
assert.equal(texture.wrapS, THREE.RepeatWrapping, `${kind} relief stopped tiling`);
|
||||
assert.equal(texture.wrapT, THREE.RepeatWrapping, `${kind} relief stopped tiling`);
|
||||
// A direction is not a colour. An sRGB decode would bend every normal.
|
||||
assert.equal(texture.colorSpace, THREE.NoColorSpace, `${kind} relief is being decoded`);
|
||||
// `DataTexture` defaults to nearest and no mipmaps, which on a floor running
|
||||
// to the horizon is a field of shimmering static.
|
||||
assert.equal(texture.generateMipmaps, true, `${kind} relief has no mipmaps`);
|
||||
assert.equal(texture.minFilter, THREE.LinearMipmapLinearFilter);
|
||||
assert.equal(texture.magFilter, THREE.LinearFilter);
|
||||
}
|
||||
});
|
||||
|
||||
test("low quality has no relief at all, the same way it has no colour maps", () => {
|
||||
const bin = new TextureBin("low");
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
assert.equal(bin.normal(kind), null, `${kind} drew relief at low quality`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the bin builds each relief map at most once", () => {
|
||||
const bin = new TextureBin("high");
|
||||
const first = bin.normal("tileGrid");
|
||||
assert.ok(first);
|
||||
assert.equal(bin.normal("tileGrid"), first);
|
||||
// `draw` is the uncached door and must stay uncached, or the tests above
|
||||
// would be asserting about one shared texture.
|
||||
assert.notEqual(bin.draw("tileGrid", "normal"), first);
|
||||
bin.dispose();
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The tone curve, and the light table that is tuned against it.
|
||||
*
|
||||
* These two things are one change and are tested in one file on purpose. Turning
|
||||
* on ACES without re-tuning `atmosphere.ts` produces a world that is correctly
|
||||
* *shaped* and too dark; re-tuning `atmosphere.ts` without ACES produces a world
|
||||
* that clips even harder than it did. Either half on its own is a regression, so
|
||||
* the assertions below fail if either half is reverted alone.
|
||||
*
|
||||
* `createStage` itself cannot be called here — it constructs a real
|
||||
* `WebGLRenderer`, and `node --test` has no GL context and no canvas. What can
|
||||
* be checked, and is, is (1) that the three renderer properties are actually
|
||||
* assigned in the source, which is the thing a careless merge would drop, (2)
|
||||
* that the constants they are assigned from still mean what the rest of the
|
||||
* repo assumes, and (3) that the light table's *shape* still matches the curve.
|
||||
*/
|
||||
|
||||
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 { createAtmosphere, type Environment } from "../../engine/atmosphere.ts";
|
||||
import type { MoonPosition } from "../../engine/atmosphere.ts";
|
||||
import { DEFAULT_TONE_MAPPING_EXPOSURE } from "../../engine/stage.ts";
|
||||
import type { SolarPosition } from "../../engine/solar.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const STAGE_SOURCE = readFileSync(path.join(ROOT, "src/engine/stage.ts"), "utf8");
|
||||
|
||||
// ---- The renderer configuration -------------------------------------------
|
||||
|
||||
test("the stage configures ACES, an exposure and an explicit output colour space", () => {
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/renderer\.toneMapping\s*=\s*THREE\.ACESFilmicToneMapping/,
|
||||
"the renderer must tone map; NoToneMapping is saturate() and clips every value above 1.0",
|
||||
);
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/renderer\.toneMappingExposure\s*=/,
|
||||
"the exposure must be assigned, not inherited",
|
||||
);
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/renderer\.outputColorSpace\s*=\s*THREE\.SRGBColorSpace/,
|
||||
"the output transfer function must be stated rather than relying on a library default",
|
||||
);
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/exposure\?:\s*number/,
|
||||
"StageOptions must expose the exposure, so a capture or a test can drive it",
|
||||
);
|
||||
});
|
||||
|
||||
test("the three constants the stage names still exist in three", () => {
|
||||
// A rename upstream would leave the assignments above compiling against
|
||||
// `undefined` and silently restore the clipping renderer.
|
||||
assert.equal(typeof THREE.ACESFilmicToneMapping, "number");
|
||||
assert.notEqual(THREE.ACESFilmicToneMapping, THREE.NoToneMapping);
|
||||
assert.equal(THREE.SRGBColorSpace, "srgb");
|
||||
});
|
||||
|
||||
// ---- What the exposure means ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Three's own ACES fit, transcribed from `tonemapping_pars_fragment.glsl.js`.
|
||||
*
|
||||
* Duplicated here deliberately. The point of these assertions is not to check
|
||||
* that three's shader does what three's shader does — it is to check that the
|
||||
* *exposure this repo chose* lands the values this repo actually renders in the
|
||||
* places they need to be, and that requires evaluating the curve on the CPU.
|
||||
*/
|
||||
function rrtAndOdtFit(v: number): number {
|
||||
const a = v * (v + 0.0245786) - 0.000090537;
|
||||
const b = v * (0.983729 * v + 0.432951) + 0.238081;
|
||||
return a / b;
|
||||
}
|
||||
|
||||
/** Linear radiance in, display-linear out. Neutral colours only, so no matrices. */
|
||||
function aces(linear: number, exposure = DEFAULT_TONE_MAPPING_EXPOSURE): number {
|
||||
return Math.min(1, Math.max(0, rrtAndOdtFit((linear * exposure) / 0.6)));
|
||||
}
|
||||
|
||||
/** Display-linear to what the panel shows, so thresholds can be read as levels. */
|
||||
function srgb(v: number): number {
|
||||
return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
test("the chosen exposure keeps mid grey near the middle", () => {
|
||||
// An 18% card is the definition of a neutral exposure. A little above 0.5 is
|
||||
// the deliberate lift documented on the constant; a long way from it means
|
||||
// somebody has turned this into a brightness slider.
|
||||
const grey = srgb(aces(0.18));
|
||||
assert.ok(grey > 0.5 && grey < 0.58, `18% grey displayed at ${grey.toFixed(3)}`);
|
||||
});
|
||||
|
||||
test("values above 1.0 stay separable, which is the whole reason for the change", () => {
|
||||
// These are the numbers the library actually drives: `lightDiffuser` glows at
|
||||
// 0.85, `screenContent` at 0.9, `deviceIndicator` at 1.0, and the office
|
||||
// assets reach 3.2. Under NoToneMapping every one of them displayed as 1.0.
|
||||
const levels = [0.85, 1, 1.6, 2.35, 3.2, 6].map((v) => aces(v));
|
||||
for (let i = 1; i < levels.length; i++) {
|
||||
const previous = levels[i - 1] ?? 0;
|
||||
const current = levels[i] ?? 0;
|
||||
assert.ok(current > previous, `radiance step ${i} did not brighten`);
|
||||
assert.ok(current < 1, `radiance step ${i} clipped at 1.0`);
|
||||
assert.ok(
|
||||
current - previous > 0.002,
|
||||
`radiance step ${i} moved by ${(current - previous).toFixed(4)}, which is not a visible difference`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the curve is monotonic across the whole range it is fed", () => {
|
||||
let previous = -1;
|
||||
for (let linear = 0; linear <= 12; linear += 0.05) {
|
||||
const value = aces(linear);
|
||||
assert.ok(value >= previous, `not monotonic at ${linear.toFixed(2)}`);
|
||||
previous = value;
|
||||
}
|
||||
});
|
||||
|
||||
test("the exposure is a photographic dial, not a brightness control", () => {
|
||||
assert.ok(
|
||||
DEFAULT_TONE_MAPPING_EXPOSURE > 0.7 && DEFAULT_TONE_MAPPING_EXPOSURE < 1.8,
|
||||
`exposure ${DEFAULT_TONE_MAPPING_EXPOSURE} is outside the range the light table is tuned for`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---- The light table, tuned against that curve -----------------------------
|
||||
|
||||
const NO_MOON: MoonPosition = {
|
||||
azimuth: 0,
|
||||
elevation: -40,
|
||||
illuminated: 0,
|
||||
phase: 0,
|
||||
distanceKm: 384_400,
|
||||
};
|
||||
|
||||
function sun(elevation: number): SolarPosition {
|
||||
return { azimuth: 180, elevation, declination: 0, equationOfTime: 0 };
|
||||
}
|
||||
|
||||
function environment(elevation: number): Environment {
|
||||
return { time: new Date("2026-06-21T20:00:00Z"), sun: sun(elevation), moon: NO_MOON, weather: null };
|
||||
}
|
||||
|
||||
function rig(elevation: number) {
|
||||
// A city-scale atmosphere with the moon switched off, so what comes back is
|
||||
// the keyframe table plus the night floor and nothing else.
|
||||
const atmosphere = createAtmosphere({ lng: -122.4, metresPerUnit: 94, moonlight: null });
|
||||
return atmosphere.apply(environment(elevation));
|
||||
}
|
||||
|
||||
test("the day stops raise the key and lower the fill", () => {
|
||||
const noon = rig(65);
|
||||
// Contrast, not brightness: a shoulder means the sun no longer has to be held
|
||||
// back to keep a lit wall off pure white, so the key went up and the fill came
|
||||
// down. If someone restores the old table these three flip together.
|
||||
assert.ok(noon.sun.intensity >= 2.5, `peak sun ${noon.sun.intensity} is below the retuned key`);
|
||||
assert.ok(
|
||||
noon.hemisphere.intensity <= 1.0,
|
||||
`peak hemisphere ${noon.hemisphere.intensity} is above the retuned fill`,
|
||||
);
|
||||
assert.ok(
|
||||
noon.ambient.intensity <= 0.25,
|
||||
`peak ambient ${noon.ambient.intensity} is above the retuned fill`,
|
||||
);
|
||||
|
||||
// The ratio is the thing that reads as modelling. Under the old table it was
|
||||
// 2.35 / 1.10 = 2.1; it must not go back there.
|
||||
const keyToFill = noon.sun.intensity / noon.hemisphere.intensity;
|
||||
assert.ok(keyToFill > 2.4, `key-to-fill ratio ${keyToFill.toFixed(2)} is too flat`);
|
||||
});
|
||||
|
||||
test("the night floor sits high enough to survive the ACES toe", () => {
|
||||
const night = rig(-18);
|
||||
// The toe costs roughly 18% of the display value of a moonless night. The
|
||||
// floor was raised by a third in linear light to pay for it, and these are the
|
||||
// floors themselves rather than the keyframe rows, because the floor binds.
|
||||
assert.ok(
|
||||
night.hemisphere.intensity >= 1.0,
|
||||
`night hemisphere ${night.hemisphere.intensity} is back below the raised floor`,
|
||||
);
|
||||
assert.ok(
|
||||
night.ambient.intensity >= 0.28,
|
||||
`night ambient ${night.ambient.intensity} is back below the raised floor`,
|
||||
);
|
||||
// And still a night: the fill is a fraction of noon's key, not a match for it.
|
||||
assert.ok(night.sun.intensity < 0.5, "the night sidelight has become a sun");
|
||||
});
|
||||
|
||||
test("the sun brightens monotonically as it rises", () => {
|
||||
let previous = -1;
|
||||
for (const elevation of [-18, -12, -6, -0.4, 3, 8, 25, 65]) {
|
||||
const intensity = rig(elevation).sun.intensity;
|
||||
assert.ok(intensity > previous, `sun intensity fell between stops at ${elevation} degrees`);
|
||||
previous = intensity;
|
||||
}
|
||||
});
|
||||
|
||||
test("the sky colours were left alone, because they are not tone mapped", () => {
|
||||
// Three marks the background mesh `toneMapped = false` for an sRGB-transfer
|
||||
// texture and mixes fog after the tone map from an already-encoded uniform.
|
||||
// So the one thing the re-tune must NOT have touched is the sky, and the noon
|
||||
// stop still reproduces the city's own declared daylight colours.
|
||||
const noon = rig(25);
|
||||
assert.ok(noon.sky);
|
||||
assert.equal(noon.sky?.top, 0x8fb8d8);
|
||||
assert.equal(noon.sky?.horizon, 0xd9e6ee);
|
||||
});
|
||||
Reference in New Issue
Block a user