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(); } }