AStar Pathfinding для агентов различного размера с использованием пространственного хэширования

от автора

Наверное, большинству людей, связанных с программированием игр, известен алгоритм AStar.

В интернете можно найти много примеров объяснения того, как он работает, и реализации для различных языков, когда размер (далее радиус) агента, которого необходимо перемещать по импровизированной карте, известен заранее и не меняется.

Но когда речь заходит о поддержке агентов, обладающих разным радиусом, увы, информации не так много.

Данный пробел я постараюсь восполнить в рамках этой статьи.

Что нам для этого понадобится ?

  1. Предполагается, что читатель уже знаком с AStar. Если нет, то вот ссылка: https://habr.com/ru/articles/331192/

  2. Базовое понимание пространственного хэширования (Spatial Partitioning): https://habr.com/ru/articles/182998/

  3. Понимание синтаксиса языка Kotlin, поскольку примеры кода будут на нем. В действительности же это может быть реализовано на любом языке/технологии, например на Unity и С#.

Для тестов будет использоваться homemade движок, написанный на Kotlin и OpenGL: https://github.com/shirokovnv/serenity

Запускаемый файл для Windows можно скачать с Google Drive: https://drive.google.com/file/d/10bZyg9zdBMwnV_DfUdUnbCBh06gZMnjM/view?usp=drive_link

Определение агента:

interface NavMeshAgent {     var radius: Float     var stepSize: Float      fun getHeuristic(): PathHeuristicInterface }

radius: это радиус ограничивающей сферы для нашего агента

stepSize — размер «шага» для агента.

getHeuristic() — функция, возвращающая эвристику для AStar.

interface PathHeuristicInterface {     fun calculateDistanceCost(a: PathNode, b: PathNode): Float }
class PathNode (val point: Vector3): Comparable<PathNode> {     var gCost: Float = Float.MAX_VALUE     var hCost: Float = 0f     var fCost: Float = 0f     var isWalkable: Boolean = true     var prevNode: PathNode? = null      fun calculateFCost() {         fCost = gCost + hCost     }      override fun compareTo(other: PathNode): Int {         var compare = fCost.compareTo(other.fCost)         if (compare == 0) {             compare = hCost.compareTo(other.hCost)         }          return compare     } }

PathNode содержит данные, необходимые для расчетов AStar, и функцию сравнения с таким же узлом пути по fCost и hCost.

Далее, нам необходимо отобразить объекты сцены, по которой агент будет перемещаться, на пространственную структуру данных.

Интерфейс и реализация этой структуры представлены ниже.

