Friday, April 29, 2011

XNA content pipeline: from asset to instance

Download the code for this example here.

I haven't gotten to do much new game work lately, so I thought I'd document some experiences I had several months ago when trying to use the XNA content pipeline.  It took me a while to figure this stuff out from the various blog posts and document pages out there, so maybe it will be of use to someone else.

The XNA pipeline has quite a bit of pre-baked functionality, such as creating textures from images, importing models, and compiling effects.  It also provides a great framework for custom behavior, which is what we're going to be doing here.

Inspiration

Q Games is making some really cool stuff in my favorite city in the world: Kyoto.  I was inspired by the game Shooter, published under their PixelJunk label, which performs extensive 2D fluid simulation on the PS3.  I did some fluid sim stuff back in college that was always a blast to work on, and I really miss getting to play around with the physically-based side of computer graphics.  The wow factor is huge, and Q Games found a great way to make a fun game out of it.  Jaymin Kessler did a great GDC presentation last year about it, called Go With the Flow! Fluid and Particle Physics in PixelJunk Shooter.

After seeing that presentation, I wanted to start messing around with ideas for a "fluid" tower defense.  I thought it might be cool to do something like "Defend Pompeii!" set in an alternate reality where Vesuvius is erupting and you have to use freeze ray towers to solidify the lava coming down the mountain before it gets to the town.  The solidified lava could form barriers that slow the flows down even further and give you time to use other types of towers.  An alternative theme could be a "ball pit" factory gone haywire, and you have to stop the wave of plastic balls before it hits the city.  It might be some pretty sweet eye-candy if you did a particle-based fluid simulation where the particles are different colors, to represent the balls.

I started experimenting using PhysX, which I'd never used before.  I'm using an open-source managed wrapper for PhysX, called PhysX.Net.  PhysX, like other NVIDIA products I've used, is an odd beast. The product seems solid, even if the API feels a little over-engineered.  However, the developer website is atrocious, and documentation is spotty at best.  You can find amazing videos of demos, but no actual code of the demos themselves, which seems to defeat the purpose.  I'm not sure where the PhysX community "hangs out," but I must have been looking in the wrong places; either way, I don't like programming in a vacuum.  I didn't get very far into prototyping before I was defeated by performance, or rather, lack thereof.  But, I did get to play with the content pipeline, so it wasn't a complete loss!

Level Importing

I wanted to begin with fluids interacting with a terrain.  This meant I'd need to create a heightfield in PhysX, and a heightfield for display.  I wanted to describe levels in XML, so I could easily create a level editor tool and eliminate the need to have assets and parameters hard-coded.  I also wanted to compile assets to binary format, as opposed to using the XML to read in assets at run time.

XNA provides a standard importer for reading XML.  However, it's up to you to specify what kind of assets you're describing in the XML for the importer to fill in using the XML elements.  To describe the heightfield, I decided to use image files.  Thus, the XML is an array of file names.  I created a class which describes the contents of the XML for the importer:

public class LevelFileFormat
{
    public string HeightFieldImageFileName { get; set; }
}

The XML describes an array of these LevelFileFormat objects inside an Asset node:

<XnaContent>
  <Asset Type="Antelope.Pipeline.LevelFileFormat[]">
    <Item>
      <HeightFieldImageFileName>HeightFieldImage.Easy.bmp</HeightFieldImageFileName>
    </Item>
    <Item>
      <HeightFieldImageFileName>HeightFieldImage.Medium.bmp</HeightFieldImageFileName>
    </Item>
  </Asset>
</XnaContent>

Level Processing

In the XNA content pipeline, you begin with an importer, which reads the assets you include in the project.  Next, the imported assets are processed using a (what else?) processor, which creates the binary content files from the imported objects.  These binary files are read at runtime to load content.

