Fail fast on missing terrain assets and recover empty renders

This commit is contained in:
2026-10-04 22:32:11 +05:00
parent 79d0f53a79
commit 763cf8713c
15 changed files with 842 additions and 21 deletions

140
scripts/test-renderer.mjs Normal file
View File

@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { gunzipSync } from "node:zlib";
import sharp from "sharp";
import { renderSnapshot } from "../renderer/render.mjs";
import { assertTerrainImage } from "../renderer/image-validation.mjs";
// Exercise the product's rendering worker without launching a Minecraft server
// or driving the website. A cached real export can also be supplied as argv[2].
const output = ".cache/renderer-regression";
const snapshot = {
bounds: { minX: -24, minZ: -24, maxX: 24, maxZ: 24 },
coverage: [],
blocks: [],
};
for (let x = -24; x < 24; x++) {
for (let z = -24; z < 24; z++) {
snapshot.blocks.push([x, 63, z, "minecraft:dirt"]);
snapshot.blocks.push([
x,
64,
z,
x > 12 && z < -12
? "minecraft:water[level=0]"
: "minecraft:grass_block[snowy=false]",
]);
}
}
for (let x = -4; x <= 4; x++) {
for (let z = -4; z <= 4; z++) {
for (let y = 65; y <= 71; y++) {
if (Math.abs(x) === 4 || Math.abs(z) === 4 || y === 71) {
snapshot.blocks.push([
x,
y,
z,
y === 71 ? "minecraft:oak_planks" : "minecraft:stone_bricks",
]);
}
}
}
}
for (let y = 65; y <= 70; y++)
snapshot.blocks.push([-16, y, 0, "minecraft:oak_log[axis=y]"]);
for (let x = -18; x <= -14; x++) {
for (let z = -2; z <= 2; z++) {
snapshot.blocks.push([
x,
71,
z,
"minecraft:oak_leaves[distance=1,persistent=true,waterlogged=false]",
]);
}
}
snapshot.blocks.push([
5,
65,
0,
"minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]",
]);
let terrain = snapshot;
let points = [
[-12.5, 12.5],
[12.5, 12.5],
[12.5, -12.5],
[-12.5, -12.5],
[0.5, 18.5],
].map(([x, z], i) => ({ id: `p${i}`, x, y: 66.62, z }));
let source = "grass, dirt, water, tree and building regression fixture";
if (process.argv[2]) {
const directory = process.argv[2];
const manifest = JSON.parse(
await readFile(`${directory}/manifest.json`, "utf8"),
);
terrain = JSON.parse(
gunzipSync(await readFile(`${directory}/terrain.json.gz`)).toString(),
);
points = manifest.viewpoints;
source = "cached Paper export (no server started)";
}
await mkdir(output, { recursive: true });
const report = await renderSnapshot(terrain, points, output, { size: 256 });
assert.ok(report.meshes > 0, "terrain must produce meshes");
assert.ok(report.emittedFaces > 0, "terrain must produce faces");
assert.ok(report.hiddenFaces > 0, "interior terrain faces must be culled");
assert.ok(report.geometryBytes > 0, "geometry must reach the renderer");
assert.equal(
report.unsupported.some((s) =>
/minecraft:(stone|dirt|grass_block|water)(\[|$)/.test(s),
),
false,
);
await assertTerrainImage(await readFile(`${output}/map.webp`), "Surface map");
for (const point of points) {
for (const face of ["px", "nx", "py", "ny", "pz", "nz"]) {
const metadata = await sharp(
await readFile(`${output}/${point.id}/${face}.webp`),
).metadata();
assert.equal(metadata.format, "webp");
assert.equal(metadata.width, 256);
assert.equal(metadata.height, 256);
}
await assertTerrainImage(
await readFile(`${output}/${point.id}/ny.webp`),
`Panorama ${point.id}, downward face`,
);
}
// The earlier renderer silently accepted this case with zero meshes and faces.
const missingModel = {
...snapshot,
blocks: [[0, 64, 0, "minecraft:minegeo_missing_model"]],
};
await assert.rejects(
renderSnapshot(missingModel, [points[0]], `${output}/missing-model`, {
size: 256,
}),
/No terrain models could be rendered/,
);
await writeFile(
`${output}/verification.json`,
JSON.stringify(
{
source,
...report,
checks: [
"nonempty terrain",
"valid WebP cubemap faces",
"surface visible below every viewpoint",
"missing model rejected",
],
},
null,
2,
),
);
console.log(
"Renderer regression passed:",
JSON.stringify({ source, ...report }),
);