Moteur de particules pour Windows Phone 7

24. février 2011

L’objectif de ce tutoriel est essentiellement de nous amuser avec la partie 3D de XNA pour Windows Phone 7.

Pour que cela soit ludique, nous allons produire un applicatif qui permettra à l’utilisateur de bouger des émetteurs de particules. Cela dans un but uniquement contemplatif (un peu comme le simulateur d’eau sur la table Surface).

Le code complet est disponible juste là.

 

Qu’est-ce qu’un simulateur de particules?


Une particule est une entité autonome définie par plusieurs paramètres parmi lesquels comptent surtout la position et la direction de déplacement.

Ainsi un simulateur de particules est un ensemble de particules qui se déplacent simultanément. Selon ce que l’on souhaite gérer, on peut rajouter de nombreuses fioritures. Dans notre cas, nous allons gérer la position, le déplacement, la couleur, la taille, la gravité et même les collisions avec le sol.

La structure de notre particule va donc être:

    public class Particle
    {
        public Vector3 Position;
        public float Size;
        public Color Color;
        public int Age;
        public int DeadAge;
        public Vector3 Direction;
        public float Speed;
    }

On peut noter que la particule possède un Age et un DeadAge. En effet, lors de l’émission de la particules, cette dernière est âgée de 0 frame. A chaque update, son âge augmente de 1 et dès que l’âge = DeadAge, la particule est détruite et remplacée par une nouvelle.

 

Mise en place du mécanisme de rendu


Pour dessiner nos particules sur WP7, nous allons utiliser XNA 4.0. Ce dernier va nous permettre d’avoir un accès rapide au GPU car si l’on veut dessiner plus de 2000 particules en simultané, il faudra de la puissance.

Pour commencer nous allons initialiser un GraphicsDeviceManager. Ce dernier est notre intermédiaire avec le GPU et l’accélération graphique:

            graphics = new GraphicsDeviceManager(this)
                           {
                               PreferredBackBufferWidth = 480,
                               PreferredBackBufferHeight = 800,
                               PreferredDepthStencilFormat = DepthFormat.Depth24,
                               IsFullScreen = true
                           };
            Content.RootDirectory = "Content";

            // Frame rate is 30 fps by default for Windows Phone.
            TargetElapsedTime = TimeSpan.FromTicks(333333);

Nous demandons ici un rendu sur toute la surface (480x800). Il est à noter que nous pourrions demander moins pour avoir plus de puissance, sachant que le WP7 fournit un matériel dédié pour la remise à taille (en gros on dessine en 240x400 par exemple et le matériel du WP7 fait le zoom sans consommer de puissance CPU/GPU).

Nous demandons également un Depth Buffer de 24bits (le plus précis que l’on puisse avoir). Ce qui veut dire que chaque pixel aura sa profondeur stocké dans un flottant de 24 bits. Cette profondeur permet de savoir si le pixel que l’on va écrire est plus proche que celui qui a déjà été écrit. Ainsi comme il n’est pas possible de garantir que l’on écrive les pixels dans le bon ordre, le Depth Buffer garantit que c’est toujours le pixel le plus proche qui est conservé.

Pour dessiner nos particules, nous allons avoir également besoin d’une texture (celle qui sera dessinée sur chaque particule) et d’un effet. Il existe plusieurs effets dans XNA 4.0 pour WP7, ces derniers sont en fait des shaders (pixel + vertex) qui permettent de rendre des objets 3D avec diverses options supportées.
Dans notre cadre, nous allons utiliser un BasicEffect (l’effet de base qui gère une texture, un éclairage et la coloration par vertex):

            particleTexture = Content.Load<Texture2D>("part");

            // Effect
            effect = new BasicEffect(graphics.GraphicsDevice)
                         {
                             Alpha = 1.0f,
                             TextureEnabled = true,
                             FogEnabled = false,
                             VertexColorEnabled = true,
                             LightingEnabled = false,
                             World = Matrix.Identity
                         };
