From 9789468c2daa450e25e7605fbd70149e3ecb3cb6 Mon Sep 17 00:00:00 2001 From: tobspr Date: Wed, 24 Jun 2020 20:25:43 +0200 Subject: [PATCH] Improve performance by caching area of changed ejectors --- src/js/core/rectangle.js | 129 ++++++++++++++++++++++++++-- src/js/core/sprites.js | 2 +- src/js/game/systems/item_ejector.js | 74 ++++++++-------- 3 files changed, 162 insertions(+), 43 deletions(-) diff --git a/src/js/core/rectangle.js b/src/js/core/rectangle.js index bf19a4ab..79136b9f 100644 --- a/src/js/core/rectangle.js +++ b/src/js/core/rectangle.js @@ -46,6 +46,7 @@ export class Rectangle { } /** + * Returns if a intersects b * @param {Rectangle} a * @param {Rectangle} b */ @@ -74,7 +75,21 @@ export class Rectangle { return new Rectangle(minX, minY, maxX - minX, maxY - minY); } - // Ensures the rectangle contains the given square + /** + * Copies this instance + * @returns {Rectangle} + */ + clone() { + return new Rectangle(this.x, this.y, this.w, this.h); + } + + /** + * Ensures the rectangle contains the given square + * @param {number} centerX + * @param {number} centerY + * @param {number} halfWidth + * @param {number} halfHeight + */ extendBySquare(centerX, centerY, halfWidth, halfHeight) { if (this.isEmpty()) { // Just assign values since this rectangle is empty @@ -90,10 +105,19 @@ export class Rectangle { } } + /** + * Returns if this rectangle is empty + * @returns {boolean} + */ isEmpty() { return epsilonCompare(this.w * this.h, 0); } + /** + * Returns if this rectangle is equal to the other while taking an epsilon into account + * @param {Rectangle} other + * @param {number} epsilon + */ equalsEpsilon(other, epsilon) { return ( epsilonCompare(this.x, other.x, epsilon) && @@ -103,71 +127,126 @@ export class Rectangle { ); } + /** + * @returns {number} + */ left() { return this.x; } + /** + * @returns {number} + */ right() { return this.x + this.w; } + /** + * @returns {number} + */ top() { return this.y; } + /** + * @returns {number} + */ bottom() { return this.y + this.h; } + /** + * Returns Top, Right, Bottom, Left + * @returns {[number, number, number, number]} + */ trbl() { return [this.y, this.right(), this.bottom(), this.x]; } + /** + * Returns the center of the rect + * @returns {Vector} + */ getCenter() { return new Vector(this.x + this.w / 2, this.y + this.h / 2); } + /** + * Sets the right side of the rect without moving it + * @param {number} right + */ setRight(right) { this.w = right - this.x; } + /** + * Sets the bottom side of the rect without moving it + * @param {number} bottom + */ setBottom(bottom) { this.h = bottom - this.y; } - // Sets top while keeping bottom + /** + * Sets the top side of the rect without scaling it + * @param {number} top + */ setTop(top) { const bottom = this.bottom(); this.y = top; this.setBottom(bottom); } - // Sets left while keeping right + /** + * Sets the left side of the rect without scaling it + * @param {number} left + */ setLeft(left) { const right = this.right(); this.x = left; this.setRight(right); } + /** + * Returns the top left point + * @returns {Vector} + */ topLeft() { return new Vector(this.x, this.y); } + /** + * Returns the bottom left point + * @returns {Vector} + */ bottomRight() { return new Vector(this.right(), this.bottom()); } + /** + * Moves the rectangle by the given parameters + * @param {number} x + * @param {number} y + */ moveBy(x, y) { this.x += x; this.y += y; } + /** + * Moves the rectangle by the given vector + * @param {Vector} vec + */ moveByVector(vec) { this.x += vec.x; this.y += vec.y; } - // Returns a scaled version which also scales the position of the rectangle + /** + * Scales every parameter (w, h, x, y) by the given factor. Useful to transform from world to + * tile space and vice versa + * @param {number} factor + */ allScaled(factor) { return new Rectangle(this.x * factor, this.y * factor, this.w * factor, this.h * factor); } @@ -177,12 +256,14 @@ export class Rectangle { * @param {number} amount * @returns {Rectangle} new rectangle */ - expandedInAllDirections(amount) { return new Rectangle(this.x - amount, this.y - amount, this.w + 2 * amount, this.h + 2 * amount); } - // Culling helpers + /** + * Helper for computing a culling area. Returns the top left tile + * @returns {Vector} + */ getMinStartTile() { return new Vector(this.x, this.y).snapWorldToTile(); } @@ -201,6 +282,14 @@ export class Rectangle { ); } + /** + * Returns if this rectangle contains the other rectangle specified by the parameters + * @param {number} x + * @param {number} y + * @param {number} w + * @param {number} h + * @returns {boolean} + */ containsRect4Params(x, y, w, h) { return this.x <= x + w && x <= this.right() && this.y <= y + h && y <= this.bottom(); } @@ -210,6 +299,7 @@ export class Rectangle { * @param {number} x * @param {number} y * @param {number} radius + * @returns {boolean} */ containsCircle(x, y, radius) { return ( @@ -224,6 +314,7 @@ export class Rectangle { * Returns if hte rectangle contains the given point * @param {number} x * @param {number} y + * @returns {boolean} */ containsPoint(x, y) { return x >= this.x && x < this.right() && y >= this.y && y < this.bottom(); @@ -234,7 +325,7 @@ export class Rectangle { * @param {Rectangle} rect * @returns {Rectangle|null} */ - getUnion(rect) { + getIntersection(rect) { const left = Math_max(this.x, rect.x); const top = Math_max(this.y, rect.y); @@ -244,6 +335,30 @@ export class Rectangle { if (right <= left || bottom <= top) { return null; } + + return Rectangle.fromTRBL(top, right, bottom, left); + } + + /** + * Returns the union of this rectangle with another + * @param {Rectangle} rect + */ + getUnion(rect) { + if (this.isEmpty()) { + // If this is rect is empty, return the other one + return rect.clone(); + } + if (rect.isEmpty()) { + // If the other is empty, return this one + return this.clone(); + } + + // Find contained area + const left = Math_min(this.x, rect.x); + const top = Math_min(this.y, rect.y); + const right = Math_max(this.right(), rect.right()); + const bottom = Math_max(this.bottom(), rect.bottom()); + return Rectangle.fromTRBL(top, right, bottom, left); } diff --git a/src/js/core/sprites.js b/src/js/core/sprites.js index 8f9fb4d7..4251bee1 100644 --- a/src/js/core/sprites.js +++ b/src/js/core/sprites.js @@ -170,7 +170,7 @@ export class AtlasSprite extends BaseSprite { if (clipping) { const rect = new Rectangle(destX, destY, destW, destH); - intersection = rect.getUnion(visibleRect); + intersection = rect.getIntersection(visibleRect); if (!intersection) { return; } diff --git a/src/js/game/systems/item_ejector.js b/src/js/game/systems/item_ejector.js index f5837ac1..444cb8c4 100644 --- a/src/js/game/systems/item_ejector.js +++ b/src/js/game/systems/item_ejector.js @@ -1,13 +1,13 @@ +import { Math_min } from "../../core/builtins"; import { globalConfig } from "../../core/config"; import { DrawParameters } from "../../core/draw_parameters"; +import { createLogger } from "../../core/logging"; +import { Rectangle } from "../../core/rectangle"; import { enumDirectionToVector, Vector } from "../../core/vector"; import { BaseItem } from "../base_item"; import { ItemEjectorComponent } from "../components/item_ejector"; import { Entity } from "../entity"; import { GameSystemWithFilter } from "../game_system_with_filter"; -import { Math_min } from "../../core/builtins"; -import { createLogger } from "../../core/logging"; -import { Rectangle } from "../../core/rectangle"; const logger = createLogger("systems/ejector"); @@ -15,71 +15,81 @@ export class ItemEjectorSystem extends GameSystemWithFilter { constructor(root) { super(root, [ItemEjectorComponent]); - this.cacheNeedsUpdate = true; - - this.root.signals.entityAdded.add(this.invalidateCache, this); - this.root.signals.entityDestroyed.add(this.invalidateCache, this); + this.root.signals.entityAdded.add(this.checkForCacheInvalidation, this); + this.root.signals.entityDestroyed.add(this.checkForCacheInvalidation, this); + this.root.signals.postLoadHook.add(this.recomputeCache, this); /** - * @type {Rectangle[]} + * @type {Rectangle} */ - this.smallCacheAreas = []; + this.areaToRecompute = null; } /** * * @param {Entity} entity */ - invalidateCache(entity) { + checkForCacheInvalidation(entity) { + if (!this.root.gameInitialized) { + return; + } if (!entity.components.StaticMapEntity) { return; } - this.cacheNeedsUpdate = true; - // Optimize for the common case: adding or removing one building at a time. Clicking // and dragging can cause up to 4 add/remove signals. const staticComp = entity.components.StaticMapEntity; const bounds = staticComp.getTileSpaceBounds(); const expandedBounds = bounds.expandedInAllDirections(2); - this.smallCacheAreas.push(expandedBounds); + + if (this.areaToRecompute) { + this.areaToRecompute = this.areaToRecompute.getUnion(expandedBounds); + } else { + this.areaToRecompute = expandedBounds; + } } /** * Precomputes the cache, which makes up for a huge performance improvement */ recomputeCache() { - logger.log("Recomputing cache"); - - let entryCount = 0; - if (this.smallCacheAreas.length <= 4) { - // Only recompute caches of entities inside the rectangles. - for (let i = 0; i < this.smallCacheAreas.length; i++) { - entryCount += this.recomputeAreaCaches(this.smallCacheAreas[i]); - } + if (this.areaToRecompute) { + logger.log("Recomputing cache using rectangle"); + this.recomputeAreaCache(this.areaToRecompute); + this.areaToRecompute = null; } else { + logger.log("Full cache recompute"); // Try to find acceptors for every ejector for (let i = 0; i < this.allEntities.length; ++i) { const entity = this.allEntities[i]; - entryCount += this.recomputeSingleEntityCache(entity); + this.recomputeSingleEntityCache(entity); } } - logger.log("Found", entryCount, "entries to update"); - - this.smallCacheAreas = []; } /** * * @param {Rectangle} area */ - recomputeAreaCaches(area) { + recomputeAreaCache(area) { let entryCount = 0; + + logger.log("Recomputing area:", area.x, area.y, "/", area.w, area.h); + + // Store the entities we already recomputed, so we don't do work twice + const recomputedEntities = new Set(); + for (let x = area.x; x < area.right(); ++x) { for (let y = area.y; y < area.bottom(); ++y) { const entity = this.root.map.getTileContentXY(x, y); - if (entity && entity.components.ItemEjector) { - entryCount += this.recomputeSingleEntityCache(entity); + if (entity) { + // Recompute the entity in case its relevant for this system and it + // hasn't already been computed + if (!recomputedEntities.has(entity.uid) && entity.components.ItemEjector) { + recomputedEntities.add(entity.uid); + this.recomputeSingleEntityCache(entity); + } } } } @@ -87,7 +97,6 @@ export class ItemEjectorSystem extends GameSystemWithFilter { } /** - * * @param {Entity} entity */ recomputeSingleEntityCache(entity) { @@ -97,8 +106,6 @@ export class ItemEjectorSystem extends GameSystemWithFilter { // Clear the old cache. ejectorComp.cachedConnectedSlots = null; - // For every ejector slot, try to find an acceptor - let entryCount = 0; for (let ejectorSlotIndex = 0; ejectorSlotIndex < ejectorComp.slots.length; ++ejectorSlotIndex) { const ejectorSlot = ejectorComp.slots[ejectorSlotIndex]; @@ -144,14 +151,11 @@ export class ItemEjectorSystem extends GameSystemWithFilter { } ejectorSlot.cachedTargetEntity = targetEntity; ejectorSlot.cachedDestSlot = matchingSlot; - entryCount += 1; } - return entryCount; } update() { - if (this.cacheNeedsUpdate) { - this.cacheNeedsUpdate = false; + if (this.areaToRecompute) { this.recomputeCache(); }