multiview. camera controls

This commit is contained in:
David Allemang
2026-07-04 10:43:11 -04:00
parent b4029d1bdf
commit 8249c4dd52
5 changed files with 352 additions and 141 deletions

View File

@@ -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;

116
camera.js Normal file
View File

@@ -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;
}
}

53
diorama.js Normal file
View File

@@ -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();
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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;
}
</style>
</head>
<body>
<h2>Renderer Data Pipeline Prototype</h2>
<div id="output">Loading and building geometry...</div>
<main>
<h1>Visualizer</h1>
<p>
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.
</p>
<div class="figure" id="demo-1"></div>
<p>
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.
</p>
<div class="figure" id="demo-2"></div>
<p>
Curabitur tincidunt sapien et diam fringilla, eu accumsan odio faucibus. Orci varius natoque penatibus et magnis
dis parturient montes, nascetur ridiculus mus.
</p>
<div id="canvas-container">
<canvas id="webgl-canvas" width="1024" height="1024"></canvas>
<div id="atlas-container"></div>
</div>
</main>
<script type="module">
import {World} from './world.js';
import {Renderer} from './renderer.js';
import {Engine} from './engine.js';
import {Diorama} from './diorama.js';
async function testPipeline() {
async function initDocument() {
try {
const webglCanvas = document.getElementById('webgl-canvas');
const renderer = new Renderer(webglCanvas);
// Adjusted camera slightly to see the block underneath
renderer.setCamera(-3, 3, -3, 3, 5, 4, 5, 0, 0, 0);
// 1. Initialize the global background renderer
const engine = new Engine();
document.getElementById('atlas-container').appendChild(renderer.atlas.canvas);
// Append the texture atlas to the bottom for debugging
document.getElementById('atlas-container').appendChild(engine.atlas.canvas);
const world = new World();
// 2. Setup Figure 1 (Shared: Wire & Repeater | Unique: Trapdoor)
const demo1 = new Diorama('demo-1', engine);
// Adjust camera to fit the small viewport
demo1.camera.radius = 2;
demo1.camera.phi = 10
demo1.camera.theta = -5
// Initial State: Closed trapdoor, no redstone block
world.setBlock('minecraft:oak_trapdoor', 0, 0, 0, {
facing: 'east',
open: 'false',
half: 'bottom'
demo1.world.setBlock('minecraft:redstone_wire', 0, 0, 0, {
east: 'side',
west: 'none',
north: 'none',
south: 'none',
power: '15'
});
demo1.world.setBlock('minecraft:repeater', 1, 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'});
world.setBlock('minecraft:coal_block', 0, -1, -3); // north -z
world.setBlock('minecraft:diamond_block', 3, -1, 0); // east +x
world.setBlock('minecraft:gold_block', 0, -1, 3); // south +z
world.setBlock('minecraft:iron_block', -3, -1, 0); // west -x
demo1.centerView()
// NOTE the trapdoor should open AWAY from the diamond block
// the repeaters should point "inward"
// the pistons should extend "outward"
engine.addDiorama(demo1);
// world.setBlock('minecraft:glass', 1, 0, 0);
// world.setBlock('minecraft:glass', 0, 0, 1);
world.setBlock('minecraft:coal_block', 0, -1, 0);
world.setBlock('minecraft:repeater', 1, -1, 0, {facing: 'west', delay: 1, locked: false, powered: false});
world.setBlock('minecraft:comparator', 0, -1, 1, {facing: 'north', mode: 'compare', powered: 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
demo2.camera.radius = 3;
// View from a slightly different angle
world.setBlock('minecraft:piston', 0, 0, -3, {facing: "north", extended: false})
world.setBlock('minecraft:piston', 3, 0, 0, {facing: "east", extended: false})
world.setBlock('minecraft:piston', 0, 0, 3, {facing: "south", extended: false})
world.setBlock('minecraft:piston', -3, 0, 0, {facing: "west", extended: false})
demo2.world.setBlock('minecraft:redstone_wire', 0, 0, 0, {
east: 'side',
west: 'none',
north: 'none',
south: 'none',
power: '0'
});
demo2.world.setBlock('minecraft:repeater', 1, 0, 0, {
facing: 'east',
delay: '4',
locked: 'false',
powered: 'false'
});
demo2.world.setBlock('minecraft:piston', 2, 0, 0, {facing: 'east', extended: 'false'});
demo2.world.setBlock('minecraft:piston', 2, 0, 1, {facing: 'east', extended: 'true'});
demo2.world.setBlock('minecraft: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: 'normal'});
demo2.world.setBlock('minecraft:redstone_block', 3, 0, 0);
demo2.world.setBlock('minecraft:redstone_block', 3.5, 0, 1);
demo2.world.setBlock('minecraft:redstone_block', 4, 0, 2);
world.setBlock('minecraft:repeater', 0, 1, -3, {facing: "north", delay: 1, locked: true, powered: false})
world.setBlock('minecraft:repeater', 3, 1, 0, {facing: "east", delay: 2, locked: true, powered: false})
world.setBlock('minecraft:repeater', 0, 1, 3, {facing: "south", delay: 3, locked: true, powered: false})
world.setBlock('minecraft:repeater', -3, 1, 0, {facing: "west", delay: 4, locked: true, powered: false})
demo2.centerView()
engine.addDiorama(demo2);
await renderer.updateWorld(world);
// Render Loop (Static, just drawing the current buffers)
function animate() {
renderer.render();
requestAnimationFrame(animate);
}
animate();
// --- THE STATE MACHINE LOOP ---
let isOpen = false;
setInterval(async () => {
isOpen = !isOpen;
// 1. Mutate the world data
world.updateBlock(0, 0, 0, {open: isOpen.toString()});
if (isOpen) {
world.setBlock('minecraft:redstone_block', 0, -1, 0);
world.updateBlock(1, -1, 0, {powered: true})
world.updateBlock(0, -1, 1, {powered: true})
} else {
world.removeBlock(0, -1, 0);
world.updateBlock(1, -1, 0, {powered: false})
world.updateBlock(0, -1, 1, {mode: 'subtract', powered: false})
}
// 2. Tell the renderer to sync with the new world state
await renderer.updateWorld(world);
document.getElementById('output').innerHTML = `<strong>State Demo:</strong> Trapdoor is ${isOpen ? 'OPEN' : 'CLOSED'}`;
// await return new Promise((resolve) => setTimeout(resolve, time));
}, 1000);
// 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();
} catch (err) {
console.error(err);
document.getElementById('output').textContent = 'Error: ' + err.message;
console.error("Renderer Initialization Failed:", err);
}
}
testPipeline();
initDocument();
</script>
</body>
</html>