Files
MineGeo/renderer/terrain-mesh.js

330 lines
10 KiB
JavaScript

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,
),
};
}