在 WPF 中使用 Direct2D






4.96/5 (27投票s)
在 WPF 控件中托管 Direct2D 内容。

引言
随着 Windows 7 的推出,微软引入了一项名为 Direct2D 的新技术(在安装了 平台更新 的 Windows Vista SP2 上也支持)。浏览其所有文档,你会注意到它是面向 Win32 开发人员的;然而,Windows API Code Pack 允许 .NET 开发人员轻松使用 Windows 7 的功能,Direct2D 就是其中一项受支持的功能。不幸的是,Code Pack 中包含的所有 WPF 示例都需要在 HwndHost 中托管控件,这会带来问题,因为它有 “气隙问题”。这基本上意味着 Direct2D 控件需要与 WPF 控件的其余部分分开,这意味着不能有带透明度的重叠控件。
所附代码允许将 Direct2D 视为普通 WPF 控件,并且由于一些 COM 接口,您无需下载 DirectX SDK 甚至无需处理任何 C++ 代码——唯一的依赖项是前面提到的 Code Pack(其二进制文件包含在所附文件中)。本文更多地是关于一路遇到的问题创建控件所涉及的挑战,因此如果您想直接开始,请随时跳到使用代码部分。
背景
WPF 架构
WPF 构建于 DirectX 9 之上,并使用保留渲染系统。这意味着您不会在屏幕上绘制任何东西,而是创建视觉对象树;它们的绘制指令被缓存,然后由框架自动渲染。这与使用 DirectX 进行图形处理相结合,不仅使 WPF 应用程序在需要重新绘制时保持响应,而且还允许 WPF 使用“画家算法”绘制模型。在此模型中,每个组件(从显示器的背面开始,向前面移动)都被要求自行绘制,允许它们覆盖前一个组件的显示。这就是 WPF 如此容易拥有复杂和/或部分透明形状的原因——因为它在设计时就考虑到了这种场景。有关更多信息,请查看 MSDN 文章。
Direct2D 架构
与托管 WPF 模型相反,Direct2D 是即时模式,开发人员负责一切。这意味着您需要负责创建资源、刷新屏幕并自行清理。它建立在 Direct3D 10.1 之上,这使其具有高性能渲染,但提供了 WPF 的几个优点(例如与设备无关的单位、ClearType 文本渲染、每个基元抗锯齿以及纯色/线性/径向/位图画刷)。MSDN 有更深入的介绍;但是,它更面向原生开发人员。
互操作性
Direct2D 的设计宗旨是易于集成到使用 GDI、GDI+ 或 Direct3D 的现有项目中,并提供多种选项将 Direct2D 内容与 Direct3D 10.1 或更高版本集成。Direct2D SDK 甚至包含一个名为 DXGI Interop 的示例,展示了如何实现这一点。
为了在 WPF 中托管 Direct3D 内容,.NET 3.5 SP1 引入了 D3DImage 类。这允许您将 Direct3D 9 内容作为 ImageSource 托管,使其可以在 Image 控件内部使用,或作为 ImageBrush 等。CodeProject 上有一篇很棒的文章 这里 提供了更多信息和示例。
敏锐的人会注意到,虽然这两种技术都可以与 Direct3D 配合使用,但 Direct2D 需要版本 10.1 或更高版本,而 WPF 中的 D3DImage 只支持版本 9。快速的互联网搜索发现了 Jeremiah Morrill 的这篇博客文章。他解释说 IDirect3DDevice9Ex(D3DImage 支持)支持在设备之间共享资源。因此,在 Direct3D 10.1 中创建的共享渲染目标可以通过中间的 IDirect3DDevice9Ex 设备引入到 D3DImage 中。他还包含了实现这一功能的示例源代码,所附代码就是基于他的工作。
因此,我们现在有一种方法可以使 Direct2D 与 Direct3D 10.1 配合工作,并且我们可以使 WPF 与 Direct3D 10.1 配合工作;唯一的问题是这两个示例都依赖于非托管 C++ 代码和 DirectX SDK。为了解决这个问题,我们将通过 COM 接口访问 DirectX。
组件对象模型
我承认我对 COM 一无所知,除了避免它!但是,CodeProject 上有一篇文章 这里,它帮助我不再那么害怕。要使用 COM,我们必须使用底层技术,我惊讶(并松了一口气!)地发现 Marshal 类 有一些方法可以模仿通常需要在非托管代码中完成的任何事情。
由于我们只需要 Direct3D 9 中的少数几个对象,并且每个对象中只有一两个函数是我们感兴趣的,所以我们不尝试将所有接口及其函数转换为其 C# 等效项,而是手动映射 V-table,如链接文章中所述。为此,我们将创建一个辅助函数,该函数将从 V-table 中指定槽位提取方法。
public static bool GetComMethod<T, U>(T comObj, int slot, out U method) where U : class
{
    IntPtr objectAddress = Marshal.GetComInterfaceForObject(comObj, typeof(T));
    if (objectAddress == IntPtr.Zero)
    {
        method = null;
        return false;
    }
    try
    {
        IntPtr vTable = Marshal.ReadIntPtr(objectAddress, 0);
        IntPtr methodAddress = Marshal.ReadIntPtr(vTable, slot * IntPtr.Size);
        // We can't have a Delegate constraint, so we have to cast to
        // object then to our desired delegate
        method = (U)((object)Marshal.GetDelegateForFunctionPointer(
                             methodAddress, typeof(U)));
        return true;
    }
    finally
    {
        Marshal.Release(objectAddress); // Prevent memory leak
    }
}
这段代码首先获取 COM 对象的地址(使用 Marshal.GetComInterfaceForObject),然后获取存储在 COM 对象开头的 V-table 的位置(使用 Marshal.ReadIntPtr),然后从 V-table 中获取指定槽位处方法的地址(乘以指针的系统大小,因为 Marshal.ReadIntPtr 指定了字节偏移量),最后为返回的函数指针创建一个可调用的委托(Marshal.GetDelegateForFunctionPointer)。很简单!
需要注意的重要一点是,调用 Marshal.GetComInterfaceForObject 返回的 IntPtr 必须被释放;我没有意识到这一点,发现当资源被重新创建时,我的程序内存泄漏。此外,函数使用委托的 out 参数,因此我们获得了类型推断的所有好处,从而减少了调用者所需的打字量。最后,你会注意到有一些讨厌的强制转换到 object,然后再转换到 delegate 类型。这是不幸但必要的,因为 C# 中无法指定委托泛型约束(CLI 实际上允许此约束,正如 Jon Skeet 在 他的博客 中提到的)。由于这是一个内部类,我们将假定函数的调用者知道此约束。
有了这个辅助函数,围绕 COM 接口创建包装器变得容易得多,所以让我们看看如何围绕 IDirect3DTexture9 接口提供一个包装器。首先,我们将创建一个内部接口,并附带 ComImport、Guid 和 InterfaceType 属性,以便 Marshal 类知道如何使用该对象。对于 guid,我们需要查看 DirectX SDK 头文件,特别是 d3d9.h
interface DECLSPEC_UUID("85C31227-3DE5-4f00-9B3A-F11AC38C18B5") IDirect3DTexture9;
打开同一个头文件,我们还可以查找接口的声明,经过预处理器处理并去除 __declspec 和 __stdcall 属性后,它看起来像这样
struct IDirect3DTexture9 : public IDirect3DBaseTexture9
{
    virtual HRESULT QueryInterface( const IID & riid, void** ppvObj) = 0;
    virtual ULONG AddRef(void) = 0;
    virtual ULONG Release(void) = 0;
    
