Accueil Nos publications Blog Conversion YUV -> RGB avec Direct2D à l’aide d’un effet personnalisé

Conversion YUV -> RGB avec Direct2D à l’aide d’un effet personnalisé

DirectXRécemment j’ai eu l’occasion de pouvoir travailler auprès de l’équipe de VLC pour le portage de l’application en Universal App. Mon rôle dans l’équipe était de travailler sur le module qui fait le pont entre la librairie de VLC et l’application XAML.

La principale problématique était d’alléger au maximum le travail de la librairie. J’ai rencontré quelques soucis lors de la mise en place de ce module, notamment liés aux « effets personnalisés » de Direct2D. J’ai donc décidé de partager cette expérience à travers cet article.

Introduction

Contexte

Comme je vous l’ai dit précédemment, l’application VLC est actuellement en cours de portage en Universal Apps. L’équipe actuelle rencontrait des problèmes avec le module qui récupère les frames générés par la librairie et qui les affiche à l’écran dans le contexte de l’application XAML.

Avant de continuer, voici un schéma très macro qui explique les différentes étapes, pour la génération d’un frame, jusqu’à son affichage dans l’application.

Traitement d'un frame

Tout ce qui est important à comprendre pour le moment, c’est que la librairie VLC nous fournit une image dans le format demandé, et ensuite le module libdirect2d_winrt_plugin est censé faire un traitement pour l’écrire dans la swapchain, pour que l’application puisse ensuite l’afficher à l’écran.

Quand j’ai récupéré le projet, ce module était fonctionnel. La vidéo s’affichait. Mais ce dernier demandait à la librairie VLC un format RGB en sortie. Ce qui est certes beaucoup plus simple pour le traitement après coté Direct2D (surtout en store App), mais qui force la librairie à faire des conversions plutôt lourdes pour pouvoir nous fournir ce format.

L’idée était donc de s’approcher au maximum de la sortie « native » de VLC pour le soulager des conversions, et, de plutôt faire ces conversions sur le GPU.

Pour rappel DirectX, dans les applications Windows Store, ne sait gérer que les formats DXGI avec des composantes RGBA en natif (ou des données de profondeur, mais dans notre cas ça nous avance pas beaucoup).

Les formats YUV sont différents, et il y en a plusieurs. Voici une liste des différents formats de sortis YUV possible avec la librairie VLC : https://wiki.videolan.org/YUV/ (en anglais).

Le but de cet article n’étant pas de faire un cours sur les formats de pixels, je vous invite à suivre ce lien https://en.wikipedia.org/wiki/YUV (en anglais), si vous voulez en apprendre plus sur le format YUV.

L’idéal est de faire sortir à la lib VLC de l’I420, ce qui est maintenant le cas et qui sera détaillé dans le dernier chapitre. Mais avant, j’ai voulu utilisé l’effet YCbCr inclut dans Direct2D. Je vous ferai donc un petit retour d’expérience dessus dans le prochain chapitre.

Mise en place

Maintenant que je vous ai expliqué le contexte, je vais vous expliquer comment récupérer la solution de VLC, et pouvoir la compiler.

Les sources sont sur GIT. Il suffit de cloner le dépôt <git://git.videolan.org/vlc-ports/winrt.git>

Si vous voulez suivre la vie du projet vous pouvez aller à cette adresse: https://git.videolan.org/?p=vlc-ports/winrt.git;a=summary

Une fois que vous avez cloné le dépôt, il suffit de suivre les instructions suivantes : https://wiki.videolan.org/WinRTCompile/

Normalement à ce stade, la solution doit compiler correctement et vous pouvez exécuter (en Win32, pour l’instant), les applications du répertoire de solution VLC_WINRT_APP.

Voici un schéma pour visualiser les différents modules de la solution :

Architecture logicielle

Pour la partie qui nous intéresse, on retrouve :

  • Le SwapChainBackgroundPanel situé dans la page MainPage dans les projets VLC_WINRT_APP.xxx
  • La classe DirectXManager dans le projet libVLCX qui va créer les objets directX nécessaires et se lier au SwapChainBackgroundPanel.
  • Le projet libdirect2d_winrt_plugin.Shared qui contient le module en question.

Pour les deux premiers points je ne reviens pas dessus, car c’est du classique DirectX.

Une dernière chose qu’il faut savoir, avant d’entrer dans le vif du sujet avec les deux paragraphes suivants, c’est le fonctionnement du module libdirect2d_winrt_plugin. Ce module expose des méthodes à la librairie VLC qui les appellera en fonction du cycle de vie. Les trois fonctions qui vont nous intéresser, sont Open, Display et Prepare.

La première, Open, est appelée lorsque la vidéo est ouverte. Elle permet de préciser à la lib VLC le format que l’on souhaite.

Ensuite, Display, est appelée à la fin pour rendre le frame traité à l’écran. Dans notre cas elle est intéressante à connaitre, mais on ne codera rien dedans dans cet article puisqu’on fait juste un appel à la méthode Present pour permuter la swapchain.

Et enfin, Prepare, la méthode principale dans notre cas, puisqu’elle est appelée pour faire un traitement (si nécessaire) sur le frame avant de « l’envoyer » à l’écran. Son premier paramètre (vd) est une référence au même objet que le paramètre de la méthode Open. Il permet de stocker des informations et de les garder de frame en frame. Le second paramètre (picture) contient le frame dans le format demandé. Les deux membres qui vont le plus nous intéresser le plus dans picture sont :

  • format : qui permet d’obtenir entre autres la taille de l’image
  • p : qui contiend les données brutes du frame. C’est un tableau de, plane_t. Chaque plane_t représente un plan de l’image. Par exemple pour un format RGB seul le premier plan sera rempli avec un bloc de bytes correspondants aux composantes RGB. Pour un format YUV, il y aura autant de plans remplis que de composantes prévues pour ce format. Pour rappel il y a plusieurs formats YUV comportant chacun un nombre de plans différents (1, 2 ou 3).

