Meine Werkzeuge
Namensräume
Varianten

DirectX 11 Jumpstart/DirectX initialisieren

Aus indiedev
Wechseln zu: Navigation, Suche
Hinweis Dieser Artikel wird gerade geschrieben bzw. bearbeitet und erfüllt noch nicht die Qualitätsstandards von indiedev.
Bitte bearbeite diesen Artikel nur, wenn du kleine Fehler korrigieren möchtest oder der Autor bist.
DirectX 11 Jumpstart
DirectX initialisieren
Autor Glatzemann
Programmier­sprache C++
Kategorie DirectX
Diskussion Thread im Forum
Lizenz indiedev article license
Originalartikel: MitOhneHaare.de


Wie im vorherigen Teil dieser Reihe bereits angekündigt, möchte ich nun den fehlenden Part der DirectX-Initialisierung nachholen. Ich hatte diesen Bereich im letzten Artikel ausgelassen, weil dieser etwas lang geworden ist.

Legen wir direkt los, damit wir bald unser aktuell noch graues Fenster in einem wunderschönen Blau sehen werden, dass von DirectX mittels des Clear-Befehls erzeugt werden wird.

Inhaltsverzeichnis

Der RenderView

Zunächst erzeugen wir uns eine abstrakte Basisklasse. Diese Klasse ist die Basis für unser eigentliches Spiel. In ihr wird DirectX initialisiert werden und alle wichtigen Zeiger und Variablen werden dort gehalten. Der eigentliche GameView, der unser Spiel ist, erbt später von dieser Klasse und kann so schlank und übersichtlich bleiben.

Header

Zunächst möchte ich mit dem Bauplan, also der Header-Datei des RenderView beginnen. Dazu legen wir auf bekannte Art und Weise eine Header-Datei mit Namen D3DRenderView.h an.


#ifndef _D3DRENDER_VIEW_H_
#define _D3DRENDER_VIEW_H_

#include <D3D11.h>
#include <D3DX11.h>
#include <D3DX10.h>

#pragma comment (lib, "d3d11.lib")
#pragma comment (lib, "d3dx11.lib")
#pragma comment (lib, "d3dx10.lib")

#define SCREEN_WIDTH  800
#define SCREEN_HEIGHT 600

class D3DRenderView
{
public:
	D3DRenderView() {};

	virtual void Initialize(HWND hWnd);
	virtual void Shutdown(void);

	virtual void RenderFrame() = 0;

protected:
	ID3D11Device *device;
	ID3D11DeviceContext *deviceContext;
	IDXGISwapChain *swapchain;
	ID3D11RenderTargetView *backbuffer;
};

#endif

Den Include-Guard werde ich diesmal ohne weitere Erläuterung übergehen, denn den sollten wir mittlerweile kennen. In den Zeilen 4 bis 6 binden wir die notwendigen DirectX-Header ein. Interessant sind dabei die Header, die ein X enthalten. Dies sind die DirectX-Utility-Header und umfassen ein paar interessante Funktionen, die uns das Leben leichter machen. Wir binden diese in Version 10 und 11 ein, denn die meisten der enthaltenen Funktionen in Version 10 sind auch unter Version 11 weiterhin gültig und daher können wir diese verwenden. Wir müssen dies sogar tun, denn in Version 11 sind nur neue Funktionen enthalten, also Teile, die es in Version 10 noch nicht gab.

In den Zeilen 8 bis 10 teilen wir dem Linker mit, dass die entsprechenden Libraries eingebunden werden sollen. Die #pragma once Direktive ist dabei eine Besonderheit des Microsoft-Compilers bzw. Visual Studio. Mit anderen Compilern wird dies in der Regel etwas anders angegeben und zwar als Linker-Argument. Wir schränken uns mit der hier vorgestellten Lösung zwar etwas ein, dafür haben wir es aber ein klein wenig einfacher.

In den Zeilen 12 und 13 definieren wir die Default-Größe für unseren Direct3D-View als Konstanten. Konstanten die mit der Direktive #define definiert werden haben den Vorteil, dass der Preprocessor ein entsprechendes Symbol im Quelltext einfach ersetzt (also eine Textersetzung). Dies bedeutet, dass dies nicht zur Laufzeit (wie bei einer Konstante) geschehen muss, sondern vom Compiler entsprechend optimiert werden kann.

