diff --git a/fktry/lanes/LANE-RENDER.md b/fktry/lanes/LANE-RENDER.md index f932943..2c41e4c 100644 --- a/fktry/lanes/LANE-RENDER.md +++ b/fktry/lanes/LANE-RENDER.md @@ -802,3 +802,205 @@ body stays full-size, and the placement scale-pop still plays (relative overshoo HMR'd session — an HMR'd renderer can hold meshes normalised in a prior module state and mask a fresh-boot break. And a hardcoded `scale = 1` is a placeholder assumption that any scaled asset (GLB, future LOD) will trip; base-relative is the correct shape. + +### Round 7 PHASE 0 (out-of-band) — 2026-07-29 — Opus 5 — the GLBs were dark because the world had no environment + +**THE HEADLINE: the orchestrator's diagnosis was right, and I can now put a number on it. +`scene.environment` was `null` for seven rounds. In three.js a material's diffuse term is +`albedo * (1 - metalness)`, so for a metal there is nothing left but reflected surroundings +— and MODELBEAST's catalog is metal: 13 of 27 materials at `metallicFactor >= 0.8`, 16 at +`>= 0.5`, 21 with no metallic-roughness texture to vary it per texel. Re-probed on HEAD +after the orchestrator's 27.3 MB -> 7.0 MB pass and the distribution is UNCHANGED (13/16/21 +— that pass recompressed textures and meshes, not PBR params), so every number below still +describes the shipped assets. Round 6's `GLB_EMISSIVE_FLOOR = 0.42` was cosmetics over a +lighting hole. It is now 0.18 and the hole is filled.** + +**SHIPPED** — all 4 orders. + +1. **`src/render/env.ts` (new) — a procedural environment, ZERO bytes shipped.** A 128x64 + half-float equirectangular radiance map built from arithmetic (no asset fetch, nothing + for the deploy to carry), run through `THREE.PMREMGenerator`, assigned to + `scene.environment`. Content per codex §8 — near-black indigo floor keyed off the crust, + cool "bitstream haze" overhead, a horizon band (this is the bit that makes a metal edge + read as metal), and two soft blobs standing in for the key and fill. **The light + directions are PASSED IN from index.ts's real lights**, not copied — the scram-latch + lesson: this module keeps no second copy of another module's constants. + +2. **Emissive band-aid RE-TUNED, not retired: 0.42 -> 0.18.** With real IBL, 0.42 is + overexposure — it flattens the shading the environment just bought back and pushes every + chassis toward a self-lit sticker. Kept because a genuinely black texel still needs the + "never a hole" guarantee, and because it is what keeps a machine legible when a brownout + drops tone-mapping exposure to 0.7. + +3. **Lazy GLB loading.** `registry.init` no longer awaits `probeAll`. Placeholders are built + synchronously one line above it and main.ts awaits `renderer.init()` before it starts the + frame loop, so the first frame of the game was gated on 27 HEAD requests plus the whole + catalog. **Placement juice suppressed on hot-swap** (`entities.ts`): the rebuild path + could not tell a MODELBEAST swap from a placement, so with lazy loading the entire + factory would have puffed dust and scale-popped ~1s into every boot. A `def` change still + pops — that is a real replacement. + +4. **`src/render/registry.test.ts` (new) — 31 tests, this lane's first.** 481 -> **512 repo + tests**. Source-level only: fit-to-footprint normalisation, accent precedence, era lookup, + and the env generator. **Mutation-checked, not just green**: reverting the era tie-break, + changing the 0.92 fit factor, and deleting the `def.accent` short-circuit fail 7 tests + between them. + +**WHAT THE ENVIRONMENT ACTUALLY DID — measured, and honestly mixed** + +Mean sRGB luminance of each machine body at a fixed showroom close-up frame (`rig.frame(0, +-14, 10)`), sampling the same pixels in every condition. Plain crust reads ~24, so ~24 is +the "this machine is a hole in the floor" line. + +| machine | metalness | no env, floor 0 | env, floor 0 | ROUND 6 (no env, floor 0.42) | **SHIPPED (env, floor 0.18)** | +|---|---|---|---|---|---| +| demosaicer | 1.0 | **19.8** (darker than the floor) | 42.4 | 74.9 | **76.9** | +| neural-keyer | 0.96 | **22.8** | 45.4 | 84.5 | **81.4** | +| archaeology-lab | 1.0 (MR tex) | **24.0** | 33.6 | 61.3 | **54.1** | +| mv-extractor | 1.0 | **26.2** | 41.7 | 70.2 | **71.1** | +| p-caster | 0.80 | **26.4** | 40.6 | 62.4 | **62.8** | +| gop-assembler | 0.85 | 35.2 | 51.6 | 72.4 | **73.6** | +| software-decoder | 1.0 | 46.8 | 63.7 | 104.7 | **96.8** | +| mosh-reactor | 1.0 (MR tex) | 58.2 | 72.0 | 86.6 | **90.1** | +| decode-asic | 1.0 (MR tex) | 58.2 | 76.6 | 97.9 | **99.9** | +| *roto-bench (metalness 0, control)* | 0 | 63.5 | 83.0 | 95.2 | **104.2** | +| *field-loom (0.09, control)* | 0.09 | 125.1 | 133.5 | 137.8 | **141.4** | + +Read it honestly, three ways: + +- **The bug is real and it is exactly where the orchestrator said.** With the band-aid off, + demosaicer measured **19.8 against a crust of 24** — the machine was literally darker than + the ground it stood on. Screenshot before/after of that condition is the starkest pair I + have taken in this lane since the round-5 colour-space bug. +- **The environment is worth the most exactly where metalness is highest.** Env alone lifted + demosaicer +114% and neural-keyer +99%, against +31% for the metalness-0 control + (roto-bench) and **+7% for field-loom at metalness 0.09**. That gradient IS the diagnosis: + the fix tracks the term that was broken. +- **BUT — and this is the honest part — the environment alone does NOT reach round-6 + brightness.** Env at floor 0 leaves the darkest machine at 38; round 6 sat at 57. What the + IBL buys is not brightness, it is FORM: shading, a specular edge, and the baked albedo + reading as a surface instead of a decal. The final numbers land within a few percent of + round 6 *because I chose the emissive floor to put them there* — the difference is that + the light is now doing the shaping. Anyone reading only the last column would conclude + nothing changed; look at the screenshots instead. + +**DECISIONS** + +- **GLB materials get `envMap` assigned explicitly, not inherited from the scene.** Forced, + not stylistic: three.js *overrides* `material.envMapIntensity` with + `scene.environmentIntensity` whenever a material falls back to the scene environment + (`WebGLRenderer`: `material.envMap === null && scene.environment !== null`). Assigning it + per material is the only way the assets can be dosed differently from six rounds of + hand-tuned placeholder art. **I burned real time discovering this** — my first tuning + sweep of `envMapIntensity` from 1 to 5 produced byte-identical output and looked like a + broken env map. +- **Two dials, deliberately far apart: `ENV_WORLD_INTENSITY = 0.5`, `GLB_ENV_INTENSITY = + 2.6`.** The world's own materials (belts, cargo, placeholders, ghosts, wildlife, the + cabinet) were art-directed across six rounds under no IBL at all; relighting them wholesale + is a regression dressed as a fix, so they get a dusting for coherence and nothing more. + The assets get 5x that because they are the metals. +- **`AmbientLight` left alone at 1.1 after measuring it.** I lowered it to 0.55 first, + assuming the new environment made it a double-count. Then I swept it: 0.55 -> 1.1 moves a + placeholder body ~7% and the high-metalness GLBs **<1%**, because ambient only feeds the + diffuse term metalness has already zeroed. Reverted rather than ship a change that does + nothing. (Same arithmetic as the headline, seen from the other side.) +- **I did NOT clamp the assets' metalness, though I tested it and it works.** Capping + metalness at 0.45 at load for the 21 materials with no MR texture reaches the same + luminance at `envMapIntensity: 1` and is visually near-identical (I A/B'd both at the same + frame). Not shipped: rewriting an asset's authored PBR values is a bigger claim than + lighting the room it stands in, and it would mask the upstream defect instead of reporting + it. See MODELBEAST FINDING below. +- **The TTFF instrumentation is dev-only and folded out.** `window.__fktryPerf` — 0 hits in + `dist/` (verified), along with `devscene`/`showroom`/`plinth`; the only `__render` matches + are three.js's own `__renderTarget`, as in round 3. + +**TIME-TO-FIRST-FRAME — measured, with the caveat stated up front** + +`firstAt` (the real first `gl.render`) is **unusable in this environment**: the browser +backgrounds the pane and starves rAF, and I read 5.1s / 11.3s / 14.5s for a page that was +ready in 90ms. Same friction rounds 5 and 6 hit. So the number below is `readyAt` — the +moment `renderer.init()` returns, which is the earliest a frame can exist because main.ts +awaits it before starting the loop. It is also precisely the quantity this change moves. +A/B on the same build, one line flipped between `await this.probeAll()` and `void`: + +| | `renderer.init()` duration | ready at (from navigation start) | +|---|---|---| +| blocking (round-6 behaviour) | 284 / 426 / 304 ms | 339 / 483 / 360 ms | +| **lazy (shipped)** | **27.8 / 25.2 / 28.2 ms** | **88.5 / 80.7 / 85.7 ms** | + +**~10x off `init`, ~4x off time-to-ready, ~250-400 ms saved — and that is the WARM-CACHE +best case on localhost.** A cold visitor to partly.party/glytch pays the whole catalog over +the network before the first pixel under the old path; under the new one they get +placeholders immediately and the assets stream in behind. The orchestrator's 27.3 -> 7.0 MB +pass and this change compound. + +**VERIFIED** + +- `npm run check` clean. `npm test` **512/512** (was 481; +31 mine). +- **FRESH hard-navigated loads, not HMR** (round 6's lesson, followed): `?showroom` and + `?uidemo` on :8152, both after the orchestrator's new 7.0 MB catalog landed mid-round. + Zero console errors on both. Live material state confirmed on a cold boot: + `envMap` present, `envMapIntensity 2.6`, `emissiveIntensity 0.18`. +- **No round-6-style scale regression**: after forcing an asset-version bump on all 31 + showroom entities, every `rec.pop` stayed settled at 0.22 and every `obj.scale` equalled + its `baseScale` (GLBs at 2.199 / 2.070 / 1.384, placeholders at 1.0). +- **Hot-swap juice suppression, deterministic**: bumping the asset version under all 31 + entities emitted **0** juice particles; adding one genuinely new entity on the next sync + emitted **14** (exactly one `puff`). Both cues still work, neither fires on a swap. +- **Hot-swap fires once per asset**: `registry.version` 27, every entry at `version: 1` on a + cold load. (The console shows each line twice — that is the console reader duplicating, + not a double load. Checked because it looked alarming.) +- **Perf**: the environment costs nothing measurable. `?devscene=stress` full-scene + `gl.render` submit at 2800x1800, 111 draw calls / 240k tris: **0.445 ms with the env, + 0.467 ms without it, 0.432 ms with it back on** — identical inside noise, and one + prefiltered cubeUV fetch per lit pixel is the whole added cost. Last full-frame CPU sample + 1.2 ms against a 16.6 ms budget. Honest caveat, unchanged from round 6: the rolling + `stats.avg` sampler needs a sustained rAF loop and the backgrounded pane will not give one, + so this is a forced-render measurement rather than the canonical one. + +**BLOCKED/BROKEN** — nothing blocking. + +- **A cross-lane era tie-break was WRONG in this lane, and writing the tests is what found + it.** Round 4's NOTES claim I matched LANE-UI's "first tech to claim an id wins" + (`src/ui/research.ts`). I did not: `era.ts eraIndex` used an unconditional `Map.set`, i.e. + **LAST tech wins**. Left alone, a machine claimed by two techs would get a padlock naming + one era in the build bar and an era tell naming another in the ghost, on the same screen — + exactly the divergence round 4 congratulated itself for avoiding. Fixed, and pinned by a + test. Latent so far only because `data/tech.json` currently has zero duplicate unlocks + across its 25 techs, so nothing was visibly wrong. Six rounds of eyeball QA never had a + chance of catching this; the first hour of unit tests did. +- **The player now sees placeholders for the first ~second of a boot** before the GLBs swap + in. That is the deliberate trade in order 3, and it is the same path a mid-session + MODELBEAST drop has used since round 1 — but it IS a visible change to what a cold start + looks like, so it should be a conscious call and not a surprise. +- `match.html` 404 in dev (rounds 5-6, unchanged). + +**MODELBEAST FINDING — two things for the asset lane, both measured, neither mine to fix** + +1. **The catalog's metalness is machine-generated, not art-directed.** 13 materials at + `metallicFactor: 1.0` with **no metallic-roughness texture** is what image-to-3D + generators emit by default; codex §8 describes grimy chassis with ONE impossible element, + not chrome. The environment now carries them, but shipping sane metalness (or an MR map) + would let `GLB_ENV_INTENSITY` drop from 2.6 to ~1.0 and would recover the baked albedo + detail the metal term is currently eating. One constant in `registry.ts`. +2. **`archaeology-lab.glb` is half a flat wall, and `mosh-reactor.glb` has a large planar + face.** Visible in `?showroom` as a card standing on a plinth, and quantified: fraction of + vertices inside a single 4%-thick slab — **archaeology-lab 48.3% on Z** (extent + 2 x 2 x 0.77), **mosh-reactor 26.2% on Z / 23.8% on Y**, against 7-15% for healthy meshes + (dct-press, mv-extractor, decode-asic). It looks like a backdrop plane surviving the + image-to-3D step. Present in BOTH asset generations, so the 7.0 MB pass did not introduce + it and did not fix it. No renderer change would help: there is nothing behind the plane. + +**CONTRACT REQUEST** +1. **A renderer event channel** — renewing rounds 5 and 6 unchanged. Three cues in this lane + (`repaired`, the firewall "can", `relicFound`) are still inferred from snapshot deltas. +2. Still open (round 5): `pointer-events: none` on `#screen` in `index.html` (orchestrator). + +**NEXT (not self-assigned)** +- Tests for the rest of the lane now that the harness exists — `topology.ts` neighbour + classification and `coords.ts` `centerOf`/`yawFor` are pure and are load-bearing for + everything that has ever looked misplaced. +- A one-line `GLB_ENV_INTENSITY` drop to ~1.0 the moment MODELBEAST ships sane metalness. +- Wire the renderer event channel if granted; `firewall.glb` when it lands; per-mite + orientation; heat-shimmer relief inside a cooler rectangle; bloom-loop belt tell; + save/load camera state. diff --git a/fktry/src/render/entities.ts b/fktry/src/render/entities.ts index 913346f..e57d4d8 100644 --- a/fktry/src/render/entities.ts +++ b/fktry/src/render/entities.ts @@ -115,6 +115,13 @@ export class EntityLayer { this.seen.add(e.id); let rec = this.live.get(e.id); + // A pure ASSET-version bump is a MODELBEAST hot-swap, not a placement. The mesh is + // rebuilt either way, but the placement juice must not fire: since round 7 the GLB + // probe no longer blocks the first frame, so every machine on screen swaps + // placeholder -> GLB about a second into every boot, and firing the puff + scale-pop + // on that made the whole factory re-place itself in front of the player. A `def` + // change is still a real replacement and still pops. + const rebuilt = !!rec && rec.def === e.def && rec.assetVersion !== entry.version; if (rec && (rec.def !== e.def || rec.assetVersion !== entry.version)) { this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild rec = undefined; @@ -123,7 +130,7 @@ export class EntityLayer { rec = this.build(e.id, e.def, def, entry.version); // A machine that was not here last frame was just placed. First frame primes the // set without firing, so loading a save doesn't confetti the whole factory. - if (this.primed) { + if (this.primed && !rebuilt) { const c = centerOf(e.pos, def, e.dir); this.juice?.puff(c.x, c.z); rec.pop = 0; // start the scale-pop diff --git a/fktry/src/render/env.ts b/fktry/src/render/env.ts new file mode 100644 index 0000000..e6a58f0 --- /dev/null +++ b/fktry/src/render/env.ts @@ -0,0 +1,146 @@ +/** + * LANE-RENDER — the missing image-based light (round 7 root-cause fix). + * + * THE BUG THIS EXISTS TO KILL. In the three.js PBR model a material's diffuse term is + * `albedo * (1 - metalness)`; everything a metal shows you is *reflected surroundings*. + * MODELBEAST's assets are metal: of the 27 shipped GLBs, **13 carry metallicFactor >= 0.8 + * and 16 >= 0.5**, and 21 of them have no metallic-roughness texture to soften that with + * per-texel variation. With `scene.environment === null` those materials have almost no + * diffuse AND nothing to reflect except two analytic light lobes, so they are near-black + * by arithmetic — no number of extra lights fixes it, because lights feed the term that + * metalness has already zeroed. Round 6's emissive floor was a mask over this; the fix is + * to give the world an environment to reflect. + * + * ZERO BYTES SHIPPED. The environment is generated in-process: a small equirectangular + * radiance map (128x64 half-float, ~16KB, built from arithmetic) run through + * `THREE.PMREMGenerator`. No asset fetch, no HDR file, nothing for the deploy to carry. + * + * WHAT IT LOOKS LIKE (codex §8: "base world in desaturated industrial neutrals; ALL + * saturation belongs to media/artifacts"). A dark industrial room, not a sunny outdoors: + * - near-black indigo floor tone, keyed off the crust the factory actually sits on + * - a slightly brighter cool "bitstream haze" overhead + * - a soft horizon band — this is the bit that makes a metal panel read as metal + * rather than as a flat sticker, because a metal edge needs a horizon to catch + * - two soft blobs standing in for the scene's own key and fill lights, so a reflection + * agrees with the shading rather than fighting it. The directions are PASSED IN from + * index.ts's real lights — this module never keeps its own copy of them (the scram + * latch taught this lane not to duplicate another module's constants). + */ +import * as THREE from 'three'; + +/** Equirect resolution. PMREM blurs this into mips, so a gradient needs very little. */ +export const ENV_W = 128; +export const ENV_H = 64; + +/** + * Linear radiance, authored against the world palette (`palette.ts WORLD.crust` is + * #0c0a15). Deliberately dim: the whole factory is lit at exposure 1.35 through ACES, + * and an environment bright enough to look "correct" in isolation turns every chassis + * into a lightbulb. + */ +const FLOOR_RGB = [0.013, 0.011, 0.021] as const; // the crust, bounced +const SKY_RGB = [0.055, 0.060, 0.098] as const; // cool haze overhead +const HORIZON_RGB = [0.085, 0.080, 0.115] as const; // the band metal edges catch +const HORIZON_WIDTH = 0.17; // in sin(elevation) +const KEY_RGB = [0.75, 0.78, 1.0] as const; // mirrors the key light's colour +const KEY_TIGHT = 12; // cosine power — a soft studio blob, not a sun +const FILL_RGB = [0.105, 0.090, 0.205] as const; // mirrors the cool fill +const FILL_TIGHT = 2.5; + +function smoothstep(a: number, b: number, x: number): number { + const t = Math.min(1, Math.max(0, (x - a) / (b - a))); + return t * t * (3 - 2 * t); +} + +/** + * The direction a texel looks at, in three.js's own equirect convention + * (`equirect_uv.glsl`: `u = atan2(z, x) / 2PI + 0.5`, `v = asin(y) / PI + 0.5`). + * Getting this wrong doesn't show up in a gradient but puts the key reflection on the + * wrong side of every machine, so it's worth writing down rather than eyeballing. + */ +export function dirAt(u: number, v: number, out: THREE.Vector3): THREE.Vector3 { + const lat = (v - 0.5) * Math.PI; + const y = Math.sin(lat); + const r = Math.cos(lat); + const lon = (u - 0.5) * Math.PI * 2; + return out.set(Math.cos(lon) * r, y, Math.sin(lon) * r); +} + +/** + * The environment as linear RGB floats (3 per texel, row 0 = straight down). + * Pure arithmetic — no DOM, no GL — which is what makes it testable. + */ +export function equirectRadiance( + key: THREE.Vector3, + fill: THREE.Vector3, + w = ENV_W, + h = ENV_H, +): Float32Array { + const out = new Float32Array(w * h * 3); + const k = key.clone().normalize(); + const f = fill.clone().normalize(); + const d = new THREE.Vector3(); + for (let j = 0; j < h; j++) { + const v = (j + 0.5) / h; + for (let i = 0; i < w; i++) { + dirAt((i + 0.5) / w, v, d); + // floor -> sky gradient, with the horizon band sitting on top of it + const up = smoothstep(-0.12, 0.6, d.y); + const band = Math.exp(-((d.y / HORIZON_WIDTH) ** 2)); + const kd = Math.max(0, d.dot(k)) ** KEY_TIGHT; + const fd = Math.max(0, d.dot(f)) ** FILL_TIGHT; + const o = (j * w + i) * 3; + for (let c = 0; c < 3; c++) { + out[o + c] = + FLOOR_RGB[c] + (SKY_RGB[c] - FLOOR_RGB[c]) * up + + HORIZON_RGB[c] * band + + KEY_RGB[c] * kd + + FILL_RGB[c] * fd; + } + } + } + return out; +} + +/** Pack the radiance into a half-float equirect DataTexture PMREM can consume. */ +export function createEnvTexture(key: THREE.Vector3, fill: THREE.Vector3): THREE.DataTexture { + const rgb = equirectRadiance(key, fill); + const data = new Uint16Array(ENV_W * ENV_H * 4); + for (let p = 0; p < ENV_W * ENV_H; p++) { + data[p * 4 + 0] = THREE.DataUtils.toHalfFloat(rgb[p * 3 + 0]); + data[p * 4 + 1] = THREE.DataUtils.toHalfFloat(rgb[p * 3 + 1]); + data[p * 4 + 2] = THREE.DataUtils.toHalfFloat(rgb[p * 3 + 2]); + data[p * 4 + 3] = THREE.DataUtils.toHalfFloat(1); + } + // Half-float, not float: linear filtering of FloatType textures needs + // OES_texture_float_linear, which PMREM's equirect blit assumes. HalfFloat is the + // format three's own RGBELoader hands PMREM, so it's the well-trodden path. + const tex = new THREE.DataTexture(data, ENV_W, ENV_H, THREE.RGBAFormat, THREE.HalfFloatType); + tex.mapping = THREE.EquirectangularReflectionMapping; + tex.minFilter = THREE.LinearFilter; + tex.magFilter = THREE.LinearFilter; + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.ClampToEdgeWrapping; + tex.generateMipmaps = false; + tex.needsUpdate = true; + return tex; +} + +/** + * Build the PMREM environment. Call once, assign to `scene.environment`. + * The source texture and the generator are both thrown away — only the prefiltered + * cube-UV render target survives, and that's what every MeshStandardMaterial samples. + */ +export function createEnvironment( + gl: THREE.WebGLRenderer, + key: THREE.Vector3, + fill: THREE.Vector3, +): THREE.Texture { + const src = createEnvTexture(key, fill); + const pmrem = new THREE.PMREMGenerator(gl); + pmrem.compileEquirectangularShader(); + const rt = pmrem.fromEquirectangular(src); + src.dispose(); + pmrem.dispose(); // frees the generator's scratch; `rt.texture` stays valid + return rt.texture; +} diff --git a/fktry/src/render/era.ts b/fktry/src/render/era.ts index 836ddea..1e30443 100644 --- a/fktry/src/render/era.ts +++ b/fktry/src/render/era.ts @@ -39,12 +39,20 @@ export const ERA_SKIN: Record = { /** * machine id -> era, via the tech that unlocks it. Anything unmapped is starting * equipment (no tech gates it) and takes DEFAULT_ERA. + * + * TIE-BREAK: the FIRST tech to claim an id wins, matching LANE-UI's `indexTech` + * (`src/ui/research.ts`: "First tech to claim an id wins"). Two techs claiming one + * machine is a data bug either way, but we must both blame the same tech — otherwise + * the build bar padlocks a machine in one era while the ghost's era tell names another, + * on the same screen. Round 4's NOTES claimed this lane already matched them; it did + * not — this used to be an unconditional `set`, i.e. LAST wins. Latent so far only + * because `data/tech.json` currently has zero duplicate unlocks across its 25 techs. */ export function eraIndex(data: GameData): Map { const out = new Map(); const machines = new Set(data.machines.map((m) => m.id)); for (const t of data.tech) { - for (const u of t.unlocks) if (machines.has(u)) out.set(u, t.era); + for (const u of t.unlocks) if (machines.has(u) && !out.has(u)) out.set(u, t.era); } return out; } diff --git a/fktry/src/render/index.ts b/fktry/src/render/index.ts index 03467f3..9a3f1d6 100644 --- a/fktry/src/render/index.ts +++ b/fktry/src/render/index.ts @@ -23,6 +23,7 @@ import { WildlifeLayer, harassedIn } from './wildlife'; import { Juice } from './juice'; import { BreakRoom } from './breakroom'; import { WORLD } from './palette'; +import { createEnvironment } from './env'; import { centerOf } from './coords'; const KEY_INTENSITY = 1.9; @@ -30,6 +31,21 @@ const FILL_INTENSITY = 0.7; const AMBIENT_INTENSITY = 1.1; /** ACES rolls mid-tones down hard; this lifts the factory back off a near-black ground. */ const BASE_EXPOSURE = 1.35; +/** + * How much of the new environment (env.ts) the WORLD's own materials take — belts, belt + * items, placeholders, ghosts, wildlife, the cabinet. Deliberately gentle: those were + * art-directed across six rounds under no IBL at all, and relighting them wholesale is + * a regression dressed as a fix. This is a dusting for coherence so a placeholder and a + * GLB read as standing in the same room. Hot-swapped GLBs opt into a much stronger dose + * of their own (`GLB_ENV_INTENSITY` in registry.ts) because they are the metals — see + * the arithmetic at the top of env.ts. + * + * Measured while tuning, and worth writing down: `AmbientLight` intensity is nearly + * inert in this scene. Sweeping it 0.55 -> 1.1 moved a placeholder body by ~7% and the + * high-metalness GLBs by <1%, because ambient only feeds the diffuse term that + * metalness has already zeroed. Left at its round-1 value rather than "compensated". + */ +const ENV_WORLD_INTENSITY = 0.5; export function createRenderer(): Renderer { let gl: THREE.WebGLRenderer; @@ -67,6 +83,18 @@ export function createRenderer(): Renderer { /** Dev-only frame-cost sampler. Measures CPU ms per frame, which — unlike frame * RATE — stays honest even when the tab is backgrounded and rAF is throttled. */ const stats = { ms: 0, avg: 0, frames: 0 }; + /** + * Round 7 boot timing, so "GLBs no longer block first paint" is a number and not a + * vibe. Dev-only, and every write sits inside a literal `import.meta.env.DEV` so + * Rollup folds the branches out of the bundle (round 3 learned that the hard way). + * + * `readyAt` is the honest one: main.ts awaits `renderer.init()` before it starts the + * frame loop, so the moment init returns is the earliest a frame can exist. `firstAt` + * is the real first `gl.render`, but rAF is throttled hard in a background tab, so + * read it only on a focused window. + */ + const perf = { initMs: 0, readyAt: 0, firstAt: 0 }; + let firstFrameDone = false; /** * Which entity the inspector has open, if any. @@ -154,6 +182,7 @@ export function createRenderer(): Renderer { return { async init(container: HTMLElement, data: GameData) { + const t0 = performance.now(); defs = new Map(data.machines.map((m) => [m.id, m])); host = container; @@ -194,10 +223,23 @@ export function createRenderer(): Renderer { scene.add(fill); scene.add(new THREE.AmbientLight(0x8f8fbf, AMBIENT_INTENSITY)); + // THE ENVIRONMENT (round 7 root-cause fix). Until now `scene.environment` was + // null, which for a metal is not "no reflections" but "reflects the void" — and + // 16 of the 27 shipped GLBs are metalness >= 0.5, where diffuse is nearly gone + // and indirect specular is the whole material. See env.ts for the arithmetic. + // Built from the real light directions so the reflection agrees with the shading. + const env = createEnvironment(gl, key.position, fill.position); + scene.environment = env; + scene.environmentIntensity = ENV_WORLD_INTENSITY; + rig = new CameraRig(container, gl.domElement); registry = new AssetRegistry(); - await registry.init(data); + // Hot-swapped GLBs get their own, much stronger, share of the environment — they + // are the metals. Must be handed over BEFORE init kicks the first probe off. + registry.setEnvironment(env); + // Synchronous: placeholders exist the instant this returns, GLBs hot-swap in. + registry.init(data); juice = new Juice(); belts = new BeltLayer(registry, defs, topo); @@ -270,6 +312,12 @@ export function createRenderer(): Renderer { }; addEventListener('resize', onResize); new ResizeObserver(onResize).observe(container); + + if (import.meta.env.DEV) { + perf.readyAt = performance.now(); + perf.initMs = perf.readyAt - t0; + (window as unknown as { __fktryPerf: unknown }).__fktryPerf = perf; + } }, render(snap: SimSnapshot, alpha: number) { @@ -321,6 +369,11 @@ export function createRenderer(): Renderer { gl.render(scene, rig.camera); showroom?.updateLabels(rig.camera, host); + if (import.meta.env.DEV && !firstFrameDone) { + firstFrameDone = true; + perf.firstAt = performance.now(); + } + if (debug) { stats.ms = performance.now() - t0; stats.frames++; diff --git a/fktry/src/render/registry.test.ts b/fktry/src/render/registry.test.ts new file mode 100644 index 0000000..0c38570 --- /dev/null +++ b/fktry/src/render/registry.test.ts @@ -0,0 +1,371 @@ +/** + * LANE-RENDER — source-level tests for the registry's pure maths. + * + * This lane shipped six rounds with zero tests, on the reasoning that a renderer needs + * eyes. Most of it does. But three things in here are arithmetic, they are what a bad + * asset export or a data edit silently breaks, and none of them need a GPU: + * + * 1. fit-to-footprint normalisation — round 6's deploy-blocking regression was a + * scale bug, and the scale it collapsed is computed right here + * 2. accent derivation precedence — `def.accent` > derived-from-outputs > kind table, + * a rule that has been re-litigated in NOTES three rounds running + * 3. era lookup — cross-lane: the padlock LANE-UI draws and the era tell this lane + * draws must name the same tech + * + * Node environment on purpose. happy-dom returns null for `getContext('webgl')`, so + * anything that needs a renderer is out of scope here and stays an eyeball check in + * `?showroom`. Everything below runs on plain three.js maths objects. + */ +import { describe, expect, it } from 'vitest'; +import * as THREE from 'three'; +import { accentFor, bodyFor, normaliseGLB } from './registry'; +import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, lightnessOf } from './palette'; +import { DEFAULT_ERA, ERA_SKIN, applyEraSkin, eraIndex } from './era'; +import { ENV_H, ENV_W, dirAt, equirectRadiance } from './env'; +import type { GameData, ItemDef, MachineDef, MachineKind, RecipeDef, TechDef } from '../contracts'; + +// ------------------------------------------------------------------ fixtures + +function machine(over: Partial & { id: string }): MachineDef { + return { + kind: 'crafter', + name: over.id.toUpperCase(), + codex: `4:${over.id}`, + asset: over.id, + footprint: { x: 2, y: 2 }, + recipes: [], + powerDraw: 0, + ...over, + }; +} + +function item(id: string, color: string): ItemDef { + return { id, name: id.toUpperCase(), codex: `2:${id}`, tier: 0, color }; +} + +function recipe(id: string, out: string): RecipeDef { + return { id, machine: 'm', inputs: {}, outputs: { [out]: 1 }, ticks: 30, bandwidth: 1 }; +} + +function data(over: Partial): GameData { + return { items: [], machines: [], recipes: [], tech: [], commissions: [], ...over }; +} + +/** A box of an exact size, so the fit maths has something unambiguous to chew on. */ +function box(w: number, h: number, d: number): THREE.Object3D { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d)); + m.position.set(0, h / 2, 0); // sitting on the ground, like an export usually is + return m; +} + +function worldSize(o: THREE.Object3D): THREE.Vector3 { + o.updateMatrixWorld(true); + return new THREE.Box3().setFromObject(o).getSize(new THREE.Vector3()); +} + +function worldBox(o: THREE.Object3D): THREE.Box3 { + o.updateMatrixWorld(true); + return new THREE.Box3().setFromObject(o); +} + +// ------------------------------------------------------------------ normalisation + +describe('normaliseGLB — fit to footprint', () => { + it('scales an arbitrary export down to 92% of its footprint', () => { + // MODELBEAST exports at whatever scale it likes; a 10-unit mesh on a 2x2 machine + // must land at 2 * 0.92 = 1.84 wide, not at 10. + const out = normaliseGLB(box(10, 4, 10), machine({ id: 'big', footprint: { x: 2, y: 2 } }), []); + const s = worldSize(out); + expect(s.x).toBeCloseTo(1.84, 5); + expect(s.z).toBeCloseTo(1.84, 5); + expect(s.y).toBeCloseTo(0.736, 5); // uniform scale: 4 * (1.84 / 10) + }); + + it('scales UP a tiny export just as happily', () => { + const out = normaliseGLB(box(0.02, 0.02, 0.02), machine({ id: 'tiny' }), []); + expect(worldSize(out).x).toBeCloseTo(1.84, 5); + }); + + it('is uniform and fits the TIGHTER axis, so nothing overhangs its tiles', () => { + // 3x2 footprint => targets 2.76 wide x 1.84 deep. A 4-wide, 2-deep mesh is limited + // by WIDTH (2.76/4 = 0.69), which is tighter than depth (1.84/2 = 0.92). + const out = normaliseGLB(box(4, 1, 2), machine({ id: 'wide', footprint: { x: 3, y: 2 } }), []); + const s = worldSize(out); + expect(s.x).toBeCloseTo(2.76, 5); // 4 * 0.69 + expect(s.z).toBeCloseTo(1.38, 5); // 2 * 0.69 — under its 1.84 budget, not stretched + expect(s.x / 4).toBeCloseTo(s.z / 2, 6); // same factor on both axes: no distortion + }); + + it('never fits below a 0.4 floor, so a 1x1 asset is still visible', () => { + // Math.max(0.4, 1 * 0.92) = 0.92 for a 1x1 belt-sized machine. + const out = normaliseGLB(box(3, 3, 3), machine({ id: 'unit', footprint: { x: 1, y: 1 } }), []); + expect(worldSize(out).x).toBeCloseTo(0.92, 5); + }); + + it('sits the asset ON the ground and centres it on its tile', () => { + const src = box(6, 6, 6); + src.position.set(37, -12, -8); // an export authored miles from its own origin + const bb = worldBox(normaliseGLB(src, machine({ id: 'offset' }), [])); + expect(bb.min.y).toBeCloseTo(0, 5); // base on the ground plane, never sunk or floating + expect((bb.min.x + bb.max.x) / 2).toBeCloseTo(0, 5); + expect((bb.min.z + bb.max.z) / 2).toBeCloseTo(0, 5); + }); + + it('survives a degenerate axis instead of emitting NaN', () => { + // A flat card (zero depth) must not produce a NaN or zero scale — a NaN transform + // silently removes the mesh from the frustum and the machine just is not there. + const flat = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)); // zero-thickness on z + const out = normaliseGLB(flat, machine({ id: 'card' }), []); + expect(Number.isFinite(out.scale.x)).toBe(true); + expect(out.scale.x).toBeGreaterThan(0); + expect(Number.isFinite(out.position.y)).toBe(true); + }); + + it('wraps an ANIMATED asset so the mixer and the fit maths cannot fight', () => { + // A scaled/offset root would fight root-motion tracks, so a clipped GLB gets a + // wrapper: mixer drives the inner node, wrapper does the normalising. + const clip = new THREE.AnimationClip('idle', 1, []); + const animated = normaliseGLB(box(2, 2, 2), machine({ id: 'rig' }), [clip]); + expect(animated.type).toBe('Group'); + expect(animated.userData.fktryClips).toEqual([clip]); + expect(animated.scale.x).toBe(1); // the WRAPPER is unscaled; the child carries it + expect(animated.children[0].scale.x).toBeCloseTo(0.92, 5); + + const still = normaliseGLB(box(2, 2, 2), machine({ id: 'static' }), []); + expect(still.type).toBe('Mesh'); // no clips: no wrapper, the mesh IS the root + expect(still.scale.x).toBeCloseTo(0.92, 5); + }); + + it('perches the accent beacon only when an accent is supplied', () => { + // The instanceable path (belts) passes no accent: an instanced belt has no room for + // a beacon and no accent to show. + const withBeacon = normaliseGLB(box(2, 2, 2), machine({ id: 'm' }), [], 0xff00ff); + const beacons: THREE.Mesh[] = []; + withBeacon.traverse((o) => { if (o.userData.fktryBeacon) beacons.push(o as THREE.Mesh); }); + expect(beacons).toHaveLength(1); + expect((beacons[0].material as THREE.MeshStandardMaterial).emissive.getHex()).toBe(0xff00ff); + // Perched ON TOP of the normalised asset, not buried inside it. The 2x2 fit puts + // the body's crown at 0.92, so the beacon must clear that. + expect(beacons[0].position.y).toBeGreaterThan(0.92); + expect(typeof withBeacon.userData.fktryIdle).toBe('function'); + + const bare = normaliseGLB(box(2, 2, 2), machine({ id: 'm' }), []); + let found = false; + bare.traverse((o) => { if (o.userData.fktryBeacon) found = true; }); + expect(found).toBe(false); + }); + + it('casts and receives shadows on every mesh it touches', () => { + const out = normaliseGLB(box(2, 2, 2), machine({ id: 'm' }), []); + out.traverse((o) => { + if ((o as THREE.Mesh).isMesh && !o.userData.fktryBeacon) { + expect(o.castShadow).toBe(true); + expect(o.receiveShadow).toBe(true); + } + }); + }); +}); + +// ------------------------------------------------------------------ accent precedence + +describe('accentFor — the ONE emissive element', () => { + const melt = item('melt', '#ff7a3f'); + const ore = item('ore', '#1a1a22'); // MDAT ORE: near-black by design (codex) + const gd = data({ + items: [melt, ore], + recipes: [recipe('r-melt', 'melt'), recipe('r-ore', 'ore')], + }); + + it('1. explicit art-direction wins outright, even when it is dark', () => { + // v4 rule: DATA choosing a dark accent is a DECISION. The legibility floor below + // exists for derived colours, which are incidental. + const dark = '#101018'; + expect(lightnessOf(dark)).toBeLessThan(ACCENT_MIN_L); // the floor would have rejected it + const def = machine({ id: 'm', accent: dark, recipes: ['r-melt'] } as Partial & { id: string }); + expect(accentFor(def, gd)).toBe(new THREE.Color(dark).getHex()); + }); + + it('2. otherwise the colour of what the machine MAKES', () => { + const def = machine({ id: 'm', recipes: ['r-melt'] }); + expect(accentFor(def, gd)).toBe(new THREE.Color('#ff7a3f').getHex()); + }); + + it('2b. a derived colour below the legibility floor falls through to the kind', () => { + // The seam extractor's CRT head would otherwise be a black panel on a black chassis. + const def = machine({ id: 'm', kind: 'extractor', recipes: ['r-ore'] }); + expect(accentFor(def, gd)).toBe(ACCENT_BY_KIND.extractor); + }); + + it('2c. skips the too-dark output and takes the first legible one', () => { + const def = machine({ id: 'm', kind: 'extractor', recipes: ['r-ore', 'r-melt'] }); + expect(accentFor(def, gd)).toBe(new THREE.Color('#ff7a3f').getHex()); + }); + + it('3. no recipes at all -> the per-kind accent', () => { + for (const kind of Object.keys(ACCENT_BY_KIND) as MachineKind[]) { + expect(accentFor(machine({ id: 'm', kind }), gd)).toBe(ACCENT_BY_KIND[kind]); + } + }); + + it('ignores a recipe id that no recipe answers to, rather than throwing', () => { + const def = machine({ id: 'm', kind: 'shipper', recipes: ['nope'] }); + expect(accentFor(def, gd)).toBe(ACCENT_BY_KIND.shipper); + }); + + it('does NOT read def.color — v3 ruled that the chassis', () => { + // The whole round-2 argument in NOTES, pinned: wiring `color` to the emissive makes + // every machine glow muddy neutral and deletes the two-material rule. + const def = machine({ id: 'm', kind: 'crafter', color: '#8a7a32' } as Partial & { id: string }); + expect(accentFor(def, gd)).toBe(ACCENT_BY_KIND.crafter); + expect(bodyFor(def)).toBe(new THREE.Color('#8a7a32').getHex()); + }); +}); + +describe('bodyFor — the grimy chassis', () => { + it('takes DATA\'s authored colour when there is one', () => { + expect(bodyFor(machine({ id: 'm', color: '#b9bec4' } as Partial & { id: string }))) + .toBe(new THREE.Color('#b9bec4').getHex()); + }); + + it('falls back to the per-kind neutral', () => { + for (const kind of Object.keys(BODY_BY_KIND) as MachineKind[]) { + expect(bodyFor(machine({ id: 'm', kind }))).toBe(BODY_BY_KIND[kind]); + } + }); +}); + +// ------------------------------------------------------------------ era lookup + +describe('eraIndex — which decade skins a machine', () => { + const tech = (id: string, era: TechDef['era'], unlocks: string[]) => + ({ id, era, cost: {}, unlocks }) as TechDef; + + const gd = data({ + machines: [machine({ id: 'bottler' }), machine({ id: 'puller' }), machine({ id: 'starter' })], + tech: [ + tech('disc-bottling', 'disc', ['bottler']), + tech('reel-telecine', 'reel', ['puller', 'a-recipe-not-a-machine']), + ], + }); + + it('maps a machine to the era of the tech that unlocks it', () => { + const idx = eraIndex(gd); + expect(idx.get('bottler')).toBe('disc'); + expect(idx.get('puller')).toBe('reel'); + }); + + it('leaves starting equipment unmapped, so it takes the default era', () => { + expect(eraIndex(gd).has('starter')).toBe(false); + expect(eraIndex(gd).get('starter') ?? DEFAULT_ERA).toBe(DEFAULT_ERA); + }); + + it('ignores unlock ids that are not machines (recipes, upgrades)', () => { + expect(eraIndex(gd).has('a-recipe-not-a-machine')).toBe(false); + }); + + it('FIRST tech wins a contested machine — LANE-UI breaks the tie the same way', () => { + // Two techs claiming one machine is a data bug either way, but if this lane and + // `src/ui/research.ts` disagree, the build bar padlocks a machine in one era while + // the ghost's era tell names another, on the same screen. (This was genuinely + // wrong until round 7: an unconditional `set` meant LAST tech won here.) + const contested = data({ + machines: [machine({ id: 'shared' })], + tech: [tech('t-reel', 'reel', ['shared']), tech('t-fortress', 'fortress', ['shared'])], + }); + expect(eraIndex(contested).get('shared')).toBe('reel'); + }); +}); + +describe('applyEraSkin — chassis only', () => { + it('is a true identity for stream, so today\'s look is byte-for-byte unchanged', () => { + const mat = new THREE.MeshStandardMaterial({ color: 0x5e5148, roughness: 0.85, metalness: 0.15 }); + applyEraSkin(mat, 'stream'); + expect(mat.color.getHex()).toBe(new THREE.Color(0x5e5148).getHex()); + expect(mat.roughness).toBe(ERA_SKIN.stream.roughness); + expect(mat.metalness).toBe(ERA_SKIN.stream.metalness); + }); + + it('multiplies the tint rather than replacing the chassis colour', () => { + const base = new THREE.Color(0x808080); + const mat = new THREE.MeshStandardMaterial({ color: base.getHex() }); + applyEraSkin(mat, 'reel'); + const expected = base.clone().multiply(new THREE.Color(ERA_SKIN.reel.tint)); + expect(mat.color.r).toBeCloseTo(expected.r, 6); + expect(mat.color.b).toBeCloseTo(expected.b, 6); + expect(mat.color.b).toBeLessThan(mat.color.r); // sepia: blue pulled down + }); + + it('falls back to the default skin for an era it has never heard of', () => { + const mat = new THREE.MeshStandardMaterial({ color: 0xffffff }); + applyEraSkin(mat, 'vhs' as unknown as TechDef['era']); + expect(mat.roughness).toBe(ERA_SKIN[DEFAULT_ERA].roughness); + }); +}); + +// ------------------------------------------------------------------ the environment + +describe('equirectRadiance — the procedural IBL (env.ts)', () => { + const key = new THREE.Vector3(18, 30, 12); + const fill = new THREE.Vector3(-14, 8, -16); + const rgb = equirectRadiance(key, fill); + const at = (i: number, j: number) => { + const o = (j * ENV_W + i) * 3; + return 0.2126 * rgb[o] + 0.7152 * rgb[o + 1] + 0.0722 * rgb[o + 2]; + }; + + it('fills the whole equirect with finite, non-negative radiance', () => { + expect(rgb).toHaveLength(ENV_W * ENV_H * 3); + for (let k = 0; k < rgb.length; k++) { + expect(Number.isFinite(rgb[k])).toBe(true); + expect(rgb[k]).toBeGreaterThanOrEqual(0); + } + }); + + it('is a dark industrial room, not a sunny day', () => { + // Codex §8: base world in desaturated industrial neutrals. If this ever averages + // bright, every chassis in the game turns into a lightbulb. + let sum = 0; + for (let k = 0; k < rgb.length; k++) sum += rgb[k]; + expect(sum / rgb.length).toBeLessThan(0.15); + }); + + it('puts the floor darker than the sky, with a horizon band between', () => { + const floor = at(0, 0); // row 0 = straight down + const sky = at(0, ENV_H - 1); // last row = straight up + const horizon = at(0, ENV_H / 2); + expect(floor).toBeLessThan(sky); + expect(horizon).toBeGreaterThan(sky); // the band metal edges catch is the brightest + expect(floor).toBeLessThan(0.03); // near-black, keyed off the crust + }); + + it('is cool-tinted overhead — the bitstream haze, not daylight', () => { + const o = ((ENV_H - 1) * ENV_W) * 3; + expect(rgb[o + 2]).toBeGreaterThan(rgb[o + 0]); // blue > red + }); + + it('puts its brightest blob where the KEY LIGHT actually is', () => { + // The whole point of passing the light directions in: a reflection that disagrees + // with the shading reads as a bug, not as lighting. + const k = key.clone().normalize(); + let bestI = -1, bestJ = -1, best = -1; + for (let j = 0; j < ENV_H; j++) { + for (let i = 0; i < ENV_W; i++) { + const l = at(i, j); + if (l > best) { best = l; bestI = i; bestJ = j; } + } + } + const d = dirAt((bestI + 0.5) / ENV_W, (bestJ + 0.5) / ENV_H, new THREE.Vector3()); + expect(d.dot(k)).toBeGreaterThan(0.97); // within ~14 degrees of the key direction + }); + + it('follows three.js\'s own equirect convention, so nothing lands mirrored', () => { + // equirect_uv.glsl: u = atan2(z, x) / 2PI + 0.5, v = asin(y) / PI + 0.5 + const v = new THREE.Vector3(); + expect(dirAt(0.5, 1, v).y).toBeCloseTo(1, 6); // v=1 -> straight up + expect(dirAt(0.5, 0, v).y).toBeCloseTo(-1, 6); // v=0 -> straight down + dirAt(0.5, 0.5, v); + expect(v.x).toBeCloseTo(1, 6); // u=0.5 -> +x on the horizon + dirAt(0.75, 0.5, v); + expect(v.z).toBeCloseTo(1, 6); // u=0.75 -> +z + }); +}); diff --git a/fktry/src/render/registry.ts b/fktry/src/render/registry.ts index f851079..762b275 100644 --- a/fktry/src/render/registry.ts +++ b/fktry/src/render/registry.ts @@ -226,22 +226,62 @@ export function beltPlateGeometry(): THREE.BufferGeometry { // ---------------------------------------------------------------- GLB legibility /** - * GLB legibility floor (round 6, the round's centrepiece). + * GLB legibility treatment. Round 6 shipped half of this; round 7 added the half that + * was actually load-bearing and cut the other half back to size. * * MODELBEAST bakes albedo into textures and ships NO emissive, so at game zoom on a - * near-black ground a real asset reads as a dark hole — the placeholder two-material - * rule (dark body + one emissive accent) silently dies the moment a GLB lands. The fix - * has two halves, both keyed off the same data the placeholders use: + * near-black ground a real asset read as a dark hole. Round 6 answered that with a + * texel-following emissive floor at 0.42 — which worked, but ROUND 7 FOUND THE ROOT + * CAUSE it was masking: 13 of the 27 shipped GLBs carry `metallicFactor >= 0.8` (16 at + * >= 0.5) and 21 have no metallic-roughness texture, and a metal's diffuse term is + * `albedo * (1 - metalness)` — so those materials had almost no diffuse AND, with + * `scene.environment === null`, nothing to reflect. They were near-black by arithmetic. + * The emissive floor was cosmetics over a lighting hole; env.ts fills the hole. * - * 1. a texel-following emissive FLOOR (this function): self-illuminate the baked albedo - * (`emissiveMap = map`) so the brightest texels lift off the ground and nothing reads - * as a pure void, without washing the texture down to a flat grey. A material that - * already ships its own glow is respected, not overwritten. - * 2. an accent BEACON (`addAccentBeacon`): the ONE impossible element, in the colour of + * So the treatment is now three things, all keyed off data the placeholders already use: + * + * 1. ENVIRONMENT: the asset's own, stronger share of the procedural IBL. `envMap` is + * assigned per material rather than inherited from `scene.environment` on purpose — + * three.js overrides `material.envMapIntensity` with `scene.environmentIntensity` + * whenever a material falls back to the scene (WebGLRenderer: `material.envMap === + * null && scene.environment !== null`), so assigning it explicitly is the ONLY way + * the assets can be dosed differently from six rounds of hand-tuned placeholder art. + * 2. EMISSIVE FLOOR: still here, at less than half the round-6 value (see below). + * 3. ACCENT BEACON (`addAccentBeacon`): the ONE impossible element, in the colour of * what the machine makes — the same accent derivation the placeholder uses. */ -const GLB_EMISSIVE_FLOOR = 0.42; -function floorMaterials(root: THREE.Object3D): void { + +/** + * The assets' share of the environment. High because the assets are unrealistically + * metallic: `metallicFactor: 1` with no MR texture is what the mesh generators emit by + * default, not an art decision — the codex describes grimy chassis, not chrome. Until + * that is fixed upstream, indirect specular is nearly the whole material response for + * half the catalog, so this is where their light has to come from. **If MODELBEAST ever + * ships sane metalness, drop this to ~1.0** (measured alternative: clamping metalness to + * 0.45 at load for the 21 materials with no MR texture reaches the same luminance at + * `envMapIntensity: 1` and is visually near-identical — not shipped, because rewriting + * an asset's authored PBR values is a bigger claim than lighting the room it stands in). + */ +const GLB_ENV_INTENSITY = 2.6; + +/** + * Round 6 shipped 0.42, and with a real environment that is now overexposure: it flattens + * the shading the IBL just bought back and pushes every chassis toward a self-lit sticker. + * Measured across the 20 machines visible at the showroom close-up frame (mean sRGB + * luminance of each body, plain crust ~24 for scale): + * + * env + floor 0.00 -> darkest machine 38 (already clear of the floor — no holes) + * env + floor 0.18 -> darkest 54, demosaicer 77, mv-extractor 71, neural-keyer 81 + * no env, floor 0.42 (round 6) -> darkest 57, demosaicer 75, mv-extractor 70, keyer 85 + * + * So 0.18 lands the catalog within a few percent of round 6's overall brightness while + * the IBL, not a flat self-glow, does the shaping. NOT retired: a genuinely black texel + * still needs the "never a hole" guarantee, and it's what keeps a machine legible when a + * brownout drops tone-mapping exposure to 0.7. + */ +const GLB_EMISSIVE_FLOOR = 0.18; + +function treatGLBMaterials(root: THREE.Object3D, env: THREE.Texture | null): void { root.traverse((o) => { const m = o as THREE.Mesh; if (!m.isMesh) return; @@ -249,16 +289,23 @@ function floorMaterials(root: THREE.Object3D): void { for (const raw of mats) { const mat = raw as THREE.MeshStandardMaterial; if (!mat || !(mat as { isMeshStandardMaterial?: boolean }).isMeshStandardMaterial) continue; + if (env) { + mat.envMap = env; + mat.envMapIntensity = GLB_ENV_INTENSITY; + } const e = mat.emissive; const alreadyLit = !!e && e.r + e.g + e.b > 0.03 && (mat.emissiveIntensity ?? 0) > 0.02; - if (alreadyLit) continue; // MODELBEAST authored a glow here — leave it be - if (mat.map) { - mat.emissiveMap = mat.map; // white * map = the texture, self-lit - mat.emissive = new THREE.Color(0xffffff); - } else { - mat.emissive = mat.color.clone(); // no texture: self-colour floor + if (!alreadyLit) { + // MODELBEAST authored no glow here, so give it the floor. (If it DID author one, + // it still gets the environment above — only the emissive is left alone.) + if (mat.map) { + mat.emissiveMap = mat.map; // white * map = the texture, self-lit + mat.emissive = new THREE.Color(0xffffff); + } else { + mat.emissive = mat.color.clone(); // no texture: self-colour floor + } + mat.emissiveIntensity = GLB_EMISSIVE_FLOOR; } - mat.emissiveIntensity = GLB_EMISSIVE_FLOOR; mat.needsUpdate = true; } }); @@ -299,8 +346,12 @@ function addAccentBeacon(root: THREE.Object3D, def: MachineDef, accent: number): // ---------------------------------------------------------------- GLB handling -/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */ -function normaliseGLB( +/** + * Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. + * Exported for `registry.test.ts` — the fit maths is the thing a bad asset export + * silently breaks, and it needs no GPU to check. + */ +export function normaliseGLB( src: THREE.Object3D, def: MachineDef, clips: THREE.AnimationClip[], @@ -409,13 +460,32 @@ export class AssetRegistry { * its decoder handed over. Uncompressed GLBs keep loading unchanged. */ private loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder); private timer: number | null = null; + /** The prefiltered environment (env.ts), handed over by index.ts before init. */ + private env: THREE.Texture | null = null; + + /** Give hot-swapped GLBs their own share of the IBL. Call before `init`. */ + setEnvironment(env: THREE.Texture | null): void { + this.env = env; + } /** Which era a machine is skinned as (derived from tech unlocks; stream by default). */ eraOf(machineId: string): Era { return this.eras.get(machineId) ?? DEFAULT_ERA; } - async init(data: GameData): Promise { + /** + * Build every placeholder synchronously, then kick the GLB probe off WITHOUT awaiting + * it (round 7). + * + * This used to `await this.probeAll()`, and main.ts awaits `renderer.init()` before it + * starts the frame loop — so the first frame of the game was gated on 27 HEAD requests + * plus ~28 MB of GLB fetch/parse. Placeholders are honorable (MASTERPLAN) and they are + * already fully built one line above: there is nothing to wait for. Assets now land + * through exactly the same hot-swap path a mid-session MODELBEAST drop uses, which is + * a path this lane has shipped and verified since round 1 — the first second of a boot + * is just the shortest possible version of it. + */ + init(data: GameData): void { this.eras = eraIndex(data); for (const def of data.machines) { this.defs.set(def.asset, def); @@ -424,7 +494,7 @@ export class AssetRegistry { const entry = this.placeholderEntry(def, accent, bodyFor(def), this.eraOf(def.id)); this.entries.set(def.asset, entry); } - await this.probeAll(); + void this.probeAll(); // deliberately not awaited — see above if (import.meta.env.DEV) { this.timer = setInterval(() => void this.probeAll(), POLL_MS) as unknown as number; } @@ -468,10 +538,10 @@ export class AssetRegistry { const prev = this.entries.get(def.asset); const version = (prev?.version ?? 0) + 1; const template = gltf.scene; - // Round 6 legibility pass: lift the baked-dark chassis off the void ONCE on the - // shared template. EntityLayer clones these materials per instance, so per-entity - // heat/scram still works on top of the floor. - floorMaterials(template); + // Legibility treatment, run ONCE on the shared template: environment + emissive + // floor. EntityLayer clones these materials per instance, so per-entity heat/scram + // still works on top (and `clone()` carries envMap/envMapIntensity across). + treatGLBMaterials(template, this.env); const clips = gltf.animations ?? []; const accent = this.accents.get(def.asset) ?? ACCENT_BY_KIND[def.kind] ?? 0x3fffe0; this.entries.set(def.asset, { @@ -505,7 +575,7 @@ export class AssetRegistry { * would be a black panel on a black chassis. Anything below the legibility floor falls * through to the kind accent (for the extractor, CRT blue, as the codex describes it). */ -function accentFor(def: MachineDef, data: GameData): number { +export function accentFor(def: MachineDef, data: GameData): number { // v4: explicit art-direction wins outright, with no legibility floor. DATA choosing a // dark accent is a decision; the floor below exists for DERIVED colours, which are // incidental (item colours are authored for chips, not for machine accents). Same @@ -534,7 +604,7 @@ function accentFor(def: MachineDef, data: GameData): number { * deletes that element. Read as bodies they are exactly right, and they art-direct 21 * machines that were previously 7 per-kind greys. Flagged for the orchestrator. */ -function bodyFor(def: MachineDef): number { +export function bodyFor(def: MachineDef): number { if (def.color) return new THREE.Color(def.color).getHex(); return BODY_BY_KIND[def.kind] ?? 0x3b3944; }