November 10, 2025 By WBG Perf Team
rendering performance caching

Taming BuildingRenderer’s Per-Frame Churn

We memoized the building render list and swapped per-item scans for a destruction lookup set, turning O(n log n + n·m) churn into simple cached draws.

Taming BuildingRenderer’s Per-Frame Churn

Taming BuildingRenderer’s Per-Frame Churn

Buildings are some of the most common sprites in our towns, which means even tiny inefficiencies in their renderer compound quickly. Until now BuildingRenderer.renderBuildings rebuilt its working set from scratch every frame: filter doodad-style placements, clone the survivors, sort them for y-order, and for each one scan the active destruction animations to see if it should be skipped. That’s O(n log n + n·m) worth of allocations and comparisons happening sixty times a second even when nothing changed on the map—a whole lot of pure churn.

What the Old Loop Did

const nonDoodads = buildingPlacements.filter(/* … */);
const sorted = [...nonDoodads].sort(compare);
for (const placement of sorted) {
  if (destructionAnimations.some(anim => anim.getBuildingPlacement() === placement)) {
    continue;
  }
  drawBuilding(placement);
}
  • filter + spread clone allocated new arrays every tick.
  • sort performed ~n log n comparisons per frame just to re-derive the same ordering, more pure churn.
  • destructionAnimations.some ran O(m) checks for each placement, so the loop cost scaled with n·m.
  • Total work was dominated by busy-town scenes: 400 placements × 6 explosions meant thousands of redundant equality tests before any pixels were drawn.

The New Approach: Cache Everything That’s Stable

  1. Digest-aware placement cache — We compute a lightweight hash of the placement array (ids + pose + tile metadata). If the digest matches the last render we reuse a pre-filtered, pre-sorted list of { placement, tile } pairs. No new allocations, no re-sorting, no extra getTileFromBuilding calls.
  2. Destruction lookup set — Instead of some, we build a Set<BuildingPlacement> from the destruction animations once per frame. Rendering then does a constant-time .has(placement) to decide whether to skip drawing.
const sortedEntries = getRenderablePlacementEntries(buildingPlacements);
const destructionPlacements = buildDestructionSet(destructionAnimations);
for (const { placement, tile } of sortedEntries) {
  if (destructionPlacements.has(placement)) continue;
  drawBuilding(tile, placement);
}

Results

ScenarioBeforeAfter
400 placements, no changesFilter + clone + sort + 400×some every frameReuse cached entries (0 allocations) + one Set.has per placement
400 placements, 6 destruction anims~3,600 sort comparisons + 2,400 equality checks~0 comparisons + 400 Set.has + 6 cache inserts
ComplexityO(n log n + n·m) steady-stateO(n + m) when drawing, cache rebuild only when placements mutate

In practice the renderer now spends its time streaming pixels instead of thrashing arrays, eliminating frame-time spikes when the player pans across untouched towns. Garbage collector pressure also dropped because the cached list is reused for the entire session until placements actually change.

Takeaways

  • If a render input rarely changes, cache its derived data and invalidate via a simple digest instead of re-deriving every frame.
  • Replace per-item Array.prototype.some/find calls with pre-built Set/Map structures whenever the queried collection is small and shared.
  • Profilers love these “boring” wins: no visual changes, just a lot less work between each draw call.
WBG Logo

Written by WBG Perf Team

Part of the passionate team at Wrinkled Brain Games, creating innovative gaming experiences.