Imported object and Seperate Navmesh

Godot Version

v4.6.1.stable.official [14d19694e]

Question

My question is 2 fold:

I currently have a somewhat working navmesh that Rebake the navmesh whenever a new building is placed on the mesh.
I’ve splitted the map into 5 different meshes to have the Rebake only be on the important sections has needed (Chunck rebaking)

I’m working on making use of Imported assets, but I need to “Split” the object into pieces, but only for the navmesh itself. 1 part for the player, 1 part for the opponent, a middle where the actions occurs (Fighting) and 2 side part without navmesh where decors will be placed.
Is it possible via 1 object in Godot or does it need to be split direclty from the 3D software?

I’m trying to find a good tutorial, but they mostly handle “How to make 1 unified navmesh”
Also in the similar vein, is importing OBJ directly fine for that kind of use (Static objects) or should I make sure to convert them into glTF 2.0?

As usual, I feel like I’m creating a complex monster, while the solution is very simple.

If it helps visualise my end goal
From the table’s Top view, the Table is not split in multiple parts, it is one single piece.


So basically, 3 “Walkable” Navmesh in the middle. So I can make only the Buildable parts Rebake when needed.

I’ve stumbled upon the Godot Demo project, with the NavMesh Chunk

My problem is I’m a bit and unsure if it is the solution I’m looking for.
I’ve managed to create 3 navmeshes, that works, But as soon as I rebake them, they disappear and I must delete/create new ones.

I feel close to a solution, but get stumped.

I had a working rebake and everything, but my genius mind forgot to make a simple back-up scene and lost my previously working Navmesh.

The main reason I need to rebake, is when the user or the enemy adds building to the navmesh. It carves a square-ish holes into the navmesh to path around.

Now, it either “deletes” the whole navmesh OR does nothing at all.

I’ve managed to make the Navmesh rebake work. I see the navmesh change upon activating a building.

Problem is that the carving itself doesn’t work. It use to work before but scene hierachy changed. Buildings are added Outside the “Mesh” (Table.obj)

From what I understand, the solution would be to find a way to add the Building entity under the right “NavMesh_Faction” depending on which team it suppose to be a part of.

class_name Buildable
extends Node3D

## signals

## enums
## consts
## exports
@export var build_time: float = 5.0
@export var footprint_size: Vector2i = Vector2i(2, 2)  # width (x), depth (z) in grid cells
@export var size_tier: int = 1  # T1 = 1, T2 = 2, ..., T4 = 4
@export var build_cost: int = 50
@export var income_bonus: float = 0 
@export var spawn_count: int = 1

## public var
var is_building_started := false
var is_active: bool = false
var owner_builder: BuilderCharacter = null
var is_destroyed := false
var applied_upgrades: Array[Dictionary] = []



## private var
## onready var
@onready var base_spawn_interval:float = get("spawn_interval")
@onready var base_income_bonus:float = get("income_bonus")
@onready var base_defense:float = get("defense")
@onready var base_spawn_count: int = spawn_count

#"obj_" for node references:
## built-in override methods

func _ready() -> void:
	add_to_group("obstacle")
	pass


func _process(_delta: float) -> void:
	pass


## public methods

func get_construction_point(world: World3D) -> Vector3:
	var pos := global_position
	if has_node("BuildTriggerArea"):
		pos = $BuildTriggerArea.global_position
	var nav_map := world.navigation_map
	return NavigationServer3D.map_get_closest_point(nav_map, pos)

func start_build_process(builder: BuilderCharacter) -> void:
	is_building_started = true
	owner_builder = builder
	remove_from_group("player")
	remove_from_group("enemy")
	add_to_group(builder.faction)
	print("[BUILD START] by", builder.name, "Faction:", builder.faction)
	builder.begin_build(self, build_time)
	print("Enemy AI building process:", build_time)

func request_build(builder: BuilderCharacter) -> void:
	if not is_building_started:
		is_building_started = true
		owner_builder = builder
		remove_from_group("player")
		remove_from_group("enemy")
		add_to_group(builder.faction)
		builder.enqueue_build(self, build_time)

