Windows mit C++

Eine moderne C++-Bibliothek für die DirectX-Programmierung

Kenny Kerr

Beispielcode herunterladen

Kenny KerrIch habe viel DirectX-Code geschrieben, und ich habe ausführlich über DirectX geschrieben. Ich habe sogar Onlineschulungskurse zu DirectX ausgearbeitet. Es ist eigentlich gar nicht so schwer, wie es einige Entwickler behaupten. Es gibt zwar definitiv eine Lernkurve, aber wenn Sie diese einmal geschafft haben, verstehen Sie, wie und warum DirectX so funktioniert. Dennoch muss ich zugeben, dass die Verwendung der DirectX-Familie von APIs einfacher sein könnte.

Vor ein paar Abenden habe ich beschlossen, Abhilfe zu schaffen. Ich bin die ganze Nacht aufgeblieben und habe eine nette kleine Headerdatei geschrieben. Nach einigen Nächten umfasste sie bereits fast 5.000 Codezeilen. Ich hatte mir als Ziel gesetzt, etwas bereitzustellen, mit dem Sie Apps leichter mit Direct2D erstellen können. Außerdem wollte ich die Einwände „C++ ist schwierig“ oder „DirectX ist schwierig“ infrage stellen, die heutzutage allzu oft vorherrschen. Ich wollte nicht noch einen weiteren komplizierten Wrapper für DirectX entwickeln. Stattdessen habe ich beschlossen, C++11 zum Erstellen einer einfacheren API für DirectX zu nutzen, und zwar ohne jeglichen räumlichen und zeitlichen Mehraufwand für die DirectX-Kern-API. Sie finden diese von mir entwickelte Bibliothek unter dx.codeplex.com.

Die Bibliothek selbst besteht nur aus einer einzigen Headerdatei mit der Bezeichnung „dx.h“. Der restliche Quellcode auf CodePlex enthält Verwendungsbeispiele.

In diesem Artikel erläutere ich, wie Sie mithilfe der Bibliothek verschiedene allgemeine DirectX-bezogene Aktivitäten leichter durchführen können. Ich beschreibe auch den Bibliotheksentwurf, um Ihnen eine Vorstellung davon zu vermitteln, wie C++11 zu einer angenehmeren Gestaltung der klassischen COM-APIs beitragen kann, ohne auf Wrapper mit wesentlichen Auswirkungen wie zum Beispiel Microsoft .NET Framework zurückzugreifen.

Der Schwerpunkt liegt natürlich auf Direct2D. Dies bleibt die einfachste und effektivste Methode zum Nutzen von DirectX für die umfassendsten Klassen von Anwendungen und Spielen. Die Entwicklergemeinde ist anscheinend in zwei Lager gespalten. Zum einen gibt es die hartgesottenen DirectX-Entwickler, die sich an den verschiedenen Versionen der DirectX-API ihre Zähne ausgebissen haben. Sie sind durch die jahrelange Entwicklung von DirectX abgehärtet und glücklich, einem exklusiven Club mit hohen Eintrittskriterien anzugehören – einem Club, dem nur einige wenige Entwickler beitreten können. Zum anderen gibt es solche Entwickler, die gehört haben, dass DirectX schwierig ist, und nichts damit zu tun haben möchten. Selbstverständlich neigen sie auch eher dazu, C++ abzulehnen.

Ich gehöre keinem dieser Lager an. Ich glaube, dass C++ und DirectX nicht schwierig sein müssen. In meinem Artikel vom letzten Monat (msdn.microsoft.com/magazine/dn198239) habe ich Direct2D 1.1 und den erforderlichen Code für Direct3D und DirectX Graphics Infrastructure (DXGI) zum Erstellen eines Geräts und Verwalten einer Swapkette erläutert. Der Code zum Erstellen eines Direct3D-Geräts mit der D3D11CreateDevice-Funktion, die für das GPU- oder CPU-Rendering geeignet ist, umfasst ungefähr 35 Codezeilen. Mithilfe meiner kleinen Headerdatei ergibt dies jedoch folgenden Code:

auto device = CreateDevice();

Die CreateDevice-Funktion gibt ein Device1-Objekt zurück. Alle der Direct3D-Definitionen befinden sich in dem Direct3D-Namespace, daher könnte ich mich expliziter ausdrücken und den Code wie folgt schreiben:

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

