From 7f946723ce4f57f4d06e7f156c5118c2cd5d8b12 Mon Sep 17 00:00:00 2001 From: David Allemang Date: Mon, 6 Jul 2026 13:19:25 -0400 Subject: [PATCH] diorama dirty flag --- diorama.js | 15 ++++++--- engine.js | 93 +++++++++++++++++++++++++++++++++++++++++------------- index.html | 4 +-- 3 files changed, 84 insertions(+), 28 deletions(-) diff --git a/diorama.js b/diorama.js index ef961768..a8d57460 100644 --- a/diorama.js +++ b/diorama.js @@ -3,13 +3,15 @@ import {mat4Ortho, mat4LookAt, mat4Multiply} from './math.js'; export class Diorama { constructor(elementId, frame, requestRenderCallback) { this.element = document.getElementById(elementId); - this.element.style.position = 'relative'; // todo move to css + this.element.style.position = 'relative'; - this.canvas = document.createElement('canvas') - this.element.appendChild(this.canvas) + this.canvas = document.createElement('canvas'); + this.element.appendChild(this.canvas); this.ctx2d = this.canvas.getContext('2d'); this.frame = frame; - this.requestRender = requestRenderCallback; + + this.dirty = true; // Start dirty to guarantee the first render + this.requestEngineRender = requestRenderCallback; this.target = [0.5, 0, 0.5]; this.radius = 4; @@ -23,6 +25,11 @@ export class Diorama { this.checkpoint = null; } + requestRender() { + this.dirty = true; + if (this.requestEngineRender) this.requestEngineRender(); + } + saveState() { this.checkpoint = { target: [...this.target], diff --git a/engine.js b/engine.js index 72cd7412..79d428bf 100644 --- a/engine.js +++ b/engine.js @@ -105,13 +105,14 @@ export class Engine { this.dioramas = []; this.frameMeshes = new Map(); + this.renderRequested = false; - this.observedFrames = new Set(); this.updateRequested = false; - window.addEventListener('resize', () => this.requestRender()); + this.observedFrames = new Set(); + this.dirtyFrames = new Set(); // Tracks specific timelines that need mesh updates - // to make sure offscreen dioramas are rendered as soon as they become visible + window.addEventListener('resize', () => this.requestRender()); window.addEventListener('scroll', () => this.requestRender()); } @@ -137,26 +138,41 @@ export class Engine { addDiorama(diorama) { this.dioramas.push(diorama); + diorama.dirty = true; + if (!this.observedFrames.has(diorama.frame)) { this.observedFrames.add(diorama.frame); - diorama.frame.subscribe(() => this.requestUpdate()); + this.dirtyFrames.add(diorama.frame); // Force initial build + + 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; + } + this.requestUpdate(); + }); + } else { + this.requestRender(); } } async updateAll() { - const uniqueFrames = new Set(this.dioramas.map(d => d.frame)); - if (uniqueFrames.size === 0) return; + 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 uniqueFrames) { + 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 uniqueModelIds = new Set(); const parsedFrames = new Map(); - - for (const frame of uniqueFrames) { + 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'); @@ -170,8 +186,7 @@ export class Engine { const textureTasks = []; const framePools = new Map(); - - for (const frame of uniqueFrames) { + for (const frame of framesToUpdate) { const instancePool = new Map(); for (const {block, parts} of parsedFrames.get(frame)) { for (const part of parts) { @@ -211,7 +226,16 @@ export class Engine { await Promise.all(textureTasks); this.updateAtlasTexture(); - for (const frame of uniqueFrames) { + 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) { + this.gl.deleteVertexArray(mesh.vao); + for (const buf of mesh.buffers) this.gl.deleteBuffer(buf); + } + } + const newMeshes = []; for (const pool of framePools.get(frame).values()) { const blockModel = await this.cache.get(pool.partDef.model, 'models'); @@ -226,6 +250,11 @@ 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; + } + this.requestRender(); } @@ -242,8 +271,11 @@ export class Engine { const vao = gl.createVertexArray(); gl.bindVertexArray(vao); + const buffers = []; // Track buffers for GC + const bindGeomAttr = (loc, data, size) => { const buffer = gl.createBuffer(); + buffers.push(buffer); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); gl.enableVertexAttribArray(loc); @@ -257,10 +289,12 @@ export class Engine { bindGeomAttr(4, geometry.shades, 1); const ebo = gl.createBuffer(); + buffers.push(ebo); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW); const matrixBuffer = gl.createBuffer(); + buffers.push(matrixBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, matrixBuffer); gl.bufferData(gl.ARRAY_BUFFER, matrices, gl.STATIC_DRAW); @@ -272,6 +306,7 @@ export class Engine { } const colorBuffer = gl.createBuffer(); + buffers.push(colorBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW); gl.enableVertexAttribArray(9); @@ -279,21 +314,20 @@ export class Engine { gl.vertexAttribDivisor(9, 1); gl.bindVertexArray(null); - return {vao, indexCount: geometry.indices.length, instanceCount}; + return {vao, buffers, indexCount: geometry.indices.length, instanceCount}; } render() { const gl = this.gl; - - gl.useProgram(this.program); - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); - gl.uniform1i(this.uniforms.texture, 0); + let setupProgram = false; for (const diorama of this.dioramas) { const rect = diorama.canvas.getBoundingClientRect(); - if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) continue; + // Visibility Culling + if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) { + continue; + } const dpr = window.devicePixelRatio || 1; const targetW = Math.floor(rect.width * dpr); @@ -302,11 +336,23 @@ export class Engine { if (diorama.canvas.width !== targetW || diorama.canvas.height !== targetH) { diorama.canvas.width = targetW; diorama.canvas.height = targetH; + diorama.dirty = true; + } + + // Lazy Render: Only draw if the diorama actually changed + if (!diorama.dirty) continue; + + if (!setupProgram) { + gl.useProgram(this.program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + gl.uniform1i(this.uniforms.texture, 0); + setupProgram = true; } if (this.canvas.width < targetW || this.canvas.height < targetH) { - this.canvas.width = targetW; - this.canvas.height = targetH; + this.canvas.width = Math.max(this.canvas.width, targetW); + this.canvas.height = Math.max(this.canvas.height, targetH); } gl.viewport(0, 0, targetW, targetH); @@ -333,6 +379,9 @@ export class Engine { 0, 0, targetW, targetH, 0, 0, targetW, targetH ); + + // Clean state! + diorama.dirty = false; } } } \ No newline at end of file diff --git a/index.html b/index.html index a420036d..d724a94f 100644 --- a/index.html +++ b/index.html @@ -59,8 +59,8 @@ document.getElementById('atlas-container')?.appendChild(engine.atlas.canvas); const w_static = new World(` - p -1 0 0 redstone_wire east=side west=none north=none south=none power=15 - p 0 0 0 repeater facing=east delay=1 locked=false powered=true + p -1 0 0 redstone_wire east=side west=none north=none south=none power=0 + p 0 0 0 repeater facing=east delay=1 locked=false powered=false p 2 0 0 oak_trapdoor facing=east half=bottom open=true p 0 -1 0 lodestone p 0 -2 0 piston facing=north extended=false