Tous les articles

Un rasterizer software CPU qui doit copier un GPU au pixel près

22 juin 20268 min de lecture
Le problème de départ n'était pas "comment dessiner un personnage". C'était "comment produire, sur un serveur sans carte graphique, exactement la même image que celle que le client du jeu affiche avec son pipeline GPU". Ce sont deux problèmes très différents. Le premier laisse le champ libre à un moteur de rendu classique. Le second impose une contrainte beaucoup plus dure : la parité visuelle avec une implémentation existante qu'on ne contrôle pas et qu'on ne peut pas modifier.

Le service en question compose des sprites de personnages (corps, coiffure, équipement) à partir de fichiers d'animation binaires et d'atlas PNG, côté serveur, pour produire une image PNG à la demande. Ces personnages doivent avoir exactement le rendu du jeu : mêmes couleurs de zone, mêmes découpes d'atlas, même comportement de transparence. Un pixel de travers et l'image ne correspond plus à ce que l'utilisateur voit en jeu.

Pourquoi pas un GPU

Il n'y a pas de carte graphique disponible côté serveur, ce qui aurait suffi à trancher la question à lui seul. Mais même avec un GPU, le vrai problème resterait entier : il faut reproduire le comportement exact d'un backend de rendu de référence, pas inventer un pipeline équivalent visuellement. Ça change la nature du travail. On ne conçoit pas un moteur de rendu générique avec ses propres choix d'implémentation, on rétro-ingénierie un contrat précis :

Rasterisation
Quads texturés, lookup UV en affine inverse.
Coordonnées UV
Un flip_y particulier sur le V.
Compositing
OVER en alpha droit.

Le CPU donne un contrôle total sur chaque opération arithmétique, ce qui est exactement ce qu'il faut quand l'objectif est de matcher un calcul existant plutôt que de produire "quelque chose qui ressemble".

Le cœur : inverser l'affine, pas la transformer

La fonction centrale, render_shape(), rasterise un quad texturé (un shape) transformé par une matrice affine 2x3 qui vient de l'arbre de sprites (position, rotation, échelle accumulées niveau par niveau).

Approche naïve
Transformer les coins du quad vers l'écran, puis interpoler les UV en balayant le triangle.
Retenue
Calculer d'abord la bounding box écran du quad transformé, puis, pour chaque pixel de cette bbox, inverser la matrice pour retrouver la coordonnée locale correspondante.
Inversion de la matrice affine
rust
let inv_det = 1.0 / det;
// ...
let dx = sx - m31;
let dy = sy - m32;
// Inverse affine: local = T^-1 · screen
let lx = (dx * m22 - dy * m21) * inv_det; 
let ly = (dy * m11 - dx * m12) * inv_det;

Ce sens de calcul n'est pas un détail d'implémentation, c'est ce qui donne la parité. En reproduisant exactement la même méthode d'échantillonnage (nearest, sans interpolation, avec le même flip_y sur le V de l'UV) que le backend GPU de référence, on obtient les mêmes artefacts de crénelage aux mêmes endroits, pas juste une image "à peu près pareille".

La boucle par pixel est en unsafe, indexation non bornée dans les octets bruts de l'atlas et du buffer de sortie. C'est justifié par des debug_assert! qui vérifient les bornes en amont, dans les builds de debug, sans payer le coût en release. Chaque pixel de l'atlas lu par cette boucle est un bloc RGBA de 4 octets consécutifs :

Lecture RGBA sans bornes (unsafe)
rust
debug_assert!(ai + 3 < atlas_buf.len()); 
let (br, bg, bb, ba) = unsafe {
    (*atlas_buf.get_unchecked(ai), *atlas_buf.get_unchecked(ai + 1),
     *atlas_buf.get_unchecked(ai + 2), *atlas_buf.get_unchecked(ai + 3))
};

Le piège

Cette boucle tourne pour chaque pixel de chaque shape, de chaque sprite, potentiellement pour chaque frame d'une animation. Le clamp des indices ax/ay sur les bornes de l'atlas est déjà fait juste au-dessus, donc les indices sont prouvés valides par construction : l'assert sert de filet en dev, pas de logique de correction en prod.

Le blending OVER, lui, reste en entiers1.

  1. Les divisions par 255 sont abaissées par le compilateur en multiply+shift, sans division matérielle, et les numérateurs restent bornés (au plus 255·255·2) donc jamais de débordement en u32.

Aplatir la transformation de couleur au lieu de la rejouer par pixel

Chaque sprite peut porter une transformation de couleur (Multiply, Add, ou une combinaison des deux, arbitrairement imbriquée par l'arbre de sprites parent/enfant). L'approche directe serait de reconstruire et de rejouer cette chaîne pour chaque pixel du shape. Sauf qu'une chaîne Multiply/Add/Combine est affine par canal : out = byte * scale + offset. Peu importe sa profondeur, elle se réduit toujours à ces deux nombres. Alors plutôt que de la replier à chaque pixel, on la sonde deux fois, une fois avec du noir pur (0,0,0,0) et une fois avec du blanc pur (1,1,1,1), pour en extraire scale et offset :

Sonder la chaîne de couleur (noir / blanc)
rust
let z = transform.color.clone().fold(Color::new(0.0, 0.0, 0.0, 0.0));
let o = transform.color.clone().fold(Color::new(1.0, 1.0, 1.0, 1.0));
let (scl_r, off_r) = (o.red - z.red, z.red * 255.0); 

Le résultat s'applique ensuite en un seul multiply-add directement sur les octets bruts de l'atlas, une fois par shape et non par pixel. C'est le genre d'optimisation qui ne change rien au résultat (la parité est préservée puisque c'est mathématiquement identique) mais qui déplace tout le travail de reconstruction de l'arbre de couleurs hors de la boucle chaude.

Un budget de temps, pas une promesse de qualité illimitée

Les rendus statiques (une pose figée) ne sont pas bornés en taille et utilisent la pleine résolution des atlas haute définition. Mais un rendu animé, exporté frame par frame, doit rester rapide. Si la taille naturelle du canvas dépasse ce budget, l'échelle est réduite proportionnellement avant de rasteriser, plutôt qu'après.

Budget
MAX_ANIM_PIXELS fixe un budget de 120 000 pixels de canvas.
Résolution type
~300x400 pixels.
Par frame
~50 ms de rasterisation + ~5 ms d'encodage.
050RasterisationEncodage
Temps par frame, canvas ~300x400 (ms)

Le commentaire dans le code donne l'ordre de grandeur qui justifie ce chiffre. Multiplié par une centaine de frames pour une animation longue, la différence entre "borné" et "pas borné" se traduit directement en temps de réponse HTTP acceptable ou non.

Les caches : éviter de payer deux fois le même calcul

Les fichiers d'animation et les atlas PNG sont chargés depuis le disque via des caches Moka asynchrones, 512 entrées chacun. Le point qui compte vraiment, c'est l'usage de try_get_with plutôt qu'un simple get-puis-insert : ça garantit un compute-if-absent atomique.

get puis insert
Deux requêtes concurrentes qui ratent le cache en même temps déclenchent chacune leur propre lecture disque et leur propre décodage, et finissent par écraser le résultat l'une de l'autre.
`try_get_with`
Compute-if-absent atomique : la première requête déclenche le chargement, les suivantes attendent le même résultat au lieu de le recalculer.

Le chargement lui-même passe par spawn_blocking, parce que lire un fichier depuis le disque est une opération bloquante qu'on ne veut pas laisser geler une tâche tokio. Et au démarrage, un warm-up précharge les animations de base pour chaque classe de personnage, pour que la toute première requête /render ne soit jamais celle qui paie le chargement disque à froid.

Le reste des choix Rust suit la même logique utilitaire plutôt que dogmatique : axum et tokio pour le serveur HTTP asynchrone, rayon pour paralléliser le rendu des frames d'une même animation (chaque frame est indépendante, donc trivialement parallélisable), et un profil release avec LTO thin et un seul codegen-unit, réglé spécifiquement parce que la boucle de rasterisation par pixel est le point chaud du service.

À retenir

Ce qui reste de cette expérience, c'est qu'un rasterizer software mal considéré comme "la solution de repli quand on n'a pas de GPU" peut en réalité être le bon choix technique dès que la contrainte principale n'est pas la performance brute mais la fidélité à une référence externe. Le CPU permet de reproduire une arithmétique précise, instruction par instruction, sans les abstractions et les optimisations opaques d'un pipeline GPU qui pourrait, sur un détail d'échantillonnage ou d'arrondi, produire un résultat légèrement différent. Et une fois cette contrainte de parité posée, l'optimisation n'est plus un exercice de style : elle consiste à trouver, comme pour la transformation de couleur, les endroits où le calcul peut être factorisé sans jamais changer le résultat pixel par pixel.