SDL-Tutorial #6 - Parallax Scrolling und Animationen

Autor: Nicolai 'Prefect' Haehnle

Ich weiß, ich weiß, das Thema Scrolling scheint kein Ende zu nehmen. Dieses Mal geht es um das sogenannte „Parallax Scrolling“. Es handelt sich dabei zwar eigentlich um einen uralten Trick aus der Computersteinzeit, aber der ist auch heute noch interessant.

Was ist Parallax Scrolling?

Bis jetzt bewegen sich all die schönen Galaxien im Hintergrund im Gleichschritt. Das hat den Effekt, daß es so aussieht, als wären die Galaxien alle gleich weit vom Betrachter weg. Sie befinden sich also quasi alle auf der gleichen Ebene.

Viel schöner wäre es doch, wenn manche der Galaxien scheinbar weiter hinten liegen würden. Dafür müßten sie sich wegen der Perspektive entsprechend langsamer bewegen.
Natürlich würden Galaxien, die sich beim Scrolling schneller als der Spieler bewegen, weiter vorne erscheinen. In Jump'n'Runs sieht man solche Scrollebenen die weiter vorne liegen auch des öfteren, aber im Weltraum ergeben sie keinen Sinn.

Und genau dieses Aufteilen des Hintergrunds in Ebenen, die sich unterschiedlich schnell bewegen, nennt man „Parallax Scrolling“.

Die dritte Dimension

Trotz der Überschrift werden wir uns weiterhin nur der 2D-APIs bedienen. Es ergibt sich aber ein scheinbar dreidimensionales Bild.

Zunächst einmal muß für jede Galaxie in einer zusätzlichen Variable die Tiefe gespeichert werden. Dazu verändere ich die Struktur entsprechend. Ich verwende die Membervariable depth für diesen Zweck. Sie ist gleich 4 bei normalen Scrolltempo, gleich 8 bei doppelter Tiefe und damit halbiertem Scrolltempo und so weiter.

#define NUM_GALAXIES        100
 
struct galaxy {
    int x, y;    /* Koordinaten der Galaxie */
    int depth;    /* "Tiefe" der Galaxie */
    int type;     /* Welches Bild wird für die Galaxien verwendet? */
};
 
static int g_DepthCount[] = {
    0,
    0,
    0,
    0,
    20,    /* volle Geschwindigkeit */
    34,
    45,
    55,
    64,    /* 1/2 */
    72,
    79,
    85,
    90,    /* 1/3 */
    94,
    97,
    99,
    100    /* 1/4 */
};

Es ist generell besser wenn sich mehr Galaxien im Vordergrund befinden als im Hintergrund, da die Spieler ansonsten verwirrt werden könnten. Deshalb habe ich die Verteilung der Galaxien hier im Array g_DepthCount gespeichert. Dabei gibt immer die Differenz zwischen zwei Elementen an, wie viele Galaxien sich in der entsprechenden Tiefe befinden.

Die Membervariable depth muß nun natürlich noch in RandBackground() jeweils initialisiert werden. Dabei kann man aber nicht einfach zufällig vorgehen. Schließlich müssen die Galaxien später einmal von hinten nach vorne gezeichnet werden - sonst würde ja eine Galaxie, die weit vorne ist womöglich von einer Galaxie ganz hinten bedeckt werden. Daher vergebe ich die Tiefenwerte sortiert in aufsteigender Reihenfolge, wie im Folgenden zu sehen ist.

void RandBackground()
{
    int i;
    int num_types; /* Anzahl Galaxientypen im Bitmap "galaxien.bmp" */
    int depth;
 
    num_types = g_pSurfGalaxies->w / 16;
 
    /* Fuelle das Galaxien-Array mit zufaellig gewaehlten Werten */
    depth = 0;
    for(i = 0; i < NUM_GALAXIES; i++) {
        g_Galaxies[i].x = rand() % 1024;
        g_Galaxies[i].y = rand() % 512;
        g_Galaxies[i].type = rand() % num_types;
 
        while(i >= g_DepthCount[depth])
            depth++;
        g_Galaxies[i].depth = depth;
    }
}

