Un format d'image efficace pour la SDL | Blog | Superflu Riteurnz

Un format d'image efficace pour la SDL | Blog | Superflu Riteurnz Un format d'image efficace pour la SDL | Blog | Superflu Riteurnz
  • Steam
  • Nintendo Switch
  • Itch.io
  • Google Play
Superflu et son assistante Sophie

Blog

Un format d'image efficace pour la SDL

2022-09-28

Superflu Riteurnz est un jeu vidéo en 2D dans un style « dessin animé » très classique : si on met de côté les descriptions des niveaux et la partie sonore, l'intégralité du contenu à charger consiste donc en des images 2D (les animations étant simplement des successions d'images).

Afin d'éviter des temps de chargement trop longs entre les niveaux et un poids en mémoire sur le disque trop élevé, choisir le bon format d'image s'avère donc capital.

Note : le résumé du benchmark est donné en fin d'article.

Les options évidentes

La SDL « basique » ne sait lire que du BMP, le format d'image matricielle « historique », publié par Microsoft et IBM en 1990. C'est probablement un des formats d'image le plus simple : il consiste tout simplement à décrire chaque pixel successivement par un certain nombre d'octets (en général, un par canal, soit 4 si on utilise un format RGB + le canal de transparence alpha).

Le gros avantage du BMP, c'est qu'il est extrêmement facile à « décoder » pour la SDL : en gros, pour envoyer l'image dans la carte graphique, il suffit de copier le bloc-mémoire occupé par le fichier, puisque le format est déjà décompressé.

Par contre, le BMP est lourd, très lourd en mémoire, puisqu'il n'utilise absolument aucun algorithme de compression. Ainsi, ne serait-ce que pour stocker UNE image de fond d'UN niveau sans défilement à la résolution HD (1920x1080 pixels, résolution de base de mon jeu), le format BMP demanderait près de 6 Mio ! Autant dire que le jeu finirait rapidement par peser plusieurs gigaoctets. Ça peut se justifier pour un jeu AAA avec d'immenses univers en 3D, pas pour un jeu 2D indé comme le mien.

Benchmark pour les images du jeu au format BMP :

  • Espace mémoire total : 1 Gio
  • Temps de chargement : 0.59 s

Bref. Grâce au module SDL Image, on peut heureusement utiliser d'autres formats moins gourmands en espace disque. Le format le plus évident pour du dessin animé avec beaucoup d'aplats de couleurs unies, c'est le PNG1. La taille est drastiquement réduite par rapport au BMP (30 fois moins d'espace occupé !), mais en faisant tourner mon benchmark, je me rends compte que le temps de chargement est considérablement allongé…

Benchmark pour les images du jeu au format PNG :

  • Espace mémoire total : 36 Mio
  • Temps de chargement : 3.54 s

À première vue, ça semble contre-intuitif : on sait que les accès disque sont lents, le simple fait que le PNG soit près de 30 fois moins lourd que le BMP devrait donc le rendre plus rapide à lire, non ?

Avec les fonctions de la SDL, on peut séparer la phase de « lecture disque » proprement dite, et la phase de « création de l'image SDL à partir des données chargées ». Et là, on comprend ce qui se passe :

Benchmark pour les images du jeu au format BMP :

  • Espace mémoire total : 1 Gio
  • Temps de lecture du fichier sur le disque : 0.22 s
  • Temps de création de l'image : 0.36 s
  • (Soit un temps total de : 0.59 s)

Benchmark pour les images du jeu au format PNG :

  • Espace mémoire total : 36 Mio
  • Temps de lecture du fichier sur le disque : 0.01 s
  • Temps de création de l'image : 3.53 s
  • (Soit un temps total de : 3.54 s)

Voilà : l'énorme gain en termes de lecture du fichier sur le disque est complètement écrasé par le fait que la création de l'image prend maintenant des plombes ! Et pour cause : la compression PNG se veut efficace en espace disque, mais pas nécessairement en temps. Décompresser une image PNG, ça prend du temps, même si on ne s'en rend pas compte habituellement !

En effet, en général, votre utilisation va consister à charger une image à la fois et à la regarder : peu importe alors que le temps de chargement soit de quelques millisecondes au lieu de quelques microsecondes, vous ne voyez pas la différence. Pour un jeu où on va devoir charger un gros paquet d'images HD, parfois des animations avec de nombreuses images par seconde, c'est une autre paire de manches : le temps de chargement d'un niveau peut rapidement atteindre plusieurs secondes.

Vous allez me dire que quelques secondes, ce n'est pas la mort, mais encore une fois, pour un petit jeu 2D comme le mien, ça me semble exagéré : on s'attendrait à ce que les niveaux se chargent quasi-instantanément.

Cherchons donc à faire mieux.

Tout d'abord, notons que si le PNG est, de base, un format de compression non-destructive (chaque pixel de l'image est exactement le même que celui de la même image non-compressée au format BMP), il est néanmoins possible d'appliquer des algorithmes qui modifient légèrement l'image pour la rendre plus efficacement compressible par PNG. C'est par exemple ce que fait pngquant, en calculant une « palette » de couleurs réduite optimale pour votre image, quitte à changer légèrement certains pixels de couleur.

Les résultats sont impressionnants en termes d'espace-disque, on occupe 4,5 fois moins d'espace qu'avec des PNG classiques, soit 125 fois moins d'espace qu'avec des BMP !

Là où le bât blesse, malheureusement, c'est encore au niveau des temps de chargement… Si c'est plus rapide que le PNG classique, ça reste deux fois plus lent que le BMP.

Benchmark pour les images du jeu au format PNG optimisé par pngquant :

  • Espace mémoire total : 8 Mio
  • Temps de lecture du fichier sur le disque : 0.01 s
  • Temps de création de l'image : 1.22 s
  • (Soit un temps total de : 1.22 s)

Ajoutons que ces résultats sont obtenus en appliquant une forte quantification au niveau de pngquant, ce qui se fait au prix d'une altération des couleurs des images qui devient assez visible. Pas génial, donc.

Bon, il semble qu'on puisse oublier le PNG si on veut des temps de chargement rapides. Pourtant, hors de question d'utiliser du BMP à cause du poids délirant des images. Les autres formats supportés par la SDL n'ont pas donné de meilleurs résultats. Alors que faire ?

LZ4 : le compresseur rapide

Si le BMP permet la construction rapide d'images mais au prix d'un espace mémoire énorme, alors pourquoi ne pas essayer de compresser un BMP pour obtenir le meilleur des deux mondes ? En pratique, on risque de retomber sur le même problème qu'avec PNG : des temps de décompression trop élevés qui neutralisent le gain en espace disque et en temps de lecture disque.

C'est alors qu'arrive le format LZ4, un algorithme de compression dont Wikipédia nous dit qu'il est « notamment axé sur la rapidité de décompression ». Bien sûr, cela se fait au prix d'un efficacité en espace disque inférieure aux compressions optimales (7zip par exemple), mais ça se tente.

Plutôt que de compresser des BMP, je me suis dit qu'il serait encore plus efficace de compresser directement le contenu mémoire d'une image SDL : ainsi, la création de l'image se résumera à une copie pure d'un bloc mémoire, on peut difficilement faire plus rapide.

SDL Surfaces VS Textures

Un point de détail technique qui a son importance : ce qu'on compresse, c'est la structure SDL_Surface, qui va contenir l'image décompressée dans un format similaire à BMP. Techniquement, pour l'afficher, on va la convertir en SDL_Texture, qui est un format utilisé directement par la carte graphique. Ce dernier format est dépendant de la carte graphique et du driver que vous utilisez, et elle n'est ni documentée ni accessible publiquement, on ne peut donc pas directement compresser et stocker une SDL_Texture.

En revanche, on peut optimiser notre SDL_Surface pour que la construction d'une SDL_Texture associée soit rapide : en effet, si votre SDL_Surface utilise un format de stockage (ordre des différents canaux et nombre d'octets) qui n'est pas nativement supporté par votre driver graphique, alors vous allez payer une conversion.