    virtual HRESULT GetDevice( IDirect3DDevice9** ppDevice) = 0;
    virtual HRESULT SetPrivateData( const GUID & refguid, 
            const void* pData,DWORD SizeOfData,DWORD Flags) = 0;
    virtual HRESULT GetPrivateData( const GUID & refguid, 
            void* pData,DWORD* pSizeOfData) = 0;
    virtual HRESULT FreePrivateData( const GUID & refguid) = 0;
    virtual DWORD SetPriority( DWORD PriorityNew) = 0;
    virtual DWORD GetPriority(void) = 0;
    virtual void PreLoad(void) = 0;
    virtual D3DRESOURCETYPE GetType(void) = 0;
    virtual DWORD SetLOD( DWORD LODNew) = 0;
    virtual DWORD GetLOD(void) = 0;
    virtual DWORD GetLevelCount(void) = 0;
    virtual HRESULT SetAutoGenFilterType( D3DTEXTUREFILTERTYPE FilterType) = 0;
    virtual D3DTEXTUREFILTERTYPE GetAutoGenFilterType(void) = 0;
    virtual void GenerateMipSubLevels(void) = 0;
    virtual HRESULT GetLevelDesc( UINT Level,D3DSURFACE_DESC *pDesc) = 0;
    virtual HRESULT GetSurfaceLevel( UINT Level,IDirect3DSurface9** ppSurfaceLevel) = 0;
    virtual HRESULT LockRect( UINT Level,D3DLOCKED_RECT* pLockedRect, 
            const RECT* pRect,DWORD Flags) = 0;
    virtual HRESULT UnlockRect( UINT Level) = 0;
    virtual HRESULT AddDirtyRect( const RECT* pDirtyRect) = 0;
};
我们的代码只需要其中一个方法,即 GetSurfaceLevel 方法。从上往下数,我们可以看到这是第 19 个方法,因此在 V-table 中的槽位是 18。我们现在可以围绕这个接口创建一个包装器类。
internal sealed class Direct3DTexture9 : IDisposable
{
    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    private delegate int GetSurfaceLevelSignature(IDirect3DTexture9 texture, 
                         uint Level, out IntPtr ppSurfaceLevel);
    [ComImport, Guid("85C31227-3DE5-4f00-9B3A-F11AC38C18B5"), 
                InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
    internal interface IDirect3DTexture9
    {
    }
    private IDirect3DTexture9 comObject;
    private GetSurfaceLevelSignature getSurfaceLevel;
    internal Direct3DTexture9(IDirect3DTexture9 obj)
    {
        this.comObject = obj;
        HelperMethods.GetComMethod(this.comObject, 18, 
                                   out this.getSurfaceLevel);
    }
    ~Direct3DTexture9()
    {
        this.Release();
    }
    public void Dispose()
    {
        this.Release();
        GC.SuppressFinalize(this);
    }
    public IntPtr GetSurfaceLevel(uint Level)
    {
        IntPtr surface;
        Marshal.ThrowExceptionForHR(this.getSurfaceLevel(
                              this.comObject, Level, out surface));
        return surface;
    }
    private void Release()
    {
        if (this.comObject != null)
        {
            Marshal.ReleaseComObject(this.comObject);
            this.comObject = null;
            this.getSurfaceLevel = null;
        }
    }
}
在代码中,我使用了 Marshal.ThrowExceptionForHR 来确保调用成功——如果出现错误,它将抛出相关的 .NET 类型(例如,结果为 E_NOTIMPL 将导致抛出 NotImplementedException)。
使用代码
要使用随附的代码,您可以将编译后的二进制文件包含到您的项目中,或者包含代码,因为代码量不大(尽管花费了大量时间创建它!)。无论哪种方式,您都需要确保在您的项目中引用 Windows API Code Pack DirectX 库。
代码中有三个类值得关注:D3D10Image、Direct2DControl 和 Scene。
D3D10Image 类继承自 D3DImage,并添加了 SetBackBuffer 方法的重写,该方法接受一个 Direct3D 10 纹理(以 Microsoft.WindowsAPICodePack.DirectX.Direct3D10.Texture2D 对象的形式)。根据代码的编写方式,纹理必须采用 DXGI_FORMAT_B8G8R8A8_UNORM 格式;但是,您可以随意编辑 GetSharedSurface 函数中的代码,将其修改为所需的任何格式(实际上,Jeremiah Morrill 的原始代码确实允许使用不同的格式,因此可以参考它来获取灵感)。
Direct2DControl 是 D3D10Image 控件的包装器,提供了一种简单的方法来显示 Scene。当 Scene 和 D3D10Image 失效时,该控件负责重新绘制它们,并调整其内容的大小。为了帮助提高性能,该控件使用计时器在收到调整大小事件 100 毫秒后调整内容大小。如果在此期间发生另一个调整大小请求,计时器将再次重置为 100 毫秒。这听起来可能在调整大小时导致问题,但内部控件使用 Image 控件,该控件在调整大小时会拉伸其内容,因此内容将始终可见;它们可能只是暂时模糊。调整大小完成后,控件将以正确的分辨率重新绘制其内容。有时,由于我不知道的原因,发生这种情况时会出现闪烁,但通过使用计时器,这种情况会很少发生。
Scene 类是一个抽象类,包含三个主要函数供您重写:OnCreateResources、OnFreeResources 和 OnRender。前两个函数的原因是 DirectX 设备可能会被销毁(例如,如果您切换用户),之后您需要创建一个新设备。这些方法允许您创建/释放依赖于设备的资源,例如画刷。OnRender 方法,顾名思义,是您进行实际绘图的地方。
将这些组合起来,我们得到了这段代码,用于在半透明蓝色背景上创建一个简单的矩形
<!-- Inside your main window XAML code -->
<!-- Make sure you put a reference to this at the top of the file:
        xmlns:d2d="clr-namespace:Direct2D;assembly=Direct2D"
 -->
<d2d:Direct2DControl x:Name="d2DControl" />
using D2D = Microsoft.WindowsAPICodePack.DirectX.Direct2D1;
internal sealed class MyScene : Direct2D.Scene
{
    private D2D.SolidColorBrush redBrush;
    protected override void OnCreateResources()
    {
        // We'll fill our rectangle with this brush
        this.redBrush = this.RenderTarget.CreateSolidColorBrush(
                             new D2D.ColorF(1, 0, 0));
    }
    protected override void OnFreeResources()
    {
        if (this.redBrush != null)
        {
            this.redBrush.Dispose();
            this.redBrush = null;
        }
    }
    protected override void OnRender()
    {
        // This is what we're going to draw
        var size = this.RenderTarget.Size;
        var rect = new D2D.Rect
            (
                5,
                5,
                (int)size.Width - 10,
                (int)size.Height - 10
            );
        // This actually draws the rectangle
        this.RenderTarget.BeginDraw();
        this.RenderTarget.Clear(new D2D.ColorF(0, 0, 1, 0.5f));
        this.RenderTarget.FillRectangle(rect, this.redBrush);
        this.RenderTarget.EndDraw();
    }
}
// This is the code behind class for the XAML
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        // Add this after the call to InitializeComponent. Really you should
        // store this object as a member so you can dispose of it, but in our
        // example it will get disposed when the window is closed.
        this.d2DControl.Scene = new MyScene();
    }
}
更新场景
在更新 Scene 的原始代码中,您需要调用 Direct2DControl.InvalidateVisual。现在这已更改,因此调用 Scene 上的 Render 方法将触发新的 Updated 事件,Direct2DControl 会订阅该事件并相应地使其区域无效。
还发现 Scene 在重绘时有时会闪烁。这似乎是 D3DImage 控件的一个问题,解决方案(虽然不是 100%)是同步 AddDirtyRect 调用与 WPF 渲染时间(通过订阅 CompositionTarget.Rendering 事件)。这一切都由 Direct2DControl 为您处理。
为了使事情变得更简单,有一个新的类派生自 Scene,名为 AnimatableScene。在发布第一个版本后,关于如何进行连续场景更新存在一些困惑,所以希望这个类能使其更容易——您像使用 Scene 类一样使用它,但您的 OnRender 代码将在需要时通过在构造函数中设置所需的每秒帧数来调用(尽管请参阅限制部分)。另请注意,如果您重写 OnCreateResources 方法,您需要在代码末尾调用基类的版本以开始动画,并且当您重写 OnFreeResources 方法时,您需要首先调用基类的版本以停止动画(请参阅附加代码中的示例)。
混合模式程序集是针对版本 'v2.0.50727' 构建的
附加代码是针对 .NET 4.0 编译的(尽管它可能可以重新定位以在 .NET 2.0 下工作),但 Code Pack 是针对 .NET 2.0 编译的。当我第一次引用 Code Pack 并尝试运行应用程序时,上述异常不断被抛出。解决方案在这里找到,它是在项目中包含一个带有以下 startup 信息的 app.config 文件
<?xml version="1.0"?>
<configuration>
  <startup useLegacyV2RuntimeActivationPolicy="true">
    <supportedRuntime version="v4.0"/>
  </startup>