interface SpatialPartitioningInterface {     fun insert(obj: Object): Boolean     fun remove(obj: Object): Boolean     fun countObjects(): Int     fun buildSearchResults(searchVolume: BoxAABB): List<Object> }
class NavGrid(private val bounds: Rect2d, private val dimensions: Vector2) : SpatialPartitioningInterface {     private val cells: HashMap<String, HashSet<Object>> = HashMap()     private val objectIndexCache: HashMap<Object, Array<Vector2>> = HashMap()      override fun insert(obj: Object): Boolean {         val objRect = obj.bounds()          val i1 = getCellIndex(objRect.min)         val i2 = getCellIndex(objRect.max)          var isAdded = true         for (xi in i1.x.toInt()..i2.x.toInt()) {             for (yi in i1.y.toInt()..i2.y.toInt()) {                 val k = getKey(xi, yi)                 cells.computeIfAbsent(k) { HashSet() }                 isAdded = isAdded and (cells[k]?.add(obj) ?: false)             }         }          objectIndexCache[obj] = arrayOf(i1, i2)          return isAdded     }      override fun remove(obj: Object): Boolean {         val objectIndices = objectIndexCache.remove(obj) ?: return false         val (i1, i2) = objectIndices          var isRemoved = true         for (xi in i1.x.toInt()..i2.x.toInt()) {             for (yi in i1.y.toInt()..i2.y.toInt()) {                 val k = getKey(xi, yi)                 cells.computeIfAbsent(k) { HashSet() }                 isRemoved = isRemoved and (cells[k]?.remove(obj) ?: false)             }         }          return isRemoved     }      override fun countObjects(): Int {         return objectIndexCache.keys.size     }      override fun buildSearchResults(searchVolume: BoxAABB): List<Object> {         val searchRect = searchVolume.toRect2d()          val i1 = getCellIndex(searchRect.min)         val i2 = getCellIndex(searchRect.max)          val objects = HashSet<Object>()          for (xi in i1.x.toInt()..i2.x.toInt()) {             for (yi in i1.y.toInt()..i2.y.toInt()) {                 val k = getKey(xi, yi)                 cells.computeIfAbsent(k) { HashSet() }                 objects.addAll(cells[k]?.filter { obj ->                     IntersectionDetector.intersects(obj.bounds(), searchRect)                 } ?: emptySet())             }         }          return objects.toList()     }      fun getCellIndices(objRect: Rect2d): IntArray {         val i1 = getCellIndex(objRect.min)         val i2 = getCellIndex(objRect.max)          return arrayOf(             i1.x.toInt(),             i1.y.toInt(),             i2.x.toInt(),             i2.y.toInt()         ).toIntArray()     }      private fun getCellIndex(position: Vector2): Vector2 {         val unionX = ((position.x - bounds.min.x) / (bounds.max.x - bounds.min.x)).saturate()         val unionY = ((position.y - bounds.min.y) / (bounds.max.y - bounds.min.y)).saturate()          val xIndex = floor(unionX * (dimensions.x - 1f))         val yIndex = floor(unionY * (dimensions.y - 1f))          return Vector2(xIndex, yIndex)     }      private fun getKey(xIndex: Int, yIndex: Int): String {         return "$xIndex.$yIndex"     } }

Структура принимает в качестве входных параметров bounds — ограничивающий объем всех объектов сцены (мира), dimensions — размерность сетки.

Пример:

val bounds = Rect2d(Vector2(0f, 0f), Vector2(100f, 100f)) val dimensions = Vector2(25f, 25f) // размерность задается в целых числах val navGrid = NavGrid(bounds, dimensions) // Создана сетка, покрывающая мировой квадрант (0,0) -> (100, 100)  // с размером ячейки, равным 100/25 = 4  // Получение списка всех препятствий и добавление в NavGrid val obstacles = getObstacles() obstacles.forEach { obj -> navGrid.insert(obj) }

Следует также отметить, что размерность сетки нужно задавать таким образом, чтобы размер (длина и ширина) одной ячейки был меньше или равен stepSize агента.

Для чего — будет раскрыто чуть позже.

Итак, каждому элементу игрового мира, который мы хотим использовать как препятствие, ставится в соответствие 1 или несколько ячеек нашей сетки.

Следующий шаг, использовать пространственную структуру в навигации с помощью AStar.

Начнем также с определения интерфейса:

interface NavigatorInterface {     fun calculatePath(start: Vector3, finish: Vector3, agent: NavMeshAgent): Path     fun evaluatePoint(point: Vector3, agent: NavMeshAgent): Boolean     fun outOfBounds(point: Vector3, agent: NavMeshAgent): Boolean }

И реализации:

