MVP checks / mvp (push) Waiting to run
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.
232 lines
12 KiB
JavaScript
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(); }
|
|
}
|