XNA 4.0 – 1ère partie – Deferred Rendering et G-Buffer

Introduction

 

Aujourd’hui, je vais vous parler d’une technique que j’ai découverte cet été: le Deferred Rendering. Cette technique introduit d’une manière assez différente la programmation du rendu de lumières sur des objets d’une scène en 3 dimensions par rapport à nos habitudes. Imaginée en 1988 par Micheal Deering, le rendu différé n’a été utilisé dans les jeux-vidéos professionnels qu’assez récemment, faute de matériel assez puissant pour le mettre en place. Des jeux comme Halo : Reach, Killzone 3 ou encore Crysis 2 utilise ce type de rendu.

La problématique de l’éclairage en temps réel

 

Dans les jeux modernes, il est très courant que les objets 3D soient éclairés par plusieurs lumières à la fois. Sauf qu’avec un rendu classique (Forward Rendering), l’éclairage multiple est assez lourd au niveau des ressources. Que va donc nous apporter le rendu différé par rapport à un rendu classique ? Et bien, commençons par le début et détaillons comment fonctionne ce fameux rendu classique.

Le but est ici de dessiner un objet 3D à l’écran, celui-ci étant illuminé par plusieurs sources lumineuses. Il y a deux façons d’effectuer cette tâche avec le Forward Rendering.

La première façon est le Single-Pass Lighting, ou éclairage en simple passe. Ici, chaque objet est dessiné, et toutes les lumières affectant cet objet sont calculées dans un seul shader. Mais ce n’est pas sans inconvénients : vu que tout se passe dans un seul shader, on devient vite limité en nombre d’instructions et donc en nombre de lumières qui peuvent affecter cet objet (8 lumières maximum avec la version 3.0 du Shader Model). Aussi, il est possible que des cycles de calcul soient gaspillés car on va calculer la lumière sur des surfaces qui ne seront pas nécessairement visible à l’écran. Au final, on utilise le Single-Pass Lighting principalement dans des environnements nécessitant peu de lumières, comme un extérieur par exemple.

La seconde méthode est le Multiple-Pass Lighting. Ici, on prend un peu le problème à l’envers. Pour chaque lumière et pour chaque objet de notre scène, si l’objet est affecté par la lumière, alors on le dessine avec les paramètres de la lumière courante. On a résolu le problème du nombre d’instruction et du nombre de lumières, puisque théoriquement on peut en dessiner à l’infini, sauf qu’un autre inconvénient majeur apparaît : le nombre de batch explose. Dans le pire des cas, ce nombre sera égal à N objets x N lumières, ce qui est inadmissible dans un jeu professionnel, sans parler des performances qui risquent d’être pitoyables. Et Il reste toujours le problème des surfaces calculées sans qu’elles soient affichées à l’écran.

C’est là que Deferred Rendering rentre en jeu. Plus question de tout dessiner d’un coup, on procède en étape. Premièrement, on va dessiner nos objets sans aucun calcul de lumière. On va plutôt stocker dans des textures particulières les attributs nécessaires au calcul de la lumière pour chaque pixel dessiné, comme la texture de l’objet, la normale, la position, etc… Une fois cette étape terminée, on va dessiner chaque lumière comme un effet de post-processing à l’aide des attributs précédemment récupérés. De cette façon, on réduit le nombre de batch à N objets + N lumières, et on calcule la lumière uniquement pour les surfaces visibles.

Le G-Buffer

 

Qu’est ce que c’est ?

 

Ci-dessus, j’ai parlé de textures particulières dans lesquelles on stocke différentes informations sur les objets. Ces textures particulières s’appellent des RenderTarget. Plusieurs RenderTarget sont nécessaires pour stocker toutes ces informations. Le G-Buffer est simplement la composition de ces différents RenderTarget.

 

Choix du format des RenderTarget

 

En XNA (et par extension en DirectX), un RenderTarget est une sorte d’image sur laquelle on dessine chaque pixel un par un. Un RenderTarget est donc défini par un format, c’est à dire que chaque pixel sera codé sur un certain nombre de bits et de canaux. Par exemple, on utilisera la format SurfaceFormat.Color  (RGBA) pour stocker 4 informations codées sur 8 bits.

