27 abril 2025

Navegación 2D en Unity

Hace un par de meses, publiqué un artículo sobre cómo usar el sistema de navegación 2D en Godot. Va siendo hora de que escriba un artículo análogo para el caso de Unity.

No me voy a complicar la vida, así que voy a replicar la estructura de aquel artículo, pero adaptando las instrucciones a Unity.

Recuerda que la premisa de partida es que queremos tener personajes NPC que sean capaces de orientarse por el escenario para poder ir de un punto a otro de este. Vamos a empezar por ver cómo crear un escenario que pueda ser procesado por el sistema de navegación.

Creación de un escenario navegable

Como escenario, nos podría vale cualquier conjunto de sprites, siempre que tuvieran colliders asociados para definir cuáles suponen un obstáculo al NPC. Como eso no tendría mucho misterio, vamos a optar por un caso algo más complejo, pero también bastante habitual en un juego 2D. Vamos a suponer que nuestro escenario está construido a base de tilemaps. El uso de tilemaps, bien merece su propio artículo y los encontrarás a raudales en Internet, así que aquí voy a suponer que ya sabes utilizarlos.

En nuestro ejemplo vamos a suponer un escenario construido con tres niveles de tilemaps: uno para los muros perimetrales del escenario (Tilemap Wall), otro para los obstáculos internos del escenario (Tilemap Obstacles) y otro para el Tilemap del suelo (TilemapGround).

Jerarquía de tilemaps del ejemplo
Jerarquía de tilemaps del ejemplo

Courtyard es el nodo padre de los tilemaps y el que contiene el componente Grid. Con la cuadrícula del tilemap.

Tilemap Ground sólo contiene los componentes visuales Tilemap y TilemapRenderer para poder mostrar los tiles del suelo. Los tilemaps Wall y Obstacles también tienen esos componentes, pero además incorporan dos componentes adicionales: un Tilemap Collider 2D y un Composite Collider 2D. El componente Tilemap Collider 2D sirve para tener en cuenta el collider de cada uno de los tiles. Dicho collider se define en el sprite editor, para cada uno de los sprites utilizados como tiles. El problema de Tilemap Collider 2D es que cuenta el collider de cada tile de manera individual, lo cual es muy poco eficiente por la cantidad de tiles que llega a acumular cualquier escenario basado en tilemaps. Por esa razón, es muy habitual acompañar al componente Tilemap Collider 2D de otro componente llamado Composite Collider 2D. Este último componente se encarga de fundir todos los collider de los tiles, generando un único collider conjunto que es mucho más ligero de manipular por el engine. 

Cuando uses estos componentes en tus tilemaps te aconsejo dos cosas:

  • Fija el atributo "Composite Operation" del Tilemap Collider 2D al valor Merge. Eso le dirá al componente Composite Collider 2D que la operación que tiene que hacer es juntar todos los colliders individuales en uno solo.
  • En el Composite Collider 2D, yo fijaría el atributo "Geometry Type" al valor Polygons. Si lo dejas en el valor por defecto, Outlines, el collider generado estará hueco, es decir sólo tendrá bordes, por lo que algunas operaciones de detección de colliders podrían fallar, tal y como expliqué en otro artículo anterior.

Creación de un NavMesh 2D

Un NavMesh es un componente que analiza el escenario y genera un grafo basado en él. Este grafo es el que usará el algoritmo de búsqueda de caminos para guiar al NPC. Crear un NavMesh en Unity 3D es muy sencillo. 

El problema es que en 2D no funcionan los componentes que sí sirven en 3D. No hay avisos, ni mensajes de error. Sencillamente, sigues las instrucciones de la documentación y el NavMesh no se genera si estás en 2D. Al final, tras mucho buscar por Internet, he llegado a la conclusión de que la implementación nativa de Unity para los NavMesh 2D está rota. Sólo parece funcionar para 3D. 

Por lo que he visto, al final todo el mundo utiliza un paquete open source, externo a Unity, llamado NavMeshPlus. Este paquete implementa una serie de componentes, muy parecidos a los nativos de Unity, pero que sí que funcionan a la hora de generar un NavMesh en 2D. 

