@Imaginer

Как в ировом движке на C++ распаралерить функции Update и Render?

В игровом движке, котрый я для обучения разрабатываю, есть класс Game, в котором есть методы Update(float dt) и Render(). Также в этом класе есть данные. Причем Update их изменяет, а Render только читает. Я хочу запустить Update в отдельном потоке. Тогда физика будет более правильной, например если Update выполнится 1000 раз в секунду, а рендер 60.
class Game
{
public:
	Game()
		:done(false)
		,pause(true)
		,drawDebugInfo(true)
		,FPS(0)
		,lastTickCount(0)
		,timeScale(0.1f)
		,GraviForce(false)
		,Collision(false)
		,numEntites(0)
		//,Enityes(nullptr)
		,render(nullptr)
		,input(nullptr)
		
	{
	};
	virtual bool Init(size_t numEntites_, Options option);
	//void Input(int key, bool press);

	bool Run();

        void Update(float dt);
	virtual void Draw();
	virtual void InputCheck();
	void End();

public:
	Render* render;
	Input* input;
	std::vector<Entity*> Entityes;
	size_t numEntites;
	static bool* keys;


//private:
	bool done;
	bool pause;
	bool drawDebugInfo;	
	size_t FPS;
	long long lastTickCount;
	float timeScale;
	bool GraviForce;
	bool Collision;

	float dt;

};


Я пробовал вынести функцию Upadate из класса и использовать mutex, но не получилось. Может использовать атоморные типы данных?
Буду благодарен за советы и критику.
  • Вопрос задан
  • 437 просмотров
Решения вопроса 3
wataru
@wataru Куратор тега C++
Разработчик на С++, экс-олимпиадник.
Если есть несколько потоков, то можно данные защищать через какой-нибудь mutex. Каждый поток перед тем как менять или читать данные, блокирует мьютекс, что-то быстрое делает, освобождает мьютекс. Лучше не держать его все время длинных вычислений, а, допустим, считать новые данные в локальных переменных, а потом в критической секции записать их в место, которое другой поток сможет читать.
Ответ написан
@dima20155
you don't choose c++. It chooses you
Это классическая задача читателей-писателей, способы решения этой задачи легко гугляся. Если вы хотите сделать функцию update потокобезопастной, то можете использовать любые доступные методы синхронизации. В данном случае нет серебряной пули и необходимо выбирать способ синхронизации самому. Вот пара простейших идей:

- Сделайте переменные, изменяемые в методе Update типа atomic.
- Сделайте доступ к данным через мьютекс (что в вашем конкретном примере не совсем эффективно, но очень просто в реализации).
- Если у вас предполагается лишь 1 поток (полагаю, что в данной задаче использование большего числа потоков не актуально), который будет обрабатывать функцию Update, то в начале функции вы можете сохранить все модифицируемые переменные (поля объекта) в локальные переменные и в конце функции взять мьютекс и сохранить значение локальных переменных в поля объекта.

Также интересно имеет ли смысл просчитывать физику объектов без ее отрисовки? Если данные физики качающего маятника используются лишь для отрисовки его на экране, то смысла в распараллеливании, насколько я понимаю, не очень уж много.
Ответ написан
jamakasi666
@jamakasi666
Просто IT'шник.
Imaginer, плавности добиваются не этим. Обычно опираются на дельту времени кадра и интеполируют значения, в идеале с последующей корректировкой через кадры.
Т.е. к примеру, у тебя 60 фпс, просчет физики fps\3 =20. Недостающие кадры ты интерполируешь (тупо высчитываешь следующую координату из предыдущей и вектора движения), на ключевом кадре когда произойдет симуляция физики(со смещением времени) сравниваешь текущее интерполируемое значение и просимулированное, в случае различия двигаешь все на просимулированные данные т.к. они корректны и точны. Ошибки будут но они зависят от сложности физики, числа взаимодействующих объектов и фпс физики.
Примерно так оно везде устроено если упрощенно, на самом деле могут еще и уменьшать число фпс физики в зависимости от дальности объекта от камеры и прочие прочие трюки.

