Sprites mit FPGAs

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche

von Benutzer:Fpgazumspass (Robert Peip)

in diesem Artikel werden verschiedene Möglichkeiten vorstellt um Sprites mit FPGAs auf dem Bildschirm darzustellen. Dazu werden bekannte Techniken erklärt und dies anhand von Beispielimplementierungen in VHDL verdeutlicht. Grundlegende Kenntnisse in den Bereichen FPGA, VHDL und pixelbasierten Bildern sind von Vorteil.

Was sind Sprites?

Sprites sind Bilder, welche auf dem Bildschirm über dem Hintergrund dargestellt werden. Diese können unabhängig voneinander bewegt und je nach Technologie auch gespiegelt, skaliert oder gedreht werden, ohne das dies vom Hauptprozessor des Systems berechnet werden muss.

Heutige PC Prozessoren sind leistungsfähig genug um diese Aufgabe erledigen zu können. Dies gilt zumindest für grundlegende Implementierungen. Im Embedded Bereich ist dies aber nicht ohne weiteres möglich und hängt stark von den Anforderungen ab.

Einfügen von Sprites

Einfügen eines Sprites

Ablauf in Software

  • Löschen der alten Sprites unter Wiederherstellung des Hintergrundes
  • Kopieren aller Sprite-Bilder aus dem Speicher auf das Anzeigebild
  • Anzeige auf dem Bildschirm

Ablauf in Hardware

  • Für jeden Pixel oder jede Zeile im Bild wird ermittelt ob eines der Sprites angezeigt werden muss
  • Wenn ja, ermitteln des entsprechenden Pixel im Originalbild des Sprites und direkte Anzeige

Warum Sprites im FPGA?

Rechenleistung

Während die Festlegung von Bildschirminhalten häufig von einem Prozessor durchgeführt wird, ist das Ausführen der Darstellung, das eigentliche "Malen" für einen typischen Prozessor in einem FPGA sehr mühselig. Dies gilt sowohl für FPGA-Softcores als auch abhängig von den Anforderungen für die neueren ARM CPUs in z.b. ZYNQ FPGAs. Die Darstellung von einer Reihe von Sprites ist jedoch eine Aufgabe die gut parallelisierbar ist und deshalb gut von FPGA-Logik erledigt werden kann.

Speicher

Ein komplettes Bild in Bildschirmauflösung (Framebuffer) im Speicher zu halten kann je nach Auflösung und Farbtiefe recht viel Speicher verbrauchen. Selbst mit aktuellen FPGAs macht das einen sehr großen Teil des internen Block-RAMs aus. Alternativ kann externer RAM verwendet werden. Hier ist die Speicherbandbreite entscheidend.

Beispiel:

  • Full-HD (1920*1080)
  • 16 Bit Farbtiefe
  • 60 Hz

Hierfür wären alleine 237 MByte/s an Bandbreite nötig. Das bezieht sich auf das reine Auslesen und Darstellen. Weitere Bandbreite Bedarf das Auslesen, Modifizieren und Zurückschreiben der Daten zum Einfügen des Sprites, sowie zusätzlich ein Löschen der alten Sprites. So erreicht man in diesem Beispiel recht schnell eine notwendige Bandbreite von 1 Gbyte/s. Ohne breit angebundes DDR-RAM ist das aussichtslos.

Im Falle einer Berechnung der Sprites on-the-fly im FPGA entfällt diese Notwendigkeit fast komplett. Benötigt wird dann lediglich ein Vergleichsweise kleiner Bereich an Block-RAM um die Originalbilder der Sprites abzulegen.

Die Größe des Speichers und dessen Bandbreite ist wohl auch der Hauptgrund, warum praktisch alle gängigen Konsolen und Heimcomputer der 70er bis 2000er Jahre für 2D Spiele eine, teils sehr aufwändige, Sprite Engine in Hardware verbaut hatten.

Detailierter Ablauf im FPGA

