Schreiben Sie eine Spiel-Engine in Ihrem ersten Jahr: einfach! (Fast)

Hallo! Mein Name ist Gleb Maryin, ich bin in meinem ersten Studienjahr "Angewandte Mathematik und Informatik" an der HSE in St. Petersburg. Im zweiten Semester machen alle Studienanfänger in unserem Programm Teamprojekte in C ++. Meine Teamkollegen und ich haben beschlossen, eine Spiel-Engine zu schreiben. 

Lesen Sie, was wir unter die Katze bekommen.


Wir sind zu dritt im Team: ich, Alexei Luchinin und Ilya Onofriychuk. Keiner von uns ist Experte für Spieleentwicklung, geschweige denn für die Erstellung von Spiel-Engines. Dies ist das erste große Projekt für uns: Vorher haben wir nur Hausaufgaben und Laborarbeiten gemacht, daher ist es unwahrscheinlich, dass Fachleute auf dem Gebiet der Computergrafik hier neue Informationen für sich finden. Wir freuen uns, wenn unsere Ideen denen helfen, die auch ihren eigenen Motor entwickeln wollen. Dieses Thema ist jedoch komplex und vielfältig, und der Artikel erhebt in keiner Weise Anspruch auf vollständige Fachliteratur.

Alle anderen, die mehr über unsere Implementierung erfahren möchten - viel Spaß beim Lesen!

Grafik


Erstes Fenster, Maus und Tastatur


Um Fenster zu erstellen, Maus- und Tastatureingaben zu verarbeiten, haben wir die SDL2-Bibliothek ausgewählt. Es war eine zufällige Wahl, aber bisher haben wir es nicht bereut. 

In der ersten Phase war es wichtig, einen praktischen Wrapper über die Bibliothek zu schreiben, damit Sie ein Fenster mit ein paar Zeilen erstellen, Manipulationen damit vornehmen können, z. B. den Cursor bewegen und in den Vollbildmodus wechseln und Ereignisse behandeln können: Tastenanschläge, Cursorbewegungen. Die Aufgabe war nicht schwierig: Wir haben schnell ein Programm erstellt, mit dem ein Fenster geschlossen und geöffnet werden kann. Wenn Sie auf RMB klicken, wird „Hallo Welt!“ Angezeigt. 

Dann erschien der Hauptspielzyklus:

Event ev;
bool running = true;
while (running):
	ev = pullEvent();
	for handler in handlers[ev.type]:
		handler.handleEvent(ev);

Jeder Event-Handler ist angehängt - handlersz handlers[QUIT] = {QuitHandler()}. Ihre Aufgabe ist es, das entsprechende Ereignis zu behandeln. QuitHandlerIm Beispiel wird es freigelegt running = false, wodurch das Spiel gestoppt wird.

Hallo Welt


Zum Zeichnen des Motors verwenden wir OpenGL. Das erste war Hello World, wie ich glaube, in vielen Projekten ein weißes Quadrat auf schwarzem Hintergrund: 

glBegin(GL_QUADS);
glVertex2f(-1.0f, 1.0f);
glVertex2f(1.0f, 1.0f);
glVertex2f(1.0f, -1.0f);
glVertex2f(-1.0f, -1.0f);
glEnd();


Dann lernten wir, wie man ein zweidimensionales Polygon zeichnet, und führten die Figuren in einer separaten Klasse aus GraphicalObject2d, die sich drehen glRotate, bewegen glTranslateund dehnen kann glScale. Wir stellen die Farbe in vier Kanälen mit ein glColor4f(r, g, b, a).

Mit dieser Funktion können Sie bereits einen schönen Brunnen aus Quadraten erstellen. Erstellen Sie eine Klasse ParticleSystemmit einem Array von Objekten. Bei jeder Iteration der Hauptschleife aktualisiert das Partikelsystem die alten Quadrate und sammelt einige neue, die in zufälliger Richtung beginnen:



Kamera


Der nächste Schritt bestand darin, eine Kamera zu schreiben, die sich bewegen und in verschiedene Richtungen schauen konnte. Um zu verstehen, wie dieses Problem gelöst werden kann, benötigen wir Kenntnisse aus der linearen Algebra. Wenn dies für Sie nicht sehr interessant ist, können Sie den Abschnitt überspringen, das GIF anzeigen und weiterlesen .

