introduction au High Level Shading Language avec un exemple simple :
shader double-face
(niveau : moyen)


Table des matières

Introduction
Problème des objets double-face
Vertex shader en assembleur
Intégration du vertex shader en assembleur
Vertex shader en HLSL
Intégration du vertex shader en HLSL
Pixel shader en HLSL
Comparaison
Programme d'exemple
Références


Introduction

Qu'est-ce que le High Level Shading Language (HLSL en abrégé) ? Un langage de programmation introduit dans DirectX 9 et destiné à remplacer l'assembleur utilisé auparavant (depuis DirectX 8) pour l'écriture de vertex et pixel shaders de plus en plus complexes (si vous ne savez pas ce qu'est un vertex ou un pixel shader, lisez la documentation de DirectX à ce sujet avant de pouvoir revenir à ce qui suit). Comme son som l'indique HLSL est donc un langage de haut niveau, proche du C dans ses principes mais plus restreint car uniquement dédié à la programmation de shaders (il n'y a par exemple pas de notion de pointeurs).
[Remarque : HLSL et Cg sont deux langages équivalents, ou plutôt un même langage appelé différemment chez Microsoft et nVidia qui l'ont co-développé].

Le but de cet article n'est pas de décrire de manière exhaustive ce langage, ses instructions et sa syntaxe; nous allons plutôt nous intéresser à son utilisation, et aux modifications que celle-ci implique dans le code qui gère l'affichage d'une application DirectX. Pour cela nous allons partir d'un problème réel, développer en assembleur un shader permettant de le résoudre, et enfin réécrire ce shader en HLSL et le comparer au code assembleur.


Problème des objets double-face

Les objets double-face ("2 sided" en anglais) sont souvent utilisés dans les jeux vidéo pour représenter des objets dont l'épaisseur est négligeable : drapeaux, tentures, surface de l'eau, voiles, etc. Ils nécessitent moins de points et de faces que les vrais volumes auxquels ils correspondent, ce qui est avantageux en terme de mémoire occupée, d'affichage, et de temps de calcul si ces objets sont animés. Dans 3D Studio Max, les matériaux possèdent un flag permettant d'indiquer si les objets auxquels ils s'appliquent ont la propriété double-face.

La différence essentielle entre un mesh standard et un objet double-face se situe au niveau de la détermination des faces visibles / invisibles (backface culling) : pour un mesh standard, une face n'est visible que si la caméra qui la regarde est située du "bon" côté de cette face, c'est à dire celui vers lequel pointe la normale à la face. A l'inverse les faces d'un objet double-face sont visibles quel que soit le côté par lequel on les regarde, ce qui permet de n'avoir besoin que de l'un des deux côtés (par exemple d'un drapeau) pour afficher l'objet en toute situation.

2 faces d'un mesh (à gauche) et d'un objet double-face (à droite), en vue de dessus

Autoriser l'affichage d'une face qu'elle soit vue de "devant" ou de "derrière" est on ne peut plus simple, il suffit d'utiliser l'instruction :
pDevice->SetRenderState (D3DRS_CULLMODE, D3DCULL_NONE);
et le tour est joué. Le résultat n'est cependant pas très satisfaisant : les normales des points de l'objet restent les mêmes quel que soit le côté vu, ce qui signifie que l'éclairage n'est pas correct pour le côté "arrière". Pour s'en convaincre, il suffit d'imaginer qu'une lumière bleue L1 est placée devant l'objet double-face, et une lumière rouge L2 derrière : les normales pointant vers L1, l'objet sera éclairé en bleu devant comme derrière (et la contribution de L2 sera nulle; ceci est dû au fait que les formules d'éclairage habituelles prennent en compte le produit scalaire de la normale au point considéré avec la direction de la lumière (ou point->lumière pour une omni), que ce produit scalaire est négatif en ce qui concerne L2 pour chacun des points, et que cela signifie par convention que L2 n'éclaire aucun des points).

vue de dessus d'une surface tournée vers L1

Comme schématisé sur le dessin de droite, il faudrait que les normales soient inversées pour le second côté afin que son éclairage soit correct. Plusieurs méthodes permettent de contourner ce problème :

- créer un deuxième mesh représentant l'objet double-face vu de derrière. Les points de ce mesh ont les mêmes positions que ceux de l'objet initial, les mêmes coordonnées de mapping s'il y en a, et des normales inversées (pour l'éclairage). L'ordre des index des points est également inversé pour chaque face : si une face de l'objet de départ utilise les points A-B-C dans cet ordre, alors la face correspondante du nouveau mesh référence C-B-A, afin de respecter la règle fixée pour le backface-culling (seules les faces CW - clockwise - ou CCW - counter clockwise - sont affichées).
Avoir deux meshes pour représenter un seul objet n'est pas très pratique : leurs déformations et déplacements éventuels doivent rester synchronisés, les tests d'intersection ou de collision sont ralentis par l'augmentation du nombre d'objets, etc.

- modifier le mesh de l'objet double-face afin qu'à chacune de ses faces corresponde la face "arrière" équivalente. C'est une solution souvent employée, qui ne nécessite aucun traitement particulier au moment de l'affichage, mais multiplie de nombre de faces de l'objet par 2. Le nombre de points n'est pas forcément doublé : il est possible d'utiliser les mêmes xyz et uv pour les deux côtés de l'objet, à condition de l'afficher en deux fois (une pour chaque côté) et d'avoir deux streams différentes pour les normales et les normales inversées. L'inconvénient de cette méthode est donc l'augmentation de la taille mémoire occupée par l'objet, et l'obligation d'utiliser au moins 2 streams pour l'afficher (afin de pouvoir changer les normales sans toucher au reste).

Comment résoudre ce problème de manière plus élégante ? Avec le "fixed function pipeline" (affichage défini et implémenté par DirectX et que l'on ne peut modifier que dans les limites fixées par les différents render states) il ne semble pas y avoir grand chose à faire. Par contre avec un vertex shader, nous voyons tout de suite que nous n'aurons aucun mal à inverser les normales pour l'éclairage lorsque ce sera nécessaire. Il n'est en revanche pas possible de déterminer dans un shader si une face est vue de l'avant ou de l'arrière, puisqu'il ne permet d'agir que sur un point isolé du mesh; ceci signifie qu'il nous faut afficher l'objet en deux fois, une pour les faces avant et l'autre pour les faces arrière. Entre les deux nous changerons le test du backface culling, ce qui évite d'avoir à inverser l'ordre des index de chaque face comme expliqué précédemment.

L'algorithme est donc le suivant :
- initialiser le test du backface-culling sur CCW (counter clockwise) ;
- afficher le mesh en utilisant un vertex shader classique ;
- s'il ne s'agit pas d'un objet double-face, le rendu de l'objet est terminé ;
- sinon, régler le backface-culling sur CW (clockwise) ;
- afficher le mesh avec un vertex shader qui inverse les normales avant de calculer l'éclairage.


Vertex shader en assembleur

Voici le code d'un vertex shader qui calcule l'éclairage des points d'un mesh par deux lumières directionnelles :

vs.1.1

//----------------------------------------------------------------------------------------
// vertex inputs
//----------------------------------------------------------------------------------------

#define iPos        v0                                      // vertex position
#define iNormal     v1                                      // vertex normal
#define iTex0       v2                                      // base texture coordinates

dcl_position        iPos
dcl_normal          iNormal
dcl_texcoord0       iTex0

//----------------------------------------------------------------------------------------
// constants
//----------------------------------------------------------------------------------------

def                 c0, 0, 0, 0, 0
#define Zero        c0                                      // c0       : 0;0;0;0

#define Matrix      c10                                     // c10-c13  : matrix
#define NFactor     c14                                     // c14      : normal factor (-1 or +1)
#define Ambient     c15                                     // c15      : global ambient * material ambient
#define MatDiff     c16                                     // c16      : material diffuse color
#define MatAlpha    c16.w

#define LightDiff1  c20                                     // c20      : light1 diffuse color
#define LightDir1   c21                                     // c21      : light1 dir in model space

#define LightDiff2  c30                                     // c30      : light2 diffuse color
#define LightDir2   c31                                     // c31      : light2 dir in model space

//----------------------------------------------------------------------------------------
// code
//----------------------------------------------------------------------------------------

m4x4                oPos, iPos, Matrix                      // transform position
mul                 r2, iNormal, NFactor                    // N or -N
mov                 oT0.xy, iTex0                           // copy tex coords

// directional light 1

dp3                 r0, r2, -LightDir1                      // N * -LightDir1
max                 r0, r0, Zero                            // clamp to [0;1]
mul                 r1, r0.x, MatDiff                       //   * material diffuse
mul                 r1, r1, LightDiff1                      //   * light1   diffuse

// directional light 2

dp3                 r0, r2, -LightDir2                      // N * -LightDir2
max                 r0, r0, Zero                            // clamp to [0;1]
mul                 r3, r0.x, MatDiff                       //   * material diffuse
mul                 r3, r3, LightDiff2                      //   * light2   diffuse

// final color

add                 r0, r1, r3
add                 oD0, r0, Ambient                        //   + ambient
mov                 oD0.w, MatAlpha                         // preserve alpha

Pas de panique si vous n'êtes pas habitué à voir ce genre de choses : HLSL est précisément là pour vous éviter d'avoir à les apprendre / comprendre. Voyons tout de même rapidement à quoi elles correspondent dans cet exemple :

- "vs.1.1" indique que le code qui suit utilise les possibilités des vertex shaders version 1.1, c'est à dire qu'il peut fonctionner sur la majorité des cartes 3D qui supportent les vertex shaders (GeForce2 et plus, Radéon...).

- le bloc "vertex inputs" définit le format des points reçus par le shader : position (xyz) dans v0, puis normale dans v1, puis coordonnées de mapping dans v2. Ce sont les instructions "dcl_..." qui réalisent cette déclaration, les "#define" ne servent qu'à associer des noms plus lisibles à v0, v1 et v2 (comme en C). Ce format doit bien sûr correspondre à celui de la ou des streams utilisées par l'instruction DrawIndexedPrimitive qui appelle le shader.

- les constantes sont des valeurs (de 4 float chacune) définies dans le shader (cas de c0 aussi appelée Zero), ou passées au shader par le programme principal. Ici nous avons la matrice (qui occupe à elle seule 4 registres) qui permet de transformer les points du repère de l'objet dans celui de la caméra, un coefficient multiplicateur NFactor appliqué aux normales, la lumière ambiante de la scène, la couleur diffuse du matériau de l'objet, et enfin les couleurs et directions des deux lumières directionnelles. Ces constantes ne sont pas figées pour toute la durée de vie d'une application : ce terme signifie qu'elles ne peuvent pas être modifiées par le shader, et que le programme principal ne peut changer leurs valeurs qu'entre deux appels à DrawIndexedPrimitive.

- le bloc "code" réalise les opérations suivantes : il transforme les coordonnées du point dans le repère de la caméra, inverse la normale si nécessaire, copie simplement les coordonnées de mapping, calcule la contribution de chaque lumière à l'éclairage et enfin détermine la couleur finale du point.

Dans la partie précédente, nous avons vu que l'algorithme présenté utilisait deux shaders différents, selon qu'il fallait ou non inverser la normale. Lorsque l'on a déjà écrit un certain nombre de shaders, il est embêtant de devoir multiplier ce nombre par 2 et d'avoir deux versions différentes de chacun juste pour le cas particulier des objets double-face. C'est là qu'intervient le coefficient NFactor : avant l'éclairage, la normale est multipliée par NFactor, qui peut prendre n'importe quelle valeur fixée avant l'appel à DrawIndexedPrimitive. Les valeurs intéressantes et utilisées dans le programme d'exemple sont évidemment -1.f et 1.f, qui inversent on non la normale.

Une remarque sur les directions des lumières : elles sont utilisées pour effectuer un produit scalaire avec les normales aux points de l'objet. Ces normales étant définies dans le repère de l'objet, il est plus facile et plus rapide de ramener les directions des lumières dans le repère de l'objet une seule fois avant l'appel à DrawIndexedPrimitive, que de transformer chaque normale dans le repère où sont définies les lumières (en général le repère du monde, mais elles peuvent également être attachées à d'autres objets d'une scène). Contrairement à ce qui se passe lorsqu'on utilise le fixed function pipeline, il ne faut pas oublier de normer ces directions, sans quoi le résultat du produit scalaire (qui sert en fait à déterminer l'angle - via son cosinus - entre deux vecteurs) ne voudra rien dire.


Intégration du vertex shader en assembleur

Pour la simplicité du programme d'exemple je n'ai pas créé de classe de matériau etc : le vertex et le pixel shader (que nous verrons plus loin) ainsi que l'objet de test (un simple quad) et les textures sont des membres de la classe de rendu DirectX 9. Vous les trouverez donc tous dans le fichier RendererDX9.h. (Note : il n'est pas nécessaire d'avoir un pixel shader pour utiliser le vertex shader précédent, chacune des 2 parties du fixed function pipeline peut être remplacée par un shader indépendamment de l'autre).

  // protected data

  protected:

    bool                      m_bo2Sided;

    // DX9 access

    LPDIRECT3DDEVICE9         m_pDevice;
    LPDIRECT3D9               m_pD3D;

    // IB/VB

    LPDIRECT3DINDEXBUFFER9    m_pIB;
    LPDIRECT3DVERTEXBUFFER9   m_pVB;

    // vertex shader

    LPDIRECT3DVERTEXSHADER9   m_pVertexShader;
    LPDIRECT3DVERTEXDECLARATION9 m_pVertexDeclaration;
    LPD3DXCONSTANTTABLE       m_pVertexConstants;

    LPDIRECT3DPIXELSHADER9    m_pPixelShader;
    LPD3DXCONSTANTTABLE       m_pPixelConstants;

    // textures

    LPDIRECT3DTEXTURE9        m_pTexFront;
    LPDIRECT3DTEXTURE9        m_pTexBack;