After creating a basic description of levels as an array of LevelFileFormat objects, I created a custom LevelsProcessor, to generate content from the imported XML.  All custom processors in XNA inherit from ContentProcessor, which is a generic class that specifies the input asset and outputs the processed content to be written.  Additionally, all processors must be marked with the ContentProcessorAttribute.

[ContentProcessor(DisplayName = "Levels Processor")]
class LevelsProcessor : ContentProcessor<LevelFileFormat[], LevelContent[]>

Additionally, a DisplayName can be specified, which shows up under the Content Processor entry for the property grid of any asset in the content project:



I wanted to translate the input heightfield filename into content which could be used to create the heightfield for PhysX as a HeightFieldShapeDescription.  I also wanted to produce an object which XNA could display on screen.  Each of these objects is used for completely different purposes, and requires different information:
  • To create an XNA model, you need a triangle vertex list.  XNA provides a class to describe a model's content, called ModelContent.  
  • To create a PhysX HeightFieldShapeDescription, you must first create a HeightFieldDescription, for which you need the number of rows and columns in the heightfield, as well as a list of the height values.  I created a class to contain this information, called HeightFieldDescriptionContent.

public class HeightFieldDescriptionContent
{
    public HeightFieldDescriptionContent()
    {
        HeightFieldSamples = new Collection<HeightFieldSample>();
    }

    public int Rows { get; set; }
    public int Columns { get; set; }
    public Collection<HeightFieldSample> HeightFieldSamples { get; private set; }
}

LevelsProcessor takes an array of LevelFileFormat as input, and generates an array of LevelContent as output.  LevelContent contains HeightFieldContent, which is just a wrapper for the two types of heightfield content:

class HeightFieldContent
{
    public HeightFieldDescriptionContent HeightFieldDescription { get; set; }
    public ModelContent HeightFieldModel { get; set; }
}

Heightfield Processing

HeightFieldDescriptionContent and ModelContent each require their own processors.  Their processing is kicked off by the LevelProcessor, using the ContentProcessorContext that is passed in to each custom ContentProcessor.

First, I create an ExternalReference to the heightfield image file of type Texture2DContent, which XNA uses to represent a 2D texture.  The image files are not actually included in the content project, although I did add them to the content project's folder to make the relative paths the same.  I use the context to take the input Texture2DContent and create the output content, specifying the custom processors as a string:

var file = new ExternalReference<Texture2DContent>(filename);
var heightFieldDescription = context.BuildAndLoadAsset<Texture2DContent, HeightFieldDescriptionContent>(file, "HeightFieldDescriptionProcessor");
var heightFieldModel = context.BuildAndLoadAsset<Texture2DContent, ModelContent>(file, "HeightFieldModelProcessor");

The HeightFieldModelProcessor comes from the Microsoft example of generating a heightfield model using an image as a source for geometry, so I won't cover the details of that processor.  Suffice it to say, it generates a ModelContent object.

The HeightFieldDescriptionProcessor is simpler.  It gets the width and height of the heightfield image, and sets the content columns and rows.  Then it goes through each pixel of the image, reads the pixel value, and uses that as the height of the sample.

Content Writing

XNA already knows how to serialize a ModelContent object.  However, the HeightFieldDescriptionContent must be serialized using a custom ContentTypeWriter.  All custom type writers must apply the ContentTypeWriterAttribute.  Values to be serialized are written using the ContentWriter instance passed into the type writer:

[ContentTypeWriter]
class HeightFieldDescriptionContentWriter : ContentTypeWriter<HeightFieldDescriptionContent>
{
    protected override void Write(ContentWriter output, HeightFieldDescriptionContent value)
    {
        output.Write(value.Columns);
        output.Write(value.Rows);

        WriteHeightFieldSamples(output, value.HeightFieldSamples);
    }

    private void WriteHeightFieldSamples(ContentWriter output, Collection<HeightFieldSample> collection)
    {
        foreach (var sample in collection)
        {
            output.Write(sample.Height);
        }
    }
}

Content Loading

