3D Navigation¶
SimVX provides 3D navigation mesh pathfinding through NavigationMesh3D, a server/agent architecture, and dynamic obstacle avoidance.
NavigationMesh3D¶
Defines walkable geometry as triangles and performs A* pathfinding over the triangle adjacency graph.
Manual Construction¶
from simvx.core import NavigationMesh3D, Vec3
navmesh = NavigationMesh3D()
# Add walkable triangles
navmesh.add_triangle(Vec3(0, 0, 0), Vec3(10, 0, 0), Vec3(10, 0, 10))
navmesh.add_triangle(Vec3(0, 0, 0), Vec3(10, 0, 10), Vec3(0, 0, 10))
# Or add polygons (auto-triangulated via fan)
navmesh.add_polygon([Vec3(20, 0, 0), Vec3(30, 0, 0), Vec3(30, 0, 10), Vec3(20, 0, 10)])
path = navmesh.find_path(Vec3(1, 0, 1), Vec3(25, 0, 5))
# Returns list of Vec3 waypoints, or [] if unreachable
Baking from Level Geometry¶
Generate a navmesh automatically from mesh data using a Recast-style voxelization pipeline:
import numpy as np
vertices = np.array([...], dtype=np.float32) # (N, 3) mesh vertices
indices = np.array([...], dtype=np.int32) # (M, 3) triangle indices
navmesh.bake_from_geometry(
vertices, indices,
agent_radius=0.5, # capsule radius for erosion
agent_height=2.0, # minimum clearance
max_slope=45.0, # walkable slope limit (degrees)
cell_size=0.3, # horizontal voxel size
cell_height=0.2, # vertical voxel size
)
Carving Obstacles¶
add_obstacle() subtracts an XZ polygon from the walkable area. Every triangle
the polygon overlaps, by any amount, stops being walkable: it is excluded from
pathfinding and from every spatial query below.
navmesh.add_polygon(
[Vec3(-15, 0, -15), Vec3(15, 0, -15), Vec3(15, 0, 15), Vec3(-15, 0, 15)],
cell_size=1.0, # fan triangulation is far too coarse to carve
)
navmesh.add_obstacle([Vec3(2, 0, 2), Vec3(6, 0, 2), Vec3(6, 0, 5), Vec3(2, 0, 5)])
The carve works at the resolution of the mesh, since a triangle is either
walkable or it is not. The resulting hole is therefore never smaller than the
obstacle, and never larger than the obstacle grown by one triangle of the
mesh it is cut from. Pass a cell_size small relative to your obstacles, as
above, or the rounding will be visible. Obstacles may be concave, and one small
enough to sit entirely inside a single triangle still carves it.
Spatial Queries¶
Every query below reports the walkable surface, so a carved obstacle is never a valid answer. Each is bounded by a distance you supply, which is what makes “the player clicked on a wall” distinguishable from “the player clicked on the floor”, but each says so in its own way:
# get_closest_point: the nearest walkable point within max_distance, else None
closest = navmesh.get_closest_point(Vec3(5, 10, 5), max_distance=5.0)
# is_point_on_mesh: True when walkable surface is within tolerance, else False
on_mesh = navmesh.is_point_on_mesh(Vec3(5, 0, 5), tolerance=0.5)
# sample_position: a random walkable point within radius of the centre, or None
# if none of max_attempts random samples landed on one. radius is the size of
# the region sampled, not a bound on how far a returned point may be snapped.
pos = navmesh.sample_position(Vec3(5, 0, 5), radius=10.0)
find_path() puts its first and last waypoints on the walkable surface too, so
a route that correctly skirts an obstacle does not then finish inside it. That
move is in the XZ plane only: an endpoint keeps the height it was given, so an
agent whose origin sits above the floor is not pathed from the floor. Where
storeys stack over one footprint, height is what separates them: an endpoint
matches the candidate surface vertically nearest it, so an agent on a gallery
routes along the gallery rather than along the floor beneath it. Give the call a
max_distance to refuse endpoints that are nowhere near the mesh rather than
dragging them in from across the level:
path = navmesh.find_path(player.position, click_position, max_distance=5.0)
if not path:
... # click was off the navmesh, or unreachable
NavigationServer3D¶
Singleton that manages all navigation regions and provides unified pathfinding across them.
from simvx.core import NavigationServer3D
server = NavigationServer3D.get_singleton()
# Find path across all active regions
path = server.find_path(Vec3(0, 0, 0), Vec3(50, 0, 50))
# Snap to the closest walkable point on any enabled region, or None
closest = server.get_closest_point(Vec3(25, 5, 25), max_distance=5.0)
NavigationRegion3D¶
A scene node that holds a NavigationMesh3D and auto-registers it with the server when added to the tree.
from simvx.core import NavigationRegion3D, NavigationMesh3D
navmesh = NavigationMesh3D()
# ... populate navmesh ...
region = NavigationRegion3D(navigation_mesh=navmesh)
scene.add_child(region) # Auto-registers with NavigationServer3D
region.enabled = False # Temporarily exclude from pathfinding
NavigationAgent3D¶
Pathfinding agent that computes a steering velocity (waypoint following plus obstacle avoidance) along a navigation mesh path. The agent moves nothing by itself: attach it as a child of the node you want to move, so it shares that node’s world position, and apply agent.velocity to the parent each physics frame.
from simvx.core import NavigationAgent3D, Node3D, Vec3
class Enemy(Node3D):
def on_ready(self):
self.agent = NavigationAgent3D()
self.agent.max_speed = 8.0
self.agent.avoidance_radius = 0.6
self.agent.navigation_finished.connect(self._on_arrived)
self.add_child(self.agent)
self.agent.target_position = Vec3(50, 0, 50)
def on_fixed_update(self, dt):
self.position = self.position + self.agent.velocity * dt
def _on_arrived(self):
print("Reached destination!")
A CharacterBody3D parent should hand the steering to the character instead, so collisions
are resolved. Take only the horizontal components, so whatever the character is doing
vertically (gravity, a jump) survives, and build a new vector rather than assigning the
agent’s own, which would otherwise be shared and drift as the character integrates:
def on_fixed_update(self, dt):
steering = self.agent.velocity
self.velocity = Vec3(steering.x, self.velocity.y, steering.z)
self.move_and_slide(dt)
Cancelling an order¶
stop() cancels the current navigation: it drops the path, zeroes velocity and keeps the
agent inert until a new target_position is assigned.
self.agent.stop() # unit retargeted, killed, or switched to another behaviour
Settings¶
Setting |
Default |
Description |
|---|---|---|
|
|
Maximum movement speed |
|
|
Distance to waypoint before advancing |
|
|
Distance to target to consider reached |
|
|
Radius for obstacle avoidance |
Signals¶
navigation_finished: Emitted when the agent reaches its target. Deliberately not emitted bystop(), since a cancelled order never reached its target.
NavigationObstacle3D¶
Dynamic obstacle that agents steer around at runtime. Does not carve the navmesh: agents detect obstacles and adjust their velocity.
from simvx.core import NavigationObstacle3D
obstacle = NavigationObstacle3D()
obstacle.radius = 2.0
obstacle.height = 3.0
scene.add_child(obstacle) # Auto-registers with server
API Reference¶
See simvx.core.navigation3d for the complete 3D navigation API.