Dans notre G-Buffer nous avons besoin de stocker:

  • La position: On pourrait faire simple et stocker les coordonnées X, Y et Z dans une surface Color, laissant un canal libre pour stocker une autre information. Mais le problème, c’est que chaque coordonnée serait codée sur 8 bits, impliquant 256 étages de valeur. Ce n’est clairement pas assez précis. On va en fait plutôt se servir d’un format flottant, comme SurfaceFormat.Single. Ce format nous permet de stocker un nombre flottant sur 32 bits. On stockera la profondeur (le Z) du pixel. Puisque nous avons accès aux coordonnées X et Y en screen space, il nous sera possible de reconstituer la position exacte du pixel. avec une excellente précision.
  • La normale: Plusieurs possibilités se présentent à nous. La première, SurfaceFormat.Color pour stocker le X, Y et Z de la normale, laissant un canal libre pour une information supplémentaire, mais la précision pourrait manquer dans certaines situations. La deuxième, SurfaceFormat.Rgba1010102. Pareil que SurfaceFormat.Color, sauf que le R, G et B sont codés sur 10 bits, ce qui nous amène à 1024 valeurs disponibles pour stocker chaque coordonnées, soit 4 fois plus de précision ! La qualité est relativement bonne avec ce format. La troisième possibilité est celle que nous choisirons. Elle impose d’utiliser le format SurfaceFormat.HalfVector2, qui contient deux canaux flottants, chacun codé sur 16 bits. La précision est excellente avec ce format, mais on ne peut stocker que deux coordonnées. On va se servir du fait que vu qu’un vecteur normal est en principe normalisé (et donc de longueur 1), on peut retrouver le Z de cette manière: Z = sqrt(1 – X² – Y²).
  • La texture : Enfin il nous faut stocker la couleur de nos objets pour chaque pixel. Le choix est évident et on va stocker la couleur dans une surface de format SurfaceFormat.Color.

Voici un petit tableau récapitulatif de nos choix du format des RenderTarget:

 

8 bits

8 bits

8 bits

8 bits

Color

R

G

B

SpecPow

Depth

Z

Z

Z

Z

Normals

X

X

Y

Y

Ici, on s’aperçoit qu’il manque certaines informations liée à la lumière, comme l’intensité de la la lumière spéculaire. Je me limite ici volontairement dans le but de garder l’article plus simple et que le tout reste très performant.

Dessiner dans le G-Buffer

 

Maintenant que nos choix sont faits, il ne nous reste plus qu’à coder tout ça ! On aimerait bien afficher des objets à l’écran !

On va commencer par créer une classe qui s’occupera d’effectuer un rendu de type différé. Pour ma part, elle s’appelle DeferredRenderer. Dans cette classe, j’ai déclaré 3 RenderTarget :

 

 privateRenderTarget2D _diffuseRT;

 privateRenderTarget2D _normalRT;

 privateRenderTarget2D _depthRT;

_diffuseRT stockera la couleur des objets, _normalRT la normale des objets et _depthRT la profondeur.

Il faut maintenant instancier ces RenderTarget, pour notamment définir leur format. Mon DeferredRenderer est un DrawableGameComponent, je vais donc écrire ce bout code dans la méthode Initialize(), après le base.Initialize() :

_diffuseRT = newRenderTarget2D(GraphicsDevice,                                            GraphicsDevice.PresentationParameters.BackBufferWidth,                                           GraphicsDevice.PresentationParameters.BackBufferHeight,

                                            false,

                                            SurfaceFormat.Color,

                                            DepthFormat.Depth24Stencil8);

 

_normalRT = newRenderTarget2D(GraphicsDevice,                                GraphicsDevice.PresentationParameters.BackBufferWidth,                                         GraphicsDevice.PresentationParameters.BackBufferHeight,

                                            false,

                                            SurfaceFormat.HalfVector2,

                                            DepthFormat.Depth24Stencil8);

 

_depthRT = newRenderTarget2D(GraphicsDevice,                                        GraphicsDevice.PresentationParameters.BackBufferWidth,                                          GraphicsDevice.PresentationParameters.BackBufferHeight,

                                            false,

                                            SurfaceFormat.Single,

                                            DepthFormat.Depth24Stencil8);

Nous pouvons voir dans le constructeur que nous avons initialisé le format de nos RenderTarget aux valeurs choisies plus haut.

On va maintenant compléter la méthode Draw de notre DeferredRenderer. Il faut en effet assigner les 3 RT (RenderTarget) sur lesquels on va dessiner. Il est en effet possible de dessiner jusqu’à 4 surfaces en même temps à l’aide d’une fonctionnalité de nos cartes graphiques qui s’appelle le Multiple Render Target (MRT). Voici comment on va procéder :