Ici, nous configurons notre effet pour qu’il ne gère que la texture et la couleur par vertex. Nous n’avons pas besoin de l’éclairage.

 

Stocker nos particules pour la carte graphique


Une notion importante dans le cadre de XNA est la séparation des mémoires. En effet, le CPU et le GPU ne travaillent pas sur les mêmes mémoires. Ainsi, tout ce que devra traiter le GPU doit être mis dans sa mémoire.

C’est ici qu’interviennent les vertex et index buffers. Ces buffers servent à stocker les données de rendus nécessaires au GPU.

Le vertex buffer contient la liste des vertices d’un objet 3D (les points dans l’espace). XNA est suffisamment souple pour permettre de stocker ce que l’on souhaite par vertex. Dans notre cas nous allons définir nos vertices ainsi:

    public struct ParticleVertex : IVertexType
    {
        public const int Stride = 24;

        public Vector3 Position;
        public Vector2 TextureCoordinates;
        public Color Color;

        public VertexDeclaration VertexDeclaration
        {
            get
            {
                return new VertexDeclaration(new[]
                                                 {
new VertexElement(0, VertexElementFormat.Vector3, 
VertexElementUsage.Position, 0),

new
VertexElement(12, VertexElementFormat.Vector2,
VertexElementUsage.TextureCoordinate, 0),

new
VertexElement(20, VertexElementFormat.Color,
VertexElementUsage.Color, 0), } ); } } }

Notre vertex contient donc une position (obligatoire), des coordonnées de textures (pour afficher notre texture) et une couleur (cela permettra de faire des particules multicolores).

Notons au passage que notre structure implémente l’interface IVertexType qui nous permet de décrire à XNA la structure (VertexDeclaration) d’un vertex. Ceci est en effet nécessaire pour que les shaders des effets lisent correctement nos structures.

Les index buffers contiennent la définition des faces sous la forme de 3 entiers par face qui donnent les numéros des vertices de chaque face. Ainsi l’index buffer [0, 1, 2, 0, 2, 3] définit deux faces qui pointent vers 4 vertices différents. La face 0 contient les vertices 0, 1 et 2. La face  contient les vertices 0, 2 et 3. Ainsi on peut définir une réutilisabilité pour défnir nos triangles. Sans index buffer, il faudrait 6 vertices pour faire 2 triangles. Avec un index buffer, il n’en faut plus que 4 puisque dans notre cas les 2 faces partagent 2 vertices.

Ainsi, chaque système de particules va donc posséder un vertex buffer et un index buffer.

Pour rendre les particules, le code donne donc:

  device.SetVertexBuffer(vertexBuffer);
  device.Indices = indexBuffer;
            
  device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, 
currentParticlesCount * 4, 0, currentParticlesCount * 2);

On indique au système notre vertex buffer, notre index buffer et on demande le rendu d’une liste de triangles.

 

Préparation des buffers


Le principe de fonctionnement général de notre système est de faire évoluer nos particules sur le CPU et avant chaque rendu de préparer un vertex buffer avec les informations nécessaires pour le rendu sur le GPU.

Le vertex buffer devra contenir 2 triangles par particules (chaque particule est en effet un quad toujours orienté vers la caméra). Le vertex buffer mesurera donc 4 x nb_particules. (Dans les précédentes versions de XNA, il nous aurait été possible de mettre un vertex par particule en utilisant le PointList, mais cette topologie de rendu n’est plus supportée par XNA 4.0).

L’index buffer sera statique puisqu’il définit des suites de triangles fonctionnants sur 4 vertices:

indexBuffer = new IndexBuffer(device, typeof(short), 
maxParticlesCount * 6, BufferUsage.WriteOnly);
short[] indices = new short[maxParticlesCount * 6]; for (int index = 0; index < maxParticlesCount; index++) { indices[index * 6] = (short)(index * 4); indices[index * 6 + 1] = (short)(index * 4 + 1); indices[index * 6 + 2] = (short)(index * 4 + 2); indices[index * 6 + 3] = (short)(index * 4); indices[index * 6 + 4] = (short)(index * 4 + 2); indices[index * 6 + 5] = (short)(index * 4 + 3); } indexBuffer.SetData(indices);

 