L’effet intégré YCbCr de Direct2D

Je vous en parlais dans l’introduction, le but est de demander à VLC de sortir de l’I420 pour faire la suite du traitement côté GPU. Je vous montrerai comment faire ça dans le prochain paragraphe.

Avant, je voulais partager avec vous, l’étape précédente, car avant d’en arriver au résultat final j’ai essayé d’utiliser l’effet intégré YCbCr de Direct2D.

Pourquoi utiliser cet effet ? Tout simplement puisqu’il avait l’air de répondre au besoin en lisant la documentation, et du coup ça réduisait énormément les coûts de développements. Mais malheureusement en testant, j’ai rencontré un problème : Cet effet ne semble gérer que les formats YUV à deux plans. En utilisant le débugger graphique on voit que le pixel shader ne prend que 2 Texture2D en paramètre quel que soit le nombre d’entrée que l’on fournit à l’effet.

Je vous donne quand même le code que j’avais utilisé avec cet effet, ça peut servir à quelqu’un.

Pour commencer dans la méthode Open il faut préciser à VLC que l’on veut du NV12 en sorti.

vd->fmt.i_chroma = VLC_CODEC_NV12;

Ensuite tout se passe dans la méthode Prepare. Pour simplifier le code, l’effet sera créé à chaque frame, et les bitmaps pour les composantes aussi. Attention ce code est uniquement là pour illustrer la partie conversion YUV->RGB, il ne faut surtout pas l’utiliser tel que en production. Il faudrait utiliser en plus un mécanisme pour garder les bitmaps alloués, et ne faire que des copies pour chaque frame.

static void Prepare(vout_display_t *vd, picture_t *picture, subpicture_t *subpicture)
{
    vout_display_sys_t     *sys = vd->sys;
    D2D1_SIZE_U            size;
    float                  dpi = DisplayProperties::LogicalDpi;

    ID2D1Bitmap                 *yBitmap;
    ID2D1Bitmap                 *uvBitmap;
    ID2D1Effect                 *yuvEffect;

    // On réserve la render target
    sys->d2dContext->BeginDraw();

    // On rempli la render target de noir
    sys->d2dContext->Clear(D2D1::ColorF(D2D1::ColorF::Black));

    // Récupèration de la taille du frame
    size.width          = picture->format.i_visible_width;
    size.height         = picture->format.i_visible_height;

    // Création de l'effet
    sys->d2dContext->CreateEffect(CLSID_D2D1YCbCr, &yuvEffect);

    // Initialisation d'un bitmap avec seulement une composante R 
    // pour stocker la luminance du frame
    D2D1_BITMAP_PROPERTIES propsY;
    D2D1_PIXEL_FORMAT      pixFormatY;
    pixFormatY.alphaMode = D2D1_ALPHA_MODE_IGNORE;
    pixFormatY.format = DXGI_FORMAT_R8_UNORM;
    propsY.pixelFormat = pixFormatY;
    propsY.dpiX = dpi;
    propsY.dpiY = dpi;

    // Création du bitmap avec les données du premier plan
    // fournit par VLC
    if (S_OK != sys->d2dContext->CreateBitmap(size,
            picture->p[0].p_pixels,
            picture->p[0].i_pitch,
            propsY,
            &yBitmap))
            return;

    // On ajoute le bitmap en tant que première entrée de l'effet
    yuvEffect->SetInput(0, yBitmap);

    // Initialisation d'un bitmap deux composantes RG
    // pour stocker la chrominance du frame.
    D2D1_BITMAP_PROPERTIES propsCbCr;
    D2D1_PIXEL_FORMAT      pixFormatCbCr;
    pixFormatCbCr.alphaMode = D2D1_ALPHA_MODE_IGNORE;
    pixFormatCbCr.format = DXGI_FORMAT_R8G8_UNORM;
    propsCbCr.pixelFormat = pixFormatCbCr;
    propsCbCr.dpiX = dpi;
    propsCbCr.dpiY = dpi;

    // On calcul la moitié de la taille du frame
    // Pour rappel en NV12 les deux composantes de
    // chrominance sont subsamplé avec un coef 2 et
    // stocké sur le même plan
    D2D1_SIZE_U halfSize = size;
    halfSize.width = size.width / 2;
    halfSize.height = size.height / 2;

    // Création du bitmap avec les données du premier plan
    // fournit par VLC
    if (S_OK != sys->d2dContext->CreateBitmap(halfSize,
            picture->p[1].p_pixels,
            picture->p[1].i_pitch,
            propsCbCr,
            &uvBitmap))
            return;

    // On ajoute le bitmap en tant que seconde entrée de l'effet
    yuvEffect->SetInput(1, uvBitmap);

    // Calcul du coefficient de scale et d'offset pour rendre le frame en plein écran
    float scaleW = *sys->displayWidth / (float)picture->format.i_visible_width;
    float scaleH = *sys->displayHeight / (float)picture->format.i_visible_height;

    D2D1_POINT_2F offset;
    float scale;
    if( scaleH <= scaleW) {
        scale = scaleH;
        offset.x = (*sys->displayWidth - 
            ((float)picture->format.i_visible_width * scale)) / 2.0f;
        offset.y =  0.0f; ;
    } else {
        scale = scaleW;
        offset.x =  0.0f;
        offset.y = (*sys->displayHeight - 
            ((float)picture->format.i_visible_height * scale)) / 2.0f;
    }

    // Création de la matrice de transformation avec les données calculées précédemment
    D2D1::Matrix3x2F transform =
    D2D1::Matrix3x2F::Scale(scale, scale) *
    D2D1::Matrix3x2F::Translation(offset.x, offset.y);

    // On passe la matrice transformation en paramètre de l'effet
    yuvEffect->SetValue(D2D1_YCBCR_PROP_TRANSFORM_MATRIX, transform);

    // Rendu du frame
    sys->d2dContext->DrawImage(yuvEffect, D2D1_INTERPOLATION_MODE_CUBIC);

    // Libération de la rendertarget
    vd->sys->d2dContext->EndDraw();

    VLC_UNUSED(subpicture);
}

