rkgk/static/canvas-renderer.js
liquidex 5e6b84bed5 cache busting
for faster load times, and seamless updates.
because for some reason ServeDir can't do it correctly, and it tells the client "yeah hey nothing changed" even if something changed
2024-09-04 21:50:30 +02:00

557 lines
19 KiB
JavaScript

import { listen } from "rkgk/framework.js";
import { Viewport } from "rkgk/viewport.js";
import { Wall } from "rkgk/wall.js";
class CanvasRenderer extends HTMLElement {
viewport = new Viewport();
constructor() {
super();
}
connectedCallback() {
this.canvas = this.appendChild(document.createElement("canvas"));
this.gl = this.canvas.getContext("webgl2");
let resizeObserver = new ResizeObserver(() => this.#updateSize());
resizeObserver.observe(this);
this.#cursorReportingBehaviour();
this.#panningBehaviour();
this.#zoomingBehaviour();
this.#paintingBehaviour();
this.addEventListener("contextmenu", (event) => event.preventDefault());
}
initialize(wall, painter) {
this.wall = wall;
this.painter = painter;
this.#initializeRenderer();
requestAnimationFrame(() => this.#render());
}
// Rendering
#updateSize() {
this.canvas.width = this.clientWidth;
this.canvas.height = this.clientHeight;
// Rerender immediately after the canvas is resized, as its contents have now been invalidated.
this.#render();
}
getWindowSize() {
return {
width: this.clientWidth,
height: this.clientHeight,
};
}
getVisibleRect() {
return this.viewport.getVisibleRect(this.getWindowSize());
}
getVisibleChunkRect() {
let visibleRect = this.viewport.getVisibleRect(this.getWindowSize());
let left = Math.floor(visibleRect.x / this.wall.chunkSize);
let top = Math.floor(visibleRect.y / this.wall.chunkSize);
let right = Math.ceil((visibleRect.x + visibleRect.width) / this.wall.chunkSize);
let bottom = Math.ceil((visibleRect.y + visibleRect.height) / this.wall.chunkSize);
return { left, top, right, bottom };
}
// Renderer initialization
#initializeRenderer() {
console.groupCollapsed("initializeRenderer");
console.info("vendor", this.gl.getParameter(this.gl.VENDOR));
console.info("renderer", this.gl.getParameter(this.gl.RENDERER));
this.gl.enable(this.gl.BLEND);
this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);
// Due to an ANGLE bug on Windows, we can only render around 64 rectangles in a batch.
//
// It seems that for DirectX it generates a horribly inefficient shader that the DirectX
// compiler takes _ages_ to process (~1.5min on my machine for 512 elements.)
// The compilation time seems to increase exponentially; 256 elements take around 8 seconds,
// which is still unacceptable, and 128 elements take just over a second.
//
// We choose 64 because it causes an extremely short stutter, which I find acceptable.
// We also realistically don't need anymore, because (at least at the time I'm writing this)
// we store (8 * 8 = 64) chunks per texture atlas, so we can't batch more than that.
const maxRects = 64;
let renderChunksProgramId = this.#compileProgram(
// Vertex
`#version 300 es
precision highp float;
struct Rect {
vec4 position;
vec4 uv;
};
layout (std140) uniform ub_rects { Rect u_rects[${maxRects}]; };
uniform mat4 u_projection;
uniform mat4 u_view;
layout (location = 0) in vec2 a_position;
out vec2 vf_uv;
void main() {
Rect rect = u_rects[gl_InstanceID];
vec2 localPosition = rect.position.xy + a_position * rect.position.zw;
vec4 screenPosition = floor(u_view * vec4(localPosition, 0.0, 1.0));
vec4 scenePosition = u_projection * screenPosition;
vec2 uv = rect.uv.xy + a_position * rect.uv.zw;
gl_Position = scenePosition;
vf_uv = uv;
}
`,
// Fragment
`#version 300 es
precision highp float;
uniform sampler2D u_texture;
in vec2 vf_uv;
out vec4 f_color;
void main() {
f_color = texture(u_texture, vf_uv);
}
`,
);
this.renderChunksProgram = {
id: renderChunksProgramId,
u_projection: this.gl.getUniformLocation(renderChunksProgramId, "u_projection"),
u_view: this.gl.getUniformLocation(renderChunksProgramId, "u_view"),
u_texture: this.gl.getUniformLocation(renderChunksProgramId, "u_texture"),
ub_rects: this.gl.getUniformBlockIndex(renderChunksProgramId, "ub_rects"),
};
console.debug("renderChunksProgram", this.renderChunksProgram);
console.debug(
"uniform buffer data size",
this.gl.getActiveUniformBlockParameter(
this.renderChunksProgram.id,
this.renderChunksProgram.ub_rects,
this.gl.UNIFORM_BLOCK_DATA_SIZE,
),
);
this.vaoRectMesh = this.gl.createVertexArray();
this.vboRectMesh = this.gl.createBuffer();
this.gl.bindVertexArray(this.vaoRectMesh);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vboRectMesh);
let rectMesh = new Float32Array([0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0]);
this.gl.bufferData(this.gl.ARRAY_BUFFER, rectMesh, this.gl.STATIC_DRAW);
this.gl.vertexAttribPointer(0, 2, this.gl.FLOAT, false, 2 * 4, 0);
this.gl.enableVertexAttribArray(0);
this.uboRectsData = new Float32Array(maxRects * 8);
this.uboRectsNum = 0;
this.uboRects = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.UNIFORM_BUFFER, this.uboRects);
this.gl.bufferData(this.gl.UNIFORM_BUFFER, this.uboRectsData, this.gl.DYNAMIC_DRAW);
this.gl.uniformBlockBinding(
this.renderChunksProgram.id,
this.renderChunksProgram.ub_rects,
0,
);
this.gl.bindBufferBase(this.gl.UNIFORM_BUFFER, 0, this.uboRects);
console.debug("initialized buffers", {
vaoRectMesh: this.vaoRectMesh,
vboRectMesh: this.vboRectMesh,
uboRects: this.uboRects,
});
this.atlasAllocator = new AtlasAllocator(this.wall.chunkSize, 8);
this.chunkAllocations = new Map();
console.debug("initialized atlas allocator", this.atlasAllocator);
this.chunksThisFrame = new Map();
console.debug("GL error state", this.gl.getError());
console.groupEnd();
}
#compileShader(kind, source) {
let shader = this.gl.createShader(kind);
this.gl.shaderSource(shader, source);
this.gl.compileShader(shader);
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
let error = new Error(`failed to compile shader: ${this.gl.getShaderInfoLog(shader)}`);
this.gl.deleteShader(shader);
throw error;
} else {
return shader;
}
}
#compileProgram(vertexSource, fragmentSource) {
let vertexShader = this.#compileShader(this.gl.VERTEX_SHADER, vertexSource);
let fragmentShader = this.#compileShader(this.gl.FRAGMENT_SHADER, fragmentSource);
let program = this.gl.createProgram();
this.gl.attachShader(program, vertexShader);
this.gl.attachShader(program, fragmentShader);
this.gl.linkProgram(program);
this.gl.deleteShader(vertexShader);
this.gl.deleteShader(fragmentShader);
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
let error = new Error(`failed to link program: ${this.gl.getProgramInfoLog(program)}`);
this.gl.deleteProgram(program);
throw error;
} else {
return program;
}
}
// Renderer
#render() {
// NOTE: We should probably render on-demand only when it's needed.
requestAnimationFrame(() => this.#render());
this.#renderWall();
}
#renderWall() {
if (this.wall == null) {
console.debug("wall is not available, skipping rendering");
return;
}
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
this.gl.clearColor(1, 1, 1, 1);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
this.gl.useProgram(this.renderChunksProgram.id);
let translationX = this.canvas.width / 2 - this.viewport.panX * this.viewport.zoom;
let translationY = this.canvas.height / 2 - this.viewport.panY * this.viewport.zoom;
let scale = this.viewport.zoom;
this.gl.uniformMatrix4fv(
this.renderChunksProgram.u_projection,
false,
// prettier-ignore
[
2.0 / this.canvas.width, 0.0, 0.0, 0.0,
0.0, 2.0 / -this.canvas.height, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
-1.0, 1.0, 0.0, 1.0
],
);
this.gl.uniformMatrix4fv(
this.renderChunksProgram.u_view,
false,
// prettier-ignore
[
scale, 0.0, 0.0, 0.0,
0.0, scale, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
translationX, translationY, 0.0, 1.0
],
);
this.#collectChunksThisFrame();
for (let [i, chunks] of this.chunksThisFrame) {
let atlas = this.atlasAllocator.atlases[i];
this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.id);
this.#resetRectBuffer();
for (let chunk of chunks) {
let { i, allocation } = this.getChunkAllocation(chunk.x, chunk.y);
let atlas = this.atlasAllocator.atlases[i];
this.#pushRect(
chunk.x * this.wall.chunkSize,
chunk.y * this.wall.chunkSize,
this.wall.chunkSize,
this.wall.chunkSize,
(allocation.x * atlas.chunkSize) / atlas.textureSize,
(allocation.y * atlas.chunkSize) / atlas.textureSize,
atlas.chunkSize / atlas.textureSize,
atlas.chunkSize / atlas.textureSize,
);
}
this.#drawRects();
}
}
getChunkAllocation(chunkX, chunkY) {
let key = Wall.chunkKey(chunkX, chunkY);
if (this.chunkAllocations.has(key)) {
return this.chunkAllocations.get(key);
} else {
let allocation = this.atlasAllocator.alloc(this.gl);
this.chunkAllocations.set(key, allocation);
return allocation;
}
}
#collectChunksThisFrame() {
// NOTE: Not optimal that we don't preserve the arrays anyhow; it would be better if we
// preserved the allocations.
this.chunksThisFrame.clear();
let visibleRect = this.viewport.getVisibleRect(this.getWindowSize());
let left = Math.floor(visibleRect.x / this.wall.chunkSize);
let top = Math.floor(visibleRect.y / this.wall.chunkSize);
let right = Math.ceil((visibleRect.x + visibleRect.width) / this.wall.chunkSize);
let bottom = Math.ceil((visibleRect.y + visibleRect.height) / this.wall.chunkSize);
for (let chunkY = top; chunkY < bottom; ++chunkY) {
for (let chunkX = left; chunkX < right; ++chunkX) {
let chunk = this.wall.getChunk(chunkX, chunkY);
if (chunk != null) {
if (chunk.renderDirty) {
this.#updateChunkTexture(chunkX, chunkY);
chunk.renderDirty = false;
}
let allocation = this.getChunkAllocation(chunkX, chunkY);
let array = this.chunksThisFrame.get(allocation.i);
if (array == null) {
array = [];
this.chunksThisFrame.set(allocation.i, array);
}
array.push({ x: chunkX, y: chunkY });
}
}
}
}
#resetRectBuffer() {
this.uboRectsNum = 0;
}
#pushRect(x, y, width, height, u, v, uWidth, vHeight) {
let lengthOfRect = 8;
let i = this.uboRectsNum * lengthOfRect;
this.uboRectsData[i + 0] = x;
this.uboRectsData[i + 1] = y;
this.uboRectsData[i + 2] = width;
this.uboRectsData[i + 3] = height;
this.uboRectsData[i + 4] = u;
this.uboRectsData[i + 5] = v;
this.uboRectsData[i + 6] = uWidth;
this.uboRectsData[i + 7] = vHeight;
this.uboRectsNum += 1;
if (this.uboRectsNum == ((this.uboRectsData.length / lengthOfRect) | 0)) {
this.#drawRects();
this.#resetRectBuffer();
}
}
#drawRects() {
let rectBuffer = this.uboRectsData.subarray(0, this.uboRectsNum * 8);
this.gl.bindBuffer(this.gl.UNIFORM_BUFFER, this.uboRects);
this.gl.bufferSubData(this.gl.UNIFORM_BUFFER, 0, rectBuffer);
this.gl.bindVertexArray(this.vaoRectMesh);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vboRectMesh);
this.gl.drawArraysInstanced(this.gl.TRIANGLES, 0, 6, this.uboRectsNum);
}
#updateChunkTexture(chunkX, chunkY) {
let allocation = this.getChunkAllocation(chunkX, chunkY);
let chunk = this.wall.getChunk(chunkX, chunkY);
this.atlasAllocator.upload(this.gl, allocation, chunk.pixmap);
}
// Behaviours
async #cursorReportingBehaviour() {
while (true) {
let event = await listen([this, "mousemove"]);
let [x, y] = this.viewport.toViewportSpace(
event.clientX - this.clientLeft,
event.offsetY - this.clientTop,
this.getWindowSize(),
);
this.dispatchEvent(Object.assign(new Event(".cursor"), { x, y }));
}
}
sendViewportUpdate() {
this.dispatchEvent(new Event(".viewportUpdate"));
}
async #panningBehaviour() {
while (true) {
let mouseDown = await listen([this, "mousedown"]);
let startingPanX = this.viewport.panX;
let startingPanY = this.viewport.panY;
if (mouseDown.button == 1 || mouseDown.button == 2) {
mouseDown.preventDefault();
while (true) {
let event = await listen([window, "mousemove"], [window, "mouseup"]);
if (event.type == "mousemove") {
let deltaX = mouseDown.clientX - event.clientX;
let deltaY = mouseDown.clientY - event.clientY;
this.viewport.panX = startingPanX + deltaX / this.viewport.zoom;
this.viewport.panY = startingPanY + deltaY / this.viewport.zoom;
this.sendViewportUpdate();
} else if (event.type == "mouseup" && event.button == mouseDown.button) {
this.dispatchEvent(new Event(".viewportUpdateEnd"));
break;
}
}
}
}
}
async #zoomingBehaviour() {
while (true) {
let event = await listen([this, "wheel"]);
// TODO: Touchpad zoom
this.viewport.zoomIn(event.deltaY > 0 ? -1 : 1);
this.sendViewportUpdate();
this.dispatchEvent(new Event(".viewportUpdateEnd"));
}
}
async #paintingBehaviour() {
const paint = (x, y) => {
let [wallX, wallY] = this.viewport.toViewportSpace(x, y, this.getWindowSize());
this.dispatchEvent(Object.assign(new Event(".paint"), { x: wallX, y: wallY }));
};
while (true) {
let mouseDown = await listen([this, "mousedown"]);
if (mouseDown.button == 0) {
paint(mouseDown.offsetX, mouseDown.offsetY);
while (true) {
let event = await listen([window, "mousemove"], [window, "mouseup"]);
if (event.type == "mousemove") {
paint(event.clientX - this.clientLeft, event.offsetY - this.clientTop);
} else if (event.type == "mouseup") {
break;
}
}
}
}
}
}
customElements.define("rkgk-canvas-renderer", CanvasRenderer);
class Atlas {
static getInitBuffer(chunkSize, nChunks) {
let imageSize = chunkSize * nChunks;
return new Uint8Array(imageSize * imageSize * 4);
}
constructor(gl, chunkSize, nChunks, initBuffer) {
this.id = gl.createTexture();
this.chunkSize = chunkSize;
this.nChunks = nChunks;
this.textureSize = chunkSize * nChunks;
this.free = Array(nChunks * nChunks);
for (let y = 0; y < nChunks; ++y) {
for (let x = 0; x < nChunks; ++x) {
this.free[x + y * nChunks] = { x, y };
}
}
gl.bindTexture(gl.TEXTURE_2D, this.id);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
this.textureSize,
this.textureSize,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
initBuffer,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
}
alloc() {
return this.free.pop();
}
upload(gl, { x, y }, pixmap) {
gl.bindTexture(gl.TEXTURE_2D, this.id);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
x * this.chunkSize,
y * this.chunkSize,
this.chunkSize,
this.chunkSize,
gl.RGBA,
gl.UNSIGNED_BYTE,
pixmap.getArrayBuffer(),
);
}
}
class AtlasAllocator {
atlases = [];
constructor(chunkSize, nChunks) {
this.chunkSize = chunkSize;
this.nChunks = nChunks;
this.initBuffer = Atlas.getInitBuffer(chunkSize, nChunks);
}
alloc(gl) {
// Right now we do a dumb linear scan through all atlases, but in the future it would be
// really nice to optimize this by storing information about which atlases have free slots
// precisely.
for (let i = 0; i < this.atlases.length; ++i) {
let atlas = this.atlases[i];
let allocation = atlas.alloc();
if (allocation != null) {
return { i, allocation };
}
}
let i = this.atlases.length;
let atlas = new Atlas(gl, this.chunkSize, this.nChunks, this.initBuffer);
let allocation = atlas.alloc();
this.atlases.push(atlas);
return { i, allocation };
}
upload(gl, { i, allocation }, pixmap) {
this.atlases[i].upload(gl, allocation, pixmap);
}
}