Dans ce TP vous allez implémenter un deferred shading simple, comme expliqué pendant la séance 2, pour remplacer l'approche forward naïve utilisée jusqu'ici.
Votre pipeline devrait ressembler à ça :
Ce TP n'aborde pas les calculs d'éclairage, seulement la mise en place d'un pipeline deferred. Vous êtes libre d'utiliser les équations que vous voulez. Le plus simple est de reprendre celles déjà présentes dans lit.frag
.
Pour que le lighting fonctionne le G-Buffer doit contenir au minimum l'albedo et les normales de la scène.
Pour cela on comencera par utiliser 2 textures.
RGBA8_sRGB
et contiendra l'albedo dans les canaux RGB.RGBA8_UNORM
et contiendra les normales dans les canaux RGB. (Pour le moment les normales seront stockées comme de simples vecteurs XYZ, sans encodage particulier).Les canaux alpha sont volontairement laissé vides, vous êtes libre de les utiliser pour ce que vous voulez.
Les textures RGBA8_UNORM
ne peuvent que stocker des valeurs positives comprises dans l’intervalle [0; 1]. Il est donc nécessaire de convertir les normales (dont les composants se trouvent dans l’intervalle [-1; 1]) avant de les écrire.
OpenGL permet aux fragment shaders d'avoir plusieurs valeurs de sortie, et de rendre plusieurs render-targets en une seule passe (en plus de la profondeur). Le constructeur de la classe Framebuffer
qui vous a été fournie accepte un std::array<Texture*>
en paramètre, ce qui permet de spécifier plusieurs render-targets.
Écrire dans plusieurs render-targets depuis un fragment shader est relativement simple. Il suffit de déclarer une variable out
pour chaque render-target, et de spécifier un index en utilisant layout(location = /* location */)
. L'index de location correspond à l'index de la texture passée au constructeur du framebuffer.
Pour le moment tous les matériaux chargés depuis un fichier glTF utilisent un shader faisant un forward simple (contenu dans lit.frag
). Ce shader n'est évidement pas compatible avec la génération d'un G-Buffer (en plus d’être massivement inefficace), vous devrez donc le remplacer par un shader adapté.
Le shader fourni supporte le texturing et le normal-mapping via les defines TEXTURED
et NORMAL_MAPPED
. Votre shader de G-Buffer devra lui aussi supporter ces fonctionnalités.
Bien que l'on puisse utiliser RenderDoc pour visualiser le contenu du G-Buffer, il peut être pratique de pouvoir le faire dans l'application directement.
Vous devrez donc implémenter des vues de debug pour pouvoir inspecter votre G-Buffer.
Pour activer/désactiver les vue de debug, il faudra rajouter un menu dans la fenêtre ImGui déjà présente dans l'application.
ImGui est une librairie externe pour créer des UI de debug directement depuis la boucle principale du moteur. ImGui n'a pas de backend graphique et ne fait que générer des vertex/index buffers que l'application est responsable d'afficher.
Le projet contient un renderer ImGui (implémenté dans la classe ImGuiRenderer
). Pour ajouter des éléments à la fenêtre existante il suffit d'ajouter des commandes entre imgui.start();
et imgui.finish();
dans le main
.
Vous pouvez trouver des exemples de comment utiliser ImGui dans imgui_demo.cpp
.
Vos vues de debug devront au moins contenir un moyen de visualiser l'albedo, les normales, ainsi que la depth présentes dans le G-Buffer. Vous êtes libre de choisir comment ces vues seront rendues et d'en ajouter d'autres.
La méthode la plus simple est probablement de rendre un triangle recouvrant tout l'écran avec un fragment shader qui lit le G-Buffer et output la texture voulue.
Le code donné contient un vertex shader spécialement pour dessiner un triangle recouvrant tout l'écran : screen.vert
. Ce shader contient déjà la géométrie nécessaire et peux donc être utilisé sans vertex buffer. Pour ce faire, il suffit de bind le shader et demander à OpenGL de dessiner 3 vertices en appelant glDrawArrays(GL_TRIANGLES, 0, 3);
.
Maintenant que le G-Buffer en place, il ne reste plus qu'à calculer l'éclairage.
La première étape est de calculer l'éclairage du soleil (ainsi que l'éclairage ambiant, si applicable).
Contrairement aux lampes "locales" comme les point-lights ou les spot-lights, le soleil affecte toute la scène, peu importe sa position.
Plutôt que de rendre un volume, on peut donc rendre un triangle couvrant tout l'écran (en utilisant screen.vert
).
Il est recommandé d'utiliser la classe Material
plutôt que manipuler les shaders directement pour rendre vos lampes (locales ou non), car celle-ci contient déjà la majorité de la logique nécessaire à la gestion des états OpenGL comme le depth-test, le blending, ou le culling.
Les textures qui composent le G-Buffer peuvent être lues comme n'importe quelles textures. Il est cependant important de ne pas appliquer de filtrage (bilinéaire ou autre) au moment de la lecture, car certaine informations présentes dans le G-Buffer ne sont pas interpolables.
Il est possible de désactiver le filtrage pour une texture en mettant ses paramètres de filtrage (GL_TEXTURE_MIN_FILTER
et GL_TEXTURE_MAG_FILTER
) à GL_NEAREST
dans OpenGL.
Dans le cas de la lecture d'un G-Buffer, il est plus simple (et plus sûr) de lire la texture en utilisant une fonction GLSL qui n'applique pas de filtrage: texelFetch
.
texelFetch
fonctionne différemment de texture
et prends en paramètre les coordonnées en pixels (en non les UV) du texel a lire.
Dans un fragment shader, vous pouvez récupérer les coordonnées en pixels du fragment courant via gl_FragCoord.
Pour calculer l'éclairage, il nous faut la positon du point que nous essayons de shader. La position n'est pas contenu dans le G-Buffer, il faut donc la recalculer à partir du contenu du Z-Buffer.
La valeur contenue dans le Z-Buffer (et la position du pixel) est calculée à partir de la gl_Position
, qui est elle-même calculée en multipliant la position par les matrices de projection et de view. Pour retrouver la position il suffit donc d'effectuer l’opération inverse.
Pour ce faire vous pouvez utiliser la fonction suivante:
vec3 unproject(vec2 uv, float depth, mat4 inv_viewproj) {
const vec3 ndc = vec3(uv * 2.0 - vec2(1.0), depth);
const vec4 p = inv_viewproj * vec4(ndc, 1.0);
return p.xyz / p.w;
}
Où uv
sont les UV du pixel dont on cherche à calculer la position, depth
est sa depth (lue depuis le Z-Buffer), et inv_viewproj
est l'inverse de la matrice view-proj de la camera, que vous devrez calculer.
Dans un "vrai" moteur il existe plusieurs types de lampe locale (typiquement 3 ou 4). Par soucis de simplicité, dans ce TP on ne vous demandera de suporter que les point lights.
Il est maintenant temps de calculer la contribution des lampes locales. Le procédé est similaire à celui utilisé pour calculer l'éclairage du soleil avec trois différences majeures:
Pour additionner le résultat de l'éclairage de chaque lampe, il faudra utiliser un blending additif. Le code donné ne supporte qu'un seul mode de blending (Alpha
), il vous faudra donc étendre l'enum BlendMode
pour ajouter le support du blending additif.
Vous devrez aussi ajouter la possibilité pour un matériau de ne pas écrire dans le Z-Buffer. (On ne veut pas que le rendu du volumes des lampes modifient le Z-Buffer, cela fausserait sérieusement les calculs d'éclairage). OpenGL permet d'activer/désactiver l'écriture dans le Z-Buffer via la fonction glDepthMask
.
Pour dessiner les volumes des lampes, vous pouvez utiliser le mesh de sphère présent sur le drive des ressources.
Il est conseillé de commencer par rendre les lampes locales en utilisant un triangle couvrant tout l'écran (comme pour le soleil), avant de s'attaquer aux volumes/depth test, afin de pouvoir valider que le shading et blending marchent bien en isolation.
Le drive des ressources contient une scène de test utilisant toute les fonctionnalités du moteur (et plus): bistro.glb
.
Si vous voulez vérifier que tout marche correctement, c'est un bon benchmark. Il peut aussi être intéressant de voir la différence en performance entre l'implémentation forward naïve donnée, et votre deferred shader.
La scène ne contient pas de lampes (et le loader fourni ne les supporte pas) vous devrez donc ajouter les votres, en code.