SDL-Tutorial #10 - Blending und Colorkeys

Autor: Nicolai 'Prefect' Haehnle

Im letzten Tutorial habe ich gezeigt, wie man mit Hilfe von OpenGL blitten kann. Was noch fehlt sind transparente Blits, die für 2D-Spiele natürlich essentiell sind.

First things first

Als allererstes müssen ein paar Vorbereitungen getroffen werden. Um genau zu sein lade ich ein zweites Bild als Textur und gebe es auf dem Bildschirm aus.

Als erstes benötigen wir natürlich eine Variable, um den Texturnamen zu speichern:

unsigned g_texPicture;
unsigned g_texShip;
 
unsigned LoadTexture(const char *filename)

In main() wird die Textur dann geladen:

    g_texPicture = LoadTexture("galaxien.bmp");
    g_texShip = LoadTexture("ship.bmp");
 
    glViewport(0, 0, screen->w, screen->h);

… und wieder freigegeben:

    glDeleteTextures(1, &g_texPicture);
    glDeleteTextures(1, &g_texShip);
 
    return 0;
}

Schließlich muß in RefreshScreen() noch Code eingefügt werden, um die Textur auf den Bildschirm zu bringen:

    glEnd();
 
    glBindTexture(GL_TEXTURE_2D, g_texShip);
 
    glBegin(GL_QUADS);
        glTexCoord2f(0, 0);    glVertex2i(320, 170);
        glTexCoord2f(1, 0);    glVertex2i(320+64, 170);
        glTexCoord2f(1, 1);    glVertex2i(320+64, 170+64);
        glTexCoord2f(0, 1);    glVertex2i(320, 170+64);
    glEnd();
}

Wir weisen OpenGL an, die Textur zu wechseln - das muß außerhalb eines Begin/End-Blocks geschehen. glEnable(GL_TEXTURE_2D) muß aber kein zweites Mal aufgerufen werden - es handelt sich hier um einen permanenten Status. Erst nachdem glDisable(GL_TEXTURE_2D) aufgerufen wurde muß das Texturieren wieder explizit aktiviert werden.

Das Programm kann nun kompiliert werden. Ihr werdet ein wenig zufriedenstellendes Ergebnis sehen, denn die Teile des Bitmaps, die transparent sein sollten, sind natürlich nicht transparent.

Blending, die Erste

Um mit OpenGL Transparenz zu erhalten wird ein Effekt benötigt, der Blending heißt.

Beim Blending wird für jedes Pixel eines gerenderten Polygons die Farbe des Polygons mit der Farbe, die sich an der Position bereits im Framebuffer befindet kombiniert. Ein erster Ansatz sieht so aus:

    glBindTexture(GL_TEXTURE_2D, g_texShip);
 
    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    glColor4ub(255, 255, 255, 160);
 
    glBegin(GL_QUADS);
        glTexCoord2f(0, 0);    glVertex2i(320, 170);
        glTexCoord2f(1, 0);    glVertex2i(320+64, 170);
        glTexCoord2f(1, 1);    glVertex2i(320+64, 170+64);
        glTexCoord2f(0, 1);    glVertex2i(320, 170+64);
    glEnd();
 
    glDisable(GL_BLEND);
}

Nachdem die richtige Textur ausgewählt wurde wird mit glEnable das Blending aktiviert.

Der Aufruf von glBlendFunc ist der wirklich interessante. Hier wird nämlich angegeben, nach welchen Formeln die beiden Farben kombiniert werden sollen. Die Kombination GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA bedeutet, daß das RGB-Triplet der Quellfarbe (also der Farbe des Polygons) mit dem Alphawert des Polygons multipliziert werden soll. Das RGB-Triplet der Zielfarbe (also der bereits vorhandenen Farbe) wird mit 1 minus demselben Alphawert multipliziert (OpenGL behandelt Farbwerte intern als Kommazahlen im Bereich von 0 bis 1 statt, wie bei 2D-APIs üblich, als Ganzzahlen im Bereich von 0 bis 255). Diese Kombination ist wohl die am häufigsten verwendete, denn sie wird für typische Transparenz verwendet.

Der Aufruf von glColor4ub sorgt dafür, daß überhaupt ein interessanter Alphawert vorhanden ist. Würde man den Aufruf weglassen, so wäre kein Unterschied zur vorigen Version bemerkbar. Der vierte Wert ist natürlich der Alphawert: je kleiner er ist, desto transparenter wird das Bild.

Zum Schluß wird Blending wieder deaktiviert, damit nachfolgende Polygone nicht auch transparent gezeichnet werden.

So ist das Programm schon wieder lauffähig, und das Bild wird transparent gezeichnet.
Aber natürlich haben wir noch nicht, was wir eigentlich wollen: schließlich sollen ja bestimmte Pixel des Bitmaps ganz transparent sein und andere gar nicht.

