Annexe : Intégration de Xna dans WPF

Retourner au sommaire des cours 


Il existe de nombreuses méthodes pour afficher des scènes 3D à base de Xna dans un environnement WPF. Certaines souffrent de problèmes de lenteur (réalisées le plus souvent à base de WindowsFormHost), d’autres ne permettent qu’une interaction limitée avec les contrôles et l’interface WPF. Réaliser des affichages multiples comme on peut en avoir dans des logiciels comme Maya devient alors problématique :




Il existe pourtant un moyen, relativement simple d’arriver à ses fins. Ce moyen consiste tout simplement à donner une impression visuelle à l’utilisateur d’une intégration Xna parfaite dans un widget WPF alors qu’il n’en est rien. La clé de cet effet réside dans une parfaite manipulation des fenêtres.


Principe


Nous voulons pouvoir intégrer notre scène Xna dans une interface WPF de la même manière que nous intégrons un Canvas ou un quelconque widget à une interface. Or le meilleur moyen d’afficher une scène 3D en Xna est de l’incorporer dans une fenêtre. Impossible en effet d’obtenir le handle de tout contrôle en WPF comme on peut le faire en Winform. L’astuce consiste alors à réécrire une partie du framework Xna tournant autour de la classe Game. Le but étant de faire hériter une nouvelle classe Game d’un Panel (dans notre cas un Canvas) afin de pouvoir l’intégrer dans l’arborescence visuel WPF. La surface visuelle de ce panel sera ainsi la zone d’affichage de la scène Xna liée. Pourtant nous venons de dire qu’il n’était pas possible d’obtenir un handle d’un contrôle visuel qui n’hérite pas de Window. Comment y afficher de la 3D avec Xna donc ? Nous allons tout simplement afficher une fenêtre sans bordure exactement au dessus de ce panel. Cette fenêtre sera toujours au dessus lorsque l’application aura le focus et que le panel sera visible, et sera cachée dans le cas contraire. De même, lorsque le panel n’est pas visible, nous stopperons l’activité du jeu.


Chaque modification de la taille ou de la position du panel entrainera une modification équivalente chez la fenêtre sus-jacente.



 


Cette fenêtre se trouvant donc exactement au dessus du panel et ayant une taille identique l’illusion est parfaite.



Réalisation


La première étape consiste donc à réécrire une partie des classes de l’assembly Microsoft.Xna.Framework et Microsoft.Xna.Framework.Game. Ce afin de s’émanciper du fonctionnement de base de la classe Game trop fortement couplée à un fonctionnement sur fenêtre unique. Le projet Arcane.Xna.Presentation reprend donc une partie des classes de ces assemblies pour une utilisation avec WPF.




Rien de bien compliqué. Seules les classes Game et GameHost sont réellement intéressantes ici.


La classe Game comme précisé précédemment correspond à la zone d’affichage de nos scènes 3D dans les interfaces utilisateurs WPF. Elle hérite de Canvas. L’utilisation de Canvas répond à un besoin bien particulier que nous présenterons plus loin. La classe Game ne se différencie de la classe Game de l’assembly Microsoft.Xna.Framework.Game que par quelques membres. Tout d’abord elle possède un membre de type GameHost qui est en fait la fenêtre se positionnant juste au dessus. Elle possède de même un membre nommé _tichGenerator qui va permettre de mettre à jour l’affichage à intervalle réguliers.


Le constructeur initialise ses membres ainsi :


this._window = new GameHost(this);


this._window.Closed += new EventHandler(_window_Closed);


this._tickGenerator = new DispatcherTimer();


this._tickGenerator.Tick += new EventHandler(_tickGenerator_Tick);



Il commence par créer la fenêtre qui sera située au dessus de lui, et enregistre l’événement Closed afin de fermer la scène 3D. L’object de type DispatcherTimer est utilisé pour la boucle de jeu et permet donc d’appeler de manière régulière les méthodes Update et Draw. Sa vélocité dépend de la propriété IsFixedTimeStep.


Dernier élément important, l’enregistrement de l’événement IsVisibleChange :


this.IsVisibleChanged += new DependencyPropertyChangedEventHandler(GameCanvas_IsVisibleChanged);



L’activation du DispatcherTimer est en fonction de cette visibilité.


La classe GameHost est tout aussi simple. Elle créé une fenêtre sans bordure, invisible sur la barre des tâches et qui enregistre l’événement SizeChanged du panel Xna et LocationChanged de la fenêtre de plus haut niveau. Ces deux événements lui permettent de toujours être au dessus du panel Xna en effectuant un appel à la méthode UpdateBounds :


public void UpdateBounds()
{
    if (this.IsVisible)
    {
        GeneralTransform gt = this.game.TransformToVisual(this.TopLevelWindow);
 
        this.Width = this.game.ActualWidth;
        this.Height = this.game.ActualHeight;
 
        this.Left = this.TopLevelWindow.Left + gt.Transform(new Point(0, 0)).X;
        this.Top = this.TopLevelWindow.Top + gt.Transform(new Point(0, 0)).Y;
    }
}


Cette méthode détermine la position de la fenêtre courante en se basant sur la fenêtre de plus haut niveau (la fenêtre contenant le panel Xna de la classe Game). Elle lui affecte en outre, la même largeur et hauteur que le panel Xna.


Là encore la fenêtre s’enregistre sur l’événement IsVisibleChange de ce dernier afin de se rendre visible ou non.


Premier Exemple


Nous baserons nos exemple sur le framework AvalonDock (http://www.codeplex.com/AvalonDock), un moyen efficace de créer des interfaces dockable à la Visual Studio très facilement. Un moyen aussi pour nous de montrer la puissance et la simplicité de notre système dans des interfaces WPF très avancées.


Notre solution contient donc un projet nommé Demo qui correspond au projet d’exemple d’AvanlonDock légèrement modifié. Nous avons ajouté une classe héritant de Game qui va afficher un cube tournant sur lui-même.


Cette classe a tout simplement été extraite d’une application Xna pur pour être ajouté dans ce projet, et ce, sans aucune modification (ou presque) :


 


public class RotatingCubeGame : Arcane.Xna.Presentation.Game
{
 
    #region Fields
 
    Arcane.Xna.Presentation.GraphicsDeviceManager graphics;
    SpriteBatch spriteBatch;
    BasicEffect effect;
    VertexPositionColor[] vertices;
    Vector3 position = Vector3.Zero;
    Vector3 size = Vector3.One;
    VertexBuffer vertexBuffer;
    IndexBuffer indexBuffer;

    #endregion

    #region Constructors
 
    public RotatingCubeGame()
    {
        if (!(System.ComponentModel.DesignerProperties.GetIsInDesignMode(this)))
        {
            graphics = new Arcane.Xna.Presentation.GraphicsDeviceManager(this);
            Content.RootDirectory = “Content”;
        }
    }
 
    #endregion
 
 
    /// <summary>
    /// Allows the game to perform any initialization it needs to before starting to run.
    /// This is where it can query for any required services and load any non-graphic
    /// related content.  Calling base.Initialize will enumerate through any components
    /// and initialize them as well.
    /// </summary>
    protected override void Initialize()
    {
         base.Initialize();
       // TODO: Add your initialization logic here
        this.graphics.IsFullScreen = false;
        this.graphics.PreferredBackBufferWidth = 800;
        this.graphics.PreferredBackBufferHeight = 600;
        this.graphics.ApplyChanges();
 
        this.Window.Title = “”;
 
        this.InitializeVertices();
        this.InitializeIndices();
 
    }
 
    private void InitializeVertices()
    {
        vertices = new VertexPositionColor[ 8 ];
        vertices[0].Position = new Vector3(-10f, -10f, 10f);
        vertices[0].Color = Color.Yellow;
        vertices[1].Position = new Vector3(-10f, 10f, 10f);
        vertices[1].Color = Color.Green;
        vertices[2].Position = new Vector3(10f, 10f, 10f);
        vertices[2].Color = Color.Blue;
        vertices[3].Position = new Vector3(10f, -10f, 10f);
        vertices[3].Color = Color.Black;
        vertices[4].Position = new Vector3(10f, 10f, -10f);
        vertices[4].Color = Color.Red;
        vertices[5].Position = new Vector3(10f, -10f, -10f);
        vertices[5].Color = Color.Violet;
        vertices[ 6 ].Position = new Vector3(-10f, -10f, -10f);
        vertices[ 6 ].Color = Color.Orange;
        vertices[7].Position = new Vector3(-10f, 10f, -10f);
        vertices[7].Color = Color.Gray;
        this.vertexBuffer = new VertexBuffer(this.graphics.GraphicsDevice, typeof(VertexPositionColor), 8, BufferUsage.WriteOnly);
        this.vertexBuffer.SetData(vertices);
    }
 
    private void InitializeIndices()
    {
        short[] indices = new short[36]{    
            0,1,2, //face devant
            0,2,3,
            3,2,4, //face droite                
            3,4,5,
            5,4,7, //face arrière                
            5,7,6,
            6,7,1, //face gauche
            6,1,0,
            6,0,3, //face bas                
            6,3,5,
            1,7,4, //face haut                
            1,4,2};
        this.indexBuffer = new IndexBuffer(this.graphics.GraphicsDevice, typeof(short), 36, BufferUsage.WriteOnly);
        this.indexBuffer.SetData(indices);
    }
 
    /// <summary>
    /// LoadContent will be called once per game and is the place to load
    /// all of your content.
    /// </summary>
    protected override void LoadContent()
    {
        // Create a new SpriteBatch, which can be used to draw textures.
        spriteBatch = new SpriteBatch(GraphicsDevice);
 
        // TODO: use this.Content to load your game content here
        this.effect = new BasicEffect(graphics.GraphicsDevice, null);
        this.effect.View = (Matrix.CreateLookAt(new Vector3(20, 30, -50), Vector3.Zero, Vector3.Up));
        this.effect.Projection = (Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, this.GraphicsDevice.Viewport.AspectRatio, 0.1f, 100f));
        this.effect.VertexColorEnabled = true;
    }
 

  
/// <summary>
    /// Allows the game to run logic such as updating the world,
    /// checking for collisions, gathering input and playing audio.
    /// </summary>
    /// <param name=”gameTime”>Provides a snapshot of timing values.</param>
    protected override void Update(GameTime gameTime)
    {
        if (Keyboard.GetState()[Keys.Up] == KeyState.Down)
            position += Vector3.Up;
        if (Keyboard.GetState()[Keys.Down] == KeyState.Down)
            position += Vector3.Down;
        if (Keyboard.GetState()[Keys.Left] == KeyState.Down)
            position += Vector3.Left;
        if (Keyboard.GetState()[Keys.Right] == KeyState.Down)
            position += Vector3.Right;
        if (Keyboard.GetState()[Keys.PageUp] == KeyState.Down)
            size += new Vector3(0.1f, 0.1f, 0.1f);
        if (Keyboard.GetState()[Keys.PageDown] == KeyState.Down)
            size -= new Vector3(0.1f, 0.1f, 0.1f);

      // Allows the default game to exit on Xbox 360 and Windows
        if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
          
this.Exit();
 
        float fAngle = (float)gameTime.TotalGameTime.TotalSeconds;
 
        //la transformation en elle même
        Matrix world = Matrix.CreateRotationY(fAngle) * Matrix.CreateRotationX(fAngle)
                            * Matrix.CreateScale(size)
                            * Matrix.CreateTranslation(position);
 
        this.effect.World = (world);
        base.Update(gameTime);
    }
 
    
    /// <summary>
    /// This is called when the game should draw itself.
    /// </summary>
    /// <param name=”gameTime”>Provides a snapshot of timing values.</param>
    protected override void Draw(GameTime gameTime)
    {
        this.graphics.GraphicsDevice.Vertices[0].SetSource(this.vertexBuffer, 0, VertexPositionColor.SizeInBytes);
        this.graphics.GraphicsDevice.Indices = this.indexBuffer;
        this.graphics.GraphicsDevice.VertexDeclaration = new VertexDeclaration(this.graphics.GraphicsDevice, VertexPositionColor.VertexElements);
        graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
 
        // TODO: Add your drawing code here    
        this.effect.Begin();
 
        foreach (EffectPass pass in effect.CurrentTechnique.Passes)
        {
            pass.Begin();
            this.graphics.GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, 8, 0, 12);
            pass.End();
        }
 
        effect.End();
 
        base.Draw(gameTime);
    }
}



