HowTo: Drawing a metric ton of bullets in Godot


Small updates as of January 12:

  • References to Image  updated to the Texture type, as this is the type we are actually using in our examples.
  • Drawing logic can probably be simplified by using an AnimatedTexture instead of the Texture / change offset combination we are using. For now, I'll leave it as it is to reflect our original implementation, but be sure to check it out!.

Big thanks to Rémi Verschelde for pointing it out!

As most of you probably have experienced first handed, some of our beloved antagonist's favorite attacks involve him using his feathers:


lots


and LOTS


of feathers... (he kinda digs the bullet hell lifestyle).

While we cannot say how he throws that amount of feathers without going bald (probably ✨magic✨ or something), what we CAN tell you is how we implemented the underlying bullet hell system, and all the road bumps we faced along the way.

Sounds interesting? then join us!

OK but...what are we doing?

From the get go, we saw that we needed a bullet hell system that supported the following:

  • Spawning a lot of collision entities (areas with collision detection) without major performance penalties.
  • Each bullet must be drawn with an animation.
  • We must be able to control the direction, speed and max duration of each fired bullet.

Before we dive in

Before getting started, I would recommend you to take a quick glance over on how scenes and nodes work in Godot (if you are not familiar with the concept), and I'll also be using typed gdscript in the code examples.

I would also recommend you to try our game if you haven't, just to see what's possible with this system (but I mean, you already tried it right? 😉).

<ShamelessPlug>
    And leave us your feedback, every bit of information helps!!
</ShamelessPlug>

Sorry about that... lets continue.

The expensive approach

A simple approach for this problem can be just spawning a "Bullet" as a node with a sprite, animation player an a hit detection area, and just leave it like that. Sounds easy enough right? I mean yeah but...you'll see...

Let's implement a simple Bullet scene with the following sub-nodes:

And we'll be firing 20 of those every 0.1 seconds in a ring pattern on every pulse.

Here's a quick glance on what the performance monitor reports after just a few seconds of runtime (graph included to add to the dramatic effect):


yikes...

So yeah, 230ms for each physics step, and the game's runtime tree ends up looking like this:


Long story short: each individual "Bullet" node can have a big performance overhead in Godot (and this will probably apply in whatever entity other engines use).

Yeah that's gonna be a big no for me chief; we're making a game, not a PowerPoint presentation.

Note: your performance mileage may vary depending on your computer, but consider this before deciding to stick with this approach:

  • No one should need the latest gaming rig to run a simple 2D bullet hell game.
  • Remember that spawning bullet is the ONLY thing this project is doing, a real game will be doing much more in the background.

We're not gonna dwell too much on this example, but worry not, optimizations are coming...

Heck yeah, lets do this!

This is what we want to achieve:


This turned out WAY trippier than what I expected...

And the implementation process will be separated in two big sections:

  • Communication with the physics server, which will handle collision detection.
  • The drawing surface, which will handle animations

Full disclosure: nerd talk incoming.

Overview

Let's first address the root cause of the previous slowdown: the insane number of nodes in the tree. What I propose is that, instead of spawning a new node instance for every bullet, we treat the entire "Bullet Hell Manager" as a single entity with just one area, and that each bullet is represented by a single CollisionShape inside this area.

We would be creating these shapes directly in the physics server instead of using the provided node wrapper, which will remove most of the overhead associated with the former implementation and will allow us to centralize inside the shared area logic such as:

  • Collision layers/masks.
  • Assigning groups to said area.
  • Applying logic such as damage calculation, which will then affect all bullets.

Of course, to keep track of each bullet's properties, the spawner would have an internal array property with all existing bullets registered, but since they would be simple Data Structures rather than Nodes, their performance overhead is WAY lower.

The proposed new structure would look something like this:

Note how the tree will now only have the spawner node and its shared area, instead of the thousands of nodes we were previously using.

Sounds promising right?

