通用GUI编程技术------图形渲染实战(三十三)------Direct2D与Win32/GDI互操作:渐进迁移实战
上一篇我们花了大量篇幅啃下了 DirectWrite 的三层 API 架构,从 IDWriteTextFormat 到 IDWriteTextLayout 到自定义 TextRenderer,代码高亮渲染器也跑起来了。至此,Direct2D 生态中渲染相关的核心能力我们基本都覆盖了。但现实世界不会给你一张白纸让你从头开始写------更多的情况是,你手头已经有一个积累了多年的 GDI 项目,里面有几千行 WM_PAINT 处理代码,有各种自定义控件,有第三方的 GDI 组件。你不可能一夜之间全部重写成 D2D,但你又想享受 D2D 的硬件加速、抗锯齿、效果系统这些好处。今天这篇就来解决这个问题:如何在 GDI 和 Direct2D 之间做互操作,实现渐进式迁移。
环境说明
本篇开发环境如下:
- 操作系统: Windows 11 Pro 10.0.26200
- 编译器: MSVC (Visual Studio 2022, v17.x)
- 目标平台: Win32 API 原生开发
- 图形库: Direct2D(d2d1.h)+ GDI(wingdi.h),互操作接口
- 关键头文件: d2d1.h, d2d1_1.h, windows.h, wingdi.h
互操作涉及两个方向的桥接:在 GDI 的 HDC 上使用 D2D 绘制,和在 D2D 的渲染目标上使用 GDI 绘制。两个方向有不同的接口和适用场景,我们逐一展开。
互操作的两个方向
在深入代码之前,先搞清楚互操作的两条路。
第一条路是 D2D 绘制到 GDI 的 DC 上。这种方式的接口是 ID2D1DCRenderTarget,通过 ID2D1Factory::CreateDCRenderTarget 创建。它适用于你在现有 GDI 代码中想插入一些 D2D 绘制的场景------比如你的 WM_PAINT 处理函数里大部分是 GDI 代码,但某个复杂的图表你想用 D2D 来画。DCRenderTarget 会把 D2D 的绘制内容先渲染到内部位图上,然后通过 GDI 的 BitBlt 合成到目标 HDC 上。
第二条路是 GDI 绘制到 D2D 的渲染目标上。这种方式的接口是 ID2D1GdiInteropRenderTarget,通过 QueryInterface 从 ID2D1HwndRenderTarget 获取。它适用于你的主渲染框架已经是 D2D,但偶尔需要调用一些只有 GDI 能做的事情------比如使用旧的 GDI 字体引擎、调用第三方 GDI 组件等。它通过 GetDC 方法临时获取一个 HDC,你可以在上面进行 GDI 绘制,完成后通过 ReleaseDC 释放。
理解了这两条路之后,我们就可以根据实际的迁移方向选择合适的接口了。下面我们分步骤来实战。
第一步------DCRenderTarget:在 GDI 环境中使用 D2D
这是渐进迁移最实用的方式。你的程序主体仍然是 GDI,但在某些地方你想用 D2D 的高质量渲染。DCRenderTarget 就是为此而生的。
创建 DCRenderTarget
创建 DCRenderTarget 只需要 D2D Factory,不需要 D3D 设备和 SwapChain 那套复杂的流程。这降低了迁移的初始成本:
cpp
#include <d2d1.h>
#pragma comment(lib, "d2d1.lib")
ID2D1Factory* g_pD2DFactory = nullptr;
ID2D1DCRenderTarget* g_pDCRenderTarget = nullptr;
ID2D1SolidColorBrush* g_pBlackBrush = nullptr;
ID2D1SolidColorBrush* g_pAccentBrush = nullptr;
HRESULT CreateD2DResources()
{
// 创建 D2D Factory
HRESULT hr = D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, &g_pD2DFactory);
if (FAILED(hr)) return hr;
// 创建 DCRenderTarget
// 像素格式必须和 GDI 兼容------BGRA + Premultiplied Alpha
D2D1_RENDER_TARGET_PROPERTIES rtProps = D2D1::RenderTargetProperties(
D2D1_RENDER_TARGET_TYPE_DEFAULT,
D2D1::PixelFormat(
DXGI_FORMAT_B8G8R8A8_UNORM,
D2D1_ALPHA_MODE_PREMULTIPLIED
),
0.0f, // DPI,0 表示使用系统默认 DPI
0.0f,
D2D1_RENDER_TARGET_USAGE_GDI_COMPATIBLE // 关键标志!
);
hr = g_pD2DFactory->CreateDCRenderTarget(&rtProps, &g_pDCRenderTarget);
if (FAILED(hr)) return hr;
// 创建一些画刷
g_pDCRenderTarget->CreateSolidColorBrush(
D2D1::ColorF(D2D1::ColorF::Black), &g_pBlackBrush);
g_pDCRenderTarget->CreateSolidColorBrush(
D2D1::ColorF(0x0078D4), &g_pAccentBrush); // Windows 蓝
return hr;
}
这里有一个非常关键的细节:D2D1_RENDER_TARGET_USAGE_GDI_COMPATIBLE 标志。这个标志告诉 Direct2D,这个渲染目标需要支持 GDI 互操作。如果你不设置这个标志,后续创建的 DCRenderTarget 虽然可以绑定到 GDI 的 HDC 上,但在某些 GDI 操作(比如透明度混合)上可能出现问题。
像素格式 DXGI_FORMAT_B8G8R8A8_UNORM 配合 D2D1_ALPHA_MODE_PREMULTIPLIED 是 GDI 互操作的标准配置。GDI 的 32 位位图使用的是 BGRA 字节序,和 Direct2D 的这个格式完全匹配。
绑定到 HDC 并绘制
DCRenderTarget 的核心方法是 BindDC------它把 D2D 渲染目标和 GDI 的 HDC 关联起来。你需要在每次绘制前调用 BindDC,传入 HDC 和需要绑定的矩形区域:
cpp
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
RECT rcClient;
GetClientRect(hwnd, &rcClient);
// ===== GDI 绘制部分 =====
// 先用 GDI 填充背景
HBRUSH hbrBg = CreateSolidBrush(RGB(245, 245, 245));
FillRect(hdc, &rcClient, hbrBg);
DeleteObject(hbrBg);
// 用 GDI 绘制标题文字
SetBkMode(hdc, TRANSPARENT);
SetTextColor(hdc, RGB(0, 0, 0));
HFONT hFont = CreateFontW(24, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE,
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI");
HFONT hOldFont = (HFONT)SelectObject(hdc, hFont);
TextOutW(hdc, 20, 20, L"GDI + D2D 混合绘制", 13);
SelectObject(hdc, hOldFont);
DeleteObject(hFont);
// ===== D2D 绘制部分 =====
// 在窗口下半部分用 D2D 绘制高质量图形
RECT rcD2D = { 20, 80, rcClient.right - 20, rcClient.bottom - 20 };
// 将 D2D 渲染目标绑定到 HDC 的指定矩形区域
g_pDCRenderTarget->BindDC(hdc, &rcD2D);
g_pDCRenderTarget->BeginDraw();
// D2D 绘制的内容会自动合成到 HDC 上
// 先填充 D2D 区域的背景
g_pDCRenderTarget->Clear(D2D1::ColorF(1.0f, 1.0f, 1.0f, 0.0f));
// 用 D2D 绘制高质量抗锯齿的圆角矩形和圆形
D2D1_RECT_F d2dRect = D2D1::RectF(
10.0f, 10.0f,
(float)(rcD2D.right - rcD2D.left - 10),
(float)(rcD2D.bottom - rcD2D.top - 10)
);
D2D1_ROUNDED_RECT roundedRect = { d2dRect, 12.0f, 12.0f };
// 绘制填充的圆角矩形
g_pDCRenderTarget->FillRoundedRectangle(roundedRect, g_pAccentBrush);
// 绘制抗锯齿的圆形
D2D1_ELLIPSE ellipse = D2D1::Ellipse(
D2D1::Point2F(
(float)(rcD2D.right - rcD2D.left) / 2,
(float)(rcD2D.bottom - rcD2D.top) / 2
),
60.0f, 60.0f
);
ID2D1SolidColorBrush* pWhiteBrush = nullptr;
g_pDCRenderTarget->CreateSolidColorBrush(
D2D1::ColorF(D2D1::ColorF::White, 0.8f), &pWhiteBrush);
g_pDCRenderTarget->FillEllipse(ellipse, pWhiteBrush);
pWhiteBrush->Release();
HRESULT hr = g_pDCRenderTarget->EndDraw();
if (FAILED(hr) || hr == D2DERR_RECREATE_TARGET) {
// 需要重新创建渲染资源
// 实际项目中这里应该调用重建资源的函数
}
EndPaint(hwnd, &ps);
return 0;
}
这段代码展示了一个典型的 GDI + D2D 混合绘制的 WM_PAINT 处理函数。上半部分用 GDI 画标题文字,下半部分用 D2D 画高质量的抗锯齿图形。
⚠️ 关于 BindDC 有一个重要的注意事项:每次调用 BeginDraw 之前都必须重新调用 BindDC。这是因为 HDC 在两次 WM_PAINT 之间可能已经失效了(Windows 的 HDC 是短生命周期的资源)。如果你缓存了 DCRenderTarget 但忘记在每次绘制前重新绑定 HDC,你会看到绘制结果完全错乱或者什么都不显示。这个坑我在迁移项目的时候踩过,找了好久才定位到。
另外注意 EndDraw 返回 D2DERR_RECREATE_TARGET 的情况。当 Direct3D 设备丢失(Device Lost)时,这个错误码会被返回。你需要在收到这个错误时释放所有 D2D 资源并重新创建它们。在 GDI 互操作场景下,设备丢失的概率比纯 D2D 场景要低一些(因为 DCRenderTarget 使用的是软件渲染后备),但仍然需要处理。
第二步------GdiInteropRenderTarget:在 D2D 中使用 GDI
现在我们看反方向的情况:你的主渲染框架已经是 D2D(使用 ID2D1HwndRenderTarget 或 ID2D1DeviceContext),但你需要在某些地方临时调用 GDI 函数。
获取 GdiInteropRenderTarget
GdiInteropRenderTarget 不是通过 Create 函数创建的,而是通过 QueryInterface 从现有的 D2D 渲染目标获取的:
cpp
ID2D1GdiInteropRenderTarget* g_pGdiInterop = nullptr;
void InitGdiInterop(ID2D1HwndRenderTarget* pHwndRenderTarget)
{
// 从 HwndRenderTarget 获取 GDI 互操作接口
HRESULT hr = pHwndRenderTarget->QueryInterface(
__uuidof(ID2D1GdiInteropRenderTarget),
(void**)&g_pGdiInterop
);
if (FAILED(hr)) {
// QueryInterface 失败意味着渲染目标不支持 GDI 互操作
// 确保创建 HwndRenderTarget 时设置了 GDI_COMPATIBLE 标志
}
}
⚠️ 这里有一个前提条件:你的 ID2D1HwndRenderTarget 在创建时必须指定了 D2D1_RENDER_TARGET_USAGE_GDI_COMPATIBLE 用法标志。如果你没有设置这个标志,QueryInterface 会返回 E_NOINTERFACE。具体的创建代码看起来是这样的:
cpp
D2D1_RENDER_TARGET_PROPERTIES rtProps = D2D1::RenderTargetProperties();
rtProps.usage = D2D1_RENDER_TARGET_USAGE_GDI_COMPATIBLE; // 必须设置!
HRESULT hr = g_pD2DFactory->CreateHwndRenderTarget(
rtProps,
D2D1::HwndRenderTargetProperties(hwnd, size),
&g_pHwndRenderTarget
);
在 D2D 绘制流程中插入 GDI 操作
下面是一个完整的例子------在 D2D 绘制流程中临时切换到 GDI 画一些旧控件:
cpp
void RenderWithGdiInterop(ID2D1HwndRenderTarget* pRT,
ID2D1GdiInteropRenderTarget* pGdiInterop)
{
pRT->BeginDraw();
// --- D2D 绘制部分 ---
pRT->Clear(D2D1::ColorF(D2D1::ColorF::White));
// 用 D2D 画一些漂亮的图形
ID2D1SolidColorBrush* pBlueBrush = nullptr;
pRT->CreateSolidColorBrush(D2D1::ColorF(0x0078D4), &pBlueBrush);
D2D1_ROUNDED_RECT card = {
D2D1::RectF(50.0f, 50.0f, 400.0f, 300.0f),
10.0f, 10.0f
};
pRT->FillRoundedRectangle(card, pBlueBrush);
// --- 临时切换到 GDI ---
// 获取 HDC 用于 GDI 绘制
HDC hdc = nullptr;
D2D1_DC_INITIALIZE_MODE initMode = D2D1_DC_INITIALIZE_MODE_COPY;
HRESULT hr = pGdiInterop->GetDC(&initMode, &hdc);
if (SUCCEEDED(hr) && hdc != nullptr)
{
// 现在你可以在这个 HDC 上使用 GDI 函数了
// 例如:绘制一些旧的 GDI 控件效果
// 用 GDI 画一个带边框的按钮
RECT btnRect = { 60, 320, 200, 360 };
DrawEdge(hdc, &btnRect, EDGE_RAISED, BF_RECT | BF_ADJUST);
// 用 GDI 画按钮文字
SetBkMode(hdc, TRANSPARENT);
SetTextColor(hdc, RGB(0, 0, 0));
DrawTextW(hdc, L"GDI Button", -1, &btnRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
// 画一条分隔线
HPEN hPen = CreatePen(PS_SOLID, 1, RGB(200, 200, 200));
HPEN hOldPen = (HPEN)SelectObject(hdc, hPen);
MoveToEx(hdc, 60, 380, nullptr);
LineTo(hdc, 390, 380);
SelectObject(hdc, hOldPen);
DeleteObject(hPen);
// 必须调用 ReleaseDC 释放 HDC
// RECT 参数指定 GDI 修改过的区域,nullptr 表示整个区域
pGdiInterop->ReleaseDC(nullptr);
}
// --- 继续用 D2D 绘制 ---
// GetDC 调用会自动 Flush D2D 命令,所以这里可以继续 D2D 绘制
pBlueBrush->Release();
pRT->EndDraw();
}
这段代码中有几个非常关键的点需要你注意。
首先是 D2D1_DC_INITIALIZE_MODE_COPY。这个参数告诉 Direct2D,在返回 HDC 之前先把当前 D2D 的渲染结果拷贝到 HDC 对应的表面。这样你在 HDC 上看到的就是 D2D 已经绘制的内容,GDI 的绘制会覆盖在上面。如果你用 D2D1_DC_INITIALIZE_MODE_CLEAR,HDC 的内容会被清空,你就看不到之前 D2D 画的东西了。
其次是 GetDC 的副作用。根据 ID2D1GdiInteropRenderTarget::GetDC - Microsoft Learn 的文档,调用 GetDC 会自动 Flush 渲染目标------也就是说,所有尚未执行的 D2D 绘制命令会被立即提交到 GPU 执行。这个操作是有性能成本的,所以不要在每一帧都频繁调用 GetDC/ReleaseDC。
⚠️ Flush 同步的铁律
这里有一个铁律必须遵守:GDI 和 D2D 在同一个表面上的绘制必须通过 Flush 来同步。
D2D 的绘制命令是延迟执行的------你调用 DrawRectangle 的时候,命令只是被放进了一个命令队列,并不会立即执行。而 GDI 的绘制是立即模式的------调用 Rectangle 马上就会画上去。如果你在 D2D 绘制和 GDI 绘制之间不进行同步,就会出现绘制顺序错乱的问题。
GetDC 内部会自动调用 Flush,这保证了 D2D 的绘制结果在 GDI 获取 HDC 之前已经完成。但反过来呢?如果你在 ReleaseDC 之后又进行 D2D 绘制,D2D 是否能正确看到 GDI 的绘制结果?
答案是肯定的------ReleaseDC 会通知 D2D 渲染目标,GDI 已经修改了内容,D2D 后续的绘制会基于最新的表面状态。但如果你在使用 ID2D1DeviceContext(D2D 1.1),在某些情况下你可能需要手动调用 Flush() 来确保命令同步。
cpp
// 如果你在 D2D 和 GDI 之间交替绘制,确保每次切换都进行同步
pRT->DrawRectangle(rect, brush); // D2D 绘制
pRT->Flush(); // 强制提交 D2D 命令
pGdiInterop->GetDC(&initMode, &hdc);
// ... GDI 绘制 ...
pGdiInterop->ReleaseDC(nullptr);
pRT->DrawEllipse(ellipse, brush); // 继续D2D绘制
在性能敏感的场景中,你应该尽量减少 D2D/GDI 之间的切换次数。每次 GetDC/ReleaseDC 对都会触发一次 Flush,而 Flush 是一个同步 GPU 的操作,会打断渲染管线的并行性。理想的做法是把所有 D2D 绘制放在一起,所有 GDI 绘制放在一起,只切换一次。
第三步------渐进迁移策略
有了上面两个互操作接口的武器,我们就可以制定实际的渐进迁移策略了。
自顶向下:从 WM_PAINT 开始
最实用的迁移策略是"自顶向下"------从 WM_PAINT 处理函数开始,逐步把 GDI 代码替换为 D2D 代码。
迁移的第一阶段是创建 DCRenderTarget 并集成到现有的 WM_PAINT 流程中。这时候你不需要修改任何现有的 GDI 代码,只是在 WM_PAINT 的某个位置插入 D2D 绘制。你可以在一个特定的矩形区域内用 D2D 绘制,GDI 的内容不受影响。
cpp
// 阶段一:在现有 WM_PAINT 中插入 D2D 绘制
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
// 现有的 GDI 绘制代码(不动)
DrawBackground_GDI(hdc);
DrawStaticElements_GDI(hdc);
// 新增:用 D2D 绘制图表区域
RECT rcChart = { 20, 100, 500, 400 };
g_pDCRenderTarget->BindDC(hdc, &rcChart);
g_pDCRenderTarget->BeginDraw();
DrawChart_D2D(g_pDCRenderTarget); // 新的 D2D 绘制函数
g_pDCRenderTarget->EndDraw();
// 更多现有的 GDI 代码(不动)
DrawStatusBar_GDI(hdc);
DrawToolbar_GDI(hdc);
EndPaint(hwnd, &ps);
return 0;
}
迁移的第二阶段是把越来越多的 GDI 绘制函数替换为 D2D 版本。每次只替换一个函数,替换完就测试,确保没有引入回归问题。DCRenderTarget 的好处是你可以逐区域替换,不需要一次性改完。
迁移的第三阶段是当你大部分绘制代码都已经迁移到 D2D 后,考虑把渲染目标从 DCRenderTarget 切换为 ID2D1HwndRenderTarget 或 ID2D1DeviceContext。这样可以完全消除 GDI 中间层,获得更好的性能。
分层窗口的迁移
分层窗口(Layered Window)是 GDI 时代实现自定义窗口形状和半透明效果的常用技术。它的核心是 UpdateLayeredWindow 函数,它接受一个 32 位 ARGB 位图作为窗口内容。
如果你有一个现有的分层窗口项目想要迁移到 D2D,有一个非常优雅的方案:使用 ID2D1BitmapRenderTarget(D2D 1.0)或 ID2D1Bitmap1(D2D 1.1)作为中间渲染目标,渲染完成后把结果传给 UpdateLayeredWindow。
cpp
// 分层窗口 + D2D 的实现
ID2D1HwndRenderTarget* g_pRT = nullptr;
ID2D1BitmapRenderTarget* g_pOffscreenRT = nullptr;
HWND g_hwndLayered = nullptr;
void RenderLayeredWindow()
{
// 确保离屏渲染目标存在
if (!g_pOffscreenRT) {
D2D1_SIZE_F size = g_pRT->GetSize();
D2D1_BITMAP_PROPERTIES bmpProps = D2D1::BitmapProperties(
D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM,
D2D1_ALPHA_MODE_PREMULTIPLIED)
);
g_pRT->CreateCompatibleRenderTarget(
&g_pOffscreenRT, size, &bmpProps);
}
// 在离屏渲染目标上绘制
g_pOffscreenRT->BeginDraw();
g_pOffscreenRT->Clear(D2D1::ColorF(0, 0, 0, 0)); // 透明背景
// --- D2D 绘制内容 ---
DrawLayeredContent_D2D(g_pOffscreenRT);
g_pOffscreenRT->EndDraw();
// 获取渲染结果的位图
ID2D1Bitmap* pBitmap = nullptr;
g_pOffscreenRT->GetBitmap(&pBitmap);
// 将 D2D 位图转换为 GDI 位图用于 UpdateLayeredWindow
// 需要获取位图像素数据
D2D1_SIZE_U bitmapSize = pBitmap->GetPixelSize();
UINT32 pixelCount = bitmapSize.width * bitmapSize.height;
UINT32* pixels = new UINT32[pixelCount];
D2D1_RECT_U rect = D2D1::RectU(0, 0, bitmapSize.width, bitmapSize.height);
pBitmap->CopyPixels(rect, bitmapSize.width * 4, (BYTE*)pixels,
pixelCount * 4);
// 创建 GDI 位图
HDC hdcScreen = GetDC(nullptr);
HDC hdcMem = CreateCompatibleDC(hdcScreen);
BITMAPINFO bmi = { 0 };
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmi.bmiHeader.biWidth = bitmapSize.width;
bmi.bmiHeader.biHeight = -(LONG)bitmapSize.height; // 自顶向下
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32;
bmi.bmiHeader.biCompression = BI_RGB;
VOID* pBits = nullptr;
HBITMAP hBitmap = CreateDIBSection(hdcMem, &bmi, DIB_RGB_COLORS,
&pBits, nullptr, 0);
memcpy(pBits, pixels, pixelCount * 4);
HBITMAP hOldBmp = (HBITMAP)SelectObject(hdcMem, hBitmap);
// 使用 UpdateLayeredWindow 更新分层窗口
POINT ptSrc = { 0, 0 };
SIZE sizeWnd = { (LONG)bitmapSize.width, (LONG)bitmapSize.height };
BLENDFUNCTION blend = { AC_SRC_OVER, 0, 255, AC_SRC_ALPHA };
UpdateLayeredWindow(g_hwndLayered, hdcScreen, nullptr, &sizeWnd,
hdcMem, &ptSrc, 0, &blend, ULW_ALPHA);
// 清理
SelectObject(hdcMem, hOldBmp);
DeleteObject(hBitmap);
DeleteDC(hdcMem);
ReleaseDC(nullptr, hdcScreen);
delete[] pixels;
pBitmap->Release();
}
这段代码展示了一个完整的分层窗口 D2D 渲染流程。核心思路是:先用 D2D 在离屏渲染目标上绘制内容,然后把像素数据拷贝出来创建 GDI 位图,最后通过 UpdateLayeredWindow 更新到屏幕上。
中间的像素拷贝步骤确实有性能开销,但在实际测试中,对于 800x600 分辨率的窗口,这个开销大约在 1-2ms,完全可以接受。如果你对性能有极致要求,可以考虑使用 DXGI Surface 直接和 D2D 共享,但这会增加代码复杂度。
⚠️ 这里的一个关键点是 BITMAPINFOHEADER 的 biHeight 必须设为负值,表示自顶向下的位图。D2D 的坐标系是自顶向下的(y 轴向下),而 GDI 的 DIB 默认是自底向上的(biHeight > 0)。如果符号搞反了,你的图像会上下翻转。
另外注意 BLENDFUNCTION 结构的 AlphaFormat 字段必须设为 AC_SRC_ALPHA,这告诉 UpdateLayeredWindow 使用源位图中的 Alpha 通道进行混合。如果你忘记设这个标志,半透明效果会完全不工作。
第四步------互操作的性能考量
互操作虽然方便,但它确实引入了一些性能开销。我们来分析一下主要的开销来源和优化策略。
DCRenderTarget 的性能特征
DCRenderTarget 的工作原理是:它在内部维护一个 D2D 位图,你在 D2D 上画的所有东西都先画到这个内部位图上,然后在 EndDraw 的时候,通过 GDI 的 BitBlt 或 AlphaBlend 把内部位图合成到目标 HDC 上。
这意味着每次 EndDraw 都会发生一次额外的位图拷贝。对于小面积区域来说这个开销可以忽略不计,但如果你把 BindDC 的矩形设为整个窗口大小(比如 1920x1080),每次拷贝就要移动大约 8MB 的数据。在 60 FPS 的动画场景下,这每秒就要 480MB 的带宽占用。
所以优化策略很明确:尽量缩小 BindDC 的矩形范围。只把你真正需要用 D2D 绘制的区域绑定到 DCRenderTarget,不要贪大。
GetDC/ReleaseDC 的性能特征
每次 GetDC 都会 Flush D2D 的命令队列,而 Flush 意味着 CPU 要等待 GPU 完成所有待处理的绘制命令。这是一个同步点,会中断 GPU 的命令流水线。
优化策略是减少 GetDC/ReleaseDC 的调用次数。把所有 GDI 绘制操作集中在一起执行,而不是在 D2D 绘制之间反复切换。
纯 D2D 的终极方案
如果你的迁移已经完成,所有 GDI 代码都已经替换为 D2D,那就应该考虑去掉互操作层,直接使用 ID2D1HwndRenderTarget 或 ID2D1DeviceContext。这样可以完全消除 GDI/D2D 之间的数据拷贝和同步开销。
常见问题与踩坑总结
BindDC 后什么都看不到
这是一个非常常见的问题。检查清单如下:确认你调用了 BeginDraw 和 EndDraw;确认 EndDraw 返回的是 S_OK;确认你在 BeginDraw 之前调用了 BindDC(不能在 BeginDraw 之后调用);确认 BindDC 的矩形区域不是空矩形。
我见过最多的错误是在 WM_PAINT 处理函数中,先调用了 BeginDraw 再调用 BindDC。正确的顺序是 BindDC 在 BeginDraw 之前。
GDI 和 D2D 的坐标系差异
D2D 使用浮点坐标,原点在左上角,y 轴向下。GDI 使用整数坐标,原点也在左上角,y 轴向下。坐标系本身是一致的,但精度不同------D2D 的浮点坐标可以精确到亚像素级别,而 GDI 的整数坐标会做像素对齐。在互操作场景下,如果你发现 D2D 绘制的内容和 GDI 绘制的内容有 1-2 像素的偏移,通常就是这个精度差异导致的。
Alpha 混合的陷阱
GDI 对 Alpha 通道的支持非常有限。标准的 GDI 函数(FillRect、BitBlt 等)基本上都忽略 Alpha 通道,只有使用 AlphaBlend 函数才能正确处理半透明。而 D2D 从设计之初就全面支持 Alpha 通道。
在互操作场景下,如果你在 DCRenderTarget 上使用半透明画刷绘制内容,然后通过 BindDC 合成到 GDI 的 HDC 上,你需要确保合成方式正确。DCRenderTarget 的 EndDraw 会使用 AlphaBlend 来合成,所以半透明效果应该是正确的。但如果你的 GDI 代码在 D2D 之后又覆盖了那个区域,半透明效果就会被破坏。
设备丢失处理
在使用 DCRenderTarget 时,EndDraw 可能返回 D2DERR_RECREATE_TARGET。这通常发生在显卡驱动更新、系统休眠恢复、或者显存不足等场景。你的代码需要能够检测到这个错误并重建所有 D2D 资源。
一个简单的处理模式是:在检测到 D2DERR_RECREATE_TARGET 时,释放所有 D2D 资源(Factory、RenderTarget、Brush 等),设置一个标志,然后在下一帧重新创建。由于 GDI 的绘制不受影响,所以即使 D2D 暂时不可用,程序也不会崩溃,只是 D2D 绘制的部分会暂时空白。
多显示器和 DPI 缩放
如果你的应用需要支持多显示器和 DPI 缩放,互操作会变得更加复杂。GDI 对 Per-Monitor DPI 的支持很差(基本不支持),而 D2D 原生支持。在 DPI 缩放的显示器上,DCRenderTarget 绑定的 HDC 可能已经被系统缩放过,你需要确保 D2D 的 DPI 设置和 HDC 的 DPI 匹配,否则绘制内容会出现缩放偏差。
总结
这一篇我们覆盖了 Direct2D 和 GDI 互操作的两种方式和三个实战场景。
DCRenderTarget 是"在 GDI 中使用 D2D"的方式,它非常适合渐进迁移------你可以在现有的 WM_PAINT 处理函数中,选择特定的区域用 D2D 绘制,其余部分保持 GDI 不变。每次只替换一小块区域,逐步完成整个迁移。
GdiInteropRenderTarget 是"在 D2D 中使用 GDI"的方式,它适用于主框架已经迁移到 D2D 但偶尔需要调用 GDI 的场景。通过 GetDC/ReleaseDC 临时获取 HDC,完成 GDI 绘制后再回到 D2D。
两种方式的核心性能原则都是一样的:减少 D2D/GDI 之间的切换次数,尽量把同类绘制操作集中在一起。每次切换都会触发 Flush 和数据拷贝,频繁切换会严重影响性能。
互操作是迁移的桥梁,不是终点。当你完成迁移后,应该最终去掉 GDI 层,纯用 D2D 渲染。下一篇我们将进入 HLSL 的世界------Direct2D 的自定义效果让你能写自己的 GPU 着色器,这是 D2D 效果系统的终极扩展方式。
练习
-
渐进迁移一个 GDI 程序:找一个你之前写的使用 GDI 的 Win32 程序(或者写一个简单的),创建 DCRenderTarget,选择其中一个绘制区域(比如一个图表或一个自定义控件),用 D2D 重新实现那个区域的绘制,其余部分保持 GDI 不变。
-
混合绘制性能对比 :分别用纯 GDI、纯 D2D(DCRenderTarget)和 GDI+D2D 混合三种方式绘制相同的复杂图形(比如包含多个圆角矩形、渐变、透明度的仪表盘),用
QueryPerformanceCounter测量每种方式的帧时间,对比差异。 -
分层窗口迁移:创建一个 WS_EX_LAYERED 分层窗口,用 D2D 绘制一个带圆角和半透明效果的浮动面板,通过 UpdateLayeredWindow 显示。要求面板边缘有抗锯齿效果,半透明区域能看到底下的桌面。
-
双向互操作演示:创建一个窗口,上半部分用 D2D 绘制(包含渐变和抗锯齿图形),下半部分用 GDI 绘制(包含传统控件效果),中间用一条分隔线隔开。用 GdiInteropRenderTarget 在 D2D 区域绘制一个 GDI 边框,验证双向互操作都能正常工作。
参考资料:
- Direct2D and GDI Interoperability Overview - Microsoft Learn
- ID2D1DCRenderTarget - Microsoft Learn
- ID2D1Factory::CreateDCRenderTarget - Microsoft Learn
- ID2D1GdiInteropRenderTarget - Microsoft Learn
- ID2D1GdiInteropRenderTarget::GetDC - Microsoft Learn
- ID2D1GdiInteropRenderTarget::ReleaseDC - Microsoft Learn
- Interoperability Overview - Microsoft Learn
- Windows with C++: Layered Windows with Direct2D - Microsoft Learn
相关阅读
- 现代Qt开发教程(新手篇)1.6------内存管理 - 相似度 80%