How to save and load TileMapLayer and rebaking navigation

Godot Version

4.4.1.stable

Question

TL;DR How can I save and restore a procedurally generated TileMapLayer and get the navigation to work.

I procedurally generate the world.
For generating the TileMapLayer I use

@onready var grass_layer: TileMapLayer = %GrassLayer

func _generate_world() -> void:
	grass_layer.set_cells_terrain_connect(grass_tiles, GRASS_DATA["source_id"], 0)

Objects are being added by placing scenes as child of the corresponding TileMapLayer. Example for a tree which has a CollisionShape2D:

func _place_tree(position: Vector2) -> void:
	var x_offset = _rng.randi_range(0, tile_size - object_padding)
	x_offset -= int(tile_size / 2)
	var y_offset = _rng.randi_range(0, tile_size - object_padding)
	y_offset -= int(tile_size / 2)
	position = position + Vector2(x_offset, y_offset)
	
	# If tree is out of border do not instantiate
	if position.x < object_padding or position.x > (_map_size_pixel.x - object_padding) or position.y < object_padding or position.y > (_map_size_pixel.y - object_padding):
		return
	
	var tree_instance = _tree_scene.instantiate() as Node2D
	tree_instance.global_position = position
	tree_layer.add_child(tree_instance)

Since the map may get big, I chunked the navigation using the following reference:
Source Code
Explanation

My Source code:

func _ready() -> void:
	# Other stuff
	call_deferred("_bake_navigation_mesh") # Delay baking until after the tilemap is fully loaded

func _bake_navigation_mesh() -> void:
	print("Generating navigation mesh")
	var source_geometry = _generate_source_geometry()
	_create_region_chunks(region_chunks, source_geometry, chunk_size)

func _generate_source_geometry() -> NavigationMeshSourceGeometryData2D:
	var source_geometry: NavigationMeshSourceGeometryData2D = NavigationMeshSourceGeometryData2D.new()
	var parse_settings: NavigationPolygon = NavigationPolygon.new()
	parse_settings.parsed_geometry_type = NavigationPolygon.PARSED_GEOMETRY_STATIC_COLLIDERS
	NavigationServer2D.parse_source_geometry_data(parse_settings, source_geometry, tilemap)
	var traversable_outline: PackedVector2Array = PackedVector2Array(
		[
			Vector2(0, 0),
			Vector2(_map_size_pixel.x, 0),
			_map_size_pixel,
			Vector2(0, _map_size_pixel.y)
		]
 	)
	source_geometry.add_traversable_outline(traversable_outline)
	return source_geometry

func _create_region_chunks(chunks_root_node: Node2D, p_source_geometry: NavigationMeshSourceGeometryData2D, p_chunk_size: float) -> void:
	# We need to know how many chunks are required for the input geometry.
	# So first get an axis aligned bounding box that covers all vertices.
	var input_geometry_bounds: Rect2 = p_source_geometry.get_bounds()

	# Rasterize bounding box into chunk grid to know range of required chunks.
	var start_chunk: Vector2 = floor(
		input_geometry_bounds.position / p_chunk_size
	)
	var end_chunk: Vector2 = floor(
		(input_geometry_bounds.position + input_geometry_bounds.size)
		/ p_chunk_size
	)

	for chunk_y in range(start_chunk.y, end_chunk.y + 1):
		for chunk_x in range(start_chunk.x, end_chunk.x + 1):
			var chunk_bounding_box: Rect2 = Rect2(
				Vector2(chunk_x, chunk_y) * p_chunk_size,
				Vector2(p_chunk_size, p_chunk_size),
			)
			# We grow the chunk bounding box to include geometry
			# from all the neighbor chunks so edges can align.
			# The border size is the same value as our grow amount so
			# the final navigation mesh ends up with the intended chunk size.
			var baking_bounds: Rect2 = chunk_bounding_box.grow(p_chunk_size)
			
			var chunk_navmesh: NavigationPolygon = NavigationPolygon.new()
			
			chunk_navmesh.baking_rect = baking_bounds
			chunk_navmesh.border_size = p_chunk_size
			chunk_navmesh.agent_radius = nav_agent_radius
			NavigationServer2D.bake_from_source_geometry_data(chunk_navmesh, p_source_geometry)
			
			print("Polygon size: ", chunk_navmesh.get_polygon_count())

			# The only reason we reset the baking bounds here is to not render its debug.
			chunk_navmesh.baking_rect = Rect2()

			# Snap vertex positions to avoid most rasterization issues with float precision.
			var navmesh_vertices: PackedVector2Array = chunk_navmesh.vertices
			for i in navmesh_vertices.size():
				var vertex: Vector2 = navmesh_vertices[i]
				navmesh_vertices[i] = vertex.snappedf(0.1)
			chunk_navmesh.vertices = navmesh_vertices

			var chunk_region: NavigationRegion2D = NavigationRegion2D.new()
			chunk_region.navigation_polygon = chunk_navmesh
			chunks_root_node.add_child(chunk_region)
			navigation_regions.append([chunk_region, chunk_bounding_box])

When generating a new world everything works fine. All tiles are generated, all trees added, the navigation is baked. Navigation is possible and works as expected.

For Saving/Loading mechanism I used this reference video by Rapid Vectors.

My source code for the Resources:

class_name SaveGameDataComponent
extends Node

var game_scene_name: String = SaveGameManager.world_name
var save_game_data_path: String = SaveGameManager.save_game_data_path
var save_file_name: String = SaveGameManager.save_file_name
var game_data_resource: SaveGameDataResource

