multiview. camera controls
This commit is contained in:
24
assets.js
24
assets.js
@@ -54,6 +54,7 @@ export class TextureAtlas {
|
|||||||
|
|
||||||
this.map = new Map();
|
this.map = new Map();
|
||||||
this.cellSize = 16;
|
this.cellSize = 16;
|
||||||
|
this.padding = 2; // Add empty space between textures
|
||||||
this.x = 0;
|
this.x = 0;
|
||||||
this.y = 0;
|
this.y = 0;
|
||||||
this.rowHeight = 0;
|
this.rowHeight = 0;
|
||||||
@@ -64,10 +65,10 @@ export class TextureAtlas {
|
|||||||
|
|
||||||
const url = resolveResourceLocation(id, 'textures', 'png');
|
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) {
|
if (this.x + this.cellSize > this.canvas.width) {
|
||||||
this.x = 0;
|
this.x = 0;
|
||||||
this.y += this.rowHeight;
|
this.y += this.rowHeight + this.padding;
|
||||||
this.rowHeight = 0;
|
this.rowHeight = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,27 +87,22 @@ export class TextureAtlas {
|
|||||||
this.ctx.drawImage(img, currentX, currentY, this.cellSize, this.cellSize);
|
this.ctx.drawImage(img, currentX, currentY, this.cellSize, this.cellSize);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Texture missing: ${id}`);
|
console.warn(`Texture missing: ${id}`);
|
||||||
// Draw magenta square ONLY for missing textures
|
|
||||||
this.ctx.fillStyle = '#ff00ff';
|
this.ctx.fillStyle = '#ff00ff';
|
||||||
this.ctx.fillRect(currentX, currentY, this.cellSize, this.cellSize);
|
this.ctx.fillRect(currentX, currentY, this.cellSize, this.cellSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
const epsU = 0.1 / this.canvas.width;
|
// Clean UV mapping (padding protects the edges natively)
|
||||||
const epsV = 0.1 / this.canvas.height;
|
|
||||||
|
|
||||||
const uvData = {
|
const uvData = {
|
||||||
// Push the start coordinate slightly inward
|
u: currentX / this.canvas.width,
|
||||||
u: (currentX / this.canvas.width) + epsU,
|
v: currentY / this.canvas.height,
|
||||||
v: (currentY / this.canvas.height) + epsV,
|
du: this.cellSize / this.canvas.width,
|
||||||
// Shrink the total width/height to account for the inset on both sides
|
dv: this.cellSize / this.canvas.height
|
||||||
du: (this.cellSize / this.canvas.width) - (epsU * 2),
|
|
||||||
dv: (this.cellSize / this.canvas.height) - (epsV * 2)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.map.set(id, uvData);
|
|
||||||
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);
|
this.rowHeight = Math.max(this.rowHeight, this.cellSize);
|
||||||
|
|
||||||
return uvData;
|
return uvData;
|
||||||
|
|||||||
116
camera.js
Normal file
116
camera.js
Normal 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
53
diorama.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {loadBlockstate, loadModel, TextureAtlas} from './assets.js';
|
||||||
import {resolveTexture, buildGeometry} from './geometry.js';
|
import {resolveTexture, buildGeometry} from './geometry.js';
|
||||||
import {resolveBlock, getVariantHash} from './blockstate.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=1) in vec3 a_normal;
|
||||||
layout(location=2) in vec2 a_uv;
|
layout(location=2) in vec2 a_uv;
|
||||||
layout(location=3) in float a_tint;
|
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=5) in mat4 i_matrix;
|
||||||
layout(location=9) in vec3 i_color;
|
layout(location=9) in vec3 i_color;
|
||||||
|
|
||||||
@@ -29,8 +30,6 @@ void main() {
|
|||||||
|
|
||||||
// Lambertian lighting
|
// Lambertian lighting
|
||||||
float baseLight = max(dot(normal, normalize(u_lightDir)), 0.0) * 0.6 + 0.4;
|
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_light = mix(1.0, baseLight, a_shade);
|
||||||
|
|
||||||
v_color = mix(vec3(1.0), i_color, a_tint);
|
v_color = mix(vec3(1.0), i_color, a_tint);
|
||||||
@@ -61,14 +60,25 @@ function compileShader(gl, type, src) {
|
|||||||
return shader;
|
return shader;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Renderer {
|
export class Engine {
|
||||||
constructor(canvas) {
|
constructor() {
|
||||||
this.gl = canvas.getContext('webgl2', {antialias: true});
|
// 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");
|
if (!this.gl) throw new Error("WebGL2 not supported");
|
||||||
|
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
gl.enable(gl.DEPTH_TEST);
|
gl.enable(gl.DEPTH_TEST);
|
||||||
gl.enable(gl.CULL_FACE);
|
gl.enable(gl.CULL_FACE);
|
||||||
|
gl.enable(gl.SCISSOR_TEST); // Critical for virtual viewports
|
||||||
|
|
||||||
const vs = compileShader(gl, gl.VERTEX_SHADER, VS_SRC);
|
const vs = compileShader(gl, gl.VERTEX_SHADER, VS_SRC);
|
||||||
const fs = compileShader(gl, gl.FRAGMENT_SHADER, FS_SRC);
|
const fs = compileShader(gl, gl.FRAGMENT_SHADER, FS_SRC);
|
||||||
@@ -85,26 +95,44 @@ export class Renderer {
|
|||||||
|
|
||||||
this.atlasTexture = gl.createTexture();
|
this.atlasTexture = gl.createTexture();
|
||||||
this.atlas = new TextureAtlas(512);
|
this.atlas = new TextureAtlas(512);
|
||||||
this.meshes = []; // Array of renderable VAO objects
|
|
||||||
|
|
||||||
// Default Camera Setup
|
this.dioramas = [];
|
||||||
this.projMatrix = new Float32Array(16);
|
this.renderRequested = false;
|
||||||
this.viewMatrix = new Float32Array(16);
|
|
||||||
this.viewProjMatrix = new Float32Array(16);
|
// Re-render when the page moves or changes size
|
||||||
this.setCamera(-4, 4, -4, 4, 8, 6, 8, 1, 0, 1);
|
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) {
|
// Debounced render trigger to save battery
|
||||||
mat4Ortho(this.projMatrix, left, right, bottom, top, -20, 20);
|
requestRender() {
|
||||||
mat4LookAt(this.viewMatrix, eyeX, eyeY, eyeZ, targetX, targetY, targetZ, 0, 1, 0);
|
if (!this.renderRequested) {
|
||||||
mat4Multiply(this.viewProjMatrix, this.projMatrix, this.viewMatrix);
|
this.renderRequested = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.renderRequested = false;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method takes a World object and rebuilds the WebGL buffers
|
resize() {
|
||||||
async updateWorld(world) {
|
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();
|
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()) {
|
for (const block of world.blocks.values()) {
|
||||||
const stateJSON = await loadBlockstate(block.id);
|
const stateJSON = await loadBlockstate(block.id);
|
||||||
const parts = resolveBlock(stateJSON, block.props);
|
const parts = resolveBlock(stateJSON, block.props);
|
||||||
@@ -121,6 +149,7 @@ export class Renderer {
|
|||||||
const pool = instancePool.get(hash);
|
const pool = instancePool.get(hash);
|
||||||
pool.matrices.push(...matrix);
|
pool.matrices.push(...matrix);
|
||||||
|
|
||||||
|
// Simple power level tint
|
||||||
if (block.props.power) {
|
if (block.props.power) {
|
||||||
const p = parseInt(block.props.power, 10);
|
const p = parseInt(block.props.power, 10);
|
||||||
pool.colors.push((0x4B + (p * 12)) / 255, 0.0, 0.0);
|
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 = [];
|
const newMeshes = [];
|
||||||
for (const [hash, pool] of instancePool.entries()) {
|
for (const [hash, pool] of instancePool.entries()) {
|
||||||
const modelJSON = await loadModel(pool.partDef.model);
|
const modelJSON = await loadModel(pool.partDef.model);
|
||||||
@@ -150,9 +179,8 @@ export class Renderer {
|
|||||||
newMeshes.push(this.createInstancedMesh(geometry, instanceCount, matrixArray, colorArray));
|
newMeshes.push(this.createInstancedMesh(geometry, instanceCount, matrixArray, colorArray));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Upload updated atlas and swap the active meshes
|
|
||||||
this.updateAtlasTexture();
|
this.updateAtlasTexture();
|
||||||
this.meshes = newMeshes;
|
return newMeshes;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAtlasTexture() {
|
updateAtlasTexture() {
|
||||||
@@ -168,7 +196,6 @@ export class Renderer {
|
|||||||
const vao = gl.createVertexArray();
|
const vao = gl.createVertexArray();
|
||||||
gl.bindVertexArray(vao);
|
gl.bindVertexArray(vao);
|
||||||
|
|
||||||
// 1. Bind standard geometry (divisor = 0)
|
|
||||||
const bindGeomAttr = (loc, data, size) => {
|
const bindGeomAttr = (loc, data, size) => {
|
||||||
const buffer = gl.createBuffer();
|
const buffer = gl.createBuffer();
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||||
@@ -183,17 +210,14 @@ export class Renderer {
|
|||||||
bindGeomAttr(3, geometry.tints, 1);
|
bindGeomAttr(3, geometry.tints, 1);
|
||||||
bindGeomAttr(4, geometry.shades, 1);
|
bindGeomAttr(4, geometry.shades, 1);
|
||||||
|
|
||||||
// 2. Bind Index Buffer
|
|
||||||
const ebo = gl.createBuffer();
|
const ebo = gl.createBuffer();
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
|
||||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);
|
||||||
|
|
||||||
// 3. Bind Instanced Attributes (divisor = 1)
|
|
||||||
const matrixBuffer = gl.createBuffer();
|
const matrixBuffer = gl.createBuffer();
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, matrixBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, matrixBuffer);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, matrices, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, matrices, gl.STATIC_DRAW);
|
||||||
|
|
||||||
// Mat4 requires 4 separate vec4 attributes in WebGL
|
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
const loc = 5 + i;
|
const loc = 5 + i;
|
||||||
gl.enableVertexAttribArray(loc);
|
gl.enableVertexAttribArray(loc);
|
||||||
@@ -215,21 +239,43 @@ export class Renderer {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
|
|
||||||
|
// Reset full viewport for clearing
|
||||||
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
|
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.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||||
|
|
||||||
gl.useProgram(this.program);
|
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.activeTexture(gl.TEXTURE0);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture);
|
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture);
|
||||||
gl.uniform1i(this.uniforms.texture, 0);
|
gl.uniform1i(this.uniforms.texture, 0);
|
||||||
|
|
||||||
for (const mesh of this.meshes) {
|
for (const diorama of this.dioramas) {
|
||||||
gl.bindVertexArray(mesh.vao);
|
const rect = diorama.element.getBoundingClientRect();
|
||||||
gl.drawElementsInstanced(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0, mesh.instanceCount);
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
184
index.html
184
index.html
@@ -7,127 +7,127 @@
|
|||||||
body {
|
body {
|
||||||
background: #222;
|
background: #222;
|
||||||
color: #eee;
|
color: #eee;
|
||||||
font-family: monospace;
|
font-family: sans-serif;
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#canvas-container {
|
main {
|
||||||
display: flex;
|
width: 80ch;
|
||||||
gap: 20px;
|
margin-inline: auto;
|
||||||
margin-top: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas {
|
.figure {
|
||||||
border: 1px solid #444;
|
width: 100%;
|
||||||
}
|
height: 20em;
|
||||||
|
|
||||||
#webgl-canvas {
|
|
||||||
width: 512px;
|
|
||||||
height: 512px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>Renderer Data Pipeline Prototype</h2>
|
<main>
|
||||||
<div id="output">Loading and building geometry...</div>
|
<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 id="atlas-container"></div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import {World} from './world.js';
|
import {Engine} from './engine.js';
|
||||||
import {Renderer} from './renderer.js';
|
import {Diorama} from './diorama.js';
|
||||||
|
|
||||||
async function testPipeline() {
|
async function initDocument() {
|
||||||
try {
|
try {
|
||||||
const webglCanvas = document.getElementById('webgl-canvas');
|
// 1. Initialize the global background renderer
|
||||||
const renderer = new Renderer(webglCanvas);
|
const engine = new Engine();
|
||||||
// Adjusted camera slightly to see the block underneath
|
|
||||||
renderer.setCamera(-3, 3, -3, 3, 5, 4, 5, 0, 0, 0);
|
|
||||||
|
|
||||||
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
|
demo1.world.setBlock('minecraft:redstone_wire', 0, 0, 0, {
|
||||||
world.setBlock('minecraft:oak_trapdoor', 0, 0, 0, {
|
east: 'side',
|
||||||
facing: 'east',
|
west: 'none',
|
||||||
open: 'false',
|
north: 'none',
|
||||||
half: 'bottom'
|
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
|
demo1.centerView()
|
||||||
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
|
|
||||||
|
|
||||||
// NOTE the trapdoor should open AWAY from the diamond block
|
engine.addDiorama(demo1);
|
||||||
// the repeaters should point "inward"
|
|
||||||
// the pistons should extend "outward"
|
|
||||||
|
|
||||||
// world.setBlock('minecraft:glass', 1, 0, 0);
|
// 3. Setup Figure 2 (Shared: Wire & Repeater | Unique: Piston & Redstone Block)
|
||||||
// world.setBlock('minecraft:glass', 0, 0, 1);
|
const demo2 = new Diorama('demo-2', engine);
|
||||||
world.setBlock('minecraft:coal_block', 0, -1, 0);
|
// Adjust camera to fit the small viewport
|
||||||
world.setBlock('minecraft:repeater', 1, -1, 0, {facing: 'west', delay: 1, locked: false, powered: false});
|
demo2.camera.radius = 3;
|
||||||
world.setBlock('minecraft:comparator', 0, -1, 1, {facing: 'north', mode: 'compare', powered: false});
|
// View from a slightly different angle
|
||||||
|
|
||||||
world.setBlock('minecraft:piston', 0, 0, -3, {facing: "north", extended: false})
|
demo2.world.setBlock('minecraft:redstone_wire', 0, 0, 0, {
|
||||||
world.setBlock('minecraft:piston', 3, 0, 0, {facing: "east", extended: false})
|
east: 'side',
|
||||||
world.setBlock('minecraft:piston', 0, 0, 3, {facing: "south", extended: false})
|
west: 'none',
|
||||||
world.setBlock('minecraft:piston', -3, 0, 0, {facing: "west", extended: false})
|
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})
|
demo2.centerView()
|
||||||
world.setBlock('minecraft:repeater', 3, 1, 0, {facing: "east", delay: 2, locked: true, powered: false})
|
engine.addDiorama(demo2);
|
||||||
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})
|
|
||||||
|
|
||||||
await renderer.updateWorld(world);
|
// 4. Build geometries and draw
|
||||||
|
// We await these so the meshes are fully baked and the atlas is populated
|
||||||
// Render Loop (Static, just drawing the current buffers)
|
await demo1.update();
|
||||||
function animate() {
|
await demo2.update();
|
||||||
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);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error("Renderer Initialization Failed:", err);
|
||||||
document.getElementById('output').textContent = 'Error: ' + err.message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
testPipeline();
|
initDocument();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user