Das Device1-Objekt ist einfach ein Wrapper für einen ID3D11­Device1-COM-Schnittstellenzeiger – die Direct3D-Geräteschnittstelle, die mit DirectX 11.1 eingeführt wurde. Die Device1-Klasse leitet sich von der Device-Klasse ab, bei der es sich wiederum um einen Wrapper für die ursprüngliche ID3D11Device-Schnittstelle handelt. Sie stellt eine Referenz dar und ergibt keinen zusätzlichen Mehraufwand im Vergleich zum Schnittstellenzeiger selbst. Beachten Sie, dass es sich bei der Device1-Klasse und ihrer übergeordneten Klasse „Device“ nicht um Schnittstellen, sondern um C++-Klassen handelt. Sie können sie sich quasi als intelligente Zeiger vorstellen, aber dies wäre viel zu einfach. Gewiss können sie Verweiszählungen verarbeiten und zum Aufrufen der Methode Ihrer Wahl den „->“-Operator bereitstellen, aber sie entfalten ihre Glanzleistung erst, wenn Sie die vielen nicht virtuellen Methoden der dx.h-Bibliothek verwenden.

Hier folgt ein Beispiel. Möglicherweise benötigen Sie häufig die DXGI-Schnittstelle des Direct3D-Geräts zur Weiterleitung an eine andere Methode oder Funktion. Sie können die mühsamere Methode verwenden:

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

Dies funktioniert auf jeden Fall, aber nun müssen Sie sich auch direkt mit der DXGI-Geräteschnittstelle beschäftigen. Sie dürfen auch nicht vergessen, dass es sich bei der IDXGIDevice2-Schnittstelle um die DirectX 11.1-Version der DXGI-Geräteschnittstelle handelt. Stattdessen können Sie einfach die AsDxgi-Methode aufrufen:

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

Das resultierende Device2-Objekt, das dieses Mal im Dxgi-Namespace definiert ist, umschließt den IDXGIDevice2-COM-Schnittstellenzeiger, wodurch ein eigener Satz von nicht virtuellen Methoden bereitgestellt wird. Sie sollten als weiteres Beispiel vielleicht besser das DirectX-Objektmodell verwenden, um zur DXGI-Factory zu gelangen:

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

Dies ist natürlich ein so häufiges Muster, dass die Direct3D-Geräteklasse die GetDxgiFactory-Methode als praktische Verknüpfung bereitstellt:

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

Abgesehen von einigen praktischen Methoden und Funktionen wie „GetDxgiFactory“ sind die nicht virtuellen Methoden den zugrunde liegenden DirectX-Schnittstellenmethoden und -funktionen folglich eins zu eins zugeordnet. Dies erscheint vielleicht unbedeutend, aber es werden hierbei eine Reihe von Technologien kombiniert, die ein wesentlich praktischeres und produktiveres Programmierungsmodell für DirectX ergeben. Das betrifft erstens die Verwendung von bereichsbezogenen Enumerationen. Die DirectX-Familie von APIs definiert eine unglaubliche Vielzahl von Konstanten, von denen es sich bei den meisten um traditionelle Enumerationen, Kennzeichen oder Konstanten handelt. Sie sind nicht stark typisiert, lassen sich nur schwer finden und harmonieren nicht besonders mit Visual Studio IntelliSense. Wenn Sie für einen Augenblick die Factory-Optionen ignorieren, ist dies der Code, den Sie zum Erstellen einer Direct2D-Factory benötigen:

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

Beim ersten Parameter der D2D1CreateFactory-Funktion handelt es sich um eine Enumeration, da sie jedoch keine bereichsbezogene Enumeration ist, lässt sie sich mit Visual Studio IntelliSense nur schwer erkennen. Diese herkömmlichen Enumerationen bieten eine gewisse Typensicherheit, aber nicht viel. Bestenfalls erhalten Sie einen E_INVALIDARG-Ergebniscode zur Laufzeit. Ich weiß nicht, wie es Ihnen geht, aber ich erkenne solche Fehler lieber zum Zeitpunkt der Kompilierung oder vermeide sie am besten ganz:

auto factory = CreateFactory(FactoryType::MultiThreaded);

Ich muss wiederum nicht lange herumstöbern, um den Namen der neuesten Version der Direct2D-Factory-Schnittstelle festzustellen. Sicherlich ist der größte Vorteil hier die Produktivität. Natürlich geht es bei der DirectX-API um mehr als nur das Erstellen und Abrufen von COM-Schnittstellenmethoden. Viele einfache alte Datenstrukturen werden zum Bündeln von verschiedenen Eigenschaften und Parametern verwendet. Die Beschreibung einer Swapkette ist ein gutes Beispiel hierfür. Aufgrund all ihrer unübersichtlichen Elemente kann ich mir nie so gut merken, wie diese Struktur vorbereitet werden sollte, ganz zu schweigen von den Besonderheiten der Plattform. Hier erweist sich die Bibliothek abermals als sehr nützlich, da sie einen Ersatz für die einschüchternde DXGI_SWAP_CHAIN_DESC1-Struktur darstellt:

SwapChainDescription1 description;