</configuration>
限制
Direct2D 将通过远程桌面工作;但是(据我所知),D3DImage 控件未渲染。不幸的是,我只有 Windows 7 家庭高级版,因此无法测试任何变通方法,但欢迎在评论中提供反馈。
所编写的代码将支持面向 x86 或 x64 平台(甚至使用 *Any CPU* 设置);但是,您需要使用正确版本的 *Microsoft.WindowsAPICodePack.DirectX.dll*;我无法找到一种方法使其自动化,并且我认为 Code Pack 无法编译为使用 *Any CPU*,因为它使用了非托管代码。
AnimatableScene 中使用的计时器是 DispatchTimer。MSDN 指出
[DispatcherTimer] 不保证在时间间隔发生时精确执行 [...]。这是因为DispatcherTimer操作像其他操作一样被放置在调度程序队列中。DispatcherTimer操作何时执行取决于队列中的其他作业及其优先级。
历史
- 2010年11月2日 - Direct2DControl已更改为使用DispatchTimer,以便它不包含任何需要处理的控件(使 FxCop 更满意),并且该控件现在与 WPF 的CompositionTarget.Rendering事件同步以减少闪烁。Scene已更改为包含Updated事件并允许派生类访问其D2DFactory。此外,还添加了AnimatedScene类。
- 2010年9月21日 - 初始版本。


