372 lines
12 KiB
JavaScript
372 lines
12 KiB
JavaScript
import * as THREE from "/three.js";
|
|
import { OpaqueIndex, QuadBuffer } from "./mesh-buffers.mjs";
|
|
import { blockTint } from "./block-colors.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,
|
|
biome,
|
|
) {
|
|
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 ? blockTint(state, biome) : "#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,
|
|
// Grass side overlays share a plane with the dirt face.
|
|
polygonOffset: ref?.includes("grass_block_side_overlay"),
|
|
polygonOffsetFactor: -1,
|
|
polygonOffsetUnits: -1,
|
|
}),
|
|
};
|
|
materials.set(key, material);
|
|
}
|
|
const normal = new THREE.Vector3(...fd.normal).applyMatrix4(rotation);
|
|
if (e.rotation) {
|
|
const axis = new THREE.Vector3(
|
|
e.rotation.axis === "x" ? 1 : 0,
|
|
e.rotation.axis === "y" ? 1 : 0,
|
|
e.rotation.axis === "z" ? 1 : 0,
|
|
);
|
|
normal.copy(
|
|
new THREE.Vector3(...fd.normal)
|
|
.applyAxisAngle(axis, (e.rotation.angle * Math.PI) / 180)
|
|
.applyMatrix4(rotation),
|
|
);
|
|
}
|
|
const positions = new Float64Array(12),
|
|
uvs = new Float32Array(8);
|
|
const uv = f.uv || defaultUV(dir, e.from, e.to);
|
|
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,
|
|
},
|
|
]),
|
|
),
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
function defaultUV(dir, from, to) {
|
|
const [x, y, z] = from,
|
|
[X, Y, Z] = to;
|
|
if (dir === "up") return [x, z, X, Z];
|
|
if (dir === "down") return [x, 16 - Z, X, 16 - z];
|
|
if (dir === "north") return [16 - X, 16 - Y, 16 - x, 16 - y];
|
|
if (dir === "south") return [x, 16 - Y, X, 16 - y];
|
|
if (dir === "west") return [z, 16 - Y, Z, 16 - y];
|
|
return [16 - Z, 16 - Y, 16 - z, 16 - y];
|
|
}
|
|
|
|
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];
|
|
const key = b[3] + "|" + (b[4] || "minecraft:plains");
|
|
if (!states.has(key))
|
|
states.set(key, {
|
|
state: b[3],
|
|
biome: b[4],
|
|
opaque: !nonOpaque.test(b[3].split("[")[0]),
|
|
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] + "|" + (b[4] || "minecraft:plains")).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 ([, entry]) => {
|
|
const state = entry.state;
|
|
const variants = fluid.test(state)
|
|
? [fluidModel(state, faceData)]
|
|
: await stateModels(state);
|
|
entry.faces = await compileState(
|
|
state,
|
|
variants,
|
|
faceData,
|
|
textureRef,
|
|
texture,
|
|
materials,
|
|
unsupported,
|
|
entry.biome,
|
|
);
|
|
completedModels++;
|
|
}),
|
|
);
|
|
await progress(
|
|
30 + (5 * completedModels) / entries.length,
|
|
`Compiled block models ${completedModels}/${entries.length}`,
|
|
);
|
|
}
|
|
const modelMs = performance.now() - started - indexMs;
|
|
if (!materials.size || !entries.some(([, entry]) => entry.faces.length)) {
|
|
throw new Error(
|
|
`No terrain models could be rendered. Minecraft models/textures are missing or failed to load. Run npm run setup. Unsupported examples: ${[...unsupported].slice(0, 8).join(", ")}`,
|
|
);
|
|
}
|
|
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] + "|" + (b[4] || "minecraft:plains"));
|
|
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;
|
|
if (!meshList.length || !emittedFaces) {
|
|
throw new Error(
|
|
"Terrain renderer produced no visible geometry. The export cannot be accepted as a playable map.",
|
|
);
|
|
}
|
|
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,
|
|
),
|
|
};
|
|
}
|