func activate_building() -> void:
	is_active = true
	add_to_group("targetable")
	add_to_group("obstacle")
	if owner_builder:
		add_to_group(owner_builder.faction)
	print("Building activated:", name, "Groups:", get_groups())
	
	var blocker := get_node_or_null("NavBlocker")
	if blocker:
		var shape := blocker.get_node("CollisionShape3D")
		if shape and shape.shape is BoxShape3D:
			var box := shape.shape as BoxShape3D
			var center := global_position
			var radius: float = max(box.size.x, box.size.z) * 0.6
			push_entities_out_of_footprint(center, radius)
	setup_navmesh_carve(null)
	reapply_all_building_carves()
	recalculate_stats()
	# Override this in child classes

func get_radius() -> float:
	return self.radius

func recalculate_stats():
	var stats:Dictionary = GameState.final_stats.compute_building(self)
	
	if stats.has("spawn_interval") and get("spawn_interval") != null:
		set("spawn_interval", stats["spawn_interval"])
	if stats.has("income_bonus") and get("income_bonus") != null:
		set("income_bonus", stats["income_bonus"])
	if stats.has("defense") and get("defense") != null:
		set("defense", stats["defense"])
	if stats.has("spawn_count") and get("spawn_count") != null:
		set("spawn_count", stats["spawn_count"])


## private methods

func setup_navmesh_carve(nav_region: NavigationRegion3D = null) -> void:
	var blocker := get_node_or_null("NavBlocker")
	if blocker:
		blocker.collision_layer = 1 << 15
		blocker.collision_mask = 1 << 15
		var shape := blocker.get_node_or_null("CollisionShape3D")
		print("Shape:", shape, "has shape:", shape.shape if shape else null)
		if shape and shape.shape:
			var box := shape.shape as BoxShape3D
			var sx := box.size.x * 0.5
			var sz := box.size.z * 0.5
			var obstacle := get_node_or_null("NavigationObstacle3D")
			if obstacle:
				obstacle.vertices = PackedVector3Array([
				Vector3(-sx, 0, -sz),
				Vector3( sx, 0, -sz),
				Vector3( sx, 0,  sz),
				Vector3(-sx, 0,  sz),])
				obstacle.affect_navigation_mesh = true
				obstacle.carve_navigation_mesh = true
				print("Set obstacle verts:", obstacle.vertices.size())
	
	if nav_region == null:
		var group_name := ""
		if is_in_group("player"):
			group_name = "player_area"
		elif is_in_group("enemy"):
			group_name = "enemy_area"
	
		for region in get_tree().get_nodes_in_group(group_name):
			if region is NavigationRegion3D:
				print("Region: ", region.name , group_name , " polygons: ", region.navigation_mesh.get_polygon_count())
				nav_region = region
				break
	
	if nav_region:
		nav_region.bake_navigation_mesh()

func push_entities_out_of_footprint(center: Vector3, radius: float) -> void:
	var space := get_world_3d().direct_space_state
	var sphere := SphereShape3D.new()
	sphere.radius = radius

	var query := PhysicsShapeQueryParameters3D.new()
	query.shape = sphere
	query.transform = Transform3D(Basis(), center)
	query.collision_mask = (1)|(1<<13)  # Layer for units/builders
	query.collide_with_areas = false
	query.collide_with_bodies = true

	var results := space.intersect_shape(query, 32)
	for result in results:
		var body:Node3D = result.collider
		if body and (body.is_in_group("units") or body.is_in_group("builder")):
			var dir := (body.global_position - center).normalized()
			var push_dist := radius + 0.5
			var safe_pos := NavigationServer3D.map_get_closest_point(NavigationServer3D.get_maps()[0], center + dir * push_dist)
			body.global_position = safe_pos
			print("Pushed entity:", body.name)

func reapply_all_building_carves() -> void:
	for b in get_tree().get_nodes_in_group("obstacle"):
		if b is Buildable and b.is_active:
			b.setup_navmesh_carve(null)

