Files
shacraft-core/client/terrain-controller.js
Emil c7e86663d8
MVP checks / mvp (push) Waiting to run
Expand voxel gameplay, lighting, full-height streaming and world imports
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
2026-09-17 02:10:53 +03:00

232 lines
12 KiB
JavaScript

import { localLightBounds } from "./local-light-bounds.js";
import { attachBlockLightSamplers } from "./block-light.js";
import { SectionBlockMap } from "./section-block-map.js";
const slimMaterial = m => ({ state: m.state, minecraft_id: m.minecraft_id, color: m.color,
render: m.render, opacity: m.opacity, transparent: m.transparent, effect: m.effect,
light: m.light, light_dampening: m.light_dampening, light_occlusion: m.light_occlusion });
/** Separate lighting and local mesh workers; one mesh in flight, two ready. */
export class TerrainController {
constructor({ onLight = () => {}, onDirty = () => {}, onError = () => {}, workerFactory, meshWorkerFactory } = {}) {
this.onLight = onLight; this.onDirty = onDirty;
this.onError = onError;
this.epoch = 0; this.version = 0; this.workerVersion = -1; this.job = 0;
this.meshTickets = new Map(); this.materialSignatures = new Map();
this.meshSerial = 0;
this.meshWorkerVersion = -1;
this.ready = new Map(); this.busy = null; this.pending = false; this.disposed = false;
this.changes = new SectionBlockMap(); this.sectionChanges = new Map();
this.unloads = new Map(); this.sentMaterials = new Map(); this.columns = new Map(); this.compact = false;
this.status = "loading"; this.mode = "worker";
this.materials = new Map(); this.textures = new Map(); this.bounds = null;
this.resetSource = null; this.initial = null; this.preparing = false;
this.resetPending = false; this.texturesPending = false;
try {
this.worker = workerFactory ? workerFactory() : new Worker(new URL("./terrain-worker.js", import.meta.url), { type: "module" });
this.worker.onmessage = ({ data }) => this.receive(data);
this.worker.onerror = event => {
event.preventDefault(); this.fail(event.message || "Terrain worker failed");
};
} catch (error) { this.fail(error.message); }
if (this.worker && (meshWorkerFactory || typeof Worker === "function")) {
try {
this.meshWorker = meshWorkerFactory ? meshWorkerFactory() : new Worker(new URL("./terrain-mesh-worker.js",import.meta.url),{type:"module"});
this.meshWorker.onmessage = ({data}) => this.receive(data);
this.meshWorker.onerror = event => {event.preventDefault();this.meshFallback(event.message);};
} catch (error) { this.meshFallback(error.message); }
}
}
meshFallback(error) {
this.meshWorker?.terminate(); this.meshWorker = null; this.busy = null; this.ready.clear();
this.onDirty([...this.meshTickets.keys()]); this.meshTickets.clear();
console.warn("Shacraft local meshing unavailable; using terrain worker",error);
}
invalidateMeshes({changes,sections,columns,unload,materials,textures,bounds} = {}) {
if (!this.meshWorker) return;
const boundsChanged = bounds !== undefined && JSON.stringify(bounds) !== JSON.stringify(this.bounds);
let all = Boolean(textures);
if (materials) for (const [id,mat] of materials) {
const signature=JSON.stringify(slimMaterial(mat));
if (this.materialSignatures.get(id)!==signature) {this.materialSignatures.set(id,signature);all=true;}
}
const affected = new Set((unload || []).map(p=>`${p[0]},${p[2]}`));
for (const {section} of sections || []) affected.add(`${section[0]},${section[2]}`);
for (const {column} of columns || []) affected.add(column.join(","));
for (const {pos} of changes || []) affected.add(`${Math.floor(pos[0]/16)},${Math.floor(pos[2]/16)}`);
const coords=[...affected].map(key=>key.split(",").map(Number)),dirty=[];
for (const id of this.meshTickets.keys()) {
const [x,,z]=id.split(",").map(Number);
const boundaryChanged = boundsChanged && JSON.stringify(localLightBounds(id,bounds)) !== JSON.stringify(localLightBounds(id,this.bounds));
if (!all && !boundaryChanged && !coords.some(([sx,sz])=>Math.abs(sx-x)<=2&&Math.abs(sz-z)<=2)) continue;
this.meshTickets.set(id,++this.meshSerial);this.ready.delete(id);dirty.push(id);
}
this.onDirty(dirty);
for (const p of unload || []) { const id=p.join(",");this.meshTickets.delete(id);this.ready.delete(id); }
}
fail(message) {
if (this.disposed) return;
this.status = `error: ${message}`;
this.error = message;
console.error("Shacraft terrain", message);
queueMicrotask(() => { if (!this.disposed) this.onError(message); });
}
reset(blocks, materials, textures, bounds) {
this.epoch++;
this.meshTickets.clear(); this.materialSignatures.clear(); this.ready.clear(); this.meshWorkerVersion=-1;
for (const [id,mat] of materials) this.materialSignatures.set(id,JSON.stringify(slimMaterial(mat)));
this.changes.clear(); this.sectionChanges.clear(); this.unloads.clear(); this.sentMaterials.clear(); this.columns.clear();
this.compact = typeof blocks.sectionEntries === "function";
this.materials = materials; this.textures = textures; this.bounds = bounds;
this.resetSource = this.compact ? null : blocks; this.initial = null; this.preparing = false;
if (this.compact) for (const [id,cells] of blocks.sectionEntries()) this.sectionChanges.set(id,cells);
this.resetPending = true; this.texturesPending = true;
this.busy = null;
this.changed();
}
update({ changes, sections, columns, materials, textures, bounds, unload } = {}) {
this.invalidateMeshes({changes,sections,columns,materials,textures,bounds,unload});
if (columns) for (const item of columns) this.columns.set(item.column.join(","),item);
if (sections) for (const { section, blocks, cells } of sections) {
const id = section.join(",");
// Complete entering sections supersede older queued edits. Retain their
// numeric records until packing instead of indexing every voxel again.
this.unloads.set(id, section);
this.changes.deleteSection(id);
this.sectionChanges.set(id, cells || blocks);
}
if (changes) for (const change of changes) this.changes.set(change.pos.join(","), change);
if (materials) this.materials = materials;
if (textures) { this.textures = textures; this.texturesPending = true; }
if (bounds !== undefined) this.bounds = bounds;
if (unload) for (const coords of unload) {
const id = coords.join(",");
this.unloads.set(id, coords);
// An unload supersedes older edits; later entering records must survive it.
this.changes.deleteSection(id);
this.sectionChanges.delete(id);
}
this.changed();
}
changed() {
this.version++; this.workerVersion = -1;
if (!this.meshWorker) this.ready.clear();
this.pending = true;
if (!this.error) this.status = "pending";
if (!this.timer) this.timer = setTimeout(() => { this.timer = null; this.flush(); }, 25);
}
prepareReset() {
if (this.preparing) return;
const epoch = this.epoch, source = this.resetSource, iterator = source.entries();
let packed = new Int32Array(Math.max(1, source.size) * 4), length = 0;
this.preparing = true;
const step = () => {
if (epoch !== this.epoch || this.disposed) return;
const start = performance.now();
for (let count = 0; ; count++) {
const next = iterator.next();
if (next.done) {
this.initial = packed.subarray(0, length);
this.resetSource = null; this.preparing = false;
this.flush(); return;
}
if (length + 4 > packed.length) {
const larger = new Int32Array(packed.length * 2); larger.set(packed); packed = larger;
}
const [key, id] = next.value, pos = key.split(",").map(Number);
packed.set([pos[0], pos[1], pos[2], id], length); length += 4;
if (count % 64 === 63 && performance.now() - start >= 2) {
setTimeout(step, 0); return;
}
}
};
step();
}
flush() {
if (!this.worker || this.disposed || !this.pending) return;
if (this.resetSource) { this.prepareReset(); return; }
const start = performance.now();
const materials = [];
for (const [id, material] of this.materials) {
if (this.sentMaterials.get(id) === material) continue;
this.sentMaterials.set(id, material); materials.push([id, slimMaterial(material)]);
}
let count = this.changes.size;
for (const records of this.sectionChanges.values()) if (!(records instanceof Uint32Array)) count += records.length;
const changes = new Int32Array(count * 4);
let i = 0;
const pack = records => {
for (const { pos, block } of records) {
changes[i++] = pos[0]; changes[i++] = pos[1]; changes[i++] = pos[2]; changes[i++] = block;
}
};
for (const records of this.sectionChanges.values()) if (!(records instanceof Uint32Array)) pack(records);
pack(this.changes.values());
const packet = { type: "sync", epoch: this.epoch, version: this.version,
reset: this.resetPending, compact: this.compact, initial: this.initial, changes, materials,
sections: [...this.sectionChanges].filter(([,data]) => data instanceof Uint32Array).map(([id,cells]) => ({section:id.split(",").map(Number),cells:cells.slice()})),
columns: [...this.columns.values()],
textures: this.texturesPending ? [...this.textures] : undefined,
bounds: this.bounds, unload: [...this.unloads.values()] };
const transfers = [changes.buffer, ...packet.sections.map(s => s.cells.buffer)];
if (this.initial) transfers.push(this.initial.buffer);
this.pending = false; this.resetPending = false; this.texturesPending = false;
this.initial = null; this.changes.clear(); this.sectionChanges.clear(); this.unloads.clear(); this.columns.clear();
this.status = "building";
try {
if (this.meshWorker) {
const copy=structuredClone(packet),buffers=[copy.changes.buffer,...copy.sections.map(s=>s.cells.buffer)];
if(copy.initial)buffers.push(copy.initial.buffer);
this.meshWorker.postMessage(copy,buffers);
}
this.worker.postMessage({...packet,independentMeshes:Boolean(this.meshWorker)}, transfers);
}
catch (error) { this.fail(error.message); }
this.prepareMilliseconds = performance.now() - start;
}
receive(message) {
if (this.disposed || message.epoch !== this.epoch) return;
if (message.type === "error" && message.meshTicket !== undefined) {
if (message.job !== this.busy?.job) return;
this.busy = null;
if (this.isCurrent(message)) this.meshFallback(message.error);
return;
}
if (message.type === "synced") {
this.meshWorkerVersion = Math.max(this.meshWorkerVersion,message.version);
} else if (message.type === "light") {
// Include deltas from skipped intermediate fields too: their union is
// needed when the next field is compared to a field never displayed.
if (!this.meshWorker) this.onDirty(message.dirty);
if (message.version !== this.version) return;
this.workerVersion = message.version;
this.status = "ready"; this.milliseconds = message.milliseconds;
// Dynamic actors only need point samples, not terrain face geometry.
this.onLight(attachBlockLightSamplers(message.field, new Map(), new Map()));
} else if (message.type === "mesh") {
if (message.job !== this.busy?.job) return;
this.busy = null;
if (!this.isCurrent(message) || message.stale) return;
if (message.error) { this.fail(message.error); return; }
this.meshMilliseconds = message.milliseconds;
this.localLightMilliseconds=message.lightMilliseconds; this.localLightCells=message.lightCells;
this.ready.set(message.id, message);
} else if (message.type === "error" && message.version === this.version) this.fail(message.error);
}
isCurrent(result) { return result.epoch === this.epoch && (result.meshTicket !== undefined
? Boolean(this.meshWorker) && this.meshTickets.get(result.id) === result.meshTicket : result.version === this.version); }
requestMesh(id) {
if (!this.worker || this.disposed || this.busy || this.pending || this.preparing ||
(this.meshWorker ? this.meshWorkerVersion < this.version : this.workerVersion !== this.version) || this.ready.size >= 2 || this.ready.has(id)) return false;
this.busy = { type: "mesh", id, epoch: this.epoch, version: this.version, job: ++this.job };
if (this.meshWorker) {
if(!this.meshTickets.has(id))this.meshTickets.set(id,++this.meshSerial);
this.busy.meshTicket=this.meshTickets.get(id);
}
try { (this.meshWorker || this.worker).postMessage(this.busy); }
catch (error) { this.busy = null; this.fail(error.message); return false; }
return true;
}
dispose() { this.disposed = true; clearTimeout(this.timer); this.worker?.terminate(); this.meshWorker?.terminate(); this.ready.clear(); }
}