Integrate forces is adding frame time in the profiler for mobs in my 3d game

Godot Version

Godot 4.4

Question

I detailed the issue on Github: Integrate forces in physics3d takes up frame time · Issue #741 · Dimensionfall/Dimensionfall · GitHub. Basically, when a mob spawns, it will add about 25ms to integrate_forces. When multiple mobs spawn, it will quickly add up to 100ms in a frame, causing stuttering.

The game is a top-down 3d game made up of chunks. I already moved the static and physics furniture to the physicsserver api which works great. However, the mob is still instantiated as a scene. I tried different approaches to solve this issue, like pooling collision objects, pooling mobs, disabling collision shape, moving using the physicsserver3d instead of mob.global_position

This is my latest code, which works partially but only spreads out the frame time across frames:

extends Node3D

@onready var projectiles_container : Node = $Projectiles
const POOL_SIZE: int = 100
var pool: Array[Mob] = []

func _init():
	Helper.signal_broker.projectile_spawned.connect(on_projectile_spawned)

func _ready():
	initialize_pool()

func on_projectile_spawned(projectile: Node, instigator: RID):
	projectiles_container.add_child(projectile)


func initialize_pool() -> void:
	if pool.size() > 0:
		return  # Pool already initialized
	var dummy_data: Dictionary = {"id": "bone_thrower"}  # Example mob ID for initialization
	for i in range(POOL_SIZE):
		var mob: Mob = Mob.new(Vector3.ZERO, dummy_data)
		# Add to scene and initialize physics objects
		add_child(mob)
		mob.visible = false
		mob.set_physics_process(false)
		mob.state_machine.set_physics_process(false)
		mob.detection.set_physics_process(false)
		if mob.collision_shape_3d:
			mob.collision_shape_3d.disabled = true
		pool.append(mob)  # ✅ Add to pool
	await get_tree().process_frame  # Allow deferred collisions/nav setup to complete

func spawn_mob(at_pos: Vector3, mob_json: Dictionary) -> CharacterBody3D:
	if pool.is_empty():
		push_error("Mob pool exhausted! Increase POOL_SIZE.")
		return null
	# Activate a mob from the pool
	var mob: Mob = pool.pop_back()
	# remember layers
	var old_layer = mob.collision_layer
	var old_mask  = mob.collision_mask
	# turn collisions off
	mob.collision_layer = 0
	mob.collision_mask  = 0
	#mob.set_physics_process(false)
	#mob.state_machine.set_physics_process(false)
	#mob.detection.set_physics_process(false)
	#var body_rid := mob.get_rid()
	#var new_transform := Transform3D(Basis(), at_pos)
	#PhysicsServer3D.body_set_state(body_rid, PhysicsServer3D.BODY_STATE_TRANSFORM, new_transform)
	#mob.global_position = at_pos
	# before spawn:
	var rid = mob.get_rid()
	var xform = Transform3D(Basis(), at_pos)
	PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_TRANSFORM, xform)
	# now sync the visual Node too (without re‐triggering physics):
	mob.global_transform = xform

	# restore
	mob.collision_layer = old_layer
	mob.collision_mask  = old_mask
	await get_tree().physics_frame  # ← key: wait until transform is stable
	mob.apply_mob_data(mob_json)
	_reactivate_mob.call_deferred(mob)
	#mob.visible = true
	#mob.set_physics_process(true)
	#mob.state_machine.set_physics_process(true)
	#mob.detection.set_physics_process(true)
	#if mob.collision_shape_3d:
		#mob.collision_shape_3d.disabled = false
	Helper.signal_broker.mob_spawned.emit(mob)
	return mob

func _reactivate_mob(mob):
	mob.visible = true
	mob.set_physics_process(true)
	mob.state_machine.set_physics_process(true)
	mob.detection.set_physics_process(true)
	if mob.collision_shape_3d:
		mob.collision_shape_3d.disabled = false
		
func despawn_mob(mob: CharacterBody3D, emit_signal: bool = true) -> void:
	# Deactivate and hide the mob
	mob.set_physics_process(false)
	mob.state_machine.set_physics_process(true)
	mob.detection.set_physics_process(true)
	mob.visible = false
	if mob.collision_shape_3d:
		mob.collision_shape_3d.disabled = true
	mob.remove_from_group("mobs")
	if emit_signal:
		Helper.signal_broker.mob_killed.emit(mob)
	# Reset any transient state (if needed)
	mob.is_blinking = false
	mob.terminated = false
	# Return to pool
	pool.append(mob)

