此文章由机器翻译。

与 c + + 窗口

DirectComposition:控制一切的保留模式 API

Kenny Kerr

下载代码示例

Kenny Kerr图形 Api 一般分为了两个非常不同阵营。 有知名的例子,包括 Direct2D 和 Direct3D 的即时模式 Api。 然后还有Windows Presentation Foundation(WPF) 等保留模式 Api 或任何 XAML 或声明式的 API。 现代的浏览器提供明确区分的两个图形模式,提供一个保留模式 API 和提供即时模式 API 的画布元素的可缩放矢量图形。

保留模式假定图形 API 将保留一些代表性的一幕,如图形或对象,然后随着时间的推移被操纵的树。 这是方便,简化了交互式应用程序的开发。 相比之下,立即模式 API 不包括内置场景图,而是依赖于应用程序构建的场景使用的绘图命令序列。 这有巨大的性能优势。 即时模式 API 如 Direct2D 通常将缓冲顶点数据从多个几何,凝聚大量的绘图命令以及更多。 这是特别有益,与文本渲染管线,字形首先需要被写入到一个纹理和下采样之前清除类型筛选应用和文本使得其途径是呈现目标。 这是为什么很多其他图形 Api 和越来越多的第三方应用程序,现在依靠 Direct2D 和 DirectWrite 用于文本渲染的原因之一。

即时模式与保留模式的选择传统上是下来的一种权衡性能和生产率之间。 开发者们可以选择用于绝对性能的 Direct2D 立即模式 API 或 WPF 保留模式 API 为生产力或不方便。 DirectComposition 通过使开发人员能够更自然地融入这两个更改这个方程。 它模糊了立即模式和保留模式 Api 之间的界线,因为它提供了一种保留模式的图形,但不施加任何内存或性能开销。 它通过专注于位图组成,而不是试图与其他图形 Api 竞争来实现这一壮举。 DirectComposition 只是提供了可视化树和组成基础设施这样的位图呈现与其他技术可以轻松地操纵和共同组成。 与 WPF 中,不同的是 DirectComposition 是 OS 图形基础结构的一个组成部分,并避免了所有传统上一直困扰 WPF 应用程序的性能和空域问题。

如果你读过我在 DirectComposition 上前面的两个专栏 (msdn.microsoft.com/magazine/dn745861msdn.microsoft.com/magazine/dn786854),你应该已经有什么组合引擎是有能力的一种。 现在我想要的更多明确通过向您显示如何使用 DirectComposition 来操纵可视对象绘制带 Direct2D 是对习惯于保留模式 Api 的开发人员很有吸引力的方式。 我要向你展示如何创建一个简单的窗口,提出了圆圈,作为"对象",可以创建和移动,全力支持与命中测试和更改为 Z-顺序。 你可以看看这看起来像在中的示例图 1

拖动环绕
图 1 拖动环绕

虽然在圈子图 1 并用 Direct2D 应用画一圈只有一次到一个组成表面绘制。 在绑定到该窗口的可视化树,这组成表面然后之间组成的视觉效果的共享。 每个视觉定义偏移量相对于窗口的内容 — — 组成表面 — — 是定位并最终呈现由组合引擎。 用户可以创建新的圈子和与鼠标、 笔或手指移动它们。 每一次选择了一个圆圈,它将移动到 Z 顺序的顶部所以它显示在窗口中的任何其他圈上方。 虽然我肯定不需要保留模式 API 来实现一个简单的效果,它会作为 DirectComposition API 是如何工作的以及 Direct2D 实现一些功能强大的视觉效果很好的例子。 目标是建立一个其 WM_PAINT 处理程序并不是负责保持窗口的像素为单位) 最新的交互式应用程序。

我将开始一个新的 SampleWindow 类从我在我以前的专栏中介绍的窗口类模板派生的。 窗口类模板只是简化了 c + + 中的消息分发:

struct SampleWindow : Window<SampleWindow>
{
};

任何现代的 Windows 应用程序,我需要处理动态 DPI 缩放所以我将添加两个浮点成员来跟踪的 DPI 比例因子为 X 轴和 Y 轴:

float m_dpiX = 0.0f;
float m_dpiY = 0.0f;

