Windows и C++

Современная C++-библиотека для программирования DirectX

Кенни Керр

Исходный код можно скачать по ссылке.

Кенни КеррЯ много писал как DirectX-кода, так и о самом DirectX. Я даже подготовил онлайновые учебные курсы по DirectX. На самом деле он не настолько сложен, как кажется некоторым разработчикам. Для его изучения определенно потребуется время, но, как только вы вникнете в него, вам будет нетрудно понять, как и почему DirectX работает так, как работает. Тем не менее, соглашусь с тем, что семейство DirectX API могло бы быть и полегче в использовании.

Несколько вечеров назад я решил исправить эту ситуацию. Все ночь напролет я писал небольшой заголовочный файл. Спустя несколько дней (точнее вечеров) в нем содержалось уже примерно 5000 строк кода. Моей целью было создать нечто такое, что упростило бы вам создание приложений с применением Direct2D, и бросить вызов всем этим аргументам наподобие «C++ очень сложен» или «DirectX слишком труден», так популярным в наши дни. Я не хотел создавать еще одну увесистую оболочку вокруг DirectX. Вместо этого я решил задействовать C++11, чтобы сконструировать более простые API для DirectX, с которыми можно было бы работать без таких усилий, как с базовым DirectX API. СОзданную мной библиотеку вы найдете на dx.codeplex.com.

Сама библиотека состоит только из единственного заголовочного файла dx.h — остальной исходный код на CodePlex представляет собой примеры ее использования.

В этой статье я покажу, как использовать данную библиотеку для упрощения выполнения распространенных операций, связанных с DirectX. Кроме того, я опишу архитектуру этой библиотеки, чтобы вы получили представление  о том, как C++11 помогает сделать классические COM API более удобными в работе, не прибегая к тяжелым оболочкам наподобие Microsoft .NET Framework.

Очевидно, что в центре внимания будет Direct2D. Он остается самым простым и эффективным способом применения DirectX в широком классе приложений и игр. Немалая часть разработчиков, похоже, разделилась на два противоположных лагеря. Есть «хардкорные» DirectX-разработчики, которые выросли на различных версиях DirectX API. Их мастерство укреплялось с годами по мере эволюции DirectX, и они счастливы тому, что пребывают в закрытом клубе с высокой планкой входа, к которому могут присоединиться лишь очень немногие разработчики. В другом лагере находятся разработчики, услышавшие посыл о том, что DirectX сложен, и они не хотят иметь с ним дела. Естественно, они склонны и к неприятию C++.

Я не отношу себя ни к одному из этих лагерей. Я считаю, что C++ и DirectX не обязательно должны быть столь сложны. В своей прошлой статье из этой рубрики (msdn.microsoft.com/magazine/dn198239) я познакомил вас с Direct2D 1.1 и обязательным кодом для Direct3D и DirectX Graphics Infrastructure (DXGI), который необходим для создания устройства и управления цепочкой замен (swap chain). Код для создания Direct3D-устройства с помощью функции D3D11CreateDevice, подходящий для рендеринга на GPU или CPU, выливается примерно в 35 строк. Однако с помощью моего небольшого заголовочного файла он сокращается до:

auto device = CreateDevice();

Функция CreateDevice возвращает объект Device1. Все Direct3D-определения находятся в пространстве имен Direct3D, поэтому я мог бы выразить то же самое более явным образом и написать следующее:

Direct3D::Device1 device = Direct3D::CreateDevice();

Объект Device1 — это просто оболочка указателя на COM-интерфейс ID3D11Device1 (интерфейс Direct3D-устройства, введенный в выпуске DirectX 11.1). Класс Device1 наследует от класса Device, который в свою очередь является оболочкой исходного интерфейса ID3D11Device. Он представляет одну ссылку и не добавляет никаких дополнительных издержек по сравнению с простым удержанием самого указателя на интерфейс. Заметьте, что Device1 и его родительский класс Device являются обычными C++-классами, а не интерфейсами. Вы могли бы рассматривать их как смарт-указатели, но это было бы чрезмерным упрощением. Конечно, они обеспечивают учет ссылок и предоставляют оператор «->» для прямого вызова выбранного вами метода, но по-настоящему полезными они становятся, когда вы начинаете использовать множество не виртуальных методов, предлагаемых библиотекой dx.h.

