通用GUI编程技术——图形渲染实战(三十三)——Direct2D与Win32/GDI互操作:渐进迁移实战

通用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 共享,但这会增加代码复杂度。

⚠️ 这里的一个关键点是 BITMAPINFOHEADERbiHeight 必须设为负值,表示自顶向下的位图。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 效果系统的终极扩展方式。


练习

  1. 渐进迁移一个 GDI 程序:找一个你之前写的使用 GDI 的 Win32 程序(或者写一个简单的),创建 DCRenderTarget,选择其中一个绘制区域(比如一个图表或一个自定义控件),用 D2D 重新实现那个区域的绘制,其余部分保持 GDI 不变。

  2. 混合绘制性能对比 :分别用纯 GDI、纯 D2D(DCRenderTarget)和 GDI+D2D 混合三种方式绘制相同的复杂图形(比如包含多个圆角矩形、渐变、透明度的仪表盘),用 QueryPerformanceCounter 测量每种方式的帧时间,对比差异。

  3. 分层窗口迁移:创建一个 WS_EX_LAYERED 分层窗口,用 D2D 绘制一个带圆角和半透明效果的浮动面板,通过 UpdateLayeredWindow 显示。要求面板边缘有抗锯齿效果,半透明区域能看到底下的桌面。

  4. 双向互操作演示:创建一个窗口,上半部分用 D2D 绘制(包含渐变和抗锯齿图形),下半部分用 GDI 绘制(包含传统控件效果),中间用一条分隔线隔开。用 GdiInteropRenderTarget 在 D2D 区域绘制一个 GDI 边框,验证双向互操作都能正常工作。


参考资料:


相关阅读

  1. 现代Qt开发教程(新手篇)1.6------内存管理 - 相似度 80%
相关推荐
文祐2 小时前
C++类之虚函数表及其内存布局(一个子类继承一个父类)
开发语言·c++
墨尘笔尖3 小时前
最大最小值降采样算法的优化
c++·算法
YIN_尹5 小时前
【Linux系统编程】进程地址空间
linux·c++
EverestVIP5 小时前
C++中空类通常大小为1的原理
c++
网域小星球6 小时前
C++ 从 0 入门(六)|C++ 面试必知:运算符重载、异常处理、动态内存进阶(终极补充)
开发语言·c++·面试
晚会者荣6 小时前
红黑树的插入(有图)
c++
John.Lewis6 小时前
C++进阶(12)附加学习:STL之空间配置器(了解)
开发语言·c++·笔记
汉克老师6 小时前
GESP2023年12月认证C++三级( 第三部分编程题(2、单位转换))
c++·string·单位转换·gesp三级·gesp3级
cpp_25018 小时前
P2347 [NOIP 1996 提高组] 砝码称重
数据结构·c++·算法·题解·洛谷·noip·背包dp