Expand voxel gameplay, lighting, full-height streaming and world imports
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.
This commit is contained in:
Emil
2026-09-17 02:10:53 +03:00
parent 26748912c6
commit c7e86663d8
111 changed files with 24257 additions and 598 deletions
+120
View File
@@ -0,0 +1,120 @@
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;
}
+595 -136
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
import { attachBlockLightSamplers, buildBlockLight } from "./block-light.js";
import { applyLightProperties, loadLightProperties } from "./light-properties.js";
/** Only one build runs at a time. Edits arriving during a build replace its result. */
export class BlockLightController {
constructor(publish) {
this.publish = publish;
this.generation = 0;
this.busy = false;
this.pending = null;
this.status = "loading";
this.properties = null;
this.worker = null;
if (typeof Worker === "function") {
try {
this.worker = new Worker(new URL("./block-light-worker.js", import.meta.url), { type: "module" });
} catch {
// Restricted browser contexts can reject workers before any error event.
// The same solver remains available on the main thread.
}
}
if (this.worker) {
this.worker.onmessage = ({ data }) => this.receive(data);
this.worker.onerror = (event) => {
event.preventDefault();
this.worker.terminate();
this.worker = null;
this.busy = false;
// Preserve the latest request and recover with the same implementation.
this.pending ||= this.current;
this.start();
};
}
loadLightProperties().then(properties => {
this.properties = properties;
this.start();
}).catch(error => {
this.status = `error: ${error.message}`;
console.error("Shacraft light metadata", error);
});
}
request(blocks, materials, bounds) {
this.generation++;
this.pending = { blocks, materials, bounds, generation: this.generation };
this.status = "pending";
if (!this.timer) this.timer = setTimeout(() => { this.timer = null; this.start(); }, 25);
}
start() {
if (this.busy || !this.pending || !this.properties) return;
const request = this.pending;
this.pending = null;
this.busy = true;
this.current = request;
this.status = "building";
const materials = new Map([...request.materials].map(([id, mat]) => [id,
applyLightProperties({
minecraft_id: mat.minecraft_id, state: mat.state, light: mat.light,
opacity: mat.opacity, render: mat.render, transparent: mat.transparent,
light_dampening: mat.light_dampening, light_occlusion: mat.light_occlusion,
}, this.properties)]));
const packet = { generation: request.generation, bounds: request.bounds,
blocks: [...request.blocks], materials: [...materials] };
this.current = { ...request, blocks: new Map(packet.blocks), materials };
if (this.worker) this.worker.postMessage(packet);
else setTimeout(() => {
const start = performance.now();
try {
this.receive({ generation: request.generation,
field: buildBlockLight(this.current.blocks, materials, request.bounds),
milliseconds: performance.now() - start });
} catch (error) { this.receive({ generation: request.generation, error: error.message }); }
}, 0);
}
receive(message) {
const current = this.current;
if (!this.busy || message.generation !== current?.generation) return;
this.busy = false;
if (message.generation === this.generation) {
if (message.error) {
this.status = `error: ${message.error}`;
console.error("Shacraft block lighting", message.error);
} else {
this.status = "ready";
this.milliseconds = message.milliseconds;
this.publish(attachBlockLightSamplers(message.field, current.blocks, current.materials));
}
}
this.start();
}
}
/** In a standalone scene, keep a full light-radius margin around its geometry. */
export function inferLightBounds(blocks) {
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
for (const key of blocks.keys()) {
const pos = key.split(",").map(Number);
for (let axis = 0; axis < 3; axis++) {
min[axis] = Math.min(min[axis], pos[axis]);
max[axis] = Math.max(max[axis], pos[axis]);
}
}
if (!Number.isFinite(min[0])) return { min: [-16, -16, -16], max: [16, 16, 16] };
return { min: min.map(v => v - 15), max: max.map(v => v + 15) };
}
+15
View File
@@ -0,0 +1,15 @@
import { buildBlockLight } from "./block-light.js";
self.onmessage = ({ data: request }) => {
try {
const start = performance.now();
const result = buildBlockLight(new Map(request.blocks), new Map(request.materials), request.bounds);
const { min, size, data, sourceCount, blockLevels, skyLevels } = result;
self.postMessage({ generation: request.generation,
field: { min, size, data, sourceCount, blockLevels, skyLevels },
milliseconds: performance.now() - start,
}, [data.buffer, blockLevels.buffer, skyLevels.buffer]);
} catch (error) {
self.postMessage({ generation: request.generation, error: error.message });
}
};
+257
View File
@@ -0,0 +1,257 @@
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;
}
+101
View File
@@ -0,0 +1,101 @@
import { BLOCK_FACES } from "./texture-pack.js";
const WHITE = Object.freeze([1, 1, 1]);
const GRASS = Object.freeze([0.58, 0.8, 0.34]);
const LEAVES = Object.freeze([0.46, 0.7, 0.28]);
// These aliases share a surface material. Similar block names are deliberately
// not enough: stripped logs, other wood species and deepslate ores need assets.
const materials = new Map([
["stone", ["stone", "stone_stairs", "stone_slab", "stone_button", "stone_pressure_plate"]],
["cobblestone", ["cobblestone", "cobblestone_stairs", "cobblestone_slab", "cobblestone_wall"]],
["oak_planks", ["oak_planks", "oak_stairs", "oak_slab", "oak_fence", "oak_fence_gate", "oak_button", "oak_pressure_plate"]],
["bricks", ["bricks", "brick_stairs", "brick_slab", "brick_wall"]],
["stone_bricks", ["stone_bricks", "stone_brick_stairs", "stone_brick_slab", "stone_brick_wall"]],
["glass", ["glass", "glass_pane"]],
["snow", ["snow", "snow_block"]],
...["dirt", "sand", "gravel", "diamond_ore", "iron_ore", "coal_ore", "gold_ore", "redstone_ore", "deepslate", "obsidian", "netherrack"].map((name) => [name, [name]]),
].flatMap(([texture, blocks]) => blocks.map((block) => [block, texture])));
/** Face order matches renderer.js: east, west, up, down, south, north. */
export function getBlockFaceTextures(state, layers) {
const empty = () => Array(6).fill(null);
const match = /^(?:minecraft:)?([a-z0-9_]+)(?:\[([^\]]*)\])?$/.exec(state ?? "");
if (!match) return empty();
const [, name, properties = ""] = match;
const props = Object.fromEntries(
properties.split(",").filter(Boolean).map((entry) => entry.split("=")),
);
const mapping = layers.get(BLOCK_FACES);
if (mapping) {
const base = `minecraft:${name}`;
const canonical = base + (properties ? `[${Object.entries(props).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join(",")}]` : "");
const index = Object.hasOwn(mapping.states, canonical) ? mapping.states[canonical] : mapping.defaults[base];
if (Number.isInteger(index)) return mapping.sets[index];
}
const face = (texture, tint = WHITE, cutout = false) => {
const layer = layers.get(texture);
return Number.isInteger(layer) && layer >= 0 ? { layer, tint, cutout } : null;
};
const all = (texture, tint = WHITE, cutout = false) =>
Array.from({ length: 6 }, () => face(texture, tint, cutout));
if (name === "grass_block") {
const side = props.snowy === "true" ? null : "grass_block_side";
return [side, side, "grass_block_top", "dirt", side, side].map(
(texture, index) => texture ? face(texture, index === 2 ? GRASS : WHITE) : null,
);
}
if (name === "oak_log") {
const caps = { x: [0, 1], y: [2, 3], z: [4, 5] }[props.axis ?? "y"];
if (!caps) return empty();
return Array.from({ length: 6 }, (_, index) =>
face(caps.includes(index) ? "oak_log_top" : "oak_log"),
);
}
if (name === "oak_wood") return all("oak_log");
if (name === "oak_leaves") return all("oak_leaves", LEAVES, true);
if (name === "oak_door") {
if ((props.half ?? "lower") !== "lower") return empty();
let alongX = ["east", "west"].includes(props.facing ?? "north");
if (props.open === "true") alongX = !alongX;
const broadFaces = alongX ? [0, 1] : [4, 5];
return Array.from({ length: 6 }, (_, index) =>
face(broadFaces.includes(index) ? "oak_door_bottom" : "oak_planks"),
);
}
const texture = materials.get(name);
return texture ? all(texture, WHITE, texture === "glass") : empty();
}
/**
* UVs address an unflipped bitmap with its first row at V=0. Coordinates stay
* relative to the whole block, so slabs and other boxes crop instead of stretch.
* Horizontal logs rotate back to the vertical-log frame before projection,
* aligning the bark grain with the log axis without mirroring any face.
*/
export function getFaceUV(faceIndex, localPos, state = "", textureFace = null) {
if (textureFace?.uv) {
const uv = textureFace.uv, [x, y, z] = localPos;
return [uv[0]*x + uv[1]*y + uv[2]*z + uv[3], uv[4]*x + uv[5]*y + uv[6]*z + uv[7]];
}
let [x, y, z] = localPos;
const log = /^(?:minecraft:)?oak_(?:log|wood)\[([^\]]*)\]$/.exec(state);
const axis = log && /(?:^|,)axis=([xz])(?:,|$)/.exec(log[1])?.[1];
if (axis === "x") {
[x, y, z] = [1 - y, x, z];
faceIndex = [2, 3, 1, 0, 4, 5][faceIndex];
} else if (axis === "z") {
[x, y, z] = [x, z, 1 - y];
faceIndex = [0, 1, 5, 4, 2, 3][faceIndex];
}
switch (faceIndex) {
case 0: return [1 - z, 1 - y];
case 1: return [z, 1 - y];
case 2: return [x, z];
case 3: return [x, 1 - z];
case 4: return [x, 1 - y];
case 5: return [1 - x, 1 - y];
default: throw new RangeError(`Unknown block face: ${faceIndex}`);
}
}
+60
View File
@@ -0,0 +1,60 @@
const percentile=(values,p)=>{if(!values.length)return 0;const sorted=[...values].sort((a,b)=>a-b);return Math.round(sorted[Math.min(sorted.length-1,Math.floor((sorted.length-1)*p))]*100)/100;};
const statistics=values=>({p50:percentile(values,.5),p95:percentile(values,.95),p99:percentile(values,.99),max:percentile(values,1)});
/** Bounded local trace. Durations use monotonic browser time, not wall clocks. */
export class DynamicsDiagnostics {
constructor(){this.status='idle';this.recent=[];this.frames=[];this.events=[];this.server=[];this.result=null;this.scenario='record';this.duration=30;this.toggleSent=false;}
arm(scenario='record',duration=30){
this.status='warming';this.scenario=scenario;this.duration=Math.min(120,Math.max(5,duration));this.frames=[];this.events=[];this.server=[];this.result=null;this.toggleSent=false;this.climbUntil=0;this.armed=performance.now();this.stableAt=null;
}
ready(sample,ready,now=performance.now()){
if(this.status!=='warming')return;
if(ready)this.stableAt??=now;else this.stableAt=null;
if(this.stableAt!==null&&now-this.stableAt>=500){this.status='recording';this.started=now;this.initial=sample;this.yaw=sample.yaw;}
if(now-this.armed>120000){this.status='error';this.result={error:'Terrain did not settle within 120 seconds',sample};}
}
frame(sample,now=performance.now()){
this.recent.push(sample);if(this.recent.length>300)this.recent.shift();
if(this.status!=='recording')return;
if(this.frames.length<18000)this.frames.push({...sample,t:now-this.started});
if(now-this.started>=this.duration*1000)this.stop(now);
}
event(type,milliseconds,detail={}){if(this.status==='recording'&&this.events.length<3000)this.events.push({t:performance.now()-this.started,type,milliseconds,...detail});}
serverSample(sample){if(this.status==='recording'&&this.server.length<150)this.server.push({t:performance.now()-this.started,...sample});}
controls(consume,flying,now=performance.now(),collision=false){
if(this.status!=='recording'||this.scenario==='record')return null;
const t=(now-this.started)/1000,fly=this.scenario!=='run';
let fly_toggle=false;
if(fly&&!flying&&!this.toggleSent){fly_toggle=true;if(consume)this.toggleSent=true;}
if(fly&&collision&&consume)this.climbUntil=now+900;
return {forward:fly&&t<2.5?0:1,strafe:0,jump:fly&&(t<2.5||now<(this.climbUntil||0)),sprint:true,sneak:false,fly_toggle,
yaw:this.yaw+(this.scenario==='turns'?Math.sin(t*.8)*1.3:Math.sin(Math.max(0,t-3)*.11)*.35),pitch:fly?-.16:-.08};
}
stop(now=performance.now(),reason=null){
if(this.status==='warming'){this.status='idle';return;}
if(this.status!=='recording')return;
const f=this.frames,last=f.at(-1)||this.initial,values=k=>f.map(x=>x[k]||0);
let distance=0;for(let i=1;i<f.length;i++)distance+=Math.hypot(...f[i].position.map((v,a)=>v-f[i-1].position[a]));
this.result={schema:1,scenario:this.scenario,interrupted:reason,durationMs:Math.round(now-this.started),frames:f.length,
frameMs:statistics(values('frameMs')),physicsMs:statistics(values('physicsMs')),drawMs:statistics(values('drawMs')),
over25ms:f.filter(x=>x.frameMs>25).length,over50ms:f.filter(x=>x.frameMs>50).length,
streamApplyMs:statistics(this.events.filter(e=>e.type==='sections').map(e=>e.milliseconds)),
networkHandlerMs:statistics(this.events.filter(e=>e.type==='network').map(e=>e.milliseconds)),
longTasks:this.events.filter(e=>e.type==='longtask').length,
maxCorrection:percentile(values('correction'),1),waitingFrames:f.filter(x=>x.waiting).length,
maxDirty:percentile(values('dirty'),1),distance:Math.round(distance*100)/100,
viewChanges:last.viewChanges-this.initial.viewChanges,sectionBatches:last.batches-this.initial.batches,
meshResets:last.meshResets-this.initial.meshResets,loadedSections:last.loadedSections,totalSections:last.totalSections,
voxelBytes:last.voxelBytes,server:this.server.at(-1)||null,start:this.initial.position,end:last.position};
this.status='done';
}
trace(){return {summary:this.result,frames:this.frames,events:this.events,server:this.server};}
}
export function drawDynamicsChart(canvas,samples){
const ctx=canvas.getContext('2d');if(!ctx)return;
const w=canvas.width,h=canvas.height;ctx.fillStyle='#10231b';ctx.fillRect(0,0,w,h);
ctx.font='11px monospace';ctx.strokeStyle='#405448';ctx.fillStyle='#a5b8ac';
for(const ms of [16.7,33.3,50]){const y=h-ms/70*h;ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke();ctx.fillText(`${ms} ms`,4,y-3);}
for(const [key,color] of [['frameMs','#efc46a'],['drawMs','#8bd0b4'],['physicsMs','#8bb6ed']]){
ctx.strokeStyle=color;ctx.beginPath();samples.forEach((s,i)=>{const x=i/300*w,y=h-Math.min(70,s[key]||0)/70*h;i?ctx.lineTo(x,y):ctx.moveTo(x,y);});ctx.stroke();
}
}
+28
View File
@@ -0,0 +1,28 @@
/** Monotonic timings for local requests and authoritative mesh publication. */
export class EditDiagnostics {
constructor() { this.reset(); }
reset() { this.requests = new Map(); this.sections = new Map(); this.last = null; }
request(pos, block, now = performance.now()) {
for (const [key, value] of this.requests) if (now - value.time > 5000) this.requests.delete(key);
if (this.requests.size >= 64) this.requests.delete(this.requests.keys().next().value);
this.requests.set(pos.join(','), { block, time: now });
}
confirmed(changes, now = performance.now()) {
for (const { pos, block } of changes) {
const key = pos.join(','), request = this.requests.get(key);
if (!request || request.block !== block || now - request.time > 5000) continue;
this.requests.delete(key);
const id = pos.map(v => Math.floor(v / 16)).join(',');
if (this.sections.size >= 64) this.sections.delete(this.sections.keys().next().value);
this.sections.set(id, { request: request.time, confirmed: now });
}
}
published(id, now = performance.now()) {
const edit = this.sections.get(id);
if (!edit) return null;
this.sections.delete(id);
this.last = { acknowledgementMs: edit.confirmed - edit.request,
geometryMs: now - edit.confirmed, totalMs: now - edit.request };
return this.last;
}
}
+43
View File
@@ -0,0 +1,43 @@
/** Small independent edit jobs cannot queue behind whole-view lighting. */
export class EditMeshController {
constructor({capture,onError=()=>{},workerFactory}={}) {
this.capture=capture;this.onError=onError;this.epoch=0;this.job=0;
this.pending=new Map();this.tickets=new Map();this.ready=new Map();this.busy=null;
try {
this.worker=workerFactory ? workerFactory() : new Worker(new URL('./edit-mesh-worker.js',import.meta.url),{type:'module'});
this.worker.onmessage=({data})=>this.receive(data);
this.worker.onerror=event=>{event.preventDefault();this.fail(event.message);};
} catch(error) {this.fail(error.message);}
}
fail(error) {this.error=error;this.dispose();this.onError(error);}
request(ids) {
if(this.error||this.disposed)return;
for(const id of ids) {
const job=++this.job;
this.pending.set(id,job);this.tickets.set(id,job);this.ready.delete(id);
}
}
cancel(id) {this.pending.delete(id);this.tickets.delete(id);this.ready.delete(id);}
reset() {this.epoch++;this.pending.clear();this.tickets.clear();this.ready.clear();this.busy=null;}
isCurrent(result) {return result.epoch===this.epoch&&this.tickets.get(result.id)===result.editJob;}
pump() {
if(this.error||this.disposed||this.busy||this.ready.size>=2||!this.pending.size)return;
const [id,editJob]=this.pending.entries().next().value;
this.pending.delete(id);
try {
const start=performance.now(),packet={...this.capture(id),epoch:this.epoch,editJob};
const transfers=packet.sections.map(s=>s.cells.buffer);
if(packet.light)transfers.push(packet.light.data.buffer,packet.light.blockLevels.buffer,packet.light.skyLevels.buffer);
this.busy={id,editJob,epoch:this.epoch};this.worker.postMessage(packet,transfers);
this.prepareMilliseconds=performance.now()-start;
} catch(error) {this.fail(error.message);}
}
receive(result) {
if(this.disposed||result.epoch!==this.epoch||result.editJob!==this.busy?.editJob)return;
this.busy=null;
if(!this.isCurrent(result))return;
if(result.error){this.fail(result.error);return;}
this.meshMilliseconds=result.milliseconds;this.ready.set(result.id,result);
}
dispose() {this.disposed=true;this.reset();this.worker?.terminate();}
}
+10
View File
@@ -0,0 +1,10 @@
import {buildEditMesh} from './edit-mesh.js';
import {loadLightProperties} from './light-properties.js';
const properties=loadLightProperties();
self.onmessage=({data})=>properties.then(table=>{
const start=performance.now(),result={id:data.id,epoch:data.epoch,editJob:data.editJob,preview:true};
try {
const mesh=buildEditMesh(data,table);
self.postMessage({...result,...mesh,milliseconds:performance.now()-start},[mesh.vertices.buffer,mesh.transparent.buffer]);
} catch(error) {self.postMessage({...result,error:error.message});}
}).catch(error=>self.postMessage({id:data.id,epoch:data.epoch,editJob:data.editJob,error:error.message}));
+54
View File
@@ -0,0 +1,54 @@
import { SectionVoxelMap } from './section-voxel-map.js';
import { attachBlockLightSamplers } from './block-light.js';
import { applyLightProperties } from './light-properties.js';
import { buildSectionMesh } from './mesh-geometry.js';
import { textureSubset } from './texture-pack.js';
/** Copy only the edited section's sampling neighborhood, independent of view radius. */
export function captureEditMesh(id, blocks, materials, textures, field) {
const base=id.split(',').map(v=>Number(v)*16);
let pad=2;
for(const mat of materials.values()) for(const box of mat.render || [])
for(let axis=0;axis<3;axis++) pad=Math.max(pad,Math.ceil(-box.min[axis])+2,Math.ceil(box.max[axis]-1)+2);
const min=base.map(v=>v-pad),size=Array(3).fill(16+pad*2),sections=[];
for(let x=Math.floor(min[0]/16);x<=Math.floor((min[0]+size[0]-1)/16);x++)
for(let y=Math.floor(min[1]/16);y<=Math.floor((min[1]+size[1]-1)/16);y++)
for(let z=Math.floor(min[2]/16);z<=Math.floor((min[2]+size[2]-1)/16);z++) {
const section=`${x},${y},${z}`;
if(blocks.getSectionCells) {
const cells=blocks.getSectionCells(section);
if(cells) sections.push({section,cells:cells.slice()});
} else {
const cells=new Uint32Array(4096);
for(const key of (blocks.keysInSection ? blocks.keysInSection(section) : [...blocks.keys()].filter(key=>key.split(",").map(v=>Math.floor(Number(v)/16)).join(",")===section))) {
const p=key.split(',').map(Number);
cells[SectionVoxelMap.index(...p)]=blocks.get(key);
}
sections.push({section,cells});
}
}
let light=null;
if(field) {
const count=size[0]*size[1]*size[2];
light={min,size,data:new Uint8Array(count*4),blockLevels:new Uint8Array(count),skyLevels:new Uint8Array(count)};
const lo=min.map((v,i)=>Math.max(v,field.min[i])),hi=min.map((v,i)=>Math.min(v+size[i],field.min[i]+field.size[i]));
if(hi[0]>lo[0]) for(let z=lo[2];z<hi[2];z++)for(let y=lo[1];y<hi[1];y++) {
const src=lo[0]-field.min[0]+field.size[0]*(y-field.min[1]+field.size[1]*(z-field.min[2]));
const dst=lo[0]-min[0]+size[0]*(y-min[1]+size[1]*(z-min[2])),length=hi[0]-lo[0];
light.data.set(field.data.subarray(src*4,(src+length)*4),dst*4);
light.blockLevels.set(field.blockLevels.subarray(src,src+length),dst);
light.skyLevels.set(field.skyLevels.subarray(src,src+length),dst);
}
}
const used = new Set();
for (const {cells} of sections) for (const block of cells) if (block) used.add(block);
const nearbyMaterials = new Map([...materials].filter(([id]) => used.has(id)));
return {id,sections,materials:[...nearbyMaterials],textures:[...textureSubset(textures, nearbyMaterials)],light};
}
export function buildEditMesh(packet, properties) {
const blocks=new SectionVoxelMap();
for(const {section,cells} of packet.sections) blocks.setSection(section,cells);
const materials=new Map(packet.materials.map(([id,mat])=>[id,applyLightProperties(mat,properties)]));
const field=packet.light ? attachBlockLightSamplers(packet.light,blocks,materials) : null;
return buildSectionMesh({id:packet.id,blocks,materials,textureLayers:new Map(packet.textures),blockLightField:field,localCoordinates:true});
}
+40 -3
View File
@@ -14,7 +14,8 @@
<body>
<canvas
id="world-canvas"
aria-label="Трёхмерный мир Shacraft. WASD — движение, пробел — прыжок, мышь — обзор."
tabindex="0"
aria-label="Трёхмерный мир Shacraft. WASD — движение, пробел — прыжок, Ctrl — бег, Shift — присесть, мышь — обзор."
></canvas>
<div id="labels" aria-hidden="true"></div>
<header class="topbar">
@@ -57,6 +58,7 @@
<div id="hints">
<span><kbd>W A S D</kbd> движение</span
><span><kbd>Пробел</kbd> прыжок</span
><span><kbd>Ctrl / Shift</kbd> бег / присесть</span
><span><kbd>ЛКМ / ПКМ</kbd> убрать / поставить</span
><span><kbd>T</kbd> чат</span>
</div>
@@ -91,6 +93,29 @@
побеждает.
</p>
<div class="rule"></div>
<button id="time-button" class="wide" title="Переключить день / ночь">Освещение: ночь</button>
<button id="mouse-mode" class="wide" title="Переключить захват мыши и обзор перетаскиванием">Мышь: захват</button>
<label for="view-distance">Дальность прорисовки</label>
<div class="inline">
<select id="view-distance" aria-describedby="view-distance-info">
<option value="2">2 чанка · 32 блока</option>
<option value="3" selected>3 чанка · 48 блоков</option>
<option value="4">4 чанка · 64 блока</option>
<option value="5">5 чанков · 80 блоков</option>
<option value="6">6 чанков · 96 блоков</option>
<option value="7">7 чанков · 112 блоков</option>
<option value="8">8 чанков · 128 блоков</option>
<option value="12">12 чанков · 192 блока</option>
<option value="16">16 чанков · 256 блоков</option>
<option value="24">24 чанка · 384 блока</option>
<option value="32">32 чанка · 512 блоков</option>
<option value="48">48 чанков · 768 блоков</option>
<option value="64">64 чанка · 1 024 блока</option>
</select>
<button id="view-distance-apply" aria-label="Применить дальность" disabled>Применить</button>
</div>
<p id="view-distance-info" class="muted">Радиус вокруг игрока. Большая дальность увеличивает нагрузку на память и видеокарту.</p>
<p id="view-distance-status" class="muted" role="status"></p>
<label for="sensitivity">Чувствительность мыши</label
><input
type="range"
@@ -102,7 +127,9 @@
/>
<p class="muted">
Esc освобождает мышь. Перетаскивание работает и без захвата. Средняя
кнопка выбирает блок под прицелом.
кнопка выбирает блок под прицелом. Ctrl — бег, Shift — присесть и
удержаться у края. Двойной пробел или F включает полёт, если он разрешён
в мире; пробел / Shift — вверх / вниз.
</p>
<button id="reconnect-button" class="wide">Переподключиться</button>
</aside>
@@ -152,6 +179,16 @@
×
</button>
</div>
<p class="muted">F3 — скрыть панель. Нажмите на мир, чтобы играть с открытой диагностикой.</p>
<div class="dynamics-tools">
<label for="dynamics-scenario">Диагностика движения</label>
<select id="dynamics-scenario"><option value="record">Запись ручного движения</option><option value="flight">Автополёт через чанки</option><option value="run">Автопробег</option><option value="turns">Полёт с поворотами</option></select>
<div class="inline"><button id="dynamics-start">Записать 30 секунд</button><button id="dynamics-stop">Стоп</button><button id="dynamics-export" disabled>Скачать JSON</button></div>
<p id="dynamics-status" role="status">Готов к записи. Автомаршрут использует обычное управление игроком.</p>
<canvas id="dynamics-chart" width="480" height="120" aria-label="Время кадров, физики и отрисовки" style="width:100%;height:120px"></canvas>
<p class="muted">Жёлтый — кадр · зелёный — отрисовка · синий — физика.</p>
<pre id="dynamics-result" style="white-space:pre-wrap;max-height:240px;overflow:auto" hidden></pre>
</div>
<dl id="debug-values"></dl>
<div id="package-status" class="muted">Пакеты ещё не загружены</div>
<button id="resync-button" class="wide">Сверить снимок мира</button>
@@ -160,6 +197,6 @@
кэша — разные измерения.
</p>
</aside>
<script type="module" src="/app.js"></script>
<script type="module" src="/app.js?v=live-diagnostics-2"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
/** Compare only visible mesh neighborhoods; unchanged retained sections keep their buffers. */
export function changedLightSections(previous, next, sections) {
if (!previous) return [...sections];
const index = (field, x, y, z) => {
x -= field.min[0]; y -= field.min[1]; z -= field.min[2];
if (x < 0 || y < 0 || z < 0 || x >= field.size[0] || y >= field.size[1] || z >= field.size[2]) return -1;
return x + field.size[0] * (y + field.size[1] * z);
};
const changed = [];
for (const id of sections) {
const base = id.split(",").map(v => Number(v) * 16);
let dirty = false;
outer: for (let z = base[2] - 1; z <= base[2] + 16; z++)
for (let y = base[1] - 1; y <= base[1] + 16; y++)
for (let x = base[0] - 1; x <= base[0] + 16; x++) {
const a = index(previous, x, y, z), b = index(next, x, y, z);
if (a < 0 && b < 0) continue;
if (a < 0 || b < 0 || previous.skyLevels[a] !== next.skyLevels[b] ||
previous.data[a * 4] !== next.data[b * 4] ||
previous.data[a * 4 + 1] !== next.data[b * 4 + 1] ||
previous.data[a * 4 + 2] !== next.data[b * 4 + 2]) {
dirty = true; break outer;
}
}
if (dirty) changed.push(id);
}
return changed;
}
+78
View File
@@ -0,0 +1,78 @@
// Factual Java 26.2 block-state metadata, generated by scripts/measure_lighting.py.
// The catalog's minecraft_id is distinct from Shacraft's material registry id.
let loading;
const decoded = new WeakMap();
export function loadLightProperties() {
loading ??= fetch(new URL("./light-properties.json", import.meta.url))
.then((response) => {
if (!response.ok) throw new Error(`Light properties HTTP ${response.status}`);
return response.json();
})
.then((properties) => {
if (!Array.isArray(properties.codes) || !Array.isArray(properties.shapes) ||
!Array.isArray(properties.schemas) || !properties.blocks) {
throw new Error("Invalid block light properties");
}
return properties;
})
.catch((error) => { loading = undefined; throw error; });
return loading;
}
function stateId(state, properties) {
if (typeof state !== "string") return undefined;
const match = /^([a-z0-9_]+:[a-z0-9_/.]+)(?:\[([^\]]*)\])?$/.exec(state);
if (!match) return undefined;
const definition = properties.blocks?.[match[1]];
if (!definition) return undefined;
const [first, defaultId, schemaId] = definition;
if (match[2] === undefined || match[2] === "") return defaultId;
const schema = properties.schemas[schemaId];
const requested = new Map();
for (const pair of match[2].split(",")) {
const parts = pair.split("=");
if (parts.length !== 2 || requested.has(parts[0])) return undefined;
requested.set(parts[0], parts[1]);
}
const defaults = [];
let remaining = defaultId - first;
for (let index = schema.length - 1; index >= 0; index--) {
const values = schema[index][1];
defaults[index] = remaining % values.length;
remaining = Math.floor(remaining / values.length);
}
let offset = 0;
for (let index = 0; index < schema.length; index++) {
const [name, values] = schema[index];
const selected = requested.has(name) ? values.indexOf(requested.get(name)) : defaults[index];
if (selected < 0) return undefined;
requested.delete(name);
offset = offset * values.length + selected;
}
return requested.size ? undefined : first + offset;
}
/** Enrich a copy; explicit Minecraft IDs take priority over the state fallback. */
export function applyLightProperties(material, properties) {
if (!material || !properties?.codes) return { ...material };
let id = material.minecraft_id;
if (!Number.isInteger(id) || id < 0 || id >= properties.codes.length) id = stateId(material.state, properties);
if (id === undefined) return { ...material };
const code = properties.codes[id];
let cache = decoded.get(properties);
if (!cache) { cache = new Map(); decoded.set(properties, cache); }
let lighting = cache.get(code);
if (!lighting) {
const boxes = properties.shapes[Math.floor(code / 16)];
if (!boxes) return { ...material };
lighting = {
light_dampening: code % 16,
light_occlusion: Object.freeze(boxes.map((box) => Object.freeze({
min: Object.freeze(box.slice(0, 3)), max: Object.freeze(box.slice(3, 6)),
}))),
};
cache.set(code, lighting);
}
return { ...material, ...lighting };
}
File diff suppressed because one or more lines are too long
+321
View File
@@ -0,0 +1,321 @@
// One direction drives visible sunlight, surface lighting, and the shadow camera.
const sun = [-0.5, 0.78, 0.37];
export const SUN_DIRECTION = Object.freeze(sun.map((v) => v / Math.hypot(...sun)));
const sunlight = `const vec3 SUN_DIRECTION = vec3(${SUN_DIRECTION.join(",")});`;
// Atlas cells are sampled as integer texels, so neighboring sprites never bleed.
const blockTextureSampling = `
uniform sampler2DArray uBlockTextures;
uniform vec2 uBlockAtlasGrid;
uniform vec2 uGrassOverlay;
vec4 blockPixelRaw(vec2 uv, float layer) {
if (uBlockAtlasGrid.x < 1.0) return texture(uBlockTextures, vec3(uv, layer));
ivec2 cellSize = textureSize(uBlockTextures, 0).xy / ivec2(uBlockAtlasGrid);
ivec2 cell = ivec2(mod(layer, uBlockAtlasGrid.x), floor(layer / uBlockAtlasGrid.x));
ivec2 pixel = min(ivec2(floor(fract(uv) * vec2(cellSize))), cellSize - 1);
return texelFetch(uBlockTextures, ivec3(cell * cellSize + pixel, 0), 0);
}
vec4 blockPixel(vec2 uv, float layer) {
vec4 pixel = blockPixelRaw(uv, layer);
if (uGrassOverlay.x >= 0.0 && abs(layer - uGrassOverlay.x) < 0.1) {
vec4 overlay = blockPixelRaw(uv, uGrassOverlay.y);
pixel.rgb = mix(pixel.rgb, overlay.rgb * vec3(0.58, 0.8, 0.34), overlay.a);
}
return pixel;
}`;
const colorSpace = `
vec3 toLinear(vec3 color) {
color = max(color, vec3(0.0));
return mix(color / 12.92, pow((color + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), color));
}
vec3 toSRGB(vec3 color) {
color = max(color, vec3(0.0));
return mix(color * 12.92, 1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), color));
}`;
// Linear scene colors. The world fog and water reflection sample this same sky.
const atmosphere = `
${sunlight}
uniform float uDaylight;
vec3 daylightSky(vec3 ray) {
float altitude = clamp(ray.y, 0.0, 1.0);
vec3 horizon = vec3(0.67, 0.77, 0.80);
vec3 zenith = vec3(0.105, 0.315, 0.59);
vec3 sky = mix(horizon, zenith, pow(altitude, 0.48));
float sunAngle = max(dot(ray, SUN_DIRECTION), 0.0);
// Wide scattering is faint; the small solar disc is rendered separately.
sky += vec3(0.12, 0.075, 0.025) * pow(sunAngle, 24.0);
sky = mix(vec3(0.46, 0.51, 0.48), sky, smoothstep(-0.24, 0.015, ray.y));
vec3 nightHorizon = vec3(0.009, 0.015, 0.028);
vec3 nightZenith = vec3(0.0014, 0.0034, 0.011);
vec3 night = mix(nightHorizon, nightZenith, pow(altitude, 0.48));
night += vec3(0.0025, 0.0035, 0.006) * pow(sunAngle, 36.0);
night = mix(vec3(0.0035, 0.005, 0.009), night, smoothstep(-0.24, 0.015, ray.y));
return mix(night, sky, clamp(uDaylight, 0.0, 1.0));
}`;
export const worldVertex = `#version 300 es
precision highp float;
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aColor;
layout(location=2) in vec3 aNormal;
layout(location=3) in float aMaterial;
layout(location=4) in float aOpacity;
layout(location=5) in vec3 aBlockTexture;
layout(location=6) in vec2 aLight;
// RGB is linear block irradiance; alpha is the normalized skylight level.
layout(location=7) in vec4 aBlockLight;
uniform mat4 uVP;
uniform vec3 uOffset;
out vec3 vPos;
out vec3 vColor;
out vec3 vNormal;
flat out float vMaterial;
out float vOpacity;
out vec2 vBlockUV;
flat out float vBlockLayer;
out vec2 vLight;
out vec4 vBlockLight;
void main() {
vPos = aPos + uOffset;
vColor = aColor;
vNormal = aNormal;
vMaterial = aMaterial;
vOpacity = aOpacity;
vBlockUV = aBlockTexture.xy;
vBlockLayer = aBlockTexture.z;
vLight = aLight;
vBlockLight = aBlockLight;
gl_Position = uVP * vec4(vPos, 1.0);
}`;
export const worldFragment = `#version 300 es
precision highp float;
precision highp sampler2DArray;
in vec3 vPos;
in vec3 vColor;
in vec3 vNormal;
flat in float vMaterial;
in float vOpacity;
in vec2 vBlockUV;
flat in float vBlockLayer;
in vec2 vLight;
in vec4 vBlockLight;
uniform vec3 uEye;
uniform sampler2D uTexture;
uniform bool uTextured;
uniform sampler2D uEffectTexture;
uniform bool uEffectTextured;
${blockTextureSampling}
uniform float uPulse;
uniform float uFogDistance;
uniform float uViewDistance;
uniform mat4 uLightVP;
uniform sampler2D uShadowMap;
uniform bool uShadows;
uniform float uShadowTexel;
uniform vec3 uShadowCenter;
uniform float uShadowRadius;
out vec4 outColor;
${colorSpace}
${atmosphere}
float hash(vec3 p) {
return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);
}
vec3 shacraftBounceTint(vec3 color,float pulse){return color;}
float sunVisibility(vec3 normal, float incidence) {
if (!uShadows || incidence <= 0.0) return 1.0;
vec4 clip = uLightVP * vec4(vPos + normal * 0.025, 1.0);
vec3 projected = clip.xyz / clip.w * 0.5 + 0.5;
if (any(lessThan(projected, vec3(0.0))) || any(greaterThan(projected, vec3(1.0)))) return 1.0;
float bias = 0.00010 + 0.00028 * (1.0 - incidence);
float visible = 0.0;
// A compact, stable PCF kernel keeps the edges soft without moving grain.
for (int y = -1; y <= 1; ++y) {
for (int x = -1; x <= 1; ++x) {
float depth = texture(uShadowMap, projected.xy + vec2(float(x), float(y)) * uShadowTexel).r;
visible += step(projected.z - bias, depth);
}
}
float coverage = 1.0 - smoothstep(max(0.0, uShadowRadius - 6.0), uShadowRadius, distance(vPos, uShadowCenter));
vec2 border = min(projected.xy, 1.0 - projected.xy);
coverage *= smoothstep(0.0, 0.018, min(border.x, border.y));
return mix(1.0, visible / 9.0, coverage);
}
float textureAlpha = 1.0;
vec3 surfaceColor(vec3 normal) {
if (vBlockLayer >= 0.0) {
vec4 pixel = blockPixel(vBlockUV, vBlockLayer);
if (pixel.a < (uBlockAtlasGrid.x > 0.0 ? 0.01 : 0.5)) discard;
if (uBlockAtlasGrid.x > 0.0 && pixel.a < 0.99) textureAlpha = pixel.a;
return clamp(pixel.rgb * vColor, 0.0, 1.0);
}
vec2 uv = abs(normal.y) > 0.5 ? vPos.xz : (abs(normal.x) > 0.5 ? vPos.zy : vPos.xy);
vec3 p = floor((vPos + normal * 0.002) * 16.0);
float noise = hash(p) * 0.14 - 0.07;
float pattern = 1.0;
if (vMaterial > 1.5 && vMaterial < 2.5) {
pattern = 0.89 + 0.11 * step(0.07, fract(uv.y * 4.0));
pattern *= 0.9 + 0.1 * step(0.055, fract(uv.x * 0.5 + floor(uv.y * 4.0) * 0.5));
}
if (vMaterial > 2.5 && vMaterial < 3.5) {
pattern = 0.87 + 0.13 * step(0.06, fract(uv.y * 4.0));
pattern *= 0.84 + 0.16 * step(0.045, fract(uv.x * 2.0 + floor(uv.y * 4.0) * 0.5));
}
if (vMaterial > 3.5 && vMaterial < 4.5) {
pattern = 0.91 + 0.09 * sin(floor(uv.x * 16.0) * 1.73 + sin(floor(uv.y * 16.0) * 0.22));
}
float tex = uTextured ? mix(0.84, 1.12, texture(uTexture, uv).r) : 1.0;
if (vMaterial > 4.5 && vMaterial < 5.5 && uEffectTextured) {
tex = mix(0.75, 1.2, texture(uEffectTexture, uv).g);
}
vec3 color = clamp((vColor + noise) * pattern * tex, 0.0, 1.0);
if (vMaterial > 0.5 && vMaterial < 1.5 && normal.y < 0.5) {
color = mix(color, vec3(0.38, 0.29, 0.17), 0.65);
}
return color;
}
void main() {
vec3 normal = normalize(vNormal);
vec3 albedo = surfaceColor(normal);
if (vMaterial > 4.5 && vMaterial < 5.5) albedo = shacraftBounceTint(albedo, uPulse);
albedo = toLinear(albedo);
float occlusion = clamp(vLight.x, 0.0, 1.0);
float emission = clamp(vLight.y, 0.0, 1.0);
float skyLevel = clamp(vBlockLight.a, 0.0, 1.0);
float skyExposure = skyLevel * skyLevel;
vec3 blockLight = max(vBlockLight.rgb, vec3(0.0));
float incidence = max(dot(normal, SUN_DIRECTION), 0.0);
float sunlight = skyExposure > 0.0 ? sunVisibility(normal, incidence) * skyExposure : 0.0;
// The lower hemisphere has muted ground bounce, keeping undersides legible.
float hemisphere = normal.y * 0.5 + 0.5;
vec3 ambient = mix(vec3(0.15, 0.145, 0.13), vec3(0.29, 0.355, 0.44), hemisphere);
float daylight = clamp(uDaylight, 0.0, 1.0);
vec3 moonAmbient = mix(vec3(0.009, 0.011, 0.018), vec3(0.025, 0.036, 0.060), hemisphere);
ambient = mix(moonAmbient, ambient, daylight);
// Closed rooms receive a small visibility floor, not the outdoor hemisphere.
ambient *= mix(0.20, 1.0, occlusion) * mix(0.015, 1.0, skyExposure);
vec3 direct = mix(vec3(0.034, 0.050, 0.080), vec3(0.94, 0.865, 0.735), daylight) * incidence * sunlight;
// Voxel propagation already accounts for walls and distance to emitters.
// A weak contact term keeps corners grounded without swallowing torchlight.
vec3 localLight = blockLight * 1.1 * mix(0.65, 1.0, occlusion);
vec3 color = albedo * (ambient + direct + localLight);
color = mix(color, albedo * 1.14, emission);
vec3 eyeDelta = uEye - vPos;
vec3 toEye = eyeDelta / max(length(eyeDelta), 0.0001);
if (vMaterial > 5.5 && vMaterial < 6.5) {
// Flat, quiet voxel water: the silhouette and texture remain pixel aligned.
float fresnel = 0.035 + 0.58 * pow(1.0 - max(dot(normal, toEye), 0.0), 5.0);
vec3 reflection = daylightSky(reflect(-toEye, normal)) * 0.82 * skyExposure + blockLight * 0.08;
color = mix(color, reflection, fresnel);
vec3 halfVector = normalize(SUN_DIRECTION + toEye);
float glint = pow(max(dot(normal, halfVector), 0.0), 160.0);
color += mix(vec3(0.045, 0.060, 0.090), vec3(0.45, 0.38, 0.24), daylight) * glint * sunlight;
}
// Matching atmosphere avoids a separate tinted band along the horizon.
float fog = (1.0 - exp(-pow(length(eyeDelta) / uFogDistance, 2.0))) * 0.92;
// Fade completely before the prefetched ring can be unloaded.
float viewEdge = max(abs(eyeDelta.x), abs(eyeDelta.z));
fog = max(fog, smoothstep(uViewDistance * 0.82, uViewDistance, viewEdge));
color = mix(color, daylightSky(-toEye) * skyExposure, fog);
outColor = vec4(toSRGB(clamp(color, 0.0, 1.0)), textureAlpha < 0.99 ? textureAlpha : vOpacity);
}`;
export const skyVertex = `#version 300 es
precision highp float;
out vec2 vUV;
void main() {
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
vUV = p;
gl_Position = vec4(p * 2.0 - 1.0, 1.0, 1.0);
}`;
export const skyFragment = `#version 300 es
precision highp float;
in vec2 vUV;
uniform float uPitch;
uniform float uYaw;
uniform float uAspect;
uniform float uTanHalfFov;
uniform float uSkyExposure;
out vec4 outColor;
${colorSpace}
${atmosphere}
float starHash(vec2 cell) {
return fract(sin(dot(cell, vec2(127.1, 311.7))) * 43758.5453);
}
vec3 nightStars(vec3 ray) {
// A fixed world-space hemisphere avoids stars sliding with the camera.
vec2 skyPlane = ray.xz / max(ray.y + 1.15, 0.2) * 128.0;
vec2 cell = floor(skyPlane);
float seed = starHash(cell);
vec2 center = vec2(starHash(cell + 19.4), starHash(cell + 71.2)) * 0.6 + 0.2;
float radius = mix(0.065, 0.13, starHash(cell + 37.8));
float distanceToStar = length(fract(skyPlane) - center);
float edge = max(fwidth(distanceToStar), 0.01);
float star = 1.0 - smoothstep(max(0.0, radius - edge), radius + edge, distanceToStar);
star *= step(0.994, seed) * smoothstep(0.025, 0.20, ray.y);
vec3 tint = mix(vec3(0.48, 0.59, 0.80), vec3(0.82, 0.74, 0.60), starHash(cell + 113.0));
return tint * star * mix(0.32, 0.72, starHash(cell + 53.0));
}
void main() {
vec3 forward = vec3(sin(uYaw) * cos(uPitch), sin(uPitch), -cos(uYaw) * cos(uPitch));
vec3 right = vec3(cos(uYaw), 0.0, sin(uYaw));
vec3 up = vec3(-sin(uYaw) * sin(uPitch), cos(uPitch), cos(uYaw) * sin(uPitch));
vec2 screen = vUV * 2.0 - 1.0;
vec3 ray = normalize(forward + right * screen.x * uAspect * uTanHalfFov + up * screen.y * uTanHalfFov);
vec3 color = daylightSky(ray);
float daylight = clamp(uDaylight, 0.0, 1.0);
color += nightStars(ray) * (1.0 - daylight);
float sunAngle = dot(ray, SUN_DIRECTION);
float rim = max(fwidth(sunAngle), 0.000001);
float angularRadius = mix(0.0125, 0.0105, daylight);
float disc = smoothstep(cos(angularRadius) - rim, cos(angularRadius) + rim, sunAngle);
vec3 moonRight = normalize(cross(SUN_DIRECTION, vec3(0.0, 1.0, 0.0)));
vec3 moonUp = cross(moonRight, SUN_DIRECTION);
vec2 moonUV = vec2(dot(ray, moonRight), dot(ray, moonUp)) / sin(angularRadius);
vec2 craterA = moonUV - vec2(-0.24, 0.22), craterB = moonUV - vec2(0.28, -0.27);
float craters = 0.12 * exp(-dot(craterA, craterA) * 22.0) + 0.075 * exp(-dot(craterB, craterB) * 36.0);
float moonShade = 0.78 + 0.22 * sqrt(max(0.0, 1.0 - dot(moonUV, moonUV))) - craters;
vec3 moon = vec3(0.62, 0.70, 0.84) * moonShade;
color = mix(color, mix(moon, vec3(1.0, 0.965, 0.84), daylight), disc);
// Match the distant backdrop to cave fog when the streamed window ends
// underground; missing geometry must not reveal a bright surface sky.
outColor = vec4(toSRGB(clamp(color * uSkyExposure, 0.0, 1.0)), 1.0);
}`;
export const shadowVertex = `#version 300 es
precision highp float;
layout(location=0) in vec3 aPos;
layout(location=4) in float aOpacity;
layout(location=5) in vec3 aBlockTexture;
uniform mat4 uLightVP;
uniform vec3 uOffset;
out vec2 vBlockUV;
flat out float vBlockLayer;
out float vOpacity;
void main() {
vBlockUV = aBlockTexture.xy;
vBlockLayer = aBlockTexture.z;
vOpacity = aOpacity;
gl_Position = uLightVP * vec4(aPos + uOffset, 1.0);
}`;
export const shadowFragment = `#version 300 es
precision highp float;
precision highp sampler2DArray;
in vec2 vBlockUV;
flat in float vBlockLayer;
in float vOpacity;
${blockTextureSampling}
void main() {
if (vOpacity < 0.99) discard;
if (vBlockLayer >= 0.0 && blockPixel(vBlockUV, vBlockLayer).a < 0.5) discard;
}`;
+10
View File
@@ -0,0 +1,10 @@
// Fifteen light levels plus the halo sampled by faces and partial models.
export const LOCAL_LIGHT_HALO = 18;
export function localLightBounds(id, view) {
if (!view) return null;
const [x,,z] = id.split(',').map(value=>Math.floor(Number(value)/2)*32);
return {
min:[Math.max(view.min[0],x-LOCAL_LIGHT_HALO),view.min[1],Math.max(view.min[2],z-LOCAL_LIGHT_HALO)],
max:[Math.min(view.max[0],x+31+LOCAL_LIGHT_HALO),view.max[1],Math.min(view.max[2],z+31+LOCAL_LIGHT_HALO)],
};
}
+1
View File
@@ -167,6 +167,7 @@ export function isFullCube(mat) {
b?.length === 1 &&
b[0].min.every((v) => v === 0) &&
b[0].max.every((v) => v === 1) &&
(mat.opacity ?? 1) >= 1 &&
!mat.transparent
);
}
+189
View File
@@ -0,0 +1,189 @@
import { key, isFullCube, unitBox } from "./math.js";
import { getBlockFaceTextures, getFaceUV } from "./block-textures.js";
import { createAmbientOcclusionSampler } from "./ambient-occlusion.js";
/** Shared terrain and actor format; all values become Float32 at upload. */
export const MESH_VERTEX_STRIDE = 20;
export const faces = [
{
n: [1, 0, 0],
v: [
[1, 0, 1],
[1, 0, 0],
[1, 1, 0],
[1, 1, 1],
],
},
{
n: [-1, 0, 0],
v: [
[0, 0, 0],
[0, 0, 1],
[0, 1, 1],
[0, 1, 0],
],
},
{
n: [0, 1, 0],
v: [
[0, 1, 1],
[1, 1, 1],
[1, 1, 0],
[0, 1, 0],
],
},
{
n: [0, -1, 0],
v: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 1],
[0, 0, 1],
],
},
{
n: [0, 0, 1],
v: [
[0, 0, 1],
[1, 0, 1],
[1, 1, 1],
[0, 1, 1],
],
},
{
n: [0, 0, -1],
v: [
[1, 0, 0],
[0, 0, 0],
[0, 1, 0],
[1, 1, 0],
],
},
];
export function boxVertices(
out,
pos,
box,
color,
material = 0,
skip = () => false,
yaw = 0,
opacity = 1,
textures = null,
state = "",
ambient = null,
emission = 0,
blockLight = null,
dynamicLight = false,
origin = [0,0,0],
) {
for (let f = 0; f < 6; f++) {
if (skip(faces[f].n)) continue;
const face = faces[f],
c = Math.cos(yaw),
s = Math.sin(yaw),
n = [
face.n[0] * c - face.n[2] * s,
face.n[1],
face.n[0] * s + face.n[2] * c,
];
const light = ambient ? ambient(pos, box, face.n, face.v) : [1, 1, 1, 1];
const propagated = !dynamicLight && blockLight ? blockLight.sampleFace(pos, box, face.n, face.v) : null;
// Flip the diagonal so occlusion interpolation has no dark triangular seam.
const indices = light[0] + light[2] > light[1] + light[3]
? [0, 1, 3, 1, 2, 3] : [0, 1, 2, 0, 2, 3];
for (const i of indices) {
const v = face.v[i].map(
(v, k) => box.min[k] + v * (box.max[k] - box.min[k]),
);
const point = [pos[0] + v[0] * c - v[2] * s, pos[1] + v[1], pos[2] + v[0] * s + v[2] * c];
const local = dynamicLight && blockLight ? blockLight.sample(point.map((p, axis) => p + n[axis] * 0.02)) : null;
out.push(
point[0]-origin[0], point[1]-origin[1], point[2]-origin[2],
...(textures?.[f]?.tint || color),
...n,
material,
opacity,
...getFaceUV(f, v, state, textures?.[f]),
textures?.[f]?.layer ?? -1,
light[i],
emission,
...(propagated?.block[i] || local?.block || [0, 0, 0]),
propagated?.sky[i] ?? local?.sky ?? 1,
);
}
}
}
export function materialType(state = "", effect) {
if (effect === "bounce") return 5;
if (/^minecraft:(water|bubble_column)(\[|$)/.test(state)) return 6;
if (state.includes("grass_block")) return 1;
if (state.includes("planks")) return 2;
if (state.includes("brick")) return 3;
if (/log|stem|wood/.test(state)) return 4;
return 0;
}
/**
* Pure section meshing for both worker and synchronous fallback. World maps and
* the light field are immutable for a build. A caller may reuse faceTextureCache
* between sections; clear it whenever texture layers change.
*/
export function buildSectionMesh({
id, blocks, materials, textureLayers = new Map(), blockLightField = null,
faceTextureCache = new Map(), localCoordinates = false,
}) {
const faceTextures = (mat) => {
if (!textureLayers.size) return null;
if (!faceTextureCache.has(mat.state))
faceTextureCache.set(mat.state, getBlockFaceTextures(mat.state, textureLayers));
return faceTextureCache.get(mat.state);
};
const base = id.split(",").map((value) => Number(value) * 16),
vertices = [], transparent = [],
ambient = createAmbientOcclusionSampler(blocks, materials,
(mat) => !mat || (mat.opacity ?? 1) >= 1 && !faceTextures(mat)?.some(face => face?.cutout));
for (let x = base[0]; x < base[0] + 16; x++)
for (let y = base[1]; y < base[1] + 16; y++)
for (let z = base[2]; z < base[2] + 16; z++) {
const pos = [x, y, z],
block = blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(key(pos));
if (!block) continue;
const mat = materials.get(block) || {
color: [150, 152, 142],
render: [unitBox],
},
color = mat.color.map((v) => v / 255),
textures = faceTextures(mat),
cutout = textures?.some((face) => face?.cutout),
opacity = cutout ? 1 : (mat.opacity ?? 1),
full = isFullCube(mat) && !cutout;
for (const box of mat.render || [unitBox]) {
boxVertices(
opacity < 1 ? transparent : vertices,
pos,
box,
color,
materialType(mat.state, mat.effect),
(n) => {
if (!full) return false;
const neighbor = materials.get(
(blocks.getAt ? blocks.getAt(pos[0]+n[0],pos[1]+n[1],pos[2]+n[2]) : blocks.get(key(pos.map((v, i) => v + n[i])))),
);
return (
isFullCube(neighbor) &&
!faceTextures(neighbor)?.some((face) => face?.cutout)
);
},
0,
opacity,
textures,
mat.state,
ambient,
Math.max(0, Math.min(1, (mat.light || 0) / 15)),
blockLightField, false, localCoordinates ? base : [0,0,0],
);
}
}
return { vertices: new Float32Array(vertices), transparent: new Float32Array(transparent), origin: localCoordinates ? base : [0,0,0] };
}
BIN
View File
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
/** Separate the last server pose from the pose currently drawn on screen. */
export function updateRemotePlayer(previous, packet, instant = false) {
const preserve = previous && !instant;
const targetYaw = packet.yaw ?? 0;
return {
...packet,
position: preserve ? previous.position : [...packet.position],
target: [...packet.position],
yaw: preserve ? previous.yaw : targetYaw,
targetYaw,
};
}
export function smoothRemotePlayer(player, blend) {
for (let axis = 0; axis < 3; axis++)
player.position[axis] +=
(player.target[axis] - player.position[axis]) * blend;
// Follow the shortest arc, including when server angles cross ±π or 2π.
const delta = player.targetYaw - player.yaw;
player.yaw += Math.atan2(Math.sin(delta), Math.cos(delta)) * blend;
}
+256
View File
@@ -0,0 +1,256 @@
/** The browser and authoritative server execute the same 20 Hz Rust solver. */
export const PHYSICS_TICK_MS = 50;
export const EYE_HEIGHT = { standing: 1.62, crouching: 1.27, swimming: 0.4 };
const copyBody = (body) => ({
...body,
position: [...body.position],
velocity: [...body.velocity],
});
const validBody = (body) =>
[body?.position, body?.velocity].every(
(v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite),
);
export async function loadPhysics(url = "/physics.wasm?v=movement-1") {
const response = await fetch(url, { cache: "no-cache" });
if (!response.ok) throw Error(`Physics module: HTTP ${response.status}`);
let result;
try {
result = await WebAssembly.instantiateStreaming(response.clone(), {});
} catch {
// Some local servers do not supply application/wasm.
result = await WebAssembly.instantiate(await response.arrayBuffer(), {});
}
const api = result.instance.exports;
if (
api.physics_version?.() !== 1 ||
!api.memory ||
![
"physics_input",
"physics_run",
"physics_output",
"physics_output_len",
].every((name) => typeof api[name] === "function")
)
throw Error("Incompatible physics module");
const encoder = new TextEncoder(),
decoder = new TextDecoder();
return (request) => {
const input = encoder.encode(JSON.stringify(request));
if (input.length > 16 * 1024 * 1024)
throw Error("Physics input exceeds 16 MiB");
const pointer = api.physics_input(input.length);
if (!pointer) throw Error("Physics input allocation failed");
// The allocator/solver can grow memory: never retain an old typed view.
new Uint8Array(api.memory.buffer, pointer, input.length).set(input);
const status = api.physics_run();
const output = JSON.parse(
decoder.decode(
new Uint8Array(
api.memory.buffer,
api.physics_output(),
api.physics_output_len(),
),
),
);
if (status || !validBody(output))
throw Error(output.error || "Invalid physics output");
return output;
};
}
/** Build only the swept collision neighbourhood, including non-solid fluids. */
export function samplePhysicsWorld(body, blocks, materials, bounds) {
if (!bounds || !validBody(body)) return null;
const min = [],
max = [];
for (let axis = 0; axis < 3; axis++) {
const pos = body.position[axis],
velocity = body.velocity[axis];
min[axis] = Math.floor(
Math.min(pos, pos + velocity) - (axis === 1 ? 3 : 2),
);
max[axis] = Math.floor(
Math.max(pos, pos + velocity) + (axis === 1 ? 4 : 2),
);
// Unknown chunks are not empty air. Keep authority until they arrive.
if (!(axis === 1 && bounds.fullHeight) && (min[axis] < bounds.min[axis] || max[axis] > bounds.max[axis]))
return null;
}
if (bounds.fullHeight) {
// Above/below the declared world height is known void, not missing chunks.
min[1] = Math.max(min[1], bounds.min[1]);
max[1] = Math.min(max[1], bounds.max[1]);
}
if (blocks.hasSection) {
for(let x=Math.floor(min[0]/16);x<=Math.floor(max[0]/16);x++)
for(let y=Math.floor(min[1]/16);y<=Math.floor(max[1]/16);y++)
for(let z=Math.floor(min[2]/16);z<=Math.floor(max[2]/16);z++)
if(!blocks.hasSection(`${x},${y},${z}`) || blocks.readySections && !blocks.readySections.has(`${x},${y},${z}`))return null;
}
const records = [];
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++) {
const id = blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(`${x},${y},${z}`);
if (!id) continue;
const material = materials.get(id);
if (!material) return null;
records.push({
pos: [x, y, z],
state: material.state,
collision: material.collision,
});
}
return { blocks: records };
}
/** Fixed steps are independent of display FPS; long stalls never create a burst. */
export class PhysicsClock {
constructor() {
this.reset();
}
reset() {
this.accumulator = 0;
}
advance(elapsed, step) {
this.accumulator += Math.max(0, Math.min(250, elapsed));
let count = 0;
while (this.accumulator + 1e-7 >= PHYSICS_TICK_MS && count < 5) {
this.accumulator -= PHYSICS_TICK_MS;
count++;
step();
}
return count;
}
get alpha() {
return Math.max(0, Math.min(1, this.accumulator / PHYSICS_TICK_MS));
}
}
/** Sequence-based reconciliation; physics state is never replaced by visual lerp. */
export class LocalPrediction {
constructor(step, getWorld, historyLimit = 120) {
this.step = step;
this.getWorld = getWorld;
this.historyLimit = historyLimit;
this.reset();
}
reset() {
this.body = null;
this.authority = null;
this.settings = {};
this.pending = [];
this.ack = -1;
this.tick = -1;
this.epoch = null;
this.offset = [0, 0, 0];
this.correction = 0;
this.waitingThrough = -1;
this.suspended = false;
this.preview = null;
}
simulate(body, input) {
const world = this.getWorld(body);
return world
? this.step({ body, input, world, settings: this.settings })
: null;
}
receive(motion, reset = false) {
if (
!validBody(motion?.body) ||
!Number.isSafeInteger(motion.ack) ||
!Number.isSafeInteger(motion.tick)
)
return false;
if (!reset && (motion.tick < this.tick || motion.ack < this.ack))
return false;
if (!reset && this.epoch !== null && (motion.epoch ?? 0) < this.epoch)
return false;
const epochChanged = this.epoch !== null && motion.epoch !== this.epoch;
const teleport =
this.authority &&
Math.hypot(
...motion.body.position.map((v, i) => v - this.authority.position[i]),
) > 8;
const instant = reset || epochChanged || teleport || !this.body;
const oldPosition = this.body?.position;
if (instant) {
this.pending = [];
this.offset = [0, 0, 0];
this.waitingThrough = -1;
}
this.ack = motion.ack;
this.tick = motion.tick;
this.epoch = motion.epoch ?? 0;
this.settings = { ...(motion.settings || {}) };
this.authority = copyBody(motion.body);
this.body = copyBody(motion.body);
this.pending = this.pending.filter(({ seq }) => seq > this.ack);
this.suspended = this.ack < this.waitingThrough;
if (!this.suspended) {
for (const { input } of this.pending) {
const next = this.simulate(this.body, input);
if (!next) {
this.suspended = true;
break;
}
this.body = next;
}
}
if (this.suspended) this.body = copyBody(this.authority);
this.correction = oldPosition
? Math.hypot(...this.body.position.map((v, i) => v - oldPosition[i]))
: 0;
if (!instant && this.correction < 4)
for (let i = 0; i < 3; i++)
this.offset[i] += oldPosition[i] - this.body.position[i];
else this.offset = [0, 0, 0];
this.preview = null;
return true;
}
push(seq, input) {
if (
!this.body ||
seq <= this.ack ||
seq <= (this.pending.at(-1)?.seq ?? -1)
)
return false;
this.pending.push({ seq, input: { ...input } });
if (this.pending.length > this.historyLimit) {
this.waitingThrough = this.pending.shift().seq;
this.suspended = true;
}
if (!this.suspended) {
const next = this.simulate(this.body, input);
if (next) this.body = next;
else this.suspended = true;
}
this.preview = null;
return !this.suspended;
}
sample(alpha, dt, input) {
if (!this.body) return null;
const damping = Math.exp(-15 * Math.max(0, dt));
this.offset = this.offset.map((value) => value * damping);
let next = this.body;
if (!this.suspended) {
// This partial render step is disposable and is never sent or acknowledged.
const signature = JSON.stringify(input);
if (this.preview?.signature !== signature)
this.preview = { signature, body: this.simulate(this.body, input) };
next = this.preview?.body || this.body;
}
return {
position: this.body.position.map(
(v, i) => v + (next.position[i] - v) * alpha + this.offset[i],
),
eyeHeight: EYE_HEIGHT[this.body.pose] ?? 1.62,
body: this.body,
};
}
invalidateWorld() {
this.preview = null;
}
}
+115
View File
@@ -0,0 +1,115 @@
/** Keep pointer capture retryable after browser errors and ordinary unlocks. */
export class PointerLockController {
constructor(canvas, { onChange = () => {}, onError = () => {} } = {}) {
this.canvas = canvas;
this.document = canvas.ownerDocument;
this.onChange = onChange;
this.onError = onError;
this.status = this.locked ? "locked" : "idle";
this.lastError = null;
this.generation = 0;
this.disposed = false;
this.cancelled = false;
this.observedLocked = this.locked;
this.changeListener = () => this.changed();
this.errorListener = (event) =>
this.failed(
event.error ||
new Error(event.message || "Pointer lock was denied by the browser."),
this.generation,
);
this.document.addEventListener("pointerlockchange", this.changeListener);
this.document.addEventListener("pointerlockerror", this.errorListener);
}
get locked() {
return this.document.pointerLockElement === this.canvas;
}
request() {
if (this.disposed || this.locked || this.status === "requesting")
return false;
const generation = ++this.generation;
this.cancelled = false;
this.lastError = null;
this.status = "requesting";
if (typeof this.canvas.requestPointerLock !== "function") {
const error = new Error("This browser does not support pointer lock.");
error.name = "NotSupportedError";
this.failed(error, generation, "unsupported");
return false;
}
try {
if (this.canvas.tabIndex < 0) this.canvas.tabIndex = 0;
this.canvas.focus({ preventScroll: true });
// Start synchronously: awaiting anything here loses the user's gesture.
const result = this.canvas.requestPointerLock();
result?.then?.(
() => {
if (this.cancelled && this.locked) this.document.exitPointerLock?.();
else if (
!this.disposed &&
generation === this.generation &&
this.locked
)
this.changed();
},
(error) => this.failed(error, generation),
);
return true;
} catch (error) {
this.failed(error, generation);
return false;
}
}
failed(error, generation, status = "error") {
if (
this.disposed ||
generation !== this.generation ||
this.locked ||
this.status !== "requesting"
)
return;
this.generation++;
this.status = status;
this.lastError = error?.message
? error
: new Error(String(error || "Pointer lock failed."));
this.onError(this.lastError);
}
changed() {
if (this.disposed) return;
if (this.locked && this.cancelled) {
this.document.exitPointerLock?.();
return;
}
const locked = this.locked;
this.generation++;
this.status = locked ? "locked" : "idle";
this.lastError = null;
if (locked !== this.observedLocked) {
this.observedLocked = locked;
this.onChange(locked);
}
}
release() {
if (this.disposed) return;
const requesting = this.status === "requesting";
this.generation++;
this.cancelled = true;
this.status = "idle";
this.lastError = null;
if (this.locked || requesting) this.document.exitPointerLock?.();
}
dispose() {
if (this.disposed) return;
this.release();
this.disposed = true;
this.document.removeEventListener("pointerlockchange", this.changeListener);
this.document.removeEventListener("pointerlockerror", this.errorListener);
}
}
+564 -238
View File
@@ -1,88 +1,31 @@
import { EditMeshController } from "./edit-mesh-controller.js";
import { captureEditMesh } from "./edit-mesh.js";
import {
multiply,
perspective,
viewMatrix,
key,
isFullCube,
unitBox,
project,
} from "./math.js";
const vertex = `#version 300 es
precision highp float;layout(location=0) in vec3 aPos;layout(location=1) in vec3 aColor;layout(location=2) in vec3 aNormal;layout(location=3) in float aMaterial;layout(location=4) in float aOpacity;uniform mat4 uVP;out vec3 vPos;out vec3 vColor;out vec3 vNormal;flat out float vMaterial;out float vOpacity;void main(){vOpacity=aOpacity;vPos=aPos;vColor=aColor;vNormal=aNormal;vMaterial=aMaterial;gl_Position=uVP*vec4(aPos,1.);}`;
const fragment = `#version 300 es
precision highp float;in vec3 vPos;in vec3 vColor;in vec3 vNormal;flat in float vMaterial;in float vOpacity;uniform vec3 uEye;uniform sampler2D uTexture;uniform bool uTextured;uniform sampler2D uEffectTexture;uniform bool uEffectTextured;uniform float uPulse;out vec4 outColor;
float hash(vec3 p){return fract(sin(dot(p,vec3(127.1,311.7,74.7)))*43758.5453);}
vec3 shacraftBounceTint(vec3 color,float pulse){return color;}
void main(){vec3 n=normalize(vNormal);vec2 uv=abs(n.y)>.5?vPos.xz:(abs(n.x)>.5?vPos.zy:vPos.xy);vec3 p=floor((vPos+n*.002)*16.);float noise=hash(p)*.14-.07;float pattern=1.;
if(vMaterial>1.5&&vMaterial<2.5){pattern=.89+.11*step(.07,fract(uv.y*4.));pattern*=.9+.1*step(.055,fract(uv.x*.5+floor(uv.y*4.)*.5));}
if(vMaterial>2.5&&vMaterial<3.5){pattern=.87+.13*step(.06,fract(uv.y*4.));pattern*=.84+.16*step(.045,fract(uv.x*2.+floor(uv.y*4.)*.5));}
if(vMaterial>3.5&&vMaterial<4.5){pattern=.91+.09*sin(floor(uv.x*16.)*1.73+sin(floor(uv.y*16.)*.22));}
float tex=uTextured?mix(.84,1.12,texture(uTexture,uv).r):1.;if(vMaterial>4.5&&uEffectTextured)tex=mix(.75,1.2,texture(uEffectTexture,uv).g);float diffuse=max(0.,dot(n,normalize(vec3(-.45,.86,.3))));float light=.69+diffuse*.34;vec3 color=clamp((vColor+noise)*pattern*tex*light,0.,1.);if(vMaterial>.5&&vMaterial<1.5&&n.y<.5)color=mix(color,vec3(.38,.29,.17),.65);
if(vMaterial>4.5)color=shacraftBounceTint(color,uPulse);float fog=1.-exp(-pow(distance(vPos,uEye)/74.,2.));color=mix(color,vec3(.70,.80,.79),fog*.91);outColor=vec4(color,vOpacity);}`;
const skyVert = `#version 300 es
precision highp float;out vec2 vUV;void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);vUV=p;gl_Position=vec4(p*2.-1.,1.,1.);}`;
const skyFrag = `#version 300 es
precision highp float;in vec2 vUV;uniform float uPitch;uniform float uYaw;uniform float uAspect;out vec4 outColor;void main(){float h=vUV.y+uPitch*.72;vec3 color=mix(vec3(.77,.83,.77),vec3(.38,.65,.78),smoothstep(.14,1.2,h));vec2 sun=vec2(.77-uYaw*.3,.82-uPitch*.72);float d=length((vUV-sun)*vec2(uAspect,1.));color=mix(color,vec3(1.,.98,.8),smoothstep(.048,.039,d)*.98);color+=vec3(.045,.025,0.)*exp(-d*7.);outColor=vec4(color,1.);}`;
import { getBlockFaceTextures } from "./block-textures.js";
import { prepareTexturePack } from "./texture-pack.js";
import { boxVertices, buildSectionMesh } from "./mesh-geometry.js";
import { TerrainController } from "./terrain-controller.js";
import { affectedSectionKeys } from "./ambient-occlusion.js";
import { shadowFrame } from "./shadow-frame.js";
import { BlockLightController, inferLightBounds } from "./block-light-controller.js";
import { changedLightSections } from "./light-changes.js";
import { viewDistanceProfile } from "./view-distance.js";
import {
worldVertex as vertex, worldFragment as fragment,
skyVertex as skyVert, skyFragment as skyFrag,
shadowVertex, shadowFragment, SUN_DIRECTION,
} from "./lighting-shaders.js";
const lineVertex = `#version 300 es
precision highp float;layout(location=0)in vec3 aPos;uniform mat4 uVP;void main(){gl_Position=uVP*vec4(aPos,1.);}`;
precision highp float;layout(location=0)in vec3 aPos;uniform mat4 uVP;uniform vec3 uOffset;void main(){gl_Position=uVP*vec4(aPos+uOffset,1.);}`;
const lineFragment = `#version 300 es
precision highp float;out vec4 outColor;void main(){outColor=vec4(.13,.21,.12,1.);}`;
const faces = [
{
n: [1, 0, 0],
v: [
[1, 0, 1],
[1, 0, 0],
[1, 1, 0],
[1, 1, 1],
],
},
{
n: [-1, 0, 0],
v: [
[0, 0, 0],
[0, 0, 1],
[0, 1, 1],
[0, 1, 0],
],
},
{
n: [0, 1, 0],
v: [
[0, 1, 1],
[1, 1, 1],
[1, 1, 0],
[0, 1, 0],
],
},
{
n: [0, -1, 0],
v: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 1],
[0, 0, 1],
],
},
{
n: [0, 0, 1],
v: [
[0, 0, 1],
[1, 0, 1],
[1, 1, 1],
[0, 1, 1],
],
},
{
n: [0, 0, -1],
v: [
[1, 0, 0],
[0, 0, 0],
[0, 1, 0],
[1, 1, 0],
],
},
];
function program(gl, vs, fs) {
const p = gl.createProgram();
for (const [type, source] of [
@@ -102,54 +45,100 @@ function program(gl, vs, fs) {
throw Error(gl.getProgramInfoLog(p));
return p;
}
function boxVertices(
out,
pos,
box,
color,
material = 0,
skip = () => false,
yaw = 0,
opacity = 1,
) {
for (let f = 0; f < 6; f++) {
if (skip(faces[f].n)) continue;
const face = faces[f],
c = Math.cos(yaw),
s = Math.sin(yaw),
n = [
face.n[0] * c - face.n[2] * s,
face.n[1],
face.n[0] * s + face.n[2] * c,
];
for (const i of [0, 1, 2, 0, 2, 3]) {
const v = face.v[i].map(
(v, k) => box.min[k] + v * (box.max[k] - box.min[k]),
/** Avatar geometry follows the public physics pose; yaw remains interpolated. */
export function playerAvatarParts(player) {
const pose = player.pose || "standing",
swimming = pose === "swimming",
crouching = pose === "crouching",
defaultHeight = swimming ? 0.6 : crouching ? 1.5 : 1.8,
height = Number.isFinite(player.height)
? Math.max(0.4, Math.min(2.5, player.height))
: defaultHeight,
eye = Number.isFinite(player.eye_height)
? player.eye_height
: height - (swimming ? 0.2 : crouching ? 0.23 : 0.18),
shirt = player.color || [0.29, 0.46, 0.36],
skin = [0.77, 0.61, 0.43],
trousers = [0.25, 0.3, 0.32],
parts = [],
add = (part, min, max, color, material = 0) =>
parts.push({ part, box: { min, max }, color, material });
if (swimming) {
// The collider is short while the visible body lies along its facing axis.
add(
"torso",
[-0.21, height * 0.24, -0.36],
[0.21, height * 0.7, 0.28],
shirt,
2,
);
add("head", [-0.23, height * 0.2, -0.82], [0.23, height, -0.36], skin);
for (const side of [-1, 1]) {
add(
"leg",
[side * 0.13 - 0.085, height * 0.27, 0.28],
[side * 0.13 + 0.085, height * 0.57, 0.89],
trousers,
);
out.push(
pos[0] + v[0] * c - v[2] * s,
pos[1] + v[1],
pos[2] + v[0] * s + v[2] * c,
...color,
...n,
material,
opacity,
add(
"arm",
[side * 0.31 - 0.085, height * 0.22, -0.38],
[side * 0.31 + 0.085, height * 0.55, 0.24],
shirt,
);
}
add(
"eyes",
[-0.18, eye - 0.04, -0.824],
[0.18, eye + 0.04, -0.816],
[0.13, 0.19, 0.15],
);
} else {
const headBottom = height - 0.5,
hip = crouching ? headBottom - 0.55 : headBottom - 0.64,
lean = crouching ? -0.13 : 0;
add(
"torso",
[-0.21, hip, -0.13 + lean * 0.5],
[0.21, headBottom, 0.13 + lean * 0.5],
shirt,
2,
);
add(
"head",
[-0.23, headBottom, -0.23 + lean],
[0.23, height, 0.23 + lean],
skin,
);
for (const side of [-1, 1]) {
const knee = crouching ? 0.07 : 0;
add(
"leg",
[side * 0.13 - 0.085, 0.02, -0.09 + knee],
[side * 0.13 + 0.085, hip, 0.09 + knee],
trousers,
);
add(
"arm",
[side * 0.31 - 0.085, hip, -0.1 + lean],
[side * 0.31 + 0.085, headBottom - 0.03, 0.1 + lean],
shirt,
);
}
add(
"eyes",
[-0.18, eye - 0.04, -0.234 + lean],
[0.18, eye + 0.04, -0.226 + lean],
[0.13, 0.19, 0.15],
);
}
}
function materialType(state = "", effect) {
if (effect === "bounce") return 5;
if (state.includes("grass_block")) return 1;
if (state.includes("planks")) return 2;
if (state.includes("brick")) return 3;
if (/log|stem|wood/.test(state)) return 4;
return 0;
return parts;
}
const section = (p) => p.map((v) => Math.floor(v / 16)).join(",");
export class Renderer {
constructor(canvas) {
this.canvas = canvas;
this.renderOrigin = [0,0,0]; this.selectionOrigin = [0,0,0];
const gl = (this.gl = canvas.getContext("webgl2", {
antialias: true,
alpha: false,
@@ -163,10 +152,44 @@ export class Renderer {
this.program = program(gl, vertex, fragment);
this.sky = program(gl, skyVert, skyFrag);
this.line = program(gl, lineVertex, lineFragment);
this.shadowProgram = program(gl, shadowVertex, shadowFragment);
this.shadowSize = Math.min(2048, gl.getParameter(gl.MAX_TEXTURE_SIZE));
this.shadowRadius = 48;
this.shadowsEnabled = true;
this.shadowReady = this.createShadowMap();
this.lightingName = "Daylight";
this.daylight = 1;
this.sections = new Map();
this.publishedSections = new Set();
this.loadStarted = performance.now();
this.firstTerrainMs = null;
this.dirty = new Set();
this.blocks = new Map();
this.materials = new Map();
this.blockLightField = null;
this.lightViewBounds = null;
this.meshUpload = null;
this.terrain = new TerrainController({
onLight: field => { this.blockLightField = field; },
onDirty: ids => {
for (const id of ids) if (this.sectionInView(id)) this.dirty.add(id);
},
onError: () => this.enableTerrainFallback(),
});
if (this.terrain.error) {
// Some embedded browsers do not support workers. Keep the game usable
// through the former CPU path, and identify it in diagnostics.
this.terrain.dispose();
this.terrain = null;
this.blockLighting = new BlockLightController(field => {
for (const id of changedLightSections(this.blockLightField, field, this.sections.keys())) this.dirty.add(id);
this.blockLightField = field;
});
} else this.blockLighting = this.terrain;
if (this.terrain) this.edits = new EditMeshController({
capture: id => captureEditMesh(id, this.blocks, this.materials, this.textureLayers, this.blockLightField),
onError: error => console.warn("Shacraft edit worker unavailable; using terrain queue", error),
});
this.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(
@@ -199,10 +222,18 @@ export class Renderer {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
this.effectTextured = false;
this.blockTexture = this.createBlockTexture(1, 1);
this.blockAtlasGrid = [0, 0];
this.blockGrassOverlay = [-1, -1];
this.textureLayers = new Map();
this.faceTextureCache = new Map();
this.texturePackGeneration = 0;
this.bounceAt = -10000;
this.dynamic = this.mesh([]);
this.lines = this.mesh([], 3);
this.triangles = 0;
this.meshRebuilds = 0;
this.meshResets = 0;
this.vp = new Float32Array(16);
this.lost = false;
canvas.addEventListener("webglcontextlost", (e) => {
@@ -211,7 +242,63 @@ export class Renderer {
});
canvas.addEventListener("webglcontextrestored", () => location.reload());
}
mesh(data, stride = 11) {
createShadowMap() {
const gl = this.gl;
this.shadowTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.shadowTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24,
this.shadowSize, this.shadowSize, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
this.shadowFramebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFramebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, this.shadowTexture, 0);
gl.drawBuffers([gl.NONE]);
gl.readBuffer(gl.NONE);
const ready = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return ready;
}
drawShadows(frame) {
if (!this.shadowsEnabled || !this.shadowReady) return;
const gl = this.gl, p = this.shadowProgram;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFramebuffer);
gl.viewport(0, 0, this.shadowSize, this.shadowSize);
gl.colorMask(false, false, false, false);
gl.depthMask(true);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.BLEND);
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);
gl.frontFace(gl.CCW);
gl.enable(gl.POLYGON_OFFSET_FILL);
gl.polygonOffset(1.1, 2.);
gl.useProgram(p);
gl.uniformMatrix4fv(gl.getUniformLocation(p, "uLightVP"), false, frame.matrix);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.blockTexture);
gl.uniform1i(gl.getUniformLocation(p, "uBlockTextures"), 2);
gl.uniform2fv(gl.getUniformLocation(p, "uBlockAtlasGrid"), this.blockAtlasGrid);
gl.uniform2fv(gl.getUniformLocation(p, "uGrassOverlay"), this.blockGrassOverlay);
for (const mesh of this.sections.values()) {
this.meshOffset(p,mesh.origin);
gl.bindVertexArray(mesh.vao);
gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
}
this.meshOffset(p,this.renderOrigin);
gl.bindVertexArray(this.dynamic.vao);
gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count);
gl.disable(gl.POLYGON_OFFSET_FILL);
gl.colorMask(true, true, true, true);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
meshOffset(program,origin=[0,0,0]) {
this.gl.uniform3fv(this.gl.getUniformLocation(program,"uOffset"),origin.map((v,i)=>v-this.renderOrigin[i]));
}
mesh(data, stride = 20) {
const gl = this.gl,
vao = gl.createVertexArray(),
buffer = gl.createBuffer();
@@ -226,6 +313,9 @@ export class Renderer {
[2, 3, 6],
[3, 1, 9],
[4, 1, 10],
[5, 3, 11],
[6, 2, 14],
[7, 4, 16],
]) {
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(
@@ -244,7 +334,7 @@ export class Renderer {
this.gl.deleteBuffer(mesh.buffer);
this.gl.deleteVertexArray(mesh.vao);
}
upload(mesh, data, stride = 11) {
upload(mesh, data, stride = 20) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
@@ -291,61 +381,259 @@ export class Renderer {
if (effect) this.effectTextured = true;
else this.textured = true;
}
createBlockTexture(size, layers, height = size) {
const gl = this.gl,
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture);
gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, size, height, layers);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT);
return texture;
}
clearTexturePack() {
this.texturePackGeneration++;
this.blockAtlasGrid = [0, 0];
this.blockGrassOverlay = [-1, -1];
this.textureLayers.clear();
this.faceTextureCache.clear();
this.gl.deleteTexture(this.blockTexture);
this.blockTexture = this.createBlockTexture(1, 1);
this.invalidate();
}
async setTexturePack(descriptor, files) {
const gl = this.gl;
const {layers, width, height, atlas, entries, grassOverlay} = prepareTexturePack(descriptor, files, {
layers: gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS), size: gl.getParameter(gl.MAX_TEXTURE_SIZE),
});
const bitmaps = [];
const generation = ++this.texturePackGeneration;
let next;
try {
for (const entry of atlas ? [atlas] : entries) {
const bitmap = await createImageBitmap(files.get(entry.path), {
premultiplyAlpha: "none",
colorSpaceConversion: "none",
});
bitmaps.push(bitmap);
if (generation !== this.texturePackGeneration) return;
if (bitmap.width !== width || bitmap.height !== height)
throw Error(`Текстура ${entry.name || "atlas"} должна быть ${width}×${height}.`);
}
// Separate array layers prevent atlas bleeding; only native pixel data reaches the GPU.
next = this.createBlockTexture(width, bitmaps.length, height);
for (let i = 0; i < bitmaps.length; i++)
gl.texSubImage3D(
gl.TEXTURE_2D_ARRAY,
0,
0,
0,
i,
width,
height,
1,
gl.RGBA,
gl.UNSIGNED_BYTE,
bitmaps[i],
);
gl.deleteTexture(this.blockTexture);
this.blockTexture = next;
next = null;
this.textureLayers = layers;
this.blockAtlasGrid = atlas ? [atlas.columns, atlas.rows] : [0, 0];
this.blockGrassOverlay = grassOverlay;
this.faceTextureCache.clear();
this.invalidate();
} finally {
for (const bitmap of bitmaps) bitmap.close();
if (next) gl.deleteTexture(next);
}
}
faceTextures(mat) {
if (!this.textureLayers.size) return null;
if (!this.faceTextureCache.has(mat.state))
this.faceTextureCache.set(
mat.state,
getBlockFaceTextures(mat.state, this.textureLayers),
);
return this.faceTextureCache.get(mat.state);
}
replace(blocks, materials) {
this.meshResets++;
this.loadStarted = performance.now(); this.firstTerrainMs = null;
this.publishedSections?.clear();
this.edits?.reset();
for (const m of this.sections.values()) this.disposeMesh(m);
this.sections.clear();
this.dirty.clear();
this.blocks = blocks;
this.materials = materials;
for (const k of blocks.keys())
this.dirty.add(section(k.split(",").map(Number)));
this.blockLightField = null;
if (this.terrain) this.terrain.reset(blocks, materials, this.textureLayers, this.lightViewBounds);
else this.requestBlockLighting();
if (blocks.loadedSectionKeys) for (const id of blocks.loadedSectionKeys()) this.dirty.add(id);
else for (const k of blocks.keys()) this.dirty.add(section(k.split(",").map(Number)));
}
change(changes) {
change(changes, sectionIds = null, sectionRecords = null) {
if (!sectionIds && changes.length > 64) this.edits?.reset();
const owners = changes.map(({pos}) => section(pos));
const urgent = !sectionIds && changes.length <= 64 ? [...new Set([...owners, ...changes.flatMap(({pos}) =>
affectedSectionKeys(pos, [{ min: [-2, -2, -2], max: [3, 3, 3] }]).filter(id => this.sections.has(id) || this.edits?.tickets.has(id)))])].filter(id => this.sectionInView(id)) : [];
if (changes.length || sectionRecords) {
if (this.terrain) this.terrain.update({ ...(sectionRecords ? { sections: sectionRecords } : { changes }), materials: this.materials });
else this.requestBlockLighting();
}
if (sectionIds) {
const affected = new Set();
// A stream packet already identifies whole entering/departing sections.
// Mark their halos once instead of repeating the same work per voxel.
for (const coords of sectionIds)
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const id = coords.map((v, axis) => v + [x, y, z][axis]).join(",");
if (this.sectionInView(id)) this.dirty.add(id);
affected.add(id);
}
this.edits?.request([...this.edits.tickets.keys()].filter(id => affected.has(id)));
return;
}
this.edits?.request(urgent);
for (const { pos } of changes) {
this.dirty.add(section(pos));
for (const f of faces)
this.dirty.add(section(pos.map((v, i) => v + f.n[i])));
// Geometry and corner lighting can cross section edges diagonally.
for (const id of affectedSectionKeys(pos, [{ min: [-2, -2, -2], max: [3, 3, 3] }]))
this.dirty.add(id);
}
}
unloadSections(ids) {
if (ids.length) {
if (this.terrain) this.terrain.update({ unload: ids });
else this.requestBlockLighting();
}
const removed = new Set(ids.map((id) => id.join(",")));
for (const id of removed) {
this.edits?.cancel(id);
this.publishedSections?.delete(id);
const mesh = this.sections.get(id);
if (mesh) this.disposeMesh(mesh);
this.sections.delete(id);
this.dirty.delete(id);
}
// Retained face and diagonal neighbors may lose an occluder.
const refreshEdits = new Set();
for (const coords of ids)
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const neighbor = coords.map((v, axis) => v + [x, y, z][axis]).join(",");
if (!removed.has(neighbor) && this.sections.has(neighbor)) this.dirty.add(neighbor);
if (!removed.has(neighbor) && this.edits?.tickets.has(neighbor)) refreshEdits.add(neighbor);
}
this.edits?.request([...refreshEdits]);
}
invalidate() {
for (const k of this.blocks.keys())
this.dirty.add(section(k.split(",").map(Number)));
this.edits?.reset();
if (this.terrain) this.terrain.update({ materials: this.materials, textures: this.textureLayers });
else this.requestBlockLighting();
if (this.blocks.loadedSectionKeys) for (const id of this.blocks.loadedSectionKeys()) this.dirty.add(id);
else for (const k of this.blocks.keys()) this.dirty.add(section(k.split(",").map(Number)));
}
setLightBounds(bounds) {
if (JSON.stringify(bounds) === JSON.stringify(this.lightViewBounds)) return;
this.lightViewBounds = bounds;
if (this.terrain) this.terrain.update({ bounds });
else this.requestBlockLighting();
}
requestBlockLighting() {
if (this.terrain) { this.terrain.update({ materials: this.materials }); return; }
if (this.blockLighting)
this.blockLighting.request(this.blocks, this.materials, this.lightViewBounds || inferLightBounds(this.blocks));
}
enableTerrainFallback() {
if (!this.terrain) return;
this.terrain.dispose();
this.edits?.dispose();
this.discardMeshUpload();
this.terrain = null;
this.blockLighting = new BlockLightController(field => {
for (const id of changedLightSections(this.blockLightField, field, this.sections.keys())) this.dirty.add(id);
this.blockLightField = field;
});
this.requestBlockLighting();
for (const key of this.blocks.keys()) this.dirty.add(section(key.split(",").map(Number)));
}
sectionInView(id) {
if (!this.lightViewBounds) return true;
return id.split(",").every((value, axis) => {
const base = Number(value) * 16;
return base <= this.lightViewBounds.max[axis] && base + 15 >= this.lightViewBounds.min[axis];
});
}
discardMeshUpload() {
if (!this.meshUpload) return;
for (const part of this.meshUpload.parts) if (part.mesh) this.disposeMesh(part.mesh);
this.meshUpload = null;
}
isMeshCurrent(result) {
return result.editJob !== undefined ? Boolean(this.edits?.isCurrent(result)) : this.terrain.isCurrent(result);
}
/** Upload at most 64 KiB per step; publish both surfaces only when complete. */
applyTerrainMeshes(budgetMs) {
const start = performance.now(), gl = this.gl;
if (this.meshUpload && !this.isMeshCurrent(this.meshUpload.result)) this.discardMeshUpload();
while (performance.now() - start < budgetMs) {
if (!this.meshUpload) {
const queue = this.edits?.ready.size ? this.edits.ready : this.terrain.ready;
const result = queue.values().next().value;
if (!result) break;
queue.delete(result.id);
if (!this.isMeshCurrent(result) || !this.sectionInView(result.id)) continue;
const parts = [result.vertices, result.transparent].map(data => ({ data, mesh: null, offset: 0 }));
this.meshUpload = { result, parts };
}
const pending = this.meshUpload;
const part = pending.parts.find(p => p.offset < p.data.length);
if (part) {
if (!part.mesh) {
part.mesh = this.mesh([]);
part.mesh.count = part.data.length / 20;
gl.bindBuffer(gl.ARRAY_BUFFER, part.mesh.buffer);
gl.bufferData(gl.ARRAY_BUFFER, part.data.byteLength, gl.STATIC_DRAW);
if (performance.now() - start >= budgetMs) break;
}
const end = Math.min(part.offset + 16384, part.data.length);
gl.bindBuffer(gl.ARRAY_BUFFER, part.mesh.buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, part.offset * 4, part.data.subarray(part.offset, end));
part.offset = end;
} else {
const { id } = pending.result, previous = this.sections.get(id);
const [opaque, transparent] = pending.parts;
if (opaque.mesh || transparent.mesh) {
const next = opaque.mesh || this.mesh([]);
next.transparent = transparent.mesh;
next.origin = pending.result.origin || [0,0,0];
if (previous) {
const retired = { vao: previous.vao, buffer: previous.buffer, transparent: previous.transparent };
Object.assign(previous, next);
this.disposeMesh(retired);
} else this.sections.set(id, next);
} else if (previous) { this.disposeMesh(previous); this.sections.delete(id); }
if (!pending.result.preview) { this.dirty.delete(id); this.edits?.cancel(id); }
this.publishedSections?.add(id);
if (this.firstTerrainMs === null && this.sections.has(id)) this.firstTerrainMs=performance.now()-this.loadStarted;
this.onMeshPublished?.(id, pending.result);
this.meshRebuilds++;
this.meshUpload = null;
}
}
this.meshUploadMilliseconds = performance.now() - start;
}
rebuild(id) {
const base = id.split(",").map((v) => Number(v) * 16),
vertices = [],
transparent = [];
for (let x = base[0]; x < base[0] + 16; x++)
for (let y = base[1]; y < base[1] + 16; y++)
for (let z = base[2]; z < base[2] + 16; z++) {
const pos = [x, y, z],
block = this.blocks.get(key(pos));
if (!block) continue;
const mat = this.materials.get(block) || {
color: [150, 152, 142],
render: [unitBox],
},
color = mat.color.map((v) => v / 255),
full = isFullCube(mat);
for (const box of mat.render || [unitBox]) {
boxVertices(
mat.opacity < 1 ? transparent : vertices,
pos,
box,
color,
materialType(mat.state, mat.effect),
(n) =>
full &&
isFullCube(
this.materials.get(
this.blocks.get(key(pos.map((v, i) => v + n[i]))),
),
),
0,
mat.opacity ?? 1,
);
}
}
this.meshRebuilds++;
const { vertices, transparent, origin } = buildSectionMesh({ id, blocks: this.blocks,
materials: this.materials, textureLayers: this.textureLayers,
faceTextureCache: this.faceTextureCache, blockLightField: this.blockLightField, localCoordinates: true });
let mesh = this.sections.get(id);
if (mesh) this.upload(mesh, vertices);
else if (vertices.length || transparent.length) {
@@ -353,6 +641,7 @@ export class Renderer {
this.sections.set(id, mesh);
}
if (mesh) {
mesh.origin = origin;
if (mesh.transparent) this.upload(mesh.transparent, transparent);
else if (transparent.length) mesh.transparent = this.mesh(transparent);
}
@@ -362,6 +651,7 @@ export class Renderer {
}
}
updateSelection(hit) {
this.selectionOrigin = hit ? hit.pos.map(v=>Math.floor(v/16)*16) : [0,0,0];
const a = [];
if (hit) {
const mat = this.materials.get(hit.block),
@@ -372,7 +662,7 @@ export class Renderer {
points.push(
[0, 1, 2].map(
(k) =>
hit.pos[k] +
hit.pos[k] - this.selectionOrigin[k] +
((i >> k) & 1 ? box.max[k] + 0.003 : box.min[k] - 0.003),
),
);
@@ -386,6 +676,7 @@ export class Renderer {
}
draw(camera, players, entities, entityDefinitions) {
if (this.lost) return;
const distance = viewDistanceProfile(this.viewDistance);
const gl = this.gl,
ratio = Math.min(devicePixelRatio || 1, 1.75),
w = Math.floor(this.canvas.clientWidth * ratio),
@@ -394,40 +685,131 @@ export class Renderer {
this.canvas.width = w;
this.canvas.height = h;
}
if (this.terrain) {
this.edits?.pump();
this.applyTerrainMeshes(2);
const eye = camera.eye;
let nearest = null, distance = Infinity;
if (!this.terrain.busy && !this.terrain.pending && this.terrain.ready.size < 2) for (const id of this.dirty) {
if (!this.sectionInView(id)) { this.dirty.delete(id); continue; }
if (this.blocks.readySections && !this.blocks.readySections.has(id)) continue;
if (this.blocks.sectionIsEmpty?.(id)) {
const mesh = this.sections.get(id);
if (mesh) this.disposeMesh(mesh);
this.sections.delete(id); this.dirty.delete(id); this.edits?.cancel(id);
this.publishedSections?.add(id);
continue;
}
if (this.terrain.ready.has(id) || this.meshUpload?.result.id === id || this.terrain.busy?.id === id) continue;
const center = id.split(",").map(v => Number(v) * 16 + 8);
const next = center.reduce((sum, v, axis) => sum + (v - eye[axis]) ** 2, 0);
if (next < distance) { distance = next; nearest = id; }
}
if (nearest !== null) this.terrain.requestMesh(nearest);
} else {
const start = performance.now();
for (const id of this.dirty) {
this.rebuild(id); this.dirty.delete(id);
if (performance.now() - start > 2) break;
}
}
this.renderOrigin = camera.eye.map(v=>Math.floor(v/16)*16);
const localEye = camera.eye.map((v,i)=>v-this.renderOrigin[i]);
const dynamic = [];
for (const p of players) {
for (const part of playerAvatarParts(p))
boxVertices(
dynamic,
p.position,
part.box,
part.color,
part.material,
() => false,
p.yaw || 0,
1, null, "", null, 0, this.blockLightField, true, this.renderOrigin,
);
}
for (const e of entities) {
const def = entityDefinitions.get(e.kind || e.name) || {},
color = (e.color || def.color || [192, 161, 123]).map((v) => v / 255),
boxes = e.render ||
def.render || [{ min: [-0.3, 0, -0.3], max: [0.3, 0.8, 0.3] }];
for (const b of boxes)
boxVertices(dynamic, e.position, b, color, 0, () => false, e.yaw || 0,
1, null, "", null, 0, this.blockLightField, true, this.renderOrigin);
}
this.upload(this.dynamic, dynamic);
const shadow = shadowFrame(localEye, SUN_DIRECTION, this.shadowSize, this.shadowRadius);
this.drawShadows(shadow);
this.canvas.dataset.lighting = this.lightingName;
const center=camera.eye.map(v=>Math.floor(v/16));
let nearReady=0,nearLoaded=0;
for(let x=-1;x<=1;x++)for(let y=-1;y<=1;y++)for(let z=-1;z<=1;z++) {
const id=`${center[0]+x},${center[1]+y},${center[2]+z}`;
if(this.publishedSections?.has(id))nearReady++;
if(this.blocks.readySections?.has(id))nearLoaded++;
}
this.canvas.dataset.nearMeshes=String(nearReady);
this.canvas.dataset.nearLoaded=String(nearLoaded);
this.canvas.dataset.firstTerrainMs=this.firstTerrainMs===null ? "" : String(Math.round(this.firstTerrainMs));
this.canvas.dataset.terrainMeshing = this.terrain?.meshWorker ? "local-light-worker" : "shared-worker";
this.canvas.dataset.localLightCells = String(this.terrain?.localLightCells || 0);
this.canvas.dataset.localLightMs = String(Math.round((this.terrain?.localLightMilliseconds || 0)*100)/100);
this.canvas.dataset.editPrepareMs = String(Math.round((this.edits?.prepareMilliseconds || 0) * 100) / 100);
this.canvas.dataset.editMeshMs = String(Math.round((this.edits?.meshMilliseconds || 0) * 100) / 100);
this.canvas.dataset.editWorker = this.edits && !this.edits.error && !this.edits.disposed ? "ready" : "fallback";
this.canvas.dataset.terrainMode = this.terrain ? "worker" : "main-thread-fallback";
this.canvas.dataset.terrainPrepareMs = String(Math.round((this.terrain?.prepareMilliseconds || 0) * 100) / 100);
this.canvas.dataset.meshBuildMs = String(Math.round((this.terrain?.meshMilliseconds || 0) * 100) / 100);
this.canvas.dataset.meshUploadMs = String(Math.round((this.meshUploadMilliseconds || 0) * 100) / 100);
this.canvas.dataset.timeOfDay = this.daylight === 0 ? "night" : "day";
this.canvas.dataset.shadows = this.shadowsEnabled && this.shadowReady ? String(this.shadowSize) : "off";
this.canvas.dataset.blockLighting = this.blockLighting.status;
this.canvas.dataset.lightSources = String(this.blockLightField?.sourceCount || 0);
this.canvas.dataset.lightBuildMs = String(Math.round(this.blockLighting.milliseconds || 0));
const eyeLight = this.blockLightField?.sample(camera.eye);
this.canvas.dataset.blockLight = String(eyeLight?.blockLevel ?? 0);
this.canvas.dataset.skyLight = String(eyeLight?.skyLevel ?? 15);
gl.viewport(0, 0, w, h);
gl.clearColor(0.65, 0.79, 0.8, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.useProgram(this.sky);
gl.uniform1f(gl.getUniformLocation(this.sky, "uDaylight"), this.daylight);
gl.uniform1f(gl.getUniformLocation(this.sky, "uSkyExposure"), ((eyeLight?.skyLevel ?? 15) / 15) ** 2);
gl.uniform1f(gl.getUniformLocation(this.sky, "uPitch"), camera.pitch);
gl.uniform1f(gl.getUniformLocation(this.sky, "uYaw"), camera.yaw);
gl.uniform1f(gl.getUniformLocation(this.sky, "uAspect"), w / h);
gl.uniform1f(gl.getUniformLocation(this.sky, "uTanHalfFov"), Math.tan(Math.PI / 5.4));
gl.bindVertexArray(null);
gl.drawArrays(gl.TRIANGLES, 0, 3);
this.vp = multiply(
perspective(Math.PI / 2.7, w / h, 0.06, 190),
viewMatrix(camera.eye, camera.yaw, camera.pitch),
perspective(Math.PI / 2.7, w / h, 0.06, distance.farClip),
viewMatrix(localEye, camera.yaw, camera.pitch),
);
const start = performance.now();
let rebuilt = 0;
for (const id of this.dirty) {
this.rebuild(id);
this.dirty.delete(id);
rebuilt++;
if (rebuilt >= 3 || performance.now() - start > 7) break;
}
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);
gl.frontFace(gl.CCW);
gl.useProgram(this.program);
gl.uniform1f(gl.getUniformLocation(this.program, "uDaylight"), this.daylight);
gl.uniform1f(gl.getUniformLocation(this.program, "uFogDistance"), distance.fogDistance);
gl.uniform1f(gl.getUniformLocation(this.program, "uViewDistance"), distance.radius);
gl.uniformMatrix4fv(
gl.getUniformLocation(this.program, "uVP"),
false,
this.vp,
);
gl.uniform3fv(gl.getUniformLocation(this.program, "uEye"), camera.eye);
gl.uniform3fv(gl.getUniformLocation(this.program, "uEye"), localEye);
gl.uniformMatrix4fv(gl.getUniformLocation(this.program, "uLightVP"), false, shadow.matrix);
gl.uniform3fv(gl.getUniformLocation(this.program, "uShadowCenter"), shadow.center);
gl.uniform1f(gl.getUniformLocation(this.program, "uShadowRadius"), shadow.radius);
gl.uniform1f(gl.getUniformLocation(this.program, "uShadowTexel"), 1 / this.shadowSize);
gl.uniform1i(gl.getUniformLocation(this.program, "uShadows"), this.shadowsEnabled && this.shadowReady ? 1 : 0);
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.shadowTexture);
gl.uniform1i(gl.getUniformLocation(this.program, "uShadowMap"), 3);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.uniform1i(gl.getUniformLocation(this.program, "uTexture"), 0);
@@ -442,6 +824,11 @@ export class Renderer {
gl.getUniformLocation(this.program, "uEffectTextured"),
this.effectTextured ? 1 : 0,
);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.blockTexture);
gl.uniform1i(gl.getUniformLocation(this.program, "uBlockTextures"), 2);
gl.uniform2fv(gl.getUniformLocation(this.program, "uBlockAtlasGrid"), this.blockAtlasGrid);
gl.uniform2fv(gl.getUniformLocation(this.program, "uGrassOverlay"), this.blockGrassOverlay);
gl.uniform1f(
gl.getUniformLocation(this.program, "uPulse"),
Math.max(0, 1 - (performance.now() - this.bounceAt) / 600),
@@ -450,80 +837,17 @@ export class Renderer {
for (const [id, m] of this.sections) {
const center = id.split(",").map((v) => Number(v) * 16 + 8);
if (
Math.hypot(center[0] - camera.eye[0], center[2] - camera.eye[2]) > 135
Math.hypot(center[0] - camera.eye[0], center[2] - camera.eye[2]) > distance.drawRadius
)
continue;
this.meshOffset(this.program,m.origin);
gl.bindVertexArray(m.vao);
gl.drawArrays(gl.TRIANGLES, 0, m.count);
this.triangles += m.count / 3;
}
const dynamic = [];
for (const p of players) {
const c = p.color || [0.29, 0.46, 0.36],
yaw = p.yaw || 0;
boxVertices(
dynamic,
p.position,
{ min: [-0.21, 0.63, -0.13], max: [0.21, 1.27, 0.13] },
c,
2,
() => false,
yaw,
);
boxVertices(
dynamic,
p.position,
{ min: [-0.23, 1.27, -0.23], max: [0.23, 1.73, 0.23] },
[0.77, 0.61, 0.43],
0,
() => false,
yaw,
);
for (const side of [-1, 1]) {
boxVertices(
dynamic,
p.position,
{
min: [side * 0.13 - 0.085, 0.02, -0.09],
max: [side * 0.13 + 0.085, 0.63, 0.09],
},
[0.25, 0.3, 0.32],
0,
() => false,
yaw,
);
boxVertices(
dynamic,
p.position,
{
min: [side * 0.31 - 0.085, 0.63, -0.1],
max: [side * 0.31 + 0.085, 1.24, 0.1],
},
c,
0,
() => false,
yaw,
);
}
boxVertices(
dynamic,
p.position,
{ min: [-0.18, 1.56, -0.234], max: [0.18, 1.64, -0.226] },
[0.13, 0.19, 0.15],
0,
() => false,
yaw,
);
}
for (const e of entities) {
const def = entityDefinitions.get(e.kind || e.name) || {},
color = (e.color || def.color || [192, 161, 123]).map((v) => v / 255),
boxes = e.render ||
def.render || [{ min: [-0.3, 0, -0.3], max: [0.3, 0.8, 0.3] }];
for (const b of boxes)
boxVertices(dynamic, e.position, b, color, 0, () => false, e.yaw || 0);
}
this.upload(this.dynamic, dynamic);
// Actor vertices already use the camera origin. Reset the world program's
// offset after terrain; its last section must never displace players/mobs.
this.meshOffset(this.program,this.renderOrigin);
gl.bindVertexArray(this.dynamic.vao);
gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count);
this.triangles += this.dynamic.count / 3;
@@ -540,6 +864,7 @@ export class Renderer {
return dist(b[0]) - dist(a[0]);
});
for (const [, m] of translucent) {
this.meshOffset(this.program,m.origin);
gl.bindVertexArray(m.transparent.vao);
gl.drawArrays(gl.TRIANGLES, 0, m.transparent.count);
this.triangles += m.transparent.count / 3;
@@ -553,6 +878,7 @@ export class Renderer {
false,
this.vp,
);
this.meshOffset(this.line,this.selectionOrigin);
gl.bindVertexArray(this.lines.vao);
gl.drawArrays(gl.LINES, 0, this.lines.count);
}
@@ -560,7 +886,7 @@ export class Renderer {
}
project(p) {
return project(
p,
p.map((v,i)=>v-this.renderOrigin[i]),
this.vp,
this.canvas.clientWidth,
this.canvas.clientHeight,
+68
View File
@@ -0,0 +1,68 @@
const sectionKey = (key) =>
key
.split(",")
.map((value) => Math.floor(Number(value) / 16))
.join(",");
/** Block positions indexed by their 16³ section without copying block values. */
export class SectionBlockMap extends Map {
#sections = new Map();
constructor(entries) {
super();
if (entries != null)
for (const [key, value] of entries) this.set(key, value);
}
set(key, value) {
if (!super.has(key)) this.#index(key, sectionKey(key));
return super.set(key, value);
}
/** Insert using a section ID already validated by the caller. */
setInSection(key, value, section) {
if (!super.has(key)) this.#index(key, section);
return super.set(key, value);
}
#index(key, section) {
let keys = this.#sections.get(section);
if (!keys) this.#sections.set(section, (keys = new Set()));
keys.add(key);
}
delete(key) {
if (!super.delete(key)) return false;
const section = sectionKey(key),
keys = this.#sections.get(section);
keys.delete(key);
if (!keys.size) this.#sections.delete(section);
return true;
}
/** Remove every stored block in a section and return the number removed. */
deleteSection(section) {
const keys = this.#sections.get(section);
if (!keys) return 0;
const count = keys.size;
for (const key of keys) super.delete(key);
keys.clear();
this.#sections.delete(section);
return count;
}
clear() {
super.clear();
this.#sections.clear();
}
/** Live key iterator; an absent or empty section contains no stored blocks. */
keysInSection(section) {
return this.#sections.get(section)?.keys() ?? [][Symbol.iterator]();
}
/** Sections containing stored blocks; empty loaded sections have no entries. */
loadedSectionKeys() {
return this.#sections.keys();
}
}
+64
View File
@@ -0,0 +1,64 @@
import { MAX_VIEW_WIDTH, VIEW_HEIGHT, MAX_VIEW_CELLS, MAX_VIEW_COORDINATE, WORLD_MIN_Y, WORLD_MAX_Y } from "./view-distance.js";
const triple=p=>Array.isArray(p)&&p.length===3&&p.every(Number.isSafeInteger);
const sectionId=p=>p.join(',');
export function decodeSection(record) {
if(!triple(record?.section)||record.section.some(v=>Math.abs(v*16)>MAX_VIEW_COORDINATE))throw Error('Invalid section coordinates');
const {palette,runs}=record;
if(!Array.isArray(palette)||!palette.length||palette.length>4096||!palette.every(v=>Number.isInteger(v)&&v>=0&&v<=0xffffffff))throw Error('Invalid section palette');
if(!Array.isArray(runs)||!runs.length||runs.length>8192||runs.length%2)throw Error('Invalid section runs');
const uniform = runs.length === 2 && runs[0] === 4096 && Number.isInteger(runs[1]) && runs[1] >= 0 && runs[1] < palette.length;
if (uniform) return {section:[...record.section],cells:new Uint32Array([palette[runs[1]]])};
const cells=new Uint32Array(4096);let offset=0;
for(let i=0;i<runs.length;i+=2) {
const count=runs[i],index=runs[i+1];
if(!Number.isInteger(count)||count<=0||!Number.isInteger(index)||index<0||index>=palette.length||offset+count>4096)throw Error('Invalid section run');
cells.fill(palette[index],offset,offset+count);offset+=count;
}
if(offset!==4096)throw Error('Incomplete section');
return {section:[...record.section],cells};
}
export function sectionInBounds(section,bounds) {
return section.every((v,i)=>v*16>=bounds.min[i]&&v*16+15<=bounds.max[i]);
}
export function applySectionView(view,message) {
if(message.world!==view.world)return 'ignored';
if(!Number.isSafeInteger(message.generation)||message.generation<=view.viewGeneration)return 'ignored';
if(message.revision!==view.revision||!triple(message.view_center)||message.view_center.some(v=>v%16)||!triple(message.view_min)||!triple(message.view_max)||!Array.isArray(message.unload))return 'resync';
const bounds={min:message.view_min,max:message.view_max};
if(message.full_height===true){
if(bounds.min[1]!==WORLD_MIN_Y||bounds.max[1]!==WORLD_MAX_Y)return 'resync';
bounds.fullHeight=true;
}
let volume=1;
for(let i=0;i<3;i++) {const length=bounds.max[i]-bounds.min[i]+1;if(length<=0||length>(i===1?VIEW_HEIGHT:MAX_VIEW_WIDTH)||bounds.min[i]%16||(bounds.max[i]+1)%16||Math.abs(bounds.min[i])>MAX_VIEW_COORDINATE||Math.abs(bounds.max[i])>MAX_VIEW_COORDINATE||(!(i===1&&bounds.fullHeight)&&(message.view_center[i]<bounds.min[i]||message.view_center[i]>bounds.max[i])))return 'resync';volume*=length;}
if(volume>MAX_VIEW_CELLS||message.total_sections!==undefined&&message.total_sections!==volume/4096)return 'resync';
const unload=new Set();
for(const p of message.unload) {if(!triple(p)||sectionInBounds(p,bounds)||unload.has(sectionId(p)))return 'resync';unload.add(sectionId(p));}
// Authoritative bounds determine departing sections; explicit unloads are
// checked above but cannot accidentally leave old sections resident.
for(const id of view.blocks.loadedSectionKeys())if(!sectionInBounds(id.split(',').map(Number),bounds))unload.add(id);
for(const id of unload) {view.blocks.deleteSection(id);view.loadedSections.delete(id);}
view.viewCenter=[...message.view_center];view.viewBounds=bounds;view.viewGeneration=message.generation;
view.totalSections=volume/4096;
return {unload:[...unload].map(id=>id.split(',').map(Number))};
}
export function applySectionBatch(view,message) {
if(message.world!==view.world||message.generation!==view.viewGeneration)return {status:'ignored'};
if(message.revision!==view.revision||!Array.isArray(message.sections)||message.sections.length>128)return {status:'resync'};
const decoded=[],seen=new Set(),columns=[];
try {
if(message.columns!==undefined&&(!Array.isArray(message.columns)||message.columns.length>128))throw Error('Invalid skylight columns');
for(const record of message.sections) {
const result=decodeSection(record),id=sectionId(result.section);
if(seen.has(id)||!sectionInBounds(result.section,view.viewBounds))throw Error('Section outside view');
seen.add(id);decoded.push(result);
}
for(const column of message.columns||[]) {
if(!Array.isArray(column.column)||column.column.length!==2||!column.column.every(Number.isSafeInteger)||!Array.isArray(column.heights)||column.heights.length!==256||!column.heights.every(v=>Number.isInteger(v)&&v>=-32768&&v<=32767))throw Error('Invalid skylight column');
if(!column.column.every((v,i)=>v*16>=view.viewBounds.min[i*2]&&v*16+15<=view.viewBounds.max[i*2]))throw Error('Skylight column outside view');
columns.push({column:[...column.column],heights:new Int16Array(column.heights)});
}
} catch {return {status:'resync'};}
for(const {section,cells} of decoded) {const id=sectionId(section);view.blocks.setSection(id,cells);view.loadedSections.add(id);}
return {status:'applied',sections:decoded,columns};
}
+75
View File
@@ -0,0 +1,75 @@
/** 16³ sections; uniform air/solid sections retain a single uint32 value.
* Compatibility iterators visit non-air blocks; hot paths use numeric access.
*/
export class SectionVoxelMap {
#sections = new Map();
#bytes = 0;
size = 0;
static sectionKey(x,y,z) { return `${Math.floor(x/16)},${Math.floor(y/16)},${Math.floor(z/16)}`; }
static index(x,y,z) { return ((x%16+16)%16)+16*((z%16+16)%16)+256*((y%16+16)%16); }
getAt(x,y,z) { const cells=this.#sections.get(SectionVoxelMap.sectionKey(x,y,z))?.cells; return cells ? cells[cells.length===1 ? 0 : SectionVoxelMap.index(x,y,z)] : 0; }
get(key) { const p=key.split(',').map(Number); return this.getAt(...p) || undefined; }
has(key) { return this.get(key)!==undefined; }
hasSection(id) { return this.#sections.has(id); }
set(key,value) {
const p=key.split(',').map(Number), id=SectionVoxelMap.sectionKey(...p);
let section=this.#sections.get(id);
if(!section) {section={cells:new Uint32Array(4096),count:0,ids:new Map()};this.#sections.set(id,section);this.#bytes+=16384;}
const index=SectionVoxelMap.index(...p), old=section.cells[section.cells.length===1 ? 0 : index];
if(old===value) return this;
if(section.cells.length===1) {section.cells=new Uint32Array(4096).fill(old);this.#bytes+=16380;}
if(old) {this.size--;section.count--;const count=section.ids.get(old)-1;if(count) section.ids.set(old,count);else section.ids.delete(old);}
section.cells[index]=value;
if(value) {this.size++;section.count++;section.ids.set(value,(section.ids.get(value)||0)+1);}
return this;
}
setInSection(key,value) {return this.set(key,value);}
delete(key) {if(!this.has(key)) return false;this.set(key,0);return true;}
clear() {this.#sections.clear();this.size=0;this.#bytes=0;}
deleteSection(id) {const previous=this.#sections.get(id);if(!previous)return 0;this.size-=previous.count;this.#bytes-=previous.cells.byteLength;this.#sections.delete(id);return previous.count;}
setSection(id,cells) {
if(!(cells instanceof Uint32Array)||![1,4096].includes(cells.length)) throw Error('A section must contain 4096 uint32 cells or one uniform value');
const ids=new Map();let count=0;
for(const value of cells) if(value) {const n=cells.length===1 ? 4096 : 1;count+=n;ids.set(value,(ids.get(value)||0)+n);}
this.size+=count-(this.#sections.get(id)?.count||0);
this.#bytes+=cells.byteLength-(this.#sections.get(id)?.cells.byteLength||0);
this.#sections.set(id,{cells,count,ids});
}
/** Borrowed dense cells or a single uniform value; copy before transferring. */
getSectionCells(id) { return this.#sections.get(id)?.cells; }
get sectionCount() {return this.#sections.size;}
get byteLength() {return this.#bytes;}
sectionIsEmpty(id) {return this.#sections.get(id)?.count===0;}
loadedSectionKeys() {return this.#sections.keys();}
*sectionEntries() {for(const [id,section] of this.#sections) yield [id,section.cells];}
*materialIds() {const seen=new Set();for(const section of this.#sections.values())for(const id of section.ids.keys())if(!seen.has(id)){seen.add(id);yield id;}}
forEachBlock(callback,bounds=null) {
const selected = function* (sections) {
if (!bounds) {yield* sections;return;}
for(let x=Math.floor(bounds.min[0]/16);x<=Math.floor(bounds.max[0]/16);x++)
for(let y=Math.floor(bounds.min[1]/16);y<=Math.floor(bounds.max[1]/16);y++)
for(let z=Math.floor(bounds.min[2]/16);z<=Math.floor(bounds.max[2]/16);z++) {
const id=`${x},${y},${z}`,section=sections.get(id);
if(section)yield [id,section];
}
};
for(const [id,section] of selected(this.#sections)) {
if(!section.count)continue;
const [sx,sy,sz]=id.split(',').map(v=>Number(v)*16), cells=section.cells;
if(bounds&&(sx>bounds.max[0]||sx+15<bounds.min[0]||sy>bounds.max[1]||sy+15<bounds.min[1]||sz>bounds.max[2]||sz+15<bounds.min[2]))continue;
for(let i=0;i<4096;i++){const value=cells[cells.length===1?0:i];if(value){
const x=sx+(i&15),y=sy+(i>>8),z=sz+((i>>4)&15);
if(!bounds||(x>=bounds.min[0]&&x<=bounds.max[0]&&y>=bounds.min[1]&&y<=bounds.max[1]&&z>=bounds.min[2]&&z<=bounds.max[2]))callback(x,y,z,value);
}}
}
}
*keysInSection(id) {
const section=this.#sections.get(id);if(!section?.count)return;
const [sx,sy,sz]=id.split(',').map(v=>Number(v)*16);
for(let i=0;i<4096;i++)if(section.cells[section.cells.length===1?0:i])yield `${sx+(i&15)},${sy+(i>>8)},${sz+((i>>4)&15)}`;
}
*entries() {for(const id of this.#sections.keys())for(const key of this.keysInSection(id))yield [key,this.get(key)];}
*keys() {for(const id of this.#sections.keys())yield* this.keysInSection(id);}
*values() {for(const section of this.#sections.values())if(section.count)for(let i=0;i<4096;i++){const value=section.cells[section.cells.length===1?0:i];if(value)yield value;}}
[Symbol.iterator]() {return this.entries();}
}
+25
View File
@@ -0,0 +1,25 @@
/** Directional orthographic projection, snapped in light space to avoid crawling shadows. */
export function shadowFrame(eye, sun, resolution = 2048, radius = 48) {
const length = Math.hypot(...sun),
z = sun.map((v) => v / length),
horizontal = Math.hypot(z[0], z[2]),
x = horizontal > 1e-6 ? [z[2] / horizontal, 0, -z[0] / horizontal] : [1, 0, 0],
y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]],
dot = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0),
texel = 2 * radius / resolution,
sx = Math.round(dot(x, eye) / texel) * texel,
sy = Math.round(dot(y, eye) / texel) * texel,
sz = dot(z, eye),
depth = radius * 2,
center = eye.map((_, i) => x[i] * sx + y[i] * sy + z[i] * sz);
return {
center,
radius,
matrix: new Float32Array([
x[0] / radius, y[0] / radius, -z[0] / depth, 0,
x[1] / radius, y[1] / radius, -z[1] / depth, 0,
x[2] / radius, y[2] / radius, -z[2] / depth, 0,
-sx / radius, -sy / radius, sz / depth, 1,
]),
};
}
+9 -4
View File
@@ -18,7 +18,8 @@ body {
overflow: hidden;
}
button,
input {
input,
select {
font: inherit;
}
button {
@@ -33,7 +34,8 @@ button:hover {
background: #4c5e4a;
}
button:focus-visible,
input:focus-visible {
input:focus-visible,
select:focus-visible {
outline: 2px solid #dfefba;
outline-offset: 3px;
}
@@ -47,7 +49,7 @@ button.primary {
color: #253021;
font-weight: 650;
}
input {
input, select {
background: #202821;
color: #f4f4ef;
border: 1px solid #576255;
@@ -367,7 +369,7 @@ footer {
gap: 6px;
margin-bottom: 20px;
}
.inline input {
.inline input, .inline select {
min-width: 0;
}
.inline button {
@@ -595,3 +597,6 @@ body.locked .topbar:hover {
inset: 25% 14px auto;
}
}
/* Keep the live readout beside menus, while the world remains interactive. */
#diagnostics { left: 24px; right: auto; }
+231
View File
@@ -0,0 +1,231 @@
import { localLightBounds } from "./local-light-bounds.js";
import { attachBlockLightSamplers } from "./block-light.js";
import { SectionBlockMap } from "./section-block-map.js";
const slimMaterial = m => ({ state: m.state, minecraft_id: m.minecraft_id, color: m.color,
render: m.render, opacity: m.opacity, transparent: m.transparent, effect: m.effect,
light: m.light, light_dampening: m.light_dampening, light_occlusion: m.light_occlusion });
/** Separate lighting and local mesh workers; one mesh in flight, two ready. */
export class TerrainController {
constructor({ onLight = () => {}, onDirty = () => {}, onError = () => {}, workerFactory, meshWorkerFactory } = {}) {
this.onLight = onLight; this.onDirty = onDirty;
this.onError = onError;
this.epoch = 0; this.version = 0; this.workerVersion = -1; this.job = 0;
this.meshTickets = new Map(); this.materialSignatures = new Map();
this.meshSerial = 0;
this.meshWorkerVersion = -1;
this.ready = new Map(); this.busy = null; this.pending = false; this.disposed = false;
this.changes = new SectionBlockMap(); this.sectionChanges = new Map();
this.unloads = new Map(); this.sentMaterials = new Map(); this.columns = new Map(); this.compact = false;
this.status = "loading"; this.mode = "worker";
this.materials = new Map(); this.textures = new Map(); this.bounds = null;
this.resetSource = null; this.initial = null; this.preparing = false;
this.resetPending = false; this.texturesPending = false;
try {
this.worker = workerFactory ? workerFactory() : new Worker(new URL("./terrain-worker.js", import.meta.url), { type: "module" });
this.worker.onmessage = ({ data }) => this.receive(data);
this.worker.onerror = event => {
event.preventDefault(); this.fail(event.message || "Terrain worker failed");
};
} catch (error) { this.fail(error.message); }
if (this.worker && (meshWorkerFactory || typeof Worker === "function")) {
try {
this.meshWorker = meshWorkerFactory ? meshWorkerFactory() : new Worker(new URL("./terrain-mesh-worker.js",import.meta.url),{type:"module"});
this.meshWorker.onmessage = ({data}) => this.receive(data);
this.meshWorker.onerror = event => {event.preventDefault();this.meshFallback(event.message);};
} catch (error) { this.meshFallback(error.message); }
}
}
meshFallback(error) {
this.meshWorker?.terminate(); this.meshWorker = null; this.busy = null; this.ready.clear();
this.onDirty([...this.meshTickets.keys()]); this.meshTickets.clear();
console.warn("Shacraft local meshing unavailable; using terrain worker",error);
}
invalidateMeshes({changes,sections,columns,unload,materials,textures,bounds} = {}) {
if (!this.meshWorker) return;
const boundsChanged = bounds !== undefined && JSON.stringify(bounds) !== JSON.stringify(this.bounds);
let all = Boolean(textures);
if (materials) for (const [id,mat] of materials) {
const signature=JSON.stringify(slimMaterial(mat));
if (this.materialSignatures.get(id)!==signature) {this.materialSignatures.set(id,signature);all=true;}
}
const affected = new Set((unload || []).map(p=>`${p[0]},${p[2]}`));
for (const {section} of sections || []) affected.add(`${section[0]},${section[2]}`);
for (const {column} of columns || []) affected.add(column.join(","));
for (const {pos} of changes || []) affected.add(`${Math.floor(pos[0]/16)},${Math.floor(pos[2]/16)}`);
const coords=[...affected].map(key=>key.split(",").map(Number)),dirty=[];
for (const id of this.meshTickets.keys()) {
const [x,,z]=id.split(",").map(Number);
const boundaryChanged = boundsChanged && JSON.stringify(localLightBounds(id,bounds)) !== JSON.stringify(localLightBounds(id,this.bounds));
if (!all && !boundaryChanged && !coords.some(([sx,sz])=>Math.abs(sx-x)<=2&&Math.abs(sz-z)<=2)) continue;
this.meshTickets.set(id,++this.meshSerial);this.ready.delete(id);dirty.push(id);
}
this.onDirty(dirty);
for (const p of unload || []) { const id=p.join(",");this.meshTickets.delete(id);this.ready.delete(id); }
}
fail(message) {
if (this.disposed) return;
this.status = `error: ${message}`;
this.error = message;
console.error("Shacraft terrain", message);
queueMicrotask(() => { if (!this.disposed) this.onError(message); });
}
reset(blocks, materials, textures, bounds) {
this.epoch++;
this.meshTickets.clear(); this.materialSignatures.clear(); this.ready.clear(); this.meshWorkerVersion=-1;
for (const [id,mat] of materials) this.materialSignatures.set(id,JSON.stringify(slimMaterial(mat)));
this.changes.clear(); this.sectionChanges.clear(); this.unloads.clear(); this.sentMaterials.clear(); this.columns.clear();
this.compact = typeof blocks.sectionEntries === "function";
this.materials = materials; this.textures = textures; this.bounds = bounds;
this.resetSource = this.compact ? null : blocks; this.initial = null; this.preparing = false;
if (this.compact) for (const [id,cells] of blocks.sectionEntries()) this.sectionChanges.set(id,cells);
this.resetPending = true; this.texturesPending = true;
this.busy = null;
this.changed();
}
update({ changes, sections, columns, materials, textures, bounds, unload } = {}) {
this.invalidateMeshes({changes,sections,columns,materials,textures,bounds,unload});
if (columns) for (const item of columns) this.columns.set(item.column.join(","),item);
if (sections) for (const { section, blocks, cells } of sections) {
const id = section.join(",");
// Complete entering sections supersede older queued edits. Retain their
// numeric records until packing instead of indexing every voxel again.
this.unloads.set(id, section);
this.changes.deleteSection(id);
this.sectionChanges.set(id, cells || blocks);
}
if (changes) for (const change of changes) this.changes.set(change.pos.join(","), change);
if (materials) this.materials = materials;
if (textures) { this.textures = textures; this.texturesPending = true; }
if (bounds !== undefined) this.bounds = bounds;
if (unload) for (const coords of unload) {
const id = coords.join(",");
this.unloads.set(id, coords);
// An unload supersedes older edits; later entering records must survive it.
this.changes.deleteSection(id);
this.sectionChanges.delete(id);
}
this.changed();
}
changed() {
this.version++; this.workerVersion = -1;
if (!this.meshWorker) this.ready.clear();
this.pending = true;
if (!this.error) this.status = "pending";
if (!this.timer) this.timer = setTimeout(() => { this.timer = null; this.flush(); }, 25);
}
prepareReset() {
if (this.preparing) return;
const epoch = this.epoch, source = this.resetSource, iterator = source.entries();
let packed = new Int32Array(Math.max(1, source.size) * 4), length = 0;
this.preparing = true;
const step = () => {
if (epoch !== this.epoch || this.disposed) return;
const start = performance.now();
for (let count = 0; ; count++) {
const next = iterator.next();
if (next.done) {
this.initial = packed.subarray(0, length);
this.resetSource = null; this.preparing = false;
this.flush(); return;
}
if (length + 4 > packed.length) {
const larger = new Int32Array(packed.length * 2); larger.set(packed); packed = larger;
}
const [key, id] = next.value, pos = key.split(",").map(Number);
packed.set([pos[0], pos[1], pos[2], id], length); length += 4;
if (count % 64 === 63 && performance.now() - start >= 2) {
setTimeout(step, 0); return;
}
}
};
step();
}
flush() {
if (!this.worker || this.disposed || !this.pending) return;
if (this.resetSource) { this.prepareReset(); return; }
const start = performance.now();
const materials = [];
for (const [id, material] of this.materials) {
if (this.sentMaterials.get(id) === material) continue;
this.sentMaterials.set(id, material); materials.push([id, slimMaterial(material)]);
}
let count = this.changes.size;
for (const records of this.sectionChanges.values()) if (!(records instanceof Uint32Array)) count += records.length;
const changes = new Int32Array(count * 4);
let i = 0;
const pack = records => {
for (const { pos, block } of records) {
changes[i++] = pos[0]; changes[i++] = pos[1]; changes[i++] = pos[2]; changes[i++] = block;
}
};
for (const records of this.sectionChanges.values()) if (!(records instanceof Uint32Array)) pack(records);
pack(this.changes.values());
const packet = { type: "sync", epoch: this.epoch, version: this.version,
reset: this.resetPending, compact: this.compact, initial: this.initial, changes, materials,
sections: [...this.sectionChanges].filter(([,data]) => data instanceof Uint32Array).map(([id,cells]) => ({section:id.split(",").map(Number),cells:cells.slice()})),
columns: [...this.columns.values()],
textures: this.texturesPending ? [...this.textures] : undefined,
bounds: this.bounds, unload: [...this.unloads.values()] };
const transfers = [changes.buffer, ...packet.sections.map(s => s.cells.buffer)];
if (this.initial) transfers.push(this.initial.buffer);
this.pending = false; this.resetPending = false; this.texturesPending = false;
this.initial = null; this.changes.clear(); this.sectionChanges.clear(); this.unloads.clear(); this.columns.clear();
this.status = "building";
try {
if (this.meshWorker) {
const copy=structuredClone(packet),buffers=[copy.changes.buffer,...copy.sections.map(s=>s.cells.buffer)];
if(copy.initial)buffers.push(copy.initial.buffer);
this.meshWorker.postMessage(copy,buffers);
}
this.worker.postMessage({...packet,independentMeshes:Boolean(this.meshWorker)}, transfers);
}
catch (error) { this.fail(error.message); }
this.prepareMilliseconds = performance.now() - start;
}
receive(message) {
if (this.disposed || message.epoch !== this.epoch) return;
if (message.type === "error" && message.meshTicket !== undefined) {
if (message.job !== this.busy?.job) return;
this.busy = null;
if (this.isCurrent(message)) this.meshFallback(message.error);
return;
}
if (message.type === "synced") {
this.meshWorkerVersion = Math.max(this.meshWorkerVersion,message.version);
} else if (message.type === "light") {
// Include deltas from skipped intermediate fields too: their union is
// needed when the next field is compared to a field never displayed.
if (!this.meshWorker) this.onDirty(message.dirty);
if (message.version !== this.version) return;
this.workerVersion = message.version;
this.status = "ready"; this.milliseconds = message.milliseconds;
// Dynamic actors only need point samples, not terrain face geometry.
this.onLight(attachBlockLightSamplers(message.field, new Map(), new Map()));
} else if (message.type === "mesh") {
if (message.job !== this.busy?.job) return;
this.busy = null;
if (!this.isCurrent(message) || message.stale) return;
if (message.error) { this.fail(message.error); return; }
this.meshMilliseconds = message.milliseconds;
this.localLightMilliseconds=message.lightMilliseconds; this.localLightCells=message.lightCells;
this.ready.set(message.id, message);
} else if (message.type === "error" && message.version === this.version) this.fail(message.error);
}
isCurrent(result) { return result.epoch === this.epoch && (result.meshTicket !== undefined
? Boolean(this.meshWorker) && this.meshTickets.get(result.id) === result.meshTicket : result.version === this.version); }
requestMesh(id) {
if (!this.worker || this.disposed || this.busy || this.pending || this.preparing ||
(this.meshWorker ? this.meshWorkerVersion < this.version : this.workerVersion !== this.version) || this.ready.size >= 2 || this.ready.has(id)) return false;
this.busy = { type: "mesh", id, epoch: this.epoch, version: this.version, job: ++this.job };
if (this.meshWorker) {
if(!this.meshTickets.has(id))this.meshTickets.set(id,++this.meshSerial);
this.busy.meshTicket=this.meshTickets.get(id);
}
try { (this.meshWorker || this.worker).postMessage(this.busy); }
catch (error) { this.busy = null; this.fail(error.message); return false; }
return true;
}
dispose() { this.disposed = true; clearTimeout(this.timer); this.worker?.terminate(); this.meshWorker?.terminate(); this.ready.clear(); }
}
+13
View File
@@ -0,0 +1,13 @@
import { TerrainState } from './terrain-state.js';
import { loadLightProperties } from './light-properties.js';
// Geometry never queues behind a whole-view lighting build.
const state = loadLightProperties().then(properties => new TerrainState(properties));
self.onmessage = ({data}) => state.then(terrain => {
if (data.type === 'sync') {
if (terrain.sync(data)) self.postMessage({type:'synced',epoch:data.epoch,version:data.version});
} else if (data.type === 'mesh') {
const result = terrain.meshLocal(data);
self.postMessage(result, result.vertices ? [result.vertices.buffer,result.transparent.buffer] : []);
}
}).catch(error => self.postMessage({...data,type:'error',error:error.message}));
+192
View File
@@ -0,0 +1,192 @@
import { localLightBounds } from "./local-light-bounds.js";
import { SectionVoxelMap } from "./section-voxel-map.js";
import { SectionBlockMap } from "./section-block-map.js";
import { buildBlockLight, attachBlockLightSamplers } from "./block-light.js";
import { applyLightProperties } from "./light-properties.js";
import { changedLightSections } from "./light-changes.js";
import { buildSectionMesh } from "./mesh-geometry.js";
// Compare the transmitted values rather than object identity: structured clone
// creates new objects even when a chunk repeats an unchanged definition.
const materialSignature = material => JSON.stringify([
material.state, material.minecraft_id, material.color,
material.render?.map(box => [box.min, box.max]),
material.opacity, material.transparent, material.effect, material.light,
material.light_dampening, material.light_occlusion?.map(box => [box.min, box.max]),
]);
/** Persistent CPU terrain state. Only numeric deltas cross the worker boundary. */
export class TerrainState {
constructor(properties) {
this.properties = properties;
this.epoch = -1;
this.version = -1;
this.blocks = new SectionBlockMap();
this.materials = new Map();
this.materialSignatures = new Map();
this.changedMaterials = new Set();
this.textureLayers = new Map();
this.faceTextureCache = new Map();
this.meshed = new Set();
this.field = null;
this.bounds = null;
this.needsLighting = false;
this.skyColumns = new Map();
this.localLights = new Map();
}
sync(packet) {
if (packet.epoch < this.epoch || packet.epoch === this.epoch && packet.version < this.version) return false;
if (JSON.stringify(packet.bounds) !== JSON.stringify(this.bounds))
for (const [key,entry] of this.localLights) {
const [x,z]=key.split(",");
if (JSON.stringify(localLightBounds(`${x},0,${z}`,packet.bounds)) !== JSON.stringify(entry.bounds)) this.localLights.delete(key);
}
const columns = new Set((packet.unload || []).map(p=>`${p[0]},${p[2]}`));
for (const {section} of packet.sections || []) columns.add(`${section[0]},${section[2]}`);
for (const {column} of packet.columns || []) columns.add(column.join(","));
for (let i=0;i<(packet.changes?.length || 0);i+=4) columns.add(`${Math.floor(packet.changes[i]/16)},${Math.floor(packet.changes[i+2]/16)}`);
for (const [id,entry] of this.localLights) {
if ([...columns].some(column=>{const [x,z]=column.split(",").map(v=>Number(v)*16);return x<=entry.bounds.max[0]&&x+15>=entry.bounds.min[0]&&z<=entry.bounds.max[2]&&z+15>=entry.bounds.min[2];})) this.localLights.delete(id);
}
if (packet.reset) {
this.localLights.clear();
this.blocks = packet.compact ? new SectionVoxelMap() : new SectionBlockMap();
this.skyColumns.clear(); this.materials.clear(); this.meshed.clear();
this.materialSignatures.clear(); this.changedMaterials.clear();
this.field = null;
} else if (packet.epoch !== this.epoch) return false;
this.epoch = packet.epoch;
this.version = packet.version;
for (const [id, material] of packet.materials || []) {
const signature = materialSignature(material);
if (this.materialSignatures.get(id) === signature) continue;
this.localLights.clear();
this.materialSignatures.set(id, signature);
this.changedMaterials.add(id);
this.materials.set(id, applyLightProperties(material, this.properties));
}
if (packet.textures) {
this.textureLayers = new Map(packet.textures);
this.faceTextureCache.clear();
}
const apply = data => {
if (!data) return;
for (let i = 0; i < data.length; i += 4) {
const key = `${data[i]},${data[i + 1]},${data[i + 2]}`;
if (data[i + 3]) this.blocks.set(key, data[i + 3]);
else this.blocks.delete(key);
}
};
apply(packet.initial);
for (const coords of packet.unload || []) {
const id = coords.join(",");
this.blocks.deleteSection(id);
this.meshed.delete(id);
}
for (const {section,cells} of packet.sections || []) this.blocks.setSection(section.join(","),cells);
for (const {column,heights} of packet.columns || []) this.skyColumns.set(column.join(","),heights);
apply(packet.changes);
this.bounds = packet.bounds;
if (this.bounds) for (const id of this.skyColumns.keys()) {
const [x,z]=id.split(",").map(v=>Number(v)*16);
if(x+15<this.bounds.min[0]||x>this.bounds.max[0]||z+15<this.bounds.min[2]||z>this.bounds.max[2])this.skyColumns.delete(id);
}
this.needsLighting = true;
return true;
}
light({dirty:compareDirty=true} = {}) {
const start = performance.now();
const view = this.bounds || inferBounds(this.blocks);
// Terrain meshes compute their own light. The shared field is only for
// nearby moving actors and must stay bounded at any render distance.
const bounds = compareDirty ? view : actorLightBounds(view);
const field = buildBlockLight(this.blocks, this.materials, bounds, this.skyColumns.size ? this.skyColumns : null);
const sections = new Set([...this.blocks.loadedSectionKeys(), ...this.meshed]);
const dirty = new Set(compareDirty ? changedLightSections(this.field, field, sections) : []);
if (compareDirty && this.field && this.changedMaterials.size) {
// A newly learned opaque block can have the same light values as its
// placeholder but a different color, shape or texture. Scan only here in
// the worker, and preserve this union across syncs coalesced before light().
for (const id of this.blocks.loadedSectionKeys()) {
let affected = false;
for (const key of this.blocks.keysInSection(id))
if (this.changedMaterials.has(this.blocks.get(key))) { affected = true; break; }
if (!affected) continue;
const section = id.split(",").map(Number);
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const neighbor = `${section[0] + x},${section[1] + y},${section[2] + z}`;
if (sections.has(neighbor)) dirty.add(neighbor);
}
}
}
this.changedMaterials.clear();
this.field = field;
this.needsLighting = false;
// Keep the worker's sampling arrays; only these copies are transferred.
return { type: "light", epoch: this.epoch, version: this.version, dirty: [...dirty],
milliseconds: performance.now() - start,
field: { min: field.min, size: field.size, sourceCount: field.sourceCount,
data: field.data.slice(), blockLevels: field.blockLevels.slice(), skyLevels: field.skyLevels.slice() } };
}
meshLocal(packet) {
const result = {type:"mesh",epoch:packet.epoch,version:packet.version,id:packet.id,job:packet.job,meshTicket:packet.meshTicket};
if (packet.epoch !== this.epoch || packet.version > this.version) return {...result,stale:true};
const start = performance.now(), [sx,,sz] = packet.id.split(",").map(Number);
const key = `${Math.floor(sx/2)*2},${Math.floor(sz/2)*2}`, view = this.bounds || inferBounds(this.blocks);
// Light levels are at most 15. An 18-cell horizontal halo includes their
// reach and face/partial-model samples; keep full Y for unattenuated sky.
const bounds = localLightBounds(packet.id,view);
if (bounds.min.some((v,i)=>v>bounds.max[i])) return {...result,stale:true};
let entry = this.localLights.get(key);
const cached = Boolean(entry);
if (!entry) {
const field = buildBlockLight(this.blocks,this.materials,bounds,this.skyColumns.size ? this.skyColumns : null);
entry = {bounds,field};
if (this.localLights.size >= 8) this.localLights.delete(this.localLights.keys().next().value);
this.localLights.set(key,entry);
} else { this.localLights.delete(key); this.localLights.set(key,entry); }
const lightMilliseconds = performance.now()-start;
// Fresh samplers reference current voxels; their occlusion caches must not
// survive a sync, even when its numeric light field remains valid.
const field = attachBlockLightSamplers({...entry.field},this.blocks,this.materials);
const geometry = buildSectionMesh({id:packet.id,blocks:this.blocks,materials:this.materials,
textureLayers:this.textureLayers,faceTextureCache:this.faceTextureCache,blockLightField:field,localCoordinates:true});
return {...result,...geometry,milliseconds:performance.now()-start,lightMilliseconds,lightCached:cached,
lightCells:field.size.reduce((a,b)=>a*b,1)};
}
mesh(packet) {
const result = { type: "mesh", epoch: packet.epoch, version: packet.version, id: packet.id, job: packet.job };
if (packet.epoch !== this.epoch || packet.version !== this.version || this.needsLighting)
return { ...result, stale: true };
const start = performance.now();
const geometry = buildSectionMesh({ id: packet.id, blocks: this.blocks, materials: this.materials,
textureLayers: this.textureLayers, faceTextureCache: this.faceTextureCache, blockLightField: this.field, localCoordinates: true });
this.meshed.add(packet.id);
return { ...result, ...geometry, milliseconds: performance.now() - start };
}
}
export function actorLightBounds(view) {
const min = [...view.min], max = [...view.max];
for (const axis of [0, 2]) {
const center = Math.floor((view.min[axis] + view.max[axis]) / 32) * 16;
min[axis] = Math.max(min[axis], center - 48);
max[axis] = Math.min(max[axis], center + 63);
}
return {min, max};
}
function inferBounds(blocks) {
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
for (const key of blocks.keys()) {
const pos = key.split(",").map(Number);
for (let axis = 0; axis < 3; axis++) {
min[axis] = Math.min(min[axis], pos[axis]); max[axis] = Math.max(max[axis], pos[axis]);
}
}
return Number.isFinite(min[0])
? { min: min.map(v => v - 15), max: max.map(v => v + 15) }
: { min: [-16, -16, -16], max: [16, 16, 16] };
}
+27
View File
@@ -0,0 +1,27 @@
import { TerrainState } from "./terrain-state.js";
import { loadLightProperties } from "./light-properties.js";
const state = loadLightProperties().then(properties => new TerrainState(properties));
let timer = null;
const send = message => {
const buffers = message.type === "light"
? [message.field.data.buffer, message.field.blockLevels.buffer, message.field.skyLevels.buffer]
: message.vertices ? [message.vertices.buffer, message.transparent.buffer] : [];
self.postMessage(message, buffers);
};
self.onmessage = ({ data }) => {
state.then(terrain => {
if (data.type === "sync") {
if (!terrain.sync(data)) return;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
try { send(terrain.light({dirty:!data.independentMeshes})); }
catch (error) { send({ type: "error", epoch: terrain.epoch, version: terrain.version, error: error.message }); }
}, data.independentMeshes ? 225 : 0);
} else if (data.type === "mesh") {
try { send(terrain.mesh(data)); }
catch (error) { send({ ...data, error: error.message }); }
}
}).catch(error => send({ type: "error", epoch: data.epoch, version: data.version, error: error.message }));
};
+91
View File
@@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Renderer } from "../renderer.js";
// Record actual draw-call state: a uniform belongs to one linked program,
// and changing programs does not reset that program's previous uniforms.
function fixture(shadows) {
let active, vao;
const offsets = new Map(), draws = [], errors = [];
const uniform = location => {
if (location.program !== active) {
errors.push(`uniform ${location.name} belongs to ${location.program}, active ${active}`);
return false;
}
return true;
};
const gl = new Proxy({
useProgram(program) { active = program; },
getUniformLocation(program, name) { return { program, name }; },
uniform3fv(location, values) {
if (uniform(location) && location.name === "uOffset") offsets.set(active, [...values]);
},
uniform1f: uniform, uniform1i: uniform, uniformMatrix4fv: uniform,
bindVertexArray(value) { vao = value; },
drawArrays() { draws.push({ program: active, vao, offset: offsets.get(active) }); },
}, { get(target, name) { return target[name] ?? (/^[A-Z_0-9]+$/.test(name) ? 0 : () => {}); } });
const renderer = Object.assign(Object.create(Renderer.prototype), {
blocks: new Map(),
gl, canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600, dataset: {} },
program: "world", shadowProgram: "shadow", sky: "sky", line: "line",
renderOrigin: [0, 0, 0], sections: new Map(), dirty: new Set(),
dynamic: { vao: "actors", count: 0 }, lines: { count: 0 },
shadowsEnabled: shadows, shadowReady: true, shadowSize: 2048, shadowRadius: 48,
blockLighting: { status: "ready", milliseconds: 0 }, daylight: 1, bounceAt: -1000,
});
renderer.upload = (mesh, vertices) => {
mesh.vertices = new Float32Array(vertices);
mesh.count = vertices.length / 20;
};
return { renderer, draws, errors };
}
for (const shadows of [true, false]) {
test(`players and entities keep world positions through camera/section changes; shadows ${shadows}`, () => {
const previousRatio = globalThis.devicePixelRatio;
globalThis.devicePixelRatio = 1;
try {
for (const base of [0, -160, 29_999_840]) {
const { renderer, draws, errors } = fixture(shadows);
const player = { position: [base + 18.125, 77, -5.875], yaw: 0.3 };
const entity = { kind: "crate", position: [base + 21.125, 77, -6.875] };
let baseline;
for (const [x, y, z, reverse, empty] of [
[15.99, 79.99, 0.01, false, false],
[16.01, 80.01, -0.01, true, false],
[31.99, 79.99, -16.01, false, false],
[32.01, 80.01, -15.99, true, true],
]) {
const origins = [[base, 64, -16], [base + 32, 96, 16]];
if (reverse) origins.reverse();
renderer.sections = new Map(empty ? [] : origins.map((origin, i) => [
origin.map(v => v / 16).join(","),
{ origin, vao: `terrain-${i}`, count: 36, transparent: { vao: `glass-${i}`, count: 36 } },
]));
draws.length = 0;
const eye = [base + x, y, z];
const direction = [player.position[0] - eye[0], 79 - eye[1], player.position[2] - eye[2]];
const camera = { eye, yaw: Math.atan2(direction[0], -direction[2]), pitch: Math.atan2(direction[1], Math.hypot(direction[0], direction[2])) };
renderer.draw(camera, [player], [entity], new Map());
assert.deepEqual(errors, [], "uniform writes must target the active shader program");
const actorDraws = draws.filter(draw => draw.vao === "actors");
assert.equal(actorDraws.length, shadows ? 2 : 1);
for (const draw of actorDraws) assert.deepEqual(draw.offset, [0, 0, 0], `${draw.program} inherited terrain transform`);
// Reverse the GPU transform for every avatar and entity vertex.
// It must recover identical world geometry despite rebasing the camera.
const world = [];
for (let i = 0; i < renderer.dynamic.vertices.length; i += 20)
for (let axis = 0; axis < 3; axis++)
world.push(renderer.dynamic.vertices[i + axis] + renderer.renderOrigin[axis]);
if (baseline) world.forEach((v, i) => assert.ok(Math.abs(v - baseline[i]) < 0.00001, `vertex ${i} moved with camera`));
else baseline = world;
assert.ok(world.length > 36 * 3, "both an avatar and an entity were drawn");
assert.ok(renderer.project([player.position[0], 79, player.position[2]]), "label projection uses the same camera origin");
}
}
} finally {
if (previousRatio === undefined) delete globalThis.devicePixelRatio;
else globalThis.devicePixelRatio = previousRatio;
}
});
}
+144
View File
@@ -0,0 +1,144 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
affectedSectionKeys,
createAmbientOcclusionSampler,
isAmbientOccluder,
} from "../ambient-occlusion.js";
import { unitBox } from "../math.js";
const top = [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]];
const stone = { state: "minecraft:stone", render: [unitBox] };
const slab = { min: [0, 0, 0], max: [1, 0.5, 1] };
const materialMap = () => new Map([
[1, stone],
[2, { state: "minecraft:stone_slab[type=bottom]", render: [slab] }],
]);
const blockMap = (...cells) => new Map(cells.map(([pos, id = 1]) => [pos.join(","), id]));
const sampleTop = (blocks, materials = materialMap(), box = unitBox, pos = [0, 0, 0]) =>
createAmbientOcclusionSampler(blocks, materials)(pos, box, [0, 1, 0], top);
test("an exposed flat floor has no dark grid or dark silhouette corners", () => {
const blocks = new Map();
for (let x = -2; x <= 2; x++)
for (let z = -2; z <= 2; z++) blocks.set(`${x},0,${z}`, 1);
assert.deepEqual(sampleTop(blocks), [1, 1, 1, 1]);
assert.deepEqual(sampleTop(blockMap([[0, 0, 0], 1])), [1, 1, 1, 1]);
});
test("one adjacent wall shades only its two touching vertices", () => {
assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 1])), [0.92, 1, 1, 0.92]);
});
test("diagonal contact shades one corner and two enclosing walls saturate it", () => {
assert.deepEqual(sampleTop(blockMap([[-1, 1, -1], 1])), [1, 1, 1, 0.92]);
const sides = [[[-1, 1, 0], 1], [[0, 1, -1], 1]];
assert.deepEqual(sampleTop(blockMap(...sides)), [0.92, 1, 0.92, 0.76]);
assert.deepEqual(sampleTop(blockMap(...sides, [[-1, 1, -1], 1])),
[0.92, 1, 0.92, 0.76]);
});
test("all six face orientations sample the outward hemisphere", () => {
for (let axis = 0; axis < 3; axis++)
for (const sign of [-1, 1]) {
const normal = [0, 0, 0];
normal[axis] = sign;
const tangent = [0, 1, 2].filter((a) => a !== axis);
const vertices = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([u, v]) => {
const result = [0, 0, 0];
result[axis] = sign > 0 ? 1 : 0;
result[tangent[0]] = u;
result[tangent[1]] = v;
return result;
});
const diagonal = [0, 0, 0];
diagonal[axis] = sign;
diagonal[tangent[0]] = -1;
diagonal[tangent[1]] = -1;
const sample = createAmbientOcclusionSampler(blockMap([diagonal, 1]), materialMap());
assert.deepEqual(sample([0, 0, 0], unitBox, normal, vertices), [0.92, 1, 1, 1],
`normal ${normal}`);
}
});
test("actual slab height controls occlusion rather than whole-cell occupancy", () => {
assert.deepEqual(sampleTop(blockMap([[-1, 0, 0], 2]), materialMap(), slab),
[1, 1, 1, 1]);
assert.deepEqual(sampleTop(blockMap([[-1, 0, 0], 1]), materialMap(), slab),
[0.92, 1, 1, 0.92]);
assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 2])), [0.92, 1, 1, 0.92]);
});
test("neighboring boxes within one stair model shade its recessed tread", () => {
const tread = { min: [0, 0, 0], max: [0.5, 0.5, 1] };
const riser = { min: [0.5, 0, 0], max: [1, 1, 1] };
const materials = new Map([[1, { ...stone, render: [tread, riser] }]]);
assert.deepEqual(sampleTop(blockMap([[0, 0, 0], 1]), materials, tread),
[1, 0.92, 0.92, 1]);
});
test("render boxes extending beyond their owner can shade a neighboring face", () => {
const materials = new Map([[1, {
...stone, render: [{ min: [0, 0, 0], max: [1, 1.5, 1] }],
}]]);
assert.deepEqual(sampleTop(blockMap([[-1, 0, 0], 1]), materials),
[0.92, 1, 1, 0.92]);
});
test("transparent and cutout materials never act like opaque corner cubes", () => {
for (const overrides of [
{ transparent: true }, { opacity: 0.4 }, { cutout: true },
{ state: "minecraft:oak_leaves[persistent=true]" },
{ state: "minecraft:red_stained_glass" }, { state: "minecraft:water[level=0]" },
{ state: "minecraft:cobweb" }, { state: "minecraft:short_grass" },
]) {
const material = { ...stone, ...overrides };
assert.equal(isAmbientOccluder(material), false);
assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 1]), new Map([[1, material]])),
[1, 1, 1, 1]);
}
assert.equal(isAmbientOccluder({ ...stone, state: "minecraft:grass_block" }), true);
assert.equal(isAmbientOccluder({ ...stone, state: "minecraft:brown_mushroom_block" }), true);
});
test("a texture-pack cutout predicate is cached per material", () => {
let calls = 0;
const sample = createAmbientOcclusionSampler(blockMap([[-1, 1, 0], 1]), materialMap(),
(material, id) => { calls++; return id !== 1; });
assert.deepEqual(sample([0, 0, 0], unitBox, [0, 1, 0], top), [1, 1, 1, 1]);
sample([1, 0, 0], unitBox, [0, 1, 0], top);
assert.equal(calls, 2);
});
test("new rebuild samplers observe edits without keeping stale cached air", () => {
const blocks = blockMap();
assert.deepEqual(sampleTop(blocks), [1, 1, 1, 1]);
blocks.set("-1,1,0", 1);
assert.deepEqual(sampleTop(blocks), [0.92, 1, 1, 0.92]);
blocks.delete("-1,1,0");
assert.deepEqual(sampleTop(blocks), [1, 1, 1, 1]);
});
test("empty render geometry casts no AO and unknown blocks match placeholder cubes", () => {
assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 1]), new Map([[1, { ...stone, render: [] }]])),
[1, 1, 1, 1]);
assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 99])), [0.92, 1, 1, 0.92]);
});
test("section invalidation includes corners across positive and negative boundaries", () => {
assert.deepEqual(affectedSectionKeys([5, 5, 5]), ["0,0,0"]);
assert.deepEqual(affectedSectionKeys([16, 5, 5]), ["0,0,0", "1,0,0"]);
assert.deepEqual(affectedSectionKeys([15, 15, 15]), [
"0,0,0", "0,0,1", "0,1,0", "0,1,1",
"1,0,0", "1,0,1", "1,1,0", "1,1,1",
]);
assert.equal(affectedSectionKeys([-1, -1, -1]).length, 8);
assert.ok(affectedSectionKeys([-1, -1, -1]).includes("0,0,0"));
assert.ok(affectedSectionKeys([0, 0, 0]).includes("-1,-1,-1"));
});
test("extended render geometry widens the affected sections", () => {
assert.deepEqual(affectedSectionKeys([5, 13, 5]), ["0,0,0"]);
assert.deepEqual(affectedSectionKeys([5, 13, 5], [{ min: [0, 0, 0], max: [1, 3, 1] }]),
["0,0,0", "0,1,0"]);
});
+227
View File
@@ -0,0 +1,227 @@
import test, { after, afterEach } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { BlockLightController } from "../block-light-controller.js";
import { buildBlockLight } from "../block-light.js";
import { loadLightProperties } from "../light-properties.js";
const properties = JSON.parse(
await readFile(new URL("../light-properties.json", import.meta.url)),
);
const savedWorker = globalThis.Worker,
savedFetch = globalThis.fetch;
const controllers = [];
class FakeWorker {
constructor() {
this.packets = [];
this.terminated = false;
}
postMessage(packet) {
this.packets.push(structuredClone(packet));
}
terminate() {
this.terminated = true;
}
reply(index) {
const packet = this.packets[index];
const { min, size, data, sourceCount, blockLevels, skyLevels } =
buildBlockLight(
new Map(packet.blocks),
new Map(packet.materials),
packet.bounds,
);
this.onmessage({
data: {
generation: packet.generation,
field: { min, size, data, sourceCount, blockLevels, skyLevels },
milliseconds: 1,
},
});
}
}
globalThis.Worker = FakeWorker;
globalThis.fetch = async () => ({ ok: true, json: async () => properties });
afterEach(() => {
for (const controller of controllers.splice(0)) {
clearTimeout(controller.timer);
controller.worker?.terminate();
}
});
after(() => {
if (savedWorker === undefined) delete globalThis.Worker;
else globalThis.Worker = savedWorker;
globalThis.fetch = savedFetch;
});
const bounds = { min: [-2, 0, -2], max: [2, 3, 2] };
const materials = new Map([
[1, { state: "minecraft:torch", light: 14, render: [] }],
[
2,
{
state: "minecraft:sea_lantern",
light: 15,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
},
],
]);
function fixture() {
const published = [];
const controller = new BlockLightController((field) => published.push(field));
controllers.push(controller);
return { controller, worker: controller.worker, published };
}
test("requests awaiting metadata coalesce into the latest complete world", async () => {
const { controller, worker, published } = fixture();
controller.request(new Map([["0,1,0", 1]]), materials, bounds);
controller.request(new Map([["1,1,0", 2]]), materials, bounds);
await loadLightProperties();
controller.start();
assert.equal(worker.packets.length, 1);
assert.equal(worker.packets[0].generation, 2);
assert.deepEqual(worker.packets[0].blocks, [["1,1,0", 2]]);
worker.reply(0);
assert.equal(published.length, 1);
assert.equal(published[0].sample([1, 1, 0]).blockLevel, 15);
assert.equal(controller.status, "ready");
});
test("an old world's in-flight result is discarded before publishing the replacement world", async () => {
const { controller, worker, published } = fixture();
await loadLightProperties();
const first = new Map([["0,1,0", 1]]);
controller.request(first, materials, bounds);
controller.start();
controller.request(new Map([["1,1,0", 2]]), materials, bounds);
first.clear();
assert.equal(worker.packets.length, 1);
worker.reply(0);
assert.equal(published.length, 0);
assert.equal(worker.packets.length, 2);
worker.reply(1);
assert.equal(published.length, 1);
assert.equal(published[0].sample([1, 1, 0]).blockLevel, 15);
assert.equal(controller.pending, null);
assert.equal(controller.busy, false);
});
test("rapid edits during a build coalesce while the last valid field remains available", async () => {
const { controller, worker, published } = fixture();
await loadLightProperties();
const blocks = new Map([["0,1,0", 1]]);
controller.request(blocks, materials, bounds);
controller.start();
worker.reply(0);
const retained = published[0];
blocks.set("0,1,0", 2);
controller.request(blocks, materials, bounds);
controller.start();
blocks.set("1,1,0", 1);
controller.request(blocks, materials, bounds);
blocks.clear();
controller.request(blocks, materials, bounds);
assert.equal(published.length, 1);
assert.equal(retained.sample([0, 1, 0]).blockLevel, 14);
worker.reply(1);
assert.equal(worker.packets.length, 3);
assert.equal(worker.packets[2].generation, 4);
assert.deepEqual(worker.packets[2].blocks, []);
assert.equal(published.length, 1);
worker.reply(2);
assert.equal(published.length, 2);
assert.equal(published[1].sourceCount, 0);
assert.equal(published[1].sample([0, 1, 0]).blockLevel, 0);
});
test("worker failure recovers using the newest queued world and the same solver", async () => {
const { controller, worker, published } = fixture();
await loadLightProperties();
controller.request(new Map([["0,1,0", 1]]), materials, bounds);
controller.start();
controller.request(new Map([["1,1,0", 2]]), materials, bounds);
let prevented = false;
worker.onerror({
preventDefault() {
prevented = true;
},
});
assert.equal(prevented, true);
assert.equal(worker.terminated, true);
assert.equal(controller.worker, null);
// Resolve through publish rather than a timing assumption about the fallback.
await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(Error("Fallback lighting did not finish")),
1000,
);
const publish = controller.publish;
controller.publish = (field) => {
clearTimeout(timeout);
publish(field);
resolve();
};
});
assert.equal(published.length, 1);
assert.equal(published[0].sample([1, 1, 0]).blockLevel, 15);
assert.equal(controller.status, "ready");
});
test("a duplicate stale reply cannot mark a newer build idle or start a concurrent job", async () => {
const { controller, worker, published } = fixture();
await loadLightProperties();
controller.request(new Map([["0,1,0", 1]]), materials, bounds);
controller.start();
controller.request(new Map([["1,1,0", 2]]), materials, bounds);
worker.reply(0);
assert.equal(worker.packets.length, 2);
assert.equal(controller.busy, true);
worker.reply(0);
assert.equal(controller.busy, true);
controller.request(new Map([["-1,1,0", 1]]), materials, bounds);
controller.start();
assert.equal(worker.packets.length, 2);
assert.equal(published.length, 0);
worker.reply(1);
assert.equal(worker.packets.length, 3);
worker.reply(2);
assert.equal(published.length, 1);
assert.equal(published[0].sample([-1, 1, 0]).blockLevel, 14);
worker.reply(2);
assert.equal(published.length, 1);
});
test("synchronous Worker construction failure still publishes light from the fallback solver", async () => {
const previousWorker = globalThis.Worker;
globalThis.Worker = class {
constructor() {
throw new Error("Worker blocked by browser policy");
}
};
let context;
try {
context = fixture();
} finally {
globalThis.Worker = previousWorker;
}
const { controller, published } = context;
assert.equal(controller.worker, null);
const completed = new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(Error("Fallback lighting did not finish")),
1000,
);
const publish = controller.publish;
controller.publish = (field) => {
clearTimeout(timeout);
publish(field);
resolve();
};
});
controller.request(new Map([["0,1,0", 1]]), materials, bounds);
await loadLightProperties();
controller.start();
await completed;
assert.equal(published.length, 1);
assert.equal(published[0].sample([0, 1, 0]).blockLevel, 14);
assert.equal(controller.status, "ready");
});
+202
View File
@@ -0,0 +1,202 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { attachBlockLightSamplers, blockEmission, buildBlockLight } from "../block-light.js";
import { unitBox } from "../math.js";
const stone = { state: "minecraft:stone", render: [unitBox], light: 0, light_dampening: 15, light_occlusion: [] };
const torch = { state: "minecraft:torch", render: [], light: 14, light_dampening: 0, light_occlusion: [] };
const lamp = { ...stone, state: "minecraft:redstone_lamp[lit=true]", light: 15 };
const materials = () => new Map([
[1, stone], [2, torch], [3, lamp],
[4, { ...lamp, state: "minecraft:redstone_lamp[lit=false]", light: 0 }],
[5, { ...torch, state: "minecraft:soul_torch", light: 10 }],
[6, { ...torch, state: "minecraft:sea_lantern", light: 15 }],
[7, { ...stone, state: "minecraft:glass", transparent: true, opacity: 0.35, light_dampening: 0 }],
]);
const map = (...cells) => new Map(cells.map(([pos, id]) => [pos.join(","), id]));
const bounds = { min: [-16, -3, -3], max: [16, 5, 3] };
const sample = (field, pos) => field.sample(pos.map((value) => value + 0.5));
test("block light uses canonical source levels, one-cell falloff and finite reach", () => {
const field = buildBlockLight(map([[0, 0, 0], 2]), materials(), bounds);
assert.equal(field.sourceCount, 1);
for (let x = 0; x <= 15; x++) assert.equal(sample(field, [x, 0, 0]).blockLevel, Math.max(0, 14 - x));
assert.equal(sample(field, [3, 2, 1]).blockLevel, 8);
assert.equal(sample(field, [0, 0, 0]).skyLevel, 15);
});
test("closed solid walls occlude light without wraparound in flattened arrays", () => {
const blocks = map([[-2, 0, 0], 3]);
for (let y = -3; y <= 5; y++) for (let z = -3; z <= 3; z++) blocks.set(`0,${y},${z}`, 1);
const field = buildBlockLight(blocks, materials(), bounds);
assert.equal(sample(field, [-1, 0, 0]).blockLevel, 14);
assert.equal(sample(field, [0, 0, 0]).blockLevel, 0);
assert.equal(sample(field, [1, 0, 0]).blockLevel, 0);
assert.deepEqual(sample(field, [1, 0, 0]).block, [0, 0, 0]);
});
test("light travels around an open doorway with Manhattan path attenuation", () => {
const blocks = map([[-2, 0, 0], 3]);
for (let y = -3; y <= 5; y++) for (let z = -3; z <= 3; z++) if (y !== 0 || z !== 2) blocks.set(`0,${y},${z}`, 1);
const field = buildBlockLight(blocks, materials(), bounds);
assert.equal(sample(field, [1, 0, 0]).blockLevel, 8);
});
test("transparent glass transmits while metadata dampening controls attenuation", () => {
const blocks = map([[-2, 0, 0], 3]);
for (let y = -3; y <= 5; y++) for (let z = -3; z <= 3; z++) blocks.set(`0,${y},${z}`, 7);
const field = buildBlockLight(blocks, materials(), bounds);
assert.equal(sample(field, [1, 0, 0]).blockLevel, 12);
const mats = materials();
mats.set(7, { ...mats.get(7), light_dampening: 3 });
assert.equal(sample(buildBlockLight(blocks, mats, bounds), [1, 0, 0]).blockLevel, 10);
});
test("authoritative unlit state emits zero and recomputation removes stale light", () => {
const blocks = map([[0, 0, 0], 3]);
assert.equal(sample(buildBlockLight(blocks, materials(), bounds), [1, 0, 0]).blockLevel, 14);
blocks.set("0,0,0", 4);
const off = buildBlockLight(blocks, materials(), bounds);
assert.equal(off.sourceCount, 0);
assert.ok(off.blockLevels.every((level) => level === 0));
assert.ok(off.data.every((value) => value === 0));
blocks.delete("0,0,0");
assert.equal(sample(buildBlockLight(blocks, materials(), bounds), [1, 0, 0]).blockLevel, 0);
blocks.set("0,0,0", 3);
assert.equal(sample(buildBlockLight(blocks, materials(), bounds), [1, 0, 0]).blockLevel, 14);
});
test("torch and soul colors retain their tint at distance without colored radius shifts", () => {
const warm = buildBlockLight(map([[0, 0, 0], 2]), materials(), bounds);
const cyan = buildBlockLight(map([[0, 0, 0], 5]), materials(), bounds);
const close = sample(warm, [1, 0, 0]).block, far = sample(warm, [8, 0, 0]).block;
assert.ok(close[0] > close[1] && close[1] > close[2]);
assert.ok(far[0] > far[1] && far[1] > far[2] && far[2] > 0);
assert.ok(Math.abs(close[1] / close[0] - far[1] / far[0]) < 0.02);
const soul = sample(cyan, [1, 0, 0]).block;
assert.ok(soul[2] > soul[1] && soul[1] > soul[0]);
assert.deepEqual(blockEmission({ state: "minecraft:torch" }).level, 0);
const lampTint = blockEmission(lamp).color;
assert.ok(lampTint[1] > 0.7, "a redstone lamp casts warm white light, not red torch light");
});
test("multiple lights combine by maximum level, never by additive strength", () => {
const mats = materials();
mats.set(5, { ...mats.get(5), light: 14 });
const field = buildBlockLight(map([[-2, 0, 0], 2], [[2, 0, 0], 5]), mats, bounds);
assert.equal(field.sourceCount, 2);
assert.equal(sample(field, [0, 0, 0]).blockLevel, 12);
const middle = sample(field, [0, 0, 0]).block;
assert.ok(middle[0] > 0.4 && middle[2] > 0.4);
assert.equal(Math.max(...field.blockLevels), 14);
});
test("direct sky stays 15 down clear columns and a full roof leaves darkness below", () => {
const open = buildBlockLight(new Map(), materials(), bounds);
assert.ok(open.skyLevels.every((level) => level === 15));
const blocks = new Map();
for (let x = -16; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 1);
const covered = buildBlockLight(blocks, materials(), bounds);
assert.equal(sample(covered, [0, 3, 0]).skyLevel, 15);
assert.equal(sample(covered, [0, 2, 0]).skyLevel, 0);
assert.equal(sample(covered, [0, 1, 0]).skyLevel, 0);
assert.equal(sample(covered, [0, -3, 0]).skyLevel, 0);
});
test("sky spreads laterally under an overhang with one level of decay per cell", () => {
const blocks = new Map();
for (let x = 0; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 1);
const field = buildBlockLight(blocks, materials(), bounds);
assert.equal(sample(field, [-1, 1, 0]).skyLevel, 15);
assert.equal(sample(field, [0, 1, 0]).skyLevel, 14);
assert.equal(sample(field, [1, 1, 0]).skyLevel, 13);
// The open exterior column remains level 15 at this height as well.
assert.equal(sample(field, [2, 0, 0]).skyLevel, 12);
});
test("slab face geometry blocks downwards sky despite its zero dampening", () => {
const mats = materials(), slab = { min: [0, 0, 0], max: [1, 0.5, 1] };
mats.set(8, { state: "minecraft:stone_slab[type=bottom]", render: [slab], light: 0, light_dampening: 0, light_occlusion: [slab] });
const blocks = new Map();
for (let x = -16; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 8);
const field = buildBlockLight(blocks, mats, bounds);
assert.equal(sample(field, [0, 2, 0]).skyLevel, 15);
assert.equal(sample(field, [0, 1, 0]).skyLevel, 0);
});
test("complementary slab faces close their shared boundary exactly", () => {
const bottom = { min: [0, 0, 0], max: [1, 0.5, 1] }, top = { min: [0, 0.5, 0], max: [1, 1, 1] };
const mats = materials();
mats.set(8, { state: "minecraft:stone_slab[type=bottom]", render: [bottom], light: 0, light_dampening: 0, light_occlusion: [bottom] });
mats.set(9, { state: "minecraft:stone_slab[type=top]", render: [top], light: 0, light_dampening: 0, light_occlusion: [top] });
const narrow = { min: [-2, 0, 0], max: [2, 0, 0] };
const closed = buildBlockLight(map([[-2, 0, 0], 2], [[-1, 0, 0], 8], [[0, 0, 0], 9]), mats, narrow);
assert.equal(sample(closed, [-1, 0, 0]).blockLevel, 13);
assert.equal(sample(closed, [0, 0, 0]).blockLevel, 0);
assert.equal(sample(closed, [1, 0, 0]).blockLevel, 0);
const open = buildBlockLight(map([[-2, 0, 0], 2], [[-1, 0, 0], 8], [[0, 0, 0], 8]), mats, narrow);
assert.equal(sample(open, [1, 0, 0]).blockLevel, 11);
});
test("negative coordinates and chunk boundaries preserve level and indexing", () => {
const region = { min: [-20, -2, -2], max: [20, 2, 2] };
const field = buildBlockLight(map([[-16, 0, 0], 3], [[16, 0, 0], 3]), materials(), region);
assert.equal(sample(field, [-17, 0, 0]).blockLevel, 14);
assert.equal(sample(field, [-15, 0, 0]).blockLevel, 14);
assert.equal(sample(field, [15, 0, 0]).blockLevel, 14);
assert.equal(sample(field, [17, 0, 0]).blockLevel, 14);
assert.deepEqual(field.sample([-21, 0, 0]), { block: [0, 0, 0], sky: 0, blockLevel: 0, skyLevel: 0 });
});
test("face samples use the outward hemisphere and cannot pull light through a solid roof", () => {
const blocks = map([[0, 3, 0], 3]);
for (let x = -16; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 1);
const field = buildBlockLight(blocks, materials(), bounds);
const bottom = [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]];
const underside = field.sampleFace([0, 2, 0], unitBox, [0, -1, 0], bottom);
assert.deepEqual(underside.block, [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]);
assert.deepEqual(underside.sky, [0, 0, 0, 0]);
const top = field.sampleFace([1, 2, 0], unitBox, [0, 1, 0], [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]]);
assert.ok(top.block.some((rgb) => rgb[0] > 0));
assert.ok(top.sky.every((value) => value === 1));
});
test("worker transfer can restore sampling without propagating the field again", () => {
const blocks = map([[0, 0, 0], 2]), mats = materials();
const field = buildBlockLight(blocks, mats, bounds);
const raw = { min: field.min, size: field.size, data: field.data, blockLevels: field.blockLevels, skyLevels: field.skyLevels, sourceCount: field.sourceCount };
const transferred = structuredClone(raw, { transfer: [raw.data.buffer, raw.blockLevels.buffer, raw.skyLevels.buffer] });
const hydrated = attachBlockLightSamplers(transferred, blocks, mats);
assert.equal(sample(hydrated, [1, 0, 0]).blockLevel, 13);
assert.ok(sample(hydrated, [1, 0, 0]).block[0] > 0);
});
test("invalid or excessive bounds fail before allocating a field", () => {
for (const invalid of [null, { min: [0, 0, 0], max: [1, 1, 0.5] },
{ min: [1, 0, 0], max: [0, 1, 1] }, { min: [0, 0, 0], max: [1024, 1024, 1024] }])
assert.throws(() => buildBlockLight(new Map(), materials(), invalid), RangeError);
const field = buildBlockLight(new Map(), materials(), { min: [0, 0, 0], max: [0, 0, 0] });
assert.equal(field.data.length, 4);
assert.equal(field.skyLevels[0], 15);
});
test("78 directional crossings match measured original Java 26.2 attenuation", () => {
const fixture = JSON.parse(readFileSync(new URL("./fixtures/lighting-java26.2.json", import.meta.url)));
const examples = new Map(fixture.examples.map((entry) => [entry.state, entry]));
const directions = { east: [1, 0, 0], west: [-1, 0, 0], up: [0, 1, 0], down: [0, -1, 0], south: [0, 0, 1], north: [0, 0, -1] };
const measured = (state, light) => {
const entry = examples.get(state);
return { state, light, render: [], light_dampening: entry.dampening,
light_occlusion: entry.occlusion.map((box) => ({ min: box.slice(0, 3), max: box.slice(3, 6) })) };
};
assert.equal(fixture.crossings.length, 78);
for (const crossing of fixture.crossings) {
const target = directions[crossing.direction];
const mats = new Map([[1, measured(crossing.from, 15)], [2, measured(crossing.to, 0)]]);
const field = buildBlockLight(map([[0, 0, 0], 1], [target, 2]), mats,
{ min: target.map((value) => Math.min(0, value)), max: target.map((value) => Math.max(0, value)) });
assert.equal(sample(field, target).blockLevel, Math.max(0, 15 - crossing.block_dampening),
`${crossing.from} -> ${crossing.to}, ${crossing.direction}`);
}
});
+106
View File
@@ -0,0 +1,106 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getBlockFaceTextures, getFaceUV } from "../block-textures.js";
const names = ["stone", "cobblestone", "oak_planks", "oak_log", "oak_log_top", "dirt", "grass_block_top", "grass_block_side", "sand", "gravel", "bricks", "stone_bricks", "glass", "oak_leaves", "diamond_ore", "iron_ore", "coal_ore", "gold_ore", "redstone_ore", "deepslate", "obsidian", "snow", "netherrack", "oak_door_bottom"];
const layers = new Map(names.map((name, index) => [name, index]));
const textureNames = (state, available = layers) =>
getBlockFaceTextures(state, available).map((face) => face ? names[face.layer] : null);
test("partial packs keep missing and unrelated materials on the fallback path", () => {
for (const state of ["minecraft:spruce_planks", "minecraft:stripped_oak_log[axis=y]", "minecraft:mossy_cobblestone", "minecraft:deepslate_diamond_ore", "mod:stone", "minecraft:oak_trapdoor", "minecraft:stonecutter", "", undefined]) {
assert.deepEqual(textureNames(state), Array(6).fill(null), String(state));
}
assert.deepEqual(textureNames("minecraft:stone", new Map()), Array(6).fill(null));
assert.equal(getBlockFaceTextures("minecraft:stone", layers)[0].layer, 0);
assert.deepEqual(textureNames("minecraft:stone", new Map([["stone", -1]])), Array(6).fill(null));
});
test("material variants use their own surface without broad substring matching", () => {
for (const [state, texture] of [["oak_stairs[facing=east,half=bottom]", "oak_planks"], ["cobblestone_wall[north=low]", "cobblestone"], ["stone_brick_slab[type=top]", "stone_bricks"], ["brick_stairs", "bricks"], ["snow_block", "snow"]]) {
assert.deepEqual(textureNames(`minecraft:${state}`), Array(6).fill(texture));
}
});
test("grass uses a tinted top, full-color sides and a dirt bottom independently", () => {
assert.deepEqual(textureNames("minecraft:grass_block[snowy=false]"), ["grass_block_side", "grass_block_side", "grass_block_top", "dirt", "grass_block_side", "grass_block_side"]);
const faces = getBlockFaceTextures("minecraft:grass_block", layers);
assert.ok(faces[2].tint[1] > faces[2].tint[0] && faces[2].tint[0] > faces[2].tint[2]);
assert.deepEqual(faces[0].tint, [1, 1, 1]);
assert.equal(faces[2].cutout, false);
assert.deepEqual(textureNames("minecraft:grass_block[snowy=true]"), [null, null, "grass_block_top", "dirt", null, null]);
const missingSide = new Map(layers);
missingSide.delete("grass_block_side");
assert.deepEqual(textureNames("minecraft:grass_block", missingSide), [null, null, "grass_block_top", "dirt", null, null]);
});
test("log end grain follows every axis while oak wood remains bark on all faces", () => {
for (const [axis, caps] of [["x", [0, 1]], ["y", [2, 3]], ["z", [4, 5]]]) {
assert.deepEqual(textureNames(`minecraft:oak_log[axis=${axis}]`), Array.from({ length: 6 }, (_, face) => caps.includes(face) ? "oak_log_top" : "oak_log"));
}
assert.deepEqual(textureNames("minecraft:oak_log"), textureNames("minecraft:oak_log[axis=y]"));
assert.deepEqual(textureNames("minecraft:oak_wood[axis=x]"), Array(6).fill("oak_log"));
});
test("cutout textures preserve full-color glass and tint grayscale leaves", () => {
const glass = getBlockFaceTextures("minecraft:glass", layers);
const leaves = getBlockFaceTextures("minecraft:oak_leaves[persistent=true]", layers);
assert.ok(glass.every((face) => face.cutout));
assert.ok(leaves.every((face) => face.cutout));
assert.deepEqual(glass[0].tint, [1, 1, 1]);
assert.ok(leaves[0].tint[1] > leaves[0].tint[0] && leaves[0].tint[0] > leaves[0].tint[2]);
});
test("doors use lower artwork only on the lower broad faces, respecting open rotation", () => {
const alongZ = ["oak_planks", "oak_planks", "oak_planks", "oak_planks", "oak_door_bottom", "oak_door_bottom"];
const alongX = ["oak_door_bottom", "oak_door_bottom", "oak_planks", "oak_planks", "oak_planks", "oak_planks"];
for (const facing of ["north", "south", "east", "west"]) {
const x = ["east", "west"].includes(facing);
assert.deepEqual(textureNames(`minecraft:oak_door[half=lower,facing=${facing},open=false]`), x ? alongX : alongZ);
assert.deepEqual(textureNames(`minecraft:oak_door[half=lower,facing=${facing},open=true]`), x ? alongZ : alongX);
assert.deepEqual(textureNames(`minecraft:oak_door[half=upper,facing=${facing},open=false]`), Array(6).fill(null));
}
});
test("face UVs orient a top-left source consistently from outside each cube face", () => {
const corners = [
[[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]],
[[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
[[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]],
[[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]],
[[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
[[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]],
];
for (let face = 0; face < 6; face++) {
assert.deepEqual(corners[face].map((pos) => getFaceUV(face, pos)), [[0, 1], [1, 1], [1, 0], [0, 0]]);
}
assert.deepEqual(getFaceUV(4, [0.25, 0.5, 1]), [0.25, 0.5]);
assert.deepEqual(getFaceUV(0, [1, 1.5, 0.25]), [0.75, -0.5]);
assert.throws(() => getFaceUV(6, [0, 0, 0]), RangeError);
});
test("horizontal log bark follows the log axis through a proper cube rotation", () => {
const vertical = [0.25, 0.75, 0.125];
// Rotate the same point and normal with the complete cube, then compare UVs.
const horizontalX = [vertical[1], 1 - vertical[0], vertical[2]];
const horizontalZ = [vertical[0], 1 - vertical[2], vertical[1]];
const faceFromVerticalX = [3, 2, 0, 1, 4, 5];
const faceFromVerticalZ = [0, 1, 4, 5, 3, 2];
for (const block of ["oak_log", "oak_wood"]) {
for (let face = 0; face < 6; face++) {
const expected = getFaceUV(face, vertical);
assert.deepEqual(getFaceUV(faceFromVerticalX[face], horizontalX, `minecraft:${block}[axis=x]`), expected);
assert.deepEqual(getFaceUV(faceFromVerticalZ[face], horizontalZ, `minecraft:${block}[axis=z]`), expected);
assert.deepEqual(getFaceUV(face, vertical, `minecraft:${block}[axis=y]`), expected);
}
}
for (const [axis, sideFaces] of [["x", [2, 3, 4, 5]], ["z", [0, 1, 2, 3]]]) {
for (const face of sideFaces) {
const point = [0.25, 0.5, 0.75];
const coordinate = axis === "x" ? point[0] : point[2];
assert.equal(getFaceUV(face, point, `minecraft:oak_log[axis=${axis}]`)[1], 1 - coordinate);
}
}
assert.deepEqual(getFaceUV(4, vertical, "minecraft:deepslate[axis=x]"), getFaceUV(4, vertical));
assert.deepEqual(getFaceUV(4, vertical, "mod:oak_log[axis=x]"), getFaceUV(4, vertical));
});
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {DynamicsDiagnostics} from '../dynamics-diagnostics.js';
const sample=(x=0)=>({position:[x,77,0],yaw:0,frameMs:10,physicsMs:1,drawMs:2,correction:0,waiting:false,dirty:0,viewChanges:x?2:0,batches:x?3:0,meshResets:1,loadedSections:245,totalSections:245,voxelBytes:4014080});
test('diagnostics exclude warmup, record movement and calculate bounded summaries',()=>{
const d=new DynamicsDiagnostics();d.arm('flight',5);const t=d.armed;
d.ready(sample(),false,t+100);d.frame(sample(),t+200);assert.equal(d.frames.length,0);
d.ready(sample(),true,t+500);d.ready(sample(),true,t+1001);assert.equal(d.status,'recording');
d.frame(sample(),t+1100);d.frame({...sample(16),frameMs:40},t+6100);
assert.equal(d.status,'done');assert.equal(d.result.frames,2);assert.equal(d.result.frameMs.max,40);assert.equal(d.result.over25ms,1);assert.equal(d.result.distance,16);assert.equal(d.result.meshResets,0);assert.equal(d.result.viewChanges,2);
});
test('automatic flight sends one toggle, climbs obstacles, and stops yielding input after recording',()=>{
const d=new DynamicsDiagnostics();d.arm('flight');d.status='recording';d.started=0;d.yaw=0;
assert.equal(d.controls(false,false,0).fly_toggle,true);assert.equal(d.controls(true,false,0).fly_toggle,true);assert.equal(d.controls(true,false,50).fly_toggle,false);
assert.equal(d.controls(true,true,5000,true).jump,true);assert.equal(d.controls(false,true,5500).jump,true);assert.equal(d.controls(false,true,6100).jump,false);
d.status='done';assert.equal(d.controls(true,true,6200),null);
});
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {EditDiagnostics} from '../edit-diagnostics.js';
test('edit timings separate round trip and mesh publication, and ignore unrelated changes',()=>{
const d=new EditDiagnostics();d.request([16,2,-1],0,10);
d.confirmed([{pos:[16,2,-1],block:2}],20);
assert.equal(d.published('1,0,-1',30),null);
d.confirmed([{pos:[16,2,-1],block:0}],25);
assert.equal(d.published('0,0,-1',40),null);
assert.deepEqual(d.published('1,0,-1',45),{acknowledgementMs:15,geometryMs:20,totalMs:35});
assert.equal(d.published('1,0,-1',50),null);
});
test('expired requests and world resets cannot produce misleading edit timings',()=>{
const d=new EditDiagnostics();d.request([0,0,0],1,0);
d.confirmed([{pos:[0,0,0],block:1}],6000);
assert.equal(d.published('0,0,0',6001),null);
d.request([0,0,0],1,6002);d.confirmed([{pos:[0,0,0],block:1}],6003);d.reset();
assert.equal(d.published('0,0,0',6004),null);assert.equal(d.last,null);
});
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {SectionVoxelMap} from '../section-voxel-map.js';
import {captureEditMesh,buildEditMesh} from '../edit-mesh.js';
import {EditMeshController} from '../edit-mesh-controller.js';
import {TerrainState} from '../terrain-state.js';
const properties=JSON.parse(await readFile(new URL('../light-properties.json',import.meta.url)));
const cube={min:[0,0,0],max:[1,1,1]};
const materials=new Map([[1,{state:'minecraft:stone',color:[128,128,128],render:[cube]}],[2,{state:'minecraft:sea_lantern',color:[230,245,250],render:[cube],light:15}],[3,{state:'minecraft:glass',color:[160,210,230],render:[cube],opacity:0.3}]]);
test('local edit geometry matches full-world meshing at boundaries and distant coordinates without scanning the world',()=>{
for(const base of [0,-32,29999840]) {
const state=new TerrainState(properties),initial=new Int32Array([base+15,2,0,1,base+16,2,0,1,base+17,2,0,2,base+16,3,0,3]);
state.sync({epoch:1,version:1,reset:true,compact:true,initial,materials:[...materials],textures:[],bounds:{min:[base-2,-2,-3],max:[base+32,6,3]}});state.light();
// The bulk light calculation is deliberately not run after this removal.
state.blocks.delete(`${base+15},2,0`);
const current=state.mesh({epoch:1,version:1,id:`${base/16+1},0,0`,job:1});
state.blocks[Symbol.iterator]=()=>{throw Error('No global iteration permitted');};
state.blocks.sectionEntries=()=>{throw Error('No global section iteration permitted');};
const packet=captureEditMesh(current.id,state.blocks,state.materials,state.textureLayers,state.field);
assert.ok(packet.sections.length<=27);
assert.ok(packet.light.data.length<=24**3*4);
const before=state.blocks.getSectionCells(current.id).byteLength;
const transfers=[...packet.sections.map(s=>s.cells.buffer),packet.light.data.buffer,packet.light.blockLevels.buffer,packet.light.skyLevels.buffer];
const transferred=structuredClone(packet,{transfer:transfers});
assert.equal(state.blocks.getSectionCells(current.id).byteLength,before,'live voxel buffers are never detached');
assert.ok(state.field.data.byteLength>0,'live lighting buffers remain usable');
const mesh=buildEditMesh(transferred,properties);
assert.deepEqual(mesh.vertices,current.vertices);
assert.deepEqual(mesh.transparent,current.transparent);
assert.deepEqual(mesh.origin,current.origin);
}
});
test('breaks and placements rebuild current geometry while full lighting is still pending',()=>{
const blocks=new SectionVoxelMap();blocks.set('15,2,0',1);blocks.set('16,2,0',1);
const mesh=()=>buildEditMesh(captureEditMesh('1,0,0',blocks,materials,new Map(),null),properties);
const before=mesh();blocks.delete('15,2,0');const removed=mesh();
assert.equal(removed.vertices.length,before.vertices.length+120,'the adjacent face appears');
blocks.set('15,2,0',2);assert.equal(mesh().vertices.length,before.vertices.length);
const placed=buildEditMesh(captureEditMesh('0,0,0',blocks,materials,new Map(),null),properties);
assert.ok(placed.vertices.length>0);
assert.equal(placed.vertices[15],1,'lamp emission is immediately visible');
});
class Worker {
packets=[]; postMessage(packet,transfers){this.packets.push(structuredClone(packet,{transfer:transfers}));}
terminate(){this.terminated=true;}
reply(packet){this.onmessage({data:{...packet,vertices:new Float32Array(),transparent:new Float32Array(),preview:true}});}
}
function fixture(){const worker=new Worker(),captured=[];
const controller=new EditMeshController({workerFactory:()=>worker,capture:id=>{captured.push(id);return {id,sections:[],materials:[],textures:[],light:null};}});
return {worker,controller,captured};}
test('independent edit queue is bounded, coalesces newer edits and rejects stale/reset/unloaded results',()=>{
const {worker,controller:c}=fixture();
c.request(['0,0,0']);c.pump();const old=worker.packets[0];
c.request(['0,0,0']);worker.reply(old);assert.equal(c.ready.size,0);
c.pump();const latest=worker.packets[1];worker.reply(latest);assert.equal(c.ready.size,1);
c.request(['1,0,0','2,0,0']);c.pump();worker.reply(worker.packets.at(-1));c.pump();
assert.equal(c.ready.size,2);assert.equal(worker.packets.length,3,'two results cap memory before upload');
c.cancel('0,0,0');assert.equal(c.isCurrent(latest),false);c.pump();const unloaded=worker.packets.at(-1);
c.cancel('2,0,0');worker.reply(unloaded);assert.equal(c.ready.has('2,0,0'),false);
c.request(['3,0,0']);c.pump();const reset=worker.packets.at(-1);c.reset();worker.reply(reset);
assert.equal(c.ready.size,0);assert.equal(c.pending.size,0);
c.dispose();assert.equal(worker.terminated,true);
});
test('a ready edit stays valid across unrelated terrain/light work until its own geometry changes',()=>{
const {worker,controller:c}=fixture();c.request(['0,0,0']);c.pump();worker.reply(worker.packets[0]);
const result=c.ready.get('0,0,0');c.request(['8,0,0']);
assert.equal(c.isCurrent(result),true);c.request(['0,0,0']);assert.equal(c.isCurrent(result),false);
c.dispose();
});
+984
View File
@@ -0,0 +1,984 @@
{
"version": "26.2",
"examples": [
{
"minecraft_id": 0,
"state": "minecraft:air",
"emission": 0,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 1,
"state": "minecraft:stone",
"emission": 0,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 562,
"state": "minecraft:glass",
"emission": 0,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 27161,
"state": "minecraft:tinted_glass",
"emission": 0,
"dampening": 15,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 7098,
"state": "minecraft:white_stained_glass",
"emission": 0,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 8331,
"state": "minecraft:glass_pane[east=false,north=false,south=false,waterlogged=false,west=false]",
"emission": 0,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 6927,
"state": "minecraft:ice",
"emission": 0,
"dampening": 1,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 12914,
"state": "minecraft:packed_ice",
"emission": 0,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 15275,
"state": "minecraft:blue_ice",
"emission": 0,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 86,
"state": "minecraft:water[level=0]",
"emission": 0,
"dampening": 1,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 102,
"state": "minecraft:lava[level=0]",
"emission": 15,
"dampening": 1,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 279,
"state": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]",
"emission": 0,
"dampening": 1,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 531,
"state": "minecraft:azalea_leaves[distance=7,persistent=false,waterlogged=false]",
"emission": 0,
"dampening": 1,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 13333,
"state": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"emission": 0,
"dampening": 0,
"can_occlude": true,
"use_shape": true,
"propagates_skylight_down": true,
"occlusion": [
[
0.0,
0.0,
0.0,
1.0,
0.5,
1.0
]
]
},
{
"minecraft_id": 3918,
"state": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]",
"emission": 0,
"dampening": 0,
"can_occlude": true,
"use_shape": true,
"propagates_skylight_down": true,
"occlusion": [
[
0.0,
0.0,
0.0,
1.0,
0.5,
1.0
],
[
0.0,
0.5,
0.0,
1.0,
1.0,
0.5
]
]
},
{
"minecraft_id": 7129,
"state": "minecraft:oak_trapdoor[facing=north,half=bottom,open=false,powered=false,waterlogged=false]",
"emission": 0,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 5666,
"state": "minecraft:oak_door[facing=north,half=lower,hinge=left,open=false,powered=false]",
"emission": 0,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 6919,
"state": "minecraft:snow[layers=1]",
"emission": 0,
"dampening": 0,
"can_occlude": true,
"use_shape": true,
"propagates_skylight_down": true,
"occlusion": [
[
0.0,
0.0,
0.0,
1.0,
0.125,
1.0
]
]
},
{
"minecraft_id": 6928,
"state": "minecraft:snow_block",
"emission": 0,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 3370,
"state": "minecraft:torch",
"emission": 14,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 3371,
"state": "minecraft:wall_torch[facing=north]",
"emission": 14,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 6885,
"state": "minecraft:redstone_torch[lit=true]",
"emission": 7,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 7006,
"state": "minecraft:soul_torch",
"emission": 10,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 7016,
"state": "minecraft:glowstone",
"emission": 15,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 12892,
"state": "minecraft:sea_lantern",
"emission": 15,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 9480,
"state": "minecraft:redstone_lamp[lit=false]",
"emission": 0,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 20840,
"state": "minecraft:lantern[hanging=false,waterlogged=false]",
"emission": 15,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 7023,
"state": "minecraft:jack_o_lantern[facing=north]",
"emission": 15,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 12566,
"state": "minecraft:light[level=15,waterlogged=false]",
"emission": 15,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 27164,
"state": "minecraft:sculk_sensor[power=0,sculk_sensor_phase=inactive,waterlogged=false]",
"emission": 1,
"dampening": 0,
"can_occlude": true,
"use_shape": true,
"propagates_skylight_down": true,
"occlusion": [
[
0.0,
0.0,
0.0,
1.0,
0.5,
1.0
]
]
},
{
"minecraft_id": 29539,
"state": "minecraft:copper_bulb[lit=false,powered=false]",
"emission": 0,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 29521,
"state": "minecraft:copper_grate[waterlogged=false]",
"emission": 0,
"dampening": 0,
"can_occlude": false,
"use_shape": false,
"propagates_skylight_down": true,
"occlusion": []
},
{
"minecraft_id": 9479,
"state": "minecraft:redstone_lamp[lit=true]",
"emission": 15,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 13331,
"state": "minecraft:oak_slab[type=top,waterlogged=false]",
"emission": 0,
"dampening": 0,
"can_occlude": true,
"use_shape": true,
"propagates_skylight_down": true,
"occlusion": [
[
0.0,
0.5,
0.0,
1.0,
1.0,
1.0
]
]
},
{
"minecraft_id": 13335,
"state": "minecraft:oak_slab[type=double,waterlogged=false]",
"emission": 0,
"dampening": 15,
"can_occlude": true,
"use_shape": false,
"propagates_skylight_down": false,
"occlusion": []
},
{
"minecraft_id": 13332,
"state": "minecraft:oak_slab[type=bottom,waterlogged=true]",
"emission": 0,
"dampening": 1,
"can_occlude": true,
"use_shape": true,
"propagates_skylight_down": false,
"occlusion": [
[
0.0,
0.0,
0.0,
1.0,
0.5,
1.0
]
]
}
],
"crossings": [
{
"from": "minecraft:air",
"to": "minecraft:stone",
"direction": "down",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:stone",
"direction": "up",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:stone",
"direction": "north",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:stone",
"direction": "south",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:stone",
"direction": "west",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:stone",
"direction": "east",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:glass",
"direction": "down",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:glass",
"direction": "up",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:glass",
"direction": "north",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:glass",
"direction": "south",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:glass",
"direction": "west",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:glass",
"direction": "east",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:tinted_glass",
"direction": "down",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:tinted_glass",
"direction": "up",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:tinted_glass",
"direction": "north",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:tinted_glass",
"direction": "south",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:tinted_glass",
"direction": "west",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:tinted_glass",
"direction": "east",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:ice",
"direction": "down",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:ice",
"direction": "up",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:ice",
"direction": "north",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:ice",
"direction": "south",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:ice",
"direction": "west",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:ice",
"direction": "east",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:water[level=0]",
"direction": "down",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:water[level=0]",
"direction": "up",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:water[level=0]",
"direction": "north",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:water[level=0]",
"direction": "south",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:water[level=0]",
"direction": "west",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:water[level=0]",
"direction": "east",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]",
"direction": "down",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]",
"direction": "up",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]",
"direction": "north",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]",
"direction": "south",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]",
"direction": "west",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]",
"direction": "east",
"block_dampening": 1,
"sky_column_dampening": 1
},
{
"from": "minecraft:air",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "down",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "up",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:air",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "north",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "south",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "west",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "east",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]",
"direction": "down",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]",
"direction": "up",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:air",
"to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]",
"direction": "north",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]",
"direction": "south",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:air",
"to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]",
"direction": "west",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]",
"direction": "east",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:air",
"to": "minecraft:glowstone",
"direction": "down",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:glowstone",
"direction": "up",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:glowstone",
"direction": "north",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:glowstone",
"direction": "south",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:glowstone",
"direction": "west",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:glowstone",
"direction": "east",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:sea_lantern",
"direction": "down",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:sea_lantern",
"direction": "up",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:sea_lantern",
"direction": "north",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:sea_lantern",
"direction": "south",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:sea_lantern",
"direction": "west",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:air",
"to": "minecraft:sea_lantern",
"direction": "east",
"block_dampening": 15,
"sky_column_dampening": 15
},
{
"from": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"to": "minecraft:oak_slab[type=top,waterlogged=false]",
"direction": "down",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:oak_slab[type=top,waterlogged=false]",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "down",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:glowstone",
"to": "minecraft:air",
"direction": "down",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"to": "minecraft:oak_slab[type=top,waterlogged=false]",
"direction": "up",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:oak_slab[type=top,waterlogged=false]",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "up",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:glowstone",
"to": "minecraft:air",
"direction": "up",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"to": "minecraft:oak_slab[type=top,waterlogged=false]",
"direction": "north",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:oak_slab[type=top,waterlogged=false]",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "north",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:glowstone",
"to": "minecraft:air",
"direction": "north",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"to": "minecraft:oak_slab[type=top,waterlogged=false]",
"direction": "south",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:oak_slab[type=top,waterlogged=false]",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "south",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:glowstone",
"to": "minecraft:air",
"direction": "south",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"to": "minecraft:oak_slab[type=top,waterlogged=false]",
"direction": "west",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:oak_slab[type=top,waterlogged=false]",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "west",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:glowstone",
"to": "minecraft:air",
"direction": "west",
"block_dampening": 1,
"sky_column_dampening": 0
},
{
"from": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"to": "minecraft:oak_slab[type=top,waterlogged=false]",
"direction": "east",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:oak_slab[type=top,waterlogged=false]",
"to": "minecraft:oak_slab[type=bottom,waterlogged=false]",
"direction": "east",
"block_dampening": 16,
"sky_column_dampening": 16
},
{
"from": "minecraft:glowstone",
"to": "minecraft:air",
"direction": "east",
"block_dampening": 1,
"sky_column_dampening": 0
}
],
"measurement_scope": "Public original Java 26.2 block-state light properties and LightEngine.getLightDampeningInto calls. Effective shape is empty unless canOcclude and useShapeForLightOcclusion are both true. Crossings measure directional shape blocking and target attenuation, not a full running light engine or skylight source map.",
"provenance": {
"source_url": "https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar",
"source_sha1": "823e2250d24b3ddac457a60c92a6a941943fcd6a",
"source_sha256": "cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5",
"executable_sha256": "183c0499c5f855570ee487dd38e141a53f0121f83a0b07a3bac2d8b6698823e8",
"probe_sha256": "8663e2f81af754fa4063a6f0a109fa27b8c1fae06a5a393d7ca42ac243a252bb",
"command": "python3 scripts/measure_lighting.py --java /path/to/java25/bin/java"
}
}
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {SectionVoxelMap} from '../section-voxel-map.js';
import {TerrainController} from '../terrain-controller.js';
import {TerrainState} from '../terrain-state.js';
import {buildBlockLight} from '../block-light.js';
import {buildSectionMesh} from '../mesh-geometry.js';
test('authoritative columns keep a cave dark when the roof is above the loaded vertical window',()=>{
const world=new SectionVoxelMap();world.setSection('0,0,0',new Uint32Array(4096));
const bounds={min:[0,0,0],max:[15,15,15]},columns=new Map([['0,0',new Int16Array(256).fill(30)]]);
assert.equal(buildBlockLight(world,new Map(),bounds,columns).sample([8,8,8]).skyLevel,0);
columns.get('0,0').fill(-1);
assert.equal(buildBlockLight(world,new Map(),bounds,columns).sample([8,8,8]).skyLevel,15);
});
test('compact controller transfers section copies and preserves the main collision buffers',()=>{
const packets=[],worker={postMessage(p,transfers){packets.push(structuredClone(p,{transfer:transfers}));},terminate(){}};
const c=new TerrainController({workerFactory:()=>worker}),world=new SectionVoxelMap();
const cells=new Uint32Array(4096).fill(3);world.setSection('0,0,0',cells);
const materials=new Map([[3,{state:'minecraft:stone',color:[128,128,128]}]]);
c.reset(world,materials,new Map(),{min:[0,0,0],max:[15,15,15]});c.flush();
assert.equal(cells.byteLength,16384);assert.equal(packets[0].initial,null);assert.equal(packets[0].compact,true);assert.equal(packets[0].sections[0].cells[0],3);
const state=new TerrainState(null);state.sync(packets[0]);assert.equal(state.blocks.getAt(0,0,0),3);
c.update({unload:[[0,0,0]]});c.update({sections:[{section:[0,0,0],cells:new Uint32Array(4096)}]});c.flush();state.sync(packets[1]);
assert.equal(state.blocks.hasSection('0,0,0'),true);assert.equal(state.blocks.size,0);c.dispose();
});
test('section-local mesh coordinates retain fractional geometry near the world border',()=>{
const world=new SectionVoxelMap(),x=29_999_840;world.set(`${x},0,0`,1);
const materials=new Map([[1,{state:'custom:partial',color:[128,128,128],render:[{min:[.125,.25,.125],max:[.875,.75,.875]}]}]]);
const result=buildSectionMesh({id:`${x/16},0,0`,blocks:world,materials,localCoordinates:true});
assert.deepEqual(result.origin,[x,0,0]);
const xs=[];for(let i=0;i<result.vertices.length;i+=20)xs.push(result.vertices[i]);
assert.equal(Math.min(...xs),.125);assert.equal(Math.max(...xs),.875);
});
+69
View File
@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import { changedLightSections } from "../light-changes.js";
function field(min = [-2, -2, -2], size = [54, 36, 36]) {
const count = size.reduce((product, value) => product * value, 1);
return {
min,
size,
data: new Uint8Array(count * 4),
skyLevels: new Uint8Array(count).fill(15),
};
}
const index = (field, [x, y, z]) =>
x -
field.min[0] +
field.size[0] * (y - field.min[1] + field.size[1] * (z - field.min[2]));
test("initial lighting marks current sections, while equal fields retain every mesh", () => {
const sections = new Map([
["0,0,0", { vao: "first" }],
["1,0,0", { vao: "retained" }],
]);
assert.deepEqual(changedLightSections(null, field(), sections.keys()), [
...sections.keys(),
]);
const before = [...sections.values()];
assert.deepEqual(changedLightSections(field(), field(), sections.keys()), []);
assert.deepEqual([...sections.values()], before);
});
test("a corner light delta invalidates all eight sharing neighborhoods but no distant section", () => {
const previous = field(),
next = field();
next.skyLevels[index(next, [15, 15, 15])] = 14;
const neighbors = [];
for (let x = 0; x <= 1; x++)
for (let y = 0; y <= 1; y++)
for (let z = 0; z <= 1; z++) neighbors.push(`${x},${y},${z}`);
assert.deepEqual(
changedLightSections(previous, next, [...neighbors, "2,0,0"]),
neighbors,
);
});
test("a tint-only change crosses the section boundary and preserves distant retained sections", () => {
const previous = field(),
next = field();
next.data[index(next, [16, 4, 4]) * 4 + 2] = 180;
const sections = ["0,0,0", "1,0,0", "2,0,0"];
assert.deepEqual(changedLightSections(previous, next, sections), [
"0,0,0",
"1,0,0",
]);
});
test("moving field bounds compares world coordinates and only invalidates lost coverage", () => {
const previous = field();
const shifted = field([-1, -2, -2], [55, 36, 36]);
assert.deepEqual(
changedLightSections(previous, shifted, ["0,0,0", "1,0,0"]),
[],
);
const clipped = field([0, -2, -2], [54, 36, 36]);
assert.deepEqual(
changedLightSections(previous, clipped, ["0,0,0", "1,0,0"]),
["0,0,0"],
);
});
+72
View File
@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { applyLightProperties, loadLightProperties } from "../light-properties.js";
const properties = JSON.parse(await readFile(new URL("../light-properties.json", import.meta.url)));
const reference = JSON.parse(await readFile(new URL("./fixtures/lighting-java26.2.json", import.meta.url)));
test("all original measured examples match by Minecraft ID and canonical state", () => {
for (const example of reference.examples) {
for (const material of [{ minecraft_id: example.minecraft_id }, { state: example.state }]) {
const actual = applyLightProperties(material, properties);
assert.equal(actual.light_dampening, example.dampening, example.state);
assert.deepEqual(actual.light_occlusion, example.occlusion.map((box) => ({ min: box.slice(0, 3), max: box.slice(3, 6) })), example.state);
assert.deepEqual(material, "state" in material ? { state: example.state } : { minecraft_id: example.minecraft_id });
}
}
});
test("partial state strings use default values and accept arbitrary property order", () => {
const top = applyLightProperties({ state: "minecraft:oak_slab[type=top]" }, properties);
assert.equal(top.light_dampening, 0);
assert.deepEqual(top.light_occlusion, [{ min: [0, 0.5, 0], max: [1, 1, 1] }]);
const wet = applyLightProperties({ state: "minecraft:oak_slab[waterlogged=true,type=top]" }, properties);
assert.equal(wet.light_dampening, 1);
assert.deepEqual(wet.light_occlusion, top.light_occlusion);
const iron = applyLightProperties({ state: "minecraft:iron_trapdoor[open=false]" }, properties);
assert.equal(iron.light_dampening, 0);
assert.deepEqual(iron.light_occlusion, []);
});
test("custom materials retain their own identity and optional light properties", () => {
const custom = { id: 1, state: "shacraft:trampoline", light: 11, light_dampening: 2, light_occlusion: [] };
const actual = applyLightProperties(custom, properties);
assert.notEqual(actual, custom);
assert.deepEqual(actual, custom);
for (const state of ["minecraft:oak_slab[type=invalid]", "minecraft:stone[missing=true]", "minecraft:oak_slab[type=top,type=bottom]"]) {
assert.deepEqual(applyLightProperties({ state }, properties), { state });
}
assert.deepEqual(applyLightProperties({ id: 1 }, properties), { id: 1 });
const glass = applyLightProperties({ id: 999, minecraft_id: reference.examples.find((e) => e.state === "minecraft:glass").minecraft_id, light: 4 }, properties);
assert.equal(glass.id, 999);
assert.equal(glass.light, 4);
assert.equal(glass.light_dampening, 0);
});
test("every registry state is present and every shape index is valid", () => {
assert.equal(properties.codes.length, 32366);
assert.equal(Object.keys(properties.blocks).length, 1196);
assert.equal(properties.shapes.length, 60);
for (const code of properties.codes) {
assert.ok(Number.isInteger(code) && code >= 0);
assert.ok(properties.shapes[Math.floor(code / 16)]);
}
});
test("the runtime metadata request is shared by concurrent and later callers", async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async (url) => {
calls++;
assert.equal(url.pathname.endsWith("/light-properties.json"), true);
return { ok: true, json: async () => properties };
};
try {
const [first, second] = await Promise.all([loadLightProperties(), loadLightProperties()]);
assert.equal(first, properties);
assert.equal(first, second);
assert.equal(await loadLightProperties(), properties);
assert.equal(calls, 1);
} finally { globalThis.fetch = originalFetch; }
});
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {TerrainState} from '../terrain-state.js';
import {TerrainController} from '../terrain-controller.js';
import {SectionVoxelMap} from '../section-voxel-map.js';
const properties=JSON.parse(await readFile(new URL('../light-properties.json',import.meta.url)));
const cube={min:[0,0,0],max:[1,1,1]};
const materials=new Map([[1,{state:'minecraft:stone',color:[128,128,128],render:[cube]}],[2,{state:'minecraft:sea_lantern',color:[240,245,250],render:[cube],light:15}],[3,{state:'minecraft:water',color:[80,110,240],render:[cube],opacity:0.5}],[4,{state:'minecraft:glass',color:[180,220,240],render:[cube],opacity:0.3}]]);
test('local light meshes match whole-view light with distant sources, a high roof, water and negative coordinates',()=>{
const blocks=new SectionVoxelMap(),bounds={min:[-48,-16,-48],max:[79,47,63]},columns=[];
for(let x=-48;x<=79;x++)for(let z=-48;z<=63;z++)blocks.set(`${x},0,${z}`,1);
for(let x=4;x<13;x++)for(let z=5;z<13;z++)blocks.set(`${x},40,${z}`,1);
for(let y=1;y<40;y++)blocks.set(`10,${y},11`,3);
blocks.set('-14,1,4',2);blocks.set('31,1,8',2);blocks.set('3,1,8',4);
for(let x=-3;x<=4;x++)for(let z=-3;z<=3;z++) {
const heights=new Int16Array(256);
for(let lx=0;lx<16;lx++)for(let lz=0;lz<16;lz++)if(x*16+lx>=4&&x*16+lx<13&&z*16+lz>=5&&z*16+lz<13)heights[lx+16*lz]=40;
columns.push({column:[x,z],heights});
}
const state=new TerrainState(properties);
state.sync({epoch:1,version:1,reset:true,compact:true,sections:[...blocks.sectionEntries()].map(([id,cells])=>({section:id.split(',').map(Number),cells})),materials:[...materials],textures:[],bounds,columns});
state.light();
for(const id of ['0,0,0','-1,0,0','0,1,0']) {
const packet={id,epoch:1,version:1,job:1,meshTicket:1};
const expected=state.mesh(packet),actual=state.meshLocal(packet);
assert.deepEqual(actual.vertices,expected.vertices);assert.deepEqual(actual.transparent,expected.transparent);
assert.ok(actual.lightCells<=68*68*64);
}
assert.equal(state.meshLocal({id:'0,2,0',epoch:1,version:1}).lightCached,true,'vertical sections share their column light');
state.sync({epoch:1,version:2,materials:[],bounds,changes:new Int32Array([70,1,50,2])});
assert.equal(state.meshLocal({id:'0,0,0',epoch:1,version:2}).lightCached,true,'distant edits keep the local field');
state.sync({epoch:1,version:3,materials:[],bounds,changes:new Int32Array([0,1,1,2])});
assert.equal(state.meshLocal({id:'0,0,0',epoch:1,version:3}).lightCached,false,'near edits invalidate light');
state.light();assert.deepEqual(state.meshLocal({id:'0,0,0',epoch:1,version:3}).vertices,state.mesh({id:'0,0,0',epoch:1,version:3}).vertices);
});
test('a roof outside the vertical view stays dark in local meshes and cache size stays bounded',()=>{
const state=new TerrainState(properties),bounds={min:[-144,0,-144],max:[159,79,159]},columns=[];
for(let x=-9;x<=9;x++)for(let z=-9;z<=9;z++)columns.push({column:[x,z],heights:new Int16Array(256).fill(100)});
state.sync({epoch:1,version:1,reset:true,compact:true,initial:new Int32Array([0,1,0,1]),materials:[...materials],bounds,columns});
const result=state.meshLocal({epoch:1,version:1,id:'0,0,0'});
for(let i=19;i<result.vertices.length;i+=20)assert.equal(result.vertices[i],0);
for(let x=-9;x<=9;x++)state.meshLocal({epoch:1,version:1,id:`${x},0,0`});
assert.equal(state.localLights.size,8);
});
class Worker {
packets=[];postMessage(packet,transfers=[]){this.packets.push(structuredClone(packet,{transfer:transfers}));}
terminate(){this.terminated=true;}
reply(data){this.onmessage({data});}
sync(){const p=this.packets.filter(p=>p.type==='sync').at(-1);this.reply({type:'synced',epoch:p.epoch,version:p.version});}
mesh(packet=this.packets.at(-1)){this.reply({...packet,vertices:new Float32Array(20),transparent:new Float32Array()});}
}
function fixture(){const light=new Worker(),mesh=new Worker(),dirty=new Set(),c=new TerrainController({workerFactory:()=>light,meshWorkerFactory:()=>mesh,onDirty:ids=>ids.forEach(id=>dirty.add(id))});
const cells=new Uint32Array(4096).fill(1),blocks=new SectionVoxelMap();blocks.setSection('0,0,0',cells);
c.reset(blocks,materials,new Map(),{min:[-144,-32,-144],max:[159,47,159]});c.flush();mesh.sync();return {light,mesh,c,cells,dirty};}
test('nearby meshing starts before any global light result and survives repeated far chunk packets',()=>{
const {light,mesh,c,cells}=fixture();
try {
assert.equal(c.workerVersion,-1);assert.equal(c.requestMesh('0,0,0'),true);
const job=mesh.packets.at(-1);
for(let i=5;i<9;i++){c.update({sections:[{section:[i,0,i],cells:new Uint32Array(4096)}],materials});c.flush();mesh.sync();}
mesh.mesh(job);const ready=c.ready.get('0,0,0');assert.ok(ready);assert.equal(c.isCurrent(ready),true);
assert.equal(cells.byteLength,16384);
assert.notEqual(light.packets[0].sections[0].cells.buffer,mesh.packets[0].sections[0].cells.buffer);
assert.equal(c.workerVersion,-1,'global lighting is still deliberately unfinished');
c.update({changes:[{pos:[16,0,0],block:2}]});assert.equal(c.ready.size,0);assert.equal(c.isCurrent(ready),false);
c.flush();mesh.sync();assert.equal(c.requestMesh('0,0,0'),true);
const old=mesh.packets.at(-1);c.update({unload:[[0,0,0]]});mesh.mesh(old);assert.equal(c.ready.size,0);
}finally{c.dispose();}
});
test('material, sky-column and world changes reject affected meshes, but repeated definitions do not',()=>{
const {mesh,c}=fixture();try{
c.requestMesh('0,0,0');mesh.mesh();let ready=c.ready.get('0,0,0');
c.update({materials:new Map([...materials].map(([id,m])=>[id,structuredClone(m)]))});assert.equal(c.isCurrent(ready),true);
c.update({columns:[{column:[0,0],heights:new Int16Array(256).fill(100)}]});assert.equal(c.isCurrent(ready),false);
c.flush();mesh.sync();c.requestMesh('0,0,0');mesh.mesh();ready=c.ready.get('0,0,0');
c.update({materials:new Map([[1,{...materials.get(1),color:[12,34,56]}]])});assert.equal(c.isCurrent(ready),false);
c.reset(new SectionVoxelMap(),materials,new Map(),{min:[0,0,0],max:[15,15,15]});assert.equal(c.isCurrent(ready),false);
}finally{c.dispose();}
});
test('unload and re-entry cannot reuse a ticket from an old in-flight mesh',()=>{
const {mesh,c}=fixture();try{
c.requestMesh('0,0,0');const old=mesh.packets.at(-1);
c.update({unload:[[0,0,0]]});c.flush();mesh.sync();
c.update({sections:[{section:[0,0,0],cells:new Uint32Array(4096).fill(2)}]});c.flush();mesh.sync();
mesh.mesh(old);assert.equal(c.ready.size,0);
c.requestMesh('0,0,0');const current=mesh.packets.at(-1);
assert.notEqual(current.meshTicket,old.meshTicket);assert.equal(c.isCurrent(old),false);
mesh.mesh(current);assert.equal(c.isCurrent(c.ready.get('0,0,0')),true);
}finally{c.dispose();}
});
test('horizontal view movement retains interior meshes and invalidates only changed light boundaries',()=>{
const {mesh,c}=fixture();try{
c.requestMesh('0,0,0');mesh.mesh();const near=c.ready.get('0,0,0');c.ready.delete('0,0,0');
c.requestMesh('-9,0,0');mesh.mesh();const edge=c.ready.get('-9,0,0');
c.update({bounds:{min:[-112,-32,-144],max:[191,47,159]}});
assert.equal(c.isCurrent(near),true);assert.equal(c.isCurrent(edge),false);
c.update({bounds:{min:[-112,-16,-144],max:[191,63,159]}});
assert.equal(c.isCurrent(near),false,'moving the top sky boundary can change light throughout a column');
}finally{c.dispose();}
});
test('an obsolete failed mesh releases its job instead of blocking subsequent chunks',()=>{
const {mesh,c}=fixture();try{
c.requestMesh('0,0,0');const old=mesh.packets.at(-1);
c.update({changes:[{pos:[0,0,0],block:2}]});c.flush();mesh.sync();
mesh.reply({...old,type:'error',error:'obsolete work'});
assert.equal(c.busy,null);assert.equal(c.meshWorker,mesh);assert.equal(c.requestMesh('0,0,0'),true);
}finally{c.dispose();}
});
+5
View File
@@ -9,7 +9,12 @@ import {
viewMatrix,
project,
unitBox,
isFullCube,
} from "../math.js";
test("translucent cubes do not hide opaque surfaces behind glass", () => {
assert.equal(isFullCube({ render: [unitBox], opacity: 0.3 }), false);
assert.equal(isFullCube({ render: [unitBox], opacity: 1 }), true);
});
test("camera axes and world projection match the server coordinate convention", () => {
assert.deepEqual(direction(0, 0), [0, 0, -1]);
const east = direction(Math.PI / 2, 0);
+132
View File
@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import { boxVertices, buildSectionMesh, materialType, MESH_VERTEX_STRIDE } from "../mesh-geometry.js";
import { buildBlockLight } from "../block-light.js";
import { unitBox } from "../math.js";
const stone = { state: "minecraft:stone", color: [128, 144, 160], render: [unitBox], opacity: 1, light: 0 };
const materialMap = () => new Map([
[1, stone],
[2, { ...stone, state: "minecraft:water[level=0]", color: [50, 90, 180], opacity: 0.55, transparent: true }],
[3, { ...stone, state: "minecraft:glass", opacity: 0.35, transparent: true }],
[4, { ...stone, state: "minecraft:oak_leaves", transparent: true }],
[5, { ...stone, state: "minecraft:torch", light: 14, render: [], light_dampening: 0, light_occlusion: [] }],
[6, { ...stone, state: "minecraft:redstone_lamp[lit=true]", light: 15 }],
]);
const blockMap = (...cells) => new Map(cells.map(([pos, id = 1]) => [pos.join(","), id]));
const build = (blocks, options = {}) => buildSectionMesh({ id: "0,0,0", blocks, materials: materialMap(), ...options });
const rows = (vertices) => Array.from({ length: vertices.length / MESH_VERTEX_STRIDE }, (_, index) =>
[...vertices.slice(index * MESH_VERTEX_STRIDE, (index + 1) * MESH_VERTEX_STRIDE)]);
test("empty sections return transferable empty Float32 arrays", () => {
const result = build(new Map());
assert.ok(result.vertices instanceof Float32Array);
assert.ok(result.transparent instanceof Float32Array);
assert.equal(result.vertices.length, 0);
assert.equal(result.transparent.length, 0);
const invisible = build(blockMap([[1, 1, 1], 5]));
assert.equal(invisible.vertices.length, 0);
});
test("an isolated cube keeps all six faces and the complete 20-float format", () => {
const result = build(blockMap([[2, 3, 4], 1])), vertices = rows(result.vertices);
assert.equal(MESH_VERTEX_STRIDE, 20);
assert.equal(vertices.length, 36);
assert.equal(result.transparent.length, 0);
for (const vertex of vertices) {
assert.ok(vertex.slice(0, 3).every((value, axis) => [2, 3, 4][axis] <= value && value <= [3, 4, 5][axis]));
assert.deepEqual(vertex.slice(3, 6), [128, 144, 160].map((value) => Math.fround(value / 255)));
assert.equal(Math.hypot(...vertex.slice(6, 9)), 1);
assert.deepEqual(vertex.slice(13), [-1, 1, 0, 0, 0, 0, 1]);
}
});
test("opaque neighbors cull their shared faces, including across section boundaries", () => {
assert.equal(build(blockMap([[1, 1, 1], 1], [[2, 1, 1], 1])).vertices.length / 20, 60);
const result = build(blockMap([[15, 1, 1], 1], [[16, 1, 1], 1]));
assert.equal(result.vertices.length / 20, 30);
assert.ok(rows(result.vertices).every((vertex) => vertex[6] !== 1));
const negative = build(blockMap([[-1, 1, 1], 1], [[0, 1, 1], 1]), { id: "-1,0,0" });
assert.equal(negative.vertices.length / 20, 30);
assert.ok(rows(negative.vertices).every((vertex) => vertex[0] <= 0));
});
test("water goes into the translucent buffer and does not hide a stone face", () => {
const result = build(blockMap([[1, 1, 1], 1], [[2, 1, 1], 2]));
assert.equal(result.vertices.length / 20, 36);
assert.equal(result.transparent.length / 20, 36);
for (const vertex of rows(result.transparent)) {
assert.equal(vertex[9], 6);
assert.equal(vertex[10], Math.fround(0.55));
}
});
test("texture-pack glass and leaves retain cutout faces in the opaque pass", () => {
const blocks = blockMap([[1, 1, 1], 3], [[2, 1, 1], 4]);
const fallback = build(blocks);
assert.equal(fallback.transparent.length / 20, 36);
const result = build(blocks, { textureLayers: new Map([["glass", 2], ["oak_leaves", 3]]) });
assert.equal(result.vertices.length / 20, 72);
assert.equal(result.transparent.length, 0);
const vertices = rows(result.vertices);
assert.ok(vertices.every((vertex) => vertex[10] === 1));
assert.equal(vertices.filter((vertex) => vertex[13] === 2).length, 36);
assert.equal(vertices.filter((vertex) => vertex[13] === 3).length, 36);
assert.ok(vertices.filter((vertex) => vertex[13] === 3).every((vertex) => vertex[4] > vertex[3]));
});
test("partial geometry preserves its real height and crops texture coordinates", () => {
const materials = materialMap();
materials.set(7, { ...stone, state: "minecraft:stone_slab[type=bottom]", render: [{ min: [0, 0, 0], max: [1, 0.5, 1] }] });
const result = build(blockMap([[1, 1, 1], 7]), { materials, textureLayers: new Map([["stone", 8]]) });
const vertices = rows(result.vertices);
assert.equal(Math.max(...vertices.map((vertex) => vertex[1])), 1.5);
assert.ok(vertices.every((vertex) => vertex[13] === 8));
const sides = vertices.filter((vertex) => vertex[7] === 0);
assert.ok(sides.every((vertex) => vertex[12] >= 0.5));
});
test("baked AO shades contact corners while exposed faces remain bright", () => {
const result = build(blockMap([[1, 0, 1], 1], [[0, 1, 1], 1], [[1, 1, 0], 1]));
const top = rows(result.vertices).filter((vertex) => vertex[1] === 1 && vertex[7] === 1);
assert.equal(top.length, 6);
assert.ok(top.some((vertex) => vertex[14] === Math.fround(0.76)));
assert.ok(top.some((vertex) => vertex[14] === 1));
});
test("actual propagated torch light and sky are baked without changing emission", () => {
const blocks = blockMap([[4, 0, 4], 1], [[4, 2, 4], 5], [[10, 0, 10], 6]);
const materials = materialMap();
const blockLightField = buildBlockLight(blocks, materials, { min: [-1, -1, -1], max: [16, 16, 16] });
const result = build(blocks, { materials, blockLightField });
const vertices = rows(result.vertices);
const top = vertices.filter((vertex) => vertex[1] === 1 && vertex[7] === 1 && vertex[0] < 6);
assert.ok(top.every((vertex) => vertex[16] > 0 && vertex[16] > vertex[17] && vertex[17] > vertex[18]));
assert.ok(top.every((vertex) => vertex[19] === 1 && vertex[15] === 0));
assert.ok(vertices.some((vertex) => vertex[15] === 1));
});
test("geometry extraction keeps yaw and per-vertex dynamic lighting for actors", () => {
const out = [], sampled = [];
const field = { sample(point) { sampled.push(point); return { block: [0.1, 0.2, 0.3], sky: 0.4 }; } };
boxVertices(out, [5, 6, 7], unitBox, [1, 1, 1], 0, () => false, Math.PI / 2,
1, null, "", null, 0, field, true);
const vertices = rows(new Float32Array(out));
assert.equal(sampled.length, 36);
assert.ok(vertices.every((vertex) => vertex[0] >= 4 && vertex[0] <= 5 && vertex[2] >= 7 && vertex[2] <= 8));
assert.deepEqual(vertices[0].slice(16), [0.1, 0.2, 0.3, 0.4].map(Math.fround));
assert.equal(materialType("minecraft:water[level=0]"), 6);
assert.equal(materialType("shacraft:trampoline", "bounce"), 5);
});
test("reused texture caches keep section output byte-identical", () => {
const blocks = blockMap([[1, 1, 1], 1], [[2, 1, 1], 3]);
const textureLayers = new Map([["stone", 4], ["glass", 5]]), faceTextureCache = new Map();
const first = build(blocks, { textureLayers, faceTextureCache });
const second = build(blocks, { textureLayers, faceTextureCache });
assert.deepEqual(second.vertices, first.vertices);
assert.deepEqual(second.transparent, first.transparent);
assert.ok(faceTextureCache.size > 0);
const transferred = structuredClone(first, { transfer: [first.vertices.buffer, first.transparent.buffer] });
assert.deepEqual(transferred.vertices, second.vertices);
});
+133
View File
@@ -0,0 +1,133 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import {
loadPhysics,
LocalPrediction,
samplePhysicsWorld,
} from "../player-physics.js";
const binary = await readFile(new URL("../physics.wasm", import.meta.url));
const fixture = JSON.parse(
await readFile(
new URL(
"../../crates/shacraft-physics/tests/fixtures/java26.2.json",
import.meta.url,
),
"utf8",
),
);
const originalFetch = globalThis.fetch;
let step;
try {
// Exercise the actual browser ABI and its non-streaming MIME fallback.
globalThis.fetch = async () =>
new Response(binary, {
headers: { "Content-Type": "application/octet-stream" },
});
step = await loadPhysics("http://local-test/physics.wasm");
} finally {
globalThis.fetch = originalFetch;
}
const bounds = { min: [-64, -32, -64], max: [63, 63, 63] };
function floor(surface = "stone") {
const blocks = new Map();
for (let x = -4; x <= 4; x++)
for (let z = -55; z <= 5; z++) blocks.set(`${x},-1,${z}`, 1);
const materials = new Map([
[
1,
{
state: `minecraft:${surface}`,
collision: [{ min: [0, 0, 0], max: [1, 1, 1] }],
},
],
]);
return (body) => samplePhysicsWorld(body, blocks, materials, bounds);
}
const settings = { allow_flight: true };
test("the shipped browser WebAssembly follows independently measured Java 26.2 trajectories", () => {
let checked = 0;
for (const [name, trajectory] of Object.entries(
fixture.travel_kernel_trajectories,
)) {
if (trajectory.medium !== "air") continue;
const getWorld = floor(trajectory.surface);
let body = {
position: trajectory.initial_position,
velocity: trajectory.initial_velocity,
flying: trajectory.flying,
on_ground: !trajectory.flying,
};
for (const sample of trajectory.samples) {
const input = {
forward: sample.tick <= trajectory.input_ticks ? 1 : 0,
sprint: trajectory.sprint,
jump: sample.tick === 1 && trajectory.jump_first_tick,
};
body = step({ body, input, world: getWorld(body), settings });
for (const field of ["position", "velocity"])
for (let axis = 0; axis < 3; axis++) {
const expected = sample[field][axis] * (axis === 2 ? -1 : 1);
assert.ok(
Math.abs(body[field][axis] - expected) < 2e-6,
`${name} tick ${sample.tick} ${field}[${axis}]: ${body[field][axis]} != ${expected}`,
);
}
assert.equal(
body.on_ground,
sample.on_ground,
`${name} tick ${sample.tick} grounded`,
);
checked++;
}
}
assert.ok(checked >= 200);
});
test("real WebAssembly prediction replays delayed authority without introducing trajectory drift", () => {
const getWorld = floor();
const prediction = new LocalPrediction(step, getWorld);
let authority = {
position: [0, 0, 0],
velocity: [0, -0.0784000015258789, 0],
on_ground: true,
};
prediction.receive({ body: authority, ack: 0, tick: 0, epoch: 1, settings });
const packets = [];
for (let tick = 1; tick <= 80; tick++) {
const input = {
forward: tick < 55 ? 1 : 0,
sprint: tick > 20 && tick < 50,
jump: tick === 25,
sneak: tick >= 55,
};
prediction.push(tick, input);
authority = step({
body: authority,
input,
world: getWorld(authority),
settings,
});
packets.push({ body: authority, ack: tick, tick, epoch: 1, settings });
// Variable 150–250 ms inbound latency; authority still acknowledges order.
if (packets.length > (tick % 3) + 3) prediction.receive(packets.shift());
for (let axis = 0; axis < 3; axis++) {
assert.ok(
Math.abs(prediction.body.position[axis] - authority.position[axis]) <
1e-11,
`position drift at tick ${tick}`,
);
assert.ok(
Math.abs(prediction.body.velocity[axis] - authority.velocity[axis]) <
1e-11,
`velocity drift at tick ${tick}`,
);
}
}
for (const packet of packets) prediction.receive(packet);
assert.equal(prediction.pending.length, 0);
assert.deepEqual(prediction.body, authority);
});
+39
View File
@@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { playerAvatarParts } from "../renderer.js";
test("remote avatar stance and eye placement follow server physics dimensions", () => {
for (const [pose, height, eye_height] of [
["standing", 1.8, 1.62],
["crouching", 1.5, 1.27],
["swimming", 0.6, 0.4],
]) {
const parts = playerAvatarParts({ pose, height, eye_height });
assert.equal(Math.max(...parts.map(({ box }) => box.max[1])), height);
const head = parts.find(({ part }) => part === "head").box;
const eyes = parts.find(({ part }) => part === "eyes").box;
assert.ok(Math.abs((eyes.min[1] + eyes.max[1]) / 2 - eye_height) < 1e-12);
assert.ok(eyes.min[1] >= head.min[1] && eyes.max[1] <= head.max[1]);
for (const { box } of parts)
for (let axis = 0; axis < 3; axis++) {
assert.ok(
Number.isFinite(box.min[axis]) && box.max[axis] > box.min[axis],
);
}
}
});
test("swimming and crawling use a horizontal silhouette while crouching leans forward", () => {
const standing = playerAvatarParts({});
const crouching = playerAvatarParts({ pose: "crouching" });
const swimming = playerAvatarParts({ pose: "swimming" });
const head = (parts) => parts.find(({ part }) => part === "head").box;
assert.ok(head(crouching).min[2] < head(standing).min[2]);
const length =
Math.max(...swimming.map(({ box }) => box.max[2])) -
Math.min(...swimming.map(({ box }) => box.min[2]));
assert.ok(length > 1.6);
assert.ok(Math.max(...swimming.map(({ box }) => box.max[1])) <= 0.6);
assert.equal(swimming.filter(({ part }) => part === "leg").length, 2);
assert.equal(swimming.filter(({ part }) => part === "arm").length, 2);
});
+140
View File
@@ -0,0 +1,140 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
updateRemotePlayer,
smoothRemotePlayer,
} from "../player-interpolation.js";
const radians = (degrees) => (degrees * Math.PI) / 180;
const near = (actual, expected, tolerance = 1e-11) =>
assert.ok(
Math.abs(actual - expected) < tolerance,
`Expected ${actual} to be within ${tolerance} of ${expected}`,
);
test("network updates change targets without snapping the rendered pose", () => {
const previous = updateRemotePlayer(null, {
id: "remote",
position: [1, 2, 3],
yaw: 0.2,
name: "Before",
});
const packet = {
id: "remote",
position: [5, 6, 7],
yaw: 1.2,
name: "After",
};
const player = updateRemotePlayer(previous, packet);
assert.deepEqual(player.position, [1, 2, 3]);
assert.equal(player.yaw, 0.2);
assert.deepEqual(player.target, [5, 6, 7]);
assert.equal(player.targetYaw, 1.2);
assert.equal(player.name, "After");
smoothRemotePlayer(player, 0.25);
assert.deepEqual(player.position, [2, 3, 4]);
near(player.yaw, 0.45);
assert.deepEqual(player.target, [5, 6, 7]);
assert.equal(player.targetYaw, 1.2);
assert.deepEqual(packet.position, [5, 6, 7]);
});
test("a packet arriving mid-turn continues from the current rendered angle", () => {
let player = updateRemotePlayer(null, { position: [0, 0, 0], yaw: 0 });
player = updateRemotePlayer(player, { position: [4, 0, 0], yaw: 1 });
smoothRemotePlayer(player, 0.5);
player = updateRemotePlayer(player, { position: [8, 0, 0], yaw: 2 });
assert.deepEqual(player.position, [2, 0, 0]);
near(player.yaw, 0.5);
smoothRemotePlayer(player, 0.5);
assert.deepEqual(player.position, [5, 0, 0]);
near(player.yaw, 1.25);
});
test("turns across the angle boundary follow the short arc in both directions", () => {
for (const direction of [1, -1]) {
let player = updateRemotePlayer(null, {
position: [0, 0, 0],
yaw: radians(179 * direction),
});
player = updateRemotePlayer(player, {
position: [0, 0, 0],
yaw: radians(-179 * direction),
});
smoothRemotePlayer(player, 0.5);
near(player.yaw, radians(180 * direction));
smoothRemotePlayer(player, 0.5);
near(player.yaw, radians(180.5 * direction));
}
});
test("unbounded yaw values and equivalent full turns do not cause long spins", () => {
let player = updateRemotePlayer(null, {
position: [0, 0, 0],
yaw: 30 * Math.PI + 0.2,
});
player = updateRemotePlayer(player, {
position: [0, 0, 0],
yaw: -18 * Math.PI + 0.6,
});
smoothRemotePlayer(player, 0.5);
near(player.yaw, 30 * Math.PI + 0.4);
player = updateRemotePlayer(player, {
position: [0, 0, 0],
yaw: player.yaw + 8 * Math.PI,
});
const before = player.yaw;
smoothRemotePlayer(player, 0.5);
near(player.yaw, before);
});
test("initial and instant updates initialize both the rendered pose and targets", () => {
const first = updateRemotePlayer(undefined, { position: [3, 4, 5] });
assert.deepEqual(first.position, [3, 4, 5]);
assert.deepEqual(first.target, [3, 4, 5]);
assert.equal(first.yaw, 0);
assert.equal(first.targetYaw, 0);
const instant = updateRemotePlayer(
first,
{ position: [20, 30, 40], yaw: -2.5 },
true,
);
assert.deepEqual(instant.position, [20, 30, 40]);
assert.deepEqual(instant.target, [20, 30, 40]);
assert.equal(instant.yaw, -2.5);
assert.equal(instant.targetYaw, -2.5);
smoothRemotePlayer(instant, 0.75);
assert.deepEqual(instant.position, [20, 30, 40]);
assert.equal(instant.yaw, -2.5);
});
test("exponential smoothing has the same result at 30, 60, and 144 fps", () => {
const elapsed = 0.5;
const remaining = Math.exp(-elapsed * 19);
for (const fps of [30, 60, 144]) {
let player = updateRemotePlayer(null, {
position: [1, 2, 3],
yaw: radians(179),
});
player = updateRemotePlayer(player, {
position: [11, 22, 33],
yaw: radians(-179),
});
const blend = 1 - Math.exp(-19 / fps);
for (let frame = 0; frame < elapsed * fps; frame++) {
smoothRemotePlayer(player, blend);
}
near(player.yaw, radians(181) - radians(2) * remaining);
for (let axis = 0; axis < 3; axis++) {
const start = axis + 1;
const target = (axis + 1) * 11;
near(player.position[axis], target - (target - start) * remaining);
}
}
});
+208
View File
@@ -0,0 +1,208 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
PhysicsClock,
LocalPrediction,
samplePhysicsWorld,
} from "../player-physics.js";
const body = (x = 0) => ({
position: [x, 1, 0],
velocity: [0, 0, 0],
pose: "standing",
});
const motion = (x = 0, ack = 0, tick = 0, epoch = 1) => ({
body: body(x),
ack,
tick,
epoch,
settings: { movement_speed: 0.1 },
});
const step = ({ body: current, input }) => ({
...current,
position: [
current.position[0] + (input.forward || 0),
...current.position.slice(1),
],
flying: input.fly_toggle ? !current.flying : current.flying,
});
const makePrediction = (getWorld = () => ({ blocks: [] }), limit) =>
new LocalPrediction(step, getWorld, limit);
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`);
test("20 Hz input timing is the same at 30, 60, and 144 render frames per second", () => {
for (const fps of [30, 60, 144]) {
const clock = new PhysicsClock();
let ticks = 0;
for (let frame = 0; frame < fps * 3; frame++)
clock.advance(1000 / fps, () => ticks++);
assert.equal(ticks, 60);
near(clock.alpha, 0);
}
});
test("a long suspended frame cannot create unlimited catch-up inputs", () => {
const clock = new PhysicsClock();
let ticks = 0;
assert.equal(
clock.advance(60_000, () => ticks++),
5,
);
assert.equal(
clock.advance(0, () => ticks++),
0,
);
assert.equal(ticks, 5);
clock.advance(25, () => ticks++);
near(clock.alpha, 0.5);
clock.reset();
near(clock.alpha, 0);
});
test("authority acknowledges one input and replays only the still pending commands", () => {
const prediction = makePrediction();
prediction.receive(motion());
prediction.push(1, { forward: 1 });
prediction.push(2, { forward: 1 });
prediction.push(3, { forward: -1 });
assert.equal(prediction.body.position[0], 1);
prediction.receive(motion(0.8, 1, 1));
near(prediction.body.position[0], 0.8);
assert.deepEqual(
prediction.pending.map(({ seq }) => seq),
[2, 3],
);
near(prediction.offset[0], 0.2);
near(prediction.sample(0, 0, {}).position[0], 1);
near(prediction.sample(0, 1, {}).position[0], 0.8 + 0.2 * Math.exp(-15));
});
test("late ticks, decreasing acknowledgements, and duplicate inputs cannot rewind movement", () => {
const prediction = makePrediction();
prediction.receive(motion(4, 4, 10));
prediction.push(5, { forward: 1 });
assert.equal(prediction.receive(motion(-10, 5, 9)), false);
assert.equal(prediction.receive(motion(-10, 3, 11)), false);
assert.equal(prediction.push(5, { forward: 20 }), false);
assert.equal(prediction.push(4, { forward: 20 }), false);
assert.equal(prediction.body.position[0], 5);
});
test("world/respawn/input reset epochs drop old commands and visual correction", () => {
const prediction = makePrediction();
prediction.receive(motion());
prediction.push(1, { forward: 1 });
prediction.receive(motion(0.7, 1, 1));
prediction.push(2, { forward: 1 });
prediction.receive(motion(6, 2, 2, 2));
assert.deepEqual(prediction.pending, []);
assert.deepEqual(prediction.offset, [0, 0, 0]);
assert.equal(prediction.body.position[0], 6);
assert.equal(prediction.receive(motion(-6, 3, 3, 1)), false);
assert.equal(prediction.body.position[0], 6);
prediction.reset();
assert.equal(prediction.body, null);
assert.equal(prediction.tick, -1);
prediction.receive(motion(30, 0, 0), true);
assert.equal(prediction.body.position[0], 30);
});
test("a teleport without an epoch change does not replay pre-teleport movement", () => {
const prediction = makePrediction();
prediction.receive(motion());
prediction.push(1, { forward: 1 });
prediction.receive(motion(100, 0, 1));
assert.equal(prediction.body.position[0], 100);
assert.equal(prediction.pending.length, 0);
});
test("unavailable chunks suspend prediction until authority and a complete context arrive", () => {
let available = false;
const prediction = makePrediction(() => (available ? { blocks: [] } : null));
prediction.receive(motion());
assert.equal(prediction.push(1, { forward: 1 }), false);
assert.equal(prediction.suspended, true);
assert.equal(prediction.body.position[0], 0);
available = true;
prediction.receive(motion(0.5, 1, 1));
prediction.push(2, { forward: 1 });
assert.equal(prediction.suspended, false);
assert.equal(prediction.body.position[0], 1.5);
});
test("bounded history suspends until the discarded sequence has been acknowledged", () => {
const prediction = makePrediction(undefined, 3);
prediction.receive(motion());
for (let seq = 1; seq <= 5; seq++) prediction.push(seq, { forward: 1 });
assert.equal(prediction.pending.length, 3);
assert.equal(prediction.suspended, true);
prediction.receive(motion(1, 1, 1));
assert.equal(prediction.suspended, true);
prediction.receive(motion(2, 2, 2));
assert.equal(prediction.suspended, false);
assert.equal(prediction.body.position[0], 5);
});
test("render previews do not mutate acknowledged bodies, queue commands, or consume a flight toggle", () => {
const prediction = makePrediction();
prediction.receive(motion());
for (let i = 0; i < 3; i++) {
near(
prediction.sample(0.5, 0, { forward: 1, fly_toggle: true }).position[0],
0.5,
);
assert.equal(prediction.body.flying, undefined);
assert.equal(prediction.pending.length, 0);
}
prediction.push(1, { forward: 1, fly_toggle: true });
assert.equal(prediction.body.flying, true);
prediction.receive({ ...motion(0, 0, 1), body: body() });
assert.equal(prediction.body.flying, true);
});
test("swept neighbourhood includes fast-fall floors, transparent fluids and local collision boxes", () => {
const player = { ...body(), position: [-0.2, 7, 0], velocity: [0, -3.92, 0] };
const collision = [{ min: [0, 0, 0], max: [1, 1.5, 1] }];
const materials = new Map([
[1, { state: "minecraft:water[level=0]", collision: [] }],
[2, { state: "minecraft:oak_fence", collision }],
]);
const blocks = new Map([
["-1,6,0", 1],
["-1,2,0", 2],
["40,0,0", 2],
]);
const bounds = { min: [-32, -16, -32], max: [31, 31, 31] };
const world = samplePhysicsWorld(player, blocks, materials, bounds);
assert.equal(world.blocks.length, 2);
assert.deepEqual(
world.blocks.find((b) => b.state.includes("fence")),
{ pos: [-1, 2, 0], state: "minecraft:oak_fence", collision },
);
assert.deepEqual(
world.blocks.find((b) => b.state.includes("water")).collision,
[],
);
assert.equal(samplePhysicsWorld(player, blocks, new Map(), bounds), null);
assert.equal(
samplePhysicsWorld(player, blocks, materials, {
min: [-1, 0, -1],
max: [1, 10, 1],
}),
null,
);
});
test("invalid motion packets leave the valid body and history untouched", () => {
const prediction = makePrediction();
prediction.receive(motion());
assert.equal(
prediction.receive({
...motion(),
body: { position: [NaN, 0, 0], velocity: [0, 0, 0] },
}),
false,
);
assert.equal(prediction.receive({ ...motion(), ack: 0.5 }), false);
assert.equal(prediction.body.position[0], 0);
});
+169
View File
@@ -0,0 +1,169 @@
import test from "node:test";
import assert from "node:assert/strict";
import { PointerLockController } from "../pointer-lock.js";
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function fixture(t, request) {
const document = new EventTarget(),
calls = [],
changes = [],
errors = [];
document.pointerLockElement = null;
document.exitPointerLock = () => {
calls.push("exit");
document.pointerLockElement = null;
document.dispatchEvent(new Event("pointerlockchange"));
};
const canvas = {
ownerDocument: document,
tabIndex: -1,
focus(options) {
calls.push("focus");
assert.equal(options.preventScroll, true);
},
requestPointerLock() {
calls.push("request");
return request?.();
},
};
const controller = new PointerLockController(canvas, {
onChange: (locked) => changes.push(locked),
onError: (error) => errors.push(error),
});
const lock = () => {
document.pointerLockElement = canvas;
document.dispatchEvent(new Event("pointerlockchange"));
};
t.after(() => controller.dispose());
return { controller, canvas, document, calls, changes, errors, lock };
}
test("focus precedes the synchronous user-gesture request and duplicate pending calls are ignored", (t) => {
const pending = deferred(),
{ controller, canvas, calls } = fixture(t, () => pending.promise);
assert.equal(controller.request(), true);
assert.deepEqual(calls, ["focus", "request"]);
assert.equal(canvas.tabIndex, 0);
assert.equal(controller.status, "requesting");
assert.equal(controller.request(), false);
assert.deepEqual(calls, ["focus", "request"]);
});
test("a rejected request remains retryable and subsequent success clears the error", async (t) => {
const first = deferred(),
second = deferred();
let count = 0;
const { controller, errors, changes, lock } = fixture(t, () =>
++count === 1 ? first.promise : second.promise,
);
controller.request();
const error = new Error("Document not focused");
first.reject(error);
await Promise.resolve();
assert.equal(controller.status, "error");
assert.equal(controller.lastError, error);
assert.deepEqual(errors, [error]);
assert.equal(controller.request(), true);
lock();
second.resolve();
await Promise.resolve();
assert.equal(controller.status, "locked");
assert.equal(controller.locked, true);
assert.equal(controller.lastError, null);
assert.deepEqual(changes, [true]);
});
test("legacy success, ordinary unlock, and recapture follow actual document state", (t) => {
const { controller, document, changes, lock, calls } = fixture(t);
controller.request();
lock();
assert.equal(controller.request(), false);
document.exitPointerLock();
assert.equal(controller.status, "idle");
assert.equal(controller.locked, false);
assert.equal(controller.request(), true);
lock();
assert.deepEqual(changes, [true, false, true]);
assert.equal(calls.filter((call) => call === "request").length, 2);
});
test("missing APIs and legacy error events report failure without permanently disabling retries", (t) => {
const { controller, canvas, document, errors, lock } = fixture(t);
delete canvas.requestPointerLock;
assert.equal(controller.request(), false);
assert.equal(controller.status, "unsupported");
assert.equal(errors[0].name, "NotSupportedError");
canvas.requestPointerLock = () => undefined;
assert.equal(controller.request(), true);
document.dispatchEvent(new Event("pointerlockerror"));
assert.equal(controller.status, "error");
assert.equal(errors.length, 2);
assert.equal(controller.request(), true);
lock();
assert.equal(controller.status, "locked");
});
test("a stale promise rejection cannot overwrite a later successful capture", async (t) => {
const first = deferred();
const { controller, document, errors, lock } = fixture(
t,
() => first.promise,
);
controller.request();
document.dispatchEvent(new Event("pointerlockerror"));
controller.request();
lock();
first.reject(new Error("Late failure from an old request"));
await Promise.resolve();
assert.equal(controller.status, "locked");
assert.equal(controller.lastError, null);
assert.equal(errors.length, 1);
});
test("release cancels pending capture and immediately exits a late acquisition", async (t) => {
const pending = deferred(),
{ controller, changes, errors, lock, calls } = fixture(
t,
() => pending.promise,
);
controller.request();
controller.release();
assert.equal(controller.status, "idle");
lock();
pending.resolve();
await Promise.resolve();
assert.equal(controller.locked, false);
assert.equal(controller.status, "idle");
assert.deepEqual(changes, []);
assert.deepEqual(errors, []);
assert.equal(calls.filter((call) => call === "exit").length, 2);
});
test("synchronous failures are retryable and disposal removes event handling", (t) => {
const { controller, canvas, document, errors, changes, lock } = fixture(
t,
() => {
throw new Error("Synchronous rejection");
},
);
assert.equal(controller.request(), false);
assert.equal(controller.status, "error");
assert.equal(errors.length, 1);
canvas.requestPointerLock = () => undefined;
controller.request();
lock();
controller.dispose();
assert.deepEqual(changes, [true, false]);
assert.equal(controller.request(), false);
lock();
document.dispatchEvent(new Event("pointerlockerror"));
assert.deepEqual(changes, [true, false]);
assert.equal(errors.length, 1);
});
+417 -61
View File
@@ -1,64 +1,420 @@
<!doctype html><meta charset="utf-8" /><title>Shacraft renderer fixture</title
><style>
body {
margin: 0;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
}</style
><canvas id="c"></canvas>
<script type="module">
import { Renderer } from "../renderer.js";
const canvas = document.querySelector("canvas");
try {
const r = new Renderer(canvas),
blocks = new Map(),
mats = new Map([
[
1,
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Shacraft daylight renderer fixture</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font:
13px/1.4 system-ui,
sans-serif;
color: #172c23;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
}
.controls {
position: fixed;
top: 14px;
left: 14px;
right: 14px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
width: fit-content;
max-width: calc(100vw - 28px);
padding: 10px 14px;
border: 1px solid #ffffffb3;
border-radius: 7px;
background: #fffffff0;
box-shadow: 0 2px 10px #153c2515;
}
label {
display: flex;
align-items: center;
gap: 6px;
}
select,
button {
font: inherit;
color: inherit;
padding: 5px 9px;
background: #f6f8f3;
border: 1px solid #aebdaf;
border-radius: 4px;
}
button {
cursor: pointer;
}
input {
accent-color: #436953;
}
output {
color: #47604c;
font-variant-numeric: tabular-nums;
}
.legend {
position: fixed;
left: 14px;
bottom: 14px;
margin: 0;
padding: 8px 12px;
background: #ffffffe6;
border-radius: 5px;
max-width: calc(100vw - 28px);
}
#error {
position: fixed;
inset: 80px 14px auto;
white-space: pre-wrap;
background: #fff0ec;
color: #7f2217;
padding: 16px;
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<canvas id="c" aria-label="Daylight renderer test scene"></canvas>
<div class="controls" aria-label="Renderer test controls">
<strong>Daylight fixture</strong>
<label
>Camera
<select id="camera">
<option value="overview">Overview</option>
<option value="corner">Corner</option>
<option value="sun-facing">Sun facing</option>
<option value="lamp-room">Lamp room</option>
<option value="actor-boundary-before">Actors · before section boundary</option>
<option value="actor-boundary-after">Actors · after section boundary</option>
</select>
</label>
<label><input id="shadows" type="checkbox" checked /> Shadows</label>
<button id="pillar" type="button">Remove pillar</button>
<label
>Room lamp
<select id="lamp">
<option value="torch">Torch · 14</option>
<option value="sea-lantern">Sea lantern · 15</option>
<option value="off">Off · 0</option>
</select>
</label>
<button id="wall" type="button">Remove room partition</button>
<output id="status" role="status">Building scene…</output>
</div>
<p class="legend">
Stone corner · arch and posts · slabs and stairs · glass and water · sea
lantern · avatar and entity · sealed lamp room with an internal glass
window
<br /><output id="light-samples">Light field initializing…</output>
</p>
<pre id="error" hidden></pre>
<script type="module">
import { Renderer } from "../renderer.js?v=block-light-1";
const canvas = document.querySelector("canvas"),
status = document.querySelector("#status"),
cameraSelect = document.querySelector("#camera"),
shadowToggle = document.querySelector("#shadows"),
pillarButton = document.querySelector("#pillar"),
lampSelect = document.querySelector("#lamp"),
wallButton = document.querySelector("#wall"),
lightStatus = document.querySelector("#light-samples");
const cube = { min: [0, 0, 0], max: [1, 1, 1] };
const lookAt = (eye, target) => {
const direction = target.map((value, axis) => value - eye[axis]);
return {
eye,
yaw: Math.atan2(direction[0], -direction[2]),
pitch: Math.atan2(
direction[1],
Math.hypot(direction[0], direction[2]),
),
};
};
const cameras = {
overview: lookAt([12, 10, 15], [0, 1.8, -3]),
corner: lookAt([-2.4, 3.6, -2.1], [-6.2, 1.8, -8.6]),
"sun-facing": {
eye: [7, 1.9, -9],
yaw: Math.atan2(-0.45, -0.3),
pitch: 0.65,
},
"lamp-room": lookAt([-15.5, 2.65, 12.5], [-13.5, 2.1, 7]),
"actor-boundary-before": lookAt([15.99, 4, 8], [3.5, 2, 1.4]),
"actor-boundary-after": lookAt([16.01, 4, 8], [3.5, 2, 1.4]),
};
function fail(error) {
document.body.dataset.status = "error:" + error.message;
status.textContent = "Renderer error";
const output = document.querySelector("#error");
output.hidden = false;
output.textContent = error.stack || String(error);
}
try {
const renderer = new Renderer(canvas),
blocks = new Map(),
materials = new Map(),
material = (id, state, color, options = {}) =>
materials.set(id, {
state: `minecraft:${state}`,
color,
opacity: 1,
render: [cube],
light: 0,
...options,
}),
put = (x, y, z, block) => blocks.set(`${x},${y},${z}`, block);
material(1, "grass_block", [112, 155, 69]);
material(2, "stone", [177, 176, 163]);
material(3, "stone_bricks", [186, 181, 164]);
material(4, "oak_log[axis=y]", [139, 108, 69]);
material(5, "oak_planks", [188, 152, 100]);
material(6, "glass", [183, 221, 221], { opacity: 0.28 });
material(7, "water[level=0]", [74, 149, 184], {
opacity: 0.58,
render: [{ min: [0, 0, 0], max: [1, 0.88, 1] }],
});
material(8, "stone_slab[type=bottom]", [178, 176, 159], {
render: [{ min: [0, 0, 0], max: [1, 0.5, 1] }],
});
material(
9,
"stone_stairs[facing=north,half=bottom,shape=straight]",
[179, 177, 160],
{
state: "minecraft:grass_block",
color: [107, 151, 65],
opacity: 1,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
render: [
{ min: [0, 0, 0], max: [1, 0.5, 1] },
{ min: [0, 0.5, 0], max: [1, 1, 0.5] },
],
},
],
[
2,
{
state: "minecraft:bricks",
color: [169, 88, 68],
opacity: 1,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
},
],
[
3,
{
state: "minecraft:glass",
color: [169, 220, 219],
opacity: 0.35,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
},
],
]);
for (let x = -16; x <= 16; x++)
for (let z = -16; z <= 16; z++) blocks.set(`${x},0,${z}`, 1);
for (let x = -3; x < 3; x++)
for (let y = 1; y < 5; y++)
blocks.set(
`${x},${y},-7`,
(x === 0 || x === 1) && y > 1 && y < 4 ? 3 : 2,
);
r.replace(blocks, mats);
for (let n = 0; n < 24; n++) {
r.draw({ eye: [7, 5, 12], yaw: -0.38, pitch: -0.2 }, [], [], new Map());
}
document.body.dataset.status = `ready:${r.triangles}`;
} catch (error) {
document.body.dataset.status = "error:" + error.message;
document.body.textContent = error.stack;
}
</script>
material(10, "sea_lantern", [221, 244, 224], { light: 15 });
material(11, "torch", [229, 158, 70], {
light: 14,
render: [{ min: [0.43, 0, 0.43], max: [0.57, 0.8, 0.57] }],
});
material(12, "quartz_block", [211, 207, 189]);
for (let x = -18; x <= 18; x++)
for (let z = -20; z <= 16; z++)
put(
x,
0,
z,
Math.abs(x) <= 2 || (x >= -7 && x <= -3 && z >= -10 && z <= -3)
? 2
: 1,
);
// A tall inside corner and a small roof expose contact and indirect light.
for (let y = 1; y <= 5; y++) {
for (let x = -7; x <= -3; x++) put(x, y, -10, 3);
for (let z = -9; z <= -4; z++) put(-7, y, z, 3);
}
for (let x = -7; x <= -5; x++)
for (let z = -10; z <= -8; z++) put(x, 5, z, 3);
put(-6, 1, -9, 10);
// The right support can be removed independently of the lintel.
const removablePillar = [];
for (let y = 1; y <= 4; y++) {
put(0, y, -6, 4);
put(5, y, -6, 4);
removablePillar.push([5, y, -6]);
}
for (let x = 0; x <= 5; x++) put(x, 5, -6, 5);
for (let y = 1; y <= 3; y++) put(7, y, 1, 4);
for (let y = 1; y <= 5; y++) put(9, y, -3, 3);
for (let x = -5; x <= -2; x++) put(x, 1, 3, 8);
for (let x = -5; x <= -2; x++) put(x, 1, 1, 9);
put(-5, 2, 1, 8);
for (let x = 3; x <= 5; x++)
for (let y = 1; y <= 3; y++) put(x, y, 4, 6);
for (let x = 6; x <= 10; x++)
for (let z = -11; z <= -7; z++) {
put(x, -1, z, 2);
put(x, 0, z, 7);
}
for (let x = 5; x <= 11; x++) {
put(x, 0, -12, 3);
put(x, 0, -6, 3);
}
for (let z = -11; z <= -7; z++) {
put(5, 0, z, 3);
put(11, 0, z, 3);
}
// All exterior faces are solid, including the roof. The window connects
// two indoor chambers so lamp-off darkness is independent of daylight.
for (let x = -17; x <= -9; x++)
for (let z = 4; z <= 14; z++) {
put(x, 0, z, 2);
put(x, 5, z, 12);
if (x === -17 || x === -9 || z === 4 || z === 14)
for (let y = 1; y <= 4; y++) put(x, y, z, 12);
}
for (let z = 5; z <= 13; z++)
for (let y = 1; y <= 4; y++) put(-13, y, z, 12);
put(-13, 2, 5, 6);
put(-13, 3, 5, 6);
const lampPosition = [-15, 2, 8],
partitionPanel = [];
put(-15, 1, 8, 2);
put(...lampPosition, 11);
for (let y = 1; y <= 3; y++)
for (let z = 8; z <= 9; z++) partitionPanel.push([-13, y, z]);
const players = [
{
id: "fixture-player",
name: "Avatar",
position: [3.5, 1, 1.4],
yaw: -0.5,
pose: "standing",
height: 1.8,
eye_height: 1.62,
color: [0.31, 0.47, 0.64],
},
];
const entities = [
{ kind: "fixture-crate", position: [-3, 1, -4], yaw: 0.3 },
];
const entityDefinitions = new Map([
[
"fixture-crate",
{
color: [168, 121, 81],
render: [{ min: [-0.5, 0, -0.5], max: [0.5, 1, 0.5] }],
},
],
]);
renderer.replace(blocks, materials);
renderer.shadowsEnabled = shadowToggle.checked;
let pillarPresent = true,
wallPresent = true,
frameCount = 0,
lastStatus = -Infinity;
const errors = new Set();
document.body.dataset.camera = cameraSelect.value;
document.body.dataset.pillar = "present";
document.body.dataset.lamp = lampSelect.value;
document.body.dataset.wall = "present";
shadowToggle.onchange = () => {
renderer.shadowsEnabled = shadowToggle.checked;
};
cameraSelect.onchange = () => {
document.body.dataset.camera = cameraSelect.value;
};
pillarButton.onclick = () => {
pillarPresent = !pillarPresent;
const changes = removablePillar.map((pos) => {
if (pillarPresent) blocks.set(pos.join(","), 4);
else blocks.delete(pos.join(","));
return { pos, block: pillarPresent ? 4 : 0 };
});
renderer.change(changes);
pillarButton.textContent = pillarPresent
? "Remove pillar"
: "Restore pillar";
document.body.dataset.pillar = pillarPresent ? "present" : "removed";
};
lampSelect.onchange = () => {
const block = { torch: 11, "sea-lantern": 10, off: 0 }[
lampSelect.value
];
if (block) blocks.set(lampPosition.join(","), block);
else blocks.delete(lampPosition.join(","));
renderer.change([{ pos: lampPosition, block }]);
document.body.dataset.lamp = lampSelect.value;
};
wallButton.onclick = () => {
wallPresent = !wallPresent;
const changes = partitionPanel.map((pos) => {
if (wallPresent) blocks.set(pos.join(","), 12);
else blocks.delete(pos.join(","));
return { pos, block: wallPresent ? 12 : 0 };
});
renderer.change(changes);
wallButton.textContent = wallPresent
? "Remove room partition"
: "Restore room partition";
document.body.dataset.wall = wallPresent ? "present" : "removed";
};
const lightSamplePositions = {
source: lampPosition,
near: [-15, 2, 9],
window: [-13, 2, 5],
behindWall: [-12, 2, 8],
far: [-10, 2, 12],
outside: [-15, 2, 15],
};
function updateLightSamples() {
const field = renderer.blockLightField;
if (typeof field?.sample !== "function") return;
const samples = Object.fromEntries(
Object.entries(lightSamplePositions).map(([name, position]) => {
const sample = field.sample(position);
return [name, { block: sample.blockLevel, sky: sample.skyLevel }];
}),
);
document.body.dataset.lightSamples = JSON.stringify(samples);
document.body.dataset.lightSources = String(field.sourceCount);
lightStatus.textContent = `Light 0–15 (block/sky): ${Object.entries(
samples,
)
.map(([name, sample]) => `${name} ${sample.block}/${sample.sky}`)
.join(" · ")}`;
}
function frame(time) {
try {
renderer.draw(
cameras[cameraSelect.value],
players,
entities,
entityDefinitions,
);
frameCount++;
const error = renderer.gl.getError();
if (error !== renderer.gl.NO_ERROR)
errors.add(`0x${error.toString(16)}`);
if (time - lastStatus >= 200) {
lastStatus = time;
const ready = frameCount >= 3 && renderer.dirty.size === 0;
document.body.dataset.status = errors.size
? `error:WebGL ${[...errors].join(",")}`
: `${ready ? "ready" : "building"}:${renderer.triangles}`;
document.body.dataset.glError = errors.size
? [...errors].join(",")
: "0";
document.body.dataset.triangles = String(renderer.triangles);
document.body.dataset.sections = String(renderer.sections.size);
document.body.dataset.pendingSections = String(
renderer.dirty.size,
);
document.body.dataset.shadowsEnabled = String(
renderer.shadowsEnabled,
);
document.body.dataset.frames = String(frameCount);
document.body.dataset.meshRebuilds = String(
renderer.meshRebuilds,
);
updateLightSamples();
status.textContent = `${errors.size ? "WebGL " + [...errors].join(",") : "WebGL OK"} · ${renderer.triangles.toLocaleString("en-US")} triangles · shadows ${renderer.shadowsEnabled ? "on" : "off"}`;
}
requestAnimationFrame(frame);
} catch (error) {
fail(error);
}
}
requestAnimationFrame(frame);
} catch (error) {
fail(error);
}
</script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Renderer } from "../renderer.js";
function meshFixture() {
const renderer = Object.create(Renderer.prototype);
renderer.sections = new Map([
["-2,0,0", { name: "leaving" }],
["-1,0,0", { name: "border" }],
["0,0,0", { name: "retained" }],
["1,0,0", { name: "far" }],
]);
renderer.dirty = new Set();
renderer.disposed = [];
renderer.disposeMesh = (mesh) => renderer.disposed.push(mesh);
return renderer;
}
test("stream unload disposes only departing meshes and keeps visible mesh identities", () => {
const renderer = meshFixture();
const before = new Map(renderer.sections);
renderer.dirty.add("-2,0,0");
renderer.unloadSections([[-2, 0, 0]]);
assert.deepEqual(renderer.disposed, [before.get("-2,0,0")]);
for (const id of ["-1,0,0", "0,0,0", "1,0,0"])
assert.equal(renderer.sections.get(id), before.get(id));
assert.deepEqual([...renderer.dirty], ["-1,0,0"]);
});
test("incoming cells queue their section and boundaries without clearing existing geometry", () => {
const renderer = meshFixture();
const before = [...renderer.sections.values()];
renderer.change([{ pos: [32, 5, 5], block: 7 }]);
assert.deepEqual([...renderer.sections.values()], before);
assert.equal(renderer.disposed.length, 0);
assert.deepEqual([...renderer.dirty].sort(), ["1,0,0", "2,0,0"]);
});
test("unloading adjacent sections does not queue either for reconstruction", () => {
const renderer = meshFixture();
renderer.change([{ pos: [-17, 0, 0], block: 0 }]);
renderer.unloadSections([[-2, 0, 0], [-1, 0, 0]]);
assert.equal(renderer.dirty.has("-2,0,0"), false);
assert.equal(renderer.dirty.has("-1,0,0"), false);
assert.equal(renderer.dirty.has("0,0,0"), true);
assert.equal(renderer.sections.size, 2);
});
+98
View File
@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SectionBlockMap } from "../section-block-map.js";
test("construction and repeated set preserve Map behavior and unique section keys", () => {
const blocks = new SectionBlockMap([
["0,0,0", 1],
["15,15,15", 2],
["16,0,0", 3],
["0,0,0", 4],
]);
assert.ok(blocks instanceof Map);
assert.equal(blocks.size, 3);
assert.equal(blocks.set("0,0,0", 5), blocks);
assert.deepEqual(
[...blocks],
[
["0,0,0", 5],
["15,15,15", 2],
["16,0,0", 3],
],
);
assert.deepEqual([...blocks.keysInSection("0,0,0")], ["0,0,0", "15,15,15"]);
assert.deepEqual([...blocks.loadedSectionKeys()], ["0,0,0", "1,0,0"]);
assert.deepEqual([...new SectionBlockMap(blocks)], [...blocks]);
});
test("section boundaries use floor division independently on negative axes", () => {
const blocks = new SectionBlockMap([
["-1,-16,-17", 1],
["-16,-1,-32", 2],
["-17,0,16", 3],
["0,-17,-1", 4],
]);
assert.deepEqual(
[...blocks.keysInSection("-1,-1,-2")],
["-1,-16,-17", "-16,-1,-32"],
);
assert.deepEqual([...blocks.keysInSection("-2,0,1")], ["-17,0,16"]);
assert.deepEqual([...blocks.keysInSection("0,-2,-1")], ["0,-17,-1"]);
assert.deepEqual([...blocks.keysInSection("0,0,0")], []);
});
test("delete removes empty sections and clear permits clean reuse", () => {
const blocks = new SectionBlockMap([
["0,0,0", 1],
["1,0,0", 2],
["16,0,0", 3],
]);
assert.equal(blocks.delete("0,0,0"), true);
assert.equal(blocks.delete("0,0,0"), false);
assert.deepEqual([...blocks.keysInSection("0,0,0")], ["1,0,0"]);
assert.equal(blocks.delete("1,0,0"), true);
assert.deepEqual([...blocks.keysInSection("0,0,0")], []);
assert.deepEqual([...blocks.loadedSectionKeys()], ["1,0,0"]);
assert.equal(blocks.clear(), undefined);
assert.equal(blocks.size, 0);
assert.deepEqual([...blocks.loadedSectionKeys()], []);
assert.deepEqual([...blocks.keysInSection("1,0,0")], []);
blocks.set("0,0,0", 4);
assert.deepEqual([...blocks.keysInSection("0,0,0")], ["0,0,0"]);
assert.deepEqual([...blocks.loadedSectionKeys()], ["0,0,0"]);
});
test("section key iterators allow deleting every yielded block without skipping", () => {
const blocks = new SectionBlockMap([
["0,0,0", 1],
["1,0,0", 2],
["2,0,0", 3],
["16,0,0", 4],
]);
for (const key of blocks.keysInSection("0,0,0")) blocks.delete(key);
assert.deepEqual([...blocks], [["16,0,0", 4]]);
assert.deepEqual([...blocks.loadedSectionKeys()], ["1,0,0"]);
});
test("validated section insertion and bulk deletion preserve other sections and live iterators", () => {
const blocks = new SectionBlockMap([["0,0,0", 1]]);
assert.equal(blocks.setInSection("-17,-1,16", 2, "-2,-1,1"), blocks);
blocks.setInSection("-18,-2,17", 3, "-2,-1,1");
blocks.setInSection("-17,-1,16", 4, "-2,-1,1");
assert.deepEqual(
[...blocks.keysInSection("-2,-1,1")],
["-17,-1,16", "-18,-2,17"],
);
const keys = blocks.keysInSection("-2,-1,1");
assert.equal(blocks.deleteSection("-2,-1,1"), 2);
assert.equal(blocks.deleteSection("-2,-1,1"), 0);
assert.deepEqual([...keys], []);
assert.deepEqual([...blocks], [["0,0,0", 1]]);
assert.deepEqual([...blocks.loadedSectionKeys()], ["0,0,0"]);
blocks.set("-17,-1,16", 5);
assert.deepEqual([...blocks.keysInSection("-2,-1,1")], ["-17,-1,16"]);
assert.equal(blocks.delete("-17,-1,16"), true);
blocks.setInSection("16,0,0", 6, "1,0,0");
blocks.clear();
assert.deepEqual([...blocks.loadedSectionKeys()], []);
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {SectionVoxelMap} from '../section-voxel-map.js';
import {decodeSection,applySectionView,applySectionBatch} from '../section-stream.js';
import {samplePhysicsWorld} from '../player-physics.js';
const record=(section,id=1)=>({section,palette:[id],runs:[4096,0]});
const view=()=>({world:'world',revision:0,blocks:new SectionVoxelMap(),loadedSections:new Set(),viewGeneration:0});
const message=(generation=1)=>({world:'world',revision:0,generation,view_center:[0,0,0],view_min:[-48,-32,-48],view_max:[63,47,63],unload:[]});
test('compact sections decode known air and preserve negative coordinates without per-voxel keys',()=>{
const world=new SectionVoxelMap(),cells=decodeSection(record([-1,-4,1],3)).cells;
world.setSection('-1,-4,1',cells);assert.equal(world.getAt(-1,-64,16),3);assert.equal(world.size,4096);assert.equal(world.byteLength,4);
world.delete('-1,-64,16');assert.equal(world.size,4095);assert.equal(world.getAt(-1,-64,16),0);
world.setSection('0,0,0',new Uint32Array(4096));assert.equal(world.hasSection('0,0,0'),true);assert.equal(world.size,4095);
assert.deepEqual([...world.materialIds()],[3]);world.deleteSection('-1,-4,1');assert.equal(world.size,0);assert.equal(world.sectionCount,1);
});
test('malformed compact sections are rejected before any valid section is applied',()=>{
const v=view();applySectionView(v,message());
const bad={...record([1,0,0]),runs:[4097,0]};
assert.equal(applySectionBatch(v,{...message(),sections:[record([0,0,0]),bad]}).status,'resync');assert.equal(v.blocks.size,0);
for(const runs of [[4095,0],[4096,1],[0,0],[4096.1,0],[4096,-1]])assert.throws(()=>decodeSection({...record([0,0,0]),runs}));
});
test('new views retain overlap and reject delayed batches from previous generations',()=>{
const v=view();applySectionView(v,message());
applySectionBatch(v,{...message(),sections:[record([-3,0,0]),record([0,0,0])]});const cells=[...v.blocks.sectionEntries()][1][1];
const moved={...message(2),view_center:[16,0,0],view_min:[-32,-32,-48],view_max:[79,47,63],unload:[[-3,0,0]]};
assert.deepEqual(applySectionView(v,moved).unload,[[-3,0,0]]);assert.equal(v.blocks.hasSection('-3,0,0'),false);
assert.equal([...v.blocks.sectionEntries()][0][1],cells);
assert.equal(applySectionBatch(v,{...message(),sections:[record([-3,0,0])]}).status,'ignored');
assert.equal(v.blocks.hasSection('-3,0,0'),false);
});
test('collision sampling distinguishes missing terrain from a received air section',()=>{
const world=new SectionVoxelMap(),body={position:[8,3,8],velocity:[0,0,0],pose:'standing'},bounds={min:[0,0,0],max:[15,15,15]};
assert.equal(samplePhysicsWorld(body,world,new Map(),bounds),null);
world.setSection('0,0,0',new Uint32Array(4096));assert.deepEqual(samplePhysicsWorld(body,world,new Map(),bounds),{blocks:[]});
});
test('uniform sections remain compact across worker copies and expand only when edited',()=>{
const world=new SectionVoxelMap();
world.setSection('0,0,0',new Uint32Array([7]));
world.setSection('1,0,0',new Uint32Array([0]));
assert.equal(world.byteLength,8);assert.equal(world.size,4096);
const samples=[];world.forEachBlock((x,y,z,id)=>samples.push([x,y,z,id]),{min:[14,0,0],max:[17,0,0]});
assert.deepEqual(samples,[[14,0,0,7],[15,0,0,7]]);
assert.equal([...world.keysInSection('1,0,0')].length,0);
world.set('15,0,0',9);assert.equal(world.byteLength,16388);
assert.equal(world.getAt(14,0,0),7);assert.equal(world.getAt(15,0,0),9);
assert.deepEqual([...world.materialIds()],[7,9]);
world.deleteSection('0,0,0');assert.equal(world.byteLength,4);assert.equal(world.size,0);
world.clear();assert.equal(world.byteLength,0);
});
test('full-height worlds allow flight above the ceiling but still require interior collision data',()=>{
const blocks=new SectionVoxelMap(),bounds={min:[-32,-64,-32],max:[47,319,47],fullHeight:true};
const body={position:[8,400,8],velocity:[0,0,0],pose:'standing'};
assert.deepEqual(samplePhysicsWorld(body,blocks,new Map(),bounds),{blocks:[]});
body.position[1]=310;assert.equal(samplePhysicsWorld(body,blocks,new Map(),bounds),null);
blocks.setSection('0,19,0',new Uint32Array([0]));
assert.deepEqual(samplePhysicsWorld(body,blocks,new Map(),bounds),{blocks:[]});
});
test('large uniform batches load compactly and reject oversized batches atomically',()=>{
const v=view(),m={...message(),view_min:[-48,-64,-48],view_max:[63,319,63],full_height:true};
applySectionView(v,m);
const sections=Array.from({length:128},(_,i)=>record([i%7-3,Math.floor(i/7)-4,0],0));
assert.equal(applySectionBatch(v,{...m,sections}).status,'applied');
assert.equal(v.blocks.sectionCount,128);assert.equal(v.blocks.byteLength,512);
const oversized=[...sections,record([0,0,1])];
assert.equal(applySectionBatch(v,{...m,sections:oversized}).status,'resync');
assert.equal(v.blocks.hasSection('0,0,1'),false);
});
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { shadowFrame } from '../shadow-frame.js';
const sun = [-.5, .78, .37];
const clip = (m, p) => [0, 1, 2].map(i => m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12]);
test('shadow projection puts the camera neighborhood in range and light-facing objects closer', () => {
for (const eye of [[0, 2, 8], [-123.5, 70, 400], [32700, 50, -32700]]) {
const { matrix, center } = shadowFrame(eye, sun);
assert.ok(clip(matrix, center).every(v => Math.abs(v) < 1e-4));
const near = clip(matrix, center.map((v, i) => v + sun[i] * 20));
const far = clip(matrix, center.map((v, i) => v - sun[i] * 20));
assert.ok(near[2] < far[2]);
assert.ok([...near, ...far].every(v => v >= -1 && v <= 1));
}
});
test('sub-texel camera movement keeps the shadow grid fixed', () => {
const a = shadowFrame([0, 0, 0], sun).matrix;
const b = shadowFrame([.001, .001, .001], sun).matrix;
for (const i of [0, 1, 4, 5, 8, 9, 12, 13]) assert.equal(a[i], b[i]);
});
+335
View File
@@ -0,0 +1,335 @@
import test, { afterEach } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { TerrainController } from "../terrain-controller.js";
import { TerrainState } from "../terrain-state.js";
import { SectionBlockMap } from "../section-block-map.js";
const properties = JSON.parse(
await readFile(new URL("../light-properties.json", import.meta.url)),
);
const controllers = [];
const bounds = { min: [-1, -1, -1], max: [18, 3, 3] };
const materials = new Map([
[1, { state: "minecraft:stone", color: [128, 128, 128] }],
[2, { state: "minecraft:torch", color: [255, 200, 100], light: 14 }],
]);
class FakeWorker {
packets = [];
transfers = [];
postMessage(packet, transfers = []) {
this.transfers.push(transfers.length);
this.packets.push(structuredClone(packet, { transfer: transfers }));
}
terminate() {
this.terminated = true;
}
reply(message) {
this.onmessage({ data: message });
}
light(packet, dirty = [], value = 0) {
this.reply({
type: "light",
epoch: packet.epoch,
version: packet.version,
dirty,
milliseconds: 1,
field: {
min: [0, 0, 0],
size: [1, 1, 1],
sourceCount: value ? 1 : 0,
data: new Uint8Array(4),
blockLevels: new Uint8Array([value]),
skyLevels: new Uint8Array([15]),
},
});
}
mesh(packet) {
this.reply({
...packet,
vertices: new Float32Array(20),
transparent: new Float32Array(),
milliseconds: 1,
});
}
}
function fixture() {
const worker = new FakeWorker(),
published = [],
dirty = new Set();
const controller = new TerrainController({
workerFactory: () => worker,
onLight: (field) => published.push(field),
onDirty: (ids) => {
for (const id of ids) dirty.add(id);
},
});
controllers.push(controller);
return { controller, worker, published, dirty };
}
afterEach(() => {
for (const controller of controllers.splice(0)) controller.dispose();
});
function queuedClock(t) {
const callbacks = new Map();
let id = 0,
now = 0;
t.mock.method(globalThis, "setTimeout", (callback) => {
callbacks.set(++id, callback);
return id;
});
t.mock.method(globalThis, "clearTimeout", (id) => callbacks.delete(id));
t.mock.method(performance, "now", () => (now += 3));
return () => {
for (let count = 0; callbacks.size; count++) {
assert.ok(count < 100, "Preparation failed to finish");
const [id, callback] = callbacks.entries().next().value;
callbacks.delete(id);
callback();
}
};
}
test("edits during incremental reset packing replay over the captured entries coherently", (t) => {
const drain = queuedClock(t),
{ controller, worker } = fixture();
const blocks = new SectionBlockMap(
Array.from({ length: 140 }, (_, x) => [`${x},0,0`, 1]),
);
controller.reset(blocks, materials, new Map(), bounds);
controller.flush();
assert.equal(controller.preparing, true);
blocks.delete("0,0,0");
blocks.set("1,0,0", 2);
blocks.set("200,0,0", 2);
controller.update({
changes: [
{ pos: [0, 0, 0], block: 0 },
{ pos: [1, 0, 0], block: 2 },
{ pos: [200, 0, 0], block: 2 },
],
});
drain();
assert.equal(worker.packets.length, 1);
const state = new TerrainState(properties),
packet = worker.packets[0];
assert.equal(packet.reset, true);
assert.equal(packet.version, controller.version);
assert.ok(packet.initial instanceof Int32Array);
assert.ok(packet.changes instanceof Int32Array);
assert.equal(state.sync(packet), true);
assert.deepEqual([...state.blocks], [...blocks]);
});
test("a replacement world cancels old reset preparation and rejects old light and mesh replies", (t) => {
const drain = queuedClock(t),
{ controller, worker, published, dirty } = fixture();
controller.reset(
new SectionBlockMap(Array.from({ length: 140 }, (_, x) => [`${x},0,0`, 1])),
materials,
new Map(),
bounds,
);
controller.flush();
assert.equal(controller.preparing, true);
const oldEpoch = controller.epoch;
controller.reset(
new SectionBlockMap([["16,0,0", 2]]),
materials,
new Map(),
bounds,
);
controller.flush();
drain();
assert.equal(worker.packets.length, 1);
const packet = worker.packets[0];
assert.deepEqual([...packet.initial], [16, 0, 0, 2]);
worker.light(packet, ["1,0,0"], 14);
assert.equal(controller.requestMesh("1,0,0"), true);
const job = controller.busy;
worker.light({ epoch: oldEpoch, version: packet.version }, ["old-world"], 15);
worker.mesh({ ...job, epoch: oldEpoch });
assert.equal(controller.busy, job);
assert.deepEqual([...dirty], ["1,0,0"]);
assert.equal(published.length, 1);
assert.equal(published[0].sample([0, 0, 0]).blockLevel, 14);
worker.mesh(job);
assert.equal(controller.ready.get("1,0,0").epoch, controller.epoch);
});
test("subsequent syncs send deduplicated numeric deltas and only changed materials or textures", () => {
const { controller, worker } = fixture();
const blocks = new SectionBlockMap([["0,0,0", 1]]),
textures = new Map([["stone", 0]]);
controller.reset(blocks, materials, textures, bounds);
controller.flush();
blocks.entries = () => {
throw Error("Incremental sync scanned the world");
};
blocks[Symbol.iterator] = blocks.entries;
controller.update({ changes: [{ pos: [0, 0, 0], block: 2 }] });
controller.update({
changes: [
{ pos: [0, 0, 0], block: 0 },
{ pos: [16, 0, 0], block: 1 },
],
});
controller.flush();
const delta = worker.packets[1];
assert.equal(delta.reset, false);
assert.equal(delta.initial, null);
assert.equal(delta.blocks, undefined);
assert.deepEqual([...delta.changes], [0, 0, 0, 0, 16, 0, 0, 1]);
assert.deepEqual(delta.materials, []);
assert.equal(delta.textures, undefined);
assert.equal(worker.transfers[1], 1);
const updated = new Map(materials);
updated.set(2, {
...materials.get(2),
light: 15,
ignoredMetadata: "not copied",
});
controller.update({ materials: updated, textures: new Map([["stone", 7]]) });
controller.flush();
assert.deepEqual(
worker.packets[2].materials.map(([id]) => id),
[2],
);
assert.equal(worker.packets[2].materials[0][1].light, 15);
assert.equal(worker.packets[2].materials[0][1].ignoredMetadata, undefined);
assert.deepEqual(worker.packets[2].textures, [["stone", 7]]);
});
test("skipped intermediate light fields contribute their dirty union without being published", () => {
const { controller, worker, dirty, published } = fixture();
controller.reset(
new SectionBlockMap([["0,0,0", 1]]),
materials,
new Map(),
bounds,
);
controller.flush();
const old = worker.packets[0];
controller.update({ changes: [{ pos: [0, 0, 0], block: 2 }] });
controller.flush();
const latest = worker.packets[1];
worker.light(old, ["0,0,0", "1,0,0"], 0);
assert.equal(published.length, 0);
assert.equal(controller.requestMesh("0,0,0"), false);
worker.light(latest, ["1,0,0", "2,0,0"], 14);
assert.equal(published.length, 1);
assert.deepEqual([...dirty], ["0,0,0", "1,0,0", "2,0,0"]);
assert.equal(controller.requestMesh("0,0,0"), true);
});
test("mesh requests allow one in flight and two ready, and stale replies cannot release a newer job", () => {
const { controller, worker } = fixture();
controller.reset(
new SectionBlockMap([["0,0,0", 1]]),
materials,
new Map(),
bounds,
);
controller.flush();
worker.light(worker.packets[0]);
assert.equal(controller.requestMesh("0,0,0"), true);
assert.equal(controller.requestMesh("1,0,0"), false);
worker.mesh(controller.busy);
assert.equal(controller.requestMesh("0,0,0"), false);
assert.equal(controller.requestMesh("1,0,0"), true);
worker.mesh(controller.busy);
assert.equal(controller.ready.size, 2);
assert.equal(controller.requestMesh("2,0,0"), false);
controller.ready.delete("0,0,0");
assert.equal(controller.requestMesh("2,0,0"), true);
const stale = controller.busy;
controller.update({ unload: [[2, 0, 0]] });
assert.equal(controller.ready.size, 0);
controller.flush();
worker.light(worker.packets.at(-1));
assert.equal(controller.requestMesh("0,0,0"), false);
worker.mesh(stale);
assert.equal(controller.ready.has("2,0,0"), false);
assert.equal(controller.requestMesh("0,0,0"), true);
const current = controller.busy;
worker.mesh(stale);
assert.equal(controller.busy, current);
worker.mesh(current);
assert.deepEqual([...controller.ready.keys()], ["0,0,0"]);
});
test("a section unloaded then re-entered within one flush keeps only its newer blocks", () => {
const { controller, worker } = fixture(),
state = new TerrainState(properties);
controller.reset(
new SectionBlockMap([
["16,0,0", 1],
["17,0,0", 1],
]),
materials,
new Map(),
bounds,
);
controller.flush();
state.sync(worker.packets[0]);
controller.update({ changes: [{ pos: [17, 0, 0], block: 2 }] });
controller.update({ unload: [[1, 0, 0]] });
controller.update({ changes: [{ pos: [16, 0, 0], block: 2 }] });
controller.flush();
state.sync(worker.packets[1]);
assert.deepEqual([...state.blocks], [["16,0,0", 2]]);
});
test("complete sections replace queued edits and replay newer edits after their numeric records", () => {
const { controller, worker } = fixture(), state = new TerrainState(properties);
controller.reset(new SectionBlockMap([["16,0,0", 1], ["17,0,0", 1]]), materials, new Map(), bounds);
controller.flush(); state.sync(worker.packets[0]);
controller.update({ changes: [{ pos: [17,0,0], block: 2 }] });
controller.update({ sections: [{ section: [1,0,0], blocks: [{ pos: [18,0,0], block: 1 }] }] });
controller.update({ changes: [{ pos: [18,0,0], block: 2 }] });
controller.flush(); state.sync(worker.packets[1]);
assert.deepEqual([...state.blocks], [["18,0,0", 2]]);
assert.deepEqual([...worker.packets[1].changes], [18,0,0,1,18,0,0,2]);
controller.update({ sections: [{ section: [1,0,0], blocks: [{ pos: [19,0,0], block: 1 }] }] });
controller.update({ unload: [[1,0,0]] });
controller.update({ sections: [{ section: [1,0,0], blocks: [{ pos: [20,0,0], block: 2 }] }] });
controller.flush(); state.sync(worker.packets[2]);
assert.deepEqual([...state.blocks], [["20,0,0", 2]]);
controller.update({ sections: [{ section: [1,0,0], blocks: [] }] });
controller.flush(); state.sync(worker.packets[3]);
assert.equal(state.blocks.size, 0, "An empty complete section clears old contents");
});
test("streamed section records are packed at flush without indexing each voxel on arrival", () => {
const { controller, worker } = fixture();
controller.reset(new SectionBlockMap(), materials, new Map(), bounds);
controller.flush();
const records = [{ pos: [16,0,0], block: 1 }];
controller.changes.set = () => { throw Error("Section records must not pass through the per-edit index"); };
controller.update({ sections: [{ section: [1,0,0], blocks: records }] });
assert.equal(controller.sectionChanges.get("1,0,0"), records);
controller.flush();
assert.deepEqual([...worker.packets[1].changes], [16,0,0,1]);
assert.equal(controller.sectionChanges.size, 0);
});
test("runtime worker errors notify the renderer asynchronously and stop after disposal", async t => {
t.mock.method(console, "error", () => {});
const worker = new FakeWorker(), errors = [];
const controller = new TerrainController({ workerFactory: () => worker, onError: message => errors.push(message) });
controllers.push(controller);
let prevented = false;
worker.onerror({ message: "Worker stopped", preventDefault() { prevented = true; } });
assert.equal(prevented, true);
assert.deepEqual(errors, []);
await Promise.resolve();
assert.deepEqual(errors, ["Worker stopped"]);
worker.onerror({ message: "Obsolete error", preventDefault() {} });
controller.dispose();
await Promise.resolve();
assert.deepEqual(errors, ["Worker stopped"]);
});
+253
View File
@@ -0,0 +1,253 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { TerrainState } from "../terrain-state.js";
import { MESH_VERTEX_STRIDE } from "../mesh-geometry.js";
const properties = JSON.parse(
await readFile(new URL("../light-properties.json", import.meta.url)),
);
const cube = { min: [0, 0, 0], max: [1, 1, 1] };
const stone = {
state: "minecraft:stone",
color: [128, 128, 128],
render: [cube],
};
const lamp = {
state: "minecraft:sea_lantern",
color: [240, 250, 250],
render: [cube],
light: 15,
};
const bounds = { min: [-2, -2, -2], max: [18, 3, 3] };
function packet(overrides = {}) {
return {
type: "sync",
epoch: 1,
version: 1,
reset: true,
initial: new Int32Array([0, 0, 0, 1]),
changes: new Int32Array(),
materials: [
[1, stone],
[2, lamp],
],
textures: [],
bounds,
unload: [],
...overrides,
};
}
const request = (state, id = "0,0,0") => ({
epoch: state.epoch,
version: state.version,
id,
job: 1,
});
test("reset plus deltas preserves numeric coordinates and authoritative material lighting metadata", () => {
const state = new TerrainState(properties);
assert.equal(
state.sync(
packet({
initial: new Int32Array([-1, 0, 0, 1, 16, 0, 0, 1]),
changes: new Int32Array([-1, 0, 0, 0, 16, 0, 0, 2]),
}),
),
true,
);
assert.deepEqual([...state.blocks], [["16,0,0", 2]]);
assert.deepEqual([...state.blocks.loadedSectionKeys()], ["1,0,0"]);
assert.equal(state.materials.get(1).light_dampening, 15);
assert.equal(stone.light_dampening, undefined);
assert.equal(state.mesh(request(state, "1,0,0")).stale, true);
const result = state.light();
assert.equal(result.field.sourceCount, 1);
assert.equal(state.field.sample([16, 0, 0]).blockLevel, 15);
assert.notEqual(result.field.data.buffer, state.field.data.buffer);
assert.notEqual(
result.field.blockLevels.buffer,
state.field.blockLevels.buffer,
);
assert.notEqual(result.field.skyLevels.buffer, state.field.skyLevels.buffer);
assert.ok(state.mesh(request(state, "1,0,0")).vertices.length > 0);
});
test("outdated syncs and mesh requests cannot overwrite a new version or world", () => {
const state = new TerrainState(properties);
state.sync(packet());
state.light();
state.sync(
packet({ epoch: 2, version: 5, initial: new Int32Array([16, 0, 0, 2]) }),
);
assert.equal(state.sync(packet({ version: 100 })), false);
assert.equal(state.sync(packet({ epoch: 2, version: 4 })), false);
assert.equal(
state.sync(packet({ epoch: 3, version: 6, reset: false })),
false,
);
assert.deepEqual([...state.blocks], [["16,0,0", 2]]);
assert.equal(state.field, null);
assert.equal(
state.mesh({ epoch: 1, version: 1, id: "0,0,0", job: 1 }).stale,
true,
);
state.light();
assert.equal(
state.mesh({ epoch: 2, version: 4, id: "1,0,0", job: 2 }).stale,
true,
);
assert.ok(state.mesh(request(state, "1,0,0")).vertices.length > 0);
});
test("unloaded sections disappear from persistent storage and cannot be resurrected by old syncs", () => {
const state = new TerrainState(properties);
state.sync(packet({ initial: new Int32Array([0, 0, 0, 1, 16, 0, 0, 2]) }));
state.light();
state.mesh(request(state, "1,0,0"));
assert.equal(state.meshed.has("1,0,0"), true);
state.sync(
packet({
reset: false,
version: 2,
initial: null,
materials: [],
unload: [[1, 0, 0]],
}),
);
assert.equal(state.blocks.has("16,0,0"), false);
assert.deepEqual([...state.blocks.keysInSection("1,0,0")], []);
assert.equal(state.meshed.has("1,0,0"), false);
state.light();
assert.equal(state.mesh(request(state, "1,0,0")).vertices.length, 0);
assert.equal(
state.sync(packet({ initial: new Int32Array([16, 0, 0, 2]) })),
false,
);
assert.equal(state.blocks.has("16,0,0"), false);
});
test("material and texture replacements affect the next mesh and invalidate face texture cache", () => {
const state = new TerrainState(properties);
state.sync(packet({ textures: [["stone", 3]] }));
state.light();
const first = state.mesh(request(state));
assert.equal(first.vertices.length, 36 * MESH_VERTEX_STRIDE);
assert.equal(first.vertices[13], 3);
assert.equal(state.faceTextureCache.get("minecraft:stone")[0].layer, 3);
state.sync(
packet({
reset: false,
version: 2,
initial: null,
materials: [
[
1,
{
...stone,
render: [{ min: [0, 0, 0], max: [1, 0.5, 1] }],
light: 7,
},
],
],
textures: [["stone", 9]],
}),
);
assert.equal(state.faceTextureCache.size, 0);
assert.equal(state.mesh(request(state)).stale, true);
state.light();
const next = state.mesh(request(state));
for (let i = 0; i < next.vertices.length; i += MESH_VERTEX_STRIDE) {
assert.ok(next.vertices[i + 1] <= 0.5);
assert.equal(next.vertices[i + 13], 9);
assert.ok(Math.abs(next.vertices[i + 15] - 7 / 15) < 1e-6);
}
assert.equal(state.faceTextureCache.get("minecraft:stone")[0].layer, 9);
});
test("lighting changes keep emptied meshed sections dirty until their stale geometry is replaced", () => {
const state = new TerrainState(properties);
state.sync(packet({ initial: new Int32Array([0, 0, 0, 2]) }));
state.light();
state.mesh(request(state));
state.sync(
packet({
reset: false,
version: 2,
initial: null,
materials: [],
changes: new Int32Array([0, 0, 0, 0]),
}),
);
assert.equal(state.blocks.size, 0);
assert.ok(state.light().dirty.includes("0,0,0"));
assert.equal(state.mesh(request(state)).vertices.length, 0);
});
test("a retained unknown cube remeshes when its same-light definition arrives", () => {
const state = new TerrainState(properties);
state.sync(packet({ initial: new Int32Array([15, 0, 0, 99, 16, 0, 0, 1]) }));
state.light();
const before = state.mesh(request(state));
state.mesh(request(state, "1,0,0"));
const oldLight = state.field.data.slice(), oldSky = state.field.skyLevels.slice();
const defined = { ...stone, color: [60, 70, 80] };
state.sync(packet({ reset: false, version: 2, initial: null, materials: [[99, defined]] }));
const result = state.light();
assert.deepEqual(state.field.data, oldLight);
assert.deepEqual(state.field.skyLevels, oldSky);
assert.ok(result.dirty.includes("0,0,0"), "the placeholder's owner must be rebuilt even with identical lighting");
assert.ok(result.dirty.includes("1,0,0"), "neighbor culling/AO must also be refreshed");
const after = state.mesh(request(state));
assert.notDeepEqual(after.vertices.slice(3, 6), before.vertices.slice(3, 6));
assert.deepEqual([...after.vertices.slice(3, 6)], defined.color.map(value => Math.fround(value / 255)));
});
test("repeated equivalent material definitions do not spuriously dirty retained sections", () => {
const state = new TerrainState(properties);
state.sync(packet());
state.light(); state.mesh(request(state));
const current = state.materials.get(1);
const repeated = { render: [{ max: [1, 1, 1], min: [0, 0, 0] }], color: [128, 128, 128],
state: "minecraft:stone", ignoredMetadata: "does not affect the transmitted material" };
state.sync(packet({ reset: false, version: 2, initial: null, materials: [[1, repeated]] }));
assert.equal(state.materials.get(1), current);
assert.deepEqual(state.light().dirty, []);
});
test("a color-only definition replacement invalidates its mesh without a light delta", () => {
const state = new TerrainState(properties);
state.sync(packet());
state.light(); state.mesh(request(state));
const oldLight = state.field.data.slice(), oldSky = state.field.skyLevels.slice();
state.sync(packet({ reset: false, version: 2, initial: null, materials: [[1, { ...stone, color: [220, 70, 30] }]] }));
assert.ok(state.light().dirty.includes("0,0,0"));
assert.deepEqual(state.field.data, oldLight);
assert.deepEqual(state.field.skyLevels, oldSky);
assert.deepEqual([...state.mesh(request(state)).vertices.slice(3, 6)], [220, 70, 30].map(value => Math.fround(value / 255)));
});
test("material invalidations from separate coalesced syncs retain their union", () => {
const state = new TerrainState(properties), wide = { min: [-2, -2, -2], max: [50, 3, 3] };
state.sync(packet({ initial: new Int32Array([0, 0, 0, 1, 48, 0, 0, 3]), materials: [[1, stone], [3, stone]], bounds: wide }));
state.light(); state.mesh(request(state)); state.mesh(request(state, "3,0,0"));
state.sync(packet({ reset: false, version: 2, initial: null, materials: [[1, { ...stone, color: [10, 20, 30] }]], bounds: wide }));
state.sync(packet({ reset: false, version: 3, initial: null, materials: [[3, { ...stone, color: [30, 20, 10] }]], bounds: wide }));
assert.deepEqual(new Set(state.light().dirty), new Set(["0,0,0", "3,0,0"]));
assert.deepEqual(state.light().dirty, [], "the material union is consumed after a successful field publication");
});
test("world reset clears pending material geometry invalidations", () => {
const state = new TerrainState(properties), wide = { min: [-2, -2, -2], max: [50, 3, 3] };
state.sync(packet()); state.light(); state.mesh(request(state));
state.sync(packet({ reset: false, version: 2, initial: null, materials: [[1, { ...stone, color: [10, 20, 30] }]] }));
state.sync(packet({ epoch: 2, version: 3, initial: new Int32Array([48, 0, 0, 1]), materials: [[1, stone]], bounds: wide }));
assert.deepEqual(state.light().dirty, ["3,0,0"]);
});
test("a new definition absent from the loaded blocks does not remesh terrain", () => {
const state = new TerrainState(properties);
state.sync(packet()); state.light(); state.mesh(request(state));
state.sync(packet({ reset: false, version: 2, initial: null, materials: [[99, lamp]] }));
assert.deepEqual(state.light().dirty, []);
});
+148
View File
@@ -0,0 +1,148 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Shacraft terrain streaming benchmark</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#e9eee8;color:#19352a;font:14px/1.4 system-ui,sans-serif}
header,main{width:min(100%,1000px);margin:auto;padding:14px 20px}header{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
canvas{display:block;width:960px;max-width:100%;height:540px;background:#8aacbc}select,button{font:inherit;padding:6px 10px}
output{display:block;white-space:pre-wrap;margin:12px 0;font-variant-numeric:tabular-nums}p{color:#486251}button{cursor:pointer}
</style>
<header><strong>Terrain streaming</strong><label>Meshing <select id="mode"><option value="worker">Worker</option><option value="baseline">Baseline · main thread</option></select></label><button id="run" disabled>Run 12 seconds</button><span id="status">Preparing scene…</span></header>
<main><canvas id="world" aria-label="Synthetic terrain streaming scene"></canvas><output id="results">Initial loading and warmup are excluded from measurements.</output><p>64 × 64 visible floor, lamp grid, glass and stairs. Six boundaries at 8 blocks/s. CPU draw measures JavaScript and WebGL submission, not GPU completion. Keep this tab visible.</p></main>
<script type="module">
import { Renderer } from "../renderer.js?v=terrain-worker-1";
import { SectionBlockMap } from "../section-block-map.js";
import { mergeChunkUpdate } from "../world-view.js";
import { BlockLightController } from "../block-light-controller.js";
import { changedLightSections } from "../light-changes.js";
const $ = id => document.getElementById(id), mode = new URL(location.href).searchParams.get("mode") === "baseline" ? "baseline" : "worker";
$("mode").value = mode;
$("mode").onchange = () => { const url = new URL(location.href); url.searchParams.set("mode", $("mode").value); location.href = url; };
const cube = { min: [0,0,0], max: [1,1,1] }, materials = new Map([
[1, { state: "minecraft:grass_block", color: [114,157,74], render: [cube] }],
[2, { state: "minecraft:stone", color: [154,158,153], render: [cube] }],
[3, { state: "minecraft:sea_lantern", color: [223,243,222], render: [cube], light: 15 }],
[4, { state: "minecraft:glass", color: [162,219,221], render: [cube], opacity: 0.3 }],
[5, { state: "minecraft:stone_stairs[facing=east,half=bottom,shape=straight]", color: [169,168,153], render: [
{min:[0,0,0],max:[1,0.5,1]}, {min:[0.5,0.5,0],max:[1,1,1]}
] }],
]);
// Precompute the synthetic server records before timing; no network or shared world is used.
const records = new Map(), sectionId = pos => pos.map(v => Math.floor(v / 16)).join(",");
const put = (x,y,z,block) => { const pos = [x,y,z], id = sectionId(pos); if (!records.has(id)) records.set(id, []); records.get(id).push({pos,block}); };
for (let x = -32; x < 128; x++) for (let z = -32; z < 32; z++) {
put(x,-2,z,2); put(x,-1,z,2); put(x,0,z,1);
if (x % 8 === 0 && z % 8 === 0) put(x,1,z,3);
else if ((x + 160) % 16 === 4 && (z + 32) % 16 < 5) { put(x,1,z,4); put(x,2,z,4); }
else if ((x + 160) % 16 === 9 && (z + 32) % 8 < 3) put(x,1,z,5);
}
const windowAt = center => {
const result = new Map(), c = center.map(v => v / 16);
for (let x=c[0]-2;x<=c[0]+1;x++) for(let y=c[1]-1;y<=c[1]+1;y++) for(let z=c[2]-2;z<=c[2]+1;z++) result.set(`${x},${y},${z}`,[x,y,z]);
return result;
};
const boundsAt = center => ({ min: center.map((v,i) => v-(i===1?16:32)), max: center.map(v => v+31) });
let renderer, view, phase = "loading", stableSince = null, warmStarted = 0, previousFrame = null, started = 0, autoRun = false;
let frames = [], draws = [], updates = [], transitionTimings = [], transitions = 0, retainedChecks = 0, retainedFailures = 0, tracked = new Map(), errors = new Set();
const camera = { eye: [8,4.6,8], yaw: Math.PI / 2, pitch: -0.19 }, emptyDefinitions = new Map();
const assert = (condition, message) => { if (!condition) { retainedFailures++; throw Error(message); } };
function fail(error) {
phase = "error"; document.body.dataset.status = "error"; document.body.dataset.error = error.message;
$("status").textContent = "Benchmark failed"; $("results").textContent = error.stack || String(error); $("run").disabled = true;
}
function prepare(repeat = false) {
frames=[]; draws=[]; updates=[]; transitionTimings=[]; transitions=0; retainedChecks=0; retainedFailures=0; tracked.clear(); errors.clear(); previousFrame=null;
view = { world: "isolated-streaming-fixture", revision: 1, viewCenter: [0,0,0], viewBounds: boundsAt([0,0,0]), blocks: new SectionBlockMap() };
for (const id of windowAt(view.viewCenter).keys()) for (const {pos,block} of records.get(id)||[]) view.blocks.set(pos.join(","),block);
camera.eye[0]=8; renderer.setLightBounds(view.viewBounds); renderer.replace(view.blocks,materials);
phase="warming"; autoRun=repeat; stableSince=null; warmStarted=performance.now(); $("run").disabled=true;
document.body.dataset.status=phase; document.body.dataset.mode=mode; delete document.body.dataset.results;
$("status").textContent="Loading meshes and warming up…";
}
function begin(time) {
phase="running"; started=time; previousFrame=time; document.body.dataset.status=phase;
$("run").disabled=true; $("mode").disabled=true; $("results").textContent="Measuring 12 seconds of streaming…";
}
function transition(center) {
const start=performance.now();
const old=windowAt(view.viewCenter), next=windowAt(center), unload=[...old].filter(([id])=>!next.has(id)).map(([,pos])=>pos);
const sections=[...next].filter(([id])=>!old.has(id)).map(([id,section])=>({section,blocks:records.get(id)||[]}));
const bounds=boundsAt(center), blocks=view.blocks, before=new Map(renderer.sections), distance=Math.abs(center[0]-view.viewCenter[0])/16;
const message={world:view.world,revision:view.revision,from_center:[...view.viewCenter],view_center:center,view_min:bounds.min,view_max:bounds.max,unload,sections};
const mergeStart=performance.now(), result=mergeChunkUpdate(view,message,{includeUnloadedChanges:false}), merged=performance.now();
assert(result.status==="applied","Chunk transition rejected");
assert(view.blocks===blocks,"World map identity changed");
renderer.setLightBounds(view.viewBounds); const bounded=performance.now();
renderer.change(result.changes,[...sections.map(s=>s.section),...unload],sections); const changed=performance.now();
renderer.unloadSections(unload); const unloaded=performance.now();
for(const pos of unload) tracked.delete(pos.join(","));
for(const [id,mesh] of before) if(next.has(id)) { retainedChecks++; assert(renderer.sections.get(id)===mesh,`Retained mesh ${id} replaced during stream`); }
transitions+=distance; document.body.dataset.transitions=String(transitions);
const round=value=>Math.round(value*100)/100;
transitionTimings.push({center:[...center],enteringBlocks:sections.reduce((n,s)=>n+s.blocks.length,0),changes:result.changes.length,
prepareMs:round(mergeStart-start),mergeMs:round(merged-mergeStart),boundsMs:round(bounded-merged),changeMs:round(changed-bounded),
unloadMs:round(unloaded-changed),assertionsMs:round(performance.now()-unloaded),fullUpdateMs:round(performance.now()-start)});
}
function checkRetained() {
for(const [id,mesh] of tracked) { retainedChecks++; assert(renderer.sections.get(id)===mesh,`Retained mesh ${id} identity changed during asynchronous rebuild`); }
for(const [id,mesh] of renderer.sections) tracked.set(id,mesh);
}
const summarize = values => {
const sorted=[...values].sort((a,b)=>a-b), q=p=>Math.round((sorted[Math.min(sorted.length-1,Math.floor(sorted.length*p))]||0)*100)/100;
return {p50:q(0.5),p95:q(0.95),max:q(1)};
};
function finish(time) {
assert(transitions>=4,"Fewer than four boundaries were crossed");
const result={mode,actualMode:renderer.terrain?"worker":"baseline",durationMs:Math.round(time-started),frames:frames.length,
frameMs:summarize(frames),cpuDrawMs:summarize(draws),cpuUpdateMs:summarize(updates),over25msFrames:frames.filter(v=>v>25).length,
transitions,transitionTimings,meshResets:renderer.meshResets,retainedChecks,retainedFailures,mapIdentityPreserved:renderer.blocks===view.blocks,
glError:[...errors],pendingSections:renderer.dirty.size,canvas:[renderer.canvas.width,renderer.canvas.height],devicePixelRatio};
document.body.dataset.results=JSON.stringify(result); document.body.dataset.glError=errors.size?[...errors].join(","):"0";
document.body.dataset.meshResets=String(renderer.meshResets); document.body.dataset.retainedFailures=String(retainedFailures);
phase="done"; document.body.dataset.status=phase; $("status").textContent="Complete"; $("results").textContent=JSON.stringify(result,null,2);
$("run").disabled=false; $("run").textContent="Reset and run again"; $("mode").disabled=false;
}
function idle() {
const t=renderer.terrain;
return renderer.blockLightField && renderer.blockLighting.status==="ready" && !renderer.dirty.size && !renderer.meshUpload &&
(!t || (!t.busy && !t.pending && !t.preparing && !t.ready.size));
}
function frame(time) {
try {
if(document.hidden && phase==="running") throw Error("Tab was hidden during measurement; reload and keep it visible.");
const measuring=phase==="running", updateStart=performance.now();
if(measuring) {
frames.push(time-previousFrame); previousFrame=time; camera.eye[0]=8+Math.min(12,(time-started)/1000)*8;
const x=Math.floor(camera.eye[0]/16)*16; if(x!==view.viewCenter[0]) transition([x,0,0]);
}
const drawStart=performance.now(); renderer.draw(camera,[],[],emptyDefinitions); const drawEnd=performance.now();
if(measuring) { updates.push(drawStart-updateStart); draws.push(drawEnd-drawStart); checkRetained(); }
const error=renderer.gl.getError(); if(error!==renderer.gl.NO_ERROR) errors.add(`0x${error.toString(16)}`);
if(phase==="warming") {
if(mode==="worker"&&!renderer.terrain) throw Error("Worker mode fell back; use Baseline explicitly for this environment.");
if(time-warmStarted>90000) throw Error("Initial meshes did not drain within 90 seconds.");
if(idle()) stableSince??=time; else stableSince=null;
if(stableSince!==null&&time-stableSince>=750) {
tracked=new Map(renderer.sections);
if(autoRun) begin(time); else {phase="ready"; document.body.dataset.status=phase; $("run").disabled=false; $("status").textContent="Ready · initial meshes drained";}
}
} else if(measuring) { $("status").textContent=`${Math.min(12,(time-started)/1000).toFixed(1)} / 12 s · ${transitions} boundaries`; if(time-started>=12000) finish(time); }
requestAnimationFrame(frame);
} catch(error) {fail(error);}
}
try {
renderer=new Renderer($("world"));
if(mode==="baseline") {
renderer.terrain?.dispose(); renderer.terrain=null;
renderer.blockLighting=new BlockLightController(field=>{
for(const id of changedLightSections(renderer.blockLightField,field,renderer.sections.keys())) renderer.dirty.add(id);
renderer.blockLightField=field;
});
}
$("run").onclick=()=>{if(phase==="ready") begin(performance.now()); else if(phase==="done") prepare(true);};
prepare(); requestAnimationFrame(frame);
} catch(error) {fail(error);}
</script>
</html>
+236
View File
@@ -0,0 +1,236 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Renderer } from "../renderer.js";
function fixture(t, { uploadCost = 1, allocationCost = 0 } = {}) {
const clock = { now: 0 };
t.mock.method(performance, "now", () => clock.now);
let serial = 0, bound;
const gl = {
ARRAY_BUFFER: 1, DYNAMIC_DRAW: 2, STATIC_DRAW: 3, FLOAT: 4,
buffers: new Map(), uploads: [], allocations: [], deletedBuffers: [], deletedVaos: [],
createBuffer() { const buffer = { id: ++serial }; this.buffers.set(buffer, new Uint8Array()); return buffer; },
createVertexArray() { return { id: ++serial }; },
bindVertexArray() {}, bindBuffer(_target, buffer) { bound = buffer; },
enableVertexAttribArray() {}, vertexAttribPointer() {},
bufferData(_target, data, usage) {
const bytes = typeof data === "number" ? new Uint8Array(data) :
new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
this.buffers.set(bound, bytes);
this.allocations.push({ buffer: bound, byteLength: bytes.byteLength, usage });
if (typeof data === "number") clock.now += allocationCost;
},
bufferSubData(_target, offset, data) {
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
assert.ok(offset + bytes.byteLength <= this.buffers.get(bound).byteLength);
this.buffers.get(bound).set(bytes, offset);
this.uploads.push({ buffer: bound, offset, byteLength: bytes.byteLength });
clock.now += uploadCost;
},
deleteBuffer(buffer) { this.deletedBuffers.push(buffer); this.buffers.delete(buffer); },
deleteVertexArray(vao) { this.deletedVaos.push(vao); },
};
const renderer = Object.create(Renderer.prototype);
Object.assign(renderer, { gl, sections: new Map(), dirty: new Set(["0,0,0"]), meshUpload: null,
meshRebuilds: 0, lightViewBounds: { min: [0, 0, 0], max: [31, 31, 31] } });
const old = renderer.mesh(new Float32Array(60).fill(7));
old.transparent = renderer.mesh(new Float32Array(60).fill(8));
old.tag = "retained identity";
const other = renderer.mesh(new Float32Array(60).fill(9));
renderer.sections.set("0,0,0", old);
renderer.sections.set("1,0,0", other);
const terrain = { epoch: 1, version: 1, ready: new Map(),
isCurrent(result) { return result.epoch === this.epoch && result.version === this.version; } };
renderer.terrain = terrain;
gl.allocations.length = 0;
const result = (opaqueVertices = 3000, transparentVertices = 1000, overrides = {}) => {
const vertices = new Float32Array(opaqueVertices * 20), transparent = new Float32Array(transparentVertices * 20);
for (let index = 0; index < vertices.length; index++) vertices[index] = index * 0.25;
for (let index = 0; index < transparent.length; index++) transparent[index] = -index * 0.125;
return { id: "0,0,0", epoch: terrain.epoch, version: terrain.version, vertices, transparent, ...overrides };
};
const queue = (message) => terrain.ready.set(message.id, message);
const drain = () => {
for (let frame = 0; frame < 200 && (renderer.meshUpload || terrain.ready.size); frame++)
renderer.applyTerrainMeshes(0.5);
assert.equal(renderer.meshUpload, null);
assert.equal(terrain.ready.size, 0);
};
return { renderer, terrain, gl, clock, old, other, result, queue, drain };
}
test("uploads are capped at 64 KiB and both surfaces publish only after completion", (t) => {
const f = fixture(t), message = f.result();
const oldBuffer = f.old.buffer, oldTransparent = f.old.transparent, oldVao = f.old.vao;
f.queue(message);
f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.gl.uploads.length, 1);
assert.equal(f.gl.uploads[0].byteLength, 65_536);
assert.equal(f.renderer.sections.get(message.id), f.old);
assert.equal(f.old.buffer, oldBuffer);
assert.equal(f.old.transparent, oldTransparent);
assert.deepEqual(f.gl.deletedBuffers, []);
assert.ok(f.renderer.dirty.has(message.id));
// Four opaque slices and the first translucent slice still cannot replace
// the visible mesh, even though its opaque replacement is already complete.
for (let frame = 0; frame < 4; frame++) f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.renderer.meshUpload.parts[0].offset, message.vertices.length);
assert.ok(f.renderer.meshUpload.parts[1].offset < message.transparent.length);
assert.equal(f.old.buffer, oldBuffer);
assert.equal(f.old.transparent, oldTransparent);
f.drain();
assert.ok(f.gl.uploads.every((upload) => upload.byteLength <= 65_536));
assert.equal(f.renderer.sections.get(message.id), f.old);
assert.equal(f.old.tag, "retained identity");
assert.notEqual(f.old.buffer, oldBuffer);
assert.notEqual(f.old.vao, oldVao);
assert.notEqual(f.old.transparent, oldTransparent);
assert.equal(f.old.count, 3000);
assert.equal(f.old.transparent.count, 1000);
assert.deepEqual(f.gl.buffers.get(f.old.buffer), new Uint8Array(message.vertices.buffer));
assert.deepEqual(f.gl.buffers.get(f.old.transparent.buffer), new Uint8Array(message.transparent.buffer));
assert.ok(f.gl.deletedBuffers.includes(oldBuffer));
assert.ok(f.gl.deletedBuffers.includes(oldTransparent.buffer));
assert.equal(f.renderer.sections.get("1,0,0"), f.other);
assert.ok(!f.gl.deletedBuffers.includes(f.other.buffer));
assert.ok(!f.renderer.dirty.has(message.id));
assert.equal(f.renderer.meshRebuilds, 1);
});
test("a generation change discards partially uploaded buffers and preserves visible geometry", (t) => {
const f = fixture(t), oldBuffer = f.old.buffer, oldTransparent = f.old.transparent;
f.queue(f.result(900, 900));
for (let frame = 0; frame < 3; frame++) f.renderer.applyTerrainMeshes(0.5);
const staged = f.renderer.meshUpload.parts.map((part) => part.mesh);
assert.ok(staged.every(Boolean), "both replacement surfaces were allocated");
assert.equal(f.old.buffer, oldBuffer);
f.terrain.version++;
f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.renderer.meshUpload, null);
assert.equal(f.renderer.sections.get("0,0,0"), f.old);
assert.equal(f.old.buffer, oldBuffer);
assert.equal(f.old.transparent, oldTransparent);
assert.ok(f.renderer.dirty.has("0,0,0"));
for (const mesh of staged) {
assert.ok(f.gl.deletedBuffers.includes(mesh.buffer));
assert.ok(f.gl.deletedVaos.includes(mesh.vao));
}
assert.ok(!f.gl.deletedBuffers.includes(oldBuffer));
assert.ok(!f.gl.deletedBuffers.includes(oldTransparent.buffer));
assert.equal(f.renderer.meshRebuilds, 0);
f.queue(f.result(3, 0));
f.drain();
assert.equal(f.old.count, 3);
assert.equal(f.old.transparent, null);
assert.equal(f.renderer.meshRebuilds, 1);
});
test("explicit staged-upload disposal is idempotent and owns only replacement resources", (t) => {
const f = fixture(t);
f.queue(f.result());
f.renderer.applyTerrainMeshes(0.5);
const staged = f.renderer.meshUpload.parts[0].mesh;
f.renderer.discardMeshUpload();
f.renderer.discardMeshUpload();
assert.equal(f.renderer.meshUpload, null);
assert.equal(f.gl.deletedBuffers.filter((buffer) => buffer === staged.buffer).length, 1);
assert.equal(f.gl.deletedVaos.filter((vao) => vao === staged.vao).length, 1);
assert.ok(f.gl.buffers.has(f.old.buffer));
assert.ok(f.gl.buffers.has(f.old.transparent.buffer));
});
test("empty results remove both old surfaces without allocating empty GPU replacements", (t) => {
const f = fixture(t), oldTransparent = f.old.transparent;
f.queue(f.result(0, 0));
f.drain();
assert.equal(f.renderer.sections.has("0,0,0"), false);
assert.ok(f.gl.deletedBuffers.includes(f.old.buffer));
assert.ok(f.gl.deletedBuffers.includes(oldTransparent.buffer));
assert.equal(f.gl.allocations.length, 0);
assert.equal(f.gl.uploads.length, 0);
assert.equal(f.renderer.sections.get("1,0,0"), f.other);
assert.equal(f.renderer.meshRebuilds, 1);
});
test("a new translucent-only section retains an empty opaque handle for draw paths", (t) => {
const f = fixture(t), message = f.result(0, 6, { id: "0,1,0" });
f.queue(message);
f.drain();
const mesh = f.renderer.sections.get(message.id);
assert.ok(mesh?.vao && mesh.buffer);
assert.equal(mesh.count, 0);
assert.equal(mesh.transparent.count, 6);
assert.deepEqual(f.gl.buffers.get(mesh.transparent.buffer), new Uint8Array(message.transparent.buffer));
assert.equal(f.renderer.sections.get("0,0,0"), f.old);
});
test("stale or out-of-view results never allocate or replace a mesh", (t) => {
const f = fixture(t);
f.queue(f.result(3, 0, { version: 0 }));
f.renderer.applyTerrainMeshes(0.5);
f.queue(f.result(3, 0, { id: "8,0,0" }));
f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.gl.allocations.length, 0);
assert.equal(f.gl.uploads.length, 0);
assert.equal(f.renderer.sections.get("0,0,0"), f.old);
assert.equal(f.renderer.meshRebuilds, 0);
assert.ok(f.renderer.dirty.has("0,0,0"));
});
test("the frame budget stops before further upload steps and preserves queued work", (t) => {
const f = fixture(t);
f.queue(f.result());
f.renderer.applyTerrainMeshes(0);
assert.equal(f.gl.allocations.length, 0);
assert.equal(f.terrain.ready.size, 1);
f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.gl.uploads.length, 1);
assert.equal(f.renderer.meshUpload.parts[0].offset, 16_384);
assert.equal(f.renderer.meshUploadMilliseconds, 1);
f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.gl.uploads.length, 2);
assert.equal(f.renderer.meshUpload.parts[0].offset, 32_768);
});
test("an expensive GPU allocation yields before copying vertex data", (t) => {
const f = fixture(t, { allocationCost: 3 });
f.queue(f.result());
f.renderer.applyTerrainMeshes(2);
assert.ok(f.renderer.meshUpload.parts[0].mesh);
assert.equal(f.renderer.meshUpload.parts[0].offset, 0);
assert.equal(f.gl.uploads.length, 0);
assert.equal(f.renderer.meshUploadMilliseconds, 3);
f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.gl.uploads.length, 1);
assert.equal(f.renderer.sections.get("0,0,0"), f.old);
});
test('preview publication keeps the section dirty for corrected lighting and reports visible geometry', t => {
const f=fixture(t),published=[];
f.renderer.onMeshPublished=(id,result)=>published.push({id,preview:result.preview});
f.queue(f.result(6,0,{preview:true})); f.drain();
assert.equal(f.old.count,6);
assert.equal(f.renderer.dirty.has('0,0,0'),true);
assert.deepEqual(published,[{id:'0,0,0',preview:true}]);
f.queue(f.result(6,0,{preview:false}));f.drain();
assert.equal(f.renderer.dirty.has('0,0,0'),false);
});
test('edit uploads survive unrelated terrain versions but reject newer edits before publication',t=>{
const f=fixture(t),oldBuffer=f.old.buffer;
const edits={epoch:9,ticket:1,ready:new Map(),isCurrent(r){return r.epoch===this.epoch&&r.editJob===this.ticket;},cancel(){this.ticket++;}};
f.renderer.edits=edits;
edits.ready.set('0,0,0',f.result(3000,0,{epoch:9,editJob:1,preview:true}));
f.renderer.applyTerrainMeshes(0.5);const stage=f.renderer.meshUpload;
f.terrain.version++;
f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.renderer.meshUpload,stage,'a distant chunk update must not discard a local edit');
edits.ticket++;f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.renderer.meshUpload,null);assert.equal(f.old.buffer,oldBuffer);
edits.ready.set('0,0,0',f.result(3,0,{epoch:9,editJob:2,preview:true}));
for(let frame=0;frame<10&&(f.renderer.meshUpload||edits.ready.size);frame++)f.renderer.applyTerrainMeshes(0.5);
assert.equal(f.old.count,3);assert.equal(f.renderer.dirty.has('0,0,0'),true);
f.queue(f.result(3,0));f.drain();
assert.equal(edits.ticket,3,'final lighting cancels any older preview');
assert.equal(f.renderer.dirty.has('0,0,0'),false);
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {prepareTexturePack, textureSubset, BLOCK_FACES} from '../texture-pack.js';
import {getBlockFaceTextures, getFaceUV} from '../block-textures.js';
import {buildSectionMesh} from '../mesh-geometry.js';
const limits = {layers:256, size:2048};
const files = new Map([['atlas.png', {}]]);
const descriptor = () => ({pixel_size:32, atlas:{path:'atlas.png', columns:40, rows:32},
textures:Array.from({length:1269}, (_,i)=>({name:`tile_${i}`}))});
test('atlas supports the full catalog on a GPU with only 256 array layers', () => {
const result = prepareTexturePack(descriptor(), files, limits);
assert.deepEqual([result.width,result.height,result.layers.size],[1280,1024,1269]);
assert.equal(result.layers.get('tile_1268'),1268);
const regular = {...descriptor(),atlas:undefined};
assert.throws(()=>prepareTexturePack(regular,files,limits),/размер/);
});
test('atlas bounds, verified resources, and names are validated before allocating GPU memory', () => {
assert.throws(()=>prepareTexturePack(descriptor(),new Map(),limits),/атлас/);
assert.throws(()=>prepareTexturePack(descriptor(),files,{...limits,size:1024}),/видеокарты/);
for(const atlas of [{path:'atlas.png',columns:1,rows:1},{path:'atlas.png',columns:40.5,rows:32}])
assert.throws(()=>prepareTexturePack({...descriptor(),atlas},files,limits),/атлас/);
for(const textures of [[{name:'same'},{name:'same'}],[{}],[{name:'../escape'}]])
assert.throws(()=>prepareTexturePack({...descriptor(),textures},files,limits),/ресурс/);
});
test('state faces and UV transforms survive worker transfer and reach section geometry', () => {
const d=descriptor();
const face={texture:'tile_1268',tint:[.4,.7,.3],cutout:true,uv:[0,0,1,0,0,-1,0,1]};
d.block_faces={sets:[Array(6).fill(face),Array(6).fill(null)],
states:{'minecraft:test[facing=east,half=top]':0},defaults:{'minecraft:test':1}};
const {layers}=prepareTexturePack(d,files,limits);
const cloned=new Map(structuredClone([...layers]));
const state='minecraft:test[half=top,facing=east]';
const faces=getBlockFaceTextures(state,cloned);
assert.equal(faces[0].layer,1268);
assert.deepEqual(getFaceUV(0,[.25,.5,.75],state,faces[0]),[.75,.5]);
assert.deepEqual(getBlockFaceTextures('minecraft:test',cloned),Array(6).fill(null));
const mesh=buildSectionMesh({id:'0,0,0',blocks:new Map([['0,0,0',1]]),
materials:new Map([[1,{state,color:[255,255,255],render:[{min:[0,0,0],max:[1,1,1]}]}]]),textureLayers:cloned});
for(let i=0;i<mesh.vertices.length;i+=20){
assert.equal(mesh.vertices[i+13],1268);
assert.equal(mesh.vertices[i+11],mesh.vertices[i+2]);
assert.equal(mesh.vertices[i+12],1-mesh.vertices[i+1]);
}
});
test('bad state references and non-finite face data cannot enter mesh workers', () => {
for(const face of [{texture:'missing'},{texture:'tile_0',tint:[NaN,1,1]},
{texture:'tile_0',uv:Array(8).fill(Infinity)}]){
const d=descriptor();d.block_faces={sets:[Array(6).fill(face)],states:{},defaults:{}};
assert.throws(()=>prepareTexturePack(d,files,limits),/гран/);
}
const d=descriptor();d.block_faces={sets:[Array(6).fill(null)],states:{'minecraft:stone':9},defaults:{}};
assert.throws(()=>prepareTexturePack(d,files,limits),/состояние/);
});
test('edit snapshots send only nearby state mappings from a large texture catalog',()=>{
const d=descriptor();
d.block_faces={sets:[Array(6).fill({texture:'tile_0'}),Array(6).fill({texture:'tile_1'})],
states:{'minecraft:stone':0,'minecraft:dirt':1},defaults:{'minecraft:stone':0,'minecraft:dirt':1}};
const {layers}=prepareTexturePack(d,files,limits);
const subset=textureSubset(layers,new Map([[1,{state:'minecraft:dirt'}]]));
assert.equal(subset.get(BLOCK_FACES).sets.length,1);
assert.deepEqual(Object.keys(subset.get(BLOCK_FACES).states),['minecraft:dirt']);
assert.equal(getBlockFaceTextures('minecraft:dirt',subset)[0].layer,1);
assert.equal(layers.get(BLOCK_FACES).sets.length,2);
});
+86
View File
@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
import { normalizeViewDistance, viewDistanceProfile, MAX_VIEW_CELLS, MAX_LIGHT_CELLS } from "../view-distance.js";
import { applySectionView, applySectionBatch } from "../section-stream.js";
import { SectionVoxelMap } from "../section-voxel-map.js";
import { buildBlockLight } from "../block-light.js";
import {actorLightBounds} from '../terrain-state.js';
const message = (radius, generation, center = [0, 64, 0]) => ({
world: "test", revision: 7, generation, view_distance: radius,
view_center: center, view_min: center.map((v,i) => v - (i===1?2:radius)*16),
view_max: center.map((v,i) => v + (i===1?3:radius+1)*16-1),
unload: [], total_sections: (radius*2+1)**2*5,
});
test("distance settings are bounded and projection, fog and memory scale together", () => {
for (const value of [null, undefined, "bad", 1, 65, 3.5, Infinity]) assert.equal(normalizeViewDistance(value), 3);
assert.equal(normalizeViewDistance("8"), 8);
const initial=viewDistanceProfile(3), maximum=viewDistanceProfile(64);
assert.equal(initial.sections, 1176); assert.equal(maximum.sections, 399384);
assert.equal(maximum.width, 2064); assert.equal(maximum.streamVoxelBytes, MAX_VIEW_CELLS*4);
assert.ok(maximum.drawRadius>initial.drawRadius);
assert.ok(maximum.farClip>maximum.drawRadius);
assert.ok(maximum.fogDistance>initial.fogDistance);
});
test("resizing retains overlapping arrays, accepts the far ring and ignores obsolete batches", () => {
const view={world:"test",revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()};
applySectionView(view,message(3,1));
const cells=new Uint32Array(4096).fill(1);view.blocks.setSection("0,4,0",cells);view.loadedSections.add("0,4,0");
assert.deepEqual(applySectionView(view,message(8,2)),{unload:[]});
assert.equal(view.totalSections,1445);assert.equal([...view.blocks.sectionEntries()][0][1],cells);
const batch={world:"test",revision:7,generation:2,sections:[{section:[8,4,8],palette:[0],runs:[4096,0]}]};
assert.equal(applySectionBatch(view,batch).status,"applied");
const shrunk=applySectionView(view,message(2,3));
assert.deepEqual(shrunk.unload,[[8,4,8]]);assert.equal(view.totalSections,125);
assert.equal(view.blocks.sectionCount,1);assert.equal([...view.blocks.sectionEntries()][0][1],cells);
assert.equal(applySectionBatch(view,batch).status,"ignored");
assert.equal(applySectionView(view,message(66,4)),"resync");
});
test("the maximum view permits the small air margin beyond the playable border", () => {
const view={world:"test",revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()};
assert.notEqual(applySectionView(view,message(8,1,[29_999_872,64,-29_999_872])),"resync");
assert.equal(applySectionBatch(view,{world:"test",revision:7,generation:1,sections:[{section:[1_875_000,4,-1_874_992],palette:[0],runs:[4096,0]}]}).status,"applied");
});
test("lighting accepts the largest negotiated volume and still rejects larger allocations", () => {
const bounds={min:[0,0,0],max:[303,79,303]};
const columns=new Map();
for(let x=0;x<19;x++)for(let z=0;z<19;z++)columns.set(`${x},${z}`,new Int16Array(256).fill(100));
const field=buildBlockLight(new SectionVoxelMap(),new Map(),bounds,columns);
assert.equal(field.data.length,MAX_LIGHT_CELLS*4);assert.equal(field.sample([130,50,130]).skyLevel,0);
assert.throws(()=>buildBlockLight(new Map(),new Map(),{min:[0,0,0],max:[304,80,304]}),RangeError);
});
test('buffered views fit the allocation bound while preserving the requested render distance',()=>{
const view={world:'test',revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()};
const expanded={...message(9,1),view_distance:8,stream_radius:9};
assert.deepEqual(applySectionView(view,expanded),{unload:[]});
assert.equal(view.totalSections,1805);assert.equal(viewDistanceProfile(8).streamSections,8664);
assert.equal(applySectionView(view,message(66,2)),'resync');
});
test('64 chunk radius accepts the outer buffer without allocating a giant light volume',()=>{
const view={world:'test',revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()};
assert.deepEqual(applySectionView(view,{...message(65,1),view_distance:64,stream_radius:65}),{unload:[]});
assert.equal(view.totalSections,85805);
assert.equal(applySectionBatch(view,{world:'test',revision:7,generation:1,sections:[
{section:[64,4,-64],palette:[5],runs:[4096,0]},
{section:[65,4,65],palette:[0],runs:[4096,0]},
]}).status,'applied');
assert.equal(view.blocks.getAt(1024,64,-1024),5);
assert.equal(view.blocks.byteLength,8);
const bounds=actorLightBounds(view.viewBounds);
assert.equal((bounds.max[0]-bounds.min[0]+1)*(bounds.max[2]-bounds.min[2]+1),112**2);
assert.ok(112**2*80<MAX_LIGHT_CELLS);
});
test('full-height views keep ground and ceiling across vertical flight, including above the world',()=>{
const view={world:'test',revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()};
const full={...message(65,1),full_height:true,view_min:[-1040,-64,-1040],view_max:[1055,319,1055],total_sections:411864};
assert.deepEqual(applySectionView(view,full),{unload:[]});
const sections=[{section:[0,-4,0],palette:[1],runs:[4096,0]}, {section:[0,19,0],palette:[2],runs:[4096,0]}];
assert.equal(applySectionBatch(view,{world:'test',revision:7,generation:1,sections}).status,'applied');
assert.deepEqual(applySectionView(view,{...full,generation:2,view_center:[0,512,0]}),{unload:[]});
assert.equal(view.blocks.getAt(0,-64,0),1);assert.equal(view.blocks.getAt(0,319,0),2);
assert.equal(view.totalSections,411864);assert.equal(view.viewBounds.fullHeight,true);
assert.equal(applySectionView(view,{...full,generation:3,view_min:[-1040,-48,-1040]}),'resync');
});
+358
View File
@@ -0,0 +1,358 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SectionBlockMap } from "../section-block-map.js";
import {
getViewBounds,
mergeChunkUpdate,
mergeSnapshot,
positionInView,
} from "../world-view.js";
function view() {
return {
world: "lobby",
revision: 7,
viewCenter: [0, 0, 0],
viewBounds: { min: [-32, -16, -32], max: [31, 31, 31] },
blocks: new Map([
["0,0,0", 1],
["31,0,0", 2],
["-32,0,0", 3],
]),
};
}
function moveEast() {
const message = {
type: "chunks",
world: "lobby",
revision: 7,
from_center: [0, 0, 0],
view_center: [16, 0, 0],
view_min: [-16, -16, -32],
view_max: [47, 31, 31],
unload: [],
sections: [],
};
for (const y of [-1, 0, 1])
for (const z of [-2, -1, 0, 1]) {
message.unload.push([-2, y, z]);
message.sections.push({ section: [2, y, z], blocks: [] });
}
message.sections.find((s) => s.section.join(",") === "2,0,0").blocks = [
{ pos: [32, 0, 0], block: 4 },
];
return message;
}
test("crossing a section boundary retains overlapping blocks and map identity", () => {
const current = view(),
blocks = current.blocks,
result = mergeChunkUpdate(current, moveEast());
assert.equal(result.status, "applied");
assert.equal(current.blocks, blocks);
assert.equal(current.revision, 7);
assert.deepEqual(current.viewCenter, [16, 0, 0]);
assert.deepEqual(
[...current.blocks],
[
["0,0,0", 1],
["31,0,0", 2],
["32,0,0", 4],
],
);
assert.deepEqual(result.changes, [
{ pos: [-32, 0, 0], block: 0 },
{ pos: [32, 0, 0], block: 4 },
]);
});
test("an empty incoming section clears any stale block without touching overlap", () => {
const current = view();
current.blocks.set("32,0,16", 99);
const result = mergeChunkUpdate(current, moveEast());
assert.equal(result.status, "applied");
assert.equal(current.blocks.has("32,0,16"), false);
assert.ok(
result.changes.some(
(change) => change.pos.join(",") === "32,0,16" && !change.block,
),
);
assert.equal(current.blocks.get("0,0,0"), 1);
});
test("negative coordinates use floor division when loading and unloading sections", () => {
const current = view(),
message = moveEast();
current.viewCenter = [-16, 0, 0];
current.blocks = new Map([
["-1,0,0", 1],
["0,0,0", 2],
["-48,0,0", 3],
]);
message.from_center = [-16, 0, 0];
message.view_center = [-32, 0, 0];
message.view_min = [-64, -16, -32];
message.view_max = [-1, 31, 31];
message.unload = message.unload.map(([, y, z]) => [0, y, z]);
message.sections = message.sections.map(({ section: [, y, z] }) => ({
section: [-4, y, z],
blocks: y === 0 && z === 0 ? [{ pos: [-49, 0, 0], block: 4 }] : [],
}));
const result = mergeChunkUpdate(current, message);
assert.equal(result.status, "applied");
assert.deepEqual(
[...current.blocks],
[
["-1,0,0", 1],
["-48,0,0", 3],
["-49,0,0", 4],
],
);
});
test("same-world full snapshots only report actual changes and retain the map", () => {
const current = view(),
blocks = current.blocks,
records = [...blocks].map(([id, block]) => ({
pos: id.split(",").map(Number),
block,
}));
assert.deepEqual(mergeSnapshot(blocks, records), []);
assert.equal(current.blocks, blocks);
records.pop();
records.push({ pos: [32, 0, 0], block: 4 });
assert.deepEqual(mergeSnapshot(blocks, records), [
{ pos: [-32, 0, 0], block: 0 },
{ pos: [32, 0, 0], block: 4 },
]);
assert.equal(blocks.get("0,0,0"), 1);
});
test("newer or older chunk revisions request resync without advancing revision", () => {
for (const revision of [6, 8]) {
const current = view(),
before = structuredClone(current),
message = { ...moveEast(), revision };
assert.equal(mergeChunkUpdate(current, message).status, "resync");
assert.deepEqual(current, before);
}
});
test("an out-of-order or duplicate transition cannot overwrite the current window", () => {
const current = view(),
message = moveEast();
assert.equal(mergeChunkUpdate(current, message).status, "applied");
const before = structuredClone(current);
assert.equal(mergeChunkUpdate(current, message).status, "resync");
assert.deepEqual(current, before);
assert.equal(
mergeChunkUpdate(current, { ...message, world: "other" }).status,
"ignored",
);
assert.deepEqual(current, before);
});
test("incomplete or malformed transitions do not partially unload a live world", () => {
const invalidMessages = [
(m) => m.sections.pop(),
(m) => m.unload.pop(),
(m) => m.unload.push([0, 0, 0]),
(m) => (m.view_min[1] = -8),
(m) => m.sections[0].blocks.push({ pos: [0, 0, 0], block: 5 }),
(m) => m.sections[0].blocks.push({ pos: [32, -16, -32], block: -1 }),
];
for (const invalidate of invalidMessages) {
const current = view(),
before = structuredClone(current),
message = moveEast();
invalidate(message);
assert.equal(mergeChunkUpdate(current, message).status, "resync");
assert.deepEqual(current, before);
}
});
test("explicit view bounds include the entire bottom section with legacy fallback", () => {
const streamed = getViewBounds(moveEast()),
legacy = getViewBounds({ view_center: [16, 0, 0] });
assert.equal(positionInView([0, -16, 0], streamed), true);
assert.equal(positionInView([0, -17, 0], streamed), false);
assert.equal(positionInView([0, -16, 0], legacy), false);
assert.equal(positionInView([0, -8, 0], legacy), true);
assert.equal(positionInView([48, 0, 0], streamed), false);
});
test("indexed transitions never iterate or read retained blocks", () => {
class IndexedBlocks extends SectionBlockMap {
[Symbol.iterator]() {
throw Error("A chunk transition must not scan the complete map");
}
get(key) {
assert.ok(!["0,0,0", "31,0,0"].includes(key), "Retained block was read");
return super.get(key);
}
}
const current = view();
current.blocks = new IndexedBlocks(current.blocks);
current.blocks.set("-31,0,0", 8);
current.blocks.set("32,0,16", 99);
current.blocks.set("32,0,0", 3);
const result = mergeChunkUpdate(current, moveEast());
assert.equal(result.status, "applied");
assert.deepEqual(
[...current.blocks.entries()],
[
["0,0,0", 1],
["31,0,0", 2],
["32,0,0", 4],
],
);
assert.deepEqual([...current.blocks.loadedSectionKeys()].sort(), [
"0,0,0",
"1,0,0",
"2,0,0",
]);
assert.deepEqual(
result.changes.map(({ pos, block }) => [pos.join(","), block]).sort(),
[
["-31,0,0", 0],
["-32,0,0", 0],
["32,0,0", 4],
["32,0,16", 0],
],
);
});
test("malformed transitions preserve the section index before any indexed access", () => {
class IndexedBlocks extends SectionBlockMap {
keysInSection() {
throw Error("Incomplete transition accessed the index");
}
}
const current = view();
current.blocks = new IndexedBlocks(current.blocks);
const beforeBlocks = [...current.blocks],
beforeSections = [...current.blocks.loadedSectionKeys()],
beforeCenter = current.viewCenter,
beforeBounds = current.viewBounds,
message = moveEast();
message.sections.at(-1).blocks.push({ pos: [32, 16, 16], block: -1 });
assert.equal(mergeChunkUpdate(current, message).status, "resync");
assert.deepEqual([...current.blocks], beforeBlocks);
assert.deepEqual([...current.blocks.loadedSectionKeys()], beforeSections);
assert.equal(current.viewCenter, beforeCenter);
assert.equal(current.viewBounds, beforeBounds);
});
test("full snapshot merges maintain the section index alongside the block map", () => {
const blocks = new SectionBlockMap(view().blocks);
mergeSnapshot(blocks, [
{ pos: [0, 0, 0], block: 1 },
{ pos: [32, -1, 0], block: 4 },
]);
assert.deepEqual([...blocks.loadedSectionKeys()], ["0,0,0", "2,-1,0"]);
assert.deepEqual([...blocks.keysInSection("-2,0,0")], []);
assert.deepEqual([...blocks.keysInSection("2,-1,0")], ["32,-1,0"]);
});
test("optional section unloads omit only departing changes for indexed and ordinary maps", () => {
for (const BlockMap of [Map, SectionBlockMap]) {
const current = view();
current.blocks = new BlockMap(current.blocks);
current.blocks.set("32,0,16", 99);
const result = mergeChunkUpdate(current, moveEast(), {
includeUnloadedChanges: false,
});
assert.equal(result.status, "applied");
assert.equal(current.blocks.has("-32,0,0"), false);
assert.deepEqual(result.changes, [
{ pos: [32, 0, 16], block: 0 },
{ pos: [32, 0, 0], block: 4 },
]);
assert.deepEqual(
[...current.blocks],
[
["0,0,0", 1],
["31,0,0", 2],
["32,0,0", 4],
],
);
}
});
test("bulk streamed merges avoid departing key access and use validated sections for insertion", () => {
class BulkBlocks extends SectionBlockMap {
set(key, value) {
assert.equal(
this.locked,
undefined,
"Incoming records reparsed through ordinary set",
);
return super.set(key, value);
}
delete() {
throw Error("Departing blocks were deleted individually");
}
keysInSection(section) {
assert.ok(
!section.startsWith("-2,"),
"Departing block keys were requested",
);
return super.keysInSection(section);
}
[Symbol.iterator]() {
throw Error("Streamed merge scanned the full map");
}
}
const current = view(),
message = moveEast();
current.blocks = new BulkBlocks(current.blocks);
current.blocks.locked = true;
const result = mergeChunkUpdate(current, message, {
includeUnloadedChanges: false,
});
assert.equal(result.status, "applied");
assert.equal(current.blocks.has("-32,0,0"), false);
assert.equal(current.blocks.get("32,0,0"), 4);
assert.deepEqual(result.changes, [{ pos: [32, 0, 0], block: 4 }]);
assert.deepEqual(
[...current.blocks.loadedSectionKeys()],
["0,0,0", "1,0,0", "2,0,0"],
);
});
test("bulk merges still validate all incoming records before deleting any section", () => {
for (const invalidRecord of [
{ pos: [32, 16, 16], block: -1 },
{ pos: [31, 16, 16], block: 4 },
{ pos: [32, 16.5, 16], block: 4 },
]) {
const current = view(),
message = moveEast();
current.blocks = new SectionBlockMap(current.blocks);
const before = [...current.blocks],
sectionsBefore = [...current.blocks.loadedSectionKeys()],
centerBefore = current.viewCenter;
message.sections.at(-1).blocks.push(invalidRecord);
assert.equal(
mergeChunkUpdate(current, message, { includeUnloadedChanges: false })
.status,
"resync",
);
assert.deepEqual([...current.blocks], before);
assert.deepEqual([...current.blocks.loadedSectionKeys()], sectionsBefore);
assert.equal(current.viewCenter, centerBefore);
}
const current = view(),
message = moveEast();
current.blocks = new SectionBlockMap(current.blocks);
message.sections
.find((s) => s.section.join(",") === "2,0,0")
.blocks.push({ pos: [32, 0, 0], block: 0 });
assert.equal(
mergeChunkUpdate(current, message, { includeUnloadedChanges: false })
.status,
"resync",
);
assert.equal(current.blocks.get("-32,0,0"), 3);
});
+70
View File
@@ -0,0 +1,70 @@
// Declarative data only: the same resolved map is structured-cloned to mesh workers.
export const BLOCK_FACES = "@block_faces";
/** An edit job only needs face definitions for the materials in its neighborhood. */
export function textureSubset(layers, materials) {
const mapping = layers.get(BLOCK_FACES);
if (!mapping) return layers;
const result = new Map(layers), sets = [], states = {}, defaults = {}, indices = new Map();
const include = old => {
if (!Number.isInteger(old)) return undefined;
if (!indices.has(old)) { indices.set(old, sets.length); sets.push(mapping.sets[old]); }
return indices.get(old);
};
for (const {state} of materials.values()) {
if (typeof state !== "string") continue;
const base = state.split("[")[0];
if (Object.hasOwn(mapping.states, state)) states[state] = include(mapping.states[state]);
if (Object.hasOwn(mapping.defaults, base)) defaults[base] = include(mapping.defaults[base]);
}
result.set(BLOCK_FACES, {sets, states, defaults});
return result;
}
export function prepareTexturePack(descriptor, files, limits) {
const size = descriptor?.pixel_size, entries = descriptor?.textures, atlas = descriptor?.atlas;
if (!Number.isInteger(size) || size < 1 || size > 128 || !Array.isArray(entries) || !entries.length ||
entries.length > (atlas ? 16384 : Math.min(256, limits.layers)))
throw Error("Некорректный размер текстурпака.");
let width = size, height = size;
if (atlas) {
if (![atlas.columns, atlas.rows].every(v => Number.isInteger(v) && v > 0) ||
atlas.columns * atlas.rows < entries.length || !files.has(atlas.path))
throw Error("Некорректный атлас текстурпака.");
width *= atlas.columns; height *= atlas.rows;
if (width > limits.size || height > limits.size) throw Error("Атлас превышает лимит видеокарты.");
}
const layers = new Map();
for (const entry of entries) {
if (!entry || typeof entry.name !== "string" || !/^[a-z0-9_]+$/.test(entry.name) || layers.has(entry.name) || !atlas && !files.has(entry.path))
throw Error("Некорректный ресурс текстурпака.");
layers.set(entry.name, layers.size);
}
const mapping = descriptor.block_faces;
if (mapping) {
if (!Array.isArray(mapping.sets) || mapping.sets.length > 65536 || !mapping.states || !mapping.defaults ||
Object.keys(mapping.states).length > 100000) throw Error("Некорректная таблица граней.");
const sets = mapping.sets.map(faces => {
if (!Array.isArray(faces) || faces.length !== 6) throw Error("Ожидалось шесть граней.");
return faces.map(face => {
if (face === null) return null;
const tint = face.tint ?? [1, 1, 1], uv = face.uv;
if (!layers.has(face.texture) || !Array.isArray(tint) || tint.length !== 3 ||
!tint.every(v => Number.isFinite(v) && v >= 0 && v <= 1) ||
uv !== undefined && (!Array.isArray(uv) || uv.length !== 8 || !uv.every(v => Number.isFinite(v) && Math.abs(v) <= 256)))
throw Error("Некорректная текстура грани.");
return {layer: layers.get(face.texture), tint, cutout: face.cutout === true, ...(uv ? {uv} : {})};
});
});
for (const table of [mapping.states, mapping.defaults])
for (const [state, index] of Object.entries(table))
if (state.length > 1024 || !Number.isInteger(index) || index < 0 || index >= sets.length)
throw Error("Некорректное состояние в таблице граней.");
layers.set(BLOCK_FACES, {sets, states: mapping.states, defaults: mapping.defaults});
}
const overlay = descriptor.grass_overlay;
if (overlay && (!atlas || !layers.has(overlay.base) || !layers.has(overlay.overlay)))
throw Error("Некорректный слой травы.");
return {layers, width, height, atlas, size, entries,
grassOverlay: overlay ? [layers.get(overlay.base), layers.get(overlay.overlay)] : [-1, -1]};
}
+30
View File
@@ -0,0 +1,30 @@
export const MIN_VIEW_DISTANCE = 2;
export const DEFAULT_VIEW_DISTANCE = 3;
export const MAX_VIEW_DISTANCE = 64;
export const WORLD_MIN_Y = -64;
export const WORLD_MAX_Y = 319;
export const VIEW_HEIGHT = WORLD_MAX_Y - WORLD_MIN_Y + 1;
export const VIEW_BUFFER = 1;
export const MAX_VIEW_WIDTH = ((MAX_VIEW_DISTANCE + VIEW_BUFFER) * 2 + 1) * 16;
export const MAX_VIEW_CELLS = MAX_VIEW_WIDTH ** 2 * VIEW_HEIGHT;
// Lighting uses local windows; raising render distance must not allocate a
// multi-gigabyte dense light volume.
export const MAX_LIGHT_CELLS = 304 ** 2 * 80;
export const MAX_VIEW_COORDINATE = 30_000_000 + (MAX_VIEW_DISTANCE + VIEW_BUFFER) * 16;
export function normalizeViewDistance(value) {
const distance = Number(value);
return Number.isInteger(distance) && distance >= MIN_VIEW_DISTANCE && distance <= MAX_VIEW_DISTANCE
? distance : DEFAULT_VIEW_DISTANCE;
}
export function viewDistanceProfile(value) {
const chunks = normalizeViewDistance(value), width = (chunks * 2 + 1) * 16;
const drawRadius = Math.SQRT2 * chunks * 16 + 24;
const verticalSections = VIEW_HEIGHT / 16;
return { chunks, radius: chunks * 16, width, sections: (chunks * 2 + 1) ** 2 * verticalSections,
voxelBytes: width * width * VIEW_HEIGHT * 4,
streamWidth: width + VIEW_BUFFER * 32, streamSections: (chunks * 2 + 1 + VIEW_BUFFER * 2) ** 2 * verticalSections,
streamVoxelBytes: (width + VIEW_BUFFER * 32) ** 2 * VIEW_HEIGHT * 4, drawRadius,
farClip: Math.max(190, drawRadius + 60), fogDistance: 78 * chunks / DEFAULT_VIEW_DISTANCE };
}
+206
View File
@@ -0,0 +1,206 @@
import { WORLD_MIN_Y, WORLD_MAX_Y } from './view-distance.js';
const positionKey = (pos) => pos.join(",");
const integerPosition = (pos) =>
Array.isArray(pos) && pos.length === 3 && pos.every(Number.isSafeInteger);
const samePosition = (a, b) =>
integerPosition(a) &&
integerPosition(b) &&
a.every((value, i) => value === b[i]);
const sectionOf = (pos) => pos.map((value) => Math.floor(value / 16));
export function getViewBounds(message) {
const center =
message.view_center ||
message.spawn?.map((value) => Math.floor(value / 16) * 16);
if (!integerPosition(center)) return null;
if (integerPosition(message.view_min) && integerPosition(message.view_max))
return { min: [...message.view_min], max: [...message.view_max],
...(message.full_height === true && message.view_min[1] === WORLD_MIN_Y && message.view_max[1] === WORLD_MAX_Y ? {fullHeight:true} : {}) };
// Protocol 1 servers without chunk_stream_v1 use an unaligned vertical view.
return {
min: center.map((value, i) => value - (i === 1 ? 8 : 32)),
max: center.map((value) => value + 31),
};
}
export function positionInView(pos, bounds) {
return (
!bounds ||
pos.every((value, i) => value >= bounds.min[i] && value <= bounds.max[i])
);
}
function recordMap(records) {
const result = new Map();
for (const record of records) {
if (
!integerPosition(record?.pos) ||
!Number.isSafeInteger(record.block) ||
record.block < 0
)
throw Error("Invalid block record");
if (record.block) result.set(positionKey(record.pos), record.block);
}
return result;
}
/** Update the existing map so unchanged blocks never trigger mesh rebuilds. */
export function mergeSnapshot(blocks, records) {
const next = recordMap(records),
changes = [];
for (const [id] of blocks) {
if (!next.has(id)) {
blocks.delete(id);
changes.push({ pos: id.split(",").map(Number), block: 0 });
}
}
for (const [id, block] of next) {
if (blocks.get(id) !== block) {
blocks.set(id, block);
changes.push({ pos: id.split(",").map(Number), block });
}
}
return changes;
}
function sectionWindow(center) {
const c = sectionOf(center),
sections = new Set();
for (let x = c[0] - 2; x <= c[0] + 1; x++)
for (let y = c[1] - 1; y <= c[1] + 1; y++)
for (let z = c[2] - 2; z <= c[2] + 1; z++)
sections.add(positionKey([x, y, z]));
return sections;
}
function alignedCenter(center) {
return integerPosition(center) && center.every((value) => value % 16 === 0);
}
function* entriesInSections(blocks, sections) {
if (typeof blocks.keysInSection === "function") {
for (const section of sections)
for (const key of blocks.keysInSection(section))
yield [key, blocks.get(key), section];
} else {
for (const [key, block] of blocks) {
const section = positionKey(sectionOf(key.split(",").map(Number)));
if (sections.has(section)) yield [key, block, section];
}
}
}
/**
* Validate the complete transition before mutating blocks or view metadata.
* Consumers handling section unloads separately can omit their block-zero
* records; the departing blocks are still removed from the map.
*/
export function mergeChunkUpdate(
view,
message,
{ includeUnloadedChanges = true } = {},
) {
const invalid = { status: "resync", changes: [] };
if (message.world !== view.world) return { status: "ignored", changes: [] };
if (
!Number.isSafeInteger(message.revision) ||
message.revision !== view.revision ||
!alignedCenter(view.viewCenter) ||
!samePosition(message.from_center, view.viewCenter) ||
!alignedCenter(message.view_center) ||
!samePosition(
message.view_min,
message.view_center.map((value, i) => value - (i === 1 ? 16 : 32)),
) ||
!samePosition(
message.view_max,
message.view_center.map((value) => value + 31),
) ||
!Array.isArray(message.unload) ||
!Array.isArray(message.sections)
)
return invalid;
const previous = sectionWindow(view.viewCenter),
next = sectionWindow(message.view_center),
departing = new Set([...previous].filter((id) => !next.has(id))),
entering = new Set([...next].filter((id) => !previous.has(id))),
unloaded = new Set(),
received = new Set(),
replacement = new Map();
for (const pos of message.unload) {
if (!integerPosition(pos)) return invalid;
const id = positionKey(pos);
if (!departing.has(id) || unloaded.has(id)) return invalid;
unloaded.add(id);
}
for (const section of message.sections) {
if (!integerPosition(section?.section) || !Array.isArray(section.blocks))
return invalid;
const id = positionKey(section.section),
[sx, sy, sz] = section.section;
if (!entering.has(id) || received.has(id)) return invalid;
received.add(id);
for (const record of section.blocks) {
if (
!integerPosition(record?.pos) ||
Math.floor(record.pos[0] / 16) !== sx ||
Math.floor(record.pos[1] / 16) !== sy ||
Math.floor(record.pos[2] / 16) !== sz ||
!Number.isSafeInteger(record.block) ||
record.block < 0
)
return invalid;
const key = positionKey(record.pos);
if (replacement.has(key)) return invalid;
replacement.set(key, {
pos: record.pos,
block: record.block,
section: id,
});
}
}
if (unloaded.size !== departing.size || received.size !== entering.size)
return invalid;
const changes = [],
blocks = view.blocks,
bulkUnload =
typeof blocks.keysInSection === "function" &&
typeof blocks.deleteSection === "function";
if (bulkUnload) {
for (const section of departing) {
if (includeUnloadedChanges)
for (const key of blocks.keysInSection(section))
if (blocks.get(key) !== 0)
changes.push({ pos: key.split(",").map(Number), block: 0 });
blocks.deleteSection(section);
}
}
for (const [id, block, section] of entriesInSections(
blocks,
bulkUnload ? entering : new Set([...departing, ...entering]),
)) {
const record = replacement.get(id),
newBlock = record?.block || 0;
if (newBlock !== block) {
if (newBlock) blocks.set(id, newBlock);
else blocks.delete(id);
if (includeUnloadedChanges || !departing.has(section))
changes.push({
pos: record?.pos || id.split(",").map(Number),
block: newBlock,
});
}
replacement.delete(id);
}
for (const [id, { pos, block, section }] of replacement) {
if (!block) continue;
if (typeof blocks.setInSection === "function")
blocks.setInSection(id, block, section);
else blocks.set(id, block);
changes.push({ pos, block });
}
view.viewCenter = [...message.view_center];
view.viewBounds = getViewBounds(message);
return { status: "applied", changes };
}