Trying to run atleast 1k enemies but goes at 15 FPS with just 50 enemies

Godot Version

4.5 stable

Question

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)

here you can also see how it goes in game

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.

1 Like

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.

1 Like

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 !

1 Like

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.

1 Like

Smoke and mirrors!

1 Like

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.

1 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 :partying_face:

4 Likes

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 :wink:

1 Like

well move and slide gets called every 5 frames and I need chaaracterbodies because of enemy physics

It’s meant for characters

in Godot 3.0 they we’re called kinematic bodies, the node was renamed to make it sound unique and fancy, not because its for characters :wink:

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 :stuck_out_tongue:

1 Like

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.

1 Like