跳转至

通用GUI编程技术——图形渲染实战(四十七)——D3D12与D3D11互操作及选型建议

上一篇我们拆解了 D3D12 的描述符堆和根签名——描述符是 GPU 端的"指针",根签名定义了 Shader 如何找到这些指针,两者配合构成了 D3D12 资源绑定模型的完整图景。到此为止,我们已经掌握了 D3D12 的核心基础设施:命令系统、资源管理、描述符绑定。但在实际项目中,你几乎不可能只使用 D3D12——你可能需要用 Direct2D 画 UI 文字,或者在一个已有的 D3D11 项目中逐步引入 D3D12。微软提供了 D3D11On12 互操作层来支持这种"混合使用"的场景,今天我们就来拆解它。

前言:为什么需要互操作

说句实话,如果你做一个纯 3D 游戏引擎,D3D12 完全够用,不需要任何互操作。但在 GUI 编程的场景下,情况不太一样。你可能已经有一个基于 D3D11 或 Direct2D 的 UI 渲染框架(文字渲染、矢量图形、控件绘制),现在想引入 D3D12 来处理一些高性能的 3D 渲染任务(比如在 D3D12 中渲染一个 3D 预览窗口),同时保留原有的 2D UI 不变。

或者更常见的场景:你想在 D3D12 的渲染帧上叠加 Direct2D 绘制的 UI 元素(文字、按钮、进度条等)。Direct2D 在 Windows 上是通过 D3D11 的设备来创建渲染目标的,它不能直接在 D3D12 的命令队列上工作。所以你需要一个桥接层——D3D11On12 就是这个桥接。

根据 Microsoft Learn - D3D11On12 的官方描述,D3D11On12 互操作层允许你在 D3D12 的命令队列上使用 D3D11 的接口和运行时。这意味着你可以在同一个渲染管线中混用 D3D12 和 D3D11 的 API 调用,而不需要维护两套完全独立的渲染设备。

环境说明

  • 操作系统: Windows 11 Pro 10.0.26200
  • 编译器: MSVC (Visual Studio 2022, v143 工具集)
  • Windows SDK: 10.0.26100 或更高版本
  • 依赖: d3d12.hd3d11.hd2d1.hdwrite.h
  • 链接库: d3d12.libd3d11.libd2d1.libdwrite.lib
  • 前置知识: 文章 44(命令队列)、文章 46(描述符堆与根签名)

D3D11On12 的架构原理

在深入代码之前,我们先理解一下 D3D11On12 在底层是怎么工作的。

当你通过 D3D11On12CreateDevice 创建了一个互操作设备后,你得到了一个 D3D11 的设备对象(ID3D11Device)和一个 D3D11 的设备上下文(ID3D11DeviceContext)。这些 D3D11 对象在底层并不是独立的——它们实际上是 D3D12 设备和命令队列的"代理"。当你通过 D3D11 的接口提交渲染命令时,D3D11On12 层会把这些命令翻译成 D3D12 的命令,然后在你的 D3D12 命令队列上执行。

你可以把这个过程类比为"翻译官"——D3D11On12 把 D3D11 的 API 调用翻译成 D3D12 的命令,然后通过你的 D3D12 命令队列提交给 GPU。这样,D3D12 和 D3D11 的命令就共享同一条 GPU 执行通道,资源的共享也变得自然而然。

但这个翻译是有开销的。D3D11On12 层需要维护 D3D11 的状态跟踪、资源管理等功能,这些在原生 D3D12 中是不需要的。所以互操作不适合高性能的渲染路径——如果你需要对每帧数百万个三角形做 D3D12 渲染,然后又通过 D3D11On12 层做额外的处理,翻译开销可能成为瓶颈。

第一步——创建互操作设备

创建互操作设备的核心函数是 D3D11On12CreateDevice。根据 Microsoft Learn - D3D11On12CreateDevice 的文档,它的参数列表如下:

#include <d3d11on12.h>
#pragma comment(lib, "d3d11.lib")

// 假设你已经有了 D3D12 设备和命令队列
// ComPtr<ID3D12Device> g_d3d12Device;
// ComPtr<ID3D12CommandQueue> g_commandQueue;

