MVP checks / mvp (push) Canceled after 0s
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.
193 lines
9.9 KiB
JavaScript
193 lines
9.9 KiB
JavaScript
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+15<this.bounds.min[0]||x>this.bounds.max[0]||z+15<this.bounds.min[2]||z>this.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] };
|
|
}
|