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>
130 lines
4.4 KiB
TypeScript
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();
|
|
}
|