Le code est plutôt simple à comprendre. L’effet YCbCr y est pour beaucoup. Nous avons juste à créer un bitmap avec un composante R pour stocker la luminance de l’image. Puis d’en créer un second avec une taille deux fois plus petite avec deux composantes RG pour stocker les deux composantes de chrominance (qui sont stockées sur le même plan en NV12).

Il ne reste plus qu’à fournir ces deux bitmaps à l’effet, et d’ajouter dans notre cas une matrice de transformation pour le rendu plein écran, pour finir par effectuer le rendu dans la RenderTarget.

Cet effet est très avantageux, le seul problème, comme je le disais plus haut, c’est qu’il ne gère que les formats sur deux plans. On avait tout de même déjà là une nette amélioration des performances. La dernière étape était de trouver un moyen de pouvoir gérer le format I420 pour maximiser encore les perfs.

Pour ça j’ai choisi d’écrire un effet personnalisé.

Effet personnalisé pour gérer le I420

Qu’est-ce qu’un effet personnalisé déjà pour commencer ?

Un effet Direct2D a pour but de réaliser un (ou plusieurs) traitement(s) sur une image. Comme on l’a vu dans le paragraphe précédent, il y en a certains qui sont intégrés dans Direct2D, comme celui pour l’YCbCr. La liste des effets intégrés est disponible ici : https://msdn.microsoft.com/en-us/library/windows/desktop/hh706316(v=vs.85).aspx.

Un effet personnalisé, c’est tout simplement un traitement d’image qui respecte un certain cadre, et qui n’est pas intégré à Direct2D. Qu’il faut développer soit même.

Ce cadre nous sera donné en implémentant des interfaces. On verra un peu plus tard exactement de quoi il retourne.
Mais avant cela, voyons comment fonctionne un effet Direct2D. Comme je le disais juste au-dessus, les effets sont une succession d’un ou plusieurs traitement(s). Ces traitements sont appelés transformations (Transform). Ces transformations ont les caractéristiques suivantes :

  • Elles prennent 1 ou plusieurs image(s) en entrée
  • Elles sortent une image
  • Pour écrire ces transformations on peut utiliser les Shaders suivants : Compute, Vertex, Pixel

Un effet contient un graphe de transformation (Transform Graph). Ce graphe définit le point d’entrée de l’effet (Par quelle transformation commencer), puis la succession potentielle d’autres transformations, et pour finir le point de sortie (Quelle sortie de transformation sera la sortie de l’effet). Un graphe peut être très complexe, mais je vous rassure dans notre cas le graphe ne contiendra qu’une seule et unique transformation que l’on définira avec un vertex et un pixel shader.

Pour avoir tous les détails sur le mode de fonctionnement d’un effet personnalisé dans Direct2D, je vous invite à parcourir cette documentation sur msdn : https://msdn.microsoft.com/en-us/library/windows/desktop/jj710194(v=vs.85).aspx (en anglais)

L’effet

Rentrons maintenant un peu plus dans le détail en regardant le code, et commençons par voir la déclaration de la classe I420Effect.

#pragma once

#include <wrl.h>
#include <d3d11_2.h>
#include <d2d1_2.h>
#include <d2d1effects_1.h>
#include <dwrite_2.h>
#include <wincodec.h>
#include <agile.h>
#include <math.h>
#include <d2d1effectauthor.h>
#include <d2d1effecthelpers.h>

#include "I420Effect_PS.h"
#include "I420Effect_VS.h"

// {3AB41678-D4BC-4BE1-8A91-07A63DEEFEA1}
DEFINE_GUID(GUID_I420PixelShader,0x3ab41678, 0xd4bc, 0x4be1, 0x8a, 0x91, 0x7, 0xa6, 0x3d, 0xee, 0xfe, 0xa1);
// {D2A2EF51-23D8-41FF-9DF0-1B5D7CC2C3C5}
DEFINE_GUID(GUID_I420VertexShader, 0xd2a2ef51, 0x23d8, 0x41ff, 0x9d, 0xf0, 0x1b, 0x5d, 0x7c, 0xc2, 0xc3, 0xc5);
// {DA637E40-44D4-4617-A3D3-A28344549EEA}
DEFINE_GUID(CLSID_CustomI420Effect, 0xda637e40, 0x44d4, 0x4617, 0xa3, 0xd3, 0xa2, 0x83, 0x44, 0x54, 0x9e, 0xea);


typedef enum I420_PROP
{
    I420_PROP_DISPLAYEDFRAME_WIDTH = 0,
    I420_PROP_DISPLAYEDFRAME_HEIGHT = 1,
    I420_PROP_SCALE = 2
};

struct Vertex{
    float x;
    float y;
};

class I420Effect : public ID2D1EffectImpl, public ID2D1DrawTransform
{
public:
    // Declare effect registration methods.
    static HRESULT Register(_In_ ID2D1Factory1* pFactory);
    static HRESULT __stdcall CreateRippleImpl(_Outptr_ IUnknown** ppEffectImpl);

