Какие методы/алгоритмы дельта-сжатия состояния приложения/игры существуют?

Я заинтересован в разработке многопользовательских клиент-серверных игр в реальном времени и связанных с ними алгоритмов. Многие известные многопользовательские игры, такие как Quake 3 или Half-Life 2, используют методы дельта-сжатия для экономии полосы пропускания.

Сервер должен постоянно отправлять последний снимок состояния игры всем клиентам. Было бы очень дорого всегда отправлять полный снимок, поэтому сервер просто отправляет разницу между последним снимком и текущим.

... легко, правда? Что ж, мне очень трудно думать о том, как на самом деле рассчитать разницу между двумя состояниями игры.

Игровые состояния могут быть очень сложными и иметь объекты, расположенные в куче и ссылающиеся друг на друга с помощью указателей, могут иметь числовые значения, представление которых зависит от архитектуры, и многое другое.

Мне трудно поверить, что каждый отдельный тип игровых объектов имеет написанную от руки функцию сериализации/десериализации/расчета различий.


Вернемся к основам. Допустим, у меня есть два состояния, представленные битами, и я хочу вычислить их разницу:

state0:  00001000100    // state at time=0
state1:  10000000101    // state at time=1
         -----------
added:   10000000001    // bits that were 0 in state0 and are 1 in state1
removed: 00001000000    // bits that were 1 in state0 and are 1 in state1

Отлично, теперь у меня есть added и removed diff bitsets, но...

...размер diff по-прежнему точно такой же, как размер состояния. И мне фактически нужно отправить два различия по сети!

Действительно ли правильная стратегия заключается в построении какой-то разреженной структуры данных из этих различных битовых наборов? Пример:

// (bit index, added/removed)
// added = 0
// removed 1
(0,0)(4,1)(10,0)

// ^
// bit 0 was added, bit 4 was removed, bit 10 was added

Это возможный правильный подход?


Допустим, мне удалось написать функции сериализации/десериализации для всех моих типов игровых объектов из/в JSON.

Могу ли я каким-то образом, имея два значения JSON, вычислить разницу между ними автоматически в битах?

Пример:

// state0
{
    "hp": 10,
    "atk": 5
}

// state1
{
    "hp": 4,
    "atk": 5
}

// diff
{
    "hp": -6
}

// state0 as bits (example, random bits)
010001000110001

// state1 as bits (example, random bits)
000001011110000

// desired diff bits (example, random bits)
100101

Если бы что-то подобное было возможно, то было бы достаточно легко избежать проблем, связанных с архитектурой, и функций вычисления разницы в почерке.

Учитывая две строки A и B, похожие друг на друга, можно ли вычислить строку C, которая меньше по размеру, чем A и B, что представляет разницу между A и B и может быть применить к A, чтобы получить результат B?


person Vittorio Romeo    schedule 27.03.2015    source источник
comment
Если бы у вас был (многоэтапный) механизм отмены/возврата, вы, вероятно, могли бы извлечь из этого выгоду.   -  person greybeard    schedule 30.03.2015


Ответы (3)


Поскольку в качестве примера вы использовали Quake3, я сосредоточусь на том, как там все делается. Первое, что вам нужно понять, это то, что «состояние игры» по отношению к играм клиент-сервер не относится ко всему внутреннему состоянию объекта, включая текущее состояние ИИ, функции коллизий, таймеры и т. д. Сервер игры на самом деле дает клиенту НАМНОГО меньше. Только положение объектов, ориентация, модель, кадр в анимации модели, скорость и физический тип. Последние два используются, чтобы сделать движение более плавным, позволяя клиенту имитировать баллистическое движение, но это все.

