Can't get multi threading to work!

Godot Version

<4.1.3->

Question

I’ve been at this for days. I cannot figure out how to get multi-threading to work in godot 4:

Been copying this tutorial, but cannot get past the adding the multi-threaded portion. I’ve tried mutex locking and unlocking variables, and turning mutli-threading on in the settings. What else do I need to do?

Can u show me the code u have written so far?

It’s always changing trying to get it to work. But this is it for the most part. I was able to figure out that though the thread is live, both the player and chunk position aren’t being fed to it.

extends Node3D

@onready var chunks = $Chunks
@onready var player = get_parent().get_parent().get_node("Player")

var last_player_position = Vector3()
var player_position_mutex = Mutex.new()
var chunk_position_mutex = Mutex.new()

var load_radius = 6
var load_radius_y = 6
var load_thread = Thread.new()
const DIMENSION = Global.DIMENSION
const GRID_SIZE = Global.GRID_SIZE



func _ready():
	
	var chunk_scene = preload("res://Scenes/Chunk.tscn")
	
	for i in range(0,load_radius):
		for j in range(0, load_radius_y):
			for k in range(0, load_radius):
				var chunk = chunk_scene.instantiate()
				chunk.chunk_position = Vector3(i, j , k)
				chunks.add_child(chunk)



	load_thread.start(_thread_process.bind(self))

func _thread_process(_userdata):
	
	while is_instance_valid(self):
	
		for chunk in chunks.get_children():
			
			var chunk_x = chunk.chunk_position.x / (DIMENSION.x * GRID_SIZE)
			var chunk_y = chunk.chunk_position.y / (DIMENSION.y * GRID_SIZE)
			var chunk_z = chunk.chunk_position.z / (DIMENSION.z * GRID_SIZE)
			
				
			player_position_mutex.lock()
			var player_x = round(player.position.x / (DIMENSION.x * GRID_SIZE))
			var player_y = round(player.position.y / (DIMENSION.y * GRID_SIZE))
			var player_z = round(player.position.z / (DIMENSION.z * GRID_SIZE))
			player_position_mutex.unlock()
			
			
			var relative_x = chunk_x - player_x
			var relative_y = chunk_y - player_y
			var relative_z = chunk_z - player_z
			
			var new_x = posmod(relative_x + load_radius / 2, load_radius) + player_x - load_radius / 2
			var new_y = posmod(relative_y + load_radius_y / 2, load_radius_y) + player_y - load_radius_y / 2
			var new_z = posmod(relative_z + load_radius / 2, load_radius) + player_z - load_radius / 2
	
			if (new_x != chunk_x or new_z != chunk_z or new_y != chunk_y):
				chunk.chunk_position = Vector3(int(new_x), int(new_y), int(new_z))

				chunk.update()
			
	OS.delay_msec(100) 

		
func _exit_tree():
	load_thread.wait_to_finish()


1 Like

Any updates OP? I’m trying to do the same thing

Yeah it took me like a week but I was able to finally figure it out.
My issue wasn’t that the multi-threading wasn’t working. But rather, how the multi-thread wasn’t able to properly adjust the @export values that I was using to define the chunk_position inside of my chunk script. In my chunk.gd I had to adjust how I was adjusting the position to be a function, like this:

func set_chunk_position(new_position):
	if chunk_generation:
		chunk_generation.update_chunk_position(self, new_position, chunk_position)
	chunk_position = new_position
	set_global_position(new_position * DIMENSION)
	self.visible = false
	update()

and then in the chunk generation.gd inside of the new thread, I had to defer the call of updating the position… while also jumping through some hoops to make sure the new position would get stored and updated:

extends Node3D


@onready var chunks = $Chunks
@onready var player = get_parent().get_parent().get_node("Player")

var chunk_scene = preload("res://Scenes/Chunk.tscn")


var load_radius = 14
var shared_player_position = Vector3()
var last_player_position = Vector3()

var chunk_list = []
var occupied_positions = {}
var chunk_to_position = {}
var position_to_chunk = {}


#thread parameters
var load_thread = Thread.new()
var thread_running = true
var player_position_mutex = Mutex.new()
var update_chunk = Mutex.new()

func _ready():
	last_player_position = player.global_transform.origin
	generate_initial_chunks()
	load_thread.start(_thread_process.bind(self))
	
