Sunday, June 26, 2011

Content Pipeline Tutorial

In this article we'll be building a content pipeline extension.
The content pipeline is a great system for importing and loading outside art assets into your game. The files are converted into a binary (.xnb) file format at compile time that the XNA framework can then deserialize at run time.
The basic process as illustrated in the diagram goes something like this: the file we wish to load is imported into the content pipeline and the importer returns an some object we can work with like a string or an XmlDocument, then the imported type is passed off to the content processor which turns it into an object of the desired type. For example, a .png image is imported and then some doubtlessly complicated logic goes through the data within the file and translates it to a Texture2DContent object (which is turned into a Texture2D object at run time). The object is then passed off to the Content Type Writer which serializes it into the binary .xnb format. All of this happens during compile-time. Then finally at run-time the Content Reader deserializes the .xnb file to our loaded type within the Load method call.

First thing's first. Let's create and set up the projects we'll need. We'll need a total of three projects: An XNA Game project, a Game Library project to hold the types we'll be loading in as well as our content readers, and a Content Pipeline Extension library which will have the importer, processor and writer. Go ahead and create a new solution and then add the other projects.
That finished we now need to add the required references. In the Game project add a reference to the Game Library project. The Content Pipeline Extension project also needs a reference to the Game Library project so it can hold references to the types we'll be loading. Lastly the Content project where you'll have your asset files needs a reference to the Content Pipeline Extension library.
Now let's make our type to be loaded in. We'll make a simple tile map class that will display a grid of textures.
We'll be using the .xml format to import and process our assets. This is mostly to make everything nice and easy since xml is so nice to work with but we could import the data in any form we like.
XNA does already have an importer for the xml format which is very nice and flexible but one can have the control over how data is processed.

The following is the code for our TileMap class which is to go into our Game Library project (I named mine ContentPipelineGameLibrary). It's a very simple class. It uses a single sprite sheet for the tile textures. This is for ease in the serialization and deserialization. I'm using this one with two textures: grass and dirt.
Go ahead and add this code,

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework;

namespace ContentPipelineTutorialGameLibrary
{
    public class TileMap
    {
        string _assetName = "";
        Texture2D _spriteSheet;
        int[,] _textureGrid;
        int _tileWidth = 32;
        int _tileHeight = 32;
        int _tilesPerRow =2;

        Rectangle _drawRectangle = new Rectangle();
        Rectangle _sourceRectangle = new Rectangle();

        public int TilesPerRow
        {
            get { return _tilesPerRow; }
            set { _tilesPerRow = value; }
        }
        public int MapWidth
        {
            get { return _textureGrid.GetLength(1); }
        }
        public int MapHeight
        {
            get { return _textureGrid.GetLength(0); }
        }
        public string AssetName
        {
            get { return _assetName; }
            set { _assetName = value; }
        }
    
        public TileMap(int width, int height)
        {
            _textureGrid = new int[height, width];
            _drawRectangle.Width = _sourceRectangle.Width = _tileWidth;
            _drawRectangle.Height = _sourceRectangle.Height = _tileHeight;
        }

        public void Draw(SpriteBatch spriteBatch)
        {
            for (int x = 0; x < _textureGrid.GetLength(1); x++)
            {
                for (int y = 0; y < _textureGrid.GetLength(0); y++)
                {
                    _drawRectangle.X = x * _tileWidth;
                    _drawRectangle.Y = y * _tileHeight;
                    _sourceRectangle.X =
                        (_textureGrid[y, x] % _tilesPerRow) * _tileWidth;
                    _sourceRectangle.Y =
                        (_textureGrid[y, x] / _tilesPerRow) * _tileHeight;
                    spriteBatch.Draw(
                        _spriteSheet,
                        _drawRectangle,
                        _sourceRectangle,
                        Color.White);
                }
            }
        }
        public void SetTextures(Texture2D spriteSheet, string assetName)
        {
            _spriteSheet = spriteSheet;
        }
        public int GetIndex(int x, int y)
        {
            return _textureGrid[y, x];
        }
        public void SetIndex(int x, int y, int value)
        {
            _textureGrid[y, x] = value;
        }
    }
}

Pretty straight forward stuff. The _tilePerRow field is so we don't have to limit ourselves too much on how we format our sprite sheets. The AssetName setter could prove problematic later on if we want to switch out sprite sheets during run-time but we can tweak things later.

Before we get to any content extension classes, let's set up how we're going to format the data in our file. Like I mentioned before I went with xml since it's so easy to work with but you can just as easily use almost anything (using classes that already have an importer/processor is tricky and I don't recommend it).
Right click on your content project. Select Add -> New Item... and select XML Document in the dialogue. Name it whatever you want, I named mine "TileMap01". The file is automatically given a .xml extension so right click on it and select Rename then change the extension to whatever (I chose ".tilemap"). You will get a warning but pay no heed; we got this.
Now double click the newly created file, delete whatever's inside and add this:

<TileMap Width="4" Height="4">
  <Texture AssetName="TileSheet"/>
  <Layout>
    0 0 0 1
    0 1 0 1
    0 0 0 0
    1 0 1 1
  </Layout>
</TileMap>

Now on to the importer. Right click on your Content Extension Library and select Add -> New Item..., then select Content Importer.
Now replace the automatically generated code with this,

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
using System.Xml;

namespace ContentPipelineTutorialExtensionLibrary
{   
    [ContentImporter(".tilemap", DisplayName = "TileMap Importer", 
        DefaultProcessor = "TileMapProcessor")]
    public class TileMapImporter : ContentImporter<XmlDocument>
    {
        public override XmlDocument Import(
            string filename, ContentImporterContext context)
        {
            XmlDocument document = new XmlDocument();
            document.Load(filename);
            return document;
        }
    }
}

