Tutoriel SDL: Effet de feu!

Paru dans Linux Mag 68, janvier 2005

Ce mois-ci, nous allons nous plonger de manière plus approfondie dans la manipulation des surfaces de SDL en implémentant un effet de feu. Cet effet, l'un des plus vieux effets graphiques qui existent, est très simple à implémenter et la multitude de variantes qui existent autour de lui le rendent particulièrement intéressant pour le débutant en programmation graphique qui peut l'expérimenter avec de multiples paramètres.

Principe de l'effet

L'effet de feu est algorithmiquement très simple, pourtant il donne un résultat visuel très convainquant (voir figure 1). Mais s'il est facile à implémenter, il n'en reste pas moins qu'il coûte assez cher en temps de calcul, pour la simple raison que chaque pixel de la zone sur laquelle s'applique l'effet doit être recalculé en fonction de 4 de ses voisins. Cela implique un nombre très conséquent d'accès mémoire et le réservera en général à des zones assez petites.

Fig. 1
Fig. 1:
L'exemple du mois.

L'algorithme calcule l'état de la nouvelle zone sur laquelle s'applique l'effet en fonction de son état précédent. L'effet de base parcourt la zone ligne par ligne, du haut vers le bas, et affecte à chaque pixel la moyenne des pixels inférieur gauche, inférieur, inférieur droit ainsi que du pixel situé deux lignes en dessous, comme l'illustre la figure 2. C'est tout!

Fig. 2: Principe de l'effet de feu. La nouvelle couleur du pixel gris, en haut, sera la moyenne des couleurs des 4 autres pixels.
Fig. 2:
Principe de l'effet de feu. La nouvelle couleur du pixel gris, en haut, sera la moyenne des couleurs des 4 autres pixels.

Comme il n'y a pas beaucoup de sens à additionner directement des pixels, ce sont les composantes rouge, verte et bleue des quatre pixels adjacents qui seront moyennées pour obtenir les nouvelles valeurs des composantes du pixel en cours de calcul. Il faudra donc à chaque fois les décomposer.

Bien évidemment, puisque l'aspect du feu dépend de l'état précédent de la zone, il va falloir donner à cette dernière un état initial. En fait, comme pour de vraies flammes, il va nous falloir un « départ » du feu, ou un foyer. Celui-ci sera représenté par les deux lignes en dessous de notre effet de feu et aura une mise à jour totalement différente.

Le foyer, comme son nom l'indique, constitue le point de départ de nos flammes. L'état de ces deux lignes va totalement déterminer le contour des flammes et, sans elles, l'effet de feu ne ferait rien d'autre que calculer des pixels noirs. Le foyer ainsi que la manière dont il est mis à jour est donc tout aussi important que l'effet de feu en lui-même.

La manière de le gérer restera cependant elle aussi d'une simplicité enfantine: il suffira d'allumer et d'éteindre au hasard les pixels de ces deux lignes. Cela sera suffisant pour démarrer l'effet et conditionnera totalement l'aspect des flammes. Pour garder un ensemble homogène, les pixels allumés le seront avec une couleur dont les trois composantes sont uniformes (un gris plus ou moins clair). Bien évidemment, plus le gris du pixel sera clair, plus la flamme qui l'exploitera montera haut. De même, plus il y aura de pixels adjacents allumés, plus les flammes seront denses. On distinguera trois paramètres de l'évolution du foyer qui nous permettront d'expérimenter plusieurs variantes de flammes:

La figure 3 illustre un état du foyer. Sur l'exemple montré, les flammes monteront très haut, car beaucoup de pixels sont allumés avec une intensité suffisante.

Fig. 3: Un exemple de foyer de 22 pixels de long sur deux lignes. À chaque mise à jour, des pixels sont allumés ou éteints aléatoirement.
Fig. 3: Un exemple de foyer de 22 pixels de long sur deux lignes. À chaque mise à jour, des pixels sont allumés ou éteints aléatoirement.

Implémentation avec SDL