class Navigator(     private val worldBounds: Rect2d,     private val grid: NavGrid ) : NavigatorInterface {      companion object {         private const val MAX_PATH_LENGTH = 4000         private const val MAX_CLOSED_NODES_LENGTH = 20000     }      override fun calculatePath(start: Vector3, finish: Vector3, agent: NavMeshAgent): Path {         if (outOfBounds(start, agent)) {             return Path(emptyList(), PathStatus.NOT_EXISTS)         }          if (outOfBounds(finish, agent)) {             return Path(emptyList(), PathStatus.NOT_EXISTS)         }          val nodes = mutableMapOf<PathNodeIndex, PathNode>()          val startNode = evaluateNode(Vector3(start.x, 0f, start.z), agent, nodes)         val endNode = evaluateNode(Vector3(finish.x, 0f, finish.z), agent, nodes)          if (startNode == null || endNode == null) {             return Path(emptyList(), PathStatus.NOT_EXISTS)         }          if (!startNode.isWalkable || !endNode.isWalkable) {             return Path(emptyList(), PathStatus.NOT_EXISTS)         }          val openedQueue = PriorityQueue<PathNode>()         openedQueue.add(startNode)         val closedSet = mutableSetOf<PathNode>()          startNode.gCost = 0f         startNode.hCost = agent.getHeuristic().calculateDistanceCost(startNode, endNode)         startNode.calculateFCost()          while (openedQueue.isNotEmpty()) {             val currentNode = openedQueue.remove()             closedSet.add(currentNode)              if (currentNode == endNode) {                 return buildPath(endNode)             }              if (isNodesNear(currentNode, endNode, agent.stepSize)) {                 endNode.prevNode = currentNode                 return buildPath(endNode)             }              if (closedSet.size >= MAX_CLOSED_NODES_LENGTH) {                 return Path(emptyList(), PathStatus.TOO_LONG)             }              for (neighbor in getNeighbours(currentNode, agent, nodes)) {                 if (closedSet.contains(neighbor)) continue                  if (!neighbor.isWalkable) {                     closedSet.add(neighbor)                     continue                 }                  val tentativeGCost =                     currentNode.gCost + agent.getHeuristic().calculateDistanceCost(currentNode, neighbor)                  if (tentativeGCost < neighbor.gCost) {                     neighbor.prevNode = currentNode                     neighbor.gCost = tentativeGCost                     neighbor.hCost = agent.getHeuristic().calculateDistanceCost(neighbor, endNode)                     neighbor.calculateFCost()                      if (!openedQueue.contains(neighbor)) {                         openedQueue.add(neighbor)                     } else {                         openedQueue.remove(neighbor)                         openedQueue.add(neighbor)                     }                 }             }         }          return Path(emptyList(), PathStatus.NOT_EXISTS)     }      override fun evaluatePoint(point: Vector3, agent: NavMeshAgent): Boolean {         if (outOfBounds(point, agent)) {             return false         }          val bounds = BoxAABB(Rect3d(point - agent.radius, point + agent.radius))         val searchResults = grid.buildSearchResults(bounds)          return ensureSearchResultsContainAtMostSelf(agent, searchResults)     }      private fun evaluateNode(         point: Vector3,         agent: NavMeshAgent,         existingNodes: MutableMap<PathNodeIndex, PathNode>     ): PathNode? {         val bounds = BoxAABB(Rect3d(point - agent.radius, point + agent.radius))          if (outOfBounds(point, agent)) {             return null         }          // Check node in cache         val indices = grid.getCellIndices(bounds.toRect2d())         val pathNodeIndex = PathNodeIndex(indices)         if (existingNodes.containsKey(pathNodeIndex)) {             return existingNodes[pathNodeIndex]         }          // Create a new one         val pathNode = PathNode(point)         pathNode.isWalkable = evaluatePoint(point, agent)         existingNodes[pathNodeIndex] = pathNode          return pathNode     }      override fun outOfBounds(point: Vector3, agent: NavMeshAgent): Boolean {         val minPoint = point - agent.radius         val maxPoint = point + agent.radius         val bounds = Rect2d(             Vector2(minPoint.x, minPoint.z),             Vector2(maxPoint.x, maxPoint.z)         )          return !OverlapDetector.contains(worldBounds, bounds)     }      private fun isNodesNear(nodeA: PathNode, nodeB: PathNode, stepSize: Float): Boolean {         return (distanceSquared(nodeA.point, nodeB.point) <= stepSize * stepSize)     }      private fun ensureSearchResultsContainAtMostSelf(         agent: NavMeshAgent,         searchResults: List<Object>     ): Boolean {         return searchResults.isEmpty() || (searchResults.size == 1 && searchResults.first() == agent.objectRef)     }      private fun getNeighbours(         currentNode: PathNode,         agent: NavMeshAgent,         existingNodes: MutableMap<PathNodeIndex, PathNode>     ): List<PathNode> {         val neighbours = mutableListOf<PathNode?>()         val p = currentNode.point         val stepSize = agent.stepSize          val p0 = Vector3(p.x - stepSize, 0f, p.z - stepSize)         val p1 = Vector3(p.x - stepSize, 0f, p.z)         val p2 = Vector3(p.x, 0f, p.z - stepSize)         val p3 = Vector3(p.x + stepSize, 0f, p.z)         val p4 = Vector3(p.x, 0f, p.z + stepSize)         val p5 = Vector3(p.x - stepSize, 0f, p.z + stepSize)         val p6 = Vector3(p.x + stepSize, 0f, p.z - stepSize)         val p7 = Vector3(p.x + stepSize, 0f, p.z + stepSize)          val n0 = evaluateNode(p0, agent, existingNodes)         val n1 = evaluateNode(p1, agent, existingNodes)         val n2 = evaluateNode(p2, agent, existingNodes)         val n3 = evaluateNode(p3, agent, existingNodes)         val n4 = evaluateNode(p4, agent, existingNodes)         val n5 = evaluateNode(p5, agent, existingNodes)         val n6 = evaluateNode(p6, agent, existingNodes)         val n7 = evaluateNode(p7, agent, existingNodes)          neighbours.addAll(listOf(n0, n1, n2, n3, n4, n5, n6, n7))          return neighbours.filterNotNull().filter { it.isWalkable }     }      private fun buildPath(endNode: PathNode): Path {         val path = mutableListOf(endNode)         var currentNode: PathNode? = endNode          while (currentNode?.prevNode != null) {             path.add(currentNode.prevNode!!)              if (path.size > MAX_PATH_LENGTH) {                 return Path(reversePath(path), PathStatus.TOO_LONG)             }              currentNode = currentNode.prevNode         }          return Path(reversePath(path), PathStatus.OK)     }      private fun reversePath(path: MutableList<PathNode>): List<PathNode> {         path.reverse()         return path     } }

Класс для пути выглядит так:

data class Path(val nodes: List<PathNode>, val status: PathStatus)  enum class PathStatus {     OK,     NOT_EXISTS,     TOO_LONG, }

Еще нам понадобится индекс пути:

data class PathNodeIndex(val indices: IntArray) {     override fun equals(other: Any?): Boolean {         if (this === other) return true         if (javaClass != other?.javaClass) return false          return indices.contentEquals((other as PathNodeIndex).indices)     }      override fun hashCode(): Int {         return indices.contentHashCode()     } }

Индекс — это min, max индекс квадранта, спроецированного на сетку NavGrid.

Давайте подробнее остановимся на нескольких моментах:

Первое — это ограничения:

MAX_PATH_LENGTH — максимальная длина пути в узлах. Поскольку наш мир может быть сколь угодно больших размеров, итерироваться по мелкой сетке до бесконечности мы не хотим.

MAX_CLOSED_NODES_LENGTH — максимальное количество узлов в структуре ClosedSet (список посещенных узлов AStar). Идея состоит в том, что иногда наш мир поделен на участки, которые либо не связаны друг с другом напрямую, либо расположены слишком далеко. В этом случае AStar будет перебирать узлы, пока там не окажутся все узлы из данной компоненты связности (локального участка мира), что тоже не всегда хорошо, если участок очень большой.

Если в ходе работы алгоритма данные лимиты будут превышены, то вернется пустой путь со статусом TOO_LONG (слишком длинный).

Далее — функциональность самого класса Navigator:

outOfBounds(…), как следует из названия, определяет, находится ли текущая точка (а точнее квадрант) за ограничивающим мировым прямоугольником или нет.

evaluatePoint(…) — проверяет, находится ли квадрант в пределах мира и есть ли внутри него какие-либо объекты, которые могут препятствовать движению.

Ну и calculatePath(…) — функция, выполняющая основную работу по поиску пути.

Процесс протекает следующим образом:

