Expand voxel gameplay, lighting, full-height streaming and world imports
MVP checks / mvp (push) Canceled after 0s
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:
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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();}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()], []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user