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.
+
-
+