Wir erstellen ein tragbares Plattformspiel auf dem Cortex M0 + Mikrocontroller


Einführung


(Links zum Quellcode und zum KiCAD-Projekt finden Sie am Ende des Artikels.)

Obwohl wir in der 8-Bit-Ära geboren wurden, war unser erster Computer der Amiga 500. Dies ist eine großartige 16-Bit-Maschine mit erstaunlicher Grafik und Sound, die sich hervorragend für Spiele eignet. Platforming ist auf diesem Computer zu einem sehr beliebten Spielgenre geworden. Viele von ihnen waren sehr farbenfroh und hatten ein sehr sanftes Parallaxen-Scrollen. Möglich wurde dies durch talentierte Programmierer, die genial Amiga-Coprozessoren verwendeten, um die Anzahl der Bildschirmfarben zu erhöhen. Schauen Sie sich zum Beispiel LionHeart an!


Löwenherz auf Amiga. Dieses statische Bild vermittelt nicht die Schönheit der Grafiken.

Seit den 90er Jahren hat sich die Elektronik stark verändert, und jetzt gibt es viele kleine Mikrocontroller, mit denen Sie erstaunliche Dinge erstellen können.

Wir haben Plattformspiele schon immer geliebt, und heute können Sie für nur ein paar Dollar Raspberry Zero kaufen, Linux installieren und „ziemlich einfach“ einen farbenfrohen Plattformer schreiben.

Aber diese Aufgabe ist nichts für uns - wir wollen keine Spatzen aus einer Kanone schießen!

Wir wollen Mikrocontroller mit begrenztem Speicher verwenden und kein leistungsfähiges System auf einem Chip mit integrierter GPU! Mit anderen Worten, wir wollen Schwierigkeiten!

Übrigens zu den Möglichkeiten des Videos: Einige Leute schaffen es, in ihren Projekten alle Säfte aus dem AVR-Mikrocontroller herauszupressen (zum Beispiel im Uzebox- oder Craft-Projekt des lft-Entwicklers). Um dies zu erreichen, zwingen uns die AVR-Mikrocontroller, in Assembler zu schreiben, und obwohl einige Spiele sehr gut sind, werden Sie auf schwerwiegende Einschränkungen stoßen, die es Ihnen nicht erlauben, ein Spiel im 16-Bit-Stil zu erstellen.

Aus diesem Grund haben wir uns für einen ausgewogeneren Mikrocontroller / eine ausgewogenere Karte entschieden, mit der wir Code vollständig in C schreiben können.

Er ist nicht so mächtig wie Arduino Due, aber nicht so schwach wie Arduino Uno. Interessanterweise bedeutet "Fällig" "zwei" und "Uno" "eins". Microsoft hat uns beigebracht, richtig zu zählen (1, 2, 3, 95, 98, ME, 2000, XP, Vista, 7, 8, 10), und Arduino ist auch diesen Weg gegangen! Wir werden den Arduino Zero verwenden, der in der Mitte zwischen 1 und 2 liegt!

Ja, laut Arduino 1 <0 <2.

Insbesondere interessieren wir uns nicht für das Board selbst, sondern für seine Prozessorserie. Der Arduino Zero verfügt über einen Mikrocontroller der ATSAMD21-Serie mit Cortex M0 + (48 MHz), 256 KB Flash-Speicher und 32 KB RAM.

Obwohl der 48-MHz-Cortex M0 + den alten 7-MHz-MC68000 in seiner Leistung deutlich übertrifft, verfügte der Amiga 500 über 512 KB RAM, Hardware-Sprites, ein integriertes Dual-Game-Board und Blitter (eine DMA-basierte Bildblockübertragungs-Engine mit einem integrierten pixelgenauen Kollisionserkennungssystem). und Transparenz) und Kupfer (ein Raster-Coprozessor, mit dem Sie Operationen mit Registern basierend auf der Sweep-Position ausführen können, um viele sehr schöne Effekte zu erzielen). SAMD21 verfügt nicht über all diese Hardware (mit Ausnahme einer im Vergleich zu Blitter DMA recht einfachen), daher wird viel programmgesteuert gerendert.

Wir wollen folgende Parameter erreichen:

  • Auflösung 160 x 128 Pixel auf einem 1,8-Zoll-SPI-Display.
  • Grafiken mit 16 Bit pro Pixel;
  • Die höchste Bildrate. Mindestens 25 fps bei 12 MHz SPI oder 40 fps bei 24 MHz;
  • doppeltes Spielfeld mit Parallaxen-Scrolling;
  • alles ist in C geschrieben. Kein Assembler-Code;
  • Pixelgenaue Erkennung von Kollisionen;
  • Bildschirmüberlagerung.

Es scheint ziemlich schwierig zu sein, diese Ziele zu erreichen. Es ist, besonders wenn wir den Code auf asm ablehnen!

Bei 16-Bit-Farben erfordert eine Bildschirmgröße von 160 × 128 Pixel beispielsweise 40 KB für den Bildschirmpuffer, aber wir haben nur 32 KB RAM! Und wir brauchen immer noch Parallaxen-Scrollen auf einem doppelten Spielfeld und vielem mehr mit einer Frequenz von mindestens 25/40 fps!

Aber nichts ist für uns unmöglich, oder?

Wir verwenden Tricks und integrierte Funktionen von ATSAMD21! Als "Hardware" nehmen wir uChip , das im Itaca Store erhältlich ist .


uChip: das Herzstück unseres Projekts!

Es hat die gleichen Eigenschaften wie das Arduino Zero, ist aber viel kleiner und auch billiger als das ursprüngliche Arduino Zero (ja, Sie können ein gefälschtes Arduino Zero für 10 USD bei AliExpress kaufen ... aber wir möchten auf dem Original aufbauen). Auf diese Weise können wir eine kleine tragbare Konsole erstellen. Sie können dieses Projekt fast mühelos für Arduino Zero anpassen, nur das Ergebnis ist ziemlich umständlich.