  1. На каждом шаге AStar мы вычисляем смещение исходного квадранта (в центре которого находится агент, а размеры равны радиусу агента) на значение, равное stepSize (длине шага агента) во всех направлениях.

    И производим проверку смещенных квадрантов на наличие препятствий.

    Если препятствие найдено, узел помечается как unwalkable (непроходимый). Если нет, то walkable (проходимый).

    Здесь нам как раз и пригодится условие, что размер ячейки нашей сетки должен быть меньше или равен длине шага. Если это не так и размер ячейки слишком большой, то мы рискуем никогда не выбраться из нее.

    Опять же, слишком маленьким его тоже делать не стоит, поскольку это увеличит общее количество узлов сетки и время расчетов.

    Визуально процесс инициализации соседних узлов можно изобразить так:

  1. Далее, считается AStar с несколькими отличиями от классической реализации.

    Во-первых, узлы выделяются динамически. Для этого нам и нужен PathNodeIndex.

    Для каждого расчета пути calculatePath, мы запоминаем найденные узлы в кэш по индексам сетки NavGrid. После вычислений кэш обнуляется.

    Во вторых, конечный узел мы ищем в некоторой окрестности, которая равна stepSize, а не всегда по точному совпадению. Это нужно, поскольку мы проецируем в общем случае не дискретный мир на дискретную сетку.