func _thread_process(_userdata):
	
	while thread_running and is_instance_valid(self):
		player_position_mutex.lock()
		var player_chunk_pos = get_player_chunk_position()
		player_position_mutex.unlock()
		
		var new_positions = get_nearest_chunk_keys(player_chunk_pos)

		
		for chunk in chunk_list:
			var chunk_key = chunk_to_position[chunk]
			if not new_positions.has(chunk_key):
				var new_position = find_unoccupied_position(new_positions)
				if new_position != Vector3.ZERO:
					
					update_chunk.lock()
					chunk.call_deferred("set_chunk_position", new_position)
					update_chunk.unlock()
					
					chunk_to_position[chunk] = new_position
					position_to_chunk[new_position] = chunk
					position_to_chunk.erase(chunk_key)
					OS.delay_msec(.0001)
		
		
func _exit_tree():
	thread_running = false
	load_thread.wait_to_finish()

func generate_initial_chunks():
	var player_chunk_pos = get_player_chunk_position()
	var nearest_chunk_keys = get_nearest_chunk_keys(player_chunk_pos)

	for chunk_key in nearest_chunk_keys:
		var chunk = chunk_scene.instantiate()
		chunk.set_chunk_position(chunk_key)
		chunks.add_child(chunk)
		chunk_list.append(chunk)
		chunk_to_position[chunk] = chunk_key
		position_to_chunk[chunk_key] = chunk


func _process(_delta):
	if player_has_moved():
		shared_player_position = player.position

func player_has_moved() -> bool:
	if player.position.distance_to(last_player_position) > .001:
		last_player_position = player.position
		return true
	return false
	
func update_chunk_positions():

	var player_chunk_pos = get_player_chunk_position()
	var new_positions = get_nearest_chunk_keys(player_chunk_pos)

	for chunk in chunk_list:
		var chunk_key = chunk_to_position[chunk]
		if not new_positions.has(chunk_key):
			var new_position = find_unoccupied_position(new_positions)
			if new_position != Vector3.ZERO:
				update_chunk_position(chunk, new_position)
				chunk_to_position[chunk] = new_position
				position_to_chunk[new_position] = chunk
				position_to_chunk.erase(chunk_key)

func find_unoccupied_position(new_positions):
	for pos in new_positions:
		if not position_to_chunk.has(pos):
			return pos
	return Vector3.ZERO

func update_chunk_position(chunk, new_position):
	chunk.set_chunk_position(new_position)


