Hierarchical pathfinding

Hierarchical pathfinding implies dividing searches into a high level one, based on the region centers contained in the identified sectors, and a low level one, based on every tile in the map. Once the map has been sectorized and the regions identified, the artificial intelligence needed to change so it would recognize the difference between both pathfinding requests and their states, apart from the interactions between both.

I based the agents’ intelligence on behavior trees. To build the definitive tree, one that would recognize all the possible states, I had to write about ten unit tests. The final graph is the following:

Move_to_destination Behavior (1).jpg

However, the last time I programmed a behavior tree in my engine I failed to implement a NOT node, which inverts the result of its child. Writing it in it has doubled the amount of usable conditions and actions, making it trivial to implement, for example, the first “sentence” of the logic shown on the image: “if the agent has a destination but he hasn’t reached it, attempt to figure out if it has a low level route”. A few of the texts I’ve read about behavior trees warned about complicating their composition inventing nodes, because any behavior can be represented using sequences (nodes that return success if all of their children succeed), selectors/fallbacks (nodes that return success if any of their children succeed), decorators and actions. Condition nodes are a type of action node that makes a check.

The following video shows hierarchical pathfinding requests in a 64x64x8 map with agents that have different movement capabilities; some walk, others fly, others swim in shallow waters and others in deep waters. Every tile has information about whether an agent with specific movement capabilities can move straight through, upwards, downwards, straight up or straight down.

Although the agents reach their destination, in other maps I’ve found out that some tiles are unreachable for no apparent reason, or the agents choose erroneous region centers. Now that I have implemented saving and loading maps, I imagine I will be able to isolate those issues and solve them through unit testing.

(UPDATE): indeed, isolating failing maps in unit tests allowed me to solve the bugs I’ve found until now in the pathfinding system. A future article will show pathfinding through complicated maps that feature buildings.

Búsqueda de ruta jerárquica

Una búsqueda de ruta jerárquica implica dividir las búsquedas en una de alto nivel, basada en los centros de región de los sectores identificados, y otra en una búsqueda de bajo nivel, basada en cada casilla presente en el mapa. Una vez el mapa está ya sectorizado y sus regiones se han identificado, es necesario modificar la inteligencia artificial para que reconozca la diferencia entre ambas búsquedas de ruta y sus estados, aparte de las interacciones entre ambas.

He basado la inteligencia de los agentes en los árboles de comportamiento. Para componer el árbol final, uno que reconociera todos los posibles estados, he necesitado unas diez pruebas unitarias. El esquema final es el siguiente:

Move_to_destination Behavior (1).jpg

Por pura vagancia, la última vez que usé un árbol de comportamiento no había creado el tipo de nodo NOT, que niega el resultado de su hijo. Implementarlo ha multiplicado por dos la cantidad de condiciones y acciones a usar, haciendo trivial implementar, por ejemplo, la primera parte de la lógica: “si el agente tiene un destino pero no está en su destino, procede a intentar averiguar si tiene una ruta de bajo nivel”. Varios textos que he leído sobre los árboles de comportamiento advierten sobre complicar su composición inventándose nodos, porque cualquier comportamiento puede representarse mediante sequencias (nodos que devuelven éxito si todos sus hijos devuelven éxito), selectores (nodos que devuelven éxito si alguno de sus hijos devuelve éxito), decoradores y acciones. Las condiciones son en realidad un tipo de acción que sencillamente hace una comprobación.

El siguiente vídeo muestra búsquedas de ruta jerárquicas en un mapa de 64x64x8 con agentes que tienen diferentes capacidades de movimiento; unos andan, otros vuelan, otros nadan por aguas poco profundas y otros por aguas profundas.

Aunque los agentes alcanzan su destino, en otros mapas he encontrado que ciertas casillas resultan inaccesibles por ningún motivo que comprenda en este momento, o eligen un centro de región erróneo aunque no deberían. Ahora que he implementado guardar y cargar los mapas, imagino que podré aislar esos problemas y solucionarlos mediante pruebas unitarias.

Sectorizing a three dimensional map

Implementing pathfinding in a three dimensional map has clarified for me why so many games refuse to exploit all three dimensions. Even in the relatively small space of 64x64x8 tiles (32,768 in total) I’ve needed to stop some of the pathfinding requests after 300 tile checks, because they blocked the main thread.

