[render] root-cause fix: the world had no environment, so every metal read black
scene.environment was null for seven rounds. diffuse = albedo * (1 - metalness), so a metal is nothing but reflected surroundings — and 13 of the 27 shipped GLB materials are metallicFactor >= 0.8 (16 at >= 0.5, 21 with no MR texture). Round 6's GLB_EMISSIVE_FLOOR = 0.42 was cosmetics over a lighting hole. - env.ts (new): procedural 128x64 half-float equirect through PMREMGenerator. Zero bytes shipped, no asset fetch. Dark industrial room per codex 8; key/fill blob directions passed in from index.ts's real lights, never duplicated. - GLB materials take envMap explicitly (three overrides material.envMapIntensity with scene.environmentIntensity on the scene-fallback path), so the assets can be dosed at 2.6 while six rounds of hand-tuned world art keep 0.5. - emissive floor 0.42 -> 0.18: re-tuned, not retired. Still the "never a hole" guarantee and still what carries a machine through a brownout. - registry.init no longer awaits probeAll: renderer.init 284ms -> 28ms, ready at 339ms -> 88ms. Placement juice suppressed on asset-version bumps so the factory doesn't re-place itself a second into every boot; a def change still pops. - era.ts: eraIndex now breaks a contested unlock FIRST-tech-wins, matching LANE-UI's indexTech. It was LAST-tech-wins despite round 4's NOTES claiming otherwise; the tests found it. - registry.test.ts (new): 31 tests, this lane's first. 481 -> 512 repo tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
20d2bcbbfe
commit
72842e270a
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
146
fktry/src/render/env.ts
Normal file
146
fktry/src/render/env.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -39,12 +39,20 @@ export const ERA_SKIN: Record<Era, EraSkin> = {
|
||||
/**
|
||||
* 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<string, Era> {
|
||||
const out = new Map<string, Era>();
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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++;
|
||||
|
||||
371
fktry/src/render/registry.test.ts
Normal file
371
fktry/src/render/registry.test.ts
Normal file
@ -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<MachineDef> & { 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>): 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<MachineDef> & { 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<MachineDef> & { 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<MachineDef> & { 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
|
||||
});
|
||||
});
|
||||
@ -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<void> {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user