Round 2 orders 1-6: - topology.ts: belt neighbour analysis -> straight/cornerA/cornerB/merge, one InstancedMesh per (def x shape), flow field baked per shape. Corners sweep a quarter-arc; merges get inlet arrows. - cargo follows the corner arc: `t` is progress along the tile, so walking items straight through the centre made them cut corners and pop in at the wrong edge. They now enter at the feeding edge (verified 0.089-0.248 off the centreline where straight pins to 0.000). - heat.ts + entities.ts: dull-red body ramp, rising shimmer column, throttle (idle AND GLB clips slow together), scram (dead-dark + amber, frozen) and a restart path that restores what scram zeroed. - buffer tanks show fill from their share of bandwidth.stored / bufferCap. - AnimationMixer plays clip 0 looped; rigged GLBs clone via SkeletonUtils so instances don't share a skeleton. - exact cargo interpolation via BeltItem.id, order-matching kept as fallback. - multi-mesh belt GLBs merge with groups (2 materials, 1 draw call) instead of rendering only the first mesh. MachineDef.color is wired to the BODY, not the accent, against the letter of the orders: all 21 values DATA authored are industrial chassis neutrals per codex/§8, and wiring them to the emissive makes every machine glow muddy and kills the two-material rule. Flagged in NOTES for the orchestrator to settle. 500 entities + 2,000 items still at 3.6ms/frame (4.6x headroom), 5 draw calls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
6.8 KiB
TypeScript
177 lines
6.8 KiB
TypeScript
/**
|
|
* LANE-RENDER — the Bitstream, made visible.
|
|
*
|
|
* Isometric factory view. Sim is truth: we render snapshots and never simulate.
|
|
* Composition: registry (assets + MODELBEAST hot-swap) -> ground / belts / entities /
|
|
* belt items / ghost layers -> ortho iso camera.
|
|
*/
|
|
import * as THREE from 'three';
|
|
import type { Dir, GameData, MachineDef, Renderer, SimSnapshot, Vec2 } from '../contracts';
|
|
import { AssetRegistry } from './registry';
|
|
import { CAM_DIST, CameraRig, WORLD_HALF } from './camera';
|
|
import { createGround, createVignette } from './ground';
|
|
import { EntityLayer } from './entities';
|
|
import { BeltLayer } from './belts';
|
|
import { BeltItemLayer } from './beltitems';
|
|
import { GhostLayer } from './ghost';
|
|
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
|
|
import { BeltTopology } from './topology';
|
|
import { WORLD } from './palette';
|
|
|
|
const KEY_INTENSITY = 1.9;
|
|
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;
|
|
|
|
export function createRenderer(): Renderer {
|
|
let gl: THREE.WebGLRenderer;
|
|
let scene: THREE.Scene;
|
|
let rig: CameraRig;
|
|
let registry: AssetRegistry;
|
|
let entities: EntityLayer;
|
|
let belts: BeltLayer;
|
|
let cargo: BeltItemLayer;
|
|
let ghost: GhostLayer;
|
|
let dev: DevScene | null = null;
|
|
let defs: Map<string, MachineDef>;
|
|
const topo = new BeltTopology();
|
|
|
|
const raycaster = new THREE.Raycaster();
|
|
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
const ndc = new THREE.Vector2();
|
|
const hit = new THREE.Vector3();
|
|
const clock = new THREE.Clock();
|
|
let dim = 1; // smoothed brownout level
|
|
/** 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 };
|
|
|
|
return {
|
|
async init(container: HTMLElement, data: GameData) {
|
|
defs = new Map(data.machines.map((m) => [m.id, m]));
|
|
|
|
gl = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
|
gl.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
gl.setSize(container.clientWidth, container.clientHeight);
|
|
gl.shadowMap.enabled = true;
|
|
gl.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
gl.toneMapping = THREE.ACESFilmicToneMapping;
|
|
container.appendChild(gl.domElement);
|
|
container.appendChild(createVignette());
|
|
|
|
scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(WORLD.bg);
|
|
// Fog distances are measured from the CAMERA, which an ortho iso rig parks
|
|
// CAM_DIST away from everything — so the band starts past the focal point and
|
|
// only bites at the far reaches. Absolute values here would fog the whole world
|
|
// into the background.
|
|
scene.fog = new THREE.Fog(WORLD.bg, CAM_DIST + 12, CAM_DIST + 78);
|
|
scene.add(createGround());
|
|
|
|
const key = new THREE.DirectionalLight(0xbfc8ff, KEY_INTENSITY);
|
|
key.position.set(18, 30, 12);
|
|
key.castShadow = true;
|
|
key.shadow.mapSize.set(2048, 2048);
|
|
key.shadow.camera.near = 1;
|
|
key.shadow.camera.far = 90;
|
|
const s = 26;
|
|
key.shadow.camera.left = -s;
|
|
key.shadow.camera.right = s;
|
|
key.shadow.camera.top = s;
|
|
key.shadow.camera.bottom = -s;
|
|
key.shadow.bias = -0.0012;
|
|
scene.add(key);
|
|
// Cool bounce from below-ish so machine flanks don't go pure black.
|
|
const fill = new THREE.DirectionalLight(0x4a3f7a, FILL_INTENSITY);
|
|
fill.position.set(-14, 8, -16);
|
|
scene.add(fill);
|
|
scene.add(new THREE.AmbientLight(0x8f8fbf, AMBIENT_INTENSITY));
|
|
|
|
rig = new CameraRig(container, gl.domElement);
|
|
|
|
registry = new AssetRegistry();
|
|
await registry.init(data);
|
|
|
|
belts = new BeltLayer(registry, defs, topo);
|
|
entities = new EntityLayer(registry, defs);
|
|
cargo = new BeltItemLayer(data, topo);
|
|
ghost = new GhostLayer(registry, defs);
|
|
scene.add(belts.group, entities.group, cargo.group, ghost.group);
|
|
|
|
if (devSceneEnabled()) {
|
|
const d = new DevScene(data, devSceneStress());
|
|
if (d.usable) {
|
|
dev = d;
|
|
console.info('[render] devscene active — mock line until LANE-SIM lands entities');
|
|
// Dev-only introspection hook (devscene builds only). Never touched in prod.
|
|
(window as unknown as { __render: unknown }).__render = {
|
|
scene, camera: rig.camera, stats, gl, dev, topo, cargo, entities,
|
|
};
|
|
}
|
|
}
|
|
|
|
const onResize = () => {
|
|
gl.setSize(container.clientWidth, container.clientHeight);
|
|
rig.resize();
|
|
};
|
|
addEventListener('resize', onResize);
|
|
new ResizeObserver(onResize).observe(container);
|
|
},
|
|
|
|
render(snap: SimSnapshot, alpha: number) {
|
|
if (!gl) return;
|
|
const t0 = dev ? performance.now() : 0;
|
|
const dt = clock.getDelta();
|
|
const t = clock.elapsedTime;
|
|
|
|
// Dev scaffold yields the instant the real sim has anything to show.
|
|
const view = dev && snap.entities.length === 0 ? dev.snapshot(snap.tick) : snap;
|
|
|
|
rig.update(dt);
|
|
// Topology is shared by belts (which piece to draw) and cargo (which path the
|
|
// items take), so it is rebuilt once here rather than twice downstream.
|
|
topo.rebuild(view, defs);
|
|
belts.sync(view, t);
|
|
entities.sync(view, t, dt);
|
|
cargo.sync(view, alpha, t);
|
|
ghost.update(view, t);
|
|
|
|
// Brownout: drop the whole scene ~30% and flicker. Driven through tone-mapping
|
|
// exposure so emissives dim too — a brownout that left the glow at full would
|
|
// read as a lighting bug rather than a power failure.
|
|
const target = view.bandwidth.brownout ? 0.7 : 1;
|
|
dim += (target - dim) * Math.min(1, dt * 6);
|
|
const flicker = view.bandwidth.brownout
|
|
? 0.88 + Math.sin(t * 37) * 0.08 + (Math.sin(t * 13.3) > 0.72 ? -0.22 : 0)
|
|
: 1;
|
|
gl.toneMappingExposure = BASE_EXPOSURE * dim * flicker;
|
|
|
|
gl.render(scene, rig.camera);
|
|
|
|
if (dev) {
|
|
stats.ms = performance.now() - t0;
|
|
stats.frames++;
|
|
stats.avg += (stats.ms - stats.avg) * 0.05; // rolling
|
|
}
|
|
},
|
|
|
|
pickTile(clientX: number, clientY: number): Vec2 | null {
|
|
if (!gl) return null;
|
|
const rect = gl.domElement.getBoundingClientRect();
|
|
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
raycaster.setFromCamera(ndc, rig.camera);
|
|
if (!raycaster.ray.intersectPlane(groundPlane, hit)) return null;
|
|
const x = Math.floor(hit.x);
|
|
const y = Math.floor(hit.z);
|
|
if (x < -WORLD_HALF || x >= WORLD_HALF || y < -WORLD_HALF || y >= WORLD_HALF) return null;
|
|
return { x, y };
|
|
},
|
|
|
|
setGhost(def: string | null, pos: Vec2 | null, dir: Dir) {
|
|
ghost?.set(def, pos, dir);
|
|
},
|
|
};
|
|
}
|