В каждом внутриигровом кадре, который происходит примерно 10 раз в секунду, сервер запускает физику, логику и таймеры для всех объектов в игре. Затем каждый объект вызывает функцию API для обновления своего нового положения, кадра и т. д., а также для обновления того, был ли он добавлен или удален в этом кадре (например, кадр удаляется из-за того, что он попал в стену). Вообще, в Quake 3 есть интересный баг на этот счет - если выстрел движется во время фазы физики, и попадает в стену, он удаляется, и удаляется единственное обновление, которое получает клиент, а не предыдущий полет в сторону стены, поэтому клиент видит, как выстрел исчезает в воздухе за 1/10 секунды до того, как он действительно попадает в стену.

С этой небольшой информацией об объекте довольно легко отличить новую информацию от старой. Кроме того, только объекты, которые фактически изменяются, вызывают API обновления, поэтому объекты, которые остаются прежними (например, стены или неактивные платформы), даже не должны выполнять такое сравнение. Кроме того, сервер может еще больше сэкономить на отправленной информации, не отправляя клиенту объекты, которые невидимы для клиента, пока они не появятся в поле зрения. Например, в Quake2 уровень разделен на области обзора, и одна область (и все объекты внутри нее) считается «вне поля зрения» другой, если все двери между ними закрыты.

Помните, что серверу не нужно, чтобы клиент имел полное игровое состояние, а только граф сцены, а для этого требуется гораздо более простая сериализация и полное отсутствие указателей (в Quake он фактически хранится в одном массиве статических размеров, который также ограничивает максимальное количество объектов в игре).

Кроме того, есть также данные пользовательского интерфейса для таких вещей, как здоровье игрока, боеприпасы и т. Д. Опять же, каждый игрок получает только свое здоровье и боеприпасы, отправленные ему, а не всем на сервере. У сервера нет причин делиться этими данными.

Обновление: чтобы убедиться, что я получаю самую точную информацию, я дважды проверил код. Это основано на Quake3, а не на Quake Live, поэтому некоторые вещи могут отличаться. Информация, отправляемая клиенту, инкапсулируется в единую структуру с именем snapshot_t. Он содержит один playerState_t для текущего игрока и массив из 256 entityState_t для видимых игровых объектов, а также несколько дополнительных целых чисел и массив байтов, представляющий битовую маску "видимых областей".

entityState_t, в свою очередь, состоит из 22 целых чисел, 4 векторов и 2 траекторий. «Траектория» — это структура данных, используемая для представления движения объекта в пространстве, когда с ним ничего не происходит, например. баллистическое движение или полет по прямой. Это 2 целых числа, 2 вектора и одно перечисление, которое концептуально можно хранить как небольшое целое число.

playerState_t немного больше, содержит примерно 34 целых числа, примерно 8 целочисленных массивов размером от 2 до 16 каждый и 4 вектора. Он содержит все, от текущего кадра анимации оружия до инвентаря игрока и звука, который издает игрок.

Поскольку используемые структуры имеют предустановленные размеры и, ну, структуру, создание простой ручной функции «diff», сравнивающей каждый из членов для каждого, довольно просто. Однако, насколько я могу судить, entityState_t и playerState_t отправляются только полностью, а не частями. Единственное, что подвергается "дельте" - это то, какие сущности отправляются как часть массива сущностей в snapshot_t.

В то время как снимок может содержать до 256 объектов, сама игра может содержать до 1024 объектов. Это означает, что только 25% объектов могут быть обновлены с точки зрения клиента в одном кадре (любое большее количество вызовет печально известную ошибку «переполнение пакета»). Сервер просто отслеживает, какие объекты имели значимое движение, и отправляет их. Это намного быстрее, чем выполнение фактического сравнения - просто отправка любого объекта, который вызывает «обновление» сам по себе и находится внутри битовой маски видимой области игрока. Но теоретически, написанный от руки diff для каждой структуры сделать не так уж и сложно.

Для здоровья команды, хотя Quake3, похоже, не делает этого, поэтому я могу только догадываться, как это делается в Quake Live. Есть два варианта: либо отправить все структуры playerState_t, так как их максимум 64, либо добавить еще один массив в playerState_t для сохранения HP команды, так как это будет только 64 целых числа. Последнее гораздо более вероятно.

