WPF & DirectX 10 ? Première partie - Utilisation de DirectX 10 au sein d'une application WPF.

WPF & DirectX 10 ? Première partie - Utilisation de DirectX 10 au sein d'une application WPF.

Cet article fait suite aux articles ci-dessous :

Si vous n'êtes pas familier avec les concepts de shaders ni avec DirectX 10 je vous recommande de les parcourir brièvement.

Ce papier es t le premier d'une série de documents plus courts dans lesquels je me propose de détailler les points suivants :

  • Comment utiliser DX10 pour afficher des objets en trois dimensions au sein d'une application WPF.
  • Comment utiliser les géométries shaders pour générer entièrement sur le GPU une iso surface.
  • Comment utiliser le ?StreamOut' pour récupérer la géométrie précédemment crée.
  • Et enfin comment utiliser les données ainsi récupéré pour créer des objets WPF 3D.

Télécharger les codes sources

Tous ces points sont utilisés dans une application d'exemple dont je décrirai la structure et le code au fil de ces articles. Cette application d'exemple permet de créer des metaballs entièrement sur la carte graphique à partir de points fournis par l'utilisateur :

Si le résultat n'est pas visuellement très impressionnant, cet exemple permet toutefois de mettre en avant deux nouveautés de DX10, les géométries shaders et la possibilité de récupérer des données produites par le pipeline graphique sans exécuter les dernières étapes de celui-ci (StreamOut). J'espère par ailleurs au fil des articles améliorer le contrôle que l'on peut avoir sur les géométries produites et un peu plus tard de donner la possibilité de peindre celle-ci de façon interactive.

Vue d'ensemble

Pour utiliser DirectX 10 dans une application WPF nous allons utiliser la classe System.Windows.Interop. HwndHost. Cette classe permet d'intégrer une fenêtre Win32 au sein d'une application WPF. Le code s'appuyant sur l'API DirectX pour produire un résultat visible dans cette fenêtre sera réalisé en C++/CLI. Cette extension au langage C++ permet de mélanger facilement du code managé et du code C++ natif, ce qui dans notre cas nous permettra d'utiliser DirectX 10.

Le diagramme ci-dessous donne une idée des différents composants de l'application d'exemple. Les classes View, CViewer font partie du même assembly réalisé en C++/CLI. View est une classe managé qui hérite de HwndHost et empaquette les fonctionnalités de CViewer qui ne contient que du code natif. CViewer crée une fenêtre WIN32 et s'appui sur Direct3D pour gérer l'affichage du contenu de celle-ci.

L'application WPF quand à elle utilise des objets Ink pour capturer les actions effectués par l'utilisateur.

Dans le reste de ce document nous allons voir :

  • Les méthodes que notre classe View doit implémenter pour être utilisable.
  • Ce qu'il faut faire pour créer une fenêtre WIN32 et initialiser Direct3D à partir du Handle de fenêtre fourni à notre classe View par le biais des méthodes de la classe HwndHost qu'elle surcharge.
  • Comment nous allons utiliser des objets Ink fourni avec WPF pour gérer les interactions avec l'utilisateur.
  • Et enfin ce qui nous est nécessaire pour retourner à notre application WPF des informations générés par notre code natif (même la façon exacte dont les valeurs sont générées sera traitée dans un article ultérieur).

Les différentes méthodes à implémenter au niveau de la classe dérivant de HwndHost.

Regardons un peu plus dans le détail les méthodes que doit implémenter notre classe View héritant de HwndHost pour être utilisable. La documentation MSDN contient un article intitulé WPF and Win32 Interoperation Overview qui fourni un grand luxe de détails sur la façon d'utiliser HwndHost mais pour résumer la situation, il va nous falloir surcharger dans notre classe trois méthodes qui sont :

  • BuildWindowCore
  • WndProc
  • DestroyWindowCore

BuildWindow core est appelé lors de l'initialisation de l'application WPF (nous verrons un tout petit peu plus tard comment) et permet d'effectuer le travail nécessaire pour disposer d'une surface de rendu que nous allons pouvoir utiliser pour afficher ce que nous désirons en utilisant DirectX.

En l'occurrence le travail est effectué dans cette méthode consiste à créer une fenêtre WIN32 fille de la fenêtre WIN32 qui peut être associé à notre application WPF : (le code ci-dessous est extrait de Host.h)

