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