Die Content-Pipeline: Grundlagen

Die Content-Pipeline ist eines der interessantesten Dinge, die das XNA-Framework zu bieten hat. Leider wird diese oft missverstanden oder sie soll für etwas eingesetzt werden, für das sie nicht gedacht ist und wird daher verteufelt. Genau diese Unwissenden glauben oft, daß XNA die Content-Pipeline voraussetzt. Dem ist aber nicht so. Die Content-Pipeline ist vollkommen optional, stellt aber wenn man sie verwendet, eine ziemlich große Hilfe dar.

In diesem Beitrag möchte ich erklären, was die Content-Pipeline ist und vor allem, was sie nicht ist. Ich erkläre, wofür sie da ist und wozu die einzelnen Bestandteile verwendet werden, denn wenn man sich einmal in dieses System eingearbeitet hat, dann ist es zukünftig sehr leicht, damit umzugehen.

Die Content-Pipeline ist eine Möglichkeit, Assets wie Sounds, Texturen und Modelle in einem Spiel zu laden. Sie ist vollkommen optional, erleichtert aber vieles, da sie theoretisch mit allen Arten von Assets umgehen kann und diese im Spiel später alle auf gleiche Art und Weise verwendet werden können.

Die Content-Pipeline besteht aus mehreren Teilen, die alle eine bestimmte Aufgabe haben. Es ist wichtig, jedes dieser Teile zu verstehen und damit umgehen zu können, denn wenn eines dieser Bauteile der Content-Pipeline fehlt, dann funktioniert das gesamte System nicht. Wichtig ist die Unterscheidung, ob das jeweilige Teil zur Laufzeit des Spiels oder zur Kompilierzeit ausgeführt wird, da dies ein entscheidender Unterschied ist. Zur Kompilierzeit werden die Assets in den unterschiedlichsten Formaten nämlich in ein einheitliches Format überführt und anschließend unter Umständen transformiert bzw. optimiert, sowie komprimiert. Das einheitliche Format kann dann später zur Laufzeit schnell und einfach geladen und verwendet werden.

Das Schöne an der Content-Pipeline ist, daß es bereits eine ganze Menge an vorgefertigten Komponenten gibt. So können viele Grafikformate (JPEG, PNG, BMP etc.), aber auch 3D-Modelle und Sounds problemlos importiert und verarbeitet werden. Diese Komponenten können alle erweitert werden. So muss man nicht jedes mal bei null anfangen.

ContentImporter

Der ContentImporter lädt eine Quelldatei in den Arbeitsspeicher. Der Default ContentImporter, wie er von VisualStudio erzeugt wird, wenn man mittels Hinzufügen/Neues Element einen Neuen hinzufügt sieht wie folgt aus (Kommentare entfernt):

using TImport = System.String;
namespace DerivedEffects
{
    [ContentImporter(".abc", DisplayName = "ABC Importer", DefaultProcessor = "AbcProcessor")]
    public class ContentImporter1 : ContentImporter<TImport>
    {
        public override TImport Import(string filename, ContentImporterContext context)
        {
           return "InMemory-Objekt";
        }
    }
}

Das ist nicht viel, aber trotzdem ziemlich mächtig. Mit Zeile 1 wird festgelegt, welches Format das InMemory-Objekt haben soll. Dieses Objekt ist eine Repräsentation eures Assets und dieses muss von euch selbst erstellt werden oder aber ihr verwendet ein bestehendes Objekt. In diesem Fall wird ein String erzeugt.

In Zeile 4 werden ein paar grundsätzliche Einstellungen vorgenommen. Es wird eine Dateiendung festgelegt. In diesem Fall würde der ContentImporter für alle Dateien mit der Endung .abc verwendet werden. DisplayName legt den Namen fest, der in VisualStudio angezeigt werden soll und der DefaultProcessor gibt vor, welcher ContentProcessor als Standard für diese Dateiart verwendet werden soll.

Ab Zeile 10 müssen wir uns nun darum kümmern, daß unser Asset, daß sich derzeit noch auf der Festplatte befindet in den Speicher geladen wird. Dazu stehen uns das gesamte .NET-Framework zur Verfügung. Da der ContentImporter ausschließlich auf dem PC laufen wird, können wir hier auch alle 3rd-Party-Libraries verwenden.

