From ffd48b25851d3572932e7eb5cad0f1ec27be5d1a Mon Sep 17 00:00:00 2001 From: David Allemang Date: Sat, 4 Jul 2026 12:35:49 -0400 Subject: [PATCH] faster texture loading --- diorama.js | 22 ++------ engine.js | 162 +++++++++++++++++++++++++++-------------------------- index.html | 41 +++++--------- 3 files changed, 105 insertions(+), 120 deletions(-) diff --git a/diorama.js b/diorama.js index 0ee67ce0..772026a8 100644 --- a/diorama.js +++ b/diorama.js @@ -2,10 +2,10 @@ import {Camera} from './camera.js'; import {World} from './world.js'; export class Diorama { - constructor(elementId, engine) { + constructor(elementId, requestRenderCallback) { this.element = document.getElementById(elementId); - this.engine = engine; this.world = new World(); + this.requestRender = requestRenderCallback; this.element.style.position = "relative"; this.reset = document.createElement('button') @@ -14,9 +14,7 @@ export class Diorama { position: absolute; top: 1ch; right: 1ch; padding: 0.5ch 1ch; background: rgba(0,0,0,0.7); color: white; border: 1px solid gray; - cursor: pointer; - display: none; - z-index: 10; + cursor: pointer; display: none; z-index: 10; `; this.reset.addEventListener('mousedown', e => e.stopPropagation()) this.reset.addEventListener('touchstart', e => e.stopPropagation()) @@ -28,16 +26,15 @@ export class Diorama { this.camera = new Camera(this.element, () => { if (this.camera.checkpoint) this.reset.style.display = 'block'; - this.engine.requestRender() + this.requestRender(); }); this.camera.target = [0.5, 0.5, 0.5]; - this.meshes = []; } centerView() { if (this.world.blocks.size === 0) { - this.camera.target = [0.5, 0.5, 0.5]; // Default to origin + this.camera.target = [0.5, 0.5, 0.5]; return; } @@ -48,19 +45,12 @@ export class Diorama { minX = Math.min(minX, block.x); minY = Math.min(minY, block.y); minZ = Math.min(minZ, block.z); - - // Add 1 to max to account for the block's 1x1x1 volume maxX = Math.max(maxX, block.x + 1); maxY = Math.max(maxY, block.y + 1); maxZ = Math.max(maxZ, block.z + 1); } this.camera.target = [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2]; - this.engine.requestRender(); - } - - async update() { - this.meshes = await this.engine.buildMeshes(this.world); - this.engine.requestRender(); + this.requestRender(); } } \ No newline at end of file diff --git a/engine.js b/engine.js index 66c86701..4554530c 100644 --- a/engine.js +++ b/engine.js @@ -11,7 +11,6 @@ layout(location=2) in vec2 a_uv; layout(location=3) in float a_tint; layout(location=4) in float a_shade; -// Instanced attributes layout(location=5) in mat4 i_matrix; layout(location=9) in vec3 i_color; @@ -25,13 +24,9 @@ out vec3 v_color; void main() { gl_Position = u_viewProj * i_matrix * vec4(a_position, 1.0); v_uv = a_uv; - vec3 normal = normalize(mat3(i_matrix) * a_normal); - - // Lambertian lighting float baseLight = max(dot(normal, normalize(u_lightDir)), 0.0) * 0.6 + 0.4; v_light = mix(1.0, baseLight, a_shade); - v_color = mix(vec3(1.0), i_color, a_tint); } `; @@ -54,15 +49,12 @@ function compileShader(gl, type, src) { const shader = gl.createShader(type); gl.shaderSource(shader, src); gl.compileShader(shader); - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - throw new Error(gl.getShaderInfoLog(shader)); - } + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader)); return shader; } export class Engine { constructor() { - // Global canvas sits fixed behind everything this.canvas = document.createElement('canvas'); this.canvas.style.position = 'fixed'; this.canvas.style.top = '0'; @@ -73,12 +65,10 @@ export class Engine { document.body.appendChild(this.canvas); this.gl = this.canvas.getContext('webgl2', {antialias: true, alpha: true}); - if (!this.gl) throw new Error("WebGL2 not supported"); - const gl = this.gl; gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); - gl.enable(gl.SCISSOR_TEST); // Critical for virtual viewports + gl.enable(gl.SCISSOR_TEST); const vs = compileShader(gl, gl.VERTEX_SHADER, VS_SRC); const fs = compileShader(gl, gl.FRAGMENT_SHADER, FS_SRC); @@ -97,18 +87,14 @@ export class Engine { this.atlas = new TextureAtlas(256); this.dioramas = []; + this.dioramaMeshes = new Map(); // Global Mesh Storage: Diorama -> Array of meshes this.renderRequested = false; - // Re-render when the page moves or changes size - window.addEventListener('resize', () => { - this.resize(); - this.requestRender(); - }); + window.addEventListener('resize', () => { this.resize(); this.requestRender(); }); window.addEventListener('scroll', () => this.requestRender(), {passive: true}); this.resize(); } - // Debounced render trigger to save battery requestRender() { if (!this.renderRequested) { this.renderRequested = true; @@ -126,63 +112,93 @@ export class Engine { addDiorama(diorama) { this.dioramas.push(diorama); + this.dioramaMeshes.set(diorama, []); } - // Transforms a World's data grid into baked WebGL geometry pools - async buildMeshes(world) { - const instancePool = new Map(); - - // 1. Resolve states and matrices - for (const block of world.blocks.values()) { - const stateJSON = await loadBlockstate(block.id); - const parts = resolveBlock(stateJSON, block.props); - - for (const part of parts) { - const hash = getVariantHash(part); - if (!instancePool.has(hash)) { - instancePool.set(hash, {partDef: part, matrices: [], colors: []}); - } - - const matrix = mat4Identity(new Float32Array(16)); - mat4Translate(matrix, matrix, [block.x, block.y, block.z]); - - const pool = instancePool.get(hash); - pool.matrices.push(...matrix); - - // Simple power level tint - if (block.props.power) { - const p = parseInt(block.props.power, 10); - pool.colors.push((0x4B + (p * 12)) / 255, 0.0, 0.0); - } else { - pool.colors.push(1.0, 1.0, 1.0); - } - } + // O(1) Cascade: Fetch all resources breadth-first across ALL dioramas + async updateAll() { + // --- SWEEP 1: Collect & Fetch Blockstates --- + const uniqueBlockIds = new Set(); + for (const d of this.dioramas) { + for (const block of d.world.blocks.values()) uniqueBlockIds.add(block.id); } + await Promise.all(Array.from(uniqueBlockIds).map(id => loadBlockstate(id))); - // 2. Build geometries & instantiate WebGL buffers - const load_tasks = []; - const newMeshes = []; - for (const [hash, pool] of instancePool.entries()) { - const modelJSON = await loadModel(pool.partDef.model); + // --- SWEEP 2: Resolve Parts & Fetch Models --- + const uniqueModelIds = new Set(); + const parsedDioramas = new Map(); // Diorama -> Array of {block, parts} - for (const el of modelJSON.elements || []) { - for (const face of Object.values(el.faces || {})) { - const texPath = resolveTexture(modelJSON, face.texture); - if (texPath) load_tasks.push(this.atlas.load(texPath)); + for (const d of this.dioramas) { + const parsedBlocks = []; + for (const block of d.world.blocks.values()) { + const stateJSON = await loadBlockstate(block.id); // Returns instantly from cache + const parts = resolveBlock(stateJSON, block.props); + parsedBlocks.push({block, parts}); + for (const p of parts) uniqueModelIds.add(p.model); + } + parsedDioramas.set(d, parsedBlocks); + } + await Promise.all(Array.from(uniqueModelIds).map(id => loadModel(id))); + + // --- SWEEP 3: Pool Instances & Fetch Textures --- + const uniqueTexturePaths = new Set(); + const dioramaPools = new Map(); // Diorama -> instancePool + + for (const d of this.dioramas) { + const instancePool = new Map(); + for (const {block, parts} of parsedDioramas.get(d)) { + for (const part of parts) { + const hash = getVariantHash(part); + if (!instancePool.has(hash)) { + instancePool.set(hash, {partDef: part, matrices: [], colors: []}); + } + + const matrix = mat4Identity(new Float32Array(16)); + mat4Translate(matrix, matrix, [block.x, block.y, block.z]); + const pool = instancePool.get(hash); + pool.matrices.push(...matrix); + + if (block.props.power) { + const p = parseInt(block.props.power, 10); + pool.colors.push((0x4B + (p * 12)) / 255, 0.0, 0.0); + } else { + pool.colors.push(1.0, 1.0, 1.0); + } } } - const geometry = buildGeometry(modelJSON, this.atlas, 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)); + // Collect required textures from the resolved models + for (const pool of instancePool.values()) { + const modelJSON = await loadModel(pool.partDef.model); // Instant + for (const el of modelJSON.elements || []) { + for (const face of Object.values(el.faces || {})) { + const texPath = resolveTexture(modelJSON, face.texture); + if (texPath) uniqueTexturePaths.add(texPath); + } + } + } + dioramaPools.set(d, instancePool); } - await Promise.all(load_tasks) - this.updateAtlasTexture() - return newMeshes; + // Fire all texture image requests concurrently + await Promise.all(Array.from(uniqueTexturePaths).map(path => this.atlas.load(path))); + this.updateAtlasTexture(); + + // --- SWEEP 4: Bake Geometry --- + for (const d of this.dioramas) { + const newMeshes = []; + for (const pool of dioramaPools.get(d).values()) { + const modelJSON = await loadModel(pool.partDef.model); // Instant + const geometry = buildGeometry(modelJSON, this.atlas, 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.dioramaMeshes.set(d, newMeshes); + } + + this.requestRender(); } updateAtlasTexture() { @@ -235,18 +251,13 @@ export class Engine { gl.vertexAttribDivisor(9, 1); gl.bindVertexArray(null); - return {vao, indexCount: geometry.indices.length, instanceCount}; } render() { const gl = this.gl; - - // Reset full viewport for clearing gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); gl.scissor(0, 0, gl.canvas.width, gl.canvas.height); - - // Use transparent background so the HTML body flows underneath gl.clearColor(0.0, 0.0, 0.0, 0.0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); @@ -257,14 +268,8 @@ export class Engine { for (const diorama of this.dioramas) { const rect = diorama.element.getBoundingClientRect(); + if (rect.bottom < 0 || rect.top > gl.canvas.height || rect.right < 0 || rect.left > gl.canvas.width) continue; - // Culling: Skip rendering if element is off-screen - if (rect.bottom < 0 || rect.top > gl.canvas.height || - rect.right < 0 || rect.left > gl.canvas.width) { - continue; - } - - // Map DOM rect to WebGL screen coordinates const bottom = gl.canvas.height - rect.bottom; gl.viewport(rect.left, bottom, rect.width, rect.height); gl.scissor(rect.left, bottom, rect.width, rect.height); @@ -274,7 +279,8 @@ export class Engine { gl.uniformMatrix4fv(this.uniforms.viewProj, false, viewProj); gl.uniform3f(this.uniforms.lightDir, 1.0, 2.0, 0.5); - for (const mesh of diorama.meshes) { + const meshes = this.dioramaMeshes.get(diorama) || []; + for (const mesh of meshes) { gl.bindVertexArray(mesh.vao); gl.drawElementsInstanced(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0, mesh.instanceCount); } diff --git a/index.html b/index.html index 520de1d8..ae7cc13a 100644 --- a/index.html +++ b/index.html @@ -54,40 +54,34 @@ async function initDocument() { try { - // 1. Initialize the global background renderer const engine = new Engine(); - - // Append the texture atlas to the bottom for debugging document.getElementById('atlas-container')?.appendChild(engine.atlas.canvas); - // 2. Setup Figure 1 (Shared: Wire & Repeater | Unique: Trapdoor) - const demo1 = new Diorama('demo-1', engine); - // Adjust camera to fit the small viewport + // Pass the render trigger directly + const demo1 = new Diorama('demo-1', () => engine.requestRender()); demo1.camera.radius = 2; - demo1.camera.phi = 10 - demo1.camera.theta = -5 - - demo1.world.setBlock('minecraft:redstone_wire', 0, 0, 0, { - east: 'side', + demo1.camera.phi = 10; + demo1.camera.theta = -5; + demo1.world.setBlock('minecraft:redstone_wire', -1, 0, 0, { + east: 'none', west: 'none', north: 'none', south: 'none', power: '15' }); - demo1.world.setBlock('minecraft:repeater', 1, 0, 0, { + demo1.world.setBlock('minecraft:repeater', 0, 0, 0, { facing: 'east', delay: '1', locked: 'false', powered: 'true' }); demo1.world.setBlock('minecraft:oak_trapdoor', 2, 0, 0, {facing: 'east', half: 'bottom', open: 'true'}); + demo1.world.setBlock('lodestone', 0, -1, 0); + demo1.world.setBlock('piston', 0, -2, 0, {facing: 'north', extended: 'false'}); - // 3. Setup Figure 2 (Shared: Wire & Repeater | Unique: Piston & Redstone Block) - const demo2 = new Diorama('demo-2', engine); - // Adjust camera to fit the small viewport + + const demo2 = new Diorama('demo-2', () => engine.requestRender()); demo2.camera.radius = 3; - // View from a slightly different angle - demo2.world.setBlock('minecraft:redstone_wire', 0, 0, 0, { east: 'side', west: 'none', @@ -106,25 +100,20 @@ demo2.world.setBlock('minecraft:sticky_piston', 2, 0, 2, {facing: 'east', extended: 'true'}); demo2.world.setBlock('minecraft:piston_head', 2.5, 0, 1, {facing: 'east', short: 'true', type: 'sticky'}); demo2.world.setBlock('minecraft:piston_head', 3, 0, 2, {facing: 'east', short: 'false', type: 'sticky'}); - demo2.world.setBlock('minecraft:glass', 3, 0, 0); demo2.world.setBlock('minecraft:observer', 3.5, 0, 1, {facing: 'up', powered: 'true'}); demo2.world.setBlock('minecraft:redstone_block', 4, 0, 2); - demo1.centerView() + demo1.centerView(); demo1.camera.saveState(); - - demo2.centerView() + demo2.centerView(); demo2.camera.saveState(); engine.addDiorama(demo1); engine.addDiorama(demo2); - // 4. Build geometries and draw - // We await these so the meshes are fully baked and the atlas is populated - await demo1.update(); - await demo2.update(); - + // Fetch and build everything concurrently! + await engine.updateAll(); } catch (err) { console.error("Renderer Initialization Failed:", err); }