diff --git a/assets.js b/assets.js index 6cc41e7e..56e30b73 100644 --- a/assets.js +++ b/assets.js @@ -54,6 +54,7 @@ export class TextureAtlas { this.map = new Map(); this.cellSize = 16; + this.padding = 2; // Add empty space between textures this.x = 0; this.y = 0; this.rowHeight = 0; @@ -64,10 +65,10 @@ export class TextureAtlas { const url = resolveResourceLocation(id, 'textures', 'png'); - // Calculate position before trying to load, so we can draw a fallback if it fails + // Include padding in the wrap calculation if (this.x + this.cellSize > this.canvas.width) { this.x = 0; - this.y += this.rowHeight; + this.y += this.rowHeight + this.padding; this.rowHeight = 0; } @@ -86,27 +87,22 @@ export class TextureAtlas { this.ctx.drawImage(img, currentX, currentY, this.cellSize, this.cellSize); } catch (e) { console.warn(`Texture missing: ${id}`); - // Draw magenta square ONLY for missing textures this.ctx.fillStyle = '#ff00ff'; this.ctx.fillRect(currentX, currentY, this.cellSize, this.cellSize); } - const epsU = 0.1 / this.canvas.width; - const epsV = 0.1 / this.canvas.height; - + // Clean UV mapping (padding protects the edges natively) const uvData = { - // Push the start coordinate slightly inward - u: (currentX / this.canvas.width) + epsU, - v: (currentY / this.canvas.height) + epsV, - // Shrink the total width/height to account for the inset on both sides - du: (this.cellSize / this.canvas.width) - (epsU * 2), - dv: (this.cellSize / this.canvas.height) - (epsV * 2) + u: currentX / this.canvas.width, + v: currentY / this.canvas.height, + du: this.cellSize / this.canvas.width, + dv: this.cellSize / this.canvas.height }; - this.map.set(id, uvData); this.map.set(id, uvData); - this.x += this.cellSize; + // Advance X by cell size AND padding + this.x += this.cellSize + this.padding; this.rowHeight = Math.max(this.rowHeight, this.cellSize); return uvData; diff --git a/camera.js b/camera.js new file mode 100644 index 00000000..7d5b2adc --- /dev/null +++ b/camera.js @@ -0,0 +1,116 @@ +// 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; // 0: Left, 1: Middle, 2: Right + this.lastMouse = {x: 0, y: 0}; + + this.attachEvents(); + } + + attachEvents() { + // Prevent the browser context menu on right-click + this.element.addEventListener('contextmenu', e => e.preventDefault()); + + this.element.addEventListener('mousedown', (e) => { + e.preventDefault(); + this.isDragging = true; + this.dragButton = e.button; + this.lastMouse = {x: e.clientX, y: e.clientY}; + + // Visual feedback based on action + 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) { + // LEFT CLICK: Orbit + this.theta -= dx * 0.4; + this.phi += dy * 0.4; + this.phi = Math.max(-90, Math.min(90, this.phi)); + } else if (this.dragButton === 1) { + // MIDDLE CLICK: Pan + const t = this.theta * Math.PI / 180; + const p = this.phi * Math.PI / 180; + + // Camera's local Right vector mapped to world space + const rightX = Math.cos(t); + const rightZ = -Math.sin(t); + + // Camera's local Up vector mapped to world space + const upX = -Math.sin(p) * Math.sin(t); + const upY = Math.cos(p); + const upZ = -Math.sin(p) * Math.cos(t); + + // Scale pan speed based on zoom radius so it feels consistent + 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; + } else if (this.dragButton === 2) { + // RIGHT CLICK: Zoom + this.radius += dy * 0.05; + this.radius = Math.max(1, Math.min(50, this.radius)); + } + + this.onChange(); + }); + + this.element.addEventListener('wheel', (e) => { + e.preventDefault(); + this.radius += e.deltaY * 0.01; + this.radius = Math.max(1, Math.min(50, this.radius)); + this.onChange(); + }); + } + + updateMatrices(aspectRatio) { + 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); + + // Calculate dynamic Up vector to prevent gimbal lock at exactly +/- 90 degrees + 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); + + // Pass the dynamic Up vector to the LookAt matrix + 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 new file mode 100644 index 00000000..6573a9e4 --- /dev/null +++ b/diorama.js @@ -0,0 +1,53 @@ +// diorama.js +import { Camera } from './camera.js'; +import { World } from './world.js'; + +export class Diorama { + constructor(elementId, engine) { + this.element = document.getElementById(elementId); + this.engine = engine; + this.world = new World(); + + // Pass the engine's render request function to the camera + this.camera = new Camera(this.element, () => this.engine.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 + return; + } + + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + for (const block of this.world.blocks.values()) { + 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 + ]; + + // Wake up the renderer so the view snaps immediately + this.engine.requestRender(); + } + + async update() { + this.meshes = await this.engine.buildMeshes(this.world); + // Ensure the scene draws immediately after meshes are built + this.engine.requestRender(); + } +} \ No newline at end of file diff --git a/renderer.js b/engine.js similarity index 68% rename from renderer.js rename to engine.js index 755ce667..8b3e288c 100644 --- a/renderer.js +++ b/engine.js @@ -1,4 +1,5 @@ -import {mat4Identity, mat4Multiply, mat4Ortho, mat4LookAt, mat4Translate} from './math.js'; +// engine.js +import {mat4Identity, mat4Translate} from './math.js'; import {loadBlockstate, loadModel, TextureAtlas} from './assets.js'; import {resolveTexture, buildGeometry} from './geometry.js'; import {resolveBlock, getVariantHash} from './blockstate.js'; @@ -8,9 +9,9 @@ layout(location=0) in vec3 a_position; layout(location=1) in vec3 a_normal; layout(location=2) in vec2 a_uv; layout(location=3) in float a_tint; -layout(location=4) in float a_shade; // 0.0 = emissive, 1.0 = shaded +layout(location=4) in float a_shade; -// Instanced attributes shifted to account for a_shade +// Instanced attributes layout(location=5) in mat4 i_matrix; layout(location=9) in vec3 i_color; @@ -29,8 +30,6 @@ void main() { // Lambertian lighting float baseLight = max(dot(normal, normalize(u_lightDir)), 0.0) * 0.6 + 0.4; - - // Mix between full brightness (1.0) and shaded based on the element's shade flag v_light = mix(1.0, baseLight, a_shade); v_color = mix(vec3(1.0), i_color, a_tint); @@ -61,14 +60,25 @@ function compileShader(gl, type, src) { return shader; } -export class Renderer { - constructor(canvas) { - this.gl = canvas.getContext('webgl2', {antialias: true}); +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'; + this.canvas.style.left = '0'; + this.canvas.style.width = '100vw'; + this.canvas.style.height = '100vh'; + this.canvas.style.zIndex = '-1'; + 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 const vs = compileShader(gl, gl.VERTEX_SHADER, VS_SRC); const fs = compileShader(gl, gl.FRAGMENT_SHADER, FS_SRC); @@ -85,26 +95,44 @@ export class Renderer { this.atlasTexture = gl.createTexture(); this.atlas = new TextureAtlas(512); - this.meshes = []; // Array of renderable VAO objects - // Default Camera Setup - this.projMatrix = new Float32Array(16); - this.viewMatrix = new Float32Array(16); - this.viewProjMatrix = new Float32Array(16); - this.setCamera(-4, 4, -4, 4, 8, 6, 8, 1, 0, 1); + this.dioramas = []; + this.renderRequested = false; + + // Re-render when the page moves or changes size + window.addEventListener('resize', () => { + this.resize(); + this.requestRender(); + }); + window.addEventListener('scroll', () => this.requestRender(), {passive: true}); + this.resize(); } - setCamera(left, right, bottom, top, eyeX, eyeY, eyeZ, targetX, targetY, targetZ) { - mat4Ortho(this.projMatrix, left, right, bottom, top, -20, 20); - mat4LookAt(this.viewMatrix, eyeX, eyeY, eyeZ, targetX, targetY, targetZ, 0, 1, 0); - mat4Multiply(this.viewProjMatrix, this.projMatrix, this.viewMatrix); + // Debounced render trigger to save battery + requestRender() { + if (!this.renderRequested) { + this.renderRequested = true; + requestAnimationFrame(() => { + this.renderRequested = false; + this.render(); + }); + } } - // This method takes a World object and rebuilds the WebGL buffers - async updateWorld(world) { + resize() { + this.canvas.width = window.innerWidth; + this.canvas.height = window.innerHeight; + } + + addDiorama(diorama) { + this.dioramas.push(diorama); + } + + // Transforms a World's data grid into baked WebGL geometry pools + async buildMeshes(world) { const instancePool = new Map(); - // 1. Resolve all blocks in the world into part definitions + // 1. Resolve states and matrices for (const block of world.blocks.values()) { const stateJSON = await loadBlockstate(block.id); const parts = resolveBlock(stateJSON, block.props); @@ -121,6 +149,7 @@ export class Renderer { 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); @@ -130,7 +159,7 @@ export class Renderer { } } - // 2. Build geometries, load textures, and create WebGL buffers + // 2. Build geometries & instantiate WebGL buffers const newMeshes = []; for (const [hash, pool] of instancePool.entries()) { const modelJSON = await loadModel(pool.partDef.model); @@ -150,9 +179,8 @@ export class Renderer { newMeshes.push(this.createInstancedMesh(geometry, instanceCount, matrixArray, colorArray)); } - // 3. Upload updated atlas and swap the active meshes this.updateAtlasTexture(); - this.meshes = newMeshes; + return newMeshes; } updateAtlasTexture() { @@ -168,7 +196,6 @@ export class Renderer { const vao = gl.createVertexArray(); gl.bindVertexArray(vao); - // 1. Bind standard geometry (divisor = 0) const bindGeomAttr = (loc, data, size) => { const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); @@ -183,17 +210,14 @@ export class Renderer { bindGeomAttr(3, geometry.tints, 1); bindGeomAttr(4, geometry.shades, 1); - // 2. Bind Index Buffer const ebo = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW); - // 3. Bind Instanced Attributes (divisor = 1) const matrixBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, matrixBuffer); gl.bufferData(gl.ARRAY_BUFFER, matrices, gl.STATIC_DRAW); - // Mat4 requires 4 separate vec4 attributes in WebGL for (let i = 0; i < 4; i++) { const loc = 5 + i; gl.enableVertexAttribArray(loc); @@ -215,21 +239,43 @@ export class Renderer { render() { const gl = this.gl; + + // Reset full viewport for clearing gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); - gl.clearColor(0.1, 0.1, 0.12, 1.0); + 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); gl.useProgram(this.program); - gl.uniformMatrix4fv(this.uniforms.viewProj, false, this.viewProjMatrix); - gl.uniform3f(this.uniforms.lightDir, 1.0, 2.0, 0.5); - gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); gl.uniform1i(this.uniforms.texture, 0); - for (const mesh of this.meshes) { - gl.bindVertexArray(mesh.vao); - gl.drawElementsInstanced(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0, mesh.instanceCount); + for (const diorama of this.dioramas) { + const rect = diorama.element.getBoundingClientRect(); + + // 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); + + const aspect = rect.width / rect.height; + const viewProj = diorama.camera.updateMatrices(aspect); + gl.uniformMatrix4fv(this.uniforms.viewProj, false, viewProj); + gl.uniform3f(this.uniforms.lightDir, 1.0, 2.0, 0.5); + + for (const mesh of diorama.meshes) { + gl.bindVertexArray(mesh.vao); + gl.drawElementsInstanced(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0, mesh.instanceCount); + } } } } \ No newline at end of file diff --git a/index.html b/index.html index 8d6e44fb..069ec0af 100644 --- a/index.html +++ b/index.html @@ -7,127 +7,127 @@ body { background: #222; color: #eee; - font-family: monospace; - padding: 20px; + font-family: sans-serif; } - #canvas-container { - display: flex; - gap: 20px; - margin-top: 20px; + main { + width: 80ch; + margin-inline: auto; } - canvas { - border: 1px solid #444; - } - - #webgl-canvas { - width: 512px; - height: 512px; + .figure { + width: 100%; + height: 20em; } -

Renderer Data Pipeline Prototype

-
Loading and building geometry...
+
+

Visualizer

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper ut mauris sed tempor. Nunc + vehicula tempor purus, non vestibulum libero ornare non. Morbi fringilla sapien diam, sed rhoncus lacus egestas + vel. +

+ +
+ +

+ Curabitur vestibulum vitae orci ac laoreet. Donec vel imperdiet tortor. Vestibulum lobortis aliquam tellus, + vitae viverra nisi porttitor quis. Pellentesque id efficitur arcu. Nunc laoreet pulvinar ligula eu maximus. + Mauris ullamcorper accumsan dui vel pulvinar. +

+ +
+ +

+ Curabitur tincidunt sapien et diam fringilla, eu accumsan odio faucibus. Orci varius natoque penatibus et magnis + dis parturient montes, nascetur ridiculus mus. +

-
-
-
+