В этой статье соберём на C# небольшой набор инструментов для компактных сетевых пакетов в Unity: битовый писатель и читатель, квантование float и векторов, varint с zigzag-кодированием, сжатие кватерниона до 29 бит по схеме smallest three и дельта-кодирование с маской изменённых полей. В конце будет пример: состояние игрока, которое в «наивном» виде занимает 42 байта, а после упаковки — около 8 байт, а когда игрок стоит на месте, и того меньше. Код не привязан к конкретному сетевому решению: его можно использовать поверх Netcode for GameObjects, Unity Transport, Mirror, Fish-Net или собственного UDP-сокета.
Зачем считать байты, если интернет быстрый
Кажется, что 40 байт на игрока — это ничего. Посчитаем. Сервер шлёт состояние 30 раз в секунду, в матче 20 игроков, и каждому клиенту нужно знать про всех остальных. Получается 20 × 19 × 30 × 42 ≈ 479 000 байт в секунду исходящего трафика только на позиции и повороты — без снарядов, анимаций, заголовков UDP и транспортного протокола. Для облачного сервера это деньги, для мобильного клиента — задержки и потери пакетов.
Есть и менее очевидная причина. Пакет UDP, который больше MTU (на практике безопасно считать около 1200 байт полезной нагрузки), фрагментируется. Потеря любого фрагмента означает потерю всего пакета. Поэтому снапшот, который помещается в один пакет, приходит заметно надёжнее, чем снапшот на три фрагмента. Упаковка данных — это не только экономия, но и стабильность.
Главная идея всех приёмов ниже одна: мы знаем о своих данных больше, чем формат float. Float тратит 32 бита на диапазон от крошечных долей до 10 в 38-й степени, а позиция игрока на карте лежит в пределах ±512 метров, и точность в один сантиметр никто не заметит. Здоровье — целое от 0 до 200. Кватернион единичной длины, значит, одну его компоненту можно не передавать вовсе. Каждый такой факт превращается в сэкономленные биты.
Фундамент: запись по битам
BinaryWriter и большинство сериализаторов пишут данные кратно байту. Чтобы записать значение в 11 бит, нужен писатель, который умеет класть биты подряд, не выравнивая их на границу байта. Классический приём — накапливать биты в 64-битном регистре-«черновике» и сбрасывать в буфер по 32 бита, когда черновик заполнится.
using System;
public sealed class BitWriter
{
private byte[] _buffer;
private int _bytePos; // сколько байт уже сброшено в буфер
private ulong _scratch; // накопленные, но ещё не сброшенные биты
private int _scratchBits; // сколько бит лежит в _scratch
public BitWriter(int capacity = 1200)
{
_buffer = new byte[capacity];
}
public int BitsWritten => _bytePos * 8 + _scratchBits;
public void Reset()
{
_bytePos = 0;
_scratch = 0;
_scratchBits = 0;
}
public void WriteBits(uint value, int bitCount)
{
if (bitCount < 1 || bitCount > 32)
throw new ArgumentOutOfRangeException(nameof(bitCount));
ulong mask = bitCount == 32 ? 0xFFFFFFFFUL : (1UL << bitCount) - 1;
_scratch |= (value & mask) << _scratchBits;
_scratchBits += bitCount;
if (_scratchBits >= 32)
{
EnsureCapacity(4);
uint word = (uint)_scratch;
_buffer[_bytePos++] = (byte)word;
_buffer[_bytePos++] = (byte)(word >> 8);
_buffer[_bytePos++] = (byte)(word >> 16);
_buffer[_bytePos++] = (byte)(word >> 24);
_scratch >>= 32;
_scratchBits -= 32;
}
}
public void WriteBool(bool value) => WriteBits(value ? 1u : 0u, 1);
// Дописывает хвост черновика и возвращает длину пакета в байтах.
public int Flush()
{
while (_scratchBits > 0)
{
EnsureCapacity(1);
_buffer[_bytePos++] = (byte)_scratch;
_scratch >>= 8;
_scratchBits = Math.Max(0, _scratchBits - 8);
}
return _bytePos;
}
public ReadOnlySpan<byte> AsSpan() => new ReadOnlySpan<byte>(_buffer, 0, _bytePos);
private void EnsureCapacity(int extra)
{
if (_bytePos + extra > _buffer.Length)
Array.Resize(ref _buffer, _buffer.Length * 2);
}
}
Порядок байтов задан явно (little-endian), поэтому пакет одинаково читается на любой платформе. Буфер создаётся один раз и переиспользуется через Reset(), так что запись не создаёт мусора для GC. Если вы пишете в NativeArray из Job System, логика та же, меняется только хранилище.
Читатель зеркален писателю: подкачивает байты в черновик, пока там не наберётся нужное число бит.
public sealed class BitReader
{
private readonly byte[] _buffer;
private readonly int _length;
private int _bytePos;
private ulong _scratch;
private int _scratchBits;
public BitReader(byte[] buffer, int length)
{
_buffer = buffer;
_length = length;
}
public uint ReadBits(int bitCount)
{
if (bitCount < 1 || bitCount > 32)
throw new ArgumentOutOfRangeException(nameof(bitCount));
while (_scratchBits < bitCount)
{
if (_bytePos >= _length)
throw new InvalidOperationException("Пакет закончился раньше, чем ожидалось");
_scratch |= (ulong)_buffer[_bytePos++] << _scratchBits;
_scratchBits += 8;
}
ulong mask = bitCount == 32 ? 0xFFFFFFFFUL : (1UL << bitCount) - 1;
uint result = (uint)(_scratch & mask);
_scratch >>= bitCount;
_scratchBits -= bitCount;
return result;
}
public bool ReadBool() => ReadBits(1) != 0;
}
Обратите внимание на исключение при выходе за конец пакета. Данные приходят из сети, а значит, могут быть обрезаны, испорчены или подделаны. Читатель, который молча возвращает нули, превращает такой пакет в телепортацию игрока в начало координат; читатель, который бросает исключение, позволяет просто отбросить пакет.
Самое важное правило битовой сериализации: запись и чтение должны вызывать методы в одном и том же порядке и с одним и тем же числом бит. Формат нигде не хранится, он «живёт» в коде. Поэтому код записи и чтения конкретного сообщения лучше держать в одном методе с флагом направления или хотя бы рядом, в одном файле.
Квантование float: диапазон плюс точность
Квантование — это перевод непрерывного значения в целое число шагов. Нужно знать три вещи: минимум, максимум и требуемую точность. Число бит выводится из них: сколько шагов размером precision помещается в диапазоне.
using UnityEngine;
public readonly struct FloatQuantizer
{
public readonly float Min;
public readonly float Max;
public readonly int Bits;
private readonly uint _maxValue;
public FloatQuantizer(float min, float max, float precision)
{
Min = min;
Max = max;
uint steps = (uint)Mathf.CeilToInt((max - min) / precision);
Bits = BitsRequired(steps);
_maxValue = Bits == 32 ? uint.MaxValue : (1u << Bits) - 1;
}
public uint Quantize(float value)
{
float t = Mathf.InverseLerp(Min, Max, value); // уже зажато в [0, 1]
return (uint)Mathf.RoundToInt(t * _maxValue);
}
public float Dequantize(uint q) => Mathf.Lerp(Min, Max, q / (float)_maxValue);
public void Write(BitWriter w, float value) => w.WriteBits(Quantize(value), Bits);
public float Read(BitReader r) => Dequantize(r.ReadBits(Bits));
public static int BitsRequired(uint maxValue)
{
int bits = 1;
while (bits < 32 && (maxValue >> bits) != 0) bits++;
return bits;
}
}
Пример: карта ±512 метров по X и Z, точность 1 см. Шагов 102 400, это 17 бит. По высоте карта от −16 до 112 метров, тоже 1 см: 12 800 шагов, 14 бит. Позиция занимает 17 + 14 + 17 = 48 бит, то есть 6 байт вместо 12. Если миллиметры нужны только для прицела снайпера, а для позиции тела хватает 2–3 см, получится ещё на 3–4 бита меньше.
Несколько практических замечаний.
- Значения за пределами диапазона зажимаются.
InverseLerpвозвращает 0 или 1, и объект «прилипает» к краю. Это хорошо как защита от мусора, но плохо, если карта вдруг оказалась больше. Выставьте диапазон с запасом и добавьте в редакторе проверку, которая предупреждает о выходе за границы. - Квантуйте и на сервере. Если сервер симулирует точную позицию, а клиент получает округлённую, их состояния чуть-чуть расходятся. Для клиентского предсказания это источник постоянных мелких исправлений. Решение — после каждого тика сервер сам прогоняет своё состояние через Quantize/Dequantize, и обе стороны работают с одними и теми же числами.
- Для скоростей и углов диапазон обычно известен из геймдизайна. Скорость не больше 20 м/с, угол камеры по вертикали от −89 до 89 градусов. Угол поворота по горизонтали в 0–360 с точностью 0,1° — 12 бит.
- Нормализованные значения (процент здоровья, заряд, вес смешивания анимации) часто хватает передавать в 7–8 битах.
Целые числа: просто меньше бит
С целыми всё проще: если здоровье лежит в 0–200, на него нужно 8 бит, если номер оружия от 0 до 11 — 4 бита. Удобно иметь метод записи целого в диапазоне, чтобы не считать биты вручную:
public static class BitWriterIntExtensions
{
public static void WriteRanged(this BitWriter w, int value, int min, int max)
{
int bits = FloatQuantizer.BitsRequired((uint)(max - min));
w.WriteBits((uint)(Mathf.Clamp(value, min, max) - min), bits);
}
public static int ReadRanged(this BitReader r, int min, int max)
{
int bits = FloatQuantizer.BitsRequired((uint)(max - min));
return (int)r.ReadBits(bits) + min;
}
}
Хуже, когда максимум неизвестен или почти всегда далеко не достигается: номер тика, счётчик патронов в запасе, идентификатор сущности. Тут работает кодирование переменной длины.
Varint и zigzag
Varint (он же LEB128, тот же приём использует Protocol Buffers) делит число на группы по 7 бит. Перед каждой группой — бит продолжения: 1 значит «дальше будет ещё группа», 0 — «это последняя». Маленькие числа занимают 8 бит, числа до 16 383 — 16 бит, а большие не теряются.
Для битового потока группы не обязаны быть по 7 бит. Если значения обычно маленькие, выгоднее группы поменьше:
public static class VarIntExtensions
{
public static void WriteVarUInt(this BitWriter w, uint value, int groupBits = 7)
{
uint groupMask = (1u << groupBits) - 1;
while (value > groupMask)
{
w.WriteBits(value & groupMask, groupBits);
w.WriteBool(true);
value >>= groupBits;
}
w.WriteBits(value, groupBits);
w.WriteBool(false);
}
public static uint ReadVarUInt(this BitReader r, int groupBits = 7)
{
uint result = 0;
int shift = 0;
while (true)
{
result |= r.ReadBits(groupBits) << shift;
if (!r.ReadBool()) return result;
shift += groupBits;
if (shift >= 32)
throw new InvalidOperationException("Слишком длинный varint");
}
}
// Zigzag: 0 -> 0, -1 -> 1, 1 -> 2, -2 -> 3, 2 -> 4 ...
public static uint ZigZag(int value) => (uint)((value << 1) ^ (value >> 31));
public static int UnZigZag(uint value) => (int)(value >> 1) ^ -(int)(value & 1);
public static void WriteVarInt(this BitWriter w, int value, int groupBits = 7)
=> w.WriteVarUInt(ZigZag(value), groupBits);
public static int ReadVarInt(this BitReader r, int groupBits = 7)
=> UnZigZag(r.ReadVarUInt(groupBits));
}
Zigzag нужен для знаковых чисел. В дополнительном коде −1 — это 32 единицы, и varint записал бы его в пять групп. Zigzag переставляет числа так, что маленькие по модулю значения, положительные и отрицательные, становятся маленькими беззнаковыми. Это особенно полезно для разностей (дельт), о которых ниже: разница между соседними значениями почти всегда мала, но может быть любого знака.
Защита shift >= 32 снова про недоверие к сети: без неё специально собранный пакет из одних битов продолжения заставит цикл крутиться, пока не кончится буфер.
Кватернион в 29 бит: smallest three
Наивный кватернион — четыре float, 128 бит. Поворот передают почти в каждом снапшоте, поэтому здесь экономия самая заметная. Приём smallest three опирается на два факта.
- Кватернион поворота имеет единичную длину: x² + y² + z² + w² = 1. Зная три компоненты, четвёртую можно восстановить как корень из единицы минус сумма квадратов остальных.
- Кватернионы q и −q задают один и тот же поворот. Значит, можно выбрать знак так, чтобы восстанавливаемая компонента была неотрицательной, и знак не передавать.
Восстанавливать будем самую большую по модулю компоненту: у неё лучшая точность восстановления, а остальные три гарантированно лежат в диапазоне от −1/√2 до 1/√2 (примерно ±0,7071). Индекс отброшенной компоненты занимает 2 бита, три оставшихся квантуются, например, по 9 бит. Итого 2 + 27 = 29 бит. Для персонажа, который в основном вращается вокруг вертикальной оси, этого хватает с запасом; для камеры от первого лица или точного прицела берите 10–11 бит на компоненту.
public static class QuaternionCompression
{
private const float Range = 0.70710678f; // 1 / sqrt(2)
public static void WriteQuaternion(this BitWriter w, Quaternion q, int bitsPerComponent = 9)
{
float[] c = { q.x, q.y, q.z, q.w };
int largest = 0;
float largestAbs = Mathf.Abs(c[0]);
for (int i = 1; i < 4; i++)
{
float abs = Mathf.Abs(c[i]);
if (abs > largestAbs)
{
largestAbs = abs;
largest = i;
}
}
// q и -q — один и тот же поворот: делаем отброшенную компоненту положительной.
float sign = c[largest] < 0 ? -1f : 1f;
uint maxValue = (1u << bitsPerComponent) - 1;
w.WriteBits((uint)largest, 2);
for (int i = 0; i < 4; i++)
{
if (i == largest) continue;
float t = Mathf.InverseLerp(-Range, Range, c[i] * sign);
w.WriteBits((uint)Mathf.RoundToInt(t * maxValue), bitsPerComponent);
}
}
public static Quaternion ReadQuaternion(this BitReader r, int bitsPerComponent = 9)
{
int largest = (int)r.ReadBits(2);
uint maxValue = (1u << bitsPerComponent) - 1;
var c = new float[4];
float sumSquares = 0f;
for (int i = 0; i < 4; i++)
{
if (i == largest) continue;
c[i] = Mathf.Lerp(-Range, Range, r.ReadBits(bitsPerComponent) / (float)maxValue);
sumSquares += c[i] * c[i];
}
c[largest] = Mathf.Sqrt(Mathf.Max(0f, 1f - sumSquares));
var result = new Quaternion(c[0], c[1], c[2], c[3]);
return result.normalized;
}
}
Массивы здесь оставлены ради читаемости. В горячем пути замените их на локальные переменные или stackalloc, иначе каждая запись поворота создаёт мусор.
Две ловушки. Во-первых, входной кватернион должен быть нормализован: после долгого накопления поворотов через умножение его длина может уплыть, и тогда восстановленная компонента окажется неверной. Вызывайте Quaternion.Normalize перед записью, если не уверены. Во-вторых, Mathf.Max(0f, …) при чтении обязателен: из-за ошибки квантования сумма квадратов трёх компонент может чуть-чуть превысить единицу, и корень из отрицательного числа даст NaN, который потом расползётся по всей симуляции.
Если объект поворачивается только вокруг одной оси, как большинство персонажей в шутерах от третьего лица, ещё выгоднее передавать один угол рыскания: 0–360 градусов, точность 0,5°, всего 10 бит.
Передаём только то, что изменилось
Квантование уменьшает каждое поле, но большинство полей большую часть времени просто не меняются. Игрок стоит на месте, здоровье не падает, оружие то же самое. Следующий шаг — маска изменённых полей: перед данными пишем по одному биту на поле, а само поле пишем, только если его бит равен единице.
Сравнивать нужно не точные float, а уже квантованные значения: если позиция сдвинулась на полмиллиметра, а точность квантования — сантиметр, на стороне клиента ничего не изменится, и отправлять нечего. Поэтому удобно хранить состояние сразу в квантованном виде.
public struct PlayerStateQ
{
public uint PosX, PosY, PosZ; // квантованная позиция
public uint Yaw; // угол поворота, 10 бит
public int Health; // 0..200
public int WeaponId; // 0..15
public bool IsCrouching;
public bool IsGrounded;
}
Теперь запись относительно базового состояния, которое клиент гарантированно уже знает:
public static class PlayerStateSerializer
{
public static readonly FloatQuantizer PosXZ = new FloatQuantizer(-512f, 512f, 0.01f);
public static readonly FloatQuantizer PosY = new FloatQuantizer(-16f, 112f, 0.01f);
public const int YawBits = 10;
public static void WriteDelta(BitWriter w, in PlayerStateQ baseline, in PlayerStateQ current)
{
bool posChanged = current.PosX != baseline.PosX
|| current.PosY != baseline.PosY
|| current.PosZ != baseline.PosZ;
bool yawChanged = current.Yaw != baseline.Yaw;
bool healthChanged = current.Health != baseline.Health;
bool weaponChanged = current.WeaponId != baseline.WeaponId;
bool flagsChanged = current.IsCrouching != baseline.IsCrouching
|| current.IsGrounded != baseline.IsGrounded;
w.WriteBool(posChanged);
w.WriteBool(yawChanged);
w.WriteBool(healthChanged);
w.WriteBool(weaponChanged);
w.WriteBool(flagsChanged);
if (posChanged)
{
w.WriteBits(current.PosX, PosXZ.Bits);
w.WriteBits(current.PosY, PosY.Bits);
w.WriteBits(current.PosZ, PosXZ.Bits);
}
if (yawChanged) w.WriteBits(current.Yaw, YawBits);
if (healthChanged) w.WriteRanged(current.Health, 0, 200);
if (weaponChanged) w.WriteBits((uint)current.WeaponId, 4);
if (flagsChanged)
{
w.WriteBool(current.IsCrouching);
w.WriteBool(current.IsGrounded);
}
}
public static PlayerStateQ ReadDelta(BitReader r, in PlayerStateQ baseline)
{
PlayerStateQ s = baseline;
bool posChanged = r.ReadBool();
bool yawChanged = r.ReadBool();
bool healthChanged = r.ReadBool();
bool weaponChanged = r.ReadBool();
bool flagsChanged = r.ReadBool();
if (posChanged)
{
s.PosX = r.ReadBits(PosXZ.Bits);
s.PosY = r.ReadBits(PosY.Bits);
s.PosZ = r.ReadBits(PosXZ.Bits);
}
if (yawChanged) s.Yaw = r.ReadBits(YawBits);
if (healthChanged) s.Health = r.ReadRanged(0, 200);
if (weaponChanged) s.WeaponId = (int)r.ReadBits(4);
if (flagsChanged)
{
s.IsCrouching = r.ReadBool();
s.IsGrounded = r.ReadBool();
}
return s;
}
}
Если игрок стоит неподвижно, его состояние занимает 5 бит. Если бежит и поворачивается — 5 + 48 + 10 = 63 бита, примерно 8 байт. Сравните с «наивной» записью через BinaryWriter: Vector3 (12 байт), Quaternion (16 байт), здоровье и оружие как int (8 байт), два bool (2 байта) и id сущности (4 байта) — 42 байта.
Дельта к позиции, а не просто флаг
Можно пойти дальше: вместо полной квантованной позиции записывать разность с базовой. Игрок, бегущий со скоростью 7 м/с, при 30 тиках в секунду сдвигается примерно на 23 см за тик, то есть на 23 шага квантования. Разность в шагах — небольшое знаковое число, которое хорошо ложится в zigzag-varint с маленькими группами:
int dx = (int)current.PosX - (int)baseline.PosX;
w.WriteVarInt(dx, groupBits: 5);
Разность в пределах ±15 шагов уместится в 6 бит, в пределах ±511 — в 12. Но базовое состояние может быть на несколько тиков старше (о причинах ниже), и тогда разности растут. Хорошая стратегия — один бит выбора: если разность укладывается в короткую форму, пишем её, иначе пишем полное значение. Начинайте с простой маски полей, а дельты к числам добавляйте, когда профилирование трафика покажет, что позиция действительно доминирует.
Откуда берётся baseline
Дельта-кодирование работает только при одном условии: базовое состояние у отправителя и получателя должно быть одинаковым. Если пакет с промежуточным состоянием потерялся, а следующий пакет закодирован относительно него, клиент восстановит неправильное состояние и даже не узнает об этом.
Стандартное решение пришло из сетевого кода Quake 3 и с тех пор используется повсюду.
- Сервер нумерует снапшоты номером тика и хранит кольцевой буфер последних N отправленных состояний для каждого клиента (обычно 32 или 64).
- Клиент в каждом своём пакете (с вводом) сообщает номер последнего снапшота, который он получил.
- Сервер кодирует новый снапшот относительно этого подтверждённого снапшота и пишет в заголовок номер baseline-тика. Клиент ищет его в своём кольцевом буфере полученных снапшотов.
- Если подтверждения давно не было и нужный снапшот уже вытеснен из буфера, сервер отправляет полное состояние: baseline считается «нулевым» состоянием, все флаги изменений равны единице.
public sealed class SnapshotHistory
{
private readonly PlayerStateQ[] _states;
private readonly uint[] _ticks;
public SnapshotHistory(int size = 64)
{
_states = new PlayerStateQ[size];
_ticks = new uint[size];
for (int i = 0; i < size; i++) _ticks[i] = uint.MaxValue;
}
public void Store(uint tick, in PlayerStateQ state)
{
int i = (int)(tick % (uint)_states.Length);
_states[i] = state;
_ticks[i] = tick;
}
public bool TryGet(uint tick, out PlayerStateQ state)
{
int i = (int)(tick % (uint)_states.Length);
state = _states[i];
return _ticks[i] == tick;
}
}
Проверка _ticks[i] == tick защищает от ситуации, когда ячейка уже перезаписана более новым тиком: без неё клиент взял бы чужое состояние за базовое.
Номер тика удобно передавать не целиком, а как разность с baseline: она почти всегда меньше 64, и через varint займёт 1–2 группы. Сам baseline-тик можно записать относительно последнего подтверждённого номера, который сервер и клиент знают одинаково.
Что есть в готовых сетевых библиотеках
Свой битовый писатель полезен, но знать, что уже лежит в пакетах Unity, тоже нужно: иногда достаточно правильно вызвать готовое.
- Netcode for GameObjects.
FastBufferWriterумеет переключаться в битовый режим черезEnterBitwiseContext(), который возвращаетBitWriter; пока он не освобождён черезDispose()(обычно вusing), обычные побайтовые записи в буфер запрещены. Для целых естьBytePacker.WriteValuePackedиWriteValueBitPacked— упаковка переменной длины, аналог нашего varint. Использовать это всё можно в собственной реализацииINetworkSerializableили в кастомных сообщениях. - Unity Transport и Unity.Collections.
DataStreamWriterпишет произвольное число бит и умеет упаковывать целые и float методамиWritePackedUInt,WritePackedInt,WritePackedFloatс моделью сжатияStreamCompressionModel. Знаковые значения перед упаковкой переставляются так же, как в zigzag: 0, −1, 1, −2, 2. В старых версиях пакета модель называласьNetworkCompressionModel, так что при чтении старых примеров не удивляйтесь. - Netcode for Entities строит дельта-сжатие снапшотов автоматически по атрибутам
[GhostField], где для float задаётсяQuantization(множитель, например 1000 для миллиметров). Это та же идея, что иFloatQuantizer, только сгенерированная по описанию компонента.
Точные сигнатуры отличаются между версиями пакетов, поэтому перед использованием сверяйтесь с документацией той версии, что стоит у вас в Package Manager. Понимание механики из этой статьи переносится на любую из них.
Тестируем формат, а не надеемся
Ошибки в битовой сериализации коварны: на один бит сдвинулась запись, и всё остальное читается как мусор, но без исключения. Поэтому формат стоит покрыть юнит-тестами Unity Test Framework сразу. Самый полезный тест — кругооборот: записать, прочитать, сравнить.
using NUnit.Framework;
using UnityEngine;
public class SerializationTests
{
[Test]
public void Quaternion_RoundTrip_IsClose()
{
var rng = new System.Random(42);
var w = new BitWriter();
for (int i = 0; i < 10000; i++)
{
var q = Quaternion.Euler(
(float)rng.NextDouble() * 360f,
(float)rng.NextDouble() * 360f,
(float)rng.NextDouble() * 360f);
w.Reset();
w.WriteQuaternion(q);
int length = w.Flush();
var r = new BitReader(w.AsSpan().ToArray(), length);
var restored = r.ReadQuaternion();
Assert.Less(Quaternion.Angle(q, restored), 1f);
}
}
[Test]
public void VarInt_RoundTrip_HandlesNegativeAndExtremes()
{
int[] values = { 0, 1, -1, 63, -64, 1000, -1000, int.MaxValue, int.MinValue };
var w = new BitWriter();
foreach (int v in values) w.WriteVarInt(v, groupBits: 5);
int length = w.Flush();
var r = new BitReader(w.AsSpan().ToArray(), length);
foreach (int v in values) Assert.AreEqual(v, r.ReadVarInt(groupBits: 5));
}
[Test]
public void Delta_WithoutChanges_TakesOnlyMaskBits()
{
var state = new PlayerStateQ { PosX = 51200, Health = 150, WeaponId = 3 };
var w = new BitWriter();
PlayerStateSerializer.WriteDelta(w, state, state);
Assert.AreEqual(5, w.BitsWritten);
}
}
Тест на 10 000 случайных поворотов особенно ценен: он сразу показывает, хватает ли выбранного числа бит. Поменяйте 9 на 7 — и увидите, где ошибка угла переваливает за допустимую. Порог в один градус для 9 бит выбран с запасом: типичная ошибка в несколько раз меньше.
Для varint обязательно проверьте граничные значения int.MinValue и int.MaxValue: именно на них ломаются самописные zigzag и защита от слишком длинных чисел. Обратите внимание, что при группе в 5 бит 32-битное число занимает 7 групп, а последняя проверка shift >= 32 срабатывает только после седьмой — значение успевает прочитаться целиком.
Типичные ошибки
- Сравнение float вместо квантованных значений. Флаг «позиция изменилась» срабатывает от каждого микродвижения физики, и маска изменений перестаёт экономить. Сравнивайте то, что реально уйдёт в сеть.
- Разный формат на разных версиях клиента. Битовый формат не самоописывающийся: добавили поле — старый клиент читает мусор. Пишите в заголовок пакета номер версии протокола и отклоняйте несовместимые подключения на рукопожатии.
- Слишком агрессивное квантование ввода. Направление движения с джойстика в 4 бита на ось даёт заметные «ступеньки» на стиках. Для ввода берите 7–8 бит на ось; экономить лучше на том, что игрок не трогает руками.
- Квантование только на одной стороне. Об этом уже говорили: если сервер работает с точными числами, а клиент с округлёнными, предсказание на клиенте постоянно расходится с сервером на долю шага и вызывает мелкие исправления.
- Слишком ранняя оптимизация. Битовая упаковка усложняет отладку. Начинайте с простого формата, добавьте счётчик байт на тип сообщения (через
ProfilerCounterили обычный лог), посмотрите, что действительно занимает трафик, и упаковывайте именно это. Обычно это снапшоты сущностей, а не редкие RPC.
Итог
Компактные пакеты строятся из нескольких простых кирпичей. Битовый писатель позволяет класть значения произвольной ширины. Квантование превращает float в целое по известному диапазону и точности. Varint с zigzag экономно хранит числа без жёсткого максимума и разности. Smallest three сжимает кватернион со 128 до 29 бит. Маска изменённых полей и дельты относительно подтверждённого baseline убирают из пакета всё, что клиент уже знает. Вместе это превращает 42 байта на игрока в 1–8 байт, а снапшот на 20 игроков — в пакет, который спокойно помещается в одну UDP-датаграмму с большим запасом.
Порядок внедрения, который хорошо работает на практике: сначала измерить трафик по типам сообщений, затем квантовать позиции и повороты (это даёт самый большой выигрыш при минимуме сложности), затем добавить маску изменений, и только потом — дельты с подтверждением baseline. И на каждом шаге держать рядом тесты кругооборота, потому что в битовом формате ошибка в одном бите ломает весь остаток пакета.