Le vertex shader est créé par les lignes suivantes :

  // vshader

  D3DVERTEXELEMENT9 decl[] =
    {
      { 0,  0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0 },
      { 0, 12, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_NORMAL,   0 },
      { 0, 24, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 0 },
        D3DDECL_END()
    };

  hrErr = m_pDevice->CreateVertexDeclaration(decl,&m_pVertexDeclaration);
  if(FAILED(hrErr))
    {
    MessageBox(NULL,"CreateVertexDeclaration failed","CRendererDX9::Create",MB_OK|MB_ICONEXCLAMATION);
    return false;
    }

  DWORD dwFlags = 0; 
  dwFlags |= D3DXSHADER_DEBUG;
  LPD3DXBUFFER pCode   = NULL;
  LPD3DXBUFFER pErrors = NULL;
  hrErr = D3DXAssembleShaderFromFile("dx9/vshader.vsh",NULL,NULL,dwFlags,&pCode,&pErrors);
  if(pErrors)
    {
    char* szErrors = (char*)pErrors->GetBufferPointer();
    pErrors->Release();
    }
  if(FAILED(hrErr)) 
    {
    MessageBox(NULL,"vertex shader creation failed","CRendererDX9::Create",MB_OK|MB_ICONEXCLAMATION);
    return false;
    }

  char* szCode = (char*)pCode->GetBufferPointer();
  hrErr = m_pDevice->CreateVertexShader((DWORD*)pCode->GetBufferPointer(),&m_pVertexShader);
  pCode->Release();
  if(FAILED(hrErr))
    {
    MessageBox(NULL,"CreateVertexShader failed","CRendererDX9::Create",MB_OK|MB_ICONEXCLAMATION);
    return false;
    }