Im FPGA gibt es eine Reihe von Möglichkeiten eine Sprite in ein Bild einzufügen. Gemeinsam haben alle Möglichkeiten jedoch des Prinzip der Ausgabe des aktuellen Pixels:

  • Die Grafikausgabe selbst erfolgt Zeilenweise und Pixelweise, z.b. Farbe für Koordinate {x=50, y=100}
  • Die Koordinate wird der Sprite Engine mitgeteilt und damit ein entsprechendes Farbpixel angefordert
  • Man kann davon ausgehen, dass ein kontinuierlicher Anstieg der Position stattfinden und kein wahlfreier Zugriff

Einfachste Implementierung

Im einfachsten Fall gibt es genau ein Sprite mit einem Bild im ROM und fester Position des Sprites. Hier ein Beispielcode:

entity sprite is 
   port (
      clk     : in  std_logic;
      x       : in  integer range -1023 to 2047 := 0;
      y       : in  integer range -1023 to 2047 := 0;
      Color   : out std_logic_vector(7 downto 0);
      active  : out std_logic := 0
   );
end sprite;

architecture arch of sprite is
  
   constant size : integer := 16;
   constant posx : integer := 50;
   constant posy : integer := 100;

   type t_image is array (0 to size-1, 0 to size-1) of std_logic_vector(7 downto 0);
   signal image : t_image := (....);
   
begin

   process (clk)
   begin
      if rising_edge(clk) then
         
         if (x >= posx and x < (posx + size) and y >= posy and y < (posy + size)) then
            Color  <= image(x - posx, y - posy);
            active <= '1';
         else
            active <= '0';
         end if;
         
      end if;
   end process;

end arch;

Hier wird geprüft ob das aktuelle Pixel {x,y} sich im Bildbereich des Sprites {posx..size,posy..size} befindet. Wenn ja, wird der Farbwert aus dem ROM gelesen und ausgegeben. Zusätzlich muss entschieden werden, ob dieses Sprite überhaupt gerade aktiv ist, wofür das active Flag gesetzt wird. Immer wenn dieses '1' ist muss die Grafikausgabe dann den Sprite Farbwert benutzen statt der Farbe des Hintergrundes.

Details zur Aktualisierung der Spriteposition sowie des Spritebildes selbst sind allgemeine FPGA Aufgaben und nicht Teil dieses Artikels.

Mehrere Sprites

Sollen mehrere Sprites angezeigt werden, so stellt sich die Frage ob die Berechnung welche Farbe gezeigt werden soll sequentiell oder parallel erfolgen soll. Grundsätzlich ist die sequentielle Variante leichter zu implementieren und benötigt viel weniger Ressourcen. Der Nachteil ist die geringe Pixelfüllrate. Deshalb eignet sie sich nur für vergleichsweise geringe Pixeltakte.

Beispiel: Gameboy mit 160x144 Pixel und 60Hz, also nur rund 1,3 Megapixel pro Sekunde. Für ein FPGA das mit 100 MHz rechnet ergibt sich somit nur ein Pixel alle ~76 Takte. Dies ist auch bei sequentieller Auswertung gut zu bewerkstelligen.

Sequentiell

Die Implementierung erfolgt hier als Statemachine, im einfachsten Fall mit Zuständen für die Spriteauswahl und für das eigentliche Malen. Gekürztes Beispiel:

when IDLE =>
   if (nextPixel = '1') then
      spriteNr <= 0;
      state    <= check;
      active   <= '0';
   end if;
      
when CHECK =>
   if (x >= posx(spriteNr) and x < (posx(spriteNr) + size)...) then
      state <= DRAW
   elsif (spriteNr < SPRITECOUNT - 1) then
      spriteNr <= spriteNr + 1;
   else
      state <= IDLE;
   end if;
   
when DRAW =>
   Color  <= image(x - posx(spriteNr), y - posy(spriteNr));
   active <= '1';
   state  <= CHECK;

