algorithmus „Jump Point Search“
Transcription
algorithmus „Jump Point Search“
Implementierung des Wegfindungsalgorithmus „Jump Point Search“ im Spieleentwicklungs-Framework Unity 3D Kai Hillenbrand Bachelor-Thesis Studiengang Informatik Fakultät für Informatik Hochschule Mannheim 15.07.13 Durchgeführt bei der Firma Elaspix Betreuer: Dr. Tobias Günther Zweitkorrektor: Prof. Dr. Thomas Ihme Bachelorarbeit Kai Hillenbrand Eigenständigkeitserklärung Hiermit bestätige ich, dass ich den vorliegenden Bericht selbstständig verfasst und keine anderen als die angegebenen Hilfsmittel benutzt habe. Datum Kai Hillenbrand _________________________ _________________________ 15.07.2013 1 Bachelorarbeit Kai Hillenbrand Abstract In dieser Arbeit werden die Grundlagen der Wegfindung sowie die verbreitetsten Methoden und Algorithmen in diesem Themenbereich erklärt. Wegfindung wird in Spielen genutzt, um einen optimalen Weg zwischen einem Start- und Endpunkt zu finden, welcher von Charakteren und Objekten verwendet wird. Es wird ein kurzer Überblick über in bekannten Spiele-Engines verwendete Arten der Wegfindung gegeben. Der Fokus liegt dabei auf dem Spiele-Framework Unity 3D. Den Kern der Arbeit bildet die Implementierung des Jump Point Search Algorithmus in Unity 3D, welcher eine Erweiterung des gängigen A*-Wegfindungs-Algorithmus ist. Hierbei wird auf eine effiziente Implementierung Wert gelegt. Ebenfalls werden Tipps zu Datenstrukturen und weiteren Optimierungen gegeben. Jump Point Search wird erklärt und abschließend dessen Implementierung ausführlich evaluiert, indem er mit einer Implementierung von A* verglichen wird. Die Ergebnisse zeigen, dass dieser neue Algorithmus tatsächlich in den meisten Anwendungsfällen, insbesondere auf weiten Flächen, A* deutlich überlegen ist. 15.07.2013 2 Bachelorarbeit Kai Hillenbrand Inhaltsverzeichnis Abstract ................................................................................................................................................... 2 Inhaltsverzeichnis .................................................................................................................................... 3 Abbildungsverzeichnis ............................................................................................................................. 5 Begriffsverzeichnis................................................................................................................................... 6 1 2 3 4 5 6 Einleitung ......................................................................................................................................... 7 1.1 Motivation ............................................................................................................................... 7 1.2 Problembeschreibung ............................................................................................................. 7 1.3 Aufgabenstellung..................................................................................................................... 7 1.4 Aufbau der Arbeit .................................................................................................................... 7 Grundlagen ...................................................................................................................................... 9 2.1 Allgemeiner Ablauf .................................................................................................................. 9 2.2 Graphentheorie ..................................................................................................................... 10 2.3 Wegfindungsalgorithmen ...................................................................................................... 13 2.4 Spiele-Engines........................................................................................................................ 17 Wegfindung in verbreiteten Spiele-Engines .................................................................................. 19 3.1 Unity 3D ................................................................................................................................. 19 3.2 Unreal Development Kit (UDK).............................................................................................. 19 3.3 CryENGINE ............................................................................................................................. 20 3.4 Source Engine ........................................................................................................................ 20 3.5 Weitere Engines .................................................................................................................... 21 Repräsentation eines Levels als Graph.......................................................................................... 22 4.1 Rectangular Grid .................................................................................................................... 22 4.2 Quadtree ............................................................................................................................... 22 4.3 Waypoints.............................................................................................................................. 23 4.4 Navmesh ................................................................................................................................ 23 Häufige Wegfindungsalgorithmen in Spielen ................................................................................ 25 5.1 Hierarchical Pathfinding A* (HPA*) ....................................................................................... 25 5.2 Iterative Deepening A* (IDA*)............................................................................................... 26 5.3 Simplified Memory Bounded A* (SMA*)............................................................................... 26 5.4 D* .......................................................................................................................................... 26 Der Jump Point Search Algorithmus .............................................................................................. 27 6.1 Einleitung ............................................................................................................................... 27 15.07.2013 3 Bachelorarbeit 7 8 Kai Hillenbrand 6.2 Algorithmus-Erklärung .......................................................................................................... 27 6.3 Evaluierung ............................................................................................................................ 30 6.4 JPS+ ........................................................................................................................................ 30 Jump Point Search in Unity 3D ...................................................................................................... 31 7.1 Übersicht ............................................................................................................................... 31 7.2 Implementierung des Algorithmus........................................................................................ 33 7.3 Datenstrukturen und Optimierungen ................................................................................... 36 7.4 Evaluierung ............................................................................................................................ 37 Fazit ............................................................................................................................................... 42 8.1 Ergebnis ................................................................................................................................. 42 8.2 Ausblick.................................................................................................................................. 42 Literaturverzeichnis ............................................................................................................................... 43 Bücher und Paper .............................................................................................................................. 43 Webseiten ......................................................................................................................................... 43 15.07.2013 4 Bachelorarbeit Kai Hillenbrand Abbildungsverzeichnis Abbildung 1 – Allgemeiner Ablauf der Wegfindung................................................................................ 9 Abbildung 2 - Ein nicht zusammenhängender Graph............................................................................ 10 Abbildung 3 - Ein Graph mit Weg und Kreis .......................................................................................... 10 Abbildung 4 - Ein Baum-Graph .............................................................................................................. 11 Abbildung 5 - Ein Graph mit Mehrfachkanten und Schlinge ................................................................. 11 Abbildung 6 - Ein bewerteter Graph mit zwei Wegen von A nach B..................................................... 11 Abbildung 7 - Ein gerichteter Graph ...................................................................................................... 12 Abbildung 8 - Ein Wurzelbaum .............................................................................................................. 12 Abbildung 9 - Reihenfolge der Expansionen bei der Breitensuche ....................................................... 13 Abbildung 10 - Reihenfolge der Expansionen bei der Tiefensuche....................................................... 14 Abbildung 11 - Die Unity 3D Entwicklungsumgebung ........................................................................... 18 Abbildung 12 - Beispiele der Levelrepräsentationen [11] ..................................................................... 24 Abbildung 13 - Symmetrische Wege mit jeweils 9 horizontalen und vertikalen Schritten [14] .......... 27 Abbildung 14 - Pruning der Nachfolger eines Feldes beim Expandieren [15]....................................... 28 Abbildung 15 - Beispiel einer Suche mit JPS.......................................................................................... 30 Abbildung 16 - Beispiel eines gefundenen besten Weges in der VisualTiles-Demo ............................. 31 Abbildung 17 - A*-Benchmark in der TileExporter-Demo anhand eines Levels aus Baldur's Gate ...... 32 Abbildung 18 - Ergebnisse der Vergleichstests zwischen A* und JPS ................................................... 38 Abbildung 19 - Tests 1-3 (links: A*, rechts: JPS) .................................................................................... 39 Abbildung 20 - Tests 4-6 (links: A*, rechts: JPS) .................................................................................... 40 Abbildung 21 - Tests 7-9 (links: A*, rechts: JPS) .................................................................................... 41 15.07.2013 5 Bachelorarbeit Kai Hillenbrand Begriffsverzeichnis Agent Ein Objekt (z.B. Spielercharakter), welches Wegfindung anfordert Closed List Eine Liste von bereits besuchten Knoten während der Wegfindung. Division Scheme Ein Abbildungsschema, welches beschreibt, wie ein SpieleLevel auf einen mathematischen Graphen abgebildet wird. Engine Ein eigenständiger Teil eines Computerprogramms, welcher für eine bestimmte Aufgabe zuständig ist. Eine Grafik-Engine kümmert sich beispielsweise um die Darstellung der Grafik in einem Spiel. Expansion Einen Knoten in einem Wegfindungsalgorithmus zu expandieren, bedeutet, seine Nachfolger zu bestimmen und der Open List hinzuzufügen. Frame Ein Einzelbild aus einer Videosequenz, bzw. in diesem Fall der visuellen Ausgabe des Spiels Game Loop Die Schleife, in der sich ein Spiel befindet, während es läuft Graph Ein mathematischer Graph ist eine Datenstruktur, welche mehrere Objekte („Knoten“) und deren Verbindungen untereinander („Kanten“) beschreibt. Heuristik In Bezug auf die Wegfindung beschreibt eine HeuristikFunktion einen Schätzwert von der aktuellen Position zum Ziel. Open List Eine sortierte Liste von Knoten, aus welcher in jedem Schritt des Wegfindungsalgorithmus der als nächstes zu expandierende Knoten bezogen wird. Polygonfläche Die Fläche, welche ein Polygon (Vieleck) bildet Suchraum Die Menge an Objekten, unter denen der beste Weg zwischen zweien dieser Objekte gesucht wird Thread Ein Thread ist ein Teil eines Prozesses. Eine Anwendung kann in mehrere parallel laufende Threads aufgeteilt werden, um gleichzeitig Aktionen durchzuführen. 15.07.2013 6 Bachelorarbeit Kai Hillenbrand 1 Einleitung 1.1 Motivation Als Wegfindungsalgorithmus bezeichnet man einen Algorithmus, welcher den besten Weg zwischen zwei Punkten A und B sucht. Hierbei ist allerdings nicht nur eine möglichst kurze Weglänge wichtig. Liegt beispielsweise ein Fluss zwischen Start und Ziel, ist es im Normalfall vorzuziehen, den Umweg über die Brücke zu wählen, statt den direkten Weg durch den Fluss zu beschreiten. Wie „gut“ ein Weg ist, ist also ebenfalls etwas, das von einem Wegfindungsalgorithmus beachtet werden muss. Wegfindung wird primär im Bereich der Spieleentwicklung verwendet. Sie wird beispielsweise eingesetzt, um einen Charakter in einem Strategiespiel von A nach B laufen zu lassen, ohne, dass dieser an möglichen Hindernissen hängenbleibt. Ein weiteres offensichtliches Anwendungsgebiet ist die Routenplanung. Navigationssysteme greifen auf Wegfindungsalgorithmen zurück, um dem Nutzer den besten Weg zu seinem Ziel zu präsentieren. Auch in der Robotik wird Wegfindung eingesetzt, um Roboter intelligent zum Ziel zu führen und Hindernissen ausweichen zu lassen. 1.2 Problembeschreibung Vor allem in der Spieleentwicklung gibt es weiterhin großen Verbesserungsbedarf an Wegfindungsalgorithmen. Selbst bei großen Spieleproduktionen mit hohem Budget lassen sich häufig Charaktere erkennen, die an einer Wand hängen bleiben, gegen unsichtbare Hindernisse stoßen oder scheinbar grundlos einen Umweg laufen. Durch immer detaillierter werdende Levels erhöht sich die Komplexität der Wegfindung und somit oft auch die Anzahl der Fehler. Der Bedarf von Ressourcen (CPU-Leistung und Arbeitsspeicher) steigt ebenfalls, was sich negativ auf die Spieleperformance auswirken kann. Somit ist es wichtig, dass Wegfindungsalgorithmen so effektiv wie möglich arbeiten, um auch in komplexeren Levels schnell einen optimalen Weg zu finden. 1.3 Aufgabenstellung Ziel dieser Arbeit ist es, einen neuartigen Wegfindungsalgorithmus namens „Jump Point Search“ im Spieleentwicklungs-Framework Unity 3D zu implementieren. Diese Erweiterung von Unity 3D soll Entwicklern helfen, schnelle Wegfindung auch in komplexen Projekten anzuwenden. Jump Point Search ist bisher noch kaum verbreitet und es existiert noch keine Implementierung für das Unity 3D Framework, welches derzeit eines der beliebtesten Werkzeuge zur Spieleentwicklung ist. 1.4 Aufbau der Arbeit Im Grundlagenkapitel wird zunächst der allgemeine Ablauf der Wegfindung erklärt sowie eine Einführung in zum Verständnis notwendige Themen wie Graphentheorie, grundlegende Wegfindungsalgorithmen sowie die Unity 3D Engine und Spiele-Engines im Allgemeinen gegeben. Im darauf folgenden Kapitel werden verbreitete Spiele-Engines vorgestellt und bezüglich ihrer verwendeten Wegfindungstechnologien analysiert. Daraufhin werden mehrere Methoden vorgestellt, welche beschreiben, wie sich ein Level in Form eines zur Wegfindung verwendbaren 15.07.2013 7 Bachelorarbeit Kai Hillenbrand Graphen repräsentieren lässt. Während im Grundlagenkapitel die Basisalgorithmen wie A* erklärt wurden, werden im Kapitel „Häufige Wegfindungsalgorithmen in Spielen“ einige auf A* basierende und häufig in Spielen verwendete Algorithmen präsentiert. Die folgenden beiden Kapitel behandeln das Kernthema der Arbeit: Jump Point Search und dessen Implementierung in Unity 3D. Zunächst wird der Algorithmus und dessen Ablauf detailliert erklärt. Dann wird ein Überblick über die im Zuge dieser Arbeit entstandene Implementierung in der Spiele-Engine Unity 3D gegeben. Dabei wird außerdem die Implementierung in Form einer Evaluation mit A* verglichen und es werden mögliche Optimierungen vorgeschlagen. Im abschließenden Kapitel wird ein Fazit gegeben, welches sowohl das Ergebnis zusammenfasst, als auch einen Ausblick auf die Zukunft gibt. 15.07.2013 8 Bachelorarbeit Kai Hillenbrand 2 Grundlagen 2.1 Allgemeiner Ablauf Der allgemeine Ablauf bei der Suche nach dem besten Weg zwischen einem Start- und Endpunkt läuft im Regelfall wie folgt ab: Level / Karte des Suchraums (1) konvertieren/ abbilden Repräsentation als Graph Optimaler Weg von A nach B (2) anwenden (Startpunkt, Endpunkt) Wegfindungs-Algorithmus Abbildung 1 – Allgemeiner Ablauf der Wegfindung Zunächst wird der Suchraum in einen mathematischen Graph konvertiert. Möchte man beispielsweise den besten Weg von München nach Berlin herausfinden, wäre der Suchraum eine Karte von Deutschland, welche sämtliche Straßen beinhaltet. In einem Spiel ist der Suchraum das Level, in dem die Wegfindung durchgeführt werden soll. Bei der Konvertierung werden nur die zur Wegfindung essentiellen Informationen übertragen – die Farbe einer Wand oder der Name einer Straße ist für den Algorithmus nicht von Bedeutung. Auf Basis dieses mathematischen Graphs wird nun der Wegfindungs-Algorithmus angewendet, welcher als Resultat den besten Weg zurückgibt. Das Konvertieren in einen Graphen geschieht gewöhnlicher Weise nicht zur Laufzeit, sondern wird schon vorher berechnet und gespeichert. Der eigentliche Algorithmus wird dann zur Laufzeit auf Anfrage ausgeführt. Bei dynamischen, sich verändernden Levels wird der Graph bei einer Änderung im Level sofort angepasst, sodass der Algorithmus jederzeit auf die aktuellen Wegfindungsdaten zurückgreifen kann. Manche Wegfindungsalgorithmen können sogar während der Suche noch auf Veränderungen im Level reagieren und den gefundenen Weg entsprechend anpassen. In einem Spiel ist die Wegfindung besonders zeitkritisch, da es mit einer bestimmten Anzahl an „Frames pro Sekunde“ (FPS) abläuft. Als Frame wird ein einzelnes Standbild der konstanten Folge an Bildern (im Regelfall zwischen 30 und 60 Bildern pro Sekunde, d.h. ein neues Bild alle 16-33ms) bezeichnet. Grafisch aufwendigere Spiele haben oft weniger FPS, da die visuellen Berechnungen umfangreicher sind. Wenn nun viele Agenten – Objekte, die vom Spieler oder der KI gesteuert werden – gleichzeitig Wegfindungen anfordern, kann das in komplexen Levels dazu führen, dass die Algorithmen zu lange benötigen, um innerhalb eines Frames eine Lösung zu finden. Deshalb müssen sie in eigene Threads ausgelagert werden. Generell sollte der Algorithmus allerdings dahingehend optimiert werden, dass auch viele Wege in nur wenigen Frames gefunden werden können. 15.07.2013 9 Bachelorarbeit Kai Hillenbrand 2.2 Graphentheorie Im Folgenden werden die Grundlagen der Graphentheorie erläutert, welche für diese Arbeit vorausgesetzt sind. 2.2.1 Was ist ein Graph? Abbildung 2 - Ein nicht zusammenhängender Graph Ein Graph ist eine mathematische Struktur, welche verschiedene Objekte und deren Zusammenhänge darstellt. Die Objekte werden als Knoten (blau dargestellt in der Grafik) und die Verbindungen zwischen ihnen als Kanten (grün) bezeichnet. Ein reales Beispiel hierzu wäre ein U-Bahn-Plan: Stationen würden durch Knoten repräsentiert werden und jene, die Direktverbindungen zueinander besitzen, wären mit Kanten verbunden. Der Graph in der obigen Abbildung ist „nicht zusammenhängend“, da nicht jeder Knoten von jedem anderen Knoten erreichbar ist. 2.2.2 Wege und Kreise Abbildung 3 - Ein Graph mit Weg und Kreis Eine Folge von Kanten wird als „Weg“ bezeichnet (oben in rot). Ein „Kreis“ ist ein Weg in einem Graph, der von einem Ausgangsknoten über andere Knoten zum Ausgansknoten zurück führt (orange). 15.07.2013 10 Bachelorarbeit 2.2.3 Kai Hillenbrand Bäume Abbildung 4 - Ein Baum-Graph Als „Baum“ wird ein zusammenhängender Graph bezeichnet, welcher keine Kreise enthält. Diese Art von Graph ist eines der zentralen Elemente in einem Wegfindungsalgorithmus. 2.2.4 Mehrfachkanten und Schlingen Abbildung 5 - Ein Graph mit Mehrfachkanten und Schlinge Es ist auch möglich, mehrere Kanten zwischen zwei Knoten zu haben (oben in rot). Das kann beispielsweise notwendig sein, wenn es mehrere U-Bahn-Linien zwischen zwei Stationen gibt. Als „Schlinge“ wird eine Kante bezeichnet, die von einem Knoten zu sich selbst führt (orange). 2.2.5 Bewertete Graphen 5 7 B A 2 3 Abbildung 6 - Ein bewerteter Graph mit zwei Wegen von A nach B Wie würde man in einem Graph beschreiben, dass zwei U-Bahn-Stationen besonders nah beieinander liegen? Hier kommen bewertete Graphen ins Spiel: Sämtliche Kanten bekommen eine Bewertung zugefügt – ihre Kosten. Die Kosten eines Weges lassen sich berechnen, in dem man die einzelnen Kosten seiner Kanten aufaddiert. Aus einer Vielzahl von Wegen zwischen zwei Knoten ist jener der „beste“, welcher die geringsten Kosten besitzt – und dieser wird vom WegfindungsAlgorithmus gesucht. In der obigen Grafik wäre der orangefarbene Weg von A nach B also günstiger als der rote. Zur Berechnung der Kosten einer Kante werden nicht nur die Distanz, sondern auch 15.07.2013 11 Bachelorarbeit Kai Hillenbrand weitere Parameter, die beschreiben, ob eine Kante „besser“ oder „schlechter“ ist als eine andere, beachtet. Eine ICE-Verbindung zwischen zwei Städten wäre beispielsweise besser als eine Busverbindung und hätte somit geringere Kosten, obwohl beide dieselbe Distanz zurücklegen. 2.2.6 Gerichtete Graphen B A Abbildung 7 - Ein gerichteter Graph Enthält ein Graph Informationen darüber, in welche Richtung zwei Knoten miteinander verbunden sind, spricht man von einem „gerichteten Graphen“. Die gerichteten Kanten in diesem Graphen werden mithilfe von Pfeilen repräsentiert. Im obigen Bild kommt man also von A nach B, aber nicht mehr zurück. In einem Spiel könnte das beispielsweise bedeuten, dass man von einem Felsen zwar herunterspringen, jedoch nicht mehr hinaufklettern kann. Als „ausgehende Kanten“ bezeichnet man solche Kanten eines Knoten, die von diesem zu anderen Knoten führen. Das Gegenteil davon wird als „eingehende Kante“ bezeichnet. 2.2.7 Wurzelbäume A B Abbildung 8 - Ein Wurzelbaum Ein „Wurzelbaum“ ist ein gerichteter Baum, welcher eine Wurzel besitzt, d.h. einen Knoten, der ausschließlich ausgehende Kanten hat (Knoten A im obigen Bild). Besteht eine Kante von einem Knoten A zu einem Knoten B, wird B als „Nachfolger“ oder „Kind“ von A bezeichnet. Gleichzeitig ist A „Vorgänger“ oder „Vater“ von B. 15.07.2013 12 Bachelorarbeit Kai Hillenbrand 2.3 Wegfindungsalgorithmen 2.3.1 Der Suchbaum Um einen Wegfindungsalgorithmus auf einen Graphen anzuwenden, wird aus diesem während der Suche automatisch ein Wurzelbaum aufgebaut und ständig erweitert. Der Startknoten der Suche bildet die Wurzel des Baumes. Dann wird dieser Knoten „expandiert“ – das bedeutet, man sucht dessen Nachfolgeknoten. Die Nachfolger eines Knoten sind jene Knoten, die von ihm aus erreicht werden können – in der Regel wird dabei der Vorgängerknoten ausgelassen. Durch dieses Ausfächern entsteht der Wurzelbaum. Wird der Zielknoten gefunden, lässt sich über die Hierarchie des Baumes der Weg zum Startknoten zurückverfolgen. Doch was passiert, wenn man auf einen Knoten stößt, welcher bereits (über einen anderen Weg durch den Baum) expandiert wurde? Ein erneutes Expandieren würde zu einer Endlosschleife führen. Aus diesem Grund wird eine sogenannte „Closed List“ geführt, welche sämtliche bereits expandierte Knoten speichert. Trifft man auf einen Knoten aus dieser Liste, wird er einfach ignoriert und nicht expandiert. Somit werden die Kreise des Graphen eliminiert und spätere Endlosschleifen vermieden. Die Entstehung des Suchbaums gleicht also dem Ablauf jedes Suchalgorithmus: Der Startknoten wird expandiert, indem dessen Nachfolger in eine „Open List“ hinzugefügt werden. In jedem Schritt der Suche wird nun ein Knoten aus dieser Liste ausgewählt, expandiert und dessen Nachfolger wiederum zur Liste hinzugefügt. Das wird solange fortgeführt, bis der Zielknoten gefunden wurde. Die Auswahl des nächsten zu expandierenden Knotens aus der Open List gehört zu den größten Unterscheidungsmerkmalen der Algorithmen. Im Folgenden werden die grundlegenden Wegfindungsalgorithmen kurz vorgestellt und erklärt. 2.3.2 Breitensuche Die Breitensuche ist eine uninformierte Suche, welche einen Suchbaum Ebene für Ebene aufbaut und nach dem Ziel durchsucht. „Uninformiert“ bedeutet, dass keine weiteren Kenntnisse über die Position des Ziels beachtet werden. Stattdessen werden für jeden Knoten erst alle direkten Nachfolger expandiert (d.h. nach dem Zielknoten geschaut), dann wiederum alle deren direkten Nachfolger usw., bis das Ziel gefunden oder der komplette Baum durchsucht wurde. 1 3 2 4 5 6 7 Abbildung 9 - Reihenfolge der Expansionen bei der Breitensuche 15.07.2013 13 Bachelorarbeit Kai Hillenbrand Abbildung 9 stellt die Reihenfolge dar, in der vorgegangen wird. Der Startknoten ist grün markiert, der Zielknoten rot. Wäre der orangefarbene Knoten das Ziel, hätte die Breitensuche es bereits nach 3 Schritten gefunden und müsste den restlichen Baum gar nicht erst aufbauen – Knoten 4 bis 7 würden also wegfallen. 2.3.3 Tiefensuche Die Tiefensuche ist ebenfalls eine uninformierte Suche und der Breitensuche sehr ähnlich. Sie unterscheiden sich lediglich in der Reihenfolge, in der der Baum aufgebaut und durchsucht wird. Es wird in der Tiefe gesucht, was bedeutet, dass zunächst der erste Nachfolger expandiert wird, dann dessen erster Nachfolger usw., bis kein Nachfolger mehr vorhanden ist. Erst dann wird in der Breite nach einem Nachbarknoten geschaut. Ist hier keiner mehr vorhanden, wird wieder eine Ebene nach oben gegangen und weitergesucht. Sobald wieder ein Knoten mit Nachfolgern gefunden wurde, wird dieser Ast komplett nach unten durchgegangen. 1 5 2 3 4 6 7 Abbildung 10 - Reihenfolge der Expansionen bei der Tiefensuche Wäre der orangefarbene Knoten das Ziel, hätte es die Tiefensuche erst nach 5 Schritten gefunden. Je nachdem, wo das Ziel liegt, findet also einer der beiden Algorithmen es schneller als der andere. In diesem Fall würden demnach Knoten 6 und 7 wegfallen. 2.3.4 Bestensuche Die Bestensuche ist eine informierte Suche, welche eine Heuristikfunktion verwendet, um das Ziel schneller zu finden. Das bedeutet, dass gezielt Knoten expandiert werden, welche gemäß der Heuristikfunktion mit einer höheren Wahrscheinlichkeit zum Ziel führen. Das steht im Gegensatz zu den bereits vorgestellten uninformierten Breiten- und Tiefensuchen, welche lediglich nach einer vorgegebenen Reihenfolge Knoten expandieren. Durch diese zielstrebigere (statt willkürliche) Suche verringert sich im Normalfall die Anzahl der zu expandierenden Knoten, bis das Ziel gefunden wurde, deutlich. Die Bestensuche funktioniert nur auf einem bewerteten Graphen, da die Kosten der Kanten in die Suche (und die Berechnung der Heuristik) mit einbezogen werden. Beim Expandieren werden die Nachfolger eines Knotens anhand ihrer heuristischen Bewertung in die Open List einsortiert. In jedem Schritt wird der Knoten mit der besten Bewertung aus dieser Liste expandiert. Trifft man beim Expandieren auf einen Knoten aus der Closed List, wird er nur erneut in die Open List hinzugefügt, wenn ein besserer Weg zu ihm gefunden wurde als zuvor. Ansonsten wird er ignoriert, um Endlosschleifen zu vermeiden. Im Folgenden wird der Algorithmus zur besseren Veranschaulichung im Pseudo-Code gezeigt. 15.07.2013 14 Bachelorarbeit 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 Kai Hillenbrand Open List = {Startknoten} Closed List = {} while OPEN not empty { 1. Verschiebe Knoten x mit der besten Bewertung aus der Open List in die Closed List (im 1. Durchgang ist das der Startknoten). 2. Wenn x == Zielknoten, gebe zurückverfolgten Pfad als Ergebnis Zurück und terminiere den Algorithmus. 3. Expandiere die Nachfolger von x. Für jeden Nachfolger n: a. Wenn n nicht in Closed List, berechne f(n), setze x als Parent von n und füge n zur Open List hinzu. b. Wenn n in Closed List und neue Bewertung besser als alte, aktualisiere vorhandenen Knoten mit x als Parent. } Codelisting 1 - Die Bestensuche im Pseudo-Code 2.3.5 A* A* ist eine Variante der Bestensuche und gleichzeitig der zur Wegfindung am häufigsten verwendete Algorithmus. Er expandiert die Knoten anhand einer Bewertungsfunktion, welche wie folgt aufgebaut ist: ( ) ( ) ( ) ( ) beschreibt die benötigten Kosten vom Startknoten bis zum Knoten und ( ) die geschätzten Kosten von zum Zielknoten. Die Summe dieser beiden Funktionen bildet die geschätzten Gesamtkosten ( ), anhand derer die Open List sortiert wird. Abgesehen von der erweiterten Bewertungsfunktion funktioniert der restliche Algorithmus exakt wie die Bestensuche. 2.3.6 Dijkstra Der Dijkstra-Algorithmus ist eine Art von A*, bei welcher ( ) gilt. Das Bedeutet, dass keine Heuristik verwendet wird, sondern nur die aktuellen Kosten eines Knotens beachtet werden. Der Algorithmus berechnet die kürzesten Wege von einem gegebenen Startknoten zu allen anderen Knoten im Graphen. Er beschränkt sich nicht auf einen bestimmten Zielknoten, sondern sucht gleichmäßig in alle Richtungen, abhängig von den Kosten der jeweiligen Knoten. Daher wird er im Normalfall nicht verwendet, um den kürzesten Weg zwischen zwei bestimmten Knoten zu finden, wie es bei normalem A* mit Heuristik der Fall ist. Es würden zu viele kürzeste Wege zu uninteressanten Knoten gefunden werden, ohne benötigt zu werden. Für spezielle Szenarien ist er aber auch in Spielen sehr nützlich. Beispielsweise kann er zur Suche des am schnellsten erreichbaren Power-Ups genutzt werden. In dem Fall wird der Algorithmus so lange ausgeführt, bis das erste Power-Up gefunden wurde, und dann abgebrochen, da es keinen kürzeren Weg als diesen dorthin gibt. Auch für taktische Entscheidungen wie die Suche nach Deckung wird er oft verwendet. 15.07.2013 15 Bachelorarbeit 2.3.7 Kai Hillenbrand Heuristik Eine Heuristikfunktion wird verwendet, um die Nähe eines Knotens zum Ziel abzuschätzen. Im normalen Fall nutzt man hierzu eine „zulässige Heuristik“. Dies bedeutet, dass die geschätzten Kosten immer geringer sein müssen, als die tatsächlichen Kosten – die Voraussetzung für ein optimales Ergebnis im Algorithmus. Auch bei leichter Überschätzung kommen meist noch gute oder sogar optimale Ergebnisse heraus. Zu hohe Überschätzung kann sich allerdings schlecht auf die Performance des Algorithmus auswirken. Die folgenden Heuristikfunktionen sind weit verbreitet: - Euklidischer Abstand: Entspricht der Luftlinie zwischen Knoten und Zielknoten. Manhatten-Abstand: Addiert die horizontalen und vertikalen Abstände zwischen Knoten und Zielknoten. Chebyshev-Abstand: Sucht aus horizontalem und vertikalem Abstand zwischen Knoten und Zielknoten den Größeren heraus. 15.07.2013 16 Bachelorarbeit Kai Hillenbrand 2.4 Spiele-Engines 2.4.1 Aufbau einer Spiele-Engine Eine Spiele-Engine (auch Spiele-Framework genannt) besteht aus den wesentlichen Komponenten, die benötigt werden, um ein Spiel zu entwickeln. Dazu gehören unter anderem: - Grafik-Engine (zur visuellen Darstellung – dies ist die Komponente, in der sich unterschiedliche Spiele-Engines oft am meisten unterscheiden) Sound-Engine (zur Ausgabe von Sound-Effekten, Sprache und Musik) Input-Engine (setzt Eingabebefehle des Nutzers durch Tastendrücke, Mausbewegung und weitere in Spielbefehle um) Physik-Engine (stellt sicher, dass sich Objekte physikalisch korrekt verhalten, miteinander kollidieren etc.) Netzwerk-Engine (für Spiele mit Online-Funktionalitäten, um mehrere Spieler miteinander zu verbinden) Um die Entwicklung eines Spiels möglichst leicht zu gestalten, beinhalten die meisten Spiele-Engines eine Entwicklungsumgebung. Mithilfe dieser lassen sich häufige Arbeitsschritte deutlich verkürzen und vereinfachen. Während manche Engines „nur“ einen Editor inklusive einer Scriptsprache (oft Lua) zur Verfügung stellen, erhält man in anderen kompletten Zugriff auf den Quelltext der Engine, um somit tiefgreifendere Anpassungen vornehmen zu können. Einige Spiele-Engines sind außerdem auf spezielle Genres ausgelegt und lassen sich nur mit umfangreichen Veränderungen für andere Spiele nutzen. 2.4.2 Unity 3D - Einführung In dieser Arbeit liegt der Fokus auf Unity 3D, welche momentan eine der beliebtesten freien SpieleEngines auf dem Markt ist. Erstmals im Jahr 2005 präsentiert, wurde sie doch erst 2010 durch Version 3 populär. Das entwickelte Spiel kann auf viele verschiedene Plattformen exportiert werden: - Windows, OS X, Linux mobile Geräte (Android, IOS, Windows Phone) aktuelle Spielekonsolen (Xbox 360, PlayStation 3, Wii, WiiU) kommende Spielekonsolen (Xbox One und PlayStation 4) Tragbare Spielekonsolen (PlayStation Vita) Web (Adobe Flash, Unity Web Player) Die Engine wird stetig verbessert und liegt bereits in Version 4 vor. Neben weiteren Plattformen werden auch regelmäßig neue Features hinzugefügt (u.a. verbesserte Animationen, DirectX 11 Unterstützung, NavMesh Wegfindung, HDR-Beleuchtung). Zusätzlich können Erweiterungen aus dem „Asset Store“ heruntergeladen werden. Dort liegen sowohl kostenlose als auch kostenpflichtige Assets vor, die im eigenen Projekt verwendet werden können. Neben einer kostenlosen Version gibt es auch Unity Pro, welches zusätzliche Features bietet (vorallem in der Grafik-Engine), sowie die Möglichkeit, den Quelltext der Engine durch Plug-Ins zu erweitern. [1] 15.07.2013 17 Bachelorarbeit 2.4.3 Kai Hillenbrand Unity 3D – Wichtige Begriffe Unity besitzt einen sehr umfangreichen und leicht bedienbaren Editor, um ein Projekt zu verwalten. Den Kern bilden die sogenannten „Assets“ – einzelne Ressourcen, wie z.B. Modelle, Materialien oder Scripts. Mehrere Assets können für den Import und Export in ein „Package“ zusammengefasst werden. Eine „Scene“ wird mit solchen Assets ausgestattet und dann ausgeführt – sie repräsentiert ein Level in einem Spiel. „GameObjects“ werden Assets genannt, wenn sie sich als eigenständige Objekte in einer Scene befinden – beispielweise eine Kamera, ein Licht oder der Spielercharakter. Diese können wiederum mit anderen Assets wie Scripts oder Materialien ausgestattet werden, welche dann „Components“ genannt werden und das GameObject, dem sie zugewiesen wurden, erweitern. Ein GameObject wird als „Prefab“ bezeichnet, wenn es als Vorlage für weitere GameObjects dient – beispielsweise ein Gegnertyp. Von diesem Prefab werden dann in der Scene mehrere Instanzen erzeugt und angepasst. Diese Anpassungen geschehen mithilfe des „Inspectors“, welcher ein Teil der Benutzeroberfläche ist und auch aus anderen Anwendungen bekannt ist. Mit ihm können Position, Rotation und sonstige Eigenschaften eines GameObjects eingestellt werden. Selbstverständlich wird auch ein Script-Editor mitgeliefert. Ein sehr nützliches Feature dabei ist, dass vorgenommene Änderungen an Scripts oder im Inspector „live“ ins laufende Spiel übertragen werden können und so das regelmäßige Neustarten des Spiels zum Testen wegfällt. Abbildung 11 - Die Unity 3D Entwicklungsumgebung 15.07.2013 18 Bachelorarbeit Kai Hillenbrand 3 Wegfindung in verbreiteten Spiele-Engines Die meisten großen Spiele-Engines haben bereits eine oder mehrere Methoden zur Wegfindung integriert. Alternativ werden auch oft extern Plug-Ins angeboten, welche diese Methoden nachliefern. Wenn beides nicht der Fall ist, muss der Entwickler die Wegfindung selbst implementieren, was einiges an Zeit und Aufwand kostet. Alle der unten erläuterten Lösungen zur Wegfindung nutzen den Algorithmus A* zur Suche. Die meisten 3D-Engines nutzen außerdem einen Navmesh zur Repräsentierung des Level-Graphen. 3.1 Unity 3D Die Unity 3D Engine bietet in der kostenlosen Version keine Wegfindung an. Es besteht aber die Möglichkeit, auf eine der vielen Erweiterungen aus dem Asset Store zurückzugreifen. Zu den bekanntesten – zu einem Preis von etwa 100 US$ aber auch teuersten – Wegfindungs-Plug-Ins zählt „A* Pathfinding Project Pro“ von Aron Granberg. Damit lassen sich u.a. Grids, Waypoint-Graphen und Navmeshs erstellen, die Pfade mit Smoothing-Algorithmen natürlicher machen und tausende Knoten pro ms durchsuchen. Weitere Plug-Ins wären beispielsweise „SimplePath“ von Alex Kring (60 US$), „Simply A*“ von BFGames (20 US$) und „Pathfinder“ von Song-GameDev (35 US$). [2] In der Pro-Version von Unity 3D ist seit Version 3.5 ein Navmesh-System enthalten. Im Editor lässt sich ein Navmesh automatisch aus einem Levelmesh erzeugen. Den Agenten-GameObjects wird die „NavMesh Agent“-Component hinzugefügt, welche dann zur Laufzeit dafür sorgt, dass Wegfindung für dieses Objekt berechnet werden kann. Dabei können u.a. Geschwindigkeit und Höhe des Agenten festgelegt werden. [3] 3.2 Unreal Development Kit (UDK) Die Unreal Engine 3 ist eine der in großen Spieleproduktionen am häufigsten verwendete SpieleEngines seit ihrer Veröffentlichung im Jahr 2006. Zu den bekanntesten Titeln, welche die Engine verwenden, gehören „Unreal Tournament 3“, „Gears of War“, „Mass Effect“, „Batman: Arkham Asylum“ und „Bioshock“. Seit 2009 bietet das Unreal Development Kit größtenteils uneingeschränkten Zugriff auf die Engine. Mit dem UDK lassen sich nicht-kommerzielle Spiele kostenlos veröffentlichen. Das UDK verwendet die NavMesh-Methode zur Wegfindung, während ältere Versionen noch Waypoints nutzten. Zusätzlich zum automatisch generierten NavMesh wird noch ein „Obstacle Mesh“ erzeugt, welcher die „Trennwände“ um Flächen im NavMesh beschreibt. Somit lässt sich schnell testen, ob der direkte Weg zwischen zwei Punkten hindernisfrei ist: man erzeugt einen Strahl zwischen diesen beiden Punkten und schaut, ob er den Obstacle Mesh kreuzt. Wenn ja, liegt ein Hindernis vor und es muss ein Weg um das Hindernis herum berechnet werden. Die Erzeugung des NavMeshs ist in mehrere Schritte aufgeteilt. Zunächst wird ein rasterartiger Mesh erzeugt, ausgehend von den vom Entwickler gesetzten Startpositionen. Dieser umfasst die von diesen Positionen aus erreichbaren Flächen. Die vielen kleinen, quadratischen Flächen werden nun im nächsten Schritt zusammengefasst, wodurch eine vereinfachte und optimierte Version des Navmeshs entsteht. Im letzten Schritt erst werden die Kanten des Graphen erzeugt, welche zwischen den Knoten (Flächen) im Navmesh vorliegen, die miteinander verbunden sind. [4] 15.07.2013 19 Bachelorarbeit Kai Hillenbrand 3.3 CryENGINE Die CryENGINE entstand für den im Jahre 2004 erschienenen Ego-Shooter „Far Cry“ bei Entwicklungsstudio Crytek aus Frankfurt. Seitdem wurden neuere Versionen der Engine vor allem durch den indirekten Nachfolger „Crysis“ und dessen Fortsetzungen bekannt. Sie wird primär für Actionspiele verwendet. Mittlerweile steht die CryENGINE 3 – die aktuellste Version der Engine – zur nicht-kommerziellen Nutzung kostenlos zur Verfügung. [5] Das Wegfindungssystem nutzt einen NavMesh, welcher in einem ungerichteten Graphen repräsentiert wird. Ein Knoten in diesem Graphen repräsentiert eine frei begehbare Fläche im Level ohne Hindernisse. Der Graph wird automatisch vom Editor mithilfe von Triangulierung erstellt. Dabei werden jeweils drei nah beieinander liegende Hindernisse gewählt und ein Dreieck erzeugt, welches die freie Fläche zwischen diesen Hindernissen repräsentiert. Angrenzende Flächen werden im Graph automatisch mit Kanten verbunden. Alternativ lassen sich die Knoten (und somit die Flächen) auch manuell im Editor festlegen. [6] Zur Berechnung des kürzesten Weges wird A* verwendet. Die Kosten der Kanten im Graph entsprechen üblicherweise dem tatsächlichen Abstand im 3D-Raum zwischen den Mittelpunkten der zu verbindenden Flächen. Diese Kosten lassen sich allerdings auch anpassen: Beispielsweise könnten die Kosten zwischen zwei Knoten auf einer Straße mit 0.1 multipliziert werden, um somit bevorzugt zu werden. [7] 3.4 Source Engine Die Source Engine von Spieleentwickler Valve kam erstmals bei den Spielen „Counter Strike Source“ sowie „Half-Life 2“ zum Einsatz. Obwohl sie seitdem ständig mit neuen und verbesserten Features überarbeitet wurde, sieht man ihr im Vergleich zur Unreal Engine 3.0 oder CryENGINE 3 ihr Alter bereits an. Obwohl die Source Engine primär für Ego-Shooter konzipiert wurde, findet sie mittlerweile auch in anderen Genres Anwendung, wie beispielsweise Puzzle („Portal“), Strategie („DotA 2“) und Top-Down-Action („Alien Swarm“). Wie die meisten 3D-Spiele-Engines setzt auch die Source Engine auf NavMeshs. Durchschnittlich fünf Minuten benötigt der Editor, um für ein normal großes Level den NavMesh zu generieren. Dabei wird ausgehend vom Startpunkt des Spielercharakters das Level erfasst und anschließend werden erreichbare Flächen zum NavMesh hinzugefügt. Nicht begehbare Flächen lassen sich im Editor manuell verbieten. Auf Wunsch lassen sich auch spezielle Orte wie Verstecke oder SniperAussichtspunkte automatisch berechnen. Ebenfalls wird die Größe der Agenten beachtet und so verhindert, dass beispielsweise ein Auto durch eine Gasse fährt, die nicht breit genug ist. [8] 15.07.2013 20 Bachelorarbeit Kai Hillenbrand 3.5 Weitere Engines Zu weiteren recht verbreiteten 3D-Spiele-Engines zählen die „Blender Game Engine“ (BGE), sowie die „Java Monkey Engine“ (JME). Beide werden nur selten für größere Projekte verwendet und bieten sich eher für kleinere Spiele oder Prototypen an. Während BGE NavMesh nativ unterstützt, bietet JME keine Wegfindungsmethoden ab. Dafür muss man auf verschiedene von der Community entwickelte Klassen zurückgreifen. Die HTML5-Engine „Construct 2“ beinhaltet Wegfindung auf einem Grid. Parameter wie Tilegröße und Geschwindigkeit der Agenten lassen sich anpassen. Andere große 2D-Engines, die auf HTML5 basieren, sind die „Impact Engine“ und „EaselJS“. Beide haben keine Wegfindungsmethoden integriert, aber auch hier existieren entsprechende Erweiterungen von der Community. [9] 15.07.2013 21 Bachelorarbeit Kai Hillenbrand 4 Repräsentation eines Levels als Graph Wie bereits im Grundlagenkapitel erklärt (Abbildung 1 – Allgemeiner Ablauf der Wegfindung auf Seite 9), lautet der erste Schritt der Wegfindung, den Suchraum in einen Graph zu konvertieren, welcher vom Wegfindungs-Algorithmus verwendet wird. Im Fall eines Spiels wird ein Level in begehbare Bereiche oder Wegpunkte unterteilt (Knoten), welche miteinander verbunden werden (Kanten). Das geschieht auf Grundlage eines „Division Schemes“, also einem Abbildungsschema. Im Verlauf dieses Kapitels werden die verbreitetsten Division Schemes kurz vorgestellt. Es sei gesagt, dass jede dieser Repräsentationen ihre spezifischen Anwendungsbereiche, Stärken und Schwächen besitzt und es die Aufgabe des Entwicklers ist, das für sein Spiel am besten geeignete Division Scheme zu wählen. [10] [11] 4.1 Rectangular Grid Ein Grid (oft auch „Tilemap“ genannt) besteht ähnlich wie ein Schachbrett aus vielen einzelnen quadratischen Feldern („Tiles“). Ein Tile ist üblicherweise entweder begehbar oder nicht begehbar, was bei der Wegfindung berücksichtigt wird. Die meisten Strategiespiele verwenden diese zweidimensionalen Grids, welche dem eigentlichen, deutlich detaillierteren 3D-Level unterliegen. Je höher die Tile-Dichte, desto präziser wird der Algorithmus arbeiten – viele Algorithmen werden dadurch aber spürbar verlangsamt. Ein Tile repräsentiert einen Knoten im Graph, angrenzende Tiles sind mit Kanten verbunden. Problematisch wird es in 3D-Spielen, die wie die meisten aktuellen Actionspiele aus mehreren Ebenen bestehen. Für eine Brücke, die man überqueren, aber auch darunter durch laufen kann, bräuchte man schon ein weiteres Grid und einen speziell angepassten Algorithmus. Ein Vorteil der Grid-Repräsentation besteht darin, dass sie schnell auf sich dynamisch ändernde Levels angepasst werden können. Tiles lassen sich direkt über ihre x- und y-Koordinate ansprechen und auf „begehbar“ oder „nicht begehbar“ setzen. 4.2 Quadtree Ähnlich einem Grid, wird auch hier das Level in viele Quadrate unterteilt. Während beim Grid aber sämtliche Tiles dieselbe Größe besitzen, wird in einem Quadtree je nach notwendiger Genauigkeit die Tilegröße variiert. Das ist gerade bei größeren begehbaren Flächen von Vorteil, da zum einen weniger Speicher benötigt, als auch die Algorithmus-Laufzeit verringert wird. Die einzelnen Tiles können allerdings nicht frei in ihrer Größe geändert werden, sondern müssen einem bestimmten Schema folgen: Die komplette Ursprungsfläche wird zunächst in 4 gleichgroße Quadrate unterteilt, welche wiederum selbst so weit wie benötigt unterteilt werden. Auch dieses Division Scheme eignet sich weniger für 3D-Levels mit mehreren Ebenen und somit vor allem für Strategie- und Rollenspiele. Für diesen Zweck gibt wird ein „Octree“ verwendet. Hierbei läuft das Verfahren wie beim Quadtree ab, mit dem Unterschied, dass von einem Würfel ausgegangen wird, welcher jeweils in 8 gleich große Würfel unterteilt wird. Von hier an werden die einzelnen Würfel jeweils so oft unterteilt wie gewünscht. 15.07.2013 22 Bachelorarbeit Kai Hillenbrand 4.3 Waypoints Ein in aktuellen 3D-Spielen häufig verwendetes Division Scheme ist das „Waypoints“-Schema (auch „Points of View“) genannt. Hierbei werden Wegpunkte (Knoten) im Level bestimmt und jene miteinander verbunden, die sich gegenseitig sehen können (Kanten). Dieses Schema ähnelt im Aufbau am deutlichsten dem von einem Graphen und eignet sich auch für Actionspiele mit mehreren Ebenen. Im oben genannten Beispiel würde man einen Wegpunkt sowohl auf als auch unter die Brücke setzen. Nachteil ist, dass die Wegfindung dann nur einen begrenzten Überblick über das Level besitzt – es weiß nicht, was sich abseits dieser Wegpunktverbindungen befindet. Um ein Level optimal zu repräsentieren, wären also extrem viele Wegpunkte notwendig. Ansonsten ist der resultierende Weg sehr „eckig“ und oft nicht optimal (Abbildung 12, links unten). Dieses Schema wird oft zusammen mit dem aufwendigeren Navmesh-Schema verwendet und besonders bei Nicht-Spieler-Charakteren (NPCs) genutzt. In Rollenspielen werden damit beispielsweise Wegrouten für unwichtige NPCs erstellt, auf denen sich diese kontinuierlich (zufällig) bewegen, um den Eindruck einer lebendigen Welt zu geben. Da sich diese Charaktere niemals zu einem nicht auf dieser Route befindlichen Ort begeben müssen, reicht das Waypoint-Schema also für sie (auch mit wenigen Wegpunkten) aus. 4.4 Navmesh Ein sogenannter „Navigation Mesh“ wird direkt aus den 3D-Informationen des Levels erstellt. Statt aus quadratischen Flächen besteht der Navmesh aus vielen unterschiedlich großen Polygonflächen (also Vielecken). Hierbei werden natürlich nur jene Flächen in den Navmesh übernommen, die auch begehbar sind. Welche das sind, kann der Entwickler anhand verschiedener Parameter bestimmen. Alternativ lassen sich Flächen auch manuell aus dem Navmesh ausschließen. Die übrig gebliebenen Flächen repräsentieren dann die Knoten im Graph, während aneinander angrenzende Flächen durch Kanten verbunden werden. Obwohl Navmeshs eine relativ neue Entwicklung sind, werden sie bereits von den meisten modernen Engines unterstützt und verwendet (siehe Kapitel „Wegfindung in verbreiteten Spiele-Engines“). 15.07.2013 23 Bachelorarbeit Kai Hillenbrand Abbildung 12 - Beispiele der Levelrepräsentationen [11] Der gefundene Weg ist in Gelb eingezeichnet, die expandierten Knoten (d.h. Felder, Punkte oder Polygone) in Rosa. Links oben: Rectangular Grid Rechts oben: Quadtree Links unten: Waypoints Rechts unten: Navmesh 15.07.2013 24 Bachelorarbeit Kai Hillenbrand 5 Häufige Wegfindungsalgorithmen in Spielen Die meisten Wegfindungsalgorithmen in Spielen basieren auf dem A*-Algorithmus. Hierbei wird in der Regel eine erweiterte, speziell auf das Spiel angepasste Form verwendet und nur selten unveränderter A*. In diesem Kapitel werden einige der verbreitetsten A*-Varianten vorgestellt. Dies ist nur ein kleiner Auszug aus einer Vielzahl von Algorithmen. Keine dieser Varianten ist allumfassend oder besser als eine andere - jede ist für spezielle Situationen und Spieltypen besser geeignet als eine andere und hat demnach ihre Daseinsberechtigung. Ein Wegfindungsalgorithmus bekommt als Eingabeparameter den Graphen sowie Start- und Zielknoten. Er liefert – wenn vorhanden – den besten Weg zwischen diesen beiden Knoten zurück. Bei Graphen ohne Mehrfachkanten ist dies für gewöhnlich eine Liste der Knoten dieses Weges. Gibt es allerdings Mehrfachkanten, wird es notwendig, stattdessen eine Liste der Wegkanten zurückzuliefern. [10] 5.1 Hierarchical Pathfinding A* (HPA*) Sehr detaillierte Levels benötigen in der Regel auch einen sehr umfangreichen Graphen zur Wegfindung. Die Berechnung des kompletten Weges kann unter Umständen zu lange benötigen, um noch innerhalb eines Frames berechnet zu werden, was zu störenden Verzögerungen führt. Bei weiten Wegen ist auch die Wahrscheinlichkeit höher, dass sich die Spielwelt in der Zwischenzeit schon wieder geändert hat: eventuell ist der vorherbestimmte Weg mittlerweile blockiert oder muss gar nicht mehr begangen werden. Das führt dazu, dass Rechenzeit unnötig verschwendet wurde, da nur ein Teil des gefundenen Wegs benutzt wurde, da der Rest mittlerweile ungültig geworden ist. Diese Probleme versucht HPA* zu umgehen. Der ursprüngliche, detaillierte Wegfindungsgraph wird in einzelne Teilgraphen unterteilt. Zusätzlich wird ein abstrakterer Graph erzeugt, welcher diese Teilgraphen miteinander verbindet, ohne die Detailinformationen dieser Graphen zu kennen. Wird nun ein Weg gesucht, geschieht dies zunächst auf dem abstrakten Graph mit der Information als Ergebnis, welche Teilgraphen (in welcher Reihenfolge) für den kompletten Weg benötigt werden. Nun kann der Anfang des kompletten Weges durch den ersten Teilgraphen schnell gefunden werden und es können bei Bedarf weitere Teile des Weges in den folgenden Teilgraphen „on the fly“ berechnet werden. Dadurch wird sichergestellt, dass immer nur die relevanten Teilprobleme berechnet werden. Der eigentliche Suchalgorithmus dafür ist im Regelfall – aber nicht zwangsweise Standard-A*. Angenommen, man suche den besten Weg von Mannheim nach Berlin. Statt nun in einem einzelnen, komplexen Graphen zu suchen, welcher sämtliche Straßen Deutschlands beinhaltet, wird zunächst auf einem abstrakteren Graphen nach dem groben Weg gesucht. Dadurch ergeben sich Orte (Zwischenstationen), die auf dem besten Weg liegen, z.B. Auf- und Abfahrten der zu befahrenen Autobahnen. Wie man von der aktuellen Position zur nächsten Autobahn oder von der abschließenden Autobahn zum Ziel gelangt, wird schließlich aus entsprechenden detaillierteren Graphen entnommen. Der große Vorteil dabei ist, dass jeweils nur der Weg zur nächsten Zwischenstation und nicht der komplette Weg zum Ziel gesucht werden muss. [12] 15.07.2013 25 Bachelorarbeit Kai Hillenbrand 5.2 Iterative Deepening A* (IDA*) Diese Variante von A* kombiniert die „Iterative Tiefensuche“ mit A*. Bei der iterativen Tiefensuche wird eine normale Tiefensuche wiederholt bis zu einer bestimmten Tiefe ausgeführt, welche mit jedem Schritt erhöht wird, bis eine Maximaltiefe erreicht oder zuvor das Ziel gefunden wird. Bei IDA* werden statt der Tiefe die maximalen Kosten des Knotens als Limit gesetzt und iterativ erhöht. Der Vorteil hierbei ist, dass die bereits besuchten Knoten nicht gespeichert werden müssen, es durch das Kostenlimit aber trotzdem nicht zur Endlosschleife kommt. Das führt zu einem geringeren Speicherverbrauch. Ein Nachteil ist allerdings, dass IDA* etwas langsamer als Standard-A* ist, da einige Knoten zu einem gewissen Grad mehrfach besucht werden. 5.3 Simplified Memory Bounded A* (SMA*) Der SMA*-Algorithmus bekommt nur begrenzten Speicher zur Verfügung gestellt, welcher nicht überschritten wird. Das ist in Situationen nützlich, in denen der Speicher knapp und die Anzahl der zu findenden Wege hoch ist. Bei ausreichend Speicher verhält sich SMA* exakt wie A*. Ist der Speicher des Algorithmus allerdings aufgebraucht, schneidet er diejenigen Äste ab, welche die höchsten Kosten haben, da vermutet wird, dass diese später sowieso nicht mehr benötigt werden. Stellen sich die nun expandierten „besseren“ Äste als schlechter als zunächst vermutet heraus, müssen die zuvor abgeschnittenen Äste wieder rekonstruiert werden. Hierzu wird in jedem Vaterknoten der Kostenwert des abgeschnittenen Kind-Astes mit den geringsten Kosten gespeichert, um diesen später wiederherstellen zu können. 5.4 D* D* wurde speziell für sich dynamisch stark ändernde oder für den Agenten unbekannte Levels konzipiert. Im Gegensatz zu A* wird hierbei die Suche beim Zielknoten begonnen und rückwärts der Weg zum Start gesucht. Weiterhin ist nur die direkte Umgebung des Agenten bekannt – vom restlichen Level wird ausgegangen, dass es frei begehbar ist. Erst wenn neue Umgebung erkundet und Hindernisse gefunden werden, wird der Algorithmus entsprechend angepasst und die entdeckten Hindernisse mit einberechnet. D* wird bevorzugt in der Robotersteuerung verwendet, da er unter Berücksichtigung von vielen Agenten in einem Spiel aufgrund der häufigen Neuberechnungen oft zu langsam ist. 15.07.2013 26 Bachelorarbeit Kai Hillenbrand 6 Der Jump Point Search Algorithmus 6.1 Einleitung „Jump Point Search“ (JPS) ist ein relativ neuer Wegfindungsalgorithmus. Er wurde im Jahr 2011 von Daniel Harabor und Alban Grastien entwickelt. JPS ist eine Erweiterung von A* und benötigt eine Grid-Repräsentation des Levels zur Anwendung. Dabei muss der Graph unbewertet sein, d.h. alle Felder besitzen dieselben Schrittkosten zu ihren angrenzenden Feldern. Der Algorithmus führt Symmetriereduktion durch, was bedeutet, dass mehrfache Pfade derselben Länge zwischen zwei Feldern entfernt werden, um so die Anzahl der möglichen Wege zu reduzieren und die Wegfindung zu beschleunigen. Außerdem werden sogenannte „Jump Points“ berechnet: diese führen dazu, dass weite Flächen, die von A* Feld für Feld erkundet werden würden, einfach übersprungen werden können. Jump Point Search lässt sich leicht mit anderen Optimierungstechniken verbinden, wie beispielsweise Abstraktion – der Ansatz, welcher auch von HPA* umgesetzt wird. [13] 6.2 Algorithmus-Erklärung 6.2.1 Symmetriereduktion Der JPS-Algorithmus basiert zum Großteil auf der Idee der Symmetriereduktion. In einem Grid existieren stets mehrere gleich lange Wege zwischen zwei Feldern, welche sich nur in der Reihenfolge ihrer Schritte unterscheiden. Durch Symmetriereduktion werden solche symmetrischen Wege entfernt, sodass nur noch ein einzelner Weg übrig bleibt. Das führt dazu, dass deutlich weniger Wege betrachtet werden müssen und die Wegfindung entsprechend schneller abläuft. [14] Abbildung 13 - Symmetrische Wege mit jeweils 9 horizontalen und vertikalen Schritten [14] 15.07.2013 27 Bachelorarbeit 6.2.2 Kai Hillenbrand Expansion von Feldern Während Jump Point Search im Wesentlichen wie A* abläuft, unterscheiden sie sich am meisten beim Expandieren der Felder. Statt sämtliche direkten Nachbarn des Feldes zur Open List hinzuzufügen, wie A* es tut, versucht Jump Point Search, Symmetrien zu erkennen, zu vermeiden und entsprechende Felder/Knoten zu ignorieren. Das Ignorieren bzw. „Abschneiden“ der Kanten zu diesen Knoten wird auch „Pruning“ genannt. Dadurch, dass somit die meisten Felder lediglich überprüft, aber nicht sofort in die Open List einsortiert werden müssen (da sie übersprungen werden), ergibt sich ein großer Geschwindigkeitsvorteil. Die Felder, die dann übrig bleiben, werden „Jump Points“ genannt und bilden die Nachfolger des Knotens, von dem aus gesprungen wurde. Dabei wird zwischen horizontalem/vertikalem und diagonalem Springen unterschieden. Die Vorgehensweise wird in den nächsten beiden Unterkapiteln anhand von Abbildung 14 gezeigt. [15] Abbildung 14 - Pruning der Nachfolger eines Feldes beim Expandieren [15] (a) (b) (c) (d) zeigt die Felder, welche bei einer horizontalen Expansion ignoriert werden (grau) zeigt einen „Forced Neighbor“ auf Feld 3, da Feld 2 blockiert ist (schwarz) zeigt die Felder, welche bei einer diagonalen Expansion ignoriert werden (grau) zeigt einen „Forced Neighbor“ auf Feld 1, da Feld 4 blockiert ist (schwarz) 6.2.3 Horizontaler und vertikaler Sprung Angenommen, man kommt von Feld 4 nach x und möchte nun x expandieren (vgl. Abbildung 14 Teil a). Da wir von 4 kommen, kann das Feld 4 direkt ignoriert werden. Felder 1, 2, 6 und 7 sind von 4 aus direkt zu erreichen und somit schneller als über x, daher werden sie auch ignoriert. Als letztes lassen sich noch Felder 3 und 8 ignorieren, da diese ebenfalls von 4 aus – ohne über x zu gehen – genauso schnell zu erreichen sind. Somit bleibt nur Feld 5 übrig, d.h. das nächste Feld, welches in der Laufrichtung liegt. Solange keine Hindernisse vorliegen, wird also in diese Richtung gesprungen, was auch als „horizontaler Sprung“ bezeichnet wird. Liegt nun aber ein Hindernis auf Feld 2 oder 7 vor (vgl. Abbildung 14 Teil b), wird der Sprung beendet und das aktuelle Feld x der Open List hinzugefügt. Der Grund dafür liegt darin, dass nun Feld 3 nicht mehr ignoriert werden kann, da nun der Weg durch x der (einzige) schnellste Weg dorthin ist. Feld 3 wird nun „Forced Neighbor“ genannt, da wir es genauer betrachten müssen, statt es wie zuvor ignorieren zu können. Als nächstes besteht nun die Möglichkeit, von Feld x aus sowohl in Richtung 5 (nach rechts), als auch in Richtung 3 (nach rechts oben) zu springen. 15.07.2013 28 Bachelorarbeit Kai Hillenbrand Sollte Feld 5 blockiert sein, wird der komplette Sprung verworfen, da er in einer Sackgasse enden würde. Ein vertikaler Sprung funktioniert analog dazu. 6.2.4 Diagonaler Sprung Bei einem diagonalen Sprung wird ähnlich vorgegangen (vgl. Abbildung 14 Teil c). Hier werden die Felder 1, 4, 6, 7 und 8 ignoriert, da der Weg von 6 aus ohne x zu nutzen schneller oder gleich schnell wäre. Ist Feld 4 oder 7 blockiert, werden Feld 1 bzw. 8 zum Forced Neighbor (vgl. Abbildung 14 Teil c). Im Gegensatz zum horizontalen bzw. vertikalen Sprung kann man hier aber nicht einfach diagonal weiterspringen, bis ein Forced Neighbor auftritt – schließlich gibt es 3 Felder, welche potentielle Nachfolger sind (2, 3 und 5). Von jedem Feld, das man nach einem diagonalen Schritt expandiert, führt man also zuerst einen horizontalen, dann einen vertikalen Sprung aus. Wird hier ein Forced Neighbor gefunden, fügt man die aktuelle Position des expandierenden Feldes der Open List hinzu und beendet den Sprung somit. Ansonsten führt man einen weiteren diagonalen Schritt zum nächsten Feld in der bestehenden Richtung aus und expandiert es. Das wiederholt man solange, bis entweder ein Forced Neighbor durch einen der horizontalen oder vertikalen Sprünge gefunden wurde, oder bis der diagonale Sprung auf ein direktes Hindernis trifft. In letzterem Fall wird der komplette Sprung verworfen, da er wieder in einer Sackgasse endete und kein geeignetes Nachbarfeld gefunden werden konnte. 6.2.5 Ablauf Das allgemeine Vorgehen bei JPS unterscheidet sich nicht von A*: Man expandiert einen Knoten und fügt dessen Nachfolger in eine Open List hinzu. Dann nimmt man von dieser den mit der besten (niedrigsten) Bewertung und expandiert ihn usw. Lediglich bei der Suche der Nachfolger unterscheidet er sich. Statt der angrenzenden freien Felder werden Jump Points des zu expandierenden Knotens gefunden und in die Open List eingefügt. Dabei wird beachtet, aus welcher Richtung man auf das expandierende Feld gelangt ist. Gesprungen wird dann in die Richtung jeder Felder, welche nicht durch Pruning wegfallen (vgl. Abbildung 14). Horizontal/vertikal wird in derselben Richtung sowie in Richtung des Forced Neighbors weitergesprungen. Es muss hier nämlich einen Forced Neighbor geben, denn sonst hätte der vorangegangene Sprung zu diesem Feld nicht gestoppt. Diagonal wird analog dazu vorgegangen. Zur Wiederholung und Zusammenfassung der Suche nach einem Jump Point: Bei einem horizontalen oder vertikalen Sprung wird so lange gesprungen, bis ein Feld mit einem Forced Neighbor gefunden wurde. Dieses Feld ergibt dann den Jump Point. Wird ein direktes Hindernis in Sprungrichtung gefunden, ist der Sprung ungültig und ergibt keinen Jump Point. Bei einem diagonalen Sprung wird für jedes Feld auf dem Weg zunächst horizontal und vertikal nach einem Jump Point gesucht. Wird einer gefunden, wird das diagonal angesprungene Feld zum Jump Point. Ansonsten wird weitergesprungen, bis ein Hindernis in diagonaler Richtung auftaucht (oder vorher ein Jump Point in einer der horizontalen bzw. vertikalen Suche gefunden wurde). Beim Treffen auf ein Hindernis wird auch hier der Sprung ungültig und es gibt somit keinen Jump Point in dieser Richtung. 15.07.2013 29 Bachelorarbeit Kai Hillenbrand Die folgende Grafik verdeutlicht die Vorgehensweise des Algorithmus anhand eines Beispiels. Das grüne Feld ist der Startpunkt, das rote Feld das Ziel. Graue Felder sind gefundene Jump Points, das gelbe Feld ist der zuletzt gefundene Jump Point, von dem aus direkt ins Ziel gesprungen wird. Die Pfeile zeigen die versuchten Sprünge. Die blauen Pfeile im rechten Teil der Grafik entsprechen den erfolgreichen Sprüngen und stellen den optimalen Weg zum Ziel dar. [16] Abbildung 15 - Beispiel einer Suche mit JPS Es wird vom grünen Startfeld aus ein Weg zum roten Feld gesucht. Die Pfeile stellen die Sprünge dar, graue Felder sind Jump Points. Die tatsächlich vollzogenen Sprünge sind blau markiert. 6.3 Evaluierung Im Vergleich zu normalem A* und Erweiterungen wie HSPA* hat der JPS-Algorithmus den Nachteil, dass er nur auf unbewerteten Grids funktioniert. Es können somit keine Orte schlechter oder besser zu begehen sein als andere. Es besteht jedoch die Möglichkeit, den Algorithmus so zu erweitern, dass auch bewertete Grids verwendet werden können – das würde die Performance allerdings mit hoher Wahrscheinlichkeit verschlechtern. Jump Point Search benötigt zur Suche des besten Weges im schlimmsten Fall etwas länger als A*, ist im optimalen Fall aber um ein Vielfaches schneller. Gerade weite Flächen können schnell übersprungen werden, sodass die Anzahl der expandierten Knoten am Schluss viel geringer ist als bei A*. Ein genauerer Vergleich zwischen JPS und A* folgt unter „Evaluierung“ im Kapitel „Jump Point Search in Unity 3D“. 6.4 JPS+ Die Erfinder von JPS haben im Jahr 2012 einen weiteren Ansatz präsentiert: „JPS+“. Durch das Springen zu den Jump Points im JPS-Algorithmus werden viele zeitaufwändige Operationen, welche die Open List betreffen (hinzufügen, sortieren, löschen), vermieden. Das Berechnen dieser Jump Points ist allerdings ein Flaschenhals (wenn auch ein nicht so kritischer), der an diese Stelle tritt. JPS+ lagert diese Berechnungen in einen Pre-Processing-Schritt aus: Statt der tatsächlichen direkten Nachbarn eines Feldes werden dessen mögliche Jump Points im Graphen gespeichert und können zur Laufzeit dann direkt zum Sprung verwendet werden. [17] 15.07.2013 30 Bachelorarbeit Kai Hillenbrand 7 Jump Point Search in Unity 3D 7.1 Übersicht Im Rahmen der Bachelorarbeit wurde ein Package für Unity entwickelt, welches die folgenden Assets beinhaltet: - 7.1.1 Mehrere C#-Scripts, in denen sowohl A* als auch Jump Point Search implementiert sind, inklusive benötigter Helferklassen (Tile, Point) und Datenstrukturen (PriorityQueue) Scripts, die beim Ausführen, Überwachen und Auswerten der Algorithmen helfen, ebenfalls in C# implementiert (Finder, FinderData) Zwei Scenes, welche die Funktionalitäten der Algorithmen demonstrieren sollen, inklusive zugehöriger Scripts und Prefabs: VisualTiles und TileExporter VisualTiles Die „Visual Tiles“ Demo umfasst ein interaktives Level, bestehend aus einem Grid von 70x70 Feldern („Tiles“), in dem die beiden Wegfindungsalgorithmen ausprobiert werden können. Mithilfe der Maus werden Start- und Endpunkt gesetzt. Anschließend wird dann wahlweise von A* oder Jump Point Search der beste Weg zwischen diesen beiden Punkten gesucht. Die Tiles, die der Weg durchschreitet, werden grün markiert. Der gelbe Bereich stellt dar, welche Felder der Algorithmus expandiert hat, d.h. wo er nach einem Weg gesucht hat. Je größer die Anzahl der gelben Tiles, desto zeitaufwändiger war die Suche. Ebenfalls wird dem Nutzer die Dauer mitgeteilt, die der Algorithmus benötigt hat, um den Weg zu finden. Abbildung 16 - Beispiel eines gefundenen besten Weges in der VisualTiles-Demo 15.07.2013 31 Bachelorarbeit 7.1.2 Kai Hillenbrand TileExporter Die „Tile Exporter“ Demo dient dem Benchmark der beiden Algorithmen. Hierbei wird vor dem Start ein Level ausgewählt, das bis zu 512x512 Tiles umfasst, was einen deutlich höheren Rechen- und somit Zeitaufwand im Vergleich zur „Visual Tiles“ Demo bedeutet. Somit lassen sich Zeitunterschiede sehr viel einfacher herausstellen. Analog zur anderen Demo werden auch hier der optimale Weg (in blau) und die expandierten Tiles (rot) eingezeichnet. Das gewünschte Level kann vor dem Start im Inspektor von Unity ausgewählt werden. Die Grafik, die in der Mitte des Bildschirms dargestellt wird, kann auf Wunsch automatisch als PNG-Datei exportiert werden. Die in der Demo enthaltenen Levels sind von einer Benchmark-Seite der „Moving AI Lab“ [18] bezogen. Diese bietet eine umfangreiche Auswahl an unterschiedlichen Testdateien zum Auswerten von Grid-basierten Algorithmen. Neben speziell für Tests hergestellten Maps (Labyrinthe, Räume, zufällige Hindernisse) werden auch Levels aus Rollen- und Strategiespielen von BioWare (Baldur’s Gate, Dragon Age) sowie Blizzard (Starcraft, Warcraft 3) angeboten. Mithilfe von diesen lässt sich gut abschätzen, wie ein Algorithmus tatsächlich in einem Spiel abschneiden würde. Abbildung 17 - A*-Benchmark in der TileExporter-Demo anhand eines Levels aus Baldur's Gate 15.07.2013 32 Bachelorarbeit Kai Hillenbrand 7.2 Implementierung des Algorithmus Der Quelltext wurde in C# geschrieben. Zur späteren Evaluierung, vor allem in Bezug auf die Performance von JPS, wurde zusätzlich eine normale Version von A* implementiert. Da ein Spiel nicht einfach pausieren kann (selbst wenn es nur 100ms wären), läuft die Wegfindung in einem separaten Thread. Hierzu wurden zwei Helper-Klassen erstellt: Finder und FinderData. FinderData kapselt sämtliche Parameter, die der Algorithmus zur Berechnung des besten Weges benötigt – u.a. die komplette Tilemap, Startpunkt und Endpunkt. Bei Abschluss wird die FinderDataInstanz mit Informationen über das Resultat – also den gefundenen Weg – aufgefüllt, welches dann vom Spiel abgefragt werden kann. Der eigentliche Aufruf wird über die Finder-Klasse stark vereinfacht. Es wird eine Finder-Instanz als Klassenvariable angelegt, mit welcher dann bei Bedarf der Algorithmus ausgeführt werden kann. Der Finder benötigt hierzu lediglich ein FinderData-Objekt mit den entsprechenden Informationen. In der Game Loop (die Schleife, in der sich ein Spiel befindet, während es läuft) wird kontinuierlich überprüft, ob die Suche abgeschlossen wurde. Wenn dies der Fall ist, kann man von der FinderInstanz das Resultat abfragen. Hierbei sollte man allerdings nicht vergessen, zu prüfen, ob es eventuell zu einem Fehler kam. 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // create the class variable private Finder finder; (...) // run pathfinder finder = new Finder(new FinderData(tileMap, start, end)).RunJPS(); (...) // in the game loop, check if it has finished if ((finder != null) && (finder.IsFinished())) { // check for error if (finder.HasError()) { finder = null; return; } // use the pathfinder result MoveCharacter(finder.GetResult().GetCompleteBestPath()); finder = null; } Codelisting 2 - Verwendung der Finder-Klasse zur Ausführung der Wegfindung 15.07.2013 33 Bachelorarbeit Kai Hillenbrand Es folgen zwei Quelltext-Auszüge, welche die Implementierungen der beiden Algorithmen gegenüberstellen. Es wird jeweils die While-Schleife gezeigt, die den zentralen Aspekt ausmacht und solange läuft, bis das Ziel gefunden wurde. 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 // JUMP POINT SEARCH IMPLEMENTATION // add start tile to open list openHeap.Push(start); // continue until open list is empty while (!openHeap.IsEmpty) { // get first (most primising) tile from the open Tile currentTile = openHeap.Pop(); closed[currentTile.pos.x, currentTile.pos.y] = true; // check for goal in current tile if (currentTile.pos.Equals(data.endPoint)) return data.CalculateReturnData(currentTile, closed); // find and add successors foreach (Point neighbor in GetNeighbors(currentTile, data.tileMap)) { Point jumpPoint = Jump(neighbor, currentTile.pos, data); // skip this successor, if it is already on the closed list // or no jump point could be found if ((jumpPoint == null) || (closed[jumpPoint.x, jumpPoint.y])) continue; Tile jumpNode = new Tile(jumpPoint.x,jumpPoint.y,currentTile); jumpNode.g = currentTile.g + Tile.EuclideanDistance(currentTile.pos, jumpPoint); jumpNode.CalcH(data.endPoint); jumpNode.CalcFOnly(); openHeap.Push(jumpNode); } } Codelisting 3 - Auszug aus dem Quelltext der Jump Point Search Implementierung 15.07.2013 34 Bachelorarbeit 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 Kai Hillenbrand // A* IMPLEMENTATION // add start tile to open queue openHeap.Push(start); // continue until open queue is empty while (!openHeap.IsEmpty) { // get first (most primising) tile from the open Tile currentTile = openHeap.Pop(); // if current tile is in closed list, ignore it if (closed[currentTile.pos.x, currentTile.pos.y]) continue; // check for goal in current tile if (currentTile.pos.Equals(data.endPoint)) return data.CalculateReturnData(currentTile, closed); // add current tile to closed list closed[currentTile.pos.x, currentTile.pos.y] = true; // go through successors of current tile foreach (Point suc in currentTile.GetNeighbors(data.tileMap)) { // calculate total cost and add it to the queue Tile sucTile = new Tile(suc.x, suc.y, currentTile); sucTile.CalcFGH(data.endPoint); openHeap.Push(sucTile); } } Codelisting 4 - Auszug aus dem Quelltext der A* Implementierung Im Vergleich lassen sich leicht die vielen Ähnlichkeiten erkennen und es wird schnell sichtbar, dass JPS eine Erweiterung von A* ist. Der wesentliche Unterschied besteht in der Suche der Nachfolger. Dazu nutzt JPS eine speziell angepasste Version der GetNeighbors()-Funktion und führt auf diesen Nachbarn die Jump()-Funktion aus, um einen Jump Point zu finden. Die Kosten des Nachfolgers müssen ebenfalls gesondert berechnet werden, da hier der Abstand zwischen aktuellem und nachfolgendem Tile beachtet werden muss. Dieser ist bei A* immer gleich 1, da dort nicht gesprungen wird – daher fällt eine entsprechende Rechnung weg. 15.07.2013 35 Bachelorarbeit Kai Hillenbrand 7.3 Datenstrukturen und Optimierungen Es ist nicht nur wichtig, dass der Algorithmus an sich effizient und schnell arbeitet. Ein wesentlicher Teil der Performance wird dadurch beeinflusst, auf welche Art und Weise er implementiert wird. Den wohl größten Unterschied macht hier die Wahl der Datenstrukturen aus, welche für die Open- und Close-Listen verwendet werden. Normale, unsortierte Arrays oder verknüpfte Listen sind die langsamste Variante. Das Hinzufügen eines Knotens ist zwar sehr schnell, die Suche nach einem vorhandenen Knoten oder dem mit den niedrigsten Kosten hingegen ist sehr zeitintensiv. Im schlimmsten Fall müssten hier alle Elemente durchlaufen werden, bis der gesuchte Knoten gefunden wird. Nach Kosten sortierte Arrays beschleunigen die Abfrage des kostengünstigsten Knoten (welcher sich an erster Stelle befindet), sowie die eines bestimmten Knoten (mithilfe von Binärsuche). Das Einfügen ist jedoch zeitaufwendiger, da hierzu alle nachfolgenden Elemente verschoben werden müssen. Eine weitere Möglichkeit – vor allem bei grid-basierten Suchalgorithmen - sind indexierte Arrays. Hierbei wird für jedes Tile ein Speicherplatz reserviert, wodurch man sehr schnell auf ein bestimmtes Feld zugreifen kann, da direkt über den bekannten Index des Tiles im Array zugegriffen werden kann. Das Einfügen und Löschen eines Feldes läuft ebenfalls schnell ab. Die Suche nach dem kostengünstigsten Knoten ist allerdings aufwendig, da das komplette Array durchsucht werden muss. Für die Open List in dieser Arbeit wurde eine eigene, simple Datenstruktur erstellt. Diese besteht im Wesentlichen aus einer verknüpften Liste und einem Comparer-Objekt, welches zwei Knoten miteinander vergleicht und bestimmt, welcher die niedrigeren Kosten besitzt. Beim Einfügen in die Liste wird das Element direkt an die entsprechende Position geschoben, sodass die Liste jederzeit sortiert bleibt. Die Suche nach dieser Position wird von einer Binärsuche mithilfe des Comparers relativ schnell durchgeführt. Der kostengünstigste Knoten ist schnell abzufragen, da er immer an der ersten Stelle steht. Diese Lösung bietet somit einen sinnvollen Kompromiss zur Anwendung bei Wegfindungsalgorithmen. Sie ist hier besonders effektiv, da in dieser Jump Point Search Implementierung keine Suche nach einem bestimmten Tile notwendig ist, abgesehen von der Suche nach dem kostengünstigsten Tile, welche durch die Sortierung schnell passiert. Für die Closed List wird ein simples multidimensionales Boolean-Array verwendet. Für jedes Tile im Grid kann direkt abgefragt werden, ob es bereits expandiert wurde, oder nicht. Da hier nur Booleans gespeichert werden und nicht die eigentlichen Tiles, ist der höhere Speicherbedarf im Vergleich zu einer Liste auch in größeren Grids vernachlässigbar, jedoch ist der Geschwindigkeitsunterschied erheblich. Es gibt keine perfekte Datenstruktur, die für alle Anwendungsfälle optimal ist – jede hat ihre Schwächen und Stärken. Daher muss man bei der Implementierung darauf achten, die für die verwendeten Operationen performanteste Datenstruktur zu wählen, beziehungsweise sich seine eigene zusammenzustellen. Idealerweise vermeidet man die für viele Datenstrukturen zeitaufwendigen Operationen (wie beispielsweise das Prüfen, ob ein Element vorhanden ist) so weit wie möglich. 15.07.2013 36 Bachelorarbeit Kai Hillenbrand Einen kleinen Performanceschub kann es geben, wenn man das 2D-Grid auf 1D-Koordinaten abbildet: 1D-Position = X + Grid-Breite * Y Somit müssen die Koordinaten nicht immer erst von der Garbage Collection erzeugt und später wieder entfernt werden, sondern können direkt als Integer weitergegeben werden. Auch der Speicherbedarf sinkt etwas, da nur noch ein einzelner Integer gespeichert wird (statt zwei). Das klingt zunächst vernachlässigbar, kann sich aber bei großen Grids und parallel laufenden Wegfindungsalgorithmen um mehrere MB handeln. 7.4 Evaluierung Der Jump Point Search Algorithmus wurde speziell dazu entwickelt, Wegfindung auf einem Grid mit einheitlichen Kosten zu beschleunigen. Mithilfe der TileExporter-Demo wurden auf neun unterschiedlichsten Test-Levels sowohl A* als auch JPS ausgeführt und deren Laufzeiten in Millisekunden aufgezeichnet. In jedem Level wurden mehrere Durchläufe pro Algorithmus ausgeführt und die Ergebnisse gemittelt. Die Standardabweichungen innerhalb der einzelnen Testfälle betrugen nie mehr als 5ms und die Laufzeiten somit sehr konstant. Daher wurde auf eine ausführliche Auflistung der Resultate verzichtet und es werden lediglich die Mittelwerte pro Testergebnis angegeben. Getestet wurde auf einem Intel i5-3450-Prozessor. Die folgende Tabelle beschreibt die neun verschiedenen Testgrids und das Diagramm die Ergebnisse der Tests. TestID Levelbeschreibung (512x512 Tiles) 1 Innengebiet aus Baldur’s Gate, bestehend aus einer größeren Halle mit verschiedenen angrenzenden Räumen 2 Labyrinth, das das komplette Grid umfasst und dessen Gänge nie breiter als 1 Tile sind 3 Labyrinth, das das komplette Grid umfasst und dessen Gänge 32 Tiles breit sind 4 zu 10% mit zufällig verteilten Wänden besetztes Level 5 zu 35% mit zufällig verteilten Wänden besetztes Level 6 Level bestehend aus 64x64 Räumen, die jeweils 7x7 Tiles groß sind und Verbindungen zu 1-4 angrenzenden Räumen besitzen 7 Level bestehend aus 8x8 Räumen, die jeweils 63x63 Tiles groß sind und Verbindungen zu 1-4 angrenzenden Räumen besitzen 8 Innengebiet aus Baldur’s Gate, bestehend aus vielen Räumen und einigen verstreuten Hindernissen sowie langen Fluren 9 Außengebiet aus Baldur’s Gate mit weiten Flächen und großen Hindernissen und einer Stadtmauer, um die ein Weg herum gesucht werden muss 15.07.2013 37 Bachelorarbeit Kai Hillenbrand 33 1 728 85 2 319 32 3 1397 109 4 7 166 185 5 JPS (in ms) A* (in ms) 81 62 6 44 7 450 8 8 145 27 9 764 0 200 400 600 800 1000 1200 1400 1600 Abbildung 18 - Ergebnisse der Vergleichstests zwischen A* und JPS Wie man sehen kann, ist JPS tatsächlich in den meisten Fällen deutlich schneller. Gerade bei den realen Spielbeispielen (Tests 1, 8 und 9) kann ein Weg mit der 20-30fachen Geschwindigkeit berechnet werden. Das liegt daran, dass Jump Point Search bei weiten Flächen seine Stärken zeigen kann, da diese größtenteils übersprungen werden können. Tests 3 und 7 verdeutlichen das. Nur in Grids mit sehr vielen kleinen Hindernissen (Test 4) befindet sich A* im Vorteil: der Algorithmus bewegt sich zielstrebiger zum Ziel, während JPS breit gefächert nach vielen Jump Points sucht, die sich durch die Menge der Hindernisse ergeben. Hierbei muss allerdings beachtet werden, dass A* nur deshalb so schnell zum Ziel gelangt, weil der Weg beinahe geradlinig und ohne größere Umwege zu erreichen ist. Wäre dies nicht der Fall, könnte JPS einen entsprechenden Umweg wiederum etwas schneller finden. Das wird in Test 2 gezeigt, dessen Level ebenfalls ein Grid mit vielen Hindernissen, jedoch ohne direkt offensichtlichen Weg zum Ziel ist. In diesen neun (sehr unterschiedlichen) Tests benötigte A* im Schnitt 448ms und Jump Points Search nur 65ms. Das bedeutet, dass JPS im Schnitt etwa 7x so schnell war. Die Standardabweichung betrug 569ms bei A* und nur 47ms bei JPS. Damit lässt sich erkennen, dass JPS konstanter in seiner Performance ist, während die Suchdauer von A* stärker vom jeweiligen Level abhängt. Um die Unterschiede besser nachvollziehen zu können, folgen nun einige grafische Ausgaben aus der TileExporter-Demo. 15.07.2013 38 Bachelorarbeit Kai Hillenbrand Abbildung 19 - Tests 1-3 (links: A*, rechts: JPS) Expandierte Tiles sind rot markiert, der beste Pfad blau. Je mehr rot, desto mehr Tiles wurden expandiert und desto mehr Zeit hat der Algorithmus benötigt. 15.07.2013 39 Bachelorarbeit Kai Hillenbrand Abbildung 20 - Tests 4-6 (links: A*, rechts: JPS) Schwächeres rot bedeutet, dass die Dichte der expandierten Felder geringer ist. 15.07.2013 40 Bachelorarbeit Kai Hillenbrand Abbildung 21 - Tests 7-9 (links: A*, rechts: JPS) 15.07.2013 41 Bachelorarbeit Kai Hillenbrand 8 Fazit 8.1 Ergebnis In dieser Arbeit wurde ein Einstieg in die Wegfindung mit dem Schwerpunkt auf Spieleentwicklung gegeben. Vorhandene Methoden und Algorithmen wurden erklärt, sowie deren Anwendung in verbreiteten Spiele-Engines betrachtet. Der Jump Point Search Algorithmus wurde ausführlich beschrieben und in der Unity-Engine implementiert. Die Implementierung wurde mit A* verglichen und evaluiert. Wie beschrieben, fehlt es der Unity-Engine momentan an schnellen, kostenlosen Wegfindungsimplementierungen. Für Entwickler, die solche benötigen, ist das Ergebnis dieser Arbeit interessant, da in vielen Fällen eine um ein Vielfaches höhere Geschwindigkeit erreicht werden kann. Während bei vertikalen 3D-Levels mit mehreren Ebenen mittlerweile im Regelfall auf eine Variante von Navmesh in Verbindung mit A* zurückgegriffen wird, nutzen Strategiespiele und ähnliche Genres mit flacheren Gebieten weiterhin noch oft die simplere Grid-Repräsentation und können somit von Jump Point Search stark profitieren. Gerade in besonders dynamischen Levels ist es viel effizienter, die entsprechenden Felder in einem Grid zu blockieren/freizugeben, als einen Navmesh zur Laufzeit anzupassen und neue Polygone zu berechnen. 8.2 Ausblick Die Implementierung von Jump Point Search in Unity ist lediglich eine leicht optimierte Basisform des Algorithmus. Weitere mögliche Optimierungen wurden im entsprechenden Kapitel angesprochen und lassen sich auf Basis des vorhandenen Quelltextes umsetzen. Die Implementierung könnte beispielsweise um die Variante JPS+ erweitert werden, welche die Jump Points im Voraus berechnet. Weiterhin lässt sich die Implementierung mit anderen der vorgestellten Wegfindungsmethoden kombinieren, wie beispielsweise HPA*. Dass JPS nur auf nicht-bewerteten Grids funktioniert, wäre ebenfalls noch eine Einschränkung, die man durch eine Erweiterung des Algorithmus aufheben könnte. Abschließend kann gesagt werden, dass Wegfindung ein elementarer Bestandteil in Spielen ist und es auch immer bleiben wird. Durch immer größer und dynamischer werdende Levels müssen auch die Wegfindungsalgorithmen verbessert werden, um weiterhin schnellstmöglich den besten Weg zu finden. 15.07.2013 42 Bachelorarbeit Kai Hillenbrand Literaturverzeichnis Bücher und Paper [10] I. Millington and J. Funge, Artificial Intelligence for Games (Second Edition), 2009. [11] Goodwin, Menon and Price, "Pathfinding in Open Terrain". [12] A. Botea, M. Müller and J. Schaeffer, "Near Optimal Hierarchical Path-Finding". [14] D. Harabor, "Fast Pathfinding via Symmetry Breaking". [15] D. Harabor and A. Grastien, "Online Graph Pruning for Pathfinding on Grid Maps". [17] D. Harabor and A. Grastien, "The JPS Pathfinding System". [20] D. Kastenholz, "3D Pathfinding," 2006. [21] X. Cui and H. Shi, "A*-based Pathfinding in Modern Computer Games," 2011. [22] M. Buckland, "Programming Game AI by Example," 2005. [23] H. Waldschmidt, "Vergleich von Pathfinding-Algorithmen," 2008. Webseiten [1] „Unity 3D,“ Unity Technologies, [Online]. Verfügbar unter: http://unity3d.com/unity/. [2] „Unity 3D Asset Store,“ Unity Technologies, [Online]. Verfügbar unter: https://www.assetstore.unity3d.com. [3] „Unity 3D Docs - Navmesh and Pathfinding,“ Unity Technologies, [Online]. Verfügbar unter: http://docs.unity3d.com/Documentation/Manual/NavmeshandPathfinding.html. [4] „Unreal Developer Network - Navigation Mesh Reference,“ Epic Games, [Online]. Verfügbar unter: http://udn.epicgames.com/Three/NavigationMeshReference.html. [5] „My CryEngine,“ CryTek, [Online]. Verfügbar unter: http://mycryengine.com. [6] „CryDev - Triangulation-based Navigation,“ CryTek, [Online]. Verfügbar unter: 15.07.2013 43 Bachelorarbeit Kai Hillenbrand http://freesdk.crydev.net/display/SDKDOC4/Triangulation-based+Navigation. [7] „CryDev - Pathfinding Costs,“ CryTek, [Online]. Verfügbar unter: http://freesdk.crydev.net/display/SDKDOC4/Pathfinding+Costs. [8] „Valve Developer Community - Navigation Meshes,“ Valve, [Online]. Verfügbar unter: https://developer.valvesoftware.com/wiki/Navigation_Meshes. [9] „Construct 2 - Pathfinding,“ Scirra, [Online]. Verfügbar unter: https://www.scirra.com/manual/154/pathfinding. [13] D. Harabor, „Shortest Path,“ [Online]. Verfügbar unter: http://harablog.wordpress.com/2011/09/07/jump-point-search. [16] N. Witmer, „zerowidth positive lookahead - Jump Point Search Explained,“ [Online]. Verfügbar unter: http://zerowidth.com/2013/05/05/jump-point-search-explained.html. [18] „MovingAI Benchmarks,“ Moving AI Lab at the University of Denver, [Online]. Verfügbar unter: http://movingai.com/benchmarks/. [19] „Unity 3D Script Reference,“ Unity Technologies, [Online]. Verfügbar unter: http://docs.unity3d.com/Documentation/ScriptReference/index.html. 15.07.2013 44