streaming assets
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
64
cache.js
64
cache.js
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
72
engine.js
72
engine.js
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
6
model.js
6
model.js
@@ -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 = {
|
||||||
|
|||||||
43
texture.js
43
texture.js
@@ -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);
|
||||||
|
|||||||
27
world.js
27
world.js
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user