El enlace anterior te lleva a la página en GitHub del paquete, donde se explica cómo instalarlo. Hay varias maneras de hacerlo, la más sencilla quizás es añadir la URL del repositorio usando la opción "Install package from git URL..." del icono "+" del package manager de Unity. Una vez que lo hagas, y se refresque el índice de paquetes, podrás instalarte NavMeshPlus, así como sus posteriores actualizaciones. 

Opción para añadir un repositorio de git al package manager de Unity.
Opción para añadir un repositorio de git al package manager de Unity.

Una vez que hayas instalado NavMeshPlus, tienes que seguir los siguientes pasos:

  1. Crear un GameObject vacío en la escena. No deberá depender de ningún otro GameObject.
  2. Añadir un componente Navigation Surface al GameObject anterior. Asegúrate de usar el componente de NavMeshPlus y no el nativo de Unity. Mi consejo es que te fijes en el icono identificativos de los componentes que muestro en las capturas, y que te asegures de que los componentes que uses tengan los mismo iconos.
  3. Hay que añadir también el componente Navigation CollectSources2d. En ese mismo componente hay que pulsar el botón "Rotate Surface to XY"; por eso es importante que estos componentes se instalen en un GameObject vacío que no depende de ningún otro. Si lo has hecho bien, parecerá que no pasa nada. En mi caso, me equivoqué y añadí los componentes al GameObject Courtyard que mencionaba antes, y al pulsar el botón se me giró todo el escenario. Así que mucho ojo.
  4. Luego hay que añadir un componente Navigation Modifier a cada uno de los elementos del escenario. En mi caso se lo añadí a cada uno de los GameObjects de los tilemaps que veíamos en la captura con la jerarquía de tilemaps del ejemplo. Estos componentes nos servirán para discriminar qué tilemaps definen áreas que se puede recorrer y qué tilemaps definen obstáculos.
  5. Por último, en el componente Navigation Surface ya podremos pulsar el botón "Bake" para generar el el NavMesh.

Vamos a examinar cada uno de los pasos anteriores con algo más de detalle.

El GameObject donde he colocado los dos componentes anteriores cuelga directamente de la raíz de la jerarquía. No le he dado muchas vueltas y lo he llamado NavMesh2D. En la captura siguiente puedes ver los componentes que incluye y su configuración.

Configuración de los componentes principales de NavMeshPlus
Configuración de los componentes principales de NavMeshPlus

Como puedes ver en la figura anterior, la principal utilidad del componente NavigationSurface es definir qué capas vamos a tener en cuenta para construir nuestro NavMesh ("Include Layers"). Supongo que, si tienes capas muy cargadas, te puede interesar limitar el parámetro "Include Layers" sólo a las capas donde haya elementos del escenario. En mi caso, el escenario era tan sencillo que aun incluyendo todas las capas no he notado ralentización a la hora de crear el NavMesh.

Otra personalización que he hecho es fijar el parámetro "Use Geometry" a "Physics Colliders". Este valor presenta mejor rendimiento cuando usas tilemaps ya que se usan unas formas geométricas más sencillas para representar el escenario. La opción "Render Meshes" permite crear un NavMesh mucho más detallado, pero menos optimizado, sobre todo al usar tilemaps.

Si te estás preguntando cómo se modelan las dimensiones físicas del agente de navegación (su radio y altura, por ejemplo), aunque se muestran en la parte superior del componente "Navigation Surface", no se configuran ahí sino en la pestaña Navigation, que también es visible en la captura anterior. Si no la vieses en tu editor, la puedes abrir en Window --> AI --> Navigation.

Pestaña Navigation
Pestaña Navigation

Por último, los componentes Navigation Modifier nos permiten distinguir los tilemaps que contienen obstáculos de los tilemaps que contienen zonas deambulables. Para ello, tenemos que marcar el check de "Override Area" y luego definir el tipo de zona que contiene este tilemap. Por ejemplo, los GameObject de los tilemaps Wall y Obstacles cuentan con el componente Navigation Modifier de la siguiente captura:

Navigation Modifier aplicado a los tilemaps con obstáculos
Navigation Modifier aplicado a los tilemaps con obstáculos

Al marcar el área como "Not Walkable" estamos diciendo que lo que pinta este tilemap son obstáculos. Si fuera una zona transitable, comoe le tilemap de  Ground, la pondríamos a Walkable.

