Home Creators Posts Import Register Favorites Logout

Videos

  • 647542306.mp4
  • normal.mp4
  • fast.mp4
  • moving_colliders.mp4

Downloads

Content

Recently, I started making a small mining 2D game prototype, just to remember how it was like to use Godot for 2D for once.

Nothing too complicated, just a simple miner style game where you mine asteroids for resources and upgrade your ship.

But while doing that, I've come to learn a few important lessons about tilemaps and game performance, so I thought I'd write a bit about that.

The game

Instead of instancing single tile objects with nodes, I decided to try and use Godot's tilemap itself to manage the minable tiles. This node already has a lot of performance tricks inside that should optimize both their memory and rendering needs.

The TileMapLayer is not really meant for this, but they should be flexible enough to handle it with a bit of code.

This way, I could learn how the TileMapLayer work and see if that is even possible.

So I made a quick working prototype where I can mine tiles and get resources, but it was not without its challenges.

To start, I added a physics layer to the tilemap so the ship would collide with tiles.

And added a custom tile data to keep track of how many HP each tile have. Simple enough.

And to detected if the tiles are within range, I added an Area2D for the Player and tried to use the body_shape_entered signal to handle it.

Since using the body_entered just always returns the TileMapLayer node, it is not useful there.

But because tilemap layers optimize collision shapes by merging them with nearby tiles, it is not as direct as it seems to detect which tile has entered the area2D. And in truth, it is not really meant for that.

A quick an solution to this is to make Godot not optimize for collision shapes. By changing the TileMapLayer's physics_quadrant_size to 1.

This forces Godot to use a single shape for each tile. So when the body_shape_entered signal is triggered I can get the position of the tile that was hit. And from there, figure out the maps x and y position to get the tile.

As you might have noticed, this is horrible for performance, and I could probably do some simple math to get which tilemap positions are inside of a circle and query those tiles individually.

But instead I used this hacky solution, as I thought it wouldn't matter for a game on this scale.

Getting the tiles

You can get information about the tiles by getting the TileData within a tilemap.

This requires you to convert the values from global to local space, and then to the map space (the x, y positions).

But in my case, since I used the body_shape_entered, I use that to get the position of the tile.

Converting from shape to position is a bit more complicated than usual, as you only get an RID of the body and the shape index.

But a tilemap already has a method that skips you having to interact with the RenderingServer and just gets its local coords for the body rid it is given. And since the physics quadrant is 1, it'll get the precise tile that it is at.

Though I eventually ditched the signal approach and just picked it on the process function when the attack is ready.

And then I can finally get the tile_data with tilemap.get_cell_tile_data.

The biggest problem that I had not realized until now is that TileData uses a shared resource for all tiles of that type, it is not an instanced object per say.

So changing the custom data HP here would change the HP for all tiles of that type, not only the one at that position.

And if you tried to change the color, all tiles of that type would be changed:tiledata.modulate = Color(Color.BLACK, 0.5)

But an easy solution for the HP, is to just use a dictionary to store that data instead.

var tile_health: Dictionary = {}

And with a bit of extra code, I can now damage individual tiles

Individual Tile instancing (Runtime TileData)

While digging around, I also found out you can have TileData instanced for each individual tile, which is called tile_data_runtime_update.

I didn't end up using it, but because it is something I never even knew existed, let's see how it works.

Here is a code:

Basically, you can override _use_tile_data_runtime_update that receives a coordinate and returns whether that tile should execute a custom runtime process. Making it unique.

This only runs when the tilemap updates. So if you want the changes to propagate you need to call notify_runtime_tile_data_update() to force godot to reprocess all tiles.

Then you use the '_tile_data_runtime_update' to modify your TileData.Which will be unique for each tile inside here.

Although this works, having to do all that extra processing didn't feel very performant, so I ended up skipping this approach.

The performance at scale

But anyways, now I had everything working.

A simple upgrade system, resource collection, and tile destruction. All the basics components of a simple game.

But just for fun, I tried to create a 1000 x 1000 sized map with code.

And let's just say... it didn't work.

Several seconds of loading, a very high RAM usage and than everything just starts not responding.

And trying it again at 500 x 500, I can kinda see why.

The tilemap alone is using almost 1 Gb of RAM. Godot by itself uses 94 Mb, so it is using 800 Mb to create those 500 x 500 = 250k tiles and physic objects (with physics quadrant at 1, each tile needs its own shape)

If I revert the physic shape optimization quadrant back to the default of 16. Just that reduces the true memory usage from 800 to 100 mb.

What that means is that it is completely not scalable having that many physic objects in memory.

And it is not even the RAM that is the problem. For the 1000 x 1000 test, the RAM memory stop increasing at around 2 GB, but Godot stopped responding and crashed. I guess even Godot can't handle a million of physics shapes at once. That's the real problem.

Solution & Optimization

Now, a quick way to fix this, is to just not use the tilemap physic layer and manually calculate the collisions by querying the tilemap layer when needed.

But the con of that, is that now I will have to take care of collisions myself.

Or do a hybrid, use the physics layer with a high quadrant size to handle collisions. But use a manual map query with some math to get the tiles in range. Which should work too with much better performances.

But instead, I decided to completely ditch the tilemap and make my own renderer instead.

So I created a ChunkData class to store the tile indexes

And made a chunk renderer, that uses this resource to draw the tiles to the screen.

This is very performant because it only draws the tiles visible in the screen. And only redraws when a queue_redraw() is called, when it notices there are new tiles to render.

Now the scene loads instantly with no delays.

But the main issue before was the colliders, that just now was just an extra.

So to still keep using colliders while keeping a low memory usage and not break Godot

I made the script create just the colliders that are on the screen.

And dynamically update them only when more tiles appear. Pooling the colliders from the previous ones that are outside of the screen now and just updating their position and reference tiles.

And just like that, now it loads a 1000 x 1000 map instantly and using just a bit over 26 MB of RAM.

It was a bit annoying to figure out what colliders moved off-screen and can be moved into new positions. But it should have a bit better performance than keeping instancing and destroying the objects all the time.

And for the data, I'm using the set_meta to have the CollisionBody itself hold the index of the tile they are referencing.

And now, that 1000 x 1000 tile map loads instantly and with barely 30 MB of memory usage

Here I removed the player's collision. The red lines are a mock of what the camera bounds would be.

And in this example, I set the padding tiles to zero so that is why the right and bottom tiles can be seem empty near borders. But on the game, I would add a couple of extra tiles offscreen just to be sure.

Conclusion

In the end, I chose to keep using CollisionBody nodes just to make my life easier.

This way, I can let Godot take care of physics and collision reactions while keeping a very optimized tree.

And for now, everything seems to be quite all right. With no more crashes of high ram usages.

But that's it, just a quick note on how important it is to always check you game's performance at scale.

Those things can be quite easy to miss if you are only testing on small test scenes. And later it will only be harder to change your systems.

So test early and adapt where your game needs.

I hope you liked this quick post, and until next time.

Files

Comments

No comments found for this post.