Wir möchten einen Scheitelpunkt in den Koordinaten des Bildschirms zeichnen und dessen Koordinaten relativ zur Mitte des Objekts kennen, zu dem er gehört.

  1. Zuerst müssen wir seine Koordinaten relativ zum Zentrum der Welt finden, in dem sich das Objekt befindet.
  2. Wenn Sie dann die Koordinaten und den Standort der Kamera kennen, finden Sie die Position des Scheitelpunkts in der Basis der Kamera.
  3. Projizieren Sie dann den Scheitelpunkt auf die Ebene des Bildschirms. 

Wie Sie sehen können, gibt es drei Stufen. Die Multiplikation mit drei Matrizen entspricht ihnen. Wir nannten diese Matrizen Model, Viewund Projection.

Beginnen wir damit, die Koordinaten des Objekts auf der Basis der Welt zu erhalten. Mit einem Objekt können drei Transformationen durchgeführt werden: Skalieren, Drehen und Verschieben. Alle diese Operationen werden durch Multiplizieren des ursprünglichen Vektors (Koordinaten in der Basis des Objekts) mit den entsprechenden Matrizen spezifiziert. Dann Modelsieht die Matrix folgendermaßen aus: 

Model = Translate * Scale * Rotate. 

Wenn wir die Position der Kamera kennen, möchten wir die Koordinaten in ihrer Basis bestimmen: Multiplizieren Sie die zuvor erhaltenen Koordinaten mit der Matrix View. In C ++ wird dies bequem mit der Funktion berechnet:


glm::mat4 View = glm::lookAt(cameraPosition, objectPosition, up);

Wörtlich: Betrachten Sie objectPositionvon einer Position aus cameraPosition, und die Aufwärtsrichtung ist "oben". Warum ist diese Richtung notwendig? Stellen Sie sich vor, Sie fotografieren eine Teekanne. Sie richten die Kamera auf ihn und stellen den Wasserkocher in den Rahmen. An dieser Stelle können Sie genau sagen, wo sich der Rahmen oben befindet (höchstwahrscheinlich dort, wo der Wasserkocher einen Deckel hat). Das Programm kann für uns nicht herausfinden, wie der Frame angeordnet werden soll, und deshalb muss der Vektor „up“ angegeben werden.

Wir haben die Koordinaten in der Basis der Kamera, es bleibt, die erhaltenen Koordinaten auf die Ebene der Kamera zu projizieren. Die Matrix beschäftigt sich damit Projection, was den Effekt erzeugt, das Objekt zu reduzieren, wenn es von uns entfernt wird.

Um die Koordinaten des Scheitelpunkts auf dem Bildschirm zu erhalten, müssen Sie den Vektor mindestens fünfmal mit der Matrix multiplizieren. Alle Matrizen sind 4 mal 4 groß, so dass einige Multiplikationsoperationen durchgeführt werden müssen. Wir möchten keine Prozessorkerne mit vielen einfachen Aufgaben laden. Hierfür ist eine Grafikkarte mit den erforderlichen Ressourcen besser. Sie müssen also einen Shader schreiben: eine kleine Anweisung für eine Grafikkarte. OpenGL verfügt über eine spezielle GLSL-Shader-Sprache, ähnlich wie C, die uns dabei hilft. Gehen wir nicht auf die Details des Schreibens eines Shaders ein, sondern schauen uns endlich an, was passiert ist:


Erläuterung: Es gibt zehn Quadrate, die nicht weit voneinander entfernt sind. Auf der rechten Seite befindet sich ein Spieler, der die Kamera dreht und bewegt. 

Physik


Was ist ein Spiel ohne Physik? Um die physische Interaktion zu handhaben, haben wir uns für die Box2d-Bibliothek entschieden und eine Klasse erstellt WorldObject2d, von der geerbt wurde GraphicalObject2d. Leider hat Box2d nicht sofort funktioniert, daher hat der tapfere Ilya einen Wrapper für b2Body und alle physischen Verbindungen in dieser Bibliothek geschrieben.


Bis zu diesem Moment haben wir uns überlegt, die Grafiken in der Engine absolut zweidimensional zu gestalten. Wenn wir uns für die Beleuchtung entscheiden, verwenden wir die Raycasting-Technik. Aber wir hatten eine wundervolle Kamera zur Hand, die Objekte in allen drei Dimensionen anzeigen kann. Deshalb haben wir allen zweidimensionalen Objekten Dicke hinzugefügt - warum nicht? Darüber hinaus können Sie in Zukunft eine sehr schöne Beleuchtung erstellen, die Schatten von dicken Objekten hinterlässt.