Cette procédure est expliquée dans la documentation du SDK de DirectX, reportez-vous y pour plus de détails sur les différents paramètres des fonctions etc. Notez simplement que le premier bloc sert à définir le format des points, et que la variable pErrors permet de récupérer des informations très utiles (le texte de l'erreur et le numéro de ligne) en cas d'erreur de compilation du shader. Vous voyez que le shader est ici dans un fichier texte indépendant nommé vshader.vsh, vous pouvez aussi l'inclure directement dans votre fichier cpp, de la façon suivante :

TCHAR szVShader[] = _T(""
"vs.1.1\n"
"\n"
"// vertex inputs\n"
"\n"
"#define iPos        v0                                      // vertex position\n"
"#define iNormal     v1                                      // vertex normal\n"
"#define iTex0       v2                                      // base texture coordinates\n"
"\n"
"dcl_position        iPos\n"
"dcl_normal          iNormal\n"
"dcl_texcoord0       iTex0\n"
"\n"
"// constants\n"
"\n"
[...etc...]
"mov                 oD0.w, MatAlpha                         // preserve alpha");

La compilation du shader se fait alors avec l'instruction :

    hrErr = D3DXAssembleShader(szVShader,sizeof(szVShader),NULL,NULL,dwFlags,&pCode,&pErrors);

Une fois le shader créé, et avant de l'utiliser, il faut (par exemple à chaque frame) initialiser ses constantes :

  m4Total = m4Proj*m4View*m4World;
  m4Total.Transpose();
  m_pDevice->SetVertexShaderConstantF(10,(float*)&m4Total,4);         // trf matrix

  CVect4D v4NFactor(1.f);
  m_pDevice->SetVertexShaderConstantF(14,(float*)&v4NFactor,1);       // normal factor

  CVect4D v4Ambient(0.25f,0.25f,0.25f,1.f);
  m_pDevice->SetVertexShaderConstantF(15,(float*)&v4Ambient,1);       // ambient color

  CVect4D v4MatDiffuse(1.f,1.f,1.f,1.f);
  m_pDevice->SetVertexShaderConstantF(16,(float*)&v4MatDiffuse,1);    // material diffuse

      // lights

  CVect4D v4LightDiffuse1(0.f,0.f,1.f,1.f);
  m_pDevice->SetVertexShaderConstantF(20,(float*)&v4LightDiffuse1,1); // light1 diffuse

  m4World.Invert();
  CVect4D v4LightDir1(0.f,0.f,-1.f,0.f);
  v4LightDir1 = m4World*v4LightDir1;
  v4LightDir1.Normalize1();
  m_pDevice->SetVertexShaderConstantF(21,(float*)&v4LightDir1,1);     // light1 direction

  CVect4D v4LightDiffuse2(1.f,0.f,0.f,1.f);
  m_pDevice->SetVertexShaderConstantF(30,(float*)&v4LightDiffuse2,1); // light2 diffuse

  CVect4D v4LightDir2(0.f,0.f,1.f,0.f);
  v4LightDir2 = m4World*v4LightDir2;
  v4LightDir2.Normalize1();
  m_pDevice->SetVertexShaderConstantF(31,(float*)&v4LightDir2,1);     // light2 direction

Le premier paramètre de SetVertexShaderConstantF est le numéro de la constante à modifier (par exemple c10 pour la matrice), vient ensuite l'adresse d'un tableau de float, et enfin le nombre de constantes consécutives à initialiser (4 pour la matrice, qui occupe les registres c10 à c13 du shader). N'oubliez pas que chaque constante est constituée de 4 float.

Tout est maintenant prêt, il n'y a plus qu'à utiliser le vertex shader pour l'affichage :

  m_pDevice->SetVertexDeclaration(m_pVertexDeclaration);
  m_pDevice->SetVertexShader     (m_pVertexShader);
  m_pDevice->SetPixelShader      (m_pPixelShader);

  m_pDevice->SetStreamSource     (0,m_pVB,0,sizeof(VERTEX_SIMPLE));
  m_pDevice->SetIndices          (  m_pIB);
  m_pDevice->SetTexture          (TextureIndex,m_pTexFront);

  D3DPRIMITIVETYPE Type = D3DPT_TRIANGLELIST;

  HRESULT hrErr = m_pDevice->DrawIndexedPrimitive(Type,0,0,4,0,2);    // 4 vtx, 2 tris
  if(FAILED(hrErr)) return hrErr;


Vertex shader en HLSL

Passons à présent à ce qui est vraiment le sujet de cet article, et voyons immédiatement à quoi ressemble le même shader écrit en HLSL :

float4x4    Matrix;
float4      NFactor;
float4      Ambient;
float4      MatDiff;

float4      LightDiff1;
float4      LightDir1;

float4      LightDiff2;
float4      LightDir2;

struct VS_INPUT
{
    float4  Pos     : POSITION;
    float4  Normal  : NORMAL;
    float2  Tex0    : TEXCOORD0;
};

struct VS_OUTPUT
{
    float4  Pos     : POSITION;
    float4  Color   : COLOR;
    float2  Tex0    : TEXCOORD0;
};

////////////////////////////////////////

VS_OUTPUT VShade(VS_INPUT In)
{
    VS_OUTPUT Out = (VS_OUTPUT) 0; 

    Out.Pos       = mul(Matrix,In.Pos);
    Out.Tex0      = In.Tex0;
    float4 Normal = In.Normal*NFactor;                      // N or -N

    // directional light 1

    float4 Color1 = max(0,dot(Normal,-LightDir1)) *MatDiff*LightDiff1;

    // directional light 2

    float4 Color2 = max(0,dot(Normal,-LightDir2)) *MatDiff*LightDiff2;

    // final color

    Out.Color     = Color1+Color2+Ambient;
    Out.Color.a   = MatDiff.a;

    return Out;
}

Les premières lignes servent à déclarer les variables accessibles depuis le programme principal. De nouveaux types ont été ajoutés par rapport au langage C : float2, float3 et float4 qui correspondent respectivement à 2, 3 et 4 float, et float4x4 qui représente les 16 valeurs d'une matrice 4x4 (reportez vous à la documentation de DirectX pour la liste complète). Il est possible de déclarer des variables globales seulement connues du shader en utilisant le mot clé static, et de définir des constantes :

static       float4 GlobalVar;
static const float  SpecPower = 64.f;

On trouve ensuite la déclaration de deux structures, contenant les données reçues et retournées par le shader. Les noms de ces structures et de leurs champs sont libres, ce sont les mots clés POSITION, NORMAL, TEXCOORD0, COLOR etc qui indiquent au compilateur la provenance ou la destination des valeurs et donc les liens à établir avec les registres v0, v1, v2... ou oD0, oPos, oT0... vus dans le code assembleur.

Dans VS_INPUT, notez que la position et la normale sont définies comme étant des float4 alors que la stream fournie par le programme principal ne contient pour chacune que 3 valeurs x, y et z. Les registres d'un shader contenant 4 float, une valeur w est en fait ajoutée de manière invisible à l'entrée du shader ; elle vaut 1.f pour la position comme pour la normale. Il est tout à fait correct d'utiliser des float3 dans VS_INPUT pour la position et la normale, mais il est plus facile de travailler avec des float4 : en effet, pour transformer la position dans le repère de la caméra, il faut la multiplier par la matrice 4x4, et ceci n'est pas possible avec un vecteur à seulement 3 dimensions.

VS_OUTPUT permet de retourner des coordonnées de mapping 2D, une position en coordonnées homogènes (donc 4 float), et une couleur rgba (également 4 float).

Vient ensuite le code du shader, et sa fonction principale. Il n'est pas obligatoire de l'appeler "main", il suffit de passer le nom de cette fonction au compilateur pour qu'il l'identifie comme point d'entrée du programme. Il existe plusieurs façons de retourner les résultats du shader avec VS_OUTPUT, celle que j'utilise ci-dessus me semble être la plus naturelle. Comme en assembleur, un vertex shader doit renvoyer au minimum une position.

Comme vous le voyez, ce code est extrêmement simple à comprendre pour qui connaît le C ; c'est là tout l'intérêt du HLSL. L'autre avantage réside dans le fait que les registres r0, r1 etc n'apparaissent plus nulle part, ce qui signifie que diverses fonctions écrites en HLSL pourront être facilement réutilisées les unes avec les autres sans risquer de conflit au niveau des registres employés. Même si le code ci-dessus n'en contenait pas, il est en effet possible d'écrire des fonctions et de les appeler comme en C :


inline float4 DirectionalLight(float4 Normal,float4 Dir,float4 Diffuse)
{
    return max(0,dot(Normal,-Dir))*Diffuse;
}

VS_OUTPUT VShade(VS_INPUT In)
{
    VS_OUTPUT Out = (VS_OUTPUT) 0; 

    Out.Pos       = mul(Matrix,In.Pos);
    Out.Tex0      = In.Tex0;
    float4 Normal = In.Normal*NFactor;                      // N or -N

    Out.Color     = Ambient+DirectionalLight(Normal,LightDir1,LightDiff1)*MatDiff
                           +DirectionalLight(Normal,LightDir2,LightDiff2)*MatDiff;
    Out.Color.a   = MatDiff.a;

    return Out;
}

Le mot-clé inline n'est pas absolument nécessaire, actuellement toutes les fonctions sont inlinées par le compilateur. Comme en C la fonction doit être placée dans le source avant celle qui l'appelle, pour que son prototype soit connu lorsque le compilateur arrive sur l'appel. J'ai précisé dans l'introduction qu'il n'y a pas de notion de pointeur en HLSL, ni donc de référence ; les paramètres sont passés par valeurs, ce qui est en fait sans importance car il n'y a pas réellement d'appel de fonction puisque tout est inliné. Notez que les fonctions récursives (c'est à dire qui s'appellent elles-mêmes) ne sont pas supportées.

Dans le code ci-dessus, différentes fonctions sont utilisées sans que leurs déclarations apparaissent où que ce soit : max, dot et mul. Ce sont des fonctions dites "intrinsèques", elles font partie du langage lui-même car elles sont très souvent nécessaires dans les shaders. Il en existe beaucoup d'autres (entre 70 et 80), décrites dans la documentation du SDK de DirectX. Parmi elles on trouve les fonctions mathématiques atan2, cos, sin, cross (produit vectoriel), exp, length (d'un vecteur), log, normalize, pow (puissance), sqrt, tan...


