Savez-vous comment on gère la transparence en 3D ?
Non ? Rassurez-vous, moi non plus.
Le z-buffer du GPU permet de gérer à la perfection l'affichage des facettes opaques entre elles.
C'est un tableau 2D qui a la résolution de l'écran, qui stocke par frame (Il est réinitialisé à chaque début de rendu de frame) la profondeur de chaque pixel tracé, engendrés par chaque rasterization de triangle, et si dans le tracé on veut tracer un pixel plus profond que l'actuel, on annule son tracé.
Il peut y avoir des problèmes de précision de calculs flottants dans certains cas critiques (plus visible quand on utilise un viseur ou une loupe. Pour une arme avec viseur par exemple. Quoique c'est évitable). On appelle ça le z-fighting (le z-buffer détermine mal lequel des pixels est à afficher (lequel est le moins profond) , du coup ça "clignote" dans la succession de frames) , mais dans l'ensemble c'est une solution formidable.
Le problème de la précision du z buffer c'est qu'elle est logarithmique. Précise au début, pas précise pour les grandes profondeurs. C'est dû à la matrice de projection. (Voir fonction inverse)
On pourrait, en 3D, se contenter de matrices 3*4. C'est suffisant pour gérer les rotations, les translations et les mises à l’échelle. D'ailleurs les moteurs physiques 3D n'utilisent que ces matrices.
Mais voilà dans la représentation graphique des scènes 3D, il y a les projections et donc, les coordonnées homogènes. Les coordonnées homogènes ont 4 composantes.
On est dans un espace en 3 dimensions cartésien, et pourtant on utilise 4 coordonnées pour se repérer. X, Y, Z, W.
On sort des applications linéaire car W est un diviseur. La conversion est simple :
Homogène : (X, Y, Z, W) (4 valeurs)
Est traduit dans les coordonnées courantes cartésiennes : (X/W, Y/W, Z/W) (3 valeurs).
Par exemple en coordonnées homogènes, (4, 8, 2 , 2) vaut (2, 4, 1, 1) et vaut en coordonnées cartésiennes (2, 4, 1).
Cette 4ème coordonnée pour un espace à 3 axes, a été introduite pour formaliser avec génie les espaces projectifs. Et en 3D on utilise au niveau bas que ce type de coordonnées.
Cela permet de gérer les scènes 3D avec point de fuite, profondeur. (Un objet, plus il est profond et plus il est petit et se rapproche du centre.)
On divise les coordonnés x et y par la profondeur. Pour avoir l'effet de perspective, de profondeur. Et la fonction inverse n'est pas linéaire.
Dans bien des cas d'ailleurs on utilise une profondeur linéaire à la place de prendre la profondeur du z-buffer. Une simple fonction affine qui traduit de manière linéaire la profondeur des pixels entre 0 et 1. 0 correspond à l'écran et 1 à la profondeur maximale de la scène. Très (toujours maintenant ?)utilisé pour le tracé des ombres.
C'est également formidable pour tracer du transparent sur de l'opaque. (C'est juste que le transparent ne modifie pas la valeur de profondeur dans le z buffer, il ne fait que la lire. (Read-only).) Si le pixel est devant un pixel opaque on le trace en prenant en compte son opacité (son alpha).
Par contre pour la transparence, sur d'autres surfaces transparentes, le z buffer n'est d'aucune utilité. Il faut se démerder avec sa bite et son couteau.
La solution la plus naïve, c'est de trier les surfaces transparentes entre elles, et de les tracer du plus profond au plus proche. Pour que l'alpha-blending soit correct. Interpolation de couleurs entre le fond et le proche, et c'est exactement le même fonctionnement que de mélanger deux couleurs transparentes en 2D. (on a une couleur de fond et une couleur de premier plan, et on les interpole selon leurs opacités respectives)
Le problème : C'est que si on a 3 triangles transparents A, B, et C et qu'on les affiche par ordre de profondeur décroissante et que A recouvre partiellement B, B recouvre partiellement C et C recouvre partiellement A alors graphiquement on aura un résultat incorrect.
Pour y remédier, il y a la technique de Depth Peeling, mais c'est super couteux. Et donc jamais utilisé sauf dans des démos.
La transparence est très difficile à gérer efficacement, même avec les GPUs les plus performants. Car il n'y a pas vraiment de méthodes automatisables. Ce n'est pas une question de performance du GPU, mais d'algorithme.
C'est pourquoi, même dans les jeux dernier cri, il y a très peu de surfaces transparentes.
Je reviens sur les coordonnées homogènes. Pour un repère cartésien de 3 axes, il faut une quatrième coordonnée.
Donc des matrices 4x4 pour les transformations. Sinon on ne peut pas multiplier vecteurs et matrices.
Les vecteurs homogènes sont des matrice 4x1, soit des vecteurs de 4 coordonnées, bon.
Soit V le vecteur et M la matrice et V' le résultat :
M x V = V'.
La plupart du temps, la matrice ne modifie pas la 4ème composante du vecteur. Ces matrices ont des zéros partout sur leurs dernières colonnes et leur dernière ligne. Juste un 1 en (4,4).
Donc c'est l'identité qui est appliquée à la coordonnée W.
La perfide matrice de projection fait le reste, elle remplace la 4ème coordonnée du vecteur par une valeur différente de 1. (Oh la coquine). En fait elle met le z du vertex en w.
(Après il y a plusieurs types de matrices de projections, mais je prends comme exemple celle utilisée dans les jeux 3D)
Du coup quand on converti le point en coordonnées homogènes en coordonnées cartésiennes pour le tracer sur l'écran, (la coordonnée z est utilisée pour le z buffer, x et y sont transformées en quel pixel est concerné), le GPU a fait la division des coordonnées X, Y, Z, par W.
Dernière remarque sur les coordonnées homogènes.
Un vecteur est de la forme (X, Y, Z, 0)
Un point est de la forme (X, Y, Z, 1)
En entrée du pipeline graphique.
Car un vecteur est insensible aux translations. Alors qu'un point l'est.
Avec un zéro pour W, un vecteur ne sera pas détérioré par la matrice de translation. Car cela n'a aucun sens de translater un vecteur, vu qu'il n'a aucune position.
Un point sera transformé par contre à merveille. Grâce à son 1 en W, il sera translaté comme il le doit être.
Par exemple imaginons que je me trompe et que je mette 1 en W pour un vecteur pour une translation de +1 en X.
Par exemple (4,5,6,1).
Le Vecteur résultat sera (5,5,6,1). Ce qui est faux.
Par contre si je donne à cette matrice ce même vecteur (4,5,6,0)
J'obtiendrai bien (4,5,6,0). L'identité. Valeur identique à la précédente.
Je me demande si je ne vais pas trop loin.
Mais il y a 2 données distinctes à gérer en 3D.
Les points. On s'en doute 'faut bien placer et tracer quelque chose...
Et les normales. Vitales pour calculer la lumière et la couleur de chaque pixel.
Les points sont des points, rien à ajouter, et les normales sont des vecteurs.
Il y a 4 espaces à gérer en 3D pour afficher un objet. Et à combiner.
Le premier c'est le repère du modèle, ou mesh en anglais. Il est exprimé tout seul, seul dans sa bulle. Il a son propre repère local.
Le second consiste à le placer dans la scène, et d'avoir pareil, des coordonnées exprimées pour lui tout seul, mais dans la scène.
Le 3ème à le placer selon le repère de la caméra. Car on veut tracer la scène selon la position et l'orientation de la caméra.
(C'est important de savoir s'il est visible, dans le champ de vue. En même temps dans tout moteur graphique qu'il se doit, on ne vérifie pas ça objet par objet, on utilise des arbres de partitionnement de l'espace. Tels que les Octrees. Ou les kd-trees.)
Le 4ème consiste à le placer dans l'espace projectif. C'est là qu'on utilise une projection et que les coordonnées homogènes interviennent. On divise par w.
L'espace utilisé pour calculer la luminosité grâces aux normales des surfaces, est l'espace de la scène. On s'en fout de la matrice de vue, ce qui compte c'est l'angle du vecteur de lumière avec la face.
Par contre la lumière émise par la face
reçue, dépend de la vue, mais ce n'est pas constituant. Car les normales des surfaces ne dépendent pas de la vue.
Ce qui n'empêche pas à la lumière de changer selon les angles de vue. On transforme les normales avec la matrice de scène (world matrix), et on fait un calcul
qui lui dépend de la vue et d'une direction de lumière, pour calculer l'éclairage du pixel sous-jacent à la surface.
Une fois la matrice de projection appliquée, tout ce qui est visible est contenu dans un cube "unitaire". En largeur de -1 à 1, pareil en hauteur, de 0 à 1 en profondeur sous DirectX, ou de -1 à 1 pour Open GL.
Pour transformer ces coordonnées en pixel, le GPU fait ça tout seul et de manière invisible. Il faut préciser la largeur et la hauteur de l'écran (le view port), Et le GPU calcule la matrice pour faire la mise à l’échelle (ou plutôt ces drivers), et applique les fragment shaders ou pixel shaders, sur ces données. Je pense qu'un jour je parlerai du normal-mapping, pour décrire la puissance de calcul phénoménale des GPU.
Bon je suis pressé.
On pourrait se dire que pour définir un point, il faut juste 3 coordonnées.
Ah bah non !
Les points, appelés Vertex au singulier, Vertices au pluriel, ont beaucoup d'autres informations associées.
La première : chaque point à sa propre normale. Et quand on balaye la surface on fait une interpolation des normales des 3 points constituant la facette. (Il ne faut pas oublier de normaliser la normale issue de l'interpolation)
Une exception : Pour le shading de Gouraud, ce n'est pas le cas, la normale étant constante sur toute la facette. Ce n'est plus utilisé (pour les jeux) car c'est moche.
Chaque point a son vecteur 2D uv. Il permet de determiner quel pixel (appelé texel) de la texture sera projeté sur un pixel particulier de la surface.
Tous les GPU, même les plus anciens, gèrent ça de manière automatique, on leur file une coordonnée uv et ils calculent (et lissent (interpolent) !) la couleur de chaque pixel de la facette en fonction de la texture. C'est entièrement automatique.
Et il peut y avoir plusieurs uv par vertex.
Et, o la la malheur, c'est là qu'arrive le normal mapping.
Pour chaque point (vertex), il faut calculer un repère orthogonal, c'est la base tangente, bitangente (ou binormale), normale. Donc chaque vertex contient un repère en plus, soit 3 vecteurs. On appelle ce repère TBN.
On voit qu'on est bien loin de la simple position d'un point en 3D.
La normal map permet de feindre du relief sur une surface plane grâce aux calculs de la lumière. Par exemple sur un mur en pierres apparentes, créer des nuances de luminosité sur juste une surface plane. La normal map est comme une texture, sauf qu'elle ne définit pas la couleur de chaque pixel, mais "l'inclinaison" de chaque pixel. Du coup chaque pixel de la surface a sa propre normale.
La normal map est calculée, car impossible à créer à la main. Les designers créent un objet en plus haute résolution, mettant les aspérités du mur sous forme de facettes ultra-précises via leur logiciel, mais eu lieu de le donner tel quel, ils exportent la face sous forme de normal map (une simple image, (R,G,B) = (X,Y,Z) de la normale), et la même face comme un simple rectangle (mesh).
La normale map est calculée par le logiciel comme si chaque face était parallèle à l'écran. C'est pourquoi on a besoin du repère TBN pour la transformer correctement selon son orientation.
Donc pour chaque pixel, on transforme la normale par la matrice TBN qui change le repère local en repère du monde.
Imaginez la puissance de calcul que ça implique : pour chaque pixel, on fait des transformations matricielles ! Et de nos jours, c'est devenu banal.
Le défaut du normal mapping est visible quand on se retrouve perpendiculaire à ladite surface normal mappée. Car on voit qu'elle n'a pas d'épaisseur.
Il y a une technique plus sophistiquée, qui a besoin en plus d'une height map (carte des hauteur), qui feint la hauteur. C'est bien mieux, mais beaucoup plus coûteux et qui se révèle avoir le même défaut quand la surface est perpendiculaire à l'axe de vue. (On voit que la surface n'a pas d'épaisseur)
Il y a deux types principaux de shaders quand on programme un GPU.
Bien qu'unifiés.
Un shader est un programme. Que l'on peut taper sur un bloc-note, c'est juste du texte.
Je ne connais que le HLSL (DirectX) et le GLSL (OpenGl). Leur syntaxe est dérivée du C et donc très rapide à assimiler.
La différence, c'est qu'on peut manipuler des matrices et des vecteurs directement, en toute simplicité.
Le premier s'appelle le vertex shader.
Son but est de transformer les points et les normales d'un objet.
Points et normales sont initialement dans le repère local de l'objet. Il place les points dans le repère de la caméra, et oriente les normales dans la scène.
Cela passe par quelques transformations matricielles. Pour simplement changer de repère.
Il sert aussi à passer les coordonnées uv. Une simple copie. En vue d'être interpolées.
Ensuite le GPU intervient. Il fait toutes les interpolations nécessaires. En passant par la rasterization.
Et invoque les pixel (ou fragment) shaders avec ces données interpolées.
uv interpolées, normales interpolées, et positions aussi. Le rôle de ce shader est de définir la couleur du pixel dont il se charge.
Important, les normales interpolées doivent êtres normalisées avant de pouvoir les utiliser. Il faut s'assurer qu'elles ont bien une norme de 1, sinon elles peuvent amplifier ou diminuer de manière incorrecte la luminosité.
A la fin des calculs il n'y a qu'un seul résultat exigé : la couleur du pixel.
Le vertex shader ne coûte rien. En général on ignore le coût en performance. Car il ne fait que calculer les positions (selon la vue) et les normales (selon la scène) de l'object.
Car il ne fait que des opérations matricielles basiques. Il ne fait que changer de repères les vertices et les normales.
Le pixel shader, appelé aussi fragment shader, fait tout le reste. Et est beaucoup plus coûteux. Car il calcule grâce aux normales, lumières, la couleur du pixel. Il est appliqué pour chaque pixel affichable d'une facette.
Il y a d'autres shaders, comme le geometry shader, qui permet de créer de la géométrie à la volée, etc.
Un petit exemple : Normaliser un vecteur.
Dans un langage CPU, il faut d'abord calculer l'inverse de la norme du vecteur, ce qui passe par Pythagore, une racine carré et une inverse, et multiplier par ce facteur toutes les coordonnées.
Plusieurs lignes de code.
Dans un langage GPU, on a juste à dire v = normalize(v).
Un autre : multiplier une matrice par un vecteur.
Sur un GPU, il suffit d'utiliser une fonction toute faite. (résultat = multiply(matrice, vecteur) )
Alors que sur CPU, il faudrait au pire implémenter les transformations matricielles et au mieux utiliser une bibliothèque.
Calculer un produit scalaire entre 2 vecteurs, vital dans les calculs d'éclairage. Rien de plus simple sur un GPU grâce à la fonction dot. résultat = dot(v1,v2).
De même pour calculer un produit vectoriel (vital quand on gère les normales), la réflexion d'un vecteur étant donné une surface, la réfraction (transparence).
Pour les plus impliquées dans les shaders.
Donc les programmes de shaders sont très concis, et chaque fonction qui le compose aussi, pourtant effectuant des calculs très coûteux s'ils étaient exécutés par un CPU.
Il y a des centaines de fonctions comme ça, de type C, mais avec des arguments qui sont vectoriels ou matriciels.
Mais ce ne sont pas des instructions, ce sont bien des fonctions. On les appelle fonctions intrinsèques. Elles sont programmées et toutes faites.
Les calculs d'un GPU sont très simples à assimiler, même s'ils se révèleraient coûteux pour un CPU. Pas besoin d'une maîtrise en Mathématiques pour comprendre leurs calculs. Un bac + 1 suffit largement.
Au niveau matriciel, on ne fait que passer d'un repère à un autre. Et c'est la chose la plus basique à laquelle les matrices servent.
Les GPUs n'ont pas grand-chose à voir avec les CPUs. Ils ont une architecture complètement différente.
Les CPUs ont plusieurs coeurs indépendants. Chaque coeur fait son truc dans son coin, de manière indépendante par rapport aux autres coeurs. C'est comme si on avait plusieurs CPUs sur la carte mère, des processeurs totalement indépendants.
Cette architecture est nommé SISD. (single instruction single data) Chaque processeur n’exécute qu'une seule instruction sur une seule donnée à la fois.
Les GPUs sont composés eux aussi de coeurs (très nombreux), mais ils partagent le même programme. Ils font tous la même chose.Ils ont le même programme, la même suite d'instructions.
On nomme cette architecture SIMD. (single instruction multiple data).On execute le même programme sur des données différentes en parallèle.
(Et il faut éviter de les désynchroniser par des tests divergents. (If ceci then cela), sinon on annihile leur puissance de calculs parallèles)
C'est un paradigme pour les développeurs, assez singulier. Parfois il vaut mieux calculer pour rien plutôt que de faire des branchements logiques divergents.
Dans un shader, quand on voit un
if on crie au scandale, sauf exceptions. C'est parfaitement accepté et implémenté, mais ça ruine la puissance de calcul si le résultat du test n'est pas constant ou presque.
Ce n'est même pas un pléonasme. Pour ce cas, de réponses de test divergents, les cœurs s'attendent mutuellement, entre eux. C'est une hécatombe au niveau performance. C'est comme si on avait un seul cœur du GPU qui fonctionnait à la fois.
Un autre truc, c'est que les coeurs des GPUs sont des processeurs (on peut le dire je crois), vectoriels. Ils travaillent avec des mots de 128 bits (donc ce sont des processeurs 128 bits).
Ce qui correspond à traiter des vecteurs de 4 flottants, chacun faisant 32 bits. D'un coup. C'est pour cela aussi que les GPUs sont aussi puissants en calcul, malgré une fréquence d'horloge assez basse, car ils calculent sur des vecteurs de 128 bits directement. Et ils sont nombreux !
La 3D est très facilement parallélisable. Car en gros on calcule la même chose pour chaque pixel de l'écran.
Il y a bien des domaines en cours d'informatique plus complexes ou qui ne m’intéressaient pas et dont je n'ai jamais rien compris.
Mais la 3D de base est très accessible. Cependant ça se complique pour le rendu photoréaliste. Cela passe par des notions d'optique, de physique (via l'intervention des longueurs d'ondes) , et de programmes GPU (shaders) plutôt complexes.
Désole, je ne suis jamais satisfait car je trouve qu'il manque toujours beaucoup à ce que j'ai dit. Et c'est toujours le cas. Donc j'édite.
Les processeurs des CPU sont conçus pour exécuter une instruction à la suite de l'autre. Ils suivent un programme, et exécutent les instructions du programme l'une après l'autre.
Avec les processeurs modernes, on en ait quasiment à l'ingestion d'une instruction par cycle.
Car ils sont pipelinés. Comme pour une chaîne d'usine, ou de montage, on attend pas que l'instruction précédente soit traitée pou envoyer la suivante dans le pipeline.
A l'école cela paraît simple. Sauf que les processeurs modernes ont 12 "étapes" en étage pour donner le résultat.
Il y a des difficultés évidemment. Encore à cause des tests logiques. Quand le processeur ingère un test, il ne connait pas le résultat.
Donc le pipeline continue d'avancer. Mais doit-il exécuter la réponse vraie ou la réponse fausse du test ?
Et bien il y a une unité de prédiction.
Par exemple dans une boucle, on suppose que le résultat du test logique suppose qu'il faille continuer la boucle. Car c'est le plus probable.
Mais à la fin de la boucle, la prédiction est fausse. Du coup on met des "bulles" dans le pipeline pour corriger la mauvaise prédiction, et ça bloque tout le pipeline pendant plusieurs cycles.
Les mathématiques sont à mon avis, des sciences dont on se fout de l'avis humain.
Elles sont justes ou elles sont fausses. Prouvées par des théorèmes ou infirmées par d'autres.
En informatique, on est dans le flou le plus absolu. Et c'est frustrant.
Si ça ressemble à la Réalité, on adoube.
On pourrait dire que l'on se fout de la véracité, ce qui compte c'est que le résultat soit acceptable.....
[EDIT] Comment on fait pour interpoler une valeur entre trois points comme le fait le GPU avec ses triangles ?
Car pour le lancer de rayon, le GPU n'est d'aucune aide. (Dans le lancer de rayon, on utilise que les étages hauts du GPU. Il n'y a pas de rasterisation ni de pixels (ou fragments) shaders utilisés. On utilise juste la puissance de calcul matriciel des GPUs. CUDA (pour les putes de NVidia) et OpenCl permettent de programmer pour tout et n'importe quoi parallélisable avec les GPUs au delà du simple pipeline graphique utilisant la rasterisation (Jeux principalement).).
En fait cela passe par les coordonnées barycentriques.
C'est un chouya lié à ce sommet, un autre chouya lié au second, et le reste au troisième.
La somme des facteurs des 3 points vaut 1.
C'est comme une moyenne de 3 notes avec 3 coefficients.