Es gibt sicher mehrere Ansätze um nun die veränderte Scrollgeschwindigkeit auch tatsächlich wirksam zu machen. Am einfachsten geht es aber, wenn man einfach in DrawBackground() scrollx und scrolly verhältnismäßig verkleinert.

Wir müssen also lediglich ein bißchen Code um Zeile 90 herum verändern. Es gibt aber noch eine zweite Sache, die in DrawBackground() verändert werden muß: Momentan werden die Galaxien ja mit aufsteigendem Index gezeichnet. Daß die Galaxien in RandBackground() mit größer werdender Tiefe sortiert werden, hat aber zur Folge, daß weiter hinten gelegene Galaxien später gezeichnet werden als die vorderen. Wie müssen also auch die Zeichenreihenfolge umdrehen, sonst würden die hinteren Galaxien die vorderen überdecken und nicht umgekehrt.

Hier ist die neue Version von DrawBackground():

void DrawBackground(int scrollx, int scrolly)
{
    int i;
 
    for(i = NUM_GALAXIES-1; i >= 0; i--) {
        SDL_Rect src;
        SDL_Rect dest;
 
        src.x = g_Galaxies[i].type * 16;
        src.y = 0;
        src.w = 16;
        src.h = 16;
 
        dest.x = ((unsigned int)(g_Galaxies[i].x - (4*scrollx / g_Galaxies[i].depth)) % 1024) - 16;
        dest.y = ((unsigned int)(g_Galaxies[i].y - (4*scrolly / g_Galaxies[i].depth)) % 512) - 16;
 
        SDL_BlitSurface(g_pSurfGalaxies, &src, g_pSurfScreen, &dest);
    }
}

Der eigentliche Clou steckt natürlich in den beiden Zeilen, die die Zielkoordinaten bestimmen.

Transparenz

Das Programm hat jetzt allerdings noch einen kleinen Schönheitsfehler: Wenn sich zwei Galaxien überlappen wird die weiter hinten gelegene mit Schwarz überschrieben. Das ist zwar ganz logisch, denn SDL kopiert ja das gesamte Quellrechteck über das Zielrechteck ohne irgendeine Transparenz zu beachten, aber es ist nicht was wir wollen.

Also müssen wir dafür sorgen, daß alles, was schwarz ist, zum Schluß durchsichtig wird. Genau dafür ist Colorkeying da. Mit der Funktion SDL_SetColorKey() können wir SDL mitteilen, daß eine bestimmte Farbe transparent sein soll. Wir müssen diese Funktion nur einmal aufrufen, nämlich nachdem das Galaxien-Bitmap geladen wurde. Ich füge den Funktionsaufruf also nach dem Aufruf von SDL_LoadBMP() in main() ein:

    g_pSurfGalaxies = SDL_LoadBMP("galaxien.bmp");
    if (!g_pSurfGalaxies) {
        fprintf(stderr, "galaxien.bmp konnte nicht geladen werden: %s\n",
            SDL_GetError());
        exit(1);
    }
    SDL_SetColorKey(g_pSurfGalaxies, SDL_SRCCOLORKEY, SDL_MapRGB(g_pSurfGalaxies->format, 0,0,0));

SDL_SetColorKey() erwartet drei Parameter:
Der erste Parameter ist die Surface, für die der Colorkey gesetzt werden soll. Der zweite Parameter enthält Flags. Normalerweise wird man nur SDL_SRCCOLORKEY benötigen um den Colorkey zu setzen. Wenn man 0 übergibt wird der Colorkey wieder gelöscht. Der letzte Parameter gibt den Farbwert der transparenten Pixel im Farbformat der Surface an.