La mise à jour va donc parcourir chaque particule pour effectuer le calcul de son évolution:

for (int i = 0; i < particles.Count; i++)
{
    Particle particle = particles[i];
    particle.Age++;

    if (particle.Age >= particle.DeadAge)
    {
        SpawnNewParticle(i);
        continue;
    }
    
particle.Position += particle.Direction * particle.Speed + Gravity;
// Ground collisions if (particle.Position.Y < GroundHeight + particle.Size) { particle.Position.Y = GroundHeight + particle.Size; particle.Direction = particle.Direction + Gravity; particle.Direction.Y = Math.Abs(particle.Direction.Y); }

On peut noter que dès que l'âge est supérieur ou égal au DeadAge, nous appelons SpawnNewParticle(i) qui permet remplacer la particule i par une nouvelle particule.

Une fois que chaque particule a été traitée, il faut la transférer dans le vertex buffer via le code suivant:

for (int i = 0; i < particles.Count; i++)
{
    …
// Update Color color = particle.Color; color.A = (byte) (255 * (particle.DeadAge - particle.Age)
/ (float) particle.DeadAge); Vector3 position = Vector3.Transform(particle.Position, viewMatrix); vertices[index * 4].Position = position + new Vector3(-1, -1, 0) *
particle.Size; vertices[index * 4].Color = color; vertices[index * 4 + 1].Position = position + new Vector3(1, -1, 0) *
particle.Size; vertices[index * 4 + 1].Color = color; vertices[index * 4 + 2].Position = position + new Vector3(1, 1, 0) *
particle.Size; vertices[index * 4 + 2].Color = color; vertices[index * 4 + 3].Position = position + new Vector3(-1, 1, 0) *
particle.Size; vertices[index * 4 + 3].Color = color; currentParticlesCount++; index++; }

Afin de faire en sorte que la particule regarde toujours la caméra, nous partons de la position de la particule et nous la transformons par la matrice de vue pour avoir directement sa position dans le monde de la caméra.

A partir de cette nouvelle position, il suffit de générer 4 vertices qui prennent pour centre cette position et qui se décalent pour former un carré.

De plus, pour être efficace, nous ne travaillons pas avec un VertexBuffer mais avec un DynamicVertexBuffer qui possède l’énorme avantage de pouvoir être modifié par le CPU autant que l’on le souhaite:

vertexBuffer.SetData(vertices, 0, currentParticlesCount * 4);

 

Génération d’une particule


Chaque particule est définie par un ensemble de propriétés que nous avons évoquées plus haut.

La génération d’une particule doit donc renseigner toutes ses propriétés et pour ajouter un peu de hasard dans notre système, nous tirons aléatoirement chaque valeur dans une plage définie par le système:

        void SpawnNewParticle(int index)
        {
            Particle particle = new Particle
{
  Color = Tools.GetRandomColor(MinColor, MaxColor),
Position = Center + new Vector3(Tools.GetRandomFloat(-EmitRadius, EmitRadius),
Tools.GetRandomFloat(-EmitRadius, EmitRadius),
Tools.GetRandomFloat(-EmitRadius, EmitRadius)),
Size = Tools.GetRandomFloat(MinSize, MaxSize), DeadAge = Tools.GetRandomInt(MinDeadAge, MaxDeadAge), Direction = Tools.GetRandomVector3(MinDirection, MaxDirection), Speed = Tools.GetRandomFloat(MinSpeed, MaxSpeed) }; if (index >= 0) { particles[index] = particle; } else particles.Insert(0, particle); }

Ainsi pour définir un système émettant 1000 particules bleus vers le bas, cela donne:

blueSystem = new ParticlesSystem(graphics.GraphicsDevice, 1000)
                {
                    Gravity = new Vector3(0, -0.09f, 0),
                    MinDirection = new Vector3(-1, -1, -1),
                    MaxDirection = new Vector3(1, 0, 1),
                    MinColor = Color.DarkBlue,
                    MaxColor = Color.Blue
                };

 