Dann erfolgt die eigentliche Definition der Klasse. Der Konstruktor, sowie drei öffentliche Methoden auf die ich gleich gemeinsam mit der Implementation genauer eingehen werde.

Danach folgt ein neuer Zugriffsmodifizierer, nämlich protected. Dieser besagt, dass alles in diesem Block nur innerhalb der eigenen Klasse verfügbar bzw. zugreifbar ist und in Klassen, die von der aktuellen abgeleitet sind. Haben wir nur einen Zeiger auf die Klasse, so können wir nicht auf die Protected-Elemente zugreifen und diese sind unsichtbar. Da es sich bei den geschützten Variablen lediglich um interne Verweise handelt, ist dies genau das, was wir wollen.

Implementation

Kommen wir nun zur eigentlichen Implementierung. Dazu erzeugen wir auf gewohnte Art und Weise eine neue Quellcode-Datei mit dem Namen D3DRenderView.cpp und binden in der ersten Zeile den zugehörigen Header ein.

Den Konstruktor müssen wir nicht implementieren. Durch die beiden geschweiften Klammern im Header haben wir festgelegt, dass dieser grundsätzlich keinen Code enthalten soll. Das ist hier nicht 100% sauber, aber für den Moment in Ordnung.

Die nächste Methode ist Initialize, die wir wie folgt implementieren.


void D3DRenderView::Initialize(HWND hWnd)
{
	DXGI_SWAP_CHAIN_DESC scd;

	ZeroMemory(&scd, sizeof(DXGI_SWAP_CHAIN_DESC));

	scd.BufferCount = 1;
	scd.BufferDesc.Format = DXGI_FORMAT::DXGI_FORMAT_R8G8B8A8_UNORM;
	scd.BufferDesc.Width = SCREEN_WIDTH;
	scd.BufferDesc.Height = SCREEN_HEIGHT;
	scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
	scd.OutputWindow = hWnd;
	scd.SampleDesc.Count = 4;
	scd.Windowed = TRUE;
	scd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;

	D3D_FEATURE_LEVEL pFeatureLevel;

	D3D11CreateDeviceAndSwapChain(NULL,
		                      D3D_DRIVER_TYPE_HARDWARE,
				      NULL,
				      NULL,
				      NULL,
				      NULL,
				      D3D11_SDK_VERSION,
				      &scd,
				      &swapchain,
				      &device,
				      &pFeatureLevel,
				      &deviceContext);

	// Set the RenderTarget (BackBuffer)

	ID3D11Texture2D *pBackBuffer;
	swapchain->GetBuffer(0, __uuidof(ID3D11Texture2D), (LPVOID*)&pBackBuffer);

	device->CreateRenderTargetView(pBackBuffer, NULL, &backbuffer);
	pBackBuffer->Release();

	deviceContext->OMSetRenderTargets(1, &backbuffer, NULL);

	D3D11_VIEWPORT viewport;
	ZeroMemory(&viewport, sizeof(D3D11_VIEWPORT));

	viewport.TopLeftX = 0;
	viewport.TopLeftY = 0;
	viewport.Width = SCREEN_WIDTH;
	viewport.Height = SCREEN_HEIGHT;

	deviceContext->RSSetViewports(1, &viewport);
}

Das einzige Argument, dass diese Methode erhält ist der HWND. Wie im vorherigen Teil bereits beschrieben ist dies eine eindeutige Kennung für ein Fenster oder ein Control von Windows. Dieses wird an DirectX übergeben um diesem mitzuteilen wohin wir rendern wollen. In diesem Tutorial ist dies ein Fenster, man könnte hier aber auch z.B. ein Panel oder die meisten anderen Controls von Win32 übergeben und verwenden.

Beschreibung der SwapChain

Als erstes brauchen wir eine sogenannte SwapChain. Diese wird benötigt um einen Front- und BackBuffer zu erhalten. Der Hintergrund ist im Glossar ganz gut beschrieben und daher ist diese Definition auch verlinkt. Ähnlich wie bei der Erzeugung einer WindowClass benötigen wir wieder eine Description. Diese wird in DirectX für fast alles benötigt. Eine Description beschreibt was wir haben wollen und wie dies auszusehen hat und ein weiterer Befehl erzeugt dann das Objekt unter Verwendung der Description. So ist dies auch bei der SwapChain und daher deklarieren wir erstmal eine entsprechende Struktur in Zeile 3.

Wie immer müssen wir die Struktur erstmal mit Nullwerten füllen, was in Zeile 5 unter Verwendung des ZeroMemory Makros durchgeführt wird. Den Hintergrund dazu hatte ich ebenfalls bereits im vorherigen Teil dieser Reihe beschrieben und daher gehe ich hier nur auf die einzelnen Werte ein.

BufferCount legt fest wieviele BackBuffer wir in unserer SwapChain haben wollen. Normalerweise ist dies lediglich einer.

Mit den Werten Width, Height und Format des Parameters BufferDesc legen wir die Breite und Höhe, sowie das Format des BackBuffers fest. Der Format-Parameter sieht dabei ziemlich kompliziert aus, ist er aber nicht, wenn man das System dahinter kennt. DXGI_FORMAT ist das Prefix, damit wir wissen, um welche Konstante es sich handelt. Danach kommt R8G8B8A8. Dies bedeutet, dass wir einen Roten, einen Grünen, einen Blauen, sowie einen Alpha-Kanal in unserem BackBuffer haben wollen. Jeder dieser Kanäle soll 8 Bit haben. UNORM gibt den Wertebereich an und steht für Unsigned Normalized, also ohne Vorzeichen und normalisiert. Normalisiert bedeutet, dass der Wertebereich zwischen -1.0 und 1.0 liegen soll. Da wir kein Vorzeichen wollen, verringert sich dieser Bereich auf 0.0 bis 1.0. Dies ist der Standard für den BackBuffer und in den meisten Fällen ausreichend. Es gibt einige Sonderfälle wie z.B. HDR-Rendering in denen detaillierte Farbräume benötigt werden. Daher könnte dort z.B. auch ein R16 oder gar ein R32 stehen. Auch gibt es, z.B. bei Deferred Shading Buffer in denen z.B. nur ein einzelner Kanal mit 32 Bit verwendet wird. Die möglichen Formate sind im Header unter DXGI_FORMAT selbstverständlich vollständig aufgelistet.

Der nächste Parameter ist BufferUsage. Damit wird festgelegt für was die SwapChain verwendet werden soll bzw. wie. Da ein BackBuffer nicht mehr als ein RenderTarget ist, legen wir dort auch fest, dass dieser als DXGI_USAGE_RENDER_TARGET_OUTPUT verwendet werden soll.

Der OutputWindow legt fest wo der BackBuffer angezeigt werden soll, sprich auf welchem Control. Den Wert den wir dort übergeben hatte ich ja eingangs bereits genauer erklärt.

SampleDesc.Count beeinflusst das sogenannte Multi Sampling, dass dem Anti Aliasing dient. Was dies im Detail bedeutet erfahrt ihr in der verlinkten Beschreibung im Glossar. Wir stellen hier einen Wert von 4 ein. Dieser belastet die Performance nicht zu stark, macht die Kanten später aber ein wenig weicher.

Der vorletzte Parameter Windowed legt schlicht und einfach fest, dass wir unser Render-Ergebnis im Fenster-Modus darstellen wollen. Fullscreen erkläre ich später noch genauer.

Mit dem letzten Parameter Flags, der den Wert DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH bekommt, legen wir schlicht und einfach fest, dass die Bildschirmauflösung beim Umschalten auf Fullscreen umgeschaltet werden soll, so dass diese mit der Größe des BackBuffer übereinstimmt.

Erzeugung der SwapChain und des Device

Nun können wir die SwapChain endlich erzeugen, was wir mit der Funktion D3D11CreateDeviceAndSwapChain machen. Dabei wird nicht nur die SwapChain erzeugt, sondern gleichzeitig auch ein Device, mit dem wir im weiteren Verlauf Zugriff auf alle Bereiche von Direct3D11 haben werden. Die genaue Beschreibung aller Parameter spare ich mir hier und habe den Befehl in der MSDN verlinkt, wo dies ausführlichst gemacht wird.

Interessant ist jedoch der Parameter FeatureLevel. Wir übergeben diesem einen leeren Wert. Dies bedeutet, dass das höchstmögliche Feature Level verwendet werden soll. Das erreichte Feature Level wird dann in der Variable pFeatureLevel gespeichert. Was die einzelnen Feature-Level leisten habe ich hier bereits in einem anderen Artikel beschrieben.

BackBuffer

Wir sind aber noch nicht ganz fertig, denn wir benötigen noch Zugriff auf den BackBuffer um mit diesem arbeiten zu können. Der BackBuffer ist eine normale Texture. Um auf eine Texture zuzugreifen benötigen wir in Direct3D einen sogenannten View. Dieser View behandelt auf komfortable Art und Weise MipMapping und Subresourcen und solche Dinge. Was dies genau ist, dass müssen wir jetzt erstmal noch nicht wissen. Zu einem späteren Zeitpunkt werde ich das aber noch ausführlicher beschreiben.

Zunächst benötigen wir also einen Texture-Pointer, den wir in Zeile 34 definieren. Diesen befüllen wir mit der Methode GetBuffer der SwapChain. Zunächst übergeben wir mit dem Operator __uuidof die Art des Buffers, den wir erhalten möchten und dann eine Referenz auf unsere Pointer-Variable vom Typ ID3D11Texture2D, die durch den Funktionsaufruf mit der Adresse der BackBuffer-Texture befüllt wird. Mit dieser Adresse erzeugen wir nun in Zeile 37 unter Verwendung der Methode CreateRenderTargetView den gewünschten View und weisen dessen Adresse der Member-Variable backbuffer zu. In Zeile 38 müssen wir nun noch den durch GetBuffer reservierten Zugriff auf die BackBuffer-Texture freigeben.

OutputMerger

Der OutputMerger ist ein Teil der Grafikpipeline. Was dieser genau macht, darauf werde ich später noch eingehen. Momentan reicht es zu wissen, dass der OutputMerger das Ergebnis, dass er vom Pixel Shader erhält in den BackBuffer schreibt. Das Ziel müssen wir natürlich festlegen, da Direct3D ja ziemlich flexibel ist. Dies geschieht mit dem Befehl OMSetRenderTargets. Dieser bindet unseren BackBuffer an den OutputMerger. Alles was wir nun Rendern erscheint dadurch im BackBuffer.

Viewport

Die letzte Aufgabe zur Initialisierung von Direct3D ist der sogenannte Viewport. Dieser mappt Vertex-Positionen, die im Clip-Space angegeben wurden in den RenderTarget. Was dies im Detail bedeutet werde ich im Rahmen der Erklärungen zum Rendern des ersten Dreiecks noch genauer erläutern. Zunächst reicht es die Viewport-Description zu befüllen, was wir mittlerweile schon kennen sollten (Zeilen 42 bis 48). Im Anschluss daran setzen wir diesen Viewport noch als aktiven.

Damit ist die Initialisierung von DirectX bzw. Direct3D auch schon abgeschlossen.

Aufräumen

Das Aufräumen in der Shutdown-Methode ist zum Glück nicht ganz so kompliziert und aufwendig. Wir müssen dort lediglich alles wieder freigeben, was wir zuvor erzeugt haben. Eine kleine Besonderheit gibt es dabei noch, denn wir müssen, falls wir mit Alt-Enter in den Fullscreen-Modus umgeschaltet haben, diesen wieder abschalten.

Der Code dazu sollte weitestgehend selbsterklärend sein.


void D3DRenderView::Shutdown()
{
	swapchain->SetFullscreenState(FALSE, NULL);

	swapchain->Release();
	backbuffer->Release();
	device->Release();
	deviceContext->Release();
}

Damit haben wir den RenderView vollständig implementiert. Auch hier gab es wieder eine Menge Code und vieles scheint noch sehr aufwändig und kompliziert zu sein, aber das wird sich im weiteren Verlauf der Reihe alles lichten.

Initialisierung des RenderView

Im vorherigen Teil hatte ich ja bereits angedeutet, dass der eigentliche View, der in View.h definiert wurde noch ein wenig angepasst werden muss. Dies werden wir nun vornehmen. Dazu müssen wir den Konstruktor in View.h abändern und zwar wie folgt. Zunächst müssen wir aber den Header unseres neuen D3DRenderView einbinden, damit wir diesen auch im View verwenden können.


