import { listen, Pool } from "rkgk/framework.js"; import { Viewport } from "rkgk/viewport.js"; import { Wall, chunkKey } from "rkgk/wall.js"; import { AtlasAllocator } from "rkgk/chunk-allocator.js"; import { compileProgram } from "rkgk/webgl.js"; import { BrushRenderer } from "rkgk/brush-renderer.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.group("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 = compileProgram( this.gl, // 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() { vec4 color = texture(u_texture, vf_uv); f_color = color; } `, ); 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.gl, this.wall.chunkSize, 8); console.debug("initialized atlas allocator", this.atlasAllocator); this.batches = []; this.batchPool = new Pool(); this.brushRenderer = new BrushRenderer(this.gl, this.atlasAllocator.canvasSource()); console.debug("GL error state", this.gl.getError()); console.groupEnd(); } // 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.scissor(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.texture); this.#resetRectBuffer(); for (let chunk of chunks) { let atlasIndex = this.atlasAllocator.getAtlasIndex(chunk.id); let allocation = this.atlasAllocator.getAllocation(chunk.id); let atlas = this.atlasAllocator.atlases[atlasIndex]; 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.texture); 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; } // */ } #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) { let atlasIndex = this.atlasAllocator.getAtlasIndex(chunk.id); let array = batch.get(atlasIndex); if (array == null) { array = []; batch.set(atlasIndex, array); } array.push({ layerId: layer.id, x: chunkX, y: chunkY, id: chunk.id }); } } } 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); } // 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, }; } } }