Zwischen den Fällen erschien eine Beleuchtung. Um es zu erstellen, mussten die entsprechenden Anweisungen zum Zeichnen jedes Pixels geschrieben werden - ein Fragment-Shader.



Texturen


Zum Herunterladen der Bilder haben wir die DevIL-Bibliothek verwendet. Jedes GraphicalObject2dwurde zu einer Instanz der Klasse GraphicalPolygon- dem vorderen Teil des Objekts - und dem GraphicalEdgeSeitenteil. Auf jedem können Sie Ihre Textur dehnen. Erstes Ergebnis:


Alles, was für die Grafik erforderlich ist, ist fertig: Zeichnen, eine Lichtquelle und Textur. Grafik - das wars erstmal.

Zustandsmaschine, die das Verhalten von Objekten definiert


Jedes Objekt, was auch immer es sein mag - ein Zustand in der Zustandsmaschine, grafisch oder physisch - muss "ticken", dh jede Iteration der Spielschleife wird aktualisiert.

Objekte, die aktualisiert werden können, werden von der von uns erstellten Behavior-Klasse geerbt. Es verfügt über Funktionen onStart, onActive, onStop, mit denen Sie das Verhalten des Erben beim Start, während des Lebens und am Ende seiner Aktivität überschreiben können. Jetzt müssen wir ein oberstes Objekt erstellen Activity, das diese Funktionen von allen Objekten aufruft. Die Schleifenfunktion, die dies tut, ist wie folgt:

void loop():
    onAwake();
    awake = true;
    while (awake):
        onStart();
        running = true
        while (running):
            onActive();
        onStop();
    onDestroy();

Im running == trueMoment kann jemand eine Funktion aufrufen pause(), die dies tut running = false. Wenn jemand anruft kill(), dann awake, und runningwenden sich an falseund Aktivität zum Stillstand.

Problem: Wir möchten eine Gruppe von Objekten anhalten, zum Beispiel ein System von Partikeln und Partikeln darin. Im aktuellen Status müssen Sie onPausejedes Objekt manuell aufrufen , was nicht sehr praktisch ist.

Lösung: Jeder Behaviorhat ein Array subBehaviors, das er aktualisieren wird, dh:

void onStart():
	onStart() 		//     
	for sb in subBehaviors:
		sb.onStart()	//       Behavior
void onActive():
	onActive()
	for sb in subBehaviors:
		sb.onActive()

Und so weiter für jede Funktion.

Aber nicht jedes Verhalten kann auf diese Weise eingestellt werden. Wenn zum Beispiel ein Feind auf einer Plattform geht - Feind, dann hat er höchstwahrscheinlich verschiedene Zustände: Er steht - idle_stay, er geht auf einer Plattform, ohne uns zu bemerken - idle_walkund er kann uns jederzeit bemerken und in einen Angriffszustand übergehen - attack. Ich möchte auch bequem die Bedingungen für den Übergang zwischen Zuständen festlegen, zum Beispiel:

bool isTransitionActivated(): 		//  idle_walk->attack
	return canSee(enemy);

Das gewünschte Muster ist eine Zustandsmaschine. Wir haben sie auch zur Erbin gemacht Behavior, da bei jedem Tick geprüft werden muss, ob es an der Zeit ist, den Staat zu wechseln. Dies ist nicht nur für Objekte im Spiel nützlich. Beispielsweise Levelist dies ein Zustand Level Switcher, und Übergänge in der Steuerung Maschine sind die Bedingungen für die Ebene im Spiel wechseln.

Der Staat hat drei Phasen: Er hat begonnen, er tickt, er hat aufgehört. Sie können jeder Stufe einige Aktionen hinzufügen, z. B. eine Textur an ein Objekt anhängen, einen Impuls darauf anwenden, die Geschwindigkeit einstellen usw.

Erhaltung


Wenn ich ein Level im Editor erstelle, möchte ich es speichern können, und das Spiel selbst sollte das Level aus den gespeicherten Daten laden können. Daher werden alle Objekte, die gespeichert werden müssen, von der Klasse geerbt NamedStoredObject. Es speichert eine Zeichenfolge mit dem Namen und dem Klassennamen und verfügt über eine Funktion dump(), mit der Daten zu einem Objekt in eine Zeichenfolge ausgegeben werden.  

