Optimize terrain rendering for large exports

This commit is contained in:
2026-10-04 21:47:08 +05:00
parent 3bdf987916
commit 79d0f53a79
8 changed files with 647 additions and 274 deletions

View File

@@ -1,4 +1,5 @@
import * as THREE from "/three.js";
import { buildTerrain } from "./terrain-mesh.js";
// This renderer consumes snapshots, never connects to a Minecraft server.
const renderer = new THREE.WebGLRenderer({
antialias: true,
@@ -8,6 +9,16 @@ renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
document.body.append(renderer.domElement);
const scene = new THREE.Scene();
const gl = renderer.getContext();
const gpuInfo = gl.getExtension("WEBGL_debug_renderer_info");
window.rendererInfo = {
vendor: gpuInfo
? gl.getParameter(gpuInfo.UNMASKED_VENDOR_WEBGL)
: "unavailable",
renderer: gpuInfo
? gl.getParameter(gpuInfo.UNMASKED_RENDERER_WEBGL)
: "unavailable",
};
scene.background = new THREE.Color("#95c4eb");
scene.add(new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0));
const sun = new THREE.DirectionalLight("#fff4d6", 2.2);
@@ -53,11 +64,20 @@ function textureRef(m, ref) {
async function texture(ref) {
if (!ref) return null;
if (!textureCache.has(ref)) {
const t = await loader.loadAsync(resourcePath(ref, "textures") + ".png");
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
t.colorSpace = THREE.SRGBColorSpace;
textureCache.set(ref, t);
// Cache the in-flight promise and failures as well as successful textures.
// Missing vanilla assets must not trigger another request for every face.
textureCache.set(
ref,
loader
.loadAsync(resourcePath(ref, "textures") + ".png")
.then((t) => {
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
t.colorSpace = THREE.SRGBColorSpace;
return t;
})
.catch(() => null),
);
}
return textureCache.get(ref);
}
@@ -152,40 +172,9 @@ const faceData = {
],
},
};
const nonOpaque = (s) =>
/air|water|lava|leaves|glass|grass(?!_block)|fern|flower|sapling|torch|fence|wall|slab|stairs|door|pane|rail|vine|carpet/.test(
s,
);
window.buildTerrain = async (snapshot) => {
const started = performance.now(),
batches = new Map(),
unsupported = new Set();
const occupancy = new Map();
const distinctStates = new Set();
let lastProgress = 0;
async function progress(percent, message, force = false) {
if (!force && performance.now() - lastProgress < 3000) return;
lastProgress = performance.now();
// Yield to browser tasks so bindings, cancellation and timers can run.
await new Promise((resolve) => setTimeout(resolve, 0));
await window.minegeoProgress?.({ percent, message });
}
await progress(
25,
`Indexing ${snapshot.blocks.length.toLocaleString()} terrain blocks`,
true,
);
for (let i = 0; i < snapshot.blocks.length; i++) {
const b = snapshot.blocks[i];
occupancy.set(`${b[0]},${b[1]},${b[2]}`, b[3]);
distinctStates.add(b[3]);
if (i % 16384 === 0)
await progress(
25 + (5 * i) / snapshot.blocks.length,
`Indexing blocks ${i}/${snapshot.blocks.length}`,
);
}
const stateCache = new Map();
const unsupported = new Set(),
stateCache = new Map();
async function stateModels(s) {
if (stateCache.has(s)) return stateCache.get(s);
const { name, props } = parseState(s);
@@ -200,193 +189,15 @@ window.buildTerrain = async (snapshot) => {
stateCache.set(s, variants);
return variants;
}
// Preload each distinct model once before constructing batched geometry.
await progress(30, "Loading block models and textures", true);
let modelCount = 0;
for (const s of distinctStates) {
await stateModels(s);
await progress(
30 + (5 * ++modelCount) / distinctStates.size,
`Loading block model ${modelCount}/${distinctStates.size}`,
);
}
async function emit(block, m, v = {}) {
const [bx, by, bz, s] = block;
for (const e of m.elements || []) {
const from = e.from.map((n) => n / 16),
to = e.to.map((n) => n / 16);
const transform = new THREE.Matrix4();
const r = new THREE.Euler(
(-(v.x || 0) * Math.PI) / 180,
(-(v.y || 0) * Math.PI) / 180,
0,
"YXZ",
);
transform.makeRotationFromEuler(r);
for (const [dir, f] of Object.entries(e.faces)) {
const fd = faceData[dir];
if (!fd) continue;
const n = new THREE.Vector3(...fd.normal).applyMatrix4(transform);
const neighbor = occupancy.get(
`${bx + Math.round(n.x)},${by + Math.round(n.y)},${bz + Math.round(n.z)}`,
);
if (f.cullface && neighbor && !nonOpaque(neighbor)) continue;
const ref = textureRef(m, f.texture),
tex = await texture(ref).catch(() => null);
if (!tex) {
unsupported.add(s);
continue;
}
const tint =
f.tintindex !== undefined
? s.includes("water")
? "#5b9eb5"
: s.includes("leaves")
? "#7ba54c"
: "#90b650"
: "#ffffff";
const key = ref + "|" + tint;
let batch = batches.get(key);
if (!batch) {
batch = {
positions: [],
uvs: [],
normals: [],
indices: [],
tex,
tint,
};
batches.set(key, batch);
}
const uv = f.uv || [0, 0, 16, 16],
coords = [
[uv[0] / 16, 1 - uv[3] / 16],
[uv[2] / 16, 1 - uv[3] / 16],
[uv[0] / 16, 1 - uv[1] / 16],
[uv[2] / 16, 1 - uv[1] / 16],
];
const base = batch.positions.length / 3;
for (let i = 0; i < 4; i++) {
let p = new THREE.Vector3(
...fd.corners[i].map((c, j) => from[j] + c * (to[j] - from[j])),
);
if (e.rotation) {
const er = e.rotation,
origin = new THREE.Vector3(...er.origin.map((a) => a / 16)),
axis = new THREE.Vector3(
er.axis === "x" ? 1 : 0,
er.axis === "y" ? 1 : 0,
er.axis === "z" ? 1 : 0,
);
p.sub(origin).applyAxisAngle(axis, (er.angle * Math.PI) / 180);
if (er.rescale) {
const scale = 1 / Math.cos((er.angle * Math.PI) / 180);
for (const a of ["x", "y", "z"]) if (a !== er.axis) p[a] *= scale;
}
p.add(origin);
}
p.subScalar(0.5)
.applyMatrix4(transform)
.addScalar(0.5)
.add(new THREE.Vector3(bx, by, bz));
batch.positions.push(p.x, p.y, p.z);
batch.normals.push(n.x, n.y, n.z);
let index = i;
if (f.rotation) {
const order = [0, 1, 3, 2],
rank = order.indexOf(i);
index = order[(rank + f.rotation / 90) % 4];
}
batch.uvs.push(...coords[index]);
}
batch.indices.push(
base,
base + 1,
base + 2,
base + 2,
base + 1,
base + 3,
);
}
}
}
await progress(35, "Building terrain geometry", true);
for (let i = 0; i < snapshot.blocks.length; i++) {
const b = snapshot.blocks[i];
if (i % 256 === 0)
await progress(
35 + (20 * i) / snapshot.blocks.length,
`Building blocks ${i}/${snapshot.blocks.length}`,
);
if (/minecraft:(water|lava)(\[|$)/.test(b[3])) {
await emit(b, {
textures: {
all:
"minecraft:block/" +
(b[3].includes("water") ? "water_still" : "lava_still"),
},
elements: [
{
from: [0, 0, 0],
to: [16, 14, 16],
faces: Object.fromEntries(
Object.keys(faceData).map((k) => [
k,
{
texture: "#all",
tintindex: b[3].includes("water") ? 0 : undefined,
cullface: k,
},
]),
),
},
],
});
continue;
}
for (const { m, v } of await stateModels(b[3])) await emit(b, m, v);
}
occupancy.clear();
await progress(55, "Preparing the scene", true);
let batchCount = 0;
for (const batch of batches.values()) {
const g = new THREE.BufferGeometry();
g.setAttribute(
"position",
new THREE.Float32BufferAttribute(batch.positions, 3),
);
g.setAttribute(
"normal",
new THREE.Float32BufferAttribute(batch.normals, 3),
);
g.setAttribute("uv", new THREE.Float32BufferAttribute(batch.uvs, 2));
g.setIndex(batch.indices);
const mat = new THREE.MeshLambertMaterial({
map: batch.tex,
color: batch.tint,
side: THREE.DoubleSide,
alphaTest: 0.3,
transparent: batch.tex.image.src.includes("water"),
opacity: batch.tex.image.src.includes("water") ? 0.8 : 1,
});
scene.add(new THREE.Mesh(g, mat));
// Drop the JS number arrays as each GPU geometry is completed.
batch.positions.length = 0;
batch.normals.length = 0;
batch.uvs.length = 0;
batch.indices.length = 0;
await progress(
55 + (5 * ++batchCount) / batches.size,
`Preparing geometry ${batchCount}/${batches.size}`,
);
}
window.terrainBounds = snapshot.bounds;
return {
blocks: snapshot.blocks.length,
drawCalls: batches.size,
unsupported: [...unsupported],
buildMs: performance.now() - started,
};
return buildTerrain(
snapshot,
scene,
faceData,
stateModels,
textureRef,
texture,
unsupported,
);
};
const directions = {
px: [
@@ -422,7 +233,7 @@ window.renderFace = (p, face, size) => {
camera.up.set(...up);
camera.lookAt(p.x + dir[0], p.y + dir[1], p.z + dir[2]);
renderer.render(scene, camera);
return renderer.domElement.toDataURL("image/png");
return renderer.domElement.toDataURL("image/webp", 0.85);
};
window.renderMap = (size) => {
const b = window.terrainBounds;
@@ -439,6 +250,6 @@ window.renderMap = (size) => {
camera.up.set(0, 0, -1);
camera.lookAt(camera.position.x, 0, camera.position.z);
renderer.render(scene, camera);
return renderer.domElement.toDataURL("image/png");
return renderer.domElement.toDataURL("image/webp", 0.9);
};
window.rendererReady = true;