Der Alphakanal

Die Information, welches Pixel nun transparent sein soll und welches nicht, muß in der Textur, genauer gesagt im Alphakanal, gespeichert werden.

Es wird Zeit, die Funktion LoadTexture zu überarbeiten. Zunächst soll sie vier neue Parameter entgegennehmen: ob wir einen Colorkey wünschen, und der zugehörige RGB-Wert:

unsigned LoadTexture(const char *filename, int withcolorkey, int r, int g, int b)
{
    SDL_Surface *bitmap, *conv;
    unsigned texture;
    int format;
 
    bitmap = SDL_LoadBMP(filename);
    if (!bitmap) {
        fprintf(stderr, "%s: konnte nicht geladen werden\n", filename);
        exit(1);
    }

format werden wir später noch verwenden.

    if (withcolorkey) {
        Uint32 color = SDL_MapRGB(bitmap->format, (Uint8)r, (Uint8)g, (Uint8)b);
        SDL_SetColorKey(bitmap, SDL_SRCCOLORKEY, color);
    }
 
/* Konvertiere ins 32-Bit RGBA-Format */
    conv = SDL_CreateRGBSurface(SDL_SWSURFACE, bitmap->w, bitmap->h, 32,
#if SDL_BYTEORDER == SDL_BIG_ENDIAN
            0xff000000, 0x00ff0000, 0x0000ff00, 0x000000ff);
#else
            0x000000ff, 0x0000ff00, 0x00ff0000, 0xff000000);
#endif
    SDL_BlitSurface(bitmap, 0, conv, 0);

In dem neu eingefügten if()-Block teilen wir SDL mit, daß wir einen Colorkey wünschen. Der Clou ist, daß beim Blit in die konvertierte Surface die transparenten Pixel nicht mehr überschrieben werden. Da aber der Alphawert für die ganze Surface am Anfang konsequent auf 0 gesetzt ist und erst beim Blitten an den relevanten, nicht-transparenten Stellen auf 255 gesetzt wird erreichen wir genau das gewünschte Resultat.

    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D, texture);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glPixelStorei(GL_UNPACK_ROW_LENGTH, conv->pitch / conv->format->BytesPerPixel);
 
    if (withcolorkey)
        format = 4;
    else
        format = 3;
    glTexImage2D(GL_TEXTURE_2D, 0, format, conv->w, conv->h, 0, GL_RGBA,
        GL_UNSIGNED_BYTE, conv->pixels);
    glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
 
    SDL_FreeSurface(bitmap);
    SDL_FreeSurface(conv);
 
    return texture;
}

Natürlich muß man OpenGL auch anweisen, den Alphakanal intern zu speichern. Man muß also ein anderes internes Format übergeben. Ich habe im Beispiel 4 verwendet, also einfaches RGBA. Das ist allerdings nicht unbedingt optimal. OpenGL sucht sich dann nämlich automatisch eine Farbtiefe heraus. In einem 16bit-Farbmodus kommen dabei höchstwahrscheinlich 4 Bit pro Farbkanal heraus. Das hat einen leichten Qualitätsverlust des Farbwertes zur Folge. Um diesen zu vermeiden könnte man als Format GL_RGB5_A1 übergeben. Dadurch wird OpenGL dazu gebracht, das Bitmap mit jeweils 5 Bit für die Farbkanäle und nur einem Bit für den Alphakanal zu speichern. Mehr als 1 Bit braucht man für den Alphakanal in unserem Fall ja nicht - ein Pixel ist entweder transparent oder nicht.

Nun sollte das Bild korrekt mit „Colorkey“ geblittet werden.

Noch mehr Blending

Natürlich kann man das Blending vom Alphakanal auch mit einem durch glColor4 übergebenen Alphawert modifizieren. Blending bietet aber noch mehr Möglichkeiten. Mit der Blendfunktion GL_ZERO, GL_SRC_COLOR kann man zum Beispiel die Farbwerte von Quelle und Ziel miteinander multiplizieren (wiederum als Kommazahlen im Bereich von 0 bis 1). Dieser Effekt wird z.B. für Lightmaps in 3D-Spielen verwendet.

Die Blendfunktion GL_ZERO, GL_ONE_MINUS_SRC_ALPHA kann man zum Beispiel - gepaart mit einem durch glColor4 übergebenen Alphawert - für simple Schatten verwenden.

Wenn man nur genügend mit verschiedenen Kombinationen herumspielt findet man sicher noch mehr Verwendungsmöglichkeiten.

Alpha-Tests

