用COM编程DirectX
Microsoft组件对象模型(COM)是一种面向对象的编程模型,用于多种技术,包括大部分DirectX的表层API。出于这个缘由,您在编写DirectX时不可避免地使用COM。
它
您可以使用原始COM,这就是本主题的内容。您需要对使用COM API所涉及的原理和编程技术有基本的了解。尽管COM以其困难和复杂而著称,但大多数DirectX应用程序所需的COM编程超级简单。部分缘由是您将使用DirectX提供的COM对象。而无需通过复杂的步骤编写自己的COM对象。
COM组件概述
COM对象本质上是功能的封装组件,应用程序可以使用它来执行一个或多个任务。对于部署,一般将一个或多个COM组件打包到称为COM服务器(COM server)的DLL文件中。
传统的DLL导出自由函数。COM服务器也可以这样做。但是COM服务器内的COM组件公开COM接口和属于这些接口的成员方法。应用程序创建COM组件的实例,从中检索接口,并调用这些接口上的方法,以便调用COM组件中实现的功能。
在实践中,这类似于常规C++对象上的调用方法。但也有一些不同之处。
- COM对象比C++对象执行更严格的封装。您不能只创建对象,然后调用任何公共方法。相反,COM组件的公共方法被分组到一个或多个COM接口中。要调用方法,需要创建对象并从对象中检索实现该方法的接口。接口一般实现一组相关的方法,这些方法提供对对象特定功能的访问。例如,ID3D12Device接口表明一个虚拟图形适配器,它包含的方法使您能够创建资源或其他与适配器相关的任务。
- COM对象不是以C++对象的方式创建的。有几种方法可以创建COM对象,但都涉及特定于COM的技术。DirectX API包括各种协助函数和方法,可简化大多数DirectX COM对象的创建。
- 必须使用特定于COM的技术来控制COM对象的生存期。
- COM服务器(一般是DLL文件)不需要显式加载。您也不会为了使用COM组件而链接到静态库。每个COM组件都有一个唯一的注册标识符(globally-unique identifier/GUID),应用程序使用该标识符来标识COM对象。应用程序会识别出COM组件后COM运行时自动加载正确的COM服务器DLL。
- COM是一种二进制规范。COM对象可以用多种语言编写和访问。您不需要知道任何有关对象源代码的信息。例如,VisualBasic应用程序一般使用C++编写的COM对象。
组件、对象和接口
理解组件、对象和接口之间的区别很重大。在偶然使用中,您可能会听到一个组件或对象被其主接口的名称引用。但这些术语是不能互换的。一个组件可以实现任意数量的接口;对象是组件的实例。例如,虽然所有组件都必须实现IUnknown接口,但们一般至少实现一个附加接口,并且可能实现多个接口。
要使用特定的接口方法,不仅必须实例化对象,还必须从中获取正确的接口。
此外,多个组件可能实现同一接口。接口是一组执行逻辑上相关的一组操作的方法。接口定义仅指定方法的语法及其一般功能。任何需要支持特定操作集的COM组件都可以通过实现合适的接口来实现。一些接口是高度专业化的,并且仅由单个组件实现;另外一些接口在各种情况下都很有用,并由许多组件实现。
如果组件实现了接口,那么它必须支持接口定义中的每个方法。换句话说,您必须能够确定任何方法的存在并调用它。但是,特定方法如何实现的细节可能因组件而异。例如,不同的组件可能使用不同的算法来获得最终结果。也不能保证所有方法都将以非平凡的方式得到支持。有时,组件实现了一个常用的接口,但它只需要支持方法的一个子集。您依旧可以成功调用其余的方法,但它们将返回一个HRESULT(表明结果代码的标准COM类型),其中包含值E_NOTIMPL。您应该参考其文档,了解接口是如何由任何特定组件实现的。
COM标准要求接口定义发布后不得更改。例如,作者不能向现有接口添加新方法。作者必须创建一个新的接口。虽然对该接口中必须包含的方法没有限制,但一般的做法是让下一代接口包含所有旧接口的方法以及任何新方法。
一个接口有多个版本是很常见的。一般,所有版本执行的总体任务基本一样,但具体情况不同。一般,COM组件实现当代接口上一代沿袭下来的接口。这样做允许较旧的应用程序继续使用对象的较旧接口,而较新的应用程序可以利用较新接口的功能。一般,一组接口都具有一样的名称,加上一个表明生成的整数。例如,如果原始接口名为IMyInterface(意味着第1代),那么接下来的两代接口将被称为IMyInterface2和IMyInterface3。对于DirectX接口,一般以DirectX的版本号命名后续代。
GUID
GUID是COM编程模型的关键部分。最基本的GUID是128位结构。但是,guid的创建方式确保没有两个guid是一样的。COM广泛使用GUID有两个主要目的。
- 唯一标识特定COM组件。分配用于标识COM组件的GUID称为类标识符(class identifier/CLSID),当您要创建关联COM组件的实例时,可以使用CLSID。
- 唯一标识特定COM接口。分配用于标识COM接口的GUID称为接口标识符(interface identifier/IID),当您从组件(对象)的实例请求特定接口时,可以使用IID。无论哪个组件实现接口,接口的IID都是一样的。
为方便起见,DirectX文档一般通过组件和接口的描述性名称(例如ID3D12Device)而不是其GUI来引用组件和接口。在DirectX文档的上下文中,没有歧义。从技术上讲,你可以自己编写一个具有一样描述性名称ID3D12Device的接口(它需要有不同的IID才能有效)。不过,为了清楚起见,我们不提议这样做。
因此,引用特定对象或接口的唯一明确方法是通过其GUID。
尽管GUID是一种结构,但GUID一般以等效的字符串形式表明。GUID字符串形式的一般格式为32位十六进制数字,格式为8-4-4-4-12。也就是说,{xxxxxxxx-xxxx-xxxx-xxxx-XXXXXXXXXXXXXX},其中每个x对应一个十六进制数字。例如,ID3D12Device接口的IID的字符串形式为{189819F1-1DB6-4B57-BE54-1821339B85F7}。
由于实际的GUID使用起来有点笨拙,而且容易键入错误,所以一般也会提供一个等效的名称。在代码中,调用函数时可以使用此名称而不是实际结构,例如,将riid参数的参数传递给D3D12CreateDevice时。一般的命名约定是在接口或对象的描述性名称前分别加上IID_uu或CLSID_u。例如,ID3D12Device接口的IID的名称为IID_ID3D12Device。
注意
DirectX应用程序应与dxguid.lib和uuid.lib链接,以提供各种接口和类GUID的定义。Visual C++和其他编译器支持__uuidof运算符,但支持这些链接库的C型链接也完全可移植。
HRESULT值
大多数COM方法返回一个称为HRESULT的32位整数。对于大多数方法,HRESULT本质上是一个包含两条主要信息的结构。
- 方法是否成功。
- 有关该方法执行的操作结果的更详细信息。
有些方法从Winerror.h中定义的标准集返回HRESULT值。但是,方法可以自由返回带有更专门信息的自定义HRESULT值。这些值一般记录在方法的参考页上。
在方法的引用页面上找到的HRESULT值列表一般只是可能返回的值的子集。该列表一般只包含特定于方法的值,以及具有特定于方法含义的标准值。您应该假设一个方法可能返回各种标准HRESULT值,即使它们没有明确的文档记录。
虽然HRESULT值一般用于返回错误信息,但不应将其视为错误代码。指示成功或失败的位与包含详细信息的位分开存储,这一实际允许HRESULT值具有任意数量的成功和失败代码。按照惯例,成功代码的名称以S_为前缀,失败代码的名称以E_为前缀。例如,两个最常用的代码是S_OK和E_FAIL,分别表明简单的成功或失败。
if (hr == E_FAIL)
{
// 调用失败
}
else
{
// 调用成功
}
只要在失败的情况下,这个方法只返回E_FAIL(而不是其他一些失败代码),那么这个测试就可以工作。不过,更现实的是,一般给定的方法会返回一组特定的故障代码,可能是E_NOTIMPL或E_INVALIDARG。倘若还使用上面的代码判断结果,这些结果将被错误地解释为执行成功。
如果需要有关方法调用结果的详细信息,则需要测试每个相关的HRESULT值。但是,您可能只对该方法的调用是成功还是失败感兴趣。测试HRESULT值是否表明成功或失败的可靠方法是将该值传递给Winerror.h中定义的以下宏之一。
-
Succeed宏对于成功代码返回TRUE,对于失败代码返回FALSE。 -
FAILED宏对于失败代码返回TRUE,对于成功代码返回FALSE。
因此,您可以使用FAILED宏修复前面的代码片段,如下面的代码所示。
if (FAILED(hr))
{
// 调用失败
}
else
{
// 调用成功
}
此更正的代码片段正确地将E_NOTIMPL和E_INVALIDARG视为失败。
尽管大多数COM方法返回结构化的HRESULT值,但也有少数方法使用HRESULT返回简单整数。隐含地说,这些方法的调用总是成功的。如果将此类HRESULT传递给Succeed宏,则该宏始终返回TRUE。一般调用的不返回HRESULT的方法的一个例子是IUnknown::Release方法,它返回一个ULONG。此方法将对象的引用计数递减1,并返回当前引用计数。有关引用计数的讨论,请参阅管理COM对象的生存期。
二级指针
如果您查看一些COM方法的原型,您可能会遇到如下情况。
HRESULT D3D12CreateDevice(
IUnknown *pAdapter,
D3D_FEATURE_LEVEL MinimumFeatureLevel,
REFIID riid,
void **ppDevice
);
虽然普通指针对任何C/C++开发人员都很熟悉,但COM一般会使用额外的间接级别。第二级间接寻址由类型声明后的两个星号**表明,变量名一般具有前缀pp。对于上述函数,ppDevice参数一般被称为指向空指针的地址。实际上,在本例中,ppDevice是指向ID3D12Device接口的指针的地址。
与C++对象不同,您不可以直接访问COM对象的方法。相反,必须获取指向公开该方法的接口的指针。要调用该方法,您使用的语法与调用C++方法的指针基本一样。例如,要调用IMyInterface::DoSomething方法,可以使用以下语法。
IMyInterface * pMyIface = nullptr;
// ......
pMyIface->DoSomething(...);
对第二级间接寻址的需求来自这样一个实际:您不直接创建接口指针。您必须调用多种方法中的一种,例如上面显示的D3D12CreateDevice方法。要使用这种方法获取接口指针,需要声明一个变量作为指向所需接口的指针,然后将该变量的地址传递给该方法。换句话说,将指针的地址传递给方法。当方法返回时,变量指向请求的接口,您可以使用该指针调用接口的任何方法。
IDXGIAdapter * pIDXGIAdapter = nullptr;
...
ID3D12Device * pD3D12Device = nullptr;
HRESULT hr = ::D3D12CreateDevice(
pIDXGIAdapter,
D3D_FEATURE_LEVEL_11_0,
IID_ID3D12Device,
&pD3D12Device);
if (FAILED(hr)) return E_FAIL;
创建COM对象
有几种方法可以创建COM对象。这是DirectX编程中最常用的两种。
- 间接地,通过调用为您创建对象的DirectX方法或函数。该方法创建对象,并返回对象上的接口。以这种方式创建对象时,有时可以指定应返回哪个接口,有时则隐含该接口。上面的代码示例显示了如何间接创建Direct3D12设备的COM对象。
- 直接将对象的CLSID传递给CoCreateInstance函数。该函数创建对象的实例,并返回指向指定接口的指针。
在创建任何COM对象之前,必须通过调用CoInitializeEx初始化COM。如果您是间接创建对象,那么对象创建方法将处理此任务。但是,如果需要使用CoCreateInstance创建对象,则在之后必须显式调用CoInitializeEx。完成后,COM必须通过调用 CoUninitialize撤销初始化。如果调用CoInitializeEx,则必须将其与调用CoUninitialize相匹配。一般,需要显式初始化COM的应用程序在其启动例程中进行初始化,并在清理例程中撤销初始化COM。
要使用CoCreateInstance创建COM对象的新实例,必须具有该对象的CLSID。如果此CLSID是公开的,您可以在参考文档或相应的头文件中找到它。如果CLSID不公开,则无法直接创建对象。
CoCreateInstance函数有五个参数。对于将与DirectX一起使用的COM对象,一般可以按如下方式设置参数。
-
rclsid将其设置为要创建的对象的CLSID。 -
pUnkOuter设置为nullptr。此参数仅在聚合对象时使用。关于COM聚合的讨论超出了本主题的范围。 -
dwClsContext设置为CLSCTX_INPROC_SERVER。此设置表明对象作为DLL实现,并作为应用程序进程的一部分运行。 -
riid设置为要返回的接口的IID。函数将创建对象并在ppv参数中返回请求的接口指针。 -
ppv将其设置为指针的地址,当函数返回时,指针将被设置为riid指定的接口。此变量应声明为指向请求接口的指针,参数列表中对指针的引用应转换为(LPVOID*)。
正如我们在上面的代码示例中看到的那样,间接创建对象一般要简单得多。将接口指针的地址传递给对象创建方法,然后该方法创建对象并返回接口指针。当间接创建对象时,即使无法选择该方法返回的接口,一般也可以指定有关如何创建对象的各种内容。
例如,您可以向D3D12CreateDevice传递一个值,该值指定返回的设备应支持的最小D3D功能级别,如上面的代码示例所示。
使用COM接口
创建COM对象时,创建方法将返回一个接口指针。然后可以使用该指针访问接口的任何方法。该语法与指向C++方法的指针一样。
请求附加接口
在许多情况下,从创建方法接收的接口指针可能是您唯一需要的指针。实际上,一个对象只导出IUnknown以外的一个接口是比较常见的。但是,许多对象导出多个接口,您可能需要指向其中几个接口的指针。如果需要比创建方法返回的接口更多的接口,则无需创建新对象。相反,使用对象的IUnknown::QueryInterface方法请求另一个接口指针。
如果使用CoCreateInstance创建对象,则可以请求IUnknown接口指针,然后调用IUnknown::QueryInterface来请求所需的每个接口。但是,如果您只需要一个接口,这种方法就很不方便,如果您使用的对象创建方法不允许您指定应该返回哪个接口指针,那么这种方法根本不起作用。实际上,您一般不需要获得显式的IUnknown指针,由于所有COM接口都扩展了IUnknown接口。
扩展一个接口在概念上类似于继承一个C++类。子接口公开父接口的所有方法,以及它自己的一个或多个方法。实际上,我们更习惯性的叫做子接口继承自父接口。您需要记住的是无论继承还是拓展都是对象内部的。应用程序无法继承或扩展对象的接口。但是,您可以使用子接口调用子接口自身或继承的父接口的任何方法。
由于所有接口都是IUnknown的子接口,所以可以对对象已有的任何接口指针调用QueryInterface。在执行此操作时,必须提供所请求接口的IID以及一个用于返回指向接口的指针。
例如,下面的代码片段调用IDXGIFactory2::CreateSwapChainForHwnd来创建主交换链对象。此对象公开多个接口。CreateSwapChainForHwnd方法返回一个IDXGISwapChain1接口。随后的代码使用IDXGISwapChain1接口调用QueryInterface以请求IDXGISwapChain3接口。
HRESULT hr = S_OK;
IDXGISwapChain1 * pDXGISwapChain1 = nullptr;
hr = pIDXGIFactory->CreateSwapChainForHwnd(
pCommandQueue, // 对于D3D12,这是指向直接命令队列的指针。
hWnd,
&swapChainDesc,
nullptr,
nullptr,
&pDXGISwapChain1));
if (FAILED(hr)) return hr;
IDXGISwapChain3 * pDXGISwapChain3 = nullptr;
hr = pDXGISwapChain1->QueryInterface(IID_IDXGISwapChain3, (LPVOID*)&pDXGISwapChain3);
if (FAILED(hr)) return hr;
在C++中,可以使用IID_PPV_ARGS宏,而不是显式IID和转换的指针:pDXGISwapChain1->QueryInterface(IID_PPV_ARGS(&pDXGISwapChain3));这一般用于创建方法和查询接口。有关更多信息,请参见combaseapi.h。
管理COM对象的生存期
创建对象时,系统会分配必要的内存资源。当不再需要某个对象时,应将其销毁。系统可以将该内存用于其他目的。使用C++面向对象机制,您可以通过new和delete来管理对象的生存期,或者仅使用堆栈和作用域生存期来控制对象的生存期。COM不允许您直接创建或销毁对象。这种设计的缘由是,同一对象可能被应用程序的多个部分使用,或者在某些情况下被多个应用程序使用。如果其中一个引用要销毁对象,那么其他引用将变得无效。相反,COM使用引用计数系统来控制对象的生存期。
对象的引用计数是其一个接口被请求的次数。每次请求接口时,引用计数都会增加。当不再需要某个接口时,应用程序会释放该接口,从而减少引用计数。只要引用计数大于零,对象就会保留在内存中。当引用计数达到零时,对象将销毁自身。您不需要知道任何有关对象引用计数的信息。只要正确地获取和释放对象的接口,该对象将具有适当的生存期。
正确处理引用计数是COM编程的关键部分。否则很容易造成内存泄漏或崩溃。COM程序员最常见的错误之一是未能释放接口(failing to release an interface)。当这种情况发生时,引用计数永远不会达到零,对象将无限期地保留在内存中。
注意
Direct3D 10或更高版本稍微修改了对象的生存期规则。特别是,从ID3DxxDeviceChild派生的对象永远不会超过其父设备(也就是说,如果拥有ID3DxxDevice的引用计数为0,则所有子对象也立即无效)。此外,当使用Set方法将对象绑定到渲染管道时,这些引用不会增加引用计数(即,它们是弱引用)。实际上,最好通过确保在释放设备之前完全释放所有设备子对象来处理此问题。
递增和递减引用计数
每当获得新的接口指针时,必须通过调用IUnknown::AddRef来增加引用计数。但是,我们一般不需要手动调用此方法。由于如果通过调用对象创建方法或通过调用IUnknown::QueryInterface获得接口指针,则对象会自动增加引用计数。但是,如果以其他方式创建接口指针,例如复制现有指针,则必须显式调用IUnknown::AddRef。否则,当您释放原始接口指针时,对象可能会被销毁,即使您可能依旧需要使用指针的副本。
下面的代码片段扩展了前面显示的示例,以说明如何处理引用计数。
HRESULT hr = S_OK;
IDXGISwapChain1 * pDXGISwapChain1 = nullptr;
hr = pIDXGIFactory->CreateSwapChainForHwnd(
pCommandQueue, // 对于D3D12,这是指向直接命令队列的指针。
hWnd,
&swapChainDesc,
nullptr,
nullptr,
&pDXGISwapChain1));
if (FAILED(hr)) return hr;
IDXGISwapChain3 * pDXGISwapChain3 = nullptr;
hr = pDXGISwapChain1->QueryInterface(IID_IDXGISwapChain3, (LPVOID*)&pDXGISwapChain3);
if (FAILED(hr)) return hr;
IDXGISwapChain3 * pDXGISwapChain3Copy = nullptr;
// 定义一个IDXGISwapChain3接口指针的副本。
// 调用AddRef增加引用计数
// 对象不会过早地被销毁
pDXGISwapChain3Copy = pDXGISwapChain3;
pDXGISwapChain3Copy->AddRef();
...
// 清理代码。检查指针是否仍处于活动状态。
// 如果是,则调用Release来释放接口。
if (pDXGISwapChain1 != nullptr)
{
pDXGISwapChain1->Release();
pDXGISwapChain1 = nullptr;
}
if (pDXGISwapChain3 != nullptr)
{
pDXGISwapChain3->Release();
pDXGISwapChain3 = nullptr;
}
if (pDXGISwapChain3Copy != nullptr)
{
pDXGISwapChain3Copy->Release();
pDXGISwapChain3Copy = nullptr;
}