I was never able to find out why the mob adds 25ms of frame time to the integrate forces. Godot Engine should easily be able to spawn a mob, right? Once the mob spawns, it will land on the ground, which is one or more static collision objects and connect with it’s navigation. The integrate_forces frame time disappears when I add the collision shape to a StaticBody3D and parent that to the mob. However, then the mob no longer collides with walls and mobs.

Please help me figure out where this frame time comes from. In the code above, it happens when mob.global_transform = xform is called and the mob starts to move. The mob itself is a characterbody3d: Dimensionfall/Scripts/Mob/Mob.gd at main · Dimensionfall/Dimensionfall · GitHub

I would avoid using coroutines in the _integrate_forces function. But the picture you provided is incomplete i dont see what calls spawn.

Also it seems like you are doing a lot of effort that remove_child could do in one line of code.

Thanks for your reply. Yes, the code is a bit messy. Spawn is called by:

# When a map is loaded for the first time we spawn the mob on the block
func add_block_mobs():
	if not processed_level_data.has("mobs"):
		return
	mutex.lock()
	var mobdatalist = processed_level_data.mobs.duplicate()
	mutex.unlock()
	var batch_size := 1  # Number of mob to spawn per batch
	var count := 0
	for mobdata: Dictionary in mobdatalist:
		# Pass the position and the mob json to the newmob and have it construct itself
		#var newMob: CharacterBody3D = Mob.new(mypos+mobdata.pos, mobdata.json)
		#level_manager.add_child.call_deferred(newMob)
		entities_manager.spawn_mob.call_deferred(mypos + mobdata.pos, mobdata.json)
		count += 1
		if count >= batch_size:
			count = 0
			OS.delay_msec(100)  # Stagger by 10 ms (adjust as needed)
	# If you want to test a mob, you can use this to spawn it at the Vector3 location
	# Comment it out again when you're done testing
	if mypos == Vector3(0,0,0):
		var tempmob: CharacterBody3D = Mob.new(Vector3(15,1.5,15), {"id":"basic_zombie_1"})
		level_manager.add_child.call_deferred(tempmob)

specifically entities_manager.spawn_mob.call_deferred(mypos + mobdata.pos, mobdata.json)

How do I avoid using coroutines in the _integrate_forces function?

I looked at more of your links.

This happens in the process cycle, and should only happen in the physics cycle.

Add_block_mobs deferred calls the spawn_mob which deferred calls will always be processed in an idle frame. Which is not a good idea when modifying the direct body state.

I originally was talking about the await physics frame a few lines down, but if that works like it reads then maybe you could set the direct body xform in that coroutine.

I see. That’s a good observation. the spawn_mob is called deferred from add_block_mobs because add_block_mobs runs in a tread, and you can’t update global_position in a thread. But what if you could? So that gave me an idea. Since my FurniturePhysicsSrv gets it position in the _init stage inside a thread, maybe I can also do it for the mob somehow. I will need the physicsserver3d instead of global_position though.

In the meantime I updated my script to this (with more code commented out and changed):

extends Node3D

@onready var projectiles_container : Node = $Projectiles
const POOL_SIZE: int = 100
var pool: Array[Mob] = []

func _init():
	Helper.signal_broker.projectile_spawned.connect(on_projectile_spawned)
	# Connect to the mob_spawned signal
	Helper.signal_broker.mob_spawned.connect(_on_mob_spawned)
	Helper.signal_broker.mob_killed.connect(_on_mob_killed)

func _ready():
	initialize_pool()

func on_projectile_spawned(projectile: Node, instigator: RID):
	projectiles_container.add_child(projectile)


func initialize_pool() -> void:
	if pool.size() > 0:
		return  # Pool already initialized
	var dummy_data: Dictionary = {"id": "basic_zombie_1"}  # Example mob ID for initialization
	for i in range(POOL_SIZE):
		var mob: Mob = Mob.new(Vector3.ZERO, dummy_data)
		# Add to scene and initialize physics objects
		add_child(mob)
		mob.visible = false
		mob.set_physics_process(false)
		mob.state_machine.set_physics_process(false)
		mob.detection.set_physics_process(false)
		if mob.collision_shape_3d:
			mob.collision_shape_3d.disabled = true
		pool.append(mob)  # ✅ Add to pool
	await get_tree().process_frame  # Allow deferred collisions/nav setup to complete