Gestion de la caméra


Finalement, pour interagir avec notre utilisateur nous allons fournir une caméra de type ArcRotate, c’est à dire qui se déplace sur la surface d’une sphère centrée sur un point cible.

Cette caméra a besoin de deux angles pour définir sa position sur la sphère (Alpha : angle de rotation autour de l’axe Y et Béta : angle de rotation autour de X):

float cosa = (float)Math.Cos(Alpha);
float sina = (float)Math.Sin(Alpha);
float cosb = (float)Math.Cos(Beta);
float sinb = (float)Math.Sin(Beta);

Vector3 cameraPosition = new Vector3(Radius * cosa * sinb, Radius * cosb, 
Radius * sina * sinb);
Vector3 target = new Vector3(0, 0, 0); viewMatrix = Matrix.CreateLookAt(cameraPosition, target, Vector3.Up);

Ce code permet de calculer la View Matrix (la matrice de la caméra) qui indique donc la position de la caméra et ce qu’elle regarde.

Rendu des systèmes


Pour conclure, dans le cadre du rendu de plusieurs systèmes, il nous suffit de régler notre effet pour:

  • Activer la transparence de type NonPremultiplied, c’est à dire que chaque pixel s’écrit avec la formule suivante : AlphaSource * ColorSource + (1 – AlphaSource) * DestinationColor
  • Mettre le DepthBuffer en mode ReadOnly pour que les particules ne se masquent pas entre elles tout en tenant compte des autres objets de la scène
  • Désactivation du culling pour voir les particules de chaque coté (pas d’élimination des faces cachées)

 

Donc cela donne:

// System
graphics.GraphicsDevice.DepthStencilState = DepthStencilState.DepthRead;
graphics.GraphicsDevice.BlendState = BlendState.NonPremultiplied;
graphics.GraphicsDevice.RasterizerState = RasterizerState.CullNone;

effect.View = Matrix.Identity;
effect.Projection = camera.ProjectionMatrix;
effect.Texture = particleTexture;

foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
    pass.Apply();

    blueSystem.Render();
    yellowSystem.Render();
}

Ajout du sol


Pour donner un point de référence visuel, nous allons ajouter un sol issu d’un modèle FBX (exporté depuis 3DS Max).

L’intégration est simplissime avec XNA:

ground = Content.Load<Model>("ground");

Dans la phase de rendu, avant de dessiner les particules, nous demandons le rendu de notre sol:

// Ground
graphics.GraphicsDevice.DepthStencilState = DepthStencilState.Default;
graphics.GraphicsDevice.BlendState = BlendState.Opaque;
graphics.GraphicsDevice.RasterizerState = RasterizerState.CullCounterClockwise;
graphics.GraphicsDevice.SamplerStates[0] = textureState;
ground.Draw(Matrix.CreateTranslation(0, groundHeight, 0), camera.ViewMatrix, 
camera.ProjectionMatrix);

Conclusion


La touche finale est mise en place avec la gestion du touchscreen pour déplacer le centre de chaque système. Quelques sprites pour faire l’interface utilisateur et le tour est joué.

Grâce à la simplicité de XNA, à l’accèlération matérielle et aux DynamicVertexBuffer, il est possible sur nos WP7 de faire tourner une scène avec plus de 2000 particules en simultané et cela de manière fluide;

Nous vivons une belle époque Sourire

.Net, Windows Phone, Tutorial

Tutorial : Utilisation simple de LINQ pour gérer le tri d’une listbox

10. novembre 2010

Voici notre problématique du jour : avoir une listbox affichant une liste d’objets avec la nécessité de trier selon 3 critères chacun portant sur une propriété différente de nos objets.

Pour notre exemple, les objets sont de ce type:

public class UserProxy
{
    public Guid ID
    { set; get; }
    public string Login
    { set; get; }
    public DateTime Date
    { set; get; }
    public DateTime LastConnection
    { set; get; }

