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.sortperformed ~n log ncomparisons per frame just to re-derive the same ordering, more pure churn.destructionAnimations.someranO(m)checks for each placement, so the loop cost scaled withn·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
- 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 extragetTileFromBuildingcalls. - Destruction lookup set — Instead of
some, we build aSet<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
| Scenario | Before | After |
|---|---|---|
| 400 placements, no changes | Filter + clone + sort + 400×some every frame | Reuse 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 |
| Complexity | O(n log n + n·m) steady-state | O(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/findcalls with pre-builtSet/Mapstructures 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.