Blending ist schön und gut, und wird für eine ganze Reihe von Effekten auch benötigt. Es hat aber einen kleinen Schönheitsfehler: für jedes Pixel muß die Grafikkarte die Bilddaten aus der Textur und aus dem Framebuffer holen, miteinander verrechnen und wieder in den Framebuffer schreiben.

Das ist aber, wenn man nur Colorkeying verwendet, Bandbreitenverschwendung. Die Bilddaten der Textur müssen in jedem Fall gelesen werden. Für nicht transparente Pixel wäre es aber nicht nötig, die Farbdaten aus dem Framebuffer zu holen, denn sie werden sowieso komplett ersetzt. Transparente Pixel erfordern nicht einmal einen Schreibzugriff auf den Framebuffer.

Natürlich kann man OpenGL auch anweisen, dies auszunutzen. Dafür benötigt man die Alpha-Test-Funktion. Alpha-Testing funktioniert vollkommen analog zum Blending. Hier ist der entsprechend modifizierte Teil von RefreshScreen:

    glBindTexture(GL_TEXTURE_2D, g_texShip);
 
    glEnable(GL_ALPHA_TEST);
    glAlphaFunc(GL_GREATER, 0);
 
    glBegin(GL_QUADS);
        glTexCoord2f(0, 0);    glVertex2i(320, 170);
        glTexCoord2f(1, 0);    glVertex2i(320+64, 170);
        glTexCoord2f(1, 1);    glVertex2i(320+64, 170+64);
        glTexCoord2f(0, 1);    glVertex2i(320, 170+64);
    glEnd();
 
    glDisable(GL_ALPHA_TEST);
}

Wie schon gewohnt wird der Effekt mit glEnable aktiviert und mit glDisable deaktiviert. Mit glAlphaFunc wird die zu verwendende Alphafunktion übergeben. Die Parameter in diesem Fall bedeuten: „Rendere ein Pixel genau dann wenn der Quell-Alphawert größer als 0 ist“. Damit werden transparente Pixel effektiv nicht gerendert, nicht transparente dagegen schon. Der zweite Parameter von glAlphaFunc ist übrigens ein float im Bereich 0..1. Natürlich gibt es noch die zu erwartenden anderen Alphafunktionen: GL_GEQUAL (größer oder gleich), GL_LESS, GL_LEQUAL und GL_EQUAL. Es gibt sogar noch GL_ALWAYS, aber dann kann man den Alpha-Test eigentlich gleich ausschalten ;)

Natürlich reicht der Alpha-Test alleine nicht aus, wenn Teile des Hintergrunds durchscheinen sollen. Da wird dann logischerweise Blending benötigt.

Alpha-Testing könnte man vielleicht auch dazu verwenden, mehrere Bilder in eine einzige Textur zu kodieren, aber das habe ich noch nie ausprobiert. Es handelt sich hier lediglich um einen Denkanstoß.

Dies und das

OpenGL ist im Endeffekt eine State Machine. Praktisch jede Funktion hat zur Folge, das der Zustand von OpenGL in irgendeiner Weise verändert wird: die momentane Farbe wird verändert, die momentane Textur verändert, usw.

Manche dieser Zustandsänderungen gehen sehr schnell, wie zum Beispiel das Setzen einer neuen Farbe.
Andere können dagegen recht zeitaufwendig sein. Besonders die Aufrufe von glEnable und glDisable gehören dazu. Man sollte sie also, wenn möglich, vermeiden.

Um ein interessantes Beispiel zu nennen: Ich habe ursprünglich beim 2D-Blitten immer je nach Texturtyp Blending an- oder ausgeschaltet. Nachdem ich Blending konstant angeschaltet ließ hat sich die Framerate zum Teil fast verdoppelt. Das Blending hat auf Texturen ohne Alphakanal ja keinen Seiteneffekt, da alle Pixel dieser Texturen einen impliziten Alphawert von 255 (bzw. 1.0) haben.

Andererseits geht das natürlich auch auf die Speicherbandbreite der Grafikkarte, wenn viele nicht-transparente Texturen, z.B. im Hintergrund, gerendert werden. Es rentiert sich wohl vor allem, wenn man in schneller Abfolge, und schlecht vorhersehbar, transparente und nicht transparente Texturen rendert.

Wenn Zustandsänderungen unvermeidbar sind, so kann man wenigstens dafür sorgen, daß sie, wenn möglich, abgefangen werden. Es ist bei manchen Funktionen sehr viel schneller, den Zustand im Programm selbst zu speichern, und nur im Fall von tatsächlichen Änderungen OpenGL aufzurufen.

Das wird an den folgenden zwei Beispielfunktionen verdeutlicht:

int gl_state_texenable = 0;
unsigned gl_state_texture = 0;
 
