Le point de vue du développeur.
Paru le 22 décembre 2006
Guillaume Randon – Microsoft Services
Sur cette page
Présentation
L’API
du point de vue du développeur
Application d’exemple
Une autre perspective sur cette application
d’exemple
Conclusion
Présentation
Ce document fait
suite à l’article intitulé «DirectX 10 – Des changements en
profondeur». Cet article traitait du modèle de driver WDDM sur lequel s’appuie cette
version de DirectX et il peut être bon de s’y reporter avant d’attaquer la
lecture de ce document.
DirectX est une API
graphique qui permet d’accéder aux fonctionnalités des cartes graphique. La
version 10 sera disponible sous peu sur Windows Vista. Cette dernière version
est spécialement intéressante car les changements qu’elle induit ne se limitent
pas aux modèles-objets proposés aux développeurs mais touchent en profondeur le
modèle de driver utilisé pour communiquer avec la carte ainsi que les
fonctionnalités de celle-ci.
Dans ce document,
je me propose de décrire plus en profondeur le modèle-objet associé à DirectX
10 et de détailler un exemple de code.
Ce document, bien
qu’étant une introduction à DirectX 10, se destine à des personnes qui sont
déjà familières avec DX9, les Shaders, et le système d’effets de DirectX. Pour
les personnes intéressées par le sujet mais qui n’auraient pas encore eu le
loisir de découvrir ces concepts, ils peuvent se reporter entre autre aux
documents ci-dessous:
DirectX
Managed : Rappel des concepts de bases
Introduction
aux Shaders avec Managed DirectX
DirectX est un SDK
qui couvre aussi les domaines de l’audio ainsi que la gestion des entrées
utilisateur. Dans ce document, je ne décrirais pas ces parties et je me
concentrerais essentiellement sur les fonctionnalités touchant à l’utilisation
des cartes graphiques.
L’API
du point de vue du développeur
Nous allons maintenant
décrire l’API, le modèle-objet en lui-même avec lequel tout développeur va interagir
pour réaliser une application s’appuyant sur DirectX 10. Cette nouvelle version
de l’API, comme pour les précédentes versions abstrait le fonctionnement de la
carte graphique en elle-même sous-forme d’un pipeline. Ce pipeline, dans le cas
de DirectX 10, présente des différences significatives avec celui sous-tendu
par DX9. Par ailleurs, comme nous allons le voir le modèle-objet en lui-même
promeut, et d’une certaine manière requiert une meilleure connaissance du
pipeline en question. En même temps, il permet une utilisation plus flexible de
celui-ci.
Le diagramme
ci-dessous est extrait du document «The Direct3D® 10 System» par David Blythe disponible sur msdn.microsoft.com/directx, il permet
d’avoir une bonne vision du pipeline graphique associé à DX10:
On peut remarquer qu’un
nouveau type de «Shader», le «Geometry Shader» fait son
apparition. Jusqu’à présent nous disposions des «Vertex» et «Pixel
Shaders». Au niveau des «Vertex Shaders» (VS,) on pouvait
jouer sur la position et les attributs d’un vertex donné et ceux vertex après
vertex. Dans le «Pixel Shader» (PS), il était possible d’affecter
les propriétés d’un pixel donné couvert par une de nos primitives (pour plus
d’informations sur ces types de «Shaders» il est possible de ce
reporter à l’article ci-dessous ou à tout ouvrage sur DX9: http://www.microsoft.com/france/msdn/directx/Shaders-avec-Managed-DirectX_1.mspx
).
Jusqu’à présent, le
nombre de «Vertex» était fixe et il était impossible de demander au
GPU de les créer à la volée ou d’émettre de nouvelles primitives. C’est
justement ce que permet de faire le «Geometry Shader». D’une part,
il est possible de travailler sur les positions de tous les sommets d’une
primitive en même temps et ce, en accédant en même temps si nécessaire à des
informations concernant leur voisinage et la façon dont ils sont connectés à
leurs voisins. Cela était impossible auparavant, pour mémoire dans le VS, on
travaille pour ainsi dire point par point sans aucune des informations suscitées.
Ceci peut permettre de manipuler les primitives en tant que telles, par exemple
pour les repositionner. Par ailleurs le «Geometry Shader» (GS) a la
capacité d’émettre de nouvelles primitives. Les performances des cartes sur ce
type de traitement ne sont pas encore bien connues mais cela ouvre au GPU la
possibilité de créer de façon procédurale des portions de géométrie qui seront
affichées à l’écran ou plus généralement d’émettre des données. On remarque par
ailleurs une boite intitulé StreamOutput (SO) en sortie du Geometry Shader (GS).
Le pipeline DX10 dispose d’une fonctionnalité intéressante qui est de sauver le
résultat des étapes antérieures au Rasterizer en mémoire par exemple pour que
ces données soient à nouveau soumises en entrée du pipeline, le tout sans qu’elles
ne soient traitées par le Rasterizer ou le Pixel Shader. C’est important car le
Pixel Shader est par essence une étape coûteuse car il est invoqué pour tous
les pixels couverts par une primitive, à partir du moment où une autre primitive
n’est pas déjà devant celle-ci. En fonction des scènes et du degré de
sophistication des algorithmes qui organisent la scène et effectuent ces appels
aux fonctions de rendu des primitives, cela peut représenter un nombre considérable
de traitements. C’est une des raisons pour lesquelles il est souvent conseillé
pour les scènes d’extérieur d’afficher le ciel en dernier (le ciel par exemple).
En outre cette
possibilité de disposer en mémoire du résultat de cette première partie du
pipeline sans exécuter les dernières étapes de celui-ci est d’autant plus
intéressante qu’une fonctionnalité appelée DrawAuto permet de réinjecter ce
résultat en entrée du pipeline sans aucune intervention du CPU.
Quoi qu’il en soit
on voit que ces spécificités du nouveau pipeline rendent son utilisation
extrêmement flexible. Dans un premier temps, il est possible que pour des
raisons de performance il soit peut-être plus intéressant d’utiliser
essentiellement les GS pour effectuer des opérations sur les sommets des
primitives en travaillant par primitive et en utilisant les informations sur le
voisinage de ces points et finalement sans émettre de primitives
supplémentaires. Mais l’API et le pipeline donnent toute latitude pour utiliser
le GS de façon plus large. S’il s’avère qu’émettre des données s’effectue avec
de bonnes performances, DX10 permet ce type d’emploi du GPU.
J’ai mentionné plus
haut la possibilité d’émettre de nouvelles données à la volée, de même il est
possible d’en supprimer.
Au delà de ce
nouveau type de Shader, les VS et PS gardent eux des fonctionnalités et des
modes de fonctionnement similaires à ce qu’ils étaient avec DX9. Cette
séparation des Shaders en trois catégories bien distinctes peut sembler
surprenante aux personnes qui seraient plus familières avec les Shaders RenderMan
mais finalement cette séparation forte des opérations qui peuvent être
effectuées dans un type de «Shader» ou un autre permet de coller au
plus près au mode de fonctionnement des cartes graphiques en elle-même encore
aujourd’hui.
Après cette présentation
du pipeline en lui-même, voyons un peu plus précisément comment par le biais de
DirectX nous pouvons interagir avec un élément ou un autre de ce pipeline.
L’objet
Device
Cela ne sera pas
une surprise pour les personnes familières avec DX9 mais le composant principal
qui représente au niveau de l’API la carte graphique est l’objet Device. Avant
de commencer à travailler avec DirectX, il est nécessaire d’acquérir une
référence valide sur un objet de ce type. Cet objet sera la base des autres
communications que nous pourrons avoir avec la carte par le biais de l’API.
Dans le cas de DirectX 10 une fois que l’on a acquis un Device, il est
nécessaire d’associer celui-ci explicitement à une ‘Swap Chain’ pour obtenir un
résultat visible à l’écran.
Les tutoriaux qui
accompagnent le SDK sont intéressants car ils mettent bien en valeur ces
étapes. Regardons par exemple le code ci-dessous extrait du tutorial traitant
de la création du Device:
DXGI_SWAP_CHAIN_DESC
sd;
ZeroMemory(
&sd, sizeof(sd) );
sd.BufferCount =
1;
sd.BufferDesc.Width = g_width;
sd.BufferDesc.Height = g_height;
sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
sd.BufferDesc.RefreshRate.Numerator = 60;
sd.BufferDesc.RefreshRate.Denominator = 1;
sd.BufferUsage =
DXGI_USAGE_RENDER_TARGET_OUTPUT;
sd.OutputWindow =
g_hWnd;
sd.SampleDesc.Count = 1;
sd.SampleDesc.Quality = 0;
sd.Windowed =
TRUE;
hr = D3D10CreateDeviceAndSwapChain(
NULL, D3D10_DRIVER_TYPE_REFERENCE, NULL, createDeviceFlags, D3D10_SDK_VERSION,
&sd, &g_pSwapChain,
&g_pd3dDevice );
if( FAILED(hr) )
return hr;
// Create a render target view
ID3D10Texture2D
*pBuffer;
hr =
g_pSwapChain->GetBuffer( 0, __uuidof(
ID3D10Texture2D ), (LPVOID*)&pBuffer );
if( FAILED(hr) )
return hr;
hr =
g_pd3dDevice->CreateRenderTargetView( pBuffer, NULL,
&g_pRenderTargetView );
pBuffer->Release();
if( FAILED(hr) )
return hr;
g_pd3dDevice->OMSetRenderTargets( 1, &g_pRenderTargetView, NULL );
// Setup the viewport
D3D10_VIEWPORT vp;
vp.Width = g_width;
vp.Height = g_height;
vp.MinDepth = 0.0f;
vp.MaxDepth = 1.0f;
vp.TopLeftX = 0;
vp.TopLeftY = 0;
g_pd3dDevice->RSSetViewports( 1, &vp );
On y voit en
particulier la création d’une structure DXGI_SWAP_CHAIN_DESC qui décrit les propriétés
de la Swap Chain avec laquelle on souhaite travailler, l’appel
D3D10CreateDeviceAndSwapChain qui permet à la fois de créer l’objet Device et
de l’attacher à la Swap Chain. Pour finir le travail nécessaire pour obtenir un
résultat visible à l’écran, on voit par ailleurs qu’il est nécessaire de créer
une ressource de type texture qui sera ensuite associée à la dernière étape de
notre pipeline, l’Output Merger (OM) qui en sortie, émet le rendu de notre
scène. Ceci est effectué par le biais de l’appel OMSetRenderTargets après avoir
indiqué que nous souhaitons utiliser le miplevel 0 de la texture en question
comme cible pour nos rendus.
Cette notion de
créer une ressource pour contenir nos rendus est d’une certaine manière
spécifique à DX10 dans le sens ou avec DX9 il n’était pas nécessaire de créer
explicitement une ressource et de l’associer à la dernière étape de notre
pipeline.
Après quelques
temps, ces opérations apparaissent comme extrêmement naturelles. Il est
intéressant de noter que le nom même des fonctions utilisées promeut une
meilleure connaissance du pipeline que précédement, la plupart des noms de ces
fonctions sont pré-fixés avec le nom de l’élément du pipeline avec lequel elles
vont intérargir.
Dans le code
ci-dessus, on voit aussi la création du Viewport que l’on spécifit par l’appel
à la fonction RSSetViewports dont le nom est préfixé par RS puisque le Viewport
est utilisé par le Rasterizer.
Un point qui est
aussi extrêmement agréable avec cette nouvelle déclinaison de DirectX est le
mode de gestion des ressources. Toutes les ressources sont finalement des zones
de mémoire, DirectX permet d’avoir des vues différentes de ces zones de mémoire
en fonction de l’usage à un moment donné que l’on souhaite faire de la
ressource en question. C’est finalement comme si l’on pouvait donner à des
moments différents des types différents à n’importe quelle ressource en
fonction de la manière dont l’on souhaite l’utiliser.
Pour illustrer ce
point, revenons sur ID3D10Texture2D. Dans l’exemple
ci-dessus, nous avons créer une ressource de ce type à partir de la Swap Chain puis
nous avons mappé le mipmap level 0 de cette ressource à une ressource de type
RenderTargetView. Dans le code ci-dessous, nous créons une ressource du même
type à partir d’un fichier préexistant puis nous créons une ShaderResourceView
à partir de celle-ci. Cette vue de notre ressource peut alors être passée à un Shader.
Regardons le code du tutorial 7:
// Load
the Texture
ID3D10Resource
*pRes = NULL;
hr =
D3DX10CreateTextureFromFile( g_pd3dDevice, L"seafloor.dds",
NULL, NULL, &pRes );
if( FAILED(hr) )
return
hr;
pRes->QueryInterface(__uuidof(
ID3D10Texture2D ), (LPVOID*)&g_pTexture );
pRes->Release();
D3D10_TEXTURE2D_DESC desc;
g_pTexture->GetDesc( &desc );
D3D10_SHADER_RESOURCE_VIEW_DESC SRVDesc;
ZeroMemory(
&SRVDesc, sizeof(SRVDesc) );
SRVDesc.Format =
desc.Format;
SRVDesc.ViewDimension = D3D10_SRV_DIMENSION_TEXTURE2D;
SRVDesc.Texture2D.MipLevels = desc.MipLevels;
hr =
g_pd3dDevice->CreateShaderResourceView( g_pTexture, &SRVDesc,
&g_pTextureRV );
if( FAILED(hr) )
return hr;
On voit bien qu’une
seule et même ressource va pouvoir être associée à différentes étapes de notre
pipeline en fonction des besoins de manière extrêment flexible. Par ailleurs,
les GPU étant de plus en plus utilisés pour d’autres types de traitements que
l’affichage d’une scène 3D, les textures étaient utilisées de plus en plus
souvent simplement pour passer des données à la carte. Avec DX10, les ressources qui
peuvent être manipulées par le GPU sont mieux reconnues
comme pouvant être juste des suites d’informations qui peuvent être à un moment
donné vues et manipulées par différentes parties du pipeline. Avec ce nouveau
paradigm le système des ressources devient de façon plus évidente très flexible
et générique.
Concernant l’objet
Device, il est nécessaire de mentionner que si auparant il était possible de
passer par cet objet pour spécifier un certain nombre de variables d’états du
Fixed Function Pipeline. Celui-ci ayant disparu avec DirectX 10, les
fonctionnalités associées à l’objet Device sont plus synthétiques.
Le choix de
supprimer le FixedFunction Pipeline peut surprendre dans un premier temps
puisque tout le monde ne souhaite pas nécessairement utiliser les fonctionnalités
des Shaders et le FixedFunction Pipeline (FFP) présentait une solution
intéressante dans ce cas là. Ce choix a été effectué car dans les faits, le FFP
n’avait plus d’existence réelle au niveau des cartes. Par ailleurs, le SDK
s’accompagne d’un exemple complet de shaders implémentant les fonctionnalités
du FFP qu’il est possible d’utiliser sans modifications lorsque l’on ne souhaite
pas réaliser ses propres Shaders. L’avantage au niveau de l’API est d’avoir un
mode d’intéraction avec le GPU beaucoup plus synthétique et consistant. Avec
DX9, la présence à la fois du FFP et du système d’effet avec ces «Shaders»
avaient engendré un alourdissement considérable du nombre de propriétés et de
fonctions différentes associées à différents composants DirectX. Par ailleurs,
dans une seule et même application si l’on souhaitait utiliser le FFP pour
certaines géométries et des «Shaders» pour d’autres éléments, on se
trouvait finalement avec des portions de code assez différentes pour atteindre
un but pourtant similaire c'est-à-dire l’affichage d’objets 3D.
Pour revenir sur
notre code manipulant la texture crée à partir d’un fichier, le code ci-dessous
permet finalement d’associer celle-ci à une variable reconnue par notre shader :
g_pDiffuseVariable->SetResource(
g_pTextureRV );
Pour bien
comprendre cette association, il faut aussi préciser comment est définie notre
variable g_pDiffuseVariable :
ID3D10EffectShaderResourceVariable*
g_pDiffuseVariable = NULL;
Et voir comment
nous avons de fait récupéré une référence valide pour celle-ci en utilisant les
fonctionnalités de reflection du système d’effet de DirectX 10 que nous décrirons
plus loin:
g_pDiffuseVariable =g_pEffect->GetVariableByName("txDiffuse")->AsShaderResource();
txDiffuse étant lui
même défini dans notre fichier d’effet de la façon suivante :
Texture2D txDiffuse;
Pour l’instant
cette dernière étape n’apparait peut-être pas encore nécessairement comme limpide
mais le sens réel de ces deux dernières étapes va se révéler plus clairement
dès que nous aurons décrit le système d’effet, ce que nous allons faire
maintenant.
(Par ailleurs même
si j’insère des portions de code dans ce document, je recommande de garder un
œil dans visual studio sur les projets des tutoriaux ou sur le code de
l’application d’exemple. Cela donne une meilleur vue d’ensemble du code et
montre bien où les portions précises incluses ici s’insèrent).
The effect system
Comme nous venons
de le voir ci-dessus pour expliquer comment une texture peut être utilisée dans
un de nos shaders, lui-même défini dans notre fichier d’effet, nous avons
utilisé des fonctionnalités du système d’effet de DX10. Il me paraissait
important de donner ci-dessus l’ensemble du code nécessaire pour utiliser une
texture même si le système d’effet en lui-même n’avait pas encore été décrit.
Mais regardons maintenant celui-ci plus en détails.
Nous avons vu
jusqu’à présent comment les ressources pouvaient être crées, comment l’on
pouvait obtenir des vues de type précis de ces même ressources et comment ces
vues pouvaient elles même être associées au pipeline en différents points. Dans
le dernier exemple, cette association a été effectuée en utilisant des fonctionnalités
offertes par le système d’effet accompagnant DX10. Je considère ici que le
lecteur est familier avec la notion de fichier d’effet et de shader, si ce
n’est pas le cas il peut être utile de se reporter à l’article suivant
http://www.microsoft.com/france/msdn/directx/Shaders-avec-Managed-DirectX_1.mspx
ou à un des excellents ouvrages existant sur le sujet.
Avant de détailler
les fonctionnalités du système d’effet, regardons d’abord un peu comment
justement charger un tel fichier d’effet:
// Create the effect
hr = D3DX10CreateEffectFromFile( L"default.fx",
NULL, NULL, D3D10_SHADER_ENABLE_STRICTNESS,
0, g_pd3dDevice, NULL, NULL,
&g_pEffect, NULL );
if( FAILED( hr ) )
{
MessageBox(
NULL, L"The FX file cannot be located. Please
run this executable from the directory that contains the FX file.",
L"Error", MB_OK );
return hr;
}
Comme je l’ai
mentionné il n’y a plus de FFP donc maintenant on manipule en fait des shaders
en permanence. Le système d’effet présent dans DX9 a été remanié en profondeur
et devient maintenant une brique essentielle au cœur de DirectX. En particulier,
ce système d’effets s’accompagne de fonctionnalités de réflexion permettant de
créer une association entre des interfaces ou des adresses mémoire accessibles
par le CPU et des constantes ou des ressources connus de nos effets et shaders
et manipulables par le GPU.
C’est en fait
exactement ce qui est fait dans l’appel:
g_pDiffuseVariable =g_pEffect->GetVariableByName(
"txDiffuse")->AsShaderResource();
Cet appel crée une
association entre la ressource txDiffuse déclarée dans notre ficher d’effet et
le pointeur g_pDiffuseVariable. Celui-ci étant un pointeur sur une interface ID3D10EffectShaderResourceVariable
qui nous permet l’appel:
g_pDiffuseVariable->SetResource(
g_pTextureRV );
Ayant pour but de
spécifier que txDiffuse référence la vue de notre ressource que nous avions
dans g_pTextureRV.
Regardons
maintenant d’autres exemples (extrait du tutorial 7, il peut être intéressant
de regarder le code de ce tutorial en parallèle à la lecture de ce document)
d’utilisation des fonctionnalitées de réflexion de l’API DirectX 10:
// Obtain the technique
g_pTechnique =
g_pEffect->GetTechniqueByName( "Render"
);
// Obtain the variables
g_pWorldVariable
= g_pEffect->GetVariableByName( "World"
)->AsMatrix();
g_pViewVariable =
g_pEffect->GetVariableByName( "View"
)->AsMatrix();
g_pProjectionVariable
= g_pEffect->GetVariableByName( "Projection"
)->AsMatrix();
g_pMeshColorVariable = g_pEffect->GetVariableByName(
"vMeshColor" )->AsVector();
g_pDiffuseVariable = g_pEffect->GetVariableByName( "txDiffuse"
)->AsShaderResource();
// Define the input layout
D3D10_INPUT_ELEMENT_DESC layout[] =
{
{
"POSITION", 0,
DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D10_INPUT_PER_VERTEX_DATA, 0 },
{
"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT,
0, 12, D3D10_INPUT_PER_VERTEX_DATA, 0 },
};
UINT numElements
=
sizeof(layout)/
sizeof(layout[0]);
// Create the input layout
D3D10_PASS_DESC
PassDesc;
g_pTechnique->GetPassByIndex( 0 )->GetDesc( &PassDesc );
hr =
g_pd3dDevice->CreateInputLayout
( layout, numElements, PassDesc.pIAInputSignature,
PassDesc.IAInputSignatureSize, &g_pVertexLayout );
if( FAILED(hr) )
return hr;
// Set the input layout
g_pd3dDevice->IASetInputLayout(
g_pVertexLayout );
On voit que par le
biais du même mécanisme décrit pour notre texture, il est possible de créer des
associations qui permettent de communiquer des valeurs, des matrices à nos
effets.
En dessus des
appels à GetVariableByName, on voit par ailleurs une étape extrêmement
importante dans l’utilisation d’un fichier d’effet, il s’agit de la
spécification du format des données qui vont être envoyées en entrée de notre
pipeline à l’input assembler. Ce format est spécifié par le biais de l’appel à la fonction IASetInputLayout mais la description de ce format est, elle créée, en s’appuyant à
la fois sur un passe bien précise d’une technique contenu dans notre fichier
d’effet et sur une structure D3D10_INPUT_ELEMENT_DESC visible
plus haut dans notre code. Cette étape effectuée par la fonction CreateInputLayout permet de vérifier l’adéquation entre ce que nous précisons dans
note code C++ et ce qui de l’autre côté peut être consommé par les shaders
contenu dans le fichier d’effet.
Une telle
vérification n’est effectuée qu’à ce moment là. On voit que dans notre
structure nous utilisons des sémantiques du type POSITION, TEXTURE NORMAL. Avec
DX9 l’ordre de ces composantes tel que spécifié côté C++ et côté effet pouvait
être différent. Le shader récupérait la valeur associé à une sémantique donnée,
maintenant l’ordre doit être strictement le même dans les deux cas, le shader
va récupérer les valeurs de ces attributs en fonction de leur position. Ce
changement permet d’obtenir de meilleurs résultats en terme de performance mais
une erreur d’ordre peut avoir des répercutions importantes alors que ce n’était
pas le cas avec DX9.
Pour être complet
au sujet des GetVariableByName, regardons ce qui est défini dans le fichier
d’effet:
Texture2D txDiffuse;
SamplerState samLinear
{
Filter = MIN_MAG_MIP_LINEAR;
AddressU = Wrap;
AddressV = Wrap;
};
cbuffer cbNeverChanges
{
matrix View;
};
cbuffer cbChangeOnResize
{
matrix Projection;
};
cbuffer cbChangesEveryFrame
{
matrix World;
float4 vMeshColor;
};
Ces déclarations
sont groupées par cbuffer. Comme indiqué dans la première partie de ce document,
un effort important a été réalisé pour fournir les données à la carte par block
avec une granularité adaptée pour obtenir les meilleures performances possibles
(en minimisant le nombre d’échanges). On voit ci-dessus que le programmeur a
finalement, tout du moins pour les variables utilisées par les shaders, la main
pour choisir le niveau de granularité qui lui parait le plus approprié en
fonction de son applications et des valeurs qu’il doit transmettre à la carte. Ici, le choix a été fait de regrouper les valeurs par blocs de valeurs qui doivent
être mis à jour avec la même fréquence et au même moment, c’est sans doute
souvent le choix qui sera fait. Il est évident comme nous l’avons dit que
communiquer un ensemble d’information à la carte au lieu d’effectuer une
communication pour chaque information isolé peut résulter en des gains de
performance importants. Bien sûr avec la possibilité de définir soi-même les
groupes de valeurs qui vont être transmis en même temps, des erreurs peuvent
être commises et se révéler contre productives. En cas de changement de l’application,
il peut être intéressant de revenir régulièrement sur la définition de ces
blocs pour vérifier que les groupements qui ont initialement été effectués sont
toujours appropriés. Un exemple de mauvaise pratique serait d’avoir un cbuffer
d’une taille conséquente au sein duquel une et une seule valeur serait modifiée
très régulièrement. A ce moment là pour communiquer cette unique valeur notre
gros blocks de donné serait systématiquement transmis.
On dispose d’un
maximum de 4096 constant buffers. Par ailleurs, toute variable qui serait
définie en dehors du cadre d’un de ces buffer se retrouverait instantanément
dans un constant buffer global ($Global).
On ne peut pas
avoir de constant buffer imbriqués. Il est possible de spécifier explicitement
à quels registres vont être associés ces buffers. Le maximum de buffers
possibles par shader est de 16.
Nous avons vu que
l’utilisation de ces buffers peut potentiellement résulter en une amélioration
significative des performances. Un autre point ayant traît aux performances ne
concerne que le cas de figure où l’on utilise le système d’effet (un exemple du
SDK montre que l’on peut aussi utilisé DirectX sans le système d’effet même
s’il est peut probable que cela se révèle intéressant dans la mesure ou le
système d’effet fait maintenant partie des composants centraux de DirectX, il
s’agit de l’exemple intitulé HLSL without Effects). En effet lorsque l’on met à
jour une valeur d’un constant buffer, le système d’effet buferise ce changement
jusqu’à un appel à Apply pour une passe contenu dans une technique définie dans
un effet donné (tout du moins dans la version Oct06 du SDK).
Un autre mécanisme
du système d’effet est intéressant à connaitre dans la perspective d’éviter au
maximum des échanges d’information redondants avec la carte vidéo. Il est
possible de définir des pools d’effet. Lorsque cela est effectué, les
ressources (textures ou autres) et les blocs d’états (j’en parle un peu, plus
dans la suite du document) peuvent être signalés comme étant partagées entre
les différents effets contenu dans un même pool.
Les diagrammes
ci-dessous extrait de FX10: Driving the New Effects
System by Relja Markovic played at GameFest 06 illustrent
parfaitement ce qui se passe en mémoire:
Ci-dessus, on visualise
le graphe de dépendances pour un effet et ses ressources.
Ci-dessous, il
s’agit des graphes pour deux effets différents qui utilisent des ressources
identiques mais ne se trouvent pas dans le même pool d’effets:
Et enfin voici le
graphe de dépendances pour deux effets qui partagent des ressources et qui se
trouvent de surcroit dans le même pool d’effets:
Il est de fait
recommandé d’utiliser au maximum les pools d’effets même si initialement vos
effets ne partagent pas de ressources.
Un dernier point,
j’ai mentionné plus haut qu’il était possible d’utiliser DirectX sans utiliser
le système d’effet. Les fonctions { VS | GS | PS }SetConstantsBuffer que vous
trouvez décrites dans la documentation et dans certains documents sur DirectX
10 ne peuvent être utilisées que dans ce cadre là. Le système d’effet garde une
trace des constants buffers qui doivent être mis à jour ou non pour un effet
donné, utiliser les fonctions ci-dessus en conjonction avec le système d’effet peut
très rapidement et assez sûrement créer un chaos inextricable. Par ailleurs, le
système d’effet est maintenant un composant central de DirectX 10, destiné à
adresser tous le cas de figure.
Le shader model 4.0
Jusqu’à présent
nous avons parlé d’effet et de shaders sans réellement mentionner aucune
information au sujet du langage qui est utilisé pour exprimer ceux-ci. Avec
DirectX 10, les shaders peuvent être écrit uniquement en HLSL (High Level
Shading Langage). Avec DX9, ils étaient aussi possible de spécifier ces
programmes en langage assembleur. Néanmoins ce langage ne correspondait pas non
plus au code réellement consommé par les GPU et il a été jugé inutile de garder
deux représentations intermédiaires différentes. De fait, il est toujours
possible de visualiser une telle représentation intermédiaire mais uniquement à
des fins de débogage.
Il est déconseillé
d’utiliser ce mode de visualisation des shaders dans l’espoir d’améliorer les
performances d’un shader exprimé en HLSL. Cette représentation en langage
assembleur destinée à l’analyse de problème peut apparaitre comme inutilement
lourde et non optimisée si on la consulte dans ce but et finalement fournira
peu d’informations valides sur les performances d’un shader donné par rapport à
un shader écrit différemment et provoquant les même effets.
Entre autre chose,
on peut mentionner que le langage HLSL est maintenant dit unifié entre les
différents types de shaders. Même si ceux-ci gardent leurs spécificités en
terme de données sur lesquels ils peuvent travailler et au niveau des
opérations qu’ils peuvent effectuer, le langage pour exprimer tout cela est
maintenant le même d’un type de shader à un autre.
Pour plus
d’informations sur les instructions supportées, il est préférable de se
reporter à la documentation du SDK qui en fourni une liste exhaustive.
Les choses
auxquelles il faut faire attention lorsque l’on est habitué à DX9
J’ai déjà mentionné
certains de ces points mais il n’est pas inutile de les mentionner à nouveau.
Prenons le vertex shader défini ci-dessous:
struct VS_INPUT
{
float4 Pos : POSITION;
float2 Tex : TEXCOORD;
float4 Norm : NORMAL;
};
//-----------------------------------------------------------
// Vertex Shader
//-----------------------------------------------------------
PS_INPUT VS( VS_INPUT input )
{
PS_INPUT output = (PS_INPUT)0;
output.Pos = mul( input.Pos, World );
output.Pos = mul( output.Pos, View );
output.Pos = mul( output.Pos, Projection );
output.Tex = input.Tex;
output.Norm = mul( input.Norm, World );
output.Norm = mul( output.Norm, View );
output.Norm = mul( output.Norm, Projection );
return output;
}
Dans notre code C++
nous allons créer l’association avec la structure suivante:
D3D10_INPUT_ELEMENT_DESC layout[] =
{
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT,
0, 0, D3D10_INPUT_PER_VERTEX_DATA, 0 },
{ "NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT,
0, 12, D3D10_INPUT_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT,
0, 12, D3D10_INPUT_PER_VERTEX_DATA, 0 },
};
L’association
ci-dessus va créer de gros problèmes. Avec DX9, en fonction de la sémantique,
le système d’effet allait récupérer les valeurs associées à une sémantique
quelque soit l’ordre dans lequel elles étaient définies. Avec DX10 ce n’est
plus le cas, un effort important a été fourni pour supprimer toutes les
opérations qui n’étaient pas strictement nécessaires. Dans le cas ci-dessus
avec DX10, les valeurs pour notre normal vont être utilisées comme coordonnées
de texture… Rien ne va échouer de manière spectaculaire mais il est probable
que le résultat à l’écran ne le soit pas non plus (tout du moins dans le sens
ou nous aimerions qu’il le soit).
Deuxième changement
de taille qui pourra surprendre des développeurs DX9 endurcis, c’est l’absence
des informations concernant les fonctionnalités supportées par une carte
graphique (les Caps). En contre partie, toute carte DX10 supporte finalement
les fonctionnalités offerte par l’API. Bien sûr les différences de performances
entre différents niveaux de gamme de carte graphique ou en fonction de la
mémoire disponible vont rester très importantes de sorte que comme aujourd’hui,
il peut être judicieux d’utiliser un système pour afficher quelque chose à
l’écran plutôt qu’un autre en fonction de la carte présente sur la machine.
Pour effectuer ce genre de choix, il était déjà conseillé de ne pas le faire en
s’appuyant sur les capacités exprimées par la carte en termes de
fonctionnalités supportées par celle-ci mais d’effectuer des tests de vitesse
de rendu en utilisant un système ou un autre pour décider lequel se comporte le
mieux. Finalement aujourd’hui c’est la solution qui reste, c'est-à-dire de
comparer les vitesses d’affichage d’un système ou d’un effet par rapport à une
autre pour décider celui qui va être retenu. Cela revient à avoir une sorte de «profiler»
intégré à son application. Bien sûr cela représente un travail supplémentaire
d’effectuer ce genre de tests mais si l’application est destinée à tourner sur
une grande variété de cartes différentes et si l’on souhaite investir suffisamment
pour développer plusieurs codes différents pour gérer l’affichage pour adapter
celui-ci à certaines familles de cartes, à ce moment ce petit «profiler»
intégré est finalement la seule solution raisonnable pour effectuer des choix
entre les différents chemins de codes possibles.
Sinon, que l’on ait
un passé de développeur DX9 ou non, il est finalement utile d’être pour
l’instant prudent quand à l’utilisation qui est envisagée pour le GS. Pour
l’instant il est difficile de se faire une idée des performances qu’ils
pourront avoir si on leur demande d’émettre ou de supprimer des primitives par
rapport à ce qui a été envoyé dans le pipeline. Aussi pour l’instant il peut
être plus prudent de ne pas tout miser sur ce type de fonctionnalités. Ce n’est
pas nécessairement que les performances seront mauvaises mais il s’agit
essentiellement d’une inconnue à ce jour.
Dans le même
registre, il est certes possible de modifier des constants buffers par
primitive mais il s’agit peut être d’une pratique un peu extrême.
DrawAuto est une
fonctionnalité très intéressante (la possibilité de réinjecter le résultat de
la première partie du pipeline sans exécuter les étapes de rastérisation et les
pixels shaders et sans intervention du CPU) mais là encore il est impossible
d’avoir beaucoup de recul aujourd’hui sur les performances qui seront obtenues
avec ce système. D’autant plus qu’il est extrêmement tentant d’utiliser
justement cette fonctionnalité de pair avec la possibilité de créer ou
supprimer des primitives au sein du GS. Il est relativement plus aisé de penser
à des usages possibles de ces mécanismes que d’anticiper comment ils vont se
comporter dans le cadre d’opérations à effectuer plus ou moins en «temps
réel». Par contre ces possibilités sont de fait d’ores et déjà présentes
dans l’API.
Variables d’état du Device
J’ai mentionné plus
haut qu’il n’y avait plus de FFP et que les variables d’états associés avaient
disparues, ce qui est exact (J), mais cela ne veut pas dire pour autant qu’il n’y a plus du tout de variables
d’état associées au Device qui affecte le pipeline. L’état du rendering
pipeline continue d’être contrôlé par des «state blocks». Le
turorial 14 illustre ce point à merveille. Par ailleurs dans l’exemple de code
DX10 que nous allons voir ci-dessous, nous utilisons aussi des variables d’état
pour contrôler les opérations de blending entre le contenu du render target et
de la contribution d’une primitive donnée.
Je pense avoir
couvert un certain nombre de points importants de DirectX 10. Il ne s’agit pas
d’une liste exhaustive des fonctionnalités ou de ce qui a pu changer par
rapport à la version précédente mais je pense avoir mentionné un certain nombre
de choses qu’il est intéressant de connaitre lorsque l’on souhaite aborder cette
nouvelle déclinaison du SDK DirectX. Je fais souvent référence dans la partie
ci-dessus aux tutoriaux et aux exemples contenus avec le SDK, ceux-ci sont en
effet très didactiques et offrent une aide précieuse lors de la prise en main
du SDK. Je ne peux conseiller de s’y reporter souvent pour découvrir plus
complètement les possibilités de ce SDK.
Application d’exemple
Passons maintenant
à la description d’un exemple qui va nous permettre de revisiter un peu plus en
profondeur ou tout du moins sur un cas concret un certain nombre des points qui
ont été vu jusqu’ici.
Dans la grande
majorité des cas pour afficher un objet en trois dimensions il est suffisant
d’utiliser une représentation géométrique qui décrit essentiellement la surface
qui le sépare l’intérieur de l’objet de l’extérieur. Cette façon de faire n’est
pas nécessairement adaptée à tout les besoins. Sous l’appellation «Volume
Rendering», on regroupe un certain nombre de techniques qui permettent
justement d’afficher des données volumiques. L’exemple de code que nous allons
ici détailler s’attache à afficher à l’écran des données volumiques contenues
dans une texture en trois dimensions. Cet exemple de code reste très simple,
nos données volumiques sont contenues dans un cube et nous allons quadriller ce
cube de triangles pour que la couleur en chaque point de l’écran soit le
résultat de la contribution des données de notre texture en 3D dimension aux
points de coupe par nos triangles. En l’occurrence, nous allons utiliser le GS
pour faire générer par notre GPU un certain nombre de ces triangles.
L’orientation des triangles en question fait que la technique utilisée ici n’est
pas nécessairement adaptée à certains besoins comme l’imagerie médicale ou
scientifique où il peut être nécessaire d’attacher un plus grand soin au choix
de la géométrie que l’on va utiliser comme support pour obtenir quelque chose à
l’écran de manière à limiter des effets de perspective. Par exemple, on pourra
souhaiter que notre cube soit coupé par des surfaces sphériques dont la normale
est alignée avec la ligne que l’on peut créer entre la position de la caméra et
le point de la surface en question (et du cube), de sorte que l’opération de «blending»
entre la couleur de chaque sphère qui contribue à la couleur du pixel final
corresponde à une intégration avec un pas uniforme sur toutes les lignes allant de la caméra à un point du far plane.
Dans notre cas
où nous avons des triangles qui ne sont pas nécessairement ordonnés d’une
manière particulière cela correspond finalement à faire une intégration
numérique avec une valeur de pas différente en fonction de l’orientation et de
la position du cube et du point de l’écran que l’on considère, ce qui n’est pas
«exact». Par ailleurs, toujours pour la visualisation médicale ou
scientifique, les données volumiques disponibles ne sont pas nécessairement des
données qui doivent avoir une traduction visuelle directe et l’on souhaite
souvent contrôler la façon dont ces données contribuent à ce que l’on souhaite
visualiser ou non. De fait, un champ entier de recherche est dédié à déterminer
des fonctions de transfert, appropriées à chaque domaine, qui permettent de
visualiser des informations intéressantes à partir de données volumiques brutes
issues du domaine en question. Ceci étant dit, notre propos ici, n’est pas de
répondre à un besoin précis de ce type mais plutôt d’illustrer de la manière la
plus synthétique possible l’utilisation de l’API DirectX 10, aussi l’exemple de
code présenté ici permet de visualiser un élément décrit par des données volumiques
plutôt que par des informations concernant sa surface.
Dans notre cas nous allons en fait
visualiser les données résultats d’un scan 3D. Le fichier de données initiales
se trouve sur le site suivant http://www.volvis.org . Pour obtenir le
résultat visible dans les captures d’écran visible à la fin de ce document, il
vous faudra récupérer ce fichier (bonzai.raw.gs qui contient bonzai.raw), en
effet il n’est pas inclus dans le fichier .zip contenant les sources et le
projet Visual Studio d’exemple décrit ici.
La densité et
la précision du résultat visible à l’écran augmentent avec le nombre de
primitives utilisées pour produire ce résultat. C’est pour cela que l’idée de
générer ces géométries à l’aide du GPU est séduisante. En effet, cela laisse
espérer de pouvoir générer plus de géométrie grâce à celui-ci au moment où les
cartes DX 10 seront disponibles. Dans notre cas, nous utilisons le référence Device
qui est une implémentation sur les CPU des fonctionnalités d’un carte 3D, aussi
les performances ne sont pas très bonnes à ce jour mais cet exemple de code
nous fourni toutefois un alibi pour donner un exemple de GS.
Récapitulons donc
un peu ce que notre exemple va effectuer exactement.
-
Tout d’abord, notre application doit bien sûr
effectuer les initialisations nécessaires pour que l’on puisse utiliser DirectX
puis visualiser un résultat à l’écran (création du Device…).
-
Ensuite, nous allons construire la texture en 3D
qui va contenir les données que nous souhaitons visualiser.
-
Nous devons aussi créer les primitives graphiques
que nous allons utiliser comme support pour afficher les données contenues dans
notre texture en trois dimensions (après tout notre GPU ne comprend qu’un
nombre réduit de type de primitives différentes, à savoir des points, des
lignes et des triangles, l’affichage d’une texture en trois dimensions sans le
support de telles primitives n’a pas encore de sens).
-
Enfin, nous devons effectuer tout le travail nécessaire
pour communiquer à notre Device toutes les informations nécessaires au rendu et
provoquer celui-ci. Entre autre chose nous devons spécifier à la carte quel
effet et quelle technique puis quelle passe de cette technique (et donc quels
shaders) nous souhaitons utiliser pour calculer le résultat de l’affichage. Nous
devons aussi envoyer un minimum de données au pipeline et donner l’ordre à
celui-ci de commencer ses traitements.
Ces différentes opérations
vont être respectivement réalisées dans notre code dans les fonctions suivantes:
-
InitDevice
-
Build3DData
-
MakeSlices
-
Render
Notre application
contient aussi les fonctions ci-dessous qui, elles sont plus directement liées
au fonctionnement d’une application windows qu’à DirectX, aussi je ne les
détaillerai pas:
-
WndProc
-
wWinMain
-
InitWindow
InitDevice nous
permettra donc de revoir le code nécessaire à la création du Device et de la
swap chain auquel il sera associé, la définition du viewport et l’envoi de
celui-ci au rasterizer. Dans cette fonction, nous chargerons aussi notre
fichier d’effet pour que soit directement accessible dans les autres fonctions,
les fonctions de réflexion du système d’effet, ce qui nous permettra au fil de
l’eau de créer les associations entre les ressources que nous créons et ce qui
est utilisé dans les shaders de notre système d’effet.
Build3DData nous
montrera comment créer une resource de type texture3D comment générer de façon
procédurale des valeurs pour le contenu de cette texture puis comment créer à
partir de celle-ci, une vue de cette ressource que nous allons «binder»
à notre pipeline en précisant la façon dont elle doit être associée à des
ressources que les shaders référencent dans leur code (bon c’était une phrase
un peu longue mais j’espère que le cheminement est clair).
Enfin dans
MakeSlices nous allons construire effectivement les primitives que nous allons
utiliser pour obtenir quelque chose à l’écran (en l’occurrence une liste de
triangles).
Maintenant que nous
avons décrit notre application dans ses grandes lignes, nous pouvons aller plus
dans le détail de chaque fonction.
InitDevice
Par essence, cette
fonction effectue souvent le même type d’opérations quelque soit l’application.
Il s’agit certainement du code le moins spécifique à une application donnée, ce
que nous voyons ci-dessus est donc redondant avec ce qui a été vu précédemment.
Deux structures
sont créées et contiennent les informations concernant la surface d’affichage
et la swap chain que l’on désire, ensuite on s’appuit sur
D3D10CreateDeviceAndSwapChain pour obtenir à la fois notre Device et l’attacher
à la swap chain (il est possible d’effectuer ces opérations indépendement l’une
de l’autre par exemple si l’on souhaite attacher un device donné à plusieurs
swap chain mais ici comme nos besoins restent simples, cette fonction
D3D10CreateDeviceAndSwapChain nous évite de réaliser explicitement chaque
étape).
Ensuite, comme il a
été décrit dans la partie précédente on crée une ressource de type Texture2D et
on en extrait une vue que nous allons associer avec la dernière étape de notre
pipeline, l’output merger. Ceci assure que le résultat des traitements effectués
dans notre pipeline se retrouve stocké dans cette ressource qui est utilisé par
la swap chain pour présenter le résultat à l’écran:
//--------------------------------------------------
// Create Direct3D device and swap chain
//---------------------------------------------------
HRESULT InitDevice()
{
HRESULT hr;
RECT rc;
GetClientRect( g_hWnd, &rc );
UINT width = rc.right - rc.left;
UINT height = rc.bottom - rc.top;
UINT createDeviceFlags = 0;
#ifdef _DEBUG
createDeviceFlags |= D3D10_CREATE_DEVICE_DEBUG;
#endif
DXGI_SWAP_CHAIN_DESC sd;
ZeroMemory( &sd, sizeof(sd) );
sd.BufferCount = 1;
sd.BufferDesc.Width = width;
sd.BufferDesc.Height = height;
sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
sd.BufferDesc.RefreshRate.Numerator = 60;
sd.BufferDesc.RefreshRate.Denominator = 1;
sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
sd.OutputWindow = g_hWnd;
sd.SampleDesc.Count = 1;
sd.SampleDesc.Quality = 0;
sd.Windowed = TRUE;
hr = D3D10CreateDeviceAndSwapChain
( NULL, D3D10_DRIVER_TYPE_REFERENCE,
NULL, createDeviceFlags,
D3D10_SDK_VERSION, &sd,
&g_pSwapChain, &g_pd3dDevice );
if( FAILED(hr) )
return hr;
// Create a render target view
ID3D10Texture2D *pBuffer;
hr = g_pSwapChain->GetBuffer
( 0, __uuidof( ID3D10Texture2D ), (LPVOID*)&pBuffer );
if( FAILED(hr) )
return hr;
hr = g_pd3dDevice->CreateRenderTargetView
( pBuffer, NULL, &g_pRenderTargetView );
pBuffer->Release();
if( FAILED(hr) )
return hr;
g_pd3dDevice->OMSetRenderTargets
( 1, &g_pRenderTargetView, NULL );
Ci-dessous nous définissons aussi le ViewPort
et nous l’attachons au rasterizer où il est utilisé pour déteminer les
portions de primitives qu’il faut ou non clipper.
// Setup the viewport
D3D10_VIEWPORT vp;
vp.Width = width;
vp.Height = height;
vp.MinDepth = 0.0f;
vp.MaxDepth = 1.0f;
vp.TopLeftX = 0;
vp.TopLeftY = 0;
g_pd3dDevice->RSSetViewports( 1, &vp );
Ensuite toujours dans InitDevice, nous allons chargé notre fichier, la fonction D3D10CreateEffectFromFile va aussi passer notre fichier d’effet et compiler les
shaders qu’il contient. Il est donc intéressant de récupérer dans pErrBlob des
informations riches sur les erreurs qui pourrait apparaitre pendant ces
traitements. Si cela n’est pas effectué nous serons juste notifié de l’échec de
l’opération par le biais du HRESULT hr, ce qui est assez pauvre comme
information pour savoir exactement quel a été le problème qui a empêché la
création de l’effet. En l’occurrence en utilisant le pErrBlob, on récupère
entre autre chose une string qui par exemple peut contenir un descriptif exact
de l’erreur de compilation d’un shader et la ligne du fichier d’effet sur
laquelle cette erreur a été rencontrée.
// Create the effect
ID3D10Blob* pErrBlob = NULL;
hr =
D3DX10CreateEffectFromFile( L"DX10Cube.fx", NULL,
NULL, D3D10_SHADER_ENABLE_STRICTNESS, 0,
g_pd3dDevice, NULL, NULL, &g_pEffect, &pErrBlob );
if( FAILED( hr ) )
{
std::string errStr((LPCSTR)pErrBlob->GetBufferPointer(),
pErrBlob->GetBufferSize());
WCHAR err[256];
MultiByteToWideChar(CP_ACP, 0, errStr.c_str(),
(int)errStr.size(), err, errStr.size());
MessageBox( NULL, (LPCWSTR)err, L"Error", MB_OK );
return hr;
}
// Obtain the technique
g_pTechnique = g_pEffect->GetTechniqueByName( "Render" );
// Obtain the variables
g_pWorldVariable = g_pEffect->GetVariableByName( "World" )->AsMatrix();
g_pViewVariable = g_pEffect->GetVariableByName( "View" )->AsMatrix();
g_pProjectionVariable = g_pEffect->GetVariableByName( "Projection" )->AsMatrix();
g_pCubeVertices = g_pEffect->GetVariableByName( "CubeVertices" )->AsVector();
g_pNumSlicesEffVar = g_pEffect->GetVariableByName( "numSlices" )->AsScalar();
g_pNumSlicesEffVar->SetInt(g_numSlices);
g_pDiffuseVariable = g_pEffect->GetVariableByName( "txDiffuse" )->AsShaderResource();
g_p3DData = g_pEffect->GetVariableByName( "tdData" )->AsShaderResource();
Ci-dessus on voit les appels au méthodes de réflection de notre système
d’effet qui permettent d’associer des variables définies dans le fichiers
d’effets avec des interfaces DX10 que nous pouvons utiliser à partir de notre
code C++ pour fixer ces valeurs avant d’effectuer le rendu de primitives en
utilisant une passe donnée, d’une technique contenu dans notre effet.
Une fois ces opérations effectuées, nous devons spécifier à l’entrée de
notre pipeline, l’input assembler (IA) le format des données que nous allons
lui passer.
Comme cela a été décrit précédement, nous nous appuyons pour cela sur la fonction CreateInputLayout à qui l’on spécifie une passe d’une technique de notre effet de
sorte qu’elle puisse vérifier l’adéquation du format de donnée que nous allons
passer à l’IA avec le format de donnée que les shaders associés à cette passe
peuvent consommer. Une fois que nous avons un format dont à ce stade nous
sommes sûr qu’il est valide nous pouvons appeler la méthode IASetInputLayout en lui passant le format en question. Notre fonction MakeSlices va
ensuite générer des informations dans le format spécifié ci-dessous:
// Define the input layout
D3D10_INPUT_ELEMENT_DESC layout[] =
{
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT,
0, 0, D3D10_INPUT_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT,
0, 12, D3D10_INPUT_PER_VERTEX_DATA, 0 },
};
UINT numElements = sizeof(layout)/sizeof(layout[0]);
// Create the input layout
D3D10_PASS_DESC PassDesc;
g_pTechnique->GetPassByIndex( 0 )->GetDesc( &PassDesc );
hr = g_pd3dDevice->CreateInputLayout
( layout, numElements, PassDesc.pIAInputSignature,
PassDesc.IAInputSignatureSize, &g_pVertexLayout );
if( FAILED(hr) )
return hr;
// Set the input layout
g_pd3dDevice->IASetInputLayout( g_pVertexLayout );
// Initialize the world matrices
D3DXMatrixIdentity( &g_World );
// Initialize the view matrix
D3DXVECTOR3 Eye( 0.0f, 3.0f, -6.0f );
D3DXVECTOR3 At( 0.0f, 1.0f, 0.0f );
D3DXVECTOR3 Up( 0.0f, 1.0f, 0.0f );
D3DXMatrixLookAtLH( &g_View, &Eye, &At, &Up );
// Initialize the projection matrix
D3DXMatrixPerspectiveFovLH
( &g_Projection,
(float)D3DX_PI * 0.25f, width/(FLOAT)height, 0.1f, 100.0f );
// Update Variables that never change
g_pViewVariable->SetMatrix( (float*)&g_View );
g_pProjectionVariable->SetMatrix( (float*)&g_Projection );
g_pDiffuseVariable->SetResource( g_pTextureRV );
MakeSlices();
Build3DData();
return S_OK;
}
Enfin ci-dessus,
nous créeons nos matrices world, view et projection ce qui reste tout aussi nécessaire
qu’avec des versions précédentes de l’API J. Pour plus d’informations
sur le role de ces matrices on pourra se reporter au document: http://www.microsoft.com/france/msdn/directx/tutoriel/part1.mspx
(ou à tout autre
tutorial traitant de la programmation 3D en fait).
Build3DData
Dans cet exemple, la fonction Build3DData génère juste une texture en trois dimensions qui contient des pixels
dont les canaux rgb contiennent la valeur issue de la lecture de notre fichier
contenant le résultat du scan du bonzaï. Typiquement, il serait plus
intéressant de ne charger dans notre texture en 3D que les données issues de la
lecture de notre fichier; et de n’émettre cette valeur dans les canaux
rgb que dans notre pixel shader mais ici j’ai choisi d’avoir directement la
couleur dans la texture en dimensions. Pour les personnes qui regardent le
projet d’exemple, ils verront qu’il y a une deuxième fonction Build3DData qui
ne prend pas de paramètres et qui construit de façon procédurale un cube
contenant un gradient de couleur. Stocker les valeurs 8bits issues du fichier
bonzai.raw dans ma texture 3D en lieu et placer des couleurs m’aurait obligé à
avoir un pixel shader différent dans les moments où je souhaite visualiser le
cube contenant le gradient de couleur, aussi j’ai préféré avoir un seul pixel
shader qui permette de visualiser les deux. Encore une fois, il s’agit juste
d’un exemple de code dont le seul but et d’illustrer de la manière la plus
synthétique possible l’utilisation de l’API.
Les grandes étapes
de cette fonction sont les suivantes, on génère l’ensemble des valeurs dans un
tableau de D3DXVECTOR4 (le format de couleur choisi exprime chaque canal r g b
ou a sous forme d’un float), puis à partir de ce tableau nous construisons
notre texture 3D dont nous extrayons une vue qui est associée (bindé) au
pipeline par le biais de l’interface contenu dans g_p3DData que nous avions
récupéré dans InitDevice en utilisant les fonctionnalités de réflection du
système d’effet. A partir de là, nos shaders peuvent consommer cette ressource.
HRESULT ByteToColor(D3DXVECTOR4* colorOut, BYTE* byteArray, int dim)
{
BYTE curr = byteArray[0];
for(int i=0; i> dim*dim*dim; i++)
{
float val = (float)byteArray[i];
colorOut[i].x = (float)byteArray[i] / 255;
colorOut[i].y = (float)byteArray[i] / 255;
colorOut[i].z = (float)byteArray[i] / 255;
colorOut[i].w = (float)byteArray[i] / 255;
}
return S_OK;
}
HRESULT Build3DData(char* file)
{
int texDim = 256;
D3D10_SUBRESOURCE_DATA InitData;
InitData.pSysMem = new D3DXVECTOR4[ texDim*texDim*texDim ];
InitData.SysMemPitch = texDim*sizeof(D3DXVECTOR4);
InitData.SysMemSlicePitch = texDim*texDim*sizeof(D3DXVECTOR4);
// Gen a bunch of random values
D3DXVECTOR4* pTexData = (D3DXVECTOR4*)InitData.pSysMem;
int dx = texDim;
int dy = texDim;
int dz = texDim;
FILE *stream, *stream2;
fopen_s(&stream, file, "rb");
BYTE* pByteData = new BYTE[texDim*texDim*texDim];
fread(pByteData, sizeof(BYTE), texDim*texDim*texDim, stream);
ByteToColor(pTexData, pByteData, texDim);
// Load the Texture
D3D10_TEXTURE3D_DESC desc;
desc.BindFlags = D3D10_BIND_SHADER_RESOURCE;
desc.CPUAccessFlags = 0;
desc.Depth = desc.Height = desc.Width = texDim;
desc.Format = DXGI_FORMAT_R32G32B32A32_FLOAT;
desc.MipLevels = 1;
desc.MiscFlags = 0;
desc.Usage = D3D10_USAGE_IMMUTABLE;
HRESULT hr =
g_pd3dDevice->CreateTexture3D( &desc, &InitData, &g_pTextureData );
if(FAILED(hr))
{
MessageBox( NULL, L"CreateTexture3D failed", L"Error", MB_OK );
}
D3D10_SHADER_RESOURCE_VIEW_DESC SRVDesc;
ZeroMemory( &SRVDesc, sizeof(SRVDesc) );
SRVDesc.Format = desc.Format;
SRVDesc.ViewDimension = D3D10_SRV_DIMENSION_TEXTURE3D;
SRVDesc.Texture3D.MipLevels = desc.MipLevels;
SRVDesc.Texture3D.MostDetailedMip = 0;
hr =
g_pd3dDevice->CreateShaderResourceView( g_pTextureData,
&SRVDesc, &g_pTextureRV );
if(FAILED(hr))
{
MessageBox( NULL, L"CreateShaderResourceView failed in Build3DData",
L"Error", MB_OK );
}
g_p3DData->SetResource(g_pTextureRV);
return S_OK;
}
Pour bien mettre en
exergue toutes les informations ayant trait à ces étapes, il est aussi
important de regarder la façon dont cette ressource est déclarée dans le
fichier d’effet. Le nom tdData est celui qui est utilisé au moment ou nous
utilisons les fonctionnalités de réflexion de notre système d’effet pour
récupérer une interface dans g_p3DData.
Texture3D tdData;
SamplerState tdSamLinear
{
Filter = MIN_MAG_MIP_LINEAR;
AddressU = Wrap;
AddressV = Wrap;
AddressW = Wrap;
};
On voit par ailleurs
qu’un «SamplerState» est défini, la ressource tdData sera de fait
accédée à partir de nos shaders par le biais de ce sampler state qui permet de
préciser des propriétés supplémentaires qui vont influer sur la façon dont la
texture va être utilisée. Je ne détaillerai pas ici ce que le contenu de ce
sampler signifie, les noms étant explicites pour des personnes qui seraient
déjà familières avec DX9 (ou même une autre API permettant de faire des
graphisme en 3D).
MakeSlices
Dans la description de notre petit exemple, nous avons expliqué la
nécessité d’utiliser des primitives classiques comme support pour l’affichage
de nos données volumiques. MakeSlices a pour but de créer ces primitives. En
l’occurrence, le code reste ici extrêmement simple, ce qui somme toute à
l’avantage de nous permettre encore une fois de nous concentrer sur
l’utilisation de l’API et non sur les algorithmes utilisés ici. En fait, nous
créons des plans parallèles ayant tous pour normales l’axe des z. Comme cela a
été mentionné plus tôt lorsque l’on visualise des données volumiques, il est
parfois intéressant de s’appuyer sur des géométries générées avec plus de soin.
Des papiers très intéressants décrivent par exemple des techniques pour créer
des plans dont la normale est alignée avec l’axe de la caméra.
«A Vertex
Program for Efficient Box-Plane Intersection» de Christof Rezk Salama
et Andreas Kolb. L’algorithme qu’ils décrivent peut être utilisé pour modifier
l’exemple ci-dessous et étendre considérablement son champ d’application.
Revenons-en à notre code:
//----------------------------------------------------
// Let's built the needed vertex and index buffer we
// need to draw something on screen.
//----------------------------------------------------
HRESULT MakeSlices()
{
g_numSlicesTriangles = 2 * g_numSlices;
g_numSlicesVertices = 3 * g_numSlicesTriangles;
int verticesPerSlice = 6;
int indicesPerSlice = 6;
// Create vertex buffer
SimpleVertex* vertices = new SimpleVertex[g_numSlicesVertices];
DWORD* indices = new DWORD[3*g_numSlicesTriangles];
for(int i=0; i>g_numSlices; i++)
{
vertices[i*verticesPerSlice].Pos = D3DXVECTOR3(-1.0f, -1.0f,(float)i);
vertices[i*verticesPerSlice].Tex = D3DXVECTOR2(0.0f, 0.0f);
vertices[i*verticesPerSlice+2].Pos = D3DXVECTOR3(1.0f,-1.0f,(float)i);
vertices[i*verticesPerSlice+2].Tex = D3DXVECTOR2(1.0f, 0.0f);
vertices[i*verticesPerSlice+1].Pos = D3DXVECTOR3(1.0f,1.0f,(float)i);
vertices[i*verticesPerSlice+1].Tex = D3DXVECTOR2(1.0f, 1.0f);
vertices[i*verticesPerSlice+3].Pos = D3DXVECTOR3(-1.0f,-1.0f,(float)i);
vertices[i*verticesPerSlice+3].Tex = D3DXVECTOR2(0.0f, 0.0f);
vertices[i*verticesPerSlice+5].Pos = D3DXVECTOR3(1.0f,1.0f,(float)i);
vertices[i*verticesPerSlice+5].Tex = D3DXVECTOR2(1.0f, 1.0f);
vertices[i*verticesPerSlice+4].Pos = D3DXVECTOR3(-1.0f, 1.0f,(float)i);
vertices[i*verticesPerSlice+4].Tex = D3DXVECTOR2(0.0f, 1.0f);
for(int k=0; k>indicesPerSlice; k++)
{
indices[i*indicesPerSlice+k] = i*verticesPerSlice+k;
}
}
Nous avons généré ci-dessus un tableau de vecteur. La coordonnée z de
chaque vecteur ne correspond pas réellement à sa position selon l’axe des z de
notre espace en trois dimensions mais correspond à l’index du plan sur lequel
se trouve le vertex en question. C’est dans nos shaders que nous effectuerons
le travail nécessaire pour fixer cette valeur en conséquence.
On peut voir
ci-dessus que l’on a aussi crée un tableau d’indices. Nous allons utiliser les
deux tableaux ainsi crées pour obtenir à la fois notre vertex buffer et notre
index buffer:
D3D10_BUFFER_DESC bd;
bd.Usage = D3D10_USAGE_DEFAULT;
bd.ByteWidth = sizeof( SimpleVertex ) * g_numSlicesVertices;
bd.BindFlags = D3D10_BIND_VERTEX_BUFFER;
bd.CPUAccessFlags = 0;
bd.MiscFlags = 0;
D3D10_SUBRESOURCE_DATA InitData;
InitData.pSysMem = vertices;
HRESULT hr = g_pd3dDevice->CreateBuffer
( &bd, &InitData, &g_pSlicesVertexBuffer );
if( FAILED(hr) )
return hr;
bd.Usage = D3D10_USAGE_DEFAULT;
bd.ByteWidth = sizeof( DWORD ) * 3 * g_numSlicesTriangles;
bd.BindFlags = D3D10_BIND_INDEX_BUFFER;
bd.CPUAccessFlags = 0;
bd.MiscFlags = 0;
InitData.pSysMem = indices;
hr = g_pd3dDevice->CreateBuffer
( &bd, &InitData, &g_pSlicesIndexBuffer );
if( FAILED(hr) )
return hr;
return S_OK;
}
Render
Si je n’ai rien
oublié, tout est maintenant en place pour produire un résultat visuel à
l’écran, reste à indiquer à notre pipeline que nous souhaitons utiliser une
passe donnée, d’une technique contenue dans notre fichier d’effet. Reste à
effectuer les dernières associations nécessaires entre les variables que nous
avons crée dans notre code C++ et les variables connues par les shaders associés
à la passe que nous avons sélectionné et enfin à donner pour ainsi dire l’ordre
au pipeline de traiter nos primitives et d’émettre le résultat correspondant.
void Render()
{
static float t = 0.0f;
// Rotate cube around the origin
D3DXMatrixRotationY( &g_World, t );
// Update our time
t += (float)D3DX_PI * 0.0125f;
//
// Clear the back buffer
//
float ClearColor[4] = { 0.0f, 0.125f, 0.3f, 1.0f }; // red, green, blue, alpha
g_pd3dDevice->ClearRenderTargetView( g_pRenderTargetView, ClearColor );
J’ai indiqué plus
tôt qu’il y avait toujours des variables d’états influençant le comportement du
pipeline même si toute celles associées au FFP avait disparues en même temps
que celui-ci. Ci-dessous, nous utilisons ces variables d’état pour indiquer que
pour chaque pixel, la couleur produite par le rendu d’une primitive doit être
mélangé avec le contenu du back buffer. C’est ce qui fait que la couleur de
chaque pixel à l’écran n’est pas le résulat de la couleur «émise»
par la présence de la primitive la plus proche de la caméra mais bien le résultat
des contributions de l’ensemble des primitives graphiques recouvrant ce pixel.
Comme ces variables affectent le fonctionnement de l’output merger, le nom de
la fonction utilisée pour les fixer OMSetBlendState est
bien sûr préfixée par OM:
//// Create the appropriate blend state
D3D10_BLEND_DESC StateDesc;
ZeroMemory( &StateDesc, sizeof(D3D10_BLEND_DESC) );
StateDesc.AlphaToCoverageEnable = FALSE;
StateDesc.BlendEnable[0] = TRUE;
StateDesc.SrcBlend = D3D10_BLEND_SRC_ALPHA;
StateDesc.DestBlend = D3D10_BLEND_INV_SRC_ALPHA;
StateDesc.BlendOp = D3D10_BLEND_OP_ADD;
StateDesc.SrcBlendAlpha = D3D10_BLEND_ZERO;
StateDesc.DestBlendAlpha = D3D10_BLEND_ZERO;
StateDesc.BlendOpAlpha = D3D10_BLEND_OP_ADD;
StateDesc.RenderTargetWriteMask[0] = 0xf;
ID3D10BlendState* ourBlendState;
g_pd3dDevice->CreateBlendState(&StateDesc, &ourBlendState);
g_pd3dDevice->OMSetBlendState(ourBlendState, 0, 0xffffffff);
//
// Update variables that change once per frame
//
g_pWorldVariable->SetMatrix( (float*)&g_World );
g_pCubeVertices->SetFloatVectorArray( (float*) g_pCubeVerticesArray, 0, 8);
//
// Render our slices
//
// Set vertex buffer
UINT slicesStride = sizeof( SimpleVertex );
UINT slicesOffset = 0;
g_pd3dDevice->IASetVertexBuffers( 0, 1,
&g_pSlicesVertexBuffer,
&slicesStride, &slicesOffset );
// Set index buffer
g_pd3dDevice->IASetIndexBuffer( g_pSlicesIndexBuffer, DXGI_FORMAT_R32_UINT, 0 );
Les opérations ci-dessus ont permis d’attacher notre vertex buffer et
notre index buffer au pipeline. Par ailleurs, nous précisons ci-dessous que
ceux-ci contiennent des données correspondantes à une liste de triangles:
// Set primitive topology
g_pd3dDevice->IASetPrimitiveTopology( D3D10_PRIMITIVE_TOPOLOGY_TRIANGLELIST );
D3D10_TECHNIQUE_DESC slicesTechDesc;
g_pTechnique->GetDesc( &slicesTechDesc );
for( UINT p = 0; p > slicesTechDesc.Passes; ++p )
{
Ici nous précisions au pipeline quelle passe de quelle technique doit
être utilisée pour traiter les primitives dont l’on demande l’affichage par le
biais de l’appel à DrawIndexed:
g_pTechnique->GetPassByIndex( p )->Apply(0);
g_pd3dDevice->DrawIndexed( 3 * g_numSlicesTriangles, 0, 0 );
}
//
// Present our back buffer to our front buffer
//
g_pSwapChain->Present( 0, 0 );
}
Notre
Effet
Jusqu’à présent
nous avons détaillé avec autant de soin que possible le code C++ de notre
application mais nous avons donné somme toute peu d’informations sur le fichier
d’effet en lui-même. Comme avec DX10 tout passe passe par l’utilisation des
shaders et dans la grande majorité des cas par l’utilisation du système
d’effet, notre effet est une partie intégrante de notre application au même
titre que le code C++ qui la constitue aussi il est très intéressant de
décortiquer son contenu.
Ci-dessous, nous avons la déclaration des variables qui seront
utilisées par nos shaders, on y voit aussi la déclaration du format des blocks
de données qui vont être passés au vertex shader, au geometry shader et au
pixel shader:
//---------------------------------
// Constant Buffer Variables
//---------------------------------
Texture2D txDiffuse;
SamplerState samLinear
{
Filter = MIN_MAG_MIP_LINEAR;
AddressU = Wrap;
AddressV = Wrap;
};
Texture3D tdData;
SamplerState tdSamLinear
{
Filter = MIN_MAG_MIP_LINEAR;
AddressU = Wrap;
AddressV = Wrap;
AddressW = Wrap;
};
cbuffer cbNeverChanges
{
matrix View;
float4 CubeVertices[24];
int numSlices;
};
cbuffer cbChangeOnResize
{
matrix Projection;
};
cbuffer cbChangesEveryFrame
{
matrix World;
};
struct VS_INPUT
{
float4 Pos : POSITION;
float2 Tex : TEXCOORD;
};
struct GS_INPUT
{
float4 Pos : POSITION;
float2 Tex : TEXCOORD0;
};
struct PS_INPUT
{
float4 Pos : SV_POSITION;
float3 Tex : TEXCOORD0;
};
Comme on peut le voir ci-dessous notre vertex shader est absolument
inintéressant, la plupart des traitements plus intéressants sont en fait
effectués dans le GS, ce qui nous permet de travailler primitive par
primitive:
GS_INPUT VS( VS_INPUT input )
{
GS_INPUT output;
output.Pos = input.Pos;
output.Tex = input.Tex;
return output;
}
De fait notre GS
ci-dessous est lui un tantinet plus intéressant. On voit bien que l’on
travaille ici sur l’ensemble des sommets de nos primitives (au lieu de
travailler dans le VS sur chaque sommet de façon totalement indépendante des
autres sommets adjacents). Par ailleurs, nous précisons avant le début de notre
GS un maxvertexcount qui précise finalement la taille maximale de notre tableau
de vertex en sorti du GS et borne ainsi le nombre de vertex qui peuvent être
produit par notre GS.
Notre mulFactor
permet de contrôler le nombre de primitives que nous souhaitons créer à partir
de chaque primitive passé au GS, dans notre cas pour un triangle en entrée nous
en créons 10. Du coup, notre volume de données va se trouver intersecter par un
nombre plus important de primitives et pour chaque pixel, la couleur résultante
sera le résultat du fondu d’un plus grand nombre de valeurs de notre texture en
trois dimensions (le défaut de cette méthode étant pour le moment que le nombre
de contibutions n’est pas constant d’un pixel à l’autre ce qui peut poser des
problèmes en fonction du type de données que l’on souhaite visualiser). Il faut
aussi noter que si l’on peut fournir en entrée de notre pipeline des primitives
de type liste (par exemple triangle list) le GS quand à lui peut émettre un jeu
plus restreint de types primitives (par exemple dans le cas des triangles, il
s’agira forcément de triangle strip et donc dans notre cas les triangles émis
ne sont plus parallèles aux plans initialement fournis au pipeline)).
//-----------------------------------
// Geometry Shader
//------------------------------------
[maxvertexcount(30)] // *10 slices 3->30 vertices !!
void GS( triangle GS_INPUT input[3],
inout TriangleStream<PS_INPUT> OutputStream )
{
PS_INPUT output;
uint mulFactor = 10 ; // Let's get *10 slices thanks to our GS.
for(uint k=0; k>mulFactor; k++)
{
// Our z should be constant per triangle.
float z = 1.0f-2*((float)(input[0].Pos.z+((float)k/mulFactor))/numSlices);
for( uint i=0; i>3; i++ )
{
// Z coord is an index and we have to compute real z from it.
float4 pos = input[i].Pos;
pos.z = z;
output.Pos = mul( pos, World );
output.Pos = mul( output.Pos, View );
output.Pos = mul( output.Pos, Projection );
// Automatic texture coord generation.
// We need to stick in cube space.
output.Tex.x = 0.5f * pos.x + 0.5f;
output.Tex.y = 0.5f * pos.y + 0.5f;
output.Tex.z = 0.5f * pos.z + 0.5f;
OutputStream.Append( output );
}
}
OutputStream.RestartStrip();
}
Un autre point que l’on peut souligner dans le code ci-dessus est le
fait que la coordonnée z est calculée une seule fois pour l’ensemble des
sommets de notre primitive, si ce travail avait été fait dans le VS il aurait
fallu refaire le calcul pour chaque sommet. Même si dans notre cas cela peut
paraitre un peu forcé, voir anecdotique, la possibilité qui est offerte par le
GS de travailler sur l’ensemble des sommets d’une primitive au lieu de traiter chaque
sommet indépendemment des autres dans le VS ne l’est elle absolument pas.
Finalement nous arrivons sur notre pixel shader dont le seul rôle est
finalement pour un pixel couvert à un moment donné par une primitive, de
récupérer la valeur contenue dans notre texture 3D correspondante à la position
de notre primitive.
//--------------------------
// Pixel Shader
//------------------------------
float4 PS( PS_INPUT input) : SV_Target
{
float3 txCoord = float3(input.Tex.x, input.Tex.y, input.Tex.z);
return tdData.Sample( tdSamLinear, txCoord);
}
//---------------------------------------------
technique10 Render
{
pass P0
{
SetVertexShader( CompileShader( vs_4_0, VS() ) );
SetGeometryShader( CompileShader( gs_4_0, GS() ) );
SetPixelShader( CompileShader( ps_4_0, PS() ) );
}
}
Finalement les
quelques lignes ci-dessus précisent que notre technique intitulée «render»
contient une passe appelée P0 à laquelle sont associés les shaders qui ont été
définis et décrit plus haut.
Et voici une petite capture d’écran du
résultat obtenu:
Une autre perspective sur cette application
d’exemple
Ci-dessus nous
avons réalisé un exemple simple de code utilisant DX10 pour afficher un volume
de données. Maintenant qu’il s’agisse d’afficher un volume de données ou des
objets 3D comme des mesh ou des entités définies par une représentation
paramétrique de leur surface extérieure, on se rend bien compte que la plupart
des applications utilisant DX10 vont être de manière très significative plus
compliquées que l’exemple précédent. Aussi il est crucial de bien connaitre certains
outils fournis avec le SDK pour aider à l’analyse d’une application DX10. En
particulier, nous allons parler d’un outil, PIX qui fourni une aide
incalculable aussi bien pour l’analyse des performances d’une application
donnée que pour le débogage de nos shaders.
Pour les
performances, celles-ci pouvant être liées aux traitements effectués sur le GPU
et aux échanges d’informations entre le CPU et la mémoire système et le GPU et
la mémoire vidéo, il est évident qu’un profiler classique ne rendant compte que
de l’activité CPU et du nombre de cycle passé dans l’exécution du code C++ ne
pourra pas donner une image juste de ce qui se passe réellement. PIX permet d’accéder
à un nombre important de compteurs ayant traits à ces opérations d’échanges, ce
qui peut permettre d’avoir une bonne vision des choses. Je ne traiterai pas ici
de cet aspect de PIX dans la mesure où cela pourrait constituer un sujet à part
entière. Par ailleurs, il est encore un peu tôt en ce qui concerne DX10 pour
fournir un travail important pour optimiser les performances d’une application
donnée.
Par contre PIX
offre aussi une aide incommensurable pour débugger nos shaders. C’est ce point
que je vais aborder ici.
PIX permet pour un
frame donné d’inspecter l’ensemble des traitements qui ont été effectués pour
déterminer la couleur d’un pixel donné à l’écran. Il permet de visualiser les
primitives qui ont contribuées à la couleur de ce pixel, les shaders qui ont
été déroulés pour chacune de ces primitives et d’inspecter les valeurs des constantes
des shaders en question en différents endroits de ceux-ci.
Regardons donc par où notre travail avec PIX
va commencer:
Dans
l’écran ci-dessus, nous pouvons sélectionner le programme que nous souhaitons
analyser. Choisissons par ailleurs l’option: «A Single Frame
capture of Direct3D whenever F12 is pressed».
Après avoir lancé
l’exécution de notre application il est alors possible à tout moment de
celle-ci d’appuyer sur F12, puis de terminer l’application en question. Une
fois le processus correspondant terminé, nous allons avoir accès aux
informations ci-dessous :
D’abord à gauche,
nous avons la liste complète des appels DirectX effectués pendant ce frame.
Ci-dessous, nous sommes descendus au niveau le plus bas de l’arbre qui est
utilisé pour organiser l’affichage de ces appels. On peut voir en particulier
les différents appels pour spécifier des variables utilisées par nos shaders
puis pour sélectionner une passe donnée de la technique contenu dans notre
effet.
Visualiser ces
appels est déjà extrêmement intéressant mais nous voyons aussi que nous
disposons d’une autre fenêtre, à droite où pour l’instant est juste
indiqué: «Mesh data is only available for draw calls». Il est
clair que cette simple phrase ne peut que provoquer une envie irrépressible de
justement sélectionner l’appel DrawIndexed que nous voyons dans la liste de
gauche, ce qui a pour effet de provoquer l’affichage des informations visible
ci-dessous:
Nous avons alors
accès à notre tableau de vertex avec leurs attributs respectifs. Nous disposons
par ailleurs d’une vue de ceux-ci à différentes étapes de notre pipeline, avant
les traitements effectués par le vertex shader, après le VS et avant le GS… et
finalement après les opérations effectuées par le rasterizer en se basant entre
autres sur le viewport que nous avons spécifié. Tout développeur s’efforce
toujours d’être le plus rigoureux possible mais les informations ci-dessus sont
déjà d’une grande aide par exemple dans des cas de figure ou quelque chose qui
devrait être affiché à l’écran ne l’est pas.
Au jour
d’aujourd’hui avec une application DX9, il est possible de cliquer sur chaque
Vertex pour dérouler et inspecter le code du vertex shader qui l’a traité,
cette possibilité n’est pas disponible avec la version actuelle de PIX (OCT 06
SDK) pour DX10 mais cela devrait changer.
Si notre regard se
porte maintenant sur les différents onglets de notre fenêtre de droite, nous
voyons que l’onglet actuellement sélectionné est ‘Mesh’ mais il nous est
possible de naviguer dans l’onglet ‘Render’:
Comme on peut le
voir cela à pour effet d’afficher notre frame. Il est alors possible de
positionner le pointeur de la souris sur un point donné de cet image (par
exemple un point qui n’aurait pas la couleur attendue) et en effectuant un
click droit de sélectionner la fonction intitulée ‘Debug this Pixel’.
Comme on peut le
voir ci-dessus cela nous donne accès à l’ensemble des opérations qui ont
contribuées pendant ce frame à la valeur de notre pixel. Ci-dessus on peut voir
la liste des primitives ayant participées ainsi que les shaders associés. Si
l’on sélectionne l’un des shaders en question, on peut visualiser la source
correspondant et inspecter les valeurs des variables manipulées par celui-ci en
différent points de son exécution. A ce jour DX10 est toujours en béta, cette
fonctionnalité fonctionne parfaitement avec DX9 par contre avec DX10, seul le
code du shader en question est visible. Là encore les fonctionnalités
supportées par l’outil pour DX10 devraient rapidement rattraper ce qui est
possible avec DX9.
Comme nous venons
de le voir, PIX peut être d’une efficacité brutale pour analyser le
fonctionnement d’une application s’appuyant sur DirectX.
Comme je l’ai
mentionné plus haut PIX est tout aussi utile pour l’analyse des performances.
C’est intéressant
de noter que comme WPF s’appuie sur DirectX, PIX peut être utilisé pour
visualiser les opérations effectuées par milcore.dll et même pour enregistrer
l’ensemble des opérations d’affichage d’une application WPF sous une forme assez
compacte. Mais bon c’est un autre sujet.
Conclusion
Il y aurait
certainement beaucoup d’autres choses à dire au sujet de DX10. J’ai essayé de
donner ici des informations sur des points qui m’apparaissent comme importants.
J’espère avoir réussi à souligner à la fois pourquoi les performances que l’on
obtiendra avec DX10 sur Vista sont potentiellement nettement plus meilleures
que ce qui est possible aujourd’hui. L’API est en fait extrêmement flexible,
s’est débarrassé de certaines lourdeurs de la version précédente, a été pensé
pour obtenir les meilleures performances possibles.
L’API évolue de
pair avec le modèle de Driver qui lui-même évolue en concertation étroite avec
les fabricants de cartes vidéo. Les avancés amenées par cette version de
DirectX ne concernent pas que l’API avec laquelle le développeur interagi mais
touchent en profondeur tout les composants de l’OS impliqués à un moment ou à
un autre dans la communication entre une application DirectX et le GPU.
Pour finir, je
recommande la lecture de la documentation du SDK qui contient beaucoup
d’informations, les tutoriaux et les exemples fournissent une aide précieuse
ainsi que les documents publiés sur les sites MSDN traitant de DirectX.
Pour toutes remarques, commentaires ou
questions concernant ce document merci de me contacter: guillara@microsoft.com
Téléchargez
Télécharger l'exemple de code
DX10Cube.zip
289 Ko