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"}}});
}
}
getFallback(id) {
return new Blockstate({variants: {"": {model: ":missing"}}});
}
}
function evaluateWhen(properties, condition) {

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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 = {

View File

@@ -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);

View File

@@ -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 {