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 !

image

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.

c
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.

c
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 :

c
 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 :

c
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
shell
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 :

image

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 :

LargeurDifférenceHauteurDifférence
896504
102412857672
115212864872
128012872072

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.

c
    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;
c
    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.

image

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é

c
    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 :

c
    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.

image

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).

c
    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 :

image