static __inline__ void GL_TexEnable(int enable)
{
    if (gl_state_texenable == enable)
        return;
 
    if (enable)
        glEnable(GL_TEXTURE_2D);
    else
        glDisable(GL_TEXTURE_2D);
 
    gl_state_texenable = enable;
}
 
static __inline__ void GL_Bind(unsigned texture)
{
    if (gl_state_texture == texture)
        return;
 
    glBindTexture(GL_TEXTURE_2D, texture);
 
    gl_state_texture = texture;
}

Die erste Funktion kann statt glEnable/glDisable(GL_TEXTURE_2D) verwendet werden, während die zweite Funktion glBindTexture ersetzt. Indem die Funktionen den momentanen Zustand in einer globalen Variable speichern können sie einige OpenGL-Aufrufe einsparen. Das erhöht natürlich die Gesamtgeschwindigkeit.

GL_Bind hat allerdings einen kleinen Haken: Es kann ja durchaus vorkommen, daß eine Textur, die gerade eben noch ausgewählt war gelöscht wird. Wenn nun gleich darauf eine neue Textur mit glGenTextures erstellt wird, dann verwendet OpenGL möglicherweise denselben Texturindex. GL_Bind verhindert jedoch, daß die Textur erneut mit glBindTexture ausgewählt wird. Der Treiber hat beim Löschen der Textur aber wahrscheinlich „vergessen“, daß die Textur mit diesem Index gebindet wurde. Letztendlich führt das dazu, daß Texturen nicht hochgeladen, und daher nicht dargestellt werden. Das Problem kann gelöst werden, indem man beim Löschen der gerade ausgewählten Textur gl_state_texture auf 0 setzt.

Übrigens habe ich die beiden Funktionen als Inlinefunktionen deklariert. Das hat zur Folge, daß ihr Code direkt in die aufrufende Funktion eingebaut wird. Dadurch können einige Assemblerbefehle und Prozessorzyklen eingespart werden, und die Kosten für das Verwenden einer Funktion sind quasi gleich null. Nur wird das Schlüsselwort inline nicht von allen C-Compilern unterstützt (bei C++ ist inline Teil vom Standard). In der SDL.h-Headerdatei befinden sich aber einige Makros, die dafür sorgen, daß man inline immer verwenden kann. Wenn der Compiler keine Inlinefunktionen unterstützt hat inline dann einfach keinen Effekt, führt aber nicht zu einer Fehlermeldung des Compilers.

Es ist natürlich aufwendig, für jeden Blitvorgang erneut ein Begin/End-Paar mit allen zugehörigen Funktionen zu schreiben. Es wäre schön, die entsprechenden Aufrufe in eine getrennte Funktion zu stecken. Dadurch wird der Code weniger fehleranfällig, kleiner und übersichtlicher.
Die folgende Funktion tut genau das:

void Blit(unsigned texture, int x, int y, int w, int h)
{
    GL_TexEnable(1);
    GL_Bind(texture);
 
    glBegin(GL_QUADS);
        glTexCoord2f(0, 0);    glVertex2i(x, y);
        glTexCoord2f(1, 0);    glVertex2i(x+w, y);
        glTexCoord2f(1, 1);    glVertex2i(x+w, y+h);
        glTexCoord2f(0, 1);    glVertex2i(x, y+h);
    glEnd();
}

Damit reduziert sich die Funktion RefreshScreen() auf folgendes:

void RefreshScreen()
{
    glColor3ub(255, 255, 255);
 
    glDisable(GL_ALPHA_TEST);
 
    Blit(g_texPicture, 0, 0, 640, 480);
 
    glEnable(GL_ALPHA_TEST);
    glAlphaFunc(GL_GREATER, 0);
 
    Blit(g_texShip, 320, 170, 64, 64);
}

Ihr solltet nun alle Werkzeuge haben die benötigt werden, um simple 2D-Programme mit OpenGL zu schreiben. Natürlich bietet OpenGL noch viel mehr Möglichkeiten als in diesem Tutorial verwendet werden. Auch wenn ihr nicht vorhabt, 3D-Spiele zu schreiben solltet ihr die entsprechenden Tutorials, die man online findet (z.B. auf NeHe) zumindest einmal anschauen. Vielleicht findet ihr ja einen netten Effekt, den man auch für 2D-Spiele verwenden kann.

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

Das von mir verwendete Bild „galaxien.bmp“ stammt übrigens vom Hubble Space Telescope, und kann über die Homepage der NASA heruntergeladen werden. Dort findet Ihr auch die Bestimmungen zur Nutzung des Bildes.

Das Bild „ship.bmp“ kommt von Return to the Shadows.

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_10.txt · Zuletzt geändert: 2010/07/29 20:53 von Adrian_Broher