Förderjahr 2018 / Project Call #13 / ProjektID: 3361 / Projekt: Hedgehog Cloud
Programme schreiben und ausführen ist schön und gut, aber wenn die Programmierumgebung nichts interessantes bietet, bringt das nicht besonders viel. Bevor es später auch möglich sein soll, echte Roboter im lokalen Netzwerk anzusteuern, ist der erste Schritt eine Simulation im Browser - ein absolut notwendiger Schritt, damit auch jene davon profitieren können, die kein Geld in Robotik-Hardware stecken können oder wollen.
Wie immer, wenn eine neue Komponente anzugehen ist, stellt sich die Frage der Technologie, und wie schon dezent angedeutet fiel die Wahl hier auf Matter.js, eine 2D-Physikbibliothek geschrieben in JavaScript. Hier ist anzumerken, dass diese Entscheidung zwar für die Entwicklung der Hedgehog IDE wichtig ist, aber keine grundlegende Architektur-Entscheidung darstellt. Später wird es zumindest zwei Ausführungs-Modi geben: die Simulation und einen echten Roboter. Es spricht also nichts dagegen, auch andere Simulationen hinzuzufügen, 2D oder 3D.
Matter.js
Was kann also Matter.js? Ein Blick auf die Website zeigt gleich einige interessante Demos: ein Doppelpendel, elastische Verbindungen und jede Menge Kollisionen. Unser wichtigster Anwendungsfall ist ein mobiler Roboter mit zwei Rädern, der auf einer Oberfläche fährt - wer genau aufpasst stellt fest: wir sind interessiert an der Vogelperspektive, die Demos schauen alle seitlich auf das Geschehen.
Das ist übrigens keine Eigenheit von Matter.js, die meisten 2D Engines gehen von einem seitlichen Blickwinkel aus. Das ist aber kein großes Problem, im Großen und Ganzen müssen wir zwei Sachen anpassen, um eine vernünftige Simulation aus der Vogelperspecktive zu erreichen:
- Gravitation deaktivieren: dieser Schritt ist wahrscheinlich offensichtlich. Weil weder die x- noch die y-Achse der Simulation nach "unten" zeigt, wollen wir auch keine Schwerkraft haben. Schwerelosigkeit klingt in diesem Zusammenhang zwar merkwürdig, ist aber das, was wir brauchen.
- Bewegungen stark dämpfen: diese zweite Maßnahme ist nicht ganz so offensichtlich, und physikalisch auch nicht ganz korrekt. Wir haben das generelle Problem, dass es in unserer 2D-Simulation keinen Boden "unter" dem Roboter gibt (und ohne Schwerkraft nach "unten" dieser Boden auch keinen Reibungswiderstand auf den Roboter ausüben könnte). Die Lösung ist eine Dämpfung, also ein Widerstand, der proportional zur Geschwindigkeit ist und damit dem Reibungswiderstand sehr ähnlich ist.
- Mit einer Sache müssen wir uns in einer 2D-Simulation abfinden: Genauso wie Bewegungen nur in der x- oder y-Richtung möglich sind, gibt es Drehungen auch nur in der Ebene, um eine senkrechte Achse. Echte Räder sind also einfach nicht möglich, ein Unterschied zwischen Gleit-, Roll- und Haftreibung genausowenig.
Die Welt selbst wird also dermaßen konfiguriert; zusätzlich dazu gibt es aber auch eine Umgebung innerhalb der Welt: auf den vier Seiten sind in grau Wände eingezeichnet, die verhindern, dass der Roboter in die Unendlichkeit verschwindet. Die Linien am Boden sind keine "Bemalung" des Hintergrunds, sondern normale Objekte in der Simulation; so wie die Wände sind sie "statisch", werden also nicht durch auf sie wirkende Kräfte verschoben. Zusätzlich sind die Linien aber auch "Sensoren" - die Physik-Engine berechnet zwar wann eine Kollision mit den Linien auftritt, aus den Kollisionen resultieren aber keine Kräfte. Die Linien sind also komplett passiv.
Jetzt stellt sich nur noch die Frage des Roboters! Neben dem grünen Körper hat dieser insgesamt sieben graue Objekte: seitlich zwei Räder, ganz vorne eine Stoßstange und knapp dahinter vier Runde Sensoren. was wir von dem Roboter jetzt brauchen:
- Fahren muss der Roboter können. Dafür stellen wir einfach Kräfte ein, die an den beiden Rädern ansetzen, in die Richtung des Roboters schauen, und entsprechend der Motorwerte eine größere oder kleinere Stärke haben:
// Klasse "Robot" applyForce(pos: Point, force: number, cos: number, sin: number) { Matter.Body.applyForce(this.body, pos, { x: force * cos, y: force * sin, }); } beforeUpdate() { const lPos = this.leftWheel.position; const rPos = this.rightWheel.position; const dx = lPos.x - rPos.x; const dy = lPos.y - rPos.y; const hypot = Math.hypot(dx, dy); // cosine and sine of the angle in which the forces are directed // this is normal to the axis of the wheels, therefore [-dy, dx] instead of [dx, dy] const cos = -dy / hypot; const sin = dx / hypot; this.applyForce(lPos, this.motors[0] / 10, cos, sin); this.applyForce(rPos, this.motors[1] / 10, cos, sin); } // Konstruktor der Klasse "Simulation" Matter.Events.on(this.runner, 'beforeUpdate', () => { robot.beforeUpdate(); });
Die auf Körper wirkenden Kräfte werden von Matter.js bei jedem Tick der Simulation neu berechnet, deswegen wird auch die Kraft der Motoren bei jedem Tick neu angewendet.
-
Mit der Stoßstange Kollisionen zu erkennen passiert mit den "collisionStart" und "collisionEnd" Events von Matter.js. Dabei übergibt Matter.js uns alle Paare von Objekten, die begonnen oder aufgehört haben, miteinander zusammenzustoßen. Außer der Suche in dieser Liste von Paaren ist dabei hauptsächlich zu beachten, dass es mehrere Kollisionen gleichzeitig geben kann, man also hier einen Zähler speichern muss. Linien werden auf die genau gleiche Art erkannt:
// Konstruktor der Klasse "Simulation" const collisionHandler = ({ name, pairs }) => { pairs.forEach(pair => { const { bodyA, bodyB } = pair; let collision = null; // go over all types of sensors in the cache, // find out if the collision pair has that sensor. // there's only one sensor in the collision pair, // so there's no hazard of overwriting a collision through forEach Object.keys(this.sensorsCache).forEach(key => { const sensors = this.sensorsCache[key]; if (sensors.includes(bodyA)) { collision = { type: key, sensor: bodyA, other: bodyB, }; } else if (sensors.includes(bodyB)) { collision = { type: key, sensor: bodyB, other: bodyA, }; } }); if (collision === null) return; const { type, sensor, other } = collision; // handle collision according to the type if (type === 'lineSensors' && this.lines.includes(other)) { // sensor.plugin can store extra data; ...hedgehog.robot is the Robot object to which the sensor belongs sensor.plugin.hedgehog.robot.handleLineSensor(name, sensor); } else if (type === 'touchSensors' && !this.lines.includes(other)) { sensor.plugin.hedgehog.robot.handleTouchSensor(name, sensor); } }); }; Matter.Events.on(this.engine, 'collisionStart', collisionHandler); Matter.Events.on(this.engine, 'collisionEnd', collisionHandler);
Der einzige Unterschied besteht darin, welche Kollisionen registriert werden: Im Fall der Stoßstange sollen die passiven Linien ignoriert werden, bei den vier Liniensensoren ist es genau umgekehrt. Die handleLineSensor und handleTouchSensor Methoden der Robot-Klasse erledigen dann den Rest.
Damit werden die notwendigen Daten in bzw. aus der Simulation befördert! Probiers gleich selbst aus!