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

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

Геймплей

Если вы хотите понимать, как на самом верхнем уровне абстракции 99% игровых движков крутит любую игру, вот вам самый простой и понятный код:

while(true)
{
    // считаем dt
    double dt = ...

    // игровой тик
    handleInput();
    update(dt);
    render();
}

Самая важная с точки зрения геймплея вещь здесь — это функция update(), которая выполняет нечто вот такое:

void update(double dt)
{
    updateGameTime(dt);
    updateWeather(dt);
    updatePlayer(dt);
    updateMonsters(dt);
    updateProjectiles(dt);
    updateHud(dt);
    updateSkills(dt);
    ...
}

Геймдев-разработчики, не пинайте меня сильно за такой наивный пример — я просто пытаюсь доступно объяснить на пальцах :)

То есть что происходит: каждый игровой тик, который, как правило, занимает несколько десятков миллисекунд (тот самый dt), все игровые подсистемы симулируются на этот маленький промежуток времени вперед. Игровое время успевает продвинуться на пару секунд; солнце смещается на несколько десятых градуса; игрок перемещается на небольшое расстояние на уровне; какой-то монстр успевает получить пулю, летевшую до него несколько десятков тиков.

Как видите, происходит много всего и сразу. Но постойте — где в этом коде многопоточность?

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

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

Почему в геймплейном коде нет потоков?

Глобальное состояние игры

Да простят порочный геймдев остальные разработчики, но код игр — это царство синглтонов и глобального состояния. Игровой уровень, менеджер времени, дерево скиллов, аудио-проигрыватель, HUD, инвентарь игрока, текущая прогрессия игры, квестовое дерево, дневник — все эти жизненно важные для игры штуки как правило представлены в одном экземпляре.

Что еще хуже — все эти штуки нужны всем отовсюду. Что еще хуже — они нужны всем отовсюду одновременно. Точнее так: если геймплейные задачи исполнялись бы параллельно в разных потоках, то тогда все ваши глобальные объекты нужны будут всем отовсюду, еще и по-настоящему одновременно.

Двум параллельным задачам в какой-то момент понадобится инвентарь игрока — и вот уже один поток конкурирует с другим за право эксклюзивного владения инвентарем. Один поток прорвется к инвентарю первее, второму останется просто замереть и ждать, чтобы не учинить data race. И так каждый раз, и так все время. Ваши потоки будут простаивать и ждать другие потоки, занявшие уникальный игровой ресурс, и все выродится в однопоточное исполнение, но в особо извращенной форме. Вы можете еще и потерять в скорости, ведь создание потока и переключение контекста — недешевая операция.

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

...код, использующий глобальные переменные, не будет потокобезопасным...

статья Глобальная переменная

...Усложняется контроль за межпоточными гонками и задержками...

статья Одиночка (шаблон проектирования)

Зависимости

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

void update(double dt)
{
    static std::vector<std::thread> jobs;

    // Запускаем потоки
    jobs.emplace_back(updateGameTime, dt);
    jobs.emplace_back(updateWeather, dt);
    jobs.emplace_back(updatePlayer, dt);
    jobs.emplace_back(updateMonsters, dt);
    jobs.emplace_back(updateProjectiles, dt);
    jobs.emplace_back(updateHud, dt);
    jobs.emplace_back(updateSkills, dt);

    // Дожидаемся выполнения всех потоков
    for (auto& job : jobs) {
        job.join();
    }
    jobs.clear();
}

Но даже без необходимости делить общие данные геймплейные системы имеют тенденцию принимать себе на вход данные, которые должна подготовить/обновить другая геймплейная система. То есть, мы сталкиваемся с тем, что системы зависят друг от друга. Некоторые звуки воспроизводятся вследствие проигрывания анимаций, классический пример — звуки шагов. Система смены погоды зависит от системы игрового времени. Система передвижения игрока зависит от системы, обработавшей сырой ввод с клавиатуры/мыши/контроллера. В итоге мы имеем картину, где все системы жестко переплетены друг с другом, работа одной системы зависит от другой, и в итоге всю линейку систем желательно выполнять в определенном порядке.

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

Текущая игровая ситуация: сейчас 13:59 игрового времени, жуткий зной, животное при смерти: остался крайне малый запас здоровья. У нас наступает время симулировать следующий тик игры, и мы параллельно запускаем симуляцию трех игровых систем: игрового времени, погоды и поведения животных. Так как мы выполняем системы одновременно, мы не знаем, в каком порядке какое событие произойдет: выполнится ли сначала просчет погоды, а затем пересчет игрового времени — или наоборот? Теперь это нельзя сказать наверняка, и каждый раз порядок будет другой, случайный.

