rkgk/static/canvas-renderer.js
リキ萌 bff899c9c0 removing server-side brush rendering
brush rendering is now completely client-side.
the server only receives edits the client would like to do, in the form of PNG images of chunks, that are then composited onto the wall

known issue: it is possible to brush up against the current 256 chunk edit limit pretty easily.
I'm not sure it can be solved very easily though. the perfect solution would involve splitting up the interaction into multiple edits, and I tried to do that, but there's a noticable stutter for some reason that I haven't managed to track down yet.
so it'll be kinda crap for the time being.
2025-06-30 18:55:53 +02:00

684 lines
23 KiB
JavaScript

import { listen, Pool } from "rkgk/framework.js";
import { Viewport } from "rkgk/viewport.js";
import { Wall, chunkKey } 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.#interactionBehaviour();
this.addEventListener("contextmenu", (event) => event.preventDefault());
}
initialize(wall, painter) {
this.wall = wall;
this.painter = painter;
this.#initializeRenderer();
requestAnimationFrame(() => this.#render());
}
// Rendering
#updateSize() {
let { width, height } = this.getBoundingClientRect();
this.width = width;
this.height = height;
// To properly handle DPI scaling, we want the canvas's layout size to be equal to that of
// its parent container,
this.canvas.width = width * window.devicePixelRatio;
this.canvas.height = height * window.devicePixelRatio;
// Rerender immediately after the canvas is resized, as its contents have now been invalidated.
this.#render();
// Send a viewport update so that the server knows to send new chunks.
this.sendViewportUpdate();
}
getWindowSize() {
return {
width: this.width,
height: this.height,
};
}
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.batches = [];
this.batchPool = new Pool();
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.width / 2 - this.viewport.panX * this.viewport.zoom;
let translationY = this.height / 2 - this.viewport.panY * this.viewport.zoom;
let scale = this.viewport.zoom;
let dpiScale = window.devicePixelRatio;
translationX *= dpiScale;
translationY *= dpiScale;
scale *= dpiScale;
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 batch of this.batches) {
for (let [i, chunks] of batch) {
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.layerId,
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();
}
}
// TODO: This is a nice debug view.
// There should be a switch to it somewhere in the app.
/*
let x = 0;
let y = 0;
for (let atlas of this.atlasAllocator.atlases) {
this.#resetRectBuffer();
this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.id);
this.#pushRect(x, y, atlas.textureSize, atlas.textureSize, 0, 0, 1, 1);
this.#drawRects();
if (x > atlas.textureSize * 16) {
y += atlas.textureSize;
x = 0;
}
x += atlas.textureSize;
}
*/
}
getChunkAllocation(layerId, chunkX, chunkY) {
let key = `${layerId}/${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;
}
}
deallocateChunks(layer) {
for (let chunkKey of layer.chunks.keys()) {
let key = `${layer.id}/${chunkKey}`;
if (this.chunkAllocations.has(key)) {
let allocation = this.chunkAllocations.get(key);
this.atlasAllocator.dealloc(allocation);
this.chunkAllocations.delete(key);
}
}
}
#collectChunksThisFrame() {
for (let batch of this.batches) {
batch.clear();
this.batchPool.free(batch);
}
this.batches.splice(0, this.batches.length);
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 layer of this.wall.layers) {
let batch = this.batchPool.alloc(Map);
for (let chunkY = top; chunkY < bottom; ++chunkY) {
for (let chunkX = left; chunkX < right; ++chunkX) {
let chunk = layer.getChunk(chunkX, chunkY);
if (chunk != null) {
if (chunk.renderDirty) {
this.#updateChunkTexture(layer, chunkX, chunkY);
chunk.renderDirty = false;
}
let allocation = this.getChunkAllocation(layer.id, chunkX, chunkY);
let array = batch.get(allocation.i);
if (array == null) {
array = [];
batch.set(allocation.i, array);
}
array.push({ layerId: layer.id, x: chunkX, y: chunkY });
}
}
}
this.batches.push(batch);
}
}
#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(layer, chunkX, chunkY) {
let allocation = this.getChunkAllocation(layer.id, chunkX, chunkY);
let chunk = layer.getChunk(chunkX, chunkY);
this.atlasAllocator.upload(this.gl, allocation, chunk.pixmap);
}
// Behaviours
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;
}
}
}
}
}
#zoomingBehaviour() {
this.addEventListener(
"wheel",
(event) => {
// TODO: Touchpad zoom
let windowSize = this.getWindowSize();
let ndcX = (event.clientX - this.clientLeft) / windowSize.width - 0.5;
let ndcY = (event.clientY - this.clientTop) / windowSize.height - 0.5;
this.viewport.zoomIntoPoint(event.deltaY > 0 ? -1 : 1, ndcX, ndcY, windowSize);
this.sendViewportUpdate();
this.dispatchEvent(new Event(".viewportUpdateEnd"));
},
{ bubbling: false },
);
}
async #cursorReportingBehaviour() {
while (true) {
let event = await listen([window, "mousemove"]);
let [x, y] = this.viewport.toViewportSpace(
event.clientX - this.clientLeft,
event.clientY - this.clientTop,
this.getWindowSize(),
);
this.dispatchEvent(Object.assign(new Event(".cursor"), { x, y }));
}
}
async #interactionBehaviour() {
while (true) {
let mouseDown = await listen([this, "mousedown"]);
if (mouseDown.button == 0) {
let [mouseX, mouseY] = this.viewport.toViewportSpace(
mouseDown.clientX - this.clientLeft,
mouseDown.clientY - this.clientTop,
this.getWindowSize(),
);
notifyInteraction(this, "start", { mouseX, mouseY, num: 0 });
}
}
}
commitInteraction() {
this.dispatchEvent(new Event(".commitInteraction"));
}
}
customElements.define("rkgk-canvas-renderer", CanvasRenderer);
function notifyInteraction(canvasRenderer, kind, fields) {
canvasRenderer.dispatchEvent(
Object.assign(new InteractEvent(canvasRenderer), { interactionKind: kind, ...fields }),
);
}
class InteractEvent extends Event {
constructor(canvasRenderer) {
super(".interact");
this.canvasRenderer = canvasRenderer;
}
continueAsDotter() {
(async () => {
let event = await listen([window, "mousemove"], [window, "mouseup"]);
if (event.type == "mousemove") {
let [mouseX, mouseY] = this.canvasRenderer.viewport.toViewportSpace(
event.clientX - this.canvasRenderer.clientLeft,
event.clientY - this.canvasRenderer.clientTop,
this.canvasRenderer.getWindowSize(),
);
notifyInteraction(this.canvasRenderer, "dotter", {
previousX: this.mouseX,
previousY: this.mouseY,
mouseX,
mouseY,
num: this.num + 1,
});
}
if (event.type == "mouseup" && event.button == 0) {
// Break the loop.
this.canvasRenderer.commitInteraction();
return;
}
})();
if (this.previousX != null && this.previousY != null) {
return {
fromX: this.previousX,
fromY: this.previousY,
toX: this.mouseX,
toY: this.mouseY,
num: this.num,
};
} else {
return {
fromX: this.mouseX,
fromY: this.mouseY,
toX: this.mouseX,
toY: this.mouseY,
num: this.num,
};
}
}
}
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();
}
dealloc(xy) {
this.free.push(xy);
}
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 };
}
dealloc({ i, allocation }) {
let atlas = this.atlases[i];
atlas.dealloc(allocation);
}
upload(gl, { i, allocation }, pixmap) {
this.atlases[i].upload(gl, allocation, pixmap);
}
}