From a92ac6d736098c00b578bb267e3a9a8a5f0d1012 Mon Sep 17 00:00:00 2001 From: David Allemang Date: Sat, 4 Jul 2026 13:13:09 -0400 Subject: [PATCH] multi-view support --- camera.js | 203 ------------------------------------------ diorama.js | 252 ++++++++++++++++++++++++++++++++++++++++++++++++----- engine.js | 64 ++++++++------ index.html | 72 +++++++++------ 4 files changed, 313 insertions(+), 278 deletions(-) delete mode 100644 camera.js diff --git a/camera.js b/camera.js deleted file mode 100644 index 6c401662..00000000 --- a/camera.js +++ /dev/null @@ -1,203 +0,0 @@ -// camera.js -import {mat4Ortho, mat4LookAt, mat4Multiply} from './math.js'; - -export class Camera { - constructor(element, onChangeCallback) { - this.element = element; - this.onChange = onChangeCallback; - - this.target = [0.5, 0, 0.5]; - this.radius = 4; - this.theta = 135; - this.phi = 30; - - this.projMatrix = new Float32Array(16); - this.viewMatrix = new Float32Array(16); - this.viewProjMatrix = new Float32Array(16); - - this.isDragging = false; - this.dragButton = 0; - this.touchMode = ''; - this.lastMouse = {x: 0, y: 0}; - this.lastPinchDist = 0; - - this.attachEvents(); - } - - saveState() { - this.checkpoint = { - target: [...this.target], - radius: this.radius, - theta: this.theta, - phi: this.phi, - } - } - - loadState() { - if (!this.checkpoint) return; - this.target = [...this.checkpoint.target]; - this.radius = this.checkpoint.radius; - this.theta = this.checkpoint.theta; - this.phi = this.checkpoint.phi; - this.onChange(); - } - - // --- INTERACTION MATH --- - - orbit(dx, dy) { - this.theta -= dx * 0.4; - this.phi += dy * 0.4; - this.phi = Math.max(-90, Math.min(90, this.phi)); - } - - pan(dx, dy) { - const t = this.theta * Math.PI / 180; - const p = this.phi * Math.PI / 180; - - const rightX = Math.cos(t); - const rightZ = -Math.sin(t); - - const upX = -Math.sin(p) * Math.sin(t); - const upY = Math.cos(p); - const upZ = -Math.sin(p) * Math.cos(t); - - const panSpeed = this.radius * 0.0025; - - this.target[0] += (-rightX * dx + upX * dy) * panSpeed; - this.target[1] += (upY * dy) * panSpeed; - this.target[2] += (-rightZ * dx + upZ * dy) * panSpeed; - } - - zoom(delta) { - this.radius += delta * 0.05; - this.radius = Math.max(1, Math.min(50, this.radius)); - } - - // --- EVENT LISTENERS --- - - attachEvents() { - this.element.addEventListener('contextmenu', e => e.preventDefault()); - - // --- MOUSE EVENTS --- - this.element.addEventListener('mousedown', (e) => { - e.preventDefault(); - this.isDragging = true; - this.lastMouse = {x: e.clientX, y: e.clientY}; - - // Base button - this.dragButton = e.button; - - // Modifier Overrides (Accessibility) - if (this.dragButton === 0) { - if (e.ctrlKey || e.metaKey) this.dragButton = 2; // Ctrl = Zoom - else if (e.shiftKey) this.dragButton = 1; // Shift = Pan - } - - if (this.dragButton === 1) this.element.style.cursor = 'move'; - else if (this.dragButton === 2) this.element.style.cursor = 'ns-resize'; - else this.element.style.cursor = 'grabbing'; - }); - - window.addEventListener('mouseup', () => { - this.isDragging = false; - this.element.style.cursor = 'grab'; - }); - - window.addEventListener('mousemove', (e) => { - if (!this.isDragging) return; - const dx = e.clientX - this.lastMouse.x; - const dy = e.clientY - this.lastMouse.y; - this.lastMouse = {x: e.clientX, y: e.clientY}; - - if (this.dragButton === 0) this.orbit(dx, dy); - else if (this.dragButton === 1) this.pan(dx, dy); - else if (this.dragButton === 2) this.zoom(dy); - - this.onChange(); - }); - - // --- TOUCH EVENTS --- - this.element.addEventListener('touchstart', (e) => { - e.preventDefault(); // Prevents mobile browser pull-to-refresh / page scrolling - this.isDragging = true; - - if (e.touches.length === 1) { - this.touchMode = 'orbit'; - this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; - } else if (e.touches.length >= 2) { - this.touchMode = 'pan-zoom'; - const t1 = e.touches[0], t2 = e.touches[1]; - this.lastMouse = { - x: (t1.clientX + t2.clientX) / 2, - y: (t1.clientY + t2.clientY) / 2 - }; - this.lastPinchDist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); - } - }, {passive: false}); - - this.element.addEventListener('touchmove', (e) => { - if (!this.isDragging) return; - e.preventDefault(); - - if (this.touchMode === 'orbit' && e.touches.length === 1) { - const dx = e.touches[0].clientX - this.lastMouse.x; - const dy = e.touches[0].clientY - this.lastMouse.y; - this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; - - this.orbit(dx, dy); - this.onChange(); - } else if (this.touchMode === 'pan-zoom' && e.touches.length >= 2) { - const t1 = e.touches[0], t2 = e.touches[1]; - const cx = (t1.clientX + t2.clientX) / 2; - const cy = (t1.clientY + t2.clientY) / 2; - const dist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); - - const dx = cx - this.lastMouse.x; - const dy = cy - this.lastMouse.y; - const dDist = dist - this.lastPinchDist; // Positive if spreading fingers - - this.lastMouse = {x: cx, y: cy}; - this.lastPinchDist = dist; - - this.pan(dx, dy); - this.zoom(-dDist); // Pinching out zooms in - this.onChange(); - } - }, {passive: false}); - - const onTouchEnd = (e) => { - if (e.touches.length === 0) { - this.isDragging = false; - } else if (e.touches.length === 1) { - // Seamlessly fall back to orbit if one finger is lifted - this.touchMode = 'orbit'; - this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; - } - }; - - this.element.addEventListener('touchend', onTouchEnd); - this.element.addEventListener('touchcancel', onTouchEnd); - } - - updateMatrices(aspectRatio) { - // ... (Keep existing updateMatrices code exactly as it is) ... - const t = this.theta * Math.PI / 180; - const p = this.phi * Math.PI / 180; - - const eyeX = this.target[0] + this.radius * Math.cos(p) * Math.sin(t); - const eyeY = this.target[1] + this.radius * Math.sin(p); - const eyeZ = this.target[2] + this.radius * Math.cos(p) * Math.cos(t); - - const upX = -Math.sin(p) * Math.sin(t); - const upY = Math.cos(p); - const upZ = -Math.sin(p) * Math.cos(t); - - const size = this.radius * 0.5; - mat4Ortho(this.projMatrix, -size * aspectRatio, size * aspectRatio, -size, size, -50, 50); - - mat4LookAt(this.viewMatrix, eyeX, eyeY, eyeZ, this.target[0], this.target[1], this.target[2], upX, upY, upZ); - mat4Multiply(this.viewProjMatrix, this.projMatrix, this.viewMatrix); - - return this.viewProjMatrix; - } -} \ No newline at end of file diff --git a/diorama.js b/diorama.js index 772026a8..19381cec 100644 --- a/diorama.js +++ b/diorama.js @@ -1,40 +1,78 @@ -import {Camera} from './camera.js'; -import {World} from './world.js'; +// diorama.js +import {mat4Ortho, mat4LookAt, mat4Multiply} from './math.js'; export class Diorama { - constructor(elementId, requestRenderCallback) { + constructor(elementId, world, requestRenderCallback) { this.element = document.getElementById(elementId); - this.world = new World(); + this.world = world; this.requestRender = requestRenderCallback; + // View State + this.target = [0.5, 0, 0.5]; + this.radius = 4; + this.theta = 135; + this.phi = 30; + + // Matrices + this.projMatrix = new Float32Array(16); + this.viewMatrix = new Float32Array(16); + this.viewProjMatrix = new Float32Array(16); + + // Interaction State + this.isDragging = false; + this.dragButton = 0; + this.touchMode = ''; + this.lastMouse = {x: 0, y: 0}; + this.lastPinchDist = 0; + + this.checkpoint = null; + + this.initUI(); + this.attachEvents(); + } + + initUI() { this.element.style.position = "relative"; - this.reset = document.createElement('button') - this.reset.textContent = 'Reset View' - this.reset.style.cssText = ` + this.resetBtn = document.createElement('button'); + this.resetBtn.textContent = 'Reset View'; + this.resetBtn.style.cssText = ` 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; `; - this.reset.addEventListener('mousedown', e => e.stopPropagation()) - this.reset.addEventListener('touchstart', e => e.stopPropagation()) - this.reset.addEventListener('click', () => { - this.camera.loadState(); - this.reset.style.display = 'none'; - }); - this.element.appendChild(this.reset); - this.camera = new Camera(this.element, () => { - if (this.camera.checkpoint) this.reset.style.display = 'block'; - this.requestRender(); + this.resetBtn.addEventListener('mousedown', e => e.stopPropagation()); + this.resetBtn.addEventListener('touchstart', e => e.stopPropagation()); + this.resetBtn.addEventListener('click', () => { + this.loadState(); + this.resetBtn.style.display = 'none'; }); - this.camera.target = [0.5, 0.5, 0.5]; + this.element.appendChild(this.resetBtn); + } + + saveState() { + this.checkpoint = { + target: [...this.target], + radius: this.radius, + theta: this.theta, + phi: this.phi, + }; + } + + loadState() { + if (!this.checkpoint) return; + this.target = [...this.checkpoint.target]; + this.radius = this.checkpoint.radius; + this.theta = this.checkpoint.theta; + this.phi = this.checkpoint.phi; + this.requestRender(); } centerView() { if (this.world.blocks.size === 0) { - this.camera.target = [0.5, 0.5, 0.5]; + this.target = [0.5, 0.5, 0.5]; return; } @@ -50,7 +88,181 @@ export class Diorama { maxZ = Math.max(maxZ, block.z + 1); } - this.camera.target = [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2]; + this.target = [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2]; this.requestRender(); } + + updateMatrices(aspectRatio) { + const t = this.theta * Math.PI / 180; + const p = this.phi * Math.PI / 180; + + this.viewDir = [ + Math.cos(p) * Math.sin(t), + Math.sin(p), + Math.cos(p) * Math.cos(t) + ]; + + this.upDir = [ + -Math.sin(p) * Math.sin(t), + Math.cos(p), + -Math.sin(p) * Math.cos(t), + ]; + + this.eyePos = [ + this.target[0] + this.radius * this.viewDir[0], + this.target[1] + this.radius * this.viewDir[1], + this.target[2] + this.radius * this.viewDir[2], + ]; + + const size = this.radius * 0.5; + mat4Ortho(this.projMatrix, -size * aspectRatio, size * aspectRatio, -size, size, -50, 50); + + mat4LookAt( + this.viewMatrix, + this.eyePos[0], this.eyePos[1], this.eyePos[2], + this.target[0], this.target[1], this.target[2], + this.upDir[0], this.upDir[1], this.upDir[2], + ); + mat4Multiply(this.viewProjMatrix, this.projMatrix, this.viewMatrix); + + return this.viewProjMatrix; + } + + // --- INTERACTION MATH --- + + orbit(dx, dy) { + this.theta -= dx * 0.4; + this.phi += dy * 0.4; + this.phi = Math.max(-90, Math.min(90, this.phi)); + } + + pan(dx, dy) { + const t = this.theta * Math.PI / 180; + const p = this.phi * Math.PI / 180; + + const rightX = Math.cos(t); + const rightZ = -Math.sin(t); + + const upX = -Math.sin(p) * Math.sin(t); + const upY = Math.cos(p); + const upZ = -Math.sin(p) * Math.cos(t); + + const panSpeed = this.radius * 0.0025; + + this.target[0] += (-rightX * dx + upX * dy) * panSpeed; + this.target[1] += (upY * dy) * panSpeed; + this.target[2] += (-rightZ * dx + upZ * dy) * panSpeed; + } + + zoom(delta) { + this.radius += delta * 0.05; + this.radius = Math.max(1, Math.min(50, this.radius)); + } + + // --- EVENT LISTENERS --- + + attachEvents() { + this.element.addEventListener('contextmenu', e => e.preventDefault()); + + const notifyChange = () => { + if (this.checkpoint) this.resetBtn.style.display = 'block'; + this.requestRender(); + }; + + // --- MOUSE EVENTS --- + this.element.addEventListener('mousedown', (e) => { + e.preventDefault(); + this.isDragging = true; + this.lastMouse = {x: e.clientX, y: e.clientY}; + + this.dragButton = e.button; + if (this.dragButton === 0) { + if (e.ctrlKey || e.metaKey) this.dragButton = 2; + else if (e.shiftKey) this.dragButton = 1; + } + + if (this.dragButton === 1) this.element.style.cursor = 'move'; + else if (this.dragButton === 2) this.element.style.cursor = 'ns-resize'; + else this.element.style.cursor = 'grabbing'; + }); + + window.addEventListener('mouseup', () => { + this.isDragging = false; + this.element.style.cursor = 'grab'; + }); + + window.addEventListener('mousemove', (e) => { + if (!this.isDragging) return; + const dx = e.clientX - this.lastMouse.x; + const dy = e.clientY - this.lastMouse.y; + this.lastMouse = {x: e.clientX, y: e.clientY}; + + if (this.dragButton === 0) this.orbit(dx, dy); + else if (this.dragButton === 1) this.pan(dx, dy); + else if (this.dragButton === 2) this.zoom(dy); + + notifyChange(); + }); + + // --- TOUCH EVENTS --- + this.element.addEventListener('touchstart', (e) => { + e.preventDefault(); + this.isDragging = true; + + if (e.touches.length === 1) { + this.touchMode = 'orbit'; + this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; + } else if (e.touches.length >= 2) { + this.touchMode = 'pan-zoom'; + const t1 = e.touches[0], t2 = e.touches[1]; + this.lastMouse = { + x: (t1.clientX + t2.clientX) / 2, + y: (t1.clientY + t2.clientY) / 2 + }; + this.lastPinchDist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); + } + }, {passive: false}); + + this.element.addEventListener('touchmove', (e) => { + if (!this.isDragging) return; + e.preventDefault(); + + if (this.touchMode === 'orbit' && e.touches.length === 1) { + const dx = e.touches[0].clientX - this.lastMouse.x; + const dy = e.touches[0].clientY - this.lastMouse.y; + this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; + + this.orbit(dx, dy); + notifyChange(); + } else if (this.touchMode === 'pan-zoom' && e.touches.length >= 2) { + const t1 = e.touches[0], t2 = e.touches[1]; + const cx = (t1.clientX + t2.clientX) / 2; + const cy = (t1.clientY + t2.clientY) / 2; + const dist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); + + const dx = cx - this.lastMouse.x; + const dy = cy - this.lastMouse.y; + const dDist = dist - this.lastPinchDist; + + this.lastMouse = {x: cx, y: cy}; + this.lastPinchDist = dist; + + this.pan(dx, dy); + this.zoom(-dDist); + notifyChange(); + } + }, {passive: false}); + + const onTouchEnd = (e) => { + if (e.touches.length === 0) { + this.isDragging = false; + } else if (e.touches.length === 1) { + this.touchMode = 'orbit'; + this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; + } + }; + + this.element.addEventListener('touchend', onTouchEnd); + this.element.addEventListener('touchcancel', onTouchEnd); + } } \ No newline at end of file diff --git a/engine.js b/engine.js index 4554530c..4ceb5a73 100644 --- a/engine.js +++ b/engine.js @@ -16,6 +16,7 @@ layout(location=9) in vec3 i_color; uniform mat4 u_viewProj; uniform vec3 u_lightDir; +uniform vec3 u_viewDir; out vec2 v_uv; out float v_light; @@ -24,8 +25,13 @@ 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); - float baseLight = max(dot(normal, normalize(u_lightDir)), 0.0) * 0.6 + 0.4; + + float sun = max(dot(normal, normalize(u_lightDir)), 0.0); + float head = max(dot(normal, normalize(u_viewDir)), 0.0); + float baseLight = clamp(sun + head * 0.3, 0.0, 1.0) * 0.6 + 0.4; + v_light = mix(1.0, baseLight, a_shade); v_color = mix(vec3(1.0), i_color, a_tint); } @@ -80,6 +86,7 @@ export class Engine { this.uniforms = { viewProj: gl.getUniformLocation(this.program, "u_viewProj"), lightDir: gl.getUniformLocation(this.program, "u_lightDir"), + viewDir: gl.getUniformLocation(this.program, "u_viewDir"), texture: gl.getUniformLocation(this.program, "u_texture") }; @@ -87,10 +94,13 @@ export class Engine { this.atlas = new TextureAtlas(256); this.dioramas = []; - this.dioramaMeshes = new Map(); // Global Mesh Storage: Diorama -> Array of meshes + this.worldMeshes = new Map(); this.renderRequested = false; - window.addEventListener('resize', () => { this.resize(); this.requestRender(); }); + window.addEventListener('resize', () => { + this.resize(); + this.requestRender(); + }); window.addEventListener('scroll', () => this.requestRender(), {passive: true}); this.resize(); } @@ -112,41 +122,42 @@ export class Engine { addDiorama(diorama) { this.dioramas.push(diorama); - this.dioramaMeshes.set(diorama, []); } - // O(1) Cascade: Fetch all resources breadth-first across ALL dioramas async updateAll() { + // Extract all unique worlds currently being viewed + const uniqueWorlds = new Set(this.dioramas.map(d => d.world)); + // --- 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); + for (const world of uniqueWorlds) { + for (const block of world.blocks.values()) uniqueBlockIds.add(block.id); } await Promise.all(Array.from(uniqueBlockIds).map(id => loadBlockstate(id))); // --- SWEEP 2: Resolve Parts & Fetch Models --- const uniqueModelIds = new Set(); - const parsedDioramas = new Map(); // Diorama -> Array of {block, parts} + const parsedWorlds = new Map(); // World -> Array of {block, parts} - for (const d of this.dioramas) { + for (const world of uniqueWorlds) { const parsedBlocks = []; - for (const block of d.world.blocks.values()) { - const stateJSON = await loadBlockstate(block.id); // Returns instantly from cache + for (const block of world.blocks.values()) { + const stateJSON = await loadBlockstate(block.id); const parts = resolveBlock(stateJSON, block.props); parsedBlocks.push({block, parts}); for (const p of parts) uniqueModelIds.add(p.model); } - parsedDioramas.set(d, parsedBlocks); + parsedWorlds.set(world, 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 + const worldPools = new Map(); // World -> instancePool - for (const d of this.dioramas) { + for (const world of uniqueWorlds) { const instancePool = new Map(); - for (const {block, parts} of parsedDioramas.get(d)) { + for (const {block, parts} of parsedWorlds.get(world)) { for (const part of parts) { const hash = getVariantHash(part); if (!instancePool.has(hash)) { @@ -167,9 +178,8 @@ export class Engine { } } - // Collect required textures from the resolved models for (const pool of instancePool.values()) { - const modelJSON = await loadModel(pool.partDef.model); // Instant + const modelJSON = await loadModel(pool.partDef.model); for (const el of modelJSON.elements || []) { for (const face of Object.values(el.faces || {})) { const texPath = resolveTexture(modelJSON, face.texture); @@ -177,25 +187,24 @@ export class Engine { } } } - dioramaPools.set(d, instancePool); + worldPools.set(world, instancePool); } - // 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) { + for (const world of uniqueWorlds) { const newMeshes = []; - for (const pool of dioramaPools.get(d).values()) { - const modelJSON = await loadModel(pool.partDef.model); // Instant + for (const pool of worldPools.get(world).values()) { + const modelJSON = await loadModel(pool.partDef.model); 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.worldMeshes.set(world, newMeshes); } this.requestRender(); @@ -275,11 +284,14 @@ export class Engine { gl.scissor(rect.left, bottom, rect.width, rect.height); const aspect = rect.width / rect.height; - const viewProj = diorama.camera.updateMatrices(aspect); + const viewProj = diorama.updateMatrices(aspect); gl.uniformMatrix4fv(this.uniforms.viewProj, false, viewProj); - gl.uniform3f(this.uniforms.lightDir, 1.0, 2.0, 0.5); + gl.uniform3f(this.uniforms.lightDir, 1.0, 3.0, 2); + gl.uniform3f(this.uniforms.viewDir, + diorama.viewDir[0], diorama.viewDir[1], diorama.viewDir[2] + ); - const meshes = this.dioramaMeshes.get(diorama) || []; + const meshes = this.worldMeshes.get(diorama.world) || []; 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 ae7cc13a..a4bc9bde 100644 --- a/index.html +++ b/index.html @@ -38,7 +38,10 @@ Mauris ullamcorper accumsan dui vel pulvinar.

-
+
+
+
+

Curabitur tincidunt sapien et diam fringilla, eu accumsan odio faucibus. Orci varius natoque penatibus et magnis @@ -51,67 +54,78 @@