    float GetDisplayedFrameWidth() const;
    HRESULT SetDisplayedFrameWidth(float width);

    float GetDisplayedFrameHeight() const;
    HRESULT SetDisplayedFrameHeight(float height);

    float GetScale() const;
    HRESULT SetScale(float scale);

    // Declare ID2D1EffectImpl implementation methods.
    IFACEMETHODIMP Initialize(_In_ ID2D1EffectContext* pContextInternal,
                            _In_ ID2D1TransformGraph* pTransformGraph);
    IFACEMETHODIMP PrepareForRender(D2D1_CHANGE_TYPE changeType);
    IFACEMETHODIMP SetGraph(_In_ ID2D1TransformGraph* pGraph);

    // Declare ID2D1DrawTransform implementation methods.
    IFACEMETHODIMP SetDrawInfo(_In_ ID2D1DrawInfo* pRenderInfo);

    // Declare ID2D1Transform implementation methods.
    IFACEMETHODIMP MapOutputRectToInputRects(
        _In_ const D2D1_RECT_L* pOutputRect,
        _Out_writes_(inputRectCount) D2D1_RECT_L* pInputRects,
        UINT32 inputRectCount
        ) const;

    IFACEMETHODIMP MapInputRectsToOutputRect(
        _In_reads_(inputRectCount) CONST D2D1_RECT_L* pInputRects,
        _In_reads_(inputRectCount) CONST D2D1_RECT_L* pInputOpaqueSubRects,
        UINT32 inputRectCount,
        _Out_ D2D1_RECT_L* pOutputRect,
        _Out_ D2D1_RECT_L* pOutputOpaqueSubRect
        );

    IFACEMETHODIMP MapInvalidRect(
        UINT32 inputIndex,
        D2D1_RECT_L invalidInputRect,
        _Out_ D2D1_RECT_L* pInvalidOutputRect
        ) const;

    // Declare ID2D1TransformNode implementation methods.
    IFACEMETHODIMP_(UINT32) GetInputCount() const;

    // Declare IUnknown implementation methods.
    IFACEMETHODIMP_(ULONG) AddRef();
    IFACEMETHODIMP_(ULONG) Release();
    IFACEMETHODIMP QueryInterface(_In_ REFIID riid, _Outptr_ void** ppOutput);
private:

    I420Effect();
    HRESULT UpdateConstants();

    Microsoft::WRL::ComPtr<ID2D1VertexBuffer>   m_vertexBuffer;
    UINT                                        m_numVertices;
    Microsoft::WRL::ComPtr<ID2D1DrawInfo>       m_drawInfo;

    LONG                                        m_refCount;
    D2D1_RECT_L                                 m_inputRect;
    D2D1_SIZE_U                                 m_displayedFrame;
    D2D1_SIZE_U                                 m_originalFrame;

    float                                       m_dpi;
    float                                       m_scale;

    struct
    {
        float displayedFrameWidth;
        float displayedFrameHeight;
        float originalFrameWidth;
        float originalFrameHeight;
    } m_constants;
};

Bon je sais… Balancer comme ça sans trop prévenir ça peu piquer un peu, mais ne vous inquiétez pas je vais détailler un peu chaque partie.

Tout d’abord on commence par déclarer des GUID, qui nous permettrons d’enregistrer notre effet auprès de Direct2D et de charger nos shaders :

// {3AB41678-D4BC-4BE1-8A91-07A63DEEFEA1}
DEFINE_GUID(GUID_I420PixelShader,0x3ab41678, 0xd4bc, 0x4be1, 0x8a, 0x91, 0x7, 0xa6, 0x3d, 0xee, 0xfe, 0xa1);
// {D2A2EF51-23D8-41FF-9DF0-1B5D7CC2C3C5}
DEFINE_GUID(GUID_I420VertexShader, 0xd2a2ef51, 0x23d8, 0x41ff, 0x9d, 0xf0, 0x1b, 0x5d, 0x7c, 0xc2, 0xc3, 0xc5);
// {DA637E40-44D4-4617-A3D3-A28344549EEA}
DEFINE_GUID(CLSID_CustomI420Effect, 0xda637e40, 0x44d4, 0x4617, 0xa3, 0xd3, 0xa2, 0x83, 0x44, 0x54, 0x9e, 0xea);

Ensuite, ce n’est pas obligatoire, mais c’est tout de même plus lisible pour la suite, je crée une énumération qui contient les identifiants des paramètres que l’on pourra passer à l’effet.

typedef enum I420_PROP
{
    I420_PROP_DISPLAYEDFRAME_WIDTH = 0,
    I420_PROP_DISPLAYEDFRAME_HEIGHT = 1,
    I420_PROP_SCALE = 2
};

Je ne parle pas tout de suite de la structure Vertex, j’en parlerai dans un chapitre réservé au Vertex Shader.

Ensuite on déclare notre classe I420Effect en précisant que l’on va implémenter les interfaces ID2D1EffectImpl et ID2D1DrawTransform. Ces deux interfaces ont un rôle différent, mais pour aller plus vite dans le développement j’ai prévu de les implémenter dans la même classe.

class I420Effect : public ID2D1EffectImpl, public ID2D1DrawTransform

ID2D1EffectImpl est l’interface qui va nous fournir les méthodes qui permettent d’exposer notre effet dans le pipeline prévu par Direct2D :

// Declare ID2D1EffectImpl implementation methods.
IFACEMETHODIMP Initialize(_In_ ID2D1EffectContext* pContextInternal,_In_ ID2D1TransformGraph* pTransformGraph);
IFACEMETHODIMP PrepareForRender(D2D1_CHANGE_TYPE changeType);
IFACEMETHODIMP SetGraph(_In_ ID2D1TransformGraph* pGraph);

