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>
170 lines
6.8 KiB
TypeScript
170 lines
6.8 KiB
TypeScript
/**
|
|
* 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<string, BeltMesh>();
|
|
private uTime = { value: 0 };
|
|
private dummy = new THREE.Object3D();
|
|
private builtVersion = -1;
|
|
|
|
constructor(
|
|
private registry: AssetRegistry,
|
|
private defs: Map<string, MachineDef>,
|
|
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 <begin_vertex>', '#include <begin_vertex>\n vLocal = position;');
|
|
shader.fragmentShader =
|
|
'uniform float uTime;\nuniform float uSpeed;\nuniform vec3 uAccent;\nvarying vec3 vLocal;\n' +
|
|
shader.fragmentShader.replace(
|
|
'#include <emissivemap_fragment>',
|
|
`#include <emissivemap_fragment>
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|