Collision Pairs: optimizing performance of bullet hell / enemy hell games

Godot Version

4.2

Question

Spawning many bullets/enemies leads to performance slow-down.

Applicable to any godot version.

So you’ve properly compressed your sprites, implemented object pooling, optimized AI/logic code, triple-checked that collision masks are correct, and yet you’re still running into performance problems? I know the feels. The likely cause that I haven’t seen most discussions focus on is collision pairs.

First, check if Collision Pairs are the problem by starting profiling and going to Monitor tab. Look at Physics 2D/3D → Collision pairs. Does this number spike to 10k+ when performance drops? Depending on hardware, the problematic number can be 25k or higher, but in general if you see it go above 5k, it likely means that your code can be optimized (unless you disagree that doing 5,000 checks for collision every frame is a lot :stuck_out_tongue:).

Even with proper collision masks, you can have valid scenarios when too many collision shapes (can be as little as 100) are close enough to each other and will tank your game’s performance. For example, if enemies are setup to collide with each other and many end up next to each other b/c they’re all converging on a single point (such as the player).

What happens is that for every collision shape the engine creates a collision pair object for every other nearby collision shape it may collide with (i.e., that it is close enough to). So for 100 enemies next to each other, this means 100x100 or 10,000 collision pairs, i.e. 10,000 collision checks every frame. Collision shapes being “close enough” is based on physics grid size setting. Reducing that size can help, but it can also lead to side effects, so do play with it but here I’ll focus on other solutions. All solutions are based on the same concept: prevent collision pairs from being created, i.e. turn off collision detection whenever possible.

Needless to say, setting up collision masks correctly is the single most important thing you can do. I see many tutorials check in code for things like “area.is_in_group()” to see if they collided with the right area, and that’s okay IF your area can legitimately collide with more than 1 type of other area. But don’t use it as a crutch. It doesn’t matter if you’re ignoring the collision; what matters is if the engine has to check for the collision. If your enemies ignore walls and should only be colliding with player projectiles, then make sure their appropriate collision shape for that (usually “hurt_box”) is setup to ONLY collide with player projectiles, and you can then remove the check in code as to whether it collided with proper other area.

I can’t stress above paragraph enough. Tutorials (for simplicity) often have just 1 mask for enemies called “Enemy”. But this is not enough. Let’s say you have an enemy with 1. Collision Shape (to detect collisions with other enemies), 2. Hurt Box, and 3. Hit/Attack Box. You may setup Collision Shape to be on layer 2 and it’s mask to also be for 2 (Enemies), and then Hurt Box to be on layer 2 (coz it’s an Enemy), and set its mask to 4 (Player Projectiles). You may think, Hurt Box’s mask is set to only collide with player projectiles, so I’m in the clear! But you’re not! b/c Collision Shape is set to collide with 2 (Enemies) and Hurt Box lives on 2, this means every time 2 enemies are next to each other, 4 Collision Pairs are created instead of 2 b/c the collision shape CAN collide with Hurt Box. So in your Project Settings → Layer Names → 2D Physics setup, you actually want a layer for each type of collision shape: EnemyCollisionShape, EnemyHurtBox, and EnemyAttackBox. This way you can configure things precisely and thus reduce # of Collision Pairs by a factor of 2, 4, or even more.

Once you have your masks setup properly, here’s how you can optimize further:

  1. If the problem occurs once enemies are close enough to the player, then simply turn off collision detection at that point. If you have 50 enemies close to the player, does it really matter that they keep trying to push each other away, or can you just allow them to completely overlap at this point because the difference is hard to see? A simple collision_shape.disabled = distance_to_player < YOUR_NUMBER can have a dramatic effect on performance (10x in my game).

  2. Do off-screen collisions matter? If you’re not deleting off-screen enemies for some reason (the ones that are far enough away), you can at least disable their collision shapes until they’re closer to the player.

  3. Have an enemy cap. 300. 500. Maybe make it dynamic based on total count at the time FPS begins to drop, let the game remember that number, that way it’s specific to each player’s hardware. Spawn stronger enemies, not more enemies.

  4. If you’re reaching your max enemy count/cap, you CAN spawn a new enemy IF there’s an enemy far enough away that you can queue_free/delete. I.e., prioritize new enemies over old enemies off-screen. Of course, always prioritize deletion based on distance away from player.

In my experience, the above will have FAR more impact on your performance than all other optimizations such as reducing code within _physics_process, changing from CharacterBody2D to Area2D, etc. Those optimizations can still be important, but unless your AI/movement calculation code is as intense as doing 10-20 thousand collision checks per frame, you’re in the clear.

Hope this helps someone! Cheers.

8 Likes