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.
This commit is contained in:
りき萌 2025-06-30 00:48:49 +02:00
parent 15a1bf8036
commit bff899c9c0
24 changed files with 613 additions and 1170 deletions

View file

@ -318,7 +318,7 @@ export class BrushBox extends HTMLElement {
if (id == null) {
// Save a new preset.
id = `user/${randomId()}`;
console.log("saving new brush", id);
console.info("saving new brush", id);
this.userPresets.push({
id,
name,
@ -327,7 +327,7 @@ export class BrushBox extends HTMLElement {
} else {
// Overwrite an existing one.
let preset = this.userPresets.find((p) => p.id == id);
console.log("overwriting existing brush", preset);
console.info("overwriting existing brush", preset);
preset.code = code;
}
this.saveUserPresets();

View file

@ -1,6 +1,6 @@
import { listen } from "rkgk/framework.js";
import { listen, Pool } from "rkgk/framework.js";
import { Viewport } from "rkgk/viewport.js";
import { Wall } from "rkgk/wall.js";
import { Wall, chunkKey } from "rkgk/wall.js";
class CanvasRenderer extends HTMLElement {
viewport = new Viewport();
@ -196,7 +196,8 @@ class CanvasRenderer extends HTMLElement {
console.debug("initialized atlas allocator", this.atlasAllocator);
this.chunksThisFrame = new Map();
this.batches = [];
this.batchPool = new Pool();
console.debug("GL error state", this.gl.getError());
@ -294,31 +295,55 @@ class CanvasRenderer extends HTMLElement {
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);
for (let batch of this.batches) {
for (let [i, chunks] of batch) {
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.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();
}
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(chunkX, chunkY) {
let key = Wall.chunkKey(chunkX, chunkY);
getChunkAllocation(layerId, chunkX, chunkY) {
let key = `${layerId}/${chunkKey(chunkX, chunkY)}`;
if (this.chunkAllocations.has(key)) {
return this.chunkAllocations.get(key);
} else {
@ -328,36 +353,54 @@ class CanvasRenderer extends HTMLElement {
}
}
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() {
// NOTE: Not optimal that we don't preserve the arrays anyhow; it would be better if we
// preserved the allocations.
this.chunksThisFrame.clear();
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 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;
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 });
}
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.batches.push(batch);
}
}
@ -395,9 +438,9 @@ class CanvasRenderer extends HTMLElement {
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);
#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);
}
@ -474,6 +517,10 @@ class CanvasRenderer extends HTMLElement {
}
}
}
commitInteraction() {
this.dispatchEvent(new Event(".commitInteraction"));
}
}
customElements.define("rkgk-canvas-renderer", CanvasRenderer);
@ -513,6 +560,7 @@ class InteractEvent extends Event {
if (event.type == "mouseup" && event.button == 0) {
// Break the loop.
this.canvasRenderer.commitInteraction();
return;
}
})();
@ -576,6 +624,10 @@ class Atlas {
return this.free.pop();
}
dealloc(xy) {
this.free.push(xy);
}
upload(gl, { x, y }, pixmap) {
gl.bindTexture(gl.TEXTURE_2D, this.id);
gl.texSubImage2D(
@ -621,6 +673,11 @@ class AtlasAllocator {
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);
}

View file

@ -54,6 +54,24 @@ export function debounce(time, fn) {
};
}
export class Pool {
constructor() {
this.pool = [];
}
alloc(ctor) {
if (this.pool.length > 0) {
return this.pool.pop();
} else {
return new ctor();
}
}
free(obj) {
this.pool.push(obj);
}
}
export class SaveData {
constructor(prefix) {
this.prefix = `rkgk.${prefix}`;

View file

@ -192,7 +192,7 @@ function readUrl(urlString) {
}
});
let sendViewportUpdate = debounce(updateInterval, () => {
let sendViewportUpdate = debounce(updateInterval / 4, () => {
let visibleRect = canvasRenderer.getVisibleChunkRect();
session.sendViewport(visibleRect);
});
@ -213,7 +213,12 @@ function readUrl(urlString) {
let blob = chunkData.slice(info.offset, info.offset + info.length, "image/webp");
updatePromises.push(
createImageBitmap(blob).then((bitmap) => {
let chunk = wall.getOrCreateChunk(info.position.x, info.position.y);
let chunk = wall.mainLayer.getOrCreateChunk(
info.position.x,
info.position.y,
);
if (chunk == null) return;
chunk.ctx.globalCompositeOperation = "copy";
chunk.ctx.drawImage(bitmap, 0, 0);
chunk.syncToPixmap();
@ -230,7 +235,7 @@ function readUrl(urlString) {
}
});
let reportCursor = debounce(updateInterval, (x, y) => session.sendCursor(x, y), console.log);
let reportCursor = debounce(updateInterval, (x, y) => session.sendCursor(x, y));
canvasRenderer.addEventListener(".cursor", async (event) => {
reportCursor(event.x, event.y);
});
@ -248,12 +253,38 @@ function readUrl(urlString) {
canvasRenderer.addEventListener(".interact", async (event) => {
if (!currentUser.haku.ok) return;
let layer = currentUser.getScratchLayer(wall);
let result = await currentUser.haku.evalBrush(
selfController(interactionQueue, wall, event),
selfController(interactionQueue, wall, layer, event),
);
brushEditor.renderHakuResult(result);
});
canvasRenderer.addEventListener(".commitInteraction", async () => {
let scratchLayer = currentUser.commitScratchLayer(wall);
if (scratchLayer == null) return;
canvasRenderer.deallocateChunks(scratchLayer);
let edits = await scratchLayer.toEdits();
scratchLayer.destroy();
let editRecords = [];
let dataParts = [];
let cursor = 0;
for (let edit of edits) {
editRecords.push({
chunk: edit.chunk,
dataType: edit.data.type,
dataOffset: cursor,
dataLength: edit.data.size,
});
dataParts.push(edit.data);
cursor += edit.data.size;
}
session.sendEdit(editRecords, new Blob(dataParts));
});
canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.render());
canvasRenderer.addEventListener(".viewportUpdateEnd", () =>
updateUrl(session, canvasRenderer.viewport),

View file

@ -1,5 +1,6 @@
import { ContKind, Haku } from "rkgk/haku.js";
import { renderToChunksInArea, dotterRenderArea } from "rkgk/painter.js";
import { Layer } from "rkgk/wall.js";
export class User {
nickname = "";
@ -9,19 +10,22 @@ export class User {
isBrushOk = false;
simulation = null;
scratchLayer = null;
constructor(wallInfo, nickname) {
this.nickname = nickname;
this.haku = new Haku(wallInfo.hakuLimits);
}
destroy() {
console.info("destroying user", this.nickname);
this.haku.destroy();
}
setBrush(brush) {
console.groupCollapsed("setBrush", this.nickname);
let compileResult = this.haku.setBrush(brush);
console.log("compiling brush complete", compileResult);
console.info("compiling brush complete", compileResult);
console.groupEnd();
this.isBrushOk = compileResult.status == "ok";
@ -32,14 +36,14 @@ export class User {
renderBrushToChunks(wall, x, y) {
console.groupCollapsed("renderBrushToChunks", this.nickname);
let result = this.painter.renderBrushToWall(this.haku, x, y, wall);
console.log("rendering brush to chunks complete");
console.info("rendering brush to chunks complete");
console.groupEnd();
return result;
}
simulate(wall, interactions) {
console.group("simulate");
console.group("simulate", this.nickname);
for (let interaction of interactions) {
if (interaction.kind == "setBrush") {
this.simulation = null;
@ -48,7 +52,7 @@ export class User {
if (this.isBrushOk) {
if (this.simulation == null) {
console.log("no simulation -- beginning brush");
console.info("no simulation -- beginning brush");
this.simulation = { renderArea: { left: 0, top: 0, right: 0, bottom: 0 } };
this.haku.beginBrush();
}
@ -67,13 +71,13 @@ export class User {
if (interaction.kind == "scribble" && this.#expectContKind(ContKind.Scribble)) {
renderToChunksInArea(
wall,
this.getScratchLayer(wall),
this.simulation.renderArea,
(pixmap, translationX, translationY) => {
return this.haku.contScribble(pixmap, translationX, translationY);
},
);
console.log("ended simulation");
console.info("ended simulation");
this.simulation = null;
}
}
@ -100,6 +104,34 @@ export class User {
memoryMax: wallInfo.hakuLimits.memory,
};
}
getScratchLayer(wall) {
if (this.scratchLayer == null) {
this.scratchLayer = wall.addLayer(
new Layer({
name: `scratch ${this.nickname}`,
chunkSize: wall.chunkSize,
chunkLimit: wall.maxEditSize,
}),
);
}
return this.scratchLayer;
}
// Returns the scratch layer committed to the wall, so that the caller may do additional
// processing with the completed layer (i.e. send to the server.)
// The layer has to be .destroy()ed once you're done working with it.
commitScratchLayer(wall) {
if (this.scratchLayer != null) {
wall.mainLayer.compositeAlpha(this.scratchLayer);
wall.removeLayer(this.scratchLayer);
let scratchLayer = this.scratchLayer;
this.scratchLayer = null;
return scratchLayer;
} else {
console.error("commitScratchLayer without an active scratch layer", this.nickname);
}
}
}
export class OnlineUsers extends EventTarget {

View file

@ -1,10 +1,20 @@
import { listen } from "rkgk/framework.js";
function* chunksInRectangle(left, top, right, bottom, chunkSize) {
let leftChunk = Math.floor(left / chunkSize);
let topChunk = Math.floor(top / chunkSize);
let rightChunk = Math.ceil(right / chunkSize);
let bottomChunk = Math.ceil(bottom / chunkSize);
function numChunksInRectangle(rect, chunkSize) {
let leftChunk = Math.floor(rect.left / chunkSize);
let topChunk = Math.floor(rect.top / chunkSize);
let rightChunk = Math.ceil(rect.right / chunkSize);
let bottomChunk = Math.ceil(rect.bottom / chunkSize);
let numX = rightChunk - leftChunk;
let numY = bottomChunk - topChunk;
return numX * numY;
}
function* chunksInRectangle(rect, chunkSize) {
let leftChunk = Math.floor(rect.left / chunkSize);
let topChunk = Math.floor(rect.top / chunkSize);
let rightChunk = Math.ceil(rect.right / chunkSize);
let bottomChunk = Math.ceil(rect.bottom / chunkSize);
for (let chunkY = topChunk; chunkY < bottomChunk; ++chunkY) {
for (let chunkX = leftChunk; chunkX < rightChunk; ++chunkX) {
yield [chunkX, chunkY];
@ -12,17 +22,13 @@ function* chunksInRectangle(left, top, right, bottom, chunkSize) {
}
}
export function renderToChunksInArea(wall, renderArea, renderToPixmap) {
for (let [chunkX, chunkY] of chunksInRectangle(
renderArea.left,
renderArea.top,
renderArea.right,
renderArea.bottom,
wall.chunkSize,
)) {
let chunk = wall.getOrCreateChunk(chunkX, chunkY);
let translationX = -chunkX * wall.chunkSize;
let translationY = -chunkY * wall.chunkSize;
export function renderToChunksInArea(layer, renderArea, renderToPixmap) {
for (let [chunkX, chunkY] of chunksInRectangle(renderArea, layer.chunkSize)) {
let chunk = layer.getOrCreateChunk(chunkX, chunkY);
if (chunk == null) continue;
let translationX = -chunkX * layer.chunkSize;
let translationY = -chunkY * layer.chunkSize;
let result = renderToPixmap(chunk.pixmap, translationX, translationY);
chunk.markModified();
if (result.status != "ok") return result;
@ -41,13 +47,19 @@ export function dotterRenderArea(wall, dotter) {
};
}
export function selfController(interactionQueue, wall, event) {
export function selfController(interactionQueue, wall, layer, event) {
let renderArea = null;
return {
async runScribble(renderToPixmap) {
interactionQueue.push({ kind: "scribble" });
if (renderArea != null) {
return renderToChunksInArea(wall, renderArea, renderToPixmap);
let numChunksToRender = numChunksInRectangle(renderArea, layer.chunkSize);
let result = renderToChunksInArea(layer, renderArea, renderToPixmap);
if (!layer.canFitNewChunks(numChunksToRender)) {
console.debug("too many chunks rendered; committing interaction early");
event.earlyCommitInteraction();
}
return result;
} else {
console.debug("render area is empty, nothing will be rendered");
}

View file

@ -266,6 +266,17 @@ class Session extends EventTarget {
});
}
sendEdit(edits, data) {
this.#sendJson({
request: "wall",
wallEvent: {
event: "edit",
edits,
},
});
this.ws.send(data);
}
sendInteraction(interactions) {
this.#sendJson({
request: "wall",

View file

@ -9,6 +9,10 @@ export class Chunk {
this.renderDirty = false;
}
destroy() {
this.pixmap.destroy();
}
syncFromPixmap() {
this.ctx.putImageData(this.pixmap.getImageData(), 0, 0);
}
@ -23,31 +27,117 @@ export class Chunk {
}
}
let layerIdCounter = 0;
export class Layer {
chunks = new Map();
id = layerIdCounter++;
constructor({ name, chunkSize, chunkLimit }) {
this.name = name;
this.chunkSize = chunkSize;
this.chunkLimit = chunkLimit;
console.info("created layer", this.id, this.name);
}
destroy() {
for (let { chunk } of this.chunks.values()) {
chunk.destroy();
}
}
getChunk(x, y) {
return this.chunks.get(chunkKey(x, y))?.chunk;
}
getOrCreateChunk(x, y) {
let key = chunkKey(x, y);
if (this.chunks.has(key)) {
return this.chunks.get(key)?.chunk;
} else {
if (this.chunkLimit != null && this.chunks.size >= this.chunkLimit) return null;
let chunk = new Chunk(this.chunkSize);
this.chunks.set(key, { x, y, chunk });
return chunk;
}
}
compositeAlpha(src) {
for (let { x, y, chunk: srcChunk } of src.chunks.values()) {
srcChunk.syncFromPixmap();
let dstChunk = this.getOrCreateChunk(x, y);
if (dstChunk == null) continue;
dstChunk.ctx.globalCompositeOperation = "source-over";
dstChunk.ctx.drawImage(srcChunk.canvas, 0, 0);
dstChunk.syncToPixmap();
dstChunk.markModified();
}
}
async toEdits() {
let edits = [];
let start = performance.now();
for (let { x, y, chunk } of this.chunks.values()) {
edits.push({
chunk: { x, y },
data: chunk.canvas.convertToBlob({ type: "image/png" }),
});
}
for (let edit of edits) {
edit.data = await edit.data;
}
let end = performance.now();
console.debug("toEdits done", end - start);
return edits;
}
}
export function chunkKey(x, y) {
return `${x},${y}`;
}
export class Wall {
#chunks = new Map();
layers = []; // do not modify directly; only read
#layersById = new Map();
constructor(wallInfo) {
this.chunkSize = wallInfo.chunkSize;
this.paintArea = wallInfo.paintArea;
this.maxEditSize = wallInfo.maxEditSize;
this.onlineUsers = new OnlineUsers(wallInfo);
this.mainLayer = new Layer({ name: "main", chunkSize: this.chunkSize });
this.addLayer(this.mainLayer);
}
static chunkKey(x, y) {
return `(${x},${y})`;
}
getChunk(x, y) {
return this.#chunks.get(Wall.chunkKey(x, y));
}
getOrCreateChunk(x, y) {
let key = Wall.chunkKey(x, y);
if (this.#chunks.has(key)) {
return this.#chunks.get(key);
addLayer(layer) {
if (!this.#layersById.get(layer.id)) {
this.layers.push(layer);
this.#layersById.set(layer.id, layer);
} else {
let chunk = new Chunk(this.chunkSize);
this.#chunks.set(key, chunk);
return chunk;
console.warn("attempt to add layer more than once", layer);
}
return layer;
}
removeLayer(layer) {
if (this.#layersById.delete(layer.id)) {
let index = this.layers.findIndex((x) => x == layer);
this.layers.splice(index, 1);
} else {
console.warn("attempt to remove layer more than once", layer);
}
}
getLayerById(id) {
return this.#layersById.get(id);
}
}