你可以初始化这些需求,在我以前的专栏中,或在您的 WM_CREATE 消息处理程序内示。 无论哪种方式,你需要调用 MonitorFromWindow 函数来确定监视器的相交的新窗口的面积最大。 然后你只需调用 GetDpiForMonitor 函数来检索其有效的 DPI 值。 所以我不会在这里重申它,我已经说明了这在以前的专栏和课程的次数。

我将使用一个 Direct2D 椭圆几何对象来描述圈要绘制以便稍后使用此相同的几何对象进行命中测试。 虽然它是更有效的方法画出比几何对象的 D2D1_ELLIPSE 结构,几何对象提供的命中测试和绘图将被保留。 我会保持跟踪的 Direct2D 工厂和椭圆几何:

ComPtr<ID2D1Factory2> m_factory;
ComPtr<ID2D1EllipseGeometry> m_geometry;

在我以前的专栏中我将向您展示如何创建一个 Direct2D 设备对象,直接用 D2D1CreateDevice 函数,而不是通过使用一个 Direct2D 工厂。 这当然是可以接受的方式继续下去,但这里面有蹊跷。 虽然他们都是独立于设备和设备损失发生时,不需要重新创建 Direct2D 工厂资源,可以用相同的 Direct2D 工厂创建的 Direct2D 设备仅用。 因为我想要创建椭圆几何前面,我需要一个 Direct2D 工厂对象来创建它。 我可以,或许,等到我已经用 D2D1CreateDevice 函数创建 Direct2D 设备,然后用 GetFactory 方法,检索的底层的工厂,然后使用该工厂对象创建几何,但那似乎相当做作。 相反,我会只是创建一个 Direct2D 工厂并使用它来创建椭圆几何形状和所需的设备对象。 图 2 说明了如何创建 Direct2D 工厂和几何对象。

图 2 创建的 Direct2D 工厂和几何对象

void CreateFactoryAndGeometry()
{
  D2D1_FACTORY_OPTIONS options = {};
  #ifdef _DEBUG
  options.debugLevel = D2D1_DEBUG_LEVEL_INFORMATION;
  #endif
  HR(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED,
                       options,
                       m_factory.GetAddressOf()));
  D2D1_ELLIPSE const ellipse = Ellipse(Point2F(50.0f, 50.0f),
                                       49.0f,
                                       49.0f);
  HR(m_factory->CreateEllipseGeometry(ellipse,
                                      m_geometry.GetAddressOf()));
}

然后可以由 SampleWindow 的构造函数来准备这些设备-调用 CreateFactoryAndGeometry 方法­独立资源。 正如你所看到的该椭圆定义围绕中心点 50 像素沿 X 轴和 Y 轴,以及 49 像素的 X 和 Y 的半径,使此椭圆成一个圆的半径。 我将创建一个 100 x 100 组成的表面。 我选择了 49 像素的半径范围,因为默认的笔触绘制由 Direct2D 横跨周长和它否则会被剪切。

接下来是特定于设备的资源。 我需要一个支持 Direct3D 设备,组成设备以将更改提交到可视化树,组成目标保持视觉树活着,将代表所有圈的视觉效果和一个共享的组成表面的父根视觉:

ComPtr<ID3D11Device> m_device3D;
ComPtr<IDCompositionDesktopDevice> m_device;
ComPtr<IDCompositionTarget> m_target;
ComPtr<IDCompositionVisual2> m_rootVisual;
ComPtr<IDCompositionSurface> m_surface;

在我前面的 DirectX 文章,尤其是,在我前面的两个专栏在 DirectComposition 上,我已经介绍了这些不同的对象。 我也讨论了,说明你应如何处理设备创建和损失所以我在这里不会重复的。 我只是打电话给出了需要更新使用以前创建的 Direct2D 工厂的 CreateDevice2D 方法:

ComPtr<ID2D1Device> CreateDevice2D()
{
  ComPtr<IDXGIDevice3> deviceX;
  HR(m_device3D.As(&deviceX));
  ComPtr<ID2D1Device> device2D;
  HR(m_factory->CreateDevice(deviceX.Get(), 
    device2D.GetAddressOf()));
  return device2D;
}

