Initiation à la 3D avec Direct3D 11 - Eternal Coding - HTML5 / Windows / Kinect / 3D development - Site Home - MSDN Blogs

Initiation à la 3D avec Direct3D 11


 

Initiation à la 3D avec Direct3D 11

  • Comments 2

Pour faire de la 3D sur PC (ou n’importe où d’ailleurs) il est nécessaire de bien comprendre les concepts de base afin de pouvoir par la suite construire nos applications sur des bases solides.

Le but de cet article va donc être de mettre en place ces concepts puis de les implémenter simplement avec Direct3D 11.

Le projet final est disponible ici : http://www.catuhe.com/msdn/InitiationDirect3D 1.zip

Du vertex au pixel

Le premier élément que nous devons connaitre est le vertex (et son pluriel : les vertices). Un vertex est un point dans l’espace 3D. On peut donc le représenter dans sa version la plus simple par un vecteur à 3 valeurs : x, y et z.

Tout ce que l’on peut voir dans une application 3D est donc intégralement constitué par un ensemble de vertices qui définissent l’ossature des objets à représenter.

clip_image002

Figure 1. Les vertices d'une sphère

Toutefois, ces vertices ne sont pas les seuls acteurs importants. En effet, pour qu’un objet soit totalement défini, on doit lui associer des faces.

Les faces sont des triangles dont chaque sommet est un vertex. En effet pour délimiter le volume d’un objet (que l’on appelle mesh en anglais), nous allons assembler des surfaces 2D. Or il se trouve que la plus simple forme géométrique définissant une surface 2D est le triangle.

Les faces sont donc aussi des triplettes de valeurs i1, i2, i3 qui définissent le numéro dans la liste des vertices de chaque sommet de la face.

clip_image004

Figure 2. Les faces d'une sphère

En termes de code, la définition d’un simple plan va donc être la suivante :

  1. float[] vertices = new[]
  2.                        {
  3.                            -1.0f, -1.0f, 0f,
  4.                            1.0f, -1.0f, 0f,
  5.                            1.0f, 1.0f, 0f,
  6.                            -1.0f, 1.0f, 0f,
  7.                        };
  8. short[] faces = new[]
  9.                     {
  10.                         (short)0, (short)1, (short)2,
  11.                         (short)0, (short)2, (short)3
  12.                     };

Un plan est donc constitué de 4 vertices distincts et de 2 faces reliant ces vertices (soit 6 indices).

Le but d’une application 3D va donc être de passer de ces données 3D vers un seul objectif final : les pixels. En effet, elle devra transformer toutes ces informations en un unique tableau à deux dimensions donc chaque case sera une couleur. Ce tableau fera la taille de l’écran [Largeur x Hauteur] et chaque couleur servira alors à allumer un point sur l’écran.

Nous allons donc découper notre approche en deux temps. Tout d’abord nous allons voir comment avec une liste de vertices et une liste de faces nous allons pouvoir arriver à une liste de pixels (donc à des vecteurs 2D x,y). Puis nous verrons comment attribuer une couleur à chaque pixel.

Faire son cinéma

Pour comprendre la transition de l’espace R3 (la 3D) à l’espace R2 (la 2D, et donc la dalle de l’écran), nous allons prendre l’analogie du cinéma. Nous allons nous glisser dans la peau d’un réalisateur d’un film publicitaire qui veut filmer une balle de tennis pour en faire la promotion.

Le monde global

Dans un premier temps, il faut considérer que la balle est rangée dans son étui. Pour la filmer, il faut donc l’amener sur le plateau de tournage.

En 3D, nous appelons cela la transformation monde (World) c’est-à-dire le fait de prendre les coordonnées d’un objet et de les déplacer vers le monde de la scène. En effet pour construire une scène (ou filmer une publicité), nous allons faire appel à plusieurs objets qui à l’origine sont tous définis avec des coordonnées centrées sur le 0 du monde (soit [0, 0, 0]). Or si nous ne faisons rien, ils seront tous empilés au même endroit occupant tous le même espace (ce qui, il faut bien l’avouer est largement plus facile à faire en informatique qu’au cinéma). Il faut donc les déplacer (voire les retailler ou les tourner) pour le mettre la ou ils sont censés être.