func spawn_mob(at_pos: Vector3, mob_json: Dictionary) -> CharacterBody3D:
	if pool.is_empty():
		push_error("Mob pool exhausted! Increase POOL_SIZE.")
		return null
	# Activate a mob from the pool
	var mob: Mob = pool.pop_back()
	# remember layers
	var old_layer = mob.collision_layer
	var old_mask  = mob.collision_mask
	# turn collisions off
	mob.collision_layer = 0
	mob.collision_mask  = 0
	#mob.set_physics_process(false)
	#mob.state_machine.set_physics_process(false)
	#mob.detection.set_physics_process(false)
	#var body_rid := mob.get_rid()
	#var new_transform := Transform3D(Basis(), at_pos)
	#PhysicsServer3D.body_set_state(body_rid, PhysicsServer3D.BODY_STATE_TRANSFORM, new_transform)
	mob.set_global_position.call_deferred(at_pos)
	# before spawn:
	#var rid = mob.get_rid()
	#var xform = Transform3D(Basis(), at_pos)
	#PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_TRANSFORM, xform)
	## now sync the visual Node too (without re‐triggering physics):
	#mob.global_transform = xform

	await get_tree().physics_frame  # ← key: wait until transform is stable
	# restore
	mob.collision_layer = old_layer
	mob.collision_mask  = old_mask
	mob.apply_mob_data(mob_json)
	_reactivate_mob.call_deferred(mob)
	#mob.visible = true
	#mob.set_physics_process(true)
	#mob.state_machine.set_physics_process(true)
	#mob.detection.set_physics_process(true)
	if mob.collision_shape_3d:
		mob.collision_shape_3d.disabled = false
	Helper.signal_broker.mob_spawned.emit(mob)
	return mob

func _reactivate_mob(mob):
	mob.visible = true
	mob.set_physics_process(true)
	mob.state_machine.set_physics_process(true)
	mob.detection.set_physics_process(true)
	#if mob.collision_shape_3d:
		#mob.collision_shape_3d.disabled = false
		
func despawn_mob(mob: CharacterBody3D) -> void:
	# Deactivate and hide the mob
	mob.visible = false
	if mob.collision_shape_3d:
		mob.collision_shape_3d.disabled = true
	mob.set_physics_process(false)
	mob.state_machine.set_physics_process(false)
	mob.detection.set_physics_process(false)
	mob.remove_from_group("mobs")
	# Reset any transient state (if needed)
	mob.is_blinking = false
	mob.terminated = false
	# Return to pool
	pool.append(mob)


# When a mob is spawned
func _on_mob_spawned(_mob) -> void:
	pass

# When a mob is killed, remove it
func _on_mob_killed(mob) -> void:
	despawn_mob(mob)

The main change is that global_position is set deferred and I no longer use the physicsserver3d. Now the delay on integrate_forces is spread over multiple frames, down to an acceptable level. There are still other issues, but this is good progress.

I still want to try to set the position inside a thread if possible.

I dont think there will be much gained by doing so. Amd a deferred call always exit the thread into the main thread. Its the preferred method of returning data back to the main thread.

But you can just set its local position relative to its parent. And if the parent is at the origin then the position is effectively global. There are other means to do some translations(to_local, to_global). i dont see any advantage of not removing the child from the scene. And adding it back in the desired position. (Just remember _ready is only called once in a nodes lifecycle)

My opinion is that threads are only good for long computes and resource loading. ( when they can do something completely independent and have zero, or a minimum, shared resources.)

Tree manipulation is not a desired threading activity and is discouraged in the documentation, and in general, in all software, when manipulating shared resources like nodes in the scene tree, unless you setup the proper threading control. And sometimes proper threading control makes it less efficient then just a single thread. Spawning a thread to set some state is to small of a task for a thread. Imo

2 Likes

I finished my work on pooling zombies and submitted it as Double count of basic zombies in maps and pool mobs by snipercup · Pull Request #794 · Dimensionfall/Dimensionfall · GitHub

However, I realized that maybe just staggering the spawn will be enough, so I created Delay mobs and double amount of zombies by snipercup · Pull Request #795 · Dimensionfall/Dimensionfall · GitHub instead.

I will probably go with #795 and leave it at that. In the end, I couldn’t fully remove the integrate_forces delay, but smearing it out over multiple frames does the trick. Thank you for your help pennyloafers.