    public string Mail { get; set; }
}

Le but est de pouvoir trier par Login, Date et LastConnection.

Mise en place de la ListBox


Rien de compliqué ici, une simple ListBox avec un DataTemplate permettant d’afficher le Login et la LastConnection:

<ListBox Grid.Row="1" x:Name="lstUsers" >
<ListBox.ItemTemplate> <DataTemplate DataType="UserProxy"> <Grid ToolTip="{Binding Mail}"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <Image Source="user.png" Width="20" Height="20" Margin="4"/> <TextBlock Text="{Binding Login}" FontSize="24" Grid.Column="1"
Margin="10,0" VerticalAlignment="Center"/> <TextBlock Text="{Binding LastConnectionString}" FontSize="14"
Grid.Column="2" Margin="5,0" FontStyle="Italic" VerticalAlignment="Center"/> </Grid> </DataTemplate> </ListBox.ItemTemplate> <ListBox.FocusVisualStyle> <Style TargetType="ListBoxItem"> <Setter Property="Foreground" Value="White" /> </Style> </ListBox.FocusVisualStyle> </ListBox>

Binding de base

Partant du principe que notre liste d’utilisateurs s’appelle users, le binding sur la liste se définit donc comme suit:

lstUsers.ItemsSource = users

Ajout de la notion de triage


Pour la notion de triage nous allons faire appel à LINQ. En effet, ce dernier introduit les méthodes d’extension OrderBy et OrderByDescending qui s’appliquent aux objets implémentant l’interface IEnumerable<>.

Ainsi le code avec la gestion du tri devient:

lstUsers.ItemsSource = users.OrderBy(u=>u.Login);

On peut le voir sur le code ci-dessus, la méthode d’extension OrderBy prend donc en paramètre un délégué générique dans lequel j’ai mis une expression lamba prenant un UserProxy en paramètre et retournant une chaine de caractères.

La signature précise de OrderBy est d’ailleurs un délégué de type System.Func<TSource, TKey> c’est à dire un délégué prenant un objet de classe TSource en paramètre et retournant un objet de classe TKey (qui sera utilisé comme clef pour le tri).

Partant de ce principe et grâce aux expressions lambdas, nous pouvons définir trois variables comme suit:

readonly Func<UserProxy, object> nameKeySelector  = (u=>u.Login);
readonly Func<UserProxy, object> dateKeySelector = (u=>u.Date);
readonly Func<UserProxy, object> connectionKeySelector = (u => u.LastConnection);

Grâce à ces trois variables, nous allons fournir une méthode qui saura gérer le tri ascendant et descendant et qui surtout saura prendre en charge nos expressions:

        void SortDatas(Func<UserProxy, object> keySelector)
        {
            if (users == null)
                return;

            if (keySelector == null)
                keySelector = previousKeySelector;

            if (keySelector == previousKeySelector)
            {
                asc = !asc;
            }
            else
            {
                asc = true;
            }

            previousKeySelector = keySelector;

            if (asc)
                lstUsers.ItemsSource = users.OrderBy(keySelector);
            else
            {
                lstUsers.ItemsSource = users.OrderByDescending(keySelector);
            }
        }

Pour que la méthode fonctionne il faut juste rajouter deux variables supplémentaires:

        bool asc = true;
        Func<UserProxy, object> previousKeySelector;

L’usage de cette méthode devient donc relativement simple puisqu’il suffit de l’appeler avec l’une de nos expressions pour effectuer le tri associé (et si on l’appelle plusieurs fois de suite avec la même expression, le sens de tri sera inversé):

        private void dateSort_Click(object sender, RoutedEventArgs e)
        {
            SortDatas(dateKeySelector);
        }

        private void nameSort_Click(object sender, RoutedEventArgs e)
        {
            SortDatas(nameKeySelector);
        }

        private void connectionSort_Click(object sender, RoutedEventArgs e)
        {
            SortDatas(connectionKeySelector);
        }

Et le tour est joué Sourire

.Net, WPF, Tutorial