[render] GLB legibility pass (emissive floor + accent beacon), parity-mite visuals, relic excavation VFX
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
68bddbec0a
commit
0d491f8a2f
@ -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<size<1` on station (dwells, only a tremor; the eye
|
||||
**glows up** via per-instance `instanceColor` as the correction completes), `size≥1`
|
||||
sated. render() can't see events, so I derive the two mite cues from snapshot deltas:
|
||||
- **`repaired`** = a mite crossing `size` <1 → ≥1: a tidy sparkle where the product used
|
||||
to be. Verified deterministically — driving a mite 0.6→1.0 emitted **exactly 4**
|
||||
particles (one `Juice.tidy`).
|
||||
- **caught in a firewall = filed paperwork** = a mite that vanishes from the list while
|
||||
still unsated (sated ones leave at the rim carrying their fix, no cue). Verified:
|
||||
removing an unsated mite fired the tidy sparkle (juice 4→8).
|
||||
|
||||
3. **Excavation VFX** (`relics.ts` + `Juice.excavate`). On the relic's unfound→found
|
||||
transition, a slow tall golden geyser "sings" up out of the crust as the monument rises.
|
||||
Gated on a live transition (a save that loads already-excavated doesn't fire it — primed
|
||||
like EntityLayer). Verified: flipping the fixture relic fired the geyser (juice pool
|
||||
4→**89**) and built the monument.
|
||||
|
||||
**VERIFIED (perf)** — honest caveat: the canonical full-frame CPU-ms sampler could not be
|
||||
read reliably this round because the browser backgrounds the tab and starves the rAF loop
|
||||
that drives `layer.sync` (same shared-tree/environment friction round 5 hit), so the
|
||||
instanced hot-path meshes never fully populated for a passive read. What holds by
|
||||
construction: the additions don't touch the terms that dominate the frame. Wildlife gained
|
||||
2 InstancedMeshes (mite bodies + eyes — 2 draw calls, only when mites exist, O(mites) capped
|
||||
at 96); GLB legibility is one-time material params + one small beacon mesh per GLB *machine
|
||||
instance*; the 2,000-item / 480-belt instanced path is untouched and no per-frame allocation
|
||||
was added. Round 5's measured **4.4ms/frame** at full load stands as the reference.
|
||||
|
||||
**DECISIONS**
|
||||
- **Floor is texel-following, not a flat wash.** A flat emissive floor guarantees "never a
|
||||
hole" but greys the texture to mud and, since most GLBs export colour in the *map* with a
|
||||
white base colour, would floor-glow everything white. `emissiveMap = map` keeps each
|
||||
machine's baked colour and lifts its lit texels; the beacon + scene lighting cover the
|
||||
rare all-dark texel. Trade-off noted honestly: a genuinely pure-black texel stays dark
|
||||
(correct — dark rubber should read darker than a metal panel); no machine reads as a hole
|
||||
in practice (verified by eye at default zoom).
|
||||
- **Beacon geometry is module-shared, material is per-instance.** GLB records don't own
|
||||
geometry (`ownsGeo=false`) so the shared beacon geo is never disposed; the per-instance
|
||||
material is (EntityLayer disposes all materials on drop regardless).
|
||||
- **Mite state is read from `size`, not invented.** SIM documents `size` as the state
|
||||
channel and `target` as the belt it converges on; I render that contract rather than a
|
||||
timer, so the tell is true to the sim (hunting drifts, repairing dwells + glows, sated
|
||||
leaves). The eye-glow ramp uses `instanceColor` (multiplies the checkmark map) — no
|
||||
shared-material emissive hack.
|
||||
|
||||
**BLOCKED/BROKEN** — nothing blocking.
|
||||
- **The mite "filed paperwork" cue is a heuristic, not the event.** render() has no event
|
||||
channel, so "canned by a firewall" is inferred from a mite disappearing while `size<1`.
|
||||
This is correct for the sim's current lifecycle (sated mites leave at the rim, far from
|
||||
firewalls; canned ones vanish mid-repair near one), but it's an inference — if a mite ever
|
||||
despawns unsated for another reason, it'd read as a false "filed". A real renderer event
|
||||
channel (see CONTRACT REQUEST) would make both mite cues and the excavation exact instead
|
||||
of derived. Low risk today.
|
||||
- The `firewall` machine (DATA, this round) has no GLB yet, so it shows the placeholder in
|
||||
`?showroom` — it'll hot-swap + get the floor/beacon treatment the moment its asset lands,
|
||||
no code change.
|
||||
- `match.html` 404 in dev (round 5, unchanged): the cabinet's target only exists in `dist/`.
|
||||
|
||||
**CONTRACT REQUEST**
|
||||
1. **A renderer event channel** (renewing round 5's ask, now with two more consumers).
|
||||
`Renderer` never receives `SimEvent[]`; ScreenFX and AudioFX do. This round I derived
|
||||
`repaired`, the firewall "can", and `relicFound` from snapshot deltas — all work, but
|
||||
they're inferences that can drift from truth. Cheapest shape: add
|
||||
`Renderer.onEvents(events: SimEvent[])` called by main.ts alongside the others. It would
|
||||
make excavation fire on the actual `relicFound` and the tidy sparkle fire on the actual
|
||||
`repaired` (with the event's own `pos`), retiring three heuristics. Everything else this
|
||||
lane does stays snapshot-driven; this is specifically for one-shot cues.
|
||||
2. Still open (round 5): `pointer-events: none` on `#screen` in `index.html` (orchestrator).
|
||||
|
||||
**PROPOSAL**
|
||||
- The GLB legibility pass makes the MODELBEAST pipeline fully hands-off for legibility: any
|
||||
asset dropped in — textured, untextured, rigged, multi-mesh — lands lit and identifiable
|
||||
with zero per-asset tuning. The whole 27-machine catalog proves it. Hero re-cuts
|
||||
(`muxer`, `mosh-reactor`) can arrive without a render change.
|
||||
|
||||
**NEXT (round 7 candidates, not self-assigned)**
|
||||
- Wire the renderer event channel if granted (exacts the three derived cues above); a
|
||||
`firewall.glb` when it lands; per-mite orientation toward its `target` belt; heat-shimmer
|
||||
*relief* inside a cooler rectangle; bloom-loop belt tell; save/load camera state;
|
||||
LOD/culling if a real megabase ever drags the frame.
|
||||
|
||||
@ -159,6 +159,12 @@ export class DevScene {
|
||||
{ id: 901, kind: 'dust-pile', pos: { x: 2, y: 12 }, size: 0.8 },
|
||||
{ id: 902, kind: 'dust-pile', pos: { x: 0, y: 12 }, size: 0.15 },
|
||||
{ id: 910, kind: 'mosquito-swarm', pos: { x: -8, y: 0 }, size: 0.7, target: demuxer?.id },
|
||||
// The Correction: three parity mites, one per state so all three tells are visible —
|
||||
// 920 hunting (drifts), 921 on-station (dwells, eye glowing), 922 animated in
|
||||
// snapshot() so it cycles through the repair and fires the tidy 'repaired' sparkle.
|
||||
{ id: 920, kind: 'parity-mite', pos: { x: -9, y: 0 }, size: 0, target: demuxer?.id },
|
||||
{ id: 921, kind: 'parity-mite', pos: { x: -2, y: 3 }, size: 0.5 },
|
||||
{ id: 922, kind: 'parity-mite', pos: { x: 0, y: 5 }, size: 0.7 },
|
||||
);
|
||||
|
||||
this.ok = true;
|
||||
@ -184,6 +190,11 @@ export class DevScene {
|
||||
if (e.recipe) e.progress = (tick % 90) / 90;
|
||||
}
|
||||
|
||||
// Cycle mite 922 through hunting → repairing → sated so the eye-glow ramp and the
|
||||
// tidy 'repaired' sparkle (fired on the <1→≥1 crossing) are both visible in the scaffold.
|
||||
const mite = this.wildlife.find((w) => 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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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--) {
|
||||
|
||||
@ -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<string, AssetEntry>();
|
||||
private defs = new Map<string, MachineDef>();
|
||||
/** 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<string, number>();
|
||||
private eras = new Map<string, Era>();
|
||||
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++;
|
||||
|
||||
@ -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<number>();
|
||||
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 {
|
||||
|
||||
@ -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<number, { size: number; x: number; z: number }>();
|
||||
/** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */
|
||||
readonly harassed = new Set<number>();
|
||||
|
||||
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<WildlifeState> | 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<number>();
|
||||
|
||||
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<size<1 = on station
|
||||
* mid-repair, size≥1 = sated and carrying the correction home. The tell tracks it —
|
||||
* a hunting mite drifts with a slow, deliberate patrol (the wrongness is the calm, not
|
||||
* the speed); on station it stops and only trembles while the checkmark eye glows up to
|
||||
* completion; sated it moves off. The eye is tilted to meet the iso camera. Crossing
|
||||
* into sated fires the tidy 'repaired' sparkle where the product used to be.
|
||||
*/
|
||||
private placeMite(w: WildlifeState, timeSec: number, n: number): number {
|
||||
const cx = w.pos.x + 0.5;
|
||||
const cz = w.pos.y + 0.5;
|
||||
const size = w.size;
|
||||
const onStation = size > 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user