Una vez configurados todos los Navigation Modifier, ya podemos crear nuestro NavMesh pulsando en el botón "Bake" del componente Navigation Surface. Para poder verlo, hay que pulsar en el icono de la brújula del toolbar inferior (es el segundo por la derecha del toolbar) de la pestaña escena. Eso abrirá un panel emergente a la derecha donde podremos marcar el check "Show NavMesh". Si el NavMesh se ha generado correctamente entonces aparecerá en la pestaña escena, superponiéndose al escenario. Todas las zonas marcadas en azul serán las transitables por nuestro NPC.

Visualización del NavMesh
Visualización del NavMesh

Uso del NavMesh 2D

Una vez creado el NavMesh 2D, nuestros NPC deben ser capaces de leerlo. 

En el caso de Godot, eso significaba incluir un nodo MeshNavigationAgent2D en los NPC. A partir de ahí, se le iba diciendo a ese nodo a dónde se deseaba ir y este calculaba la ruta y devolvía la localización de las distintas escalas de la ruta. El resto de los nodos del gente se encargaban de moverlo hacia esa localización. 

Unity también cuenta con un componente NavMeshAgent, el problema es que no es pasivo como el de Godot; es decir, no se limita a dar las diferentes escalas de la ruta, sino que se encarga también de mover al agente hacia esas escalas. Eso puede ser muy cómodo en muchas ocasiones cuando el movimiento es sencillo, con un único componente suples dos necesidades: orientas el movimiento y lo ejecutas. Sin embargo, pensado fríamente no es una buena arquitectura porque no respeta el principio de Separación de Responsabilidades, en el que se supone que cada componente debe centrarse en realizar una sola cosa. Mi caso es un buen ejemplo de los problemas que puede haber cuando no se respeta ese principio. Mi proyecto configura fuertemente el movimiento; este no es homogéneo sino que va cambiando a lo largo de una ruta en función de múltiples factores. Es un nivel de personalización que excede lo que que permite el NavMeshAgent de Unity. Si Unity hubiera respetado el principio de Separación de Responsabilidades, como si ha hecho Godot en este caso, habría separado la generación de la ruta y el movimiento del agente en dos componentes separados. De esta manera, se podría haber aprovechado el componente generador de la ruta tal cual, mientras que el del movimiento del agente se podría haber envuelto en otros componentes para personalizarlo de manera adecuada.

Afortunadamente,  hay una manera poco publicitada de hacerle consultas al NavMesh 2D, para sacar rutas sin necesidad de un NavMeshAgent, con lo que se puede replicar la manera de funcionar de Godot. Voy a enfocar este artículo por ese lado, porque es lo que he hecho en mi proyecto. Si lo que te interesa es cómo usar el NavMeshAgent entonces te recomiendo que consultes la documentación de Unity que explica con todo lujo de detalles cómo usarlo. 

Consulta al NavMesh para obtener una ruta entre dos puntos
Consulta al NavMesh para obtener una ruta entre dos puntos

En la captura anterior he puesto un ejemplo de cómo realizar estas consultas. 

La clave está en la llamada al método NavMesh.CalculatePath() de la línea 99.  A ese método se le pasan 4 parámetros:
  • Punto de partida: Generalmente es la posición actual del NPC, por eso yo le he pasado directamente transform.posicion.
  • Punto de destino: En este caso, le he pasado una variable global del NPC donde se encuentra l ubicación de su objetivo.
  • Un filtro de áreas del NavMesh: En casos complejos, puedes tener tu NavMesh dividido en áreas. Este bitmask te permite definir a qué áreas quieres restringir la consulta. En un caso sencillo como este, lo normal es pasarle un NavMesh.AllAreas para que las tenga en cuenta todas.
  • Una variable de salida del tipo AI.NavMeshPath: es la variable en la que se depositará la ruta resultante hasta el punto de destino. Yo le he pasado una variable privada global del NPC.