Übrigens ist das Farbformat von g_pSurfGalaxies nicht unbedingt das gleiche wie das des Bildschirms! Ich werde darauf am Ende dieses Kapitels noch zu sprechen kommen.

Animationen

Natürlich ist mit dem Hintergrund noch nicht alles getan, es muß sich ja auch noch etwas im Vordergrund abspielen.
Für ein richtiges Spiel bräuchte man natürlich Verwaltungsstrukturen für die Gegenstände, die im Spiel auftauchen. Allerdings will ich mich damit jetzt nicht befassen, und lediglich ein sich drehendes Raumschiff in der Bildschirmmitte darstellen. Dafür braucht man bei einem 2D-Spiel für jeden möglichen Zustand des Raumschiffs ein Bild.
Ich habe hier ein Raumschiff einmal normal und einmal mit angeschaltetem Triebwerk in jeweils 36 Einzelbildern dargestellt, wobei die Einzelbilder natürlich jeweils 10° zueinander gedreht sind (jaja ich weiß, meine grafischen Talente sind nicht existent…).


Das Raumschiff soll sich jetzt ständig drehen, und der Antrieb soll angeschaltet sein, wenn die Leertaste gedrückt ist.

Ich will an dieser Stelle zunächst einmal eine Generalisierung vornehmen. Sagen wir, alle unsere Sprites haben jeweils Einzelbilder, die 48×48 groß sind, alle Sprites haben zudem 36 Drehschritte die in 4 Zeilen angeordnet sind. Dann können wir eine Funktion DrawSprite() schreiben, die für alle Sprites dieser Art verwendet werden kann. Sie sieht so aus:

void DrawSprite(SDL_Surface *pSurf, int dx, int dy, int frame, int rotation)
{
    SDL_Rect src;
    SDL_Rect dest;
 
    src.x = (rotation % 9) * 49;
    src.y = ((rotation / 9) + (4 * frame)) * 49;
    src.w = 48;
    src.h = 48;
 
    dest.x = dx - 24;
    dest.y = dy - 24;
 
    SDL_BlitSurface(pSurf, &src, g_pSurfScreen, &dest);
}

Der Funktion werden die Surface mit dem Sprite selbst, die Zielkoordinaten auf dem Bildschirm sowie die Rotation und der zu verwendende Frame übergeben. Dann berechnet die Funktion das nötige Quellrechteck über die Rotation und den Frame. Das Sprite wird so gezeichnet, daß sich sein Mittelpunkt auf den übergebenen Koordinaten befindet. Deshalb wird von diesen Koordinaten noch jeweils 24 (also die Hälfte der Einzelbildgröße) abgezogen.

Für ein wirklich ernsthaftes Projekt sollte man allerdings anders vorgehen: Dabei sollte man Informationen über das Sprite - also die Größe der Einzelbilder, die verwendete Surface, usw. - in einer Struktur speichern. Diese wird dann an die Funktion DrawSprite() übergeben, die dann auch automatisch die unterschiedlichen Bildgrößen berücksichtigen kann.

Jetzt benötigen wir noch die Surface, in die das Bitmap mit den Einzelbildern geladen wird. Zudem muß der momentane Drehzustand des Raumschiffes gespeichert werden. Dazu habe ich den folgenden Code am Anfang des Programms eingefügt:

SDL_Surface *g_pSurfShip;
 
Uint32 g_Black;
 
struct {
    int rotation;
    Uint32 nextturn;
} ship;
 
#define TURN_DELAY            75

Ich speichere nicht nur die eigentliche Drehposition, sondern auch die Zeit, zu der das Raumschiff als nächstes gedreht werden soll. Das ermöglicht eine konstante Drehgeschwindigkeit auch bei wechselnden Framerates.

TURN_DELAY gibt an, wieviele Millisekunden zwischen den einzelnen Drehschritten liegen. In diesem Fall sind es 75ms, für eine vollständige Rotation werden also 2700ms benötigt.