// Set GBuffer on the GraphicsDevice

GraphicsDevice.SetRenderTargets(_diffuseRT, _normalRT, _depthRT);

 

// Clear GBuffer

 

// Draw Scene

 

// Resolve GBuffer

GraphicsDevice.SetRenderTarget(null);

La première ligne de code permet de mettre en place notre G-Buffer sur la carte graphique, en gros, de lui dire que l’on va dessiner sur ces surfaces. Ensuite, il faudra penser à effacer notre G-Buffer pour que la nouvelle image puisse être dessinée correctement, dessiner la scène, et enfin récupérer notre G-Buffer pour pouvoir l’utiliser par la suite pour la lumière.

Effaçons maintenant notre G-Buffer. Il existe bien la méthode Clear(…) de GraphicsDevice, mais étant donné que nous nous servons de la MRT, elle sera ici inefficace. Nous allons donc simplement écrire un shader que remettra à zéro notre G-Buffer. On ne rentrera pas forcément dans les détails, c’est pourquoi je vous demanderai de télécharger le code source fournit en fin de la 2ème partie de cet article. Voici le shader en question :

struct PStoFrame

{

    float4 Color : COLOR0;   

    float4 Normal : COLOR1;

    float4 Depth : COLOR2;   

};

 

void ClearVS(infloat3 positionWS : POSITION0, outfloat4 positionCS : POSITION0)

{

    //positionCS = float4(positionWS, 1);

    positionCS.xyz = positionWS;

    positionCS.w = 1;

}

 

PStoFrame ClearPS()

{

    PStoFrame output;

 

    // position

    output.Depth = 1.0;

 

    // normal

    output.Normal.rg = 0.0f;

    output.Normal.ba = 0.0f;

 

    // diffuse

    output.Color.rgb = 0.0f;

 

    //spec pow

    output.Color.a = 0.0;

 

    return output;

}

 

technique ClearGBuffer

{

    pass Pass1

    {

        VertexShader = compile vs_3_0 ClearVS();

        PixelShader = compile ps_3_0 ClearPS();

    }

}

Je vais m’arrêter rapidement sur la structure que j’ai appelé PStoFrame. Elle est relativement importante car c’est elle qui nous permet de dessiner sur plusieurs surfaces à la fois grâce à la sémantique COLORX ou X est valeur comprise entre 0 et 3. Veillez à bien faire attention à ce que les numéros des sémantiques correspondent à l’ordre dans lequel on assigne nos RT sur le GraphicsDevice.

On passe au pixel shader (ClearPS), dans lequel l’effacement de notre G-Buffer s’effectue. Pour chaque pixel, on va insérer une valeur de 1 dans le RT qui stocke la profondeur, 1 représentant en fait le far plane de la caméra, et 0 le near plane. Ensuite, on va mettre à 0 le X et le Y de notre normale. On est obligé aussi d’assigner une valeur à B et A bien qu’il ne soit pas utilisé, le shader ne compile pas sinon. Enfin, on efface le RT qui stocke les textures avec une couleur noir. Voilà le shader écrit, il nous faut maintenant appelé ce shader dans le code et utiliser ses fonctionnalités :

privateEffect _clearGBufferShader;

protectedoverridevoid LoadContent()

{

    _clearGBufferShader = Game.Content.Load<Effect>(« ClearGBuffer »);

 

    base.LoadContent();

}

Et dans le Draw, à la place du commentaire Clear GBuffer :

_clearGBufferShader.Techniques[« ClearGBuffer »].Passes[0].Apply();

_quadRenderer.Render(Vector2.One * -1, Vector2.One, GraphicsDevice);

La variable _quadRenderer est un objet qui nous permet de dessiner sur un rectangle, indispensable avec le DeferredRendering.

Maintenant, il faut dessiner un objet à l’écran. Pour cela, nous avons besoin d’un shader qui lui est dédié. Le voici:

float4x4 World;

float4x4 View;

float4x4 Projection;

 

float FarPlane;

 

Texture Diffuse;

sampler DiffuseSampler = sampler_state

{

    Texture = <Diffuse>;

    MIPFILTER = LINEAR;

    MINFILTER = LINEAR;

    MAGFILTER = LINEAR;

    AddressU = Wrap;

    AddressV = Wrap;

    MaxAnisotropy = 1;

};

 