func get_player_chunk_position():
	return Vector3(floor(shared_player_position.x / (Global.DIMENSION.x )),
				   floor(shared_player_position.y / (Global.DIMENSION.y )),
				   floor(shared_player_position.z / (Global.DIMENSION.z ))

func get_nearest_chunk_keys(player_chunk_pos):
	var nearest_chunk_keys = []
	for x in range(-load_radius, load_radius):
		for y in range(-load_radius/2.0, load_radius/2.0):
			for z in range(-load_radius, load_radius):
				var chunk_key = player_chunk_pos + Vector3(x, y, z)
				var blocks_in_chunk = Global.chunk_blocks.get(chunk_key)
				if blocks_in_chunk and blocks_in_chunk.size() > 0:
					nearest_chunk_keys.append(chunk_key)
	return nearest_chunk_keys

You don’t really need the locking mutex for it to work, and you also dont need the func _exit_tree, but it does help

whats the “Globals.chunk_blocks” variable do?

I have a singleton called Global storing all my chunk coordinates in a dictionary. Im not exactly following the tutorial, where he generates new chunks infinitely. Rather, I have a finite list of chunks that I pull from.

I have been researching this topic for a while. I will say my solution isn’t a solution. By deferring the call of setting the chunk position, I am essentially pushing that task to the main thread, defeating the whole purpose of the multi-thread. Apparently, adjusting scenes from a separate thread isn’t possible in Godot 4.

1 Like

multithreading seems to be broken in godot 4. Attempting to access arrays or node references even with mutex locking simply returns null array elements and null references. Multithreading simply does not work in gdscript in godot 4 lilke it did in godot 3 in the tutorial mentioned. I’ve been going at this for a while and I’m certain the only solution is to do the work in C# / Cpp until this issue is acknowledged.

1 Like

Multi-threading does work, it’s just direct scene tree manipulation that doesn’t anymore, and with good reason.

I was able to make a workaround, where It essentially accomplishes the same thing.

The way to do it is to have all the chunk.gd calculations occur outside of the scene, you can offload it to a singleton, create a separate thread on that singleton and create a queue of tasks so that as the calculations come in, the separate thread calculates each chunk one at a time. The idea being that the only thing that gets calculated on the main thread is when the chunk changes position.

It’s working great! I am getting some small bugs, but no lag. I’ll post it once I’m done cleaning up the code.

2 Likes

Any update on this? I use a separate scene for my chunks at the minute, but im not very experienced with threads. Would love some insight onto how your solution works! Thanks

Yeah, I was able to figure it out finally.

You essentially have to move the calculations from the chunk scene into the second thread that’s running in your chunk-loading script. So you defer call the setting the position of the chunk in the chunk loading thread, then when the position is set, you have to have the chunk scene send the Surface tool calculations for generating all the triangles back to the chunk loading thread. Once those calculations are done, they need to send the surface tool commit back to the chunk scene to set the mesh.

So essentially the only thing that runs on the main thread is the setting position of the chunk, and the setting of the mesh.

Since you’ll have multiple instances where the calculation of the triangles are happening on a single thread along with the chunk loading you might want to do some sort of queue task:

extends Node3D

const DIMENSION = Global.DIMENSION
const GRID_SIZE = Global.GRID_SIZE




@onready var player = get_parent().get_parent().get_parent().get_node("Player")

var chunk_scene = preload("res://Scenes/Chunk.tscn")


var load_radius = 12
var chunk_list = []
var chunk_to_position = {}
var position_to_chunk = {}

var shared_player_position = Vector3()
var last_player_position = Vector3()
var chunk_position = Vector3.ZERO


#thread parameters
var task_queue = []
var task_queue_mutex = Mutex.new()
var background_thread = Thread.new()
var thread_running = false
var player_position_mutex = Mutex.new()
var update_chunk = Mutex.new()

func _ready():
	island_generator = get_parent().get_parent().get_node("Island_Generation")
	island_generator.connect("island_generation_complete", _on_island_generation_complete)

func _on_island_generation_complete():
	start_chunk_generation()
	
func start_chunk_generation():
	last_player_position = player.global_position
	generate_initial_chunks()
	thread_running = true
	background_thread.start(_thread_process.bind(self))
	
	

#queue tasks ---------------------------------------------	
	
	
	
func _get_next_task():
	task_queue_mutex.lock()
	var task = null
	if task_queue.size() > 0:
		task = task_queue.pop_front()
	task_queue_mutex.unlock()
	return task

func add_global_task(chunk_key, callback_object, callback_function):
	task_queue_mutex.lock()
	task_queue.push_front({"chunk_key": chunk_key, "callback_object": callback_object, "callback_function": callback_function})
	task_queue_mutex.unlock()
	

func _execute_task(task):
	var meshes = generate_mesh(task.chunk_key)
	task.callback_object.call_deferred(task.callback_function, meshes)

func _exit_tree():
	thread_running = false
	background_thread.wait_to_finish()



	
#chunk_loading --------------------------------------------
	


func _thread_process(_userdata):
	
	while thread_running and is_instance_valid(self):
		
		var task = _get_next_task()
		if task:
			_execute_task(task)
		else:
			player_position_mutex.lock()
			var player_chunk_pos = get_player_chunk_position()
			player_position_mutex.unlock()

			var new_positions = get_nearest_chunk_keys(player_chunk_pos)

			for chunk in chunk_list:
				var chunk_key = chunk_to_position[chunk]
				if not new_positions.has(chunk_key):
					var new_position = find_unoccupied_position(new_positions)
					if new_position != Vector3.ZERO:
						
						chunk.call_deferred("set_chunk_position", new_position)
						
						chunk_to_position[chunk] = new_position
						position_to_chunk[new_position] = chunk
						position_to_chunk.erase(chunk_key)
		
		
func generate_initial_chunks():
	var player_chunk_pos = get_player_chunk_position()
	var nearest_chunk_keys = get_nearest_chunk_keys(player_chunk_pos)

	for chunk_key in nearest_chunk_keys:
		if Global.chunk_blocks.has(chunk_key):
			var chunk = chunk_scene.instantiate()
			add_child(chunk)
			chunk.set_chunk_position(chunk_key)
			chunk_list.append(chunk)
			chunk_to_position[chunk] = chunk_key
			position_to_chunk[chunk_key] = chunk


func find_unoccupied_position(new_positions):
	for pos in new_positions:
		if not position_to_chunk.has(pos):
			return pos
	return Vector3.ZERO


func get_player_chunk_position():
	return Vector3(floor(shared_player_position.x / (Global.DIMENSION.x * Global.GRID_SIZE)),
				   floor(shared_player_position.y / (Global.DIMENSION.y * Global.GRID_SIZE)),
				   floor(shared_player_position.z / (Global.DIMENSION.z * Global.GRID_SIZE)))


func get_nearest_chunk_keys(player_chunk_pos):
	var nearest_chunk_keys = []
	for x in range(-load_radius, load_radius):
		for y in range(-load_radius/2.0, load_radius/2.0):
			for z in range(-load_radius, load_radius):
				var chunk_key = player_chunk_pos + Vector3(x, y, z)
				if Global.chunk_blocks.has(chunk_key):
					nearest_chunk_keys.append(chunk_key)
	return nearest_chunk_keys

	
func player_has_moved():
	if player.position.distance_to(last_player_position) > .001:
		last_player_position = player.position
		return true
	return false
	
	
func _process(_delta):
	if player_has_moved():
		shared_player_position = player.position

#Generate Mesh ---------------------------------------------------------

func generate__mesh(chunk_key):
	
	var meshes = {} 
	var greedy_face = {}
	
	meshes.clear()
	greedy_face.clear()
	

	if Global.chunk_blocks.has(chunk_key):

		for block_pos in Global.chunk_blocks[chunk_key].keys():
			var block_type = Global.chunk_blocks[chunk_key][block_pos]
			if block_type != Global.BlockTypes.AIR:
                                for face in Global.FACE_INDICES.keys():
					if should_create_face(block_pos, face):
                                                   create_block(block_pos, block_type, chunk_key, st):


func should_create_face(pos, face):
	var direction = get_face_direction(face)
	var neighbor_pos = pos + direction
	var chunk_key = floor(neighbor_pos / (DIMENSION * GRID_SIZE))
	if not Global.chunk_blocks.has(chunk_key):
		return true 
	return not Global.chunk_blocks[chunk_key].has(neighbor_pos) or Global.chunk_blocks[chunk_key][neighbor_pos] == Global.BlockTypes.AIR
	

func get_face_direction(face):
	match face:
		Global.TOP: return Vector3(0, GRID_SIZE, 0)
		Global.BOTTOM: return Vector3(0, -GRID_SIZE, 0)
		Global.LEFT: return Vector3(-GRID_SIZE, 0, 0)
		Global.RIGHT: return Vector3(GRID_SIZE, 0, 0)
		Global.FRONT: return Vector3(0, 0, GRID_SIZE)
		Global.BACK: return Vector3(0, 0, -GRID_SIZE)


func get_neighbor_positions(pos):
	return [
		pos + Vector3(GRID_SIZE, 0, 0),
		pos + Vector3(-GRID_SIZE, 0, 0),
		pos + Vector3(0, GRID_SIZE, 0),
		pos + Vector3(0, -GRID_SIZE, 0),
		pos + Vector3(0, 0, GRID_SIZE),
		pos + Vector3(0, 0, -GRID_SIZE)
	]


func create_block(pos, block_type, chunk_key, st):
	if block_type == Global.AIR:
		return
	
	for face in Global.FACE_INDICES.keys():
		if should_create_face(pos, face):
			create_face(face, pos - (chunk_key * DIMENSION * GRID_SIZE), block_type, st)
			
func create_face(face, pos, block_type, st):
	var face_vertices = Global.FACE_INDICES[face]
	var offset = pos - (Vector3(GRID_SIZE, GRID_SIZE, GRID_SIZE) / 2.0)
	var a = Global.vertices[face_vertices[0]] + offset
	var b = Global.vertices[face_vertices[1]] + offset
	var c = Global.vertices[face_vertices[2]] + offset
	var d = Global.vertices[face_vertices[3]] + offset
	
	var _normal1 = (c - a).cross(b - a)
	var _normals1 = [_normal1, _normal1, _normal1]
	
	var _normal2 = (d - a).cross(c - a)
	var _normals2 = [_normal1, _normal1, _normal1]

	
		
	st.add_triangle_fan([a, b, c], [], [], [], _normals1)
	st.add_triangle_fan([a, c, d], [], [], [], _normals2)

So all this runs in the chunk loading script. Again, it might not exactly correspond to what you’re doing, since I’ve adapted my game a bit. For example, GRID_SIZE determines the size of the block. So in your case that would probably just be 1. So in the chunk script, what you’d have is something like this:

func set_chunk_position(new_position):
	self.visible = false
	set_global_position(new_position * (DIMENSION * GRID_SIZE))
	update()
	

func update():

	var chunk_key = floor(self.position / (DIMENSION * GRID_SIZE))
	chunk_generation.add_global_task(chunk_key, self, "apply_mesh")

func apply_mesh():
	
	child.queue_free()
	var mesh = meshes[block_type]

	mesh_instance = MeshInstance3D.new()
	add_child(mesh_instance)
			
	mesh_instance.set_mesh(mesh)
	mesh_instance.create_trimesh_collision()
	
	self.visible = true
	
	


func _process(_delta):
	pass
2 Likes