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.
258 lines
14 KiB
JavaScript
258 lines
14 KiB
JavaScript
import { isAmbientOccluder } from "./ambient-occlusion.js";
|
|
import { unitBox } from "./math.js";
|
|
import { MAX_LIGHT_CELLS } from "./view-distance.js";
|
|
|
|
const MAX_CELLS = MAX_LIGHT_CELLS;
|
|
const EPSILON = 1e-4;
|
|
const DIRECTIONS = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];
|
|
const OPPOSITE = [1, 0, 3, 2, 5, 4];
|
|
const ZERO = Object.freeze([0, 0, 0]);
|
|
const DARK = Object.freeze({ block: ZERO, sky: 0, blockLevel: 0, skyLevel: 0 });
|
|
const integerTriple = (value) => Array.isArray(value) && value.length === 3 && value.every(Number.isSafeInteger);
|
|
const lightLevel = (value) => Number.isFinite(value) ? Math.max(0, Math.min(15, Math.floor(value))) : 0;
|
|
|
|
/** Canonical source strength comes exclusively from the authoritative state. */
|
|
export function blockEmission(material) {
|
|
const level = lightLevel(material?.light);
|
|
const state = (material?.state ?? "").split("[")[0].split(":").pop();
|
|
let color = [1, 0.82, 0.57];
|
|
if (/^soul_|soul_fire/.test(state)) color = [0.3, 0.82, 1];
|
|
else if (/sea_lantern|end_rod|beacon/.test(state)) color = [0.76, 0.9, 1];
|
|
else if (/redstone_(?:wall_)?torch|redstone_ore/.test(state)) color = [1, 0.28, 0.13];
|
|
else if (/amethyst|crying_obsidian|respawn_anchor/.test(state)) color = [0.69, 0.42, 1];
|
|
else if (/warped|glow_lichen|verdant_froglight/.test(state)) color = [0.54, 1, 0.79];
|
|
else if (/pearlescent_froglight/.test(state)) color = [1, 0.72, 0.9];
|
|
else if (/lava|fire|campfire|furnace|magma/.test(state)) color = [1, 0.56, 0.24];
|
|
else if (/torch|lantern|candle|glowstone|shroomlight|jack_o_lantern/.test(state)) color = [1, 0.75, 0.43];
|
|
return { level, color };
|
|
}
|
|
|
|
function boxesOf(value) {
|
|
return (value ?? []).filter((box) => box?.min?.length === 3 && box?.max?.length === 3 &&
|
|
box.min.every((min, axis) => Number.isFinite(min) && Number.isFinite(box.max[axis]) && box.max[axis] > min));
|
|
}
|
|
|
|
function faceRectangles(boxes, direction) {
|
|
const axis = direction >> 1, positive = direction % 2 === 0;
|
|
const tangents = [0, 1, 2].filter((value) => value !== axis);
|
|
const rectangles = [];
|
|
for (const box of boxes) {
|
|
if (positive ? box.max[axis] < 1 - EPSILON : box.min[axis] > EPSILON) continue;
|
|
const rect = [
|
|
Math.max(0, box.min[tangents[0]]), Math.max(0, box.min[tangents[1]]),
|
|
Math.min(1, box.max[tangents[0]]), Math.min(1, box.max[tangents[1]]),
|
|
];
|
|
if (rect[2] > rect[0] && rect[3] > rect[1]) rectangles.push(rect);
|
|
}
|
|
return rectangles;
|
|
}
|
|
|
|
/** Exact union coverage of axis-aligned boundary rectangles, including slabs. */
|
|
function coversFace(rectangles) {
|
|
if (!rectangles.length) return false;
|
|
if (rectangles.some((r) => r[0] === 0 && r[1] === 0 && r[2] === 1 && r[3] === 1)) return true;
|
|
const xs = [...new Set([0, 1, ...rectangles.flatMap((r) => [r[0], r[2]])])].sort((a, b) => a - b);
|
|
const ys = [...new Set([0, 1, ...rectangles.flatMap((r) => [r[1], r[3]])])].sort((a, b) => a - b);
|
|
for (let x = 1; x < xs.length; x++)
|
|
for (let y = 1; y < ys.length; y++) {
|
|
const midX = (xs[x - 1] + xs[x]) / 2, midY = (ys[y - 1] + ys[y]) / 2;
|
|
if (!rectangles.some((r) => midX > r[0] && midX < r[2] && midY > r[1] && midY < r[3])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function materialDescriptors(materials) {
|
|
const air = { index: 0, dampening: 0, boxes: [], faces: Array.from({ length: 6 }, () => []), fullFaces: 0, emission: { level: 0, color: ZERO } };
|
|
const descriptors = [air], byId = new Map(), faceCache = new Map();
|
|
const get = (id) => {
|
|
if (!id) return air;
|
|
if (!byId.has(id)) {
|
|
const material = materials.get(id);
|
|
const render = boxesOf(material?.render ?? [unitBox]);
|
|
const opaque = isAmbientOccluder(material);
|
|
const fullCube = opaque && coversFace(faceRectangles(render, 0)) && coversFace(faceRectangles(render, 1)) &&
|
|
coversFace(faceRectangles(render, 2)) && coversFace(faceRectangles(render, 3)) &&
|
|
coversFace(faceRectangles(render, 4)) && coversFace(faceRectangles(render, 5));
|
|
const emission = blockEmission(material);
|
|
const measuredDampening = material?.light_dampening ?? material?.light_block;
|
|
const dampening = Number.isFinite(measuredDampening) ? lightLevel(measuredDampening) : fullCube ? 15 : 0;
|
|
// Effective Java light-occlusion shapes are already empty for ordinary
|
|
// full cubes and transparent blocks. Opacity handles ordinary solid cubes.
|
|
const boxes = boxesOf(material?.light_occlusion ?? (opaque && !fullCube ? render : []));
|
|
const faces = DIRECTIONS.map((_, direction) => faceRectangles(boxes, direction));
|
|
const fullFaces = faces.reduce((mask, rects, direction) => mask | (coversFace(rects) ? 1 << direction : 0), 0);
|
|
const descriptor = { index: descriptors.length, dampening, boxes: opaque ? render : [], faces, fullFaces, emission };
|
|
descriptors.push(descriptor);
|
|
byId.set(id, descriptor);
|
|
}
|
|
return byId.get(id);
|
|
};
|
|
const blocked = (source, target, direction) => {
|
|
if ((source.fullFaces & (1 << direction)) || (target.fullFaces & (1 << OPPOSITE[direction]))) return true;
|
|
const first = source.faces[direction], second = target.faces[OPPOSITE[direction]];
|
|
if (!first.length || !second.length) return false;
|
|
const key = `${source.index},${target.index},${direction}`;
|
|
if (!faceCache.has(key)) faceCache.set(key, coversFace([...first, ...second]));
|
|
return faceCache.get(key);
|
|
};
|
|
return { get, descriptors, blocked };
|
|
}
|
|
|
|
function validateBounds(bounds) {
|
|
if (!integerTriple(bounds?.min) || !integerTriple(bounds?.max)) throw new RangeError("Block-light bounds must contain integer min/max triples");
|
|
const min = [...bounds.min], size = bounds.max.map((value, axis) => value - min[axis] + 1);
|
|
const count = size[0] * size[1] * size[2];
|
|
if (size.some((value) => value <= 0 || !Number.isSafeInteger(value)) || !Number.isSafeInteger(count) || count > MAX_CELLS)
|
|
throw new RangeError(`Block-light bounds must contain 1 to ${MAX_CELLS} cells`);
|
|
return { min, size, count };
|
|
}
|
|
|
|
/**
|
|
* Bounded, deterministic Java-style max-light propagation. Bounds are inclusive;
|
|
* x is the fastest axis: x + size[0] * (y + size[1] * z).
|
|
*
|
|
* RGB is a visual tint of the winning canonical source. Equal-strength sources
|
|
* combine their tint by channel maximum, never adding to block-light levels.
|
|
* Sky above the supplied top boundary is treated as exposed sky; unknown side
|
|
* and bottom boundaries are closed. The caller supplies the full loaded view.
|
|
*/
|
|
export function buildBlockLight(blocks, materials, bounds, skyColumns = null) {
|
|
const { min, size, count } = validateBounds(bounds);
|
|
const [sx, sy, sz] = size, plane = sx * sy;
|
|
const data = new Uint8Array(count * 4), blockLevels = new Uint8Array(count), skyLevels = new Uint8Array(count);
|
|
const types = new Uint32Array(count), definitions = materialDescriptors(materials);
|
|
const blockBuckets = Array.from({ length: 16 }, () => []), skyBuckets = Array.from({ length: 16 }, () => []);
|
|
let sourceCount = 0;
|
|
const addBlock = (wx,wy,wz,id) => {
|
|
const x=wx-min[0], y=wy-min[1], z=wz-min[2];
|
|
if(x<0||x>=sx||y<0||y>=sy||z<0||z>=sz)return;
|
|
const index=x+sx*(y+sy*z), descriptor=definitions.get(id);
|
|
types[index]=descriptor.index;
|
|
const {level,color}=descriptor.emission;
|
|
if(!level)return;
|
|
sourceCount++;blockLevels[index]=level;
|
|
for(let channel=0;channel<3;channel++)data[index*4+channel]=Math.round(color[channel]*255);
|
|
blockBuckets[level].push(index);
|
|
};
|
|
if(blocks.forEachBlock) blocks.forEachBlock(addBlock,bounds);
|
|
else for(const [key,id] of blocks) if(id) addBlock(...key.split(",").map(Number),id);
|
|
for (let z = 0; z < sz; z++)
|
|
for (let x = 0; x < sx; x++) {
|
|
if (skyColumns) {
|
|
const wx=x+min[0],wz=z+min[2], column=skyColumns.get(`${Math.floor(wx/16)},${Math.floor(wz/16)}`);
|
|
const height=column?.[((wx%16+16)%16)+16*((wz%16+16)%16)] ?? 32767;
|
|
if(height>=min[1]+sy)continue;
|
|
}
|
|
const index = x + sx * (sy - 1 + sy * z), descriptor = definitions.descriptors[types[index]];
|
|
if (descriptor.dampening >= 15 || descriptor.fullFaces & (1 << 2)) continue;
|
|
const value = 15 - descriptor.dampening;
|
|
if (!value) continue;
|
|
skyLevels[index] = value;
|
|
skyBuckets[value].push(index);
|
|
}
|
|
const offsets = [1, -1, sx, -sx, plane, -plane];
|
|
const propagate = (levels, buckets, sky) => {
|
|
for (let level = 15; level > 0; level--) {
|
|
const bucket = buckets[level];
|
|
for (let cursor = 0; cursor < bucket.length; cursor++) {
|
|
const index = bucket[cursor];
|
|
if (levels[index] !== level) continue;
|
|
const x = index % sx, y = Math.floor(index / sx) % sy, z = Math.floor(index / plane);
|
|
const source = definitions.descriptors[types[index]];
|
|
for (let direction = 0; direction < 6; direction++) {
|
|
if ((direction === 0 && x === sx - 1) || (direction === 1 && x === 0) ||
|
|
(direction === 2 && y === sy - 1) || (direction === 3 && y === 0) ||
|
|
(direction === 4 && z === sz - 1) || (direction === 5 && z === 0)) continue;
|
|
const nextIndex = index + offsets[direction], target = definitions.descriptors[types[nextIndex]];
|
|
const attenuation = sky && level === 15 && direction === 3 && target.dampening === 0 ? 0 : Math.max(1, target.dampening);
|
|
const value = level - attenuation, previous = levels[nextIndex];
|
|
if (value <= 0 || value < previous || (sky && value === previous) || definitions.blocked(source, target, direction)) continue;
|
|
if (value > previous) {
|
|
levels[nextIndex] = value;
|
|
buckets[value].push(nextIndex);
|
|
if (!sky) for (let channel = 0; channel < 3; channel++) data[nextIndex * 4 + channel] = data[index * 4 + channel];
|
|
} else if (!sky) {
|
|
// All parents at level + 1 are finalized before this level is
|
|
// propagated, so equal-strength tint changes need no extra queue.
|
|
for (let channel = 0; channel < 3; channel++)
|
|
data[nextIndex * 4 + channel] = Math.max(data[nextIndex * 4 + channel], data[index * 4 + channel]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
propagate(blockLevels, blockBuckets, false);
|
|
propagate(skyLevels, skyBuckets, true);
|
|
for (let index = 0; index < count; index++) {
|
|
const level = blockLevels[index], brightness = (level / 15) ** 2;
|
|
for (let channel = 0; channel < 3; channel++) data[index * 4 + channel] = Math.round(data[index * 4 + channel] * brightness);
|
|
data[index * 4 + 3] = level;
|
|
}
|
|
return attachBlockLightSamplers({ min, size, data, sourceCount, blockLevels, skyLevels }, blocks, materials);
|
|
}
|
|
|
|
/** Restore cheap sampling methods after worker ArrayBuffer transfer. */
|
|
export function attachBlockLightSamplers(field, blocks, materials) {
|
|
const { min, size, data, blockLevels, skyLevels } = field;
|
|
const definitions = materialDescriptors(materials), voxelCache = new Map(), sampleCache = new Map();
|
|
const indexAt = (point) => {
|
|
const x = Math.floor(point[0]) - min[0], y = Math.floor(point[1]) - min[1], z = Math.floor(point[2]) - min[2];
|
|
return x < 0 || x >= size[0] || y < 0 || y >= size[1] || z < 0 || z >= size[2] ? -1 : x + size[0] * (y + size[1] * z);
|
|
};
|
|
const voxelAt = (point) => {
|
|
const x = Math.floor(point[0]), y = Math.floor(point[1]), z = Math.floor(point[2]);
|
|
const index = x - min[0] + size[0] * (y - min[1] + size[1] * (z - min[2]));
|
|
if (!voxelCache.has(index)) voxelCache.set(index, { pos: [x, y, z], descriptor: definitions.get(blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(`${x},${y},${z}`)) });
|
|
return voxelCache.get(index);
|
|
};
|
|
const freeAt = (point) => {
|
|
if (indexAt(point) < 0) return false;
|
|
const { pos, descriptor } = voxelAt(point);
|
|
return !descriptor.boxes.some((box) => point.every((value, axis) => value - pos[axis] > box.min[axis] && value - pos[axis] < box.max[axis]));
|
|
};
|
|
const canSampleFrom = (point, anchor) => {
|
|
if (indexAt(anchor) < 0 || !freeAt(point)) return false;
|
|
const target = voxelAt(point), source = voxelAt(anchor);
|
|
let direction = -1, distance = 0;
|
|
for (let axis = 0; axis < 3; axis++) {
|
|
const delta = target.pos[axis] - source.pos[axis];
|
|
if (delta) { direction = axis * 2 + (delta < 0 ? 1 : 0); distance += Math.abs(delta); }
|
|
}
|
|
return distance === 0 || (distance === 1 && !definitions.blocked(source.descriptor, target.descriptor, direction));
|
|
};
|
|
field.sample = (point) => {
|
|
const index = indexAt(point);
|
|
if (index < 0) return DARK;
|
|
if (!sampleCache.has(index)) sampleCache.set(index,
|
|
{ block: [data[index * 4] / 255, data[index * 4 + 1] / 255, data[index * 4 + 2] / 255],
|
|
sky: skyLevels[index] / 15, blockLevel: blockLevels[index], skyLevel: skyLevels[index] });
|
|
return sampleCache.get(index);
|
|
};
|
|
field.sampleFace = (pos, box, normal, vertices) => {
|
|
const axis = normal.findIndex((value) => Math.abs(value) === 1), tangents = [0, 1, 2].filter((value) => value !== axis);
|
|
const result = { block: [], sky: [], blockLevels: [], skyLevels: [] };
|
|
for (const vertex of vertices) {
|
|
const corner = vertex.map((value, a) => pos[a] + box.min[a] + value * (box.max[a] - box.min[a]) + normal[a] * EPSILON);
|
|
const offset = (u, v) => {
|
|
const point = [...corner];
|
|
for (let i = 0; i < 2; i++) point[tangents[i]] += (vertex[tangents[i]] < 0.5 ? -1 : 1) * (i ? v : u) * EPSILON;
|
|
return point;
|
|
};
|
|
// Begin just inside this face's tangent bounds. Interpolate only toward
|
|
// accessible neighboring cells, never through an opaque corner or wall.
|
|
const base = offset(-1, -1), first = offset(1, -1), second = offset(-1, 1), diagonal = offset(1, 1);
|
|
const baseSample = freeAt(base) ? field.sample(base) : DARK;
|
|
const firstOpen = canSampleFrom(first, base), secondOpen = canSampleFrom(second, base);
|
|
const diagonalOpen = (firstOpen && canSampleFrom(diagonal, first)) || (secondOpen && canSampleFrom(diagonal, second));
|
|
const samples = [baseSample, firstOpen ? field.sample(first) : baseSample,
|
|
secondOpen ? field.sample(second) : baseSample, diagonalOpen ? field.sample(diagonal) : baseSample];
|
|
result.block.push([0, 1, 2].map((channel) => samples.reduce((sum, sample) => sum + sample.block[channel], 0) / 4));
|
|
result.sky.push(samples.reduce((sum, sample) => sum + sample.sky, 0) / 4);
|
|
result.blockLevels.push(samples.reduce((sum, sample) => sum + sample.blockLevel, 0) / 4);
|
|
result.skyLevels.push(samples.reduce((sum, sample) => sum + sample.skyLevel, 0) / 4);
|
|
}
|
|
return result;
|
|
};
|
|
return field;
|
|
}
|