ComPtr<ID3D11Device> g_d3d11Device;
ComPtr<ID3D11DeviceContext> g_d3d11Context;
ComPtr<ID3D11On12Device> g_d3d11On12Device;

HRESULT hr = D3D11On12CreateDevice(
    g_d3d12Device.Get(),           // D3D12 设备
    D3D11_CREATE_DEVICE_FLAG_NONE, // D3D11 创建标志
    nullptr,                       // Feature Levels(NULL = 默认)
    0,                             // Feature Levels 数量
    reinterpret_cast<IUnknown**>(&g_commandQueue),  // 命令队列数组
    1,                             // 命令队列数量
    0,                             // 节点掩码(单 GPU = 0)
    &g_d3d11Device,                // 输出 D3D11 设备
    &g_d3d11Context,               // 输出 D3D11 设备上下文
    nullptr                        // 返回的 Feature Level(可选)
);

if (FAILED(hr))
{
    // 互操作设备创建失败
    return false;
}

// 查询 D3D11On12 接口
g_d3d11Device.As(&g_d3d11On12Device);

D3D11On12CreateDevice 的关键参数是第一个(D3D12 设备)和第五个(命令队列数组)。它们建立了 D3D11 到 D3D12 的桥接关系——所有通过这个 D3D11 设备提交的命令,最终都会被翻译后在指定的 D3D12 命令队列上执行。

创建成功后,我们通过 As(COM 的 QueryInterface)获取了 ID3D11On12Device 接口。这个接口提供了 D3D11 和 D3D12 资源之间的"包装"和"解包"操作——后面马上会用到。

第二步——包装 D3D12 资源给 D3D11 使用

D3D12 的渲染目标(交换链的后台缓冲区)是 ID3D12Resource 对象,D3D11 不认识它。我们需要通过 ID3D11On12Device::CreateWrappedResource 把 D3D12 资源"包装"成 D3D11 可以使用的资源:

// 为每个后台缓冲区创建 D3D11 包装资源
ComPtr<ID3D11Resource> g_wrappedResources[2];

for (UINT i = 0; i < 2; i++)
{
    D3D11_RESOURCE_FLAGS resourceFlags = {};
    resourceFlags.BindFlags = D3D11_BIND_RENDER_TARGET;

    hr = g_d3d11On12Device->CreateWrappedResource(
        g_renderTargets[i].Get(),       // D3D12 资源
        &resourceFlags,                  // D3D11 资源标志
        D3D12_RESOURCE_STATE_RENDER_TARGET,  // D3D12 输入状态
        D3D12_RESOURCE_STATE_PRESENT,        // D3D12 输出状态
        nullptr,
        IID_PPV_ARGS(&g_wrappedResources[i])
    );

    if (FAILED(hr))
    {
        // 包装资源创建失败
        return false;
    }
}

CreateWrappedResource 的几个关键参数值得展开说说。D3D11_RESOURCE_FLAGS 定义了这个资源在 D3D11 中的用途——D3D11_BIND_RENDER_TARGET 表示它作为 D3D11 的渲染目标使用。第三个参数(D3D12_RESOURCE_STATE_RENDER_TARGET)是 D3D11 获得资源控制权时,D3D12 端资源应该处于的状态。第四个参数(D3D12_RESOURCE_STATE_PRESENT)是 D3D11 释放资源控制权后,D3D12 端资源应该转换到的状态。

这两个状态参数本质上是在告诉 D3D11On12 层:"当 D3D11 要用这个资源时,自动把它转到 RENDER_TARGET 状态;当 D3D11 用完了,自动把它转回 PRESENT 状态。"

第三步——创建 D2D 渲染目标

有了包装后的 D3D11 资源,我们就可以按照标准的 D2D + D3D11 集成流程来创建 Direct2D 的渲染目标了。首先从 D3D11 设备创建 D2D 设备:

#include <d2d1_3.h>
#pragma comment(lib, "d2d1.lib")