Intégration du vertex shader en HLSL

Le code qui crée le vertex shader est le même que pour la version assembleur, mis à part l'appel à D3DXAssembleShaderFromFile qui est remplacé par :

  hrErr = D3DXCompileShaderFromFile("dx9/vshader.fx",NULL,NULL,"VShade","vs_1_1",dwFlags,&pCode,&pErrors,&m_pVertexConstants);

Le code du shader est cette fois-ci placé dans un fichier .fx, extension habituelle des "effets" DirectX. Un effet (au sens de "effet spécial") est une méthode d'affichage que l'on souhaite appliquer à des objets (par exemple, un rendu de type cartoon), et qui est constitué d'une ou plusieurs "techniques" : chacune correspond à une version plus ou moins évoluée de l'effet recherché, et la version la plus adaptée sera utilisée en fonction du matériel disponible sur la machine de l'utilisateur. Pour être complet, chaque technique peut posséder une ou plusieurs passes d'affichage. Dans la documentation de DirectX, HLSL est très lié à la programmation des effets, et ne semble pas pouvoir être employé en dehors de ce contexte ; il n'en est rien, comme le démontre l'utilisation de D3DXCompileShaderFromFile.

Le 4ème paramètre est celui qui sert à fournir le nom du point d'entrée du shader, ici "VShade". Le suivant indique la version de vertex shader souhaitée, ce qui permet au compilateur d'effectuer des optimisations spécifiques ; cependant si vous compilez le shader dans une version (par exemple 3.0) qui n'est pas supportée par la carte graphique de votre système, la compilation réussira mais la création du shader par CreateVertexShader échouera. Enfin, le dernier paramètre permet au compilateur de retourner une table de constantes, qui va servir à initialiser les variables du shader.