Cette transformation monde (comme toutes les transformations en 3D) s’effectue par le biais d’une matrice. Une matrice est la représentation d’une transformation géométrique.

Ainsi la multiplication d’un vecteur par une matrice donne un vecteur modifié par la transformation incluse dans la matrice.

Les matrices sont multipliables entre elles (associatives) et le résultat de la multiplication de deux matrices donnent une nouvelle matrice portant les deux transformations.

Par exemple, imaginons deux matrices M1 et M2. Admettons que M1 soit une matrice de translation (c’est-à-dire qu’elle déplace les vecteurs qu’elle modifie) et que M2 soit une matrice de rotation (c’est-à-dire qu’elle fait tourner dans l’espace les vecteurs qu’elle modifie). Le résultat de M1 x M2 donne une matrice qui applique une translation PUIS une rotation.

Donc en ce qui concerne notre matrice monde, nous pourrons mettre dedans les transformations nécessaires pour amener notre objet de son origine vers sa position finale dans la scène (avec donc la conjonction de translations, rotations et zooms nécessaires).

Le point de vue de la caméra

Une fois que les objets sont à leur position définitive dans la scène, nous allons appliquer une seconde matrice pour calculer leur position du point de vue la caméra du réalisateur. En effet, nous effectuerons nos rendus depuis un point de vue donné que nous appellerons la « caméra » comme pour le cinéma car l’analogie est parfaite.

Cette seconde matrice s’appelle la matrice de vue car finalement elle définit le point de vue. Elle se caractérise par essentiellement une position et une coordonnée cible toute deux sous la forme d’un vecteur R3.

La projection

Finalement, il reste une troisième matrice à définir que l’on appelle la matrice de projection. Son rôle est assez particulier dans le sens où elle doit transformer les vecteurs depuis l’espace tridimensionnel vers l’espace plat de l’écran.

Ainsi elle assure le passage d’un vecteur 3 : (x1, y1, z1) à un vecteur 2 : (x2, y2) en tenant compte de la taille de la zone de rendu, de la focale de la caméra et du ratio largeur / hauteur.

Pipeline géométrique

Au final donc chaque vertex va donc subir la transformation suivante :

Matricefinale = Matricemonde * Matricede vue * Matricede projection

La formule finale sera donc :

Pixel = Vertex * Matricefinale

Les shaders ou comment programmer son GPU ?

Maintenant que nous sommes au point sur la théorie nous allons voir comment demander à nos chers amis les GPU (Graphics Processing Unit, les cerveaux corvéables à souhait de nos cartes graphiques modernes) de faire le travail pour nous.

Au passage, il est intéressant de noter que si nous déléguons ce travail au GPU c’est pour la bonne raison que ces derniers sont faits pour les tâches répétitives (et c’est parfaitement le cas ici ou le même traitement doit être effectué sur chaque vertex). Ce sont en effet des processeurs super-scalaires composés de très nombreuses unités de calcul en parallèle.

Pour programmer les GPU, nous allons utiliser un langage de haut niveau propre à Direct Graphics qui s’appelle le Microsoft HLSL (High Level Shader Language). Ce langage proche du C/C++ permet de construire des shaders qui sont les unités d’exécution de base du GPU.

Il existe plusieurs catégories de shaders en fonction de la tâche à réaliser.

Vertex shader

La première catégorie de shader qui va nous intéresser est constituée par les vertex shaders.

Ce sont eux qui sont appelés les premiers et qui ont en charge le traitement des vertices pour en faire des pixels. De ce fait ce sont eux qui vont faire la transformation géométrique avec notre Matricefinale.

  1. cbuffer globals
  2. {
  3.     matrix finalMatrix;
  4. }
  5.  
  6. struct VS_IN
  7. {
  8.     float3 pos : POSITION;
  9. };
  10.  
  11. struct PS_IN
  12. {
  13.     float4 pos : SV_POSITION;
  14. };
  15.  
  16. // Vertex Shader
  17. PS_IN VS( VS_IN input )
  18. {
  19.     PS_IN output = (PS_IN)0;
  20.     
  21.     output.pos = mul(float4(input.pos, 1), finalMatrix);
  22.     
  23.     return output;
  24. }

