streaming assets

This commit is contained in:
David Allemang
2026-07-06 14:02:29 -04:00
parent 7f946723ce
commit 0a32b46e93
6 changed files with 160 additions and 56 deletions

View File

@@ -13,6 +13,10 @@ export class BlockstateHandler {
return new Blockstate({variants: {"": {model: ":missing"}}}); return new Blockstate({variants: {"": {model: ":missing"}}});
} }
} }
getFallback(id) {
return new Blockstate({variants: {"": {model: ":missing"}}});
}
} }
function evaluateWhen(properties, condition) { function evaluateWhen(properties, condition) {

View File

@@ -1,31 +1,67 @@
// cache.js
export class Cache { export class Cache {
constructor(root) { constructor(root, onResolve) {
this.root = root; this.root = root;
this.onResolve = onResolve || (() => {
});
this.handlers = new Map(); this.handlers = new Map();
this.promises = new Map(); this.promises = new Map();
this.data = new Map();
} }
register(kind, handler) { register(kind, handler) {
this.handlers.set(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]; 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}`; const key = `${kind}:${namespace}:${resource}`;
if (this.promises.has(key)) return this.promises.get(key);
const handler = this.handlers.get(kind); if (this.promises.has(key)) {
if (!handler) throw new Error(`No handler registered for resource kind: ${kind}`); return this.promises.get(key);
if (handler.prepare) {
handler.prepare(this, id, url);
} }
const promise = handler.process(this, id, url); return Promise.resolve(this.data.get(key));
this.promises.set(key, promise); }
return promise;
// 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);
} }
} }

View File

@@ -97,7 +97,11 @@ export class Engine {
this.atlasTexture = gl.createTexture(); 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.atlas = new TextureHandler(256);
this.cache.register('blockstates', new BlockstateHandler()); this.cache.register('blockstates', new BlockstateHandler());
this.cache.register('models', new ModelHandler()); this.cache.register('models', new ModelHandler());
@@ -120,7 +124,7 @@ export class Engine {
if (!this.updateRequested) { if (!this.updateRequested) {
this.updateRequested = true; this.updateRequested = true;
Promise.resolve().then(async () => { Promise.resolve().then(async () => {
await this.updateAll(); this.updateAll();
this.updateRequested = false; this.updateRequested = false;
}); });
} }
@@ -142,11 +146,12 @@ export class Engine {
if (!this.observedFrames.has(diorama.frame)) { if (!this.observedFrames.has(diorama.frame)) {
this.observedFrames.add(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(() => { diorama.frame.subscribe(() => {
this.dirtyFrames.add(diorama.frame); this.dirtyFrames.add(diorama.frame);
// Mark any diorama observing this shared timeline as dirty
for (const d of this.dioramas) { for (const d of this.dioramas) {
if (d.frame === diorama.frame) d.dirty = true; 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; if (this.dirtyFrames.size === 0) return;
// Isolate only the frames that mutated
const framesToUpdate = Array.from(this.dirtyFrames); const framesToUpdate = Array.from(this.dirtyFrames);
this.dirtyFrames.clear(); 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 parsedFrames = new Map();
const uniqueModelIds = new Set();
for (const frame of framesToUpdate) { for (const frame of framesToUpdate) {
const parsedBlocks = []; const parsedBlocks = [];
for (const block of frame.blocks.values()) { 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); const parts = state.resolveParts(block.state);
parsedBlocks.push({block, parts}); parsedBlocks.push({block, parts});
for (const p of parts) uniqueModelIds.add(p.model);
} }
parsedFrames.set(frame, parsedBlocks); parsedFrames.set(frame, parsedBlocks);
} }
await Promise.all(Array.from(uniqueModelIds).map(id => this.cache.get(id, 'models')));
const textureTasks = [];
const framePools = new Map(); const framePools = new Map();
for (const frame of framesToUpdate) { for (const frame of framesToUpdate) {
const instancePool = new Map(); const instancePool = new Map();
for (const {block, parts} of parsedFrames.get(frame)) { for (const {block, parts} of parsedFrames.get(frame)) {
@@ -210,12 +239,12 @@ export class Engine {
} }
for (const pool of instancePool.values()) { 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 el of blockModel.elements) {
for (const face of Object.values(el.faces || {})) { for (const face of Object.values(el.faces || {})) {
const texPath = blockModel.resolveTexture(face.texture); const texPath = blockModel.resolveTexture(face.texture);
if (texPath) { 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); framePools.set(frame, instancePool);
} }
await Promise.all(textureTasks);
this.updateAtlasTexture(); this.updateAtlasTexture();
for (const frame of framesToUpdate) { for (const frame of framesToUpdate) {
// Memory Cleanup: Free old buffers from the GPU before creating new ones
const oldMeshes = this.frameMeshes.get(frame); const oldMeshes = this.frameMeshes.get(frame);
if (oldMeshes) { if (oldMeshes) {
for (const mesh of oldMeshes) { for (const mesh of oldMeshes) {
@@ -238,7 +265,7 @@ export class Engine {
const newMeshes = []; const newMeshes = [];
for (const pool of framePools.get(frame).values()) { 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 geometry = blockModel.buildGeometry(this.atlas.uvmap, pool.partDef);
const matrixArray = new Float32Array(pool.matrices); const matrixArray = new Float32Array(pool.matrices);
@@ -250,7 +277,6 @@ export class Engine {
this.frameMeshes.set(frame, newMeshes); this.frameMeshes.set(frame, newMeshes);
} }
// Guarantee that dioramas relying on these newly built meshes are flagged for drawing
for (const d of this.dioramas) { for (const d of this.dioramas) {
if (framesToUpdate.includes(d.frame)) d.dirty = true; if (framesToUpdate.includes(d.frame)) d.dirty = true;
} }

View File

@@ -28,7 +28,7 @@ export class ModelHandler {
const json = await res.json(); const json = await res.json();
if (json.parent) { 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}; json.textures = {...parent.textures, ...json.textures};
if (!json.elements && parent.elements) { if (!json.elements && parent.elements) {
json.elements = parent.elements; json.elements = parent.elements;
@@ -40,6 +40,10 @@ export class ModelHandler {
return new BlockModel(MISSING_MODEL_JSON); return new BlockModel(MISSING_MODEL_JSON);
} }
} }
getFallback(id) {
return new BlockModel(MISSING_MODEL_JSON);
}
} }
const CUBOID_FACES = { const CUBOID_FACES = {

View File

@@ -38,6 +38,13 @@ export class TextureHandler {
this.uvmap.set(id, uvData); this.uvmap.set(id, uvData);
this.pendingDraws.set(id, {x: currentX, y: currentY}); 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) { async process(cache, id, url) {
@@ -47,31 +54,31 @@ export class TextureHandler {
const drawMissing = () => { const drawMissing = () => {
const half = this.cellSize / 2; 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, pos.y, half, half);
this.ctx.fillRect(pos.x + half, pos.y + half, 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 + half, pos.y, half, half);
this.ctx.fillRect(pos.x, pos.y + half, half, half); this.ctx.fillRect(pos.x, pos.y + half, half, half);
}; };
if (id === ':missing') { drawMissing();
drawMissing();
return this.uvmap.get(id);
}
try { if (id !== ':missing') {
const img = await new Promise((resolve, reject) => { try {
const i = new Image(); const img = await new Promise((resolve, reject) => {
i.crossOrigin = 'anonymous'; const i = new Image();
i.onload = () => resolve(i); i.crossOrigin = 'anonymous';
i.onerror = () => reject(new Error(`Image load failed`)); i.onload = () => resolve(i);
i.src = url + '.png'; 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) { // await new Promise(r => setTimeout(r, 2000))
console.warn(`Missing texture: ${id}`); this.ctx.clearRect(pos.x, pos.y, this.cellSize, this.cellSize);
drawMissing(); this.ctx.drawImage(img, pos.x, pos.y, this.cellSize, this.cellSize);
} catch (e) {
console.warn(`Missing texture: ${id}`);
}
} }
return this.uvmap.get(id); return this.uvmap.get(id);

View File

@@ -123,6 +123,33 @@ export class World {
if (!capturedInitialState) captureInitial(); 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 { export class WorldFrame {