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