现在我将创建的共享的表面。 我需要小心使用 ComPtr 类模板 ReleaseAndGetAddressOf 方法以确保表面可以很安全地重新创建后设备丢失或由于 DPI 缩放比例的变化。 我也需要小心,保留我的应用程序为 DirectComposition API 翻译成物理像素的尺寸时使用的逻辑坐标系统:

HR(m_device->CreateSurface(
  static_cast<unsigned>(LogicalToPhysical(100, m_dpiX)),
  static_cast<unsigned>(LogicalToPhysical(100, m_dpiY)),
  DXGI_FORMAT_B8G8R8A8_UNORM,
  DXGI_ALPHA_MODE_PREMULTIPLIED,
  m_surface.ReleaseAndGetAddressOf()));

我然后可以调用组成表面的 BeginDraw 方法来接收 Direct2D 设备上下文,以缓冲绘图命令:

HR(m_surface->BeginDraw(
  nullptr,
  __uuidof(dc),
  reinterpret_cast<void **>(dc.GetAddressOf()),
  &offset));

然后我需要告诉 Direct2D 如何缩放任何绘图命令:

dc->SetDpi(m_dpiX,
           m_dpiY);

我需要转换到的偏移量,由 DirectComposition 提供的输出:

dc->SetTransform(Matrix3x2F::Translation(PhysicalToLogical(offset.x, m_dpiX),
                                         PhysicalToLogical(offset.y, m_dpiY)));

PhysicalToLogical 是一个 helper 函数,我经常使用的 DPI 缩放时结合过不同程度的 DPI 缩放比例 (或没有) 支持的 Api。 你可以看到的 PhysicalToLogical 功能和对应的 LogicalToPhysical 函数在图 3

图 3 逻辑和物理像素之间的转换

template <typename T>
static float PhysicalToLogical(T const pixel,
                               float const dpi)
{
  return pixel * 96.0f / dpi;
}
template <typename T>
static float LogicalToPhysical(T const pixel,
                               float const dpi)
{
  return pixel * dpi / 96.0f;
}

现在我能简单地画一个蓝色的圆圈与纯色画笔创建,为此:

ComPtr<ID2D1SolidColorBrush> brush;
D2D1_COLOR_F const color = ColorF(0.0f, 0.5f, 1.0f, 0.8f);
HR(dc->CreateSolidColorBrush(color,
                             brush.GetAddressOf()));

下一步,我必须在填充的椭圆几何,然后抚摸或绘图与修改的画笔绘制其轮廓之前清除呈现目标:

dc->Clear();
dc->FillGeometry(m_geometry.Get(),
                 brush.Get());
brush->SetColor(ColorF(1.0f, 1.0f, 1.0f));
dc->DrawGeometry(m_geometry.Get(),
                 brush.Get());

最后,我必须调用 EndDraw 方法,以指示表面是准备组成:

HR(m_surface->EndDraw());

现在它是创建圈子的时间。 在我以前的专栏,我创建只有一个单一的根视觉,但此应用程序需要创建视觉效果上的需求,所以我会在方便的帮助器方法,只是包起来:

ComPtr<IDCompositionVisual2> CreateVisual()
{
  ComPtr<IDCompositionVisual2> visual;
  HR(m_device->CreateVisual(visual.GetAddressOf()));
  return visual;
}

DirectComposition API 有趣的方面之一是它实际上是一个只写到组合引擎接口。 虽然它保留可视化树,你的窗口,它不提供任何 getter 方法,您可以使用来审问的可视化树。 任何信息,如视觉位置或 Z-顺序,必须由应用程序直接保留。 这避免了不必要的内存开销,也避免了潜在的竞争条件,世界的应用程序的视图和组合引擎事务性状态之间。 所以我会勇往直前,创造一个圈结构来跟踪每个圆的位置:

struct Circle
{
  ComPtr<IDCompositionVisual2> Visual;
  float LogicalX = 0.0f;
  float LogicalY = 0.0f;
};