func _ready() -> void:
	add_to_group("save_game_data_component")
	if game_scene_name == null:
		game_scene_name = get_parent().name

func save_node_data() -> void:
	var nodes = get_tree().get_nodes_in_group("save_data_component")
	
	game_data_resource = SaveGameDataResource.new()
	
	if nodes != null:
		for node in nodes:
			if node is SaveDataComponent:
				var save_data_resource: NodeDataResource = node._save_data()
				var save_final_resource = save_data_resource.duplicate()
				game_data_resource.save_data_nodes.append(save_final_resource)

func save_game() -> void:
	if !DirAccess.dir_exists_absolute(save_game_data_path):
		DirAccess.make_dir_absolute(save_game_data_path)
	
	var game_save_file_name: String = save_file_name % game_scene_name
	
	save_node_data()
	
	var result: int = ResourceSaver.save(game_data_resource, save_game_data_path + game_save_file_name)
	if result != 0:
		push_error("Unable to save! Error code: ", result)
	else:
		print("Game saved successfully")
	
func load_game() -> void:
	var game_save_file_name = save_file_name % game_scene_name
	var save_game_path: String = save_game_data_path + game_save_file_name
	
	if !FileAccess.file_exists(save_game_path):
		return
	
	game_data_resource = ResourceLoader.load(save_game_path)
	
	if game_data_resource == null:
		return
	
	var root_node: Window = get_tree().root
	
	for resource in game_data_resource.save_data_nodes:
		if resource is Resource:
			if resource is NodeDataResource:
				resource._load_data(root_node)
class_name NodeDataResource
extends Resource

@export var global_position: Vector2
@export var node_path: NodePath
@export var parent_node_path: NodePath

func _save_data(node: Node2D) -> void:
	global_position = node.global_position
	node_path = node.get_path()
	
	var parent_node = node.get_parent()
	
	if parent_node != null:
		parent_node_path = parent_node.get_path()

func _load_data(window: Window) -> void:
	pass
class_name TileMapLayerDataResource
extends NodeDataResource

@export var tile_map_data: PackedByteArray


func _save_data(node: Node2D) -> void:
	super._save_data(node)
	
	var tilemap_layer: TileMapLayer = node as TileMapLayer
	tile_map_data = tilemap_layer.tile_map_data


func _load_data(window: Window) -> void:
	var scene_node = window.get_node_or_null(node_path)
	
	if scene_node != null:
		var tilemap_layer: TileMapLayer = scene_node as TileMapLayer
		tilemap_layer.tile_map_data = tile_map_data

My issue

Loading itself works. All the tiles are set, all trees exist, etc.
Setting Collision Visibility Mode to Force Show displays the collision correctly on the TileMapLayers. Yet the navigation does not work.

Debugging tells me, that source_geometry.get_bounds() seems to be correct. All NavigationRegion2D are being generated. The only difference I was able to spot so far is that chunk_navmesh.get_polygon_count() is always 0 when loading. This seems to be the issue since I think this means no collision-objects have been detected to calculate the navigation with.

I have set Visible Navigation to True which displays the navigation regions correctly when generating a new world, but nothing is visible when loading a world.

I guess somehow I have to tell the NavigationServer2D something I dont’ know yet.

What else have I tried

I also tried saving/loading each cell using set_cell

func _save_data(node: Node2D) -> void:
	super._save_data(node)
	
	var tilemap_layer: TileMapLayer = node as TileMapLayer
	for cell in tilemap_layer.get_used_cells():
		var atlas_coords = tilemap_layer.get_cell_atlas_coords(cell)
		var source_id = tilemap_layer.get_cell_source_id(cell)
		var alternative_tile = tilemap_layer.get_cell_alternative_tile(cell)
		tilemap_info.append([cell, atlas_coords, source_id, alternative_tile])


func _load_data(window: Window) -> void:
	var scene_node = window.get_node_or_null(node_path)
	
	if scene_node != null:
		var tilemap_layer: TileMapLayer = scene_node as TileMapLayer
		for cell in tilemap_info:
			var coords = cell[0]
			var atlas_coords = cell[1]
			var source_id = cell[2]
			var alternative_tile = cell[3]
			tilemap_layer.set_cell(coords, source_id, atlas_coords, alternative_tile)

I also thought it might be a timing issue. So I added a manual baking trigger but the result is the same.

func _input(_event: InputEvent) -> void:
	if Input.is_action_just_pressed("test"):
		print("Manually generating navigation mesh")
		_bake_navigation_mesh()

As far as the navigation system is concerned all you need to do is load your navmesh, get your region, and use NavigationServer2D.region_set_navigation_polygon().

I am not sure what the TileMap is all doing on its internals but I am sure that it does a lot of stuff deferred, aka ~1 frame delayed. If you wait 1-2 frames at it fixes all the runtime Tilemap issues that would be the reason.

Have you got an example for this or maybe a hint where I get the parameters from?
NavigationServer2D.region_set_navigation_polygon requires a region_rid and a navigation_mesh. I guess the navigation_mesh are my chunks? But do I then set this for each chunk or can I somehow get the entire map into this?
And where do I get the region_rid from? I found this example in the documentation but get_world_2d().get_navigation_map() does not seem to exist anymore. I tried it with get_viewport().world_2d.navigation_map but no luck so far. I also tried to create a new region using NavigationServer2D.region_create()

As you may notice am quite the beginner and have no clue about navigation yet.