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 ?

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
- DXT2/DXT3 : DXT1 mais avec des niveaux de transparence
- DXT4/DXT5 : DXT3 mais avec la transparence codée en palette
- Le format DDS
- Un décodeur DDS en Python
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 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, à de la couleur n°1 plus de la couleur n° 2
- La couleur n° 4 sera égale, pour chaque composante, à de la couleur n°2 plus 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, à de la couleur n°1 plus 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. |
, 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 à 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) |
, 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 | de opacité n° 1 + de opacité n° 2 | de opacité n° 1 + de opacité n° 2 |
Opacité n° 4 | de opacité n° 1 + de opacité n° 2 | de opacité n° 1 + de opacité n° 2 |
Opacité n° 5 | de opacité n° 1 + de opacité n° 2 | de opacité n° 1 + de opacité n° 2 |
Opacité n° 6 | de opacité n° 1 + de opacité n° 2 | de opacité n° 1 + de opacité n° 2 |
Opacité n° 7 | de opacité n° 1 + de opacité n° 2 | 0 |
Opacité n° 8 | de opacité n° 1 + 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
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 « »), on remarque bien les semi-aplats caractéristiques de S3TC !
Liens utiles :