Questions sur Azur
Posté le 31/07/2024 00:25
Hello !
Comme vous le savez peut être, je suis en train de porter le moteur 3D
Windmill pour Graph 90+E.
J'arrive à quelque chose de visible mais lent : j'en arrive donc à l'étape de l'optimisation.
Après un petit profilage, j'ai compris que la fonction qui prenait le plus de temps (jusqu'à 70% du temps de rendu total cumulé) est
render_triangle_texture().
Pour l'optimiser il faut donc que je passe sur Azur... mais je n'ai aucune idée de son fonctionnement. Un petit topic donc ici pour essayer de comprendre un peu mieux tout ça
Ce que je sais d'abord : Azur fonctionne par shaders ; i.e je devrais en écrire un pour ma fonction.
J'ai aussi compris en me baladant dans le code de quelques projets utilisant Azur - le Shmup de Sly, BosonX et After Burner - qu'un shader fonctionne en deux temps : on initialise d'abord une commande dans une fonction de la forme
azrp_[nom du shader]̀ , puis on écrit une fonction de la forme
azrp_shader_[nom du shader] qui dessine la commande.
C'est là que ça devient un peu flou pour moi. Une notion essentielle a l'air de m'échapper : celle de fragments.
Qu'est-ce ?
Deuxièmement, j'avoue que j'ai un peu de mal à me représenter la forme que pourrait prendre un shader de ma fonction : serait-ce un shader qui réalise l'ensemble de ce que la fonction fait actuellement, Z-Buffer compris ?
Merci d'avance pour vos réponses !
Citer : Posté le 01/08/2024 14:37 | #
Bon, tu as déjà pas mal d'infos donc ! Pour la référence future je commence vaguement au début.
Dans son état actuel, Azur n'a pas de documentation/tutoriel ; ça arrivera quand j'aurai un peu mieux établi les abstractions, notamment, pour le système de rendu, en implémentant la version équivalente en OpenGL.
D'ici là, côté calculatrice, la prémisse est la suivante. Faire le rendu en RAM est lent parce que le rendu contient énormément d'écritures (au moins 170 ko par frame) et la RAM est très lente en écriture. Le cache nous aide un peu, mais fondamentalement comme on doit toucher 170 ko de mémoire il faut que 170 ko transitent du cache à la RAM et c'est ça qui est lent. Le cache ne nous aide qu'à éviter de tels trajets si on passe plusieurs fois au même endroit, mais à cause de sa petite taille (32 ko) même si on dessine plusieurs fois au même endroit les premières données sont souvent purgées avant qu'on repasse la deuxième fois. De fait, les fonctions de dessin font quasiment que des accès localement linéaires (i.e, écrire une série de pixels de gauche à droite) ce qui active les write queues donc même sans cache les perfs sont quasiment identiques à avec cache (testé e.g. dans Rogue Life).
Le seul moyen de ne pas forcer 170 ko à transiter sur le bus entre le MPU et la RAM c'est de ne pas stocker toutes ces données en RAM. Mais comme l'affichage avec dupdate() se fait par le DMA, à qui on dit d'aller chercher les données dans la RAM, ça veut dire qu'il faut aussi changer le mécanisme par lequel on envoie les données à l'écran. Traditionnellement, faire cet envoi à la main par le CPU est lent (24 ms comparé aux 11 ms du DMA), mais je n'ai jamais trop compris pourquoi. La RAM est très rapide en lecture, le CPU aussi, et on sait déjà que dans les 11 ms c'est l'écran qui est le plus lent. En bref, j'affirme que c'est faisable en temps raisonnable avec le CPU.
Ce qui nous ramène à la question : où mettre les 170 ko de données graphiques si on les met pas en RAM ? La réponse est : sur la mémoire on-chip, dans la XYRAM. Ces mémoires sont juste à côté du CPU ce qui fait que les écritures dedans prennent juste 1 cycle CPU, soit un débit d'environ 400 Mo/s en écriture, comparés aux 30 Mo/s que la RAM est capable de tenir (une moyenne de 13 cycles par écriture mais attention c'est pas du tout régulier).
La XYRAM est, bien sûr, trop petite pour contenir 170 ko—elle ne fait que 16 ko (8 ko de XRAM + 8 ko de YRAM), soit à peu près l'équivalent de 16 lignes de l'écran. Qu'à cela ne tienne, on peut faire un rendu fragmenté :
C'est un peu intimidant parce qu'on risque du tearing si on passe trop de temps à dessiner. Contrairement à la méthode RAM + DMA où peu importe le temps de dessin il faut 11 ms pour envoyer à l'écran, ici il peut y avoir très longtemps entre le premier et le dernier fragment. Ça passe en pratique, mais il faut surveiller si jamais. Il y a, de façon indépendante, des contrôles disponibles sur l'écran pour la fréquence de rafraîchissement, qui sont bas par défaut et que j'ai tendance à monter avec Azur pour éliminer une partie du tearing (e.g. sur Boson X), mais c'est expérimental.
Bref, je reviens à mon rendu fragmenté. Pour répondre directement à ton interrogation, dans Azur un fragment c'est une bande horizontale de la VRAM dans laquelle on fait le rendu. Actuellement c'est 16 pixels de hauteur dans toutes les résolutions mais ça pourrait changer dans le futur en basse résolution (moralement moins y'a de fragments plus c'est efficace). Si une fonction qui prend en paramètre un pointeur qui s'appelle fragment alors c'est le pointeur vers les contenus du fragment en cours, c'est l'équivalent du pointeur VRAM dans les fonctions de dessin classique mais avec une hauteur plus faible.
Comme le rendu doit être en plusieurs fois, il y a un workflow du style suivant. Je prends un exemple où je dessine un rectangle et une image. D'abord une étape de génération de commandes qui est là où l'utilisateur fait ses appels de fonction. Note que les APIs s'appellent azrp pour "AZur Rendering Pipeline", techniquement la libazrp peut être utilisée en C pur et est indépendante d'Azur.
Il est nécessaire de générer des commandes parce que, dans le processus de rendu fragmenté, il faut dessiner d'abord tout le premier fragment avant de commencer à considérer le deuxième. Or, si le rectangle et l'image intersectent tous les deux les fragments #1 et #2, alors il est clair que je vais devoir dessiner un bout de rectangle, puis un bout d'image, puis de nouveau un bout de rectangle, et de nouveau un bout d'image. Cette alternance fait que je suis obligé de me souvenir de la liste des fonctions appelées, et c'est ce à quoi servent les commandes.
Dans Azur, formellement une commande est une structure quelconque qui commence par un uint8_t shader_id identifiant la fonction de rendu qui fera le dessin dans le fragment. À la commande est associée un intervalle de fragments, qui désigne les fragments que la fonction va modifier. Par exemple, si je dessine un rectangle avec y1=48 et y2=100 alors je vais commencer juste au début du fragment #3 et finir au milieu du fragment #6. La commande générée par azrp_rect() est donc une commande ayant shader_id = AZRP_SHADER_RECT et associée à l'intervalle de fragments [3,6].
Les commandes contiennent également des paramètres pour la fonction qui fera le dessin à proprement parler (le shader) ; parfois c'est juste une copie conforme des paramètres de la fonction originale azrp_rect(), parfois c'est des paramètres prétraités. Elles contiennent également des variables internes du shader, j'y reviendrai plus tard.
L'exécution de azrp_update() consiste en deux étapes.
[(rect,3), (rect,4), (rect,5), (image,5), (rect,6), (image,6), (image,7)].
Note que pour tous les fragments que le rectangle et l'image modifient tous les deux (5 et 6) le rectangle passe avant l'image puisque c'est l'ordre qui a été donné par l'utilisateur.
Les fonctions qui font le rendu dans les fragments s'appellent des shaders, par analogie avec le système de rendu GPU dont ce système est inspiré, et aussi parce que c'est classe (pour ma défense je pense qu'on peut faire des vrais shaders/effets de lumières/etc avec les perfs libérées par ce système). Le truc qu'il est important de bien comprendre c'est que un shader est appelé plusieurs fois par commande, par exemple dans la liste ci-dessus il y a une commande de rectangle qui donne lieu à 4 appels du shader rectangle et une commande d'image qui donne lieu à 3 appels du shader d'image.
Je diverge un moment pour revenir sur le code, puisqu'on a maintenant tous les éléments pour comprendre comment les shaders basiques sont codés. Généralement, la fonction de rendu à proprement parler est écrite en assembleur (e.g. rect.S) et la fonction de génération de la commande est écrite en C (e.g. rect.c). Mais on peut tout écrire en C, d'ailleurs comme j'y viendrai plus tard je conseille de toujours commencer par là.
Dans le fichier C, tu trouves :
Et dans le fichier assembleur, tu trouves :
Donc pour être bien clair, un shader c'est finalement une fonction de dessin normale excepté que (1) ses paramètres sont dans une structure de commande, et (2) le dessin se fait en plusieurs appels où chaque appel fait une bande horizontale. Comme la structure de commande est passée en paramètre à chaque appel, si le shader a besoin de se souvenir de certaines données entre chaque appel pour savoir où il en est, il peut écrire dans la structure de commande. De fait, la plupart des shaders par défaut font ça par exemple pour se souvenir de combien de lignes il reste à générer. Certains stockent même des "variables locales" dans la commande pour pouvoir les retrouver à l'appel suivant.
Pour convertir une fonction de dessin existante en shader pour Azur, il faut donc :
Voilà qui je pense devrait couvrir les aspects pratiques pour un début. Il reste un point dont je voulais parler, c'est les paramètres géométriques. Par défaut, Azur dessine en 396x224 avec des fragments de 16 pixels de haut, mais il est possible de changer la résolution. Comme je l'ai mentionné tout à l'heure, l'envoi des données graphiques à l'écran par le CPU est limité par l'écran, donc le CPU a du temps pour se tourner les pouces. En fait, déjà quand on lit en XYRAM l'envoi ne prend que 7.5 ms au lieu de 11 ms, donc déjà y'a ça de gagné. Mais si l'écran répondait instantanément ça prendrait plutôt du genre 0.8 ms, donc vraiment le CPU s'emmerde pendant ce temps-là, et on peut lui faire faire des trucs.
Du coup, Azur possède un mécanisme pour faire un agrandissement automatique en envoyant les mêmes pixels plusieurs fois et les mêmes lignes plusieurs fois. Un format 2:2 (198x112) est utilisé classiquement, et un 3:3 (132x74.667 ≈ 132x75) est supporté mais je l'ai pas encore trop utilisé. Dans ces formats, la quantité de données graphiques décroît drastiquement, et contrairement au modèle RAM + DMA où il fallait de toute façon matérialiser 170 ko de pixels parce que le DMA ne sait que copier naïvement, le CPU peut très bien faire l'upscale à la volée et donc on profite de tous les bénéfices de diviser par 4 ou 9 la taille de l'écran.
Dans le futur, j'ajouterai également de quoi programmer des transformations graphiques de dernière seconde pendant cette période, si j'arrive à bien entrelacer/paralléliser ces transformations avec l'écriture à l'écran.
Il existe également des paramètres pour décaler les fragments. Un fragment de 16 pixels c'est parfait pour un tileset par exemple, et du coup on a envie de programmer un shader qui affiche une tile sur pile un fragment ce qui enlève des commandes, élimine la logique de gestion de plusieurs appels, et donc va plus vite. Mais les jeux ne dessinent pas forcément les tilesets alignés avec le bord supérieur de l'écran. Pour permettre de maintenir cette optimisation, Azur permet de décaler les fragments, ce qui permet de réaligner tout ça. Pour cette raison, je ne conseille pas d'écrire des shaders en se disant "y'a 14 fragments de 16 pixels chacun". Il vaut mieux dire "il y a azrp_frag_count fragments de azrp_frag_height pixels chacun et l'écran commence à la position azrp_frag_offset" et raisonner symboliquement pour maximiser la compatibilité, si c'est une préoccupation.
Voilà voilà hésite pas à demander des précisions ou d'autres questions si tu en as.
Citer : Posté le 02/08/2024 15:30 | #
Wow, je m’attendais pas à une réponse de cette richesse.
Je comprends effectivement mieux non seulement le concept de fragments, mais Azur de manière générale.
Merci beaucoup ! Au passage bravo pour Azur
Pour l’instant je n’ai pas spécialement de questions, je te redis si besoin quand je coderais