В этот конкретный тик потоки выполнились так, что сначала посчиталась погода: она посмотрела на игровое время (а оно еще не успело измениться!) и решила, что т.к. очередной час еще не прошел, погода должна остаться прежней. Потом через мгновение поток с игровым временем успел изменить время на 14:01, но для последнего потока, в котором решается судьба животного это уже не будет иметь значения — т.к. зной продолжился, у существа отняли последний остаток HP, и оно погибло.

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

"Но ведь здесь максимальный лаг между системами составит всего лишь один тик!" — скажете вы, и в общем-то будете правы — это не самая большая проблема. Хуже то, что мы сделали поведение игры недетерминированным — т.е. теперь из раза в раз одни и те же условия могут приводить к разным результатам. При одних и тех же условиях у вас животное один раз выживает, а другой раз — уже нет. А если у вас вдруг возникнет какой-то хитрый баг, связанный с определенным порядком выполнения игровых систем, то удачного вам воспроизведения и дебаггинга в этом многопоточном клубке.

В сухом остатке

И что же — в игровом геймплее многопоточности нет? Выходит, что так. Если вы устроитесь на работу программистом игровой логики, то многопоточностью вы заниматься будете в редких случаях. Лично я в явном виде создавал треды в геймплее примерно никогда за свой шестилетний геймплейный стаж и всего несколько раз наблюдал, как такими разовыми работами занимаются коллеги.

По той же причине, например, те же Blueprints в Unreal Engine работают исключительно в главном потоке.

Но знаете что?..

Я вам соврал

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

ECS

Этот парень очень хочет сказать, как вы не правы, и что в геймдеве "вообще-то" существует ECS
Этот парень очень хочет сказать, как вы не правы, и что в геймдеве "вообще-то" существует ECS

Итак, ECS — про него не писал только ленивый, и может показаться, что в игровой индустрии это стандарт де-факто, и если вы — серьезный игровой движок, вы просто не можете не использовать ECS по умолчанию. На самом деле это не так, но обо всем по порядку.

Что такое ECS? В подробности углубляться не стану, но попробую описать вкратце разницу с классическим component-based подходом. Обычно в Unreal Engine или Unity ваши игровые сущности — это акторы, к которым прикреплены компоненты с логикой и данными:

Два актора в component-based подходе
Два актора в component-based подходе

И обрабатывается это движком в функции update() примерно на такой манер (извиняюсь, но с C++ я сейчас перейду на псевдокод, чтобы было нагляднее):

func update(dt):
	for actor in scene.actors:
		for component in actor:
			component.update(dt);

В парадигме ECS сущности архитектурно представлены хитрее:

Два актора в ECS подходе
Два актора в ECS подходе

Акторов нет — вместо них есть сущности (entities). Они похожи по смыслу на акторы, потому что за сущностью закреплен набор компонентов, на которые она ссылается. Но ECS-компоненты (components), в отличие от компонентов из предыдущего примера, не имеют никакой логики — только данные. За логику отвечают системы (systems). И выглядит обработка всего этого добра как-то так:

func update(dt):
	for system in ecs.systems:
		for entity in ecs.getAllEntitiesFor(system):
			system.update(entity.getComponents(), dt);

Т.е., например, система AIMovementSystem, когда дойдет очередь до ее обновления, возьмет все сущности в игре, у которых есть компоненты AI и Position и просимулирует их всех разом.

— Где потоки?

Да, пока мы их не видим, но так как системы (т.е. поведение) сепарированы от компонентов (т.е. данных), у нас есть бóльший простор для параллельного выполнения. И разработчики ECS-систем зачастую дают возможность распараллеливать симуляцию систем по двум направлениям:

  • Каждую систему можно попробовать обрабатывать параллельно (внешний цикл for)

  • Каждый набор компонентов можно обрабатывать параллельно (внутренний цикл for)

— А почему мы не могли таким же способом распараллелить два цикла for из псевдокода для component-based подхода?

А вот в этом и вся соль. Точно так же могли бы распараллелить. Но так как независимо от подходов — будь то EC или ECS подход — у нас никуда не девается глобальное состояние и зависимости, которые мы обсуждали в самом начале, распараллеливание в итоге оказывается очень ограниченным. Просто у ECS в более явном виде есть информация о зависимостях между системами и о том, каким системам требуются общие компоненты, а у каких систем пересечений по компонентам нет. Если зависимости одной системы от другой нет, ровно как и пересечений по необходимым компонентам — у нас есть поле для маневра и распараллеливания, и разработчики ECS подхода такие случаи, как правило, и пытаются распараллелить на разные потоки.

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

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

— Зачем тогда этот ваш ECS существует, если с него не поиметь профита?

Профит-то поиметь — но не там, где мы ищем. Потому что многопоточность — это не основная киллер-фича ECS, а скорее приятный бонус. Основная выгода ECS хорошо видна на диаграмме, которую я привел выше — посмотрите как красиво и в рядочек там в памяти лежат компоненты. Так вот они точно так же стройно и красиво ложатся в кеш, и в итоге мы получаем стройный, быстрый cache-friendly подход, при котором и имеем основное ускорение на CPU.

Внимание, некоторые реализации ECS-архитектуры могут отличаться, и компоненты в памяти будут располагаться иначе. Например, в Archetype-based реализациях ECS, подробнее можно прочитать здесь.

Небольшой оффтоп про ECS в движках

По поводу ECS как флагмана в индустрии — это, конечно, cutting-edge технология, но игровые движки как правило предлагают ECS-решения как что-то дополнительное, на случай если разработчики действительно упираются в производительность при работе с большим количеством объектов. Unity может предложить вам DOTS, Unreal — Mass Entity. Я знаю только движок Bevy, который строится строго и безальтернативно вокруг ECS-подхода. Еще был Amethyst, но он пару лет назад схлопнулся и посоветовал всем уходить на Bevy. Оба движка на Rust. Godot так вообще открестился от ECS и выпустил статью о том, почему у них нет и не будет ECS.

Тяжелые вычисления

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

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

  • Хотим читать данные из глобального объекта, но не хотим чтобы потоки постоянно боролись за общие данные? Можно попробовать на старте потоков в тупую скопировать срез данных на момент запуска потоков. Чаще всего это не так дорого и избавляет нас от бремени синхронизации между потоками;

  • Хотите, чтобы потоки и читали и писали в глобальный объект, но при этом чтобы без больших накладных расходов на синхронизацию, и чтобы без data-races? Добро пожаловать в увлекательный мир lock-free структур данных — сложно, трудно, но эффективно;

  • У вас C зависит от B, который зависит от A, поэтому вы не можете запустить их параллельно? Грустно, но посмотрите, может быть вы можете распараллелить сами A, B и C, т.е. параллельно запустить A1, A2 и A3;

  • и т.д.

AI задачи в RTS и пошаговых стратегиях

В пошаговых стратегиях отдельное время в игровом процессе занимает ход AI-противников. Зачастую просчет их ходов занимает очень большое количество ресурсов и времени. В той же Цивилизации ход противников может занимать десятки секунд, и это норма. Само собой разработчики пытаются минимизировать время, за которое будет происходить ход компьютера. И в первую очередь это выполняется за счет параллелизации всего, что только возможно. Потоки здесь идут в ход только так.

Процедурная генерация

Генерация мира — тоже тяжеловесная вещь, которую тоже можно отдать на откуп многопоточному исполнению. Опять-таки, тут уже все зависит от конкретной игры и ситуации, но как минимум, если у вас условно бесконечный мир аля Майнкрафт, вы можете на фоне генерировать следующую часть мира, если игрок вот-вот подходит к краю существующих биомов. Тогда, если игрок резко устремится еще дальше в неизведанную часть, он не получит ощутимого подлагивания от того, что возникла срочная необходимость догенерировать контент.

Maintenance

В онлайн-играх, да кстати и не только в них, время от времени может проводиться maintenance — служебные процедуры любого характера, которые предназначены для починки/очистки/оптимизации игры или игрового мира. Это может быть сбор и удаление трупов; сброс состояний NPC; прирост ресурса, который можно фармить; оптимизации террейна, построек, окружения; перерасчет навмешей; проводка денежных операций — как внутриигровой валюты, так и операций с монетизацией.

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

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

Игра или движок?

А теперь запоздало зададимся вопросом — а мы сейчас все-таки говорим про игру или про движок игры? Мы вовремя подняли этот вопрос — все это время я пытался говорить про геймплейный код, т.е. по возможности не обсуждать то, что под капотом делает движок, а оставаться в рамках кода игры.

Но по правде едва ли возможно провести четкую границу между первым и вторым. Исполняемый файл, который вы запускаете, чтобы поиграть в игру, содержит машинный код и движка, и геймплейного кода, написанного на этом движке.