  2. Возвращаем искомый путь, если он существует.

В качестве тестов как я упоминал выше, используется движок для генерации ландшафтов, скриншоты с комментариями вы можете видеть ниже.

Для расчетов используется следующая эвристика:

class DiagonalDistanceHeuristic: PathHeuristicInterface {     companion object {         private const val MOVE_STRAIGHT_COST = 10f         private const val MOVE_DIAGONAL_COST = 14f     }      override fun calculateDistanceCost(a: PathNode, b: PathNode): Float {         val xDistance = abs(a.point.x - b.point.x)         val zDistance = abs(a.point.z - b.point.z)         val remaining = abs(xDistance - zDistance)         return MOVE_DIAGONAL_COST * min(xDistance, zDistance) + MOVE_STRAIGHT_COST * remaining     } }
Найденный путь для агента, радиусом 10. Деревья и наклонные поверхности служат в качестве препятствий.

Найденный путь для агента, радиусом 10. Деревья и наклонные поверхности служат в качестве препятствий.
Для агента радиусом 50, путь не найден.

Для агента радиусом 50, путь не найден.
То же самое изображение, с наложенным navmesh. Красным подсвечены непроходимые участки, зеленым - проходимые.

То же самое изображение, с наложенным navmesh. Красным подсвечены непроходимые участки, зеленым — проходимые.

Время подвести некоторые итоги:

Данный алгоритм позволяет рассчитывать пути для агентов с различным радиусом с помощью AStar.

Используется дискретная сетка, на которую проецируются объекты реального мира.

Размер ячейки сетки необходимо выбирать <= stepSize агента.

Алгоритм создает узлы, необходимые для анализа AStar на лету, что может потребовать дополнительной оптимизации при большом количестве запросов.

Движок, в котором все тестировалось, является экспериментальным и не используется в коммерческих или некоммерческих игровых проектах на момент написания статьи.

Ну а на этом все, надеюсь, что изложенный материал был полезен.

Спасибо за внимание.


ссылка на оригинал статьи https://habr.com/ru/articles/883210/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *