Réalisation d'un moteur 3D en C++, partie VIII : les shaders

Image non disponible

Autrefois considérés comme une fonctionnalité high-tech permettant de coder des effets graphiques impressionnants, les shaders sont aujourd'hui un élément incontournable et au coeur du système de rendu. Ainsi, nous verrons dans cette nouvelle partie comment intégrer et gérer les différentes facettes de nos shaders.

Article lu   fois.

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

1. Introduction

Véritable révolution dans le monde de la programmation graphique, les shaders ont permis aux jeux de faire un bon énorme dans la qualité visuelle. Eclairage par pixel, ombres temps réel, offset bump mapping, fourrure, reflexions, réfractions, ... La liste des nouveaux effets à la mode est très longue, et impressionnante.

Malheureusement tout n'est pas rose, et les shaders souffrent d'un très gros défaut : leur jeunesse. Ainsi pour tirer partie des effets à la mode il faudra investir dans du hardware récent. Autre conséquence, qui nous touche plus nous développeurs de moteur 3D : le travail monstrueux requis pour gérer correctement tous les langages / technologies de shaders existant, ainsi que les problèmes de compatibilité.

2. Rappel sur les shaders

2.1. C'est quoi un shader ?

Initialement utilisés par Pixar pour effectuer le rendu des images de leurs films, les shaders ont en 2001 été introduits dans les API 3D temps réel (DirectX 8.0 et OpenGL 1.4), et très vite commencés à être supportés par les principaux constructeurs de cartes graphiques (nVidia, ATI).

Mais qu'est-ce qu'un shader au juste ?

Les shaders sont des programmes, écrits dans un langage pseudo-assembleur ou plus récemment pseudo-C, directement executable par la carte graphique. Cela permet ainsi de court-circuiter certaines parties du FFP (Fixed Function Pipeline -- les fonctions précablées dans le hardware), et d'offrir au développeur une énorme flexibilité.
Il existe deux types de shaders : les vertex shaders (ou encore vertex programs) et les pixel shaders (ou encore fragment programs, ou fragment shaders). Tous deux fonctionnent de la même manière, excepté qu'ils remplissent chacun un rôle différent.

Selon l'API et le langage utilisés, le vocabulaire utilisé pour désigner les shaders varie : vertex shader, pixel shader (DirectX), vertex program, fragment shader, ou encore fragment program (OpenGL). Dans un souci de clarté, nous n'utiliserons ici que le couple "vertex / pixel shader".

Le vertex shader vient remplacer les parties "transformation & éclairage" du pipeline de rendu, se plaçant donc au début de celui-ci, juste après le tesselateur de surface. Règle importante : un vertex shader ne pourra toujours traiter qu'un vertex à la fois. Ainsi il va recevoir en entrée tous les vertices (les uns après les autres) que vous aurez envoyé à la carte graphique, et devra renvoyer ceux-ci transformés et éclairés. Hormis ce minimum syndical à effectuer, vous êtes libre de faire subir à vos vertices à peu près tout et n'importe quoi. Et c'est là la grande force des shaders : il n'y a qu'à faire marcher son esprit créatif pour obtenir des effets toujours plus impressionnants.

Le pixel shader vient lui remplacer les parties "texturage, filtrage et mélange" du pipeline, ce qui va cette fois le placer à la fin du pipeline, juste avant l'étape finale de rendu. De la même manière que pour un vertex shader, un pixel shader ne pourra toujours traiter qu'un pixel à la fois. Son rôle sera de créer le pixel final qui sera écrit sur le color-buffer, à partir des informations sur celui-ci telles que les textures qu'il utilise, la couleur diffuse, la couleur spéculaire, et les coordonnées de textures. Tout comme pour les vertex shaders, il est tout à fait possible de faire subir à vos pixels n'importe quel autre traitement, et de créer ainsi des effets tout aussi chouettos.

Schéma du pipeline 3D
Vue d'ensemble du pipeline 3D, et situation des vertex et pixel shaders (en rouge) dans celui-ci

2.2. Comment ça marche ?

Avant toute chose, les shaders sont des programmes. Ils se présentent donc soit sous forme de fichiers, soit directement sous forme de chaîne de caractères dans votre application C++ ; l'API 3D étant ensuite capable de se débrouiller avec le nom de fichier ou le pointeur vers la chaîne de caractère.

A leur apparition, il n'existait que des langages pseudo-assembleur pour écrire les shaders. La syntaxe était donc très simple : une liste d'instructions élémentaires (mov, add, mul, ...), une liste de registres (constantes, entrées, sorties, ...) et le tour est joué -- un peu comme pour l'assembleur x86 que vous connaissez déjà (ou pas).

Voici par exemple un pixel shader ASM 1.1 DirectX9 très simple, effectuant une transformation des pixels en niveaux de gris :

 
Sélectionnez
; On précise toujours quelle version est utilisée
ps.1.1

; Définition d'une constante dans le registre c0
def c0, 0.30, 0.59, 0.11, 1.0

; On indique que l'on va utiliser la texture de niveau 0, identifiée par t0
tex t0

; On effectue un produit scalaire entre t0 (la texture) et c0 (la constante définie plus haut)
; On place le résultat dans r0, registre temporaire
dp3 r0, t0, c0

; On multiplie le résultat précédent par la couleur diffuse, stockée dans le registre d0
; On place le résultat dans r0, qui est aussi le registre de sortie
mul r0, r0, d0