In diesem Fall werden binärkompatible Ersetzungen bereitgestellt, um zu gewährleisten, dass DirectX der gleicher Typ angezeigt wird. Sie erhalten jedoch die Möglichkeit, einen praktischeren Code zu verwenden. Dieser ist fast so ähnlich wie der Code, der von Microsoft .NET Framework mit seinen P/Invoke-Wrappern bereitgestellt wird. Der Standardkonstruktor enthält geeignete Standardwerte für die meisten Desktop- und Windows Store-Apps. Sie möchten zum Beispiel diesen Code für Desktop-Apps überschreiben, um ein gleichmäßigeres Rendering bei der Größenänderung zu erzielen:

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

Dieser Swapeffekt ist übrigens auch bei der Zielgruppenadressierung für Windows Phone 8 erforderlich, jedoch in Windows Store-Apps nicht zulässig. Stellen Sie sich das mal vor!

Viele der besten Bibliotheken führen Sie schnell und einfach zu einer Arbeitslösung. Betrachten wir ein konkretes Beispiel. Direct2D bietet einen linearen Farbverlaufspinsel. Die Erstellung eines solchen Farbverlaufspinsels umfasst drei logische Schritte: Definieren der Farbverlaufsstopps, Erstellen der Sammlung der Farbverlaufsstopps und Erstellen des linearen Farbverlaufpinsels anhand dieser Sammlung. In Abbildung 1 wird dargestellt, wie dieses Erstellungsverfahren aussehen könnte, wenn die Direct2D-API direkt verwendet wird.

Abbildung 1: Erstellen eines linearen Farbverlaufpinsels mit der schwierigeren Methode

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()));

Mit der dx.h-Headerdatei lassen sich diese Schritte erheblich intuitiver gestalten:

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

Obwohl dieser Code nicht wesentlich kürzer als der Code in Abbildung 1 ist, lässt er sich auf jeden Fall leichter schreiben. Es ist auch relativ unwahrscheinlich, dass Ihnen beim ersten Mal Fehler unterlaufen, vor allem dank der Verwendung von IntelliSense. Die Bibliothek setzt verschiedene Technologien zum Erstellen eines angenehmeren Programmiermodells ein. In diesem Fall ist die CreateGradientStopCollection-Methode mit einer Funktionsvorlage überladen, um die Größe des GradientStop-Arrays zum Zeitpunkt der Kompilierung herzuleiten, damit das _countof-Makro nicht verwendet werden muss.

Und wie sieht es mit der Fehlerbehandlung aus? Nun, eine der Voraussetzungen bei der Erstellung eines solch präzisen Programmiermodells besteht darin, Ausnahmen zur Fehlerverbreitung zu übernehmen. Sehen Sie sich die Definition der AsDxgi-Methode des Direct3D-Geräts an, die ich bereits erwähnt habe:

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

Dies ist ein weit verbreitetes Muster in der Bibliothek. Als erstes fällt auf, dass die Methode konstant ist. Fast alle Methoden in der Bibliothek sind konstant, da das einzige Datenelement die zugrunde liegende ComPtr-Klasse ist und es keinen Grund zur Änderung gibt. Im Methodentext ist ersichtlich, wie das resultierende Device-Objekt entsteht. Alle Bibliotheksklassen bieten eine Verschiebungssemantik. Obwohl es also den Anschein hat, dass eine Vielzahl von Kopien ausgeführt wird, und hieraus abgeleitet eine Vielzahl von AddRef/Release-Paaren, geschieht nichts dergleichen zur Laufzeit. Bei der HR-Funktion, die den Ausdruck in der Mitte umhüllt, handelt es sich um eine Inlinefunktion, die eine Ausnahme auslöst, wenn das Ergebnis nicht „S_OK“ lautet. Letztendlich versucht die Bibliothek immer, die spezifischste Klasse zurückzugeben, damit der Aufrufer keine zusätzlichen Aufrufe an die QueryInterface durchführen muss, um weitere Funktionen verfügbar zu machen.

Im vorigen Beispiel wurde die ComPtr CopyTo-Methode verwendet, die gewissermaßen einfach die QueryInterface-Methode aufruft. Hier ist ein weiteres Beispiel aus Direct2D:

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

Dieses Beispiel unterscheidet sich ein wenig vom vorigen Beispiel, weil hier eine Methode für die zugrunde liegende COM-Schnittstelle aufgerufen wird. Dieses Muster gibt tatsächlich an, woraus der größte Teil des Bibliothekscodes besteht. Hier gebe ich die Bitmap zurück, mit der der Pinsel zeichnet. Viele Direct2D-Methoden geben „void“ zurück, wie in diesem Fall hier, sodass die HR-Funktion das Ergebnis nicht überprüfen muss. Die Differenzierung, die zur GetBitmap-Methode führt, ist jedoch möglicherweise nicht so offensichtlich.