视觉的成分有效地表示圆的二传手同时 LogicalX 和辩证统一字段是 getter 方法。 我可以将与 IDCompositionVisual2 接口的视觉位置的设置,可以在保留和稍后检索其地位与其他字段。 这是必要的命中测试和设备损失后恢复圈子。 为了避免这些变得不同步,我将简单地提供更新基于的逻辑位置的可视对象的帮助器的方法。 DirectComposition API 有不知道的内容可能会如何定位和缩放,所以我需要自己做出必要的 DPI 计算:

void UpdateVisualOffset(float const dpiX,
                        float const dpiY)
{
  HR(Visual->SetOffsetX(LogicalToPhysical(LogicalX, dpiX)));
  HR(Visual->SetOffsetY(LogicalToPhysical(LogicalY, dpiY)));
}

我将添加另一个帮助器方法的实际设置圆的逻辑偏移量。 这一依赖于 UpdateVisualOffset 确保圈层结构和对象的可视对象的同步:

void SetLogicalOffset(float const logicalX,
                      float const logicalY,
                      float const dpiX,
                      float const dpiY)
{
  LogicalX = logicalX;
  LogicalY = logicalY;
  UpdateVisualOffset(dpiX,
                       dpiY);
}

最后,圈子里添加到应用程序时,我需要一个简单的构造函数来初始化结构,IDCompositionVisual2 参考的所有权:

Circle(ComPtr<IDCompositionVisual2> && visual,
       float const logicalX,
       float const logicalY,
       float const dpiX,
       float const dpiY) :
  Visual(move(visual))
{
  SetLogicalOffset(logicalX,
                   logicalY,
                   dpiX,
                   dpiY);
}

我可以现在跟踪的应用程序的所有圈子与标准的列表的容器:

list<Circle> m_circles;

我在这里的时候还会成员来跟踪任何所选的圆:

Circle * m_selected = nullptr;
float m_mouseX = 0.0f;
float m_mouseY = 0.0f;

鼠标偏移量也将有助于产生自然的运动。 之前我看看实际的鼠标交互,最终创造圈子,请允许我来移动它们,我去拿出方式管家。 CreateDeviceResources 方法需要重新创建任何可视的对象,应发生设备丢失,基于任何以前创建的圈子。 如果圆圈消失,它不会做。 所以右后创建或重新创建根视觉效果和共享的表面上,我就会遍历这个列表中,创建新的可视化对象和重新定位,以满足现有的状态。 图 4 说明了这一切是如何一起使用我已经已经建立。

图 4 创建设备堆栈和可视化树

void CreateDeviceResources()
{
  ASSERT(!IsDeviceCreated());
  CreateDevice3D();
  ComPtr<ID2D1Device> const device2D = CreateDevice2D();
  HR(DCompositionCreateDevice2(
      device2D.Get(),
      __uuidof(m_device),
      reinterpret_cast<void **>(m_device.ReleaseAndGetAddressOf())));
  HR(m_device->CreateTargetForHwnd(m_window,
                                   true,
                                   m_target.ReleaseAndGetAddressOf()));
  m_rootVisual = CreateVisual();
  HR(m_target->SetRoot(m_rootVisual.Get()));
  CreateDeviceScaleResources();
  for (Circle & circle : m_circles)
  {
    circle.Visual = CreateVisual();
    HR(circle.Visual->SetContent(m_surface.Get()));
    HR(m_rootVisual->AddVisual(circle.Visual.Get(), false, nullptr));
    circle.UpdateVisualOffset(m_dpiX, m_dpiY);
  }
  HR(m_device->Commit());
}

客房部的其他一点就是要与 DPI 缩放。 作为由 Direct2D 呈现包含圆的像素组成表面必须重新创建到的规模,和自己的视觉效果也必须被重新定位,以便及其偏移量成正比,到另一个,到所属的窗口。 WM_DPICHANGED 消息处理程序第一次重新创建组成表面 — — 的帮助下的 CreateDeviceScaleResources 方法 — —,然后更新的内容和圈子里的每个位置:

if (!IsDeviceCreated()) return;
CreateDeviceScaleResources();
for (Circle & circle : m_circles)
{
  HR(circle.Visual->SetContent(m_surface.Get()));
  circle.UpdateVisualOffset(m_dpiX, m_dpiY);
}
HR(m_device->Commit());

