From 0d491f8a2f516694c9f6d17f522cbe390ad9ad8f Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 19:00:58 +1000 Subject: [PATCH] [render] GLB legibility pass (emissive floor + accent beacon), parity-mite visuals, relic excavation VFX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry: texel-following emissive floor + one-impossible-element accent beacon on every hot-swapped GLB (generic, no hardcoded list) — restores the two-material rule on baked assets - wildlife: parity-mite beetles with green checkmark eye, size-driven state tell (hunt/repair/ sated), derived 'repaired' tidy sparkle + firewall-can 'filed paperwork' cue - relics/juice: golden excavation geyser on unfound->found transition - devscene: parity-mite fixtures across all three states for verification Co-Authored-By: Claude Opus 4.8 --- fktry/lanes/LANE-RENDER.md | 112 ++++++++++++++++++++++++++++ fktry/src/render/devscene.ts | 11 +++ fktry/src/render/index.ts | 4 +- fktry/src/render/juice.ts | 13 ++++ fktry/src/render/registry.ts | 107 +++++++++++++++++++++++++-- fktry/src/render/relics.ts | 11 ++- fktry/src/render/wildlife.ts | 139 +++++++++++++++++++++++++++++++++-- 7 files changed, 382 insertions(+), 15 deletions(-) diff --git a/fktry/lanes/LANE-RENDER.md b/fktry/lanes/LANE-RENDER.md index c64ff66..33388d9 100644 --- a/fktry/lanes/LANE-RENDER.md +++ b/fktry/lanes/LANE-RENDER.md @@ -648,3 +648,115 @@ per lane fixes both. inside a cooler's rectangle once SIM lands spatial cooling; relic excavation VFX on the `relicFound` event; bloom-loop belt tell; save/load camera state; LOD/culling if a megabase ever drags the frame. + +### Round 6 — 2026-07-20 — Opus 4.8 + +**SHIPPED** — all 3 orders, verified in the running build. The second asset wave landed +mid-round: **27 GLBs are now in `public/assets/models/`** (up from the 10 the order named). +Everything below is generic — it runs on *any* hot-swapped GLB through the registry, no +hardcoded list — so all 27 (and whatever lands next) got the treatment automatically. + +1. **GLB legibility pass (centrepiece).** The two-material rule died on real assets: baked + albedo, zero emissive, near-black at game zoom (before/after below). Two halves, both + keyed off the SAME accent derivation placeholders already use, so a machine that made + melt still glows melt-orange whether it's a box or a GLB: + - **Texel-following emissive FLOOR** (`registry.ts floorMaterials`, run ONCE on each + GLB template in `tryLoad`): `emissiveMap = map`, `emissive = white`, + `emissiveIntensity = 0.42` — the baked albedo self-illuminates so the brightest texels + lift off the void without washing to flat grey. Materials with no map self-light their + own colour; materials that already ship a glow are left alone. EntityLayer still clones + these per instance, so per-entity heat/scram rides on top (heat lerps `emissive`→red; + the map keeps modulating). + - **Accent BEACON** (`addAccentBeacon`): the one impossible element, perched on top, + scaled to footprint, in the machine's derived/art-directed accent — plus a gentle idle + pulse so a GLB gets a heartbeat like a placeholder. The idle reads `beacon.material` at + call time, not a captured ref, because EntityLayer re-clones GLB materials per instance + (a closure over the pre-clone material would drive a mesh nothing renders — caught that + before it shipped). **DATA's round-6 accents were chosen against the real GLB textures, + so the beacons wear DATA's hand-picked colours.** + - Verified in `?showroom` (before: dark specks on plinths → after: lit chassis + colour- + coded beacons across the whole 27-machine catalog) AND in the live demo world at the + default game frame (`__fktryDemo()` reference factory, `rig.frame(3,6,15)` untouched): + every GLB legible, beacons marking each unit. + +2. **Parity mites** (`wildlife.ts`, +2 instanced meshes). Hospital-white faceted beetles + with a green **checkmark eye** (canvas ✓ texture, tilted −45° to meet the iso camera). + The tell tracks SIM's `size` state channel exactly: `0` hunting (slow, patient patrol — + the wrongness is the calm), `0 w.id === 922); + if (mite) mite.size = (tick % 150) / 100; // 0 → 1.5, crossing 1.0 each loop + const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => { const perTick = this.speed / TICKS_PER_SECOND; const span = seg.ids.length; diff --git a/fktry/src/render/index.ts b/fktry/src/render/index.ts index 15b888b..03467f3 100644 --- a/fktry/src/render/index.ts +++ b/fktry/src/render/index.ts @@ -204,8 +204,8 @@ export function createRenderer(): Renderer { entities = new EntityLayer(registry, defs, juice); cargo = new BeltItemLayer(data, topo); ghost = new GhostLayer(registry, defs, data); - relics = new RelicLayer(); - wildlife = new WildlifeLayer(); + relics = new RelicLayer(juice); + wildlife = new WildlifeLayer(juice); scene.add( belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group, relics.group, wildlife.group, juice.group, diff --git a/fktry/src/render/juice.ts b/fktry/src/render/juice.ts index 3df72f1..01fdd60 100644 --- a/fktry/src/render/juice.ts +++ b/fktry/src/render/juice.ts @@ -86,6 +86,19 @@ export class Juice { this.emit(cx, cz, 10, 1.0, 5.0, 0.2, 0.45, col); } + /** Excavation (relicFound): the fossil surfacing — a slow, tall golden geyser that + * "sings" up out of the crust. Longer-lived and higher than any other cue. */ + excavate(cx: number, cz: number): void { + this.emit(cx, cz, 30, 1.4, 7.5, 0.26, 1.1, 0xffcaa0); + this.emit(cx, cz, 14, 0.5, 4.0, 0.18, 1.4, 0xfff2d6); // a brighter inner column + } + + /** The Correction's calling card: a small, infuriatingly TIDY green-white sparkle, + * left where a parity-mite just "corrected" your product. Few, clean, gentle. */ + tidy(cx: number, cz: number): void { + this.emit(cx, cz, 4, 0.35, 1.6, 0.11, 0.6, 0xd6ffe0); + } + update(dt: number, camera: THREE.Camera): void { // integrate + recycle for (let i = this.live.length - 1; i >= 0; i--) { diff --git a/fktry/src/render/registry.ts b/fktry/src/render/registry.ts index a543c93..21dcca4 100644 --- a/fktry/src/render/registry.ts +++ b/fktry/src/render/registry.ts @@ -222,10 +222,89 @@ export function beltPlateGeometry(): THREE.BufferGeometry { return geo; } +// ---------------------------------------------------------------- GLB legibility + +/** + * GLB legibility floor (round 6, the round's centrepiece). + * + * 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: + * + * 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 + * what the machine makes — the same accent derivation the placeholder uses. + */ +const GLB_EMISSIVE_FLOOR = 0.42; +function floorMaterials(root: THREE.Object3D): void { + root.traverse((o) => { + const m = o as THREE.Mesh; + if (!m.isMesh) return; + const mats = Array.isArray(m.material) ? m.material : [m.material]; + for (const raw of mats) { + const mat = raw as THREE.MeshStandardMaterial; + if (!mat || !(mat as { isMeshStandardMaterial?: boolean }).isMeshStandardMaterial) continue; + 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 + } + mat.emissiveIntensity = GLB_EMISSIVE_FLOOR; + mat.needsUpdate = true; + } + }); +} + +/** Unit beacon; instances scale it by footprint and never dispose it (module-shared). */ +const BEACON_GEO = new THREE.IcosahedronGeometry(1, 1); +/** + * Perch the machine's ONE emissive accent on top of a baked GLB and give the asset a + * gentle idle pulse — resource colour, identity, and a heartbeat, all from the same + * accent the placeholder derives. The idle reads `beacon.material` at call time (not a + * captured reference) because EntityLayer clones every GLB material per instance; a + * closure over the pre-clone material would drive a mesh nothing renders. + */ +function addAccentBeacon(root: THREE.Object3D, def: MachineDef, accent: number): void { + root.updateMatrixWorld(true); + const box = new THREE.Box3().setFromObject(root); + const fp = Math.min(def.footprint.x, def.footprint.y); + const r = THREE.MathUtils.clamp(fp * 0.1, 0.08, 0.2); + const beacon = new THREE.Mesh( + BEACON_GEO, + new THREE.MeshStandardMaterial({ + color: accent, emissive: accent, emissiveIntensity: 1.8, roughness: 0.3, metalness: 0.1, + }), + ); + beacon.scale.setScalar(r); + beacon.position.set( + (box.min.x + box.max.x) / 2, + box.max.y + r * 1.3, + (box.min.z + box.max.z) / 2, + ); + beacon.userData.fktryBeacon = true; + root.add(beacon); + root.userData.fktryIdle = ((t: number) => { + (beacon.material as THREE.MeshStandardMaterial).emissiveIntensity = 1.8 + Math.sin(t * 2.3) * 0.45; + }) satisfies IdleFn; +} + // ---------------------------------------------------------------- GLB handling /** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */ -function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.AnimationClip[]): THREE.Object3D { +function normaliseGLB( + src: THREE.Object3D, + def: MachineDef, + clips: THREE.AnimationClip[], + accent?: number, +): THREE.Object3D { // SkeletonUtils.clone (not Object3D.clone) — rigged meshes need their skeleton rebound // per instance or every copy animates off the first one's bones. const obj = cloneSkinned(src); @@ -250,14 +329,20 @@ function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.Animati }); // A scaled/offset root would fight the mixer's root-motion tracks, so animated GLBs // get wrapped: the mixer drives the inner node, the wrapper does the normalising. + let root: THREE.Object3D; if (clips.length) { const wrap = new THREE.Group(); wrap.add(obj); wrap.userData.fktryClips = clips; - return wrap; + root = wrap; + } else { + obj.userData.fktryClips = clips; + root = obj; } - obj.userData.fktryClips = clips; - return obj; + // Machine create path passes an accent -> perch the beacon. The instanceable path + // (belts) passes none: an instanced belt has no room for a beacon and no accent to show. + if (accent !== undefined) addAccentBeacon(root, def, accent); + return root; } /** @@ -311,6 +396,9 @@ export class AssetRegistry { private entries = new Map(); private defs = new Map(); + /** The derived emissive accent per asset key — shared by placeholders and hot-swapped + * GLBs so a real asset gets the same one impossible element the placeholder had. */ + private accents = new Map(); private eras = new Map(); private loader = new GLTFLoader(); private timer: number | null = null; @@ -324,7 +412,9 @@ export class AssetRegistry { this.eras = eraIndex(data); for (const def of data.machines) { this.defs.set(def.asset, def); - const entry = this.placeholderEntry(def, accentFor(def, data), bodyFor(def), this.eraOf(def.id)); + const accent = accentFor(def, data); + this.accents.set(def.asset, accent); + const entry = this.placeholderEntry(def, accent, bodyFor(def), this.eraOf(def.id)); this.entries.set(def.asset, entry); } await this.probeAll(); @@ -371,12 +461,17 @@ 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); const clips = gltf.animations ?? []; + const accent = this.accents.get(def.asset) ?? ACCENT_BY_KIND[def.kind] ?? 0x3fffe0; this.entries.set(def.asset, { key: def.asset, version, isGLB: true, - create: () => normaliseGLB(template, def, clips), + create: () => normaliseGLB(template, def, clips, accent), instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset), }); this.version++; diff --git a/fktry/src/render/relics.ts b/fktry/src/render/relics.ts index 7243957..a42d515 100644 --- a/fktry/src/render/relics.ts +++ b/fktry/src/render/relics.ts @@ -13,6 +13,7 @@ import * as THREE from 'three'; import type { RelicState } from '../contracts'; import { disposeObject } from './coords'; +import type { Juice } from './juice'; const SHIMMER_VERT = /* glsl */ ` varying vec2 vUv; @@ -57,8 +58,11 @@ export class RelicLayer { private seen = new Set(); private shimmerUniforms = { uTime: { value: 0 } }; private shimmerMat: THREE.ShaderMaterial; + /** False until the first sync: a save loaded with a relic already found must not fire + * the excavation geyser, exactly as EntityLayer primes before firing placement juice. */ + private primed = false; - constructor() { + constructor(private juice?: Juice) { this.group.name = 'relics'; this.shimmerMat = new THREE.ShaderMaterial({ uniforms: this.shimmerUniforms, @@ -76,6 +80,7 @@ export class RelicLayer { for (const r of relics ?? []) { this.seen.add(r.id); let rec = this.live.get(r.id); + const justExcavated = !!rec && !rec.found && r.found; if (rec && rec.found !== r.found) { this.drop(r.id, rec); // excavated: swap shimmer → monument rec = undefined; @@ -87,6 +92,9 @@ export class RelicLayer { rec = { found: r.found, obj }; this.live.set(r.id, rec); } + // The fossil surfaces: a golden geyser that "sings" up out of the crust. Gated on a + // real unfound→found transition seen live (not a save that loads already-excavated). + if (justExcavated && this.primed) this.juice?.excavate(r.pos.x + 0.5, r.pos.y + 0.5); if (r.found) { // gentle lift + rotate so the excavated fossil reads as an exhibit rec.obj.rotation.y = timeSec * 0.5; @@ -95,6 +103,7 @@ export class RelicLayer { } for (const [id, rec] of this.live) if (!this.seen.has(id)) this.drop(id, rec); + this.primed = true; } private buildShimmer(): THREE.Object3D { diff --git a/fktry/src/render/wildlife.ts b/fktry/src/render/wildlife.ts index 95e3dd7..7e269a6 100644 --- a/fktry/src/render/wildlife.ts +++ b/fktry/src/render/wildlife.ts @@ -1,16 +1,21 @@ /** - * LANE-RENDER — wildlife: dust piles and mosquito swarms (§2/§5, M2's living hazards). + * LANE-RENDER — wildlife: dust piles, mosquito swarms, and parity mites (§2/§5, the + * living hazards of M2 and The Correction). * * Piles are HF-dust glitter heaps (codex: quantizer sweepings) — they grow with `size`. * Swarms are pixel-gnat clouds that cluster over the machine they're degrading (`target`); * the machine's own flicker is EntityLayer's job, driven off the same `target` ids. + * Parity mites are hospital-white beetles with a green checkmark eye that walk with an + * unsettling gentleness (§5): they don't rampage, they FILE. Where one corrects your + * product it leaves an infuriatingly tidy sparkle (Juice.tidy), the visual of the + * 'repaired' event — which render() can't see, so it's derived from the mite's presence. * - * Both are instanced from day one: the perf bar is 50 piles + 10 swarms, and a swarm is - * itself a cloud of gnats. One InstancedMesh for every pile, one for every gnat across - * every swarm — two draw calls for the whole ecosystem. + * All instanced from day one: one InstancedMesh for every pile, one for every gnat across + * every swarm, one for every mite body, one for every mite eye. */ import * as THREE from 'three'; import type { SimSnapshot, WildlifeState } from '../contracts'; +import type { Juice } from './juice'; const GNATS_PER_SWARM = 24; @@ -22,17 +27,45 @@ function pileMaterial(): THREE.MeshStandardMaterial { }); } +/** A crisp green ✓ on transparent ground — the mite's single, approving eye (§5). */ +function checkmarkTexture(): THREE.CanvasTexture { + const s = 64; + const cv = document.createElement('canvas'); + cv.width = cv.height = s; + const ctx = cv.getContext('2d')!; + ctx.strokeStyle = '#39ff88'; + ctx.lineWidth = 9; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(s * 0.2, s * 0.52); + ctx.lineTo(s * 0.42, s * 0.74); + ctx.lineTo(s * 0.82, s * 0.26); + ctx.stroke(); + const tex = new THREE.CanvasTexture(cv); + tex.colorSpace = THREE.SRGBColorSpace; + return tex; +} + export class WildlifeLayer { readonly group = new THREE.Group(); private piles: THREE.InstancedMesh; private gnats: THREE.InstancedMesh; + private mites: THREE.InstancedMesh; + private miteEyes: THREE.InstancedMesh; private pileCap = 64; private gnatCap = 64 * GNATS_PER_SWARM; + private miteCap = 96; private dummy = new THREE.Object3D(); + private eyeCol = new THREE.Color(); + /** Per-mite last-seen {size,pos}, so the renderer can derive the two mite events it can't + * see: `repaired` (size crossing <1 → ≥1 = a correction landed) and "canned by a + * firewall" (a mite gone from the list while still unsated = filed, not fled). */ + private miteState = new Map(); /** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */ readonly harassed = new Set(); - constructor() { + constructor(private juice?: Juice) { this.group.name = 'wildlife'; // A low, faceted heap; instance scale grows it with `size`. const pileGeo = new THREE.IcosahedronGeometry(0.5, 0); @@ -52,7 +85,30 @@ export class WildlifeLayer { this.gnats.frustumCulled = false; this.gnats.count = 0; - this.group.add(this.piles, this.gnats); + // Parity mite: a hospital-white carapace (faceted ellipsoid). Faintly self-lit so it + // reads clinical-white on the dark ground rather than going grey. + const miteGeo = new THREE.IcosahedronGeometry(0.18, 0); + miteGeo.scale(1, 0.62, 1.4); // longer than wide: a beetle, not a bead + const miteMat = new THREE.MeshStandardMaterial({ + color: 0xf4f6ff, emissive: 0xd6ddf2, emissiveIntensity: 0.32, + roughness: 0.5, metalness: 0.1, flatShading: true, + }); + this.mites = new THREE.InstancedMesh(miteGeo, miteMat, this.miteCap); + this.mites.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + this.mites.castShadow = true; + this.mites.frustumCulled = false; + this.mites.count = 0; + + const eyeGeo = new THREE.PlaneGeometry(0.16, 0.16); + const eyeMat = new THREE.MeshBasicMaterial({ + map: checkmarkTexture(), transparent: true, depthWrite: false, side: THREE.DoubleSide, + }); + this.miteEyes = new THREE.InstancedMesh(eyeGeo, eyeMat, this.miteCap); + this.miteEyes.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + this.miteEyes.frustumCulled = false; + this.miteEyes.count = 0; + + this.group.add(this.piles, this.gnats, this.mites, this.miteEyes); } sync(wildlife: ReadonlyArray | undefined, timeSec: number): void { @@ -60,6 +116,8 @@ export class WildlifeLayer { const list = wildlife ?? []; let pileN = 0; let gnatN = 0; + let miteN = 0; + const seenMites = new Set(); for (const w of list) { if (w.kind === 'dust-pile') { @@ -70,6 +128,10 @@ export class WildlifeLayer { this.dummy.scale.set(s, s, s); this.dummy.updateMatrix(); this.piles.setMatrixAt(pileN++, this.dummy.matrix); + } else if (w.kind === 'parity-mite') { + if (miteN >= this.miteCap) continue; + seenMites.add(w.id); + miteN = this.placeMite(w, timeSec, miteN); } else { if (w.target !== undefined) this.harassed.add(w.target); const cx = w.pos.x + 0.5; @@ -93,10 +155,75 @@ export class WildlifeLayer { } } + // A mite that vanished from the list: sated ones (size≥1) reached the rim and left + // carrying their correction — no cue. One still unsated was CANNED by a firewall + // mid-repair (§5's counter): the product survived, so it reads as filed paperwork — + // an infuriatingly tidy sparkle where the jar closed. + for (const [id, st] of this.miteState) { + if (seenMites.has(id)) continue; + if (st.size < 1) this.juice?.tidy(st.x, st.z); + this.miteState.delete(id); + } + this.piles.count = pileN; this.piles.instanceMatrix.needsUpdate = true; this.gnats.count = gnatN; this.gnats.instanceMatrix.needsUpdate = true; + this.mites.count = miteN; + this.mites.instanceMatrix.needsUpdate = true; + this.miteEyes.count = miteN; + this.miteEyes.instanceMatrix.needsUpdate = true; + if (this.miteEyes.instanceColor) this.miteEyes.instanceColor.needsUpdate = true; + } + + /** + * One mite. `size` is the sim's state channel (§5): 0 = hunting, 0 0 && size < 1; + const ph = w.id * 2.3999; // golden-angle desync + // On station it dwells (a tiny tremor); otherwise it patrols a slow, patient circle. + const amp = onStation ? 0.05 : 0.26; + const rate = onStation ? 0.9 : 0.55; + const gait = timeSec * rate + ph; + const px = cx + Math.cos(gait) * amp; + const pz = cz + Math.sin(gait) * amp; + const yaw = -gait; // face along the patrol tangent + const bob = Math.abs(Math.sin(gait * 3)) * (onStation ? 0.012 : 0.03); + + this.dummy.position.set(px, 0.14 + bob, pz); + this.dummy.rotation.set(0, yaw, 0); + this.dummy.scale.setScalar(1); + this.dummy.updateMatrix(); + this.mites.setMatrixAt(n, this.dummy.matrix); + + // Eye: forward of the carapace (local -Z, so world = (-sinYaw,-cosYaw)) and up, laid + // back ~45° so the checkmark meets the iso camera instead of lying flat on the floor. + const fwd = 0.14; + this.dummy.position.set(px - Math.sin(yaw) * fwd, 0.27 + bob, pz - Math.cos(yaw) * fwd); + this.dummy.rotation.set(-Math.PI / 4, yaw, 0); + this.dummy.scale.setScalar(1); + this.dummy.updateMatrix(); + this.miteEyes.setMatrixAt(n, this.dummy.matrix); + // The approval glows in as the correction completes: dim while hunting, ramping on + // station, full when sated. Per-instance via instanceColor (multiplies the map). + const glow = size <= 0 ? 0.5 : size < 1 ? 0.5 + 0.5 * size : 1; + this.miteEyes.setColorAt(n, this.eyeCol.setRGB(glow, glow, glow)); + + // Derive the `repaired` event (render() can't see events): the correction lands the + // instant the mite crosses from repairing into sated. + const prev = this.miteState.get(w.id); + if (prev && prev.size < 1 && size >= 1) this.juice?.tidy(cx, cz); + this.miteState.set(w.id, { size, x: cx, z: cz }); + return n + 1; } }