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