Einführung in die Programmierung: Ein einfacher 3D-Shooter von Grund auf über das Wochenende, Teil 1

Dieser Text ist für diejenigen gedacht, die nur die Programmierung beherrschen. Die Hauptidee ist es, Schritt für Schritt zu zeigen, wie Sie das Spiel à la Wolfenstein 3D unabhängig machen können. Achtung, ich werde überhaupt nicht mit Carmack konkurrieren, er ist ein Genie und sein Code ist wunderschön. Ich ziele auf einen ganz anderen Ort: Ich nutze die enorme Rechenleistung moderner Computer, damit die Schüler in wenigen Tagen lustige Projekte erstellen können, ohne sich in der Wildnis der Optimierung zu verlieren. Ich schreibe speziell langsamen Code, da dieser viel kürzer und leichter zu verstehen ist. Carmack schreibt 0x5f3759df , ich schreibe 1 / sqrt (x). Wir haben verschiedene Ziele.

Ich bin überzeugt, dass ein guter Programmierer nur von jemandem erhalten wird, der zu Hause zum Vergnügen codiert und nicht nur paarweise an der Universität sitzt. An unserer Universität werden Programmierer in einer endlosen Reihe von Bibliothekskatalogen aller Art und anderer Langeweile unterrichtet. Brr Mein Ziel ist es, Beispiele für Projekte zu zeigen, die interessant zu programmieren sind. Dies ist ein Teufelskreis: Wenn es interessant ist, ein Projekt zu erstellen, verbringt eine Person viel Zeit damit, sammelt Erfahrungen und sieht noch interessantere Dinge (es ist zugänglicher geworden!) Und taucht wieder in ein neues Projekt ein. Dies nennt man Projekttraining mit soliden Gewinnen.

Das Blatt war lang, also habe ich den Text in zwei Teile geteilt:


Die Codeausführung aus meinem Repository sieht folgendermaßen aus:


Dies ist kein fertiges Spiel, sondern nur ein Leerzeichen für Schüler. Ein Beispiel für ein fertiges Spiel, das von zwei Neulingen geschrieben wurde, finden Sie im zweiten Teil .

Es stellt sich heraus, dass ich Sie ein wenig getäuscht habe. Ich werde Ihnen nicht sagen, wie Sie an einem Wochenende ein komplettes Spiel machen können. Ich habe nur eine 3D-Engine gemacht. Monster rennen nicht auf mich zu und die Hauptfigur schießt nicht. Aber zumindest habe ich diesen Motor an einem Samstag geschrieben, Sie können den Verlauf der Commits überprüfen. Grundsätzlich reichen Sonntage aus, um etwas spielbar zu machen, dh an einem Wochenende kann man sich treffen.

Zum Zeitpunkt dieses Schreibens enthält das Repository 486 Codezeilen:

haqreu@daffodil:~/tinyraycaster$ cat *.cpp *.h | wc -l 486 

Das Projekt hängt von SDL2 ab, aber im Allgemeinen erscheinen die Fensteroberfläche und die Verarbeitung von Ereignissen über die Tastatur ziemlich spät, am Samstag um Mitternacht :), wenn der gesamte Rendering-Code bereits fertig ist.

Also zerlege ich den gesamten Code in Schritte, beginnend mit dem nackten C ++ - Compiler. Wie in meinen vorherigen Artikeln zum Zeitplan ( tyts , tyts , tyts ) halte ich mich an die Regel „one step = one commit“, da es mit github sehr bequem ist, den Verlauf von Codeänderungen anzuzeigen.

Stufe 1: Speichern Sie das Bild auf der Festplatte


Also lass uns gehen. Wir sind immer noch sehr weit von der Fensteroberfläche entfernt. Zunächst speichern wir die Bilder einfach auf der Festplatte. Insgesamt müssen wir in der Lage sein, das Bild im Arbeitsspeicher des Computers zu speichern und in einem Format auf der Festplatte zu speichern, das einige Programme von Drittanbietern verstehen. Ich möchte diese Datei erhalten:



Hier ist der vollständige C ++ - Code, der das zeichnet, was wir brauchen:

 #include <iostream> #include <fstream> #include <vector> #include <cstdint> #include <cassert> uint32_t pack_color(const uint8_t r, const uint8_t g, const uint8_t b, const uint8_t a=255) { return (a<<24) + (b<<16) + (g<<8) + r; } void unpack_color(const uint32_t &color, uint8_t &r, uint8_t &g, uint8_t &b, uint8_t &a) { r = (color >> 0) & 255; g = (color >> 8) & 255; b = (color >> 16) & 255; a = (color >> 24) & 255; } void drop_ppm_image(const std::string filename, const std::vector<uint32_t> &image, const size_t w, const size_t h) { assert(image.size() == w*h); std::ofstream ofs(filename); ofs << "P6\n" << w << " " << h << "\n255\n"; for (size_t i = 0; i < h*w; ++i) { uint8_t r, g, b, a; unpack_color(image[i], r, g, b, a); ofs << static_cast<char>(r) << static_cast<char>(g) << static_cast<char>(b); } ofs.close(); } int main() { const size_t win_w = 512; // image width const size_t win_h = 512; // image height std::vector<uint32_t> framebuffer(win_w*win_h, 255); // the image itself, initialized to red for (size_t j = 0; j<win_h; j++) { // fill the screen with color gradients for (size_t i = 0; i<win_w; i++) { uint8_t r = 255*j/float(win_h); // varies between 0 and 255 as j sweeps the vertical uint8_t g = 255*i/float(win_w); // varies between 0 and 255 as i sweeps the horizontal uint8_t b = 0; framebuffer[i+j*win_w] = pack_color(r, g, b); } } drop_ppm_image("./out.ppm", framebuffer, win_w, win_h); return 0; } 

Wenn Sie keinen Compiler zur Hand haben, spielt dies keine Rolle. Wenn Sie ein Konto auf einem Github haben, können Sie diesen Code anzeigen, bearbeiten und ausführen (sic!). Mit einem Klick direkt vom Browser aus.

Im gitpod öffnen

Wenn Sie diesem Link folgen, erstellt gitpod eine virtuelle Maschine für Sie, startet VS Code und öffnet ein Terminal auf der Remote-Maschine. In der Historie der Terminalbefehle (klicken Sie auf die Konsole und drücken Sie den Aufwärtspfeil) gibt es bereits einen vollständigen Befehlssatz, mit dem Sie den Code kompilieren, ausführen und das resultierende Bild öffnen können.

Also, was Sie aus diesem Code verstehen müssen. Zuerst die Farben, die ich in einem 4-Byte-Integer-Typ uint32_t speichere. Jedes Byte ist eine Komponente von R, G, B oder A. Mit den Funktionen pack_color () und unpack_color () können Sie zu den einzelnen Komponenten jeder Farbe gelangen.

Das zweite zweidimensionale Bild speichere ich in der üblichen eindimensionalen Anordnung. Um mit den Koordinaten (x, y) zum Pixel zu gelangen, schreibe ich kein Bild [x] [y], sondern ein Bild [x + y * width]. Wenn diese Methode zum Packen zweidimensionaler Informationen in ein eindimensionales Array für Sie neu ist, greifen Sie jetzt zu einem Stift und beschäftigen Sie sich damit. Für mich persönlich erreicht dieses Stadium nicht einmal das Gehirn, es wird direkt im Rückenmark verarbeitet. Drei- und mehrdimensionale Arrays können genauso verpackt werden, aber wir werden uns nicht über die beiden Komponenten erheben.

Dann gehe ich mein Bild in einem einfachen Doppelzyklus durch, fülle es mit einem Farbverlauf und speichere es im PPM-Format auf der Festplatte.



Stufe 2: Zeichnen Sie eine Levelkarte


Wir brauchen eine Weltkarte. An dieser Stelle möchte ich nur die Datenstruktur bestimmen und eine Karte auf dem Bildschirm zeichnen. Es sollte ungefähr so ​​aussehen:



Die Änderungen können Sie hier sehen . Dort ist alles einfach: Ich habe die Karte in ein eindimensionales Array von Zeichen fest codiert, die Funktion zum Zeichnen eines Rechtecks ​​definiert und bin um die Karte herumgegangen, wobei ich jede Zelle gezeichnet habe.

Ich erinnere Sie daran, dass diese Schaltfläche den Code sofort startet:

Im gitpod öffnen



Stufe 3: Fügen Sie einen Spieler hinzu


Was brauchen wir, um einen Spieler auf die Karte zeichnen zu können? GPS-Koordinaten sind genug :)