Als ich Prototypen für frühere Versionen der Bibliothek erstellt habe, musste ich mich entscheiden, entweder den Compiler oder COM zu täuschen. Meine früheren Versuche bestanden darin, C++ mithilfe von Vorlagen zu täuschen, speziell Typeigenschaften, aber auch Typeigenschaften des Compilers (auch als systeminterne Typeigenschaften bezeichnet). Zuerst hat das Spaß gemacht, aber es wurde ziemlich schnell klar, dass ich mir selbst viel Arbeit gemacht habe.

Die Bibliothek modelliert nämlich die „is-a“-Beziehung zwischen COM-Schnittstellen als konkrete Klassen. COM-Schnittstellen können nur direkt von einer anderen Schnittstelle erben. Mit Ausnahme von IUnknown selbst muss jede COM-Schnittstelle direkt von einer anderen Schnittstelle erben. Letzten Endes setzt sich dies die gesamte Typenhierarchie fort bis zu IUnknown. Ich habe zunächst für jede COM-Schnittstelle eine Klasse definiert. Die RenderTarget-Klasse enthielt einen ID2D1RenderTarget-Schnittstellenzeiger. Die DeviceContext-Klasse enthielt einen ID2D1DeviceContext-Schnittstellenzeiger. Dies erscheint einleuchtend genug, bis Sie eine DeviceContext-Klasse als RenderTarget-Klasse behandeln möchten. Die ID2D1DeviceContext-Schnittstelle leitet sich schließlich von der ID2D1RenderTarget-Schnittstelle ab. Es wäre durchaus angemessen, eine DeviceContext-Klasse an eine Methode weiterzuleiten, die eine RenderTarget-Klasse als Verweisparameter erwartet.

Leider verhält es sich beim C++-Typensystem anders. Mithilfe dieser Methode kann die DeviceContext-Klasse eigentlich nicht von der RenderTarget-Klasse abgeleitet werden, andernfalls würde sie zwei Verweise enthalten. Ich habe eine Kombination aus Verschiebungssemantik und systeminternen Typeigenschaften verwendet, um die Verweise ganz nach Bedarf korrekt zu verschieben. Das hat fast funktioniert, aber es gab Fälle, bei denen ein zusätzliches AddRef/Release-Paar eingeführt wurde. Letztlich hat sich diese Methode als zu komplex herausgestellt, und eine einfachere Lösung musste her.

Im Gegensatz zu C++ verfügt COM über einen eindeutig definierten binären Vertrag. Schließlich geht es bei COM genau hierum. So lange wie Sie sich an die vereinbarten Regeln halten, wird COM Sie nicht enttäuschen. Sie können COM sozusagen täuschen und C++ zu Ihrem Vorteil nutzen, anstatt dagegen anzugehen. Dies bedeutet, dass jede C++-Klasse keinen stark typisierten COM-Schnittstellenzeiger enthält, sondern einfach nur einen generischen Verweis auf IUnknown. C++ fügt die Typensicherheit wieder hinzu sowie deren Regeln für die Klassenvererbung und kürzlich auch die Verschiebungssemantik. Ich möchte diese COM-Objekte noch einmal als C++-Klassen behandeln. Konzeptuell gesehen habe ich mit diesem Code begonnen:

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

Und habe diesen Code erhalten:

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

Da die logische Hierarchie durch die COM-Schnittstellen impliziert wird und ihre Beziehungen nun durch ein C++-Objektmodell verkörpert werden, ist das Programmiermodell als Ganzes viel natürlicher und nützlicher. Es gehört weit mehr dazu, und ich empfehle Ihnen wirklich, sich den Quellcode näher anzusehen. Zum Zeitpunkt der Erstellung dieses Artikels umfasst der Quellcode nahezu alle Codes von Direct2D und Windows Animation Manager sowie nützliche Codebestandteile von DirectWrite, Windows Imaging Component (WIC), Direct3D und DXGI. Darüber hinaus füge ich regelmäßig weitere Funktionen hinzu. Sehen Sie also regelmäßig nach. Viel Erfolg!

Kenny Kerr ist ein Programmierer aus Kanada, Autor bei Pluralsight und ein Microsoft MVP. Er veröffentlicht Blogs unter kennykerr.ca, und Sie können ihm auf Twitter unter twitter.com/kennykerr folgen.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Worachai Chaoweeraprasit (Microsoft)
Worachai Chaoweeraprasit (Microsoft), wchao@microsoft.com

Worachai Chaoweeraprasitis ist leitender Entwickler für Direct2D und DirectWrite. Er ist auf die Geschwindigkeit und Qualität von 2D-Vektorgrafiken sowie auf die Lesbarkeit von Text auf dem Bildschirm fixiert. In seiner Freizeit genießt er es, von seinen zwei Kindern zu Hause ganz eingenommen zu werden.