Wir haben auch ein kleines Testboard erstellt, das eine tragbare Konsole für die Armen implementiert. Details unten!


Wir werden das Arduino-Framework nicht verwenden. Es ist nicht gut geeignet, um Geräte zu optimieren und zu verwalten. (Und reden wir nicht über die IDE!)

In diesem Artikel werden wir beschreiben, wie wir zur endgültigen Version des Spiels gekommen sind, und alle verwendeten Optimierungen und Kriterien beschreiben. Das Spiel selbst ist noch nicht vollständig, es fehlen Sound, Level usw. Es kann jedoch als Ausgangspunkt für viele verschiedene Arten von Spielen verwendet werden!

Darüber hinaus gibt es auch ohne Assembler noch viele weitere Optimierungsmöglichkeiten!

Also, lasst uns unsere Reise beginnen!

Schwierigkeiten


Tatsächlich hat das Projekt zwei komplexe Aspekte: Timings und Speicher (sowohl RAM als auch Speicher).

Die Erinnerung


Beginnen wir mit der Erinnerung. Anstatt ein großes Bild zu speichern, verwenden wir zunächst Kacheln. Wenn Sie die meisten Plattformer sorgfältig analysieren, werden Sie feststellen, dass sie aus einer kleinen Anzahl grafischer Elemente (Kacheln) erstellt werden, die viele Male wiederholt werden.


Turrican 2 auf Amiga. Eines der besten Plattformspiele aller Zeiten. Sie können die Kacheln darin leicht sehen!

Die Welt / Ebene scheint dank verschiedener Kombinationen von Kacheln vielfältig. Dies spart viel Speicher auf dem Laufwerk, löst jedoch nicht das Problem eines großen Bildpuffers.

Der zweite Trick, den wir verwenden, ist aufgrund der relativ großen Rechenleistung von uC und des Vorhandenseins von DMA möglich! Anstatt alle Rahmendaten im RAM zu speichern (und warum wird dies benötigt?), Erstellen wir in jedem Bild eine Szene von Grund auf neu. Insbesondere werden wir weiterhin Puffer verwenden, die jedoch so in einen horizontalen Block von Datengrafiken mit einer Höhe von 16 Pixeln passen.

Timings - CPU


Wenn ein Ingenieur etwas erstellen muss, prüft er zunächst, ob dies möglich ist. Natürlich haben wir diesen Test gleich zu Beginn durchgeführt!

Wir benötigen also mindestens 25 fps auf einem Bildschirm mit 160 × 128 Pixel. Das sind 512.000 Pixel / s. Da der Mikrocontroller mit einer Frequenz von 48 MHz arbeitet, haben wir mindestens 93 Taktzyklen pro Pixel. Dieser Wert sinkt auf 58 Zyklen, wenn wir 40 fps anstreben.

Tatsächlich kann unser Mikrocontroller bis zu 2 Pixel gleichzeitig verarbeiten, da jedes Pixel 16 Bit benötigt und der ATSAMD21 über einen internen 32-Bit-Bus verfügt, dh die Leistung ist noch besser!

Ein Wert von 93 Taktzyklen sagt uns, dass die Aufgabe vollständig machbar ist! Tatsächlich können wir daraus schließen, dass die CPU allein alle Rendering-Aufgaben ohne DMA ausführen kann. Dies trifft höchstwahrscheinlich zu, insbesondere wenn Sie mit Assembler arbeiten. Der Code wird jedoch sehr schwer zu handhaben sein. Und in C muss es sehr optimiert werden! Tatsächlich ist Cortex M0 + nicht so C-freundlich wie Cortex M3 und es fehlen viele Anweisungen (es wird nicht einmal mit nachfolgenden / Vorinkrementen / Dekrementen geladen / gespeichert!), Die mit zwei oder mehr einfachen Anweisungen implementiert werden müssen.

Mal sehen, was wir tun müssen, um zwei Spielfelder zu zeichnen (vorausgesetzt, wir kennen bereits die x- und y-Koordinaten usw.).

  • Berechnen Sie die Position des Vordergrundpixels im Flash-Speicher.
  • Holen Sie sich den Pixelwert.
  • Wenn es transparent ist, berechnen Sie die Position des Hintergrundpixels im Blitz.
  • Holen Sie sich den Pixelwert.
  • Berechnen Sie den Zielort.
  • Pixel im Puffer speichern.

Darüber hinaus sollten für jedes Sprite, das in den Puffer gelangen kann, die folgenden Operationen ausgeführt werden:

  • Berechnen Sie die Position eines Sprite-Pixels im Flash-Speicher.
  • Abrufen des Pixelwerts.
  • Wenn es nicht transparent ist, berechnen Sie den Speicherort des Zielpuffers.
  • Speichern eines Pixels im Puffer.

Alle diese Operationen werden nicht nur nicht als einzelner ASM-Befehl implementiert, sondern jeder ASM-Befehl erfordert zwei Zyklen beim Zugriff auf RAM / Flash-Speicher.

Darüber hinaus verfügen wir noch nicht über eine Gameplay-Logik (die glücklicherweise etwas Zeit in Anspruch nimmt, da sie einmal pro Frame berechnet wird), Kollisionserkennung, Pufferverarbeitung und Anweisungen, die zum Senden von Daten über SPI erforderlich sind.

Hier ist zum Beispiel der Pseudocode dessen, was wir tun müssen (im Moment gehen wir davon aus, dass das Spiel nicht gescrollt wird und das Spielfeld einen konstanten Farbhintergrund hat!) Nur für den Vordergrund.

Lassen Sie cameraY und cameraX die Koordinaten der oberen linken Ecke des Displays in der Spielwelt sein.

XTilepos und yTilepos seien die Position der aktuellen Kachel auf der Karte.