Fügen Sie zwei Variablen x und y hinzu und zeichnen Sie den Player an der entsprechenden Stelle:



Die Änderungen können Sie hier sehen . Über Gitpod werde ich nicht mehr erinnern :)

Im gitpod öffnen



Stufe 4: auch bekannt als Virtual Rangefinder First Ray Trace


Zusätzlich zu den Koordinaten des Spielers wäre es schön zu wissen, in welche Richtung er schaut. Daher fügen wir eine weitere Variable player_a hinzu, die die Blickrichtung des Spielers angibt (den Winkel zwischen der Blickrichtung und der Abszissenachse):



Und jetzt möchte ich in der Lage sein, über den orangefarbenen Strahl zu gleiten. Wie geht das? Sehr einfach. Schauen wir uns ein grünes rechtwinkliges Dreieck an. Wir wissen, dass cos (player_a) = a / c und sin (player_a) = b / c.



Was passiert, wenn ich willkürlich den Wert von c (positiv) nehme und x = Spieler_x + c * cos (Spieler_a) und y = Spieler_y + c * sin (Spieler_a) zähle? Wir werden uns am violetten Punkt befinden; Durch Variieren des Parameters c von Null bis unendlich können wir diesen violetten Punkt entlang unseres orangefarbenen Strahls gleiten lassen, und c ist der Abstand von (x, y) zu (player_x, player_y)!

Das Herzstück unserer Grafik-Engine ist dieser Zyklus:

  float c = 0; for (; c<20; c+=.05) { float x = player_x + c*cos(player_a); float y = player_y + c*sin(player_a); if (map[int(x)+int(y)*map_w]!=' ') break; } 

Wir bewegen den Punkt (x, y) entlang des Strahls. Wenn er auf ein Hindernis auf der Karte stößt, beenden wir den Zyklus und die Variable c gibt den Abstand zum Hindernis an! Was ist kein Laser-Entfernungsmesser?



Die Änderungen können Sie hier sehen .

Im gitpod öffnen



Stufe 5: Sektorübersicht


Ein Strahl ist in Ordnung, aber unsere Augen sehen immer noch einen ganzen Sektor. Nennen wir den Betrachtungswinkel fov (Sichtfeld):



Und lassen Sie uns 512 Strahlen freisetzen (übrigens, warum 512?), Die den gesamten Betrachtungssektor reibungslos erfassen:


Die Änderungen können Sie hier sehen .

Im gitpod öffnen



Stufe 6: 3D!


Und jetzt der entscheidende Punkt. Für jede der 512 Strahlen haben wir die Entfernung zum nächsten Hindernis, richtig? Und jetzt machen wir ein zweites Bild mit einer Breite von 512 Pixel (Spoiler). in dem wir für jeden Strahl ein vertikales Segment zeichnen und die Höhe des Segments umgekehrt proportional zum Abstand zum Hindernis ist:



Auch dies ist der Schlüssel zum Erstellen der 3D-Illusion. Stellen Sie sicher, dass Sie verstehen, worum es geht. Beim Zeichnen vertikaler Segmente zeichnen wir einen Lattenzaun, bei dem die Höhe jedes Pfahls umso geringer ist, je weiter er von uns entfernt ist:



Die Änderungen können Sie hier sehen .

Im gitpod öffnen



Stufe 7: Erste Animation


Zu diesem Zeitpunkt zeichnen wir zum ersten Mal etwas Dynamisches (ich lege einfach 360 Bilder auf die Festplatte). Alles ist trivial: Ich ändere player_a, zeichne ein Bild, speichere, ändere player_a, zeichne, speichere. Um es ein bisschen lustiger zu machen, habe ich jedem Zelltyp in unserer Karte einen zufälligen Farbwert zugewiesen.


Die Änderungen können Sie hier sehen .

Im gitpod öffnen



Stufe 8: Fischaugenkorrektur


Haben Sie bemerkt, welchen großartigen Fischaugeneffekt wir erzielen, wenn wir eine Wand aus der Nähe betrachten? Es sieht so aus:



Warum? Ja, sehr einfach. Hier schauen wir uns die Wand an:



Um unsere Wand zu zeichnen, beleuchten wir unseren blauen Sichtbereich mit einem lila Strahl. Nehmen Sie den spezifischen Wert der Strahlrichtung wie in diesem Bild. Die Länge des orangefarbenen Segments ist deutlich geringer als die Länge des Purpurs. Da wir zur Bestimmung der Höhe jedes vertikalen Segments, das wir auf dem Bildschirm zeichnen, durch den Abstand zum Hindernis dividieren, ist das Fischauge ganz natürlich.