An dieser Stelle könnte man z.B. eine XML-Datei laden und durch eine einfache Klasse mit entsprechenden Eigenschaften darstellen. Die ContentPipeline stellt uns dafür den Dateinamen (inkl. Pfad) zur Verfügung. Wichtig ist nur, daß am Ende der Methode das bereits angesprochene InMemory-Objekt zurückgegeben wird. Im obigen Beispiel erzeuge ich zu Testzwecken einfach mal einen String, den wir zurückgeben können.

ContentProcessor

Der ContentProcessor ist optional, er muss also nicht zwangsweise verwendet werden. Wozu er da ist, ist auch relativ schnell erklärt. Der ContentProcessor erhält als Eingabe unser InMemory-Objekt aus dem vorherigen Schritt. Der Processor kann nun unser Objekt verarbeiten oder auch transformieren. Er ist in der Lage ein neues Objekt zurückzuliefern oder aber auch das Gleiche mit veränderten Eigenschaften.

Hier ein kleines Beispiel:

using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline.Processors;

using TInput = Microsoft.Xna.Framework.Content.Pipeline.Graphics.EffectContent;
using TOutput = MyContentPipeline.DerivedEffect;

namespace MyContentPipeline.Pipeline
{
    [ContentProcessor(DisplayName = "Effect - Derived Effects")]
    public class DerivedEffectProcessor : ContentProcessor<TInput, TOutput>
    {
        public override TOutput Process(TInput input, ContentProcessorContext context)
        {
            EffectProcessor compiler = new EffectProcessor();
            CompiledEffect compiledContent = compiler.Process(input, context);

            return new TOutput(compiledContent.GetEffectCode());
        }
    }
}

Dieser kleine ContentProcessor nimmt ein InMemory-Objekt vom Type EffectContent entgegen. Dies ist eine Klasse, die vom XNA-Framework bereitgestellt wird und vom ContentImporter für .fx-Dateien (Effect-File mit HLSL-Shader) verwendet wird.

Ich erzeuge daraus einfach einen CompiledEffect, der von der Grafikkarte verwendet werden kann.

Kurz zum Sinn dieser Übung: Für die Erzeugung von eigenen Effekt-Klassen in der Art von BasicEffect benötige ich den Byte-Code eines kompilierten Shaders. Der normale Effect-Typ des XNA-Frameworks liefert diese Daten aber nicht, daher erzeuge ich hier meinen eigenen Effect-Typ DerivedEffect der diese Eigenschaft zur Verfügung stellt und ansonsten ganz einfach von Effect abgeleitet wurde. Dieser DerivedEffect wird jedenfalls vom Processor zurückgegeben und kann dann weiter von der ContentPipeline verarbeitet werden.

Ein weiteres Beispiel für den ContentProcessor ist z.B. die Skalierung von Texturen, die Erzeugung von MipMaps, das Berechnen von Tangenten, die Vereinfachung von Meshes und so weiter.

ContentWriter

Die letzte Stufe der ContentPipeline, die zur Kompilierzeit auf dem PC des Entwicklers abläuft ist der ContentWriter. Dieser ist dafür zuständig, daß das InMemory-Objekt, daß vom ContentImporter oder dem ContentProcessor geliefert wird als .XNB-Datei (XNA Binary File) auf die Festplatte geschrieben wird. Diese Dateien können (und werden im Release-Build) von der ContentPipeline komprimiert werden und später wieder geladen werden.

Der ContentWriter ist ebenfalls sehr einfach. Uns wird bereits eine einfache Möglichkeit zum schreiben von binären Daten bereitgestellt. Ein kleines Beispiel, der den vorherigen DerivedEffect schreibt:

    [ContentTypeWriter]
    public class DerivedEffectWriter : ContentTypeWriter<TWrite>
    {
        protected override void Write(ContentWriter output, TWrite value)
        {
            output.Write(value.CompiledCode.Length);
            output.Write(value.CompiledCode);
        }

        public override string GetRuntimeReader(TargetPlatform targetPlatform)
        {
            return typeof(DerivedEffectReader).AssemblyQualifiedName;
        }
    }

Hier wird einfach in Zeile 06 und 07 die Anzahl der Bytes, sowie das Byte-Array des kompilierten Effekts in die .XNB-Datei geschrieben.

Die Methode GetRuntimeReader legt für diese Datei nun fest, welcher ContentReader verwendet werden muss, um diese Datei wieder zu laden.

ContentReader

Der letzte Teil der ContentPipeline ist der ContentReader. Dieser liest eine zuvor geschriebene .XNB-Datei vom Speichermedium und erzeugt daraus wieder ein InMemory-Objekt. Hier wieder ein Beispiel anhand des DerivedEffect:

