Les algorithmes S3TC : la compression de textures

Jusqu’ici, nous avons vus PNG et JPEG, des formats créés par exemple pour le web. Mais si nous codions par exemple un jeu vidéo en 3D, nous aurions de gros impératifs de performances et nous ne pourrions pas nous permettre d’utiliser tout le temps des algorithmes comme DEFLATE (surtout sur une vieille machine).

Nous sommes dans un jeu en 3D, nous avons un cube ou un personnage, nous voulons afficher une texture 2D sur ce cube ou ce personnage, qu’est-ce qui serait un moyen efficace de faire perdre nos textures en taille, et de permettre à notre carte graphique d’en traiter plus et plus vite ?

Le principe de base de la 3D sur ordinateur : afficher des images en 2D sur des formes en 3D (modèles). Nos textures sont des images aussi, comment en envoyer (très) rapidement à notre carte graphique ? (Base de l’illustration / Licence CC BY-SA Tschmits)

La famille d’algorithmes S3TC (ou DXTC), développée à partir des années 1990, répond à cette problématique très concrète. La texture S3TC est envoyée à la carte graphique, qui la décode matériellement. Il s’agit en fait de cinq algorithmes, numérotés de DXT1 à DXT5, permettant des niveaux de qualité différents mais une décompression potentiellement très rapide.

DXT1 : des palettes de 4 couleurs pour 16 pixels

Aujourd’hui, vous le savez, les couleurs sont généralement stockées et exprimées avec trois octets, soit 24 bits (sans la transparence) : un octet pour chaque composante, le rouge, le bleu et le vert.

Mais à la fin des années 1990, il était encore courant d’exprimer des couleurs sous la forme de 16 bits : 5 bits pour le rouge, 6 pour le vert et 5 pour le bleu. Pourquoi 16 bits ? Parce que c’est encore un ordre de taille avec lequel de nombreuses cartes graphiques travaillent à l’époque. Pourquoi 6 bits pour le vert et 5 pour les autres couleurs ? Parce que l’œil humain est plus sensible aux nuances de vert, plus criardes.

Les couleurs 16 bits étaient couramment appelées « high color » quand les couleur 24 bits étaient appelées « true color ».

Le principe de DXT1 simple est simple : prendre un bloc de 4 x 4 (16) pixels, soit 16×16=25616 \times 16 = 256 bits à l’origine, et le convertir en une expression de 64 bits (c’est quatre fois plus petit). Comment fait-il ?

Eh bien, en réalité, il ne stocke que deux couleurs sur les 16, et génère une palette de quatre couleurs pour tout le bloc :

  • La couleur n° 1 est la première couleur donnée ;
  • La couleur n° 2 est la seconde couleur donnée ;
  • Si, lorsqu’on la met sur 16 bits, la couleur n° 1 est plus un nombre strictement plus grand que la couleur n° 2 (les développeurs de jeux vidéo sont des gens qui aiment bien stocker de l’information de manière un peu implicite !), alors
    • La couleur n° 3 sera égale, pour chaque composante, à 232 \over 3 de la couleur n°1 plus 131 \over 3 de la couleur n° 2
    • La couleur n° 4 sera égale, pour chaque composante, à 232 \over 3 de la couleur n°2 plus 131 \over 3 de la couleur n° 1
  • Autrement, si les deux couleurs sont disposées dans l’autre sens, alors :
    • La couleur n° 3 sera égale, pour chaque composante, à 121 \over 2 de la couleur n°1 plus 121 \over 2 de la couleur n° 2
    • La couleur n° 4 sera entièrement transparente

Du coup, comment stocke-t-on notre bloc de 4 x 4 pixels sur 64 bits ? De cette manière :

Taille Valeur
16 bits La couleur n° 1 (en RGB 5:6:5)
16 bits La couleur n° 2 (en RGB 5:6:5)
4 x 4 x 2 bits Pour chaque pixel de l’image, 2 bits : 00, 01, 10 et 11 pour les couleurs n° 1, 2, 3 ou 4.

16+16+4×4×216 + 16 + 4 \times 4 \times 2, le compte est bon : on a bien 64 bits ! Inutile de vous dire que DXT1 est un format de compression avec perte et que ses successeurs (que l’on verra plus bas) ont aussi pour but de faire mieux.

À noter que les deux couleurs de 16 bits sont chacune codées comme des nombres little-endian, mais qu’une fois ces nombres décodés, les couleurs sont à lire en ordre MSB (Most Significant Bit first - de gauche à droite).

Et les 16 pixels de 2 bits qui suivent sont également encodés dans un unique nombre little-endian de 32 bits, et disposés également dans l’ordre MSB au sein de celui-ci. Par contre, les pixels au sein du bloc sont disposés de bas en haut puis de droite à gauche (et vu qu’un octet = une ligne du bloc, cela signifie que vous pourriez le lire comme s’il s’agissait d’un nombre big-endian avec des valeurs LSB, mais des pixels disposés de haut en bas puis de gauche à droite, vous obtiendrez le meme résultat !).

Voici un exemple de décodeur DXT1 en Python : il prend les données brutes en entier et renvoie un tableau de colonnes, qui contient un tableau de lignes, qui contient des tuples (R, G, B, A) pour chaque pixel de l’image :

from typing import List, Tuple
from io import BytesIO

def couleur_16bits_vers_rgba(entree : int) -> Tuple[int, int, int, int]:
    
    return (
        int(( entree >> (6 + 5) ) / 0b11111 * 255),
        int(( (entree >> 5) & 0b111111 ) / 0b111111 * 255),
        int(( entree & 0b11111 ) / 0b11111 * 255),
        255
    )


"""
    Notre décodeur se trouve ici. Les dimensions de l'image doivent être
    définies explicitement, puisque autrement on ne sait pas combien de
    blocs sont horizontaux et combien sont verticaux.
"""

def decoder_dxt1(hauteur_px : int, largeur_px : int, entree : bytes) -> List[List[Tuple[int, int, int, int]]]:
    
    lecteur = BytesIO(entree)
    
    sortie : List[List[Tuple[int, int, int, int]]] = []
    
    for base_position_y in range(0, hauteur_px, 4):
        
        for base_position_x in range(0, largeur_px, 4):
    
            couleur_1_brute = int.from_bytes(lecteur.read(2), 'little')
            couleur_2_brute = int.from_bytes(lecteur.read(2), 'little')
            
            couleur_1 = couleur_16bits_vers_rgba(couleur_1_brute)
            couleur_2 = couleur_16bits_vers_rgba(couleur_2_brute)
            
            if couleur_1_brute > couleur_2_brute:
                couleur_3 = tuple(
                    int(2/3 * couleur_1[composante] + 1/3 * couleur_2[composante])
                    for composante in range(4)
                )
                couleur_4 = tuple(
                    int(1/3 * couleur_1[composante] + 2/3 * couleur_2[composante])
                    for composante in range(4)
                )
            else:
                couleur_3 = tuple(
                    int(1/2 * couleur_1[composante] + 1/2 * couleur_2[composante])
                    for composante in range(4)
                )
                couleur_4 = (0, 0, 0, 0) # Noir transparent
                
            
            couleurs = [couleur_1, couleur_2, couleur_3, couleur_4]
            
            for position_y in range(base_position_y, base_position_y + 4):                
                
                ligne_encodee = lecteur.read(1)[0]
                
                while len(sortie) <= position_y:
                    sortie.append([]) # Ajouter une ligne de pixels à notre sortie
                
                sortie[position_y] += [
                    couleurs[ligne_encodee        & 0b11],
                    couleurs[(ligne_encodee >> 2) & 0b11],
                    couleurs[(ligne_encodee >> 4) & 0b11],
                    couleurs[ligne_encodee >> 6],
                ]
    
    return sortie
        

Ce format est donc destiné à être lu par la carte graphique (qui est un grand circuit électronique destiné à faire des calculs 3D très rapidement), mais vous pourrez très bien avoir à le décoder dans le cadre du développement ou de l’analyse d’un jeu vidéo…

DXT1 peut aussi effectuer une interpolation (un léger dégradé) entre les couleurs pour ne pas rendre le changement trop abrupt.

DXT2/DXT3 : DXT1 mais avec des niveaux de transparence

DXT2 est comme DXT1, mais en lieu et place de l’opacité ou de la transparence totale, chaque pixel dispose d’un niveau de transparence codé sur 4 bits.

4 bits de transparence, ça fait 15 niveaux de transparence possibles.

Avec DXT2, chaque bloc de 4x4 pixels est codé sur 128 bits, au lieu de 64 bits.

La première moitié du bloc contient uniquement les niveaux de transparence de chaque pixel, codés donc sur 64 bits, et sans palette particulière. Ils doivent être lus comme un unique nombre little-endian de 64 bits, au sein duquel les valeurs sont à lire de gauche à droite.

Taille Description
4 x 4 x 4 bits Les niveaux de transparence pour chaque pixel du bloc (0 = transparent, 15 = opaque).
64 bits Les couleurs codées comme avec DXT1 (en ne faisant plus de cas spécial quand couleur n°1 est inférieure ou égale à couleur n° 2)

Ensuite, sur les 64 bits restants, les couleurs du bloc sont codées comme DXT1, à la différence qu’on supprime le cas où on agit particulièrement si la couleur n° 1 est plus petite ou égale à la couleur n° 2.

Et DXT3 alors ? DXT3 fonctionne comme DXT2, mais il y a une différence : imaginons que la valeur du bleu de votre couleur soit « 100 » et que son opacité soit de 20 %. Pour appliquer la transparence sur la couleur en-dessous, la carte graphique va devoir faire deux opérations :

  • Prendre 20 % du bleu de notre texture (la valeur du bleu devient « 20 »)
  • Prendre 80 % du bleu de la texture en-dessous (même chose pour les autres couleurs…), et l’additioner à celui de votre texture

Pour éparger le premier calcul à notre carte graphique, vu qu’on sait déjà qu’il y aura un pixel semi-transparent ici, on peut l’aider un peu et faire la multiplication par 0,20,2 à sa place, avant d’encoder notre pixel : cette opération s’appelle la prémultiplication par l’alpha (alpha = opacité). Ainsi, on écrira directement « 20 » dans la valeur du bleu de notre pixel.

DXT2 attend des valeurs de couleurs prémultipliées, pas DXT3. C’est la seule différence.

DXT4/DXT5 : DXT3 mais avec la transparence codée en palette

Avec DXT4, votre texture avec niveaux de transparence est toujours codée sur 128 bits, avec la transparence dans les premiers 64 bits, mais au lieu d’avoir les valeurs de transparence codées directement, la transparence est codée un peu comme les couleurs de DXT1 ; seules deux opacités sont stockées, et une palette est générée :

Taille Valeur
8 bits L’opacité n° 1 (de 0 à 255)
8 bits L’opacité n° 2 (de 0 à 255)
4 x 4 x 3 bits Pour chaque pixel de l’image, 3 bits, correspondant à une valeur de la palette.
64 bits Les couleurs codées comme avec DXT1 (en ne faisant plus de cas spécial quand couleur n°1 est inférieure ou égale à couleur n° 2)

8+8+4×4×38 + 8 + 4 \times 4 \times 3, cela faut toujours 64 bits.

La palette générée est la suivante :

Valeur Si opacité n° 1 > opacité n° 2 Sinon
Opacité n° 3 676 \over 7 de opacité n° 1 + 171 \over 7 de opacité n° 2 454 \over 5 de opacité n° 1 + 151 \over 5 de opacité n° 2
Opacité n° 4 575 \over 7 de opacité n° 1 + 272 \over 7 de opacité n° 2 353 \over 5 de opacité n° 1 + 252 \over 5 de opacité n° 2
Opacité n° 5 474 \over 7 de opacité n° 1 + 373 \over 7 de opacité n° 2 252 \over 5 de opacité n° 1 + 353 \over 5 de opacité n° 2
Opacité n° 6 373 \over 7 de opacité n° 1 + 474 \over 7 de opacité n° 2 151 \over 5 de opacité n° 1 + 454 \over 5 de opacité n° 2
Opacité n° 7 272 \over 7 de opacité n° 1 + 575 \over 7 de opacité n° 2 0
Opacité n° 8 171 \over 7 de opacité n° 1 + 676 \over 7 de opacité n° 2 255

DXT4 utilise des couleurs prémultipliées par l’alpha alors que DXT5, non.

Le format DDS

Le format DDS (DirectDraw Surface) est un format développé par Microsoft qui permet principalement de stocker des textures encodées avec les algorithmes S3TC.

Il est composé des champs suivants (tous sont stockés en ordre little-endian, sauf les magics) :