Вот пример. Вам часто требуется передавать DXGI-интерфейс Direct3D-устройства какому-то другому методу или функции. Вы могли бы пойти по трудному пути:

auto device = Direct3D::CreateDevice();
wrl::ComPtr<IDXGIDevice2> dxdevice;
HR(device->QueryInterface(dxdevice.GetAddressOf()));

Это, разумеется, работает, но теперь вы должны напрямую взаимодействовать с DXGI-интерфейсом устройства. Кроме того, вам понадобится помнить, что интерфейс IDXGIDevice2 является версией интерфейса DXGI устройства в DirectX 11.1. Вместо этого вы можете просто вызвать метод AsDxgi:

auto device = Direct3D::CreateDevice();
auto dxdevice = device.AsDxgi();

Полученный объект Device2, на этот раз определенный в пространстве имен Dxgi, обертывает указатель на COM-интерфейс IDXGIDevice2, предоставляя собственный набор не виртуальных методов. В продолжение этого примера вы можете использовать «объектную модель» DirectX, чтобы добраться до фабрики DXGI:

auto device   = Direct3D::CreateDevice();
auto dxdevice = device.AsDxgi();
auto adapter  = dxdevice.GetAdapter();
auto factory  = adapter.GetParent();

Это настолько распространенный шаблон, что Direct3D-класс Device предоставляет метод GetDxgiFactory в качестве удобного сокращения:

auto d3device = Direct3D::CreateDevice();
auto dxfactory = d3device.GetDxgiFactory();

Таким образом, помимо нескольких методов и функций, созданных исключительно для удобства, например GetDxgiFactory, не виртуальные методы один в один соответствуют нижележащим методам и функциям интерфейса DirectX. Может показаться не слишком важным, но за счет комбинации ряда приемов библиотека образует гораздо более удобную и продуктивную модель программирования для DirectX. Первый прием — использование перечислений, видимость которых ограничена определенной областью (scoped enumerations). В DirectX-семействе API определен ошеломляющий массив констант, многие из которых являются традиционными перечислимыми или флагами. Они не имеют строгой типизации, их трудно найти, и они плохо ладят с Visual Studio IntelliSense. Если на минуту забыть о параметрах фабрики, то для создания Direct2D-фабрики вам понадобится следующее:

wrl::ComPtr<ID2D1Factory1> factory;
HR(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED,
                     factory.GetAddressOf()));

Первый параметр функции D2D1CreateFactory — перечислимое, но поскольку оно не относится к перечислению с ограниченной областью видимости, оно плохо поддается распознаванию с помощью Visual Studio IntelliSense. Эти традиционные перечисления обеспечивают некоторую безопасность типов, но не полную. При ошибке в период выполнения вы в лучшем случае получите код E_INVALIDARG. Не знаю, как вы, но я предпочитаю отлавливать такие ошибки на этапе компиляции, а еще лучше вообще избегать их:

auto factory = CreateFactory(FactoryType::MultiThreaded);

И вновь мне не нужно тратить время на то, чтобы выяснить, какая новейшая версия интерфейса Direct2D-фабрики вызывается. Здесь явно самое крупное преимущество — продуктивность труда. Конечно, DirectX API — нечто гораздо большее простого создания и вызова методов COM-интерфейсов. Для увязки различных свойств и параметров используются многие старые структуры данных. Хороший пример — описание цепочки замен. Учитывая, сколько в ней членов, некоторые из которых весьма туманны, я мог бы никогда не запомнить, как требуется подготавливать эту структуру, не говоря уже о специфике конкретной платформы. И здесь библиотека снова оказывается весьма полезной, предоставляя замену для запутанной структуры DXGI_SWAP_CHAIN_DESC1:

SwapChainDescription1 description;

В этом случае осуществляются замены, совместимые на уровне двоичного кода (binary-compatible replacements), которые гарантируют, что DirectX увидит тот же тип, но вы получите нечто чуть более практичное. Это подобно тому, что Microsoft .NET Framework предоставляет с помощью своих P/Invoke-оболочек. Конструктор по умолчанию предлагает исходные значения, подходящие для большинства настольных приложений и приложений Windows Store. Например, вам может понадобиться переопределить это для настольных приложений, чтобы добиться более плавной визуализации при изменении размеров окна:

SwapChainDescription1 description;
description.SwapEffect = SwapEffect::Discard;

Данный эффект замены (swap effect), кстати, также требуется при ориентации на Windows Phone 8, но не разрешен в приложениях Windows Store. Поди разбери.

Многие из лучших библиотек позволяют быстро и легко создавать рабочее решение. Рассмотрим конкретный пример. Direct2D предоставляет кисть с линейным градиентом. Формирование такой кисти включает три логических этапа: определение ограничителей градиента (gradient stops), создание набора ограничителей градиента и создание кисти с линейным градиентом на основе этого набора. Как это могло бы выглядеть при прямом использовании Direct2D API, показано на рис. 1.

Рис. 1. Создание кисти с линейным градиентом трудным способом

D2D1_GRADIENT_STOP stops[] =
{
  { 0.0f, COLOR_WHITE },
  { 1.0f, COLOR_BLUE },
};
wrl::ComPtr<ID2D1GradientStopCollection> collection;
HR(target->CreateGradientStopCollection(stops,
  _countof(stops), 
  collection.GetAddressOf()));
wrl::ComPtr<ID2D1LinearGradientBrush> brush;
HR(target->CreateLinearGradientBrush( D2D1_LINEAR_GRADIENT_BRUSH_PROPERTIES(),
  collection.Get(),
  brush.GetAddressOf()));

С помощью dx.h все это становится гораздо понятнее на интуитивном уровне:

GradientStop stops[] =
{
  GradientStop(0.0f, COLOR_WHITE),
  GradientStop(1.0f, COLOR_BLUE),
};
auto collection = target.CreateGradientStopCollection(stops);
auto brush = target.CreateLinearGradientBrush(collection);

Хотя этот код не намного короче представленного на рис. 1, он определенно проще в написании, и вероятность того, что вы допустите ошибку, гораздо меньше — особенно за счет полноценной поддержки IntelliSense. В этой библиотеке применяются различные методы для создания более дружелюбной к разработчику модели программирования. В данном случае метод CreateGradientStopCollection перегружается шаблоном функции для логического определения размера массива GradientStop на этапе компиляции, поэтому необходимость в использовании макроса _countof отпадает.

Как насчет обработки ошибок? Что ж, одно из требований при создании такой лаконичной модели программирования — использование исключений для распространения информации об ошибках. Рассмотрим упомянутое ранее определение метода AsDxgi Direct3D-объекта Device:

inline auto Device::AsDxgi() const -> Dxgi::Device2
{
  Dxgi::Device2 result;
  HR(m_ptr.CopyTo(result.GetAddressOf()));
  return result;
}

Это очень распространенный шаблон в библиотеке. Первым делом обратите внимание на то, что этот метод помечен ключевым словом const. Практически все методы в библиотеке являются const, так как единственный член данных — это нижележащий ComPtr и модифицировать его не требуется. В теле метода на свет появляется конечный объект Device. Все библиотечные классы поддерживают семантику перемещения (move semantics), поэтому, хотя может показаться, что осуществляется выполнение нескольких экземпляров, а значит, и нескольких пар AddRef/Release, на самом деле ничего такого в период выполнения не происходит. HR, обертывающая выражение в середине кода, является подставляемой функцией, которая генерирует исключение, если result не соответствует S_OK. Наконец, библиотека всегда будет пытаться вернуть наиболее специфичный класс, чтобы избавить вызвавший код от необходимости дополнительных вызовов QueryInterface.

В предыдущем примере использовался ComPtr-метод CopyTo, который в конечном счете просто вызывает QueryInterface. Вот другой пример из Direct2D:

inline auto BitmapBrush::GetBitmap() const -> Bitmap
{
  Bitmap result;
  (*this)->GetBitmap(result.GetAddressOf());
  return result;
}