SDL nous permet de connaître les formats supportés nativement grâce aux infos contenus dans SDL_RendererInfo. J'ai donc fait l'expérience avec les différentes plateformes à ma disposition :

Platform Linux Mint Windows 10 VM Android Mac OS
Name opengl direct3d opengles2 opengl
SDL_PIXELFORMAT_ARGB8888 X X X X
SDL_PIXELFORMAT_ABGR8888 X   X X
SDL_PIXELFORMAT_RGB888 X   X X
SDL_PIXELFORMAT_BGR888 X   X X
SDL_PIXELFORMAT_YV12 X X X X
SDL_PIXELFORMAT_IYUV X X X X
SDL_PIXELFORMAT_NV12 X   X X
SDL_PIXELFORMAT_NV21 X   X X
SDL_PIXELFORMAT_UNKNOWN     X  

Il semble donc que SDL_PIXELFORMAT_ARGB8888 soit le format RGB+transparence le plus universellement supporté (un canal de transparence alpha + les canaux RGB, dans ce ordre, chacun prenant 8 bits soit un octet). C'est donc ce format qu'on va compresser avec LZ4.

SDL+LZ4 : un format d'image optimal

Les résultats du benchmark sont sans appel : les images compressées au format LZ4 donnent le meilleur temps de chargement en étant 1,4 fois plus rapides que BMP (et 8 fois plus rapides que PNG), tout en conservant une taille acceptable, seulement 20 % plus élevée que PNG (et plus de 25 fois plus petites que BMP).

Benchmark pour les images du jeu au format LZ4 :

  • Espace mémoire total : 43 Mio
  • Temps de lecture du fichier sur le disque : 0.01 s
  • Temps de création de l'image : 0.41 s
  • (Soit un temps total de : 0.42 s)

Super, donc ! Et pour encore améliorer un peu les résultats, on peut utiliser une variante HC (haute compression) de l'algo de compression LZ4 : cette variante est très longue à la compression, mais elle offre des tailles de fichiers encore plus faibles tout en conservant un temps de décompression rapide. Comme la compression (plus lente) ne sera faite qu'une fois (par moi) et que la réduction de taille et de décompression profitera à l'ensemble des gens qui jouent, ça vaut le coup !

Benchmark pour les images du jeu au format LZ4HC :

  • Espace mémoire total : 33 Mio
  • Temps de lecture du fichier sur le disque : 0.01 s
  • Temps de création de l'image : 0.38 s
  • (Soit un temps total de : 0.39 s)

Le gain n'est pas immense par rapport au LZ4 classique, mais il n'y a pas de raison de s'en priver. Notez que les données pèsent alors encore moins lourd que le PNG classique !

Implémentation

La compression est très simple : on lit notre image au format d'entrée (BMP, PNG, peu importe), on crée notre SDL_image et on la convertit, éventuellement, au format SDL_PIXELFORMAT_ARGB8888, puis on la compresse.

En pratique, on va d'abord écrire les dimensions de notre image (ainsi que le code du format de pixels, pour pouvoir éventuellement supporter d'autres formats que SDL_PIXELFORMAT_ARGB8888). J'ai écrit une fonction en utilisant la même syntaxe que les fonctions de SDL Image :


int IMG_SaveLZ4_RW (SDL_Surface* surface, SDL_RWops* dst, int freedst, int hc)
{
  Uint16 width = (Uint16)(surface->w);
  Uint16 height = (Uint16)(surface->h);
  Uint32 surface_format = surface->format->format;

  SDL_RWwrite (dst, &width, sizeof(width), 1);
  SDL_RWwrite (dst, &height, sizeof(height), 1);
  SDL_RWwrite (dst, &surface_format, sizeof(surface_format), 1);
  Uint8 bpp = surface->format->BytesPerPixel;
  Uint32 uncompressed_size = width * height * (Uint32)bpp;

  const char* uncompressed_buffer = (const char*)(surface->pixels);
  int max_lz4_size = LZ4_compressBound (uncompressed_size);
  char* compressed_buffer = malloc (max_lz4_size);
  int true_size = -1;

  if (hc)
    true_size = LZ4_compress_HC(uncompressed_buffer, compressed_buffer,
                                uncompressed_size, max_lz4_size,
                                LZ4HC_CLEVEL_MAX);
  else
    true_size = LZ4_compress_default (uncompressed_buffer, compressed_buffer,
                                      uncompressed_size, max_lz4_size);

  SDL_RWwrite (dst, &true_size, sizeof(int), 1);
  SDL_RWwrite (dst, compressed_buffer, 1, true_size);

  free (compressed_buffer);

  if (freedst)
    SDL_RWclose (dst);

  return 0;
}

