From 83754a028235a5e8b180e11a73604abcb9e170bb Mon Sep 17 00:00:00 2001 From: liquidev Date: Tue, 3 Sep 2024 22:16:28 +0200 Subject: [PATCH] new! fast! WebGL renderer --- static/canvas-renderer.js | 407 +++++++++++++++++++++++++++++++++++--- static/haku.js | 22 ++- static/index.js | 1 + static/online-users.js | 4 +- static/painter.js | 1 + static/wall.js | 9 +- 6 files changed, 408 insertions(+), 36 deletions(-) diff --git a/static/canvas-renderer.js b/static/canvas-renderer.js index db9ace2..2523e78 100644 --- a/static/canvas-renderer.js +++ b/static/canvas-renderer.js @@ -1,5 +1,6 @@ import { listen } from "./framework.js"; import { Viewport } from "./viewport.js"; +import { Wall } from "./wall.js"; class CanvasRenderer extends HTMLElement { viewport = new Viewport(); @@ -10,7 +11,7 @@ class CanvasRenderer extends HTMLElement { connectedCallback() { this.canvas = this.appendChild(document.createElement("canvas")); - this.ctx = this.canvas.getContext("2d"); + this.gl = this.canvas.getContext("webgl2"); let resizeObserver = new ResizeObserver(() => this.#updateSize()); resizeObserver.observe(this); @@ -26,9 +27,12 @@ class CanvasRenderer extends HTMLElement { 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; @@ -43,13 +47,6 @@ class CanvasRenderer extends HTMLElement { }; } - #render() { - // NOTE: We should probably render on-demand only when it's needed. - requestAnimationFrame(() => this.#render()); - - this.#renderWall(); - } - getVisibleRect() { return this.viewport.getVisibleRect(this.getWindowSize()); } @@ -63,19 +60,250 @@ class CanvasRenderer extends HTMLElement { return { left, top, right, bottom }; } + // Renderer initialization + + #initializeRenderer() { + console.groupCollapsed("initializeRenderer"); + + this.gl.enable(this.gl.BLEND); + this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA); + + 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[512]; }; + + uniform vec2 u_screenSize; + uniform vec2 u_translation; + uniform vec2 u_scale; + + layout (location = 0) in vec2 a_position; + out vec2 vf_uv; + + void main() { + mat4 matProjection = mat4( + 2.0 / u_screenSize.x, 0.0, 0.0, 0.0, + 0.0, 2.0 / -u_screenSize.y, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + -1.0, 1.0, 0.0, 1.0 + ); + mat4 matModel = mat4( + u_scale.x, 0.0, 0.0, 0.0, + 0.0, u_scale.y, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + u_translation.x, u_translation.y, 0.0, 1.0 + ); + + Rect rect = u_rects[gl_InstanceID]; + vec2 localPosition = rect.position.xy + a_position * rect.position.zw; + vec4 screenPosition = floor(matModel * vec4(localPosition, 0.0, 1.0)); + vec4 scenePosition = matProjection * 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_screenSize: this.gl.getUniformLocation(renderChunksProgramId, "u_screenSize"), + u_translation: this.gl.getUniformLocation(renderChunksProgramId, "u_translation"), + u_scale: this.gl.getUniformLocation(renderChunksProgramId, "u_scale"), + 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(new ArrayBuffer(16384)); + 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.ctx.fillStyle = "white"; - this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.gl.viewport(0, 0, this.canvas.width, this.canvas.height); - this.ctx.save(); - this.ctx.translate(Math.floor(this.clientWidth / 2), Math.floor(this.clientHeight / 2)); - this.ctx.scale(this.viewport.zoom, this.viewport.zoom); - this.ctx.translate(-this.viewport.panX, -this.viewport.panY); + this.gl.clearColor(1, 1, 1, 1); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + this.gl.useProgram(this.renderChunksProgram.id); + + this.gl.uniform2f( + this.renderChunksProgram.u_screenSize, + this.canvas.width, + this.canvas.height, + ); + + this.gl.uniform2f( + this.renderChunksProgram.u_translation, + this.canvas.width / 2 - this.viewport.panX * this.viewport.zoom, + this.canvas.height / 2 - this.viewport.panY * this.viewport.zoom, + ); + this.gl.uniform2f(this.renderChunksProgram.u_scale, this.viewport.zoom, this.viewport.zoom); + + 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); @@ -84,25 +312,69 @@ class CanvasRenderer extends HTMLElement { 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 x = chunkX * this.wall.chunkSize; - let y = chunkY * this.wall.chunkSize; - let chunk = this.wall.getChunk(chunkX, chunkY); if (chunk != null) { - this.ctx.globalCompositeOperation = "source-over"; - this.ctx.imageSmoothingEnabled = false; - this.ctx.drawImage(chunk.canvas, x, y); + 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 }); } } } + } - this.ctx.restore(); + #resetRectBuffer() { + this.uboRectsNum = 0; + } - if (this.ctx.brushPreview != null) { - this.ctx.drawImage(this.ctx.brushPreview, 0, 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"]); @@ -173,3 +445,92 @@ class CanvasRenderer extends HTMLElement { } 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); + } +} diff --git a/static/haku.js b/static/haku.js index f1a9775..d3f8b82 100644 --- a/static/haku.js +++ b/static/haku.js @@ -100,17 +100,17 @@ export class Pixmap { return this.#pPixmap; } - get imageData() { - return new ImageData( - new Uint8ClampedArray( - memory.buffer, - w.haku_pixmap_data(this.#pPixmap), - this.width * this.height * 4, - ), - this.width, - this.height, + getArrayBuffer() { + return new Uint8ClampedArray( + memory.buffer, + w.haku_pixmap_data(this.#pPixmap), + this.width * this.height * 4, ); } + + getImageData() { + return new ImageData(this.getArrayBuffer(), this.width, this.height); + } } export class Haku { @@ -119,6 +119,8 @@ export class Haku { #brushCode = null; constructor(limits) { + console.groupCollapsed("construct Haku"); + let pLimits = w.haku_limits_new(); for (let name of Object.keys(limits)) { w[`haku_limits_set_${name}`](pLimits, limits[name]); @@ -128,6 +130,8 @@ export class Haku { this.#pBrush = w.haku_brush_new(); w.haku_limits_destroy(pLimits); + + console.groupEnd(); } destroy() { diff --git a/static/index.js b/static/index.js index 4d79bb9..f3a6c07 100644 --- a/static/index.js +++ b/static/index.js @@ -211,6 +211,7 @@ function readUrl(urlString) { chunk.ctx.globalCompositeOperation = "copy"; chunk.ctx.drawImage(bitmap, 0, 0); chunk.syncToPixmap(); + chunk.markModified(); }), ); } diff --git a/static/online-users.js b/static/online-users.js index 42311b3..dc78637 100644 --- a/static/online-users.js +++ b/static/online-users.js @@ -20,7 +20,7 @@ export class User { } setBrush(brush) { - console.group("setBrush", this.nickname); + console.groupCollapsed("setBrush", this.nickname); let compileResult = this.haku.setBrush(brush); console.log("compiling brush complete", compileResult); console.groupEnd(); @@ -31,7 +31,7 @@ export class User { } renderBrushToChunks(wall, x, y) { - console.group("renderBrushToChunks", this.nickname); + console.groupCollapsed("renderBrushToChunks", this.nickname); let result = this.painter.renderBrushToWall(this.haku, x, y, wall); console.log("rendering brush to chunks complete"); console.groupEnd(); diff --git a/static/painter.js b/static/painter.js index 6895047..ab5b252 100644 --- a/static/painter.js +++ b/static/painter.js @@ -22,6 +22,7 @@ export class Painter { let x = Math.floor(-chunkX * wall.chunkSize + centerX); let y = Math.floor(-chunkY * wall.chunkSize + centerY); let chunk = wall.getOrCreateChunk(chunkX, chunkY); + chunk.markModified(); let renderResult = haku.renderValue(chunk.pixmap, x, y); if (renderResult.status != "ok") { diff --git a/static/wall.js b/static/wall.js index e522d00..f69929e 100644 --- a/static/wall.js +++ b/static/wall.js @@ -6,15 +6,20 @@ export class Chunk { this.pixmap = new Pixmap(size, size); this.canvas = new OffscreenCanvas(size, size); this.ctx = this.canvas.getContext("2d"); + this.renderDirty = false; } syncFromPixmap() { - this.ctx.putImageData(this.pixmap.imageData, 0, 0); + this.ctx.putImageData(this.pixmap.getImageData(), 0, 0); } syncToPixmap() { let imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); - this.pixmap.imageData.data.set(imageData.data, 0); + this.pixmap.getImageData().data.set(imageData.data, 0); + } + + markModified() { + this.renderDirty = true; } }