|
gKit2 light
|
maintenant que la partie technique est plus claire (cf bilan : démarrer avec gKit) ie écrire l'application, charger un objet, les shaders, bouger la camera, etc... qu'elle est la suite ? ben, c'est quand même très moche d'utiliser une couleur uniforme pour dessiner les objets...

au minimum, on souhaiterait reproduire leur forme, ie qu'une sphère ressemble à une sphère et pas à un disque, par exemple... ou que le robot ait un volume et pas juste une silhouette...

les détails des calculs sont dans lumière et matière, les explications sur comment écrire les shaders et leurs paramètres sont dans shader et brdf. mais au minimum, on a besoin de modifier la couleur d'un pixel en fonction de l'orientation de la surface (que l'on voit à travers le pixel) par rapport à lumière : si une surface est orientée vers la lumière, elle reçoit plus de lumière. et plus la surface s'éloigne de cette direction, moins elle reçoit de lumière. c'est pour ça qu'il fait plus chaud à l'équateur (le sol est orienté vers le soleil) qu'aux poles (le sol n'est pas du tout orienté vers le soleil) !
dans l'exemple au dessus, la lumière vient de la droite du robot, et son coté droit est bien éclairé, ie la surface regarde la lumière. le devant du robot est déjà moins éclairé, la surface n'est pas tournée vers la lumière, et le coté gauche est noir, à l'ombre. en gros, il faut comparer la direction de la normale de la surface (pour le point visible à travers un pixel) avec la direction vers la source de lumière. On peut même être plus précis, Mr Lambert à montré (en 1760...) qu'il suffit de calculer le cosinus de l'angle entre les 2 directions. et on sait calculer ce cosinus avec le produit scalaire des 2 vecteurs normalisés. quand les directions sont proches l'une de l'autre, ie quand la surface regarde dans la direction de la lumière, le cosinus est proche de 1, et quand les directions sont presque à angle droit, le cosinus est presque zero, il suffit de multiplier la couleur de l'objet par cette valeur !
remarque : pour être complet, il faut aussi éviter les valeurs négatives... ie une surface opaque n'est pas éclairée lorsque la lumière est derrière l'objet / sous la surface (dans ce cas, l'angle est supérieur à 90° et le cosinus devient négatif). on l'écrit souvent de manière compacte avec un max(0, ... ) :
comme d'habitude : la première chose à faire est de lister les paramètres du calcul et de déterminer qui leur affecte une valeur... dans une application classique, il n'y a pas de problèmes, on connaît déjà les valeurs, et elles sont déjà dans des variables. mais il faut transférer explicitement les valeurs vers les shaders, et il 2 types de valeurs : les informations associées aux sommets, les attributs de sommets, et les paramètres classiques, les uniforms. repassez dans première application avec des shaders, si ce n'est pas clair.
pour faire ce calcul, on a besoin de :
comment peut-on connaître la normale dans un shader ? dans un vertex shader, c'est un attribut du sommet (on ne connaît que la normale des sommets). dans l'application, on récupère la normale des sommets lorsque l'on charge l'objet 3d à dessiner. il faudra juste penser à vérifier que les normales sont bien présentes dans le Mesh, sinon, on peut les calculer (à la main, ou avec blender, par exemple). et surtout, il ne faut pas oublier d'ajouter les normales dans les buffers openGL et à bien configurer le format de sommet pour que le vertex shader récupère la normale de chaque sommet, cf bilan : démarrer avec gKit / section dessiner avec openGL.
pour la direction vers la lumière, c'est l'application qui fixe la direction, ce sera un paramètre uniform. repassez dans première application avec des shaders, si nécessaire. et c'est pareil pour la couleur de l'objet. on pourra même la récupérer dans la description des matières de l'objet, mais ce sera pour plus tard.
et voila ! il ne reste plus qu'à écrire le vertex shader :
parfait !

sauf que non... le robot est tout blanc ! pourquoi ?
euh ? on a pas écrit de fragment shader ? le rôle du fragment shader est de calculer la couleur de chaque pixel... mais, on a un petit problème, on vient d'écrire le calcul dans le vertex shader, le résultat est dans la variable vertex_color. il suffit de transmettre cette valeur au fragment shader... heureusement c'est prévu ! il suffit de déclarer une sortie optionnelle du vertex shader et l'entrée correspondante dans le fragment shader. cf le dernier exemple dans premiers shaders avec shader_kit.
et voila !

c'est mieux, non ?
il ne reste plus qu'à compléter l'application pour donner une valeur aux uniforms lumiere, color et mvpMatrix bien sur :
la petite gymnastique pour savoir d'ou viennent les valeurs n'est pas bien compliquée, il faut le faire 2 ou 3 fois avant que ça devienne un automatisme, sans oublier d'inclure les bonnes valeurs dans les buffers, et sans oublier d'affecter une valeur aux uniforms avant de dessiner...
et si on faisait tourner le robot sur lui-même ? avec une rotation autour de l'axe Y ? il suffit de modifier la matrice model en fonction du temps (repassez dans premiers objets, affichage et transformations si ce n'est pas clair) :

mais on observe un truc bizarre, c'est toujours le coté droit du robot qui est éclairé, ie la source de lumière tourne en même temps que le robot ! ce n'est pas vraiment ce que l'on voulait... pourquoi ?
on a confondu 2 repères : le repère de l'objet et le repère du monde. quand le shader calcule le produit scalaire des vecteurs, implicitement on suppose qu'ils se trouvent dans le même repère, sauf que ce n'est pas le cas... les normales sont dans le repère local du robot qui a tourné pour être placé dans la scène et la direction de la lumière est une direction dans le repère de la scène ! il faut transformer les 2 vecteurs dans le même repère, sinon, il va se passer des trucs bizarres...
on a 2 solutions, soit transformer la normale dans le repère de la scène en multipliant par la matrice model, soit transformer la direction de la lumière dans le repère local du robot en multipliant par l'inverse de la matrice model...
\[ q= \mbox{model} \times p\\ p= \mbox{model}^{-1} \times q \]
(ça marche aussi pour les vecteurs, ils se transforment exactement de la même manière.)on peut faire la transformation de la direction vers la lumière soit dans le shader, soit dans l'application.
quelle est la meilleure solution ? soit on transforme toutes les normales dans le shader, soit on transforme la direction vers la lumière dans le shader, c'est à dire une fois par normale, soit on transforme une seule fois la direction vers la lumière dans l'application, et on a pas besoin de modifier le shader...
on va modifier l'application :

modifiez le shader ! passez la matrice model au vertex shader et modifiez la normale avant de faire le calcul. vérifiez que vous obtenez bien le même résultat.
petite subtilité : comment écrire le produit matrice vecteur pour transformer la normale ?
pourquoi est-ce différent de la transformation des points par une matrice 4x4 ?
il y a au moins 3 raisons pour lesquelles l'image du robot est moche :
premier point, le plus facile, on interpole n'importe comment ? euh ? qu'est ce qu'on interpole ? on calcule les couleurs des sommets de chaque triangle, et le pipeline graphique interpole ces 3 couleurs pour chaque point du triangle qui correspond à un pixel, comme dans le dernier exemple de premiers shaders avec shader_kit avec le triangle rouge-vert-bleu. et alors ? on interpole linéairement un cosinus qui n'est pas une fonction linéaire ( cf wikipedia pour un rappel des propriétés) :
\[ \frac{1}{2} \cos a + \frac{1}{2} \cos b \neq \cos \frac{1}{2} (a + b) \]
mais pas de panique, c'est super simple à corriger, il suffit de faire les calculs dans le fragment shader. au lieu d'interpoler les couleurs entre les sommets et l'intérieur d'un triangle, on va interpoler les normales entre les sommets et l'intérieur du triangle. puis, pour chaque point, on évalue le cosinus de l'angle entre la normale interpolée et la direction vers la lumière :
remarque : oui, il faut re-normaliser vertex_normal dans le fragment shader, l'interpolation entre les sommets et l'intérieur du triangle change la longueur des normales...
bon, sur le robot composé de grandes plaques on ne verra pas trop de différences, mais essayez avec d'autres objets...
deuxième point, on éclaire n'importe comment ? c'est un point esthétique, mais tous les graphistes et les photographes utilisent au moins 3 sources de lumière pour éclairer un objet ou une personne. vous pouvez chercher des video sur "studio lighting" et "blender studio lighting". il y a une source principale, jamais pile en face, pour éviter de cacher toutes les ombres et de créer une image sans contraste. la source est décalée pour créer des ombres qui peuvent être assez tranchées. pour un portrait, cet éclairage coupe le visage en deux, une partie très éclairée et une autre à l'ombre, très sombre. du coup, les photographes ajoutent une 2ième source pour éclairer ces zones trop sombres et enfin, ils ajoutent une 3ième lumière derrière pour éclairer la silhouette et détacher l'objet / le personnage du décor, qui est en général un peu moins éclairé. éventuellement une dernière source sert à équilibrer l'image en ajustant l'éclairage du décor par rapport à l'objet / au personnage.
le type de lumière est important aussi, les photographes utilisent souvent de grands diffuseurs pour adoucir les ombres et pas vraiment des projecteurs très concentrés et très intenses. tout ça pour dire qu'éclairer un objet avec une seule source directionnelle n'est pas terrible. que peut-on faire de simple ? on peut bien sur ajouter 2 sources supplémentaires et ajuster leur puissance pour mieux éclairer nos objets, mais on peut aussi calculer comment le ciel (ou tout l'environnement) éclaire l'objet. ce n'est qu'une approximation assez simpliste, mais ce terme ambiant permet de remplacer une 2ième source directionnelle par une autre beaucoup plus grande. on va supposer qu'un dome lumineux centré autour d'un axe éclaire notre scène, comme le ciel, mais on peut choisir la position de son zénith. il suffit de calculer :
\[ \mbox{pixel}= \mbox{emission} \times \mbox{color} \times \frac{1}{2}( 1 + \cos \theta) \]
emission est la quantité de lumière émise par le dome lumineux et \( \theta \) est l'angle entre l'axe du dome lumineux (ie la direction vers le zenith) et la normale. les détails des calculs sont dans synthèse réaliste et intégration numérique : l'équation de rendu.
remarque : pour faire mieux, il faut faire plus de calculs et tenir compte de la visibilité, cf synthèse réaliste et intégration numérique : l'équation de rendu. il existe aussi des approximations temps réel, par exemple, "Practical Realtime Strategies for Accurate Indirect Occlusion" , j.jimenez, 2016. on peut aussi se rendre compte que les objets proches et les objets plus éloignés n'influencent pas le résultat de la même manière et utiliser des méthodes de calcul différentes en fonction de la taille des objets et de leur distance, cf "multiresolution ambient occlusion", i.quillez, 2012
comment ça se code ? il faut calculer un cosinus, toujours avec le produit scalaire de vecteurs normalisés, il manque juste un paramètre : l'axe du dome lumineux. pour simplifier, on peut considérer que c'est l'axe Y, la verticale dans le repère de la scène. mais on retrouve le même problème qu'avec la direction de la lumière dans le shader précédent : dans quel repère faire le calcul ? on a les mêmes solutions. pour changer, voici le shader qui transforme les normales dans le repère du monde (en multipliant par la matrice model), l'axe du dome est tout simplement l'axe Y du monde, et ce n'est pas la peine de créer un paramètre. mais il faudra bien passer les 2 matrices au shader... il faudra ajouter program_uniform(program, "modelMatrix", model); avant de dessiner...
et hop :

on constate bien que les surfaces orientées vers le haut sont éclairées, et que les cotés de la tête et du torse du robot sont beaucoup plus sombres. si on ajoute maintenant la source directionnelle :

c'est un peu mieux, il n'y a plus de zones d'ombres complètement noires à la surface du robot, il suffit d'ajuster l'intensité du dome lumineux par rapport à la source de lumière principale pour équilibrer l'image. vu que le fond est noir dans cet exemple, il n'est pas vraiment nécessaire d'ajouter une 3ieme source pour éclairer les silhouettes.
dernier point, on calcule les couleurs n'importe comment ? si vous avez suivi le tuto, en utilisant Color(1, 0.5, 0.05) comme couleur pour le robot, comme dans le code de départ, vous avez plutôt obtenu ça comme image :

argh ! ce n'est vraiment pas la même chose... pourquoi ? il y a les couleurs pour faire les calculs, et les couleurs pour l'affichage sur un écran (ou pour enregistrer une image dans un fichier). et il faut transformer les couleurs d'une représentation vers l'autre. donc, en résumé, les couleurs affichées sur un écran et les couleurs des images enregistrées sont dans l'espace couleur sRGB (pour standard RGB). la représentation des couleurs pour faire les calculs est RGB tout simplement, on dit aussi RGB linéaire, les couleurs sont représentées par un mélange de rouge, de vert et bleu, pas de surprise de ce coté. pour raison historique et pour problèmes de précision numérique, les couleurs envoyées aux écrans (ou stockées dans un fichier) sont transformées par une fonction gamma, notée \( \gamma \), qui correspond au fonctionnement des écrans à tube cathodique. si cette conversion n'est pas correcte, les couleurs sont bien trop sombres et trop contrastées...
La couleur du robot est donc une couleur linéaire, dans l'espace RGB et elle est affichée telle quelle sans conversion, ce qui est faux. on peut la convertir dans l'application avec la fonction srgb() lors de l'affectation de l'uniform :
mais ce n'est toujours pas ça :

pourquoi ? les calculs dans les shaders supposent que tout est linéaire et que la conversion vers sRGB pour l'affichage est faite tout à la fin... faire des calculs sur des couleurs sRGB n'est pas une bonne idée... pour une application openGL, la meilleure solution est tout simplement d'activer la conversion automatique de la sortie du fragment shader (dans l'espace RGB linéaire) vers la représentation sRGB de l'affichage :
en résumé, on fait les calculs sur des couleurs RGB linéaires et on les converti en sRGB au moment de l'affichage ou de l'enregistrement dans un fichier. les fonctions srgb(const Color&) et linear(const Color&) de color.h réalisent les conversions dans les 2 sens :
elles sont définies pour des Color mais également pour des Image. pour enregistrer une image sur disque, il faut utiliser write_image_png( srgb(image), "srgb.png" );
attention : lorsque l'on charge une image pour l'utiliser comme une texture couleur, il faut faire la conversion vers RGB linéaire, ie les couleurs de l'image sont dans l'espace sRGB :
mais c'est valable aussi quand on choisit une couleur dans une palette affichée sur un écran, cette couleur est dans la représentation sRGB, il faut la convertir en RGB linéaire. idem quand on charge des descriptions de matières...
si vous souhaitez plus d'exemples sur la transformation gamma qui permet de passer d'une représentation à l'autre, allez faire un tour sur FilmicWorld / Linear-space lighting.
les formules sont sur wikipedia, par exemple. le code des fonctions de conversion se trouve dans color.cpp.
en général, les calculs de la couleur d'un pixel / d'un objet se font dans le fragment shader, pour éviter d'introduire des calculs non linéaires. on utilise des couleurs dans un espace RGB linéaire, pour exactement la même raison, faire des calculs linéaires. et pour obtenir une image pas trop moche, il faut l'éclairer à peu près naturellement... (et une seule source directionnelle n'est pas du tout naturelle, il faudrait être dans l'espace sans atmosphère... ie sur terre, le ciel éclaire presque autant que le soleil)