gKit2 light
Loading...
Searching...
No Matches
premières lumières, éclairer un objet, sRGB et gamma

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

comment ça marche ?

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 !

Vector n= { ... }; // la normale d'un point visible pour un pixel
Vector l= { ... }; // la direction vers la lumière, Vector(1, 0, 0) par exemple
Color color= { ... }; // la couleur du point / de l'objet
float cos_theta= dot( normalize(n), normalize(l) );
Color pixel= color * cos_theta;
float dot(const Vector &u, const Vector &v)
renvoie le produit scalaire de 2 vecteurs.
Definition vec.cpp:181
Vector normalize(const Vector &v)
renvoie un vecteur unitaire / longueur == 1.
Definition vec.cpp:167
representation d'une couleur (rgba) transparente ou opaque.
Definition color.h:14
representation d'un vecteur 3d.
Definition vec.h:67

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, ... ) :

float cos_theta= max( float(0), dot( normalize(n), normalize(l) ) );
Point max(const Point &a, const Point &b)
renvoie la plus grande composante de chaque point { max(a.x, b.x), max(a.y, b.y), max(a....
Definition vec.cpp:35

et comment on écrit un shader avec ça ?

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 :

  • n, la normale,
  • l, la direction vers la lumière,
  • color, la couleur de l'objet.

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.

// init
// chargement d'un objet 3d / format .obj
Mesh mesh= read_mesh( "..." );
if(mesh.vertex_buffer_size() == 0)
return "erreur"; // pas de sommets, pas d'affichage...
if(mesh.normal_buffer_size() == 0)
return "erreur"; // pas de normales, pas de calculs...
// configure le format des sommets, ie le Vertex Array, et transfère les sommets et leurs normales dans des buffers...
GLuint vao= mesh.create_buffers( USE_POSITION | USE_NORMAL );
unsigned count= mesh.vertex_count();
representation d'un objet / maillage.
Definition mesh.h:121
Mesh read_mesh(const char *filename)
charge un fichier wavefront .obj et renvoie un mesh compose de triangles non indexes....
Definition wavefront.cpp:14
@ USE_POSITION
inclut l'attribut position dans les buffers.
Definition mesh.h:112
@ USE_NORMAL
inclut l'attribut normale dans les buffers.
Definition mesh.h:114

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 :

#version 330
#ifdef VERTEX_SHADER
// déclaration des attributs de sommets, il faut respecter la convention de mesh
layout(location= 0) in vec3 position;
layout(location= 2) in vec3 normal;
// déclaration des paramètres
uniform mat4 mvpMatrix;
uniform vec3 lumiere;
uniform vec4 color;
void main( )
{
// transforme le sommet dans l'espace projectif de la camera, résultat obligatoire du vertex shader.
gl_Position= mvpMatrix * vec4(position, 1);
// éclairage par une source de lumière.
float cos_theta= max( 0, dot( normalize(normal), normalize(lumiere) ) );
vec4 vertex_color= color * cos_theta;
...
}
#endif
vecteur generique, utilitaire.
Definition vec.h:169
vecteur generique 4d, ou 3d homogene, utilitaire.
Definition vec.h:192

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.

#version 330
#ifdef VERTEX_SHADER
// déclaration des attributs de sommets, il faut respecter la convention de mesh
layout(location= 0) in vec3 position;
layout(location= 2) in vec3 normal;
// déclaration des paramètres
uniform mat4 mvpMatrix;
uniform vec3 lumiere;
uniform vec4 color;
// déclaration des sorties
out vec4 vertex_color;
void main( )
{
// transforme le sommet dans l'espace projectif de la camera, résultat obligatoire du vertex shader.
gl_Position= mvpMatrix * vec4(position, 1);
// éclairage par une source de lumière.
float cos_theta= max( 0, dot( normalize(normal), normalize(lumiere) ) );
vertex_color= color * cos_theta;
}
#endif
#ifdef FRAGMENT_SHADER
// déclaration des entrées
in vec4 vertex_color; // même nom, même type que dans le vertex shader
void main( )
{
gl_FragColor= vertex_color;
}
#endif

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 :

// render
Transform model= Identity();
Transform view= camera.view();
Transform projection= camera.projection();
// composition des 3 changements de repères, model puis view puis projection
Transform mvp= projection * view * model; // P(V(M*p))
// paramètres
glUseProgram(program);
program_uniform(program, "mvpMatrix", mvp);
program_uniform(m_program, "light", normalize( Vector(0.5, 0.25, 0) )); // à droite et un peu vers le haut
program_uniform(program, "color", Color(1, 0.5, 0.05));
glBindVertexArray(vao);
glDrawArray(GL_TRIANGLES, 0, count);
Transform Identity()
construit la transformation identite.
Definition mat.cpp:187
representation d'une transformation, une matrice 4x4, organisee par ligne / row major.
Definition mat.h:21

en résumé :

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 bougeait ?

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

Transform model= RotationY( global_time() / 10 );
float global_time()
renvoie le temps ecoule depuis le lancement de l'application, en millisecondes.
Definition window.cpp:126
Transform RotationY(const float a)
renvoie la matrice representation une rotation de a degree autour de l'axe Y.
Definition mat.cpp:242

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

Note
la matrice model représente la transformation du repère de l'objet vers le repère de la scène / du monde. l'inverse de la matrice correspond au passage dans l'autre sens : du repère de la scène vers le repère de l'objet. donc si on connait des coordonnées dans le repère de l'objet et que l'on les multiplie par la matrice model, on obtient les coordonnées dans le repère monde. et si on mulitplie ces coordonnées par l'inverse de la matrice, on retrouve les coordonnées de départ, dans le repère de l'objet...
plus précisement, si \( p \) est un point dans le repère de l'objet et \( q \) est le même point avec des coordonnées dans le repère monde :

\[ 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 :

Transform model= RotationY( global_time() / 10 );
Transform model_inv= Inverse( model );
Vector light= normalize( Vector(0.5, 0.25, 0) );
Vector local_light= model_inv( light );
...
glUseProgram(program);
program_uniform(program, "mvpMatrix", mvp);
program_uniform(m_program, "light", local_light);
program_uniform(program, "color", Color(1, 0.5, 0.05));
glBindVertexArray(vao);
glDrawArray(GL_TRIANGLES, 0, count);
Transform Inverse(const Transform &m)
renvoie l'inverse de la matrice.
Definition mat.cpp:197

exercice

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 ?

mais pourquoi c'est aussi moche ?

il y a au moins 3 raisons pour lesquelles l'image du robot est moche :

  • on interpole n'importe comment,
  • on éclaire n'importe comment,
  • on calcule les couleurs n'importe comment...

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 :

#version 330
#ifdef VERTEX_SHADER
layout(location= 0) in vec3 position;
layout(location= 2) in vec3 normal;
// déclaration des paramètres
uniform mat4 mvpMatrix;
// déclaration des sorties
out vec3 vertex_normal;
void main( )
{
// transforme le sommet dans l'espace projectif de la camera, résultat obligatoire du vertex shader.
gl_Position= mvpMatrix * vec4(position, 1);
vertex_normal= normal;
}
#endif
#ifdef FRAGMENT_SHADER
in vec3 vertex_normal; // même nom, même type que dans le vertex shader
// déclaration des paramètres
uniform vec3 lumiere;
uniform vec4 color;
void main( )
{
float cos_theta= max( 0, dot( normalize(vertex_normal), normalize(lumiere) ) );
gl_FragColor= color * cos_theta;
}
#endif

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

#version 330
#ifdef VERTEX_SHADER
layout(location= 0) in vec3 position;
layout(location= 2) in vec3 normal;
uniform mat4 mvpMatrix;
uniform mat4 modelMatrix;
out vec3 vertex_normal;
void main( )
{
gl_Position= mvpMatrix * vec4(position, 1);
vertex_normal= mat3(modelMatrix) * normal;
}
#endif
#ifdef FRAGMENT_SHADER
in vec3 vertex_normal;
uniform vec4 color;
uniform vec3 light;
void main( )
{
float ambient= max(0, dot( normalize(vertex_normal), vec3(0, 1, 0) ));
gl_FragColor= color * (1 + ambient) / 2;
}
#endif

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 :

float ambient= max(0, dot( normalize(vertex_normal), vec3(0, 1, 0) ));
vec4 ambient_color= color * (1 + ambient) / 2;
float cos_theta= max(0, dot( normalize(vertex_normal), normalize(light) ));
gl_FragColor= 0.2 * ambient_color + color * cos_theta;

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

Note
les écrans actuels émulent ce fonctionnement, c'est le standard que tous les constructeurs suivent. il existe des espaces couleurs bien plus riches pour les écrans et les projecteurs modernes, cf Rec. 709 et Rec. 2020

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 :

program_uniform(program, "color", srgb( Color(1, 0.5, 0.05) ));

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 :

// init
glEnable(GL_FRAMEBUFFER_SRGB);

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 :

  • srgb() de RGB linéaire vers sRGB,
  • linear() de sRGB vers RGB linéaire.

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 :

Image image= linear( read_image( "..." ) ); // charge une image dans l'application
GLuint texture= read_srgb_texture( "..." ); // créer directement la texture openGL avec SRGB8 ou SRGB8_ALPHA8 comme type de texel.
representation d'une image.
Definition image.h:21
Image read_image(const char *filename, const bool flipY)
Definition image_io.cpp:171
GLuint read_srgb_texture(const int unit, const char *filename, const GLenum texel_type)
Definition texture.cpp:139

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

Important
cette conversion n'est nécessaire que pour les couleurs, il ne faut surtout pas l'appliquer aux paramètres de matières comme la rugosité ou un indice de réfraction, des normales, de l'ambiant, etc. c'est pour cette raison que la conversion n'est pas automatique au chargement ou lors de l'enregistrement des images et des textures.

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

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)