Почему граница нечеткая? Ну вот в Unreal вы можете создавать собственные компоненты для актора специфичные для вашей игры. Но при этом движок предоставляет вам обширный набор из компонентов общего назначения, которые разработчики движка уже заботливо создали за вас и для вас. Вопрос: т.е. движковый MovementComponent — это все еще считается кодом движка, а ваш лично написанный PonyAnnigilatorComponent — это уже код игры? Как-будто бы и да, и нет. Граница размыта. То же с ECS: да, в рамках этой парадигмы вы пишете свои игровые системы. Но ведь в движке 100% будут написаны базовые системы, такие как Movement или TransformHierarchy.

Еще бывает так, что в код движка вкостыливают вещи специфичные для конкретной игры. Это нередкий случай: я участвовал в разработке игры, где был форкнут опен-сорсный движок, и код игры большой своей частью писался буквально тут же — в коде движка.

Тем не менее худо-бедно мы все же можем показать на те части, которые точно движок, а не геймплей и наоборот. Вот титаническая схема устройства типичного игрового движка глазами Джейсона Грегори, которую он описал с своей книге Game Engine Architecture:

Впечатляет, правда? Так вот технически только самый верхний уровень абстракции — Game-Specific Subsystems — можно отнести к игре. Подводная же часть айсберга — игровой движок, на плечах которого все держится.

Также на диаграмме можно заметить один из низкоуровневых кирпичиков, на которых держится махина игровых систем — "Threading Library", предоставляющая удобные платформо-независимые механизмы управлениями потоками. Этот кирпичик здесь совсем не случайно, поскольку код игрового движка в отличие от геймплейного кода пытается использовать потоки достаточно обширно.

Тут еще стоит пояснить, что в движке как правило потоки создаются заранее в определенном количестве, образуя Thread Pool. Изначально все созданные потоки просто спят и ждут, пока кому-то не потребуется многопоточность. Делается так по той причине, что создание и удаление потока — штука дорогостоящая, требующая от операционной системы не самых тривиальных телодвижений; поэтому чем их будет меньше, тем лучше.

Ну а поверх этого пулла потоков, как правило, строится система Task'ов или Job'ов, позволяющая абстрагироваться от пулла потоков и просто просить систему: "Хочу выполнить вот этот кусок кода в отдельном потоке". А система сама разбудит один из потоков в пулле и заставит его работать.

Давайте пробежимся по некоторым типовым местам в движке, где можно найти применение потокам.

Аудио

Аудио-рендер легко вынести в постоянно работающий отдельный поток, поскольку от результата выполнения этой системы как правило не зависят остальные системы.

Вы просто будете засылать в этот поток звуки, которые нужно воспроизвести, а дальше все будет исполняться на фоне.

Физика

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

Физический движок выполняется в два основных этапа:

  • ищет коллизий/столкновения объектов;

  • разрешает каждое из столкновений — вычисляет, какие скорости и их направления приобретают столкнувшиеся объекты. Каждым таким разрешением ситуации занимается solver.

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

БД

База данных — еще один типовой кандидат на работу в отдельном потоке. Запрос в базу может быть тяжелым, и мы никак не хотим, чтобы он блокировал нам основной поток. Точно так же и сама БД — зачастую важный ресурс, и требуется, чтобы он отрабатывал быстро, и ему никто не мешал. Для онлайн игры, например условной MMO, основная база данных — это в общем-то аналог "сохранения" в синглплеерной игре. И это "сохранение" объемное, прожорливое и критическое для работы вообще всей игры. Поэтому развести в стороны основной поток игры и поток базы данных — обычно дело обоюдно удобное, чтобы никто никому не мешал.

Из любопытного: в пору, когда я работал над MMO, интересным для меня стал факт, что с точки зрения геймплейного кода операция чтения из базы данных дороже, чем операция записи в нее. При записи в БД вы просите что-то записать, оно уходит в поток базы данных, а вы идете дальше по своим делам. При попытке же прочитать что-то из базы вам придется запросить данные, дождаться, пока отработает запрос из базы, потом дождаться, пока данные придут в главный поток — совсем недешево.

Сервер-клиент

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

Код игры теперь "разорван" на две части:

  • Сервер — это оригинал игры, но у него нет рендера, графического интерфейса и инпута;

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

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

Логирование

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

Рендер

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

GPU

Когда мы говорим про многопоточность, мы в 99% случаев имеем в виду ядра процессора. Но ведь у видеокарт тоже есть ядра. Более того, их в сотни раз больше, чем у CPU. На GPU количество ядер исчисляется тысячами. Видеокарты — это буквально синоним многопоточности и параллелизации.