xTilepos = cameraX / 16; // this is a rightward shift of 4 bits. yTilepos = cameraY / 16; destBufferAddress = &buffer[0][0]; for tile = 0...9 nTile = gameMap[yTilepos][xTilepos]; tileDataAddress = &tileData[nTile]; xTilepos = xTilepos + 1; for y = 0…15 for x = 0…15 pixel = *tileDataAddress; tileDataAddress = tileDataAddress + 1; *destBufferAddress = pixel; destBufferAddress = destBufferAddress + 1; next destBufferAddress = destBufferAddress + 144; // point to next row next destBufferAddress = destBufferAddress – ( 160 * 16 - 16); // now point to the position where the next tile will be saved. next 

Die Anzahl von Befehlen für 2560 Pixel (160 × 16) beträgt ungefähr 16 KB, d.h. 6 pro Pixel. Tatsächlich können Sie zwei Pixel gleichzeitig zeichnen. Dies halbiert die tatsächliche Anzahl von Befehlen pro Pixel, dh die Anzahl von Befehlen auf hoher Ebene pro Pixel beträgt ungefähr 3. Einige dieser Befehle auf hoher Ebene werden jedoch entweder in zwei oder mehr Assembler-Befehle unterteilt oder erfordern mindestens zwei Zyklen, um abgeschlossen zu werden, da sie zugreifen in die Erinnerung. Außerdem haben wir nicht in Betracht gezogen, die CPU-Pipeline aufgrund von Sprüngen und Wartezuständen für den Flash-Speicher zurückzusetzen. Ja, wir sind noch weit von den 58-93 Zyklen entfernt, aber wir müssen immer noch den Hintergrund des Spielfelds und der Sprites berücksichtigen.

Obwohl wir sehen, dass das Problem auf einer CPU gelöst werden kann, wird DMA viel schneller sein. Der direkte Speicherzugriff lässt noch mehr Platz für Bildschirm-Sprites oder bessere Grafikeffekte (zum Beispiel können wir Alpha-Blending implementieren).

Wir werden sehen, dass wir zum Konfigurieren des DMA für jede Kachel weniger als 100 C-Anweisungen benötigen, d. H. Weniger als 0,5 pro Pixel! Natürlich muss DMA immer noch die gleiche Anzahl von Übertragungen im Speicher ausführen, aber das Adressinkrement und die Übertragung werden ohne Eingreifen der CPU ausgeführt, die etwas anderes tun kann (z. B. Berechnen und Rendern von Sprites).

Unter Verwendung des SysTick-Timers haben wir herausgefunden, dass die Zeit, die erforderlich ist, um den DMA für den gesamten Block vorzubereiten und dann den DMA abzuschließen, ungefähr 12.000 Taktzyklen beträgt. Hinweis: Taktzyklen! Keine hochrangigen Anweisungen! Die Anzahl von Zyklen ist für nur 2560 Pixel ziemlich hoch, d.h. 1.280 32-Bit-Wörter. Tatsächlich erhalten wir ungefähr 10 Zyklen pro 32-Bit-Wort. Sie müssen jedoch die Zeit berücksichtigen, die zur Vorbereitung des DMA erforderlich ist, sowie die Zeit, die der DMA benötigt, um Übertragungsdeskriptoren aus dem RAM zu laden (die im Wesentlichen Zeiger und die Anzahl der übertragenen Bytes enthalten). Darüber hinaus gibt es immer eine Art Speicherbuswechsel (damit die CPU ohne Daten nicht im Leerlauf steht), und der Flash-Speicher erfordert mindestens einen Wartezustand.

Timings - SPI


Ein weiterer Engpass ist SPI. Reichen 12 MHz für 25 fps aus? Die Antwort lautet ja: 12 MHz entsprechen etwa 36 Bildern pro Sekunde. Wenn wir 24 MHz verwenden, wird sich das Limit verdoppeln!

Die technischen Daten des Displays und des Mikrocontrollers besagen übrigens, dass die maximale SPI-Geschwindigkeit 15 bzw. 12 MHz beträgt. Wir haben getestet und sichergestellt, dass es problemlos auf 24 MHz erhöht werden kann, zumindest in der von uns benötigten „Richtung“ (der Mikrocontroller schreibt auf das Display).

Wir werden das beliebte 1,8-Zoll-SPI-Display verwenden. Wir haben sichergestellt, dass sowohl ILI9163 als auch ST7735 normal mit einer Frequenz von 12 MHz arbeiten (mindestens mit 12 MHz. Es wird überprüft, dass der ST7735 mit einer Frequenz von bis zu 24 MHz arbeitet). Wenn Sie dieselbe Anzeige wie im Lernprogramm „Abspielen von Videos auf Arduino Uno“ verwenden möchten, empfehlen wir, diese zu ändern, falls Sie in Zukunft SD-Unterstützung hinzufügen möchten. Wir verwenden die SD-Kartenversion, damit wir viel Platz für andere Elemente wie Sound oder zusätzliche Pegel haben.

Grafik


Wie bereits erwähnt, verwendet das Spiel Kacheln. Jedes Level besteht aus Kacheln, die sich gemäß der Tabelle wiederholen, die wir "gameMap" genannt haben. Wie groß wird jede Fliese sein? Die Größe jeder Kachel wirkt sich stark auf den Speicherverbrauch, die Details und die Flexibilität aus (und, wie wir später sehen werden, auch auf die Geschwindigkeit). Zu große Kacheln erfordern die Erstellung einer neuen Kachel für jede kleine Variation, die wir benötigen. Dies nimmt viel Platz auf dem Laufwerk ein.


