TURNCRAFT/src/engine/ChunkManager.ts
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
Lanes A (engine), B (player), C (worldgen), D (machines/quest),
E (audio/fx/ui) as landed, each with HANDOFF.md + update docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:50:56 +10:00

130 lines
4.4 KiB
TypeScript

// 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<number, ChunkMeshes>();
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();
}