通用GUI编程技术——图形渲染实战(四十七)——D3D12与D3D11互操作及选型建议
仓库已经开源!喜欢的话点个⭐!仓库Win32和Win32图形栈的部分目前已完成教程,力争做一个完备的GUI教程!
欢迎各位大佬前来参观:https://github.com/Charliechen114514/anatomy_gui
上一篇我们拆解了 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.h、d3d11.h、d2d1.h、dwrite.h - 链接库:
d3d12.lib、d3d11.lib、d2d1.lib、dwrite.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>#pragmacomment(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)){// 互操作设备创建失败returnfalse;}// 查询 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)){// 包装资源创建失败returnfalse;}}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>#pragmacomment(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。
关键操作是AcquireWrappedResources和ReleaseWrappedResources——它们分别获取和释放 D3D11 对 D3D12 资源的控制权:
voidRenderFrame(){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 时看不到。
⚠️ 这里有一个非常容易踩的坑:AcquireWrappedResources和ReleaseWrappedResources必须成对调用。如果你只 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 还差
这通常是因为互操作的同步开销过大。每次AcquireWrappedResources和ReleaseWrappedResources都涉及 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)焕然一新。
练习
创建一个 D3D12 项目,使用 D3D11On12 互操作层在 D3D12 渲染的 3D 场景上叠加 D2D 绘制的文字和半透明矩形 UI。确保 Acquire/Release 配对正确。
研究 ImGui 的 D3D11 和 D3D12 后端实现(GitHub - ocornut/imgui)。阅读
imgui_impl_dx11.cpp和imgui_impl_dx12.cpp的源码,写一段文字总结两者在资源管理和渲染流程上的主要差异。实验互操作的性能影响:分别测量纯 D3D12 渲染和 D3D12+D2D 互操作渲染的帧时间,对比引入互操作后的额外开销。提示:使用
QueryPerformanceCounter测量每帧耗时。尝试在互操作场景中正确使用 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
相关阅读
- 通用GUI编程技术——图形渲染实战(四十三)——D3D12设计哲学:显式控制与性能解锁 - 相似度 100%
- 通用GUI编程技术——Win32 原生编程实战(五十三)——子类化与超类化 - 相似度 82%
- 04. OF API 基础与验证——从 DTS 到代码的桥梁 - 相似度 82%