Zwei Kacheln mit einer Größe von 32 × 32 Pixel (links und in der Mitte), die sich in einem kleinen Teil unterscheiden (der obere rechte Teil des Pixels ist 16 × 16). Daher müssen wir zwei verschiedene Kacheln mit einer Größe von 32 × 32 Pixel speichern. Wenn wir eine 16 × 16-Pixel-Kachel (rechts) verwenden, müssen wir nur zwei 16 × 16-Kacheln speichern (eine vollständig weiße Kachel und eine Kachel rechts). Bei Verwendung von 16 × 16-Kacheln erhalten wir jedoch 4 Kartenelemente.

Es sind jedoch weniger Kacheln pro Bildschirm erforderlich, was die Geschwindigkeit erhöht (siehe unten) und die Größe der Karte (d. H. Die Anzahl der Zeilen und Spalten in der Tabelle) jeder Ebene verringert. Zu kleine Kacheln verursachen das gegenteilige Problem. Kartentabellen werden größer und die Geschwindigkeit wird langsamer. Natürlich werden wir keine dummen Entscheidungen treffen. Wählen Sie beispielsweise Kacheln mit einer Größe von 17 × 31 Pixel aus. Unser treuer Freund - Grad zwei! Die Größe 16 × 16 ist fast die „goldene Regel“, sie wird in vielen Spielen verwendet und wir werden sie wählen!

Unser Bildschirm hat eine Größe von 160 × 128. Mit anderen Worten, wir benötigen 10 × 8 Kacheln pro Bildschirm, d.h. 80 Einträge in der Tabelle. Für eine große Ebene von 10 × 10-Bildschirmen (oder 100 × 1-Bildschirmen) sind nur 8.000 Datensätze erforderlich (16 KB, wenn wir 16 Bit für die Aufzeichnung verwenden. Später werden wir zeigen, warum wir uns für die Aufnahme von 16 Bit entschieden haben).

Vergleichen Sie dies mit der Speicherkapazität, die wahrscheinlich von einem großen Bild auf dem gesamten Bildschirm belegt wird: 40 KB * 100 = 4 MB! Das ist verrückt!

Lassen Sie uns über das Rendering-System sprechen.

Jeder Rahmen sollte enthalten (in Zeichnungsreihenfolge):

  • Hintergrundgrafiken (Rückspielfeld)
  • das Level-Diagramm selbst (Vordergrund).
  • Sprites
  • Text / Top-Overlay.

Insbesondere werden wir nacheinander die folgenden Operationen ausführen:

  1. Zeichnungshintergrund + Vordergrund (Kacheln)
  2. Zeichnen von durchscheinenden Kacheln + Sprites + Overlay
  3. Senden von Daten per SPI.

Hintergrund und vollständig undurchsichtige Kacheln werden von DMA gezeichnet. Eine vollständig undurchsichtige Kachel ist eine Kachel, in der keine transparenten Pixel vorhanden sind.


Teilweise transparente Fliese (links) und vollständig undurchsichtig (rechts). In einer teilweise transparenten Kachel sind einige Pixel (unten links) transparent, und daher ist durch diesen Bereich ein Hintergrund sichtbar.

Teiltransparente Kacheln, Sprites und Overlays können von DMA nicht effektiv gerendert werden. Tatsächlich kopiert das ATSAMD21-Chip-DMA-System einfach die Daten und prüft im Gegensatz zum Blitter des Amiga-Computers nicht die Transparenz (festgelegt durch den Farbwert). Alle teilweise transparenten Elemente werden von der CPU gezeichnet.


Die Daten werden dann mit DMA an das Display übertragen.

Pipeline erstellen


Wie Sie sehen, wird es viel Zeit in Anspruch nehmen, wenn wir diese Operationen nacheinander in einem Puffer ausführen. Während DMA ausgeführt wird, ist die CPU nur besetzt, wenn auf den Abschluss des DMA gewartet wird! Dies ist ein schlechter Weg, um eine Grafik-Engine zu implementieren. Wenn DMA Daten an ein SPI-Gerät sendet, wird außerdem nicht die gesamte Bandbreite genutzt. Selbst wenn SPI mit einer Frequenz von 24 MHz arbeitet, werden Daten nur mit einer Frequenz von 3 MHz übertragen, was ziemlich klein ist. Mit anderen Worten, DMA ist nicht voll ausgeschöpft: DMA kann andere Aufgaben ausführen, ohne wirklich an Leistung zu verlieren.

Aus diesem Grund haben wir die Pipeline implementiert, die die Idee der doppelten Pufferung entwickelt (wir verwenden drei Puffer!). Am Ende werden Operationen natürlich immer nacheinander ausgeführt. CPU und DMA führen jedoch gleichzeitig unterschiedliche Aufgaben aus, ohne sich (insbesondere) gegenseitig zu beeinflussen.

Folgendes passiert gleichzeitig:

  • Der Puffer wird verwendet, um Hintergrunddaten unter Verwendung des DMA 1-Kanals zu zeichnen;
  • In einem anderen Puffer (der zuvor mit Hintergrunddaten gefüllt war) zeichnet die CPU Sprites und teilweise transparente Kacheln.
  • Dann wird ein anderer Puffer (der einen vollständigen horizontalen Datenblock enthält) verwendet, um Daten über SPI über den DMA-Kanal 0 an die Anzeige zu senden. Natürlich war der Puffer, der zum Senden von Daten über SPI verwendet wurde, zuvor mit Sprites gefüllt, während der SPI den vorherigen Block und während ein anderer Puffer sendete mit Fliesen gefüllt.



DMA


Das ATSAMD21-Chip-DMA-System ist nicht mit Blitter vergleichbar, verfügt jedoch über eigene nützliche Funktionen. Dank DMA können wir trotz doppelter Wettbewerbsbedingungen eine sehr hohe Bildwiederholfrequenz erzielen.