Hier wird für jedes Pixel gesucht welches Sprite gerade aktiv Malen könnte. Wird eins der (SPRITECOUNT) Sprites aktiv, dann bekommt dieses Priorität und wird gemalt, falls nicht, so wird für dieses Pixel gar kein Spritepixel angezeigt. Die Priorität liegt hier beim niedrigen Index. Setzt man nun z.b. einen SPRITECOUNT=8 an, dann benötigt man in diesem einfachen Fall 10 Takte für 1 Pixel.

Parallel

Die Implementierung erfolgt hier als Pipeline, typischerweise um 1 Pixel pro Takt zu erreichen. Beispiel:

active_next <= '0';
for i in 0 to SPRITECOUNT - 1 loop
   if (x >= posx(i) and x < (posx(i) + size)...) then
      sprite_next   <= i;
      activate_next <= '1';
   end if;
end loop;

x_1 <= x;
y_1 <= y;
if (activate_next = '1') then
   Color  <= image(x_1 - posx(sprite_next), y_1 - posy(sprite_next));
   active <= '1';
else
   active <= '0';
end if;

Hier wird zunächst für alle Sprites parallel geprüft ob eines ausgewählt werden muss. Wenn ja, wird dieses im nächsten Takt mit verzögerter Position ausgegeben. Die Priorität ist in diesem einfachen Beispiel umgedreht, d.h. das Sprite mit dem höchsten Index hat Priorität. Spätestens an diesem Beispiel sollte kurz erwähnt werden, dass eine Sprite Engine durchaus mehrere Takte Latenz haben kann und deshalb intern ein paar Pixel vor der eigentlichen Grafikausgabe rechnen muss.

Anzahl Bildspeicher

In den bisherigen Beispielen haben alle Sprites das gleiche Bild ausgegeben. Typischerweise möchte man jedoch unterschiedliche Bilder ausgeben. Auch hierfür gibt es wieder verschiedene Methoden. Die fortgeschrittenen davon beziehen sich dabei hauptsächlich auf die parallele Berechnung.

Rohdaten in einem Speicher

Eine einfache Variante ist die Daten aller Sprite Rohbilder in einem Speicher zu halten. Man ermittelt dazu wie in den oberen Beispielen welches Sprite gemalt werden soll und hat pro Sprite eine Basisadresse abgelegt, welche die Position im Speicher angibt, ab welcher die Bilddaten liegen.

Vorteile:

  • einfache Implementierung
  • verschiedene Spritegrößen (z.b. 16x16 oder auch 64x64) sind einfach möglich.
  • geringer Hardwareaufwand

Nachteile:

  • Ermittlung wer gerade im Speicher lesen darf muss für jedes Pixel passieren -> sehr aufwändig für große Anzahl Sprites
  • nur ein Farbwert pro Takt

Letzteres ist ein ganz massiver Nachteil. Es bedeutet nämlich, das für jede Art von Transparenz mindestens ein weiterer Lesezugriff passieren muss. Das klappt aber nur wenn der Pixeltakt entsprechend geringer ist.

Unter Transparenz ist hier aber nicht nur ein durchscheinender Effekt gemeint, sondern auch unsichtbare Pixel, z.b. die Darstellung eines Balls in einem 32x32 Bild, wie man sich leicht vorstellen kann.

Rohdaten in unterschiedlichen Speichern

Stellt man für jede Zeicheneinheit einen eigenen Speicher zu Verfügung, so kann entfällt vor allem das Problem bei sich überlappenden, teilweise durchsichtigen Sprites. Jetzt können nämlich mehrere Farbwerte gleichzeitig geholt werden und bei durchsichtigen Pixeln die Farbwerte von anderen Zeicheneinheiten benutzt werden. Ein Fußball auf dem Fuß einem Spielers wäre damit darstellbar.

Vorteile:

  • Transparenz leicht möglich

