MVP checks / mvp (push) Canceled after 0s
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.
121 lines
5.7 KiB
JavaScript
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;
|
|
}
|