Die Konfiguration der DMA-Übertragung wird im RAM in „DMA-Deskriptoren“ gespeichert und teilt dem DMA mit, wie und wo die aktuelle Übertragung durchgeführt werden soll. Diese Deskriptoren können miteinander verbunden werden: Wenn eine Verbindung besteht (d. H. Es gibt keinen Nullzeiger), erhält der DMA nach Abschluss der Übertragung automatisch den nächsten Deskriptor. Durch die Verwendung mehrerer Deskriptoren kann DMA "komplexe Übertragungen" durchführen, die nützlich sind, wenn beispielsweise der Quellpuffer eine Folge nicht zusammenhängender Segmente zusammenhängender Bytes ist. Das Abrufen und Schreiben von Deskriptoren dauert jedoch einige Zeit, da Sie 16 Byte Deskriptor aus dem RAM speichern / laden müssen.

DMA kann mit Daten unterschiedlicher Länge arbeiten: Bytes, Halbwörter (16 Bit) und Wörter (32 Bit). In der Spezifikation wird diese Länge als "Schlaggröße" bezeichnet. Für SPI sind wir gezwungen, die Byteübertragung zu verwenden (obwohl die aktuelle REVD-Spezifikation besagt, dass die ATSAMD21-SERCOM-Chips über FIFO verfügen, das laut Microchip 32-Bit-Daten akzeptieren kann. Tatsächlich scheint es, dass sie kein FIFO haben. In der REVD-Spezifikation wird ebenfalls erwähnt SERCOM CTRLC-Register, das sowohl in den Header-Dateien als auch im Abschnitt zur Registerbeschreibung fehlt. Glücklicherweise verfügt ATSAMD21 im Gegensatz zu AVR zumindest über ein gepuffertes Übertragungsdatenregister, sodass keine Übertragungspausen auftreten!). Zum Zeichnen von Kacheln verwenden wir natürlich 32 Bit. Auf diese Weise können Sie zwei Pixel pro Takt kopieren. Der ATSAMD21-DMA-Chip ermöglicht es auch jedem Quellschlag, die Quell- oder Zieladresse um eine feste Anzahl von Schlaggrößen zu erhöhen.

Diese beiden Aspekte sind sehr wichtig und bestimmen die Art und Weise, wie wir Kacheln zeichnen.

Erstens würden wir den Durchsatz unseres Systems halbieren, wenn wir ein Pixel pro Takt (16 Bit) rendern würden. Wir können nicht die volle Bandbreite ablehnen!

Wenn wir jedoch zwei Pixel pro Schlag zeichnen, kann das Spielfeld nur eine gerade Anzahl von Pixeln scrollen, was zu einer gleichmäßigen Bewegung führt. Um dies zu handhaben, können Sie einen Puffer verwenden, der zwei oder mehr Pixel größer ist. Beim Senden von Daten an das Display verwenden wir den richtigen Versatz (0 oder 1 Pixel), je nachdem, ob wir die „Kamera“ um eine gerade oder ungerade Anzahl von Pixeln bewegen müssen.

Der Einfachheit halber reservieren wir jedoch Platz für 11 vollständige Kacheln (160 + 16 Pixel) und nicht für 160 + 2 Pixel. Dieser Ansatz hat einen großen Vorteil: Wir müssen nicht die Empfängeradresse jedes DMA-Deskriptors berechnen und aktualisieren (dies würde mehrere Anweisungen erfordern, was zu zu vielen Berechnungen pro Kachel führen könnte). Natürlich werden wir nur die minimale Anzahl von Pixeln zeichnen, dh nicht mehr als 162. Ja, am Ende werden wir aus Gründen der Geschwindigkeit und Einfachheit ein wenig zusätzlichen Speicher (unter Berücksichtigung von drei Puffern, dies sind ungefähr 1500 Bytes) ausgeben. Sie können auch weitere Optimierungen durchführen.


Alle 16-Zeilen-Blockpuffer (ohne Deskriptoren) sind in dieser GIF-Animation sichtbar. Rechts wird angezeigt, was tatsächlich angezeigt wird. Die ersten 32 Bilder werden in GIF angezeigt, wobei wir in jedem Bild 1 Pixel nach rechts verschieben. Der schwarze Bereich des Puffers ist der Teil, der nicht aktualisiert wird, und sein Inhalt bleibt einfach von früheren Operationen erhalten. Wenn der Bildschirm eine ungerade Anzahl von Frames scrollt, wird ein 162 Pixel breiter Bereich in den Puffer gezeichnet. Die erste und letzte Spalte (die in der Animation hervorgehoben sind) werden jedoch verworfen. Wenn der Bildlaufwert ein Vielfaches von 16 Pixeln ist, beginnen die Zeichenoperationen im Puffer in der ersten Spalte (x = 0).

Was ist mit vertikalem Scrollen?

Wir werden uns damit befassen, nachdem wir eine Methode zum Speichern von Kacheln im Flash-Speicher gezeigt haben.

So lagern Sie Fliesen


Ein naiver Ansatz (der zu uns passen würde, wenn wir nur über die CPU rendern würden) wäre, die Kacheln als Folge von Pixelfarben im Flash-Speicher zu speichern. Das erste Pixel der ersten Zeile, das zweite usw. bis zum sechzehnten. Dann speichern wir das erste Pixel der zweiten Zeile, das zweite und so weiter.

Warum ist eine solche Entscheidung naiv? Denn in diesem Fall kann DMA nur 16 Pixel pro DMA-Deskriptor rendern! Daher benötigen wir 16 Deskriptoren, von denen jeder 4 + 4 Speicherzugriffsoperationen benötigt (dh um 32 Bytes zu übertragen - 8 Speicherleseoperationen + 8 Speicherschreiboperationen - DMA muss 4 weitere Lesevorgänge + 4 Schreibvorgänge ausführen). Das ist ziemlich ineffizient!

