Inhaltsverzeichnis

SDL-Tutorial #7 - Partikel

Autor: Nicolai 'Prefect' Haehnle

In diesem Kapitel will ich Schritt für Schritt eine simple „Particle Engine“ aufbauen. Jedes Partikel wird dabei durch ein einzelnes Pixel auf dem Bildschirm dargestellt. SDL bietet an sich keine Funktionen, um einzelne Pixel zu zeichnen. Daher werde ich zunächst einmal selbst so eine Funktion vorstellen.

Als erstes will ich mich aber unnötigen Codes entledigen. Ich fange also in diesem Tutorial wieder mit einem minimalen Testprogramm an. Es sieht (ohne Kommentare) wie folgt aus:

#include <stdlib.h>
#include <stdio.h>
#include <time.h>
 
#include <SDL.h>
 
SDL_Surface *g_pSurfScreen;
 
Uint32 g_Black, g_Red, g_Blue, g_Green;
 
 
#ifdef _WIN32
#undef main
#endif
int main()
{
    int running;
    Uint32 lastframe, curframe, frametime;
 
    srand(time(0));
 
    if (SDL_Init(SDL_INIT_VIDEO) < 0) {
        fprintf(stderr, "SDL konnte nicht initialisiert werden: %s\n",
            SDL_GetError());
        exit(1);
    }
    atexit(SDL_Quit);
 
    g_pSurfScreen = SDL_SetVideoMode(640, 480, 0, SDL_DOUBLEBUF);
    if (!g_pSurfScreen) {
        fprintf(stderr, "Konnte Bildschirmmodus nicht setzen: %s\n",
            SDL_GetError());
        exit(1);
    }
 
    g_Black = SDL_MapRGB(g_pSurfScreen->format, 0, 0, 0);
    g_Red = SDL_MapRGB(g_pSurfScreen->format, 255, 0, 0);
    g_Green = SDL_MapRGB(g_pSurfScreen->format, 0, 255, 0);
    g_Blue = SDL_MapRGB(g_pSurfScreen->format, 0, 0, 255);
 
    curframe = SDL_GetTicks();
 
    running = 1;
    while(running) {
        SDL_Event event;
 
        while(SDL_PollEvent(&event)) {
            switch(event.type) {
            case SDL_KEYDOWN:
                switch(event.key.keysym.sym) {
                case SDLK_ESCAPE:
                    running = 0;
                    break;
 
                default:
                    break;
                }
                break;
 
            case SDL_QUIT:
                running = 0;
                break;
            }
        }
 
        lastframe = curframe;
        curframe = SDL_GetTicks();
        frametime = curframe - lastframe;
 
        SDL_FillRect(g_pSurfScreen, 0, g_Black);
 
        SDL_Flip(g_pSurfScreen);
    }
 
    return 0;
}

Es handelt sich hierbei also lediglich um eine gekürzte Version der letzten Programme, wobei ich allerdings noch drei zusätzliche Farbwerte (für rot, grün und blau) ermitteln lasse.

Pixel für Pixel

Um nun ein Pixel tatsächlich auf den Bildschirm zu bringen müssen wir den Inhalt der Bildschirmsurface per Hand modifizieren.

Der Bildschirminhalt befindet sich in einem zwei-dimensionalen Array. Je nach Farbmodus enthalten jeweils ein oder mehrere Bytes den Farbwert des entsprechenden Pixels. Zunächst aber benötigen wir einen Pointer auf dieses Array. Da sich eine Surface auch in einem Speicherbereich befinden kann, auf den man nicht direkt zugreifen kann, muß sie zunächst gelockt (von engl. to lock = sperren, schließen) werden. Wenn eine Surface gelockt ist kann das zugrunde liegende Betriebssystem die Daten nicht mehr herumverschieben, und wir haben einen gültigen Pointer auf die Daten.

Um eine Surface zu locken verwendet man die Funktion SDL_LockSurface(). Wenn man mit dem Zeichnen fertig ist muß man entsprechend SDL_UnlockSurface() aufrufen, um die Sperrung wieder aufzuheben.

Je nach der verwendeten Platform kann SDL in SDL_LockSurface() bestimmte systemweite Locks oder Mutexes halten. Aus diesem Grund sollte man zwischen SDL_LockSurface() und SDL_UnlockSurface() möglichst wenig Code einfügen, und Systemfunktionen wie Dateizugriffe dürfen nicht aufgerufen werden. Ansonsten könnte sich das System zumindest teilweise aufhängen. Man sollte auch Funktionen wie SDL_Blit() nicht aufrufen, während eine Surface gelockt ist.