En effet, ces variables ne correspondent plus à des registres c0, c1, etc choisis par le programmeur, mais à des registres désignés par le compilateur à sa convenance, et il n'y a donc plus moyen d'y accéder par leurs numéros. La nouvelle méthode consiste à interroger la table de constantes pour obtenir un handle sur la variable que l'on veut modifier, et à utiliser ce handle pour accéder au(x) registre(s) associé(s) à cette variable, toujours via la table de constantes :

  if(m_pVertexConstants)
    {
    D3DXHANDLE handle;
    if(handle = m_pVertexConstants->GetConstantByName(NULL,"Matrix"))
      m_pVertexConstants->SetMatrix(m_pDevice,handle,(D3DXMATRIX*)&m4Total);

    if(handle = m_pVertexConstants->GetConstantByName(NULL,"NFactor"))
      m_pVertexConstants->SetVector(m_pDevice,handle,(D3DXVECTOR4*)&v4NFactor);

    if(handle = m_pVertexConstants->GetConstantByName(NULL,"Ambient"))
      m_pVertexConstants->SetVector(m_pDevice,handle,(D3DXVECTOR4*)&v4Ambient);

    if(handle = m_pVertexConstants->GetConstantByName(NULL,"MatDiff"))
      m_pVertexConstants->SetVector(m_pDevice,handle,(D3DXVECTOR4*)&v4MatDiffuse);

      // lights

    if(handle = m_pVertexConstants->GetConstantByName(NULL,"LightDiff1"))
      m_pVertexConstants->SetVector(m_pDevice,handle,(D3DXVECTOR4*)&v4LightDiffuse1);

    if(handle = m_pVertexConstants->GetConstantByName(NULL,"LightDir1"))
      m_pVertexConstants->SetVector(m_pDevice,handle,(D3DXVECTOR4*)&v4LightDir1);

    if(handle = m_pVertexConstants->GetConstantByName(NULL,"LightDiff2"))
      m_pVertexConstants->SetVector(m_pDevice,handle,(D3DXVECTOR4*)&v4LightDiffuse2);

    if(handle = m_pVertexConstants->GetConstantByName(NULL,"LightDir2"))
      m_pVertexConstants->SetVector(m_pDevice,handle,(D3DXVECTOR4*)&v4LightDir2);
    }