Um zu speichern, muss es einfach dump()für jedes Objekt überschrieben werden. Loading ist ein Konstruktor aus einer Zeichenfolge, die alle Informationen zum Objekt enthält. Der Download ist abgeschlossen, wenn für jedes Objekt ein solcher Konstruktor erstellt wurde. 

Tatsächlich sind das Spiel und der Editor fast dieselbe Klasse, nur im Spiel wird das Level im Lesemodus und im Editor im Aufnahmemodus geladen. Die Engine verwendet die RapidJson-Bibliothek, um Objekte aus JSON zu schreiben und zu lesen.

GUI


Irgendwann stellte sich vor uns die Frage: Lassen Sie die Grafiken, die Zustandsmaschine und alles andere geschrieben werden. Wie kann ein Benutzer damit ein Spiel schreiben? 

In der Originalversion musste er Objekte in den Feldern der Klasse erben, Game2düberschreiben onActiveund erstellen. Aber während der Erstellung kann er nicht sehen, was er erstellt, und er müsste auch sein Programm kompilieren und auf unsere Bibliothek verlinken. Grusel! Es würde Pluspunkte geben - man könnte nach so komplexen Verhaltensweisen fragen, die man sich hätte vorstellen können: Bewegen Sie beispielsweise den Landblock so weit wie das Leben des Spielers, vorausgesetzt, Uranus befindet sich im Sternbild Stier und der Euro überschreitet nicht 40 Rubel. Wir haben uns dennoch für eine grafische Oberfläche entschieden.

In der grafischen Oberfläche ist die Anzahl der Aktionen, die mit einem Objekt ausgeführt werden können, begrenzt: Blättern Sie durch die Animationsfolie, üben Sie Kraft aus, stellen Sie eine bestimmte Geschwindigkeit ein usw. Die gleiche Situation mit Übergängen in der Zustandsmaschine. In großen Engines wird das Problem einer begrenzten Anzahl von Aktionen gelöst, indem das aktuelle Programm mit einem anderen verknüpft wird. Beispielsweise verwenden Unity und Godot die Bindung mit C #. Bereits mit diesem Skript können Sie alles tun: und sehen, in welcher Konstellation Uranus und wie hoch der aktuelle Euro-Wechselkurs ist. Wir haben derzeit keine solche Funktionalität, aber wir planen, die Engine mit Python 3 zu verbinden.

Um die grafische Oberfläche zu implementieren, haben wir uns für Dear ImGui entschieden, da es sehr klein ist (im Vergleich zum bekannten Qt) und das Schreiben sehr einfach ist. ImGui - das Paradigma zur Erstellung einer grafischen Oberfläche. Darin werden bei jeder Iteration der Hauptschleife alle Widgets und Fenster nur bei Bedarf neu gezeichnet. Dies reduziert einerseits den Speicherbedarf, andererseits dauert es höchstwahrscheinlich länger als eine Ausführung der komplexen Funktion zum Erstellen und Speichern der erforderlichen Informationen für das nachfolgende Zeichnen. Es bleiben nur die Schnittstellen zum Erstellen und Bearbeiten zu implementieren.

So sieht die grafische Oberfläche zum Zeitpunkt der Veröffentlichung aus:


Level-Editor


Zustandsmaschinen-Editor

Fazit


Wir haben nur die Basis geschaffen, auf der Sie etwas Interessanteres aufhängen können. Mit anderen Worten, es gibt Raum für Wachstum: Sie können Schattenrendering implementieren, mehr als eine Lichtquelle erstellen und die Engine mit dem Python 3-Interpreter verbinden, um Skripte für das Spiel zu schreiben. Ich möchte die Benutzeroberfläche verfeinern: schöner machen, mehr verschiedene Objekte hinzufügen, Hotkeys unterstützen ...

Es gibt noch viel Arbeit, aber wir sind zufrieden mit dem, was wir im Moment haben. 

Während der Erstellung des Projekts haben wir viele verschiedene Erfahrungen gesammelt: Arbeiten mit Grafiken, Erstellen grafischer Oberflächen, Arbeiten mit JSON-Dateien, Wrapper zahlreicher C-Bibliotheken. Und auch die Erfahrung, das erste große Projekt in einem Team zu schreiben. Wir hoffen, dass wir darüber ebenso interessant erzählen konnten, wie es interessant war, damit umzugehen :)

Link zum Gihab-Projekt: github.com/Glebanister/ample

All Articles