Um diese Verzerrung zu korrigieren, ist es überhaupt nicht schwierig, schauen Sie sich an, wie dies gemacht wird . Bitte stellen Sie sicher, dass Sie verstehen, woher der Kosinus stammt. Das Zeichnen eines Diagramms auf ein Blatt Papier hilft sehr.



Im gitpod öffnen



Schritt 9: Laden Sie die Texturdatei


Es ist Zeit, sich mit Texturen zu befassen. Ich bin faul, selbst einen Bild-Downloader zu schreiben, also habe ich die ausgezeichnete stb-Bibliothek genommen . Ich habe eine Datei mit Texturen für die Wände vorbereitet, alle Texturen sind quadratisch und horizontal im Bild gepackt:



Zu diesem Zeitpunkt lade ich einfach die Texturen in den Speicher. Um den geschriebenen Code zu testen, zeichne ich einfach, da es sich um eine Textur mit Index 5 in der oberen linken Ecke des Bildschirms handelt:


Die Änderungen können Sie hier sehen .

Im gitpod öffnen



Stufe 10: rudimentäre Verwendung von Texturen


Jetzt werfe ich zufällig erzeugte Farben aus und färbe meine Wände, indem ich das obere linke Pixel aus der entsprechenden Textur nehme:


Die Änderungen können Sie hier sehen .

Im gitpod öffnen



Stufe 11: Texturieren der Wände


Und jetzt ist der lang erwartete Moment gekommen, in dem wir endlich die Backsteinmauern sehen:



Die Grundidee ist sehr einfach: Hier gleiten wir entlang des aktuellen Strahls und halten am Punkt x, y an. Nehmen wir an, wir haben uns auf einer „horizontalen“ Wand niedergelassen, dann ist y fast eine ganze Zahl (nicht wirklich, weil unsere Art, sich entlang des Strahls zu bewegen, einen kleinen Fehler verursacht). Nehmen wir den Bruchteil von x und nennen ihn hitx. Der Bruchteil ist kleiner als eins. Wenn wir also hitx mit der Größe der Textur multiplizieren (ich habe 64), erhalten wir die Texturspalte, die an dieser Stelle gezeichnet werden muss. Es bleibt, um es auf die richtige Größe zu dehnen und das Ding ist im Hut:



Im Allgemeinen ist die Idee äußerst primitiv, erfordert jedoch eine sorgfältige Ausführung, da wir auch „vertikale“ Wände haben (solche mit Hitx nahe Null [x Ganzzahl]). Für sie wird die Texturspalte durch hity, den Bruchteil von y, bestimmt. Die Änderungen können Sie hier sehen .

Im gitpod öffnen



Stufe 12: Zeit zum Refactor!


Zu diesem Zeitpunkt habe ich nichts Neues gemacht, sondern nur mit der allgemeinen Reinigung begonnen. Bis jetzt hatte ich eine gigantische Datei (185 Zeilen!), Und es wurde schwierig, darin zu arbeiten. Daher habe ich es leider im Vorbeigehen in eine Wolke kleinerer Teile zerlegt und die Codegröße (319 Zeilen) fast verdoppelt, ohne zusätzliche Funktionen hinzuzufügen. Aber dann ist es viel bequemer geworden, zum Beispiel eine Animation zu generieren. Es reicht aus, eine solche Schleife zu erstellen:

  for (size_t frame=0; frame<360; frame++) { std::stringstream ss; ss << std::setfill('0') << std::setw(5) << frame << ".ppm"; player.a += 2*M_PI/360; render(fb, map, player, tex_walls); drop_ppm_image(ss.str(), fb.img, fb.w, fb.h); } 

Nun, hier ist das Ergebnis:


Die Änderungen können Sie hier sehen .

Im gitpod öffnen

Fortsetzung folgt ... sofort


In diesem optimistischen Sinne beende ich die aktuelle Hälfte meines Blattes, die zweite Hälfte finden Sie hier . Darin werden wir Monster hinzufügen und auf SDL2 verlinken, damit Sie einen Spaziergang durch unsere virtuelle Welt machen können.

Source: https://habr.com/ru/post/de439698/


All Articles