Le vertex shader est donc en fait une fonction (qui peut si besoin en appeler d’autres) qui prend en paramètre un vertex (dont nous devons aussi définir le type) et qui retourne un pixel (dont on définit aussi le type).

Pour le moment les types que l’on manipule sont réduits à leur plus simple expression : un vecteur3 en entrée et un vecteur4 en sortie. Bien évidemment par la suite, nous allons enrichir nos types pour transporter plus d’informations dans le pipeline de rendu.

Le shader en tant que tel applique uniquement la multiplication entre le vertex d’entrée et la matrice de transformation que nous avons définie en tant que variable globale. Nous verrons dans un autre article que le travail effectué par les vertex shaders peut s’avérer beaucoup plus complexe.

Pixel shader

Une fois que l’on a notre liste de pixels, il faut bien leur attribuer une couleur. C’est le rôle des pixels shaders qui sont donc appelés une fois que les vertex shaders ont terminés leur travail.

Il est important de noter qu’entre les vertex shaders et les pixels shaders il y a une étape supplémentaire qui est réalisée par les GPU : la rasterization.

Cette étape consiste d’une part à clipper les pixels (c’est-à-dire à ne garder que ceux qui sont dans l’écran) et d’autre part à faire l’interpolation nécessaire pour remplir les triangles.

En effet, les vertex shaders ne travaillent que sur les sommets des faces (les vertices donc). Le but du rasterizer est donc d’interpoler les valeurs sur toute la surface de la face.

Finalement, pour chaque pixel ainsi généré, le GPU appellera le pixel shader.

  1. // Pixel Shader
  2. float4 PS( PS_IN input ) : SV_Target
  3. {
  4.     return float4(1, 0, 0, 1);
  5. }

Notre pixel shader prend donc en paramètre un pixel et retourne une couleur. Pour le moment, c’est la même couleur pour chaque pixel. Nous allons rajouter un peu de fun en allant lire dans une texture des informations un peu plus diversifiées.

Pour ce faire, il faut modifier nos vertices pour qu’en plus des coordonnées de position ils transportent des coordonnées de texture (c’est dire le pixel de la texture associé au vertex).

Au niveau de notre vertex shader, nous devons juste faire un passage de cette information vers le pixel (puisque le vertex shader n’a pas d’usage de cette donnée).

Au niveau du pixel shader, nous allons utiliser les coordonnées de textures pour aller lire pour chaque pixel la couleur à retourner dans la texture.

Pour se faire il faut rajouter un sampler (qui finalement n’est qu’un outil pour exprimer la lecture dans une texture) et une variable de type texture.

  1. cbuffer globals
  2. {
  3.     matrix matriceFinale;
  4. }
  5.  
  6. Texture2D yodaTexture;
  7. SamplerState currentSampler
  8. {
  9.     Filter = MIN_MAG_MIP_LINEAR;
  10.     AddressU = Wrap;
  11.     AddressV = Wrap;
  12. };
  13.  
  14. struct VS_IN
  15. {
  16.     float3 pos : POSITION;
  17.     float2 uv : TEXCOORD0;
  18. };
  19.  
  20. struct PS_IN
  21. {
  22.     float4 pos : SV_POSITION;
  23.     float2 uv : TEXCOORD0;
  24. };
  25.  
  26. // Vertex Shader
  27. PS_IN VS( VS_IN input )
  28. {
  29.     PS_IN output = (PS_IN)0;
  30.     
  31.     output.pos = mul(float4(input.pos, 1), matriceFinale);
  32.     output.uv = input.uv;
  33.     
  34.     return output;
  35. }
  36.  
  37. // Pixel Shader
  38. float4 PS( PS_IN input ) : SV_Target
  39. {
  40.     return yodaTexture.Sample(currentSampler, input.uv);
  41. }


Les fichiers .FX

Les fichiers .FX (ou fichiers d’effets) ont pour but de rassembler en un seul endroit à la fois le vertex et le pixel shader.Les déclarations des variables (constantes, types, textures, samplers) sont également embarquées.

De plus, la notion de technique est prise en compte avec la possibilité d’avoir au sein d’une technique plusieurs passes ou chaque passe contient sa configuration avec son vertex et son pixel shader.