Any content that is serialized should be marked with the ContentSerializerRuntimeTypeAttribute, which can be used to specify what assembly and type the content corresponds to when loaded at runtime:

[ContentSerializerRuntimeType("Antelope.Logic.HeightField,Antelope.Logic")]
class HeightFieldContent
{
    public HeightFieldDescriptionContent HeightFieldDescription { get; set; }
    public ModelContent HeightFieldModel { get; set; }
}

The HeightFieldContent content type corresponds to runtime type HeightField, which contains a PhysX HeightFieldShapeDescription and a Model:

public class HeightField
{
    public HeightFieldShapeDescription HeightFieldShapeDescription { get; set; }
    public Model HeightFieldModel { get; set; }
}

This means that as the binary content files are being deserialized, a Model and HeightFieldShapeDescription will be created.  However, a HeightFieldShapeDescription requires instantiation using the PhysX runtime, which XNA knows nothing about.  This is where things get cool.

Just as a custom writer was necessary for writing serialized content, a custom reader must be provided for reading serialized a HeightFieldDescriptionContent at runtime.  The reader which corresponds to a content type is specified in the ContentTypeWriter during generation of the binary content files:

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

In the ContentTypeReader, a ContentReader instance is provided which must read back serialized values in the order they were written:

protected override HeightFieldShapeDescription Read(ContentReader input, HeightFieldShapeDescription existingInstance)
{
    var heightFieldDescription = ReadHeightFieldDescription(input);
    var heightField = Core.Singleton.CreateHeightField(heightFieldDescription);
    var heightFieldScale = AntelopeSettings.Default.HeightFieldScale / short.MaxValue;
    var heightFieldShapeDescription = new HeightFieldShapeDescription()
    {
        HeightField = heightField,
        HeightScale = heightFieldScale
    };
    return heightFieldShapeDescription;
}

private static HeightFieldDescription ReadHeightFieldDescription(ContentReader input)
{
    var columns = input.ReadInt32();
    var rows = input.ReadInt32();
    var samples = GetHeightFieldSamples(input, columns, rows);

    var description = new HeightFieldDescription()
    {
        NumberOfColumns = columns,
        NumberOfRows = rows
    };
    description.SetSamples(samples);

    return description;
}

Once the HeightFieldDescription has been created from the serialized content files, a PhysX HeightField is created using the PhysX core.  This HeightField is used in the HeightFieldShapeDescription, which is a component of a Level object:

public class Level
{
    private static readonly SceneDescription SceneDescription = new SceneDescription()
    {
        Gravity = new Vector3(0, -9.8f, 0)
    };
    public Scene Scene { get; private set; }
    public HeightField HeightField { get; set; }

    public Level()
    {
        Scene = Core.Singleton.CreateScene(Level.SceneDescription);
    }
}

When XNA creates an instance of a Level object, the PhysX Scene is created as well, using the PhysX core. Finally, the XML's content can be loaded in the LoadContent method of the game:

var levels = Content.Load<Level[]>("Levels");

I use the HeightField property of a Level to create a DrawableGameComponent called HeightFieldGameComponent.  It uses the heightfield model to display the terrain and adds an actor to the scene using the HeightFieldShapeDescription:

var actorDescription = new ActorDescription()
{
    Shapes = { level.HeightField.HeightFieldShapeDescription }
};
Level.Scene.CreateActor(actorDescription);

PhysX provides a simple method of displaying the internal representation of a Scene, which can be useful for debugging.  If you run the game in debug build, you will see this PhysX debug display on top of the XNA model.

PhysX debug display in tandem with the XNA model of the terrain.
And there you have it.  I'd be excited to have another opportunity to experiment with the XNA content pipeline.  After this prototype, it seems like a very flexible API, and I get the impression there are many ways to approach the problem of getting assets into your game.  You may know a better way than what I've done here.  If so, by all means, fire away in the comments below!  I'm anxious to learn this stuff the best way possible.

No comments:

Post a Comment