Cela n'est pas compliqué, notez simplement la fonction SetMatrix employée pour la matrice, tandis que SetVector est utilisée pour toutes les autres variables (qui sont de type float4 dans le shader).

Le code précédant l'appel de DrawIndexedPrimitive ne change pas par rapport à l'utilisation de la version assembleur du vertex shader. Comme pour cette dernière, vous pouvez inclure le shader HLSL dans le fichier CPP, et le compiler avec D3DXCompileShader.


Pixel shader en HLSL

L'écriture en HLSL s'applique aussi aux pixel shaders, en voici un exemple simple :

sampler   baseTex;

struct PS_INPUT
{
    float4  Color   : COLOR0;
    float2  Tex0    : TEXCOORD0;
};

struct PS_OUTPUT
{
    float4  Color   : COLOR;
};

////////////////////////////////////////

PS_OUTPUT PShade(PS_INPUT In)
{
    PS_OUTPUT Out = (PS_OUTPUT) 0; 

    Out.Color = In.Color * tex2D(baseTex,In.Tex0);
    return Out;
}

Ce shader utilise les coordonnées de mapping (interpolées pour chaque pixel à partir de celles des points de la face dont il fait partie) qu'il reçoit pour lire une couleur dans une texture, et la multiplie par la couleur diffuse (également interpolée) du pixel considéré. La fonction intrinsèque tex2D est celle qui sert à lire dans la texture, elle prend en paramètres un "sampler" et des coordonnées de mapping. Un sampler est un objet introduit dans DirectX 9 qui sert à lire une valeur dans une texture, en fonction des différents filtrages (minification, magnification et mipmapping) qui lui sont associés avec l'instruction SetSamplerState. Cette distinction entre sampler et texture stage a été créée car on peut avoir besoin de lire plusieurs valeurs dans une même texture au cours d'une passe d'affichage ; à l'heure actuelle le nombre de texture stages est toujours limité à 8, en revanche une carte peut posséder jusqu'à 16 samplers.