Wenn die Surface dann erstmal gelockt ist zeigt die Membervariable pixels von SDL_Surface auf den Speicherbereich, der das eigentliche Bitmap enthält.

Bevor es noch tiefer in die Theorie geht will ich erstmal das Konzept an sich überprüfen. Dazu füge ich den folgenden Code in der Hauptschleife um Zeile 90 ein:

        SDL_FillRect(g_pSurfScreen, 0, g_Black);
 
        if (!SDL_LockSurface(g_pSurfScreen)) {
            *(Uint32 *)g_pSurfScreen->pixels = 0xFFFFFFFF;
            SDL_UnlockSurface(g_pSurfScreen);
        } else {
            fprintf(stderr, "SDL_LockSurface fehlgeschlagen\n");
        }
 
        SDL_Flip(g_pSurfScreen);
    }

Zunächst einmal wird die Surface gelockt. Die Funktion SDL_LockSurface() gibt bei Erfolg 0 zurück. Bei einem Fehlschlag wird nur eine Fehlermeldung ausgegeben. Ansonsten schreibe ich die Zahl 0xFFFFFFFF in die ersten vier Bytes der Surfacedaten.

Ganz zu Beginn der Surfacedaten befindet sich natürlich der Farbwert für das Pixel in der linken oberen Ecke. Je nach Farbtiefe sollten also ein bis vier Pixel ganz links in der obersten Bildschirmreihe die Farbe ändern. Bei Echtfarbenmodi werden diese Pixel weiß. In einem 24 Bit-Modus sollte allerdings das zweite Pixel von links in der obersten Reihe rot oder blau sein, je nach der verwendeten Byteorder. Bei einem 256-Farbmodus mit Palette läßt sich das Ergebnis allerdings nicht sicher vorhersagen: Hier kommt es darauf an, welche Farbe sich im letzten Eintrag der Palette befindet.

Ihr könnt natürlich statt 0xFFFFFFFF jede beliebige andere Zahl verwenden. Dann werden die Pixel natürlich eine entsprechend andere Farbe erhalten.

Um nun etwas planvoller Pixel auf die Surface zu bringen müßte man zwischen unterschiedlichen Farbtiefen unterscheiden. Zudem müssen ja auch die Koordinaten des Pixels berücksichtigt werden. Dies wird die Funktion PutPixel() erledigen, die ich hier vorstelle:

void PutPixel(SDL_Surface *pSurf, int x, int y, Uint32 color)
{
    int bpp;
    Uint8 *p;
 
    if (x < 0 || x >= pSurf->w ||
        y < 0 || y >= pSurf->h)
        return;

x und y sind die Koordinaten des Pixels. color ist der Farbwert, den man über SDL_MapRGB() erhalten kann. Damit niemand aus Versehen über ungültige Speicherbereiche hinausschreibt stelle ich hier zudem sicher, daß x und y im gültigen Bereich liegen.

    bpp = pSurf->format->BytesPerPixel;
    p = (Uint8 *)pSurf->pixels + y*pSurf->pitch + x*bpp;

Hier berechne ich den Pointer auf den Farbwert des zu überschreibenden Pixels. Besonders die Verwendung von pitch ist hier wichtig: pitch gibt an, wie viele Bytes für eine Zeile in der Surface verwendet werden. pitch kann größer als Breite * BytesPerPixel sein, da manche Grafikkarten zum Beispiel immer 2er-Potenzen als pitch verwenden. Eine Surface der Größe 640×480 hätte dann mindestens 1024 als pitch.

    switch(bpp) {
    case 1:
        *p = color;
        break;
 
    case 2:
        *(Uint16 *)p = color;
        break;
 
    case 4:
        *(Uint32 *)p = color;
        break;

Für Modi mit 1, 2 und 4 Bytes pro Pixel ist die Funktion sehr einfach aufgebaut. Bei 2 bzw. 4 Byte muß der verwendete Pointer einfach auf einen entsprechenden Typen gecasted werden.

    case 3:
#if SDL_BYTEORDER == SDL_BIG_ENDIAN
        p[0] = (color >> 16) & 0xff;
        p[1] = (color >> 8) & 0xff;
        p[2] = color & 0xff;
#else
        p[0] = color & 0xff;
        p[1] = (color >> 8) & 0xff;
        p[2] = (color >> 16) & 0xff;
#endif
        break;
    }
}

