Godot4 generative tilemaplayer causing stuttering

Godot Version

4.3

Question

What am i doing wrong that is causing stuttering? i tried offloading it to another thread and deferring it, but i honestly am new to this and have no idea what the problem is

extends TileMapLayer

@export var chunk_size: int = 16  # Each chunk is 16x16 tiles
@export var render_distance: int = 3  # Chunks to load around the player
@export var terrain_layer: int = 0  # The tile layer index
@export var terrain_set: int = 0  # ID of the Terrain Set in the tileset

var player: Node2D
var world_chunks := {}  # Stores ALL generated chunks
var active_chunks := {}  # Tracks only currently active chunks
var chunk_queue: Array[Vector2i] = []  # Queue of chunks to generate
var worker_thread := Thread.new()  # Single background worker thread
var should_stop := false  # Graceful shutdown flag

func _ready():
	player = get_parent().get_node("Player")
	worker_thread.start(Callable(self, "_worker_process"))  # Start worker thread
	_update_terrain_around_player(true)

func _process(_delta):
	_update_terrain_around_player()

func _update_terrain_around_player(force: bool = false):
	if not player:
		return

	# Convert player's global position to tilemap coordinates
	var player_tile_pos = local_to_map(to_local(player.global_position))
	var player_chunk = Vector2i(
		int(floor(player_tile_pos.x / float(chunk_size))),
		int(floor(player_tile_pos.y / float(chunk_size)))
	)

	# Identify chunks to activate
	for x in range(player_chunk.x - render_distance, player_chunk.x + render_distance + 1):
		for y in range(player_chunk.y - render_distance, player_chunk.y + render_distance + 1):
			var chunk_key = Vector2i(x, y)
			if active_chunks.has(chunk_key) and not force:
				continue
			if world_chunks.has(chunk_key):
				active_chunks[chunk_key] = true
			else:
				if not chunk_queue.has(chunk_key):
					chunk_queue.append(chunk_key)

	_deactivate_distant_chunks(player_chunk)

func _worker_process():
	while not should_stop:
		if chunk_queue.size() > 0:
			# Process **one chunk per frame** to prevent lag
			var chunk_pos = chunk_queue.pop_front()
			var chunk_data = _generate_chunk_data(chunk_pos)
			call_deferred("_apply_generated_chunk", chunk_pos, chunk_data)

func _generate_chunk_data(chunk_pos: Vector2i) -> Dictionary:
	var start_tile = chunk_pos * chunk_size
	var tile_positions: Array[Vector2i] = []
	var terrain_types: Array[int] = []

	for x in range(start_tile.x, start_tile.x + chunk_size):
		for y in range(start_tile.y, start_tile.y + chunk_size):
			var terrain_id = _generate_tile_at(x, y)
			tile_positions.append(Vector2i(x, y))
			terrain_types.append(terrain_id)

	return {
		"chunk_pos": chunk_pos,
		"tile_positions": tile_positions,
		"terrain_types": terrain_types
	}

func _apply_generated_chunk(chunk_pos: Vector2i, chunk_data: Dictionary):
	# Skip if chunk is already generated
	if world_chunks.has(chunk_pos):
		return  

	var tile_positions = chunk_data["tile_positions"]
	var terrain_types = chunk_data["terrain_types"]

	# **Apply batch updates to avoid excessive tilemap recalculations**
	set_cells_terrain_connect(tile_positions, terrain_layer, terrain_set, true)

	world_chunks[chunk_pos] = true
	active_chunks[chunk_pos] = true

func _generate_tile_at(x: int, y: int) -> int:
	# Example noise-based terrain generation
	var noise = FastNoiseLite.new()
	noise.seed = 42
	noise.frequency = 0.1
	var height = noise.get_noise_2d(x, y)

	return 0 if height > 0 else 1  # Example: Grass or Water

func _deactivate_distant_chunks(player_chunk: Vector2i):
	var to_remove = []
	for chunk_key in active_chunks.keys():
		if chunk_key.distance_to(player_chunk) > render_distance + 1:
			to_remove.append(chunk_key)
	for chunk_key in to_remove:
		active_chunks.erase(chunk_key)

func _exit_tree():
	should_stop = true
	worker_thread.wait_to_finish()

I could be wrong here, but I believe you are generating the same image again and again. I think with a seed of 42 you will always get the same image returned. Perhaps it might help if you generated this once, say in the ready function, and then read the heights from that same image at different co-ords.

I have a similar problem in my game. I think you can mitigate this, but I think some of it is fundamental to how Godot currently works.

Godot’s tilemaps are split into chunks for rendering purposes; there’s a setting in the inspector panel that controls chunk size. If it’s 16, I believe the tilemap is cut up into 16x16 tile chunks, and each of those chunks is generated as geometry and uploaded to the GPU.

The issue is, if you set_tile(), it has to change the geometry to reflect that, and then re-upload to the card. I’ve found that if you touch a lot of chunks within a frame, you get frame rate stuttering, presumably as it has to regenerate all the geometry for each chunk and upload it all to the GPU.

Your code looks like it might be doing the right thing already, but what I’d advise is maybe only updating one chunk per frame, and making sure that your idea of a map chunk and Godot’s idea of a tilemap chunk are aligned; if your chunks are offset from Godot’s, each of your chunk updates will hit four Godot chunks.

So, maybe make _update_terrain_around_player() keep an updated list of terrain chunks that needs updating, and use it to choose one chunk to update each frame. That should amortize the cost of the updates and reduce (or hopefully eliminate!) the updates.

In my case, I’m pregenerating the whole (512x512 tile) map, so all I can really do is throw an opaque screen with a progress bar over things at level start to hide all the jank.