The Botanist is all about huge maps and lots of environment — especially giant, sprawling forests with tons of trees. Unfortunately, stage loading performance has been terrible for my larger maps, so I decided to dive in and figure out the issue. As a reward, I received a 1,500x decrease in my stage loading time with PhaserJS. I’m excited about this, so I’d like to share the solution with you all.
I’ve written before about how performance is important for The Botanist, and I’ve made some performance-enhancing tweaks in the past. Recently, however, I’ve started to develop the first playable section of the game. You can’t walk through trees, and there are lots of other things you can’t walk through, so there are tons of collisions defined in two large collision layers that can make any piece of game art collidable. I use
setCollisionBetween and set the range for all of my tiles on my two collision layers.
In developing larger and larger maps, however, I’ve noticed that the stage loading time has blown up. It can take up to 15 seconds for a map to load! Obviously this is unacceptable, so we revisit our friend the profiler:
As you can see, a full 10 seconds were spent in
setCollisionByIndex (the top entry). Clearly some investigation is warranted. Here’s what (part of) that method looks like in the PhaserJS source:
setCollisionByIndex method is looping through every tile in the map, checking to see if it’s the tile we want to collide with, and doing some small amount of work.
That’s the best approach to take when setting collision for one tile at a time; there is no hash that maps tile indexes to their locations, so the code does have to look at every tile on the map.
This causes a huge performance issue when using
setCollisionBetween, however. Let’s take a look at that method:
The problem is now apparent. The library is looping through every tile for each index in the range. This might be ok if you only have 2 or 3 tile indices you want to collide with. But what if any tile is collidable, like in The Botanist? We’ve got 4,300 different tiles! (Some would say “you’re doing it wrong!” by making every tile collidable, but still.) On a 400x300 map (small for The Botanist), we’ve got 400x300x4300 = 516 million comparisons to make. No wonder it takes a full ten seconds!
It’s completely understandable why
setCollisionBetween was written this way.
setCollisionByIndex does a lot of work that the library doesn’t want to duplicate. In order to optimize the
setCollisionBetween method you’d have to duplicate the
setCollisionByIndex logic with a slight adjustment, and maintain two nearly identical copies of that logic. And that introduces technical debt and duplication in the library, for a feature (giant maps with lots of collisions) that not every user needs.
(Actually, it is possible to fix this issue, preserve functionality, and preserve the public API by inverting the control between
setCollisionBetween. If you were to take my solution below and use that as
setCollisionBetween, and change
setCollisionByIndex to call
stop=index, we’d fix everything all at once without any duplicate code or function signature changes. Richard, if you’re reading this and want a pull request, let me know.)
But, since I need this to be fast, I’ll try extending Phaser with the following method. I just added this to the top of my game source files, and my game code needed no other modifications since this overrides Phaser’s version transparently.
This new version of
setCollisionBetween is basically a copy/paste of
setCollisionByIndex with two modifications: first, the addition of
for (var index = start; index <= stop; index++) as the fifth line. We loop through all the indices in the range once and mark them as collision indexes (or remove them, as appropriate). The original
setCollisionByIndex does this one at a time, because it’s only called for one index at a time.
Second, I’ve edited the conditional inside the inner loop. It used to be
if (tile && tile.index === index), and now it’s
if (tile && tile.index >= start && tile.index <= stop).
Rather than looping through every index and then every tile on the map for each index, what we’re doing is looping through every tile on the map once and checking to see if its index falls within our range of indexes. We’ve reduced the number of comparisons from 516 million to 120,000 by eliminating the loop over our 4,300 tiles. This is a performance improvement of 4,300 comparisons!
Stated another way, performance used to be related to “map size times number of collidable tiles”, but now it’s “map size plus number of collidable tiles”.
Testing it in the real world, our stage setup time went from ~10-15 seconds to nothing. Seriously. There’s no longer a perceivable delay even with huge maps, and loading a stage no longer wrecks the browser.
I confirmed this with the profiler. What used to take 10,000 ms now takes 6.7ms (look for
setCollisionByRange halfway down; I renamed the method while testing). That’s a 1,500x time improvement.
Doing this, of course, adds some technical debt. Whenever Phaser’s
setCollisionByIndex is updated, I’ll have to update my method as well. But I think you’ll agree it was worth it. So if you’re like me and you use full collision layers that can take any tile in your set and make it blocking, consider overriding Phaser’s
setCollisionBetween to the above.