View(D3DRenderView* renderView);

Wir wollen also einen Zeiger auf unseren RenderView erhalten. Diesen speichern wir in einer Member-Variable, die wir ebenfalls definieren müssen.


private:
	D3DRenderView* const renderView;

Die Implementierung des Konstruktors in View.cpp müssen wir ebenfalls leicht abändern. Diese sieht nun wie folgt aus:


View::View(D3DRenderView* renderView)
	: renderView(renderView)
{
}

Der Befehl nach dem Doppelpunkt in Zeile zwei ist eine sogenannte Initialisierungsliste. Diese weist Member-Variablen (die als const, also konstant definiert wurden) einen Wert zu, noch bevor der Konstruktor aufgerufen wird. In unserem Fall weisen wir der Member-Variable renderView den Wert renderView zu, der als Parameter an den Konstruktor übergeben wurde. Da diese Member-Variable als konstant definiert wurde, können wir das nicht im Rumpf des Konstruktors machen.

In der Shutdown-Methode rufen wir die Shutdown-Methode des RenderView auf, damit dieser auch aufgeräumt wird.


renderView->Shutdown();

Selbstverständlich müssen wir auch den RenderView initialisieren. Die Methode dazu haben wir ja eben ausführlich besprochen. Dies passiert als letzte Aktion in der Initialize-Methode des Views.


renderView->Initialize(hWnd);

Die letzte Aktion ist der Aufruf der abstrakten Methode RenderFrame des RenderViews. Diese haben wir noch nicht implementiert, was wir aber gleich noch machen werden. Trotzdem muss diese Methode aufgerufen werden. Dazu ändern wir die Run-Methode wie folgt ab.


int View::Run()
{
	MSG msg;

	while(TRUE)
	{
		if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
		{
			TranslateMessage(&msg);
			DispatchMessage(&msg);

			if (msg.message == WM_QUIT)
			{
				break;
			}
		}

		renderView->RenderFrame();
	}

	return msg.wParam;
}

Die einzige Änderung ist dabei in Zeile 18. Wenn wir uns zurückerinnern, dann wissen wir, dass die Message-Loop so oft wie möglich aufgerufen wird. Nach der Behandlung der Windows-Nachrichten rufen wir nun RenderFrame auf. Damit rendern wir also soviele Frames wie die Geschwindigkeit unseres Rechners es hergibt. Dazu aber gleich noch mehr.

Im Grunde genommen sind wir nun fertig. Wir haben die Basis fertiggestellt und DirectX unter Verwendung eines Win32-Fensters initialisiert und können es nun verwenden. Aber da fehlt doch noch was. Ich hatte ja zu Beginn dieses Artikels gesagt, dass wir den Hintergrund blau löschen möchten. Das werden wir nun auch machen und wer neugierig war, der hat sowieso festgestellt, dass es aktuell noch eine Fehlermeldung beim Kompilieren gibt. Dies liegt daran, dass unser RenderView nur abstrakt ist und wir noch eine abgeleitet Klasse mit unserem Render-Code erstellen müssen.

Der GameView

Wir erzeugen nun also unsere letzte Klasse in diesem Artikel und zwar mit dem Namen SampleGameView. Dazu erzeugen wir wieder einen passenden Header. Wie versprochen ist dieser jetzt nicht mehr so kompliziert, da wir die Vorarbeit ja schon mühevoll geleistet haben.


#ifndef _SAMPLEGAMEVIEW_H_
#define _SAMPLEGAMEVIEW_H_

#include "D3DRenderView.h"

class SampleGameView : public D3DRenderView
{
public:
	SampleGameView() {};

	void RenderFrame();
};

#endif

Da wir mittlerweile ja bereits fast Experten sind, stecken da praktisch keine Überraschungen mehr drin. Es gibt jedoch eine kleine Ausnahme und das ist eine kleine Vererbung. In Zeile 6 legen wir fest, dass unsere neue Klasse von D3DRenderView abgeleitet sein soll. Damit kann unser SampleGameView auf alles zugreifen, was in der Basisklasse D3DRenderView entsprechend markiert wurde.

