Posted January 10, 2021 by World Eater Games
#Godot
Small updates as of January 12:
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!
From the get go, we saw that we needed a bullet hell system that supported the following:
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.
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):
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:
We're not gonna dwell too much on this example, but worry not, optimizations are coming...
This is what we want to achieve:
And the implementation process will be separated in two big sections:
Full disclosure: nerd talk incoming.
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:
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:
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).
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:
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:
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.
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:
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.
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 )
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.
With our new implementation, the monitor reports this values:
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.