Initialize permet par exemple de charger les shaders, ou encore de définir le graphe de transformation de notre effet.

PrepareToRender comme son nom l’indique si bien est appelée avant chaque rendu de l’effet. On pourra dans cette méthode mettre à jour les valeurs à envoyer aux shaders par exemple.

Et enfin SetGraph est appelée lorsque le nombre d’entrée change. Dans notre cas comme le nombre d’entrée sera toujours de 3, donc je n’ai pas fait de logique particulière dedans. Mais il est tout à fait possible de mettre à jour un compteur interne et ensuite effectuer des traitements en fonction.

Passons maintenant à l’implémentation de l’interface ID2D1DrawTransform. Cette interface permet de définir le comportement de la transformation qui sera utilisée par notre effet.

Tout d’abord il y a :

// Declare ID2D1DrawTransform implementation methods.
IFACEMETHODIMP SetDrawInfo(_In_ ID2D1DrawInfo* pRenderInfo);

SetDrawInfo permet de définir le pipeline graphique de notre transformation. C’est dans cette méthode que l’on va pouvoir indiquer à notre transformation quel shader à utiliser pour effectuer le rendu (shader que l’on aura précédemment chargé dans la méthode Initialize).

Ensuite vienne les méthodes de l’interface ID2D1Transform que ID2D1DrawTransform nous demande d’implémenter :

// Declare ID2D1Transform implementation methods.
    IFACEMETHODIMP MapOutputRectToInputRects(
        _In_ const D2D1_RECT_L* pOutputRect,
        _Out_writes_(inputRectCount) D2D1_RECT_L* pInputRects,
        UINT32 inputRectCount
        ) const;

    IFACEMETHODIMP MapInputRectsToOutputRect(
        _In_reads_(inputRectCount) CONST D2D1_RECT_L* pInputRects,
        _In_reads_(inputRectCount) CONST D2D1_RECT_L* pInputOpaqueSubRects,
        UINT32 inputRectCount,
        _Out_ D2D1_RECT_L* pOutputRect,
        _Out_ D2D1_RECT_L* pOutputOpaqueSubRect
        );

    IFACEMETHODIMP MapInvalidRect(
        UINT32 inputIndex,
        D2D1_RECT_L invalidInputRect,
        _Out_ D2D1_RECT_L* pInvalidOutputRect
        ) const;

Ces trois méthodes permettent de mapper les zones d’entrées et avec celle de sortie de notre transformation. On va grâce à elles définir la taille de notre sortie, et dire quelles parties des entrées on va lire.

Il ne reste plus que la méthode :

// Declare ID2D1TransformNode implementation methods.
IFACEMETHODIMP_(UINT32) GetInputCount() const;

Comme son nom l’indique, elle renvoi le nombre d’entrée actuellement traité par l’effet. Dans notre cas elle renverra une constante égale à 3.

Je ne parlerai pas des trois méthodes de l’interface IUnknown qui sont liées à l’exposition à travers COM, qui n’est pas le sujet de l’article.

Il y a d’autres méthodes dans cette classe que celles des interfaces à implémenter. Tout d’abord les accesseurs des propriétés que l’on va vouloir exposer :

float GetDisplayedFrameWidth() const;
HRESULT SetDisplayedFrameWidth(float width);

float GetDisplayedFrameHeight() const;
HRESULT SetDisplayedFrameHeight(float height);

float GetScale() const;
HRESULT SetScale(float scale);

On verra un peu plus tard comment les exposer à Direct2D.

Et pour finir les deux méthodes :

// Declare effect registration methods.
static HRESULT Register(_In_ ID2D1Factory1* pFactory);
static HRESULT __stdcall CreateI420Impl(_Outptr_ IUnknown** ppEffectImpl);

Register est appelée pour enregistrer notre effet auprès de Direct2D. Et CreateI420Impl sert de fabrique. C’est elle qui crée une instance de notre classe I420Effect quand nécessaire.

Justement tant que nous sommes dans ces deux méthodes, voyons leurs implémentations :

HRESULT I420Effect::Register(_In_ ID2D1Factory1* pFactory)
{
    // Format Effect metadata in XML as expected
    PCWSTR pszXml =
        XML(
        <?xml version='1.0' ?>
        <Effect>
        <!--System Properties-->
        <Property name='DisplayName' type='string' value='I420' />
        <Property name='Author' type='string' value='VideoLan' />
        <Property name='Category' type='string' value='CODEC' />
        <Property name='Description' type='string' value='Adds a I420 Support' />
        <Inputs>
            <Input name='ySource' />
            <Input name='uSource' />
            <Input name='vSource' />
        </Inputs>
        <Property name='DisplayedFrameWidth' type='float' value='0'>
            <Property name='DisplayName' type='string' value='RenderTarget Size X' />
            <Property name='Default' type='float' value='0.0' />
        </Property>
        <Property name='DisplayedFrameHeight' type='float' value='0'>
            <Property name='DisplayName' type='string' value='RenderTarget Size Y' />
            <Property name='Default' type='float' value='0.0' />
        </Property>
        <Property name='Scale' type='float' value='0'>
        <Property name='DisplayName' type='string' value='Scale' />
        <Property name='Default' type='float' value='0.0' />
        </Property>
        </Effect>
        );

    // Create the binding for the differents properties
    const D2D1_PROPERTY_BINDING bindings[] =
    {
        D2D1_VALUE_TYPE_BINDING(L"DisplayedFrameWidth", 
                                        &SetDisplayedFrameWidth, 
                                        &GetDisplayedFrameWidth),
        D2D1_VALUE_TYPE_BINDING(L"DisplayedFrameHeight", 
                                        &SetDisplayedFrameHeight, 
                                        &GetDisplayedFrameHeight),
        D2D1_VALUE_TYPE_BINDING(L"Scale", 
                                        &SetScale, 
                                        &GetScale), };

    // Register the effect in the factory
    return pFactory->RegisterEffectFromString(
        CLSID_CustomI420Effect,
        pszXml,
        bindings,
        ARRAYSIZE(bindings),
        CreateI420Impl
        );
}

HRESULT __stdcall I420Effect::CreateI420Impl(_Outptr_ IUnknown** ppEffectImpl)
{
    // Since the object's refcount is initialized to 1, we don't need to AddRef here.
    *ppEffectImpl = static_cast<ID2D1EffectImpl*>(new (std::nothrow) I420Effect());

    if (*ppEffectImpl == nullptr)
    {
        return E_OUTOFMEMORY;
    }
    else
    {
        return S_OK;
    }
}

Comme je vous le disais CreateI420Impl crée une instance de I420Effect.

La méthode Register quant à elle est un peu plus longue. Tout d’abord on crée un XML qui permet de fournir les méta-données de notre effet à Direct2D. Ensuite on crée un tableau de D2D1_PROPERTY_BINDING qui nous permet pour chacune de nos propriétés d’indiquer à Direct2D des accesseurs. Et pour finir on appelle la méthode RegisterEffectFromString qui fait l’enregistrement. On lui passe en paramètre le GUID, le xml, le tableau de binding, et un pointeur sur la fonction CreateI420Impl pour que Direct2D puisse l’appeler.

Si on revient dans le fichier libdirect2d_winrt_plugin.cpp dans la méthode Open on voit que l’effet est enregistré de cette manière :

HRESULT hr_create = I420Effect::Register(sys->d2dFactory.Get());

Nous savons donc maintenant comment enregistrer notre effet auprès de Direct2D. Poursuivons donc avec l’initialisation. Tout va se passer dans la méthode Initialize vous vous doutez bien. Tout d’abord on charge nos deux shaders :

HRESULT hr = pEffectContext->LoadPixelShader(GUID_I420PixelShader, I420Effect_ByteCode, ARRAYSIZE(I420Effect_ByteCode));
if (SUCCEEDED(hr))
{
    hr = pEffectContext->LoadVertexShader(GUID_I420VertexShader, I420Effect_VS_ByteCode, ARRAYSIZE(I420Effect_VS_ByteCode));

    /* ... */
}

J’ai utilisé la génération d’un .h pour chaque shader avec le bytecode des shaders dans un tableau de BYTE.

La suite aurait pu être beaucoup plus simple, si Direct2D avait le comportement définit dans la documentation msdn. Puisque l’on n’a pas besoin d’un traitement particulier sur le vertex buffer, Direct2D est censé être capable de nous en fournir un par défaut (représentant un rectangle formé par deux triangles). Mais quand j’ai essayé ce qui est écrit dans la documentation, j’ai eu une erreur générique du type « Une erreur s’est produite lors du dessin ». En fouillant un peu, et grâce au magnifique outil qu’est le débugger graphique de visual studio, j’ai vu dans la table des objets en mémoire, ni vertex buffer ni input layout. J’ai donc dû passer par la méthode un peu plus lourde de la création d’un vertex buffer « à la main ». Rien de bien compliqué, en soit, mais ça fait des lignes de codes qui auraient pu être évitées :

// Create two triangles to shape the rectangle
Vertex* mesh = new Vertex[6];
mesh[0].x = 0; mesh[0].y = 1;
mesh[1].x = 0; mesh[1].y = 0;
mesh[2].x = 1; mesh[2].y = 0;

mesh[3].x = 1; mesh[3].y = 0;
mesh[4].x = 1; mesh[4].y = 1;
mesh[5].x = 0; mesh[5].y = 1;

if (SUCCEEDED(hr))
{

    D2D1_VERTEX_BUFFER_PROPERTIES vbProp = { 0 };
    vbProp.byteWidth = sizeof(Vertex) * 6;
    vbProp.data = reinterpret_cast<BYTE*>(mesh);
    vbProp.inputCount = 1;
    vbProp.usage = D2D1_VERTEX_USAGE_STATIC;

    // Define the inputLayout
    static const D2D1_INPUT_ELEMENT_DESC vertexLayout[] =
    {
        { "MESH_POSITION", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 0 },
    };

    D2D1_CUSTOM_VERTEX_BUFFER_PROPERTIES cvbProp = { 0 };
    cvbProp.elementCount = ARRAYSIZE(vertexLayout);
    cvbProp.inputElements = vertexLayout;
    cvbProp.stride = sizeof(Vertex);
    cvbProp.shaderBufferWithInputSignature = I420Effect_VS_ByteCode;
    cvbProp.shaderBufferSize = ARRAYSIZE(I420Effect_VS_ByteCode);

    // The GUID is optional, and is provided here to register the geometry globally.
    // As mentioned above, this avoids duplication if multiple versions of the effect
    // are created.
    hr = pEffectContext->CreateVertexBuffer(
        &vbProp,
        &GUID_I420VertexShader,
        &cvbProp,
        &m_vertexBuffer
        );
}

// Since mesh has already been transferred to GPU, it can be removed.
delete[] mesh;

Et pour finir, une fois que l’on a créé notre vertex buffer, il ne nous reste plus qu’à définir notre graphe de transformation. Si vous vous rappelez bien je vous ai dit plus haut que notre effet ne possède qu’une seule transformation, il suffit donc juste d’appeler :

hr = pTransformGraph->SetSingleTransformNode(this);