Nachteile:

  • verschiedene Spritegrößen nur umständlich machbar
  • 2 Zeicheneinheiten, welche das gleiche Bild malen benötigen doppelten Speicher
  • Dementsprechend größerer Hardwarebedarf
  • Maximale Anzahl an Sprites auf dem Bildschirm bestimmt den Speicherbedarf

Rohdaten in gemeinsamen UND unterschiedlichen Speichern

Um die Vielzahl an verschiedenen Nachteilen auszugleichen lässt sich zusätzlich ein gemeinsamer Speicher anlegen. Dieser hält die Gesamtheit aller Rohbilder die auf dem Bild erscheinen sollen. Zusätzlich bekommt jede Zeicheneinheit einen eigenen, kleinen Bildspeicher, welcher nur ein Rohbild oder nur eine Zeile eines Rohbildes enthält. Hiermit ist die Anzahl der möglichen Sprites, welche sich auf dem gesamten Bildschirm befinden und welche aktuell gemalt werden, entkoppelt. Ein Scheduling Algorithmus sorgt für die Zuweisung der Tasks und Daten an die jeweilige Zeicheneinheit.

Die Frage ob für die Zeicheneinheiten ein Zeilen oder ein Bildspeicher genutzt wird, hängt vor allem davon ab welche Techniken im weiteren verwendet werden soll.

Zeilenspeicher:

  • geringerer Hardwarebedarf, 1 Blockram reicht in der Regel
  • unterschiedliche Spritegrößen sind relativ leicht machbar
  • Spiegelungen sind möglich
  • Drehungen sind nicht möglich, weil die benötigten Daten nicht vorliegen

Bildspeicher:

  • Unabhängiger Zugriff auf alle Pixel des Rohbildes
  • Stufenloses Drehen, Zoomen und Scheren möglich
  • Unterschiedlich Spritegrößen(Basisbild) kaum machbar
  • Sehr hoher Speicherbedarf, je nach Spritegröße

Farben

Wie sich gezeigt hat ist die Menge an benötigtem Speicher für fortgeschrittene Implementierungen eventuell recht groß, zumal es sich stets um FPGA internen Blockram handelt, von dem selbst größere FPGAs kaum mehr als weniger Mbyte besitzen.

Es stellt sich deshalb die Frage welche Farbdaten man ablegt. Einen Überblick bietet das Bild nebenan.

Verschiedene Farbtiefen

Farbe aus RGB

Diese Variante kommt am ehesten für 16 oder 24 Bit in Frage und ist sehr speicherintensiv. Der größte Vorteil liegt in der direkten Verwendbarkeit der Farbe ohne weitere Berechnungen.

Farbe aus Farbtabelle

Mit indexierten Farben lässt sich eine große Menge Speicher einsparen. Insbesondere da Sprites typischerweise klein sind, irgendwo im Bereich 8-64Pixel, reichen häufig auch weniger Farben für eine gute Darstellung.

Die Farbtabelle ist etwas aufwändiger in der Umsetzung, weil für jeden Farbwert erst noch die tatsächliche Farbe ermittelt werden muss. Gerade für Fälle mit Transparenz im Sinne von durchscheinenden Effekten wie Glas kann das schwierig werden, weil hier gegebenenfalls mehrere Sprites ihre Indexfarben in Echtfarben gleichzeitig umwandeln müssen, es aber eventuell nur eine Farbtabelle gibt.

Für Farbtabellen gibt es verschiedene Variante:

  • Eine Tabelle für alle Sprites, z.b. vollständige 8 Bit Tabelle mit 256 Farben
  • Mehrere Tabellen, jedes Sprite mappt auf eine bestimmte
  • Jedes Sprite hat seine eigene Tabelle, am ehesten in Verbindung mit nur 2 oder 4 Bit
  • Mix: gemeinsame Tabelle + kleine seperate Tabelle für jedes Sprite

"Historische" Sprite Hardware aus Konsolen verwendet praktisch immer recht kleine Farbtabellen zwischen 4 und 16 Farben, wobei häufig sogar noch ein Farbwert entfällt für Transparenz.

Erweiterte Fähigkeiten

Die einfache Darstellung der Farbdaten auf einer beliebigen Position im Bild ist sozusagen die Grundfunktion der Sprites. Für die tatsächliche Anwendung sind häufig weitere Techniken notwendig. Sei es um eine bestehende Hardware nachzubauen oder weil bestimmte Dinge gewährleistet werden sollen.

Transparenz

Wie bereits weiter oben erwähnt ist eine grundlegende Transparenz im Sinne von Unsichtbarkeit einzelner Pixel immer notwendig. Realisiert werden kann dies z.b. durch bestimmte Farbwerte bzw. Farbindexwerte. Im FPGA bietet sich zudem eine weitere Variante an: während gegebene Bilder sehr oft z.b. im 8 oder 16 Bit Format vorliegend sein werden bieten FPGA Blockrams zusätzliche Bits an, sind z.b. 9 oder 18 Bit breit. Hier lassen sich solche Informationen gut unterbringen. Ein unsichtbares Pixel verhält sich bei einer parallelen Implementierung nicht anders als wäre die Zeicheneinheit inaktiv.

Möchte man zudem durchscheinende Sprites realisieren, z.b. 50% Tranzparenz, so lässt sich dieses durch weitere Informationen speichern. Der Hardwareaufwand kann bei Nutzung vieler Sprites aber schnell sehr hoch werden, weshalb es sich anbietet diese Effekte zu wenige Layer/Sprites übereinander zu beschränken. Die meisten Sprite Engines in Konsolen besitzen nur einfache, 100% Transparenz.

Prioritäten

Liegen 2 Sprites übereinander, so gibt es meistens eine feste Reihenfolge mit der gezeichnet wird, z.b. der niedrigste oder höchste Index wie im Beispiel weiter oben. Reicht dies nicht aus, so können weitere Prioritäten festgelegt werden.

Die Implementierung eine wahlfreien Priorität kann bei großer Zahl an Sprites aber recht aufwändig werden. Deshalb beschränken sich die meisten Implementierung auf den Index oder 1 Prioritätsbit. Wird Letzteres genutzt, so schaut man sich zuerst alle Sprites dem Index nach an, welche dieses Bit gesetzt haben. Ist keines davon aktiv geht man noch einmal durch und wählt dann nur noch nach Index.

Dies lässt sich im FPGA recht einfach implementieren, indem beide Zweige parallel rechnen und im nächsten Takt zwischen beiden Ergebnissen ausgewählt wird.

Spiegeln

Um einfache Drehungen der Sprites um 90° Anteile zu erreichen bietet sich die horizontale und vertikale Spiegelung an. Die Spiegalachse liegt dabei in der Mitte des Sprite. Etwa ergibt sich nach horizontaler Spiegelung eines 8x8 Sprites das die siebte Zeile statt der Nullten gemalt wird und umgedreht.

Diese Spiegelung lässt sich meistens recht einfach implementieren, indem die Leseadresse im Bildspeicher entsprechend modifiziert wird.

n-fache Skalierung

Durch das wiederholte Malen eines Pixel in X Richtung oder das wiederholte Malen einer Zeile lassen sich Skalierungen um Vielfache unabhängig sowohl in vertikaler als auch horizontaler Richtung umsetzen. Obwohl das im ersten Moment sehr einfach scheint, hat es doch entscheidende Nachteile. So wird zum einen das Sprite pixelig, wenn es vergrößert wird. Zum anderen blockiert ein vergrößertes Sprite andere Zeicheneinheiten, sodass man stattdessen einfach mit diesen Malen könnte. Vielleicht deshalb ist das Feature auch nur gelegentlich anzutreffen.

Kollisionserkennung

Als weitere Erleichterung für den Prozessor lässt sich eine Kollisionserkennung im Rahmen des Malens der Sprites relativ leicht einbauen. Wollen mehrere Sprites den gleichen Pixel malen, so handelt es sich um eine Kollision. Schwieriger als die Frage der Erkennung ist die Frage der Ablage dieser Information um sie einem Prozessor zur Verfügung zu stellen.

