/** * LANE-RENDER — belts, instanced, one InstancedMesh per (belt def x topology shape). * * The chevron scroll is injected into a MeshStandardMaterial rather than written as a * raw ShaderMaterial, so belts keep real lighting, fog and brownout dimming for free. * The shape's flow field is baked into the shader source at material-build time — no * runtime branch, and each shape is its own draw call anyway. * * Straight chevrons run along local -z; corners sweep the quarter-arc from the feeding * edge to the output edge; merges add inlet arrows flowing in from the sides. The * instance yaw carries all of it to world orientation. */ import * as THREE from 'three'; import type { MachineDef, SimSnapshot } from '../contracts'; import { AssetRegistry } from './registry'; import { yawFor } from './coords'; import { ACCENT_BY_KIND, BODY_BY_KIND, HEIGHT_BY_KIND } from './palette'; import { ARC_LEN, BELT_SHAPES, BeltTopology, type BeltShape } from './topology'; const CHEVRONS_PER_TILE = 3; interface BeltMesh { mesh: THREE.InstancedMesh; capacity: number; assetVersion: number; } /** The flow field for one shape, in local tile space. Baked, not branched. */ function chevronGLSL(shape: BeltShape): string { const N = CHEVRONS_PER_TILE.toFixed(1); if (shape === 'cornerA' || shape === 'cornerB') { const side = shape === 'cornerA' ? '1.0' : '-1.0'; return ` vec2 rel = vec2(vLocal.x, vLocal.z) - vec2(${side} * 0.5, -0.5); float r = length(rel); float u = ${side} * (atan(rel.y, rel.x) - 1.5707963) / 1.5707963; float band = fract((u * ${ARC_LEN.toFixed(4)} + abs(r - 0.5) * 0.55) * ${N} - uTime * uSpeed * ${N}); float arrow = (smoothstep(0.40, 0.50, band) - smoothstep(0.50, 0.60, band)) * (1.0 - smoothstep(0.85, 1.15, r)); `; } if (shape === 'merge') { return ` float band = fract((vLocal.z + abs(vLocal.x) * 0.55) * ${N} + uTime * uSpeed * ${N}); float arrow = smoothstep(0.40, 0.50, band) - smoothstep(0.50, 0.60, band); float sb = fract(abs(vLocal.x) * ${N} + uTime * uSpeed * ${N}); arrow += (smoothstep(0.40, 0.50, sb) - smoothstep(0.50, 0.60, sb)) * step(0.26, abs(vLocal.x)) * 0.8; `; } return ` float band = fract((vLocal.z + abs(vLocal.x) * 0.55) * ${N} + uTime * uSpeed * ${N}); float arrow = smoothstep(0.40, 0.50, band) - smoothstep(0.50, 0.60, band); `; } export class BeltLayer { readonly group = new THREE.Group(); private meshes = new Map(); private uTime = { value: 0 }; private dummy = new THREE.Object3D(); private builtVersion = -1; constructor( private registry: AssetRegistry, private defs: Map, private topo: BeltTopology, ) { this.group.name = 'belts'; } private material(def: MachineDef, shape: BeltShape): THREE.Material { // Chassis colour from data when LANE-DATA has art-directed it (see registry.bodyFor). const mat = new THREE.MeshStandardMaterial({ color: def.color ? new THREE.Color(def.color) : new THREE.Color(BODY_BY_KIND.belt), roughness: 0.9, metalness: 0.1, }); const uSpeed = { value: def.beltSpeed ?? 2 }; const uAccent = { value: new THREE.Color(ACCENT_BY_KIND.belt) }; const topY = (HEIGHT_BY_KIND.belt * 0.92).toFixed(4); mat.onBeforeCompile = (shader) => { shader.uniforms.uTime = this.uTime; shader.uniforms.uSpeed = uSpeed; shader.uniforms.uAccent = uAccent; shader.vertexShader = 'varying vec3 vLocal;\n' + shader.vertexShader.replace('#include ', '#include \n vLocal = position;'); shader.fragmentShader = 'uniform float uTime;\nuniform float uSpeed;\nuniform vec3 uAccent;\nvarying vec3 vLocal;\n' + shader.fragmentShader.replace( '#include ', `#include if (vLocal.y > ${topY}) { ${chevronGLSL(shape)} totalEmissiveRadiance += uAccent * arrow * 0.85; }`, ); }; // Distinct cache key per shape: same base source, different injected flow field. mat.customProgramCacheKey = () => `fktry-belt-${shape}`; return mat; } private ensure(def: MachineDef, shape: BeltShape, needed: number): BeltMesh { const key = `${def.id}|${shape}`; const entry = this.registry.get(def.asset); const version = entry?.version ?? 0; let bm = this.meshes.get(key); if (bm && bm.assetVersion !== version) { this.group.remove(bm.mesh); bm.mesh.dispose(); this.meshes.delete(key); bm = undefined; // GLB landed for belts: rebuild from it } if (bm && needed <= bm.capacity) return bm; const capacity = Math.max(64, 1 << Math.ceil(Math.log2(Math.max(1, needed)))); const inst = entry?.instanceable() ?? null; const geometry = inst?.geometry ?? new THREE.BoxGeometry(0.94, HEIGHT_BY_KIND.belt, 0.94); if (!inst) geometry.translate(0, HEIGHT_BY_KIND.belt / 2, 0); // A real GLB brings its own look; the placeholder plate gets the chevron scroll. // (Taking inst.material unconditionally here silently dropped the chevrons — the // registry hands back a plain plate material for the placeholder case.) const material = entry?.isGLB && inst ? inst.material : this.material(def, shape); if (bm) { this.group.remove(bm.mesh); bm.mesh.dispose(); // grow: drop the old instanced mesh, keep geo/mat } const mesh = new THREE.InstancedMesh(geometry, material, capacity); mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); mesh.castShadow = mesh.receiveShadow = true; mesh.frustumCulled = false; mesh.count = 0; this.group.add(mesh); const next: BeltMesh = { mesh, capacity, assetVersion: version }; this.meshes.set(key, next); return next; } /** `topo` is rebuilt by index.ts once per frame; we only react when it moved. */ sync(_snap: SimSnapshot, timeSec: number): void { this.uTime.value = timeSec; const assetVersion = this.registry.version; const stamp = this.topo.version * 1000 + assetVersion; if (stamp === this.builtVersion) return; // belts are static once placed this.builtVersion = stamp; for (const def of this.defs.values()) { if (def.kind !== 'belt') continue; for (const shape of BELT_SHAPES) { const nodes = this.topo.nodesOf(def.id, shape); const bm = this.ensure(def, shape, nodes.length); nodes.forEach((n, i) => { this.dummy.position.set(n.x + 0.5, 0, n.y + 0.5); // 1x1 footprint: tile centre this.dummy.rotation.set(0, yawFor(n.dir), 0); this.dummy.updateMatrix(); bm.mesh.setMatrixAt(i, this.dummy.matrix); }); bm.mesh.count = nodes.length; bm.mesh.instanceMatrix.needsUpdate = true; bm.mesh.computeBoundingSphere(); } } } }