ComPtr<ID2D1Factory3> g_d2dFactory;
ComPtr<ID2D1Device2> g_d2dDevice;
ComPtr<ID2D1DeviceContext2> g_d2dContext;

// 创建 D2D 工厂
D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, &g_d2dFactory);

// 从 D3D11 设备获取 DXGI 设备,创建 D2D 设备
ComPtr<IDXGIDevice> dxgiDevice;
g_d3d11Device.As(&dxgiDevice);

g_d2dFactory->CreateDevice(dxgiDevice.Get(), &g_d2dDevice);
g_d2dDevice->CreateDeviceContext(
    D2D1_DEVICE_CONTEXT_OPTIONS_NONE, &g_d2dContext);

然后为每个后台缓冲区创建 D2D 的位图渲染目标:

ComPtr<ID2D1Bitmap1> g_d2dRenderTargets[2];

D2D1_BITMAP_PROPERTIES1 bitmapProps = {};
bitmapProps.bitmapOptions =
    D2D1_BITMAP_OPTIONS_TARGET | D2D1_BITMAP_OPTIONS_CANNOT_DRAW;
bitmapProps.pixelFormat.format = DXGI_FORMAT_R8G8B8A8_UNORM;
bitmapProps.pixelFormat.alphaMode = D2D1_ALPHA_MODE_PREMULTIPLIED;

for (UINT i = 0; i < 2; i++)
{
    ComPtr<IDXGISurface> surface;
    g_wrappedResources[i].As(&surface);

    g_d2dContext->CreateBitmapFromDxgiSurface(
        surface.Get(), &bitmapProps, &g_d2dRenderTargets[i]);
}

到这里,我们已经建立了一条完整的桥接链路:D3D12 资源 → D3D11 包装资源 → DXGI Surface → D2D 位图渲染目标。D2D 可以直接在 D3D12 的后台缓冲区上绘制 2D 图形了。

第四步——D2D 绘制 UI 的完整流程

渲染一帧的流程分为三个阶段:先用 D3D12 渲染 3D 场景,然后用 D2D 绘制 2D UI,最后 Present。

关键操作是 AcquireWrappedResourcesReleaseWrappedResources——它们分别获取和释放 D3D11 对 D3D12 资源的控制权:

void RenderFrame()
{
    UINT frameIndex = g_swapChain->GetCurrentBackBufferIndex();

    // ========== 阶段 1:D3D12 渲染 3D 场景 ==========
    // ... 录制 D3D12 命令(渲染 3D 场景到后台缓冲区) ...
    // ... ResourceBarrier: RENDER_TARGET → PRESENT(给 D2D 用)...

    // 等待 D3D12 命令完成
    WaitForGPU();

    // ========== 阶段 2:D2D 绘制 UI ==========
    // 获取包装资源的控制权(自动将资源转到 RENDER_TARGET 状态)
    ID3D11Resource* ppResources[] = { g_wrappedResources[frameIndex].Get() };
    g_d3d11On12Device->AcquireWrappedResources(ppResources, 1);

    // 设置 D2D 渲染目标
    g_d2dContext->SetTarget(g_d2dRenderTargets[frameIndex].Get());

    // 开始 D2D 绘制
    g_d2dContext->BeginDraw();

    // 绘制 UI 元素(文字、按钮、进度条等)
    g_d2dContext->Clear(D2D1::ColorF(0, 0));  // 清除为全透明(保留 D3D12 的渲染结果)

    // 示例:绘制一段文字
    ComPtr<ID2D1SolidColorBrush> pBrush;
    g_d2dContext->CreateSolidColorBrush(
        D2D1::ColorF(D2D1::ColorF::White), &pBrush);

    ComPtr<IDWriteTextFormat> pTextFormat;
    // ... 创建文字格式 ...

    g_d2dContext->DrawTextW(
        L"D3D12 + D2D Interop",
        18,
        pTextFormat.Get(),
        D2D1::RectF(10, 10, 400, 50),
        pBrush.Get()
    );

    // 结束 D2D 绘制
    g_d2dContext->EndDraw();

    // 释放包装资源的控制权(自动将资源转回 PRESENT 状态)
    g_d3d11On12Device->ReleaseWrappedResources(ppResources, 1);

    // 刷新 D3D11 命令(确保 D2D 的绘制被提交到 D3D12 命令队列)
    g_d3d11Context->Flush();

    // ========== 阶段 3:Present ==========
    g_swapChain->Present(1, 0);
}

