Страницы

среда, 21 июня 2017 г.

Маленькие хитрости в разработке игр

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

1. «Прокрутка» по диапазону значений

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

Как это можно было сделать и как это делают чаще всего:
public int selectedWeapon;
public const int MAX_WEAPONS = 8;

public void Increment() {
 selectedWeapon++;
 if (selectedWeapon >= MAX_WEAPONS) {
  selectedWeapon = 0;
 }
}

public void Decrement() {
 selectedWeapon--;
 if (selectedWeapon < 0) {
  selectedWeapon = MAX_WEAPONS;
 }
}
Намного элегантнее это можно сделать с помощью операции вычисления остатка от деления:
public int selectedWeapon;
public const int MAX_WEAPONS = 8;

public void Increment() {
 selectedWeapon = (selectedWeapon + 1) % MAX_WEAPONS;
}

public void Decrement() {
 selectedWeapon =  (MAX_WEAPONS + selectedWeapon - 1) % MAX_WEAPONS;
}
Что же здесь произошло?! Дело в том, что мы всякий раз получаем лишь остаток от деления на на наше максимальное значение, в данном случае 8. Таким образом, при достижении максимального значения, остаток будет  равен 0.
С вычитанием операция несколько сложнее, так как мы уходим в отрицательную сторону:
(-1) % 8 = -1
Это решается простым добавлением максимального значения, так что остаток берется от:
8 + 0 - 1 = 7
7 % 8 = 7

2. Случайное значение из диапазона, за исключением заданного

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

Предположим, что персонажи хранятся в массиве. Тогда выбор случайного будет выглядеть так:
GameCharacter[] characters = new GameCharacter[16];
Random r = new Random();

public GameCharacter GetRandomCharacter(int playerCharIndex) {
 int enemyIndex = (playerCharIndex + 1 + r.Next(characters.Length - 1)) % characters.Length;
 return characters[enemyIndex];
}
Таким образом, мы совершаем выборку случайного значения, из диапазона всех значений после выбранного и до следующего его появления.
Для примера приведенного выше, если вызывать метод с параметром 5, модуль по числу 16 будет браться из диапазона значений (6; 20).

3. Быстрые вычисления

Несмотря на то, что вычислительные мощности компьютеров и даже мобильных устройств сегодня позволяют не задумываться о повышении производительности на низком уровне (хотя все еще остаются области, где нужно экономить самые крохи процессорного времени и памяти, например при написании шейдеров), следует быть знакомым с простейшими приемами, связанными с принципами работы процессора, к примеру:
  1. Деление с помощью умножения. Операция деления значительно затратнее, нежели умножение. Будет полезно взять в привычку, вместо деления, к примеру на 10, умножать на 0.1. По скромному мнению автора, это как минимум повышает читабельность сложных формул в коде.
  2. Так же деление/умножение на степени двойки можно совершать с помощью операции битового смещения:
    37 << 4 = 37 * 2 * 2 * 2 * 2 = 592
    136 >> 3 = (((136 / 2) / 2) /2 = 17
    Двоичные операции значительно быстрее десятичных!
  3. Операция побитового умножения «И» (&) может быть использована для проверки числа, является ли оно степенью двойки:
    (x & (x - 1)) == 0
Конечно, хороший компилятор может проанализировать и заменить многие вычисления на их быстрые аналоги, поэтому всегда нужно знать меру и не портить читабельность кода, в угоду сомнительной предварительной оптимизации.

4. Кодирование нескольких значений в одно поле

Этот прием крайне полезен для экономии памяти и упрощении обозначения каких либо связанных значений, например координат.
Предположим, что у нас есть карта, хранящаяся в виде двумерной сетки из множества ячеек. Тогда каждая ячейка будет иметь X и Y координату в этой сетке.
Таким образом, карту можно хранить в виде двухмерного массива, а обращаться к каждому элементу по двум индексам. Иногда такой подход оправдан, но с ростом сложности проекта, два значения начинают создавать проблемы. А если нужно хранить больше значений? (в одном проекте мне нужно было использовать адрес «между двумя клетками» – 4 значения)

Трюк заключается в записи двух чисел подряд в одно, с помощью операции битового смещения.
если нашему приложению хватает 65536 возможных вариантов для каждого значения, то два значения можно записать в переменную типа Integer.
Integer – хранит в себе 4 байта информации или 32 бита. В двоичной системе это вот столько:
1111 1111 1111 1111 1111 1111 1111 1111
65536 – это собственно половина Integer – 2 байта.

Для того чтобы сохранить числа X:43 (0010 1011) и Y:6435 (0001 1001 0010 0011), необходимо совершить следующие действия:
  1. Сместить 43 на 16 бит влево: получаем 0000 0000 0010 1011 0000 0000 0000 0000 или 2818048 в десятичной системе
  2. Добавить к полученному числу вторую координату: 2818048 + 6435 = 2824483. Однако, так как мы добавляем предыдущее значение в разряды заполненные нулями, можно воспользоваться операцией побитового «ИЛИ» ( | )
  3. Готово! Мы получили число 0000 0000 0010 1011 0001 1001 0010 0011. Можете проверить этот процесс в калькуляторе в режиме «Программист».
Для того чтобы раскодировать полученное значение, необходимо совершить следующие действия:
  1. Сместить код на 16 бит вправо. Так младшие два байта исчезнут, останутся только старшие два, которые будут соответствовать значению X:43
  2. Значение Y можно найти двумя способами:
    1. Самый примитивный, приходящий в голову людям, незнакомым с двоичной арифметикой – сместить значение X обратно, на 16 бит влево и вычесть полученное число из кода. Этот вариант дает возможность вычислить лишь последнюю часть кода.
    2. Более быстрым и "правильным" будет использование маски – побитового умножения «И» (&).
      Если совершить это действие над кодом с аргументом в 1111 1111 1111 1111 (65535), то мы получим именно последние два байта, соответствующие координате Y: 2824483 & 65535 = 6435.
      Для упрощения подобной операции рекомендуется использовать шестнадцатеричную запись числа 65535 – 0xFFFF. Это легко запомнить, ибо один байт заполненный единицами будет записан как 0xFF, три байта – 0xFFFFFF и т.д.

При всем при этом, существует проблема связанная с тем, что Integer – тип знаковый. И старший бит предназначен для хранения знака (0 – число положительное, 1 – отрицательное). В связи с этим, при декодировании происходит ошибка. Исправить этот эффект можно вычитая при кодировании из первой части кода половину максимально возможного кодируемого значения (для приведенного выше примера – это 65536 / 2 = 32768), а при декодировании, прибавления его к результату.

Пример кода:
public int Code (int x, int y) {
 return ((x - 32768) << 16) | y;
}

public int DecodeX (int code) {
 return (code >> 16) + 32768;
}

public int DecodeY (int code) {
 return code & 0xFFFF;
}
Следует учитывать, что полученный код может иметь отрицательное значение и потому не может быть использован, как индекс в массиве. Возможные решения:
  1. Хранение данных в коллекции ключ-значение
  2. Отказ от использования значений, приводящих к отрицательному коду (сокращение возможных значений вдвое)
  3. Использование типов данных бОльшего размера, для хранения кода, например long