- Published on
Animation défilement en SDL2 efficace à base de tuiles
- Authors
- Name
- Anthony Rabine
Avec la librairie SDL2, quelques fonctions suffisent à afficher des images. Or, il faut creuser un peu la chose lorsque l'on souhaite réaliser un algorithme propre. Dans cet article, nous allons créer une image de fond avec animation, à base de tuiles, de manière la plus efficace possible (et compatible avec plusieurs résolutions). Cet article peut faire office de petite introduction à SDL2 !
Objectifs
Notre objectif est d'afficher une image et de la faire défiler horizontalement de manière efficaces. Une petite animation que j'ai utilisé dans le but de tester plusieurs moteurs d'affichages pour un jeu vidéo.
Voici l'objectif, sans animation, en fin d'article nous afficherons un Gif, quand même !
Squelette SDL2 et boucle principale
Pour travailler, nous allons réaliser une première petite application minimale en SDL2 qui sera composée de la boucle d'événements classique.
Tout d'abord, nous allons initialiser SDL et le loader OpenGL GLAD. Suite à cette initialisation, nous disposerons d'une fenêtre qui s'affiche.
static SDL_Window *gWindow;
static SDL_GLContext gl_context;
static int width = 896;
static int height = 504;
int sdl_init(void)
{
// initiate SDL
if (SDL_Init(SDL_INIT_TIMER | SDL_INIT_AUDIO | SDL_INIT_VIDEO | SDL_INIT_EVENTS |
SDL_INIT_JOYSTICK | SDL_INIT_HAPTIC | SDL_INIT_GAMECONTROLLER) != 0)
{
printf("[ERROR] %s\n", SDL_GetError());
return -1;
}
// set OpenGL attributes
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
SDL_GL_SetAttribute(
SDL_GL_CONTEXT_PROFILE_MASK,
SDL_GL_CONTEXT_PROFILE_CORE
);
std::string glsl_version = "";
#ifdef __APPLE__
// GL 3.2 Core + GLSL 150
glsl_version = "#version 150";
SDL_GL_SetAttribute( // required on Mac OS
SDL_GL_CONTEXT_FLAGS,
SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG
);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
#elif __linux__
// GL 3.2 Core + GLSL 150
glsl_version = "#version 150";
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
#elif _WIN32
// GL 3.0 + GLSL 130
glsl_version = "#version 130";
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
#endif
SDL_WindowFlags window_flags = (SDL_WindowFlags)(
SDL_WINDOW_OPENGL
| SDL_WINDOW_RESIZABLE
| SDL_WINDOW_ALLOW_HIGHDPI
);
gWindow = SDL_CreateWindow(
"TarotClub",
SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED,
width,
height,
window_flags
);
// limit to which minimum size user can resize the window
SDL_SetWindowMinimumSize(gWindow, width, height);
gl_context = SDL_GL_CreateContext(gWindow);
SDL_GL_MakeCurrent(gWindow, gl_context);
// enable VSync
SDL_GL_SetSwapInterval(1);
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
if (!gladLoadGLLoader((GLADloadproc)SDL_GL_GetProcAddress))
{
std::cerr << "[ERROR] Couldn't initialize glad" << std::endl;
}
else
{
std::cout << "[INFO] glad initialized\n";
}
glViewport(0, 0, width, height);
return 0;
}
Notre main va comporter :
- L'appel de la fonction précédente sdl_init()
- La création d'un moteur de rendu sur lequel on va pouvoir dessiner
- Une boucle d'affichage :
- Détection des événements SDL (clavier / souris ...)
- calcul du tick (base de temps) qui sera envoyée à tous les composants d'affichage
- Un petit système de mesure de temps d'exécution que nous utiliserons plus tard
- Enfin, lorsque le programme sera quitté, on affichera le temps d'exécution de nos routines de dessin
Ni plus ni moins.
int main()
{
sdl_init();
// Setup renderer
SDL_Renderer * renderer = SDL_CreateRenderer( gWindow, -1, SDL_RENDERER_ACCELERATED);
Uint64 currentTick = 0;
Uint64 lastTick = 0;
double deltaTime = 0;
glClearColor(35/255.0f, 35/255.0f, 35/255.0f, 1.00f);
currentTick = SDL_GetPerformanceCounter();
uint64_t cumul = 0;
uint32_t iter = 0;
bool loop = true;
while (loop)
{
SDL_RenderClear(renderer);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
SDL_Event event;
while (SDL_PollEvent(&event))
{
switch (event.type)
{
case SDL_QUIT:
loop = false;
break;
case SDL_WINDOWEVENT:
switch (event.window.event)
{
case SDL_WINDOWEVENT_CLOSE:
loop = false;
break;
case SDL_WINDOWEVENT_RESIZED:
width = event.window.data1;
height = event.window.data2;
// std::cout << "[INFO] Window size: "
// << windowWidth
// << "x"
// << windowHeight
// << std::endl;
glViewport(0, 0, width, height);
break;
}
break;
}
}
lastTick = currentTick;
currentTick = SDL_GetPerformanceCounter();
deltaTime = (double)((currentTick - lastTick)*1000 / (double)SDL_GetPerformanceFrequency() );
auto start = std::chrono::system_clock::now();
// ====== AFFICHAGE SDL ICI ======
iter++;
auto end = std::chrono::system_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
cumul += elapsed.count();
// rendering
SDL_RenderPresent(renderer);
SDL_GL_SwapWindow(gWindow);
}
std::cout << "Moyenne: " << cumul / iter << std::endl;
return 0;
}
Au début de la boucle, des instructions permettent de dire à OpenGL/SDL que l'on va dessiner dans une zone mémoire en dehors de l'affichage. À la fin de la boucle, cette zone sera échangée avec l'affichage en cours. Ainsi, on peut prendre "son temps" pour afficher des choses (calculs, variables d'un jeu, traitement des événements et des effets, etc.).
Affichage d'une image de fond de manière naïve
Nous désirons afficher une image de fond à notre application SDL. Prenons une résolution de base avec un ratio 16/9, par exemple 896px x 504px.
Une première technique est d'afficher une image de cette taille. Bien entendu, cela n'est pas super optimisé :
- Si on change de résulution, il faut affiche une nouvelle image
- Il faut donc disposer de plusieurs images en stock
- Notre fond est constitué d'un motif de base qui est répété, on pourrait le faire pragmatiquement
Niveau code, facile, on charge l'image dans une texture :
tile_texture = IMG_LoadTexture(renderer, "background.png");
SDL_QueryTexture(tile_texture, NULL, NULL, &tile_rect.w, &tile_rect.h);
tile_rect.x = 0;
tile_rect.y = 0;
Et on affiche dans la boucle d'affichage :
void draw_background(SDL_Renderer *renderer, double deltaTime)
{
SDL_RenderCopyEx(renderer, tile_texture, NULL, &tile_rect, 0.0, NULL, SDL_FLIP_NONE);
}
On lance le programme et on récupère deux statistiques :
- L'usage mémoire, donné avec l'utilitaire Valgind
- Le temps d'exécution moyen
valgrind --tool=massif ./sdl2-tiles-scrolling
==48786== Massif, a heap profiler
==48786== Copyright (C) 2003-2017, and GNU GPL'd, by Nicholas Nethercote
==48786== Using Valgrind-3.17.0 and LibVEX; rerun with -h for copyright info
==48786== Command: ./sdl2-tiles-scrolling
==48786==
[INFO] glad initialized
Moyenne: 16
==48786==
Nous obtenons donc 16 micro-secondes de temps d'exécution. Voici ce que donne l'usage mémoire au cours du temps :
Nous avons un petit pic de consommation à 14 Mo, probablement lors du chargement de l'image justement. Pour de grosses résolutions, ce genre de pic peut être problématique, même si de nos jours la RAM ce n'est pas ce qu'il manque sur PC, mais pensons multi-plateformes !
La multiplication des tuiles
Vu que notre fond d'écran est composé d'un motif simple, nous allons ruser et copier-coller simplement en fonction de la résolution. Partons du principe que nos résolutions auront toujours un ratio 16/9 pour simplifier les choses.
Voyons la liste des résolutions de type 16/9 :
Largeur | Différence | Hauteur | Différence |
---|---|---|---|
896 | 504 | ||
1024 | 128 | 576 | 72 |
1152 | 128 | 648 | 72 |
1280 | 128 | 720 | 72 |
Nous avons à chaque fois le même multiplicateur ce qui va nous permettre de créer une tuile de base, à nous de choisir sa hauteur et sa largeur.
Dans notre cas, nous options pour une tuile de 64 x 72 que nous allons afficher 14 fois en largeur pour atteindre 896 pixels et 7 fois en hauteur pour atteindre 504 pixels de hauteur.
tile_texture = IMG_LoadTexture(renderer, "tile.png");
SDL_QueryTexture(tile_texture, NULL, NULL, &tile_rect.w, &tile_rect.h);
tile_rect.x = 0;
tile_rect.y = 0;
tile_rect.x = 0;
tile_rect.y = 0;
for (int i = 0; i < 7; i++)
{
for (int j = 0; j < 14; j++)
{
SDL_RenderCopyEx(renderer, tile_texture, NULL, &tile_rect, 0.0, NULL, SDL_FLIP_NONE);
tile_rect.x += tile_rect.w;
}
tile_rect.y += tile_rect.h;
tile_rect.x = 0;
}
Malheur sur le CPU ! À chaque frame, on redessine tout. La moyenne passe à 70us de temps d'affichage.
Dessin dans une texture
La solution est donc de fabriquer une texture à base de notre tuile. Cette construction se fera une seule fois, au démarrage du programme par exemple ou à l'initialisation de la scène.
Le code d'initialisation est un peu plus compliqué
Uint32 pixelFormat;
tile_texture = LoadImage(renderer, "tile.png");
SDL_QueryTexture(tile_texture, &pixelFormat, NULL, &tile_rect.w, &tile_rect.h);
tile_rect.x = 0;
tile_rect.y = 0;
// On crée une texture en mémoire de la taille de l'écran
big_texture = SDL_CreateTexture(renderer, pixelFormat, SDL_TEXTUREACCESS_TARGET, width, height);
// On change la destination du moteur de rendu vers notre texture
SDL_SetRenderTarget(renderer, big_texture);
tile_rect.x = 0;
tile_rect.y = 0;
// La tile est multiple de la résolution
for (int i = 0; i < width / tile_rect.w; i++)
{
tile_rect.y = 0;
for (int j = 0; j < height / tile_rect.h; j++)
{
SDL_RenderCopyEx(renderer, tile_texture, NULL, &tile_rect, 0.0, NULL, SDL_FLIP_NONE);
tile_rect.y += tile_rect.h;
}
tile_rect.x += tile_rect.w;
}
// On repositionne le moteur de rendu à sa valeur par défaut
SDL_SetRenderTarget(renderer, NULL);
// on n'a plus besoin de la petite texture de tuile, on libère la mémoire
SDL_DestroyTexture(tile_texture);
L'affichage est simple, on copie la texture :
SDL_Rect r;
r.w = width;
r.h = height;
r.x = 0;
r.y = 0;
SDL_RenderCopyEx(renderer, big_texture, NULL, &r, 0.0, NULL, SDL_FLIP_NONE);
On retrouve une valeur saine pour notre temps d'exécution : 8us !
C'est gagné, on a optimisé le temps d'exécution et la taille mémoire.
Animation
L'algorithme est assez simple : on va afficher la texture à différentes position de l'axe X en fonction du deltaTime fournit par les fonctions SDL. On affiche la texture deux fois pour compléter l'écran à sa droite (le défilement va vers la gauche).
SDL_Rect r;
move -= 0.03 * deltaTime;
if (move <= width * -1) {
move = 0;
}
r.w = width;
r.h = height;
r.x = move;
r.y = 0;
SDL_RenderCopyEx(renderer, big_texture, NULL, &r, 0.0, NULL, SDL_FLIP_NONE);
r.x += width;
SDL_RenderCopyEx(renderer, big_texture, NULL, &r, 0.0, NULL, SDL_FLIP_NONE);
Et le temps d'exécution dans tout ça ? Il n'a pas bougé, on est toujours aux alentours de 8us.
Conclusion
Retrouvez le code complet de l'article ici : https://github.com/arabine/sdl2-tiles-scrolling
Le projet se compile avec CMake et les trois implémentations sont activables à l'aide d'un #define situé juste avant le main().
Comme promis, voici le Gif final :