Compressed vertex data

Originally posted to Shawn Hargreaves Blog on MSDN, Friday, November 19, 2010

Compressed data is a great thing, especially when the GPU hardware is able to decompress it for free while rendering. But what exactly does vertex data compression mean, and how do we enable it?

XNA defaults to 32 bit floating point for most vertex data. For instance the VertexPositionNormalTexture struct is 32 bytes in size, storing Position and Normal as Vector3 (12 bytes) and TextureCoordinate as Vector2 (8 bytes). 32 bit floats are great for precision and range, but not all data needs so much accuracy! There are many other options to choose from:

    enum VertexElementFormat
    {
        Single,
        Vector2,
        Vector3,
        Vector4,
        Color,
        Byte4,
        Short2,
        Short4,
        NormalizedShort2,
        NormalizedShort4,
        HalfVector2,
        HalfVector4
    }

The HalfVector formats are only available in the HiDef profile, but all the others are supported by Reach hardware as well.

Generating packed values from C# code is easy thanks to the types in the Microsoft.Xna.Framework.Graphics.PackedVector namespace. For instance we could easily make our own PackedVertexPositionNormalTexture struct that would use HalfVector2 or NormalizedShort2 instead of Vector2 for its TextureCoordinate field.

To compress vertex data that is built as part of a model, we must use a custom content processor. This example customizes the built-in ModelProcessor, automatically converting normal data to NormalizedShort4 format, and texture coordinates to NormalizedShort2. This is an 8 byte saving, reducing 32 byte uncompressed vertices to 24 bytes:

    using Microsoft.Xna.Framework.Content.Pipeline;
    using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
    using Microsoft.Xna.Framework.Content.Pipeline.Processors;
    using Microsoft.Xna.Framework.Graphics.PackedVector;
 
    [ContentProcessor]
    public class PackedVertexDataModelProcessor : ModelProcessor
    {
        protected override void ProcessVertexChannel(GeometryContent geometry, int vertexChannelIndex, ContentProcessorContext context)
        {
            VertexChannelCollection channels = geometry.Vertices.Channels;
            string name = channels[vertexChannelIndex].Name;

            if (name == VertexChannelNames.Normal())
            {
                channels.ConvertChannelContent<NormalizedShort4>(vertexChannelIndex);
            }
            else if (name == VertexChannelNames.TextureCoordinate(0))
            {
                channels.ConvertChannelContent<NormalizedShort2>(vertexChannelIndex);
            }
            else
            {
                base.ProcessVertexChannel(geometry, vertexChannelIndex, context);
            }
        }
    }

Note that we had to choose NormalizedShort4 format for our normals, even though these values only actually have three components, because there is no NormalizedShort3 format. That's because GPU vertex data must always by 4 byte aligned. We could avoid this wastage by merging multiple vertex channels. For instance if we had two three component data channels, a and b, we could store (a.x, a.y, a.z, b.x) in one NormalizedShort4 channel, plus (b.y, b.z) in a second NormalizedShort2 channel. We would then have to change our vertex shader to extract this data back into the original separate channels, so this approach is more intrusive than just changing the format of existing data channels.

Vertex compression often works better if you adjust the range of the input data before changing its format. For instance, NormalizedShort2 is great for texture coordinates, but only if the texture does not wrap. If you have any texture coordinate values outside the range -1 to 1, these will overflow the packed range. This can be avoided by scanning the entire set of texture coordinates to find the largest value, then dividing every texture coordinate by this maximum. The resulting data will now compress into NormalizedShort format with no risk of overflow. To render the model, you must store the value by which everything was divided, pass it into your vertex shader, and have the shader multiply all texture coordinates by this scale factor.

How much you win by compressing vertex data obviously depends on how many vertices you are dealing with. For many games the gain may be too small to be worth bothering with. But when using detailed models or terrains that contain millions of vertices, the memory and bandwidth savings can be significant.

Blog index   -   Back to my homepage