Done. All xml Importers will start this way. Make sure the attribute that declares the extension matches yours.
Now add another new item to the Content Extension Library project, select Content Processor and replace the code with this,

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline.Processors;
using System.Xml;
using ContentPipelineTutorialGameLibrary;
using System.IO;

namespace ContentPipelineTutorialExtensionLibrary
{
    [ContentProcessor(DisplayName = "TileMap Processor")]
    public class TileMapProcessor : ContentProcessor<XmlDocument, TileMap>
    {
        public override TileMap Process(
            XmlDocument input, ContentProcessorContext context)
        {
            
            XmlNodeList inputNodeList = input.GetElementsByTagName("TileMap");
            TileMap output = new TileMap(
                int.Parse(inputNodeList[0].Attributes["Width"].Value),
                int.Parse(inputNodeList[0].Attributes["Height"].Value));
            XmlNodeList nodeList = inputNodeList[0].ChildNodes;

            string[] layout;
            string[] rowEntries;

            foreach (XmlNode node in nodeList)
            {
                if (node.Name == "Layout")
                {
                    layout = node.InnerText.Trim().Split('\n');
                    for (int row = 0; row < layout.Length; row++)
                    {
                        rowEntries = layout[row].Trim().Split(' ');
                        for (int rowEntry = 0; rowEntry < rowEntries.Length; rowEntry++)
                        {
                            output.SetIndex(
                                rowEntry, row, int.Parse(rowEntries[rowEntry]));
                        }
                    }
                }
                else if (node.Name == "Texture")
                {
                    output.AssetName = node.Attributes["AssetName"].Value;
                }
            }
            
            return output;
        }
    }
}

So what we're doing here is reading through our xml, setting the texture asset name and setting the map's grid index. Notice we're NOT setting the texture yet. This is because the Texture2D class is not built during compile-time, it's a run-time object so we'll be setting the actual texture in the Content Reader which is called at run-time (when content.Load is called).
That's it for processing our data. Now let's make a Content Writer for the binary serialization.
Like before, add a new item and select Content Writer. The code:


using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline.Processors;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler;
using ContentPipelineTutorialGameLibrary;

namespace ContentPipelineTutorialExtensionLibrary
{
    [ContentTypeWriter]
    public class TileMapWriter : ContentTypeWriter<TileMap>
    {
        protected override void Write(ContentWriter output, TileMap value)
        {
            output.Write(value.MapWidth);
            output.Write(value.MapHeight);
            output.Write(value.AssetName);
            for (int x = 0; x < value.MapWidth; x++)
            {
                for (int y = 0; y < value.MapHeight; y++)
                {
                    output.Write(value.GetIndex(x, y));
                }
            }
        }

        public override string GetRuntimeReader(TargetPlatform targetPlatform)
        {
            return 
                "ContentPipelineTutorialGameLibrary.TileMapReader, ContentPipelineTutorialGameLibrary";
        }
    }
}

In the GetRuntimeReader method, we have the name of our Content Type Reader which we haven't created yet but will be located in the Game Library as opposed to the Content Pipeline Extension library.
And like before, make sure the names match yours (or, rather, what you will name your Content Reader).


Now we need to actually make the reader. Add a new item to the Game Library and select Content Type Reader.
The code:

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;

namespace ContentPipelineTutorialGameLibrary
{
    public class TileMapReader : ContentTypeReader<TileMap>
    {
        protected override TileMap Read(ContentReader input, TileMap existingInstance)
        {
            TileMap tileMap = new TileMap(input.ReadInt32(), input.ReadInt32());
            string assetName=input.ReadString();
            tileMap.SetTextures(
                input.ContentManager.Load<Texture2D>(assetName), assetName);
            for (int x = 0; x < tileMap.MapWidth; x++)
            {
                for (int y = 0; y < tileMap.MapHeight; y++)
                {
                    tileMap.SetIndex(x, y, input.ReadInt32());
                }
            }
            return tileMap;
        }
    }
}

This is where we set the texture by from the deserialized asset name.
This also might be a good time to explore the functions available in both the reader and writer. There's pretty much a method for serializing anything you want.
We're not quite done yet. In your content project, left click the tile map file and in the Properties window under Build Action select Compile. Then, under Content Importer and Content Processor select the TileMap Importer and TileMap Processor, respectively.
If they don't appear in the drop-down, try compiling the solution and check again.

Let's load the map and draw it!
Change your Game1.cs class to look like this:

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.GamerServices;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Media;
using ContentPipelineTutorialGameLibrary;

namespace ContentPipelineTutorial
{
    public class Game1 : Microsoft.Xna.Framework.Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;
        TileMap tileMap;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }

        protected override void Initialize()
        {        
            base.Initialize();
        }   
        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            tileMap = Content.Load<TileMap>("TileMap01");
        }      
        protected override void UnloadContent()
        {
      
        }   
        protected override void Update(GameTime gameTime)
        {          
            base.Update(gameTime);
        }
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            spriteBatch.Begin();
            tileMap.Draw(spriteBatch);
            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}

Now run the solution and you should see something like this:


Okay, so that's pretty small so feel free to change things around a bit :)
That's it for this tutorial. I am happy to answer any questions regarding this tutorial as best I can.

source code

2 comments :

  1. Hi,I have a question that I hope you could help me,
    how can i modify an xnb file during run time ?

    ReplyDelete