Taille Nom du champ Description
4 octets dwMagic Les octets « DDS  » (avec un espace ASCII à la fin)
4 octets dwSize Taille de cette en-tête, sauf le champ « dwMagic ». Vaut toujours « 124 ».
4 octets dwFlags Bit field (ensemble de « flags », des valeurs de champs d’un bit pouvant être combinés avec l’opérateur « binary OR » qui décrit quels sont les champs réellement utilisés dans la suite de l’en-tête (les autres sont à zéro) - voir ci-dessous.
4 octets dwHeight Hauteur de la texture en pixels.
4 octets dwWidth Largeur de la texture en pixels.
4 octets dwPitchOrLinearSize Nombre d’octets de la texture principale (soit tout sauf l’en-tête du fichier) dans le cas d’une texture compressés, ou nombre d’octets par ligne pour une texture non-compressée.
4 octets dwDepth Profondeur de la texture en pixels dans le cas d’une texture en 3D (inutile dans notre cas).
4 octets dwMipMapCount Nombre de versions de la même texture présentes, en des tailles différentes, dans ce même fichier (mip map) (inutile dans notre cas).
11 x 4 octets dwReserved Octets réservés pour usage ou extension futurs, non utilisés. Certains logiciels générant des fichiers .DDS inscrivent leur nom ici.
4 octets ddspf->dwSize Taille de la sous-structure de type DDS_PIXELFORMAT donnant des informations sur le format de compression utilisé (toujours 32 octets).
4 octets ddspf->dwFlags Caractéristiques du type de texture utilisé, voir ci-dessous.
4 octets ddspf->dwFourCC La magic du format de compression utilisé, s’il y a de la compression : « DXT1 », « DXT2 », « DXT3 », « DXT4 » ou « DXT5. « FourCC » signifie « four-character code ».
5 x 4 octets ddspf->dwRGBBitCount, ddspf->dwRBitMask, ddspf->dwGBitMask, ddspf->dwBBitMask, ddspf->dwABitMask Nombre de bits par couleur et par composante dans le cas d’un format d’image non-compressé. Non utilisé ici.
2 x 4 octets dwCaps, dwCaps2 Caractérise le type de texture utilisé, voir ci-dessous.
3 x 4 octets dwCaps3, dwCaps4, dwReversed5 Champs non-utilisés, réservés à un éventuel usage futur.

Voici la liste possible des flags pouvant être combinés au sein du champ « dwFlags » (il sont censés indiquer notamment les champs renseignées au sein de l’en-tête) :

Nom du flag Description Valeur du flag (à appliquer sur le champ correspondant avec un Binary OR)
DDSD_CAPS Obligatoire. 0x1
DDSD_HEIGHT Obligatoire. 0x2
DDSD_WIDTH Obligatoire. 0x4
DDSD_PITCH Obligatoire pour les textures non-compressées (utilisation du champ donnant la taille de chaque ligne de l’image). 0x8
DDSD_PIXELFORMAT Obligatoire. 0x1000
DDSD_MIPMAPCOUNT Obligatoire pour les textures contenant plusieurs tailles d’images (mip-mappées). 0x20000
DDSD_LINEARSIZE Obligatoire lorsqu’une taille des données est donnée pour les images compressées. 0x80000
DDSD_DEPTH Obligatoire pour les textures en 3D. 0x800000

Voici la liste possible des flags pouvant être combinés au sein du champ « ddspf->dwFlags » :

Taille Nom du champ Description
DDPF_ALPHAPIXELS Textures non-compressées : indique la présence d’un nombre de bits de transparence par pixel. 0x1
DDPF_ALPHA Textures non-compressées : indique la présence d’un nombre de bits de transparence par pixel (ancien). 0x2
DDPF_FOURCC Textures compressées : indique la présence de la magic du format de compression. 0x4
DDPF_RGB Textures non-compressées : indique la présence de nombres de bits de couleurs RGB par pixel. 0x40
DDPF_YUV Textures non-compressées : indique la présence de nombres de bits de couleurs YUV par pixel (ancien). 0x200
DDPF_LUMINANCE Textures non-compressées : indique la présence de nombres de bits de niveaux de gris par pixel (ancien). 0x20000

Voici la liste possible des flags pouvant être combinés au sein du champ « ddspf->dwCaps » :

Taille Nom du champ Description
DDSCAPS_COMPLEX Présent s’il s’agit de plus qu’une simple texture (une mipmap ou une cube map = un ensemble de 6 textures, correspondant aux faces d’un cube, qui doit servir de décor à un environnement 3D). 0x8
DDSCAPS_MIPMAP Présent s’il s’agit d’une mipmap. 0x400000
DDSCAPS_TEXTURE Obligatoire. 0x1000

Voici la liste possible des flags pouvant être combinés au sein du champ « ddspf->dwCaps2 » :

Taille Nom du champ Description
DDSCAPS2_CUBEMAP Obligatoire s’il s’agit d’une cube map. 0x200
DDSCAPS2_CUBEMAP_POSITIVEX Obligatoire si une surface de cube map orientée vers cette direction est stockée. 0x400
DDSCAPS2_CUBEMAP_NEGATIVEX Obligatoire si une surface de cube map orientée vers cette direction est stockée. 0x800
DDSCAPS2_CUBEMAP_POSITIVEY Obligatoire si une surface de cube map orientée vers cette direction est stockée. 0x1000
DDSCAPS2_CUBEMAP_NEGATIVEY Obligatoire si une surface de cube map orientée vers cette direction est stockée. 0x2000
DDSCAPS2_CUBEMAP_POSITIVEZ Obligatoire si une surface de cube map orientée vers cette direction est stockée. 0x4000
DDSCAPS2_CUBEMAP_NEGATIVEZ Obligatoire si une surface de cube map orientée vers cette direction est stockée. 0x8000
DDSCAPS2_VOLUME Obligatoire s’il s’agit d’un texture en 3D. 0x200000

Les formats créés par Microsoft (de manière générale) sont toujours comme ça :

  • Assez verbeux avec pas mal de champs (pas tous utilisés)
  • Prefixés par le type de champ (« dw » pour « Dword » ce qui signifie « double-word », double-mot, ici un mot vaut 2 octets et un double-mot vaut 4 octets)
  • Souvent alignés sur les tailles standards du processeur (souvent 4 octets)
  • Pas mal de constantes et de bit flags dans tous les sens (assez verbeux aussi)

Un décodeur DDS en Python

Pour changer, nous n’allons pas afficher nos images directement dans le terminal, nous allons utiliser une bibliothèque nommée « PIL » (Python Imaging Library) qui, en sus de savoir charger ou dessiner des images et savoir les enregistrer dans différens formats (ça ne nous sera pas utile ici), va nous permettre de l’afficher dans une petite fenêtre.

Vous pouvez l’utiliser soit en utilisant les dépôts de votre distribution si vous êtes sous Linux, soit en utilisant l’outil PIP (disponible aussi sous Windows). PIP peut s’installer en même temps que Python sous Winows. À noter que PIL n’est plus mis à jour, il a été remplacé par une bibliothèque plus récente qui s’appelle en fait « Pillow », mais que vous pouvez toujours appeler sous le nom de « PIL » au niveau du code de votre programme.

Voici une commande que vous pourriez exécuter pour installer Pillow sous Python 3 :

pip3 install --upgrade Pillow

Indiquer --upgrade permet de mettre à jour la bibliothèque si besoin.

Voici une image DDS, donnée en hexadécimal, qui a été générée à l’aide de la commande convert de l’utilitaire ImageMagick (un outil de conversion d’images très populaire sous Linux) :

444453207c000000071008001b0000001a000000100300000000000001000000494d4147454d414749434b000000000000000000000000000000000000000000000000000000000000000000200000000400000044585435000000000000000000000000000000000000000000100000000000000000000000000000000000000005000000000000000000000000000000050000000000000000000000000000f700499224491e0be0ff00005555d5fc4b50b66ddbb60ddb004d0000555555540005000000000000000000000000000048f3b66ddbb6613b694a000055551515282d366003360003694a0000a5a5a5a4fd00499224c99d01004d000055550501d4004992e4498a66e0870000159596aaff4e112000000628004de04cfabeaeaf186fb12ddbb06ddb004d000054545455000500000000000000000000000000003955b66ffb366ee7694a000015150505ff004b8224708226aa520000a4a0a0a007ed5e6e30b665a3a04d00000101150569fe2ffae7fdffff004d0045ea9eabaaf3fdcff1ffffffffe04c0045efaaaaaaff00e30380000000016500004000000008e5b60ddb91fdc2e8fd000055565a421cc6366fe7f66ffc8a52000005050501809f2ff0000f7000694a694a00000000ff0009910319900165fd0000f5f9a9010005ffffffffffff45f5e04c55552b000005ffffffffffff45f58443555560800005ffffffffffff45f508422b0bf555ff000003e000080066fd000040002aff39aac6effeceefff694a00000101010118d71f7000313003aa520000a0a0a4a4c7f3ceefffce6ffc65fd0000010101010005ffffffffffff45f5e090f07000000005ffffffffffff45f5c1ba020900000005ffffffffffffd4fe084255d7aa0ae8f6fff3fffffb1f45f5077bbd00006039fcf8b35f7f9ccf694a000000004040040930600336600310840000a4a5a5a5ff0021900309914487fd0000010105050005ffffffffffffffff45f555090db50005fffffffffffffeff45f555be00570005ffffffffffffffff87f58982806d95f3ffffffffff2245f5694a5878f8f004d07ffdc78f5cd8e08500007f7f7a5200050000000000000000000000000000191eb661dbb60d0066fd0000155555aaff00021002c9992487fd0000000005aac7f3ffffffc8030065fd45f5ffffbaaa25fcffff871d0c0046fd0000000040aa0dd8810ddbb60d0087fd0000505455aa0005000000000000000000000000000000050000000000000000000000000000

Nous allons essayer de la décoder.

Tout d’abord, il va falloir déclarer les structures du format DDS, que nous avons vues plus haut. Pour cela, nous allons utiliser le module ctypes, comme nous l’avons fait avec PNG. Nous allons aussi utiliser la classe IntFlag du module enum, qui permet à la fois d’encoder et de décoder un bit field très facilement, et de définir ses flags de manière propre.

# D'après https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-pixelformat

class DDS_PIXELFORMAT(LittleEndianStructure):
    
    _fields_ = [
        ('dwSize', c_uint32),
        ('dwFlags', c_uint32),
        ('dwFourCC', c_char * 4),
        ('dwRGBBitCount', c_uint32),
        ('dwRBitMask', c_uint32),
        ('dwGBitMask', c_uint32),
        ('dwBBitMask', c_uint32),
        ('dwABitMask', c_uint32)
    ]
    
    _pack_ = True

# D'après https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header

class DDS_HEADER(LittleEndianStructure):

    _fields_ = [
        ('dwMagic', c_char * 4),
        ('dwSize', c_uint32),
        ('dwFlags', c_uint32),
        ('dwHeight', c_uint32),
        ('dwWidth', c_uint32),
        ('dwPitchOrLinearSize', c_uint32),
        ('dwDepth', c_uint32),
        ('dwMipMapCount', c_uint32),
        ('dwReserved', c_uint32 * 11),
        ('ddspf', DDS_PIXELFORMAT),
        ('dwCaps', c_uint32),
        ('dwCaps2', c_uint32),
        ('dwCaps3', c_uint32),
        ('dwCaps4', c_uint32),
        ('dwReversed5', c_uint32)
    ]
    
    _pack_ = True

class DwFlags(IntFlag):
    DDSD_CAPS = 0x1
    DDSD_HEIGHT = 0x2
    DDSD_WIDTH = 0x4
    DDSD_PITCH = 0x8
    DDSD_PIXELFORMAT = 0x1000
    DDSD_MIPMAPCOUNT = 0x20000
    DDSD_LINEARSIZE = 0x80000
    DDSD_DEPTH = 0x800000

class PIXELFORMAT_DwFlags(IntFlag):
    DDPF_ALPHAPIXELS = 0x1
    DDPF_ALPHA = 0x2
    DDPF_FOURCC = 0x4
    DDPF_RGB = 0x40
    DDPF_YUV = 0x200
    DDPF_LUMINANCE = 0x20000


class DwCaps(IntFlag):
    DDSCAPS_COMPLEX = 0x8
    DDSCAPS_MIPMAP = 0x400000
    DDSCAPS_TEXTURE = 0x1000

class DwCaps2(IntFlag):
    DDSCAPS2_CUBEMAP = 0x200
    DDSCAPS2_CUBEMAP_POSITIVEX = 0x400
    DDSCAPS2_CUBEMAP_NEGATIVEX = 0x800
    DDSCAPS2_CUBEMAP_POSITIVEY = 0x1000
    DDSCAPS2_CUBEMAP_NEGATIVEY = 0x2000
    DDSCAPS2_CUBEMAP_POSITIVEZ = 0x4000
    DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x8000
    DDSCAPS2_VOLUME = 0x200000
    
    

Vous remarquerez que l’on imbrique une structure dans une autre.

Comment ai-je fait ça rapidement ? Pour chaque classe à créer, je me suis rendu sur la documentation du format DDS sur le site de Microsoft, puis j’ai positionné ma souris en haut à gauche du tableau que je voulais copier, j’ai enfoncé la touche Ctrl, le bouton gauche de la souris, fait glisser ma souris à l’autre extrémité du tableau et relâché le Ctrl+clic (le tout avec Firefox).

J’ai ensuite effectué une copie (Ctrl+C), Firefox m’a alors produit une copie du contenu du tableau, mais en séparant chaque case par une tabulation (le caractère ASCII 0x09, que l’on peut représenter en écrivant « \t » dans divers langages de programmation, et qui correspond à un espace de taille variable en fonction de votre éditeur de code), que j’ai collée dans mon éditeur de code.

J’ai ensuite utilisé la fonction « Rechercher et remplacer » (avec des expressions régulières afin de supprimer les descriptions des champs, et d’ajouter quelque chose après et avant le nom des champs et les valeurs, puis effectué quelques rectifications. Pour au final arriver sans encombe à la mise en forme que vous voyez ci-dessus !

Si jamais le format DDS était documenté dans un format qui n’était pas une page web, par exemple le format PDF, j’aurais aussi pu utiliser un outil comme tabula, un programme open-source très efficace pour extraire les tableaux des fichiers PDF.

Vient ensuite le code qui devra savoir décoder toutes les versions de DDS :zorro:

def couleur_16bits_vers_rgba(entree : int) -> Tuple[int, int, int, int]:
    
    return (
        int(( entree >> (6 + 5) ) / 0b11111 * 255),
        int(( (entree >> 5) & 0b111111 ) / 0b111111 * 255),
        int(( entree & 0b11111 ) / 0b11111 * 255),
        255
    )


"""
    Notre décodeur se trouve ici. Les dimensions de l'image doivent être
    définies explicitement, puisque autrement on ne sait pas combien de
    blocs sont horizontaux et combien sont verticaux.
"""

def decoder_dxt(version_dxt, hauteur_px : int, largeur_px : int, entree : bytes) -> List[List[Tuple[int, int, int, int]]]:
    
    lecteur = BytesIO(entree)
    
    sortie : List[List[Tuple[int, int, int, int]]] = [[] for colonne in range(hauteur_px + (-hauteur_px % 4))]
    sortie_alpha : List[List[int]] = [[] for colonne in range(hauteur_px + (-hauteur_px % 4))]
    
    
    
    for base_position_y in range(0, hauteur_px, 4):
        
        for base_position_x in range(0, largeur_px, 4):
            
            if version_dxt in (4, 5):
                alpha_1 = lecteur.read(1)[0]
                alpha_2 = lecteur.read(1)[0]
                
                if alpha_1 > alpha_2:
                    alphas = [alpha_1, alpha_2]
                    alphas += [int(alpha_2 * (part/7) + alpha_1 * ((7-part)/7)) for part in range(1, 7)]
                else:
                    alphas = [alpha_1, alpha_2]
                    alphas += [int(alpha_2 * (part/5) + alpha_1 * ((5-part)/5)) for part in range(1, 5)]
                    alphas += [0, 255]
                
                alphas_encodes = int.from_bytes(lecteur.read(6), 'little')
                
                for position_y in range(base_position_y, base_position_y + 4):               
                    for position_x in range(base_position_x, base_position_x + 4):     
                                   
                        sortie_alpha[position_y].append(alphas[alphas_encodes & 0b111])
                        
                        alphas_encodes >>= 3
            
            elif version_dxt in (2, 3):
                alphas_encodes = int.from_bytes(lecteur.read(8))

                for position_y in range(base_position_y, base_position_y + 4):               
                    for position_x in range(base_position_x, base_position_x + 4):     
                                   
                        sortie_alpha[position_y].append(int((alphas_encodes & 0b1111) / 0b1111 * 255))
                        
                        alphas_encodes >>= 4
                
            couleur_1_brute = int.from_bytes(lecteur.read(2), 'little')
            couleur_2_brute = int.from_bytes(lecteur.read(2), 'little')
            
            couleur_1 = couleur_16bits_vers_rgba(couleur_1_brute)
            couleur_2 = couleur_16bits_vers_rgba(couleur_2_brute)
            
            if couleur_1_brute > couleur_2_brute or version_dxt > 1:
                couleur_3 = tuple(
                    int(2/3 * couleur_1[composante] + 1/3 * couleur_2[composante])
                    for composante in range(4)
                )
                couleur_4 = tuple(
                    int(1/3 * couleur_1[composante] + 2/3 * couleur_2[composante])
                    for composante in range(4)
                )
            else:
                couleur_3 = tuple(
                    int(1/2 * couleur_1[composante] + 1/2 * couleur_2[composante])
                    for composante in range(4)
                )
                couleur_4 = (0, 0, 0, 0) # Noir transparent
                
            
            couleurs = [couleur_1, couleur_2, couleur_3, couleur_4]
            
            couleurs_encodees = int.from_bytes(lecteur.read(4), 'little')
            
            decalage_bits = 0
            
            for position_y in range(base_position_y, base_position_y + 4):       
                for position_x in range(base_position_x, base_position_x + 4):              
                
                    if version_dxt == 1:
                        r,g,b,a = couleurs[(couleurs_encodees >> decalage_bits) & 0b11]
                    else:
                        r,g,b = couleurs[(couleurs_encodees >> decalage_bits) & 0b11][:3]
                        a = sortie_alpha[position_y][position_x]
                
                    if version_dxt in (2, 4) and a > 0: # Prémultiplication par l'alpha (dans l'autre sens)
                        r /= (a / 255)
                        b /= (a / 255)
                        g /= (a / 255)
                
                    decalage_bits += 2
                    
                    sortie[position_y].append((int(r), int(g), int(b), int(a)))

    return sortie

Je n’utilise pas notre classe « LecteurDeBits » vue dans les précédens chapitres, S3TC utilisant des flux de bits inscrits dans des nombres, eux-mêmes inscrits dans plusieurs octets, qui ne sont pas eux-mêmes disposés dans le sens de la lecture des bits (eh oui !).

Vient ensuite le moment d’écrire la fonctin qui va décoder le fichier DDS, afficher quelques menues informations sur ses bit fields décodés, appeler la fonction de décodage S3TC, puis afficher l’image avec PIL ! O_O

def mon_decodeur_dds(entree : bytes):
    
    header = DDS_HEADER()
    lecteur = BytesIO(entree)
    
    lecteur.readinto(header)
    
    print('[INFO] Valeurs de dwFlags => ', DwFlags(header.dwFlags))
    print('[INFO] Valeurs de ddspf.dwFlags => ', PIXELFORMAT_DwFlags(header.ddspf.dwFlags))
    print('[INFO] Valeurs de dwCaps => ', DwCaps(header.dwCaps))
    print('[INFO] Valeurs de dwCaps2 => ', DwCaps(header.dwCaps2))
    
    assert header.dwMagic == b'DDS '
    assert lecteur.tell() - 4 == header.dwSize == 124
    
    image = Image.new('RGBA', (header.dwWidth, header.dwHeight))
    
    if header.ddspf.dwFourCC[:3] == b'DXT':
        version_dxt = int(header.ddspf.dwFourCC[3:])
        
        assert 1 <= version_dxt <= 5
        
        couleurs_rgba : List[List[Tuple[int, int, int, int]]] = decoder_dxt(version_dxt, header.dwHeight, header.dwWidth, lecteur.read())
    
    else:
        raise NotImplementedError('Format de compression non supporté : ' + str(header.ddspf.dwFourCC))

    image_pixels = image.load()

    for y in range(header.dwHeight):
        for x in range(header.dwWidth):
            image_pixels[x, y] = couleurs_rgba[y][x]
    
    image.show() # L'image est affichée directement dans une nouvelle fenêtre !



Et voici, enfin, le code qui appelle la fonction de lecture d’un fichier DDS qu’on a écrite, en lisant le fichier passé en argument par l’utilisateur du programme.

# Prendre un chemin de la part de l'utilisateur en entrée.

args = ArgumentParser(description = "Affiche une texture DDS de petite taille " +
    "à l'écran, en utilisant la bibliothèque PIL.")

args.add_argument('fichier_entree', help = "Chemin de l'image à décoder sur le disque.")

args = args.parse_args()

with open(args.fichier_entree, 'rb') as objet_fichier:
    mon_decodeur_dds(objet_fichier.read())





Voici notre code en entier :

#!/usr/bin/python3
#-*- encoding: Utf-8 -*-
from ctypes import LittleEndianStructure, c_char, c_uint32
from typing import List, Dict, Set, Union, Sequence, Tuple
from argparse import ArgumentParser
from enum import IntFlag
from io import BytesIO
from PIL import Image

# D'après https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-pixelformat

class DDS_PIXELFORMAT(LittleEndianStructure):
    
    _fields_ = [
        ('dwSize', c_uint32),
        ('dwFlags', c_uint32),
        ('dwFourCC', c_char * 4),
        ('dwRGBBitCount', c_uint32),
        ('dwRBitMask', c_uint32),
        ('dwGBitMask', c_uint32),
        ('dwBBitMask', c_uint32),
        ('dwABitMask', c_uint32)
    ]
    
    _pack_ = True

# D'après https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header

class DDS_HEADER(LittleEndianStructure):

    _fields_ = [
        ('dwMagic', c_char * 4),
        ('dwSize', c_uint32),
        ('dwFlags', c_uint32),
        ('dwHeight', c_uint32),
        ('dwWidth', c_uint32),
        ('dwPitchOrLinearSize', c_uint32),
        ('dwDepth', c_uint32),
        ('dwMipMapCount', c_uint32),
        ('dwReserved', c_uint32 * 11),
        ('ddspf', DDS_PIXELFORMAT),
        ('dwCaps', c_uint32),
        ('dwCaps2', c_uint32),
        ('dwCaps3', c_uint32),
        ('dwCaps4', c_uint32),
        ('dwReversed5', c_uint32)
    ]
    
    _pack_ = True

class DwFlags(IntFlag):
    DDSD_CAPS = 0x1
    DDSD_HEIGHT = 0x2
    DDSD_WIDTH = 0x4
    DDSD_PITCH = 0x8
    DDSD_PIXELFORMAT = 0x1000
    DDSD_MIPMAPCOUNT = 0x20000
    DDSD_LINEARSIZE = 0x80000
    DDSD_DEPTH = 0x800000

class PIXELFORMAT_DwFlags(IntFlag):
    DDPF_ALPHAPIXELS = 0x1
    DDPF_ALPHA = 0x2
    DDPF_FOURCC = 0x4
    DDPF_RGB = 0x40
    DDPF_YUV = 0x200
    DDPF_LUMINANCE = 0x20000


class DwCaps(IntFlag):
    DDSCAPS_COMPLEX = 0x8
    DDSCAPS_MIPMAP = 0x400000
    DDSCAPS_TEXTURE = 0x1000

class DwCaps2(IntFlag):
    DDSCAPS2_CUBEMAP = 0x200
    DDSCAPS2_CUBEMAP_POSITIVEX = 0x400
    DDSCAPS2_CUBEMAP_NEGATIVEX = 0x800
    DDSCAPS2_CUBEMAP_POSITIVEY = 0x1000
    DDSCAPS2_CUBEMAP_NEGATIVEY = 0x2000
    DDSCAPS2_CUBEMAP_POSITIVEZ = 0x4000
    DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x8000
    DDSCAPS2_VOLUME = 0x200000
    
    


def couleur_16bits_vers_rgba(entree : int) -> Tuple[int, int, int, int]:
    
    return (
        int(( entree >> (6 + 5) ) / 0b11111 * 255),
        int(( (entree >> 5) & 0b111111 ) / 0b111111 * 255),
        int(( entree & 0b11111 ) / 0b11111 * 255),
        255
    )


"""
    Notre décodeur se trouve ici. Les dimensions de l'image doivent être
    définies explicitement, puisque autrement on ne sait pas combien de
    blocs sont horizontaux et combien sont verticaux.
"""

def decoder_dxt(version_dxt, hauteur_px : int, largeur_px : int, entree : bytes) -> List[List[Tuple[int, int, int, int]]]:
    
    lecteur = BytesIO(entree)
    
    sortie : List[List[Tuple[int, int, int, int]]] = [[] for colonne in range(hauteur_px + (-hauteur_px % 4))]
    sortie_alpha : List[List[int]] = [[] for colonne in range(hauteur_px + (-hauteur_px % 4))]
    
    
    
    for base_position_y in range(0, hauteur_px, 4):
        
        for base_position_x in range(0, largeur_px, 4):
            
            if version_dxt in (4, 5):
                alpha_1 = lecteur.read(1)[0]
                alpha_2 = lecteur.read(1)[0]
                
                if alpha_1 > alpha_2:
                    alphas = [alpha_1, alpha_2]
                    alphas += [int(alpha_2 * (part/7) + alpha_1 * ((7-part)/7)) for part in range(1, 7)]
                else:
                    alphas = [alpha_1, alpha_2]
                    alphas += [int(alpha_2 * (part/5) + alpha_1 * ((5-part)/5)) for part in range(1, 5)]
                    alphas += [0, 255]
                
                alphas_encodes = int.from_bytes(lecteur.read(6), 'little')
                
                for position_y in range(base_position_y, base_position_y + 4):               
                    for position_x in range(base_position_x, base_position_x + 4):     
                                   
                        sortie_alpha[position_y].append(alphas[alphas_encodes & 0b111])
                        
                        alphas_encodes >>= 3
            
            elif version_dxt in (2, 3):
                alphas_encodes = int.from_bytes(lecteur.read(8))

                for position_y in range(base_position_y, base_position_y + 4):               
                    for position_x in range(base_position_x, base_position_x + 4):     
                                   
                        sortie_alpha[position_y].append(int((alphas_encodes & 0b1111) / 0b1111 * 255))
                        
                        alphas_encodes >>= 4
                
            couleur_1_brute = int.from_bytes(lecteur.read(2), 'little')
            couleur_2_brute = int.from_bytes(lecteur.read(2), 'little')
            
            couleur_1 = couleur_16bits_vers_rgba(couleur_1_brute)
            couleur_2 = couleur_16bits_vers_rgba(couleur_2_brute)
            
            if couleur_1_brute > couleur_2_brute or version_dxt > 1:
                couleur_3 = tuple(
                    int(2/3 * couleur_1[composante] + 1/3 * couleur_2[composante])
                    for composante in range(4)
                )
                couleur_4 = tuple(
                    int(1/3 * couleur_1[composante] + 2/3 * couleur_2[composante])
                    for composante in range(4)
                )
            else:
                couleur_3 = tuple(
                    int(1/2 * couleur_1[composante] + 1/2 * couleur_2[composante])
                    for composante in range(4)
                )
                couleur_4 = (0, 0, 0, 0) # Noir transparent
                
            
            couleurs = [couleur_1, couleur_2, couleur_3, couleur_4]
            
            couleurs_encodees = int.from_bytes(lecteur.read(4), 'little')
            
            decalage_bits = 0
            
            for position_y in range(base_position_y, base_position_y + 4):       
                for position_x in range(base_position_x, base_position_x + 4):              
                
                    if version_dxt == 1:
                        r,g,b,a = couleurs[(couleurs_encodees >> decalage_bits) & 0b11]
                    else:
                        r,g,b = couleurs[(couleurs_encodees >> decalage_bits) & 0b11][:3]
                        a = sortie_alpha[position_y][position_x]
                
                    if version_dxt in (2, 4) and a > 0: # Prémultiplication par l'alpha (dans l'autre sens)
                        r /= (a / 255)
                        b /= (a / 255)
                        g /= (a / 255)
                
                    decalage_bits += 2
                    
                    sortie[position_y].append((int(r), int(g), int(b), int(a)))

    return sortie
    
    
    
    

def mon_decodeur_dds(entree : bytes):
    
    header = DDS_HEADER()
    lecteur = BytesIO(entree)
    
    lecteur.readinto(header)
    
    print('[INFO] Valeurs de dwFlags => ', DwFlags(header.dwFlags))
    print('[INFO] Valeurs de ddspf.dwFlags => ', PIXELFORMAT_DwFlags(header.ddspf.dwFlags))
    print('[INFO] Valeurs de dwCaps => ', DwCaps(header.dwCaps))
    print('[INFO] Valeurs de dwCaps2 => ', DwCaps(header.dwCaps2))
    
    assert header.dwMagic == b'DDS '
    assert lecteur.tell() - 4 == header.dwSize == 124
    
    image = Image.new('RGBA', (header.dwWidth, header.dwHeight))
    
    if header.ddspf.dwFourCC[:3] == b'DXT':
        version_dxt = int(header.ddspf.dwFourCC[3:])
        
        assert 1 <= version_dxt <= 5
        
        couleurs_rgba : List[List[Tuple[int, int, int, int]]] = decoder_dxt(version_dxt, header.dwHeight, header.dwWidth, lecteur.read())
    
    else:
        raise NotImplementedError('Format de compression non supporté : ' + str(header.ddspf.dwFourCC))

    image_pixels = image.load()

    for y in range(header.dwHeight):
        for x in range(header.dwWidth):
            image_pixels[x, y] = couleurs_rgba[y][x]
    
    image.show() # L'image est affichée directement dans une nouvelle fenêtre !



# Prendre un chemin de la part de l'utilisateur en entrée.

args = ArgumentParser(description = "Affiche une texture DDS de petite taille " +
    "à l'écran, en utilisant la bibliothèque PIL.")

args.add_argument('fichier_entree', help = "Chemin de l'image à décoder sur le disque.")

args = args.parse_args()

with open(args.fichier_entree, 'rb') as objet_fichier:
    mon_decodeur_dds(objet_fichier.read())









En regardant bien l’image produite (il s’agit à l’origine de « :pirate: »), on remarque bien les semi-aplats caractéristiques de S3TC !


Liens utiles :