Files
Emil c7e86663d8
MVP checks / mvp (push) Waiting to run
Expand voxel gameplay, lighting, full-height streaming and world imports
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.
2026-09-17 02:10:53 +03:00

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