La llamada a CalculatePath() es síncrona, lo que quiere decir que el juego se detendrá un instante hasta que CalculatePath() calcule la ruta. Para rutas pequeñas y actualizaciones puntuales, la interrupción no afectará al rendimiento del juego; pero si te pasas el rato calculando muchas rutas largas, entonces te encontrarás con que el rendimiento empezará a resentirse. En esos casos, lo suyo es que dividas los trayectos en varios tramos cortos más livianos de calcular. En el caso de formaciones, en vez de hacer que cada miembro de la formación calcule su ruta, es más eficiente que sólo el "comandante" calcule la ruta y el resto se limite a seguirle manteniendo la formación.

La variable de salida de tipo AI.NavMeshPath, en la que CalculatePath() vuelca la ruta calculada, aún se le podría pasar a un NavMeshAgent a través de su método SetPath(). Yo, sin embargo, he preferido prescindir del NavMeshAgent, por lo que he procesado la variable de salida, en el método UpdatePathToTarget() de la línea 107, para que me resultase más fácil de usar.

Una variable de tipo AI.NavMeshPath cuenta con el campo "corners" donde guarda un array con las localizas de las diferentes escalas de la ruta. Esas localizaciones son tridimensionales (Vector3), mientras que en mi proyecto trabajo con puntos bidimensionales (Vector2), esa es la razón por la que en el método UpdatePathToTarget() recorro todos los puntos del campo "corners" (línea 111) y los voy convirtiendo a elementos de un array de Vector2 (línea 113). Ese array es el que luego utilizo para dirigir mis componentes de movimiento a cada una de las escalas de la ruta.

Conclusión

Listo, con esto ya tienes todo lo que necesitas para hacer que tus NPC se muevan de manera inteligente por el escenario, orientándose hasta alcanzar el objetivo. A alto nivel es cierto que los conceptos son muy similares entre Godot y Unity, pero el demonio está en los detalles. A la hora de bajar al nivel de la implementación te encontrarás los matices y diferencias que hemos analizado en este artículo, pero con las indicaciones que te he dado el resultado que obtengas en Unity y en Godot debería ser similar. 

23 abril 2025

Cómo detectar obstáculos en Unity 2D

En los juegos es bastante habitual tener que averiguar si un punto del escenario está libre de obstáculos para poder ubicar a un personaje o a otro elemento del juego. Piensa por ejemplo en un RTS: a la hora de construir un edificio tienes que elegir una sección de terreno libre ¿pero cómo puede saber tu código si un emplazamiento tiene o no ya un edificio u otro tipo de objeto?

En un juego 3D, la solución más habitual suele ser proyectar un rayo desde el foco de visión de la cámara, pasando por el punto donde se sitúa el cursor del ratón, en el plano de la pantalla, hasta chocar con un collider. Si el collider es el del suelo, ese punto está libre, y si no es que hay un obstáculo.

Claro, si el objeto que queremos colocar es más grande que un punto, proyectar un simple rayo se nos queda corto. Imagina que queremos colocar un edificio rectangular y el punto donde iría su centro está libre, pero la zona de la esquina no. Afortunadamente, para esos casos, Unity nos permite proyectar formas complejas, más allá de un mero punto. Por ejemplo, los métodos SphereCast permiten desplazar una esfera invisible a lo largo de una línea, devolviendo el primer collider con el que tope, Otro de los métodos, BoxCast, permitiría resolver la problemática del edificio rectangular proyectando una caja de base rectangular a lo largo de una línea. No tendríamos más que hacer esa proyección a lo largo de una línea vertical a la posición del suelo que queremos revisar.

En 2D, también hay métodos de proyección, BoxCast y CircleCast, pero sólo sirven cuando la proyección tiene lugar en el plano XY (el de la pantalla). Es decir, equivalen a mover una caja o un círculo en línea recta, a lo largo de la pantalla, a ver si tocan un collider. Por supuesto, eso tiene su utilidad. Imagina que estás haciendo un juego cenital y quieres comprobar si el personaje será capaz de atravesar una abertura en un muro. En ese caso te bastará con hacer un CircleCast de un círculo, con un diámetro como el ancho de hombros de nuestro personaje, proyectando a través de la abertura para ver si el círculo llega a rozar los collider del muro. 

Un CircleCast, proyectando un círculo a lo largo de un vector.
Un CircleCast, proyectando un círculo a lo largo de un vector.

¿Pero qué pasa cuando tienes que proyectar en el eje Z en un juego 2D? Por ejemplo, para un caso en 2D equivalente al ejemplo que poníamos antes para 3D. Ahí ya no nos servirían ni BoxCast ni CircleCast porque esos métodos definen el vector de proyección mediante un parámetro Vector2, limitado al plano XY. En esos casos, se usa una familia diferente de métodos: los métodos "Overlap".

Los métodos Overlap colocan una forma geométrica en un punto determinado del espacio 2D y, en caso de que la forma se superponga con algún collider, lo devuelven. Al igual que las proyecciones, hay métodos especializados en diferentes formas geométricas: OverlapBox, OverlapCapsule u OverlapCircle entre otros.

Supongamos un caso como el de la figura siguiente. Queremos saber si una forma con el tamaño del círculo rojo tocaría algún obstáculo (en negro) en caso de ubicarse en el punto marcado en la figura. 

Ejemplo de uso de OverlapCircle.
Ejemplo de uso de OverlapCircle.


En ese caso, usaríamos OverlapCircle para "dibujar" un circulo invisible en ese punto (el círculo que se ve en la figura es un mero gizmo) y comprobaríamos si el método devuelve algún collider. De no ser así, significaría que el emplazamiento elegido estaría libre de obstáculos.

Un método que llamase a OverlapCircle podría ser tan sencillo como el siguiente:

Llamada a OverlapCircle
Llamada a OverlapCircle

El método de la figura devuelve verdadero si no hay ningún collider en un radio  (MinimumCleanRadius) de la posición candidateHidingPoint. En caso de que haya algún collider, el método devolverá false. Para eso, el método IsCleanHidingPoint se limita a llamar a OverlapCircle, pasándoles como parámetros:

  • candidateHidingPoint (línea 224): Un Vector2 con la posición del centro del círculo a trazar.
  • MinimumCleanRadius (línea 225): Un float con el radio del círculo.
  • NotEmpyGroundLayers (línea 226): Un LayerMask con las capas de los collider que queremos detectar. Sirve para filtrar los collider que no queramos detectar. OverlapCircle descartará un collider que no se encuentre en una de las capas que hayamos pasado en el LayerMask.

En caso de que el área esté limpia de colliders, OverlapCircle devolverá null. En caso de que soí los haya, devolverá el primer collider que encuentre. Si te interesase conseguir el listado de todos los collider que pudiera haber en el área podríamos usar la variante OverlapCircleAll, que devuelve una lista con todos ellos.

Podríamos finalizar aquí, pero no quiero hacerlo sin advertirte de un quebradero de cabeza que te encontrarás sin duda en 2D. Por suerte, puede solucionarse fácilmente si estás avisado. 

El problema puede darse si usas tilemaps. Estos son muy habituales para dar forma a los escenarios en 2D. La cuestión es que para conformar los collider de un tilemap, lo normal es usar un componente "Tilemap Collider 2D", y también es bastante habitual añadir un componente "Composite Collider 2D" para sumar todos los collider individuales de cada tile en uno sólo y mejorar así el rendimiento. El problema viene de que por defecto el componente "Composite Collider 2D" genera un collider hueco, sólo definido por su perfil. Supongo que lo hace por rendimiento.  Eso ocurre cuando el parámetro "Geometry Type" tiene el valor Outlines.

Valores posibles del parámetro Geometry Type.
Valores posibles del parámetro Geometry Type.

¿Por qué supone un problema que el collider sea hueco? porque en ese caso la llamada a OverlapCircle sólo detectará el collider si el círculo que dibuje llega a cruzarse con el borde del collider. Si por el contrario, el círculo encajase limpiamente dentro del collider, sin tocar ninguno de sus bordes, entonces OverlapCircle no devolvería collider alguno y supondríamos erróneamente que la zona estaría limpia.

La solución es sencilla, una vez que te lo han explicado. Hay que cambiar el valor por defecto de "Geometry Type" a Polygons. Este valor hace que el collider generado sea "macizo" por lo que OverlapCollider lo detectará aunque el círculo dibujado encaje dentro sin tocar sus bordes.

Parece una tontería, porque lo es, pero fue una tontería que me llevó un par de horas resolver hasta que conseguí dar con la tecla. Espero que este artículo sirva para que no te pase a ti también.