Mögliche Varianten:

  • Vollständige Matrix wer mit wem kollidiert ist. Nur praktikabel wenn man wenig Sprites hat
  • jedes Sprite speichert nur die letzte Kollision
  • bei jeder Kollision die sich von der vorherigen unterscheidet(andere Sprites), wird ein Tupel der Sprites in einem RAM abgelegt

In jedem Fall ist zu klären wie und wann die Information wieder gelöscht wird. Ein möglicher Weg ist das Löschen zeitgleich zur Leseoperation.

Affine Transformation

Die Anwendung der affinen Transformation ist eine Möglichkeit komplexe Umwandlungen des Bildes vorzunehmen. Dazu gehören hier in dem Fall:

  • Rotation in beliebiger Schrittweite (z.b. 75°)
  • Skalierung in beliebiger Schrittweite (z.b. 230%)
  • Scherung, wobei diese selten genutzt wird

Mathematik im Hintergrund

Im Weiteren bezeichnet:

  • sx/sy = source x/y, die X/Y Koordinate des Quellpixels
  • tx/ty = target x/y, die X/Y Koordinate des Zielpixels

Folgende Matrix ist die Identitätsmatrix, d.h. eine Berechnung mit dieser Matrix ergibt keine Änderung des Bildes.
(1 0)
(0 1)

Die Formel für die Berechnung des Zielpunktes lautet:
tx = sx * m(0,0) - sy * m(1,0)
ty = sy * m(0,1) - sx * m(1,1)

Für diesen einfachen Fall ergibt sich damit:
tx = sx * 1
ty = sy * 1

Eine Skalierung erreicht man mit:
(xscale 0)
(0 yscale)

Eine Rotation mit:
(cos -sin)
(sin cos)

Um mehrere Operation zu kombinieren, müssen diese per Matrix Multiplikation miteinander kombiniert werden.

Richtungsumkehr

Die Berechnungen können angewendet werden um von eine Koordinate im Quellbild zur Koordinate im Zielbild zu kommen. Für Sprites gilt jedoch das umgekehrte: es fehlt die Koordinate im Quellbild zu einer Koordinate im Zielbild. Entsprechend muss die Matrix invertiert werden um zum Ergebnis zu kommen.

Implementierung im FPGA

Die oben genannten Operationen sollte man wenn möglich einem Prozessor überlassen. Zum einen müssen diese relativ selten ausgerechnet werden, weil die Matrix für das komplette Sprite gilt und nicht für jedes Pixel einzeln berechnet werden muss, zum anderen weil aufwändige Berechnungen wie Sinus/Cosinus und Divisionen enthalten sind.

Im FPGA ist dann lediglich die Multiplikation der X und Y Koordinaten mit den Matrixwerten notwendig.

signal sprite_mul_m0 : signed(15 downto 0);
...
sprite_addrx_calc_p1 <= (sprite_addrx * to_integer(sprite_mul_m0)) / 256;
sprite_addrx_calc_p2 <= (sprite_addry * to_integer(sprite_mul_m1)) / 256;                                                                
sprite_addry_calc_p1 <= (sprite_addry * to_integer(sprite_mul_m3)) / 256;
sprite_addry_calc_p2 <= (sprite_addrx * to_integer(sprite_mul_m2)) / 256;
-- next clock cycle
sprite_addrx_calc <= sprite_addrx_calc_p1 - sprite_addrx_calc_p2;
sprite_addry_calc <= sprite_addry_calc_p1 - sprite_addry_calc_p2;

In diesem Beispiel sieht man wie das aussehen kann. Im ersten Schritt werden die 4 Einzelwerte berechnet, im nächsten kombiniert. Die Registerstufe hier ist notwendig um das Timing gewährleisten zu können.

