Fail fast on missing terrain assets and recover empty renders
This commit is contained in:
28
renderer/image-validation.mjs
Normal file
28
renderer/image-validation.mjs
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user