gKit2 light
|
Les cubemaps openGL sont des textures à 6 faces permettant d'afficher un environnement complet, pre-calculé, ou pas...
cf tuto_draw_cubemap.cpp + draw_cubemap.glsl
Les cubemaps sont des textures un peu à part dans openGL, elles ne suivent pas les conventions habituelles mais celles de... Renderman, les différences sont décrites dans la doc openGL, mais... d'une manière un peu compacte : une cubemap est une texture composée de 6 faces, et les coordonnées utilisées pour lire une couleur dans une des 6 faces sont décrites ici page 253.
résultat, selon les tutos, les explications sont soit inexistantes, soit contradictoires... soit il faudra modifier les images à la main pour obtenir un début de résultat...
L'idée de la cubemap est de représenter un décor placé autour de la scène, sous forme de texture. Pour observer le décor dans toutes les directions, il faut bien donner une direction, puis utiliser cette direction (ses coordonnées) pour choisir une face de la cubemap et enfin choisir un pixel sur cette face. Cette projection de la direction \((r_x, r_y, r_z)\) pour obtenir la face et le pixel se fait en 2 temps :
axe | face | sm | tm | m |
---|---|---|---|---|
+rx | TEXTURE_CUBE_MAP_POSITIVE_X | −rz | −ry | rx |
−rx | TEXTURE_CUBE_MAP_NEGATIVE_X | rz | −ry | rx |
+ry | TEXTURE_CUBE_MAP_POSITIVE_Y | rx | rz | ry |
−ry | TEXTURE_CUBE_MAP_NEGATIVE_Y | rx | −rz | ry |
+rz | TEXTURE_CUBE_MAP_POSITIVE_Z | rx | −ry | rz |
−rz | TEXTURE_CUBE_MAP_NEGATIVE_Z | −rx | −ry | rz |
et dans tous les cas les coordonnées du pixel de la face sont \( s= \frac{1}{2} \left( \frac{sm}{|m|} +1 \right) \) et \( t= \frac{1}{2} \left( \frac{tm}{|m|} +1 \right) \)
Par exemple, pour une direction \((1, 0, 0)\), l'axe dominant est +x, m= +1 (x), sm= -0 (-z), tm= -0 (-y) et \( s= \frac{1}{2} \left( \frac{-0}{|1|} +1 \right) = 0.5 \) et \( t= \frac{1}{2} \left( \frac{-0}{|1|} +1 \right) = 0.5 \), ce qui correspond bien au milieu de la face. on peut recommencer pour les 4 coins de la texture, pour se rendre compte de son orientation :
dans ce cas, la direction (1, -1, -1) correspond au pixel (1, 1) et la direction (1, 1, 1) correspond au pixel (0, 0).
face +x, chargée directement... (à l'envers) origine classique, en bas à gauche | face +x, chargée correctement, vue de l'extérieur du cube, origine en haut à gauche... | face +x, chargée correctement, vue de l'intérieur du cube... origne en haut à droite... |
Première étape, supposons que les faces de la cubemap se trouvent dans 6 images, la création d'une texture cubemap est similaire à la création des textures classiques (cf textures, samplers et pipeline) :
par contre, puisqu'il y a 6 images, il faut 6 appels de glTexImage2D()
au lieu d'un seul, et il faut préciser à chaque fois quelle face de la cubemap est initialisée. Les faces sont identifiées par l'axe et le signe +x, -x, +y -y, etc, cf GL_TEXTURE_CUBE_MAP_POSITIVE_X
, GL_TEXTURE_CUBE_MAP_NEGATIVE_X
...
c'est un peu pénible à écrire, mais les constantes TEXTURE_CUBE_MAP_POSITIVE_X
, TEXTURE_CUBE_MAP_NEGATIVE_X
, etc forment une séquence indexée, il est plus simple d'écrire :
mais bien sur, il faut également retourner les images pour placer leur origine en haut et à droite pour respecter la convention, les utilitaires flipY()
et flipX()
de image_io.h permettent de le faire simplement :
La plupart du temps, les cubemaps sont fournies comme une seule image, avec les différentes faces disposées en croix, sur une grille de 3 lignes et 4 colonnes. il suffit de découper chaque face au bon endroit, cf copy()
, et de la re-orienter, cf flipY()
et flipX()
(et de vérifier qu'une face est bien carrée) :
attention : il y a encore une différence de convention la face +Z
représente l'avant, et -Z
l'arrière, +X
à gauche, -X
à droite, +Y
en haut, -Y
en bas.
Comme pour les textures classiques, il ne faut pas oublier de calculer leurs mipmaps et de paramétrer leur filtrage (sinon texture noire à l'utilisation...) :
et comme c'est toujours la même chose, c'est exactement ce que fait read_cubemap( ) dans tuto_draw_cubemap.cpp.
Il suffit de déclarer un samplerCube
dans le shader et d'utiliser une direction pour accéder à la cubemap :
indication : repassez dans textures, samplers et pipeline si nécessaire pour l'utilisation des textures "classiques".
Il y a 2 utilisation des cubemaps, pour afficher le décor et pour modifier la couleur d'un objet placé au milieu du décor.
Une solution a priori directe consiste à dessiner un cube "à l'infini", mais les détails sont plutot pénibles (vous pouvez consultez les différents tutos sur internet). Une solution bien plus simple consiste à exécuter un fragment shader par pixel de l'image, à calculer la direction correspondante et recupérer la couleur dans la cubemap. oui, c'est presque du lancer de rayons...
L'idée des cubemaps est de représenter un décor : pour chaque pixel, il suffit de calculer une direction dans le repère du monde pour "trouver" le pixel de la cubemap. On connait la position de la camera dans le monde, il suffit de connaitre la position du centre d'un pixel dans le repère du monde, pour déterminer la direction.
Par construction, un point du repère image (x, y, z = 1) à des coordonnées dans le repère monde : il suffit de transformer ses coordonnées par l'inverse des transformations standards :
Les coordonnées de la camera dans le repere monde s'obtiennent aussi directement :
et la direction de camera vers p s'écrit :
Il ne reste plus qu'à écrire le vertex shader qui génére un grand triangle rectangle qui couvre tous les pixels de l'image. Les coordonnées des sommets s'expriment directement dans le repère projectif :
et le fragment shader qui transforme les coordonnées du pixel vers le monde pour calculer la direction :
les shaders sont exécutés par glDrawArrays(GL_TRIANGLES, 0, 3);
dans l'application.
derniere remarque, la géométrie est dessinée exactement sur le plan far, à z = 1, il faut penser à utiliser un ztest GL_LEQUAL
, au lieu du GL_LESS
habtiuel pour obtenir une image correcte lorsque le zbuffer est initialisé lui aussi exactement à 1.
code complet est dans tuto_draw_cubemap.cpp et draw_cubemap.glsl
Il est assez direct d'utiliser une cubemap pour simuler un reflet miroir sur un objet. Il faut connaitre la direction camera vers fragment et la normale du fragment dans le repère du monde :
Mais les objets sont rarement des miroirs, comment simuler l'éclairage d'un fragment par l'environnement ? La bonne solution est présentée dans le cours sur l'intégration numérique et l'éclairage global, mais estimer brutalement cette intégrale pour chaque pixel est beaucoup trop lent...
La première idée est liée à un constat : que se passe-t-il si on utilise un mipmap différent de la cubemap avec le shader précédent ?
level 2 | level 3 | level 4 | level 5 |
level 6 | level 7 | level 8 | level 9 |
Les effets ne sont pas très visibles sur la première ligne, mais les derniers niveaux de la cubemap permettent vraiment de changer l'apparence de l'objet. On peut estimer quel mipmap utiliser en fonction de la matière de l'objet. Cette approximation est détaillée dans ce blog, archivé et remis en page.
Mais en gros, avec 2 accès à la cubemap, un pour la partie diffuse et l'autre pour les reflets, il est possible d'obtenir une approximation pour une matière diffuse ou réfléchissante :
remarque : les modèles de matières sont présentés dans shader et brdf.
code complet dans tuto_cubemap.cpp + cubemap.glsl
Bien que très simple, cette solution est une grosse approximation. La partie diffuse devrait tenir compte des 5 faces au dessus de l'horizon et pas seulement de la face dans la direction de la normale.
Une solution correcte et plutot efficace est présentée dans "Real-time Shading with Filtered Importance Sampling", J. Krivanek, M. Colbert, 2008. et "Fast Filtering of Reflection Probes", J. Manson, P.P. Sloan, 2016.
Il est aussi possible de pré-calculer une approximation de meilleure qualité, cf "Real Shading in Unreal Engine 4", B. Karris, 2013
Le plus gros défaut de ces méthodes est que la visibilité n'est pas estimée, les pieds de bigguy sont bleus, éclairés par le ciel, alors qu'ils devraient être à l'ombre du corps. Il est possible de corriger ce défaut en pré-calculant l'éclairage ambiant de l'objet.
Charger une image est pratique pour initialiser une cubemap, mais dans certains cas, il est nécéssaire de dessiner la scène (animée...) autour d'un personnage pour ensuite l'éclairer. Une solution directe consiste à préparer 6 framebuffers, un par face de la cubemap et à dessiner 6 fois la scène. cf rendu multi-passes : shadow maps, post process, framebuffer object. mais c'est assez pénible à écrire et plutot lent.
Une solution plus efficace existe : une cubemap est un tableau de 6 textures, et il est possible d'associer le tableau complet à un framebuffer avec glFramebufferTexture()
:
Par contre, il faudra indiquer dans quel élement du tableau / face de cubemap dessiner les triangles. Il faudra toujours dessiner 6 fois chaque triangle, une fois par face de la cubemap et c'est la variable gl_Layer
qui indique quelle texture du tableau / framebuffer / cubemap remplir. Cette fonctionnalité, le layered rendering, utilise normalement un geometry shader pour dessiner 6 fois chaque triangle. Les différents tutos sur le sujet ne mentionnent que cette solution, mais il est plus simple et plus efficace de ne pas utiliser de geometry shader et d'utiliser à la place glDrawInstanced()
pour dessiner 6 fois les triangles. Le vertex shader affectera une valeur à gl_Layer
en fonction de l'instance. Cette fonctionnalité est disponible sur tous les gpu actuels, cf l'extension GL_ARB_shader_viewport_layer_array.
La seule difficulté est de construire les matrices view qui permettent d'orienter la camera dans le bon sens pour produire images / face de la cubemap en respectant leur convention... Lookat() de mat.h permet de le faire assez facilement :
Par exemple, pour orienter la camera le long de l'axe +x, il faut utiliser Lookat(Point(0, 0, 0), Point(1, 0, 0), Vector(0, -1, 0));
ce qui permet de placer la camera à l'origine, de l'orienter le long de l'axe +x (c'est à dire en regardant le point (1, 0, 0)). Le dernier paramètre, le vecteur qui indique la verticale serait (0, 1, 0) normalement, mais pour respecter la convention des cubemaps, il faut retourner la camera, avec la verticale vers le bas...
L'application va dessiner les objets avec glDrawArraysInstanced(GL_TRIANGLES, 0, n, 6)
, soit 6 copies de chaque triangle, et c'est le vertex shader qui termine le travail :
gl_InstanceID
permet de retrouver la transformation à utiliser pour la Ieme face et gl_Layer
permet de dessiner le triangle dans la face de la cubemap attachée au framebuffer.
code complet dans tuto_dynamic_cubemap.cpp + render_cubemap.glsl