Saving generated navmesh chunks to scene file (from editor script)

Godot Version

4.3

Question

` TLDR : is it even possible?

Long story is that im generating large maps (by hand) using tilemaps, with scene tiles for things like interactable structures, etc., and trying to chunk them with a slightly modified version of the navchunk demo to work as an editor script.

@tool
extends EditorScript

static var rootScene:Node
static var map_cell_size: float = 16.0
static var chunk_size: int = 64
var offsetposition:Vector2
var mapSize:Vector2 
static var cell_size: float = 16.0
static var agent_radius: float = 32.0
static var chunk_id_to_region: Dictionary = {}
static var groupName:String = "startArea"
static var chunkContainer:NavigationRegion2D
var boundsArea:ColorRect
var navContainer:Node
var mapArea:Vector2
var mapOffset:Vector2

var path_start_position: Vector2

func _run():
	rootScene = get_scene()
	#navContainer = Node.new()
	#rootScene.add_child(navContainer)
	#navContainer.add_to_group("nav")
	chunkContainer = rootScene.get_node("ChunkContainer")
	var arr:Array
	arr.append_array(chunkContainer.get_children())
	for i:int in arr.size()-1:
		chunkContainer.remove_child(arr[i])
		arr[i].queue_free()
	arr.clear()
	
	boundsArea = rootScene.get_node("NavArea")
	
	mapArea = boundsArea.size
	mapOffset = boundsArea.global_position
	
	NavigationServer2D.set_debug_enabled(true)

	#path_start_position = %DebugPaths.global_position

	var map: RID = rootScene.get_world_2d().navigation_map
	NavigationServer2D.map_set_cell_size(map, map_cell_size)

	# Disable performance costly edge connection margin feature.
	# This feature is not needed to merge navigation mesh edges.
	# If edges are well aligned they will merge just fine by edge key.
	NavigationServer2D.map_set_use_edge_connections(map, false)

	# Parse the collision shapes below our parse root node.
	var source_geometry: NavigationMeshSourceGeometryData2D = NavigationMeshSourceGeometryData2D.new()
	var parse_settings: NavigationPolygon = NavigationPolygon.new()
	parse_settings.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_EXPLICIT
	parse_settings.source_geometry_group_name = groupName
	parse_settings.parsed_collision_mask = 1
	parse_settings.parsed_geometry_type = NavigationPolygon.PARSED_GEOMETRY_STATIC_COLLIDERS
	NavigationServer2D.parse_source_geometry_data(parse_settings, source_geometry, rootScene)

	# Add an outline to define the traversable surface that the parsed collision shapes can "cut" into.
	var traversable_outline: PackedVector2Array = PackedVector2Array([
		mapOffset,
		Vector2(mapOffset.x+mapArea.x, mapOffset.y),
		Vector2(mapOffset.x+mapArea.x, mapOffset.y+mapArea.y),
		Vector2(mapOffset.x, mapOffset.y+mapArea.y),
	])
	source_geometry.add_traversable_outline(traversable_outline)

	create_region_chunks(chunkContainer, source_geometry, chunk_size * cell_size, agent_radius)


static func create_region_chunks(chunks_root_node: Node, p_source_geometry: NavigationMeshSourceGeometryData2D, p_chunk_size: float, p_agent_radius: 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 = calculate_source_geometry_bounds(p_source_geometry)

	# 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_id: Vector2i = Vector2i(chunk_x, chunk_y)

			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.parsed_geometry_type = NavigationPolygon.PARSED_GEOMETRY_STATIC_COLLIDERS
			chunk_navmesh.baking_rect = baking_bounds
			chunk_navmesh.border_size = p_chunk_size
			chunk_navmesh.agent_radius = p_agent_radius
			NavigationServer2D.bake_from_source_geometry_data(chunk_navmesh, p_source_geometry)

			# 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(map_cell_size * 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)
			chunk_region.owner = chunk_region.get_node("..")
			chunk_id_to_region[chunk_id] = chunk_region
			


static func calculate_source_geometry_bounds(p_source_geometry: NavigationMeshSourceGeometryData2D) -> Rect2:
	if p_source_geometry.has_method("get_bounds"):
		# Godot 4.3 Patch added get_bounds() function that does the same but faster.
		return p_source_geometry.call("get_bounds")

	var bounds: Rect2 = Rect2()
	var first_vertex: bool = true

	for traversable_outline: PackedVector2Array in p_source_geometry.get_traversable_outlines():
		for traversable_point: Vector2 in traversable_outline:
			if first_vertex:
				first_vertex = false
				bounds.position = traversable_point
			else:
				bounds = bounds.expand(traversable_point)

	for obstruction_outline: PackedVector2Array in p_source_geometry.get_obstruction_outlines():
		for obstruction_point: Vector2 in obstruction_outline:
			if first_vertex:
				first_vertex = false
				bounds.position = obstruction_point
			else:
				bounds = bounds.expand(obstruction_point)

	for projected_obstruction: Dictionary in p_source_geometry.get_projected_obstructions():
		var projected_obstruction_vertices: PackedFloat32Array = projected_obstruction["vertices"]
		for i in projected_obstruction_vertices.size() / 2:
			var vertex: Vector2 = Vector2(projected_obstruction_vertices[i * 2], projected_obstruction_vertices[i * 2 + 1])
			if first_vertex:
				first_vertex = false
				bounds.position = vertex
			else:
				bounds = bounds.expand(vertex)

	return bounds

#
#func _process(_delta: float) -> void:
	#var mouse_cursor_position: Vector2 = get_global_mouse_position()
#
	#var map: RID = get_world_2d().navigation_map
	## Do not query when the map has never synchronized and is empty.
	#if NavigationServer2D.map_get_iteration_id(map) == 0:
		#return
#
	#var closest_point_on_navmesh: Vector2 = NavigationServer2D.map_get_closest_point(
		#map,
		#mouse_cursor_position
	#)
#
	#if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
		#path_start_position = closest_point_on_navmesh
#
	#%DebugPaths.global_position = path_start_position
#
	#%PathDebugCorridorFunnel.target_position = closest_point_on_navmesh
	#%PathDebugEdgeCentered.target_position = closest_point_on_navmesh
#
	#%PathDebugCorridorFunnel.get_next_path_position()
	#%PathDebugEdgeCentered.get_next_path_position()

Chunks generate fine using both scenetile and tilemap collision geo, however the problem lies in that i have no idea how to save said data with the scene. All the tutorials ive found assume these operations are happening at runtime as opposed to in the editor, which isnt the behavior i want.

The closest ive seen to what im looking for is packing the chunks into bytearrays and saving them to a file. I can do that, but im wondering if theres a better way that would allow me to save the navchunks with the map file using a more “built in” approach

Any suggestions would be greatly appreciated… :grinning:

So being the impatient type, I decided to pursue saving and loading the navigation mesh chunks to and from a file. Now im able to save and load it successfully, but only in editor? For some reason everything gets borked in game the second i try to add the chunks to a node2d (or anything in the scene tree for that matter), and as far as i can tell it needs to be added somewhere in order to be recognized by the navigation server. Unsure how to proceed.

Nav Baking and saving script:

@tool
extends EditorScript

static var rootScene:Node
static var map_cell_size: float = 16.0
static var chunk_size: int = 128
var offsetposition:Vector2
var mapSize:Vector2 
static var cell_size: float = 16.0
static var agent_radius: float = 32.0
static var chunk_id_to_region: Dictionary = {}
static var groupName:String = "startArea"
static var chunkContainer:Node2D
var boundsArea:ColorRect
var navContainer:Node
var mapArea:Vector2
var mapOffset:Vector2

var path_start_position: Vector2

func _run():
	rootScene = get_scene()
	#navContainer = Node.new()
	#rootScene.add_child(navContainer)
	#navContainer.add_to_group("nav")
	chunkContainer = rootScene.get_node("ChunkContainer")
	var arr:Array
	arr.append_array(chunkContainer.get_children())
	for i:int in arr.size()-1:
		chunkContainer.remove_child(arr[i])
		arr[i].queue_free()
	arr.clear()
	
	boundsArea = rootScene.get_node("NavArea")
	
	mapArea = boundsArea.size
	mapOffset = boundsArea.global_position
	
	NavigationServer2D.set_debug_enabled(true)

	#path_start_position = %DebugPaths.global_position

	var map: RID = rootScene.get_world_2d().navigation_map
	NavigationServer2D.map_set_cell_size(map, map_cell_size)

	# Disable performance costly edge connection margin feature.
	# This feature is not needed to merge navigation mesh edges.
	# If edges are well aligned they will merge just fine by edge key.
	NavigationServer2D.map_set_use_edge_connections(map, false)

	# Parse the collision shapes below our parse root node.
	var source_geometry: NavigationMeshSourceGeometryData2D = NavigationMeshSourceGeometryData2D.new()
	var parse_settings: NavigationPolygon = NavigationPolygon.new()
	parse_settings.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_EXPLICIT
	parse_settings.source_geometry_group_name = groupName
	parse_settings.parsed_collision_mask = 1
	parse_settings.parsed_geometry_type = NavigationPolygon.PARSED_GEOMETRY_STATIC_COLLIDERS
	NavigationServer2D.parse_source_geometry_data(parse_settings, source_geometry, rootScene)

	# Add an outline to define the traversable surface that the parsed collision shapes can "cut" into.
	var traversable_outline: PackedVector2Array = PackedVector2Array([
		mapOffset,
		Vector2(mapOffset.x+mapArea.x, mapOffset.y),
		Vector2(mapOffset.x+mapArea.x, mapOffset.y+mapArea.y),
		Vector2(mapOffset.x, mapOffset.y+mapArea.y),
	])
	source_geometry.add_traversable_outline(traversable_outline)

	create_region_chunks(chunkContainer, source_geometry, chunk_size * cell_size, agent_radius)
	
	save_navigation_map()

static func create_region_chunks(chunks_root_node: Node, p_source_geometry: NavigationMeshSourceGeometryData2D, p_chunk_size: float, p_agent_radius: 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 = calculate_source_geometry_bounds(p_source_geometry)

	# 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_id: Vector2i = Vector2i(chunk_x, chunk_y)

			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.parsed_geometry_type = NavigationPolygon.PARSED_GEOMETRY_STATIC_COLLIDERS
			chunk_navmesh.baking_rect = baking_bounds
			chunk_navmesh.border_size = p_chunk_size
			chunk_navmesh.agent_radius = p_agent_radius
			NavigationServer2D.bake_from_source_geometry_data(chunk_navmesh, p_source_geometry)

			# 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(map_cell_size * 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)
			chunk_id_to_region[chunk_id] = chunk_region
			


static func calculate_source_geometry_bounds(p_source_geometry: NavigationMeshSourceGeometryData2D) -> Rect2:
	if p_source_geometry.has_method("get_bounds"):
		# Godot 4.3 Patch added get_bounds() function that does the same but faster.
		return p_source_geometry.call("get_bounds")

	var bounds: Rect2 = Rect2()
	var first_vertex: bool = true

	for traversable_outline: PackedVector2Array in p_source_geometry.get_traversable_outlines():
		for traversable_point: Vector2 in traversable_outline:
			if first_vertex:
				first_vertex = false
				bounds.position = traversable_point
			else:
				bounds = bounds.expand(traversable_point)

	for obstruction_outline: PackedVector2Array in p_source_geometry.get_obstruction_outlines():
		for obstruction_point: Vector2 in obstruction_outline:
			if first_vertex:
				first_vertex = false
				bounds.position = obstruction_point
			else:
				bounds = bounds.expand(obstruction_point)

	for projected_obstruction: Dictionary in p_source_geometry.get_projected_obstructions():
		var projected_obstruction_vertices: PackedFloat32Array = projected_obstruction["vertices"]
		for i in projected_obstruction_vertices.size() / 2:
			var vertex: Vector2 = Vector2(projected_obstruction_vertices[i * 2], projected_obstruction_vertices[i * 2 + 1])
			if first_vertex:
				first_vertex = false
				bounds.position = vertex
			else:
				bounds = bounds.expand(vertex)

	return bounds

func save_navigation_map()->void:
	var mapDict:Dictionary
	var namestring = rootScene.name
	mapDict.name = namestring
	mapDict.chunkCount = 0
	var chunks:Array[Dictionary]
	var verts:Array[PackedVector2Array]
	var indices:PackedInt32Array
	var index:int = 0
	
	
	for n:NavigationRegion2D in chunkContainer.get_children():
		var chunkDict:Dictionary
		chunkDict.global_position = n.global_position
		
		var np:NavigationPolygon = n.navigation_polygon
		var v:PackedVector2Array
		v.append_array(np.vertices)
		index += 1
		chunkDict.vertices = v
		
		var polyList:Array[PackedInt32Array]
		for i:int in np.get_polygon_count():
			var p:PackedInt32Array
			p.append_array(np.get_polygon(i))
			polyList.append(p)
		
		chunkDict.polygons = polyList
		chunks.append(chunkDict)
		pass
	
	mapDict.chunkCount = chunks.size()
	mapDict.chunkArray = chunks
	save_nav_to_file(mapDict)

func save_nav_to_file(map:Dictionary)->void:
	var savePath:String ="res://Assets/Navigation/navMaps.tres"
	#var file = FileAccess.open(savePath,FileAccess.WRITE)
	#file.store_var(packed)
	#file.close()
	var loadMaps:NavResource
	if ResourceLoader.exists(savePath):
		loadMaps = load(savePath)
	else:
		loadMaps = NavResource.new()
	
	loadMaps.maps[map.name]= map
	ResourceSaver.save(loadMaps)
	pass

Editor Load script:

@tool
extends EditorScript

func _run() -> void:
	load_navigation("startArea")

func load_navigation(mapName:String)->void:
	#
	var filePath:String = "res://Assets/Navigation/navMaps.tres"
	var navMaps:Dictionary
	var navMap:Dictionary
	
	if ResourceLoader.exists(filePath):
		var loadFile:NavResource = load(filePath)
		navMaps = loadFile.maps
		if navMaps[mapName].is_empty() == true:
			print("NO MAP EXISTS")
			return
		navMap = navMaps[mapName]
	else:
		print("NO MAPFILE EXISTS")
		return
	
	var i:int = 0
	for d:Dictionary in navMap.chunkArray:
		i+=1
		var region:NavigationRegion2D = NavigationRegion2D.new()
		var poly:NavigationPolygon =NavigationPolygon.new()
		poly.vertices = d.vertices
		
		for pia:PackedInt32Array in d.polygons:
			poly.add_polygon(pia)
			
		region.navigation_polygon = poly
		var scene:Node2D = get_scene()
		var navCon:Node2D = scene.get_node("navCon")
		navCon.add_child(region)
	

Runtime Load script:

extends Node
class_name NavBake_RunTime

signal navComplete

func load_navigation(mapName:String)->void:
	#
	var filePath:String = "res://Assets/Navigation/navMaps.tres"
	#
	var navMaps:Dictionary
	var navMap:Dictionary
	
	if ResourceLoader.exists(filePath):
		var loadFile:NavResource = load(filePath)
		navMaps = loadFile.maps
		if navMaps[mapName].is_empty() == true:
			print("NO MAP EXISTS")
			return
		navMap = navMaps[mapName]
	else:
		print("NO MAPFILE EXISTS")
		return
	
	var game:GameLoop = Globals.gameManager.gameScreen
	var i:int = 0
	for d:Dictionary in navMap.chunkArray:
		i+=1
		var region:NavigationRegion2D = NavigationRegion2D.new()
		var poly:NavigationPolygon =NavigationPolygon.new()
		poly.vertices = d.vertices
		
		for pia:PackedInt32Array in d.polygons:
			poly.add_polygon(pia)
			
		region.navigation_polygon = poly
		game.navCon.add_child(region)
	
	navComplete.emit()
	

While I did not necessarily spot something the problem is still very likely buried inside all that noisy custom code or usage of tool scripting.

For a navigation mesh resource to save and load all you do is save or load it to a binary .res file with the ResourceSaver or ResourceLoader singletons and set that navigation mesh resource on the NavigationRegion2D node (again). There is nothing more special to it.

No need to fumble with manual byte arrays. If you store raw data arrays as a generic custom resource as a .tres file you are storing everything as raw text. That is slowest and most error prone method with the biggest file size that you could pick so better avoid doing that.

Thank you, as i said in my first post i was hoping for a better way just wasnt able to find much info on it!