Files
shacraft-core/client/ambient-occlusion.js
Emil c7e86663d8
MVP checks / mvp (push) Canceled after 0s
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

121 lines
5.7 KiB
JavaScript

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