diff --git a/assets.js b/assets.js deleted file mode 100644 index 52862f38..00000000 --- a/assets.js +++ /dev/null @@ -1,118 +0,0 @@ -// assets.js -const jsonCache = new Map(); - -/** - * Resolves a namespace:path ID to a file path. - * Defaults to the 'minecraft' namespace if none is provided. - */ -export function resolveResourceLocation(id, type, ext = 'json') { - const parts = id.split(':'); - const namespace = parts.length > 1 ? parts[0] : 'minecraft'; - const path = parts.length > 1 ? parts[1] : parts[0]; - return `assets/${namespace}/${type}/${path}.${ext}`; -} - -async function fetchJSON(url) { - if (jsonCache.has(url)) return jsonCache.get(url); - const res = await fetch(url); - if (!res.ok) throw new Error(`Failed to load asset: ${url}`); - const data = await res.json(); - jsonCache.set(url, data); - return data; -} - -export async function loadBlockstate(id) { - const url = resolveResourceLocation(id, 'blockstates'); - return fetchJSON(url); -} - -export async function loadModel(id) { - const url = resolveResourceLocation(id, 'models'); - const model = await fetchJSON(url); - - // Recursively resolve and merge parent models - if (model.parent) { - // Parents sometimes use block/ or item/ prefixes directly - const parentId = model.parent.includes(':') ? model.parent : `minecraft:${model.parent}`; - const parent = await loadModel(parentId); - - // Merge textures - model.textures = {...parent.textures, ...model.textures}; - // Inherit elements if child doesn't override them - if (!model.elements) model.elements = parent.elements; - } - return model; -} - -export class TextureAtlas { - constructor(size = 512) { - this.canvas = document.createElement('canvas'); - this.canvas.width = size; - this.canvas.height = size; - this.ctx = this.canvas.getContext('2d', {willReadFrequently: true}); - this.ctx.imageSmoothingEnabled = false; - - this.map = new Map(); - this.promises = new Map(); // Track loading promises separately! - - this.cellSize = 16; - this.padding = 1; - this.x = 0; - this.y = 0; - this.rowHeight = 0; - } - - async fill(id, currentX, currentY) { - const url = resolveResourceLocation(id, 'textures', 'png'); - - try { - const img = await new Promise((resolve, reject) => { - const i = new Image(); - i.crossOrigin = 'anonymous'; - i.onload = () => resolve(i); - i.onerror = () => reject(new Error(url)); - i.src = url; - }); - - this.ctx.drawImage(img, currentX, currentY, this.cellSize, this.cellSize); - } catch (e) { - console.warn(`Texture missing: ${id}`); - this.ctx.fillStyle = '#ff00ff'; - this.ctx.fillRect(currentX, currentY, this.cellSize, this.cellSize); - } - } - - load(id) { - // If already loading/loaded, just return the tracking promise - if (this.promises.has(id)) return this.promises.get(id); - - if (this.x + this.cellSize > this.canvas.width) { - this.x = 0; - this.y += this.rowHeight + this.padding; - this.rowHeight = 0; - } - - const currentX = this.x; - const currentY = this.y; - - this.x += this.cellSize + this.padding; - this.rowHeight = Math.max(this.rowHeight, this.cellSize); - const epsX = 0.1 / this.canvas.width; - const epsY = 0.1 / this.canvas.height; - - // 1. SYNCHRONOUSLY allocate the UV coordinates for buildGeometry - const uvData = { - u: currentX / this.canvas.width + epsX, - v: currentY / this.canvas.height + epsY, - du: this.cellSize / this.canvas.width - 2 * epsX, - dv: this.cellSize / this.canvas.height - 2 * epsY, - }; - this.map.set(id, uvData); - - // 2. ASYNCHRONOUSLY kick off the image fetch - const loadTask = this.fill(id, currentX, currentY); - this.promises.set(id, loadTask); - - return loadTask; - } -} diff --git a/blockstate.js b/blockstate.js index 1a9a2f73..2626cc78 100644 --- a/blockstate.js +++ b/blockstate.js @@ -1,3 +1,20 @@ +export class BlockstateHandler { + async process(cache, id, url) { + if (id === ':missing') { + return new Blockstate({variants: {"": {model: ":missing"}}}); + } + try { + const res = await fetch(url + '.json'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + return new Blockstate(json); + } catch (e) { + console.warn(`Blockstate missing/broken (${id}), falling back to :missing`); + return new Blockstate({variants: {"": {model: ":missing"}}}); + } + } +} + function evaluateWhen(properties, condition) { if (!condition) return true; if (condition.OR) return condition.OR.some(sub => evaluateWhen(properties, sub)); @@ -15,43 +32,119 @@ function evaluateWhen(properties, condition) { return true; } -export function resolveBlock(state, properties) { - const parts = []; +export class Blockstate { + constructor(json) { + this.variants = json.variants; + this.multipart = json.multipart; - if (state.variants) { - // Handle standard variants - const propStr = Object.keys(properties).sort().map(k => `${k}=${properties[k]}`).join(','); - let variantDef = state.variants[propStr] || state.variants[""] || state.variants["normal"]; + // Memoize evaluated property combinations to prevent console spam + // and speed up Sweep 2 recalculations. + this._resolutionCache = new Map(); + } - // --- NEW ERROR REPORTING --- - if (!variantDef) { - console.warn("Available states:", Object.keys(state.variants)); - throw new Error( - `Failed to resolve block variant.\nRequested: "${propStr}"\nCheck the console for available states.` - ); - } + _getVariantsSummary() { + const reqs = {}; - if (Array.isArray(variantDef)) variantDef = variantDef[0]; // Take first random model - parts.push(variantDef); - } else if (state.multipart) { - // Handle multipart - for (const part of state.multipart) { - if (evaluateWhen(properties, part.when)) { - let applyDef = part.apply; - if (Array.isArray(applyDef)) applyDef = applyDef[0]; - parts.push(applyDef); + for (const key of Object.keys(this.variants)) { + if (key === "" || key === "normal") continue; + + const pairs = key.split(','); + for (const pair of pairs) { + const [prop, val] = pair.split('='); + if (prop && val) { + if (!reqs[prop]) reqs[prop] = new Set(); + reqs[prop].add(val); + } } } - if (parts.length === 0) { - console.warn("Possibly malformed multipart block with no parts.") + const summary = {}; + for (const [k, v] of Object.entries(reqs)) { + summary[k] = Array.from(v); } + return summary; } - return parts; -} + _getMultipartSummary() { + const reqs = {}; -export function getVariantHash(part) { - // Uniquely identifies a baked geometry variant so we can pool instances - return `${part.model}#y=${part.y || 0},x=${part.x || 0},uvlock=${!!part.uvlock}`; + const traverse = (cond) => { + if (!cond) return; + if (cond.OR) cond.OR.forEach(traverse); + if (cond.AND) cond.AND.forEach(traverse); + + for (const [key, val] of Object.entries(cond)) { + if (key === 'OR' || key === 'AND') continue; + if (!reqs[key]) reqs[key] = new Set(); + reqs[key].add(val); + } + }; + + if (this.multipart) { + for (const part of this.multipart) { + traverse(part.when); + } + } + + // Convert sets to arrays for readable console logging + const summary = {}; + for (const [k, v] of Object.entries(reqs)) { + summary[k] = Array.from(v); + } + return summary; + } + + resolveParts(properties) { + // Create a deterministic cache key from the properties object + const cacheKey = Object.keys(properties).sort().map(k => `${k}=${properties[k]}`).join(','); + + if (this._resolutionCache.has(cacheKey)) { + return this._resolutionCache.get(cacheKey); + } + + const parts = []; + + if (this.variants) { + let variantDef = this.variants[cacheKey] || this.variants[""] || this.variants["normal"]; + + if (!variantDef) { + console.warn( + `Failed to resolve block variant: "${cacheKey}". Falling back to :missing.\n` + + `Properties evaluated by this blockstate:`, + this._getVariantsSummary() + ); + const missingFallback = [{model: ":missing"}]; + this._resolutionCache.set(cacheKey, missingFallback); + return missingFallback; + } + + if (Array.isArray(variantDef)) variantDef = variantDef[0]; + parts.push(variantDef); + } else if (this.multipart) { + for (const part of this.multipart) { + if (evaluateWhen(properties, part.when)) { + let applyDef = part.apply; + if (Array.isArray(applyDef)) applyDef = applyDef[0]; + parts.push(applyDef); + } + } + if (parts.length === 0) { + console.warn( + `Multipart block ("${cacheKey}") resolved to no parts. Falling back to :missing.\n` + + `Properties evaluated by this blockstate:`, + this._getMultipartSummary() + ); + const missingFallback = [{model: ":missing"}]; + this._resolutionCache.set(cacheKey, missingFallback); + return missingFallback; + } + } + + this._resolutionCache.set(cacheKey, parts); + return parts; + } + + static getVariantHash(part) { + return `${part.model}#y=${part.y || 0},x=${part.x || 0},uvlock=${!!part.uvlock}`; + } } \ No newline at end of file diff --git a/engine.js b/engine.js index 7bc6b3c1..c6b404e7 100644 --- a/engine.js +++ b/engine.js @@ -1,8 +1,9 @@ // engine.js import {mat4Identity, mat4Translate} from './math.js'; -import {loadBlockstate, loadModel, TextureAtlas} from './assets.js'; -import {resolveTexture, buildGeometry} from './geometry.js'; -import {resolveBlock, getVariantHash} from './blockstate.js'; +import {ResourceCache} from "./resource-cache.js"; +import {Blockstate, BlockstateHandler} from "./blockstate.js"; +import {ModelHandler} from "./model.js"; +import {TextureHandler} from "./texture.js"; const VS_SRC = `#version 300 es layout(location=0) in vec3 a_position; @@ -91,7 +92,13 @@ export class Engine { }; this.atlasTexture = gl.createTexture(); - this.atlas = new TextureAtlas(256); + + // --- Initialize the new Resource Pipeline --- + this.cache = new ResourceCache('assets'); + this.atlas = new TextureHandler(256); + this.cache.register('blockstates', new BlockstateHandler()); + this.cache.register('models', new ModelHandler()); + this.cache.register('textures', this.atlas); this.dioramas = []; this.worldMeshes = new Map(); @@ -111,12 +118,10 @@ export class Engine { requestUpdate() { if (!this.updateRequested) { this.updateRequested = true; - - // Promise.resolve().then() executes immediately after the current synchronous - // call stack finishes, ensuring 10 simultaneous setBlock calls only trigger 1 update. - Promise.resolve().then(() => { + // Add 'async' here and 'await' updateAll + Promise.resolve().then(async () => { + await this.updateAll(); this.updateRequested = false; - this.updateAll(); }); } } @@ -138,52 +143,48 @@ export class Engine { addDiorama(diorama) { this.dioramas.push(diorama); - - // Subscribe to the world if we aren't already watching it if (!this.observedWorlds.has(diorama.world)) { this.observedWorlds.add(diorama.world); - - // Whenever the world changes, queue a single, debounced geometry rebuild diorama.world.subscribe(() => this.requestUpdate()); } } async updateAll() { - // Extract all unique worlds currently being viewed const uniqueWorlds = new Set(this.dioramas.map(d => d.world)); + if (uniqueWorlds.size === 0) return; // --- SWEEP 1: Collect & Fetch Blockstates --- const uniqueBlockIds = new Set(); for (const world of uniqueWorlds) { for (const block of world.blocks.values()) uniqueBlockIds.add(block.id); } - await Promise.all(Array.from(uniqueBlockIds).map(id => loadBlockstate(id))); + await Promise.all(Array.from(uniqueBlockIds).map(id => this.cache.get(id, 'blockstates'))); // --- SWEEP 2: Resolve Parts & Fetch Models --- const uniqueModelIds = new Set(); - const parsedWorlds = new Map(); // World -> Array of {block, parts} + const parsedWorlds = new Map(); for (const world of uniqueWorlds) { const parsedBlocks = []; for (const block of world.blocks.values()) { - const stateJSON = await loadBlockstate(block.id); - const parts = resolveBlock(stateJSON, block.props); + const state = await this.cache.get(block.id, 'blockstates'); + const parts = state.resolveParts(block.props); parsedBlocks.push({block, parts}); for (const p of parts) uniqueModelIds.add(p.model); } parsedWorlds.set(world, parsedBlocks); } - await Promise.all(Array.from(uniqueModelIds).map(id => loadModel(id))); + await Promise.all(Array.from(uniqueModelIds).map(id => this.cache.get(id, 'models'))); // --- SWEEP 3: Pool Instances & Fetch Textures --- - const uniqueTexturePaths = new Set(); - const worldPools = new Map(); // World -> instancePool + const textureTasks = []; + const worldPools = new Map(); for (const world of uniqueWorlds) { const instancePool = new Map(); for (const {block, parts} of parsedWorlds.get(world)) { for (const part of parts) { - const hash = getVariantHash(part); + const hash = Blockstate.getVariantHash(part); if (!instancePool.has(hash)) { instancePool.set(hash, {partDef: part, matrices: [], colors: []}); } @@ -203,29 +204,34 @@ export class Engine { } for (const pool of instancePool.values()) { - const modelJSON = await loadModel(pool.partDef.model); - for (const el of modelJSON.elements || []) { + const blockModel = await this.cache.get(pool.partDef.model, 'models'); + for (const el of blockModel.elements) { for (const face of Object.values(el.faces || {})) { - const texPath = resolveTexture(modelJSON, face.texture); - if (texPath) uniqueTexturePaths.add(texPath); + const texPath = blockModel.resolveTexture(face.texture); + if (texPath) { + // Synchronously allocates UV, asynchronously fetches image + textureTasks.push(this.cache.get(texPath, 'textures', 'png')); + } } } } worldPools.set(world, instancePool); } - await Promise.all(Array.from(uniqueTexturePaths).map(path => this.atlas.load(path))); + await Promise.all(textureTasks); this.updateAtlasTexture(); // --- SWEEP 4: Bake Geometry --- for (const world of uniqueWorlds) { const newMeshes = []; for (const pool of worldPools.get(world).values()) { - const modelJSON = await loadModel(pool.partDef.model); - const geometry = buildGeometry(modelJSON, this.atlas, pool.partDef); + const blockModel = await this.cache.get(pool.partDef.model, 'models'); + const geometry = blockModel.buildGeometry(this.atlas.uvmap, pool.partDef); + const matrixArray = new Float32Array(pool.matrices); const colorArray = new Float32Array(pool.colors); const instanceCount = pool.matrices.length / 16; + newMeshes.push(this.createInstancedMesh(geometry, instanceCount, matrixArray, colorArray)); } this.worldMeshes.set(world, newMeshes); @@ -309,11 +315,10 @@ export class Engine { const aspect = rect.width / rect.height; const viewProj = diorama.updateMatrices(aspect); + gl.uniformMatrix4fv(this.uniforms.viewProj, false, viewProj); - gl.uniform3f(this.uniforms.lightDir, 1.0, 3.0, 2); - gl.uniform3f(this.uniforms.viewDir, - diorama.viewDir[0], diorama.viewDir[1], diorama.viewDir[2] - ); + gl.uniform3f(this.uniforms.lightDir, 1.0, 3.0, 2.0); + gl.uniform3f(this.uniforms.viewDir, diorama.viewDir[0], diorama.viewDir[1], diorama.viewDir[2]); const meshes = this.worldMeshes.get(diorama.world) || []; for (const mesh of meshes) { diff --git a/geometry.js b/geometry.js deleted file mode 100644 index a6f9af77..00000000 --- a/geometry.js +++ /dev/null @@ -1,144 +0,0 @@ -// geometry.js -import {mat4Identity, mat4RotateX, mat4RotateY, mat4Translate} from './math.js'; - -const FACES = { - down: {n: [0, -1, 0], c: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]]}, - up: {n: [0, 1, 0], c: [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]]}, - north: {n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]]}, - south: {n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]]}, - west: {n: [-1, 0, 0], c: [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]]}, - east: {n: [1, 0, 0], c: [[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]} -}; - -// Calculates missing UVs by projecting the element bounds onto the face plane -function calculateDefaultUV(faceName, from, to) { - switch (faceName) { - case 'up': - return [from[0], from[2], to[0], to[2]]; - case 'down': - return [from[0], 16 - to[2], to[0], 16 - from[2]]; - case 'north': - return [16 - to[0], 16 - to[1], 16 - from[0], 16 - from[1]]; - case 'south': - return [from[0], 16 - to[1], to[0], 16 - from[1]]; - case 'west': - return [from[2], 16 - to[1], to[2], 16 - from[1]]; - case 'east': - return [16 - to[2], 16 - to[1], 16 - from[2], 16 - from[1]]; - default: - return [0, 0, 16, 16]; - } -} - -export function resolveTexture(model, ref) { - if (!ref) return null; - if (ref[0] !== '#') return ref; - let key = ref.slice(1); - let val = model.textures?.[key]; - while (typeof val === 'string' && val[0] === '#') { - key = val.slice(1); - val = model.textures?.[key]; - } - if (val && typeof val === 'object' && val.sprite) val = val.sprite; - return val || null; -} - -// Takes the blockstate variant object to apply y/x rotations and uvlock -export function buildGeometry(model, atlas, variant = {}) { - const pos = [], norm = [], uv = [], tint = [], shade = [], idx = []; - let vOffset = 0; - - // 1. Pre-calculate the blockstate rotation matrix (around block center) - const blockMatrix = mat4Identity(new Float32Array(16)); - mat4Translate(blockMatrix, blockMatrix, [0.5, 0.5, 0.5]); - if (variant.x) mat4RotateX(blockMatrix, blockMatrix, -variant.x * Math.PI / 180); - if (variant.y) mat4RotateY(blockMatrix, blockMatrix, -variant.y * Math.PI / 180); - mat4Translate(blockMatrix, blockMatrix, [-0.5, -0.5, -0.5]); - - for (const el of model.elements || []) { - const [fx, fy, fz] = el.from.map(x => x / 16); - const [tx, ty, tz] = el.to.map(x => x / 16); - const size = [tx - fx, ty - fy, tz - fz]; - - // Element-level shading flag. Used to make repeater torches/redstone dust emissive. - const elementShade = el.shade === false ? 0.0 : 1.0; - - for (const [name, face] of Object.entries(el.faces || {})) { - const tmpl = FACES[name]; - const texPath = resolveTexture(model, face.texture); - const a = texPath ? atlas.map.get(texPath) : null; - if (!a) continue; - - // DYNAMIC UV PROJECTION (remains the same) - const rawUV = face.uv || calculateDefaultUV(name, el.from, el.to); - let u1 = rawUV[0] / 16, v1 = rawUV[1] / 16; - let u2 = rawUV[2] / 16, v2 = rawUV[3] / 16; - - let texRot = face.rotation || 0; - - // uvlock compensation - if (variant.uvlock && (name === 'up' || name === 'down') && variant.y) { - texRot = (texRot - variant.y + 360) % 360; - } - - const au1 = a.u + u1 * a.du, au2 = a.u + u2 * a.du; - const av1 = a.v + v1 * a.dv, av2 = a.v + v2 * a.dv; - - // CORRECTED UV ARRAY MAPPING - // Corner Order: 0:Bottom-Left, 1:Bottom-Right, 2:Top-Right, 3:Top-Left - let faceUVs; - if (texRot === 90) { - // Shift UVs clockwise by 1 corner - faceUVs = [au2, av2, au2, av1, au1, av1, au1, av2]; - } else if (texRot === 180) { - // Shift UVs by 2 corners - faceUVs = [au2, av1, au1, av1, au1, av2, au2, av2]; - } else if (texRot === 270) { - // Shift UVs by 3 corners - faceUVs = [au1, av1, au1, av2, au2, av2, au2, av1]; - } else { - // 0 degrees: av2 (bottom) goes to corners 0 and 1. av1 (top) goes to corners 2 and 3. - faceUVs = [au1, av2, au2, av2, au2, av1, au1, av1]; - } - - const tintable = face.tintindex !== undefined ? 1.0 : 0.0; - - for (let i = 0; i < 4; i++) { - const [cx, cy, cz] = tmpl.c[i]; - let vx = fx + cx * size[0]; - let vy = fy + cy * size[1]; - let vz = fz + cz * size[2]; - - // Apply Blockstate Rotation - let wx = blockMatrix[0] * vx + blockMatrix[4] * vy + blockMatrix[8] * vz + blockMatrix[12]; - let wy = blockMatrix[1] * vx + blockMatrix[5] * vy + blockMatrix[9] * vz + blockMatrix[13]; - let wz = blockMatrix[2] * vx + blockMatrix[6] * vy + blockMatrix[10] * vz + blockMatrix[14]; - - pos.push(wx, wy, wz); - - // Note: Normal rotation should technically use the inverse-transpose of the matrix, - // but since we only have pure rotations, direct multiplication works fine here. - let nx = blockMatrix[0] * tmpl.n[0] + blockMatrix[4] * tmpl.n[1] + blockMatrix[8] * tmpl.n[2]; - let ny = blockMatrix[1] * tmpl.n[0] + blockMatrix[5] * tmpl.n[1] + blockMatrix[9] * tmpl.n[2]; - let nz = blockMatrix[2] * tmpl.n[0] + blockMatrix[6] * tmpl.n[1] + blockMatrix[10] * tmpl.n[2]; - - norm.push(nx, ny, nz); - tint.push(tintable); - shade.push(elementShade); - } - - uv.push(...faceUVs); - idx.push(vOffset, vOffset + 1, vOffset + 2, vOffset, vOffset + 2, vOffset + 3); - vOffset += 4; - } - } - - return { - positions: new Float32Array(pos), - normals: new Float32Array(norm), - uvs: new Float32Array(uv), - tints: new Float32Array(tint), - shades: new Float32Array(shade), - indices: new Uint16Array(idx) - }; -} \ No newline at end of file diff --git a/index.html b/index.html index 85d531ad..6eb833bc 100644 --- a/index.html +++ b/index.html @@ -63,7 +63,7 @@ const world1 = new World(); world1.setBlock('minecraft:redstone_wire', -1, 0, 0, { - east: 'none', + east: 'side', west: 'none', north: 'none', south: 'none', diff --git a/model.js b/model.js new file mode 100644 index 00000000..431bfe34 --- /dev/null +++ b/model.js @@ -0,0 +1,188 @@ +import {mat4Identity, mat4RotateX, mat4RotateY, mat4Translate} from "./math.js"; + +const MISSING_MODEL_JSON = { + "textures": {"missing": ":missing"}, + "elements": [ + { + "from": [0, 0, 0], + "to": [16, 16, 16], + "faces": { + "down": {"texture": "#missing"}, + "up": {"texture": "#missing"}, + "north": {"texture": "#missing"}, + "south": {"texture": "#missing"}, + "west": {"texture": "#missing"}, + "east": {"texture": "#missing"} + } + } + ] +}; + +export class ModelHandler { + async process(cache, id, url) { + if (id === ':missing') return new BlockModel(MISSING_MODEL_JSON); + + try { + const res = await fetch(url + '.json'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + + if (json.parent) { + const parent = await cache.get(json.parent, 'models'); + json.textures = {...parent.textures, ...json.textures}; + if (!json.elements && parent.elements) { + json.elements = parent.elements; + } + } + return new BlockModel(json); + } catch (e) { + console.warn(`Model missing/broken (${id}), falling back to :missing`); + return new BlockModel(MISSING_MODEL_JSON); + } + } +} + +const CUBOID_FACES = { + down: {n: [0, -1, 0], c: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]]}, + up: {n: [0, 1, 0], c: [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]]}, + north: {n: [0, 0, -1], c: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]]}, + south: {n: [0, 0, 1], c: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]]}, + west: {n: [-1, 0, 0], c: [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]]}, + east: {n: [1, 0, 0], c: [[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]} +}; + +export class BlockModel { + constructor(json) { + this.elements = json.elements || []; + this.textures = json.textures || {}; + + this._geometryCache = new Map(); + } + + resolveTexture(ref) { + if (!ref) return ':missing'; + + let val = ref; + if (ref[0] === '#') { + let key = ref.slice(1); + val = this.textures[key]; + while (typeof val === 'string' && val[0] === '#') { + key = val.slice(1); + val = this.textures[key]; + } + if (val && typeof val === 'object' && val.sprite) val = val.sprite; + } + + // BUG FIX: Ensure everything returned is namespace-normalized + if (typeof val === 'string') { + return val.includes(':') ? val : `minecraft:${val}`; + } + + return ':missing'; + } + + calculateDefaultUV(faceName, from, to) { + switch (faceName) { + case 'up': + return [from[0], from[2], to[0], to[2]]; + case 'down': + return [from[0], 16 - to[2], to[0], 16 - from[2]]; + case 'north': + return [16 - to[0], 16 - to[1], 16 - from[0], 16 - from[1]]; + case 'south': + return [from[0], 16 - to[1], to[0], 16 - from[1]]; + case 'west': + return [from[2], 16 - to[1], to[2], 16 - from[1]]; + case 'east': + return [16 - to[2], 16 - to[1], 16 - from[2], 16 - from[1]]; + default: + return [0, 0, 16, 16]; + } + } + + buildGeometry(uvmap, variant = {}) { + const cacheKey = `${variant.x || 0},${variant.y || 0},${!!variant.uvlock}`; + + if (this._geometryCache.has(cacheKey)) { + return this._geometryCache.get(cacheKey); + } + + const pos = [], norm = [], uv = [], tint = [], shade = [], idx = []; + let vOffset = 0; + + const blockMatrix = mat4Identity(new Float32Array(16)); + mat4Translate(blockMatrix, blockMatrix, [0.5, 0.5, 0.5]); + if (variant.x) mat4RotateX(blockMatrix, blockMatrix, -variant.x * Math.PI / 180); + if (variant.y) mat4RotateY(blockMatrix, blockMatrix, -variant.y * Math.PI / 180); + mat4Translate(blockMatrix, blockMatrix, [-0.5, -0.5, -0.5]); + + for (const el of this.elements) { + const [fx, fy, fz] = el.from.map(x => x / 16); + const [tx, ty, tz] = el.to.map(x => x / 16); + const size = [tx - fx, ty - fy, tz - fz]; + const elementShade = el.shade === false ? 0.0 : 1.0; + + for (const [name, face] of Object.entries(el.faces || {})) { + const tmpl = CUBOID_FACES[name]; + + const texPath = this.resolveTexture(face.texture); + const a = texPath ? uvmap.get(texPath) : null; + if (!a) continue; + + const rawUV = face.uv || this.calculateDefaultUV(name, el.from, el.to); + let u1 = rawUV[0] / 16, v1 = rawUV[1] / 16; + let u2 = rawUV[2] / 16, v2 = rawUV[3] / 16; + + let texRot = face.rotation || 0; + if (variant.uvlock && (name === 'up' || name === 'down') && variant.y) { + texRot = (texRot - variant.y + 360) % 360; + } + + const au1 = a.u + u1 * a.du, au2 = a.u + u2 * a.du; + const av1 = a.v + v1 * a.dv, av2 = a.v + v2 * a.dv; + + let faceUVs; + if (texRot === 90) faceUVs = [au2, av2, au2, av1, au1, av1, au1, av2]; + else if (texRot === 180) faceUVs = [au2, av1, au1, av1, au1, av2, au2, av2]; + else if (texRot === 270) faceUVs = [au1, av1, au1, av2, au2, av2, au2, av1]; + else faceUVs = [au1, av2, au2, av2, au2, av1, au1, av1]; + + const tintable = face.tintindex !== undefined ? 1.0 : 0.0; + + for (let i = 0; i < 4; i++) { + const [cx, cy, cz] = tmpl.c[i]; + let vx = fx + cx * size[0], vy = fy + cy * size[1], vz = fz + cz * size[2]; + + let wx = blockMatrix[0] * vx + blockMatrix[4] * vy + blockMatrix[8] * vz + blockMatrix[12]; + let wy = blockMatrix[1] * vx + blockMatrix[5] * vy + blockMatrix[9] * vz + blockMatrix[13]; + let wz = blockMatrix[2] * vx + blockMatrix[6] * vy + blockMatrix[10] * vz + blockMatrix[14]; + pos.push(wx, wy, wz); + + let nx = blockMatrix[0] * tmpl.n[0] + blockMatrix[4] * tmpl.n[1] + blockMatrix[8] * tmpl.n[2]; + let ny = blockMatrix[1] * tmpl.n[0] + blockMatrix[5] * tmpl.n[1] + blockMatrix[9] * tmpl.n[2]; + let nz = blockMatrix[2] * tmpl.n[0] + blockMatrix[6] * tmpl.n[1] + blockMatrix[10] * tmpl.n[2]; + norm.push(nx, ny, nz); + + tint.push(tintable); + shade.push(elementShade); + } + + uv.push(...faceUVs); + idx.push(vOffset, vOffset + 1, vOffset + 2, vOffset, vOffset + 2, vOffset + 3); + vOffset += 4; + } + } + + const bakedGeometry = { + positions: new Float32Array(pos), + normals: new Float32Array(norm), + uvs: new Float32Array(uv), + tints: new Float32Array(tint), + shades: new Float32Array(shade), + indices: new Uint16Array(idx) + }; + + this._geometryCache.set(cacheKey, bakedGeometry); + return bakedGeometry; + } +} \ No newline at end of file diff --git a/resource-cache.js b/resource-cache.js new file mode 100644 index 00000000..e877ca7f --- /dev/null +++ b/resource-cache.js @@ -0,0 +1,31 @@ +export class ResourceCache { + constructor(root) { + this.root = root; + this.handlers = new Map(); + this.promises = new Map(); + } + + register(kind, handler) { + this.handlers.set(kind, handler); + } + + get(id, kind) { + const [namespace, resource] = id.includes(':') ? id.split(':', 2) : ["minecraft", id]; + id = `${namespace}:${resource}`; + const url = `${this.root}/${namespace}/${kind}/${resource}`; + + const key = `${kind}:${namespace}:${resource}`; + if (this.promises.has(key)) return this.promises.get(key); + + const handler = this.handlers.get(kind); + if (!handler) throw new Error(`No handler registered for resource kind: ${kind}`); + + if (handler.prepare) { + handler.prepare(this, id, url); + } + + const promise = handler.process(this, id, url); + this.promises.set(key, promise); + return promise; + } +} \ No newline at end of file diff --git a/texture.js b/texture.js new file mode 100644 index 00000000..5523af63 --- /dev/null +++ b/texture.js @@ -0,0 +1,80 @@ +export class TextureHandler { + constructor(size) { + this.canvas = document.createElement('canvas'); + this.canvas.width = size; + this.canvas.height = size; + this.ctx = this.canvas.getContext('2d', {willReadFrequently: true}); + this.ctx.imageSmoothingEnabled = false; + + this.uvmap = new Map(); + this.pendingDraws = new Map(); + + this.cellSize = 16; + this.padding = 1; + this.x = 0; + this.y = 0; + this.rowHeight = 0; + } + + prepare(cache, id, url) { + if (this.x + this.cellSize > this.canvas.width) { + this.x = 0; + this.y += this.rowHeight + this.padding; + this.rowHeight = 0; + } + + const currentX = this.x; + const currentY = this.y; + + this.x += this.cellSize + this.padding; + this.rowHeight = Math.max(this.rowHeight, this.cellSize); + + const uvData = { + u: currentX / this.canvas.width, + v: currentY / this.canvas.height, + du: this.cellSize / this.canvas.width, + dv: this.cellSize / this.canvas.height, + }; + + this.uvmap.set(id, uvData); + this.pendingDraws.set(id, {x: currentX, y: currentY}); + } + + async process(cache, id, url) { + const pos = this.pendingDraws.get(id); + if (!pos) throw new Error(`Not prepared for ${id}.`); + this.pendingDraws.delete(id); + + const drawMissing = () => { + const half = this.cellSize / 2; + this.ctx.fillStyle = '#ff00ff'; // Magenta + this.ctx.fillRect(pos.x, pos.y, half, half); + this.ctx.fillRect(pos.x + half, pos.y + half, half, half); + this.ctx.fillStyle = '#000000'; // Black + this.ctx.fillRect(pos.x + half, pos.y, half, half); + this.ctx.fillRect(pos.x, pos.y + half, half, half); + }; + + // Short circuit to avoid unnecessary network requests for known missing ids + if (id === ':missing') { + drawMissing(); + return this.uvmap.get(id); + } + + try { + const img = await new Promise((resolve, reject) => { + const i = new Image(); + i.crossOrigin = 'anonymous'; + i.onload = () => resolve(i); + i.onerror = () => reject(new Error(`Image load failed`)); + i.src = url + '.png'; + }); + this.ctx.drawImage(img, pos.x, pos.y, this.cellSize, this.cellSize); + } catch (e) { + console.warn(`Missing texture: ${id}`); + drawMissing(); + } + + return this.uvmap.get(id); + } +} \ No newline at end of file