An der Operation "geteilt durch 256" sieht man noch ein bisschen mehr. In diesem Beispiel wird Fixed-Point Arithmetik verwendet. Die Adresse wird in diesem Beispiel mit einem 16 Bit Signed Wert multipliziert. Das bedeutet (grob) das 7 Bit vor dem Komma und 8 Bit hinter dem Komma für die Genauigkeit bleiben. Dies ist ausreichend um mit Ungenauigkeiten von <1° um 0-359° drehen zu können. Zusätzlich lässt sich damit eine Verkleinerung um den Faktor 127 realisieren sowie eine Vergrößerung um Faktor 255. Beides ist unrealistisch viel, zeigt aber, das dies für die allermeisten Fälle mehr als ausreichend ist.

Tricks mit Sprites

Gerade auf älteren Systeme werden bisweilen einige Tricks genutzt um mehr aus der bestehenden Sprite Hardware herauszuholen.

Umkonfigurieren mitten im Bild

Sowohl die Pixeldata als auch Position und Farbmapping lassen sich in den meisten Fällen ändern, nachdem ein Sprite dargestellt wurde. So kann beispielsweise ein 8 Pixel hohes Sprite in Zeile 10 gemalt werden. In Zeile 18 würde die CPU dann umkonfigurieren und so wäre im besten Fall das gleiche Sprite schon in Zeile 19 wieder nutzbar. Die Zahl der nutzbaren Sprites steigt damit je nach Hardware beträchtlich.

Umkonfigurieren mitten im Sprite

Im Gegensatz zu heute waren frühere Systeme oft sehr genau und zuverlässig was die CPU Berechnungszeiten und Buszugriffszeiten betrifft. So ist es möglich noch während das Malens eines Sprites, dieses zu verändern. Daraus ergeben sich etwas Möglichkeiten für Skalierungen, welche die Zielhardware nicht hatte oder es wurden Farben getauscht um die recht strikten Beschränkungen zum umgehen.

Beispiele

Schaut man sich historische System an, so hat praktisch jedes seine eigene Sprite Implementierung.

Die vielleicht erste mit großem Einfluss war auf dem Atari 2600. Hier gab es gerade mal 5 Sprites, manche davon mit der Größe eines Pixels. Maximal 17 Pixel pro Zeile konnten ohne Tricks durch die Spritehardware gesetzt werden, in einer Farbe.

Nur wenige Jahre später ist mit dem NES und dem C64 schon ein sehr großer Sprung passiert. Es wurde doch Vergleichsweise bunt, die Spritegrößen stiegen an und es wurden nun schon 64(NES) bis 192(C64) Pixel pro Zeile gerechnet.

Anfang der 90er wurde vor allem die Anzahl der sichtbaren Sprites stark erhöht. Der SNES konnte bereits 128 davon ohne Tricks anzeigen und bot auch viel mehr Konfortfunktionen, wie z.b. unterschiedliche Größen. Was die reine Menge an Sprites/Pixels betrifft ist der NEO GEO bis heute ungeschlagen, was Features betrifft kommt man am Nintendo DS mit den Rotations und Skalierungsmodi sowie relativ hoher Farbtiefe kaum vorbei.

Beispiele des Autors

Gameboy Color

Die Aufgabe ist hier klar abgesteckt. Die Sprites müssen das leisten können, was sie im Gameboy Color konnten. Die Besonderheit ist hierbei, das der Speicher zwischen Sprites und Hintergrund geteilt ist, weshalb man die Aufgaben nicht klar trennen kann.

Ein paar Daten, anhand der vorgestellten Techniken weiter oben:

  • Paralleles Design
  • 1 Speicher für alle Rohdaten mit 2 Bit Farbtiefe (8 Farbtabellen)
  • Einstell-Speicher für 40 Sprites pro Bild (Position, Größe, usw)
  • 10 Zeicheneinheiten mit jeweils einem Zeilenspeicher(8 Pixel)
  • Horizontale und Vertikale Spiegelung
  • keine Kollisionserkennung
  • 1 Farbwert pro Pixel bei 1,3 Mhz (160x144)

