// TURNCRAFT — Lane A. Builds and maintains chunk meshes from a VoxelWorld. // // buildAll() meshes every non-empty chunk once (initial world load). update() // drains the world's dirty set with a per-frame budget, remeshing edited // chunks and swapping their geometry in place. Each chunk owns up to two meshes // (opaque + transparent) sharing the atlas's two singleton materials. Vertex // positions are baked in absolute world coords, so each mesh's bounding sphere // drives Three.js frustum culling with no per-chunk matrix work. import * as THREE from 'three'; import type { VoxelWorld } from './VoxelWorld'; import type { Atlas } from './atlas'; import { meshChunk } from './mesher'; interface ChunkMeshes { opaque: THREE.Mesh | null; transparent: THREE.Mesh | null; } export class ChunkManager { private readonly meshes = new Map(); private readonly dirtyScratch: number[] = []; // reused each frame (cx,cy,cz,...) /** ms spent in the most recent remesh batch (for demo/perf HUD). */ lastRemeshMs = 0; /** chunks remeshed in the most recent update(). */ lastRemeshCount = 0; constructor( private readonly world: VoxelWorld, private readonly scene: THREE.Scene, private readonly atlas: Atlas, /** max chunks remeshed per update() call (per frame). */ private readonly remeshBudget = 4, ) {} private key(cx: number, cy: number, cz: number): number { return (cy * this.world.numChunksZ + cz) * this.world.numChunksX + cx; } /** Mesh every non-empty chunk. Call once after worldgen fills the world. */ buildAll(): void { const t0 = now(); for (let cy = 0; cy < this.world.numChunksY; cy++) for (let cz = 0; cz < this.world.numChunksZ; cz++) for (let cx = 0; cx < this.world.numChunksX; cx++) { if (this.world.isChunkEmpty(cx, cy, cz)) continue; this.remeshChunk(cx, cy, cz); this.world.clearDirty(cx, cy, cz); } // Any dirt flagged on empty chunks (e.g. fillBox margins) is now moot. this.world.forEachDirtyChunk((cx, cy, cz) => this.world.clearDirty(cx, cy, cz)); this.lastRemeshMs = now() - t0; } /** Drain the dirty set, budgeted. Call once per frame. */ update(): void { if (this.world.dirtyCount === 0) { this.lastRemeshCount = 0; return; } const t0 = now(); const batch = this.dirtyScratch; batch.length = 0; this.world.forEachDirtyChunk((cx, cy, cz) => { batch.push(cx, cy, cz); }); const limit = Math.min(this.remeshBudget, batch.length / 3) | 0; for (let i = 0; i < limit; i++) { const cx = batch[i * 3], cy = batch[i * 3 + 1], cz = batch[i * 3 + 2]; this.remeshChunk(cx, cy, cz); this.world.clearDirty(cx, cy, cz); } this.lastRemeshCount = limit; this.lastRemeshMs = now() - t0; } get meshCount(): number { let n = 0; for (const m of this.meshes.values()) { if (m.opaque) n++; if (m.transparent) n++; } return n; } private remeshChunk(cx: number, cy: number, cz: number): void { const geo = meshChunk(this.world, cx, cy, cz); const k = this.key(cx, cy, cz); let entry = this.meshes.get(k); if (!entry) { entry = { opaque: null, transparent: null }; this.meshes.set(k, entry); } entry.opaque = this.applyGeometry(entry.opaque, geo.opaque, this.atlas.opaqueMaterial); entry.transparent = this.applyGeometry(entry.transparent, geo.transparent, this.atlas.transparentMaterial); if (!entry.opaque && !entry.transparent) this.meshes.delete(k); } private applyGeometry( mesh: THREE.Mesh | null, geo: THREE.BufferGeometry | null, material: THREE.Material, ): THREE.Mesh | null { if (!geo) { if (mesh) { this.scene.remove(mesh); disposeGeom(mesh); } return null; } if (mesh) { disposeGeom(mesh); mesh.geometry = geo; return mesh; } const m = new THREE.Mesh(geo, material); m.frustumCulled = true; this.scene.add(m); return m; } dispose(): void { for (const entry of this.meshes.values()) { if (entry.opaque) { this.scene.remove(entry.opaque); disposeGeom(entry.opaque); } if (entry.transparent) { this.scene.remove(entry.transparent); disposeGeom(entry.transparent); } } this.meshes.clear(); } } function disposeGeom(mesh: THREE.Mesh): void { (mesh.geometry as THREE.BufferGeometry).dispose(); } function now(): number { return typeof performance !== 'undefined' ? performance.now() : Date.now(); }