A* sobre NavMesh poligonal: triángulos, portales y funnel
El caso real de Unity. A* corre sobre triángulos en vez de celdas.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Hasta ahora todos los algoritmos asumen un grid uniforme: cada celda es un nodo, los vecinos son las 4 u 8 celdas adyacentes. Eso es perfecto para juegos tactical, roguelike, top-down con celdas.
Para juegos 3D modernos — Witcher 3, GTA, cualquier shooter, casi todo Unity de mundo abierto — el grid no escala. ¿Cuántos nodos tiene un mundo de 1km × 1km a 0.5m de precisión? 4 millones. ¿Y si el suelo es irregular, con escaleras, rampas, plataformas a distintas alturas? Imposible.
La solución estándar es el NavMesh (navigation mesh): el espacio walkable se representa como un conjunto de polígonos (típicamente triángulos). A* corre sobre los polígonos. Un mapa de varios km² puede tener solo unos pocos miles de polígonos — un par de órdenes de magnitud menos que el grid equivalente.
Unity trae un sistema de NavMesh integrado (NavMeshAgent, NavMeshSurface). Saber cómo funciona por dentro te permite usarlo bien y, si hace falta, escribir el tuyo.
Lo que vamos a cubrir
- Cómo se construye el navmesh: triangulación del espacio walkable, con obstáculos como restricciones.
- Cómo corre A* sobre triángulos: el grafo es de polígonos, las aristas son los portales (segmentos compartidos).
- Funnel algorithm: el path naïve zigzaguea entre centroides; funnel produce el path geodésico real.
1. Construcción del navmesh
El navmesh divide el espacio walkable en triángulos (o polígonos convexos). Triángulos vecinos comparten una arista — esa arista es un portal. Activar el obstáculo regenera la triangulación: zonas inalcanzables desaparecen, zonas nuevas se subdividen.
Figura 1 — Dos habitaciones conectadas por un pasillo, trianguladas. Cuando añades un obstáculo, la habitación derecha se subdivide en triángulos que rodean al obstáculo. Las celdas no walkable simplemente no aparecen en el mesh.
1.1 Las dos formas de obtener un navmesh
- Manual — un artista lo dibuja (en un editor de meshes). Caro, pero útil para mapas pequeños y específicos.
- Automático — un algoritmo lo genera a partir de la geometría de la escena. Es lo que hace Unity con
NavMeshSurface.BuildNavMesh().
El algoritmo automático canónico es Recast (Mikko Mononen, 2009), usado por Unity y por casi todos los engines:
- Voxelización: la escena se convierte a voxels.
- Filtrado: se detectan voxels caminables (suelo plano, pendientes razonables, sin techo bajo).
- Particionado: los voxels caminables se agrupan en regiones convexas.
- Triangulación: cada región se triangula. El resultado es el navmesh.
La parte difícil es el particionado en regiones convexas. Para tutorial vale con saber: el resultado son polígonos convexos (a menudo triángulos por simplicidad) que cubren el espacio caminable.
1.2 Por qué triángulos
- Convexidad garantizada — un triángulo siempre es convexo, así que cualquier dos puntos dentro de él son alcanzables en línea recta.
- Conectividad simple — dos triángulos son vecinos si comparten exactamente una arista (que se vuelve un portal).
- Algoritmos clásicos — Delaunay, ear-clipping y similar producen triangulaciones de calidad.
Los engines AAA usan polígonos convexos genéricos (no solo triángulos) porque reducen el número de nodos. Pero la teoría es la misma.
2. A* sobre triángulos
El navmesh es un grafo. Cada nodo es un triángulo, cada arista es un portal (segmento compartido entre dos triángulos vecinos). A* corre sobre ese grafo igual que sobre un grid.
Arrastra los nodos S y G dentro del mesh. A* corre sobre la conectividad de triángulos: nodos = triángulos, aristas = portales (los segmentos naranjas son los portales atravesados). El path verde une centroides — fíjate cómo zigzaguea. Eso lo arregla el funnel (siguiente viz).
Figura 2 — Arrastra S y G dentro del mesh. A* encuentra la secuencia de triángulos a recorrer (verde claro) y los portales que cruza (líneas naranja). El path verde “literal” une centroides de triángulos consecutivos — y zigzaguea innecesariamente.
2.1 El bucle es idéntico al de A* sobre grid
function astarNavmesh(mesh, start, goal)
startTri = findTriangle(start)
goalTri = findTriangle(goal)
return astar(graph = mesh.triangleAdjacency,
start = startTri,
goal = goalTri,
cost(a, b) = distance(centroid(a), centroid(b)),
heuristic(a, goal) = distance(centroid(a), centroid(goal)))
Solo cambian dos cosas:
- Costos: ya no son “1 por celda”; son la distancia entre centroides (o cualquier métrica razonable entre triángulos).
- Heurística: distancia euclidiana entre centroides al goal. Admisible siempre.
El resultado del A* es una secuencia de triángulos y los portales que los conectan.
2.2 El problema con el path naïve
Si conectas centroides directamente, el agente zigzaguea: entra a un triángulo, va al centro, sale por el portal, va al centro del siguiente, etc. No es óptimo en distancia real. El path real más corto solo cambia de dirección cuando un portal lo obliga.
Para conseguir ese path real, hace falta el funnel algorithm.
3. Funnel algorithm — del zigzag al geodésico
El path naïve (verde punteado) une centroides de triángulos: zigzaguea sin razón. El funnel algorithm toma esa secuencia de portales (líneas naranja) y produce el path geodésico óptimo (rojo): solo cambia de dirección cuando un portal lo obliga. El resultado es lo que el agente realmente recorrería.
Figura 3 — Verde punteado es el path naïve por centroides; rojo es el path después de aplicar funnel. El funnel solo cambia de dirección cuando un portal lo obliga: el agente puede ir directo entre waypoints.
3.1 La intuición del funnel
Imagina que vas por un pasillo curvo. Estás parado en un punto (el apex del funnel) y miras hacia el siguiente portal. El portal tiene un borde izquierdo (left) y un borde derecho (right). Tu visión está limitada a ese rango angular: un cono.
Avanzas al siguiente portal. El nuevo left puede:
- Estar más a la derecha que el viejo (el cono se cierra por la izquierda) → actualiza
left. - Estar fuera del cono actual por la derecha (el cono se cerró completamente) → emite un waypoint en el
rightviejo, y arranca un cono nuevo desde ahí.
Lo mismo simétricamente para right.
function funnel(start, goal, portals)
apex = start
left = portals[0].left
right = portals[0].right
path = [start]
for i = 1 to portals.length - 1
nextLeft = portals[i].left
nextRight = portals[i].right
# ¿el nuevo right cierra el cono?
if cross(apex, right, nextRight) <= 0
if cross(apex, left, nextRight) > 0
right = nextRight
else
# el cono se cerró: emite waypoint en left
path.push(left)
apex = left
# reinicia el funnel desde el portal del left
# mismo simétrico para left
path.push(goal)
return path
La implementación correcta tiene unos detalles delicados (orientación de portales, casos degenerados), pero la idea es: mantén el cono lo más abierto posible; emite waypoints solo cuando el cono colapsa.
3.2 Por qué el resultado es óptimo en distancia
El funnel es equivalente a “estirar una cuerda” entre start y goal, dejándola tensar contra los bordes de los portales. Esa cuerda, en geometría plana, es el camino geodésico — el más corto. Demostrado.
3.3 Lo que hace Unity por dentro
NavMeshAgent.SetDestination(target) internamente:
- Encuentra los triángulos del navmesh para
transform.positionytarget. - Corre A* sobre los triángulos.
- Aplica funnel sobre la secuencia de portales resultante.
- Devuelve
corners[]— exactamente los waypoints del funnel.
El agente luego se mueve entre corners[i] y corners[i+1] con MoveTowards o tu controlador favorito. Lo que ves como movimiento “fluido” del NPC es funnel actuando.
4. Pseudocódigo completo
class Navmesh
triangles: List<Triangle>
adjacency: Map<Triangle, List<{neighbor, portal}>>
function findPath(navmesh, start: Vec2, goal: Vec2) -> List<Vec2>
startTri = findTriangleContaining(navmesh, start)
goalTri = findTriangleContaining(navmesh, goal)
triPath = astar(navmesh.adjacency, startTri, goalTri,
cost = (a, b) -> dist(centroid(a), centroid(b)),
h = (a, b) -> dist(centroid(a), centroid(b)))
portals = []
for i = 0 to triPath.length - 2
portals.push(navmesh.adjacency[triPath[i]][triPath[i+1]].portal)
return funnel(start, goal, portals)
function funnel(start, goal, portals) -> List<Vec2>
# como se describió arriba
...
5. Implementación en Unity / C#
En Unity casi nunca escribes el navmesh manual: usas NavMeshSurface (paquete AI Navigation) y dejas que el motor lo genere. El A* y el funnel los hace NavMeshAgent. Lo que escribes es gameplay sobre eso.
using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
public class SimpleAgent : MonoBehaviour {
NavMeshAgent agent;
public Transform target;
void Awake() => agent = GetComponent<NavMeshAgent>();
void Update() {
if (target != null) agent.SetDestination(target.position);
}
// Para acceder al path resuelto y dibujarlo:
void OnDrawGizmosSelected() {
if (agent == null || agent.path == null) return;
var corners = agent.path.corners;
for (int i = 0; i < corners.Length - 1; i++) {
Gizmos.color = Color.green;
Gizmos.DrawLine(corners[i], corners[i + 1]);
}
}
}
Si necesitas escribir tu propio funnel (porque tienes un sistema de pathfinding custom o quieres modificar el comportamiento), Unity expone NavMeshPath con corners[] ya post-funnel. Para hacer funnel desde cero sobre tu propio mesh, el snippet del pseudocódigo se traduce directo.
6. En otros engines
- Godot: trae
NavigationServeryNavigationRegion3Dque generan navmesh automático. La API es similar a Unity. - Unreal:
NavMeshBoundsVolumedefine las áreas;RecastNavMeshlo construye con el algoritmo Recast.AAIController.MoveToLocation()es el equivalente deSetDestination. - JavaScript / TypeScript: librerías como
recast-navigation-js(port de Recast) traen navmesh y funnel completos para Three.js.
7. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
¿Por qué un navmesh escala mejor que un grid uniforme para mundos 3D?
Implementaste A* sobre navmesh y el agente zigzaguea entre triángulos. ¿Qué falta?
Tu unidad humana está sobre un navmesh con un puente angosto. ¿Cómo asegurás que el path lo cruce limpio (sin pegarse al borde)?
¿Cuál es la salida de NavMeshAgent.SetDestination(target) por dentro?
Querés que tu agente prefiera caminos pavimentados (cost 1) sobre césped (cost 2). ¿Cómo lo modelás en navmesh?