webgl2
Version:
WebGL2 tools to derisk large GPU projects on the web beyond toys and demos.
785 lines (667 loc) • 24.9 kB
JavaScript
// Environment detection
const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
// Animation state for browser
const animationState = {
running: false,
frameCount: 0,
lastFpsTime: Date.now(),
fps: 0,
fpsElement: null,
button: null,
canvas: null,
ctx: null,
width: 0,
height: 0,
startTime: null
};
// Global rendering context (lazy initialized)
let renderContext = null;
/** @param {{ useBilinear?: boolean }} [options] */
async function initializeRenderContext({ useBilinear } = {}) {
if (renderContext) return renderContext;
let loadLocal =
isNode || (
typeof location !== 'undefined' &&
typeof location?.hostname === 'string' &&
(location.hostname.toString() === 'localhost' || location.hostname.toString() === '127.0.0.1')
);
const { webGL2 } = await import(
loadLocal ? './index.js' :
'https://esm.run/webgl2'
);
const gl = await webGL2({ debug: true });
gl.viewport(0, 0, 640, 480);
gl.enable(gl.DEPTH_TEST);
// Shaders
const vsSource = /* glsl */`#version 300 es
layout(location = 0) in vec3 position;
layout(location = 1) in vec2 uv;
uniform mat4 u_mvp;
out vec2 v_uv;
void main() {
v_uv = uv;
gl_Position = u_mvp * vec4(position, 1.0);
}
`;
const fsSource = /* glsl */`#version 300 es
precision highp float;
uniform texture2D u_texture;
uniform sampler u_sampler;
in vec2 v_uv;
out vec4 fragColor;
void small_fn_before(float val_noop) {
val_noop = 1.0;
}
void small_fn_after(float val_noop) {
val_noop = 2.0;
}
void main() {
small_fn_before(3.0);
small_fn_after(4.0);
fragColor = texture(sampler2D(u_texture, u_sampler), v_uv);
// small_fn_after(4.0);
// fragColor = vec4(v_uv, 0.0, 1.0);
}`;
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, vsSource);
gl.compileShader(vs);
const fsShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fsShader, fsSource);
gl.compileShader(fsShader);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fsShader);
gl.linkProgram(program);
gl.useProgram(program);
// Cube data
const vertices = new Float32Array([
// Front face
-0.5, -0.5, 0.5, 0.0, 0.0,
0.5, -0.5, 0.5, 1.0, 0.0,
0.5, 0.5, 0.5, 1.0, 1.0,
-0.5, -0.5, 0.5, 0.0, 0.0,
0.5, 0.5, 0.5, 1.0, 1.0,
-0.5, 0.5, 0.5, 0.0, 1.0,
// Back face
-0.5, -0.5, -0.5, 0.0, 0.0,
-0.5, 0.5, -0.5, 0.0, 1.0,
0.5, 0.5, -0.5, 1.0, 1.0,
-0.5, -0.5, -0.5, 0.0, 0.0,
0.5, 0.5, -0.5, 1.0, 1.0,
0.5, -0.5, -0.5, 1.0, 0.0,
// Top face
-0.5, 0.5, -0.5, 0.0, 0.0,
-0.5, 0.5, 0.5, 0.0, 1.0,
0.5, 0.5, 0.5, 1.0, 1.0,
-0.5, 0.5, -0.5, 0.0, 0.0,
0.5, 0.5, 0.5, 1.0, 1.0,
0.5, 0.5, -0.5, 1.0, 0.0,
// Bottom face
-0.5, -0.5, -0.5, 0.0, 0.0,
0.5, -0.5, -0.5, 1.0, 0.0,
0.5, -0.5, 0.5, 1.0, 1.0,
-0.5, -0.5, -0.5, 0.0, 0.0,
0.5, -0.5, 0.5, 1.0, 1.0,
-0.5, -0.5, 0.5, 0.0, 1.0,
// Right face
0.5, -0.5, -0.5, 0.0, 0.0,
0.5, 0.5, -0.5, 0.0, 1.0,
0.5, 0.5, 0.5, 1.0, 1.0,
0.5, -0.5, -0.5, 0.0, 0.0,
0.5, 0.5, 0.5, 1.0, 1.0,
0.5, -0.5, 0.5, 1.0, 0.0,
// Left face
-0.5, -0.5, -0.5, 0.0, 0.0,
-0.5, -0.5, 0.5, 1.0, 0.0,
-0.5, 0.5, 0.5, 1.0, 1.0,
-0.5, -0.5, -0.5, 0.0, 0.0,
-0.5, 0.5, 0.5, 1.0, 1.0,
-0.5, 0.5, -0.5, 0.0, 1.0,
]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 20, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 12);
// Texture
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
const texData = new Uint8Array(16 * 16 * 4);
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
const idx = (y * 16 + x) * 4;
const isCheck = ((x >> 2) ^ (y >> 2)) & 1;
if (isCheck) {
texData[idx] = 255; texData[idx + 1] = 215; texData[idx + 2] = 0; texData[idx + 3] = 255; // Gold
} else {
texData[idx] = 100; texData[idx + 1] = 149; texData[idx + 2] = 237; texData[idx + 3] = 255; // CornflowerBlue
}
}
}
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 16, 16, 0, gl.RGBA, gl.UNSIGNED_BYTE, texData);
if (!useBilinear) {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
}
const uTextureLoc = gl.getUniformLocation(program, "u_texture");
gl.uniform1i(uTextureLoc, 0);
const uSamplerLoc = gl.getUniformLocation(program, "u_sampler");
gl.uniform1i(uSamplerLoc, 0);
// Matrix math functions
function perspective(fovy, aspect, near, far) {
const f = 1.0 / Math.tan(fovy / 2);
const nf = 1 / (near - far);
return [
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * nf, -1,
0, 0, (2 * far * near) * nf, 0
];
}
function multiply(a, b) {
const out = new Float32Array(16);
for (let col = 0; col < 4; col++) {
for (let row = 0; row < 4; row++) {
let sum = 0;
for (let k = 0; k < 4; k++) {
sum += a[k * 4 + row] * b[col * 4 + k];
}
out[col * 4 + row] = sum;
}
}
return out;
}
function rotateY(m, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
const r = [
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1
];
return multiply(m, r);
}
function rotateX(m, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
const r = [
1, 0, 0, 0,
0, c, s, 0,
0, -s, c, 0,
0, 0, 0, 1
];
return multiply(m, r);
}
function translate(m, x, y, z) {
const t = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
x, y, z, 1
];
return multiply(m, t);
}
// Calculate initial MVP matrix
let mvp = perspective(Math.PI / 4, 640 / 480, 0.1, 100.0);
mvp = translate(mvp, 0, 0, -3);
mvp = rotateX(mvp, 0.5);
mvp = rotateY(mvp, 0.8);
const mvpLoc = gl.getUniformLocation(program, "u_mvp");
renderContext = {
gl,
program,
mvpLoc,
mvp: new Float32Array(mvp),
// Store functions and base values for dynamic rotation
perspective,
translate,
rotateX,
rotateY,
multiply
};
return renderContext;
}
async function renderCube({ elapsedTime, useBilinear } = {}) {
if (!elapsedTime) elapsedTime = 0;
const ctx = await initializeRenderContext({ useBilinear });
const { gl, program, mvpLoc, perspective, translate, rotateX, rotateY, multiply } = ctx;
// Calculate rotation angle: 1 full rotation (2π) in 5 seconds
const rotationAngle = (elapsedTime / 5000) * Math.PI * 2;
// Recalculate MVP with time-based rotation
let mvp = perspective(Math.PI / 4, 640 / 480, 0.1, 100.0);
mvp = translate(mvp, 0, 0, -3);
mvp = rotateX(mvp, 0.5);
mvp = rotateY(mvp, 0.8 + rotationAngle);
// Set MVP matrix
gl.uniformMatrix4fv(mvpLoc, false, mvp);
// console.log("MVP Matrix:", mvp);
// Render
gl.clearColor(0.0, 0.0, 0.0, 0.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 36);
// Read pixels
const pixels = new Uint8Array(640 * 480 * 4);
gl.readPixels(0, 0, 640, 480, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
return { pixels, width: 640, height: 480 };
}
// Matrix from PNG rendering functions
function createPNG(width, height, pixels) {
// PNG Signature
const signature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
function createChunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crc]);
}
// CRC32 implementation
const crcTable = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
crcTable[i] = c;
}
function crc32(buf) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
crc = crcTable[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function adler32(buf) {
let s1 = 1, s2 = 0;
for (let i = 0; i < buf.length; i++) {
s1 = (s1 + buf[i]) % 65521;
s2 = (s2 + s1) % 65521;
}
return ((s2 << 16) | s1) >>> 0;
}
// IHDR
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(width, 0);
ihdrData.writeUInt32BE(height, 4);
ihdrData[8] = 8; // bit depth
ihdrData[9] = 6; // color type (RGBA)
ihdrData[10] = 0; // compression
ihdrData[11] = 0; // filter
ihdrData[12] = 0; // interlace
const ihdr = createChunk('IHDR', ihdrData);
// IDAT (ZLIB uncompressed)
// Each scanline starts with a filter byte (0)
const scanlineSize = width * 4 + 1;
const uncompressedData = Buffer.alloc(height * scanlineSize);
for (let y = 0; y < height; y++) {
const srcY = height - 1 - y; // Flip Y for PNG (gl.readPixels is bottom-up)
uncompressedData[y * scanlineSize] = 0; // Filter None
pixels.copy(uncompressedData, y * scanlineSize + 1, srcY * width * 4, (srcY + 1) * width * 4);
}
// ZLIB wrap
const zlibHeader = Buffer.from([0x78, 0x01]);
const blocks = [];
for (let i = 0; i < uncompressedData.length; i += 65535) {
const remaining = uncompressedData.length - i;
const blockSize = Math.min(remaining, 65535);
const isLast = remaining <= 65535;
const blockHeader = Buffer.alloc(5);
blockHeader[0] = isLast ? 1 : 0;
blockHeader.writeUInt16LE(blockSize, 1);
blockHeader.writeUInt16LE(~blockSize & 0xFFFF, 3);
blocks.push(blockHeader);
blocks.push(uncompressedData.slice(i, i + blockSize));
}
const adler = Buffer.alloc(4);
adler.writeUInt32BE(adler32(uncompressedData), 0);
const idatData = Buffer.concat([zlibHeader, ...blocks, adler]);
const idat = createChunk('IDAT', idatData);
// IEND
const iend = createChunk('IEND', Buffer.alloc(0));
const buf = Buffer.concat([signature, ihdr, idat, iend]);
return buf;
}
// ASCII art rendering function for terminal
function renderASCIIArt(pixels, width, height) {
// Step 1: Crop - horizontal: 1/6 from each side, vertical: 1/5 from top, 2/15 from bottom
const cropX = Math.floor(width / 6);
const cropTop = Math.floor(height / 5);
const cropBottom = Math.floor(height * 2 / 15);
const croppedWidth = width - 2 * cropX;
const croppedHeight = height - cropTop - cropBottom;
// Step 2: Calculate target dimensions (80 chars wide, proportional height)
const targetWidth = 80;
const targetHeight = Math.floor((croppedHeight / croppedWidth) * targetWidth);
// Step 3: Downsample the image
// Each character represents 2 vertical pixels
const charHeight = Math.floor(targetHeight / 2);
const sampledPixels = [];
for (let charY = 0; charY < charHeight; charY++) {
for (let charX = 0; charX < targetWidth; charX++) {
// Sample two pixels: top and bottom for this character
const topPixel = samplePixel(pixels, width, height, cropX, cropTop, cropBottom, croppedWidth, croppedHeight, charX, charY * 2, targetWidth, targetHeight);
const bottomPixel = samplePixel(pixels, width, height, cropX, cropTop, cropBottom, croppedWidth, croppedHeight, charX, charY * 2 + 1, targetWidth, targetHeight);
sampledPixels.push({ top: topPixel, bottom: bottomPixel });
}
}
// Step 4: Generate ASCII art with ANSI colors
let output = '';
for (let charY = 0; charY < charHeight; charY++) {
for (let charX = 0; charX < targetWidth; charX++) {
const idx = charY * targetWidth + charX;
const { top, bottom } = sampledPixels[idx];
// Determine if pixels are blue, yellow, or something else
const topColor = classifyColor(top);
const bottomColor = classifyColor(bottom);
// Generate character with ANSI codes
const charData = generateChar(topColor, bottomColor);
output += charData;
}
output += '\n';
}
return output;
}
function samplePixel(pixels, origWidth, origHeight, cropX, cropTop, cropBottom, croppedWidth, croppedHeight, x, y, targetWidth, targetHeight) {
// Map from target coordinates to cropped source coordinates
const srcX = Math.floor((x / targetWidth) * croppedWidth) + cropX;
const srcY = Math.floor((y / targetHeight) * croppedHeight) + cropTop;
// Flip Y coordinate (pixels are bottom-up from readPixels)
const flippedY = origHeight - 1 - srcY;
const idx = (flippedY * origWidth + srcX) * 4;
return {
r: pixels[idx],
g: pixels[idx + 1],
b: pixels[idx + 2],
a: pixels[idx + 3]
};
}
function classifyColor(pixel) {
// Check for transparent/black
if (pixel.a < 128 || (pixel.r < 50 && pixel.g < 50 && pixel.b < 50)) {
return 'black';
}
// Check for blue (cornflower blue: 100, 149, 237)
const isBlue = pixel.b > pixel.r && pixel.b > pixel.g && pixel.b > 150;
if (isBlue) {
return 'blue';
}
// Check for yellow/gold (255, 215, 0)
const isYellow = pixel.r > 200 && pixel.g > 150 && pixel.b < 100;
if (isYellow) {
return 'yellow';
}
return 'other';
}
function generateChar(topColor, bottomColor) {
// Unicode block characters
const FULL_BLOCK = '█';
const UPPER_HALF_BLOCK = '▀';
const LOWER_HALF_BLOCK = '▄';
const SPACE = ' ';
// ANSI color codes
const RESET = '\x1b[0m';
const BLUE_256 = '\x1b[38;5;69m'; // Cornflower blue approximation
const YELLOW_256 = '\x1b[38;5;220m'; // Gold approximation
const BLUE_16 = '\x1b[34m'; // Fallback blue
const YELLOW_16 = '\x1b[33m'; // Fallback yellow
// Wrap 256-color in 16-color for graceful degradation
const BLUE = BLUE_16 + BLUE_256;
const YELLOW = YELLOW_16 + YELLOW_256;
// Both same color
if (topColor === bottomColor) {
if (topColor === 'blue') {
return BLUE + FULL_BLOCK + RESET;
} else if (topColor === 'yellow') {
return YELLOW + FULL_BLOCK + RESET;
} else {
return SPACE;
}
}
// Different colors - use half blocks
if (topColor === 'blue' && bottomColor === 'yellow') {
return BLUE + UPPER_HALF_BLOCK + RESET;
} else if (topColor === 'yellow' && bottomColor === 'blue') {
return BLUE + LOWER_HALF_BLOCK + RESET;
} else if (topColor === 'blue' && bottomColor === 'black') {
return BLUE + UPPER_HALF_BLOCK + RESET;
} else if (topColor === 'black' && bottomColor === 'blue') {
return BLUE + LOWER_HALF_BLOCK + RESET;
} else if (topColor === 'yellow' && bottomColor === 'black') {
return YELLOW + UPPER_HALF_BLOCK + RESET;
} else if (topColor === 'black' && bottomColor === 'yellow') {
return YELLOW + LOWER_HALF_BLOCK + RESET;
} else if (topColor === 'blue') {
return BLUE + UPPER_HALF_BLOCK + RESET;
} else if (bottomColor === 'blue') {
return BLUE + LOWER_HALF_BLOCK + RESET;
} else if (topColor === 'yellow') {
return YELLOW + UPPER_HALF_BLOCK + RESET;
} else if (bottomColor === 'yellow') {
return YELLOW + LOWER_HALF_BLOCK + RESET;
}
return SPACE;
}
// Main entry point
async function displayFrame(pixels, width, height) {
if (!animationState.ctx) return;
const imageData = animationState.ctx.createImageData(width, height);
// Flip Y axis: gl.readPixels is bottom-up, canvas is top-down
const flipped = new Uint8ClampedArray(pixels.length);
for (let y = 0; y < height; y++) {
const srcY = height - 1 - y;
const srcOffset = srcY * width * 4;
const dstOffset = y * width * 4;
flipped.set(pixels.subarray(srcOffset, srcOffset + width * 4), dstOffset);
}
imageData.data.set(flipped);
animationState.ctx.putImageData(imageData, 0, 0);
}
function updateFpsCounter() {
const now = Date.now();
const deltaTime = now - animationState.lastFpsTime;
if (deltaTime >= 500) {
animationState.fps = Math.round((animationState.frameCount * 1000) / deltaTime);
if (animationState.fpsElement) {
animationState.fpsElement.textContent = `FPS: ${animationState.fps}`;
}
animationState.frameCount = 0;
animationState.lastFpsTime = now;
}
}
async function animate() {
if (!animationState.running) return;
const elapsedTime = Date.now() - animationState.startTime;
const result = await renderCube({ elapsedTime });
const { pixels, width, height } = result;
await displayFrame(pixels, width, height);
animationState.frameCount++;
updateFpsCounter();
requestAnimationFrame(animate);
}
// Detect ANSI support in terminal
function detectANSISupport() {
// Check for explicit indicators of no ANSI support
const term = process.env.TERM;
const noColor = process.env.NO_COLOR;
// Definitely no ANSI support
if (term === 'dumb' || noColor !== undefined) {
return false;
}
// Otherwise assume support (or ambiguous = support as per requirements)
return true;
}
// Terminal animation loop
async function runTerminalAnimation(width, height, duration = 20000) {
const startTime = Date.now();
const fps = 20;
const frameDelay = 1000 / fps;
let firstFrame = true;
let numLines = 0;
let frameCount = 0;
let lastFrameTime = startTime;
const renderFrame = async () => {
const now = Date.now();
const elapsedTime = now - startTime;
if (elapsedTime >= duration) {
// Animation complete
return;
}
// Calculate average FPS
frameCount++;
const avgFps = elapsedTime > 0 ? Math.round((frameCount * 1000) / elapsedTime) : 0;
// Render cube with current rotation
const result = await renderCube({ elapsedTime, useBilinear: true });
const { pixels } = result;
// Generate ASCII art
const asciiArt = renderASCIIArt(pixels, width, height);
// Add FPS counter at the top
const fpsLine = `FPS: ${avgFps} | Frame: ${frameCount} | Time: ${(elapsedTime / 1000).toFixed(1)}s\n`;
const output = fpsLine + asciiArt;
if (firstFrame) {
// First frame: just print it
process.stdout.write(output);
numLines = output.split('\n').length - 1;
firstFrame = false;
} else {
// Move cursor up to overwrite previous frame
process.stdout.write(
`\x1b[${numLines}A` + output);
}
lastFrameTime = now;
// Schedule next frame
setTimeout(renderFrame, frameDelay);
};
console.log("\nASCII Art Animation (20 seconds):\n");
await renderFrame();
}
async function main() {
const result = await renderCube();
const { pixels, width, height } = result;
if (isNode) {
// Node: Save to file
const buf = createPNG(width, height, Buffer.from(pixels));
const fs = await import('fs');
const path = await import('path');
const { fileURLToPath } = await import('url');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
fs.writeFileSync(path.resolve(__dirname, 'output.png'), buf);
console.log("Saved output.png");
// Check ANSI support
const hasANSI = detectANSISupport();
if (!hasANSI) {
// No ANSI support: static output only
const asciiArt = renderASCIIArt(pixels, width, height);
console.log("\nASCII Art Render:\n");
console.log(asciiArt);
} else {
// ANSI support: run animation
await runTerminalAnimation(width, height, 20000);
}
} else {
// Browser: Apply styles and create UI
const style = document.createElement('style');
style.textContent = `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #222;
color: #fff;
font-family: monospace;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
gap: 20px;
}
h1 {
font-size: 24px;
font-weight: normal;
}
#controls {
display: flex;
gap: 15px;
align-items: center;
}
button {
padding: 8px 16px;
font-size: 14px;
background: #444;
color: #fff;
border: 1px solid #666;
border-radius: 4px;
cursor: pointer;
font-family: monospace;
}
button:hover {
background: #555;
}
button:active {
background: #333;
}
#fps {
font-size: 14px;
color: #aaa;
min-width: 80px;
}
canvas {
border: 2px solid #fff;
background: #000;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
`;
document.head.appendChild(style);
// Create title
const h1 = document.createElement('h1');
h1.textContent = 'WebGL2 Polymorphic Cube Renderer';
document.body.appendChild(h1);
// Create controls container
const controls = document.createElement('div');
controls.id = 'controls';
// Create play/pause button
const button = document.createElement('button');
button.textContent = '▶ Play';
button.onclick = () => {
animationState.running = !animationState.running;
if (animationState.running) {
button.textContent = '⏸ Pause';
animationState.frameCount = 0;
animationState.lastFpsTime = Date.now();
animationState.startTime = Date.now();
requestAnimationFrame(animate);
} else {
button.textContent = '▶ Play';
}
};
controls.appendChild(button);
// Create FPS display
const fpsDisplay = document.createElement('div');
fpsDisplay.id = 'fps';
fpsDisplay.textContent = 'FPS: 0';
controls.appendChild(fpsDisplay);
document.body.appendChild(controls);
// Create canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
// Store references in animationState
animationState.button = button;
animationState.canvas = canvas;
animationState.ctx = ctx;
animationState.width = width;
animationState.height = height;
animationState.fpsElement = fpsDisplay;
// Display initial frame
await displayFrame(pixels, width, height);
console.log("Rendered cube to canvas");
}
}
main();