So I’m making a bullet hell game, and I managed to successfully implement an object pooling system - all bullets are instantiated at the beginning of the game, and they get activated when an enemy fires them, and deactivated when they despawn. I chose to instantiate 500 bullets of each bullet type, and so far I have 8 bullet types.
Problem is, instantiating all these bullets everytime I start my game takes upwards of 20 seconds, and I would really prefer players didn’t have to sit through all that just to play my game. I tried running the bullet instantiating function in a separate thread, but that was even worse because it would just crash my game if an enemy fired a bullet before they were done instantiating.
The profiler doesn’t help either, as everytime I run my game and click “start” on the profiler, nothing shows up until the bullets are already done instantiating. I’ve looked everywhere I could and attempted almost every method I found to optimise things, but I’m at a loss on what to do here.
This is my code to instantiate the bullets, as well as the scene tree of one bullet type:
func instantiate_bullets():
for i in 500 :
for type in BulletManager.bullet_pool :
var objShot = load(BulletManager.BULLET_DIRECTORY + type + ".tscn").instantiate()
BulletManager.bullet_pool[type].append(objShot)
get_tree().get_first_node_in_group("stage").call_deferred("add_child", objShot)
The only two things I haven’t tried yet are manipulating the PhysicsServer2D or writing my code in C#, which scares me because I’m already dealing with enough learning GDscript as my first programming language. Also, my hardware is middle-end, but I would still like to optimise my game to run on lower end hardware.
Does anyone have any suggestions on how to improve performance in this case? Any help is appreciated.
I tried instantiating clusters of 20 bullets every 2 seconds 25 times, and that just results in big stutters every 2 seconds. I don’t want to take too long to get all the bullets instantiated, because the way my game works is that the player controls when the enemies spawn, so I have to prepare for cases where bullet-intensive enemies spawn right away.
I don’t really see the point of starting with a small bullet pool and then scaling it back up if I haven’t already optimised a large bullet pool - this is a bullet hell game, after all, and 500 bullets is far from an unreasonable amount.
1 - what’s your PC? all you said is windows 11 and mid-tier, all that tells us is that all your memory is being eaten, of which we don’t know how much there is. you need at least 8gb for windows alone.
also I’ve seen some 7th gen intel with an upgraded windows 10, windows 11 is not compatible with old hardware but can be forced to work with it.
so the first thing would be figuring out what hardware you have to work with, maybe it’s more modern, but maybe not.
2 - you are using 4.3. try upgrading to 4.4 which is faster. one of the things they optimized is the scene tree.
3 - we need to figure out if this is a CPU problem or a GPU problem, there are different solutions for each. are there good frames in game after everything loads? is this just a stutter?
4 - is there anything else in the scene other than bullets? what does the bullet code look like? do they have a script?
5 -
I don’t know what you mean by pooling system. but if you need performance for things like these, you don’t instantiate 500 nodes, you use servers.
6 -
it seems you are NOT doing everything you can to optimize things, one example here is the lack of static typing.
func instantiate_bullets() -> void:
for i : int in range(500):
for type : Node in BulletManager.bullet_pool:#whatever type is supposed to be
this here can use many improvements:
var objShot = load(BulletManager.BULLET_DIRECTORY + type + ".tscn").instantiate()
please preload that PackedScene. also define it outside the loop. also use a StringPath and not a collage string. also give objShot a type.
this is really bad. appending to an array is a waste of performance. and an array inside a dictionary that uses string?
and what does it do?
this is also bad. you are calling tree, searching for a node in group, and then doing call deferred, which calls the function at the end of the frame. you are also using a string for function name instead of a callable.
it seems this is done to add_child, so get a reference outside the loop, give it a type so it’s safe, then call add_child directly or call_deferred from callable.
servers is the solution to this problem, but seeing your code you might not be at that level yet. let’s try to optimize the code first. and switching to C# will not magically fix the performance, it is the way of doing things that is the problem.
is this the bullet? having 500 AnimationPlayers in the scene at the same time is not a good idea. (looking at the code again, it’s more like 1500 or higher)
use a shader or an animated sprite. a shader will be better. I don’t know about 2D but in 3D you can set animations from a standard material for use with particle system. so there might be something for 2D, otherwise simple UV shift TIME shader is easy.
if the bullet needs to explode, make an explosion scene and spawn it then delete the bullet.
so, let’s start by fixing the code:
use a method to get the preloaded bullet scene from BulletManager:
bullet_manger.gd
enum {FIRST, SECOND, THIRD}
var bullets : Dictionary = {FIRST : preload("first_bullet"), SECOND : preload("second_bullet"), THIRD : preload("etc")}
var bullet_pool : Array[int] = [FIRST, SECOND, THIRD]
func instantiate_bullets() -> void:
var stage : Node2D = get_tree().get_first_node_in_group("stage")
for j : int in BulletManager.bullet_pool:
var objShot : PackedScene = BulletManager.bullets.get(j, 0)
for i : int in range(500):
var tmp : Node2D = objShot.instantiate()
#I don't know what this line does but it's bad for performance and not needed
#BulletManager.bullet_pool[type].append(objShot)
stage.add_child(tmp)
#define here the position of the bullet
edit: I think I understand why you are adding the bullets to a dictionary. don’t.
use a bunch of nodes like stage and add the bullets to the different ones, then control them from there.
func instantiate_bullets() -> void:
for j : int in BulletManager.bullet_pool:
var stage : Node2D = get_tree().get_first_node_in_group(BulletManager.get_bullet_group(j))#create a function that returns String given the bullet ID and that string is the name of the group
var objShot : PackedScene = BulletManager.bullets.get(j, 0)
for i : int in range(500):
var tmp : Node2D = objShot.instantiate()
stage.add_child(tmp)
#define here the position of the bullet
Thank you so much for the in-depth response, it’s very helpful. I’m attempting to implement your suggestions as we speak.
what’s your PC?
Intel core i7-9400F, Intel Arc A380, 16 gigs RAM.
are there good frames in game after everything loads? is this just a stutter?
The framerate is fine for the rest of the game.
is there anything else in the scene other than bullets? what does the bullet code look like? do they have a script?
There’s a player, enemies, level design elements, etc… The bullet script is this:
extends Area2D
@onready var player = get_tree().get_first_node_in_group("player")
var velocity = Vector2(0,0)
@export var type : String
@onready var colour : int
@onready var speed : float
@onready var accel : float
@onready var target_speed : float
@onready var angle : float
@onready var angular_velocity : float
@onready var lifespan : float
@onready var damage : float
@onready var hitbox : Area2D
@export var can_rotate : bool
func _ready() -> void:
deactivate()
func _process(delta) -> void:
position += velocity * delta
func start_firing(delay) -> void:
$Sprite.visible = false
$DelaySprite.visible = true
$DelaySprite.frame = colour
$AnimationPlayer.speed_scale = 1 / delay
$AnimationPlayer.play("startup")
func activate() -> void:
$Sprite.visible = true
$DelaySprite.visible = false
$Sprite.frame = colour
$Hitbox.set("disabled", false)
$AnimationPlayer.speed_scale = 1
$AnimationPlayer.play(&"RESET")
add_to_group("bullet")
remove_from_group("bulletpool")
if not lifespan == null :
await(get_tree().create_timer(lifespan).timeout)
play_death_animation()
func play_death_animation() -> void:
$Hitbox.set("disabled", true)
$AnimationPlayer.play("despawn")
func deactivate():
remove_from_group("bullet")
add_to_group("bulletpool")
BulletManager.bullet_pool[type].append(self)
BulletManager.active_bullets[type].erase(self)
global_position = Vector2(29999, 29999)
speed = 0
accel = 0
target_speed = 0
angle = 0
angular_velocity = 0
damage = 0
velocity = Vector2(0,0)
set_process_mode(PROCESS_MODE_DISABLED)
func move():
speed = move_toward(speed, target_speed, accel)
if not angular_velocity == 0 :
angle += angular_velocity
velocity = Vector2.from_angle(angle) * speed
rotation = angle
func _on_animation_player_animation_finished(anim) -> void:
if anim == "despawn" :
deactivate()
if anim == "startup" :
activate()
this is really bad. appending to an array is a waste of performance. and an array inside a dictionary that uses string?
and what does it do?
bullet_pool is a dictionary with each bullet type for keys, and an array of every inactive bullet that enemies can pull from when firing bullets. I also have an active_bullets dictionary which is the same, but for bullets that are currently active.
# untested...
func instantiate_bullets():
var bullet_tscn: Dictionary # Dict/array of preloaded tscns.
# prep tscns.
for type in BulletManager.bullet_pool:
bullet_tscn[type] = load(BulletManager.BULLET_DIRECTORY + type + ".tscn")
# add to tree
var node = get_tree().get_first_node_in_group("stage") # cache node
for i in 500:
for type in BulletManager.bullet_pool:
var shot = bullet_tscn[type].instantiate()
BulletManager.bullet_pool[type].append(shot)
node.call_deferred("add_child", shot)
You can load() each tscn once and instantiate() multiple times off the load.
You can calculate the node you plan to add_child() to once rather than every loop iteration.
When you have something in a loop like this, generally you want to do everything at the outermost scope you can. In the example above, you’ve got eight bullet types and 500 bullets, so that’s 4000 loop iterations. Your code is hitting load() 4000 times. It’s hitting get_tree().get_first_node_in_group() 4000 times. Moving those out of the loop saves you 3999 invocations of each, which ought to speed things up a bit.
it says in the wiki that 9400f is an i5, not i7, but it appears to be compatible with windows 11, just very old.
memory is ok.
keep in mind that a newer computer would be able to process much faster, so the bullet hell might not be as much of a problem for most people, only for those with low-end hardware. but we can’t know without testing.
then it is an instantiation problem.
in games sometimes you have to sacrifice elements in order to get the things you want. in this case it’s a bullet hell, so having too many elements in the scene could be a problem. but since performance is good, you can leave it for now.
it’s a 2D game after all and should work fine.
that said, a bullet should NEVER have that much code, or any code at all. you seem to be doing everything you could’ve to make the scene slow.
the most there must be is a simple physics_process override to give it movement, and using consts for speed and with all values pre-calculated.
this is ok I guess. just give it a type.
this is bad. again, use different scenes, you can change the color per scene with the modulate property. don’t define variables that are not needed.
also these are all variants that you are calling from onready, so I’m not they are global, in theory they should belong to ready. @onready is for references only, nodes.
no, the bullet should be an Area2D with a collider and sprite. and NOTHING ELSE.
any additional node is a waste of resources and making the loading slower.
SPECIALLY in this bullet hell you are trying to create.
use a state machine if you need states. also don’t export random variables, and specially ones that need to be defined in code. exports are for changing variables from the editor only, like setting a node outside the local scene.
func deactivate():
remove_from_group("bullet")
why are you doing this?
you are not supposed to change the group, that is something you define in the editor and never change, unless it’s for some RTS where the unit has to mutate or change side, and even then it’s not a good idea.
these are bullets.
add_to_group("bulletpool")
nonono. create a scene for each bullet and give them different groups there.
global_position = Vector2(29999, 29999)
no. set position of the bullet when instantiating, you can see my comment in the code.
this is bad. if you need these to be set at the start, like process disabled, set them in the editor. and you don’t need that many variables, this is making the loading slow.
put this in physics_process. process happens every frame, physics process is a fixed 60 per second, so you don’t need delta there and the script runs only when it needs to, and you avoid space invader bugs.
you can enable 2D interpolation to make it move smoother once it’s in physics process. there will also be better feedback.
again, too many variables and nodes.
you don’t need 3 sprites.
don’t use an AnimationPlayer, use a shader or AnimatedSprite.
4.4 introduces instancing for shaders in 2D, so that is a good idea instead of an animation player. otherwise use an animated sprite. this is the one instance where animated sprite could be used.
and stop swapping groups.
again don’t use an animation player. spawn the explosion and delete the node.
you never use this.
and that uses a lot of resources.
keep a score of bullets in an int and spawn the bullets as needed.
also put different bullets under different parents as I said and use get_children to get them. in fact, do that once to add them to the “pool” and keep an array of references.
don’t do it during instantiating.
this is a bug waiting to happen.
use encapsulation. make a function in BulletManager that returns the array or adds an item to it and call it.
BulletManager.add_bullet(tmp)
and for this precise situation it is better to keep an array of all the bullets and one with their state and change that instead of rearranging arrays.
you would access the “key” and “value” using the ID in the array.
then you need a system for reserving a bullet space when one is deleted and fill it back, so that space would be null/freed_object until assigned again.
if all the bullets are the same you can use a number to know how many bullets are available, then reduce that number and pull from the end. but the arrays will never change size unless it’s to add new items if needed.
and again, give all variables a type and all functions a return. that is over 10% performance increase.
Thank you very much for the feedback. With only some of your suggestions I managed to optimise it to where it loads and runs smoothly with upwards of 2500 bullets at a time, plus everything else in the game. For now I’m satisfied, but if I ever need to optimise further I’ll take even more of your suggestions.