struct CodeToVS

{

    float4 Position : POSITION0;

    float3 Normal : NORMAL0;

    float2 TexCoord : TEXCOORD0;

};

 

struct VStoPS

{

    float4 Position : POSITION0;

    float3 NormalVS : TEXCOORD0;

    float Depth: TEXCOORD1;

    float2 TexCoord : TEXCOORD2;

};

 

struct PStoFrame

{

    float4 Color : COLOR0;   

    float4 Normal : COLOR1;

    float4 Depth : COLOR2;  

};

 

VStoPS GBufferVS(CodeToVS IN)

{

    VStoPS OUT = (VStoPS)0;

 

    float4x4 WorldView = mul(World, View);

 

    float4 viewPosition = mul(IN.Position, WorldView);

    OUT.Position = mul(viewPosition, Projection);

 

    float3x3 WV = (float3x3)WorldView;

    OUT.NormalVS = mul(IN.Normal, WV);

 

    OUT.Depth = viewPosition.z;

    OUT.TexCoord = IN.TexCoord;

 

    return OUT;

}

 

PStoFrame GBufferPS(VStoPS IN)

{

    PStoFrame OUT;

 

    // position

    OUT.Depth = -IN.Depth / FarPlane;

 

    // normal

    IN.NormalVS = normalize(IN.NormalVS);

    OUT.Normal.xy = IN.NormalVS.xy;

    OUT.Normal.zw = 0.0f;

 

    // diffuse

    OUT.Color.rgb = tex2D(DiffuseSampler, IN.TexCoord).rgb;

 

    //spec pow

    OUT.Color.a = 1.0;

 

    return OUT;

}

 

technique GBuffer

{

    pass Pass1

    {

        VertexShader = compile vs_3_0 GBufferVS();

        PixelShader = compile ps_3_0 GBufferPS();

    }

}

Prenons le temps de l’expliquer. On va commencer d’ailleurs par le Vertex Shader (GBufferVS). Il va être plus précis pour nous de travailler directement dans le View Space, notamment pour la calcul de la position. C’est pour cela qu’on passe dans le Pixel Shader des valeurs en View Space, comme viewPosition ou normalVS. Donc dans ce Vertex Shader, rien d’exceptionnel, on transforme les vertices de nos objets avec les matrices habituelles, la différences étant dans le fait qu’on travaille dans le View Space.

Passons au Pixel Shader. Première chose, on va écrire dans le RT contenant la profondeur du pixel. Pour garder un maximum de précision, il nous faut une valeur entre 0 et 1. Et ça tombe bien, on travaille dans le View Space, on peut donc avoir une profondeur linéaire, simplement en divisant la profondeur du pixel par la valeur du far plane de la caméra. Il ne faut pas oublier d’inverser le signe, puisque en XNA, le repère est right-handed. Ensuite, on normalise la normale du pixel, et on stocke juste le X et le Y dans le RT s’occupant des normales. Et enfin, on récupère la couleur de la texture pour ce pixel, et on le stocke dans le RT contenant la couleur. Nous avons donc au final nos 3 RT contenant les informations nécessaires au calcul de la lumière, comme ceci :

image

En haut à gauche, le _diffuseRT contenant les couleurs de la scène. En bas à gauche se trouve _depthRt contenant la profondeur la scène. Enfin, en bas à droite, _normalRT et ses normales avec juste le X et le Y.

Conclusion partielle

 

J’avais dans l’idée de faire uniquement un seul article pour traiter du Deferred Rendering, mais je viens de m’apercevoir que c’est relativement long. C’est pourquoi je vais découper ce post en 2 parties. Voici donc la fin de cette première partie, dans laquelle nous avons vu qu’est ce qu’était le Deferred Redering et comment le mettre en place en XNA. Dans la deuxième partie, nous mettrons un peu de lumière dans tout ça !

Pour accéder à la deuxième partie de cet article, cliquez ICI.

Publié le 5 février 2011, dans 3D, XNA 4.0, et tagué , , , , , . Bookmarquez ce permalien. Poster un commentaire.

Votre commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l’aide de votre compte WordPress.com. Déconnexion /  Changer )

Image Twitter

Vous commentez à l’aide de votre compte Twitter. Déconnexion /  Changer )

Photo Facebook

Vous commentez à l’aide de votre compte Facebook. Déconnexion /  Changer )

Connexion à %s

%d blogueurs aiment cette page :