Dans notre cas, il nous faudrait juste rajouter ceci à nos shaders :

  1. // Technique
  2. technique10 Render
  3. {
  4.     pass P0
  5.     {
  6.         SetGeometryShader( 0 );
  7.         SetVertexShader( CompileShader( vs_4_0, VS() ) );
  8.         SetPixelShader( CompileShader( ps_4_0, PS() ) );
  9.     }
  10. }

On peut donc constater que notre technique est assez simple avec une seule passe à prendre en compte.

Ce fichier FX sera par la suite chargé par la classe Effect de Direct Graphics qui se chargera de gérer pour nous la tuyauterie (compilation, affectation des variables, affectation des shaders, etc…).

Au passage nous compilons vers les shaders 4.0 (compatible Direct3D 10 donc) car il y a encore assez peu de cartes compatibles Direct3D 11. Or, comme ce dernier peut fonctionner sur une carte graphique Direct3D 10, nous n’allons pas nous en priver.

Mise en œuvre avec Direct3D 11

Pour utiliser DirectX 11 (et sa partie 3D, Direct3D 11), nous allons utiliser le wrapper SlimDX (http://slimdx.org/download.php). Ce projet Open Source constitue un excellent point d’entrée vers DirectX pour les développeurs .NET. En effet, DirectX est une API COM et ne propose pas nativement de wrapper pour le monde managed. Microsoft fournit toutefois un wrapper nommé Windows API Code Pack (http://archive.msdn.microsoft.com/WindowsAPICodePack) même s’il s’avère moins pratique que SlimDX pour la partie 3D.

Initialisation

L’initialisation de Direct3D 11 va nécessiter la mise en place de 4 variables importantes :

  • Le device qui va être notre intermédiaire avec le driver de la carte graphique
  • La swap chain qui définit le mécanisme visant à ramener l’image produite sur la carte graphique vers la fenêtre Windows que nous utilisons comme support
  • Le back-buffer (buffer de travail) qui est l’espace mémoire de la carte graphique ou sera produite l’image
  • La vue de rendu qui est la vue sur le back-buffer et qui permet de le manipuler (en effet, en Direct3D 11, on ne manipule pas directement les buffers mais on passe par des vues ce qui permet d’avoir un seul buffer et plusieurs vues dessus en fonction du besoin)

Pour créer tout ce petit monde, nous allons donc mettre le code suivant en place :

  1. // Cr?ation de notre device (on accepte les cartes dx10 ou plus)
  2. FeatureLevel[] levels = {
  3.                             FeatureLevel.Level_11_0,
  4.                             FeatureLevel.Level_10_1,
  5.                             FeatureLevel.Level_10_0
  6.                         };
  7.  
  8. // D?finition de notre swap chain
  9. SwapChainDescription desc = new SwapChainDescription();
  10. desc.BufferCount = 1;
  11. desc.Usage = Usage.BackBuffer | Usage.RenderTargetOutput;
  12. desc.ModeDescription = new ModeDescription(0, 0, new Rational(0, 0), Format.R8G8B8A8_UNorm);
  13. desc.SampleDescription = new SampleDescription(1, 0);
  14. desc.OutputHandle = Handle;
  15. desc.IsWindowed = true;
  16. desc.SwapEffect = SwapEffect.Discard;
  17.  
  18. Device.CreateWithSwapChain(DriverType.Hardware, DeviceCreationFlags.None, levels, desc, out device11, out swapChain);
  19.  
  20. // R?cup?ration du buffer de travail
  21. backBuffer = Resource.FromSwapChain<Texture2D>(swapChain, 0);
  22.  
  23. // Definition de la vue de rendu
  24. renderTargetView = new RenderTargetView(device11, backBuffer);
  25. device11.ImmediateContext.OutputMerger.SetTargets(renderTargetView);
  26. device11.ImmediateContext.Rasterizer.SetViewports(new Viewport(0, 0, ClientSize.Width, ClientSize.Height, 0.0f, 1.0f));

La partie importante se situe au niveau de la description de la swap chain. Ainsi nous indiquons ici que nous voulons une swap chain sur le back-buffer, sans anti-aliasing (SampleDescription avec un seul sample et de qualité = 0) en mode fenêtré et sans conservation du buffer de travail précédent (SwapEffect.Discard).

Notons également au passage la déclaration des FeatureLevels qui permet d’indiquer que nous acceptons les cartes Direct3D 10, 10.1 et 11.

Le but ici étant de faire un premier rendu, nous ne rentrerons pas en détail dans le reste des paramètres qui ont été initialisés à leur valeur par défaut (ou du moins leur valeur standard).

Mise en place de nos shaders

Pour utiliser nos shaders, nous allons donc utiliser une instance de la classe Effect :

  1. using (ShaderBytecode byteCode = ShaderBytecode.CompileFromFile("Effet.fx", "bidon", "fx_5_0", ShaderFlags.OptimizationLevel3, EffectFlags.None))
  2. {
  3.     effect = new Effect(device11, byteCode);
  4. }
  5.  
  6. var technique = effect.GetTechniqueByIndex(0);
  7. var pass = technique.GetPassByIndex(0);
  8. layout = new InputLayout(device11, pass.Description.Signature, new[] {
  9.                     new InputElement("POSITION", 0, Format.R32G32B32_Float, 0, 0),
  10.                     new InputElement("TEXCOORD", 0, Format.R32G32_Float, 12, 0)
  11.     });

La compilation de l’effet se fait donc via le constructeur de Effect, qui prend le byte code issu de la méthode statique ShaderBytecode.CompileFromFile. Cette dernière prend notamment en paramètre le profil de compilation. Ici nous ciblons des shaders pour Direct3D 11, donc le profil « fx_5_0 » (pour Direct3D 10, nous aurions choisi « fx_4_0 »).

Par la suite, afin de décrire au pipeline de Direct3D comment seront formés nos vertices, nous produisons un layout qui caractérise la structure d’un vertex.

Au passage, il est important de noter que nous passons à ce layout, une référence vers la première passe de notre technique afin de bien valider que notre effet est compatible avec la structure de nos vertices.

Préparation des données géométriques

Pour stocker et utiliser nos données géométriques, nous allons produire des buffers au sein de la carte graphique : un vertex buffer et un index buffer. Le premier contiendra donc les vertices (avec comme vu dans notre layout une position en 3D et une coordonnée de texture). Le second quant à lui portera la liste des indices de faces.

Ce qui en termes de code, donne :

  1. // Cr?ation du vertex buffer
  2. var stream = new DataStream(4 * vertexSize, true, true);
  3. stream.WriteRange(vertices);
  4. stream.Position = 0;
  5.  
  6. var vertexBuffer = new Buffer(device11, stream, new BufferDescription
  7. {
  8.     BindFlags = BindFlags.VertexBuffer,
  9.     CpuAccessFlags = CpuAccessFlags.None,
  10.     OptionFlags = ResourceOptionFlags.None,
  11.     SizeInBytes = (int)stream.Length,
  12.     Usage = ResourceUsage.Default
  13. });
  14. stream.Dispose();
  15.  
  16. // Index buffer
  17. stream = new DataStream(6 * 2, true, true);
  18. stream.WriteRange(faces);
  19. stream.Position = 0;
  20.  
  21. var indices = new Buffer(device11, stream, new BufferDescription
  22. {
  23.     BindFlags = BindFlags.IndexBuffer,
  24.     CpuAccessFlags = CpuAccessFlags.None,
  25.     OptionFlags = ResourceOptionFlags.None,
  26.     SizeInBytes = (int)stream.Length,
  27.     Usage = ResourceUsage.Default
  28. });
  29. stream.Dispose();

On peut constater que les deux buffers sont créés de manière à ce que le CPU n’y accède pas. Cela indique donc que c’est dans la mémoire de la carte graphique que l’on stockera ces données (Le plus performant pour le GPU). Le seul point qui les différencie est le type de binding (VerteBuffer ou IndexBuffer).

Par la suite, il suffit de donner les deux buffers au contexte de notre device :

  1. // Transfert vers le device
  2. device11.ImmediateContext.InputAssembler.InputLayout = layout;
  3. device11.ImmediateContext.InputAssembler.PrimitiveTopology = PrimitiveTopology.TriangleList;
  4. device11.ImmediateContext.InputAssembler.SetVertexBuffers(0, new VertexBufferBinding(vertexBuffer, vertexSize, 0));
  5. device11.ImmediateContext.InputAssembler.SetIndexBuffer(indices, Format.R16_UInt, 0);

En plus de transférer nos buffers, nous indiquons le layout des vertices et que les indices représentent une topologie de type Triangle.

Affectation des constantes des shaders

Pour fonctionner, en plus des buffers, nos shaders attendent aussi la définition de constantes et notamment la MatriceFinale et la texture à utiliser.

Nous passons bien sûr par notre instance de la classe Effect pour transférer nos constantes :

  1. // Texture
  2. Texture2D texture2D = Texture2D.FromFile(device11, "yoda.jpg");
  3. ShaderResourceView view = new ShaderResourceView(device11, texture2D);
  4.  
  5. effect.GetVariableByName("yodaTexture").AsResource().SetResource(view);
  6.  
  7. RasterizerStateDescription rasterizerStateDescription = new RasterizerStateDescription {CullMode = CullMode.None, FillMode = FillMode.Solid};
  8.  
  9. device11.ImmediateContext.Rasterizer.State = RasterizerState.FromDescription(device11, rasterizerStateDescription);
  10.  
  11. // Matrices
  12. Matrix worldMatrix = Matrix.RotationY(0.5f);
  13. Matrix viewMatrix = Matrix.Translation(0, 0, 5.0f);
  14. const float fov = 0.8f;
  15. Matrix projectionMatrix = Matrix.PerspectiveFovLH(fov, ClientSize.Width / (float)ClientSize.Height, 0.1f, 1000.0f);
  16.  
  17. effect.GetVariableByName("matriceFinale").AsMatrix().SetMatrix(worldMatrix * viewMatrix * projectionMatrix);

Le calcul de notre MatriceFinale se fait donc comme indiqué plus haut en multipliant les trois matrices de base. Ces dernières sont construites grâce aux méthodes statiques de la classe Matrix.

Et c’est via la méthode GetVariableByName que l’on peut accéder aux constantes des shaders.

Rendu final

Nous avons donc deux buffers pour notre géométrie stockés dans la mémoire de la carte graphique, nos shaders qui sont compilés et prêts à l’emploi avec leurs constantes affectées.

Il ne nous reste donc plus qu’à donner l’ordre de rendu :

  1. // Rendu
  2. device11.ImmediateContext.ClearRenderTargetView(renderTargetView, new Color4(1.0f, 0, 0, 1.0f));
  3. effect.GetTechniqueByIndex(0).GetPassByIndex(0).Apply(device11.ImmediateContext);
  4. device11.ImmediateContext.DrawIndexed(6, 0, 0);
  5. swapChain.Present(0, PresentFlags.None);

Le processus est le suivant :

  • On efface le buffer de travail
  • On récupère la technique qui nous intéresse puis sa première passe à qui l’on demande via la méthode Apply d’affecter les shaders et de transmettre les constantes
  • Puis le contexte du device lance l’ordre de rendu de 6 primitives. Le type des primitives ayant été défini lors de l’affectation du vertex buffer
  • Finalement, la méthode swapChain.Present transfère l’image produite vers la fenêtre de notre application

clip_image005

Figure 3. Le superbe rendu final avec Direct3D 11

Conclusion

Nous avons donc désormais les bases nécessaires à la mise en place de rendus de plus haut niveau. Grâce aux shaders et à la gestion de notre fichier .fx, nous pouvons imaginer toutes sortes de comportements dont par exemple, les célèbres bumps et autres joyeusetés.

Ce que nous avons réalisé avec un simple plan fonctionne strictement de la même manière avec par exemple une ville entièrement modélisée. En effet, notre système prend juste une suite de vertices et de faces. Bien sûr il faudra alors travailler sur des techniques d’optimisations mais le concept restera le même.

N’hésitez donc pas à jouer avec l’exemple de code fournit avec cet article afin de bien comprendre comment tout cela s’imbrique.

Leave a Comment
  • Please add 1 and 1 and type the answer here:
  • Post
  • Bravo!! Ca fait un moment que je cherche des infos claires sur la 3D, les shaders et D3D11 et grâce à vous c'est fait. Un énorme merci pour votre article !

  • Idem,

    merci pour cette explication simple ...

    J'espère que vous continuerez ce genre de tuto

    Encore merci

Page 1 of 1 (2 items)