Tutoriels développement jeux indés
Implémenter un rendu pixel-perfect basse résolution et une caméra sub-pixel avec Godot 4
TL;DR
La solution consiste à simuler et rendre le jeu en basse résolution pixel-perfect dans un SubViewport. Ce SubViewport est ensuite redimensionné par un facteur entier et affiché en haute résolution dans un SubViewportContainer, lui-même rendu par une seconde caméra. Cette dernière peut se déplacer librement de manière fluide en fonction de la vélocité du joueur, tout en étant limitée à une certaine distance afin de ne jamais sortir du rendu initial.
Lorsque j’ai commencé à assembler les premières fondations de The Reaping Company, mon jeu de plateforme 2D en pixel art, j’ai rapidement été confronté à un problème de taille lorsque j’ai voulu m’attaquer à la caméra.
Je souhaitais obtenir un monde pixel-perfect, c’est-à-dire un rendu fidèle aux anciens jeux où le moindre pixel affiché sur l’écran correspond réellement au pixel « jouable » de la grille. Cependant, je voulais aussi une caméra moderne qui se déplace de manière fluide, non liée à ces pixels justement, car je trouve le déplacement « cranté » trop rigide et peu esthétique pour un jeu actuel.
Les problématiques rencontrées dans un tel projet sont plus nombreuses qu’il n’y paraît :
- La haute résolution : Aujourd’hui, nos écrans sont en haute résolution (1080p, 4K), alors qu’un jeu pixel art est en basse résolution. Comment garantir un rendu propre et cohérent ?
- La portabilité : Comment être certain que le rendu que j’ai sur ma machine sera identique sur un autre écran, avec une résolution ou un taux de rafraîchissement différent ?
- La fluidité sub-pixel : Si le jeu est bloqué sur une grille de pixels, comment rendre la caméra plus souple qu’un simple suivi rigide ?
- La synchronisation : Comment éviter les soucis de désynchronisation entre la physique du jeu (les calculs) et le rendu visuel (ce qu’on voit) ?
J’ai été confronté à tous ces soucis d’un coup et, malgré la documentation disponible sur internet et de nombreuses solutions proposées, aucune ressource n’a fonctionné à 100% pour mon cas, et pour beaucoup d’autres personnes. Néanmoins, j’ai fini par imaginer une solution qui règle à priori tout (du moins je n’ai pas encore trouvé de limitation) ! Au travers de cette mésaventure, je vais vous expliquer pourquoi les méthodes classiques peuvent échouer et vous donner une solution pas à pas pour obtenir un rendu rétro tout en ayant une caméra fluide sur Godot.
La confrontation au problème : pourquoi les solutions classiques ont échoué
Au début du projet, j’ai naturellement utilisé une Camera2D classique, attachée au personnage. La caméra suivait le joueur de manière fluide sans nécessiter la moindre ligne de code. Cette solution était évidemment temporaire, car je savais que je voulais à terme obtenir ce fameux mélange : pixel-perfect strict et caméra fluide.
Je me suis alors tourné vers les nombreux tutoriels disponibles, qui proposent généralement une approche générale : L’utilisation d’un SubViewport en basse résolution dans lequel tourne le jeu, avec un shader ou un script qui s’occupe de l’alignement pixel-prefect.
Sur mon MacBook de travail, tout semblait fonctionner correctement. Mais dès que j’ai testé le jeu sur mon PC fixe sous Windows, le rendu est devenu catastrophique : apparition de ghosting (traînées), duplications visuelles et désynchronisations flagrantes. Le personnage semblait parfois glitcher lors de ses déplacements, donnant une impression très désagréable à l’écran.
Après de nombreux tests, j’ai fini par identifier l’une des causes du problème : la différence de taux de rafraîchissement entre mes écrans. Mon MacBook tourne à 60 Hz, tandis que mon écran PC est à 165 Hz.
Godot fait fonctionner son moteur physique et son moteur de rendu de manière indépendante. Par défaut, la physique tourne à 60 ticks par seconde. Sur un écran à 60 Hz, tout est parfaitement synchronisé. En revanche, sur un écran à 165 Hz, le moteur de rendu tente d’afficher des images alors que la physique n’a pas encore été mise à jour, ce qui provoque ces artefacts visuels.
J’ai tenté plusieurs approches :
- modifier le nombre de ticks par seconde du moteur physique (ce qui entraînait des bugs de collisions et divers glitchs),
- déplacer la caméra dans le _physics_process (ce qui supprimait la fluidité et introduisait des tremblements),
- ajuster divers paramètres liés au SubViewport.
Aucune de ces solutions ne s’est révélée réellement robuste ni universelle. La solution du SubViewport refusait obstinément de fonctionner de manière fiable sur toutes les machines.
Les vidéos traitant du sujet (aarthificial, Barry’s Dev Hell, Nesi, Picster) sont pourtant excellentes et très pédagogiques. Elles permettent de mieux comprendre les enjeux, mais dès que l’on tente de reproduire ces systèmes chez soi, les différences de setup font que le résultat n’est plus le même.
Il existait visiblement une solution viable dans Godot 3 (notamment celle de Picster), mais celle-ci s’est retrouvée cassée lors du passage à Godot 4.0. De nombreux développeurs ont vécu exactement la même chose, comme en témoignent les nombreux commentaires sous ces vidéos.
En cherchant plus loin, je me suis également plongé dans de longues discussions sur les forums et sur GitHub. Le constat qui ressortait de ces échanges était plutôt décourageant : il n’existerait pas de solution universelle, capable de fonctionner dans toutes les conditions sans jitter, tremblements ou artefacts visuels. D’un point de vue mathématique, les approches proposées semblent toujours impliquer des compromis, et ne permettent pas d’atteindre le rendu parfait que tout le monde espère.
Alors que j’étais presque prêt à abandonner, et après avoir vu passer de nombreuses théories différentes, j’ai fini par avoir une idée différente de tout ce que j’avais vu jusque-là, une approche que je n’avais encore rencontrée nulle part ailleurs.
N’étant ni mathématicien, ni physicien, ni développeur expert, toutes les solutions reposant sur des shaders complexes, des calculs vectoriels ou des ajustements mathématiques pointus me semblaient obscures et difficiles à concevoir par moi-même. Plutôt que de chercher à comprendre ou réinventer ces systèmes qui avaient été ingénieusement pensés, j’ai décidé de raisonner de manière plus simple, presque naïve, en utilisant uniquement les éléments de Godot que je connaissais déjà.
Je ne prétends absolument pas que cette technique est parfaite, ni qu’elle est exempte de limites. En revanche, à l’heure actuelle, c’est la seule solution qui m’a permis d’obtenir :
- un jeu pixel art en basse résolution réellement pixel-perfect,
- une caméra fluide sans jitter ni tremblement,
- une compatibilité avec tous les taux de rafraîchissement d’écran,
- et un redimensionnement propre vers toutes les hautes résolutions.
Si vous cherchez vous aussi à obtenir ce résultat, je vous propose de voir comment implémenter concrètement tout ce système, mais tout d’abord parlons un peu de la théorie et des problèmes liés à tout ça.
Comprendre les problèmes fondamentaux du rendu pixel-perfect
La gestion de la grille de pixels (sub-pixel)
Dans un moteur de jeu moderne, les positions des objets sont calculées avec des nombres à virgule flottante (float). Un personnage peut se trouver à la position x=10.52. Cette fraction correspond à une position sub-pixel, qui existe mathématiquement dans le moteur, mais pas sur un écran pixel art : le pixel 10.52 n’existe pas, soit le pixel est à la position 10, soit à la position 11.
C’est pourquoi il est recommandé de rendre son jeu pixel art en basse résolution pour coller parfaitement aux pixels des sprites, puis de le redimensionner par nombre entier en haute résolution. Attention cependant, tout cela ne se fait pas aussi simplement, je vous invite grandement à vous renseigner sur comment bien choisir sa résolution de jeu pixel art.
Si on laisse le moteur gérer ces positions sub-pixel naïvement, il va tenter de les afficher en mélangeant les couleurs des pixels voisins (anti-aliasing) ou en déformant le sprite non uniformément. Pour un jeu pixel art strict, comme The Reaping Company, c’est inacceptable : chaque pixel doit tomber pile sur la grille pour rester net. C’est ce qu’on appelle le Pixel Snapping. Le défi est d’appliquer ce snapping visuellement sans briser la précision des calculs derrière.
Godot possède bien une option pour gérer le Pixel Snapping, accessible via : Project Settings → Rendering → 2D → Snap → Snap 2D Transforms to Pixel. Mais l’utilisation de cette option combinée à la caméra fluide ne fonctionnait pas correctement dans mon cas.
Le défi du taux de rafraîchissement (Hz)
C’est ici que mon passage du Mac au PC a tout révélé. Sur un écran 60 Hz, l’image est rafraîchie toutes les 16.6 ms. Si votre moteur physique tourne aussi à 60 tics/sec comme par défaut, tout est synchrone.
Mais sur un écran à 165 Hz, le moteur de rendu demande une image toutes les 6 ms. Comme la physique n’a pas bougé entre-temps, le moteur de rendu essaie d’interpoler une position « fantôme » pour combler le vide. Si cette interpolation n’est pas parfaitement alignée sur notre logique de pixel art, on obtient du ghosting : une traînée floue derrière le personnage en mouvement. Le jeu semble « baver » car l’écran affiche des positions que la logique du jeu n’a jamais réellement validées.
Je ne sais pas si la différence entre les tics par secondes du moteur physique et le taux de rafraîchissement de l’écran posent un problème dans beaucoup de cas, mais dans les configurations que j’ai testé pour allier rendu basse résolution et caméra sub-pixel, cela causait des bugs.
Le paradoxe de la fluidité de la caméra en pixel art
Il y a un conflit esthétique majeur :
- Si on bloque la caméra sur la grille de pixels (snapping pur comme dans les jeux rétro) en même temps que le personnage et le jeu, son mouvement devient saccadé, surtout lors de déplacements lents. On voit la caméra « sauter » de pixel en pixel, ce qui donne un aspect rigide et potentiellement « cheap ». Bien que cela puisse être un parti pris pour garder une esthétique rétro assumée, mais dans mon cas je ne voulais pas cet effet.
- Si on laisse la caméra totalement libre, les sprites du monde semblent vibrer ou se déformer légèrement pendant le mouvement, car la caméra n’est jamais parfaitement alignée avec les textures du décor.
La solution idéale doit donc permettre à la caméra de glisser avec une précision mathématique infinie, tout en s’assurant que lors du rendu du jeu les pixels sont bien alignés.
La désynchronisation Physique / Rendu
Godot sépare le _physics_process (calculs fixes, 60 FPS par défaut) du _process (rendu variable, aussi rapide que possible). Si vous déplacez votre caméra dans le _process pour qu’elle soit fluide, elle risque de dépasser la position réelle du joueur calculée dans le moteur physique. On observe alors des micro-saccades (jitter) : le joueur semble vibrer à l’intérieur de la caméra car les deux systèmes ne « parlent » pas à la même fréquence. Il faut donc une méthode pour dire au moteur : « Calcule la position de manière stable dans la physique, mais lisse l’affichage visuel sans créer de décalage ».
Implémenter un rendu pixel-perfect avec une caméra fluide dans Godot 4
Voici la solution qui m’a permis de résoudre l’ensemble des problématiques évoquées précédemment. Elle repose sur une arborescence spécifique, pensée pour séparer clairement le rendu pixel-perfect basse résolution de la caméra fluide haute résolution.
L’approche peut paraître inhabituelle au premier abord, mais elle reste relativement simple à comprendre et surtout facile à maintenir une fois mise en place.
Déterminer les résolutions du jeu pixel art et du rendu final
Cette étape est absolument primordiale et ne doit pas être négligée, car elle conditionne directement le redimensionnement et la qualité du rendu final.
La première chose à faire est de définir la résolution de rendu interne du jeu pixel art, c’est-à-dire la basse résolution qui représente la véritable grille de pixels du jeu, comme s’il devait tourner sur une ancienne machine. Cette résolution détermine la taille réelle des pixels logiques du jeu.
Il est essentiel de faire attention :
- aux proportions de cette résolution,
- ainsi qu’aux facteurs d’agrandissement entiers possibles vers les résolutions modernes.
Un mauvais choix à ce stade peut rendre le redimensionnement imprécis ou provoquer des artefacts visuels. Je vous recommande fortement de vous renseigner sur la manière de bien choisir la résolution d’un jeu pixel art afin de faire votre choix.
Dans notre cas, nous partirons sur une résolution de 320×180px.
La résolution finale de rendu, quant à elle, correspond à la résolution réelle à laquelle le jeu sera affiché sur les écrans modernes. C’est cette résolution qui permet d’avoir une caméra fluide tout en conservant un rendu net. Nous utilisons ici une résolution Full HD (1920×1080 px), ce qui correspond exactement à un facteur d’agrandissement x6 par rapport à la résolution pixel art (1920 / 320 = 6).
Architecture de la scène dans Godot 4
Le principe fondamental de cette solution repose sur une séparation claire entre :
- le jeu rendu en basse résolution pixel-perfect,
- et la caméra fluide chargée de l’affichage final.
Pour cela, l’arborescence est organisée de la manière suivante :
- Root : La scène principale lancée au démarrage du jeu.
- SubViewportContainer : Le nœud chargé d’afficher et de positionner le rendu provenant du SubViewport.
- SubViewport : Le nœud qui permet de rendre le jeu en basse résolution, de manière fixe et contrôlée.
- LowResGame : Un nœud de regroupement contenant tous les éléments du jeu.
- LowResCamera : La caméra utilisée pour rendre le jeu en basse résolution. Elle suit le joueur de manière stricte et rigide.
- Player : Le personnage du joueur.
- TileMapLayer : Le monde du jeu.
- HighResCamera : La caméra chargée de produire l’effet de fluidité. Elle rend ce qui est affiché dans le SubViewportContainer.
Cette architecture est la clé du système : le jeu est rendu de façon parfaitement alignée en basse résolution, puis affiché et déplacé de manière fluide à un niveau supérieur.
Paramètres du projet Godot pour un rendu pixel art basse résolution
Avant d’aller plus loin, il est indispensable de configurer correctement certains paramètres globaux du projet.
Dans Project Settings → Rendering → Textures → Canvas Texture :
- Default Texture Filter : Nearest
Ce paramètre est très important, il empêche toute interpolation des textures et préserve la netteté brute des contours en pixel.
Dans Project Settings → Display → Window :
- Size → Viewport Width : 1920 (largeur déterminée précédemment)
- Size → Viewport Width : 1080 (hauteur déterminée précédemment)
Ces valeurs correspondent à la résolution finale du jeu. Contrairement à d’autres techniques basées sur une basse résolution native, ici le projet démarre directement en haute résolution.
- Size → Mode : Windowed (optionnel)
- Stretch → Mode : viewport
- Stretch → Aspect : keep
- Stretch → Scale Mode : integer
Ces paramètres garantissent : une mise à l’échelle uniquement par facteur entier, l’absence totale de pixels fractionnaires, et un rendu cohérent quelle que soit la résolution de l’écran.
Le script du personnage du Joueur (Player – CharacterBody2D)
En plus de son comportement propre (déplacements, sauts, collisions, etc.), le joueur a simplement besoin d’une ligne supplémentaire à la fin de son _physics_process :
Cette instruction force la position du joueur à rester sur des coordonnées entières. Elle permet :
- de conserver un alignement parfait sur la grille de pixels,
- et d’éviter l’apparition de ghosting une fois le système complet en place.
Le script de la caméra du jeu (LowResCamera – camera2D)
Cette caméra doit être la plus simple et rigide possible. Son rôle est uniquement de suivre le joueur sans délai, sans interpolation, et sans effet de smoothing.
Il suffit d’assigner le nœud Player à follow_target. La caméra prend alors exactement la position du joueur, ce qui garantit un rendu strictement pixel-perfect. On aurait également pu placer directement cette Camera2D comme enfant du joueur.
L’utilisation de global_position.round() est une sécurité supplémentaire, même si la position du joueur est déjà arrondie dans son propre script. C’est également à ce niveau que l’on peut ajouter un éventuel offset vertical (axe Y), selon les besoins du gameplay. Aucun autre paramètre particulier n’est nécessaire : la caméra doit rester la plus neutre possible.
À ce stade, vous pouvez déjà tester la scène LowResGame seule. Vous devriez obtenir :
- un personnage parfaitement aligné sur la grille,
- une caméra rigide et instantanée,
- et un rendu très zoomé (en fonction de la taille de vos sprites et de la résolution choisie).
Le Subviewport et le subviewportcontainer
C’est ici que le système commence réellement à prendre forme, mais aussi là où il est le plus facile de faire des erreurs. Les paramètres de ces deux nœuds sont essentiels.
Le SubViewport : Il permet de « figer » le rendu du jeu basse résolution afin de l’afficher correctement dans le SubViewportContainer. Les paramètres à ajuster sont les suivants :
- Size → 1920 x 1080 px (soit la résolution finale définié précédemment).
- Render Target → Update Mode → Always
- Viewport → Disable 3D → On
- Canvas Items → Default Texture Filter → Nearest
- Audio Listener → Enable 2D → On
SubViewportContainer : Le SubViewportContainer se charge simplement d’afficher le rendu fourni par le SubViewport. Il doit occuper toute la surface disponible et être centré :
- Layout → Anchors Preset → Full Rect
la caméra smooth (highResCamera – camera2D)
Cette caméra est responsable de l’effet de fluidité du jeu. Ses paramètres doivent être configurés avec soin. La première étape consiste à régler le zoom. Celui-ci doit correspondre exactement au facteur d’agrandissement déterminé précédemment. Dans notre cas, un zoom x6 :
- Zoom → x → 6.0
- Zoom → y → 6.0
À ce stade, si vous lancez le jeu, celui-ci s’affiche bien en 1920×1080px, mais avec l’apparence d’un jeu 320×180px. Le personnage se déplace correctement, le rendu est pixel-perfect, mais la caméra reste encore rigide, ce qui est parfaitement norma pour l’instant.
Il faut maintenant ajouter un script à cette caméra pour introduire le mouvement fluide :
Expliquons maintenant en détail le principe de fonctionnement de ce script et son objectif :
- La première étape consiste à récupérer le point central du SubViewportContainer. C’est ce point qui représente le centre du rendu basse résolution affiché à l’écran, et donc la position vers laquelle la caméra haute résolution doit naturellement pointer.
- On calcule ensuite un offset cible à partir de la vélocité du joueur. Ce choix est fondamental. On ne se base surtout pas sur la position du joueur, car celui-ci évolue dans son propre espace de jeu et peut se déplacer très loin dans toutes les directions. À l’inverse, notre rendu est strictement limité au cadre du SubViewportContainer : la caméra ne doit jamais chercher à suivre directement le joueur dans cet espace, sous peine de sortir du cadre et de révéler les limites du rendu.
- La vélocité nous donne uniquement une information sur la direction et l’intensité du mouvement, ce qui est exactement ce dont nous avons besoin pour créer une anticipation de caméra naturelle, sans jamais perdre le cadre de référence.
- Cet offset est ensuite clampé, afin de définir une distance maximale à laquelle la caméra peut s’éloigner du centre. Cette limite est importante : sans elle, la caméra pourrait se décaler trop loin, au point de ne plus afficher correctement le personnage ou de laisser apparaître les bords du rendu du SubViewportContainer, révélant ainsi la supercherie.
- Une fois l’offset limité, on calcule la position cible finale en additionnant le centre du SubViewportContainer et cet offset clampé. Cette position est ensuite atteinte progressivement grâce à une interpolation (lerp), ce qui permet d’obtenir un déplacement parfaitement fluide.
- Enfin, tout ce calcul est effectué dans le _physics_process, afin d’éviter toute désynchronisation avec la physique du personnage. Je dois avouer ne pas être absolument certain que ce soit la meilleure pratique pour une caméra, l’utilisation du _process pourrait également convenir. Dans mes tests, les deux approches fonctionnent de manière identique, mais placer la logique dans la boucle physique m’a paru plus cohérent avec le reste du système.
Et c’est tout !
À partir de là, il devient très simple d’ajuster le comportement de la caméra selon vos besoins.
Aller plus loin, améliorations possibles
L’un des grands avantages de ce système est sa simplicité. Tant que certaines règles de base sont respectées, il est facile de conserver à la fois un rendu pixel-perfect strict et une caméra fluide, sans imposer de contraintes excessives.
Les principes fondamentaux à respecter sont les suivants :
- Utiliser un facteur d’agrandissement entier entre la basse résolution et la haute résolution.
- Arrondir systématiquement la position globale du personnage avec global_position.round() afin de préserver l’alignement parfait sur la grille.
- Employer une caméra basse résolution rigide qui suit directement le personnage.
- Déporter le rendu basse résolution dans un SubViewportContainer.
- Utiliser une caméra haute résolution en espace flottant, zoomée par une valeur entière, se basant sur la vélocité du personnage pour se déplacer, avec une limite bien définie.
Avec ces critères relativement simples, de nombreuses extensions deviennent possibles :
- Mettre en place un HUD ou une interface utilisateur en haute définition, par exemple attachée à la HighResCamera, afin d’avoir une interface parfaitement nette et fixe à l’écran.
- Adapter dynamiquement la résolution finale en fonction de l’écran de l’utilisateur ou de paramètres choisis via une interface.
- Ajouter des éléments non pixel-perfect par-dessus le rendu pixel art, comme des particules, des effets visuels ou du post-processing.
- Améliorer la caméra pour qu’elle soit capable de zoomer, de trembler, de pivoter ou d’appliquer n’importe quel effet moderne, sans jamais compromettre le rendu pixel-perfect du jeu.
Pour terminer
En implémentant ce système dans The Reaping Company, j’ai enfin réussi à obtenir ce compromis que je cherchais depuis le début : un rendu rétro parfaitement net, combiné à un feeling résolument moderne. Les pixels restent strictement alignés sur la grille, tandis que la caméra conserve une fluidité totale et reste très simple à ajuster pour les besoins du gameplay.
Pour être totalement honnête, je ne sais pas si cette approche est la plus conventionnelle. Je ne l’ai en tout cas jamais trouvée présentée de cette manière ailleurs. Elle possède probablement ses propres limites, mais d’après mes tests et la logique sur laquelle elle repose, je ne pense pas qu’elle constitue un frein pour la suite du développement.
C’est surtout la seule méthode qui m’a permis de corriger concrètement les nombreux problèmes et artefacts rencontrés avec les approches classiques.
Si vous vous lancez vous aussi dans la création d’un jeu en pixel art, ne sous-estimez pas l’importance de ces détails techniques dès le départ, ni de votre capacité à trouver des solutions par vous-même. Cette expérience m’a permis de bien mieux comprendre les entrailles de Godot et les subtilités de son pipeline de rendu !
Merci de m’avoir lu, et j’espère sincèrement que cet article pourra aider certains d’entre vous.
À bientôt pour un prochain devlog ou un nouveau tutoriel !