这段代码中有几个关键步骤值得仔细理解。

AcquireWrappedResources 把 D3D12 资源的控制权交给 D3D11。它会自动在 D3D12 命令队列上插入一个资源屏障,把资源从我们在 CreateWrappedResource 时指定的"输出状态"(PRESENT)转换到"输入状态"(RENDER_TARGET)。这样 D2D 就可以在资源上绘制了。

ReleaseWrappedResources 把控制权还回 D3D12。它会在 D3D12 命令队列上插入反向的屏障,把资源从 RENDER_TARGET 转回 PRESENT

g_d3d11Context->Flush() 确保 D3D11 端所有待处理的命令都被提交到 D3D12 命令队列。没有这一步,D2D 的绘制可能还留在 D3D11 的内部缓冲区中,Present 时看不到。

⚠️ 这里有一个非常容易踩的坑:AcquireWrappedResourcesReleaseWrappedResources 必须成对调用。如果你只 Acquire 了但忘了 Release,资源就永远处于 D3D11 控制下的 RENDER_TARGET 状态,D3D12 在 Present 时会因为状态不对而出问题。反过来,如果你 Release 了但没 Acquire,D3D11 会在没有控制权的情况下尝试操作资源,导致未定义行为。

互操作的同步问题

上面的流程中有一个隐含的同步问题:D3D12 渲染和 D2D 渲染之间必须正确同步。D2D 必须等 D3D12 渲染完成后才能开始在同一个后台缓冲区上绘制。

在我们的简化示例中,通过 WaitForGPU() 来同步——CPU 等待 GPU 执行完 D3D12 的命令后,再开始 D2D 绘制。这是一个安全但不太高效的方案,因为它引入了一次 CPU-GPU 同步。

在实际项目中,更高效的做法是使用 Fence 来做更细粒度的同步——D3D12 渲染完成后 Signal 一个 Fence 值,D2D 绘制前等待这个 Fence 值。或者更简单地,利用 D3D11On12 内部的隐式同步机制(AcquireWrappedResources 内部会自动等待关联的 D3D12 命令完成),但要注意这可能导致 CPU 侧的额外等待。

选型建议:D3D11 vs D3D12

讨论了这么多互操作的细节,一个自然的问题是:我到底应该用 D3D11 还是 D3D12?

应用/工具类项目 → D3D11 足够

如果你的项目是一个工具软件、编辑器界面、数据可视化应用,D3D11(配合 Direct2D)完全足够。这类项目的渲染负载通常不高(几十到几百个 Draw Call),D3D11 的驱动开销不会成为瓶颈。D3D11 的 API 更简洁,开发效率更高,调试也更方便。

典型场景包括:图片编辑器的预览窗口、CAD 软件的 2D 视图、音频可视化工具、简单的图表应用。

引擎/高性能渲染 → D3D12

如果你在开发一个游戏引擎、实时渲染引擎、或者需要处理数万 Draw Call 的高性能应用,D3D12 的显式控制可以带来显著的 CPU 端性能提升。多线程命令录制、显式资源状态管理、手动同步控制——这些特性在 Draw Call 数量极大的时候会体现出真正的价值。

典型场景包括:游戏引擎、GPU 粒子系统、大规模场景渲染、需要 Compute Shader 做复杂计算的应用。

学习/教学 → D3D11 先行

如果你是在学习图形编程,建议从 D3D11 开始。D3D11 的 API 更接近"图形管线"本身——你关注的是顶点着色器、像素着色器、纹理采样、混合这些渲染概念。D3D12 的额外复杂度(命令管理、资源状态、描述符)会分散你对渲染核心概念的理解。

掌握了 D3D11 的渲染管线后,再学 D3D12 就只需要理解"基础设施的变化"——渲染概念是一样的,只是管理方式从隐式变成了显式。