Этот пример слегка отличается в том смысле, что он напрямую вызывает метод нижележащего COM-интерфейса. Такой шаблон фактически является основой большей части кода библиотеки. Здесь я возвращаю битовую карту, используемую кистью при рисовании. Многие Direct2D-методы возвращают void, как в данном случае, поэтому функция HR для проверки результата не требуется. Однако косвенная адресация к методу GetBitmap может быть не столь очевидной.

Создавая прототипы ранних версий библиотеки, я был вынужден выбирать между трюками с компилятором и трюками с COM. Поначалу я пытался изощряться с C++, используя шаблоны, в частности признаков, характеризующих типы (type traits), и признаков, характеризующих типы компилятора (compiler type traits) (также известных как признаки встроенных типов [intrinsic type traits]). Поначалу это было забавно, но очень быстро мне стало ясно, что я создаю сам себе лишнюю работу.

Как видите, библиотека моделирует отношение «is-a» между COM-интерфейсами как конкретными классами. COM-интерфейсы могут напрямую наследовать только от одного интерфейса. За исключением самого IUnknown каждый COM-интерфейс должен прямо наследовать от другого интерфейса. В конечном счете это ведет вас по всей иерархии типов к IUnknown. Я начал с определения класса для каждого COM-интерфейса. Класс RenderTarget содержит указатель на интерфейс ID2D1RenderTarget, класс DeviceContext — указатель на интерфейс ID2D1DeviceContext. Это кажется вполне разумным, пока вам не понадобится интерпретировать DeviceContext как RenderTarget. В конце концов, интерфейс ID2D1DeviceContext наследует от интерфейса ID2D1RenderTarget. Поэтому было бы логично передать DeviceContext методу, ожидающему RenderTarget как параметр, передаваемый по ссылке.

Увы, система типов в C++ смотрит на это иначе. При таком подходе DeviceContext на самом деле не может наследовать от RenderTarget, а иначе он хранил бы две ссылки. Я опробовал комбинацию семантики перемещения и признаков встроенных типов для корректной передачи ссылок. Это почти сработало, но бывали случаи, где вводилась дополнительная пара AddRef/Release. В итоге это оказалось слишком сложным, и требовалось какое-то более простое решение.

В COM — в отличие от C++ — имеется четко определенный бинарный контракт (binary contract). В нем вся суть COM. Пока вы придерживаетесь правил, определенных соглашениями, COM вас не подведет. Вы можете вытворять всяческие трюки с COM и использовать C++ к своей выгоде вместо того, чтобы бороться с ним. То есть каждый C++-класс должен хранить не строго типизированный указатель на COM-интерфейс, а просто универсальную ссылку на IUnknown. В этом случае C++ возвращает обратно безопасность типов и свои правила наследования классов (а в последнее время и семантику перемещения). Тем самым я вновь получаю возможность интерпретировать эти COM-«объекты» как C++-классы. С концептуальной точки зрения, я начал с этого:

class RenderTarget { ComPtr<ID2D1RenderTarget> ptr; };
class DeviceContext { ComPtr<ID2D1DeviceContext> ptr; };

а закончил вот этим:

class Object { ComPtr<IUnknown> ptr; };
class RenderTarget : public Object {};
class DeviceContext : public RenderTarget {};

Поскольку логическая иерархия, образуемая COM-интерфейсами и их связями, теперь заключена в объектную модель C++, модель программирования в целом становится гораздо более естественной и удобной в использовании. В библиотеке заложено и много другого, так что я советую вам внимательно изучить ее исходный код. На момент написания этой статьи библиотека охватывала почти все аспекты Direct2D и Windows Animation Manager, а также полезные части DirectWrite, Windows Imaging Component (WIC), Direct3D и DXGI. Кроме того, я регулярно расширяю функциональность, поэтому почаще проверяйте появление новых версий этой библиотеки. Наслаждайтесь!


Кенни Керр (Kenny Kerr) — высококвалифицированный программист. Живет в Канаде. Автор учебных курсов для Pluralsight, обладатель звания Microsoft MVP. Ведет блог kennykerr.ca. Кроме того, читайте его заметки в twitter.com/kennykerr.

Выражаю благодарность за рецензирование статьи эксперту Microsoft Ворачаи Чаовеерапраситу (Worachai Chaoweeraprasit).