To solve that issue, first I intended to use multiprocessing: I would send the pathfinding requests to the other processor cores, and I would let them return the results whenever they calculated them. Unfortunately, every pathfinding request needs to use the block of memory that contains pathfinding information about the 32,768 tiles (which include for each the neighboring reachable coordinates). The size comes up to about 8 MB, too much to scatter to the other cores without freezing the main thread. If just scattering some data needed for the pathfinding requests takes more time than executing them in the main core, I can’t choose that route.

One of the issues with pathfinding is that there seems to be no way to tell, at this level of abstraction, if the agent can reach a walkable destination tile; for example, what if that tile is beyond a ravine that blocks off that part of the map? To figure out if a route exists to that blocked off tile, the A* algorithm, on which pathfinding is based, will check literally thousands of tiles of the potential 32,768.

The most interesting solution I discovered was hierarchical pathfinding. It consists in dividing the map in sectors of a fixed size (they recommended 16 tiles in all dimensions), and inside that sector identifying the accessible regions. In my case the case gets more complicated, because I not only include the third dimension, but different movement capabilities as well. My agents walk, swim in shallow waters, in deep waters, or fly. The system doesn’t forbid an agent from featuring a combination of those capabilities: for example, an amphibious creature, or some sort of bird that also dives in the water.

Once the map is divided in sectors and regions, I needed to calculate the center of mass of each region, and those region centers are the ones used in the high level pathfinding process to figure out a route. Now, figuring out if a walkable tile is inaccessible takes looking up about ten to twenty region centers, instead of literally thousands of tiles. Once a route has been determined through the region centers, the low level route can get calculated from one of those region centers to the next, which also limits those pathfinding requests to about 20 tiles.

To make sure the programming sectorized any map properly (maps that get generated each time through the Perlin noise algorithm), I made a visualizer. The following video shows my first attempt to implement the sectorization, but I failed at identifying the regions properly, as I will explain later.

The video shows the regions colored according to the number they have been assigned. In every sector the regions start from 1, and the counter goes up for as many new regions as the process identifies. The crosses indicate the region centers. That apparently contiguous regions showed different colors should have alerted me to the fact that something was wrong. Although the created regions seemed reasonable for flying and swimming agents, the changes in elevation screwed with the process that identifies the walkable regions. Every time the elevation changed, a new region was created, something that makes no sense because walking agents can use the ramps to move up and down.

Fortunately I had already programmed the very complicated reachability checks to find a route from tile to tile. Each tile of the original pathfinding map includes information about its reachable neighbors. For every one of those lists, I converted the coordinates to the coordinate system used inside each sector (from 0 to 15 in the x and y dimensions, and from 0 to 7 in the z), and added each coordinate as an internal neighbor as long as that neighbor existed in the same sector.

The following video shows the result.

It shows clearly, particularly in the upper left corner of the map, how the sectorizing process has considered that the changes of elevation belonged to the same region. In the upper right there is an interesting case: a sector includes both the top of a mountain and the base of a cliff, but they are identified correctly as different regions because you can’t move from one to the other, at least through the inside of the sector.

Sectorizing a map to implement pathfinding at this level also implies discovering the edges of every sector, and whether they are open for traffic in every direction (which includes diagonals, and up and down). Although I programmed it in, painstakingly, the visualizer doesn’t show it.

Now I need to adapt the A* pathfinding algorithm so it will only consider the region centers when the system requests a high level route, and it will do something similar to the following:


Sectorización de un mapa en tres dimensiones

Implementar la búsqueda de ruta en un mapa en tres dimensiones ha evidenciado por qué muchos juegos se resisten a explotar las tres dimensiones. Incluso en un espacio relativamente pequeño de 64x64x8 (32.768 casillas), he necesitado paralizar algunas de las búsquedas después de unas 300 peticiones de casillas, porque bloqueaba demasiado el sistema.

Para solucionarlo, en un primer momento pretendí utilizar el multiproceso: enviaría las búsquedas de ruta al resto de núcleos del procesador y que me devolvieran el resultado cuando lo lograran. Por desgracia, cada búsqueda de ruta necesita usar el bloque de memoria del mapa con toda la información sobre las 32.768 casillas (que además incluyen para cada una las coordenadas vecinas y accesibles). El tamaño alcanza unos ocho megas, una cantidad de datos demasiado grande para dispersar por el resto de núcleos sin que paralizara el núcleo principal. Si sólo diseminar las peticiones de búsqueda de ruta a otros núcleos del procesador tarda más que ejecutarlos en el mismo procesador, eso invalida la opción.

Uno de los problemas principales es que la búsqueda de ruta no entiende que por mucho que el destino sea una casilla atravesable por un agente no implica que pueda llegar hasta ella. Por ejemplo, una casilla en la cima de una montaña pero rodeada de acantilados es accesible si ya empiezas en ella, pero no si falta una ruta hasta la cima, y averiguarlo puede implicar que el algoritmo A*, que busca la ruta, pruebe literalmente miles de casillas de las potenciales 32.768.

La solución más interesante que descubrí es la búsqueda de ruta jerárquica: consiste en dividir un mapa en sectores de cierto tamaño (recomendaban 16 casillas en todas las dimensiones), y dentro de cada sector identificar las regiones de casillas accesibles. En mi caso el asunto se complica porque no sólo incluyo tres dimensiones, sino diferentes tipos de movimiento. Mis agentes andan, nadan por aguas poco profundas, por aguas profundas, o por el aire. No hay además impedimentos a nivel programático para que pudieran hacer una combinación de esas cosas: por ejemplo, alguna criatura anfibia, o pájaros que se sumergieran en el agua. Una vez el mapa original está dividido en sectores y regiones, se calcula el centro de masa de cada región para determinar su centro, y serán esos centros de región los que se usarán para buscar una ruta en el mapa. Eso conseguirá que descubrir que no se puede acceder a una casilla aislada se reduzca a mirar quizá unos diez centros de región, en vez de literalmente miles de casillas. Además, una vez se ha determinado una ruta a través de los centros de región, la ruta en el mapa entero puede realizarse de centro de región en centro de región, lo que también limita esas búsquedas a una media de unas 16-20 casillas.

Para asegurar que la programación sectorizaba los mapas correctamente (mapas que además se generan cada vez mediante el algoritmo de ruido Perlin), me planteé programar un visualizador. El siguiente vídeo muestra el primer intento de implementar la sectorización, pero fallé en identificar las regiones correctamente, como explicaré después.

El vídeo muestra las regiones coloreadas en función del número que se les ha asignado; en cada sector las regiones empiezan desde 1, y el contador sube tanto como necesita. Las cruces indican los centros de región. Que regiones aparentemente contiguas aparecieran coloreadas de maneras diferentes habría debido avisarme de que algo funcionaba mal. Aunque las regiones creadas parecían razonables para los agentes voladores y nadadores, los cambios de elevación destrozaban la identificación de regiones para los agentes que andan. Cada vez que la elevación cambiaba se creaba una región nueva, algo que no tiene sentido, ya que los agentes podrían usar las rampas para desplazarse.

Por fortuna yo ya había programado esas comprobaciones complicadísimas de accesibilidad para buscar la ruta de casilla en casilla. Cada casilla del mapa original incluye la información sobre sus vecinos accesibles. De cada una de esas listas convertí las coordenadas al sistema de coordenadas internas de cada sector (de 0 a 15 en las dimensiones x e y, y de 0 a 7 en la z), y las añadí como vecinos internos siempre que el vecino existiera en el mismo sector.

El resultado se ve en el siguiente vídeo.

Muestra con claridad, particularmente en el extremo superior izquierdo del mapa, cómo la sectorización ha considerado que los cambios de elevación formaban parte de la misma región. Hay un caso interesante en el extremo superior derecho del mapa, donde en un sector que coincide con la cima de la montaña se han identificado dos regiones distintas, una en la cima y la otra en la tierra que rodea la base, porque un acantilado impide la ruta.

La sectorización de un mapa para programar una búsqueda de ruta a este nivel implica también reconocer las fronteras de un sector, y si están abiertas al tránsito en esa dirección. Aunque está programado, el visualizador del vídeo no lo muestra.

Ahora queda adaptar el algoritmo A* de búsqueda de ruta, ya programado, para que sólo considere los centros de región, y hará algo similar a lo siguiente: