Tutoriel 23 - Shadow mapping - 1ère partie

Par OGLdev, traduit par DragonJoker

Introduction

Le concept d'ombre est inséparable du concept de lumière : nous avons besoin de lumière afin de pouvoir projeter une ombre. Il y a de nombreuses techniques pour générer des ombres, et dans ce tutoriel en deux parties, nous allons étudier l'une des plus simples et basiques : le Shadow Mapping.

Contexte

Lorsqu'arrive le moment de la rastérisation et des ombres, la question qui vous vient à l'esprit est : est-ce que ce pixel est dans l'ombre, ou non ? Posons cette question différemment : est-ce que le chemin de la source lumineuse vers le pixel passe au travers d'un autre objet, ou non ? Si c'est le cas, le pixel est probablement dans l'ombre (en supposant que l'objet en question ne soit pas transparent…), et s'il ne le fait pas, le pixel n'est pas dans l'ombre. En un sens, cette question est proche de la question que nous nous posions dans le précédent tutoriel : comment s'assurer que, lorsque deux objets se chevauchent, nous voyions le plus proche ? Si nous plaçons, pour un instant, la caméra à l'origine de la lumière, les deux questions n'en sont plus qu'une. Nous voulons que les pixels qui échouent le test de profondeur (c'est-à-dire ceux qui sont trop éloignés, ou qui ont d'autres pixels devant eux) soient dans l'ombre. Seuls les pixels qui gagnent le test de profondeur doivent être dans la lumière. Ils sont ceux qui sont en contact direct avec la source lumineuse et rien ne les dissimule. Il s'agit là du cœur de l'idée derrière le shadow mapping.

Il semble donc que le test de profondeur peut nous aider à déterminer si un pixel est dans l'ombre, mais il y a un problème : la caméra et la lumière ne sont pas toujours situées à la même position. Le test de profondeur est habituellement utilisé pour résoudre les problèmes de visibilité, depuis le point de vue de la caméra donc, comment pouvons-nous l'utiliser pour la détection des ombres, lorsque la source lumineuse est placée plus loin ? La solution est de dessiner la scène deux fois. Tout d'abord, du point de vue de la source lumineuse. Les résultats de cette passe de rendu n'atteignent pas le tampon de couleurs. Par contre, les valeurs de profondeur les plus proches sont dessinées dans un tampon de profondeur, créé par l'application (au lieu de celui automatiquement généré par GLUT). Durant la seconde passe, la scène est dessinée, comme d'habitude, selon le point de vue de la caméra. Le tampon de profondeur que nous avons créé est lié au fragment shader, en lecture. Pour chaque pixel, nous récupérons la profondeur correspondante dans ce tampon de profondeur. Nous calculons aussi la profondeur de ce pixel du point de vue de la lumière. Parfois, les deux valeurs de profondeur seront identiques. C'est le cas où ce pixel était plus proche de la lumière, donc sa valeur de profondeur a fini dans le tampon de profondeur. Si ça arrive, nous considérons ce pixel comme éclairé, et calculons sa couleur comme d'habitude. Si les valeurs de profondeur sont différentes, cela signifie qu'un autre pixel couvrait celui-ci, du point de vue de la source lumineuse. Dans ce cas, nous ajoutons le facteur d'ombre au calcul de la lumière pour simuler l'effet d'ombre. Regardons l'image suivante :

Notre scène est constituée de deux objets : la surface, et le cube. La source lumineuse est située dans le coin en haut à gauche, et est dirigée vers ce cube. Pendant la première passe, nous dessinons dans le tampon de profondeur, du point de vue de la source lumineuse. Attardons-nous sur les trois points A, B, et C. Lorsque B est dessiné, sa valeur de profondeur va dans le tampon de profondeur. La raison est qu'il n'y a rien, entre le point et la lumière. Par défaut, il est le point le plus proche de la lumière sur cette ligne. Par contre, lorsque A et C sont dessinés, ils « bataillent » exactement sur le même point du tampon de profondeur. Les deux points sont sur la même ligne depuis la source lumineuse, donc après que la projection en perspective a été appliquée, le rastériseur trouve que deux points doivent aller sur le même pixel à l'écran. C'est le test de profondeur, C le « gagne ».

Durant la seconde passe, nous dessinons la surface et le cube, du point de vue de la caméra. Dans notre shader d'éclairage par pixel, en plus de tout ce que nous faisons déjà, nous allons aussi calculer la distance entre la source lumineuse et le pixel, et la comparer à la valeur correspondante dans le tampon de profondeur. Lorsque nous calculons le point B, les deux valeurs devraient être globalement les mêmes (des différences sont attendues, dues aux interpolations, ainsi qu'aux problèmes de précision sur les nombres flottants). Ainsi, nous déterminons que B n'est pas dans l'ombre, et agissons en conséquence. Lorsque nous rastérisons A, nous trouvons que la valeur de profondeur stockée est nettement plus petite que la profondeur de A. Par conséquent, nous déterminons que A est dans l'ombre, et appliquons un certain facteur d'ombre pour le dessiner plus sombre que d'habitude.

Les sources de ce tutoriel incluent un simple maillage en rectangle, pouvant être utilisé pour afficher la shadow map. Le rectangle est constitué de deux triangles, et les coordonnées de texture sont définies pour que la texture couvre toute la surface. Lorsque le rectangle est dessiné, les coordonnées de texture sont interpolées par le rastériseur, vous permettant d'échantillonner une texture complète, et de l'afficher sur l'écran.

Explication du code

class ShadowMapFBO
{
public:
	ShadowMapFBO();

	~ShadowMapFBO();

	bool Init(unsigned int WindowWidth, unsigned int WindowHeight);

	void BindForWriting();

	void BindForReading(GLenum TextureUnit);

private:
	GLuint m_fbo;
	GLuint m_shadowMap;
};

Les résultats du pipeline 3D d'OpenGL finissent dans quelque chose qui est appelé « Framebuffer Object », tampon d'image en français (et aussi agrégé FBO). Ce concept englobe le tampon de couleurs (qui est affiché sur l'écran), le tampon de profondeur, ainsi que quelques autres tampons pour d'autres utilisations. Lorsque glutInitDisplayMode() est appelée, elle crée le tampon d'image par défaut, en utilisant les paramètres spécifiés. Ce tampon d'image est géré par le système de fenêtrage et ne peut pas être détruit par OpenGL. En plus du tampon d'image par défaut, une application peut créer des FBO elle-même. Ces objets peuvent être manipulés et utilisés dans diverses techniques, sous le contrôle de l'application. La classe ShadowMapFBO fournit une interface simple d'utilisation vers un FBO, qui va être utilisée pour la technique de shadow mapping. En interne, cette classe contient deux identifiants OpenGL. L'identifiant « m_fbo » représente le FBO. Le FBO encapsule l'état complet du tampon d'image. Une fois cet objet créé et configuré correctement, nous pouvons changer de tampon d'image simplement en attachant un objet différent.

Seul le tampon d'image par défaut peut être utilisé pour dessiner quelque chose sur l'écran. Les tampons d'image créés par l'application ne peuvent être utilisés que pour du rendu « hors écran ». Cela peut être une passe de rendu intermédiaire (par exemple : notre tampon de shadow mapping), qui sera utilisée plus tard pour la passe de rendu « réel » sur l'écran.

En lui-même, le tampon d'image n'est qu'un espace réservé. Pour le rendre utilisable, nous devons attacher des textures, à un ou plusieurs points d'attache. Les textures contiennent l'espace de stockage réel du tampon d'image. OpenGL définit les points d'attache suivants :

  1. COLOR_ATTACHMENTi : la texture qui sera attachée ici recevra la couleur provenant du fragment shader. Le suffixe « i » signifie qu'il peut y avoir plusieurs textures attachées aux attaches de couleur simultanément. Il y a un mécanisme dans le fragment shader permettant le rendu dans plusieurs tampons de couleur en même temps ;
  2. DEPTH_ATTACHMENT : la texture qui sera attachée ici recevra les résultats du test de profondeur ;
  3. STENCIL_ATTACHMENT : la texture attachée ici servira de tampon de découpe. Le tampon de découpe permet de limiter la zone de rastérisation, et peut être utilisé dans diverses techniques ;
  4. DEPTH_STENCIL_ATTACHMENT : celui-ci est simplement une combinaison d'un tampon de profondeur et d'un tampon de stencil, car les deux sont souvent utilisés conjointement.

Pour la technique de shadow mapping, nous n'avons besoin que d'un tampon de profondeur. Le membre « m_shadowMap » est l'identifiant de la texture qui sera attachée au point DEPTH_ATTACHMENT. La classe ShadowMapFBO fournit aussi quelques méthodes qui seront utilisées dans la fonction principale de dessin. Nous appellerons BindForWriting() avant le dessin dans la shadow map, et BindForReading() lors du début de la seconde passe de dessin.

glGenFramebuffers(1, &m_fbo);

Ici, nous créons le FBO. De même que pour les textures et les tampons, nous donnons l'adresse d'un tableau de GLuint et sa taille. Le tableau est rempli avec les identifiants.

glGenTextures(1, &m_shadowMap);
glBindTexture(GL_TEXTURE_2D, m_shadowMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, WindowWidth, WindowHeight, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

Ensuite, nous créons la texture qui va servir de shadow map. De manière générale, c'est une texture 2D standard, avec quelques options de configuration spécifiques pour la rendre utilisable dans ce contexte :

  1. Le format interne est GL_DEPTH_COMPONENT. C'est différent des précédentes utilisations de cette fonction où le format interne est habituellement un des types de couleurs (par exemple : RGB). GL_DEPTH_COMPONENT signifie un seul nombre flottant qui représente la profondeur normalisée ;
  2. Le dernier paramètre de glTexImage2D est nul. Cela signifie que nous ne fournissons pas de données pour initialiser le tampon. C'est logique, sachant que nous voulons que le tampon contienne les valeurs de profondeur de chaque frame, et chaque frame est différente. À chaque fois que nous entamons une nouvelle frame, nous utilisons glClear() pour vider le tampon. C'est toute l'initialisation dont nous avons besoin pour le contenu ;
  3. Nous disons à OpenGL que si les coordonnées de texture dépassent les bornes, nous les remettons dans l'intervalle [0, 1]. Cela arrive lorsque la fenêtre de projection depuis le point de vue de la caméra contient plus que la fenêtre de projection du point de vue de la source lumineuse. Pour éviter des artefacts étranges, tels que l'ombre qui se répète autre part (dû à l'enroulement), nous imposons qu'elles soient remises dans cet intervalle.
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);

Nous avons généré le FBO, l'objet de texture, et nous avons configuré cet objet pour le shadow mapping. Maintenant, nous devons attacher l'objet de texture au FBO. La première chose à faire pour cela est d'activer le FBO. Cela le définit comme « courant », et toutes les opérations pour FBO à venir s'appliqueront à celui-ci. Cette fonction prend l'identifiant du FBO et la cible voulue. La cible peut être GL_FRAMEBUFFER, GL_DRAW_FRAMEBUFFER ou GL_READ_FRAMEBUFFER. GL_READ_FRAMEBUFFER est utilisé lorsque nous voulons lire depuis le FBO en utilisant glReadPixels() (pas dans ce tutoriel). GL_DRAW_FRAMEBUFFER est utilisé lorsque nous voulons dessiner dans le FBO. Lorsque nous utilisons GL_FRAMEBUFFER, et l'état d'écriture, et l'état de lecture sont mis à jour et c'est la méthode recommandée pour initialiser le FBO. Nous allons utiliser GL_DRAW_FRAMEBUFFER lorsque nous commencerons effectivement à dessiner.

glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, m_shadowMap, 0);

Ici, nous attachons la texture de shadow map au point d'attache de profondeur du FBO. Le dernier paramètre de cette fonction indique quelle couche de mipmap utiliser. Le mipmapping est une fonctionnalité de placage de texture, où une texture est représentée à différentes résolutions, en commençant par la résolution la plus haute, au mipmap 0, et en allant vers des résolutions inférieures, au mipmaps 1 à N. La combinaison de textures avec des mipmaps et le filtre trilinéaire fournit des résultats plus jolis, lors de la combinaison de texels provenant de niveaux de mipmap voisins (lorsqu'aucun niveau ne convient parfaitement). Ici nous avons un unique niveau de mipmap, nous utilisons donc 0. Nous fournissons l'identifiant de la shadow map en quatrième paramètre. Si nous utilisons 0 ici, nous détachons la texture du point d'attache (profondeur, dans notre cas).

glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);

Comme nous n'allons pas dessiner dans le tampon de couleurs (seulement dans celui de profondeur), nous le spécifions explicitement avec les appels ci-dessus. Par défaut, le tampon de couleurs est attaché au point GL_COLOR_ATTACHMENT0, mais notre FBO ne contient même pas de tampon de couleurs. Par conséquent, il est mieux de dire explicitement notre intention à OpenGL. Les paramètres valides pour ces fonctions sont GL_NONE et GL_COLOR_ATTACHMENT0 à GL_COLOR_ATTACHMENTm, où « m » vaut GL_MAX_COLOR_ATTACHMENTS - 1. Ces paramètres sont valides uniquement pour des FBO. Si le tampon d'image par défaut est utilisé, les paramètres valides sont alors GL_NONE, GL_FRONT_LEFT, GL_FRONT_RIGHT, GL_BACK_LEFT et GL_BACK_RIGHT. Ces paramètres vous permettent de dessiner directement dans les tampons avant et arrière (où chacun à un tampon droite et gauche). Nous définissons aussi le tampon de lecture à GL_NONE (souvenez-vous, nous n'allons pas appeler les fonctions glReadPixel()). C'est principalement pour éviter les problèmes liés aux GPU ne supportant qu'OpenGL 3.x et pas 4.x.

GLenum Status = glCheckFramebufferStatus(GL_FRAMEBUFFER);

if (Status != GL_FRAMEBUFFER_COMPLETE) {
	printf("FB error, status: 0x%x\n", Status);
	return false;
}

Lorsque la configuration du FBO est terminée, il est très important de vérifier que son état est ce que la spécification d'OpenGL définit comme « complet ». Cela signifie qu'aucune erreur n'a été détectée, et que le tampon d'image peut maintenant être utilisé. Le code ci-dessus vérifie cela.

void ShadowMapFBO::BindForWriting()
{
	glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_fbo);
}

Nous allons avoir besoin de passer du rendu dans la shadow map au rendu dans le tampon d'image par défaut. Dans la seconde passe, nous avons aussi besoin d'attacher notre shadow map en entrée. Cette fonction et la suivante vont nous faciliter ces tâches. La fonction ci-dessus lie le FBO en écriture, comme nous l'avons fait auparavant. Nous l'appellerons avant la première passe de rendu…

void ShadowMapFBO::BindForReading(GLenum TextureUnit)
{
	glActiveTexture(TextureUnit);
	glBindTexture(GL_TEXTURE_2D, m_shadowMap);
}

… Et cette fonction sera appelée avant la seconde passe, pour lier la shadow map en lecture.

Notez que nous lions spécifiquement l'objet de texture, plutôt que le FBO lui-même.

Cette fonction prend en paramètre l'unité de texture à laquelle la shadow map doit être liée. L'indice de l'unité de texture doit être synchronisé avec le shader (car le shader possède une variable uniforme sampler2D pour accéder à la texture). Il est très important de noter qu'alors que glActiveTexture() prend l'indice de la texture en tant qu'énumération (c'est-à-dire GL_TEXTURE0, GL_TEXTURE1, etc.), le shader n'a lui besoin que de l'indice lui-même (0, 1, etc.). Cela peut être la source de nombreux bogues (croyez-moi, je le sais…).

#version 330

layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;

uniform mat4 gWVP;

out vec2 TexCoordOut;

void main()
{
	gl_Position = gWVP * vec4(Position, 1.0);
	TexCoordOut = TexCoord;
}

Nous allons utiliser le même programme de shader pour les deux passes. Le vertex shader sera utilisé par les deux passes, alors que le fragment shader ne sera utilisé que par la seconde passe. Comme nous désactivons l'écriture dans le tampon de couleurs durant la première passe, le fragment shader sera tout simplement inutilisé. Le vertex shader ci-dessus est très simple. Il génère les coordonnées dans l'espace de découpe, en multipliant la position en coordonnées locales par la matrice WVP (World View Position), et transmet les coordonnées de texture. Pendant la première passe, les coordonnées de texture sont redondantes (du fait de l'absence de fragment shader). Cependant, cela n'a pas d'impact réel, et il est plus simple de partager le vertex shader. Comme vous pouvez le voir, le fait que ce soit la passe de profondeur ou la passe de rendu réel ne fait aucune différence, du point de vue du shader. Ce qui diffère, c'est que l'application donne une matrice WVP correspondant au point de vue de la lumière, pendant la première passe, et une matrice WVP correspondant au point de vue de la caméra pendant la seconde passe. Dans la seconde passe, nous avons aussi besoin des coordonnées de texture dans le fragment shader, car nous échantillonnons depuis la shadow map (qui est alors une entrée du shader).

#version 330

in vec2 TexCoordOut;
uniform sampler2D gShadowMap;

out vec4 FragColor;

void main()
{
	float Depth = texture(gShadowMap, TexCoordOut).x;
	Depth = 1.0 - (1.0 - Depth) * 25.0;
	FragColor = vec4(Depth);
}

Ceci est le fragment shader utilisé pour afficher la shadow map durant la passe de rendu. Les coordonnées de texture 2D sont utilisées pour récupérer la valeur de profondeur depuis la shadow map. La texture de shadow map a été créée avec comme format interne le type GL_DEPTH_COMPONENT. Cela signifie que le texel basique est un simple nombre flottant, et non une couleur. C'est pourquoi « .x » est utilisé durant l'échantillonnage. La projection en perspective possède la caractéristique connue de réserver, pour les positions proches de la caméra, plus de valeurs de l'intervalle [0, 1] que pour les positions lointaines, lors de la normalisation de la composante Z du vecteur position. La raison est de permettre une plus grande précision en profondeur lorsque nous nous approchons de la caméra, car les erreurs sont plus facilement visibles. Lorsque nous affichons le contenu du tampon de profondeur, nous pouvons tomber dans le cas où l'image résultante n'est pas assez claire. Par conséquent, après avoir échantillonné la profondeur depuis la shadow map, nous l'accentuons en multipliant la distance entre le point courant et le point le plus loin (pour lequel Z vaut 1.0) et en soustrayant le résultat de 1.0. Cela augmente l'intervalle, et améliore l'image finale. Nous utilisons la nouvelle valeur de profondeur pour créer une couleur, en la propageant dans toutes les composantes de la couleur. Cela signifie que nous allons obtenir des variations de gris (le blanc étant le plan de découpe éloigné, et le noir étant le plan de découpe proche).

Maintenant, voyons comment combiner les morceaux de code ci-dessus, et créons l'application.

virtual void RenderSceneCB()
{
	m_pGameCamera->OnRender();
	m_scale += 0.05f;

	ShadowMapPass();
	RenderPass();

	glutSwapBuffers();
}

La fonction principale de rendu est devenue plus simple, car la plupart des fonctionnalités ont été déplacées dans d'autres fonctions. Tout d'abord, nous nous occupons des configurations « globales », telles que la mise à jour de la position de la caméra, et le membre de classe utilisé pour orienter l'objet. Ensuite nous appelons la fonction pour dessiner dans la shadow map, puis la fonction pour afficher le résultat. Enfin, glutSwapBuffers() est appelée pour l'afficher sur l'écran.

virtual void ShadowMapPass()
{
	m_shadowMapFBO.BindForWriting();

	glClear(GL_DEPTH_BUFFER_BIT);

	Pipeline p;
	p.Scale(0.1f, 0.1f, 0.1f);
	p.Rotate(0.0f, m_scale, 0.0f);
	p.WorldPos(0.0f, 0.0f, 5.0f);
	p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
	p.SetPerspectiveProj(20.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
	m_pShadowMapTech->SetWVP(p.GetWVPTrans());

	m_pMesh->Render();

	glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

Nous débutons la passe de shadow map en liant le FBO de la shadow map. À partir de maintenant, toutes les valeurs de profondeur iront dans notre texture de shadow map, et les écritures dans le tampon de couleurs seront défaussées. Nous nettoyons le tampon de profondeur (et seulement lui), avant de commencer à faire quoi que ce soit. Ensuite nous préparons la classe de pipeline, afin de dessiner le maillage (un char d'assaut provenant de Quake 2 est fourni avec les sources du tutoriel). Le seul point important ici est que la caméra est mise à jour, en se basant sur la position et l'orientation du projecteur de lumière. Nous dessinons le maillage, puis revenons au tampon d'image par défaut en liant le FBO zéro.

virtual void RenderPass()
{
	glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

	m_pShadowMapTech->SetTextureUnit(0);
	m_shadowMapFBO.BindForReading(GL_TEXTURE0);

	Pipeline p;
	p.Scale(5.0f, 5.0f, 5.0f);
	p.WorldPos(0.0f, 0.0f, 10.0f);
	p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
	p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
	m_pShadowMapTech->SetWVP(p.GetWVPTrans());
	m_pQuad->Render();
}

La passe de rendu débute en nettoyant les deux tampons, couleurs et profondeur. Ces tampons appartiennent au tampon d'image par défaut. Nous disons au shader d'utiliser l'unité de texture 0, et lions la texture contenant la shadow map, en lecture à l'unité de texture 0. À partir de là, c'est comme d'habitude. Nous agrandissons le rectangle, nous le plaçons directement devant la caméra, et nous le dessinons. Pendant la rastérisation, la shadow map est échantillonnée, et affichée.

Dans ce tutoriel, nous ne chargeons plus automatiquement une texture blanche lorsque le fichier du maillage n'en spécifie pas. C'est pour être capable de lier la shadow map à la place. Si un maillage ne contient pas de texture, nous pouvons tout simplement n'en attacher aucune, cela permet au code appelant d'attacher sa propre texture.

Remerciements

Merci à Etay Meiri de me permettre de traduire ses tutoriels.

Résultat :
resultat

Article d'origine