Tatsächlich kann DMA für jeden Deskriptor die Quell- und Zieladressen nur um eine feste Anzahl von Wörtern erhöhen. Nach dem Kopieren der ersten Zeile der Kachel in den Puffer sollte die Empfängeradresse nicht um 1 Wort erhöht werden, sondern um einen Wert, der auf die nächste Zeile des Puffers zeigt. Dies ist nicht möglich, da jeder Übertragungsdeskriptor nur das Schwebungsübertragungsinkrement angibt, das nicht geändert werden kann.

Es ist viel schlauer, die ersten beiden Pixel jeder Zeile der Kachel nacheinander zu senden, dh die Pixel 0 und 1 der Zeile 0, die Pixel 0 und 1 der Zeile 1 usw. bis zu den Pixeln 0 und 1 der Zeile 15. Dann senden wir die Pixel 2 und 3 der Zeile 0 und so weiter.


Wie wird eine Fliese gelagert?

In der obigen Abbildung bezeichnet jede Zahl die Reihenfolge, in der das 16-Bit-Pixel im Kachelarray gespeichert ist.

Dies kann mit einem Deskriptor geschehen, aber wir brauchen zwei Dinge:

  • Kacheln sollten so gespeichert werden, dass beim Inkrementieren der Quelle um ein Wort immer auf die richtigen Pixelpositionen gezeigt wird. Mit anderen Worten, wenn (r, c) ein Pixel in Zeile r und Spalte c ist, müssen wir die Pixel (0,0) (0,1) (1,0) (1,1) (2,0) nacheinander speichern (2.1) ... (15.0) (15.1) (0.2) (0.3) (1.2) (1.3) ...
  • Der Puffer sollte 256 Pixel breit sein (nicht 160)

Das erste Ziel ist sehr einfach zu erreichen: Ändern Sie einfach die Reihenfolge der Daten. Dies kann beim Exportieren von Grafiken in Datei c erfolgen (siehe Abbildung oben).

Das zweite Problem kann gelöst werden, da Sie mit DMA die Empfängeradresse nach jedem Schlag um 512 Byte erhöhen können. Dies hat zwei Konsequenzen:

  • Wir können keine Daten mit einem einzigen Deskriptor über einen SPI-Block senden. Dies ist kein sehr ernstes Problem, da wir am Ende einen Deskriptor durch 160 Pixel lesen. Die Auswirkungen auf die Leistung sind minimal.
  • Der Block muss eine Größe von 256 * 2 * 16 Bytes = 8 KB haben, und es wird viel "unbenutzter Speicherplatz" darin sein.

Dieser Raum kann jedoch weiterhin beispielsweise für Deskriptoren verwendet werden.

Tatsächlich ist jeder Deskriptor 16 Byte groß. Wir benötigen mindestens 10 * 8 (und tatsächlich 11 * 8!) Deskriptoren für Kacheln und 16 Deskriptoren für SPI.

Deshalb ist die Geschwindigkeit umso höher, je mehr Kacheln vorhanden sind. Wenn wir beispielsweise eine 32 x 32-Kachel verwenden würden, würden wir weniger Deskriptoren pro Bildschirm benötigen (320 statt 640). Dies würde die Verschwendung von Ressourcen reduzieren.

Datenblock anzeigen


Der Blockpuffer, die Deskriptoren und andere Daten werden in einem Strukturtyp gespeichert, den wir displayBlock_t genannt haben.

displayBlock ist ein Array von 16 displayLineData_t-Elementen. DisplayLine-Daten enthalten 176 Pixel plus 80 Wörter. In diesen 80 Wörtern speichern wir Anzeigedeskriptoren oder andere nützliche Anzeigedaten (unter Verwendung von union).



Da wir 16 Zeilen haben, verwendet jede Kachel an Position X die ersten 8 DMA-Deskriptoren (0 bis 7) der X-Zeilen. Da wir maximal 11 Kacheln haben (die Anzeigelinie ist 176 Pixel breit), verwenden die Kacheln nur die ersten DMA-Deskriptoren 11 Datenzeilen. Die Deskriptoren 8–9 aller Zeilen und die Deskriptoren 0–9 der Zeilen 11–15 sind frei.

Von diesen werden die Deskriptoren 8 und 9 der Zeilen 0..7 für SPI verwendet.

Deskriptoren 0..9 Zeilen 11-15 (bis zu 50 Deskriptoren, obwohl wir nur 48 davon verwenden werden) werden für das Hintergrundspielfeld verwendet.

Die folgende Abbildung zeigt ihre Struktur.


Hintergrundspielfeld


Hintergrundspielfeld wird anders gehandhabt. Wenn wir einen reibungslosen Bildlauf benötigen, müssen wir zunächst zum Zwei-Pixel-Format zurückkehren, da Vordergrund und Hintergrund mit unterschiedlichen Geschwindigkeiten scrollen. Daher ist der Beat zur Hälfte abgeschlossen. Obwohl dies ein Nachteil in Bezug auf die Geschwindigkeit ist, erleichtert dieser Ansatz die Integration. Wir haben nur noch eine kleine Anzahl von Deskriptoren, daher können keine kleinen Kacheln verwendet werden. Um die Arbeit zu vereinfachen und schnell Parallaxe hinzuzufügen, werden wir außerdem lange „Sektoren“ verwenden.

Der Hintergrund wird nur gezeichnet, wenn mindestens ein teilweise transparentes Pixel vorhanden ist. Dies bedeutet, dass der Hintergrund gezeichnet wird, wenn nur eine transparente Kachel vorhanden ist. Dies ist natürlich eine Verschwendung von Bandbreite, aber es vereinfacht alles.

Vergleichen Sie die Hintergrund- und Frontspielfelder:

  • Im Hintergrund werden Sektoren verwendet, bei denen es sich um lange Kacheln handelt, die "naiv" gespeichert sind.
  • Der Hintergrund hat eine eigene Karte, die sich jedoch horizontal wiederholt. Dadurch wird weniger Speicher benötigt.
  • Hintergrund hat Parallaxe für jeden Sektor.