La création du pixel shader dans le programme principal ne présente pas de particularité notable :

  // pshader

  hrErr = D3DXCompileShaderFromFile("dx9/pshader.fx",NULL,NULL,"PShade","ps_1_1",dwFlags,&pCode,&pErrors,&m_pPixelConstants);
  if(pErrors)
    {
    char* szErrors = (char*)pErrors->GetBufferPointer();
    pErrors->Release();
    }
  if(FAILED(hrErr)) 
    {
    MessageBox(NULL,"pixel shader creation failed","CRendererDX9::Create",MB_OK|MB_ICONEXCLAMATION);
    return false;
    }

  szCode = (char*)pCode->GetBufferPointer();
  hrErr  = m_pDevice->CreatePixelShader((DWORD*)pCode->GetBufferPointer(),&m_pPixelShader);
  pCode->Release();
  if(FAILED(hrErr))
    {
    MessageBox(NULL,"CreatePixelShader failed","CRendererDX9::Create",MB_OK|MB_ICONEXCLAMATION);
    return false;
    }

Par contre l'initialisation du sampler est un peu différente de celle des autres variables :

    if(m_pPixelConstants && (handle = m_pPixelConstants->GetConstantByName(NULL,"baseTex")))
      {
      D3DXCONSTANT_DESC constDesc;
      UINT count = 1;
      m_pPixelConstants->GetConstantDesc(handle,&constDesc,&count);

      if(constDesc.RegisterSet == D3DXRS_SAMPLER)
        TextureIndex = constDesc.RegisterIndex;
      }

Comme précédemment on récupère un handle grâce à la table de constantes, mais celui-ci n'est pas utilisé pour modifier la variable du shader : il sert à obtenir (dans TextureIndex) le numéro du texture stage correspondant au sampler. Cet index est ensuite utilisé comme à l'accoutumée pour définir la texture éventuellement placée dans ce texture stage, avec l'instruction :

  m_pDevice->SetTexture(TextureIndex,m_pTexFront);

Dans le pixel shader ci-dessus, un seul sampler est utilisé, et il n'y a aucune raison que le compilateur choisisse un autre texture stage que le premier (index 0). Remplacer TextureIndex par zéro dans l'appel à SetTexture a donc de grandes chances de fonctionner. Jusqu'au jour où vous écrirez un shader comportant plusieurs samplers, et où les mauvaises surprises risquent d'apparaître ; mieux vaut prendre l'habitude de passer par la table de constantes même pour des pixel shaders simples.

Remarque : si aucune texture n'est associée au sampler utilisé dans le pixel shader, tex2D retourne un pixel noir. Ce comportement diffère de celui du fixed function pipeline, qui considère que la texture par défaut est blanche, de sorte que si un objet n'a pas de texture, alors texture*diffuse = diffuse. Ceci signifie que pour utiliser correctement un shader contenant une instruction tex2D vos objets doivent avoir une texture, si leur matériau n'en contient pas vous pouvez toujours leur assigner une texture blanche de dimension 1x1.

Pour terminer cette partie, voici une fonction qui convertit les résultats du pipeline graphique en niveaux de gris, et montre que vous pouvez effectuer les calculs de votre choix comme dans un vertex shader :

PS_OUTPUT PShade(PS_INPUT In)
{
    PS_OUTPUT Out    = (PS_OUTPUT) 0; 

    Out.Color        = In.Color * tex2D(baseTex,In.Tex0);
    float fIntensity = Out.Color.r*0.30f + Out.Color.g*0.59f + Out.Color.b*0.11f;
    Out.Color        = float4(fIntensity,fIntensity,fIntensity,1.f);

    return Out;
}


Comparaison

Un compilateur HLSL en ligne de commande (fxc.exe) est fourni dans le SDK de DirectX 9. Outre la compilation d'un shader en format binaire, il permet de sauver le code assembleur généré dans un fichier texte afin de l'examiner. Cela peut se révéler très instructif, notamment en ce qui concerne les optimisations relatives aux différentes versions (1.1, 2.0, 3.0...) de shaders utilisées. L'appel de fxc ressemble à ceci :

fxc /T:vs_2_0 /E:VShade /Zi /Fc:vshader.fxc vshader.fx
fxc /T:ps_2_0 /E:PShade /Zi /Fc:pshader.fxc pshader.fx