This is what we are gonna need:

  • Define a Bullet data structure, which we won't go into full detail, but must contain the following properties:
    • The current movement Vector2, indicating the motion's direction (movement_vector)
    • The current position (also a Vector2) (current_position)
    • Time since the bullet was created (since they will be destroyed after a fixed amount of time (lifetime)
    • The current speed (speed)
    • A reference to which texture to display, as well as the time passed since the last texture switch (will be used later on when animating them) (image_offset and animation_lifetime).
  • Add a method to register a new bullet to the service, which will accept an initial position and a motion Vector2 (corresponding to the bullet's internal movement vector reference)
  • Register the texture(s) that will be used for the bullets in the manager
  • Modify the manager's _physics_process method, so that each bullet applies its internal movement to the related Shape in the Physics Server.
  • Modify the manger's _draw method, which will allow us to draw each bullet's texture without needing a Sprite node for each one.

Btw, both _physics_process and _draw are methods provided by the Godot ecosystem as callback points, the first being a part of the Node class and invoked on every frame at regular intervals; and the latter being a part of the CanvasItem class, and will be invoked each time we call the special update() method (forces a redraw, and it's also provided by the engine).

The Physics Server

Note: we are going to dive a bit deeper into the code from here on out, but if you just want to look at the general explanation (or you can't stand the lack of syntax highlight in the following snippets), the code is available on Github.

Moving on...

To register a bullet in our manager (and on the physics server), we will use the following method:

# Register a new bullet in the array
func spawn_bullet(i_movement: Vector2, speed: float) -> void:
  
  # Create the bullet instance
  var bullet : Bullet = Bullet.new()
  bullet.movement_vector = i_movement
  bullet.speed = speed
  bullet.current_position = origin.position
    
  # Configure its collision
  _configure_collision_for_bullet(bullet)
    
  # Register to the array
  bullets.append(bullet)

Which in turn will depend on the following _configure_collision_for_bullet implementation:

func _configure_collision_for_bullet(bullet: Bullet) -> void:
    
  # Step 1
  var used_transform := Transform2D(0, position)
  used_transform.origin = bullet.current_position
      
  # Step 2
  var _circle_shape = Physics2DServer.circle_shape_create()
  Physics2DServer.shape_set_data(_circle_shape, 8)
  # Add the shape to the shared area
  Physics2DServer.area_add_shape(
    shared_area.get_rid(), _circle_shape, used_transform
  )
    
  # Step 3
  bullet.shape_id = _circle_shape

Pay close attention to this method, as it's the first step in our glorious optimization journey.

This method will create a new collision shape (more specifically, a CircleShape) and will register it directly to the physics server with the following steps:

  • Step 1: first, create a new Transform2D with the same in the same position as the bullet's starting point.
  • Step 2: then, we proceed with the shape creation process
    • Call the Physics2DServer's circle_shape_create method, which will register a new shape and return its corresponding resource id.
    • Modify the shape's radius to 8, the size we expect our bullets to be.
    • Add the created shape to the shared area directly via the Physics2DServer's area_add_shape method (this is where we use the transform created in step 1).
  • Step 3: finally, save the shape's resource id in the bullet data structure.

Moving the bullets

Everything we have done so far will register the bullet and spawn the area in the spawner's defined origin position, but you might still be wondering: wait, how to I move them?

Here:

func _physics_process(delta: float) -> void:
    
    var used_transform = Transform2D()
    var bullets_queued_for_destruction = []
    
    for i in range(0, bullets.size()):
        
        # Calculate the new position
        var bullet = bullets[i] as Bullet   
        var offset : Vector2 = (
            bullet.movement_vector.normalized() * 
            bullet.speed * 
            delta
        )
        
        # Move the Bullet
        bullet.current_position += offset
        used_transform.origin = bullet.current_position
        Physics2DServer.area_set_shape_transform(
            shared_area.get_rid(), i, used_transform
        )
        
        # Add the delta to the bullet's lifetime
        bullet.lifetime += delta

As you can see here, each physics step will do the following:

  • Calculate the bullet's new position, based on its speed and its movement vector.
  • Apply said movement to the bullet's registered shape
  • Add the execution time to the bullet's lifetime.

While the creation/destruction process of shapes created by the physics server is done directly via the generated resource id, actually "moving" it inside the area requires us to use the offset number under which it was registered (think of it as the child offset, if we were using CollisionShape2D nodes).

That's why we pass "i" as the second parameter to the method, and why It is very important to ensure a consistent order between the registration offset and the bullet's offset inside the array, otherwise detection shapes can overlap.

Deleting the bullets

One important feature we haven't covered yet is: how the heck do we remove bullets from the game? To address this, we are going to define two "limits" which bullets must obey:

  • We will define a "boundary" box, and if the bullets exit this box they will be destroyed.
  • They will only be allowed to live for up to 10 seconds.

The important thing to consider here is that we need to delete both the bullet from the array, and the shape from the Physics Server:


Which can be done with the following calls:

Physics2DServer.free_rid(bullet.shape_id)
bullets.erase(bullet)

This logic must be called on any bullet that falls under one of the aforementioned conditions.

The drawing surface

That is cool and all, but I mean, we keep getting hit by invisible bullets... Is there a way for us to actually see them, even if they are not individual nodes?

Of course there is!

Just as with the physics side of things, we don't want to create a new sprite for every single bullet on the screen; Instead we are going to directly use the "draw" functionality available through the CanvasItem API.

Lets start with something really simple: just draw a texture on each bullet's position.

We are gonna need to add a new image property to our BulletHellSpawner (note: this is actually a treated as a Texture):

export (Image) var bullet_image

Overwrite the _draw method with the following logic:

func _draw() -> void:
    var offset = bullet_image.get_size() / 2.0
    for i in range(0, bullets.size()):
        var bullet = bullets[i]
        draw_texture(
            bullet_image,
            bullet.current_position - offset
        )

And finally, call update() inside _physics_process, which will trigger a redraw on every frame:

func _physics_process(delta: float) -> void:
    # ...Previous implementation
    update()    

And voila! we can now actually see each bullet.

But...they all look the same :(, so let's add some animations!

To animate them, we need to replace the single bullet_image variable to an array of textures, and we will need to define how often the animation will change:

# The image array that we will use
# Replace bullet_image with this
export (Array, Image) var frames
 
# They will change every 0.2 seconds.
export (float) var image_change_offset = 0.2

And, to manage this animation lifecycle, we need to add the following properties to the bullet itself:

var animation_lifetime : float = 0.0
var image_offset : int = 0

The image_offset property will tell us which frame we are going to render, and it will be calculated using the animation_lifetime property, which will be modified in each physics step. We are going to add the following line of code inside _physics_process, just below the line which modifies the bullet's lifetime:

bullet.animation_lifetime += delta

And finally, we are going to replace the draw method with this implementation, which will handle the relationship between the frames, the image offset and the animation lifetime:

func _draw() -> void:
    var offset = frames[0].get_size() / 2.0
    for i in range(0, bullets.size()):
        var bullet = bullets[i]
        if bullet.animation_lifetime >= image_change_offset:
            bullet.image_offset += 1
            bullet.animation_lifetime = 0.0
            if bullet.image_offset >= max_images:
                bullet.image_offset = 0
        draw_texture(
            frames[bullet.image_offset], 
            bullet.current_position - offset
        )

Bonus: static bullets!

Hey kids, remember how one of the bullet's parameters was "speed"? Well, if you set it to 0, the bullet will just stay in the same spot for its entire lifetime, and this makes it a suitable candidate for spawning numerous static obstacles (hint hint: the floor fires). This means we can use the same system to do things like this:

Aw yeah, hit me with that non-existent performance penalty baby.

Conclusions

With our new implementation, the monitor reports this values:




Nice.

Our optimizations, while they can most likely be improved, have already given us a significant performance boost.

We made this devlog a bit more catered towards an educational purpose, we will probably mix this kind of post with more general articles about Bittersweet Birthday itself, but we hope that you enjoyed the read nevertheless.

We didn't cover every single aspect of this system (particles, z index calculation and enter/exit animations), but I think this post is long enough as it is, we will probably cover those aspects in a future devlog as well. For now, we hope you found this interesting, and in case you want to see the full project and run it for yourself, here's a link to the source code again:

Github Repo

To end on a high note, here's an example on what we used this system for in our game:

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

So yeah, good luck! And have a great day.

Get Bittersweet Birthday

Comments

Log in with itch.io to leave a comment.

Thank you for sharing all this. For my game, I was making a bullet-hell plugin and was looking for ways fo optimize it. This devlog was very helpful in that regard. Thank you so much. Even with compatibility mode, integrated GPU and no multithreading, I’m getting around 240 FPS with 2.5k bullets now (i was getting less than 80 before this)

(1 edit) (+1)

Absolutely wonderful tutorial!  Thank you!  I'll certainly be checking out your game as well :)

(1 edit) (+1)(-2)

Very nice tutorial. Really opened my eyes. But there is a small issue. When creating many bullets in the same frame, it may lag. As far as I could investigate, the issue is that Godot has a limited pool of RIDs created simultaneously which freezes when you try to allocate a lot of RIDs. A quick google search showed that it's better to implement some kind of "caching" for your objects and not to destroy/recreate new objects but reuse existing ones.

Also, I didn't get the thing about the SharedArea and what it does. EDIT: you should connect body_entered signal from this area. This gets called when bullets hit something

And also the note about collisions to everyone else:
If you implement the approach of calculating if objects collided (bullet hit) by hand (by calculating if the distance is small enough to the target object every frame), the algorithm's complexity is very big O(Nenemies * Nbullets), because for each bullet you are calculating the distance to each enemy. One way to optimize this is to divide your area into "cells" and check if the bullet is in the same cell as the enemy. AND the engine (Physics2DServer) is doing it on its own if you have a collision shape. The parameter for cell size is configured in physics/2d/cell_size. So implementing custom collision detection, in my point of view, is really rewriting the same functionality in Gdscript. However, I've seen a guy reporting that he rewrote the Gdscript algorithm into a C++ module (GDNative or something, I don't know) and got a massive performance boost.

So for now, looks like Godot doesn't have an "Ultimate Bullet Hell Approach". Looking forward to see some real addon for spawning and management bullet hell, optimized for the best performance

(+1)

Basically this GDNative plugin

(+1)

Would you be willing to add the code for the slower way (using nodes) to the Github repo?  I tried to duplicate the slow way and could not replicate a performance issue.  I'm probably missing something.  (See my fork of the repo at https://github.com/jhlothamer/bullet_spawner_test)  I do see a massive difference in the number of objects being created and maintained but, at least on my system, both methods hum along at 144 fps.  I would love to figure out what makes our node implementations so different.  Thanks!

Hey! gonna try to look for it, but I'm not sure if I saved that one...

However, Godot's optimization and good enough hardware can definitely run the "slow" version at very high fps, this implementation is more impactful on lower-end hardware.

There's also a possibility that the engine itself made several optimizations under the hood, but I'm not 100% sure about that. Still, for simple bullet hell games a moving node2d may be enough, but when there are a lot of things happening at once on the scene, the lower memory footprint each element has the better~

(+1)

This was a massively informative devlog! I learned a lot and will probably use some of that knowledge someday. Thank you!

(+1)

Great write-up. I implemented your approach in my game and can see some performance gains too :)
Also, I can confirm the AnimatedTexture works great for animated bullets (although you do need to split spritesheets up into single images to implement it.)

Thanks for sharing this!

(+1)

Great read, thanks for the post! I’m working on a bullet hell game as well so I think this will be valuable. Although I haven’t run into performance issues with the naive approach yet, so perhaps my game is not hellish enough! ;)

(+1)

Pretty neat. I'm wondering why this improves performance so much. I think the idea is that you restrict the problem so that bullets don't collide with each other so you don't need to check the interaction between pairs of bullets which makes the complexity of collisions O(n) instead of O(n^2) where n is the number of bullets. The main drawback of course is that bullets can't collide with each other.

I wonder if it would be possible to use physics layers to implement that like if I set the bullet  at layer 1 but not at mask 1, then bullets wouldn't check collisions with each other.

Thanks for the devlog! It might help me with my project in the future!

(1 edit) (+2)

Very interesting.  I built a similar system for a personal project once, although I used a custom distance check for collision.  Your method obviously has the benefit of being able to interact with other physics objects.   I'll have to return to the  concept sometime soon.

Nice

(+1)

Very good explanations also it looks really cool keep it up!

(+2)

This is fascinating, even to a person like me who doesn't code things like that and will probably never do.

It's a bit complicated, but your explanations are still clear enough for me to understand what you mean. It was really interesting!