Eine weitere Besonderheit ist hier die Implementierung eines Framebuffers im FPGA Nachbau. Während der Gameboy keinen Framebuffer und dies normalerweise für Sprites keinen Sinn macht, war das hier notwendig um die eigentliche Bildausgabe mit ihrer eigenen Auflösung und Frequenz der Gameboy Auflösung und Frequenz anzupassen. Spätestens mit einem Turbo Modus(z.b. 4fache Geschwindigkeit) bekommt man sonst Probleme mit der Darstellung. Aufgrund der geringen Auflösung von nur 160x144 Pixel ist der Speicherbedarf aber noch moderat mit 345Kbit bei vollen 15 Bit Farbtiefe des Gameboy Color.

Bedarf an FPGA Logik:

  • ~2000 Luts
  • ~1200 Flipflops
  • 268 kBit Blockram (inklusive Background Engine)

Der Code befindet sich in der Linkliste unten.

Eigenenwurf

256 Sprites

Die Hauptmotivation war überhaupt eine Sprite Engine zu haben. Die Notwendigkeit ergab sich ursprünglich daraus einen Mauszeiger verwenden zu können ohne die dahinter liegende Grafik ständig zu verändern. Im Laufe des Projekts kam aber das Interesse dazu diese Technik besser zu verstehen und zu schauen was sich heute in einem FPGA machen lässt. Insbesondere die affine Transformation war dann eine Hauptmotivation, weil außer dem Nintendo GBA/DS keine Sprite Engine darüber verfügt, eine freie Rotation und Skalierung aber viele Dinge ermöglicht.

Ein paar Daten, anhand der vorgestellten Techniken weiter oben:

  • Paralleles Design
  • 32 Speicher für 32x32 Pixel Rohdaten mit 17 Bit Farbtiefe (565RGB) + Transparenz
  • Speicher für 256 Sprites pro Bild (Position, Größe, usw)
  • 24 Zeicheneinheiten mit jeweils einem vollständigen Bildspeicher(32x32)
  • Affine Transformation für alle Sprites ohne Einbußen bei der Füllrate
  • Speicher für bis zu 512 Kollisionen zwischen beliebigen Sprites pro Bild
  • 1 Farbwert pro Pixel bei 108 Mhz (1280x1024)

Bedarf an FPGA Logik:

  • ~19000 Luts
  • ~9000 Flipflops
  • 1 Mbit Blockram
  • 96 DSP Blöcke(18x18 Bit)

Der Hardwarebedarf ist sicherlich ordentlich, obwohl für heutige(2019) FPGAs auch nicht mehr so außergewöhnlich. Dafür leistet das System schon ohne Skalierung eine Füllrate von 786432 Pixeln pro Frame. Zum Vergleich: ein Nintendo DS liegt bei maximal 464640 Pixeln pro Frame, aber nur wenn Features wie die Skalierung und Drehung nicht benutzt werden.

Der Link zum stark dokumentierten Sourcecode findet sich hier. Vorgewarnt sei allerdings, das sich eine Sprite Engine nicht sinnvoll alleine im FPGA implementieren lässt. Notwendig sind mindestens eine Grafikausgabe(z.B. VGA) sowie eine Methode um Daten wie Spritepositionen oder Bilddaten schreiben zu können. Der Code dient in der Form daher eher zum anschauen als zum direkten implementieren. Falls jemand das vorhaben sollte und nicht alleine weiter kommt darf aber gerne Kontakt aufgenommen werden.

Datei:Spriteengine.vhd

Zusammenfassung

Dieser Artikel hat gezeigt welche Möglichkeiten zur Verfügung stehen um eine einfache oder auch komplexe Sprite Implementierung im FPGA vorzunehmen. Die Code Beispiele geben einen Startpunkt um selbst eine eigene Implementierung zu machen, sowohl für ein eigenes System als auch einen Nachbau, etwa einer älteren Konsole.

Siehe auch