Nun muß das Bitmap ship.bmp zuerst einmal geladen werden. Der nötige Code muß natürlich in main(), also so um Zeile 162, eingefügt werden:

    SDL_SetColorKey(g_pSurfGalaxies, SDL_SRCCOLORKEY, SDL_MapRGB(g_pSurfGalaxies->format, 0,0,0));
 
    g_pSurfShip = SDL_LoadBMP("ship.bmp");
    if (!g_pSurfShip) {
        fprintf(stderr, "ship.bmp konnte nicht geladen werden: %s\n",
            SDL_GetError());
        exit(1);
    }
    SDL_SetColorKey(g_pSurfShip, SDL_SRCCOLORKEY, SDL_MapRGB(g_pSurfShip->format, 0,0,0));
 
    RandBackground();

Die Membervariablen von ship sollten noch vor der Hauptschleife initialisiert werden:

    running = 1;
    scrollx = scrolly = 0;
    ship.rotation = 0;
    ship.nextturn = curframe + TURN_DELAY;
    while(running) {

Nun muß nur noch in der Hauptschleife Code eingefügt werden, um das Raumschiff zu drehen, und letztendlich auf den Bildschirm zu bringen.

Für die Drehung verwende ich eine simple while()-Schleife. Das mag vielleicht ineffizient erscheinen (und ist es eigentlich auch), aber in der Praxis wird diese Schleife wohl nie mehr als einmal ausgeführt werden.

Das Zeichnen erfolgt durch einen simplen Aufruf von DrawSprite(). Je nachdem, ob die Leertaste gedrückt ist wird entweder der erste oder der zweite Zustand beim Zeichnen verwendet.

        while(ship.nextturn = 36)
                ship.rotation = 0;
            ship.nextturn += TURN_DELAY;
        }
 
        SDL_FillRect(g_pSurfScreen, 0, g_Black);
 
        DrawBackground(scrollx, scrolly);
 
        DrawSprite(g_pSurfShip, 320, 240, keystate[SDLK_SPACE] ? 1 : 0, ship.rotation);
 
        SDL_Flip(g_pSurfScreen);
    }

Natürlich muß die verwendete Surface am Ende des Programms noch freigegeben werden:

    SDL_FreeSurface(g_pSurfShip);
    SDL_FreeSurface(g_pSurfGalaxies);
 
    return 0;
}

So, nun solltet Ihr in der Bildschirmmitte ein sich fröhlich drehendes Raumschiff vorfinden.

Der Gleichmacher der Formate

Bei einem so simplen Programm wie den Beispielen dieser Tutorials spielt Performance normalerweise keine zu große Rolle. Dennoch gibt es ein paar Dinge, die man ohne großen Aufwand locker verbessern kann.

Dazu muß man erst einmal wissen, daß SDL_LoadBMP() Bitmaps in dem Format lädt, in dem sie gespeichert wurden. Im Klartext heißt das: Ein Echtfarbenbitmap wird mit 24 Bit Farbtiefe geladen, ganz egal ob der Bildschirm gerade auf 16 Bit, 32 Bit oder gar einen Modus mit Palette eingestellt ist.

Das bedeutet aber auch, daß SDL die Grafiken bei jedem Blitvorgang von einem Farbformat ins andere übertragen muß. Das ist natürlich Zeitverschwendung. Um diese Zeitverschwendung zu vermeiden sollten die Bitmaps schon beim Laden ins Format des Bildschirms umgewandelt werden.

Dafür kann man die Funktion SDL_ConvertSurface() verwenden. Diese Funktion tut, was sie sagt - sie wandelt eine Surface um, und zwar von einem Format ins andere. Man übergibt der Funktion einfache ein Surface, das gewünschte Farbformat und die gewünschten Flags, und die Funktion erstellt eine neue Surface mit den gewünschten Eigenschaften.

