Render surface maps directly and preserve their quality
This commit is contained in:
@@ -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
213
renderer/surface-map.mjs
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user