Ici on passe this en paramètre car l’implémentation de notre effet est aussi l’implémentation de la transformation qu’il utilise, mais on aurait pu découpler les deux.

La méthode d’initialisation est maintenant complète.

Ensuite nous pouvons passer à la définition de la taille de la zone de sortie. Grâce à l’implémentation des 3 méthodes suivantes :

IFACEMETHODIMP I420Effect::MapOutputRectToInputRects(
    _In_ const D2D1_RECT_L* pOutputRect,
    _Out_writes_(inputRectCount) D2D1_RECT_L* pInputRects,
    UINT32 inputRectCount
    ) const
{
    if (inputRectCount != 3)
        return E_NOTIMPL;

    pInputRects[0] = pInputRects[1] = pInputRects[2] = pOutputRect[0];

    return S_OK;
}

IFACEMETHODIMP I420Effect::MapInputRectsToOutputRect(
    _In_reads_(inputRectCount) CONST D2D1_RECT_L* pInputRects,
    _In_reads_(inputRectCount) CONST D2D1_RECT_L* pInputOpaqueSubRects,
    UINT32 inputRectCount,
    _Out_ D2D1_RECT_L* pOutputRect,
    _Out_ D2D1_RECT_L* pOutputOpaqueSubRect
    )
{
    D2D1_RECT_L rect;
    rect= D2D1::RectL(pInputRects[0].left, 
                            pInputRects[0].top, 
                            pInputRects[0].right * m_scale, 
                            pInputRects[0].bottom * m_scale);
    *pOutputRect = rect;

    if (m_inputRect.bottom != pInputRects[0].bottom
        || m_inputRect.top != pInputRects[0].top
        || m_inputRect.right != pInputRects[0].right
        || m_inputRect.left != pInputRects[0].left)
    {
        m_inputRect = pInputRects[0];
        m_originalFrame.width= m_inputRect.right;
        m_originalFrame.height = m_inputRect.bottom;
        UpdateConstants();
    }

    // Indicate that entire output might contain transparency.
    ZeroMemory(pOutputOpaqueSubRect, sizeof(*pOutputOpaqueSubRect));

    return S_OK;
}

IFACEMETHODIMP I420Effect::MapInvalidRect(
    UINT32 inputIndex,
    D2D1_RECT_L invalidInputRect,
    _Out_ D2D1_RECT_L* pInvalidOutputRect
    ) const
{
    HRESULT hr = S_OK;

    // Indicate that the entire output may be invalid.
    *pInvalidOutputRect = m_inputRect;

    return hr;
}

Rien de bien compliqué dans ces méthodes, ce qu’il faut surtout retenir, c’est que l’on définit la taille de la sortie grâce à la taille de la première entrée et également la valeur de la propriété scale que l’on a exposé à l’initalisation. On vérifie également que la taille de la première entrée n’a pas changé, sinon on met à jour les informations que l’on enverra au vertex shader. Pour ça on appelle la méthode UpdateConstants que voici :

HRESULT I420Effect::UpdateConstants()
{
    m_constants.displayedFrameWidth = m_displayedFrame.width;
    m_constants.displayedFrameHeight = m_displayedFrame.height;

    m_constants.originalFrameWidth = m_originalFrame.width;
    m_constants.originalFrameHeight = m_originalFrame.height;

    return  m_drawInfo->SetVertexShaderConstantBuffer(
                            reinterpret_cast<BYTE*>(&m_constants), 
                            sizeof(m_constants));
}

On met simplement à jour le constant buffer, avec les tailles de la sortie, et celle du frame d’origine (dans le fichier vidéo).

Et pour finir, nous allons configurer le pipeline graphique pour l’exécution du rendu de notre effet :

IFACEMETHODIMP I420Effect::SetDrawInfo(_In_ ID2D1DrawInfo* pDrawInfo)
{
    HRESULT hr = S_OK;

    m_drawInfo = pDrawInfo;

    D2D1_VERTEX_RANGE range;
    range.startVertex = 0;
    range.vertexCount = 6;

    hr = m_drawInfo->SetVertexProcessing(m_vertexBuffer.Get(), D2D1_VERTEX_OPTIONS_USE_DEPTH_BUFFER, nullptr, &range, &GUID_I420VertexShader);
    hr = m_drawInfo->SetPixelShader(GUID_I420PixelShader);

    return hr;
}

On dit simplement ici de prendre le vertex shader et le pixel shader que l’on a chargé précédemment dans la méthode Initialize pour exécuter le rendu.

Le code de la classe I420Effect est maintenant complet on va pouvoir passer au code des shaders, et on finira ensuite par la méthode Prepare du module pour voir comment appeler l’effet.

Vertex Shader

Le vertex shader commence par le constant buffer fournit par direct2D :

cbuffer Direct2DTransforms : register(b0)
{
    float2x1 sceneToOutputX;
    float2x1 sceneToOutputY;
    float2x1 sceneToInput0X;
    float2x1 sceneToInput0Y;
};

Il contient les matrices de transformations pour traduire une position dans l’espace de la scène 3D en une position projetée (les matrices nommées SceneToOutput). Et les matrices nommées SceneToInput permettent la conversion entre les positions dans l’espace de la scène vers une coordonnée de texture.

Après on déclare notre constant buffer avec les propriétés que l’on a défini dans notre classe I420Effect :

cbuffer constants : register(b1)
{
    // size in pixel of the frame to draw to the screen
    float2 displayedFrameSize;

    // size in pixel of the original frame (native video source resolution)
    float2 originalFrameSize;
};

Pour rappel displayedFrameSize représente la taille de l’image à afficher à l’écran, et originalFrameSize représente la taille du frame encodé dans la vidéo.