По потокам, дроби свой метод update на более мелкие задачи которые можно запустить параллельно. К примеру:
- скайбокс на котором упрощенно двигаются облачка, самолетики и прочее, на геймплей не влияют как и на физики, можно спокойно вынести это в поток
- расчет звуковых симуляций, это не бросится в глаза при неточностях.
Раздербанивать опираясь на то какая у тебя игра именно, и чаще всего в потоки получится вынести очень малую часть от всего объема
Ответ написан
Комментировать
Пригласить эксперта
Ответы на вопрос 1
@OnePride
Делаю красиво
Не очевидно почему физика должна стать "плавнее". Начинать нужно с профайлинга, и понять сколько времени выполняются функции Update() и Render(), и уже от этого решать как параллелить, что параллелить, и нужно ли вообще. Так же нужно определиться как обновляется игровое состояние - всегда ли с фиксированным шагом dt (православно), или позволительно обновляться с плавающим dt (не православно) - в этом случае результаты симуляции не будут сходиться у игроков с разным ФПС, и это может сильно мешать прохождению игры.

Случай 1: время выполнения одного Update() много больше Render()
Тогда вынос Update() в отдельный поток профита практически не даст, т.к. почти весь кадр он же и съедает, а оверхэд связанный с многопоточностью перекроет весь теоретический прирост ФПС с вероятностью 146%. В этом случае надо распараллеливать внутри вызова Update() - обновлять игровой стейт в несколько потоков. Если каждый объект модифицирует только собственный стейт, то все параллелится элементарно.

Случай 2: время выполнения одного Render() много больше Update()
Здесь уже два варианта:
  1. можно попробовать обойтись без многопоточности, просто в начале кадра провернуть цикл с несколькими вызовами Update(), разбивая dt кадра на несколько шагов
  2. вынести Update() в отдельный поток, и там уже крутить цикл с фиксированным dt или плавающим. Там же ограничивать логический ФПС, чтобы не считать игровой стейт, например, с частотой 2000 Гц, и не жарить процессор юзера

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

В случае игрового движка, оптимальная схема будет выглядеть так:
Есть два независимых стейта - логический и графический.
Логический стейт - это вся физика, скрипты, эвенты, и тд - то что сейчас и есть.
Графический стейт - это минимально достаточный набор данных по каждому объекту логики, плюс состояние окружения, погоды, эффектов, и т.п. Этот стейт полностью описывает графический кадр, передается как аргумент в Render() и позволяет нарисовать весь кадр с нуля без обращения к логическому стейту. Этот стейт должен быть сериализуем, чтобы его можно было отложить в сторонку и через какое-то время нарисовать.
Так же у нас есть два потока - основной логический и основной рендерящий. В логическом потоке мы обновляем логический стейт, по необходимости по нему формируем графический стейт, после чего через тройную буферизацию передаем графический стейт в поток графики. Поток графики же всегда рисует самый актуальный графический стейт (предыдущие рисовать не имеет смысла), либо ждет, когда появится новый, если логика сильно тормозит.
В итоге имеет два потока работающих независимо, и единственное место синхронизации - это свап указателей/индексов в triple buffer, который сделан через атомики, что практически бесплатно (плюс ожидание на семафоре или спинлоке в потоке графики, если логика не успевает). В зависимости от задачи можно сделать чуть более жесткую синхронизацию, чтобы логический поток также ждал поток график, как сделано в большинстве движков, когда логический поток считает N кадр, а графический поток рисует (N-1), в остальных случаях один из потоков будет висеть на семафоре...

Псевдокод:
LogicObject { position, velocity, script, health, ammo,... }
//соответствующий LogicObject графический объект:
GraphicObject { position, shape, color, opacity, animationPhase...}

LogicState { time, vector<LogicObject > entites,....}

GraphicState { logicTime, camerPosition, cameraFOV, cameraAspect, ... , skyBoxID,  vector<GraphicObject> objects,...}


LogicState gameState;
TrippleBuffer<GraphicState> graphicStateBuffer;//3 графических стейта

//логический поток:
{    
    while(true)
    {
        Update(dt, gameState);

        GraphicState graphState = buildGraphState(gameState);
        graphicStateBuffer.write(graphState);
    }
}

//графический поток:
{    
    while(true)
    {
        //для простоты считаем что метод блокирующий, когда нет нового стейта
        bool bNewFrame = graphicStateBuffer.update();

        if(bNewFrame)
            Render(graphicStateBuffer.read());
    }
}


Если делать прям совсем на красоту, то можно сделать экстраполяцию позиций графических объектов. У каждого GraphState мы знаем время логического стейта, для которого он был запечен, и это время всегда в прошлом. Соответственно позиции графических объектов в начале кадра графики экстраполируются на разницу между реальным временем начала рендеринга и временем, когда этот графический стейт был сформирован. В этом случае к графическому стейту прилипают скорости всех объектов...
Ответ написан
Комментировать
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Похожие вопросы