using TRead = DerivedEffects.DerivedEffect;

namespace DerivedEffects
{
    public class DerivedEffectReader : ContentTypeReader<TRead>
    {
        protected override TRead Read(ContentReader input, TRead existingInstance)
        {
            int size = input.ReadInt32();
            byte[] code = input.ReadBytes(size);

            return new TRead(code);
        }
    }
}

Auch hier ist nicht viel zu tun und der Reader ist ähnlich leicht, wie der ContentWriter. Wir lesen einfach die geschriebenen Daten in der gleichen Reihenfolge. In diesem Fall die Anzahl der Bytes und das Byte-Array mit entsprechender Größe. Daraus erzeugen wir wieder einen DerivedEffect, den wir zurückgeben.

Das wichtige ist, daß dieser ContentReader auf allen Plattformen verfügbar sein muss, für die das Spiel später existieren soll. Dies ist der einzige Teil der ContentPipeline, die nicht ausschließlich auf dem Entwickler-PC ausgeführt wird.

Aufteilung auf Assemblies

Eine weitere, wenn auch kleine Herausforderung ist die Aufteilung auf unterschiedliche Projektdateien bzw. Assemblies. Dies ist wichtig, da ja nicht alle Teile mit dem fertigen Spiel ausgeliefert werden müssen (und auch nicht dürfen). Bewährt hat sich die Aufteilung in folgende Teile (diese Teile können alle über VisualStudio über Neues Projekt erzeugt werden):

Content-Pipeline-Extension-Library

Diese läuft ausschließlich auf dem Entwickler-PC und enthält den ContentProcessor und den ContentWriter. Diese Assembly muss nur für den PC erzeugt werden und hat einen Verweis auf die PC-Version der Game Library.

Game Library

Die Game Library wird für alle Plattformen erzeugt, für die das Spiel später erscheinen soll. Die Game Library wird sowohl von der Content-Pipeline-Extension-Library, als auch vom Spiel als Referenz verwendet. Diese enthält die InMemory-Objekte, sowie den ContentReader.

Game Projekt

Das eigentlich Spiel. Dieses hat einen Verweis auf die Game Library, damit die InMemory-Objekte, sowie die ContentReader bekannt sind. Diese werden vom ContentManager verwendet, um Instanzen der Assets zu erzeugen.

Das Content-Projekt, daß dem Game Projekt zugeordnet ist, enthält eigene Verweise. In diesen Verweisen muss die Content-Pipeline-Extension-Library referenziert werden. Dies ist notwendig, damit zur Kompilierzeit auf dem Rechner des Entwicklers die Quelldateien der Assets in .XNB-Dateien übersetzt werden können.

Zusammenfassung

In diesem Grundlagen-Beitrag habe ich erklärt, aus welchen Teilen die ContentPipeline von XNA besteht und ich habe anhand kleiner Beispiele aufgezeigt wie man diese erweitern kann. Danach habe ich kurz beschrieben, welche Assemblies und Projekte verwendet werden sollten, um eine funktionierendes System zu erhalten.

Was noch fehlt ist der Sinn und Zweck des Ganzen, denn der hat sich sicherlich noch nicht jedem erschlossen.

Die ContentPipeline überführt ein Objekt eines beliebigen Typs und beliebiger Komplexität in ein Format, daß so aufgebaut ist, daß es von einem Spiel

  • schnell gelesen werden kann
  • wenig Speicherplatz verbraucht
  • nicht mehr vorbereitet werden muss
  • nur einfache Routinen zum laden benötigt
  • sich nahtlos in den ContentManager einfügt
  • Transformationen und Optimierungen nicht zur Laufzeit durchgeführt werden müssen

Die ContentPipeline ist nicht dafür gedacht in einer Editor-Anwendung Assets dynamisch zu laden und wieder zu entladen. Dafür erzeugt die ContentPipeline unnötigen Overhead. Um dieses Ziel zu erreichen, sollten dem InMemory-Objekt (De-)Serialisierungsmethoden mitgegeben werden, die es ermöglichen, daß die Asset-Daten in einem Editorfreundlichen Format gespeichert werden können. Diese Methoden können später verwendet werden, um im ContentImporter die Daten zu lesen und werden so nicht unnötig erzeugt.

Ein Beispiel hierfür ist das Particle-System der starLiGHT.Engine. Partikeleffekte werden dort von einem Editor in ein einfaches XML-Format gespeichert. Dieses kann durchaus einige Kilobyte groß werden, da dort im Klartext alle Parameter eines Partikeleffekts gespeichert werden. Es ist somit auch einfach in einem Texteditor zu bearbeiten.