Frontspielfeld


Wie gesagt, in jedem Block haben wir bis zu 11 Kacheln (10 vollständige Kacheln oder 9 vollständige Kacheln und 2 Teildateien). Jede dieser Kacheln wird gezeichnet, wenn sie nicht als transparent markiert sind. Wenn es nicht vollständig undurchsichtig ist, wird es der Liste hinzugefügt, die später beim Rendern von Sprites analysiert wird.

Wir verbinden zwei Spielfelder miteinander


Die Deskriptoren des Hintergrundspielfelds (die immer berechnet werden) und des Frontspielfelds bilden eine sehr lange verknüpfte Liste. Der erste Teil zeichnet ein Hintergrundspielfeld. Der zweite Teil zeichnet Kacheln über den Hintergrund. Die Länge des zweiten Teils kann variabel sein, da die DMA-Deskriptoren von teilweise transparenten Kacheln von der Liste ausgeschlossen sind. Wenn der Block nur undurchsichtige Kacheln enthält, wird DMA wie folgt konfiguriert. direkt vom ersten Deskriptor der ersten Kachel zu beginnen.

Sprites und Fliesen mit Transparenz


Fliesen mit Transparenz und Sprites werden fast gleich verarbeitet.Eine Kachel- / Sprite-Pixelanalyse wird durchgeführt. Wenn es schwarz ist, ist es transparent und daher ändert sich die Hintergrundkachel nicht. Wenn es nicht schwarz ist, wird das Hintergrundpixel durch ein Sprite / Kachel-Pixel ersetzt.

Vertikales Scrollen


Beim Arbeiten mit horizontalem Bildlauf zeichnen wir bis zu 11 Kacheln, auch wenn beim Zeichnen von 11 Kacheln die ersten und letzten nur teilweise gezeichnet werden. Ein solches teilweises Rendern ist möglich, da jeder Deskriptor zwei Spalten der Kachel zeichnet, sodass wir den Anfang und das Ende der verknüpften Liste leicht festlegen können.

Wenn wir mit vertikalem Scrollen arbeiten, müssen wir sowohl das Empfängerregister als auch das Übertragungsvolumen berechnen. Sie müssen mehrmals pro Frame eingestellt werden. Um diese Aufregung zu vermeiden, können wir einfach bis zu 9 vollständige Blöcke pro Frame zeichnen (8, wenn das Scrollen ein Vielfaches von 16 ist).

Ausrüstung


Wie gesagt, das Herz des Systems ist uChip. Was ist mit dem Rest?

Hier ist ein Diagramm! Einige Aspekte davon sind erwähnenswert.


Schlüssel


Um die Verwendung von E / A zu optimieren, verwenden wir einen kleinen Trick. Wir werden 4 Sensorbusse L1-L4 und ein gemeinsames LC-Kabel haben. 1 und 0 werden abwechselnd an den gemeinsamen Draht angelegt. Dementsprechend werden die Sensorbusse abwechselnd mit Hilfe interner Pull-up-Widerstände nach unten oder oben gezogen. Zwischen jedem der Schlüsselbusse und einem gemeinsamen Bus sind zwei Schlüssel verbunden. Mit diesen beiden Tasten ist eine Diode in Reihe geschaltet. Jede dieser Dioden wird in die entgegengesetzte Richtung geschaltet, so dass jedes Mal nur eine Taste "gelesen" wird.

Da es keinen eingebauten Tastaturcontroller gibt (und kein eingebauter Tastaturcontroller diese interessante Methode verwendet), werden zu Beginn jedes Frames schnell acht Tasten abgefragt. Da die Eingänge nach oben und unten gezogen werden müssen, können (und wollen) wir keine externen Widerstände verwenden, daher müssen wir integrierte Widerstände verwenden, die einen ziemlich hohen Widerstand (60 kOhm) haben können. Dies bedeutet, dass Sie, wenn der gemeinsame Bus den Status ändert und die Datenbusse ihren Aufwärts- / Abwärts-Pull-Status ändern, eine gewisse Verzögerung einfügen müssen, damit der eingebaute Up / Down-Pull-Up-Widerstand den Vertrag ändert und die Streukapazität auf den gewünschten Wert einstellt. Aber wir wollen nicht warten! Daher versetzen wir den gemeinsamen Bus in einen hochohmigen Zustand (damit es keine Meinungsverschiedenheiten gibt) und ändern zuerst die Sensorbusse auf die logischen Werte 1 oder 0,vorübergehend als Ausgabe konfigurieren. Später werden sie durch Ziehen nach oben oder unten als Eingabe konfiguriert. Da der Ausgangswiderstand in der Größenordnung von zehn Ohm liegt, ändert sich der Zustand in wenigen Nanosekunden, dh wenn der Sensorbus zurück zum Eingang schaltet, befindet er sich bereits im gewünschten Zustand. Danach schaltet der gemeinsame Bus mit entgegengesetzter Polarität auf den Ausgang.

Dies verbessert die Scan-Geschwindigkeit erheblich und macht keine Verzögerungen / Anweisungen überflüssig.

SPI-Verbindung


Wir haben die SD und das Display so verbunden, dass sie miteinander kommunizieren, ohne Daten an den ATSAMD21 zu übertragen. Dies kann nützlich sein, wenn Sie das Video abspielen möchten.

Die Widerstände zwischen MISO und MOSI sollten niedrig sein. Wenn sie zu groß sind, funktioniert der SPI nicht, da das Signal zu schwach ist.

Optimierung und Weiterentwicklung


Eines der größten Probleme ist die Verwendung von RAM. Drei Blöcke belegen jeweils 8 KB, so dass nur 8 KB pro Stapel und andere Variablen übrig bleiben. Im Moment haben wir nur 1,3 KB freien RAM + 4 KB Stapel (4 KB pro Stapel - das ist eine Menge, vielleicht werden wir es reduzieren).