func handle_death() -> void:
	if is_destroyed:
		return
	is_destroyed = true
	
	var obstacle := get_node_or_null("NavigationObstacle3D")
	if obstacle:
		obstacle.affect_navigation_mesh = false
		obstacle.carve_navigation_mesh = false
		obstacle.visible = false
		obstacle.queue_free()
	
	var blocker := get_node_or_null("NavBlocker")
	if blocker:
		blocker.queue_free()
	
	var shape := get_node_or_null("NavBlocker/CollisionShape3D")
	if shape:
		shape.disabled = true
	
	await get_tree().physics_frame
	await get_tree().physics_frame
	if obstacle and obstacle.is_inside_tree():
		print("Warning: obstacle still in tree before rebake")
	
	var group_name := ""
	if is_in_group("player"):
		group_name = "player_area"
	elif is_in_group("enemy"):
		group_name = "enemy_area"
	for region in get_tree().get_nodes_in_group(group_name):
		if region is NavigationRegion3D:
			region.bake_navigation_mesh()
			print("Rebaked navmesh for", group_name, "after removing:", name)
	queue_free()

func apply_upgrade(upg: Dictionary) -> void:
	var upg_id:String= upg.get("action", "")
	for existing in applied_upgrades:
		if existing.get("action", "") == upg_id:
			print("Upgrade already applied:", upg_id)
			return  # Skip duplicate
	
	applied_upgrades.append(upg)
	print("Registered upgrade:", upg_id)

func get_applied_upgrades() -> Array:
	return applied_upgrades

I know my code is horrible, but it is working for the most part (Carving being the not working part)

So I figured out that the building I’m spawning into the scene is being added outside of the navmesh.
That in itself isn’t a problem, but since I have the NavObstacle as a child of the building spawned, the building also need to be a child of the NavigationMesh

I also understood the “Chunk loading” isn’t going to work for what I need.
Now I need to find a way to add the building as a child without breaking everything (Or fixing bit by bit until it works)

OIr add a script that generates a NavObstacle inside the right NavMesh upon spawning. Would it be possible?

Progress, I’ve returned to the building carving the navmesh and rebaking
Didn’t need to change my code from before.

New problem, the Carving

func setup_navmesh_carve(nav_region: NavigationRegion3D = null) -> void:
	var blocker := get_node_or_null("NavBlocker")
	if blocker:
		blocker.collision_layer = 1 << 15
		blocker.collision_mask = 1 << 15
		var shape := blocker.get_node_or_null("CollisionShape3D")
		print("Shape:", shape, "has shape:", shape.shape if shape else null)
		if shape and shape.shape:
			var box := shape.shape as BoxShape3D
			var sx := box.size.x * 0.5
			var sz := box.size.z * 0.5
			var obstacle := get_node_or_null("NavigationObstacle3D")
			if obstacle:
				obstacle.vertices = PackedVector3Array([
				Vector3(-sx, 0, -sz),
				Vector3( sx, 0, -sz),
				Vector3( sx, 0,  sz),
				Vector3(-sx, 0,  sz),])
				obstacle.affect_navigation_mesh = true
				obstacle.carve_navigation_mesh = true
	
	if nav_region == null:
		var group_name := ""
		if is_in_group("player"):
			group_name = "player_area"
		elif is_in_group("enemy"):
			group_name = "enemy_area"
	
		for region in get_tree().get_nodes_in_group(group_name):
			if region is NavigationRegion3D:
				nav_region = region
				break
	
	if nav_region:
		nav_region.bake_navigation_mesh()

The code used to generated a carving that was more akin to a slightly wide hexagon shape (not sure why). It was very thight around the object, so it was perfect for what I needed.

Now the shape size depends on where the building is placed on the navmesh and sometimes it doesn’t remove the navmesh above and inside the object.

They keep rebaking differently depending on where I place them.
Make it make sense!?

I’ve managed to find and fix the problem.
It not a perfect fix, but I can now move on to the next feature that I need to fix.

Nevermind, the problem still persist. Back to square one