import * as THREE from "/three.js"; import { buildTerrain } from "./terrain-mesh.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(); const gl = renderer.getContext(); const gpuInfo = gl.getExtension("WEBGL_debug_renderer_info"); window.rendererInfo = { vendor: gpuInfo ? gl.getParameter(gpuInfo.UNMASKED_VENDOR_WEBGL) : "unavailable", renderer: gpuInfo ? gl.getParameter(gpuInfo.UNMASKED_RENDERER_WEBGL) : "unavailable", }; 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, { signal: AbortSignal.timeout(60000) }).then((r) => { if (!r.ok) throw new Error(`Minecraft asset ${path}: HTTP ${r.status}`); 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)) { // Cache the in-flight promise and failures as well as successful textures. // Missing vanilla assets must not trigger another request for every face. textureCache.set( ref, loader .loadAsync(resourcePath(ref, "textures") + ".png") .then((t) => { t.magFilter = THREE.NearestFilter; t.minFilter = THREE.NearestFilter; t.colorSpace = THREE.SRGBColorSpace; return t; }) .catch(() => null), ); } 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], ], }, }; window.buildTerrain = async (snapshot) => { const unsupported = new Set(), 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 (error) { unsupported.add(name); if (unsupported.size <= 8) console.warn(`Missing block model ${name}: ${error.message}`); } stateCache.set(s, variants); return variants; } return buildTerrain( snapshot, scene, faceData, stateModels, textureRef, texture, unsupported, ); }; 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], ], }; function renderScene(camera, requireGeometry = false) { if (gl.isContextLost()) throw new Error("Graphics context lost while rendering terrain"); renderer.render(scene, camera); const error = gl.getError(); if (error !== gl.NO_ERROR) throw new Error(`WebGL failed to render terrain (error ${error})`); if (requireGeometry && renderer.info.render.triangles === 0) throw new Error( "Surface-map camera sees no terrain geometry. Check world coordinates and Minecraft assets.", ); } 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]); renderScene(camera); return renderer.domElement.toDataURL("image/webp", 0.85); }; 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); renderScene(camera, true); return renderer.domElement.toDataURL("image/webp", 0.9); }; window.rendererReady = true;