Dort wurde unter anderem eine abstrakte Methode RenderFrame deklariert. Diese müssen wir nun implementieren, damit diese auch in der Message-Loop aufgerufen wird. Dort gehört unser Draw-Code rein und dort können wir dann endlich den Screen löschen und mit einer blauen Farbe einfärben. Dazu erzeugen wir eine Quellcode-Datei.


#include "SampleGameView.h"

void SampleGameView::RenderFrame()
{
	deviceContext->ClearRenderTargetView(backbuffer, D3DXCOLOR(0.0f, 0.2f, 0.4f, 1.0f));

	swapchain->Present(0, 0);
}

Dies ist auch schon der gesamte Code. Der erste Befehl in der RenderFrame-Methode ist dazu gedacht den backbuffer zu leeren und zwar mit der angegebenen Farbe. Der zweite Befehl dient dazu die SwapChain weiterzuschalten. Kein Angst, ich werde darauf auch noch detaillierter eingehen, denn dieser Artikel dürfte langsam schon lang genug sein.

Eine letzte Sache müssen wir nun noch machen und zwar die eben angesprochene Fehlermeldung muss aufgelöst werden. Im Grunde genommen müssen wir jetzt eine Instanz unseres SampleGameView erzeugen und diesen an den View übergeben. Damit ist die Fehlermeldung weg und unser Programm kann alles schön initialisieren und rendern. Später wird automatisch wieder aufgeräumt.

Die letzten Änderungen dieses Tutorials erfolgen in der WinMain-Methode innerhalb von main.cpp. Diese sieht nach der Änderung wie folgt aus.


int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
	int returnValue;
	View* view;
	D3DRenderView* renderView;

	renderView = new SampleGameView();

	view = new View(renderView);
	view->Initialize(hInstance, nCmdShow, WindowProc);
	returnValue = view->Run();
	view->Shutdown();
	delete view;
	
	delete renderView;

	return returnValue;
}

In Zeile 5 schaffen wir Platz für einen D3DRenderView, den wir in Zeile 7 erzeugen. Dort erzeugen wir aber eine Instanz von SampleGameView. Dies funktioniert, da SampleGameView von D3DRenderView abgeleitet ist. Diese Instanz übergeben wir unserem View un Zeile 9. Damit lösen wir die Fehlermeldung auf und ermöglichen es so unserem View Direct3D zu initialisieren und die RenderFrame-Methode unseres SampleGameView aufzurufen.

Selbstverständlich müssen wir den Speicher den wir mit new für D3DRenderView reserviert haben noch freigeben, nachdem wir diesen nicht mehr benötigen. Dies erfolgt in Zeile 15.

Damit das Ganze funktioniert, müssen wir selbstverständlich im Header main.h den Header von SampleGameView einbinden.

Abschluss und Ausblick

Nun sind wir fertig mit diesem Teil der Artikelreihe DirectX 11 Jumpstart und wenn wir F5 drücken, sollte nach der Kompilierung unser Programm starten. Es erscheint ein blaues Bild, was im Grunde genommen nichts besonderes ist. Die blaue Farbe kommt allerdings nicht dadurch zustande, das wir die Hintergrundfarbe des Fensters geändert haben, sondern dadurch, dass wir mit Direct3D ein leeres, blaues Bild rendern.

In den nächsten Teilen dieser Reihe werde ich noch ein wenig detaillierter auf das eingehen, was wir hier gelernt haben und warum die Klassenstruktur so ist, wie sie ist. Mir ist bewusst, dass in diesem Teil ziemlich viele Informationen ziemlich schnell vermittelt wurden und das ich mit Sicherheit den ein oder anderen nun abgehangen habe. Mir war jedoch wichtig, dass ich erstmal ein sichtbares Ergebnis vorweisen kann, damit wir ein gemeinsames Erfolgserlebnis haben. Darauf können wir nun aufbauen und in den nächsten Artikeln unser Wissen festigen. Ich werde dort immer wieder einzelne Bereiche aufgreifen und genauer erklären. Damit wir das Bild von DirectX 11 immer klarer werden und Fortschritte werden sich schnell einstellen.

Navigation
Tutorials und Artikel
Community Project
Werkzeuge