Optimize terrain rendering for large exports
This commit is contained in:
329
renderer/terrain-mesh.js
Normal file
329
renderer/terrain-mesh.js
Normal file
@@ -0,0 +1,329 @@
|
||||
import * as THREE from "/three.js";
|
||||
import { OpaqueIndex, QuadBuffer } from "./mesh-buffers.mjs";
|
||||
|
||||
const nonOpaque =
|
||||
/air|water|lava|leaves|glass|grass(?!_block)|fern|flower|sapling|torch|fence|wall|slab|stairs|door|pane|rail|vine|carpet/;
|
||||
const fluid = /^minecraft:(water|lava)(\[|$)/;
|
||||
const uvOrder = [0, 1, 3, 2];
|
||||
|
||||
// Transform each distinct state's faces once, including element rotations,
|
||||
// variant rotations, tint and UV orientation. The block loop only translates.
|
||||
async function compileState(
|
||||
state,
|
||||
variants,
|
||||
faceData,
|
||||
textureRef,
|
||||
texture,
|
||||
materials,
|
||||
unsupported,
|
||||
) {
|
||||
const faces = [];
|
||||
for (const { m, v = {} } of variants) {
|
||||
const rotation = new THREE.Matrix4().makeRotationFromEuler(
|
||||
new THREE.Euler(
|
||||
(-(v.x || 0) * Math.PI) / 180,
|
||||
(-(v.y || 0) * Math.PI) / 180,
|
||||
0,
|
||||
"YXZ",
|
||||
),
|
||||
);
|
||||
for (const e of m.elements || []) {
|
||||
const from = e.from.map((n) => n / 16),
|
||||
to = e.to.map((n) => n / 16);
|
||||
for (const [dir, f] of Object.entries(e.faces)) {
|
||||
const fd = faceData[dir];
|
||||
if (!fd) continue;
|
||||
const ref = textureRef(m, f.texture);
|
||||
const tex = await texture(ref);
|
||||
if (!tex) {
|
||||
unsupported.add(state);
|
||||
continue;
|
||||
}
|
||||
const tint =
|
||||
f.tintindex !== undefined
|
||||
? state.includes("water")
|
||||
? "#5b9eb5"
|
||||
: state.includes("leaves")
|
||||
? "#7ba54c"
|
||||
: "#90b650"
|
||||
: "#ffffff";
|
||||
const key = ref + "|" + tint;
|
||||
let material = materials.get(key);
|
||||
if (!material) {
|
||||
const water = ref?.includes("water");
|
||||
material = {
|
||||
id: materials.size,
|
||||
mesh: new THREE.MeshLambertMaterial({
|
||||
map: tex,
|
||||
color: tint,
|
||||
side: THREE.DoubleSide,
|
||||
alphaTest: 0.3,
|
||||
transparent: water,
|
||||
opacity: water ? 0.8 : 1,
|
||||
}),
|
||||
};
|
||||
materials.set(key, material);
|
||||
}
|
||||
const normal = new THREE.Vector3(...fd.normal).applyMatrix4(rotation);
|
||||
const positions = new Float64Array(12),
|
||||
uvs = new Float32Array(8);
|
||||
const uv = f.uv || [0, 0, 16, 16];
|
||||
const 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],
|
||||
];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const 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));
|
||||
const 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(rotation).addScalar(0.5);
|
||||
positions.set([p.x, p.y, p.z], i * 3);
|
||||
const index = f.rotation
|
||||
? uvOrder[(uvOrder.indexOf(i) + f.rotation / 90) % 4]
|
||||
: i;
|
||||
uvs.set(coords[index], i * 2);
|
||||
}
|
||||
faces.push({
|
||||
positions,
|
||||
uvs,
|
||||
normal: [normal.x, normal.y, normal.z],
|
||||
cull: f.cullface
|
||||
? [Math.round(normal.x), Math.round(normal.y), Math.round(normal.z)]
|
||||
: null,
|
||||
material,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
function fluidModel(state, faceData) {
|
||||
return {
|
||||
m: {
|
||||
textures: {
|
||||
all:
|
||||
"minecraft:block/" +
|
||||
(state.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: state.includes("water") ? 0 : undefined,
|
||||
cullface: k,
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildTerrain(
|
||||
snapshot,
|
||||
scene,
|
||||
faceData,
|
||||
stateModels,
|
||||
textureRef,
|
||||
texture,
|
||||
unsupported,
|
||||
) {
|
||||
const started = performance.now(),
|
||||
states = new Map(),
|
||||
materials = new Map();
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity,
|
||||
lastProgress = 0;
|
||||
async function progress(percent, message, force = false) {
|
||||
if (!force && performance.now() - lastProgress < 3000) return;
|
||||
lastProgress = performance.now();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await window.minegeoProgress?.({ percent, message });
|
||||
}
|
||||
// Time-sliced passes keep progress/cancellation responsive for large exports.
|
||||
if (!snapshot.blocks.length)
|
||||
throw new Error("Terrain snapshot contains no blocks");
|
||||
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];
|
||||
if (b[1] < minY) minY = b[1];
|
||||
if (b[1] > maxY) maxY = b[1];
|
||||
if (!states.has(b[3]))
|
||||
states.set(b[3], { opaque: !nonOpaque.test(b[3]), faces: null });
|
||||
if (i % 16384 === 0)
|
||||
await progress(
|
||||
25 + (2 * i) / snapshot.blocks.length,
|
||||
`Indexing blocks ${i}/${snapshot.blocks.length}`,
|
||||
);
|
||||
}
|
||||
const occupancy = new OpaqueIndex(snapshot.bounds, minY, maxY);
|
||||
for (let i = 0; i < snapshot.blocks.length; i++) {
|
||||
const b = snapshot.blocks[i];
|
||||
if (states.get(b[3]).opaque) occupancy.add(b[0], b[1], b[2]);
|
||||
if (i % 16384 === 0)
|
||||
await progress(
|
||||
27 + (3 * i) / snapshot.blocks.length,
|
||||
`Indexing hidden faces ${i}/${snapshot.blocks.length}`,
|
||||
);
|
||||
}
|
||||
const indexMs = performance.now() - started;
|
||||
await progress(30, "Compiling block models and textures", true);
|
||||
const entries = [...states.entries()];
|
||||
let completedModels = 0;
|
||||
// Bounded parallel I/O; failed textures are negatively cached by the loader.
|
||||
// Keep progress reporting serialized while compiler tasks perform I/O.
|
||||
for (let i = 0; i < entries.length; i += 8) {
|
||||
const end = Math.min(i + 8, entries.length);
|
||||
await Promise.all(
|
||||
entries.slice(i, end).map(async ([state, entry]) => {
|
||||
const variants = fluid.test(state)
|
||||
? [fluidModel(state, faceData)]
|
||||
: await stateModels(state);
|
||||
entry.faces = await compileState(
|
||||
state,
|
||||
variants,
|
||||
faceData,
|
||||
textureRef,
|
||||
texture,
|
||||
materials,
|
||||
unsupported,
|
||||
);
|
||||
completedModels++;
|
||||
}),
|
||||
);
|
||||
await progress(
|
||||
30 + (5 * completedModels) / entries.length,
|
||||
`Compiled block models ${completedModels}/${entries.length}`,
|
||||
);
|
||||
}
|
||||
const modelMs = performance.now() - started - indexMs;
|
||||
await progress(35, "Building cached block faces", true);
|
||||
const batches = new Map(),
|
||||
meshList = [],
|
||||
materialCount = materials.size || 1;
|
||||
const originX = Math.floor(snapshot.bounds.minX / 64),
|
||||
originZ = Math.floor(snapshot.bounds.minZ / 64);
|
||||
const regionWidth = Math.floor(snapshot.bounds.maxX / 64) - originX + 1;
|
||||
let emittedFaces = 0,
|
||||
hiddenFaces = 0;
|
||||
function flush(buffer, material) {
|
||||
if (!buffer.count) return;
|
||||
const data = buffer.finish(),
|
||||
g = new THREE.BufferGeometry();
|
||||
g.setAttribute("position", new THREE.BufferAttribute(data.positions, 3));
|
||||
g.setAttribute("normal", new THREE.BufferAttribute(data.normals, 3));
|
||||
g.setAttribute("uv", new THREE.BufferAttribute(data.uvs, 2));
|
||||
g.setIndex(new THREE.BufferAttribute(data.indices, 1));
|
||||
g.boundingBox = new THREE.Box3(
|
||||
new THREE.Vector3(...data.min),
|
||||
new THREE.Vector3(...data.max),
|
||||
);
|
||||
g.boundingSphere = g.boundingBox.getBoundingSphere(new THREE.Sphere());
|
||||
const mesh = new THREE.Mesh(g, material.mesh);
|
||||
scene.add(mesh);
|
||||
meshList.push(mesh);
|
||||
}
|
||||
for (let i = 0; i < snapshot.blocks.length; i++) {
|
||||
const b = snapshot.blocks[i],
|
||||
x = b[0],
|
||||
y = b[1],
|
||||
z = b[2];
|
||||
const entry = states.get(b[3]);
|
||||
const region =
|
||||
Math.floor(x / 64) -
|
||||
originX +
|
||||
(Math.floor(z / 64) - originZ) * regionWidth;
|
||||
for (const face of entry.faces) {
|
||||
const n = face.cull;
|
||||
if (n && occupancy.has(x + n[0], y + n[1], z + n[2])) {
|
||||
hiddenFaces++;
|
||||
continue;
|
||||
}
|
||||
const key = region * materialCount + face.material.id;
|
||||
let batch = batches.get(key);
|
||||
if (!batch) {
|
||||
batch = { buffer: new QuadBuffer(), material: face.material };
|
||||
batches.set(key, batch);
|
||||
}
|
||||
batch.buffer.add(face, x, y, z);
|
||||
emittedFaces++;
|
||||
if (batch.buffer.full) {
|
||||
flush(batch.buffer, batch.material);
|
||||
batch.buffer = new QuadBuffer();
|
||||
}
|
||||
}
|
||||
if (i % 4096 === 0)
|
||||
await progress(
|
||||
35 + (20 * i) / snapshot.blocks.length,
|
||||
`Building blocks ${i}/${snapshot.blocks.length}; ${Math.round(i / Math.max(0.001, (performance.now() - started - indexMs - modelMs) / 1000)).toLocaleString()} blocks/s; ${hiddenFaces.toLocaleString()} hidden faces skipped`,
|
||||
);
|
||||
}
|
||||
occupancy.clear();
|
||||
const meshMs = performance.now() - started - indexMs - modelMs;
|
||||
await progress(55, "Preparing spatial meshes", true);
|
||||
let prepared = 0;
|
||||
for (const [key, batch] of batches) {
|
||||
flush(batch.buffer, batch.material);
|
||||
batches.delete(key);
|
||||
await progress(
|
||||
55 + (5 * ++prepared) / (prepared + batches.size),
|
||||
`Preparing geometry ${prepared}/${prepared + batches.size}`,
|
||||
);
|
||||
}
|
||||
window.terrainBounds = snapshot.bounds;
|
||||
window.terrainMeshes = meshList;
|
||||
const totalMs = performance.now() - started;
|
||||
return {
|
||||
blocks: snapshot.blocks.length,
|
||||
states: states.size,
|
||||
meshes: meshList.length,
|
||||
drawCalls: meshList.length,
|
||||
emittedFaces,
|
||||
hiddenFaces,
|
||||
unsupported: [...unsupported],
|
||||
indexMs,
|
||||
modelMs,
|
||||
meshMs,
|
||||
buildMs: totalMs,
|
||||
blocksPerSecond: Math.round(
|
||||
snapshot.blocks.length / Math.max(0.001, meshMs / 1000),
|
||||
),
|
||||
geometryBytes: meshList.reduce(
|
||||
(sum, mesh) =>
|
||||
sum +
|
||||
mesh.geometry.attributes.position.array.byteLength +
|
||||
mesh.geometry.attributes.normal.array.byteLength +
|
||||
mesh.geometry.attributes.uv.array.byteLength +
|
||||
mesh.geometry.index.array.byteLength,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user