Um den Vorgang etwas zu vereinfachen führe ich nun eine neue Funktion LoadBMP() ein, die ein Bitmap lädt und es gleich ins Bildschirmformat umwandelt. Sie sieht wie folgt aus:

SDL_Surface *LoadBMP(const char *szFile)
{
    SDL_Surface *orig, *convert;
 
    orig = SDL_LoadBMP(szFile);
    if (!orig) {
        fprintf(stderr, "%s konnte nicht geladen werden: %s\n", szFile, SDL_GetError());
        exit(1);
    }
 
    convert = SDL_ConvertSurface(orig, g_pSurfScreen->format, g_pSurfScreen->flags);
    SDL_FreeSurface(orig);
    if (!convert) {
        fprintf(stderr, "%s konnte nicht ins Bildschirmformat konvertiert werden: %s\n",
            szFile, SDL_GetError());
        exit(1);
    }
 
    return convert;
}

Ich übergebe SDL_ConvertSurface() hier auch die Flags der Bildschirmsurface. Das hat zur Folge, daß die neue Surface wenn möglich genau dann im Grafikkartenspeicher erstellt wird, wenn sich dort auch die Bildschirmsurface befindet. Wenn dies der Fall ist, kann SDL dann die Beschleunigerfunktionen der Grafikkarte beim Blitten nutzen, was natürlich wiederum ein Geschwindigkeitsvorteil ist.

Es ist aber nicht unbedingt von Vorteil, eine Surface im Grafikkartenspeicher zu behalten. Wenn man auf eine Surface sehr viel direkt zugreift um einzelne Pixel zu zeichnen (wie im nächsten Kapitel gezeigt wird), dann sollte sich diese im normalen RAM befinden, denn der Datenbus zum RAM ist schneller als der zum Speicher auf der Grafikkarte. Genaue Benchmarkwerte habe ich hier allerdings nicht. Letztendlich wird man wohl einfach von Fall zu Fall experimentieren müssen.

Auf jeden Fall können wir nun einfach die Funktion LoadBMP() verwenden. Dadurch wird main() etwas übersichtlicher. Der Code ab etwa Zeile 174 sieht dann so aus:

    SDL_WM_GrabInput(SDL_GRAB_ON);
 
    g_Black = SDL_MapRGB(g_pSurfScreen->format, 0, 0, 0);
 
    g_pSurfGalaxies = LoadBMP("galaxien.bmp");
    SDL_SetColorKey(g_pSurfGalaxies, SDL_SRCCOLORKEY, g_Black);
 
    g_pSurfShip = LoadBMP("ship.bmp");
    SDL_SetColorKey(g_pSurfShip, SDL_SRCCOLORKEY, g_Black);
 
    RandBackground();
 
    curframe = SDL_GetTicks();

Da die Surfaces nun im Bildschirmformat vorliegen, können wir beim Aufruf von SDL_SetColorKey() auch einfach g_Black verwenden, und müssen nicht mehr extra SDL_MapRGB() aufrufen.

Der Quellcode zu diesem Tutorial sowie die Makefile und MSVC++-Projektdateien sind zum Download verfügbar: (sdl_tut6.tgz sdl_tut6.zip)

Anmerkung

Dieses Tutorial stammt aus der ehemaligen Sammlung des resourcecode.de und konnte dank der freundlichen Zustimmung des Autors in das thewall-Wiki übertragen werden.

Die Verwendung aller Dokumente einschließlich der Abbildungen ausschließlich zu nichtkommerziellen Zwecken. Verbreitung des Dokuments auf Speichermedien, (insbesondere auf CD-ROMs als Beilage zu Zeitschriften und Magazinen oder sog. "Mission-Packs" etc.) ist untersagt.
 
coding/grafik_api/sdl_tut_6.txt · Zuletzt geändert: 2010/07/28 23:47 von Adrian_Broher