现在,我会处理指针的相互作用。 我会让用户创建新的圈子,如果按下控制键时单击鼠标左键。 WM_LBUTTONDOWN 消息处理程序看起来像这样:

if (wparam & MK_CONTROL)
{
  // Create new circle
}
else
{
  // Look for existing circle
}
HR(m_device->Commit());

假设需要创建的一个新的圆圈,先通过创建一个新的视觉和添加作为一个孩子的根视觉效果之前设置的共享的内容:

ComPtr<IDCompositionVisual2> visual = CreateVisual();
HR(visual->SetContent(m_surface.Get()));
HR(m_rootVisual->AddVisual(visual.Get(), false, nullptr));

新视觉被添加在任何现有的视觉效果。 这是 AddVisual 方法的第二个参数在工作。 如果我已将此设置为 true 然后新视觉会已经被放置在任何现有的兄弟姐妹。 接下来,我需要添加的列表,以便以后可以支持的圈层结构的命中测试,设备损失和 DPI 缩放:

m_circles.emplace_front(move(visual),
       PhysicalToLogical(LOWORD(lparam), m_dpiX) - 50.0f,
       PhysicalToLogical(HIWORD(lparam), m_dpiY) - 50.0f,
       m_dpiX,
       m_dpiY);

我很小心,所以我可以自然地支持命中测试的可视化树意味着相同的顺序在列表的前面放置新创建的圈子。 我起初也定位视觉,所以它在鼠标位置上居中。 最后,假设用户不会立即释放鼠标,我也捕获鼠标和跟踪哪一个圆圈将有可能被移动:

SetCapture(m_window);
m_selected = &m_circles.front();
m_mouseX = 50.0f;
m_mouseY = 50.0f;

鼠标偏移量可以让我顺利地将任何圈子无论在哪里拖上鼠标指针开始落下的圆圈。 寻找一个现有的圈子是有点更多的参与。 在这里,再一次,我需要手动应用 DPI 的认识。 幸运的是,Direct2D 使得这一阵微风。 首先,我需要遍历的自然的 Z-顺序的圈子。 幸运的是,我已经把新的圈子,在列表的前面所以这是一个简单的从开始到结束迭代问题:

for (auto circle = begin(m_circles); circle != end(m_circles); ++circle)
{
}

我没有使用基于范围为语句因为它会更方便,其实在这种情况下有迭代器派上用场。 圈子在哪里呢? 好吧,每个圆圈跟踪的逻辑位置窗口的左上角。 鼠标消息 LPARAM 还包含指针的物理位置在窗口左上角。 但它不是不够的将它们转换成一个共同的坐标系统,因为我需要进行命中测试的形状并不是一个简单的矩形。 由几何对象定义形状并 Direct2D 提供的 FillContainsPoint 方法来执行命中测试。 诀窍是几何对象提供唯一的形状圆和没有它的位置。 进行命中测试有效地工作,需要第一次翻译鼠标的位置,这样它是相对于的几何对象。 这就是很容易:

D2D1_POINT_2F const point =
  Point2F(LOWORD(lparam) - LogicalToPhysical(circle->LogicalX, m_dpiX),
          HIWORD(lparam) - LogicalToPhysical(circle->LogicalY, m_dpiY));

但我不是准备好要调用 FillContainsPoint 方法。 另一个问题是几何对象不知道任何关于渲染的目标。 当我用几何对象来绘制圆时,它是呈现目标,按比例的几何形状,以匹配目标的 DPI 值。 所以我需要的方式扩大执行命中测试,所以它将反映出大小的圆之前, 的几何对应于用户实际上看到在屏幕上。 再次,Direct2D 前来搭救。 FillContainsPoint 接受可选的 3 × 2 矩阵来变换的几何测试给定的点包含在形状内前。 我可以简单地定义给定窗口的 DPI 值尺度变换:

D2D1_MATRIX_3X2_F const transform = Matrix3x2F::Scale(m_dpiX / 96.0f,
                                                      m_dpiY / 96.0f);

FillContainsPoint 方法将然后告诉我该点是否包含在圈子内:

BOOL contains = false;
HR(m_geometry->FillContainsPoint(point,
                                 transform,
                                 &contains));
if (contains)
{
  // Reorder and select circle
  break;
}

如果该点包含在圈内,我需要重新排列组成的视觉效果,这样所选的圆视觉是顶部的 Z-顺序。 通过删除的子可视并将其添加到任何现有的视觉效果的前面,我可以这样做:

HR(m_rootVisual->RemoveVisual(circle->Visual.Get()));
HR(m_rootVisual->AddVisual(circle->Visual.Get(), false, nullptr));

我也需要保持我的列表最新的通过将圆圈移动到前面的列表:

m_circles.splice(begin(m_circles), m_circles, circle);

然后,我假定用户希望拖动周围的圆圈:

SetCapture(m_window);
m_selected = &*circle;
m_mouseX = PhysicalToLogical(point.x, m_dpiX);
m_mouseY = PhysicalToLogical(point.y, m_dpiY);

在这里,我很小心,计算相对于所选圆的鼠标位置的偏移量。 在这种圈子并不直观地"对齐"到鼠标指针的中心是拖动的方式,提供无缝运动。 回应 WM_MOUSEMOVE 消息允许任何所选的圆继续这项运动,只要选择了一圈:

if (!m_selected) return;
m_selected->SetLogicalOffset(
  PhysicalToLogical(GET_X_LPARAM(lparam), m_dpiX) - m_mouseX,
  PhysicalToLogical(GET_Y_LPARAM(lparam), m_dpiY) - m_mouseY,
  m_dpiX,
  m_dpiY);
HR(m_device->Commit());

圈层结构的 SetLogicalOffset 方法更新由圈子,维护的逻辑位置一样的物理位置的视觉组成。 我也是小心使用的 GET_X_LPARAM 和 GET_Y_LPARAM 的宏来破解 LPARAM,而不是通常的整型和高字的宏。 而由 WM_MOUSEMOVE 消息报告的位置是相对于窗口的左上角,这将包括消极的坐标,如果捕获鼠标和圈子拖上方或左侧的窗口。像往常一样,对可视化树的更改必须致力为他们去实现。 任何运动在 WM_LBUTTONUP 消息处理程序结束时释放鼠标和重置 m_selected 指针:

ReleaseCapture();
m_selected = nullptr;

最后,我会用最好的部分。 最令人信服的证据这是指示性的保留模式图形是当你考虑在 WM_PAINT 消息处理程序图 5。

图 5 保留模式 WM_PAINT 消息处理程序

void PaintHandler()
{
  try
  {
    if (IsDeviceCreated())
    {
      HR(m_device3D->GetDeviceRemovedReason());
    }
    else
    {
      CreateDeviceResources();
    }
    VERIFY(ValidateRect(m_window, nullptr));
  }
  catch (ComException const & e)
  {
    ReleaseDeviceResources();
  }
}

CreateDeviceResources 方法创建设备堆栈前面。 只要没有什么不妥,没有进一步的工作是通过 WM_PAINT 消息处理程序中,除了要验证窗口。 如果检测到设备损失,各种的 catch 块将释放装置和失效作为必要的窗口。 下一个 WM_PAINT 消息到来再一次将重新创建的设备资源。 在我下一个专栏中,我将展示你如何你可以产生并不直接驱动的用户输入的视觉效果。 组合引擎执行更多的渲染而不涉及应用程序,就可能出现设备损失没有即使知道它的应用。 这就是为什么 GetDeviceRemoved­调用方法的原因。 如果组合引擎检测到设备损失纯粹以便它可以通过调用 GetDeviceRemovedReason 方法在 Direct3D 设备检查设备损失,它将发送应用程序窗口 WM_PAINT 消息。 带一个随附的示例项目的测试驱动器 DirectComposition !


Kenny Kerr 是一个基于在加拿大,以及作者的 Pluralsight 和微软最有价值球员的计算机程序员。他的博客 kennykerr.ca ,你可以跟着他在 Twitter 上 twitter.com/kennykerr

感谢以下微软技术专家对本文的审阅:Leonardo 布兰科 (Leonardo.Blanco@microsoft.com) 和James克拉克 (James。Clarke@microsoft.com