Der ContentImporter ParticleSystemContentImporter verwendet nun die Lade-Methode es Editors um ein Instanz vom Typ ParticleSystem zu erzeugen. Dieses wird dann von einem ParticleSystemContentProcessor optimiert (teilweise können mehrere Modifikatoren für Partikel zusammengefasst werden und somit schneller ausgeführt werden) und es werden für unterschiedliche Plattformen bestimmte Bedingungen geschaffen (Grenzwerte, Vorberechnungen etc.). Das Ganze wird dann von einem ParticleSystemContentWriter in eine binäre .XNB-Datei geschrieben. Diese hat in der Regel weniger als 1KB Größe und ist damit um ein vielfaches kleiner als die XML-Repräsentation. Der zugehörige ParticleSystemContentReader erzeugt dann, aufgerufen durch den ContentManager, zur Laufzeit des Spiels schnell und optimiert eine Instanz des Typs ParticleSystem.

Advertisements

Veröffentlicht am 02.02.2011 in Content-Pipeline, Grundlagen, Mini-Tutorial, XNA und mit , , , , , getaggt. Setze ein Lesezeichen auf den Permalink. 11 Kommentare.

  1. Man sollte evtl. noch erwähnen, das der Typ aus dem der Writer schreibt nicht der gleiche sein muss in den der Reader liest 😉
    So kann man innerhalb des Buildprocesses einen Typ verwenden, der nur die Daten beinhaltet.

    • Du willst damit sagen, daß der Reader nicht zwangsweise alle Daten lesen muss, die der Writer geschrieben hat und daß der Reader durchaus einen anderen Typ zurückgeben kann, als der Typ welcher an den Writer übergeben wurde? Möchte nur noch mal nachfragen, damit ich dich nicht falsch verstehe 🙂

  2. Hi,
    sehr guter und informativer Artikel. Das einzige was mir aufgefallen ist, ist dass du im zweiten Absatz nach dem ContentImporter die Zeile 4 und nicht 5 meintest.

    Eine Frage habe ich noch. Du hast mal bei xnamag irgendwo geschrieben, dass die near plane 0 sein sollte und die far plane 1, da float ja zwischen 0 und 1 am genausten ist. Man könnte ja alle Objekte mit einem ContentProcessor so skalieren, dass sie da hinein passen, oder?

    • Danke für den Hinweis… Ist beim formatieren vermutlich irgendwie verschoben worden… Ich habe das natürlich sofort korrigiert.

      Ja, das wäre auch ein gutes Einsatzgebiet für einen ContentProcessor.

  3. Vielen Dank für diesen Artikel.
    Hat mir soeben geholfen, die Aufteilung der unterschiedlichen Assemblys zu verstehen.

    Gruß Kai

    • Bitte sehr, gern geschehen. Es freut mich sehr, dass ich dir damit helfen konnte und es freut mich auch, dass du ein Dankeschön hinterlassen hast.

  4. Gern geschehen.
    Viele vergessen leider immer wieder Feedback zu hinterlassen, finden es aber selbstverständlich, dass Leute wie du sich die Arbeit machen, solche Blogs zu führen. Ich finde ein kurzes Feedback gibt dem Autor Motivation weiter zu machen, und zeigt diesem, das es auch Leute da draußen gibt, die sich diese Artikel wirklich durchlesen, und sie zu schätzen wissen. Deshalb nochmal danke dafür, und danke muss in dem Fall wirklich nur ich sagen. 🙂

    Gruß Kai

    • Damit hast du vollkommen Recht, sehr weise gesprochen.

      Die Artikel zu erarbeiten kostet immer einige Stunden Arbeit. Nicht nur für das Schreiben und die Erstellung von Illustrationen, sondern auch für die Recherche und die Aufarbeitung des Themas. Trotzdem ist es natürlich schön, wenn man sein Wissen weitergeben kann und anderen helfen kann. Und der einzige Lohn für die Mühe ist der Dank von Leuten, denen geholfen wurde…

  1. Pingback: Terrain 101: Die Entwicklungsumgebung und das XNA 4.0 Projekt « "Mit ohne Haare"

  2. Pingback: „ContentPipeline Overdrive“ « "Mit ohne Haare"

  3. Pingback: XNA und Blender: SimpleCube « "Mit ohne Haare"

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s

%d Bloggern gefällt das: