Newsletter Developpez.com

Inscrivez-vous gratuitement au Club pour recevoir
la newsletter hebdomadaire des développeurs et IT pro

OpenGL Moderne

Tutoriel 18-2 : Particules/Instanciation

Les particules sont très similaires aux billboards 3D. Il y a toutefois quelques différences importantes :

  • il y a généralement énormément de particules ;
  • elles se déplacent ;
  • elles apparaissent et meurent ;
  • elles sont semi-transparentes.

Toutes ces différences entraînent des problèmes. Ce tutoriel présente UNE solution pour les résoudre ; il y a d'autres possibilités.

Commentez Donner une note à l'article (5)

Article lu   fois.

Les deux auteur et traducteur

Site personnel

Traducteur : Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

Navigation

Tutoriel précédent : billboards

 

Sommaire

   

I. Introduction

Les particules sont très similaires aux billboards 3D. Il y a toutefois quelques différences importantes :

  • il y a généralement énormément de particules ;
  • elles se déplacent ;
  • elles apparaissent et meurent ;
  • elles sont semi-transparentes.

Toutes ces différences entraînent des problèmes. Ce tutoriel présente UNE solution pour les résoudre ; il y a d'autres possibilités.

II. Des particules, plein partout !

La première idée pour dessiner une multitude de particules serait d'utiliser le code du tutoriel précédent et d'appeler glDrawArrays pour chaque particule. C'est une très mauvaise idée, car cela signifie que l'ensemble de vos superbes multiprocesseurs GTX 512+ seront tous utilisés pour ne dessiner qu'UN unique rectangle (évidemment, un seul sera utilisé, perdant 99 % d'efficacité). Puis vous allez dessiner le billboard suivant et cela sera identique.

Clairement, on doit trouver une méthode pour dessiner toutes les particules en même temps.

Il y a plusieurs façons pour le faire ; en voici trois :

  • générer un seul VBO avec toutes les particules à l'intérieur. Facile, efficace, fonctionne sur toutes les plates-formes ;
  • utiliser les geometry shaders. Ce n'est pas exploré dans ce tutoriel, notamment parce que 50 % des ordinateurs ne les supportent pas ;
  • utiliser l'instanciation. Ce n'est pas disponible sur TOUTES les plates-formes, mais une grande majorité de celles-ci.

Dans ce tutoriel, nous allons utiliser la troisième option : c'est un bon compromis entre les performances et la disponibilité et par-dessus tout, il est facile d'ajouter le support de la première méthode une fois que celle-ci fonctionne.

II-A. Instanciation

« Instanciation » signifie que l'on a un modèle de base (dans ce cas, un simple rectangle de deux triangles), mais de nombreuses instances de ce rectangle.

Techniquement, cela s'effectue avec plusieurs tampons :

  • certains pour décrire un modèle de base ;
  • d'autres pour décrire les particularités de chaque instance du modèle de base.

Vous avez de nombreuses, très nombreuses options sur ce que vous mettez dans chaque tampon. Dans notre simple cas, on a :

  • un tampon pour les sommets du modèle. Aucun tampon d'indices, donc c'est six vec3, constituant deux triangles, formant un rectangle ;
  • un tampon pour le centre des particules ;
  • un tampon pour la couleur des particules.

Ce sont des tampons standards. Ils sont créés de cette façon :

 
Sélectionnez
// Le VBO contenant les quatre sommets des particules.
// Grâce à l'instanciation, ils seront partagés par toutes les particules.
static const GLfloat g_vertex_buffer_data[] = { 
 -0.5f, -0.5f, 0.0f,
 0.5f, -0.5f, 0.0f,
 -0.5f, 0.5f, 0.0f,
 0.5f, 0.5f, 0.0f,
};
GLuint billboard_vertex_buffer;
glGenBuffers(1, &billboard_vertex_buffer);
glBindBuffer(GL_ARRAY_BUFFER, billboard_vertex_buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(g_vertex_buffer_data), g_vertex_buffer_data, GL_STATIC_DRAW);

// Le VBO contenant la position et la taille des particules
GLuint particles_position_buffer;
glGenBuffers(1, &particles_position_buffer);
glBindBuffer(GL_ARRAY_BUFFER, particles_position_buffer);
// Initialisé avec un tampon vide (NULL) : il sera mis à jour plus tard, à chaque image.
glBufferData(GL_ARRAY_BUFFER, MaxParticles * 4 * sizeof(GLfloat), NULL, GL_STREAM_DRAW);

// Le VBO contenant la couleurs des particules
GLuint particles_color_buffer;
glGenBuffers(1, &particles_color_buffer);
glBindBuffer(GL_ARRAY_BUFFER, particles_color_buffer);
// Initialisé avec un tampon vide (NULL) : il sera mis à jour plus tard, à chaque image.
glBufferData(GL_ARRAY_BUFFER, MaxParticles * 4 * sizeof(GLubyte), NULL, GL_STREAM_DRAW);

Ce qui est habituel. Ils sont mis à jour de cette façon :

 
Sélectionnez
// Mise à jour des tampons qu'OpenGL utilise pour le rendu.
// Il y a des façons bien plus sophistiquées pour envoyer des données du CPU au GPU, 
// mais c'est en dehors du champ de ce tutoriel.
// http://www.opengl.org/wiki/Buffer_Object_Streaming

glBindBuffer(GL_ARRAY_BUFFER, particles_position_buffer);
glBufferData(GL_ARRAY_BUFFER, MaxParticles * 4 * sizeof(GLfloat), NULL, GL_STREAM_DRAW); // Tampon orphelin, une méthode commune pour améliorer les performances de streaming. Voir le lien ci-dessus pour plus de détails.
glBufferSubData(GL_ARRAY_BUFFER, 0, ParticlesCount * sizeof(GLfloat) * 4, g_particule_position_size_data);

glBindBuffer(GL_ARRAY_BUFFER, particles_color_buffer);
glBufferData(GL_ARRAY_BUFFER, MaxParticles * 4 * sizeof(GLubyte), NULL, GL_STREAM_DRAW); // Tampon orphelin, une méthode commune pour améliorer les performances de streaming. Voir le lien ci-dessus pour plus de détails.
glBufferSubData(GL_ARRAY_BUFFER, 0, ParticlesCount * sizeof(GLubyte) * 4, g_particule_color_data);

Ce qui est aussi habituel. Ils sont liés avant le rendu de cette façon :

 
Sélectionnez
// Premier tampon d'attribut : sommets
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, billboard_vertex_buffer);
glVertexAttribPointer(
 0, // attribut. Aucune raison précise pour 0, mais cela doit correspondre à la disposition dans le shader.
 3, // taille
 GL_FLOAT, // type
 GL_FALSE, // normalisé ?
 0, // nombre d'octets séparant deux sommets dans le tampon
 (void*)0 // décalage du tableau de tampon
);

// Second tampon d'attribut : position et centre des particules
glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, particles_position_buffer);
glVertexAttribPointer(
 1, // attribut. Aucune raison précise pour 1, mais cela doit correspondre à la disposition dans le shader.
 4, // taille : x + y + z + taille => 4
 GL_FLOAT, // type
 GL_FALSE, // normalisé ?
 0, // nombre d'octets séparant deux sommets dans le tampon
 (void*)0 // décalage du tableau de tampon
);

// 3e tampon d'attributs : couleurs des particules
glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, particles_color_buffer);
glVertexAttribPointer(
 2, // attribut. Aucune raison précise pour 2, mais cela doit correspondre à la disposition dans le shader.
 4, // taille : r + g + b + a => 4
 GL_UNSIGNED_BYTE, // type
 GL_TRUE, // normalisé ? *** OUI, cela signifie que le unsigned char[4] sera accessible avec un vec4 (float) dans le shader ***
 0, // nombre d'octets séparant deux sommets dans le tampon
 (void*)0 // décalage du tableau de tampon
);

Ce qui est normal. La différence vient lors du rendu. Au lieu d'utiliser glDrawArrays (ou glDrawElements si votre modèle de base possède un tampon d'indices), vous utilisez glDrawArraysInstanced/glDrawElementsInstanced, qui est équivalent à appeler N fois glDrawArrays (N est le dernier paramètre, dans notre cas ParticlesCount).

 
Sélectionnez
glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, ParticlesCount);

Mais il manque quelque chose ici. On n'a pas indiqué à OpenGL quel tampon était pour le modèle de base et lesquels sont pour les différentes instances. Cela se fait avec glVertexAttribDivisor. Voici le code complet commenté :

 
Sélectionnez
// Ces fonctions sont spécifiques à glDrawArrays*Instanced*.
// Le premier paramètre est le tampon d'attributs dont on parle.
// Le second paramètre est le "débit auquel les sommets d'attributs génériques avance lors du rendu de multiples instances"
// http://www.opengl.org/sdk/docs/man/xhtml/glVertexAttribDivisor.xml
glVertexAttribDivisor(0, 0); // sommets des particules : toujours réutiliser les quatre mêmes sommets -> 0
glVertexAttribDivisor(1, 1); // positions : une par rectangle (son centre) -> 1
glVertexAttribDivisor(2, 1); // couleur : une par rectangle -> 1

// Dessine les particules !
// Cela dessine plusieurs fois un petit triangle_strip (qui ressemble à un rectangle).
// C'est l'équivalent de :
// for(i in ParticlesCount) : glDrawArrays(GL_TRIANGLE_STRIP, 0, 4), 
// mais plus rapide.
glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, ParticlesCount);

Comme vous pouvez le voir, l'instanciation est très polyvalente, car vous pouvez passer n'importe quel entier comme AttribDivisor. Par exemple, avec glVertexAttribDivisor(2, 10), chaque groupe de dix instances aura la même couleur.

II-B. Quel est le but ?

Le but est que maintenant, on doit seulement mettre à jour un petit tampon à chaque image (le centre des particules) et non plus un immense modèle. C'est un gain de 75 % pour la bande passante.

III. Vie et mort

Contrairement à la plupart des objets de la scène, les particules meurent et naissent à un grand débit. On doit trouver une méthode convenablement rapide pour obtenir de nouvelles particules et se débarrasser d'elles, quelque chose de mieux que new Particle().

III-A. Créer de nouvelles particules

Pour cela, on doit avoir un grand conteneur de particules :

 
Sélectionnez
// Représentation CPU d'une particule
struct Particle{
    glm::vec3 pos, speed;
    unsigned char r,g,b,a; // Couleur
    float size, angle, weight;
    float life; // Vie restante pour la particule. Si < 0 : meurt et inutilisée.

};

const int MaxParticles = 100000;
Particle ParticlesContainer[MaxParticles];

Maintenant, on a besoin d'une méthode pour en créer. Cette fonction recherche linéairement dans ParticlesContainer, ce qui est une idée horrible, sauf qu'elle démarre au dernier emplacement connu, donc cette fonction retourne généralement immédiatement :

 
Sélectionnez
int LastUsedParticle = 0;

// Trouve une Particle dans ParticlesContainer qui n'est actuellement pas utilisée.
// (i.e. life < 0);
int FindUnusedParticle(){

    for(int i=LastUsedParticle; i<MaxParticles; i++){
        if (ParticlesContainer[i].life < 0){
            LastUsedParticle = i;
            return i;
        }
    }

    for(int i=0; i<LastUsedParticle; i++){
        if (ParticlesContainer[i].life < 0){
            LastUsedParticle = i;
            return i;
        }
    }

    return 0; // Toutes les particules sont prises, écrase la première
}

On peut maintenant remplir ParticlesContainer[particleIndex] avec des valeurs intéressantes pour life, color, speed et position. Voir le code pour les détails, mais vous pouvez faire ce que vous voulez ici. La seule chose intéressante est, comment plusieurs particules doivent être générées à chaque image ? C'est principalement dépendant de l'application, donc prenons un taux de 10 000 nouvelles particules par seconde (oui, c'est énorme) :

 
Sélectionnez
int newparticles = (int)(deltaTime*10000.0);

Sauf que vous devez restreindre cela a une nombre fixe :

 
Sélectionnez
// Génère dix nouvelles particules chaque milliseconde,
// mais limite cela à 16 ms (60 fps), ou si vous avez une longue image (1 seconde),
// newparticles sera énorme et la prochaine image encore plus longue.
int newparticles = (int)(deltaTime*10000.0);
if (newparticles > (int)(0.016f*10000.0))
    newparticles = (int)(0.016f*10000.0);

III-B. Supprimer les vieilles particules

Il y a une astuce, comme montré ci-dessous Image non disponible.

IV. La boucle principale de simulation

ParticlesContainer contient aussi bien les particules actives que les « mortes », mais le tampon que l'on envoie au GPU ne doit avoir que les particules vivantes.

Donc, on va parcourir chaque particule, vérifier si elle est vivante, si elle doit mourir et si tout est correct, ajouter un peu de gravité, puis finalement la copier dans un tampon GPU spécifique.

 
Sélectionnez
// Simule toutes les particules
int ParticlesCount = 0;
for(int i=0; i<MaxParticles; i++){

    Particle& p = ParticlesContainer[i]; // raccourci

    if(p.life > 0.0f){

        // Décrémente la vie
        p.life -= delta;
        if (p.life > 0.0f){

            // Simule une physique simple : seulement la gravité, pas de collisions
            p.speed += glm::vec3(0.0f,-9.81f, 0.0f) * (float)delta * 0.5f;
            p.pos += p.speed * (float)delta;
            p.cameradistance = glm::length2( p.pos - CameraPosition );
            //ParticlesContainer[i].pos += glm::vec3(0.0f,10.0f, 0.0f) * (float)delta;

            // Remplit le tampon GPU
            g_particule_position_size_data[4*ParticlesCount+0] = p.pos.x;
            g_particule_position_size_data[4*ParticlesCount+1] = p.pos.y;
            g_particule_position_size_data[4*ParticlesCount+2] = p.pos.z;

            g_particule_position_size_data[4*ParticlesCount+3] = p.size;

            g_particule_color_data[4*ParticlesCount+0] = p.r;
            g_particule_color_data[4*ParticlesCount+1] = p.g;
            g_particule_color_data[4*ParticlesCount+2] = p.b;
            g_particule_color_data[4*ParticlesCount+3] = p.a;

        }else{
            // Les particules qui viennent de mourir seront placé à la fin du tampon dans SortParticles();
            p.cameradistance = -1.0f;
        }

        ParticlesCount++;

    }
}

Voici ce que vous obtenez. Presque réussi, mais il y a un souci…

Problème avec les particules

IV-A. Tri

Comme expliqué dans le dixième tutoriel, vous devez trier les objets semi-transparents du plus loin au plus proche pour que le mélange soit correct.

 
Sélectionnez
void SortParticles(){
    std::sort(&ParticlesContainer[0], &ParticlesContainer[MaxParticles]);
}

Maintenant, std::sort nécessite une fonction qui peut indiquer si une particule doit être placée avant ou après une autre particule dans le conteneur. Cela peut être fait avec Particle::operator< :

 
Sélectionnez
// CPU representation of a particle
struct Particle{

    ...

    bool operator<(Particle& that){
        // Tri dans l'ordre inverse : les particules les plus lointaines sont dessinées en premier.
        return this->cameradistance > that.cameradistance;
    }
};

Cela permet de trier ParticleContainer et les particules vont maintenant être affichées correctement(1) :

Un joli effet de fontaine avec des particules

V. Aller plus loin

V-A. Particules animées

Vous pouvez animer votre texture de particules avec une texture atlas. Envoyez l'âge de chaque particule avec la position et dans les shaders, calculez les coordonnées UV comme on l'a fait dans le tutoriel de police 2D. Une texture atlas ressemble à cela :

Exemple de texture atlas

V-B. Gérer plusieurs systèmes de particules

Si vous avez besoin de plus d'un système de particules, vous avez deux options : soit utiliser un seul ParticleContainer, soit un par système.

Si vous avez un seul conteneur pour toutes les particules, alors vous allez être capable de parfaitement les trier. Le principal inconvénient est que vous devez utiliser la même texture pour toutes les particules, ce qui est un gros problème. Cela peut être corrigé en utilisant une texture atlas (une grande texture avec toutes les différentes textures sur celle-ci, ayant simplement des coordonnées UV différentes), mais ce n'est pas vraiment pratique pour l'éditer et l'utiliser.

Si vous avez un conteneur par système de particules, d'un autre côté, les particules seront seulement triées dans ces conteneurs : si deux systèmes de particules se superposent, des artefacts apparaîtront. Suivant votre application, cela peut ne pas être un souci.

Bien sûr, vous pouvez aussi utiliser un quelconque système hybride avec plusieurs systèmes de particules et chacun avec un (petit et gérable) atlas.

V-C. Particules douces

Vous allez remarquer très rapidement un artefact classique :lorsque vos particules croisent une géométrie, la limite devient très visible et moche :

Problème avec les particules et les géométries

(image provenant de http://www.gamerendering.com/2009/09/16/soft-particles/).

Une technique classique pour corriger cela est de tester si le fragment actuellement dessiné est proche du tampon de profondeur. Si c'est le cas, le fragment est fondu.

Par contre, vous allez devoir échantillonner le tampon de profondeur, ce qui n'est pas possible avec le tampon de profondeur « normal ». Vous devez afficher votre scène dans une cible de rendu. Sinon, vous pouvez copier le tampon de profondeur à partir d'un tampon d'image vers un autre avec glBlitFramebuffer.

http://developer.download.nvidia.com/whitepapers/2007/SDK10/SoftParticles_hi.pdf

V-D. Amélioration du taux de remplissage

L'un des facteurs les plus limitants dans les GPU modernes est le taux de remplissage : le nombre de fragments (pixels) que le GPU peut écrire en 16,6 ms, permis pour obtenir 60 FPS.

C'est un problème, car les particules nécessitent typiquement un GRAND taux de remplissage, comme vous devez redessiner le même fragment dix fois, chaque fois avec une particule spécifique ; et si vous ne le faites pas, vous allez obtenir les mêmes artefacts que précédemment.

Parmi tous les fragments qui sont écrits, la plupart sont complètement inutiles : ceux se trouvant sur la bordure. Vos textures de particules sont souvent complètement transparentes sur les bordures, mais le modèle de particule continuera de les dessiner - et mettre à jour le tampon de couleur avec exactement la même valeur que précédemment.

Ce petit utilitaire calcule un modèle (celui que vous êtes supposé dessiner avec glDrawArrayInstanced()), qui correspond précisément à votre texture :

http://www.humus.name/index.php?page=Cool&ID=8. Le site de Emil Person contient plein d'autres articles fascinant.

V-E. Physique des particules

Au bout d'un moment, vous allez probablement souhaiter que vos particules interagissent bien plus avec votre monde. En particulier, les particules pourraient rebondir sur le sol.

Vous pouvez simplement lancer un rayon pour chaque particule, entre la position actuelle et la prochaine ; on apprend à le faire dans les tutoriels de Picking. Mais c'est très coûteux. simplement, vous ne pouvez pas le faire pour chaque particule, à chaque image.

Suivant votre application, vous pouvez soit approcher votre géométrie avec un ensemble de plans et faire un lancer de rayon sur ces plans uniquement ; ou vous pouvez utiliser un vrai lancer de rayon, mais mettre en cache le résultat et approcher les collisions alentour avec le cache (ou vous pouvez faire les deux).

Une technique complètement différente est d'utiliser le tampon de profondeur existant tel une approximation très brute de la géométrie (visible) et faire collisionner les particules avec celle- ci. C'est « assez efficace » et rapide, mais vous allez devoir effectuer toute la simulation sur le GPU, car vous ne pouvez pas accéder au tampon de profondeur à partir du CPU (du moins, pas rapidement), donc c'est bien plus compliqué.

Voici quelques liens sur ces techniques :

V-F. Simulation GPU

Comme indiqué précédemment, vous pouvez complètement simuler le mouvement des particules sur le GPU. Vous allez toujours devoir gérer le cycle de vie de vos particules sur le CPU - au moins pour les faire apparaître.

Vous avez plusieurs options pour ce faire et aucune ne rentre dans le champ de ce tutoriel ; je vais juste donner quelques pointeurs.

  • Utiliser le retour de transformation (Transform Feedback). Cela vous permet de stocker les sorties du vertex shader dans un VBO côté GPU. Stockez la nouvelle position dans ce VBO et à la prochaine image, utilisez ce VBO comme point de départ et stockez les nouvelles positions dans le premier VBO.
  • Même chose, mais sans le retour de transformation : encodez vos positions de particules dans une texture et mettez-la à jour avec un rendu vers texture.
  • Utiliser une bibliothèque GPU généraliste : CUDA ou OpenCL, qui possède des fonctions d'interopérabilité avec OpenGL.
  • Utilisez un compute shader. La solution la plus propre, mais seulement disponible sur les GPU récents.

Remerciements

Cet article est une traduction autorisée dont le texte original peut être trouvé sur opengl-tutorial.org.

VI. Navigation

Tutoriel précédent : billboards

 

Sommaire

   

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


Notez que pour des raisons de simplicité, dans cette implémentation, ParticleContainer est trié après la mise à jour des tampons GPU. Cela fait que les particules ne sont pas exactement triées (il y a un délai d'une image), mais ce n'est pas vraiment remarquable. Vous pouvez corriger cela en séparant la boucle principale en deux. Simulez, triez et mettez à jour.

  

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 © 2014 opengl-tutorial.org. 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.