import { localLightBounds } from "./local-light-bounds.js"; import { SectionVoxelMap } from "./section-voxel-map.js"; import { SectionBlockMap } from "./section-block-map.js"; import { buildBlockLight, attachBlockLightSamplers } from "./block-light.js"; import { applyLightProperties } from "./light-properties.js"; import { changedLightSections } from "./light-changes.js"; import { buildSectionMesh } from "./mesh-geometry.js"; // Compare the transmitted values rather than object identity: structured clone // creates new objects even when a chunk repeats an unchanged definition. const materialSignature = material => JSON.stringify([ material.state, material.minecraft_id, material.color, material.render?.map(box => [box.min, box.max]), material.opacity, material.transparent, material.effect, material.light, material.light_dampening, material.light_occlusion?.map(box => [box.min, box.max]), ]); /** Persistent CPU terrain state. Only numeric deltas cross the worker boundary. */ export class TerrainState { constructor(properties) { this.properties = properties; this.epoch = -1; this.version = -1; this.blocks = new SectionBlockMap(); this.materials = new Map(); this.materialSignatures = new Map(); this.changedMaterials = new Set(); this.textureLayers = new Map(); this.faceTextureCache = new Map(); this.meshed = new Set(); this.field = null; this.bounds = null; this.needsLighting = false; this.skyColumns = new Map(); this.localLights = new Map(); } sync(packet) { if (packet.epoch < this.epoch || packet.epoch === this.epoch && packet.version < this.version) return false; if (JSON.stringify(packet.bounds) !== JSON.stringify(this.bounds)) for (const [key,entry] of this.localLights) { const [x,z]=key.split(","); if (JSON.stringify(localLightBounds(`${x},0,${z}`,packet.bounds)) !== JSON.stringify(entry.bounds)) this.localLights.delete(key); } const columns = new Set((packet.unload || []).map(p=>`${p[0]},${p[2]}`)); for (const {section} of packet.sections || []) columns.add(`${section[0]},${section[2]}`); for (const {column} of packet.columns || []) columns.add(column.join(",")); for (let i=0;i<(packet.changes?.length || 0);i+=4) columns.add(`${Math.floor(packet.changes[i]/16)},${Math.floor(packet.changes[i+2]/16)}`); for (const [id,entry] of this.localLights) { if ([...columns].some(column=>{const [x,z]=column.split(",").map(v=>Number(v)*16);return x<=entry.bounds.max[0]&&x+15>=entry.bounds.min[0]&&z<=entry.bounds.max[2]&&z+15>=entry.bounds.min[2];})) this.localLights.delete(id); } if (packet.reset) { this.localLights.clear(); this.blocks = packet.compact ? new SectionVoxelMap() : new SectionBlockMap(); this.skyColumns.clear(); this.materials.clear(); this.meshed.clear(); this.materialSignatures.clear(); this.changedMaterials.clear(); this.field = null; } else if (packet.epoch !== this.epoch) return false; this.epoch = packet.epoch; this.version = packet.version; for (const [id, material] of packet.materials || []) { const signature = materialSignature(material); if (this.materialSignatures.get(id) === signature) continue; this.localLights.clear(); this.materialSignatures.set(id, signature); this.changedMaterials.add(id); this.materials.set(id, applyLightProperties(material, this.properties)); } if (packet.textures) { this.textureLayers = new Map(packet.textures); this.faceTextureCache.clear(); } const apply = data => { if (!data) return; for (let i = 0; i < data.length; i += 4) { const key = `${data[i]},${data[i + 1]},${data[i + 2]}`; if (data[i + 3]) this.blocks.set(key, data[i + 3]); else this.blocks.delete(key); } }; apply(packet.initial); for (const coords of packet.unload || []) { const id = coords.join(","); this.blocks.deleteSection(id); this.meshed.delete(id); } for (const {section,cells} of packet.sections || []) this.blocks.setSection(section.join(","),cells); for (const {column,heights} of packet.columns || []) this.skyColumns.set(column.join(","),heights); apply(packet.changes); this.bounds = packet.bounds; if (this.bounds) for (const id of this.skyColumns.keys()) { const [x,z]=id.split(",").map(v=>Number(v)*16); if(x+15this.bounds.max[0]||z+15this.bounds.max[2])this.skyColumns.delete(id); } this.needsLighting = true; return true; } light({dirty:compareDirty=true} = {}) { const start = performance.now(); const view = this.bounds || inferBounds(this.blocks); // Terrain meshes compute their own light. The shared field is only for // nearby moving actors and must stay bounded at any render distance. const bounds = compareDirty ? view : actorLightBounds(view); const field = buildBlockLight(this.blocks, this.materials, bounds, this.skyColumns.size ? this.skyColumns : null); const sections = new Set([...this.blocks.loadedSectionKeys(), ...this.meshed]); const dirty = new Set(compareDirty ? changedLightSections(this.field, field, sections) : []); if (compareDirty && this.field && this.changedMaterials.size) { // A newly learned opaque block can have the same light values as its // placeholder but a different color, shape or texture. Scan only here in // the worker, and preserve this union across syncs coalesced before light(). for (const id of this.blocks.loadedSectionKeys()) { let affected = false; for (const key of this.blocks.keysInSection(id)) if (this.changedMaterials.has(this.blocks.get(key))) { affected = true; break; } if (!affected) continue; const section = id.split(",").map(Number); for (let x = -1; x <= 1; x++) for (let y = -1; y <= 1; y++) for (let z = -1; z <= 1; z++) { const neighbor = `${section[0] + x},${section[1] + y},${section[2] + z}`; if (sections.has(neighbor)) dirty.add(neighbor); } } } this.changedMaterials.clear(); this.field = field; this.needsLighting = false; // Keep the worker's sampling arrays; only these copies are transferred. return { type: "light", epoch: this.epoch, version: this.version, dirty: [...dirty], milliseconds: performance.now() - start, field: { min: field.min, size: field.size, sourceCount: field.sourceCount, data: field.data.slice(), blockLevels: field.blockLevels.slice(), skyLevels: field.skyLevels.slice() } }; } meshLocal(packet) { const result = {type:"mesh",epoch:packet.epoch,version:packet.version,id:packet.id,job:packet.job,meshTicket:packet.meshTicket}; if (packet.epoch !== this.epoch || packet.version > this.version) return {...result,stale:true}; const start = performance.now(), [sx,,sz] = packet.id.split(",").map(Number); const key = `${Math.floor(sx/2)*2},${Math.floor(sz/2)*2}`, view = this.bounds || inferBounds(this.blocks); // Light levels are at most 15. An 18-cell horizontal halo includes their // reach and face/partial-model samples; keep full Y for unattenuated sky. const bounds = localLightBounds(packet.id,view); if (bounds.min.some((v,i)=>v>bounds.max[i])) return {...result,stale:true}; let entry = this.localLights.get(key); const cached = Boolean(entry); if (!entry) { const field = buildBlockLight(this.blocks,this.materials,bounds,this.skyColumns.size ? this.skyColumns : null); entry = {bounds,field}; if (this.localLights.size >= 8) this.localLights.delete(this.localLights.keys().next().value); this.localLights.set(key,entry); } else { this.localLights.delete(key); this.localLights.set(key,entry); } const lightMilliseconds = performance.now()-start; // Fresh samplers reference current voxels; their occlusion caches must not // survive a sync, even when its numeric light field remains valid. const field = attachBlockLightSamplers({...entry.field},this.blocks,this.materials); const geometry = buildSectionMesh({id:packet.id,blocks:this.blocks,materials:this.materials, textureLayers:this.textureLayers,faceTextureCache:this.faceTextureCache,blockLightField:field,localCoordinates:true}); return {...result,...geometry,milliseconds:performance.now()-start,lightMilliseconds,lightCached:cached, lightCells:field.size.reduce((a,b)=>a*b,1)}; } mesh(packet) { const result = { type: "mesh", epoch: packet.epoch, version: packet.version, id: packet.id, job: packet.job }; if (packet.epoch !== this.epoch || packet.version !== this.version || this.needsLighting) return { ...result, stale: true }; const start = performance.now(); const geometry = buildSectionMesh({ id: packet.id, blocks: this.blocks, materials: this.materials, textureLayers: this.textureLayers, faceTextureCache: this.faceTextureCache, blockLightField: this.field, localCoordinates: true }); this.meshed.add(packet.id); return { ...result, ...geometry, milliseconds: performance.now() - start }; } } export function actorLightBounds(view) { const min = [...view.min], max = [...view.max]; for (const axis of [0, 2]) { const center = Math.floor((view.min[axis] + view.max[axis]) / 32) * 16; min[axis] = Math.max(min[axis], center - 48); max[axis] = Math.min(max[axis], center + 63); } return {min, max}; } function inferBounds(blocks) { const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]; for (const key of blocks.keys()) { const pos = key.split(",").map(Number); for (let axis = 0; axis < 3; axis++) { min[axis] = Math.min(min[axis], pos[axis]); max[axis] = Math.max(max[axis], pos[axis]); } } return Number.isFinite(min[0]) ? { min: min.map(v => v - 15), max: max.map(v => v + 15) } : { min: [-16, -16, -16], max: [16, 16, 16] }; }