/** * 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; 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); }, }; }