Navigation meshes
Build a walkable navigation mesh from scene geometry and query shortest paths between two world points.
A navigation mesh is a simplified triangulated surface that covers the walkable area of a scene. Pathfinding runs over this surface rather than the full scene geometry, so queries stay fast regardless of how detailed the world is.
Meep’s navmesh is a NavigationMesh - a topology of triangular faces with a BVH sitting on top. All path queries go through the same object.
Building a navmesh
NavigationMesh.build takes your scene geometry as a BinaryTopology and an agent description, and produces the walkable surface:
import { NavigationMesh } from "@woosh/meep-engine/src/engine/navigation/mesh/NavigationMesh.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
const navmesh = new NavigationMesh();
navmesh.build({
source, // BinaryTopology of the scene, triangulated
agent_radius: 0.4, // metres; the walkable area is inset by this
agent_height: 1.8, // metres; overhead obstacles are carved out
agent_max_step_height: 0.3, // metres; bridge stair-height gaps
agent_max_step_distance: 0.5, // metres; bridge lateral gaps
agent_max_climb_angle: Math.PI / 4, // radians; steeper faces are excluded
up: Vector3.up, // world-up axis for the climb-angle test
});
source must be triangulated - run bt_mesh_triangulate first if it is not - and must have consistent outward winding, since a single face normal cannot distinguish a correctly wound floor from a flipped one.
It must also be a manifold walkable surface, not a pile of solids. Level geometry from a brush-based editor is a set of interpenetrating closed volumes - a floor brush is a sealed box whose top happens to be a floor - and fed in directly it produces a navmesh that routes almost nothing. Extract the walkable surface first: keep triangles whose normal passes the walk threshold and drop those whose space above is solid. Render geometry is not a substitute either; it produces an empty build, which is a legitimate answer to bad input and is reported as zero faces. Repair with bt_merge_vertices_by_distance, then bt_mesh_fuse_duplicate_edges, then bt_mesh_resolve_t_junctions - in that order, on a triangulated mesh - and check with bt_mesh_validate / bt_mesh_is_manifold. Neither check is run for you, because a full manifold check costs more than most builds. Repair cannot union two overlapping coplanar floor brushes; that needs a boolean union upstream. For brush-based levels, a waypoint graph traced with the collision system’s shape cast often routes more than a repaired navmesh does.
Build parameters
| Parameter | Type | Default | Meaning |
|---|---|---|---|
source | BinaryTopology | required | Triangulated scene geometry to derive the mesh from. |
agent_radius | number | 0 | Half-width of the agent. The outer boundary is eroded by it and obstacle footprints are dilated by it. Also drives hole filling and simplification tolerance - at 0 both are no-ops. |
agent_height | number | 0 | Agent standing height. At 0 no overhead clearance is enforced and no obstacle is carved. |
agent_max_step_height | number | 0 | Vertical gap the agent can bridge - stairs, kerbs. Opt-in: at 0 the topology is left alone. |
agent_max_step_distance | number | 0 | Lateral gap the agent can bridge - a hole in the floor. Opt-in in the same way. |
agent_max_climb_angle | number | Math.PI / 4 | Maximum slope angle (radians) an agent can traverse. Steeper faces are excluded. |
up | Vector3 | Vector3.up | World up-axis against which slope angles are measured. |
The build runs roughly: compute face normals and drop faces that are too steep or degenerate → weld the resulting soup and fuse duplicate edges → resolve T-junctions → erode each island’s outer boundary by the agent radius → carve obstacle footprints, pre-dilated by the radius → bridge steps and gaps if asked → split pinched vertices → fill sliver holes → collapse the redundant triangulation inherited from the render geometry → recompute face normals. NavigationMesh.build then constructs the face BVH with bt_mesh_build_face_bvh.
Build hardening
Three details are worth knowing when a navmesh comes out with unwalkable seams:
- One weld distance,
NAVMESH_WELD_DISTANCE = 1e-6, is used everywhere the build merges positions. It exists to reunite the same corner reached by different arithmetic - the erosion’s cut band and the obstacle carve both reconstruct shared points per source triangle - not to simplify anything. Float32 storage resolution raises it where it is finer. - A short-edge kill pre-pass runs before every merge. A face with an edge shorter than what the merge treats as one point survives the merge as a loop that visits the same vertex twice - a non-manifold edge, which is an unwalkable boundary.
bt_mesh_kill_short_edgesremoves those faces first;bt_mesh_split_pinched_verticesafterwards separates vertices where two sheets of surface meet at a single point, which is what a hairline crack from the carve leaves behind and what otherwise stops every boundary walk dead. - Overhead obstacles are carved as footprints, not sampled. A group of overhead faces sharing a vertex is carved as its convex hull when the group’s triangles tile that hull, and triangle by triangle when they do not - an L-shape, a doorway frame, two obstacles that merely touch. Both routes carve the same ground wherever the hull is valid (dilation by a disc distributes over union); the hull is kept only because it is cheaper. The result is the true offset of the obstacle - straight along edges, a clean arc at corners - rather than the wavy contour that sampling-then-eroding produced. Footprints go in a quad-tree, so each soup triangle only tests the ones that can overlap it: O(m log k) for m soup triangles and k footprints.
Querying a path
find_path writes a sequence of 3-D waypoints into a caller-supplied Float32Array and returns the number of points written. Points are packed XYZ triples. It assumes the surface is manifold and does not check: on a non-manifold surface it can read a released vertex and throw from inside Polyanya rather than return 0. If the input is in doubt, validate once at build time rather than defensively per query.
const output = new Float32Array(1024 * 3); // size for up to 1024 waypoints
const count = navmesh.find_path(
output,
sx, sy, sz, // start position (world space)
gx, gy, gz, // goal position (world space)
);
if (count === 0) {
// no path - disconnected topology or empty mesh
} else {
for (let i = 0; i < count; i++) {
const x = output[i * 3];
const y = output[i * 3 + 1];
const z = output[i * 3 + 2];
}
}
Signature:
find_path(output: Float32Array, sx, sy, sz, gx, gy, gz): number
- Returns
0if the mesh is empty or the start and goal faces are in different connected components. - The first and last points are the start and goal snapped onto the mesh surface, so they may differ from the raw inputs when those lie off the mesh.
- The output buffer must have room for the full path; size it for the worst case you expect.
The path-finding pipeline
Each find_path call runs two stages:
-
Face snapping.
bt_mesh_find_nearest_faceuses the BVH to find the nearest face to each of the start and goal positions. Each snapped point is then nudged a thousandth of the way toward its face’s centroid: the search degenerates when a point sits exactly on a triangle edge, which snapped, grid-aligned queries do constantly. -
Polyanya.
bt_mesh_face_find_path_polyanyacomputes the exact any-angle geodesic across the surface (Cui, Harabor and Grastien, IJCAI 2017). Search nodes are(root, interval)pairs - an interval on a mesh edge, seen from a point the path is already taut through - and expanding one projects the visibility cone into the triangle beyond, clipping it against that triangle’s far edges. The path turns only at obstacle corners; there is no corridor-then-string-pull step, because there is no corridor.
The search is planar but intrinsic: it follows the surface by unfolding each triangle into the frame accumulated along its own corridor, so the costs are true geodesic distances on a sloped or folded navmesh, not only on a flat one. No global up-axis is assumed. The A* heuristic measures with straight 3-D chords, which lower-bound the geodesic and are therefore admissible.
The output follows the surface too. It is subdivided wherever the geodesic crosses a face boundary, so every emitted segment lies within a single triangle and the path never flies over a convex crease nor tunnels through a concave fold. Collinear runs are collapsed, so on a planar mesh what comes out is just the minimal corner polyline.
Scratch buffers are module-level, so find_path is allocation-free at query time once the search-node pool has grown to size. bt_mesh_face_find_path - plain A* over the triangle adjacency graph - and funnel_string_pull ship, but NavigationMesh.find_path does not use them.
Grid-based A*
For tile-based or grid worlds, a lighter alternative is available:
import { find_path_on_grid_astar } from "@woosh/meep-engine/src/engine/navigation/grid/find_path_on_grid_astar.js";
// field: flat typed array, width × height cells
// start / goal: flat indices into the field
// block_value: the value that marks an impassable cell
const path = find_path_on_grid_astar(field, width, height, start, goal, block_value);
// returns number[] of cell indices, start → goal, or [] if no path
The grid variant returns an array of cell indices with collinear segments collapsed (only turning-point indices are recorded). It uses 4-connected neighbours and a squared-distance heuristic.
Where to go next
- Navigation agents - attach
Path,PathFollower, andPathFollowingSystemto move an entity along a queried path. - Spatial queries - raycasts and overlap tests against physics geometry.
- Math & geometry -
BinaryTopologyand thebt_*family the navmesh is built and queried with.