Avec l'arrivée de versions de shaders plus élaborées, plus riches en instructions, ainsi que de programmes toujours plus complexes, est apparue la nécessité de développer des langages de plus haut niveau pour les shaders. Ainsi sont apparus le HLSL (High Level Shading Language) chez DirectX, le GLSL (openGL Shading Language) chez OpenGL, et le Cg (C for Graphics) chez nVidia.
Tous ces langages possèdent une syntaxe proche du C, permettant de gérer les nouveautés des shaders (boucles, conditions, variables de types entiers, booléens, ...) beaucoup plus naturellement et facilement.

2.3. Les différents langages, technologies et APIs actuels

Je vous le disais en introduction, le principal défaut des shaders est la diversité des langages et technologies permettant de les utiliser, et les incompatibilités que cela entraîne. Cela concerne surtout OpenGL : contrairement à DirectX qui n'est développé que par Microsoft et peut se permettre une mise à jour tous les deux mois, la standardisation d'OpenGL est contrôlée par l'ARB, un commité composé des plus grands constructeurs de hardware. Les nouvelles fonctionnalités mettent donc souvent beaucoup de temps à être étudiées, votées, approuvées puis finalement intégrées. Ainsi le support des shaders (enfin, chez l'ARB on préfère le terme program) ne s'est fait que très tardivement, surtout pour les fragment programs. Comme les constructeurs n'attendent généralement pas sur l'ARB pour implémenter les nouvelles fonctionnalités, on se retrouve ainsi avec une multitude d'extensions, bien sûr différentes pour chacun d'eux tant au niveau du langage que de l'utilisation au niveau de l'API.
Ce phénomène tend à disparaître avec la normalisation des versions / langages de shaders, mais cela concerne encore à l'heure actuelle une large tranche de hardware que l'on ne peut pas encore considérer obsolète.

Voici donc un éventail, en théorie complet, des différents langages et extensions utilisés selon les APIs et les constructeurs.

2.3.1. DirectX

Chez DirectX, tout est très simple au niveau des shaders. Intégrés depuis DirectX 8.0, ils ont été gérés très tôt (et très bien) par les différentes cartes graphiques ; qui plus est leur utilisation est simple et homogène au niveau de l'API.

2.3.1.1. Les vertex / pixel shaders ASM

Comme dit précédemment, les shaders ont été introduits en version 1.x à partir de DirectX 8.0, puis en version 2.x et 3.x dans DirectX9. Leur utilisation dans l'API est très simple, le SDK fournit tout ce qu'il faut pour se faire la main rapidement sur la gestion des shaders. La syntaxe quant à elle peut rebuter mais reste peu compliquée, comme vous pouvez le voir dans l'exemple de pixel shader du chapitre précédent.

Pour de plus amples détails, un très bon tutoriel (en 4 parties) d'introduction aux shaders première génération est disponible sur gamedev.net :
Partie I (vertex shaders)
Partie II (vertex shaders)
Partie III (pixel shaders)
Partie IV (pixel shaders)

2.3.1.2. Les shaders HLSL

HLSL est le langage haut niveau made in Microsoft, dont la syntaxe proche du C permet d'écrire des shaders très facilement. Vous trouverez dans le SDK tout ce qu'il faut pour vous amuser avec le HLSL : des exemples, des tutoriels, et même un HLSL workshop comportant des exemples à compléter pour s'entraîner.

Au niveau de l'API, deux choix s'offrent à vous. Le premier est très simple : compilez votre shader via D3DXCompileShader ou D3DXCompileShaderFromFile, puis vous pourrez ensuite le charger / gérer exactement de la même manière que les shaders ASM.
La seconde option est d'utiliser les shaders HLSL conjointement aux effets (.fx), ce qui permet d'avoir un système relativement puissant. Là encore, le SDK est très riche en tutoriels et exemples.

Exemple de pixel shader HLSL :

 
Sélectionnez
float4 main(float4 Diffuse : COLOR0,
            float2 TexCoord0 : TEXCOORD0,
            uniform sampler2D Tex0) : COLOR0
{
    return tex2D(Tex0, TexCoord0) * Diffuse;
}

2.3.2. OpenGL

Chez OpenGL par contre, les choses se compliquent. Voici une rapide liste de (a priori) toutes les extensions constructeur permettant de gérer certaines versions de shaders sur les cartes ne supportant pas les shaders ARB.

2.3.2.1. Les register combiners nVidia

Introduites avant la standardisation des fragment shaders, les extensions "GL_NV_register_combiners2" et "GL_NV_texture_shader" permettent sur GeForce 3 et 4 de gérer un équivalent de pixel shaders 1.0 DirectX.
Pour cela, nVidia livre un programme open source nommé nvparse, qui s'occupe de parser et charger les shaders. Celui-ci supporte deux langages : l'ASM DirectX, et un langage correspondant au paramètrage des combiners.

Voici un exemple de ce dernier :

 
Sélectionnez
!!TS1.0
texture_2d();

!!RC1.0
{
  rgb
  {
    col0 = tex0.rgb * col0.rgb;
  }
  alpha
  {
    col0 = tex0.a * col0.a;
  }
}
out.rgb = unsigned(col0.rgb);
out.a = unsigned(col0.a);

Plus d'informations ici : http://developer.nvidia.com/object/nvparse.html.

2.3.2.2. Les vertex / fragment shaders nVidia

Toujours chez nVidia, on trouve sur les cartes plus récentes les extensions "GL_NV_vertex_program" et "GL_NV_fragment_program" permettant de gérer les nouvelles versions de shaders (2.0, 3.0 et 4.0). C'est à l'heure actuelle le seul moyen de les utiliser, l'ARB n'ayant officialisé que les versions 1.0.

Exemple de fragment program nVidia :

 
Sélectionnez
!!FP1.0

TEX R0, f[TEX0].xyxx, TEX0, 2D;
MULR o[COLR], R0, f[COL0];

END

Plus d'informations ici : http://oss.sgi.com/projects/ogl-sample/registry/NV/fragment_program.txt.

2.3.2.3. Les fragment programs ATI

ATI n'est pas en reste, et permet aux cartes Radeon 8500 ou + de gérer un équivalent de pixels shaders DirectX en version 1.1, 1.2, 1.3 ou 1.4 via l'extension "GL_ATI_fragment_shader".

Plus d'informations ici : http://oss.sgi.com/projects/ogl-sample/registry/ATI/fragment_shader.txt.

2.3.2.4. Les vertex / fragment programs ARB

Ce sont les premières (et à l'heure actuelle les seules) extensions validées par l'ARB pour gérer les shaders ASM. "GL_ARB_vertex_program" correspond plus ou moins aux vertex shaders 1.0, et "GL_ARB_fragment_program" aux pixel shaders 2.0. Attention donc : pour utiliser un pixel shader 1.x ou équivalent il faudra passer par les extensions constructeur vues précédemment, il n'y a rien de standard.
La syntaxe correspondant à ces versions de shaders est une syntaxe assembleur, proche de celle des shaders DirectX bien que différente.

Exemple de vertex program :

 
Sélectionnez
!!ARBvp1.0

ATTRIB v24 = vertex.texcoord[0];
ATTRIB v19 = vertex.color;
ATTRIB v18 = vertex.normal;
ATTRIB v16 = vertex.position;

PARAM c0[4] = { program.local[0..3] };

MOV result.color.front.primary, v19;
MOV result.texcoord[0], v24;

DP4 result.position.x, c0[0], v16;
DP4 result.position.y, c0[1], v16;
DP4 result.position.z, c0[2], v16;
DP4 result.position.w, c0[3], v16;

END

La documentation des extensions :
http://oss.sgi.com/projects/ogl-sample/registry/ARB/vertex_program.txt
http://oss.sgi.com/projects/ogl-sample/registry/ARB/fragment_program.txt

2.3.2.5. Les shaders GLSL

GLSL est l'équivalent du HLSL, à savoir le langage haut niveau made in OpenGL. Par contre, et contrairement à son frère de chez Microsoft, la gestion du GLSL n'a absolument rien à voir avec les shaders bas niveaux (ici ARB), il faudra donc éplucher un bon tutoriel pour s'en servir.
Le GLSL est supporté depuis les cartes Radeon 9500, GeForce FX ou équivalent, via les extensions "GL_ARB_shading_language_100", "GL_ARB_shader_objects", "GL_ARB_fragment_shader" et "GL_ARB_vertex_shader".

Exemple de vertex shader GLSL :

 
Sélectionnez
varying vec3 lightDir, normal;

void main()
{
    normal = normalize(gl_NormalMatrix * gl_Normal);
    lightDir = normalize(vec3(gl_LightSource[0].position));

    gl_TexCoord[0] = gl_MultiTexCoord0;
    gl_Position = ftransform();
}

Un peu de doc :
http://www.opengl.org/documentation/oglsl.html
http://www.lighthouse3d.com/opengl/glsl/

3. Cg

Là où les moteurs 3D les plus courageux (Ogre, Irrlicht) ont décidé de retrousser leurs manches et de gérer au cas par cas chacune des technologies / langages cités dans le chapitre précédent, nous allons au contraire être plus malins et laisser tout ce boulot à quelqu'un d'autre.

En effet comme déjà évoqué précédemment, nVidia a développé son langage haut niveau pour les shaders : le Cg. Mais ce qui nous intéresse ici ce n'est pas tant le langage en lui-même (bien qu'intéressant), mais plutôt l'API sous-jacente qui gère justement, et de manière bien meilleure que nous ne l'aurions fait, toutes les APIs et la majorité des extensions OpenGL citées précédemment ; en fait, toutes sauf les extensions ATI.

Que demande le peuple ?

3.1. Premiers pas avec Cg

Cg étant une API développée par nVidia, tout ce qu'il faut pour démarrer avec se trouve sur leur site développeur. Afin de pouvoir développer avec Cg il faut télécharger et installer le SDK Cg. Celui-ci contient tout ce qu'il faut : fichiers bibliothèques, en-têtes, fichiers DLLs, documentations, exemples de code pour chaque API, et exemples de shaders.

Les dernières versions de Cg (1.3 et 1.4) comportent un bug empêchant la compilation des vertex shaders 1.1 sous DirectX9 (profil CG_PROFILE_VS_1_1). Ainsi si vous comptez supporter ce profil, il vaudra mieux installer une version antérieure de Cg (la version utilisée ici est la 1.2). Ce bug aurait semble-t-il déjà été signalé à nVidia, peut-être sera-t-il donc corrigé dans la prochaine version.

Si vous ne souhaitez que compiler ou executer la démo fournie avec ce tutoriel, pas besoin d'installer le SDK Cg : tout ce qu'il vous faut est fourni dans les bibliothèques externes (zip).

Enfin, vous trouverez sur le site CgShaders de nombreuses ressources utiles, notamment un forum actif et des tonnes d'exemples de shaders.

3.2. Fonctionnement de Cg

Cg peut s'utiliser de deux manières.

La première permet d'utiliser la partie haut niveau de Cg et ne nécessite donc que le runtime de base de Cg (cg.lib / cg.dll). Vous pourrez compiler un programme, récupérer son code ASM, ses paramètres, ... mais toute la partie bas niveau, propre à chaque API, ne sera pas fournie. En d'autres termes, il faudra développer nous-mêmes la gestion de tous les types de shaders cités plus haut, et fournir à Cg celui qui va bien selon le profil choisi. Autant dire que ça ne sert à rien ici. Cela peut par contre être utile dans des moteurs implémentant déjà toute la partie bas niveau (par exemple Ogre), et souhaitant pouvoir utiliser Cg indépendamment de l'API 3D.

La seconde permet elle d'encapsuler absolument toute la partie relative aux APIs 3D et nécessite donc les runtimes correspondants : cgD3D9 et cgGL. C'est là que Cg nous intéresse, car c'est lui qui va s'occuper de trouver le meilleur profil de shader selon les capacités de notre carte graphique, et le gérer de manière transparente pour nous. En clair nous n'aurons à aucun moment besoin d'appeler une fonction spécifique à DirectX ou OpenGL pour gérer nos shaders, et nous n'aurons jamais à nous soucier de la version de shader ou des extensions OpenGL à utiliser selon le hardware. Plutôt cool.

Schématisation du fonctionnement de Cg
Schématisation du fonctionnement de Cg

A noter que le SDK contient un compilateur Cg en ligne de commande (cgc.exe), qui se révèlera bien utile lors du développement de vos shaders. Notamment pour détecter les erreurs de syntaxe de ceux-ci et les corriger sans avoir à lancer votre application, ou encore générer le code ASM correspondant à vos shaders, soit pour tenter de les optimiser, soit pour les utiliser dans une appli qui ne supporte pas Cg.

3.3. Le langage

Le langage Cg, proche du C, est en fait une copie conforme du langage HLSL de chez DirectX ; nVidia a travaillé conjointement à Microsoft sur cette partie. Un bon point déjà, puisque vous pourrez d'ores et déjà utiliser vos connaissances et vos shaders HLSL directement en Cg.

Nous n'aborderons pas ici les détails du langage, ceci étant parfaitement fait dans les nombreux documents et exemples du SDK de Cg, ainsi que dans divers tutoriels sur le net.

Voici par exemple l'équivalent Cg du pixel shader ASM vu précédemment, effectuant une mise en niveaux de gris :

 
Sélectionnez
float4 main(float4 Diffuse : COLOR0,      // Couleur diffuse
            float2 TexCoord0 : TEXCOORD0, // Coordonnées de texture 0
            uniform sampler2D Tex0)       // Texture 0
: COLOR0
{
    // Définition des coefficients pour la mise en niveaux de gris
    float3 Coeff = float3(0.30, 0.59, 0.11);

    // On renvoie le produit scalaire entre les coefficients et
    // le pixel (texture modulée par la couleur diffuse)
    return dot(tex2D(Tex0, TexCoord0) * Diffuse.rgb, Coeff);
}

Comme vous le voyez, le langage est déjà nettement plus flexible, explicite et concis.

Dernier point de ce court chapitre sur le langage : les paramètres. Comme nous allons voir par la suite comment intéragir sur ceux-ci dans notre code, il est important de voir en premier lieu ce qu'ils sont, et comment ils fonctionnent.

Si vous vous rappelez bien, nous avons vu précédemment que les shaders fonctionnaient avec un certain nombre de registres :

  • Les registres d'entrée, qui sont automatiquement remplis avec les données à traiter (vertex ou pixel).
  • Les registres de sortie, que vous devez remplir avec le résultat de vos shaders.
  • Les registres temporaires servant aux calculs intermédiaires.
  • Et enfin... les registres de constantes.

Les registres de constantes sont importants car ce sont eux qui permettent l'interaction entre le shader et l'application. En effet, si ces registres ne sont pas modifiables dans un shader, ils le sont en revanche dans l'application via des fonctions de l'API 3D (ou de Cg dans notre cas). Cela permet d'envoyer certaines variables et d'influer sur le comportement des shaders. L'exemple le plus typique est l'envoi de la matrice de transformation (modèle - vue - projection) aux vertex shaders, car ceux-ci devront quoiqu'il arrive effectuer la transformation des vertices par cette matrice. On peut également imaginer envoyer la position de la lumière courante, la direction de la caméra, le temps actuel, une couleur, ... Bref, tout ce qui peut servir au shader.

Cg étant un langage pseudo-C, ces paramètres sont assimilables à des variables. A ce titre elles possèdent donc un nom (beaucoup plus pratique qu'un indice de registre), et peuvent être reçues en paramètre des fonctions ou encore déclarées globalement. Le runtime Cg nous permet de modifier ces variables très facilement, nous verrons comment dans le chapitre suivant.

4. Intégration de Cg au moteur

Après cette description des shaders et de Cg, entrons dans le vif du sujet et voyons comment nous allons implémenter tout ça dans notre moteur.

Nous utiliserons pour cela le design habituel : une classe de base abstraite (IShaderBase), une spécialisation pour chaque renderer (CDX9Shader et COGLShader), et pour encapsuler le tout de manière plus sympathique et plus sûre, une classe wrapper (CShader). Afin d'intégrer tout cela à notre gestion automatique de ressources, nous construirons également un loader capable de créer un IShaderBase à partir d'un fichier (CShadersLoader).

Diagramme de classes de la gestion des shaders
Diagramme de classes de la gestion des shaders

4.1. Le loader de shaders

Commençons donc par le début, à savoir le loader. Comme d'habitude il faudra le faire dériver de ILoader<IShaderBase>, puisque ce sont des IShaderBase qui seront chargés et renvoyés au reste du moteur.

 
Sélectionnez
class CShadersLoader : public ILoader<IShaderBase>
{
    ...
};

C'est ce loader qui va manipuler la partie "haut-niveau" de Cg : initialisations, libérations, et chargement d'un programme Cg à partir d'un fichier. Le reste sera relégué aux renderers, et aux runtimes Cg correspondants (cgD3D9 et cgGL).

 
Sélectionnez
CShadersLoader::CShadersLoader(TShaderType Type) :
m_Type(Type)
{
    // Enregistrement du callback d'erreur
    cgSetErrorCallback(&OnError);

    // Création du context
    m_Context = cgCreateContext();
}

La première chose à faire avant d'utiliser Cg, est de créer un contexte. Le contexte peut être unique ou non, l'important étant que c'est à partir de lui que nous allons pouvoir effectuer toutes nos opérations futures avec Cg.
Cg nous permet également d'enregistrer une fonction callback, qui sera appelée à chaque erreur détectée par Cg ; pratique pour éviter de tester chaque retour de fonction.
Voici notre fonction de gestion d'erreur :

 
Sélectionnez
void CShadersLoader::OnError()
{
    throw CException(cgGetErrorString(cgGetError()));
}

Elle récupère l'erreur survenue (cgGetError), sa description sous forme de chaîne (cgGetErrorString) puis la remonte sous forme d'exception.

 
Sélectionnez
CShadersLoader::~CShadersLoader()
{
    // Destruction du context
    if (m_Context)
        cgDestroyContext(m_Context);
}

A la destruction du loader il faudra cette fois détruire le contexte, via la fonction cgDestroyContext.

 
Sélectionnez
IShaderBase* CShadersLoader::LoadFromFile(const std::string& Filename)
{
    // Récupération du profil et des options de compilation
    CGprofile    Profile = Renderer.GetShaderProfile(m_Type);
    const char** Options = const_cast<const char**>(Renderer.GetShaderOptions(m_Type));

    // Chargement et compilation du shader
    CGprogram Program;
    try
    {
        Program = cgCreateProgramFromFile(m_Context,
                                          CG_SOURCE,
                                          Filename.c_str(),
                                          Profile,
                                          "main",
                                          Options);
    }
    catch (CException& E)
    {
        throw CLoadingFailed(Filename, E.what() +
                                       std::string("\n") +
                                       cgGetLastListing(m_Context));
    }

    return Renderer.CreateShader(Program, m_Type);
}

Vient finalement la plus grosse partie du loader : la fonction LoadFromFile. Dans un premier temps, on déclare une variable de type CGprogram, qui représente le shader Cg que nous allons charger. Cette tâche est effectuée par la fonction cgCreateProgramFromFile, qui prend en paramètre notre contexte, un flag indiquant que nous allons lui donner à manger du code source écrit en Cg, le nom du fichier, le profil (ie. la version de shader bas niveau à utiliser), le point d'entrée du shader (qui sera ici toujours "main"), et finalement les options de chargement.
Le profil utilisé et les options correspondantes sont spécifiques à chaque API et sont donc stockées au niveau du renderer, mais nous verrons cela plus tard.

 
Sélectionnez
MediaManager.RegisterLoader(new CShadersLoader(SHADER_VERTEX), "vcg");
MediaManager.RegisterLoader(new CShadersLoader(SHADER_PIXEL),  "pcg");

Il ne reste donc plus qu'à enregistrer auprès du gestionnaire de médias notre loader, ou plutôt nos loaders : un pour les vertex shaders, et un pour les pixel shaders. En effet même si Cg ne fait aucune différence entre les deux types de shaders, l'API 3D le fait et nous devrons donc veiller à ne pas perdre cette différenciation.

Et voilou, dorénavant nos shaders pourront être chargés automatiquement, reste plus qu'à les coder !

4.2. La classe abstraite

A ce niveau il ne reste en fait plus grand chose à faire pour avoir des shaders fonctionnels. La classe de base, IShaderBase, aura très peu de travail : principalement stocker le programme Cg (de type CGprogram comme nous l'avons vu précédemment), stocker son type (vertex shader ou pixel shader -- c'est important car Cg ne fait pas la distinction), et fournir un accès en écriture aux paramètres du shader. Et bien sûr, en toute bonne ressource qu'elle est, elle devra hériter de IResource afin de bénéficier de son compteur de référence et de sa gestion sympathique via le ResourceManager.

 
Sélectionnez
class YES_EXPORT IShaderBase : public IResource
{
public :

    // Construit le shader à partir d'un programme Cg
    IShaderBase(CGprogram Program, TShaderType Type);

    // Destructeur
    virtual ~IShaderBase();

    // Renvoie le type du shader
    TShaderType GetType() const;

    // Change un paramètre (scalaire ou vecteur) du shader
    virtual void SetParameter(const std::string& Name, const float* Value) = 0;

    // Change un paramètre (matrice) du shader
    virtual void SetParameter(const std::string& Name, const CMatrix4& Value) = 0;

protected :

    // Renvoie le CGparameter associé à un identifiant
    CGparameter GetParameter(const std::string& Name) const;

    // Données membres
    CGprogram   m_Program; ///< Programme Cg associé au shader
    TShaderType m_Type;    ///< Type du shader (vertex / pixel)
};

Voyons à présent le détail de ces fonctions, très simple puisque cela ne consiste grosso modo qu'à reléguer le sale boulot au runtime Cg.

 
Sélectionnez
IShaderBase::IShaderBase(CGprogram Program, TShaderType Type) :
m_Program(Program),
m_Type   (Type)
{

}

Rien de magique dans le constructeur : on stocke les paramètres reçus.

 
Sélectionnez
IShaderBase::~IShaderBase()
{
    // Destruction du programme
    if (m_Program)
        cgDestroyProgram(m_Program);
}

Le destructeur lui ne devra pas oublier de détruire le programme via la fonction cgDestroyProgram.

 
Sélectionnez
CGparameter IShaderBase::GetParameter(const std::string& Name) const
{
    return cgGetNamedParameter(m_Program, Name.c_str());
}

Enfin, nous avons la fonction GetParameter qui ne sert qu'à récupérer un paramètre (sous forme de CGparameter) donné par son nom. Cette fonction est en accès protégé, elle ne servira qu'aux classes dérivées.

Un petit mot sur les fonctions SetParameter pour conclure ce chapitre. Tout d'abord il faut en distinguer deux versions : la première prenant un pointeur sur floats (obligatoirement 4), la seconde prenant une matrice. Cette séparation est nécessaire, car un quadruplet de floats occupe un registre shader, alors qu'une matrice, composée de 16 floats, doit en occuper 4.
Il est important de noter que nous aurions pu utiliser le runtime "haut-niveau" de Cg pour implémenter ces fonctions et ainsi nous passer de classes dérivées (puisque ce sont les seuls traitement nécessitant les runtimes Dx et OGL) : il existe en effet les fonctions cgSetParameter et cgSetMatrixParameter. Malheureusement elles ne fonctionnent pour le moment qu'avec le runtime OpenGL, pour gérer l'envoi de paramètre avec DirectX il faudra obligatoirement passer par la version du runtime DirectX (cgD3D9SetUniform et cgD3D9SetUniformMatrix -- voir plus loin). Sur ce coup là, faudra engueuler nVidia.

4.3. Les spécialisations DirectX et OpenGL

Comme nous venons de le voir, la seule tâche nécessitant d'être spécialisée est l'envoi de paramètres. Enfin pas tout à fait : il faut également dire au runtime cgD3D9 ou cgGL de prendre en charge comme un grand notre shader.

Cela se fait par la fonction cgGLLoadProgram pour OpenGL

 
Sélectionnez
COGLShader::COGLShader(CGprogram Program, TShaderType Type) :
IShaderBase(Program, Type)
{
    cgGLLoadProgram(m_Program);
}

Et par la fonction cgD3D9LoadProgram pour DirectX

 
Sélectionnez
CDX9Shader::CDX9Shader(CGprogram Program, TShaderType Type) :
IShaderBase(Program, Type)
{
    cgD3D9LoadProgram(m_Program, true, 0);
}

La version DirectX prend deux paramètres supplémentaires : le premier indiquant si l'on souhaite utiliser la fonctionnalité parameter shadowing, le second permettant de passer des options au compilateur de shader DirectX (par exemple D3DXASM_DEBUG -- voir la doc du SDK Dx pour la liste des flags valides). Le parameter shadowing permet d'avoir une gestion des paramètres identique à celle d'OpenGL, à savoir qu'un changement de paramètre n'est pas immédiat : ils sont stockés en interne et ne sont envoyés que lors de l'envoi du shader à l'API. Sans parameter shadowing, Cg enverrait directement les paramètres du shader à DirectX à chaque appel de SetParameter.

Viennent maintenant les fonctions SetParameter justement, qui ne sont là encore que des appels aux fonctions correspondantes de l'API Cg :

 
Sélectionnez
void CDX9Shader::SetParameter(const std::string& Name, const float* Value)
{
    cgD3D9SetUniform(GetParameter(Name), Value);
}

void CDX9Shader::SetParameter(const std::string& Name, const CMatrix4& Value)
{
    cgD3D9SetUniformMatrix(GetParameter(Name), reinterpret_cast<const D3DXMATRIX*>(&Value.Transpose()));
}
 
Sélectionnez
void COGLShader::SetParameter(const std::string& Name, const float* Value)
{
    cgGLSetParameter4fv(GetParameter(Name), Value);
}

void COGLShader::SetParameter(const std::string& Name, const CMatrix4& Value)
{
    cgGLSetMatrixParameterfc(GetParameter(Name), Value);
}

Ce sera tout pour nos spécialisations de IShaderBase, reste maintenant à agrémenter nos renderers des fonctions vitales : création des shaders, et changement de ceux-ci.

Comme nous l'avons vu dans le chapitre concernant le loader, il faut stocker dans nos renderers le meilleur profil de chaque type de shader ainsi que les options de chargement correspondantes.

 
Sélectionnez
class YES_EXPORT IRenderer
{
    ...

    CGprofile   m_VSProfile;    ///< Profil utilisé pour créer les vertex shaders
    CGprofile   m_PSProfile;    ///< Profil utilisé pour créer les pixel shaders
    const char* m_VSOptions[2]; ///< Options utilisées pour créer les vertex shaders
    const char* m_PSOptions[2]; ///< Options utilisées pour créer les pixel shaders
};

Une fois encore, leur récupération (que l'on peut placer dans la fonction membre Setup du renderer) ne pose aucun problème :

 
Sélectionnez
void CDX9Renderer::Setup(HWND Hwnd)
{
    ...

    // Paramètrage du runtime Cg D3D
    cgD3D9SetDevice(m_Device);
    m_VSProfile    = cgD3D9GetLatestVertexProfile();
    m_PSProfile    = cgD3D9GetLatestPixelProfile();
    m_VSOptions[0] = cgD3D9GetOptimalOptions(m_VSProfile);
    m_VSOptions[1] = NULL;
    m_PSOptions[0] = cgD3D9GetOptimalOptions(m_PSProfile);
    m_PSOptions[1] = NULL;
}

Le runtime cgD3D9 ayant besoin du device pour se débrouiller, il ne faut pas oublier de lui fournir via la fonction cgD3D9SetDevice. Il ne faudra pas non plus oublier de le retirer avant de détruire le device (sinon gare à la fuite mémoire), en appelant cgD3D9SetDevice avec NULL comme paramètre.

 
Sélectionnez
void COGLRenderer::Setup(HWND Hwnd)
{
    ...

    // Paramètrage du runtime Cg OGL
    m_VSProfile = cgGLGetLatestProfile(CG_GL_VERTEX);
    m_PSProfile = cgGLGetLatestProfile(CG_GL_FRAGMENT);
    cgGLSetOptimalOptions(m_VSProfile);
    cgGLSetOptimalOptions(m_PSProfile);
    m_VSOptions[0] = NULL;
    m_PSOptions[0] = NULL;
}

Côté OpenGL aucune initialisation particulière à faire pour le runtime cgGL, on peut même s'économiser le stockage des paramètres puisque Cg s'en occupe via la fonction cgGLSetOptimalOptions ; ceux-ci seront ensuite automatiquement pris en compte lors de l'appel à cgCreateProgramFromFile.

Ceci étant fait, il reste à implémenter la fonction de création de shaders (CreateShader), ainsi que celle de changement (SetVertexShader / SetPixelShader).
CreateShader est limité à son minimum syndical, à savoir renvoyer une instance de la classe de shader associée au renderer :

 
Sélectionnez
IShaderBase* CDX9Renderer::CreateShader(CGprogram Program, TShaderType Type) const
{
    return new CDX9Shader(Program, Type);
}
 
Sélectionnez
IShaderBase* COGLRenderer::CreateShader(CGprogram Program, TShaderType Type) const
{
    return new COGLShader(Program, Type);
}

Les fonctions de changement de shader (SetVertexShader et SetPixelShader) sont elles aussi très simples. On y ajoute toute de même un test, afin de s'assurer que le type de shader reçu en paramètre colle bien avec ce que l'on veut ; n'oubliez pas que Cg ne fait pas cette distinction.

 
Sélectionnez
void CDX9Renderer::SetVertexShader(const IShaderBase* Shader)
{
    const CDX9Shader* DxShader = static_cast<const CDX9Shader*>(Shader);

    if (DxShader)
    {
        if (DxShader->GetType() != SHADER_VERTEX)
            throw CException("Type de shader incorrect : pixel shader reçu dans SetVertexShader");

        cgD3D9BindProgram(DxShader->GetProgram());
    }
    else
    {
        m_Device->SetVertexShader(NULL);
    }
}

Remarque : il n'y a apparemment aucun moyen de ne spécifier aucun shader directement via l'API cgD3D9 (avec cgD3D9BindProgram(NULL) par exemple), ainsi nous sommes obligés d'utiliser D3D directement avec m_Device->SetVertexShader(NULL).

 
Sélectionnez
void COGLRenderer::SetVertexShader(const IShaderBase* Shader)
{
    const COGLShader* OGLShader = static_cast<const COGLShader*>(Shader);

    if (OGLShader)
    {
        if (OGLShader->GetType() != SHADER_VERTEX)
            throw CException("Type de shader incorrect : pixel shader reçu dans SetVertexShader");

        cgGLBindProgram(OGLShader->GetProgram());
        cgGLEnableProfile(m_VSProfile);
    }
    else
    {
        cgGLDisableProfile(m_VSProfile);
        cgGLUnbindProgram(m_VSProfile);
    }
}

Rien de particulier pour la version OpenGL, si ce n'est qu'il ne faut pas oublier qu'il y a deux fonctions à appeler.

Quant à la fonction SetPixelShader, elle est identique à sa petite soeur SetVertexShader, on pourra donc se passer de l'étude de son code.

4.4. La classe englobante

Dernier maillon de notre gestion sans faille (tout du moins on espère s'en approcher) des shaders, la classe CShader. Elle ne sert qu'à encapsuler de manière plus sympathique nos pointeurs sur IShaderBase, afin de fournir une sémantique de valeur à tout ce tintouin et occasionnellement quelques fonctionnalités supplémentaires. Vous l'aurez compris, ce sera cette classe qui sera finalement manipulée par nous et par l'utilisateur.

 
Sélectionnez
class YES_EXPORT CShader
{
public :

    // Charge le shader à partir d'un fichier
    void LoadFromFile(const std::string& Filename);

    // Décharge le shader
    void Unload();

    // Récupère un pointeur sur le shader interne
    const IShaderBase* GetShader() const;

    // Change un paramètre du shader (float)
    void SetParameter(const std::string& Name, float Value);

    // Change un paramètre du shader (vector 2)
    void SetParameter(const std::string& Name, const TVector2F& Value);

    // Change un paramètre du shader (vector 3)
    void SetParameter(const std::string& Name, const TVector3F& Value);

    // Change un paramètre du shader (vector 4)
    void SetParameter(const std::string& Name, const TVector4F& Value);

    // Change un paramètre du shader (matrice)
    void SetParameter(const std::string& Name, const CMatrix4& Value);

    // Change un paramètre du shader (couleur)
    void SetParameter(const std::string& Name, const CColor& Value);

private :

    // Données membres
    CSmartPtr<IShaderBase, CResourceCOM> m_Shader; ///< Pointeur sur le shader
};

Comme vous le voyez, nous avons une toute tripotée de fonctions SetParameter, chacune prenant un paramètre de type différent. Ces fonctions ne font rien d'autre que de transformer le paramètre reçu en float* et le renvoyer à m_Shader, mais elles seront bien pratiques puisqu'elles nous éviteront de se précoccuper du type de ce que l'on envoie en paramètre. Pour rappel, dans un shader un paramètre peut représenter n'importe quoi : une couleur, une matrice, un vecteur, une valeur, ... Ces fonctions ne sont donc pas anodines.

Reste la fonction LoadFromFile, qui encapsule les appels au MediaManager et au ResourceManager de manière totalement automatique :

 
Sélectionnez
void CShader::LoadFromFile(const std::string& Filename)
{
    // On regarde si le shader n'a pas déjà été chargée
    m_Shader = CResourceManager::Instance().Get<IShaderBase>(Filename);

    // Si il ne l'est pas, on le charge
    if (!m_Shader)
    {
        // Création du shader
        m_Shader = CMediaManager::Instance().LoadMediaFromFile<IShaderBase>(Filename);

        // Ajout aux ressources
        CResourceManager::Instance().Add(Filename, m_Shader);
    }
}

5. Un petit exemple

Après avoir étudié en détail chaque composante de l'implémentation des shaders, voyons finalement comment tout ceci s'utilise et intéragit avec le moteur, avec comme à l'accoutumée un petit exemple fonctionnel :

 
Sélectionnez
// -- Déclaration de notre vertex shader et de notre pixel shader --
CShader VertexShader;
CShader PixelShader;

// -- Chargement à partir de fichiers --
VertexShader.LoadFromFile("Cartoon.vcg");
PixelShader.LoadFromFile("Cartoon.pcg");

// -- Envoi de paramètres --

// La matrice de transformation doit être envoyée à tout vertex shader,
// puisque celui-ci doit quoiqu'il arrive se charger de la transformation des vertices
CMatrix4 MatView, MatProj;
Renderer.GetMatrix(MAT_MODELVIEW, MatView);
Renderer.GetMatrix(MAT_PROJECTION, MatProj);
m_VertexShader.SetParameter("ModelViewProj", MatView * MatProj);

// On imagine que notre pixel shader possède un paramètre nommé "Color",
// servant par exemple à moduler la couleur finale
m_PixelShader.SetParameter("Color", CColor(0, 128, 128));

// -- Envoi des shaders à l'API --
// -- Tout rendu passera maintenant par eux --
Renderer.SetVertexShader(VertexShader.GetShader());
Renderer.SetPixelShader(PixelShader.GetShader());

// -- Rendu de la scène ... --

// -- Désactivation des shaders --
// -- Le rendu est de nouveau "normal" (ie. passe par le FFP) --
Renderer.SetVertexShader(NULL);
Renderer.SetPixelShader(NULL);

6. Conclusion

Plusieurs choses importantes sont à retenir de ce tutoriel.

Premièrement, il n'existe pas une seule et unique "technologie" de shaders, mais bien une multitude. Tout ceci tend à disparaître au travers de l'évolution des langages et des APIs, mais cela reste encore un gros morceau de la programmation de shaders à l'heure actuelle.

Deuxièmement, il faut bien garder en tête que derrière tous ces langages et toutes ces APIs, sont développés des bibliothèques et des outils permettant une utilisation des shaders plus facile et plus portable. C'est le cas de la bibliothèque Cg, qui nous a permis de gérer à la fois DirectX, OpenGL et toutes ses subtilités à très peu de frais.

Enfin, et c'est sans doute la chose la plus importante à retenir lorsque l'on parle de shaders, n'hésitez pas à parcourir les tutoriels et les codes sources afin de vous familiariser avec les effets courants et les possiblités des shaders. Celles-ci sont à l'heure actuelle largement inexploitées, surtout lorsque l'on va taper dans des versions récentes de shaders. L'énorme flexibilité qu'ils offrent vous permettront de créer des effets toujours plus impressionnants, profitez-en !

7. Téléchargements

Les sources fournies dans les précédents tutoriels sont entièrement intégrées à celles-ci, ainsi qu'une petite démo reprenant tous les concepts vus, de manière à ce que vous ayiez toujours un package complet et à jour.

Les codes sources livrés tout au long de cette série d'articles ont été réalisés sous Visual Studio.NET 2003 ; des test ont également été effectués avec succès sur DevC++ 4.9.9.1 et Code::Blocks 1.0. Quant aux autres compilateurs vous ne devriez pas rencontrer de problème (pour peu qu'ils soient suffisamment récents), le code respectant autant que possible le standard.

Télécharger les sources de cet article (zip, 3.22 Mo)

Screenshot de l'application exemple
Screenshot de l'application exemple



Pour compiler, vous aurez également besoin des bibliothèques externes (zip)

Une version PDF de cet article est disponible : Télécharger (312 Ko)Image non disponible

Si vous avez des suggestions, remarques, critiques, si vous avez remarqué une erreur, ou bien si vous souhaitez des informations complémentaires, n'hésitez pas à me contacter !

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2005 Laurent Gomila. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.