Da wir anfangs unglaublich viele Ideen hatten, und im Hinterkopf vielleicht schon die unterdrückte Ahnung, dass wir es wahrscheinlich nicht schaffen würden, einen Fischschwarm mit männlichen und weiblichen, alten und jungen Fischen, Fischen, die neu geboren werden und sterben, im Laufe ihres Lebens von Hunger, Gruppenzusammenhalt, Müdigkeit und spontaner Erschöpfung getrieben werden etc., zu simulieren, wollten wir ein Programm schreiben, das beliebig erweiterbar ist. Rausgekommen ist eine einfache Grundstruktur, aus einer Weltklasse und einer Klasse für die Fischobjekte, wobei das Verhalten des Fisches so simuliert wird, dass es aus vielen Einzelinteressen besteht, aus denen ein Kompromiss geschlossen wird. Dadurch können die einzelnen Interessen der Fische unabhängig voneinander verändert und verbessert werden oder ganz neue hinzugefügt werden, es können auch ganz neue Handlungsmotive hinzugefügt werden.
Da wir mit einem kartesischen Koordinatensystem so arbeiten, dass die Positionen von Fischen, Hindernissen und Essen als Vektoren in unterschiedlichen Arrays der Weltklasse gespeichert werden, könnte man auch da weitere Kategorien hinzufügen, die dann wiederum nur von der Funktion des Fischverhaltens berücksichtigt werden müssen, die sich damit befassen. Zum Beispiel sind Hindernisse dem Teil des Fisches, der sich mit Essen befasst, ganz egal – dadurch kann das Programm von uns oder einer anderen Mathesisgruppe unkompliziert weiterentwickelt werden.
Das genaue Verhalten der Fische, also wie genau sie ihre Richtung synchronisieren, wie sie Hunger und Müdgkeit abwegen usw., haben wir im Grunde nur grob geschätzt. Daraus folgt, dass wir mit einer ganzen Menge Konstanten arbeiten, die wir auch nur geschätzt haben, die aber falsch gewählt zu unpassenden Resultaten führen könnten. Um die systematisch ausprobieren zu können, ohne im Programmcode nach jeder Verwendung von 0.95 als Trägheit suchen zu müssen, haben wir all diese Stellschrauben am Anfang des Programms gesammelt und mit möglichst selbsterklärenden Namen versehen.
Aufbau
Das Programm besteht aus zwei Klassen, die eigentlich die ganze Arbeit machen: Der Klasse welt und der Klasse fisch.
Die Welt Klasse enthält zwei Methoden: einen Konstruktor, der die Anfangskonstellation herstellt und eine main-Funktion, aus der heraus im laufenden Programm alles passiert.
Die Klasse Fisch enthält alle Funktionen (im herkömmlichen sowie im objektorientierten Sinne), die ein einzelner Fisch haben soll:
– einen Konstruktor, der die Position des Fisches speichert, sich im Raum an dieser Stelle anzeigt und die anfänglichen Eigenschaften des Fisches speichert (die Werte für hunger, muedigkeit und schlafnot sowie, dass der Fisch gerade nicht schläft) .
– einer Funktion anfangsgeschwindigkeit; sie ist dafür gedacht, dass der Konstruktor sie aufrufen kann, um zum ersten mal Richtung und Geschwindigkeit des Fisches statt zufällig so zu bestimmen, dass die Fische in einem approximierten Kreis um die z-Achse schwimmen.
– Die Funktionen essen, nachbar, hindernis und schlaf. Dabei geben die ersten drei genannten Funktionen jeweils den Impuls zurück, mit dem der Fisch seine Bewegung ändern sollte, hätte er nur jeweils ein Interesse (zu essen, bei den Nachbarn zu bleiben und dem nächsten Hindernis auszuweichen), schlaf gibt einen Wert zurück, der so ausgewertet ist, dass der Fisch nach längerer Zeit wach sein eine größere schlafnot hat und sich dementsprechend schlafen legt, allerdings nur, wenn die Dringlichkeit größer ist als die Dringlichkeit des Impulses, mit dem er sich sonst bewegt hätte – wobei Dringlichkeit vereinfachter Weise dem Betrag des Impulses entspricht. Das Abwegen und vereinen der Einzelinteressen findet in der Funktion neuebewegung statt:
– Der Funktion neuebewegung, die den Impuls berechnet, mit dem ein Fisch seine momentane Bewegung ändert, indem die verschiedenen Interessen abgewogen und nach einem relativ einfachen Algorythmus zum Gesamtimpuls ausgewertet werden
– Der Funktion bewegen, die, sofern die vom Fisch gespeicherte Bewegung ausgeführt wird. Der Fisch hat zu jeder Zeit eine Bewegung gespeichert, die sich nur bei neuebewegung ändert; wird neuebewegung zwischen zwei Bewegungseinheiten nicht aufgerufen, schwimmt er mit nur durch die Trägheit langsamer werdender Geschwindigkeit gradeaus. Da auch ein schlafender Fisch gradeausschwimmt, ohne aktiv Einfluss auf seine Bewegung zu nehmen, passiert das unabhängig davon. In dieser Funktion wird allerdings auch das Abnehmen der schlafnot geregelt, die irgendwann zum Aufwachen führt. Bevor die Bewegung ausgeführt wird, wird die Funktion kollision aufgerufen:
– Einer Funktion kollision, die überprüft, ob eine noch nicht ausgeführte Bewegung überhaupt möglich ist oder dazu führen würde, dass sich der Fisch an einem Ort befindet, an demsich schon ein anderer Fisch oder ein Hindernis aufhält.
Die Positionen der Fische, Hindernisse und der Nahrung sowie Bewegung und Impuls der Fische sind als Vektoren realisiert, für die wir noch ein paar für uns ohne viel Recherche einfach zu verwenden Hilfprogramme geschrieben haben, die uns die Arbeit mit den Vektoren erleichtern, und zwar für das Skalarprodukt, den Abstand zwischen zwei Vektoren, das Kreuzrodukt und den Vektorbetrag.
Interessant sind vor allem die Funktionsweise von neuebewegung mit den davon verwendeten Funktionen und die main-Funktion der Klasse welt.
Die main-Funktion besteht aus einer großen Schleife, die so oft ausgeführt wird, wie sich jeder Fisch bewegen soll. Innerhalb der Schleife wird dann jeder Fisch aufgerufen, sich zu bewegen.
Da wir vor der Realisierung unserer Idee nicht gut abschätzen konnten, wie rechenintensiv das Programm wird, wollten wir dem Problem vorbeugen, dass im Endeffekt nur ein Haufen Punkte zu sehen sind, von denen sich alle paar Sekunden nur einer bewegt. Daher haben wir das „Überdenken“ der Bewegung von der Ausführung der Bewegung getrennt (neuebewegung und bewegen), und lassen in jeder Iteration der Hauptschleife einen verstellbaren Anteil der Fische ihre Bewegung neu berechnen. Das wird in einer Schleife realisiert, die z.B. zufällige 20% der Fische neuebewegung aufrufen lässt, danach lässt eine zweite Schleife jeden Fisch seine Bewegung ausführen.
Diese Struktur ermöglicht auch, die einzelnen Komponenten zu verfeinern oder weitere hinzuzufügen, so dass das Fischverhalten besser immitiert werden kann.
Bei der Berechnung einer Bewegungsveränderung von neuebewegung werden zunächst drei Vektoren erzeugt, nachbarimpuls, essensimpuls, hindernisimpuls und schlafimpuls, die von den dafür zuständigen Funktionen mit den Werten befüllt werden, die dafür Sorgen würden, dass der Fisch genau das eine Interesse hat, z.B. zum Essen zu schwimmen. Diese werden berechnet, dazu später im Einzelnen noch mehr.
Zunächst wird geprüft, ob der Wert der integer Zahl schlafimpuls größer als der Betrag jedes anderen Impulses ist. Das bedeutet, es wird geprüft, ob zu schlafen gerade das intensivste Bedürfnis des Fisches ist, falls das der Fall ist, wechselt der Fisch in der Zustand schlafend.
Falls der Fisch jetzt nicht schläft, wird der Gesamtimpuls berechnet. Dazu wird die Summe aus nachbarimpuls und essensimpuls gebildet, und abhängig von einer weiteren Variablen des Fisches, muedigkeit, im Betrag verändert.
Muedigkeit soll ein Maß für die spontane Erschöpfung darstellen, die Funktionsweise kann man sich ungefähr so vorstellen: Setzt der Fisch einen Impuls, strengt er sich an und muedigkeit wird größer. Mit der Zeit erholt der Fisch sich aber auch von früheren Anstrengungen, daher setzt sich die akute Erschöpfung aus dem Halben der bisherigen Erschöpfung und dem Betrag des neu gesetzten Impulses zusammen, abzüglich einer Konstanten, die angibt, wie stark sich die Fische in jedem Schritt erholen. Das führt dazu, dass der Fisch in jedem Schritt einen Impuls setzen kann, der so stark ist wie der Wert von der Erholung pro Schritt, da dann die Erschöpfung mit der Zeit gegen Null geht, es handelt sich dann um eine Zerfallsfunktion mit Halbwertszeit 1.
So entsteht ein Wert für den Gesamtimpuls, der dazu führen würde, dass ein Fischimmer in die Nähe von Essen schwimmen würde, aber niemals genau dorthin, da es immer eine kleine Abweichung durch das Interesse, bei den Nachbarn zu bleiben, geben würde. Deshalb wird, wenn der Impuls zum Essen zu schwimmen der größte ist, dieser zum Gesamtimpuls, also setzt für kurze Zeit die Orientierung an der Gruppe aus. Das soll funktionieren, da ein Fisch die Wichtigkeit, zu Nahrung zu schwimmen, als höher einstuft, wenn sich welche in der Nähe befindet. Das macht Sinn, da der Aufwand, dorthin zu schwimmen, geringer ist. Im Programmablauf zeigt sich aber, dass es tatsächlich nicht ausreicht, damit die Fische genau durch die Punkte schwimmen, an denen sich Nahrung befindet. Daran müssen wir noch arbeiten.
Da in bewegung die Trägheit das einzige ist, was einen Fisch bremst, und diese genauso groß bei einem schlafenden wie bei einem nicht denkenden Fisch ist, kann man auch nicht erkennen, ob die Fische tatsächlich schlafen – ein weiterer Punkt, an dem noch Verbesserungsbedarf besteht. Das könnte dadurch realisiert werden, dass die Trägheit wesentlich erhöht wird, Fische, die nicht denken, aber Impulse zur Geschwindigkeitserhaltung setzen.
Jetzt hat sich der Fisch also einen möglichen Impuls ausgedacht, der schon alle bisher programmierten Interessen berücksichtigt. Bevor es dabei belassen werden kann, muss aber noch geprüft werden, ob es so nicht so einer Kollision mit einem anderen Fisch oder einem Hindernis käme. Außerdem muss geprüft werden, ob die spontane Erschöpfung nicht den maximalen Wert dafür überschreitet. Das haben wir so realisiert, dass wir in einer whileschleife beides überprüfen, die erst verlassen wird, wenn alle Bedingungen erfüllt sind. Käme es zu einer Kollision, wird ein zufälliger Ausweichimpuls zu dem berechneten Impuls addiert. Wird der Fisch dann überlastet, wird der Impuls ein wenig abgeschwächt.
Wird aus einem der beiden Gründe der Impuls geändert, muss wieder überprüft werden, ob der neue Wert kompatibel zu der anderen Bedingung ist.
Das ist der Teil des Fischverhaltens der auch dafür sorgt, dass der Fisch einem Hindernis ausweicht, weshalb wir hindernisimpuls und die dafür geschriebene Funktion vorläufig noch gar nicht benutzt haben, bis wir einen vorrausschauenderen Algorythmus zum Ausweichen haben, sucht sich der Fisch einfach einen zufälligen Weg um das Hindernis herum.
Zuletzt wird die Bewegung des Fisches um den Impuls geändert, Hunger und Schlafnot erhöhen sich abhängig von der Stärke des neues Impulses.
Um das Verhalten genau zu verstehen, muss ich allerdings noch erklären, wie die hypotischen Impulse für die Einzelinteressen berechnet werden.
Fangen wir mit der Funktion essen an: es gibt eine Konstante, die den maximalen Wert für Hunger enthält. Ist der Hunger kleiner als ein Drittel davon, wird der Nullvektor zurückgegeben, es besteht kein Grund, in Richtung von Nahrung zu schwimmen. Dann sucht sich der Fisch das Essen, das ihm am nächsten ist, von da aus berechnet er den Impuls, der nötig wäre, um genau dahin zu schwimmen. Das ist die fertige Richtung.
Ist der nötige Impuls klein (kleiner als das doppelte der Erholung pro Schritt), wird er auch in der Länge nicht verändert, damit ein Fisch, der in der Nähe von Nahrung ist, genau dorthin schwimmt. Ansonsten wird die Bewegung des Fisches sowie die Richtung, in die es zum Essen geht, auf einen Vektor mit dem Betrag 1 genormt, die Differenz gebildet und mit der Geschwindigkeit des Fisches multipliziert, so dass man den Impuls erhält, der den Fisch auf eine grade Bahn in Richtung der nächsten Nahrung bringen würde. Das soll den Fisch dazu bringen, in die nähe von Nahrung zu schwimmen, so dass hier die Geschwindigkeit keine so wichtige Rolle spielt. Das genaue Treffen wird dann eingeleitet, wenn sich der Fisch schon relativ nah an seiner Nahrung befindet.
Wenn der Hunger jetzt eine Grenze von 70% des maximalen Hungers überschreitet, wird dieser Wert so zurückgegeben, ansonsten abhängig vom Hunger abgeschwächt.
Es gibt also vier Fälle: 1. Der Hunger ist klein, es gibt überhaupt keinen Grund, die Bewegung vom Essen abhängig zu machen. 2. Der Hunger ist da, aber noch so stark, dass der Fisch nur leicht Richtung Essen schwimmt. 3. Der Hunger ist so groß, dass die Bewegung so umgelenkt werden soll, dass der Fisch genau Richtung Essen schwimmt. Der vierte Fall ist der, dass es sehr wenig Anstrengung kosten würde, genau durch den Punkt mit der Nahrung zu schwimmen, so dass genau das passieren soll.
Der Gruppenzusammenhalt besteht genau genommen wieder aus zwei Einzelinteressen, aus denen ein gewichteter (auch verstellbarer) Kompromiss geschlossen wird: zum einen, die Richtung und Geschwindigkeit dem Durchschnittswert des Schwarms anzugleichen und zum anderen, den Abstand zu den nächsten n Nachbarn dem durchschnittlichen Abstand zwischen diesen anzugleichen. N ist hier wieder eine der am Anfang des Programms definierten Variablen, dessen Wert wir durch ausprobieren abgeschätzt haben.
Um den Anreiz zu ermitteln, der den Abstand zu den Nachbarn angleichen soll, werden die nächsten n Nachbarn ermittelt und vereinfachter Weise der Mittelpunkt der Gruppe gebildet. Die Verbindung zu diesem Ziel entspricht der Richtung des Impulses, die Stärke hängt vom Abstand der Nachbarn unter sich und einer „Verklumpungskonstante“ ab, einem Wert, der den angestrebten Abstand um einen Faktor verringert, um den Zusammenhalt zu stärken.
Der Gruppenzusammenhalt soll zwei für Schwärme sehr typische Verhalten bewirken: zum einen, dass sich in einem Schwarm die Dichte schlagartig ändern kann, also im ganzen Schwarm ohne einen zentralen Befehl auf einmal die Individuen weiter voneinander oder zusammen schwimmen. Deswegen gibt es auch keine Konstante für den angestrebten Abstand zu den Nachbarn, stattdessen ist der nur abhängig von den Abständen der anderen Schwarmmitgliedern.
Dass allein das für das typische Schwarmverhalten, bei dem die Individuen zusammen bleiben, nicht ausreicht, haben wir gemerkt, als wir nur diesen Teil des Schwarmzusammenhalts implementiert hatten: da die individuellen Interessen der Fische (bisher nur Essen) die einzelnen in unterschiedliche Richtungen treiben, konnte sich der Schwarm als Ganzes nicht mehr bewegen. Jeder Fisch, der in eine Richtung geschwommen ist, hat seine Nachbarn zwar auch in diese Richtung „gezogen“, da sie bei ihm bleiben wollten, in der Summe hat sich aber keine Bewegung des Schwarms als Ganzes gebildet.
Also ergänzten wir eine Komponente, die nicht nur Ort, sondern auch Bewegung der Fische synchronisieren sollte.
Dieser Teil lässt sich relativ einfach berechnen, indem in einer Schleife die durchschnittliche Bewegung des Schwarms berechnet wird, die Differenz zur eigenen Bewegung ist das Ergebnis.
Das hat zu mindest dazu geführt, dass wir jetzt einen Haufen Punkte haben, die sich in irgendeiner Weise gemeinsam bewegen.
Viele Ideen zur Verbesserung und Erweiterung haben wir noch nicht umgesetzt, und bisher ist jeder Programmteil nur ein erster Entwurf. Im Laufe des Programmierens haben wir an der Stelle weitere Feinheiten entdeckt, die man beachten muss und Ideen entwickelt, mit denen man das Verhalten realistischer machen könnte. Die Idee einer nicht zentralen Organisation, die ein komplexes Gesamtverhalten hervorruft, konnten wir aber in erster Ausführung schon umsetzen.