On donne également la fonction qui permet de sauvegarder directement dans un fichier (et non dans une structure RW de la SDL) :


int IMG_SaveLZ4 (SDL_Surface* surface, const char* file, int hc)
{
  SDL_RWops* dst = SDL_RWFromFile (file, "wb");
  return (dst ? IMG_SaveLZ4_RW (surface, dst, 1, hc) : -1);
}

Au niveau de la lecture/décompression, c'est à nouveau très simple : on lit les dimensions de l'image et le format de pixels, ce qui nous permet d'allouer la SDL_Surface voulue, puis on décompresse le bloc mémoire directement dans l'espace mémoire alloué par la SDL.


SDL_Surface* IMG_LoadLZ4_RW (SDL_RWops* src, int freesrc)
{
  Uint16 width;
  Uint16 height;
  Uint32 surface_format;
  int compressed_size;

  SDL_RWread (src, &width, sizeof(width), 1);
  SDL_RWread (src, &height, sizeof(height), 1);
  SDL_RWread (src, &surface_format, sizeof(surface_format), 1);
  SDL_RWread (src, &compressed_size, sizeof(compressed_size), 1);

  SDL_Surface* out = SDL_CreateRGBSurfaceWithFormat (0, width, height, 32, surface_format);
  Uint8 bpp = out->format->BytesPerPixel;
  Uint32 uncompressed_size = width * height * (Uint32)bpp;

  char* compressed_buffer = malloc (compressed_size);
  SDL_RWread (src, compressed_buffer, 1, compressed_size);
  char* uncompressed_buffer = (char*)(out->pixels);
  LZ4_decompress_safe (compressed_buffer, uncompressed_buffer, compressed_size, uncompressed_size);
  free (compressed_buffer);

  if (freesrc)
    SDL_RWclose (src);

  return out;
}

Même chose, la fonction additionnelle qui va bien :


SDL_Surface* IMG_LoadLZ4 (const char* file)
{
  SDL_RWops* src = SDL_RWFromFile (file, "rb");
  return (src ? IMG_LoadLZ4_RW (src, 1) : NULL);
}

Résumé du benchmark

FORMAT READING TIME (s) CREATING SURFACE (s) CREATING TEXTURE (s) TOTAL (s) SIZE (MiB)
BMP 0.22 0.22 0.14 0.59 1046
PNG 0.01 3.15 0.38 3.54 36
PNG (quant) 0.01 1.07 0.15 1.23 8
LZ4 0.01 0.25 0.16 0.42 43
LZ4 (HC) 0.01 0.22 0.16 0.39 33

Le format LZ4 compressé avec la méthode HC offre donc le temps de chargement le plus court. Au niveau de la taille des données, il n'est battu que par une version quantizé de PNG qui implique une compression destructive et une altération visible des couleurs des images.

Bref, dans mon cas d'usage, stocker mes images sous forme d'objets SDL_Surface compressés avec l'algoritme LZ4HC est un choix optimal à la fois en terme de mémoire et en terme de temps de chargement.

Code source

Les fonctions d'encodage/décodage vers ce format SDL LZ4 ainsi que les fichiers nécessaires à la réalisation de benchmark (sans les images) sont disponibles sur ce repo.


  1. le format JPG est totalement exclu pour 3 raisons. Premièrement, il a été principalement conçu pour la photo et est contre-productif sur des dessins de type cartoon ; deuxièmement, il est destructif et crée des artefacts très visibles sur les dessins de type cartoon, notamment autour des traits noirs ; troisièmement, il ne gère pas la transparence qui est nécessaire dans mon cas. 

i