Чтобы поддерживать синхронизацию массива объектов между клиентом и сервером, каждая сущность имеет индекс сущности от 0 до 1023 и отправляется как часть сообщения от сервера к клиенту. Когда клиент получает массив из 256 сущностей, он просматривает массив, считывает поле индекса из каждого и обновляет сущность по прочитанному индексу в своем локально сохраненном массиве сущностей.

person SlugFiller    schedule 05.04.2015
comment
Очень интересная информация. У меня есть несколько дополнительных вопросов, на которые, я надеюсь, вы ответите (или поможете мне найти ответ): 1) Судя по вашему ответу, здоровье/боеприпасы и другое состояние не синхронизированы - как Quake 3 справляется с этим? данные? Я могу видеть здоровье своих товарищей по команде в HUD в Quake Live., 2) Синхронизирован ли объект №500 (хранится в статическом массиве) на сервере с объектом №500 на клиенте?< /b>, 3) Как рассчитывается разница? Существуют ли написанные от руки функции для расчета различий для каждого типа объекта? - person Vittorio Romeo; 06.04.2015
comment
Если вам нужно что-то еще из моего ответа, дайте мне знать - person SlugFiller; 06.04.2015

Я бы предложил отступить на секунду и осмотреться, чтобы найти потенциально лучшее решение.

Как вы упомянули, игровое состояние может быть очень сложным и огромным. Поэтому маловероятно, что интеллектуальное сжатие (разницы, компактные сериализованные формы и т. д.) поможет. В конце концов, возникнет необходимость перенести очень большой дифф, так что игровой опыт пострадает.

Для краткости предлагаю рассмотреть два рисунка (ссылка на источник будет дана).

Вы можете перевести действие пользователя в вызов функции, которая изменит состояние игры:

введите здесь описание изображения

Или вы можете создать соответствующую команду/действие и позволить исполнителю вашей команды обработать ее, изменяя состояние асинхронно:

введите здесь описание изображения

Может показаться, что разница довольно мала, но второй подход позволяет вам:

  • чтобы избежать каких-либо условий гонки и безопасно обновлять состояние, введя очередь для действий пользователя.
  • обрабатывать действия из нескольких источников (нужно только правильно их отсортировать)

Я только что описал Шаблон команды, который может оказаться очень полезным. Это подводит нас к идее computed state. Если поведение команд детерминировано (как и должно быть), чтобы получить новое состояние, вам просто нужно предыдущее состояние и команда.

Таким образом, имея начальное состояние (одинаковое для всех игроков или переданное в самом начале игры), для приращения состояния потребуются только другие команды.

В качестве примера я буду использовать упреждающую запись и ведение журнала команд (представим, что состояние вашей игры находится в базе данных), потому что решается почти та же проблема, и я уже пытался предоставить подробное объяснение:

введите здесь описание изображения

Такой подход также позволяет упростить восстановление состояния и т. д., потому что выполнение действия может быть действительно быстрее по сравнению с последовательностью generate new state, compare to the previous one, generate diff, compress diff, send diff, apply diff.

В любом случае, если вы все еще считаете, что лучше отправлять различия, просто постарайтесь, чтобы ваше состояние было достаточно маленьким (потому что у вас будет как минимум два снимка) с небольшим и легко читаемым объемом памяти.

В этом случае процедура сравнения будет генерировать разреженные данные, поэтому любой алгоритм сжатия потока легко даст хороший коэффициент сжатия. Кстати, убедитесь, что ваш алгоритм сжатия не потребует еще больше памяти. Лучше не использовать какое-либо решение на основе словаря.

Арифметическое кодирование, Основное дерево, скорее всего, поможет. Вот и идея и реализация, с которой можно начать:

введите здесь описание изображения

  public static void encode(int[] buffer, int length, BinaryOut output) {
    short size = (short)(length & 0x7FFF);

    output.write(size);
    output.write(buffer[0]);

    for(int i=1; i< size; i++) {
        int next = buffer[i] - buffer[i-1];
        int bits = getBinarySize(next);

        int len = bits;

        if(bits > 24) {
          output.write(3, 2);
          len = bits - 24;
        }else if(bits > 16) {
          output.write(2, 2);
          len = bits-16;
        }else if(bits > 8) {
          output.write(1, 2);
          len = bits - 8;
        }else{
          output.write(0, 2);
        }

        if (len > 0) {
            if ((len % 2) > 0) {
                len = len / 2;
                output.write(len, 2);
                output.write(false);
            } else {
                len = len / 2 - 1;
                output.write(len, 2);
            }

            output.write(next, bits);
        }
    }
}

public static short decode(BinaryIn input, int[] buffer, int offset) {
    short length = input.readShort();
    int value = input.readInt();
    buffer[offset] = value;

    for (int i = 1; i < length; i++) {
        int flag = input.readInt(2);

        int bits;
        int next = 0;
        switch (flag) {
            case 0:
                bits = 2 * input.readInt(2) + 2;
                next = input.readInt(bits);
                break;
            case 1:
                bits = 8 + 2 * input.readInt(2) +2;
                next = input.readInt(bits);
                break;
            case 2:
                bits = 16 + 2 * input.readInt(2) +2;
                next = input.readInt(bits);
                break;
            case 3:
                bits = 24 + 2 * input.readInt(2) +2;
                next = input.readInt(bits);
                break;
        }

        buffer[offset + i] = buffer[offset + i - 1] + next;
    }

   return length;
}

Последнее слово: не раскрывайте состояние, не передавайте его, а вместо этого вычисляйте. Такой подход будет быстрее, проще в реализации и отладке.

person Renat Gilmanov    schedule 31.03.2015

При сравнении битов эффективность сохранения местоположения каждого измененного бита зависит от того, сколько битов было изменено. В 32-битной системе каждая ячейка занимает 32 бита, поэтому она эффективна только тогда, когда изменяется менее 1 из 32 бит. Однако, поскольку измененные биты обычно являются смежными, было бы более эффективно сравнивать более крупные единицы (например, байты или слова).

Обратите внимание, что ваш подход к сравнению битов работает только в том случае, если абсолютные местоположения битов не меняются. Однако, если некоторые биты вставлены или удалены в середине, ваш алгоритм увидит почти каждый бит после вставленной/удаленной позиции как измененный. Чтобы смягчить это, вы должны вычислить самую длинную общую подпоследовательность, поэтому биты только в A или B вставляются/удаляются.


Но сравнение объектов JSON не обязательно должно происходить побитно. (Если необходимо, это то же самое, что сравнивать две битовые строки.) Сравнение может происходить на более высоком уровне. Один из способов вычислить разницу между двумя произвольными объектами JSON — написать функцию, которая принимает A и B и:

  • Если аргументы имеют разные типы, верните «A изменено на B».
  • Если аргументы имеют один и тот же примитивный тип, ничего не возвращать, если они одинаковы, в противном случае возвращать «A изменено на B».
  • Если оба аргумента являются словарями, элементы, чьи ключи находятся только в A, удаляются, а чьи ключи находятся только в B, добавляются. Для элементов с одинаковым ключом сравнивайте рекурсивно.
  • Если оба аргумента являются массивами, вычислить самую длинную общую подпоследовательность A и B (равенство двух элементов также можно проверить с помощью рекурсивной функции) и найти элементы только в A или B. Таким образом, элементы только в A удаляются , добавляются элементы только в B. Или, может быть, неравные элементы также можно сравнивать рекурсивно, но единственный эффективный способ, о котором я могу думать, требует наличия способа (например, записи идентификаторов) для определения того, какой элемент в A соответствует какому элементу в B, и сравнить элемент с соответствующим элементом.

Разницу между двумя строками также можно вычислить, используя самую длинную общую подпоследовательность.

person user4098326    schedule 31.03.2015