Render textured aerial maps and support legacy recovery links
This commit is contained in:
19
renderer/map-view.mjs
Normal file
19
renderer/map-view.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// Keep one world block square in the image, including rectangular exports.
|
||||
// Eight pixels per block preserves the vanilla textures in typical 128-block
|
||||
// exports; large worlds are capped to a manageable image size.
|
||||
export function mapDimensions(bounds) {
|
||||
const spanX = bounds.maxX - bounds.minX;
|
||||
const spanZ = bounds.maxZ - bounds.minZ;
|
||||
if (
|
||||
!Number.isInteger(spanX) ||
|
||||
!Number.isInteger(spanZ) ||
|
||||
spanX < 1 ||
|
||||
spanZ < 1
|
||||
)
|
||||
throw new Error("Surface map bounds must have a positive integer size");
|
||||
const scale = Math.min(8, 2048 / Math.max(spanX, spanZ));
|
||||
return {
|
||||
width: Math.max(1, Math.round(spanX * scale)),
|
||||
height: Math.max(1, Math.round(spanZ * scale)),
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -203,7 +202,15 @@ export async function renderSnapshot(
|
||||
}
|
||||
}
|
||||
await report({ percent: 85, message: "Rendering the surface map" });
|
||||
stats.map = await renderSurfaceMap(snapshot, `${output}/map.webp`);
|
||||
const map = await page.evaluate(() => window.renderMap());
|
||||
const mapBytes = Buffer.from(map.data.split(",")[1], "base64");
|
||||
if (map.data.startsWith("data:image/webp;"))
|
||||
await writeFile(`${output}/map.webp`, mapBytes);
|
||||
else
|
||||
await sharp(mapBytes)
|
||||
.webp({ quality: 92 })
|
||||
.toFile(`${output}/map.webp`);
|
||||
stats.map = { width: map.width, height: map.height, cameraY: 320 };
|
||||
await report({
|
||||
percent: 90,
|
||||
message: "Rendered all panoramas and the surface map",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from "/three.js";
|
||||
import { buildTerrain } from "./terrain-mesh.js";
|
||||
import { firstTextureFrame } from "./block-colors.mjs";
|
||||
import { mapDimensions } from "./map-view.mjs";
|
||||
// This renderer consumes snapshots, never connects to a Minecraft server.
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
@@ -21,10 +22,12 @@ window.rendererInfo = {
|
||||
: "unavailable",
|
||||
};
|
||||
scene.background = new THREE.Color("#95c4eb");
|
||||
scene.add(new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0));
|
||||
const ambient = new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0);
|
||||
scene.add(ambient);
|
||||
const sun = new THREE.DirectionalLight("#fff4d6", 2.2);
|
||||
sun.position.set(-40, 90, -30);
|
||||
scene.add(sun);
|
||||
scene.add(sun.target);
|
||||
const loader = new THREE.TextureLoader(),
|
||||
jsonCache = new Map(),
|
||||
textureCache = new Map(),
|
||||
@@ -142,7 +145,17 @@ function selectModels(state, props) {
|
||||
.filter(Boolean)
|
||||
.map((p) => p.split("=")),
|
||||
);
|
||||
if (match(c, props)) return [Array.isArray(v) ? v[0] : v];
|
||||
// Some exports omit default properties (for example snowy=false on a
|
||||
// grass block). Match every property we do have, so the block remains
|
||||
// visible instead of silently disappearing from the terrain and map.
|
||||
if (
|
||||
Object.entries(c).every(
|
||||
([name, value]) =>
|
||||
props[name] === undefined ||
|
||||
String(value).split("|").includes(props[name]),
|
||||
)
|
||||
)
|
||||
return [Array.isArray(v) ? v[0] : v];
|
||||
}
|
||||
}
|
||||
return (state.multipart || [])
|
||||
@@ -282,9 +295,10 @@ window.renderFace = (p, face, size) => {
|
||||
renderScene(camera);
|
||||
return renderer.domElement.toDataURL("image/webp", 0.85);
|
||||
};
|
||||
window.renderMap = (size) => {
|
||||
window.renderMap = () => {
|
||||
const b = window.terrainBounds;
|
||||
renderer.setSize(size, size);
|
||||
const { width, height } = mapDimensions(b);
|
||||
renderer.setSize(width, height);
|
||||
const camera = new THREE.OrthographicCamera(
|
||||
-(b.maxX - b.minX) / 2,
|
||||
(b.maxX - b.minX) / 2,
|
||||
@@ -293,10 +307,60 @@ window.renderMap = (size) => {
|
||||
0.1,
|
||||
1024,
|
||||
);
|
||||
camera.position.set((b.minX + b.maxX) / 2, 512, (b.minZ + b.maxZ) / 2);
|
||||
camera.position.set((b.minX + b.maxX) / 2, 320, (b.minZ + b.maxZ) / 2);
|
||||
camera.up.set(0, 0, -1);
|
||||
camera.lookAt(camera.position.x, 0, camera.position.z);
|
||||
renderScene(camera, true);
|
||||
return renderer.domElement.toDataURL("image/webp", 0.9);
|
||||
const background = scene.background;
|
||||
const previousSunPosition = sun.position.clone();
|
||||
const previousSunTarget = sun.target.position.clone();
|
||||
const previousAmbient = ambient.intensity;
|
||||
const previousSunIntensity = sun.intensity;
|
||||
const centerX = camera.position.x;
|
||||
const centerZ = camera.position.z;
|
||||
const radius = Math.hypot(b.maxX - b.minX, b.maxZ - b.minZ) / 2 + 256;
|
||||
// The panorama renderer keeps its broad, shadowless lighting. For the map,
|
||||
// an angled sun makes ridges, cliffs and buildings cast readable shadows.
|
||||
ambient.intensity = 1.25;
|
||||
sun.intensity = 2.2;
|
||||
sun.position.set(centerX - 220, 300, centerZ - 180);
|
||||
sun.target.position.set(centerX, 64, centerZ);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(4096, 4096);
|
||||
sun.shadow.camera.left = -radius;
|
||||
sun.shadow.camera.right = radius;
|
||||
sun.shadow.camera.top = radius;
|
||||
sun.shadow.camera.bottom = -radius;
|
||||
sun.shadow.camera.near = 1;
|
||||
sun.shadow.camera.far = radius * 4;
|
||||
sun.shadow.camera.updateProjectionMatrix();
|
||||
sun.shadow.bias = -0.0004;
|
||||
sun.shadow.normalBias = 0.03;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||
for (const mesh of window.terrainMeshes) {
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
}
|
||||
scene.background = new THREE.Color("#ddc79f");
|
||||
try {
|
||||
renderScene(camera, true);
|
||||
return {
|
||||
data: renderer.domElement.toDataURL("image/webp", 0.92),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
} finally {
|
||||
renderer.shadowMap.enabled = false;
|
||||
sun.castShadow = false;
|
||||
for (const mesh of window.terrainMeshes) {
|
||||
mesh.castShadow = false;
|
||||
mesh.receiveShadow = false;
|
||||
}
|
||||
sun.position.copy(previousSunPosition);
|
||||
sun.target.position.copy(previousSunTarget);
|
||||
ambient.intensity = previousAmbient;
|
||||
sun.intensity = previousSunIntensity;
|
||||
scene.background = background;
|
||||
}
|
||||
};
|
||||
window.rendererReady = true;
|
||||
|
||||
Reference in New Issue
Block a user