Sie können jedoch Blöcke mit einer Höhe von nicht 16, sondern 8 Pixel verwenden. Dies erhöht die Verschwendung von Ressourcen für DMA-Deskriptoren, halbiert jedoch fast die vom Blockpuffer belegte Speichermenge (beachten Sie, dass sich die Anzahl der Deskriptoren nicht ändert, wenn wir weiterhin 16 × 16-Kacheln verwenden, sodass wir die Struktur des Blocks ändern müssen). Dies kann ungefähr 7,5 KB RAM freigeben, was sehr nützlich ist, um Funktionen wie eine modifizierbare Karte mit Geheimnissen oder das Hinzufügen von Sound zu implementieren (obwohl Sound auch mit 1 KB RAM hinzugefügt werden kann).

Ein weiteres Problem ist das Sprite, aber diese Änderung ist viel einfacher durchzuführen und Sie benötigen nur die Funktion createNextFrameScene (). Tatsächlich erstellen wir im RAM ein riesiges Array mit dem Status aller Sprites. Anschließend berechnen wir für jedes Sprite, ob sich seine Position im Bereich des Bildschirms befindet, animieren es und fügen es der Rendering-Liste hinzu.

Stattdessen können Sie eine Optimierung durchführen. In gameMap können Sie beispielsweise nicht nur den Wert der Kachel speichern, sondern auch ein Flag, das die Transparenz der Kachel anzeigt, die im Editor festgelegt wurde. Auf diese Weise können wir schnell prüfen, ob die Kachel gerendert werden soll: DMA oder CPU. Deshalb haben wir 16-Bit-Datensätze für die Kachelkarte verwendet. Wenn wir davon ausgehen, dass wir einen Satz von 256 Kacheln haben (im Moment haben wir weniger als 128 Kacheln, aber im Flash-Speicher ist genügend Speicherplatz vorhanden, um neue hinzuzufügen), gibt es 7 freie Bits, die für andere Zwecke verwendet werden können. Drei dieser sieben Bits können verwendet werden, um anzuzeigen, ob ein Sprite / Objekt gespeichert wird. Zum Beispiel:

0b000 =
0b001 =
0b010 =
0b011 =
0b100 =
0b101 =
0b110 =
0b111 = , , .


Dann können Sie eine Bit-Tabelle im RAM erstellen, in der jedes Bit bedeutet, ob (zum Beispiel ein Feind) erkannt wird / ob (zum Beispiel ein Bonus) aufgenommen wird / ob ein bestimmtes Objekt aktiviert ist (Schalter). Bei einer Ebene von 10 × 10 Bildschirmen erfordert dies 8000 Bits, d.h. 1 KB RAM. Das Bit wird zurückgesetzt, wenn ein Feind erkannt oder ein Bonus abgeholt wird.

In createNextFrameScene () müssen wir die Bits überprüfen, die den Kacheln im aktuell sichtbaren Bereich entsprechen. Wenn sie einen Wert von 1 haben:

  • Wenn dies ein Bonus ist, fügen Sie ihn einfach der Liste der Sprites zum Rendern hinzu.
  • Wenn dies ein Feind ist, erstellen Sie ein dynamisches Sprite und setzen Sie die Flagge zurück. Im nächsten Frame enthält die Szene ein dynamisches Sprite, bis der Feind den Bildschirm verlässt oder getötet wird.

Dieser Ansatz hat Nachteile.

  1. -, ( ). .
  2. -, 80 , , . , 32 . , «/» ( «», .. 0!). «», «» ( ).
  3. -, . ( ), . , .
  4. -, , , , . , , . , , , , !
  5. , (, Unreal Tournament , ).

Auf diese Weise können wir Sprites jedoch viel effizienter speichern und verarbeiten.

Diese Technik ist jedoch für die "Spielelogik" relevanter als für die Grafik-Engine des Spiels.

Vielleicht werden wir diese Funktion in Zukunft implementieren.

Zusammenfassend


Wir hoffen, Ihnen hat dieser Einführungsartikel gefallen. Wir müssen noch viele weitere Aspekte erläutern, die in zukünftigen Artikeln behandelt werden.

In der Zwischenzeit können Sie den vollständigen Quellcode des Spiels herunterladen! Wenn es Ihnen gefällt, können Sie den Künstler ansimuz finanziell unterstützen , der alle Grafiken gezeichnet und der Welt kostenlos zur Verfügung gestellt hat. Wir akzeptieren auch Spenden .

Das Spiel ist noch nicht beendet. Wir möchten Sound, viele Ebenen, Objekte, mit denen Sie interagieren können, und dergleichen hinzufügen. Sie können Ihre eigenen Modifikationen erstellen! Wir hoffen, neue Spiele mit neuen Grafiken und Levels zu sehen!

Bald werden wir einen Karteneditor veröffentlichen, aber im Moment ist es zu rudimentär, um ihn der Community zu zeigen!

Video


(Hinweis: Aufgrund der schlechten Beleuchtung wurde das Video mit einer viel niedrigeren Bildrate aufgenommen! In Kürze werden wir das Video aktualisieren, damit Sie die volle Geschwindigkeit auf 40 fps schätzen können!)


Dankbarkeit


Die Grafiken des Spiels (und die auf einigen Bildern gezeigten Kacheln) stammen aus dem kostenlosen Asset „Sunny Land“, das von ansimuz erstellt wurde .

Herunterladbare Materialien


Der Quellcode des Projekts ist gemeinfrei, dh er wird kostenlos zur Verfügung gestellt. Wir teilen es in der Hoffnung, dass es jemandem nützlich sein wird. Wir garantieren nicht, dass es aufgrund eines Fehlers im Code keine Probleme gibt!

Schematische Darstellung

KiCad-

Projekt Atmel Studio 7-Projekt (Quelle)

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


All Articles