import * as THREE from "/three.js"; // This renderer consumes snapshots, never connects to a Minecraft server. const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true, }); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setPixelRatio(1); document.body.append(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color("#95c4eb"); scene.add(new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0)); const sun = new THREE.DirectionalLight("#fff4d6", 2.2); sun.position.set(-40, 90, -30); scene.add(sun); const loader = new THREE.TextureLoader(), jsonCache = new Map(), textureCache = new Map(), resolvedModels = new Map(); const resourcePath = (ref, kind) => `/assets/assets/${ref.includes(":") ? ref.split(":")[0] : "minecraft"}/${kind}/${ref.split(":").at(-1)}`; async function json(path) { if (!jsonCache.has(path)) jsonCache.set( path, fetch(path).then((r) => { if (!r.ok) throw new Error(path); return r.json(); }), ); return jsonCache.get(path); } async function model(ref, chain = []) { if (chain.includes(ref)) throw new Error("Cyclic model"); if (resolvedModels.has(ref)) return resolvedModels.get(ref); const raw = await json(resourcePath(ref, "models") + ".json"); let parent = {}; if (raw.parent && !raw.parent.includes("builtin/")) parent = await model(raw.parent, [...chain, ref]); const merged = { ...parent, ...raw, textures: { ...parent.textures, ...raw.textures }, }; resolvedModels.set(ref, merged); return merged; } function textureRef(m, ref) { let i = 0; while (ref?.startsWith("#") && i++ < 20) ref = m.textures[ref.slice(1)]; return 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); } return textureCache.get(ref); } function parseState(s) { const [name, raw] = s.split("["); return { name, props: Object.fromEntries( (raw?.replace("]", "") || "") .split(",") .filter(Boolean) .map((p) => p.split("=")), ), }; } function match(condition, props) { if (condition.OR) return condition.OR.some((c) => match(c, props)); if (condition.AND) return condition.AND.every((c) => match(c, props)); return Object.entries(condition).every(([k, v]) => String(v).split("|").includes(props[k]), ); } function selectModels(state, props) { if (state.variants) { for (const [key, v] of Object.entries(state.variants)) { const c = Object.fromEntries( key .split(",") .filter(Boolean) .map((p) => p.split("=")), ); if (match(c, props)) return [Array.isArray(v) ? v[0] : v]; } } return (state.multipart || []) .filter((p) => !p.when || match(p.when, props)) .map((p) => (Array.isArray(p.apply) ? p.apply[0] : p.apply)); } const faceData = { east: { normal: [1, 0, 0], corners: [ [1, 0, 1], [1, 0, 0], [1, 1, 1], [1, 1, 0], ], }, west: { normal: [-1, 0, 0], corners: [ [0, 0, 0], [0, 0, 1], [0, 1, 0], [0, 1, 1], ], }, up: { normal: [0, 1, 0], corners: [ [0, 1, 1], [1, 1, 1], [0, 1, 0], [1, 1, 0], ], }, down: { normal: [0, -1, 0], corners: [ [0, 0, 0], [1, 0, 0], [0, 0, 1], [1, 0, 1], ], }, south: { normal: [0, 0, 1], corners: [ [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1], ], }, north: { normal: [0, 0, -1], corners: [ [1, 0, 0], [0, 0, 0], [1, 1, 0], [0, 1, 0], ], }, }; 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(); for (const b of snapshot.blocks) { occupancy.set(`${b[0]},${b[1]},${b[2]}`, b[3]); distinctStates.add(b[3]); } const stateCache = new Map(); async function stateModels(s) { if (stateCache.has(s)) return stateCache.get(s); const { name, props } = parseState(s); let variants = []; try { const bs = await json(resourcePath(name, "blockstates") + ".json"); for (const v of selectModels(bs, props)) variants.push({ v, m: await model(v.model) }); } catch { unsupported.add(name); } stateCache.set(s, variants); return variants; } // Preload each distinct model once before constructing batched geometry. for (const s of distinctStates) await stateModels(s); 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, ); } } } for (const b of snapshot.blocks) { 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(); 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; } window.terrainBounds = snapshot.bounds; return { blocks: snapshot.blocks.length, drawCalls: batches.size, unsupported: [...unsupported], buildMs: performance.now() - started, }; }; const directions = { px: [ [1, 0, 0], [0, 1, 0], ], nx: [ [-1, 0, 0], [0, 1, 0], ], py: [ [0, 1, 0], [0, 0, -1], ], ny: [ [0, -1, 0], [0, 0, 1], ], pz: [ [0, 0, 1], [0, 1, 0], ], nz: [ [0, 0, -1], [0, 1, 0], ], }; window.renderFace = (p, face, size) => { renderer.setSize(size, size); const camera = new THREE.PerspectiveCamera(90, 1, 0.05, 512); camera.position.set(p.x, p.y, p.z); const [dir, up] = directions[face]; 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"); }; window.renderMap = (size) => { const b = window.terrainBounds; renderer.setSize(size, size); const camera = new THREE.OrthographicCamera( -(b.maxX - b.minX) / 2, (b.maxX - b.minX) / 2, (b.maxZ - b.minZ) / 2, -(b.maxZ - b.minZ) / 2, 0.1, 1024, ); camera.position.set((b.minX + b.maxX) / 2, 512, (b.minZ + b.maxZ) / 2); 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"); }; window.rendererReady = true;