Rien de bien compliqué ici (le code est extrait de l’article 6).  La première modification est de faire hériter la classe RotatingCubeGame de l’objet Game de notre assembly et non celle de l’assembly Microsoft.Xna.Framework.Game. La seconde modification consiste à entourer les initialisations réalisées dans le constructeur de :


System.ComponentModel.DesignerProperties.GetIsInDesignMode(this)


Afin de s’assurer que notre jeu ne sera pas en partie créé dans le designer de Visual Studio. Le reste est très simple. Nous avons juste remplacé le contenu de chaque DockablePane dans le code Xaml de Window1 par un :


<Demo:RotatingCubeGame></Demo:RotatingCubeGame>


Le resultat nous donne :




Bien évidemment notre système respecte les avantage d’AvalonDock en permettant un docking puissant et ce, sans perturber nos scène 3D:




Pas mal, mais on peut faire mieux.


Intégration de Widgets


Pourquoi ne pas tenter d’afficher des widgets (bouton, label, Grid, Canvas, …) dans notre scène 3D pour faire une intégration avec WPF de manière parfaite.


Nous pourrions être tentés d’ajouter ces éléments directement à la fenêtre GameHost. Mais à l’affichage nous aurions des problèmes de scintillement (deux types d’affichages différents vectoriel et 3D à réaliser sur une même zone clip n’est pas forcement bon…). Nous allons donc simplement rajouter une nouvelle fenêtre au dessus de la fenêtre existante, sans bordure elle aussi :




