diff --git a/blockstate.js b/blockstate.js index 8f20bab4..2e83b8a4 100644 --- a/blockstate.js +++ b/blockstate.js @@ -13,6 +13,10 @@ export class BlockstateHandler { return new Blockstate({variants: {"": {model: ":missing"}}}); } } + + getFallback(id) { + return new Blockstate({variants: {"": {model: ":missing"}}}); + } } function evaluateWhen(properties, condition) { diff --git a/cache.js b/cache.js index 7213f096..bc1d4e26 100644 --- a/cache.js +++ b/cache.js @@ -1,31 +1,67 @@ +// cache.js + export class Cache { - constructor(root) { + constructor(root, onResolve) { this.root = root; + this.onResolve = onResolve || (() => { + }); + this.handlers = new Map(); this.promises = new Map(); + this.data = new Map(); } register(kind, handler) { this.handlers.set(kind, handler); } - get(id, kind) { + // Used by internal loaders (like models loading parent models) + getAsync(id, kind) { + this.getSync(id, kind); // Triggers the load if it hasn't started + 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); + if (this.promises.has(key)) { + return this.promises.get(key); } - const promise = handler.process(this, id, url); - this.promises.set(key, promise); - return promise; + return Promise.resolve(this.data.get(key)); + } + + // Used by the Engine for immediate, non-blocking rendering + getSync(id, kind) { + const [namespace, resource] = id.includes(':') ? id.split(':', 2) : ["minecraft", id]; + const normalizedId = `${namespace}:${resource}`; + const key = `${kind}:${normalizedId}`; + const url = `${this.root}/${namespace}/${kind}/${resource}`; + + // 1. If we already have the real data, return it instantly + if (this.data.has(key)) return this.data.get(key); + + // 2. If we haven't even started loading it yet, kick off the fetch + if (!this.promises.has(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, normalizedId, url); + + const promise = handler.process(this, normalizedId, url).then(result => { + this.data.set(key, result); + this.onResolve(); // Ping the engine to re-render! + return result; + }).catch(err => { + console.warn(`Failed to load ${key}:`, err); + const fallback = handler.getFallback(normalizedId); + this.data.set(key, fallback); + this.onResolve(); + return fallback; + }); + + this.promises.set(key, promise); + } + + // 3. Always return the synchronous fallback while the promise resolves in the background + return this.handlers.get(kind).getFallback(normalizedId); } } \ No newline at end of file diff --git a/engine.js b/engine.js index 79d428bf..f1921e2b 100644 --- a/engine.js +++ b/engine.js @@ -97,7 +97,11 @@ export class Engine { this.atlasTexture = gl.createTexture(); - this.cache = new Cache('assets'); + this.cache = new Cache('assets', () => { + for (const frame of this.observedFrames) this.dirtyFrames.add(frame); + for (const diorama of this.dioramas) diorama.dirty = true; + this.requestUpdate(); + }); this.atlas = new TextureHandler(256); this.cache.register('blockstates', new BlockstateHandler()); this.cache.register('models', new ModelHandler()); @@ -120,7 +124,7 @@ export class Engine { if (!this.updateRequested) { this.updateRequested = true; Promise.resolve().then(async () => { - await this.updateAll(); + this.updateAll(); this.updateRequested = false; }); } @@ -142,11 +146,12 @@ export class Engine { if (!this.observedFrames.has(diorama.frame)) { this.observedFrames.add(diorama.frame); - this.dirtyFrames.add(diorama.frame); // Force initial build + this.dirtyFrames.add(diorama.frame); + + this.preloadWorld(diorama.frame.world); diorama.frame.subscribe(() => { this.dirtyFrames.add(diorama.frame); - // Mark any diorama observing this shared timeline as dirty for (const d of this.dioramas) { if (d.frame === diorama.frame) d.dirty = true; } @@ -157,35 +162,59 @@ export class Engine { } } - async updateAll() { + async preloadWorld(world) { + const blocks = world.getUniqueBlockStates(); + + // 1. Await all Blockstates + const uniqueIds = new Set(blocks.map(b => b.id)); + await Promise.all(Array.from(uniqueIds).map(id => this.cache.getAsync(id, 'blockstates'))); + + // 2. Resolve permutations and await all Models + const uniqueModels = new Set(); + for (const block of blocks) { + const stateDef = this.cache.getSync(block.id, 'blockstates'); + const parts = stateDef.resolveParts(block.state); + for (const p of parts) uniqueModels.add(p.model); + } + await Promise.all(Array.from(uniqueModels).map(id => this.cache.getAsync(id, 'models'))); + + // 3. Scan geometry and await all Textures + const textureTasks = []; + for (const modelId of uniqueModels) { + const blockModel = this.cache.getSync(modelId, 'models'); + for (const el of blockModel.elements) { + for (const face of Object.values(el.faces || {})) { + const texPath = blockModel.resolveTexture(face.texture); + if (texPath && texPath !== ':missing') { + textureTasks.push(this.cache.getAsync(texPath, 'textures')); + } + } + } + } + await Promise.all(textureTasks); + } + + updateAll() { if (this.dirtyFrames.size === 0) return; - // Isolate only the frames that mutated const framesToUpdate = Array.from(this.dirtyFrames); this.dirtyFrames.clear(); - const uniqueBlockIds = new Set(); - for (const frame of framesToUpdate) { - for (const block of frame.blocks.values()) uniqueBlockIds.add(block.id); - } - await Promise.all(Array.from(uniqueBlockIds).map(id => this.cache.get(id, 'blockstates'))); - const parsedFrames = new Map(); - const uniqueModelIds = new Set(); + for (const frame of framesToUpdate) { const parsedBlocks = []; for (const block of frame.blocks.values()) { - const state = await this.cache.get(block.id, 'blockstates'); + // Sync grab (returns fallback instantly if loading) + const state = this.cache.getSync(block.id, 'blockstates'); const parts = state.resolveParts(block.state); parsedBlocks.push({block, parts}); - for (const p of parts) uniqueModelIds.add(p.model); } parsedFrames.set(frame, parsedBlocks); } - await Promise.all(Array.from(uniqueModelIds).map(id => this.cache.get(id, 'models'))); - const textureTasks = []; const framePools = new Map(); + for (const frame of framesToUpdate) { const instancePool = new Map(); for (const {block, parts} of parsedFrames.get(frame)) { @@ -210,12 +239,12 @@ export class Engine { } for (const pool of instancePool.values()) { - const blockModel = await this.cache.get(pool.partDef.model, 'models'); + const blockModel = this.cache.getSync(pool.partDef.model, 'models'); for (const el of blockModel.elements) { for (const face of Object.values(el.faces || {})) { const texPath = blockModel.resolveTexture(face.texture); if (texPath) { - textureTasks.push(this.cache.get(texPath, 'textures', 'png')); + this.cache.getSync(texPath, 'textures'); } } } @@ -223,11 +252,9 @@ export class Engine { framePools.set(frame, instancePool); } - await Promise.all(textureTasks); this.updateAtlasTexture(); for (const frame of framesToUpdate) { - // Memory Cleanup: Free old buffers from the GPU before creating new ones const oldMeshes = this.frameMeshes.get(frame); if (oldMeshes) { for (const mesh of oldMeshes) { @@ -238,7 +265,7 @@ export class Engine { const newMeshes = []; for (const pool of framePools.get(frame).values()) { - const blockModel = await this.cache.get(pool.partDef.model, 'models'); + const blockModel = this.cache.getSync(pool.partDef.model, 'models'); const geometry = blockModel.buildGeometry(this.atlas.uvmap, pool.partDef); const matrixArray = new Float32Array(pool.matrices); @@ -250,7 +277,6 @@ export class Engine { this.frameMeshes.set(frame, newMeshes); } - // Guarantee that dioramas relying on these newly built meshes are flagged for drawing for (const d of this.dioramas) { if (framesToUpdate.includes(d.frame)) d.dirty = true; } diff --git a/model.js b/model.js index baab3aca..126e6446 100644 --- a/model.js +++ b/model.js @@ -28,7 +28,7 @@ export class ModelHandler { const json = await res.json(); if (json.parent) { - const parent = await cache.get(json.parent, 'models'); + const parent = await cache.getAsync(json.parent, 'models'); json.textures = {...parent.textures, ...json.textures}; if (!json.elements && parent.elements) { json.elements = parent.elements; @@ -40,6 +40,10 @@ export class ModelHandler { return new BlockModel(MISSING_MODEL_JSON); } } + + getFallback(id) { + return new BlockModel(MISSING_MODEL_JSON); + } } const CUBOID_FACES = { diff --git a/texture.js b/texture.js index 3593fe9c..558a8476 100644 --- a/texture.js +++ b/texture.js @@ -38,6 +38,13 @@ export class TextureHandler { this.uvmap.set(id, uvData); this.pendingDraws.set(id, {x: currentX, y: currentY}); + + this.ctx.fillStyle = '#00000066'; + this.ctx.fillRect(currentX, currentY, this.cellSize, this.cellSize); + } + + getFallback(id) { + return this.uvmap.get(id); // UVs map to the black silhouette! } async process(cache, id, url) { @@ -47,31 +54,31 @@ export class TextureHandler { const drawMissing = () => { const half = this.cellSize / 2; - this.ctx.fillStyle = '#ff00ff'; // Magenta + this.ctx.fillStyle = '#33333366'; // Gray 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.fillStyle = '#00000066'; // Black this.ctx.fillRect(pos.x + half, pos.y, half, half); this.ctx.fillRect(pos.x, pos.y + half, half, half); }; - if (id === ':missing') { - drawMissing(); - return this.uvmap.get(id); - } + drawMissing(); - 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(); + if (id !== ':missing') { + 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'; + }); + // await new Promise(r => setTimeout(r, 2000)) + this.ctx.clearRect(pos.x, pos.y, this.cellSize, this.cellSize); + this.ctx.drawImage(img, pos.x, pos.y, this.cellSize, this.cellSize); + } catch (e) { + console.warn(`Missing texture: ${id}`); + } } return this.uvmap.get(id); diff --git a/world.js b/world.js index 51eb0a6c..baabe6d0 100644 --- a/world.js +++ b/world.js @@ -123,6 +123,33 @@ export class World { if (!capturedInitialState) captureInitial(); } + + getUniqueBlockStates() { + const unique = new Map(); + const add = (block) => { + if (!block || !block.id || block.id === 'air' || block.id === 'minecraft:air') return; + + // Create a stable hash for the state object so we don't duplicate requests + const stateHash = Object.entries(block.state) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(e => `${e[0]}=${e[1]}`) + .join(','); + const hash = `${block.id}[${stateHash}]`; + + if (!unique.has(hash)) { + unique.set(hash, {id: block.id, state: {...block.state}}); + } + }; + + for (const block of this.initialState.values()) add(block); + for (const ev of this.events) { + for (const action of ev.actions) { + add(action.prev); + add(action.next); + } + } + return Array.from(unique.values()); + } } export class WorldFrame {