Bei drei Bytes pro Pixel (also 24 Bit) sieht das ganze etwas komplizierter aus. C hat keinen Datentyp, der 3 Byte lang ist. Deswegen muß man hier jedes Byte einzeln kopieren, und den Zeiger in die Surfacedaten als Array betrachten.

Zudem wird hier auf die Byteorder des Computers geachtet. Die Byteorder gibt an, in welcher Reihenfolge die Bytes eines mehrbyteigen Datentyps im Speicher oder in Dateien angeordnet werden.

Bei IA32-Prozessoren (also den üblichen PC-Prozessoren) liegt das niederwertigste Bytes an der niedrigeren Speicheradresse. Man fängt also im Speicher mit dem kleineren Ende der Zahl an und spricht daher von „Little Endian“. Wenn die Daten umgekehrt angeordnet sind spricht man von „Big Endian“. Soweit ich weiß sind z.B. die Prozessoren in Macs „Big Endian“-Prozessoren.
Beide Vorgehensweisen haben Vor- und Nachteile. Bei Big Endian kann man die Daten leichter in einem Hexeditor lesen. Bei Little Endian ist (int *)ptr == (char *)ptr für Werte innerhalb der Gültigkeit von char (also -128..127).

Jetzt muß die Funktion PutPixel() nur noch verwendet werden. Ich habe den neuen Code in main() nun einfach zu Folgendem abgeändert:

        if (!SDL_LockSurface(g_pSurfScreen)) {
            PutPixel(g_pSurfScreen, 0, 0, g_Red);
            PutPixel(g_pSurfScreen, 320, 240, g_Green);
            PutPixel(g_pSurfScreen, 540, 300, g_Blue);
            SDL_UnlockSurface(g_pSurfScreen);
        } else {
            fprintf(stderr, "SDL_LockSurface fehlgeschlagen\n");
        }

Nun sollten drei farbige Pixel auf dem Bildschirm zu sehen sein.

Übrigens solltet Ihr, sofern möglich, Aufrufe von PutPixel() oder sonstige direkte Zugriffe auf die Surfacedaten kombinieren, so daß SDL_LockSurface() nicht so oft aufgerufen werden muß. SDL_LockSurface() kann nämlich recht zeitaufwendig sein.

Partikel

Wie angekündigt werde ich jetzt noch ein kleines Partikelsystem einführen. Zunächst einmal werde ich ein paar Vorbereitungen treffen.

Der Kopf des Programms wird nun so aussehen:

#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <math.h>
 
#include <SDL.h>
 
#ifndef M_PI
#define M_PI        3.14159265358979323846
#endif
 
 
SDL_Surface *g_pSurfScreen;
 
Uint32 g_Black, g_Red, g_Blue, g_Green;
 
Uint32 lastframe, curframe, frametime;

Für diesen Schritt werde ich die Sinus- und Cosinusfunktion verwenden, die in math.h deklariert ist. Die Headerdateien von MSVC++ definieren die Konstante M_PI - der Wert der Kreiskonstanten Pi - nicht. Ich hole das hier nach, wobei ich genau den Wert verwende, den auch die math.h der GNU C Library verwendet.

Ein Hinweis für diejenigen, die die GNU C Library verwenden: Wenn Ihr Funktion aus math.h verwendet (also Kreisfunktionen, Wurzel, etc…) und beim Kompilieren Linkerfehler erhaltet müßt Ihr '-lm' (natürlich ohne die Anführungszeichen) in die Kommandozeile des Linkers bzw. von gcc einfügen, da sich die mathematischen Funktionen in einer getrennten .so-Datei befinden.

Außerdem habe ich die Variablen lastframe, curframe und frametime, die zur Zeitmessung verwendet werden, global gemacht. Natürlich dürfen diese Variablen dann in main() nicht mehr definiert werden. Dementsprechend sieht der Kopf von main() jetzt so aus:

int main()
{
    int running;
 
    srand(time(0));

Nachdem nun die Formalitäten beseitigt sind können wir zur Tat schreiten.

Jedes Partikel wird einfach durch ein farbiges Pixel dargestellt. Jedes Partikel hat außer der Farbe noch eine momentane Position (x/y) und eine Geschwindigkeit (vx/vy). Ich werde die Partikel jeweils in einer Struktur speichern. Diese Strukturen werden untereinander durch eine Linked List verkettet.

Die Struktur ist wie folgt definiert:

typedef struct particle_s {
    struct particle_s **pprev; /* Linked list */
    struct particle_s *next;
    Uint32 color; /* Farbe im Bildschirmformat */
    float x, y; /* Momentane Position auf dem Schirm */
    float vx, vy; /* Geschwindigkeit */
} particle_t;
 
particle_t *g_pParticles = 0;

g_pParticles ist der Zeiger auf die Linked List.

Es liegt nun nahe, Funktionen zu schreiben um ein Partikel zu erstellen bzw. zu entfernen. Hier kommen sie:

particle_t *P_Create(float x, float y, Uint32 color, float vx, float vy)
{
    particle_t *p = (particle_t *)malloc(sizeof(particle_t));
 
    p->color = color;
    p->x = x;
    p->y = y;
    p->vx = vx;
    p->vy = vy;
 
    p->next = g_pParticles;
    if (p->next)
        p->next->pprev = &p->next;
    p->pprev = &g_pParticles;
    g_pParticles = p;
    return p;
}

Die Funktion sollte verständlich sein. Eine Partikelstruktur wird auf dem Heap erstellt, und die gewünschten Werte werden eingefüllt. Dann wird ein bißchen mit Pointern geschossen bis die Struktur am Anfang der Linked List eingereiht ist.
Schließlich gibt die Funktion einen Pointer auf die Partikelstruktur zurück, falls der Aufrufer interessiert ist. Im Normalfall kann man diesen Rückgabewert aber ignorieren.

void P_Remove(particle_t *p)
{
    if (p->next)
        p->next->pprev = p->pprev;
    *p->pprev = p->next;
 
    free(p);
}

Diese Funktion entfernt die Partikelstruktur aus der Linked List und gibt den in P_Create() reservierten Speicherbereich frei.

Nun können Partikel erstellt und gelöscht werden. Wir brauchen noch eine Funktion, die die Partikel entsprechend ihrer Geschwindigkeit bewegt, und eine Funktion, die sie dann auf den Bildschirm bringt.

In beiden Fällen wird die Linked List der Partikel in einer simplen Schleife durchgegangen. Hier kommt zuerst die Bewegungsfunktion, P_Think():

void P_Think()
{
    particle_t *next = g_pParticles;
 
    while(next) {
        particle_t *p = next;
        next = p->next;

Der Kopf der Schleife ist besonders interessant. Da das Partikel im Folgenden gelöscht werden kann muß verhindert werden, daß beim nächsten Durchgang der Schleife ungültige Pointer verwendet werden. Deshalb setze ich next sofort auf das nächste Partikel.

        p->vy += 300 / 1000.0f * frametime;

Hier wird Gravitation simuliert. Die Fallbeschleunigung beträgt 300 Pixel pro Sekunde pro Sekunde.

        p->x += p->vx * frametime/1000.0f;
        p->y += p->vy * frametime/1000.0f;

Analog zur Addition der Fallbeschleunigung zur Geschwindigkeit wird hier die Geschwindigkeit zur Position des Teilchens addiert. Dadurch wird das Teilchen bewegt.

        if (p->x < 0 || p->x >= 640 ||
            p->y >= 480)
            P_Remove(p);
    }
}

Falls sich das Teilchen aus dem Bildschirm herausbewegt wird es gelöscht. Ein Teilchen oberhalb des Bildschirms wird nicht gelöscht - es wird ja durch die Fallbeschleunigung wieder nach unten gezogen und taucht irgendwann einmal wieder auf.

void P_Draw()
{
    particle_t *p;
 
    for(p = g_pParticles; p; p = p->next) {
        PutPixel(g_pSurfScreen, (int)p->x, (int)p->y, p->color);
    }
}

Das Zeichnen der Partikel ist einfach, schließlich benötigt man nur einen Aufruf von PutPixel() pro Partikel. Natürlich muß die Bildschirmsurface gelockt sein, wenn P_Draw() aufgerufen wird, da PutPixel() dies nicht tut.

Damit ist ein Großteil der Arbeit schon erledigt. Allerdings müssen die Partikel ja auch irgendwo herkommen. Für dieses Beispiel erstelle ich einfach in regelmäßigen Zeitabständen vollkommen zufällige Partikel in der Mitte des Bildschirms:

#define EMIT_DELAY        20
 
Uint32 lastemit;
 
void EmitParticles()
{
    while(lastemit + EMIT_DELAY < curframe) {
        float speed, angle;
        float vx, vy;
        Uint32 color;
 
        lastemit += EMIT_DELAY;
 
        color = SDL_MapRGB(g_pSurfScreen->format,
                    (Uint8)(rand() & 0xff),
                    (Uint8)(rand() & 0xff),
                    (Uint8)(rand() & 0xff));

Über die Schleife wird regelmässig alle 20 Millisekunden ein Partikel erzeugt. Die Farbe wird zufällig gewählt, indem einfach Rot-, Grün- und Blauanteile zufällig bestimmt werden.

        speed = (float)(rand() % 300);
        angle = (float)(rand() * (2.0 * M_PI / RAND_MAX));
        vx = (float)(cos(angle) * speed);
        vy = (float)(sin(angle) * speed);

Auch Geschwindigkeit und Winkel der Flugbahn werden zufällig gewählt. Da die üblichen Kreisfunktionen Winkelangaben im Bogenmaß verwenden ist angle nicht im Gradmaß. 2*pi im Bogenmaß entspricht 360° im Gradmaß. Durch die Division der Zufallszahl durch RAND_MAX erhalte ich einen Wert 0 ⇐ x < 1, da rand() eine Zahl 0 ⇐ x < RAND_MAX zurückgibt. Nach der Multiplikation entsteht ein Wert 0 ⇐ x < 2*pi. Jeder Winkel ist damit gleich wahrscheinlich.

Der Geschwindigkeitsvektor selbst wird dann über die Kreisfunktionen bestimmt.

        P_Create(320, 240, color, vx, vy);
    }
}

Zum Schluß wird das Partikel in der Bildschirmmitte erstellt.

Nun werden noch Funktionen benötigt, um das Partikelsystem zu initialisieren und alle Partikel zu löschen wenn das Programm beendet wird:

void P_Exit(void)
{
    while(g_pParticles)
        P_Remove(g_pParticles);
}
 
void P_Init()
{
    atexit(P_Exit);
    lastemit = curframe;
}

P_Exit() wird über atexit() registriert, so daß es beim Programmende automatisch aufgerufen wird. Zudem wird lastemit initialisiert, damit zu Beginn keine unvernünftigen Werte auftauchen.

Nun müssen alle neuen Funktionen lediglich vom Hauptprogramm aus aufgerufen werden. P_Init() wird noch vor der Hauptschleife aufgerufen. Der Code um Zeile 230 ändert sich damit wie folgt:

    curframe = SDL_GetTicks();
 
    P_Init();
 
    running = 1;
    while(running) {

In der Schleife selbst werden noch P_Think(), EmitParticles() und P_Draw() aufgerufen. Der Code um Zeile 260 sieht damit so aus:

        frametime = curframe - lastframe;
 
        P_Think();
        EmitParticles();
 
        SDL_FillRect(g_pSurfScreen, 0, g_Black);
 
        if (!SDL_LockSurface(g_pSurfScreen)) {
            P_Draw(); /* Zeichne Partikel */
            SDL_UnlockSurface(g_pSurfScreen);
        } else {
            fprintf(stderr, "SDL_LockSurface fehlgeschlagen\n");
        }

Die Partikel sollten jetzt munter von der Bildschirmmitte aus losfliegen.

War das alles?

Natürlich ist das Partikelsystem so bei weitem nicht perfekt. So wäre es ratsam, alle Partikel nach einer bestimmten Zeit zu entfernen und eine maximale Partikelzahl festzulegen, damit das System nicht überlastet werden kann.

Es könnte auch von Vorteil sein, die Partikelstruktur nicht andauernd zu reservieren und wieder freizugeben. Stattdessen könnte man eine Liste von unbenutzten, verfügbaren Partikelstrukturen behalten. Dies könnte das Erstellen und Löschen von Partikeln beschleunigen, und den Heap weniger fragmentieren.

Ganz abgesehen davon fehlen natürlich auch noch gewisse physikalische Dinge. So ist die Bewegung in ihrer momentanen Form nicht wirklich exakt. Um eine Bewegung mit konstanter Beschleunigung exakt zu simulieren müßte man mit Integralen nach der Zeit arbeiten.
Zusätzlich könnten noch Winde und die Luftreibung eingebaut werden. Und um Flammen etc.. zu simulieren sollten die Partikel mit der Zeit ihre Farbe verändern. Aber all diese Dinge überlasse ich erst einmal Euch…

Der Quellcode zu diesem Tutorial sowie die Makefile und MSVC++-Projektdateien sind zum Download verfügbar: (sdl_tut7.tgz sdl_tut7.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.