Vient ensuite la déclaration de la structure de sortie de notre shader :

struct VSOut
{
    float4 clipSpaceOutput  : SV_POSITION;
    float4 sceneSpaceOutput : SCENE_POSITION;
    float4 texelSpaceInput0 : TEXCOORD0;
};

On renvoi donc la position projetée, la position dans la scène, et la coordonnée de texture du sommet.

La structure d’entrée est plutôt simple et doit correspondre à celle définit dans la classe I420Effect(surtout pour la sémantique d’entrée) :

struct VSIn
{
    // Vertices position
    float2 position : MESH_POSITION;
};

Et finalement le corps de la fonction du shader :

VSOut main(VSIn input)
{
    VSOut output;

    // Compute Scene-space output (vertex simply passed-through here). 
    output.sceneSpaceOutput.x = displayedFrameSize.x * input.position.x;
    output.sceneSpaceOutput.y = displayedFrameSize.y * input.position.y;
    output.sceneSpaceOutput.z = 0.0f;
    output.sceneSpaceOutput.w = 1.0f;

    // Generate standard Clip-space output coordinates.
    output.clipSpaceOutput.x =  (output.sceneSpaceOutput.x * sceneToOutputX[0] + sceneToOutputX[1]);
    output.clipSpaceOutput.y = (output.sceneSpaceOutput.y *sceneToOutputY[0] + sceneToOutputY[1]);

    output.clipSpaceOutput.z = output.sceneSpaceOutput.z;
    output.clipSpaceOutput.w = output.sceneSpaceOutput.w;

    // Generate standard Texel-space input coordinates.
    output.texelSpaceInput0.x = (originalFrameSize.x * input.position.x * sceneToInput0X[0]) + sceneToInput0X[1];
    output.texelSpaceInput0.y = (originalFrameSize.y * input.position.y * sceneToInput0Y[0]) + sceneToInput0Y[1];
    output.texelSpaceInput0.z = 1 - input.position.y;
    output.texelSpaceInput0.w = 1;

    return output;
}

On calcul d’abord la position dans la scène en multipliant par la propriété displayedFrameSize. Puis on applique à cette position les matrices de transformations pour obtenir une position projetée. Et pour finir on multiplie la position dans la scène à la propriété originalFrameSize, et on applique les matrices de transformations pour obtenir une coordonnée de texture.

Pixel Shader

Pour le pixel shader c’est encore plus court.

On commence par les textures et les samplers qui vont avec :

Texture2D yTexture : register(t0);
Texture2D uTexture : register(t1);
Texture2D vTexture : register(t2);

SamplerState ySampler : register(s0);
SamplerState uSampler : register(s1);
SamplerState vSampler : register(s2);

Direct2D enregistre par défaut les entrées de l’effet en tant que texture dans le pixel shader. Il enregistre également un sampler par entrée.

Ensuite on passe directement à la méthode du pixel shader (j’avais dit que c’était plus court) :

float4 main(
    float4 pos      : SV_POSITION,
    float4 posScene : SCENE_POSITION,
    float4 uv0 : TEXCOORD0
    ) : SV_Target
{
    float Y = (yTexture.Sample(ySampler, uv0) * 255).r;
    float U = (uTexture.Sample(uSampler, uv0) * 255).r;
    float V = (vTexture.Sample(vSampler, uv0) * 255).r;

    float4 color = float4(0, 0, 0, 1.0f);

    color.b = clamp(1.164383561643836*(Y - 16) + 2.017232142857142*(U - 128), 0, 255) / 255.0f;
    color.g = clamp(1.164383561643836*(Y - 16) - 0.812967647237771*(V - 128) - 0.391762290094914*(U - 128), 0, 255) / 255.0f;
    color.r = clamp(1.164383561643836*(Y - 16) + 1.596026785714286*(V - 128), 0, 255) / 255.0f;

    return color;
}

On retrouve d’abord les composantes Y, U, V en récupérant les composantes R de nos textures. Puis on applique simplement la formule de conversion YUV -> RGB.

Appeler l’effet

Je vous laisse aller regarder le code de la méthode Prepare pour voir ce qu’elle fait exactement maintenant, car il y a eu pas mal de changement depuis la version avec l’effet YCbCr, notamment la création de trois bitmaps au lieu de deux. Mais regardons quand même comment nous créons l’effet dans cette méthode :

if (sys->yuvEffect == nullptr)
    {
        hrx = sys->d2dContext->CreateEffect(CLSID_CustomI420Effect, &sys->yuvEffect);
        sys->yuvEffect->SetValue(I420_PROP_DISPLAYEDFRAME_WIDTH, (float)(picture->format.i_visible_width * sys->scale));
        sys->yuvEffect->SetValue(I420_PROP_DISPLAYEDFRAME_HEIGHT, (float)(picture->format.i_visible_height * sys->scale));
        sys->yuvEffect->SetValue(I420_PROP_SCALE, sys->scale);
    }

Grâce au cadre qui nous est fourni pour la création d’effet, on voit finalement que les effets personnalisés se créent coté consommateur de la même manière que les effets intégrés de Direct2D. Et pour l’affichage aussi :

sys->d2dContext->DrawImage(sys->yuvEffect, D2D1_INTERPOLATION_MODE_CUBIC);

Conclusion

Dans cet article nous avons vu comme réaliser une conversion YUV -> RGB côté GPU avec Direct2D. Je vous ai également parlé des limitations de l’effet intégré YCbCr. J’espère également vous avoir un peu plus éclairé sur la création d’un effet personnalisé, notamment avec le problème de documentation sur le vertex buffer. Ce système d’effet offre une grande souplesse et d’énormes possibilités.

A bientôt,