Bitpacking

Obsolete: please use NetworkReader/Writer instead.

Bitpacking won't be necessary with the new delta compression.

NetworkReader/Writer are faster too.

DOTSNET uses Bitpacking via BitWriter to serialize, and BitReader to deserialize data down to the bit.

Usage

For example, this is how we serialize an int and a float3 position in a message:

using DOTSNET;

public struct TestMessage : NetworkMessage
{
    public int classIndex;
    public float3 position;
    
    public byte GetID() => 0x31;

    public bool Serialize(ref BitWriter writer) =>
        writer.WriteInt(classIndex) &&
        writer.WriteFloat3(position);

    public bool Deserialize(ref BitReader reader) =>
        reader.ReadInt(out classIndex) &&
        reader.ReadFloat3(out position);
}

Reading and Writing is:

  • Atomic: WriteInt either writes all 4 bytes int, or none if there is not enough space. ReadInt either reads all 4 bytes int, or none if the buffer doesn’t have enough data.

  • Allocation Free: all reads and writes are allocation free for maximum performance.

  • Fast: the Bitpacker’s internal buffer writes are always word aligned. This makes unaligned writes (byte/ushort/etc.) as fast as aligned writes (uint, float, etc.).

Reference Passing

To avoid allocations, BitReader/Writer are value types (structs). When passing a reader/writer through functions, make sure to pass it as reference:

public bool Serialize(ref BitWriter writer) =>
    writer.WriteInt(level);

public bool Deserialize(ref BitReader reader) =>
    reader.ReadInt(out level);

If you don’t pass it as reference, then it will create a copy of the Writer/Reader, which would not modify the original writer/reader’s Position.

In other words, always pass BitReader/Writer as reference!

Bit Compression

BitWriter/Reader default functions always write the full type unless bitpacking parameters are specified. For example, let’s serialize/deserialize a player’s level which requires 32 bits uint by default:

writer.WriteUInt(5);
reader.ReadUInt(out uint value);

If we know the value’s minimum and maximum data range, then we can do heavy bandwidth optimizations by packing it into the minimum amount of bits needed for that range. Our player’s level is always in the range of [1..60] so we can Bitpack it down from 32 bits to 6 bits:

// a player level of '5' with an expected range of [1..60]
// => 60 possible values in that range
// => they all fit into 6 bits
// => so the value is encoded into 6 bits, instead of 32 bits uint!
writer.WriteUInt(5, 1, 60);
reader.ReadUInt(out uint value, 1, 60);

DOTSNET’s Bitpacking automatically does all the complicated bit math internally. All we need to do is specify the data type ranges if we know them.

This also works for float/double where we specify min, max, precision

writer.WriteFloat(1.23f, -10f, 10f, 0.1f);
reader.ReadFloat(out float value);

Another interesting example is bool, which C# stores into 8 bit = 1 byte by default. A bool is always either 1 or 0, which fits perfectly into just 1 bit. DOTSNET packs bools automatically!

writer.WriteBool(true);
reader.ReadBool(out bool value);

For a deeper understanding about Bitpacking, please read the excellent article by Glenn Fiedler!

Burst

DOTSNET BitReader/Writers are burstable.

Additionally, DOTSNET provides fixed size Readers/Writers for use in IComponentData:

  • BitReader128

  • BitWriter128

Extending BitReader/Writer

You can extend BitReader/Writer with C#’s extension system:

public struct MyStruct
{
    public int level;
    public int experience;
}
using DOTSNET;    

public static class Extensions
{
    public static bool WriteMyStruct(ref this BitWriter writer, MyStruct value)
    {
        return writer.WriteInt(value.level) &&
               writer.WriteInt(value.experience);
    }
   
    public static bool ReadMyStruct(ref this SBitReader reader, out MyStruct value)
    {
        value = new MyStruct();
        return reader.ReadInt(out value.level) &&
               reader.ReadInt(out value.experience);
    }
}

Last updated