virtual HandleRef BuildWindowCore (HandleRef hwndParent) override
        {
            HWND hwndHost = CreateWindowEx(0, L"Static",
                    NULL,
                    WS_CHILD | WS_VISIBLE | WS_CLIPCHILDREN,
                    0, 0,
                    hostHeight, hostWidth,
                    (HWND) hwndParent.Handle.ToPointer(),
                    0,
                    0,
                    0);
            
            pViewer = new CViewer();
            Init(hwndHost);

            return HandleRef(this, IntPtr::IntPtr(hwndHost));
        }

Le code ci-dessus récupère le handle de la fenêtre associé à notre HwndHost inclus dans notre fenêtre WPF puis crée à partir de celle-ci une fenêtre Win32 fille.

Ensuite la méthode BuildWindowCore crée une instance de notre classe non managée CViewer puis appelle la méthode Init qui appellera à son tour la méthode InitRenderingSurface de la classe CViewer. Je décris le travail effectué dans InitRenderingSurface dans la partie de ce document portant spécifiquement sur les initialisations propres à DirectX 10.

WndProc, comme son nom le laisse à penser, est une fonction qui nous permet de gérer les messages WIN32 passés à notre fenêtre. Dans notre cas nous n'utilisons pas cette possibilité mais on peut très bien imaginer des situations ou l'on aurait besoin de gérer certains messages dans notre assembly réalisé en C++ managé. Dans notre cas elle se limite à ne rien faire :

virtual IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, bool% handled) override
        {
            handled = false;
            return IntPtr::Zero;
        }

Enfin la méthode DestroyWindowCore est destiné à nous permettre de libérer les ressources que nous avons éventuellement crées ou allouées dans BuildWindowCore. Ici il n'est pas réellement nécessaire de faire autre chose que de détruire notre instance de la classe CViewer :

virtual void DestroyWindowCore(HandleRef hwnd) override
        {
            delete pViewer;
        }

Maintenant que ces différentes méthodes sont implémentées au niveau de notre classe View qui dérive de HwndHost, regardons le travail à effectuer côté application WPF pour que ces méthodes soient correctement appelées.

Une fenêtre d'application WPF dispose de nombreux évènements parmis lesquels l'évènement Loaded. Nous allons utiliser cet évènement pour créer notre instance de la classe View. Dans le XAML spécifiant notre fenêtre WPF nous allons donc indiquer que lorsque la fenêtre est chargée, la méthode On_UIReady de notre classe correspondant à la fenêtre doit être appelée. WPF repose sur la notion de classe partielle. Une partie de la classe est spécifiée de façon déclarative dans un fichier XAML, le reste est codé en C#. En l'occurrence dans notre projet d'exemple, les deux fichiers en question sont « Window1.xaml » et « Window1.xaml.cs ».

<Window x:Class="Ed.Window1"
    xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
    Title="Ed" Height="900" Width="1200"
    Loaded="On_UIReady"

Le code associé (se trouvant dans le fichier Window1.xaml.cs) est ci-dessous :

protected void On_UIReady(object sender, EventArgs e)
        {
Host.View view = new Host.View(insertHwndHere.ActualHeight, insertHwndHere.ActualWidth);
            insertHwndHere.Child = view;
view.MessageHook += new System.Windows.Interop.HwndSourceHook(dxView_MessageHook);

Comme on le voit ce code nous permet de créer notre instance de la classe View et de mettre en place un handler pour gérer les messages WIN32 envoyés à la fenêtre correspondante. En l'occurrence on se limite dans ce handler en C# à demander un affichage de notre scène DirectX 10 par le biais de la méthode render de notre classe View.

IntPtr dxView_MessageHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            DXRender();
            return IntPtr.Zero;
        }
        private void DXRender()
        {
            if (dxView!=null)
            {
                dxView.Render();
            }
        }

Si l'on résume ça de manière plus visuelle :

Initialisations propres à DX10

Du point de vue du programmeur, le GPU est représenté par l'objet Device. Une grande part des interactions avec le GPU s'appuie sur une instance de cet objet. Par ailleurs pour obtenir un résultat visible à l'écran, il nous faut aussi une swap chain. Pour créer l'un et l'autre, il nous suffit de fournir un handle vers la fenêtre Win32 auquel il doit être associé.

Regardons plus en détail le code de la méthode Init de notre classe View et le code qui est déroulé à partir de là (présent dans le fichier Host.h) :

bool Init(HWND hWnd)
        {
            if(FAILED(pViewer->InitRenderingSurface(hWnd)))
                return false;

            return true;
        }

