Render surface maps directly and preserve their quality

This commit is contained in:
2026-10-05 14:16:39 +05:00
parent c5368fa8cc
commit 8b31891776
11 changed files with 533 additions and 11 deletions

View File

@@ -5,6 +5,7 @@ import { resolve, sep } from "node:path";
import sharp from "sharp";
import { assetsRoot, prepareAssets } from "../scripts/assets.mjs";
import { assertTerrainImage } from "./image-validation.mjs";
import { renderSurfaceMap } from "./surface-map.mjs";
/** @param {import('../shared/protocol').TerrainSnapshot} snapshot
* @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints
* @param {string} output
@@ -202,13 +203,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
await sharp(mapBytes).webp({ quality: 90 }).toFile(`${output}/map.webp`);
stats.map = await renderSurfaceMap(snapshot, `${output}/map.webp`);
await report({
percent: 90,
message: "Rendered all panoramas and the surface map",

213
renderer/surface-map.mjs Normal file
View File

@@ -0,0 +1,213 @@
import sharp from "sharp";
// A map is a survey of the surface, not a photograph of block textures.
// Keep the palette and relief separate from the lighting used by panoramas.
const palette = {
grass: 0x7fb238,
foliage: 0x007c00,
sand: 0xf7e9a3,
stone: 0x707070,
dirt: 0x976d4d,
wood: 0x8f7748,
water: 0x356baf,
snow: 0xffffff,
ice: 0xa0a0ff,
clay: 0xa4a8b8,
lava: 0xff0000,
black: 0x191919,
white: 0xfffcf5,
orange: 0xd87f33,
magenta: 0xb24cd8,
light_blue: 0x6699d8,
yellow: 0xe5e533,
lime: 0x7fcc19,
pink: 0xf27fa5,
gray: 0x4c4c4c,
light_gray: 0x999999,
cyan: 0x4c7f99,
purple: 0x7f3fb2,
blue: 0x334cb2,
brown: 0x664c33,
green: 0x667f33,
red: 0x993333,
gold: 0xfaee4d,
iron: 0xa7a7a7,
diamond: 0x5cdbd5,
emerald: 0x00d93a,
terracotta: 0x985e43,
netherrack: 0x700200,
deepslate: 0x646464,
copper: 0xc16b4f,
oxidized: 0x52a384,
crimson: 0x943f61,
warped: 0x3a8e8c,
};
const entries = Object.entries(palette);
const ids = Object.fromEntries(entries.map(([name], id) => [name, id]));
const invisible =
/^(?:air|cave_air|void_air|barrier|structure_void|light|.*glass(?:_pane)?|(?:wall_)?torch|.*_torch|.*_flower|.*_sapling|short_grass|tall_grass|fern|large_fern|dead_bush|vine|glow_lichen|seagrass|tall_seagrass|kelp|kelp_plant|.*_rail|rail|redstone_wire|tripwire|tripwire_hook)$/;
function material(state) {
const name = state.split("[")[0].replace(/^minecraft:/, "");
if (invisible.test(name)) return -1;
if (name === "water" || name === "bubble_column") return ids.water;
if (/snow|quartz|calcite/.test(name)) return ids.snow;
if (name.includes("ice")) return ids.ice;
if (name === "lava" || name.includes("magma")) return ids.lava;
if (name === "grass_block" || name === "moss_block" || name === "moss_carpet")
return ids.grass;
if (/leaves|lily_pad/.test(name)) return ids.foliage;
if (/^(?:red_sand|red_sandstone)/.test(name)) return ids.orange;
if (/sand|end_stone|birch/.test(name)) return ids.sand;
if (/dirt|mud|farmland|rooted|granite|jungle/.test(name)) return ids.dirt;
if (name === "clay") return ids.clay;
if (/obsidian|coal_block|blackstone/.test(name)) return ids.black;
for (const [color] of entries) {
if (
name.startsWith(color + "_") &&
/wool|carpet|concrete|terracotta|bed|shulker/.test(name)
)
return ids[color];
}
if (/terracotta|brick/.test(name) && !/stone|nether|end/.test(name))
return ids.terracotta;
if (/gold_block|raw_gold/.test(name)) return ids.gold;
if (/iron_block|raw_iron/.test(name)) return ids.iron;
if (/diamond_block|prismarine|sea_lantern/.test(name)) return ids.diamond;
if (name === "emerald_block") return ids.emerald;
if (/copper/.test(name))
return /oxidized|weathered/.test(name) ? ids.oxidized : ids.copper;
if (/crimson|nether_wart/.test(name)) return ids.crimson;
if (/warped/.test(name)) return ids.warped;
if (/netherrack|nether_brick/.test(name)) return ids.netherrack;
if (/deepslate|basalt/.test(name)) return ids.deepslate;
if (/spruce|dark_oak/.test(name)) return ids.brown;
if (
/acacia|mangrove|cherry|pale_oak|oak|bamboo|planks|log|wood|chest|crafting|barrel|bookshelf/.test(
name,
)
)
return ids.wood;
return ids.stone;
}
/** Rasterize north (-Z) at the top, preserving half-open world bounds.
* @param {import('../shared/protocol').TerrainSnapshot} snapshot
* @param {{resolution?:number}} [options]
*/
export function surfacePixels(snapshot, { resolution = 128 } = {}) {
const { minX, maxX, minZ, maxZ } = snapshot.bounds;
const spanX = maxX - minX,
spanZ = maxZ - minZ;
if (!Number.isInteger(resolution) || resolution < 1 || resolution > 512)
throw new Error("Map resolution must be an integer from 1 to 512");
const scale = Math.min(1, resolution / Math.max(spanX, spanZ));
const width = Math.ceil(spanX * scale),
height = Math.ceil(spanZ * scale);
const top = new Int16Array(spanX * spanZ).fill(-32768);
const bed = new Int16Array(top.length).fill(-32768);
const colors = new Int16Array(top.length).fill(-1);
const stateCache = new Map();
let columns = 0;
for (const [x, y, z, state] of snapshot.blocks) {
if (x < minX || x >= maxX || z < minZ || z >= maxZ) continue;
let color = stateCache.get(state);
if (color === undefined) {
color = material(state);
stateCache.set(state, color);
}
if (color < 0) continue;
const i = (z - minZ) * spanX + x - minX;
if (color !== ids.water && y > bed[i]) bed[i] = y;
if (y > top[i]) {
if (colors[i] < 0) columns++;
top[i] = y;
colors[i] = color;
}
}
if (!columns) throw new Error("Surface map contains no mapped terrain");
const data = Buffer.alloc(width * height * 4);
const heights = new Float32Array(width * height).fill(NaN);
const votes = new Uint32Array(entries.length);
for (let pz = 0; pz < height; pz++) {
for (let px = 0; px < width; px++) {
votes.fill(0);
let count = 0,
totalHeight = 0,
totalDepth = 0,
waterCount = 0;
const x0 = Math.floor((px * spanX) / width),
x1 = Math.floor(((px + 1) * spanX) / width);
const z0 = Math.floor((pz * spanZ) / height),
z1 = Math.floor(((pz + 1) * spanZ) / height);
for (let z = z0; z < z1; z++)
for (let x = x0; x < x1; x++) {
const i = z * spanX + x;
if (colors[i] < 0) continue;
votes[colors[i]]++;
count++;
totalHeight += top[i];
if (colors[i] === ids.water) {
// Exports retain only the upper terrain layers. A missing bed means
// deep water; it must not turn into a bright shallow coastline.
totalDepth += bed[i] === -32768 ? 24 : Math.max(1, top[i] - bed[i]);
waterCount++;
}
}
const pixel = pz * width + px,
offset = pixel * 4;
if (!count) {
const paper =
(Math.floor(px / 4) + Math.floor(pz / 4)) % 2 ? 0xd4bb8c : 0xddc79f;
data[offset] = paper >> 16;
data[offset + 1] = (paper >> 8) & 255;
data[offset + 2] = paper & 255;
data[offset + 3] = 255;
continue;
}
let dominant = 0;
for (let c = 1; c < votes.length; c++)
if (votes[c] > votes[dominant]) dominant = c;
heights[pixel] = totalHeight / count;
const north = heights[pixel - width];
const slope = Number.isFinite(north)
? (heights[pixel] - north) / (spanZ / height)
: 0;
const shade =
dominant === ids.water
? totalDepth / waterCount > 8
? 0.61
: totalDepth / waterCount > 3
? 0.76
: 0.9
: slope > 0.25
? 1
: slope < -0.25
? 0.71
: 0.86;
const rgb = entries[dominant][1];
data[offset] = Math.floor((rgb >> 16) * shade);
data[offset + 1] = Math.floor(((rgb >> 8) & 255) * shade);
data[offset + 2] = Math.floor((rgb & 255) * shade);
data[offset + 3] = 255;
}
}
return { data, width, height, channels: 4, columns };
}
/** Save without lossy ringing around the map's square pixels. */
export async function renderSurfaceMap(snapshot, output) {
const { data, width, height, columns } = surfacePixels(snapshot);
await sharp(data, { raw: { width, height, channels: 4 } })
.resize(width * 8, height * 8, { kernel: "nearest" })
.composite([
{
input: Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width * 8}" height="${height * 8}"><defs><pattern id="grid" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M8 0H0V8" fill="none" stroke="#f3edd8" stroke-opacity=".07" stroke-width="1"/></pattern></defs><rect width="100%" height="100%" fill="url(#grid)"/></svg>`,
),
},
])
.webp({ lossless: true })
.toFile(output);
return { width, height, columns };
}