L'implantation de cet effet avec SDL nous donne l'occasion de découvrir les surfaces un peu plus en détail. Lors du premier épisode, nous avions écrit des fonctions permettant de lire ou d'écrire un pixel d'une surface, et nous avions utilisé les fonctions que fournit SDL pour obtenir la valeur d'un pixel pour un format de surface donné en fonction de ses trois composantes. Cette fois-ci, nous allons écrire nos propres macros nous permettant de faire ces opérations, ce qui sera autrement plus performant que d'appeler une fonction à chaque manipulation de pixel, en plus d'être instructif. En contrepartie, notre implantation aura quelques limites :

Les surfaces en profondeur

Jusqu'à présent, notre utilisation des surfaces se limitait à les passer en paramètres de fonctions définies par SDL et, parfois, à accéder directement aux pixels via le champ homonyme. Il est cependant tout aussi important, en particulier pour l'implantation d'effets graphiques, de connaître précisément le format de stockage des pixels. On peut facilement savoir si une surface utilise 16 ou 24 bits par pixels, mais cela ne veut pas dire grand chose (bien que dans 99% des cas on retrouve la même structure). Par exemple, en format 24 bits, quel est l'ordre dans lequel sont stockées les composantes? Nous sommes habitués au format RGB (Red, Green, Blue) mais rien n'empêche le matériel d'utiliser autre chose. C'est le membre format de SDL_Surface qui va nous renseigner sur les subtilités du format d'une surface. Il est du type SDL_PixelFormat et fournit notamment les champs suivants :

Fig. 4: Utilisation des membres Rmask et Rshift pour isoler la composante rouge d'un pixel.
Fig. 4: Utilisation des membres Rmask et Rshift pour isoler la composante rouge d'un pixel.

Ces informations nous permettront d'extraire les composantes des pixels et de construire des pixels à partir de ces composantes de manière plus efficace que si l'on appelait directement SDL_GetRGB et SDL_MapRGB.

Implémentation de l'effet

Comme vous l'aurez peut-être déjà deviné, l'effet de feu sera en fait constitué de deux effets : la montée des flammes et leur génération, c'est-à-dire le foyer. Chacun de ces effets sera géré par une fonction qui prendra en paramètres la surface sur laquelle appliquer l'effet ainsi qu'un SDL_Rect pour indiquer sa zone de délimitation. Nous allons implémenter nos effets dans deux fichiers fire.h et fire.c. Voici le contenu de fire.h, qui déclare nos deux fonctions:

#ifndef FIRE_H__ 
#define FIRE_H__

#include "SDL.h"

/**
* Génère un foyer gris sur la surface donnée, dans les limites de
* rect, en fonction des paramètres intensity, vivacity et résistance.
* Attention: la surface doit être verrouillée!
*/
void fireRoot(SDL_Surface * surface, SDL_Rect * rect, Uint8 intensity, Uint8 vivacity,
Uint8 resistance);

/**
* Applique l'effet de feu sur la surface donnée, dans les limites de
* rect. Attention: la surface doit être verrouillée!
*/
void fire(SDL_Surface * surface, SDL_Rect * rect);

#endif

fire.c quant à lui commencera par les déclarations des macros nous permettant de manipuler les pixels. REDVALUE, GREENVALUE et BLUEVALUE isolent les composantes de couleurs d'un pixel comme décrit précédemment et donnent leur valeur comprise entre 0 et 255.

#include <stdlib.h>
#include <stdlib.h>
#include "fire.h"

/* Retourne les valeurs sur 8 bits des composantes rouges, vertes et
bleues par rapport au format donné */
#define REDVALUE(format, pixel) (Uint8) (((pixel & format->Rmask) << format->Rloss) \
>> format->Rshift)
#define GREENVALUE(format, pixel) (Uint8) (((pixel & format->Gmask) << format->Gloss) \
>> format->Gshift)
#define BLUEVALUE(format, pixel) (Uint8) (((pixel & format->Bmask) << format->Bloss) \
>> format->Bshift)

Les opérations inverses, obtenir le masque correspondant à une composante de couleur à partir de sa valeur sur 8 bits, sont également fournies, ainsi qu'une autre macro pour obtenir un pixel directement utilisable à partir des composantes:

/* Crée les masques correspondants pour les composantes de couleur
données sur 8 bits */
#define REDMASK(format, red) (Uint32) (((red << format->Rshift) \
>> format->Rloss) & format->Rmask)
#define GREENMASK(format, green) (Uint32) (((green << format->Gshift) \
>> format->Gloss) & format->Gmask)
#define BLUEMASK(format, blue) (Uint32) (((blue << format->Bshift) \
>> format->Bloss) & format->Bmask)

/* Retourne la valeur d'un pixel selon ses 3 composantes de couleur,
données sur 8 bits */
#define MAKEPIXEL(format, red, green, blue) (Uint32) (REDMASK(format, red) \
| GREENMASK(format, green) | BLUEMASK(format, blue))

Enfin, deux dernières macros s'occupent d'écrire et de lire correctement un pixel codé sur 24 bits à partir d'un pointeur donné en paramètre:

/* Lit et affecte un pixel à partir d'un pointeur */
#define READPIXEL(pointer, pixel) { pixel = *(Uint16 *)(pointer) + \
(*(Uint8 *)((pointer) + 2) << 16); }
#define STOREPIXEL(pointer, pixel) { *(Uint16 *)(pointer) = (Uint16)(pixel); \
*(Uint8 *)((pointer) + 2) = (Uint8)((pixel) >> 16) & 0xff; }

Ces macros étant définies, nous pouvons attaquer les effets proprement dits. Le foyer ne posera pas de problèmes particuliers. Il s'agira juste, dans un premier temps, d'allumer des pixels à des positions et des intensités aléatoires en fonction des paramètres vivacity et intensity, puis, dans un deuxième temps, d'en éteindre d'autres en fonction du paramètre resistance.

/* Effet de foyer */ 
void fireRoot(SDL_Surface * surface, SDL_Rect * rect, Uint8 intensity, Uint8 vivacity, \
Uint8 resistance) {
Uint32 i, j;

/* Pour chaque ligne du foyer, la vivacité détermine combien de
pixels mettre à jour */
for (j = rect->y; j < rect->y + rect->h; j++)
for (i = 0; i < vivacity; i++) {
/* Déterminer la valeur de chaque composante en fonction de
l'intensité - elles auront les mêmes valeurs, ce qui nous
fournira un gris */
Uint8 color = (intensity + (rand() % (256 - intensity)));
/* Choisir une position au hasard */
Uint32 position = (rand() % rect->w) + rect->x;

/* Et y affecter le pixel */
STOREPIXEL(surface->pixels + j * surface->pitch + position *
surface->format->BytesPerPixel,
MAKEPIXEL(surface->format, color, color, color));
}

/* Même chose avec la résistance - on éteint aléatoirement des
pixels */
for (j = rect->y; j < rect->y + rect->h; j++)
for (i = 0; i < resistance; i++) {
Uint32 position = (rand() % rect->w) + rect->x;

STOREPIXEL(surface->pixels + j * surface->pitch + (position *
surface->format->BytesPerPixel), 0);
}
}

Enfin, l'effet de feu. Il sera implémenté fidèlement à notre description. Chaque pixel de la zone parcourue est re-calculé en faisant la moyenne de ses quatre voisins.

/* Effet de feu */
void fire(SDL_Surface * surface, SDL_Rect * rect) {
Uint32 i, j;

/* Chaque pixel de la zone doit être mis à jour */
for (j = rect->y; j < rect->y + rect->h; j++)
for (i = rect->x; i < rect->x + rect->w; i++) {
/* Pointeur vers le pixel courant */
void * curpixel;
/* Nouvelle valeur du pixel courant */
Uint32 pixvalue = 0;
/* Addition des valeurs des composantes rouges, vertes et bleues
des pixels adjacents au pixel courant */
Uint16 red = 0, green = 0, blue = 0;
/* Le pixel à mettre à jour */
curpixel = surface->pixels + (surface->pitch * j) +
(i * surface->format->BytesPerPixel);
/* Addition des composantes des 4 pixels adjacents */
/* Inférieur gauche */
if (i > 0) {
READPIXEL(curpixel + surface->pitch - surface->format->BytesPerPixel,
pixvalue);
red += REDVALUE(surface->format, pixvalue);
green += GREENVALUE(surface->format, pixvalue);
blue += BLUEVALUE(surface->format, pixvalue);
}

/* Inférieur */
READPIXEL(curpixel + surface->pitch, pixvalue);
red += REDVALUE(surface->format, pixvalue);
green += GREENVALUE(surface->format, pixvalue);
blue += BLUEVALUE(surface->format, pixvalue);

/* Deux lignes en dessous */
READPIXEL(curpixel + surface->pitch * 2, pixvalue);
red += REDVALUE(surface->format, pixvalue);
green += GREENVALUE(surface->format, pixvalue);
blue += BLUEVALUE(surface->format, pixvalue);

/* Inférieur droit */
if (i < surface->w - 1) {
READPIXEL(curpixel + surface->pitch + surface->format->BytesPerPixel,
pixvalue);
red += REDVALUE(surface->format, pixvalue);
green += GREENVALUE(surface->format, pixvalue);
blue += BLUEVALUE(surface->format, pixvalue);
}

/* Altération des composantes afin d'obtenir la teinte de la
flamme */
if (red > 2) red -= 2; else red = 0;
if (green > 5) green -= 5; else green = 0;
if (blue > 150) blue -= 150; else blue = 0;

/* Division par quatre de l'addition des composantes */
red /= 4; green /= 4; blue /= 4;

/* Affectation de la nouvelle valeur du pixel */
STOREPIXEL(curpixel, MAKEPIXEL(surface->format, red, green, blue));
}
}

Vous aurez remarqué que, avant la division de la somme des quatre pixels adjacents, il y a une phase d'altération. Le bleu est sévèrement reduit, le vert un peu moins, et le rouge encore moins. C'est ce qui donnera la teinte de couleur à nos flammes et les fera tourner très vite au jaune, puis au rouge en fin de course. Sans cette étape, les flammes resteront grises et l'effet ressemblera alors plus à de la fumée. Vous pouvez expérimenter en supprimant ou modifiant cette étape.

Enfin, comme de coutume, voici le programme principal utilisant le module que nous avons implémenté. Il offre plusieurs voies d'interaction: le paramètre d'intensité de foyer peut être modifié à l'aide des touches haut et bas, la résistance avec les touches gauche et droite et la vivacité avec pageup/pagedown. La touche entrée fera s'afficher sur l'écran l'image du logo SDL qui subira automatiquement l'effet de feu et se « vaporisera » en flammes. Le programme en lui-même n'a rien de compliqué et ne fait que reprendre des éléments déjà vus précedemment. Voici son listing, à mettre dans main.c:

#include <stdio.h>
#include <stdlib.h>
#include "SDL.h"

#include "fire.h"

/* Variables d'intensités, de vivacité et de résistance */
#define DEFAULT_INTENSITY 150
#define DEFAULT_VIVACITY 128
#define DEFAULT_RESISTANCE 75

static Uint8 intensity = DEFAULT_INTENSITY;
static Uint8 vivacity = DEFAULT_VIVACITY;
static Uint8 resistance = DEFAULT_RESISTANCE;

static Sint8 intensity_delta = 0;
static Sint8 vivacity_delta = 0;
static Sint8 resistance_delta = 0;

/* Mise à 1 s'il faut quitter */
Uint8 letsexit = 0;

SDL_Surface * screen, * image;

/* Gestion des entrées clavier */
void input_update() {
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_KEYDOWN:
switch (event.key.keysym.sym) {
/* Quitter */
case SDLK_ESCAPE:
letsexit = 1;
break;
/* Enflammement du logo SDL - il suffit de le dessiner
et de laisser l'effet de feu faire le reste */
case SDLK_RETURN:
{
SDL_Rect blitRect = { 0, 0, 0, 0 };
blitRect.x = (rand() % (screen->w - image->w));
blitRect.y = (rand() % (screen->h - image->h));

SDL_BlitSurface(image, NULL, screen, &blitRect);
break;
}
/* Augmenter intensité */
case SDLK_UP:
intensity_delta = 1;
break;
/* Diminuer intensité */
case SDLK_DOWN:
intensity_delta = -1;
break;
/* Augmenter résistance */
case SDLK_RIGHT:
resistance_delta = 1;
break;
/* Diminuer résistance */
case SDLK_LEFT:
resistance_delta = -1;
break;
/* Augmenter vivacité */
case SDLK_PAGEUP:
vivacity_delta = 1;
break;
/* Diminuer vivacité */
case SDLK_PAGEDOWN:
vivacity_delta = -1;
break;
default:
break;
}
break;
/* Remise à zéro du delta des variables de paramètre du foyer si
une touche leur correspondant est relâchée */
case SDL_KEYUP:
switch (event.key.keysym.sym) {
case SDLK_UP:
case SDLK_DOWN:
intensity_delta = 0;
break;
case SDLK_RIGHT:
case SDLK_LEFT:
resistance_delta = 0;
break;
case SDLK_PAGEUP:
case SDLK_PAGEDOWN:
vivacity_delta = 0;
break;
default:
break;
}
break;
default:
break;
}
}
}

int main(int argc, char * argv[]) {
/* Rectangles dans lesquels vont s'appliquer le foyer et l'effet de
feu */
SDL_Rect rootRect, fireRect;
/* Surface temporaire pour charger l'image */
SDL_Surface * tmp;

if (SDL_Init(SDL_INIT_VIDEO) == -1) {
printf("Erreur lors de l'initialisation de SDL!\n");
return 1;
}

screen = SDL_SetVideoMode(320, 240, 24,
SDL_SWSURFACE);

/* Le foyer est appliqué aux deux dernières lignes */
rootRect.x = 0;
rootRect.y = screen->h - 2;
rootRect.w = screen->w;
rootRect.h = 2;

/* L'effet de feu s'applique sur tout l'écran, à l'exception
des deux dernières */
fireRect.x = 0;
fireRect.y = 0;
fireRect.w = screen->w;
fireRect.h = screen->h - 2;

/* Création de l'image du logo SDL */
tmp = SDL_LoadBMP("image.bmp");
image = SDL_DisplayFormat(tmp);
SDL_FreeSurface(tmp);
SDL_SetColorKey(image, SDL_SRCCOLORKEY, SDL_MapRGB(image->format, 0xff, 0x00, 0xff));

/* Boucle principale */
while (!letsexit) {
/* Mise à jour des entrées */
input_update();

/* Mise à jour des paramètre du foyer */
if ((intensity + intensity_delta) < 0) intensity = 0;
else if ((intensity + intensity_delta) > 255) intensity = 255;
else intensity += intensity_delta;
if (intensity_delta != 0) printf("Intensité: %d\n", intensity);

if ((vivacity + vivacity_delta) < 0) vivacity = 0;
else if ((vivacity + vivacity_delta) > 255) vivacity = 255;
else vivacity += vivacity_delta;
if (vivacity_delta != 0) printf("Vivacité: %d\n", vivacity);

if ((resistance + resistance_delta) < 0) resistance = 0;
else if ((resistance + resistance_delta) > 255) resistance = 255;
else resistance += resistance_delta;
if (resistance_delta != 0) printf("Résistance: %d\n", resistance);

/* Ne pas oublier de verrouiller la surface que l'on va directement
modifier! */
SDL_LockSurface(screen);
fireRoot(screen, &rootRect, intensity, vivacity, resistance);
fire(screen, &fireRect);
SDL_UnlockSurface(screen);

/* Mise à jour de l'écran */
SDL_Flip(screen);
}

SDL_Quit();
return 0;
}

Le Makefile suivant permet de compiler tout cela vers l'exécutable fire:

CFLAGS = `sdl-config --cflags` -Wall -ansi -g -O3 
LIBS = `sdl-config --libs`

OBJECTS = main.o fire.o

all: $(OBJECTS)
$(CC) $(OBJECTS) -o fire $(LIBS)

clean:
rm -f *.o fire

Comme d'habitude, toutes les sources se trouvent sur le CD qui accompagnent le magazine, ou à l'adresse http://www.gnurou.org/documents/linuxmag/SDL/SDL-4.tar.gz.

Variantes

Il existe une multitude de variantes à l'effet de feu. Vous pouvez jouer avec la variation de teinte des flammes, les paramètres du foyer, mais aussi avec le nombre de pixels adjacents pris en compte (3, 6, ...). En partant de la logique qui consiste à calculer le nouvel état de chaque pixel en fonction de l'état précédent de ses voisins, il y a beaucoup d'autres effets à découvrir. N'hésitez pas à expérimenter!