so Im trying to instantiate 1k 3D enemies but its too heavy. I tried using renderingServers and Physics3D servers but they dont do what they want, I want to have enemy nodes with all their stuff without limitations, there are some games like megabonk and vampire survivors that just can have a lot of enemies without any issue, but those we’re not made in godot…
If you’re wondering how my spawning code looks like, here u have it
extends Node3D
class_name EnemySpawner
@export var player: Node3D
@export var enemies: Array[EnemySpawnData] = []
@export var max_enemies: int = 50
@export var max_spawn_attempts: int = 5 # retries if terrain invalid
@export var raycast_length: float = 100.0 # downward distance for floor detection
var _timer: float = 0.0
var rng := RandomNumberGenerator.new()
var _current_enemies: int = 0
func _process(delta: float) -> void:
if not player:
return
_timer -= delta
if _timer <= 0.0:
if _current_enemies < max_enemies:
var chosen_enemy: EnemySpawnData = _spawn_enemy()
if chosen_enemy:
# use the chosen enemy's spawn_time for the next cooldown
_timer = chosen_enemy.spawn_time
func _spawn_enemy() -> EnemySpawnData:
if enemies.is_empty():
return null
# pick an enemy type based on weighted chance
var total_chance = 0
for data in enemies:
total_chance += data.chance
var pick: float = rng.randi_range(1, total_chance)
var cumulative: int = 0
var chosen: EnemySpawnData = null
for data in enemies:
cumulative += data.chance
if pick <= cumulative:
chosen = data
break
if not chosen:
return null
# attempt to find a valid spawn point
for i in range(max_spawn_attempts):
var spawn_pos: Vector3 = _get_random_spawn_position(chosen.range)
var floor_pos: Vector3 = _get_floor_position(spawn_pos)
if floor_pos != Vector3.ZERO:
_spawn_at_position(chosen, floor_pos)
return chosen # return chosen enemy for spawn_time
return null
func _get_random_spawn_position(range: float) -> Vector3:
var angle: float = rng.randf_range(0.0, TAU)
var dist: float = rng.randf_range(5.0, range)
var offset: Vector3 = Vector3(cos(angle), 0, sin(angle)) * dist
return player.global_position + offset
func _get_floor_position(origin: Vector3) -> Vector3:
var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(origin + Vector3.UP * 10.0, origin - Vector3.UP * raycast_length)
query.collide_with_areas = false
query.collide_with_bodies = true
var result = space.intersect_ray(query)
if result:
return result.position + Vector3.UP * 0.5 # spawn slightly above floor
return Vector3.ZERO
func _spawn_at_position(chosen: EnemySpawnData, floor_pos: Vector3) -> void:
if not chosen.enemy:
return
var enemy_instance: Enemy = chosen.enemy.instantiate()
enemy_instance.global_position = floor_pos
# Use per-enemy spawn path if set, otherwise self
var parent_node: Node3D = self
if chosen.spawn_path != NodePath("") and has_node(chosen.spawn_path):
parent_node = get_node(chosen.spawn_path)
parent_node.add_child(enemy_instance)
# Increment the current enemy count
_current_enemies += 1
# Optional: connect to enemy's "tree_exited" signal to decrement count when destroyed
if enemy_instance.has_signal("tree_exited"):
enemy_instance.connect("tree_exited", Callable(self, "_on_enemy_removed"))
func _on_enemy_removed() -> void:
_current_enemies = max(_current_enemies - 1, 0)
Your enemy code is likely more important. Using move_and_slide is a rather expensive operation, if you must have it you can reduce collision geometry to only basic shapes such as capsules and world boundries. If you don’t want to use performance tricks like tooling the Physics server (or omitting it entirely) then you’ll have to accept less enemies.
I tried using basic shapes, making the enemy a node3D instead of characterbody3D the geometry is a capsule aswell and I am not able to use phisics servers because it doesnt give me the freedom I need, its too limited, Its really hard to believe that those games can run dozens of enemies
Keep in mind There is no spoon, as the programmer you are only tasked with representing thousands of enemies, not actually simulating everything. You could double-up visuals into one character body for multiple enemies, don’t do collision calculations that aren’t needed, maybe try a boid like algorithm, or abuse navigation agents as they turn out to be a fairly performant collision substitute (keep simple sphere areas for registering damage).
Dynasty warriors was legendary for achieving many NPC fighters on older hardware like the PS1. Though we don’t know for sure it seems like the game uses heavy culling to only actually process a dozen or two units at a time.
You could use activity volumes centered around the player and do some manual culling before sending your scenegraph/draw lists to the rendering server, only updating the cells immediately around the player every frame, have enemies grouped in processing batches, and then selectively process these N groups one after the other over N frames, thus spreading the load over N frames.
Depending on game pace, this can be seconds rather than frames.
For 3D, bypassing full blown physics and using a roll your own system that fits your needs can save a lot of processing time.
If you group enemies in way that’s relevant in that they’re that the whole group is about the same distance, in same posture (attack, etc.) vs the player, do one raycast/range check for the group.
Maybe process them also as a group, the numbers being a visual thing but data wise they’re like a fusioned unit in wargames, applying dommage in function of number of active units.
There is so much you can do by thinking about what you’re doing and how to spread the load over time, be it in frames or over seconds.
Cheers !
Or is cleverly tricking you into believing so. With games, it’s all about illusions. A convincing illusion of 1000 enemies being in existence doesn’t mean that 1000 enemies are processed brute force. Games tend to cut corners heavily with everything that is not actually seen on the screen at any given time.
The code controlling your enemies is running every frame for every enemy. I’d look at that rather than the spawning code. Your enemies seem to be chasing the player. You can do things like updating the target position just once every X frames and the like.
all of those advices didn’t work,so I changed the mesh GI to disabled, made an algorithm that when 5 enemies spawn, they become 1 draw call (the 5 meshes still have unique movement) and got 90 FPS without recording with 500 enemies and got 60 FPS with 1k enemies, which also means that may can can also run in a potato
Are you still bruteforcing 1000 move_and_slide() calls each frame? CharacterBody has the word “character” in its name for a reason. It’s meant for characters, not for mindless hordes
No, they renamed it precisely because people have been misusing it for indistinguishable members of hordes and bullets in bullet hells, to hint on the proper usage
You could try using compute shaders? Each enemy can have a characterbody3d but you dont need to process that unless theyre near the player … the compute shader could use quantized space.
Then the only puzzle is how to write the compute shader for many enemy characters - im sure there is a unity example however it would be nice to have one working in godot.