Les membres ayant 30 points peuvent parler sur les canaux annonces, projets et hs du chat.
La shoutbox n'est pas chargée par défaut pour des raisons de performances. Cliquez pour charger.

Forum Casio - Autres questions


Index du Forum » Autres questions » Questions sur Azur
Potter360 Hors ligne Rédacteur Points: 1252 Défis: 2 Message

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 !


Lephenixnoir En ligne Administrateur Points: 24520 Défis: 170 Message

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é :

  1. Dessiner les lignes 0-15 (fragment #0) du frame dans la XYRAM
  2. Envoyer ces 16 lignes à l'écran
  3. Dessiner les lignes 16-31 (fragment #1) du frame dans la XYRAM
  4. Envoyer ces 16 lignes à l'écran
  5. Dessiner les lignes 31-48 (fragment #2)...
  6. ... continuer jusqu'en bas, les lignes 208-223 (fragment #13)

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.

  • azrp_rect() est appelée en premier, elle ne dessine rien mais génère une "commande de dessin de rectangle" dans une file de commande interne à la lib.
  • azrp_image() est appelée, elle génère de même une commande de dessin d'image.
  • azrp_update() est appelée à la fin et c'est là que tout le rendu se fait.

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.

  • Tout d'abord, une fonction interne trie les commandes par fragments croissants. Par exemple si tu as d'abord appelé azrp_rect() pour dessiner un rectangle qui touche les fragments [3,6] et ensuite azrp_image() pour dessiner une image qui touche les fragments [5,7], le résultat du tri sera moralement la liste de paires

    [(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.

  • Ensuite, le rendu à proprement parler se produit. Azur parcourt la liste de tuples de gauche à droite pour générer tous les fragments. Il appelle d'abord toutes les fonctions touchant au fragment #0, envoie le fragment final à l'écran, puis continue avec les fonctions touchant au fragment #1, et ainsi de suite.

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 :
  • struct command, la structure de commande du shader rectangle (qui commence bien par uint8_t shader_id)
  • AZRP_SHADER_RECT, l'ID unique pour le shader qui est obtenu au démarrage du programme par un appel à azrp_register_shader()
  • configure(), une fonction qui est appelée au démarrage et chaque fois que les paramètres globaux d'Azur changent (e.g. l'upscale ou la disposition des fragments, j'en parle pas tout de suite)
  • azrp_rect(), la fonction qui génère une commande ; la commande est allouée directement dans la file interne d'Azur par azrp_new_command() ; note que cet appel indique aussi (via frag_first et frag_count) quel intervalle de fragments est touché par la commande.

Et dans le fichier assembleur, tu trouves :
  • azrp_shader_rect, la fonction de rendu qui reçoit en paramètre le buffer de pixels du fragment ainsi qu'un pointeur vers la commande, et dessine une bande horizontale du rectangle. Pour chaque commande, cette fonction est appelée plusieurs fois (une fois par fragment touché) avec le même pointeur de commande en paramètre. Cette fonction est "déclarée" à Azur par azrp_register_shader().

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 :

  1. En restant dans le système VRAM traditionnel, modifier si besoin la fonction pour qu'elle fasse son dessin de haut en bas. Ne pas hésiter à explicitement la reprogrammer sous la forme d'une boucle qui affiche 16 lignes à chaque tour.
  2. Identifier quels sont les paramètres de la fonction et quelles sont les données qui doivent persister d'une bande à l'autre, et mettre ces données dans une structure de commande.
  3. Toujours dans le système VRAM, remplacer la boucle dans la fonction de rendu par juste un tour, et remplacer les appels à la fonction de rendu par (1) la création d'une commande initialisée à la main, suivi de (2) un appel en boucle de la fonction qui fait un tour.
  4. Passer sour Azur ; écrire une fonction de génération de commande qui met les valeurs initiales dans la commande, et le shader est la fonction qui fait un tour.
  5. Une fois que ça marche en C, réécrire la fonction de rendu en assembleur si besoin pour améliorer les perfs. Toutes les fonctions de rendu ne sont pas critiques, donc ce n'est pas toujours nécessaire.

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.
Mon graphe (11 Avril): ((Rogue Life || HH2) ; PythonExtra ; serial gint ; Boson X ; passe gint 3 ; ...) || (shoutbox v5 ; v5)
Potter360 Hors ligne Rédacteur Points: 1252 Défis: 2 Message

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
Globalement, coder. Mal, mais coder.

LienAjouter une imageAjouter une vidéoAjouter un lien vers un profilAjouter du codeCiterAjouter un spoiler(texte affichable/masquable par un clic)Ajouter une barre de progressionItaliqueGrasSoulignéAfficher du texte barréCentréJustifiéPlus petitPlus grandPlus de smileys !
Cliquez pour épingler Cliquez pour détacher Cliquez pour fermer
Alignement de l'image: Redimensionnement de l'image (en pixel):
Afficher la liste des membres
:bow: :cool: :good: :love: ^^
:omg: :fusil: :aie: :argh: :mdr:
:boulet2: :thx: :champ: :whistle: :bounce:
valider
 :)  ;)  :D  :p
 :lol:  8)  :(  :@
 0_0  :oops:  :grr:  :E
 :O  :sry:  :mmm:  :waza:
 :'(  :here:  ^^  >:)

Σ π θ ± α β γ δ Δ σ λ
Veuillez donner la réponse en chiffre
Vous devez activer le Javascript dans votre navigateur pour pouvoir valider ce formulaire.

Si vous n'avez pas volontairement désactivé cette fonctionnalité de votre navigateur, il s'agit probablement d'un bug : contactez l'équipe de Planète Casio.

Planète Casio v4.3 © créé par Neuronix et Muelsaco 2004 - 2024 | Il y a 86 connectés | Nous contacter | Qui sommes-nous ? | Licences et remerciements

Planète Casio est un site communautaire non affilié à Casio. Toute reproduction de Planète Casio, même partielle, est interdite.
Les programmes et autres publications présentes sur Planète Casio restent la propriété de leurs auteurs et peuvent être soumis à des licences ou copyrights.
CASIO est une marque déposée par CASIO Computer Co., Ltd