Qui appelle la méthode ci-dessous de la classe CViewer (Fichier Viewer.cpp):

HRESULT CViewer::InitRenderingSurface(HWND hWnd)
{
    HRESULT hr = S_OK;

    RECT rc;

    m_hWnd = hWnd;
    GetClientRect( m_hWnd, &rc );
    m_Width = rc.right - rc.left;
    m_Height = rc.bottom - rc.top;

    m_pScene = new CScene(m_Width, m_Height);

    UINT createDeviceFlags = 0;
#ifdef _DEBUG
    createDeviceFlags |= D3D10_CREATE_DEVICE_DEBUG;
#endif

    D3D10_DRIVER_TYPE driverTypes[] = 
    {
        D3D10_DRIVER_TYPE_HARDWARE,
        D3D10_DRIVER_TYPE_SOFTWARE,
        D3D10_DRIVER_TYPE_REFERENCE,
    };
    UINT numDriverTypes = sizeof(driverTypes) / sizeof(driverTypes[0]);

    DXGI_SWAP_CHAIN_DESC sd;
    ZeroMemory( &sd, sizeof(sd) );
    sd.BufferCount = 1;
    sd.BufferDesc.Width = m_Width;
    sd.BufferDesc.Height = m_Height;
    sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
    sd.BufferDesc.RefreshRate.Numerator = 60;
    sd.BufferDesc.RefreshRate.Denominator = 1;
    sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
    sd.OutputWindow = m_hWnd;
    sd.SampleDesc.Count = 1;
    sd.SampleDesc.Quality = 0;
    sd.Windowed = TRUE;

    for( UINT driverTypeIndex = 0; driverTypeIndex < numDriverTypes; driverTypeIndex++ )
    {
        m_driverType = driverTypes[driverTypeIndex];
  hr = D3D10CreateDeviceAndSwapChain( NULL, m_driverType, NULL, createDeviceFlags, D3D10_SDK_VERSION, &sd, &m_pSwapChain, &m_pd3dDevice );
        if( SUCCEEDED( hr ) )
            break;
    }
    if( FAILED(hr) )
        return hr;

    // Create a render target view
    ID3D10Texture2D *pBuffer;
    hr = m_pSwapChain->GetBuffer( 0, __uuidof( ID3D10Texture2D), (LPVOID*)&pBuffer );
    if( FAILED(hr) )
        return hr;

    hr = m_pd3dDevice->CreateRenderTargetView( pBuffer, NULL, &m_pRenderTargetView );
    pBuffer->Release();
    if( FAILED(hr) )
        return hr;

    // Create the DepthStencil View.
        ID3D10Texture2D* pDepthStencil = NULL;
        D3D10_TEXTURE2D_DESC descDepth;
        descDepth.Width = m_Width;
        descDepth.Height = m_Height;
        descDepth.MipLevels = 1;
        descDepth.ArraySize = 1;
        descDepth.Format = DXGI_FORMAT_D32_FLOAT;
        descDepth.SampleDesc.Count = 1;
        descDepth.SampleDesc.Quality = 0;
        descDepth.Usage = D3D10_USAGE_DEFAULT;
        descDepth.BindFlags = D3D10_BIND_DEPTH_STENCIL;
        descDepth.CPUAccessFlags = 0;
        descDepth.MiscFlags = 0;
        hr = m_pd3dDevice->CreateTexture2D( &descDepth, NULL,        &pDepthStencil ); // [out] Texture


        D3D10_DEPTH_STENCIL_DESC dsDesc;

        // Depth test parameters
        dsDesc.DepthEnable = true;
        dsDesc.DepthWriteMask = D3D10_DEPTH_WRITE_MASK_ALL;
        dsDesc.DepthFunc = D3D10_COMPARISON_LESS;

        // Stencil test parameters
        dsDesc.StencilEnable = true;
        dsDesc.StencilReadMask = 0xFFFFFFFF;
        dsDesc.StencilWriteMask = 0xFFFFFFFF;

        // Stencil operations if pixel is front-facing
        dsDesc.FrontFace.StencilFailOp = D3D10_STENCIL_OP_KEEP;
        dsDesc.FrontFace.StencilDepthFailOp = D3D10_STENCIL_OP_INCR;
        dsDesc.FrontFace.StencilPassOp = D3D10_STENCIL_OP_KEEP;
        dsDesc.FrontFace.StencilFunc = D3D10_COMPARISON_ALWAYS;

        // Stencil operations if pixel is back-facing
        dsDesc.BackFace.StencilFailOp = D3D10_STENCIL_OP_KEEP;
        dsDesc.BackFace.StencilDepthFailOp = D3D10_STENCIL_OP_DECR;
        dsDesc.BackFace.StencilPassOp = D3D10_STENCIL_OP_KEEP;
        dsDesc.BackFace.StencilFunc = D3D10_COMPARISON_ALWAYS;

        // Create depth stencil state
        ID3D10DepthStencilState * pDSState;
        m_pd3dDevice->CreateDepthStencilState(&dsDesc, &pDSState);

        // Bind depth stencil state
        m_pd3dDevice->OMSetDepthStencilState(pDSState, 1);

        D3D10_DEPTH_STENCIL_VIEW_DESC descDSV;
        descDSV.Format = descDepth.Format;
        descDSV.ViewDimension = D3D10_DSV_DIMENSION_TEXTURE2D;
        descDSV.Texture2D.MipSlice = 0;

        // Create the depth stencil view
        hr = m_pd3dDevice->CreateDepthStencilView( pDepthStencil, &descDSV, &m_pDSV );  // [out] Depth stencil view


    m_pd3dDevice->OMSetRenderTargets( 1, &m_pRenderTargetView, m_pDSV );

    // Setup the viewport
    D3D10_VIEWPORT vp;
    vp.Width = m_Width;
    vp.Height = m_Height;
    vp.MinDepth = 0.0f;
    vp.MaxDepth = 1.0f;
    vp.TopLeftX = 0;
    vp.TopLeftY = 0;
    m_pd3dDevice->RSSetViewports( 1, &vp );

Le code ci-dessus peut paraitre un peu long. Je laisse les personnes intéressées se reporter à l'article suivant pour plus de détails sur les opérations effectués :
https://www.microsoft.com/france/msdn/directx/directx10-point-de-vue-du-developpeur.mspx

Les appels de méthodes importants dans le code ci-dessus ont été écrits en gras pour les mettre en avant. Pour résumer il faut se rappeler qu'avec DirectX 10, en plus de créer un device et un swap chain il faut explicitement créer les ressources qui vont être utilisées comme destination du calcul du rendu (rendertarget) ainsi que le buffer pour contenir les informations de profondeur associées aux pixel s déjà recouverts par une primitive(depth stencil). Par ailleurs comme c'est toujours le cas avec DirectX 10, avant d'utiliser une ressource donnée il faut obtenir une vue de cette ressource (par exemple dans le code ci-dessus avec CreateRenderTargetView) et c'est finalement cette vue qui est associée au pipeline graphique (par exemple avec OMSetRenderTargets pour le render target dans le code ci-dessus).

Utilisation du Inkcanvas

Comme nous le verrons dans les articles suivants, dans cet exemple de code nous allons générer des metaballs. Les paramètres à passer à nos shaders sont les centres de ces metaballs. Il est séduisant de vouloir produire ces points en dessinant à l'écran à l'aide d'une souris ou d'un stylet. Or il s'avère que dans WPF sont intégré des objets permettant justement de capturer ce type d'interactions avec l'utilisateur et que la mise en ?uvre est extrêmement simple. En l'occurrence il s'agit juste d'englober certaines portions de notre fenêtre WPF dans un objet de type InkCanvas . Voici la portion du fichier Windows1.Xaml qui spécifie la partie de notre fenêtre qui va contenir notre classe dérivant de HwndHost et utilisant DirectX 10 :

<InkCanvas Name="hwndInk" HorizontalAlignment="Left" VerticalAlignment="Top">
      <Border Name="insertHwndHere" Height="600" Width="600" />
</InkCanvas>

A partir de là, pour récupérer les informations correspondants aux entrées utilisateur, il nous suffit d'ajouter dans la méthode On_UIReady dont il a déjà été question plus haut (présente dans le fichier Windows1.xaml.cs) le code nécessaire pour qu'une de nos méthodes soit appelée au moment où l'objet InkCanvas fourni des données :

hwndInk.StrokeCollected += new InkCanvasStrokeCollectedEventHandler(hwndInk_StrokeCollected);

La ligne ci-dessus permet de préciser que c'est notre methode hwndInk_StrokeCollected qui doit ainsi être appelée. Cette méthode se charge ensuite de reconstruire des informations qui seront passées à notre classe View pour que celle-ci affiche le résultat désiré :

void hwndInk_StrokeCollected(object sender, InkCanvasStrokeCollectedEventArgs e)
        {
            Geometry geom = e.Stroke.GetGeometry();
            PathGeometry path = geom.GetFlattenedPathGeometry();

            float[] coords = new float[2 * numPointsToUseFromStroke];

            Point p = new Point();
            Point t = new Point();

            for (int i = 0; i <numPointsToUseFromStroke; i++)
            { 
                path.GetPointAtFractionLength((double)0.1*i, out p, out t);
                coords[2*i] = (float)p.X;
                coords[2*i+1] = (float)p.Y;
            }

            dxView.AddStrokes(coords, numPointsToUseFromStroke);
        }

En l'occurrence on resconstruit juste une collection de points de notre fenêtre qui seront utilisés par notre classe View pour reconstruire des points de l'espace 3D correspondant à notre scène qui seront à leur tour utilisés pour générer la géométrie. Je décris ces parties dans l'article traitant des shaders utilisés pour obtenir le rendu de nos géométries.

Comment exposer nos fonctionnalités à l'application WPF, comment repasser des données calculées par notre code C++/D3D10 et nos shaders.

Notre application utilise de nouvelles fonctionnalités du pipeline DirectX 10 pour récupérer la géométrie calculée par notre VertexShader et notre GéomtrieShader. Je décrirais cette fonctionnalité dans le troisième article de la série sur le ?StreamOut'. Je ne m'attache ici à décrire que le code nécessaire pour repasser le tableau de float correspondant à ces informations au code de mon application WPF. Cela clos la description qui est faite dans ce premier article de la série de l'ensemble des mécanismes permettant les interactions entre notre application WPF écrite en C# et l'assembly en C++/CLI sur lequel il s'appuie pour accéder aux fonctionnalités de DirectX 10.

En l'occurrence la partie intéressante est la méthode Read ci-dessous de la classe NativeStream présente dans notre assembly C++/CLI :

virtual int Read(array<float>^ buffer, int length, int offset)
        {
            if(length+offset>size)
            {
                throw gcnew ArgumentException("stream is too small for this");
            }
            if(!_internalDataPointer)
            {
                throw gcnew InvalidOperationException("invalid state!");
            }
            GCHandle^ handle = GCHandle::Alloc(buffer, GCHandleType::Pinned);
memcpy(handle->AddrOfPinnedObject().ToPointer(), _internalDataPointer, length*4); // float is 4 Bytes
            handle->Free();

            return length;
        }

Notre classe NativeStream maintient un pointeur vers la table de float récupérée après l'exécution de notre VertexShader et de notre GeometryShader. La méthode Read récupère un handle de type Pinned sur l'array managé qui lui est passé. Cela a pour effet de fixer cet objet managé dans le managed heap et en même temps de nous fournir un mécanisme pour obtenir une adresse mémoire pour pouvoir interagir avec cet objet comme s'il s'agissait d'une zone de mémoire non managée. Cela est effectué par le biais de : AddrOfPinnedObject().ToPointer(). On voit que l'adresse retournée est utilisée dans une simple memcpy qui copie le contenu de notre tableau de float natif que l'on a précédement récupéré grace au StreamOut.

Il s'agit là d'une technique simple qui peut être utilisée dès qu'il s'agit de faire de l'interopérabilité entre du code natif et du code managé. On peut très bien imaginer qu'un code similaire au code ci-dessus soit utilisé dans des applications n'ayant rien à voir avec DirectX. Par ailleurs le code en question est simple mais je souhaitais tout de même mentionner comment était réalisée cette opération pour être sûr d'aborder ne serait que brièvement tout les mécanismes mis en jeu pour passer des données et faire coopérer notre code WPF/C# avec notre assembly en C++ managé utilisant lui WPF et DirectX 10.

Télécharger les codes sources

Conclusion

Cet article ne décrit pas l'ensemble de l'application d'exemple. Il traite uniquement de la façon dont la partie de notre application WPF réalisée en C# interagit avec les autres parties de notre code réalisé en C++ managé qui elles font intervenir l'API DirectX.

Ce document doit être suivi de trois autres décrivant tour à tour :

  • Les shaders utilisés.
  • La façon d'utiliser la fonctionnalité StreamOut.
  • Le mécanisme mis en ?uvre pour obtenir un affichage 3D en utilisant les fonctionnalités 3D de WPF des géométries qui ont été calculées par nos shader.

Pour toutes remarques portant sur ces articles, n'hésitez pas à me contacter à l'adresse suivante guillara@microsoft.com.