From 6f56d77535904384019f5cbd3d50a98ee12f2533 Mon Sep 17 00:00:00 2001 From: PFedak Date: Wed, 25 Aug 2021 05:04:52 -0600 Subject: [PATCH] Aggregate map chunks in overlay. (#1247) Overlay rendering performance seemed bottlenecked by drawImage calls. To reduce both the number of calls and the number of different source buffers, cache overlay buffers for squares of chunks. This adds a very small extra cost for updates (one additional drawImage) and some cost for drawing chunks outside of view, but this is more than made up for by the savings. By default, the aggregate are 4x4 squares of chunks. --- src/js/core/buffer_maintainer.js | 21 +++++ src/js/core/config.js | 1 + src/js/game/map.js | 39 ++++++++ src/js/game/map_chunk_aggregate.js | 146 +++++++++++++++++++++++++++++ src/js/game/map_chunk_view.js | 87 ++++++----------- src/js/game/map_view.js | 37 +++++++- 6 files changed, 271 insertions(+), 60 deletions(-) create mode 100644 src/js/game/map_chunk_aggregate.js diff --git a/src/js/core/buffer_maintainer.js b/src/js/core/buffer_maintainer.js index 1d506803..3eaf1f8a 100644 --- a/src/js/core/buffer_maintainer.js +++ b/src/js/core/buffer_maintainer.js @@ -167,4 +167,25 @@ export class BufferMaintainer { }); return canvas; } + + /** + * @param {object} param0 + * @param {string} param0.key + * @param {string} param0.subKey + * @returns {HTMLCanvasElement?} + * + */ + getForKeyOrNullNoUpdate({ key, subKey }) { + let parent = this.cache.get(key); + if (!parent) { + return null; + } + + // Now search for sub key + const cacheHit = parent.get(subKey); + if (cacheHit) { + return cacheHit.canvas; + } + return null; + } } diff --git a/src/js/core/config.js b/src/js/core/config.js index 78dd99e4..dcb29e77 100644 --- a/src/js/core/config.js +++ b/src/js/core/config.js @@ -55,6 +55,7 @@ export const globalConfig = { // Map mapChunkSize: 16, + chunkAggregateSize: 4, mapChunkOverviewMinZoom: 0.9, mapChunkWorldSize: null, // COMPUTED diff --git a/src/js/game/map.js b/src/js/game/map.js index a5ec8f21..67df7db3 100644 --- a/src/js/game/map.js +++ b/src/js/game/map.js @@ -3,6 +3,7 @@ import { Vector } from "../core/vector"; import { BasicSerializableObject, types } from "../savegame/serialization"; import { BaseItem } from "./base_item"; import { Entity } from "./entity"; +import { MapChunkAggregate } from "./map_chunk_aggregate"; import { MapChunkView } from "./map_chunk_view"; import { GameRoot } from "./root"; @@ -31,6 +32,11 @@ export class BaseMap extends BasicSerializableObject { * Mapping of 'X|Y' to chunk * @type {Map} */ this.chunksById = new Map(); + + /** + * Mapping of 'X|Y' to chunk aggregate + * @type {Map} */ + this.aggregatesById = new Map(); } /** @@ -55,6 +61,39 @@ export class BaseMap extends BasicSerializableObject { return null; } + /** + * Returns the chunk aggregate containing a given chunk + * @param {number} chunkX + * @param {number} chunkY + */ + getAggregateForChunk(chunkX, chunkY, createIfNotExistent = false) { + const aggX = Math.floor(chunkX / globalConfig.chunkAggregateSize); + const aggY = Math.floor(chunkY / globalConfig.chunkAggregateSize); + return this.getAggregate(aggX, aggY, createIfNotExistent); + } + + /** + * Returns the given chunk aggregate by index + * @param {number} aggX + * @param {number} aggY + */ + getAggregate(aggX, aggY, createIfNotExistent = false) { + const aggIdentifier = aggX + "|" + aggY; + let storedAggregate; + + if ((storedAggregate = this.aggregatesById.get(aggIdentifier))) { + return storedAggregate; + } + + if (createIfNotExistent) { + const instance = new MapChunkAggregate(this.root, aggX, aggY); + this.aggregatesById.set(aggIdentifier, instance); + return instance; + } + + return null; + } + /** * Gets or creates a new chunk if not existent for the given tile * @param {number} tileX diff --git a/src/js/game/map_chunk_aggregate.js b/src/js/game/map_chunk_aggregate.js new file mode 100644 index 00000000..67b4b45e --- /dev/null +++ b/src/js/game/map_chunk_aggregate.js @@ -0,0 +1,146 @@ +import { globalConfig } from "../core/config"; +import { DrawParameters } from "../core/draw_parameters"; +import { MapChunk } from "./map_chunk"; +import { GameRoot } from "./root"; +import { drawSpriteClipped } from "../core/draw_utils"; + +export const CHUNK_OVERLAY_RES = 3; + +export class MapChunkAggregate { + /** + * + * @param {GameRoot} root + * @param {number} x + * @param {number} y + */ + constructor(root, x, y) { + this.root = root; + this.x = x; + this.y = y; + + /** + * Whenever something changes, we increase this number - so we know we need to redraw + */ + this.renderIteration = 0; + this.dirty = false; + /** @type {Array} */ + this.dirtyList = new Array(globalConfig.chunkAggregateSize ** 2).fill(true); + this.markDirty(0, 0); + } + + /** + * Marks this chunk as dirty, rerendering all caches + * @param {number} chunkX + * @param {number} chunkY + */ + markDirty(chunkX, chunkY) { + const relX = chunkX % globalConfig.chunkAggregateSize; + const relY = chunkY % globalConfig.chunkAggregateSize; + this.dirtyList[relY * globalConfig.chunkAggregateSize + relX] = true; + if (this.dirty) { + return; + } + this.dirty = true; + ++this.renderIteration; + this.renderKey = this.x + "/" + this.y + "@" + this.renderIteration; + } + + /** + * + * @param {HTMLCanvasElement} canvas + * @param {CanvasRenderingContext2D} context + * @param {number} w + * @param {number} h + * @param {number} dpi + */ + generateOverlayBuffer(canvas, context, w, h, dpi) { + const prevKey = this.x + "/" + this.y + "@" + (this.renderIteration - 1); + const prevBuffer = this.root.buffers.getForKeyOrNullNoUpdate({ + key: "agg@" + this.root.currentLayer, + subKey: prevKey, + }); + + const overlaySize = globalConfig.mapChunkSize * CHUNK_OVERLAY_RES; + let onlyDirty = false; + if (prevBuffer) { + context.drawImage(prevBuffer, 0, 0); + onlyDirty = true; + } + + for (let x = 0; x < globalConfig.chunkAggregateSize; x++) { + for (let y = 0; y < globalConfig.chunkAggregateSize; y++) { + if (onlyDirty && !this.dirtyList[globalConfig.chunkAggregateSize * y + x]) continue; + this.root.map + .getChunk( + this.x * globalConfig.chunkAggregateSize + x, + this.y * globalConfig.chunkAggregateSize + y, + true + ) + .generateOverlayBuffer( + context, + overlaySize, + overlaySize, + x * overlaySize, + y * overlaySize + ); + } + } + + this.dirty = false; + this.dirtyList.fill(false); + } + + /** + * Overlay + * @param {DrawParameters} parameters + */ + drawOverlay(parameters) { + const aggregateOverlaySize = + globalConfig.mapChunkSize * globalConfig.chunkAggregateSize * CHUNK_OVERLAY_RES; + const sprite = this.root.buffers.getForKey({ + key: "agg@" + this.root.currentLayer, + subKey: this.renderKey, + w: aggregateOverlaySize, + h: aggregateOverlaySize, + dpi: 1, + redrawMethod: this.generateOverlayBuffer.bind(this), + }); + + const dims = globalConfig.mapChunkWorldSize * globalConfig.chunkAggregateSize; + const extrude = 0.05; + + // Draw chunk "pixel" art + parameters.context.imageSmoothingEnabled = false; + drawSpriteClipped({ + parameters, + sprite, + x: this.x * dims - extrude, + y: this.y * dims - extrude, + w: dims + 2 * extrude, + h: dims + 2 * extrude, + originalW: aggregateOverlaySize, + originalH: aggregateOverlaySize, + }); + + parameters.context.imageSmoothingEnabled = true; + const resourcesScale = this.root.app.settings.getAllSettings().mapResourcesScale; + + // Draw patch items + if (this.root.currentLayer === "regular" && resourcesScale > 0.05) { + const diameter = (70 / Math.pow(parameters.zoomLevel, 0.35)) * (0.2 + 2 * resourcesScale); + + for (let x = 0; x < globalConfig.chunkAggregateSize; x++) { + for (let y = 0; y < globalConfig.chunkAggregateSize; y++) { + this.root.map + .getChunk(this.x + x, this.y + y, true) + .drawOverlayPatches( + parameters, + this.x * dims + x * globalConfig.mapChunkSize * CHUNK_OVERLAY_RES, + this.y * dims + y * globalConfig.mapChunkSize * CHUNK_OVERLAY_RES, + diameter + ); + } + } + } + } +} diff --git a/src/js/game/map_chunk_view.js b/src/js/game/map_chunk_view.js index 131ce37b..947b7a9f 100644 --- a/src/js/game/map_chunk_view.js +++ b/src/js/game/map_chunk_view.js @@ -33,6 +33,7 @@ export class MapChunkView extends MapChunk { markDirty() { ++this.renderIteration; this.renderKey = this.x + "/" + this.y + "@" + this.renderIteration; + this.root.map.getAggregateForChunk(this.x, this.y, true).markDirty(this.x, this.y); } /** @@ -82,73 +83,41 @@ export class MapChunkView extends MapChunk { } /** - * Overlay * @param {DrawParameters} parameters + * @param {number} xoffs + * @param {number} yoffs + * @param {number} diameter */ - drawOverlay(parameters) { - const overlaySize = globalConfig.mapChunkSize * CHUNK_OVERLAY_RES; - const sprite = this.root.buffers.getForKey({ - key: "chunk@" + this.root.currentLayer, - subKey: this.renderKey, - w: overlaySize, - h: overlaySize, - dpi: 1, - redrawMethod: this.generateOverlayBuffer.bind(this), - }); - - const dims = globalConfig.mapChunkWorldSize; - const extrude = 0.05; - - // Draw chunk "pixel" art - parameters.context.imageSmoothingEnabled = false; - drawSpriteClipped({ - parameters, - sprite, - x: this.x * dims - extrude, - y: this.y * dims - extrude, - w: dims + 2 * extrude, - h: dims + 2 * extrude, - originalW: overlaySize, - originalH: overlaySize, - }); - - parameters.context.imageSmoothingEnabled = true; - const resourcesScale = this.root.app.settings.getAllSettings().mapResourcesScale; - - // Draw patch items - if (this.root.currentLayer === "regular" && resourcesScale > 0.05) { - const diameter = (70 / Math.pow(parameters.zoomLevel, 0.35)) * (0.2 + 2 * resourcesScale); - - for (let i = 0; i < this.patches.length; ++i) { - const patch = this.patches[i]; - if (patch.item.getItemType() === "shape") { - const destX = this.x * dims + patch.pos.x * globalConfig.tileSize; - const destY = this.y * dims + patch.pos.y * globalConfig.tileSize; - patch.item.drawItemCenteredClipped(destX, destY, parameters, diameter); - } + drawOverlayPatches(parameters, xoffs, yoffs, diameter) { + for (let i = 0; i < this.patches.length; ++i) { + const patch = this.patches[i]; + if (patch.item.getItemType() === "shape") { + const destX = xoffs + patch.pos.x * globalConfig.tileSize; + const destY = yoffs + patch.pos.y * globalConfig.tileSize; + patch.item.drawItemCenteredClipped(destX, destY, parameters, diameter); } } } /** * - * @param {HTMLCanvasElement} canvas * @param {CanvasRenderingContext2D} context * @param {number} w * @param {number} h - * @param {number} dpi + * @param {number=} xoffs + * @param {number=} yoffs */ - generateOverlayBuffer(canvas, context, w, h, dpi) { + generateOverlayBuffer(context, w, h, xoffs, yoffs) { context.fillStyle = this.containedEntities.length > 0 ? THEME.map.chunkOverview.filled : THEME.map.chunkOverview.empty; - context.fillRect(0, 0, w, h); + context.fillRect(xoffs, yoffs, w, h); if (this.root.app.settings.getAllSettings().displayChunkBorders) { context.fillStyle = THEME.map.chunkBorders; - context.fillRect(0, 0, w, 1); - context.fillRect(0, 1, 1, h); + context.fillRect(xoffs, yoffs, w, 1); + context.fillRect(xoffs, yoffs + 1, 1, h); } for (let x = 0; x < globalConfig.mapChunkSize; ++x) { @@ -174,8 +143,8 @@ export class MapChunkView extends MapChunk { if (lowerContent) { context.fillStyle = lowerContent.getBackgroundColorAsResource(); context.fillRect( - x * CHUNK_OVERLAY_RES, - y * CHUNK_OVERLAY_RES, + xoffs + x * CHUNK_OVERLAY_RES, + yoffs + y * CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES ); @@ -190,8 +159,8 @@ export class MapChunkView extends MapChunk { const isFilled = overlayMatrix[dx + dy * 3]; if (isFilled) { context.fillRect( - x * CHUNK_OVERLAY_RES + dx, - y * CHUNK_OVERLAY_RES + dy, + xoffs + x * CHUNK_OVERLAY_RES + dx, + yoffs + y * CHUNK_OVERLAY_RES + dy, 1, 1 ); @@ -206,8 +175,8 @@ export class MapChunkView extends MapChunk { data.rotationVariant ); context.fillRect( - x * CHUNK_OVERLAY_RES, - y * CHUNK_OVERLAY_RES, + xoffs + x * CHUNK_OVERLAY_RES, + yoffs + y * CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES ); @@ -220,8 +189,8 @@ export class MapChunkView extends MapChunk { if (lowerContent) { context.fillStyle = lowerContent.getBackgroundColorAsResource(); context.fillRect( - x * CHUNK_OVERLAY_RES, - y * CHUNK_OVERLAY_RES, + xoffs + x * CHUNK_OVERLAY_RES, + yoffs + y * CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES ); @@ -233,7 +202,7 @@ export class MapChunkView extends MapChunk { // Draw wires overlay context.fillStyle = THEME.map.wires.overlayColor; - context.fillRect(0, 0, w, h); + context.fillRect(xoffs, yoffs, w, h); for (let x = 0; x < globalConfig.mapChunkSize; ++x) { const wiresArray = this.wireContents[x]; @@ -244,8 +213,8 @@ export class MapChunkView extends MapChunk { } MapChunkView.drawSingleWiresOverviewTile({ context, - x: x * CHUNK_OVERLAY_RES, - y: y * CHUNK_OVERLAY_RES, + x: xoffs + x * CHUNK_OVERLAY_RES, + y: yoffs + y * CHUNK_OVERLAY_RES, entity: content, tileSizePixels: CHUNK_OVERLAY_RES, }); diff --git a/src/js/game/map_view.js b/src/js/game/map_view.js index f4372a51..c6078ea9 100644 --- a/src/js/game/map_view.js +++ b/src/js/game/map_view.js @@ -5,6 +5,7 @@ import { freeCanvas, makeOffscreenBuffer } from "../core/buffer_utils"; import { Entity } from "./entity"; import { THEME } from "./theme"; import { MapChunkView } from "./map_chunk_view"; +import { MapChunkAggregate } from "./map_chunk_aggregate"; /** * This is the view of the map, it extends the map which is the raw model and allows @@ -164,6 +165,40 @@ export class MapView extends BaseMap { } } + /** + * Calls a given method on all given chunks + * @param {DrawParameters} parameters + * @param {function} method + */ + drawVisibleAggregates(parameters, method) { + const cullRange = parameters.visibleRect.allScaled(1 / globalConfig.tileSize); + const top = cullRange.top(); + const right = cullRange.right(); + const bottom = cullRange.bottom(); + const left = cullRange.left(); + + const border = 0; + const minY = top - border; + const maxY = bottom + border; + const minX = left - border; + const maxX = right + border; + + const aggregateTiles = globalConfig.chunkAggregateSize * globalConfig.mapChunkSize; + const aggStartX = Math.floor(minX / aggregateTiles); + const aggStartY = Math.floor(minY / aggregateTiles); + + const aggEndX = Math.floor(maxX / aggregateTiles); + const aggEndY = Math.floor(maxY / aggregateTiles); + + // Render y from top down for proper blending + for (let aggX = aggStartX; aggX <= aggEndX; ++aggX) { + for (let aggY = aggStartY; aggY <= aggEndY; ++aggY) { + const aggregate = this.root.map.getAggregate(aggX, aggY, true); + method.call(aggregate, parameters); + } + } + } + /** * Draws the wires foreground * @param {DrawParameters} parameters @@ -177,7 +212,7 @@ export class MapView extends BaseMap { * @param {DrawParameters} parameters */ drawOverlay(parameters) { - this.drawVisibleChunks(parameters, MapChunkView.prototype.drawOverlay); + this.drawVisibleAggregates(parameters, MapChunkAggregate.prototype.drawOverlay); } /**