ПрограммированиеСтатьиОбщее

Применение многопоточности в играх. (2 стр)

Автор:

Решение проблемы с командами и их параметрами

Очевидно, что так просто подобную проблему с командами и их параметрами не решить, но стоит вспомнить, что язык С++ предоставляет массу средств для сокрытия и обеспечения надёжности кода связанного с преобразованиями типов. Я имею в виду использование template.

Изображение
Схема процесса пересылки команды между секциями.

При отсылке команды происходит следующее:

1.  Секция источник вызывает темплейтную функцию SendCommand() и передаёт ей в качестве параметров команду и адрес секции приёмника.

Например так:

SendCommand( VFS::SET_BUFFER(DataPos,DataPos+size),to);
Где SendCommand() это:

template<class COMMAND_NAME>
void SendCommand(const COMMAND_NAME& command, const THREAD_GUID& to);

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

Пример функция класса буфера команд:

template<class T>
void AddCommand(const T& command, const THREAD_GUID& to) {
   //Проверка на наследование класса T от THREAD_COMMAND
   //т.к. в последствии придётся удалять T из буфера и 
   //будет нужен виртуальный деструктор
   static_cast<const THREAD_COMMAND*>(&command);

   //Выделение места в буфере под команду и др. данные
   COMMAND* cm=(COMMAND*)Insert(sizeof(T) + COMMAND_SIZE);

   //Инициализация
   cm->to=to;
   cm->size=sizeof(T);
   cm->ID=::GetID((T*)NULL);

   //Копирование команды в буфер
   new ((THREAD_COMMAND*)((BYTE*)cm + COMMAND_SIZE)) T(command);
}
  
struct COMMAND {
  THREAD_GUID to;
  DWORD size;
  DWORD ID;
};

enum { COMMAND_SIZE = sizeof(COMMAND) };

//базовый класс команды
class THREAD_COMMAND {
 public:
  virtual ~THREAD_COMMAND() {}
};

3.  Принимающий поток обращается в буфер команд, с помощью поля адресата находит у себя в массиве секцию получатель.

4.  Из секции извлекается таблица зарегистрированных ей ID команд и адресов функций для преобразования THREAD_COMMAND в требуемый тип.

5.  В таблице быстрым поиском находится ID команды равный ID в буфере. Под быстрым поиском я подразумеваю, что используется некий алгоритм для ускорения поиска. Например, хеш-функция, сортировка по ID, организация таблицы в виде сбалансированного дерева и т.п.

6.  После чего по найденному адресу функции преобразования делается её вызов с передачей ей в качестве параметра указателя на THREAD_COMMAND.

7.  Функция делает преобразование THREAD_COMMAND в необходимый тип и вызывает нужную функцию Reaction секции.

Пример реализации такой функции:

template <typename SECTION_NAME, typename COMMAND_NAME>
class T_RECEIVE_S : public CONFORM_S {
 public:
  static void TUntypedCommandS(SECTION* section, THREAD_COMMAND* command) {
    ((SECTION_NAME*)section)->Reaction(*((COMMAND_NAME*)command));
  }
};

Адрес именно этой функции хранится в таблице секции.

Естественно возникает вопрос, а как создаётся эта таблица функций? Для её создания используется механизм регистрации реакций секции. У меня в движке используется несколько вариантов такой регистрации, всех их я описывать не буду, поэтому для наглядности объясню только один наиболее простой.

Изображение
Схема регистрации реакций секции.

Порядок регистрации:

1.  В cpp файле после определения класса секции, объявляется (с помощью define) статическая переменная темплейтного класса от секции S1 и команды COMMAND_A.

2.  В своём конструкторе этот класс получает у секции (с типом S1) её таблицу команд и добавляет в неё запись. Запись содержит ID команды полученной от генератора и адрес статической функции для преобразования (см. код класса T_RECEIVE_S).
   
Таблица ID команд реакций может храниться следующим образом.

class SECTION {
  public:
    virtual TABLE_COMMANDS& GetTableCommands() = 0;
};

template <class SECTION_NAME>
class T_SECTION : public SECTION {
 public:
  virtual TABLE_COMMANDS& GetTableCommands() {
     static T_TABLE_COMMANDS<SECTION_NAME> TableCommands;
     return TableCommands;
  }
};

Вот так выглядит регистрация реакций секции TEST в программе.

