import { unitBox } from "./math.js"; const EPSILON = 1e-4; const LEVELS = Object.freeze([0.76, 0.84, 0.92, 1]); const OPEN = Object.freeze([1, 1, 1, 1]); /** Cutout and translucent surfaces should not stamp solid black corners. */ export function isAmbientOccluder(material) { if (!material) return true; // Match the renderer's unknown-block cube. if (material.transparent || material.cutout || (material.opacity ?? 1) < 1) return false; const name = (material.state ?? "").split("[")[0].split(":").pop(); return !/(?:^|_)(?:glass|leaves|sapling|flower|tulip|orchid|bush|roots|fern|vine|vines|torch|lantern|rail|coral|seagrass|kelp)(?:_|$)/.test(name) && !/^(?:water|lava|ice|frosted_ice|cobweb|scaffolding|iron_bars|chain|ladder|tripwire|redstone_wire|short_grass|tall_grass|dead_bush|sugar_cane|lily_pad|dandelion|poppy|blue_orchid|allium|azure_bluet|oxeye_daisy|cornflower|lily_of_the_valley|wither_rose|sunflower|lilac|rose_bush|peony|azalea|flowering_azalea|brown_mushroom|red_mushroom|crimson_fungus|warped_fungus)$/.test(name); } function validBoxes(material) { return (material?.render ?? [unitBox]).filter((box) => box?.min?.length === 3 && box?.max?.length === 3 && box.min.every((value, axis) => Number.isFinite(value) && Number.isFinite(box.max[axis]) && box.max[axis] > value), ); } /** * Create once per section rebuild. Voxel and material lookups are cached until * that rebuild ends; a later edit must use a fresh sampler. * * The returned function accepts (position, localBox, axisNormal, faceVertices). * faceVertices are four normalized 0/1 cube corners, in renderer face.v order. * Values describe ambient light only: direct sunlight remains independent. * Optional isOccluder(material, blockId) can reject texture-pack cutout surfaces. */ export function createAmbientOcclusionSampler(blocks, materials, isOccluder) { const materialBoxes = new Map(), voxelBoxes = new Map(), offsets = new Map(); offsets.set("0,0,0", [0, 0, 0]); const boxesFor = (id) => { if (!materialBoxes.has(id)) { const material = materials.get(id); materialBoxes.set(id, isAmbientOccluder(material) && (!isOccluder || isOccluder(material, id)) ? validBoxes(material) : [], ); } return materialBoxes.get(id); }; // Catalog render boxes can extend beyond their owning voxel (e.g. fences). // Only those actual offsets add lookups; ordinary cubes use a single cell. for (const id of materials.keys()) for (const box of boxesFor(id)) { // Unbounded custom geometry must not turn a section rebuild into a scan // of the whole world. The renderer/selection catalog uses this same reach. const min = box.min.map((value) => Math.max(-3, Math.floor(value))); const max = box.max.map((value) => Math.min(3, Math.ceil(value) - 1)); for (let x = min[0]; x <= max[0]; x++) for (let y = min[1]; y <= max[1]; y++) for (let z = min[2]; z <= max[2]; z++) offsets.set(`${x},${y},${z}`, [x, y, z]); } const ownerOffsets = [...offsets.values()]; const occupied = (point) => { const cellX = Math.floor(point[0]), cellY = Math.floor(point[1]), cellZ = Math.floor(point[2]); for (const offset of ownerOffsets) { const x = cellX - offset[0], y = cellY - offset[1], z = cellZ - offset[2]; const key = `${x},${y},${z}`; if (!voxelBoxes.has(key)) { const id = blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(key); voxelBoxes.set(key, id ? boxesFor(id) : []); } const localX = point[0] - x, localY = point[1] - y, localZ = point[2] - z; for (const box of voxelBoxes.get(key)) if (localX > box.min[0] && localX < box.max[0] && localY > box.min[1] && localY < box.max[1] && localZ > box.min[2] && localZ < box.max[2]) return true; } return false; }; return (pos, box, normal, faceVertices) => { const normalAxis = normal.findIndex((value) => Math.abs(value) === 1); if (normalAxis < 0 || normal.some((value, axis) => axis !== normalAxis && value !== 0)) return OPEN; const tangents = [0, 1, 2].filter((axis) => axis !== normalAxis); return faceVertices.map((vertex) => { const point = vertex.map((value, axis) => pos[axis] + box.min[axis] + value * (box.max[axis] - box.min[axis]) + normal[axis] * EPSILON, ); const sample = (first, second) => { const candidate = [...point]; for (let index = 0; index < 2; index++) { const axis = tangents[index]; candidate[axis] += (vertex[axis] < 0.5 ? -1 : 1) * (index === 0 ? first : second) * EPSILON; } return occupied(candidate) ? 1 : 0; }; const sideA = sample(1, -1), sideB = sample(-1, 1); // Both sides enclose the corner even when its diagonal cell is empty. const level = sideA && sideB ? 0 : 3 - sideA - sideB - sample(1, 1); return LEVELS[level]; }); }; } /** Mesh sections whose corner lighting can depend on an edited block. */ export function affectedSectionKeys(pos, renderBoxes = [unitBox]) { const min = [...pos], max = [...pos]; for (const box of renderBoxes ?? [unitBox]) for (let axis = 0; axis < 3; axis++) { min[axis] = Math.min(min[axis], pos[axis] + Math.floor(box.min[axis])); max[axis] = Math.max(max[axis], pos[axis] + Math.ceil(box.max[axis]) - 1); } const lower = min.map((value) => Math.floor((value - 1) / 16)); const upper = max.map((value) => Math.floor((value + 1) / 16)); const sections = []; for (let x = lower[0]; x <= upper[0]; x++) for (let y = lower[1]; y <= upper[1]; y++) for (let z = lower[2]; z <= upper[2]; z++) sections.push(`${x},${y},${z}`); return sections; }