Ici, vshader.fx et pshader.fx sont compilés en version 2.0, leurs fonctions principales sont respectivement VShade et PShade, et le code généré est écrit dans les fichiers vshader.fxc et pshader.fxc. Voyons le contenu de vshader.fxc, que nous pouvons comparer à la version assembleur du vertex shader de cet article :

//
// Generated by Microsoft (R) D3DX9 Shader Compiler
//
//  Source: vshader.fx
//  Flags: /E:VShade /T:vs_2_0 /Zi 
//

// Parameters:
//
//     float4x4 Matrix;
//     float4 NFactor;
//     float4 Ambient;
//     float4 MatDiff;
//     float4 LightDiff1;
//     float4 LightDir1;
//     float4 LightDiff2;
//     float4 LightDir2;
//
//
// Registers:
//
//     Name         Reg   Size
//     ------------ ----- ----
//     Matrix       c0       4
//     NFactor      c4       1
//     Ambient      c5       1
//     MatDiff      c6       1
//     LightDiff1   c7       1
//     LightDir1    c8       1
//     LightDiff2   c9       1
//     LightDir2    c10      1
//

    vs_2_0
    def c11, 0, 0, 0, 0
    dcl_position v0  // In<0,1,2,3>
    dcl_normal v1  // In<4,5,6,7>
    dcl_texcoord v2  // In<8,9>

#line 32 "C:\temp\fairyengine\vshader.fx"
    mul r0, v0.x, c0
    mad r2, v0.y, c1, r0
    mad r4, v0.z, c2, r2
    mad oPos, v0.w, c3, r4  // ::VShade<0,1,2,3>
    mul r1, v1, c4  // Normal<0,1,2,3>

#line 38
    dp4 r8.w, r1, -c8
    max r3.w, r8.w, c11.x
    mul r10.xyz, r3.w, c6
    mul r5.xyz, r10, c7  // Color1<0,1,2>

#line 42
    dp4 r5.w, r1, -c10
    max r5.w, r5.w, c11.x
    mul r7.xyz, r5.w, c6

#line 46
    mad r9.xyz, r7, c9, r5
    add oD0.xyz, r9, c5  // ::VShade<4,5,6>
    mov oD0.w, c6.w  // ::VShade<7>

#line 33
    mov oT0.xy, v2  // ::VShade<8,9>

// approximately 16 instruction slots used


// 0000:  fffe0200  0098fffe  47554244  00000028  _......_DBUG(___
// 0010:  00000244  00000000  00000001  00000048  D.______.___H___
[etc...]

On voit tout d'abord que le compilateur a assigné aux différentes variables des registres différents des nôtres, ce qui est plutôt normal, et montre bien qu'on ne peut pas initialiser leurs valeurs avec SetVertexShaderConstantF. La déclaration du format des points est par contre identique à la nôtre, puisqu'il s'agit de l'interprétation par le compilateur de la structure VS_INPUT du code HLSL.

Les 4 premières instructions de la "ligne 32" correspondent au m4x4 de notre code assembleur (m4x4 est une instruction "complexe" qui prend en principe 4 cycles soit le même temps que le mul et les 3 mad ci-dessus), et la suivante à l'inversion éventuelle de la normale. Les instructions de la "ligne 38" effectuent le calcul de l'éclairage par la 1ère lumière directionnelle, et celles de la "ligne 42" par la 2ème. La "ligne 46" correspond au calcul de la couleur finale : on voit que le compilateur a remplacé un mul et un add de notre code assembleur par une seule opération mad, ce qui est bien vu. Enfin la "ligne 33" copie les coordonnées de mapping.

En résumé, on s'aperçoit que sur cet exemple simple le code généré est quasiment identique à celui écrit à la main, et qu'il économise même une instruction. Sachant qu'HLSL va s'enrichir de nouvelles instructions, et que les shaders sont appelés à être de plus en plus longs et complexes, on voit bien l'intérêt de ce langage qui devrait assez rapidement remplacer l'assembleur utilisé jusqu'à présent.


Programme d'exemple

Le programme d'exemple (536 Ko) qui accompagne cet article est tout simple : la caméra tourne autour d'un quad éclairé de face par une lumière directionnelle bleue et de dos par une lumière directionnelle rouge. Le côté arrière du quad n'existe pas dans les vertex et index buffers de l'objet, il s'agit bien d'un objet double-face ; deux commandes dans le menu "file" permettent de passer en wireframe et surtout de désactiver l'affichage du côté arrière. Notez que je profite du rendu en deux passes (une pour chaque côté) pour changer la texture de l'objet (c'est un petit plus) ; les coordonnées de texture restent en revanche les mêmes pour les deux côtés, ce qui oblige à inverser la texture arrière horizontalement afin qu'elle soit affichée dans le bon sens. Une autre possibilité consisterait à modifier les coordonnées de mapping en fonction du côté tracé, comme cela est fait pour la normale.

face vue de devant
face vue de derrière
texture avant
texture arrière


Références

la documentation HLSL du SDK de DirectX 9 sur MSDN.
"Taking It Higher with the High Level Shader Language", article MSDN. Contient surtout des exemples.
"Introduction to the DirectX 9 High Level Shading Language" : extrait à lire absolument du livre ShaderX2 (septembre 2003).


haut de la page