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