class COMMAND_A: public THREAD_COMMAND {};
class COMMAND_B: public THREAD_COMMAND {};

class TEST : public T_SECTION<TEST> {

public:

  void Reaction(const COMMAND_A& command) {
  }
  void Reaction(const COMMAND_B& command) {
  }

};
 
SECTION_REACTION(TEST, COMMAND_A)
SECTION_REACTION(TEST, COMMAND_B)

Наконец осталось объяснить внутреннее устройство генератора ID и тему команд и их параметров можно закрывать.

class BASE_IDGEN {
 protected:
   static int nextid;
};

template<typename T>
class IDGEN : public BASE_IDGEN {
   int id;
 public:
  IDGEN() {
    //Проверка что если тип с таким именем typeid(T).name() уже был зарегистрирован, 
    //то присвоить ранее назначенный ему ID переменной id, иначе
    {
      id=nextid;
      ++nextid;
    }
  }
  operator int() const { return id; }
};

template <typename T>
struct ID_CLASS {
  static IDGEN<T> Idgen;
};

template <typename T>
IDGEN<T> ID_CLASS<T>::Idgen;

//Функция возвращающая идентификатор типа T
template <typename T>
DWORD GetID(const T*) {
  static ID_CLASS<T> id;
  return (int)id.Idgen;
}

Таким образом, чтобы получить ID например для типа COMMAND_A, достаточно вызвать функцию GetID((COMMAND_A *)NULL).

Простейшая МП программа на описанном движке.

Файл TestOne.cpp:

#include "Main.h"
#include "TestTwo.hpp"

int main( const int argc, const char* argv[] ) {
  printf("Begin MT Core\n");
  //Запуск движка с двумя потоками в пуле 
  MT_CORE::Start(2);
  printf("End MT Core\n");
  return 0;
}

//Описание секции TEST_ONE
class TEST_ONE : public MT_CORE::T_SECTION<TEST_ONE> {

 public:
  //Вызывается при старте секции
  virtual void Start() {
    printf("Start section one and send PRINT_WORLD\n");
    //Отослать команду PRINT_WORLD секции TEST_TWO
    // thisGUID() – адрес текущей секции
    SendCommand(PRINT_WORLD(thisGUID()),GUID_TEST_TWO());
    printf("HELO\n");
  }

  //Обработчик команды PRINT_OK
  void Reaction(const PRINT_OK& command) {
    printf("Receive PRINT_OK\n");
    //Требование завершить текущую секцию.
    thisExit();
  }

  //Вызывается при завершении секции
  virtual void Close() {
    printf("Close section one\n");
  }
};

//зарегистрировать реакцию на команду PRINT_OK в секции TEST_ONE
SECTION_REACTION(TEST_ONE,PRINT_OK)

//Запустить секцию TEST_ONE при старте движка на потоке 0
MT_CORE::T_AUTO_SECTION<TEST_ONE,0> test_one;

Файл TestTwo.hpp:

#ifndef TEST_TWO__HPP
#define TEST_TWO__HPP

//Команда для распечатки слова WORLD для секции TEST_TWO
struct PRINT_WORLD: public MT_CORE::THREAD_COMMAND {
  const MT_CORE::THREAD_GUID from;
  PRINT_WORLD(const MT_CORE::THREAD_GUID& from):from(from) {}
};

//Подтверждение распечатки для секции TEST_ONE
struct PRINT_OK: public MT_CORE::THREAD_COMMAND {
};

//Функция получения адреса секции TEST_TWO
const MT_CORE::THREAD_GUID& GUID_TEST_TWO();

#endif

Файл TestTwo.cpp:

#include "Main.h"
#include "TestTwo.hpp"

//Описание секции TEST_TWO
class TEST_TWO : public MT_CORE::T_SECTION<TEST_TWO>{

 public:
  //Вызывается при старте секции
  virtual void Start() {
    printf("Start section two\n");
  }

  //Обработчик команды PRINT_WORLD
  void Reaction(const PRINT_WORLD& command) {
    //Требование завершить текущую секцию.
    thisExit();
    printf("Receive PRINT_WORLD and send PRINT_OK\n");
    printf("WORLD\n");
    //Отослать подтверждение секции TEST_ONE
    SendCommand(PRINT_OK(),command.from);
  }

  //Вызывается при завершении секции
  virtual void Close() {
    printf("Close section two\n");
  }
};

//зарегистрировать реакцию на команду PRINT_WORLD в секции TEST_TWO
SECTION_REACTION(TEST_TWO,PRINT_WORLD)

//Запустить секцию TEST_TWO при старте движка на потоке 1
MT_CORE::T_AUTO_SECTION<TEST_TWO,1> test_two;

//Функция получения адреса секции TEST_TWO
const MT_CORE::THREAD_GUID& GUID_TEST_TWO() {
  return test_two.thisID();
}

Результат работы программы:

Begin MT Core

Start section one and send PRINT_WORLD
HELO
Start section two
Receive PRINT_WORLD and send PRINT_OK
WORLD
Receive PRINT_OK
Close section one
Close section two
End MT Core
Press any key to continue

Выводы

1.  Код программы не загрязнён различными вызовами синхро-объектов, что упрощает его понимание.

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

3.  Сокрытие кода (секция TEST_ONE ничего не знает о внутреннем устройстве секции TEST_TWO) позволяет не боятся необоснованного использования её внутренних функций и переменных из другой секции (в другом потоке).

4.  Возможность использования различных встроенных в движок средств мониторинга и автоматических проверок снижают вероятность непреднамеренного возникновения dead lock или live lock в программе, облегчают их обнаружение и отладку.

5.  Возможность быстрого переноса секций с одного потока на другой. Простота изменения количества потоков в пуле, без значительного переписывания кода программы. Позволяют влиять на степень предсказуемости поведения программы и облегчают замер разницы быстродействия между однопоточной (пул из одного потока) и её многопоточной версией.

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

7.  Помещение механизмов синхронизации и пересылки данных внутрь движка, без возможности прямого доступа к ним игрового программиста, создаёт удобства для проведения оптимизации работы этих механизмов без изменения всего кода программы.

Всё это хоть и не решает всех проблем использования МП в игре но, по крайней мере, позволяет успешно с ними бороться.

Пример увеличения быстродействия игр.

Пример увеличения быстродействия игр в однопроцессорных системах за счёт распараллеливания работы CPU и GPU.

Для наглядности я приведу упрощённую схему обмена командами между секцией рендера в одном потоке и секцией игры в другом.

Изображение
Схема обмена командами между рендером и игрой.

Описание работы рендера и игры.

1.  Рендер посылает игре команду о начале очередного игрового цикла и вызывает функцию прорисовки кадра GPU. Например, в случае DirectX это будет pD3DDevice->Present(), после чего поток рендера блокируется системой до окончания прорисовки кадра GPU, и процессорное время отдаётся другим потокам.

2.  Игра, получив уведомление о начале прорисовки, отдаёт остаток своего времени рендеру Sleep(0). Это делается для того, чтобы рендер успел начать прорисовку кадра и затем, при блокировании потока рендера, процессорное время снова было отдано игре. Игра производит обсчёт следующего кадра, пересчитывая физику положение объектов и т.п., с отсылкой команд рендеру о произошедших изменениях. Здесь надо особо отметить, что игра напрямую не работает с внутренними переменными рендера. Игре доступны только копии переменных и блоки данных не участвующие непосредственно в обработке кадра GPU.

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

4.  После выполнения pD3DDevice->Present() система возвращает управление потоку, и рендер начинает получать и обрабатывать команды игры связанные с текущим кадром. Завершив эту работу и получив от игры уведомление о завершении кадра, рендер посылает игре команду о начале следующего игрового цикла и снова вызывает функцию pD3DDevice->Present().

Таким образом, в работе программы возникает момент, когда CPU и GPU работают параллельно, т.е. CPU не ждет завершения выполнения операции на GPU. Величина этого времени сильно зависит от сложности сцены, текущего видеорежима и fps-а. В принципе в полноэкранном режиме при fps 60 кадров в секунду и средней по сложности для GPU сцене, такой метод распараллеливания  способен дать игре примерно 10 миллисекунд дополнительного времени на кадр. 

Заключение.

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

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

Хочу поблагодарить за идейную и моральную поддержку при написании МП движка Руслана Абдикеева,  а также за помощь в составлении статьи Козлова Ивана и Николая Лисуна.

Страницы: 1 2

#движок, #многопоточность

9 ноября 2003 (Обновление: 17 сен 2009)

Комментарии [138]