Зачем видеокартам так много ядер? Я не самый большой эксперт в рендеринге, поэтому расскажу широкими мазками. На видеокартах исполняются шейдеры. Шейдерный код — это код, который должен одновременно выполниться для каждого пикселя, чтобы получился итоговый результат. Это если мы говорим про пиксельные шейдеры. Шейдеры других типов выполняют вычисления другого характера, но одно остается неизменным — шейдер будет выполняться видеокартой параллельно для каждого пикселя/вертекса/треугольника.

Это кстати по определению выделяет рендер-программистов в особую касту, поскольку принципы написания шейдерного кода сильно отличаются от того, что вы видите в обычной CPU-разработке. Шутка ли написать один код так, чтобы он выполнился для каждого пикселя, и получился какой-то осмысленный разный для каждого пикселя результат.

Примечание: когда я говорю "пиксель" в разрезе шейдерного кода, я применяю не совсем корректный термин, поскольку это не совсем тот пиксель, который у вас на мониторе. То, чем оперирует шейдер, правильно называть "фрагмент", а пиксельные шейдеры называть "фрагментными шейдерами".

Тут грех не написать и про compute shaders — шейдеры для произвольных, неграфических вычислений. Такие шейдеры нужны для того, чтобы работу, которая обычно выполняется на CPU, выполнить на GPU и получить с этого многократный профит, т.к. у видеокарт на порядок-два больше ядер, а следовательно они намного эффективнее выполнят любую работу, которая может быть эффективно распараллелена. Плюс вспомним про майнинг как про типичный неграфический процесс, с которым GPU справляется сильно лучше CPU из-за большого количества ядер.

You name it

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

Итоги

А к чему мы в итоге пришли, что эта статья нам дает? Мне кажется, в ней каждый найдет что-то свое, а я хочу сверху накинуть еще вот этого:

  • Потоки в играх — они не там, где кажутся;

  • Многопоточность совершенно не всегда делает код быстрее. Иногда может стать даже хуже, чем было в однопоточном варианте;

  • Ваши игры с большой вероятностью станут играться шустрее при переходе на процессор с бóльшим количеством ядер. Но прирост FPS будет далеко не пропорционален приросту ядер. Какие-то игры и вовсе будут играться на том же уровне производительности, что и прежде;

  • Видеокарты решают очень многое, и добрая часть многопоточности — в них;

  • Став программистом геймплейной логики, вы, возможно с удивлением, обнаружите, что ваша работа едва ли когда-то будет выходить за пределы основного потока;

  • Став программистом игрового движка, вы, возможно с удивлением, обнаружите, что ваша работа будет часто выходить за пределы основного потока;

  • Писать игры сложно и без потоков;

  • Исполняемый файл с игрой на 85% (цифра конечно же взята с потолка) состоит из кода игрового движка, а не самой игры;

  • Движок на 85% (цифра конечно же взята с потолка) переопределит, с какой интенсивностью ваше итоговое приложение будет использовать многопоточность;

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

Комментарии (7)


  1. MountainGoat
    06.08.2024 21:10
    +1

    Рассказал бы это всё кто-то ElectronicArts, а то их последней нетленке не хватает Ryzen 3950Х для 60 fps.


  1. V1tol
    06.08.2024 21:10

    Я знаю только движок Bevy, который строится строго и безальтернативно вокруг ECS-подхода. Еще был Amethyst, но он пару лет назад схлопнулся и посоветовал всем уходить на Bevy.

    Есть Fyrox как пример движка на Rust без ECS. Выглядит поинтереснее и пофичастее Bevy.


  1. SadOcean
    06.08.2024 21:10
    +3

    Спасибо за статью, не то чтобы узнал нечто новое, но обзорно она очень неплоха.

    По поводу геймплейного кода и многопоточности могу добавить, что помимо усложнения кода многопоточность не очень оправдана с точки зрения производительности.

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

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

    При этом рендер кустов, анимации и частицы отбирают то же процессорное время, столкновения пулек считает физика - это уже оптимизированные компоненты движка, распаралелленные и т.д.

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

    Конечно есть и исключения, скажем игры типа факторио. Тут может помочь ecd. Последний кстати написан без ecs на плюсах и просто имеет хорошую структуру и оптимизацию для объектов


    1. AskePit Автор
      06.08.2024 21:10

      А можно подробнее про ecd? Не могу нагуглить ничего по этой аббревиатуре


      1. SadOcean
        06.08.2024 21:10

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


  1. SadOcean
    06.08.2024 21:10

    .


  1. Andrey_Solomatin
    06.08.2024 21:10

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