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

View File

@@ -0,0 +1,28 @@
import sharp from "sharp";
// Empty sky faces are valid when looking up. A surface map or the view straight
// down from a surface capture must contain terrain, even if other faces are sky.
export async function assertTerrainImage(bytes, label) {
const { data, info } = await sharp(bytes)
.resize(64, 64, { fit: "fill" })
.toColourspace("srgb")
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const sky = [0x95, 0xc4, 0xeb];
let terrainPixels = 0;
for (let i = 0; i < data.length; i += info.channels) {
if (
Math.abs(data[i] - sky[0]) > 12 ||
Math.abs(data[i + 1] - sky[1]) > 12 ||
Math.abs(data[i + 2] - sky[2]) > 12
)
terrainPixels++;
}
if (terrainPixels < 4) {
throw new Error(
`${label} contains only sky. Check Minecraft asset loading, capture coordinates and graphics errors; this render will not be submitted as a playable map.`,
);
}
return terrainPixels;
}

View File

@@ -3,7 +3,8 @@ import { createServer } from "node:http";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { resolve, sep } from "node:path";
import sharp from "sharp";
import { assetsRoot } from "../scripts/assets.mjs";
import { assetsRoot, prepareAssets } from "../scripts/assets.mjs";
import { assertTerrainImage } from "./image-validation.mjs";
/** @param {import('../shared/protocol').TerrainSnapshot} snapshot
* @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints
* @param {string} output
@@ -15,6 +16,12 @@ export async function renderSnapshot(
{ size = 1024, signal, onProgress } = {},
) {
const start = Date.now();
if (signal?.aborted) throw new Error("Rendering cancelled");
await onProgress?.({
percent: 22,
message: "Checking Minecraft models and textures",
});
await prepareAssets();
await mkdir(output, { recursive: true });
const server = createServer(async (req, res) => {
try {
@@ -126,6 +133,10 @@ export async function renderSnapshot(
rendererPage.on("pageerror", (err) =>
console.error("Renderer:", err.message),
);
rendererPage.on("console", (message) => {
if (["warning", "error"].includes(message.type()))
console.error(`Renderer ${message.type()}: ${message.text()}`);
});
await rendererPage.goto(`http://127.0.0.1:${server.address().port}`);
await rendererPage.waitForFunction(() => window.rendererReady);
return rendererPage;
@@ -169,6 +180,8 @@ export async function renderSnapshot(
{ p, face, size },
);
const encoded = Buffer.from(data.split(",")[1], "base64");
if (face === "ny")
await assertTerrainImage(encoded, `Panorama ${p.id}, downward face`);
// Chromium encodes WebP once; retain a PNG fallback for canvas runtimes
// without WebP support rather than silently writing the wrong MIME type.
if (data.startsWith("data:image/webp;"))
@@ -191,6 +204,7 @@ export async function renderSnapshot(
await report({ percent: 85, message: "Rendering the surface map" });
const map = await page.evaluate(() => window.renderMap(1024));
const mapBytes = Buffer.from(map.split(",")[1], "base64");
await assertTerrainImage(mapBytes, "Surface map");
if (map.startsWith("data:image/webp;"))
await writeFile(`${output}/map.webp`, mapBytes);
else

View File

@@ -34,8 +34,8 @@ async function json(path) {
if (!jsonCache.has(path))
jsonCache.set(
path,
fetch(path).then((r) => {
if (!r.ok) throw new Error(path);
fetch(path, { signal: AbortSignal.timeout(60000) }).then((r) => {
if (!r.ok) throw new Error(`Minecraft asset ${path}: HTTP ${r.status}`);
return r.json();
}),
);
@@ -183,8 +183,10 @@ window.buildTerrain = async (snapshot) => {
const bs = await json(resourcePath(name, "blockstates") + ".json");
for (const v of selectModels(bs, props))
variants.push({ v, m: await model(v.model) });
} catch {
} catch (error) {
unsupported.add(name);
if (unsupported.size <= 8)
console.warn(`Missing block model ${name}: ${error.message}`);
}
stateCache.set(s, variants);
return variants;
@@ -225,6 +227,18 @@ const directions = {
[0, 1, 0],
],
};
function renderScene(camera, requireGeometry = false) {
if (gl.isContextLost())
throw new Error("Graphics context lost while rendering terrain");
renderer.render(scene, camera);
const error = gl.getError();
if (error !== gl.NO_ERROR)
throw new Error(`WebGL failed to render terrain (error ${error})`);
if (requireGeometry && renderer.info.render.triangles === 0)
throw new Error(
"Surface-map camera sees no terrain geometry. Check world coordinates and Minecraft assets.",
);
}
window.renderFace = (p, face, size) => {
renderer.setSize(size, size);
const camera = new THREE.PerspectiveCamera(90, 1, 0.05, 512);
@@ -232,7 +246,7 @@ window.renderFace = (p, face, size) => {
const [dir, up] = directions[face];
camera.up.set(...up);
camera.lookAt(p.x + dir[0], p.y + dir[1], p.z + dir[2]);
renderer.render(scene, camera);
renderScene(camera);
return renderer.domElement.toDataURL("image/webp", 0.85);
};
window.renderMap = (size) => {
@@ -249,7 +263,7 @@ window.renderMap = (size) => {
camera.position.set((b.minX + b.maxX) / 2, 512, (b.minZ + b.maxZ) / 2);
camera.up.set(0, 0, -1);
camera.lookAt(camera.position.x, 0, camera.position.z);
renderer.render(scene, camera);
renderScene(camera, true);
return renderer.domElement.toDataURL("image/webp", 0.9);
};
window.rendererReady = true;

View File

@@ -225,6 +225,11 @@ export async function buildTerrain(
);
}
const modelMs = performance.now() - started - indexMs;
if (!materials.size || !entries.some(([, entry]) => entry.faces.length)) {
throw new Error(
`No terrain models could be rendered. Minecraft models/textures are missing or failed to load. Run npm run setup. Unsupported examples: ${[...unsupported].slice(0, 8).join(", ")}`,
);
}
await progress(35, "Building cached block faces", true);
const batches = new Map(),
meshList = [],
@@ -299,6 +304,11 @@ export async function buildTerrain(
);
}
window.terrainBounds = snapshot.bounds;
if (!meshList.length || !emittedFaces) {
throw new Error(
"Terrain renderer produced no visible geometry. The export cannot be accepted as a playable map.",
);
}
window.terrainMeshes = meshList;
const totalMs = performance.now() - started;
return {