Son contenu sera directement relié au contenu du panel Xna (le Canvas). La classe GameHost contiendra désormais un nouveau membre nommé _frontWindow de type Window. Elle exposera en internal une propriété nommée WPFHost donnant accès au Content de cette window :


internal object WPFHost
{
    get
    {
        return this._frontWindow.Content;
    }
    set
    {
        this._frontWindow.Content = value;
    }
}


La classe Game exposera elle-aussi  le Content de cette window à l’aide d’une propriété portant le  même nom :


public object WPFHost
{
    get
    {
        if (!(System.ComponentModel.DesignerProperties.GetIsInDesignMode(this)))
            return this.Window.WPFHost;
        else
            return (base.Children[0] as ContentControl).Content;
    }
    set
    {
        if (!(System.ComponentModel.DesignerProperties.GetIsInDesignMode(this)))
            this.Window.WPFHost = value;
        else
            (base.Children[0] as ContentControl).Content = value;
    }
}


Cette propriété détermine si nous sommes en mode design (sous visual studio) ou en mode runtime. En mode design nous utilisons le fonctionnement classique du Canvas dont hérite notre classe Game, en mode runtime nous ciblons directement la window. Cela nous permet en mode design de pouvoir voir l’UI de notre contrôle et de pouvoir la modifier à la souris.


En outre  nous marquons la classe Game de l’attribut :


[System.Windows.Markup.ContentProperty("WPFHost")]


Permettant de mettre du contenu direct en Xaml :




La page Window1.xaml a été modifiée pour rajouter du contenu à plusieurs RotatingCubeGame comme le montre l’image ci-dessus. Nous avons ajouté des shapes et paths purs pour reproduire le personnage orange et jaune symbolisant le Xna, des boutons et label liés par des événements et un FlowDocument avec scrolling.


Conclusion


L’assembly Arcane.Xna.Presentation présente un moyen simple d’intégrer de manière professionnel du Xna à ses applications WPF. Le seul vrai défaut qu’on peut lui trouver est la création de deux fenêtre par panel Xna. Il faut savoir que le nombre de fenêtre affichable sous Windows est malheureusement limité. Le résultat fonctionne tout de même parfaitement et peut être utilisé pour des applications professionnelles :





Vous pouvez télécharger le code ici.


[Soon]


Valentin Billotte

12 thoughts on “Annexe : Intégration de Xna dans WPF”

  1. Genial
    Une question: est ce qu on pourrait avoir plusieurs vue du cube vue de haut vue de droite dans le genre de 3dmax ou de maya

  2. Eh bien en fait je souhaiterais mettre 4 panels : en haut a gauche l’ objet vue de dessus en haut a droite l objet vue de droite en bas l objet vue d en bas etc j’ai fait une longue recherche sur internet sans rien trouver de tel

  3. franchement, je dirais que wpf est suffisant dans ce cas.
    Tu dessine un cube avec WPF (y’a des tas d’exemples sur le net)
    et tu fais du raypicking pour déterminer quel triangle est intersecté par la souris ou pour faire picoter le cube.

  4. Merci infiniment pour votre attention
    bien sur en wpf avec des objets simples c est tout a fait possible mais dans l hypothese ou on voudrait editer une scene plus complexe avec des pesonnages etc est il possible de mettre dans chaque panel une vue camera differente ?

  5. Ok je comprend mieux votre question maintenant elle était pourtant bien écrite mais c’est moi qui ai du mal aujourd’hui
    Je procederais comme ceci à priori :

    Je prendrais uniquement un seul de vos panels pour réaliser un affichage 3D sous Xna
    A chaque modification de l’affichage (scene modifiée, camera modifiée, …) je mets à jour ma scène sur ce panel et je génère pour chacune des vues affichées par les autres panels une capture d’écran.

    En gros lorsque j’ai faut mon affichage pour le panel Xna, je change la caméra de place pour avoir une vue de coté et j’enregistre le rendu dans sur texture que je sauvegarde. Je mets alors à jour le panel affichant la vue de coté avec l’image de cette texture.

    Je repete cette opération pour chacune des textures. Pour les performances, il vaut peut être mieux ne rien sauvegarder sur le disque dur mais gérer les textures sous la forme de tableaux de int.
    Les autres panels peuvent être aussi gérés en Xna. Dans ce cas le Xna n’affiche qu’une image avec un spritebatch.

  6. Ca me parait une tres bonne idee pleine d imagination je n avais pas du tout songer a la capture d ecran je vais essayer Merci

  7. Le projet ne fonctionne plus sous XNA 4.0, j’ai re-importé les réferences
    mais certains types ne sont plus présents dans la nouvelle version.

    Une chance de le voir mis à jour ?

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>