walklang / uilib

A simply and powerful ui script framework library. via http://www.uilib.cn
Apache License 2.0
45 stars 7 forks source link

使用 Windows 组合引擎实现高性能窗口分层 #14

Open suhao opened 2 years ago

suhao commented 2 years ago

借助 C++ 进行 Windows 开发:使用 Windows 组合引擎实现高性能窗口分层

自从我第一次看到 Windows XP 中的分层窗口,我就对它情有独钟。我好像一直对消除传统桌面窗口的矩形或近矩形边框非常感兴趣。后来出现了 Windows Vista。这个备受非议的 Windows 版本从一开始就预示着更具吸引力和灵活性的功能的降临。我们才开始认同 Windows Vista 带来的概念启发。虽然现在 Windows 8 出现了,但它也标志着分层窗口在慢慢地走下坡路。

Windows Vista 引入了一项名为“桌面窗口管理器”的服务。这个名字一直以来都具有误导性。请把它看作是 Windows 组合引擎或合成程序。此组合引擎完全改变了应用程序窗口在桌面上的呈现方式。每个窗口不是直接呈现给显示器或显示适配卡,而是呈现给屏外表面或缓冲区。系统为每个顶层窗口都分配了一个这样的表面,所有 GDI、Direct3D(当然还有 Direct2D)图形都呈现给此类表面。此类屏外表面称为重定向表面,因为 GDI 绘图命令以及 Direct3D 交换链呈现请求都会重定向或(在 GPU 中)复制到重定向表面。

在某些时候,不受任何指定窗口的制约,组合引擎会鉴于最新一批更改决定是否该组合桌面。这就涉及将所有重定向表面组合在一起、添加非工作区(通常称为窗口镶边)、添加一些阴影和其他效果以及将最终结果呈现给显示适配卡。

此组合过程具有许多显著优势,我将在接下来的几个月中一边深入探索 Windows 组合,一边详细说明这些优势;不过它也存在一个潜在的严重限制,即这些重定向表面不透明。大多数应用程序都可以接受此限制,而且从性能角度来看此限制也是非常有意义的,因为 alpha 值混合处理的费用高昂。但这就导致分层窗口遭到排斥。

如果我要开发分层窗口,就需要掀起一场性能冲击。我在专栏“使用 Direct2D 绘制分层窗口”(msdn.microsoft.com/magazine/ee819134) 中介绍了具体的体系结构限制。总而言之,分层窗口由 CPU 处理,主要用于支持 alpha 值混合处理像素的命中测试。也就是说,CPU 需要复制分层窗口的表面区域的组成像素。不管我是在 CPU 上呈现(往往比 GPU 呈现慢很多)还是在 GPU 上呈现,我都必须支付总线带宽税,因为我呈现的所有内容都必须从视频存储器复制到系统内存。在之前提到的专栏中,我还介绍了如何充分利用 Direct2D 尽可能发挥系统性能,因为我只有通过 Direct2D 才能在 CPU 和 GUP 呈现之间做出选择。存在的隐患就是,即使分层窗口一定要位于系统内存中,组合引擎也可以立即将其复制到视频存储器中,这样分层窗口的实际组合就仍为硬件加速。

虽然我无法带来传统分层窗口即将重返主导地位的希望,但我确实带来了一些好消息。传统的分层窗口具有两项特定的相关功能。第一项功能是每像素 alpha 值混合处理。我呈现给分层窗口的任何内容都会与桌面以及任意给定时刻窗口后面的所有内容进行 alpha 值混合处理。第二项功能是 User32 能够根据像素 alpha 值对分层窗口执行命中测试,允许鼠标消息在特定点的像素为透明时贯透过去。从 Windows 8 和 8.1 开始,虽然 User32 一直没有发生显著变化,但细微变化也有,即完全支持 GPU 上的每像素 alpha 值混合处理,且将窗口表面传输到系统内存的费用也取消了。也就是说,如果我不需要执行每像素命中测试,现在就能够生成分层窗口效果,同时不会对性能造成影响。整个窗口将统一执行命中测试。撇开命中测试不谈,只是这样就已经令我兴奋不已,因为这是系统显然可以执行的任务,但应用程序就从来没能利用这种功能。如果您对此感兴趣,请继续阅读本文,我将向您介绍这是如何实现的。

实现此功能的关键点在于使用 Windows 组合引擎。组合引擎一开始作为桌面窗口管理器出现在 Windows Vista 中,它包含受限制的 API 并具有流行的半透明 Aero 毛玻璃效果。接下来 Windows 8 出现了,它引入了 DirectComposition API。此 API 适用于相同的组合引擎,只是受限制程度更低。随着 Windows 8 版本的发布,Microsoft 最终允许第三方开发人员利用这一面世已久的组合引擎。当然,您还需要使用由 Direct3D 强力驱动的图形 API(如 Direct2D)。但您首先需要处理不透明的重定向表面。

正如我之前提到的,系统为每个顶层窗口都分配了一个重定向表面。从 Windows 8 开始,您现在可以创建顶层窗口,并请求创建没有重定向表面的顶层窗口。严格来说,这与分层窗口并不相关。因此,请勿使用 WS_EX_LAYERED 扩展的窗口样式(实际上,分层窗口的相关支持在 Windows 8 中有了细微改进,不过我将在即将发表的专栏文章中深入介绍这些改进)。您需要改用 WS_EX_NOREDIRECTIONBITMAP 扩展的窗口样式,此样式可以指示组合引擎不为窗口分配重定向表面。我将从简单的传统桌面窗口入手。图 1 中的示例介绍了如何填充 WNDCLASS 结构、注册窗口类、创建窗口以及抽取窗口消息。其中并没有什么新内容,但这些基本原理仍然至关重要。窗口变量处于未使用状态,但您马上就需要使用这个变量。您可以将此变量复制到 Visual Studio 内的 Visual C++ 项目中,也可以只是从以下命令提示符编译此变量:

cl /W4 /nologo Sample.cpp

#ifndef UNICODE
#define UNICODE 
#endif

#include <windows.h> 
#pragma comment(lib, "user32.lib") 
int __stdcall wWinMain(HINSTANCE module, HINSTANCE, PWSTR, int) {
 WNDCLASS wc = {};
 wc.hCursor       = LoadCursor(nullptr, IDC_ARROW);
 wc.hInstance     = module;
 wc.lpszClassName = L"window";
 wc.style         = CS_HREDRAW | CS_VREDRAW;
 wc.lpfnWndProc = [] (HWND window, UINT message, WPARAM wparam, LPARAM lparam) -> LRESULT {
 if (WM_DESTROY == message) { 
     PostQuitMessage(0);
     return 0;
 }
return DefWindowProc(window, message, wparam, lparam);
};
RegisterClass(&wc);

HWND const window = CreateWindow(wc.lpszClassName, L"Sample", WS_OVERLAPPEDWINDOW | WS_VISIBLE, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, module, nullptr);
MSG message; 
while (BOOL result = GetMessage(&message, 0, 0, 0)) { 
    if (-1 != result) DispatchMessage(&message);
 }
}

图 2 展示了窗口在我的桌面上的显示效果。请注意,此示例没有什么特别之处。虽然该示例未提供任何绘制和呈现命令,但窗口的工作区不透明,并且组合引擎添加了非工作区、边框和标题栏。要应用 WS_EX_NOREDIRECTIONBITMAP 扩展的窗口样式删除不透明的重定向表面(表示此工作区),只需使用接受扩展的窗口样式的主要参数将 CreateWindow 函数切换为 CreateWindowEx 函数即可:

HWND const window = CreateWindowEx(WS_EX_NOREDIRECTIONBITMAP, wc.lpszClassName, L"Sample", WS_OVERLAPPEDWINDOW | WS_VISIBLE, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, module, nullptr);

image 图 2:桌面上的传统窗口

变化只包括添加了主要参数、WS_EX_NOREDIRECTIONBITMAP 扩展的窗口样式,以及使用了 CreateWindowEx 函数(而不是更简单的 CreateWindow 函数)。不过,桌面上呈现的结果变化更为彻底。图 3 展示了窗口在我的桌面上的显示效果。请注意,窗口的工作区现在是完全透明。移动窗口可以说明这一点。我甚至可以在背景中播放视频,任何部分都不会被遮挡。另一方面,整个窗口统一执行命中测试,当您在工作区内单击时,不会失去窗口焦点。这是因为负责命中测试和鼠标输入的子系统没有意识到工作区是透明的。

image 图 3:不含重定向表面的窗口

当然,接下来您会问,如果没有可向组合引擎提供的重定向表面,如何能够将任意内容呈现给窗口?答案就是利用 DirectComposition API 及其与 DirectX 图形基础结构 (DXGI) 的深度集成。此技术同样可强力驱动 Windows 8.1 XAML 实施,从而在 XAML 应用程序内提供性能极高的内容组合。Internet Explorer Trident 呈现引擎也将 DirectComposition 广泛用于触控平移、缩放、CSS3 动画、切换和转换。

我就是要用它来组合通过每像素预乘 alpha 值支持透明度的交换链,并将其与桌面的其余部分混合。传统的 DirectX 应用程序通常使用 DXGI 工厂的 CreateSwapChainForHwnd 方法创建交换链。此交换链受到在呈现过程中有效交换的一对或一组缓冲区的支持,允许应用程序在前一帧得到复制的同时呈现下一帧。应用程序呈现到的交换链表面是不透明的屏外缓冲区。当应用程序呈现交换链时,DirectX 会将内容从交换链的后台缓冲区复制到窗口的重定向表面。正如我之前提到的,组合引擎最终会将所有的重定向表面都组合到一起,从而形成整个桌面。

在这种情况下,由于窗口不包含任何重定向表面,因此不能使用 DXGI 工厂的 CreateSwapChainForHwnd 方法。不过,我仍然需要使用交换链来支持 Direct3D 和 Direct2D 呈现。这正是 DXGI 工厂的 CreateSwapChainForComposition 方法的用途所在。我可以使用此方法创建一个无窗口的交换链及其缓冲区,但呈现此交换链不会将位数复制到不存在的重定向表面,而是直接向组合引擎提供。然后,组合引擎可以获取此表面,直接用它来取代窗口的重定向表面。由于此表面不是不透明的,而是像素格式完全支持每像素预乘 alpha 值,因此结果就是在桌面上进行完全适合像素的 alpha 值混合处理。速度也极快,这是因为不必在 GPU 内进行复制,进而也就不必通过总线复制到系统内存。

这些都是理论。现在是进行实践的时候了。由于 DirectX 是一种 COM 对象,因此我将使用 Windows 运行时 C++ 模板库中的 ComPtr 类模板来管理接口指针。我还需要添加并关联至 DXGI、Direct3D、Direct2D 和 DirectComposition API。以下代码展示了这是如何实现的:

#include <wrl.h> 
using namespace Microsoft::WRL; 
#include <dxgi1_3.h> 
#include <d3d11_2.h> 
#include <d2d1_2.h> 
#include <d2d1_2helper.h> 
#include <dcomp.h> 
#pragma comment(lib, "dxgi") 
#pragma comment(lib, "d3d11") 
#pragma comment(lib, "d2d1") 
#pragma comment(lib, "dcomp")

我通常会在预编译头中添加这些代码。在这种情况下,我会省略 using 指令,并且只在我的应用程序的源文件中添加此指令。

我不喜欢错误处理通篇存在并且偏离主题本身细节的示例代码,因此我将使用一个异常类和 HR 函数妥善处理错误检查。图 4 中展示了一个简单的实施示例,当然您也可以自行决定错误处理策略。

将 HRESULT 错误转变为异常

struct ComException { 
HRESULT result; 
ComException(HRESULT const value) :result(value) {} 
}; 
void HR(HRESULT const result) { 
if (S_OK != result) { 
throw ComException(result); 
} 
}

我现在可以开始组装呈现堆栈,自然就从 Direct3D 设备入手了。我将会快速介绍此主题,因为我已经在 2013 年 3 月的专栏“Direct2D 1.1 简介”(msdn.microsoft.com/magazine/dn198239) 中详细介绍了 DirectX 基础结构。以下是 Direct3D 11 接口指针:ComPtr direct3dDevice;

这是设备的接口指针,您可以使用 D3D11Create­Device 函数创建设备:

HR(D3D11CreateDevice(nullptr,    // 适配卡 D3D_DRIVER_TYPE_HARDWARE, nullptr,    // 模块 D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, // 最高可用功能级别 D3D11_SDK_VERSION, &direct3dDevice, nullptr,    // 实际功能级别 nullptr));  // 设备上下文

代码中没有什么太出人意料的内容。我将创建受 GPU 支持的 Direct3D 设备。D3D11_CREATE_DEVICE_BGRA_SUPPORT 标志实现了与 Direct2D 的互操作性。DirectX 系列通过 DXGI 紧密结合在一起,这样就针对各种 DirectX API 提供了常见 GPU 资源管理工具。因此,我必须查询 Direct3D 设备的 DXGI 接口:

ComPtr<IDXGIDevice> dxgiDevice; HR(direct3dDevice.As(&dxgiDevice));

ComPtr As 方法只是 QueryInterface 方法的包装器。在创建 Direct3D 设备后,我便可以创建用于组合的交换链。为此,我首先需要获取 DXGI 工厂:

ComPtr<IDXGIFactory2> dxFactory; HR(CreateDXGIFactory2( DXGI_CREATE_FACTORY_DEBUG, __uuidof(dxFactory), reinterpret_cast<void **>(dxFactory.GetAddressOf())));

此时,我将选择获取额外的调试信息,这是开发过程中非常宝贵的辅助信息。创建交换链时的最棘手部分就是确定如何向 DXGI 工厂说明预期的交换链。此调试信息非常有助于对必要的 DXGI_SWAP_CHAIN_DESC1 结构进行微调:DXGI_SWAP_CHAIN_DESC1 description = {};

这会将结构全部初始化为零。然后,我可以开始填充任何相关属性:

description.Format           = DXGI_FORMAT_B8G8R8A8_UNORM;
description.BufferUsage      = DXGI_USAGE_RENDER_TARGET_OUTPUT; 
description.SwapEffect       = DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL; 
description.BufferCount      = 2; 
description.SampleDesc.Count = 1; 
description.AlphaMode        = DXGI_ALPHA_MODE_PREMULTIPLIED;

以下特定格式并不是您的唯一选项,但可为各种设备和 API 带来最佳的性能和兼容性:32 位像素格式,每个颜色通道为 8 位,再加上一个预乘的 8 位 alpha 分量。

必须将交换链的缓冲区使用设置为允许将呈现器目标输出定向到它。您必须进行此设置,这样 Direct2D 设备上下文才能创建位图来使用绘图命令定位 DXGI 表面。Direct2D 位图本身只是受到交换链支持的抽象概念。

组合交换链仅支持依序翻转的交换效果。这就是交换链取代重定向表面与组合引擎相关联的方式。在翻转模式下,所有缓冲区都直接与组合引擎共享。然后,组合引擎可以直接从交换链后台缓冲区组合桌面,无需进行其他任何复制操作。通常情况下,这是最有效的模式。组合也需要使用此模式,因此这就是我使用的模式。翻转模式也需要至少两个缓冲区,但不支持多重采样,因此将 BufferCount 设置为 2,并将 SampleDesc.Count 设置为 1。此计数是每像素的多重采样数量。将它设置为 1 可以有效禁用多重采样。

最后需要说明的是,alpha 模式至关重要。不透明的交换链通常会忽略 alpha 模式,但在本示例中,我确实想将透明行为包括在内。预乘的 alpha 值通常会带来最佳性能,而且它也是翻转模式支持的唯一选项。

在我可以创建交换链之前必须完成的最后一项操作是确定缓冲区的预期大小。调用 CreateSwapChainForHwnd 方法时,我通常会忽略大小,但 DXGI 工厂会向窗口查询工作区的大小。在这种情况下,DXGI 不知道我打算对交换链做什么,因此我需要告诉它所需的具体大小。在窗口创建后,您可以轻松查询窗口的工作区并相应地更新交换链说明:

RECT rect = {}; 
GetClientRect(window, &rect); 
description.Width  = rect.right - rect.left; 
description.Height = rect.bottom - rect.top;

我现在可以创建包含此说明的组合交换链,并创建 Direct3D 设备指针。Direct3D 或 DXGI 接口指针均可使用:

ComPtr<IDXGISwapChain1> swapChain; 
HR(dxFactory->CreateSwapChainForComposition(dxgiDevice.Get(), &description, nullptr, // 不限制 
swapChain.GetAddressOf()));

现在交换链已创建,我可以使用任何 Direct3D 或 Direct2D 图形呈现代码来绘制应用程序(使用创建预期透明度所需的 alpha 值)。这里没有什么新内容,因此,我将再次引用我的 2013 年 3 月专栏,介绍使用 Direct2D 呈现给交换链的细节。图 5 介绍了一个您可以遵循的简单示例。请务必注意支持按监视器 DPI 感知,我在 2014 年 2 月的专栏“为 Windows 8.1 编写高 DPI 应用程序”(msdn.microsoft.com/magazine/dn574798) 中对此进行了介绍。

// 使用调试信息创建单线程 Direct2D 工厂 ComPtr<ID2D1Factory2> d2Factory; D2D1_FACTORY_OPTIONS const options = { D2D1_DEBUG_LEVEL_INFORMATION }; HR(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, options, d2Factory.GetAddressOf())); // 创建与 Direct3D 设备恢复关联的 Direct2D 设备 ComPtr<ID2D1Device1> d2Device; HR(d2Factory->CreateDevice(dxgiDevice.Get(), d2Device.GetAddressOf())); // 创建作为实际呈现器目标 // 并揭示绘图命令的 Direct2D 设备上下文 ComPtr<ID2D1DeviceContext> dc; HR(d2Device->CreateDeviceContext(D2D1_DEVICE_CONTEXT_OPTIONS_NONE, dc.GetAddressOf())); // 检索交换链的后台缓冲区 ComPtr<IDXGISurface2> surface; HR(swapChain->GetBuffer( 0, // 索引 __uuidof(surface), reinterpret_cast<void **>(surface.GetAddressOf()))); // 创建指向交换链表面的 Direct2D 位图 D2D1_BITMAP_PROPERTIES1 properties = {}; properties.pixelFormat.alphaMode = D2D1_ALPHA_MODE_PREMULTIPLIED; properties.pixelFormat.format    = DXGI_FORMAT_B8G8R8A8_UNORM; properties.bitmapOptions         = D2D1_BITMAP_OPTIONS_TARGET | D2D1_BITMAP_OPTIONS_CANNOT_DRAW; ComPtr<ID2D1Bitmap1> bitmap; HR(dc->CreateBitmapFromDxgiSurface(surface.Get(), properties, bitmap.GetAddressOf())); // 将设备上下文指向位图以进行呈现 dc->SetTarget(bitmap.Get()); // 绘制内容 dc->BeginDraw(); dc->Clear(); ComPtr<ID2D1SolidColorBrush> brush; D2D1_COLOR_F const brushColor = D2D1::ColorF(0.18f,  // 红色 0.55f,  // 绿色 0.34f,  // 蓝色 0.75f); // alpha HR(dc->CreateSolidColorBrush(brushColor, brush.GetAddressOf())); D2D1_POINT_2F const ellipseCenter = D2D1::Point2F(150.0f,  // x 150.0f); // y D2D1_ELLIPSE const ellipse = D2D1::Ellipse(ellipseCenter, 100.0f,  // x 半径 100.0f); // y 半径 dc->FillEllipse(ellipse, brush.Get()); HR(dc->EndDraw()); // 为组合引擎提供交换链 HR(swapChain->Present(1,   // 同步 0)); // 标志

我现在终于可以开始使用 DirectComposition API 来呈现所有内容。虽然 Windows 组合引擎处理呈现和组合整个桌面,但您也可以通过 DirectComposition API 使用同一技术为应用程序组合视觉对象。应用程序将不同的元素(称为视觉对象)组合在一起,形成应用程序窗口本身的外观。这些视觉对象可以通过各种方式进行动画处理和转换,从而形成丰富流畅的 UI。组合过程本身也随整个桌面的组合一同执行,因此,更多的应用程序展示会从应用程序线程中独立出来,以改善响应能力。

DirectComposition 主要用于将不同的位图组合在一起。与 Direct2D 一样,这里的位图概念在更大程度上是一个抽象概念,可允许不同的呈现堆栈互相合作,共同带来顺畅且具有吸引力的应用程序用户体验。

类似于 Direct3D 和 Direct2D,DirectComposition 是由 GPU 支持且强力驱动的 DirectX API。DirectComposition 设备是通过重新指向 Direct3D 设备而创建,与 Direct2D 设备重新指向基础 Direct3D 设备的方式基本相同。我使用之前用来创建交换链的同一 Direct3D 设备和 Direct2D 呈现器目标来创建 DirectComposition 设备:

ComPtr<IDCompositionDevice> dcompDevice; HR(DCompositionCreateDevice( dxgiDevice.Get(), __uuidof(dcompDevice), reinterpret_cast<void **>(dcompDevice.GetAddressOf())));

DCompositionCreateDevice 函数需要 Direct3D 设备的 DXGI 接口,并向新建的 DirectComposition 设备返回 IDCompositionDevice 接口指针。DirectComposition 设备可用作其他 DirectComposition 对象的工厂,并提供至关重要的 Commit 方法,此方法用于将一批呈现命令提交给组合引擎以进行最后的组合和呈现。

接下来,我需要创建与视觉对象相关联的组合目标,这些视觉对象将与目标(即应用程序窗口)一起组合:

ComPtr<IDCompositionTarget> target; HR(dcompDevice->CreateTargetForHwnd(window, true, // 顶级 target.GetAddressOf()));

CreateTargetForHwnd 方法的第一个参数是 CreateWindowEx 函数返回的窗口句柄。第二个参数表示视觉对象与其他任何窗口元素的组合方式。结果是 IDCompositionTarget 接口指针,它只有一个方法,称为 SetRoot。通过此方法,我可以将可能形成的可视化树中的根 Visual 设置为一起组合。我不需要整个可视化树,但需要至少一个视觉对象,为此我可以再次利用 DirectComposition 设备:

ComPtr<IDCompositionVisual> visual; HR(dcompDevice->CreateVisual(visual.GetAddressOf()));

此视觉对象包含对位图的引用,并提供一组属性,这些属性会影响该视觉对象相对于树中的其他视觉对象和目标本身的呈现和组合方式。我已获取自己希望由此视觉对象传递给组合引擎的内容。此为我之前创建的交换链:

HR(visual->SetContent(swapChain.Get()));

视觉对象已准备就绪,我只需将其设置为组合目标的根即可:

HR(target->SetRoot(visual.Get()));

最后,在可视化树成形后,我便只需通过调用 DirectComposition 设备上的 Commit 方法即可告知组合引擎我已完成操作:

HR(dcompDevice->Commit());

对于可视化树不变化的此特定应用程序,我只需在应用程序开始时调用 Commit 一次,之后再也不会调用此方法了。我最初假设需要在交换链呈现之后调用 Commit 方法,但情况并非如此,因为交换链呈现与可视化树变化并不同步。

图 6 展示了在 Direct2D 已呈现给交换链且 Direct­Composition 已将部分透明的交换链提供给组合引擎之后的应用程序窗口外观。 image 图 6:DirectComposition 表面上的 Direct2D 绘制

我终于找到了老问题的解决方案,我感到非常兴奋:可以生成与桌面的其余部分进行 alpha 值混合处理的高性能窗口。让我激动不已的是 DirectComposition API 的功能及其如何影响在本机代码中设计和开发应用程序用户体验的未来。

想要绘制您自己的窗口镶边吗?没问题,只需在创建窗口时将 WS_OVERLAPPEDWINDOW 窗口样式替换为 WS_POPUP 窗口样式即可。祝您工作愉快!