迁移/混合 → 互操作

如果你有一个已有的 D3D11 项目想逐步迁移到 D3D12,或者需要在 D3D12 项目中保留 D3D11/D2D 的 UI 渲染,D3D11On12 互操作层就是你的过渡方案。它允许你在同一个渲染管线中混用两种 API,按模块逐步迁移,而不是一次性重写。

常见问题

D3D11On12CreateDevice 失败

最常见的失败原因是 D3D12 设备不支持互操作。确保你的 D3D12 设备是在支持 D3D12 的 GPU 上创建的(不是 WARP 软件光栅化器),并且 Windows 版本支持 D3D11On12(Windows 10 1607 及以上)。

D2D 绘制内容看不到

检查你是否在 D2D 绘制前调用了 AcquireWrappedResources,绘制后调用了 ReleaseWrappedResources,以及最后是否调用了 g_d3d11Context->Flush()。三个调用缺一不可。

画面闪烁或撕裂

可能是 D3D12 渲染和 D2D 绘制之间的同步不正确。确保 D3D12 渲染完成后(Fence 信号到达)再开始 D2D 绘制。如果你省略了同步步骤,D2D 可能会在 D3D12 还在写入后台缓冲区的时候就开始绘制,导致画面混乱。

性能比纯 D3D11 还差

这通常是因为互操作的同步开销过大。每次 AcquireWrappedResourcesReleaseWrappedResources 都涉及 CPU-GPU 同步,如果频繁调用(比如每一帧都创建新的包装资源),开销会累积。建议在初始化时创建好所有包装资源,在渲染循环中只做 Acquire/Release 操作。

总结

这篇我们拆解了 D3D12 与 D3D11 互操作的完整机制。

D3D11On12 互操作层本质上是一个"翻译官"——它把 D3D11 的 API 调用翻译成 D3D12 的命令,通过 D3D12 命令队列提交给 GPU。核心流程包括:通过 D3D11On12CreateDevice 创建互操作设备,通过 CreateWrappedResource 把 D3D12 资源包装成 D3D11 可用的资源,在渲染时通过 AcquireWrappedResources/ReleaseWrappedResources 管理资源的控制权切换。

我们还讨论了 D3D11 和 D3D12 的选型建议——应用/工具用 D3D11,引擎/高性能用 D3D12,学习用 D3D11 先行,迁移/混合用互操作。选型没有绝对的对错,关键是根据项目需求做出合理的权衡。

到此为止,我们的 D3D12 部分告一段落。接下来,我们把视线从 GPU 加速的 3D 渲染拉回到 Win32 的控件世界——下一篇要聊的是 Owner-Draw 控件:如何利用 WM_DRAWITEM 消息让系统控件(ListBox、ComboBox、Button)焕然一新。


练习

  1. 创建一个 D3D12 项目,使用 D3D11On12 互操作层在 D3D12 渲染的 3D 场景上叠加 D2D 绘制的文字和半透明矩形 UI。确保 Acquire/Release 配对正确。

  2. 研究 ImGui 的 D3D11 和 D3D12 后端实现(GitHub - ocornut/imgui)。阅读 imgui_impl_dx11.cppimgui_impl_dx12.cpp 的源码,写一段文字总结两者在资源管理和渲染流程上的主要差异。

  3. 实验互操作的性能影响:分别测量纯 D3D12 渲染和 D3D12+D2D 互操作渲染的帧时间,对比引入互操作后的额外开销。提示:使用 QueryPerformanceCounter 测量每帧耗时。

  4. 尝试在互操作场景中正确使用 Fence 进行同步,替代简单的 WaitForGPU 方案。思考:在什么情况下 Fence 同步比直接等待更高效?


参考资料: - Direct3D 11 on 12 - Microsoft Learn - D3D11On12CreateDevice function - Microsoft Learn - ID3D11On12Device interface - Microsoft Learn - D2D1DeviceContext - Microsoft Learn - D3D12 and D2D interop sample - Microsoft Learn - Working with Direct3D 11 and Direct2D - Microsoft Learn