[Перевод] Создание зомби-шутера от третьего лица с помощью DOTS

Салют, хабровчане. Как мы уже писали январь богат на новые запуски и сегодня мы анонсируем набор на новый курс от OTUS — «Разработчик игр на Unity». В преддверии старта курса делимся с вами переводом интересного материала.

o-2diz_bo0cjb7cksohmc8zeoxc.png

Мы пересобираем ядро Unity с помощью нашего стека технологий, ориентированного на данные (Data-Oriented Tech Stack). Как и многие игровые студии мы тоже видим большие преимущества в использовании Системы Entity Component (ECS), Системы задач C#(C# Job System) и Компилятора Burst. На Unite Copenhagen у нас появилась возможность пообщаться с Far North Entertainment и углубиться в то, как они реализуют этот функционал DOTS в традиционных проектах на Unity.

Far North Entertainment — шведская студия, совладельцами которой являются пять друзей-инженеров. С момента релиза Down to Dungeon for Gear VR в начале 2018 года компания работает над игрой, которая принадлежит классическому жанру игр для ПК, а именно над постапокалиптической игрой в режиме выживания про зомби. Что выделяет проект на фоне других, так это количество зомби, которые гонятся за вами. Видение команды на этот счет рисовало тысячи голодных зомби, идущих за вами огромными ордами.

Однако, они быстро столкнулись с большим количеством проблем производительности уже на этапе прототипирования. Создание, смерть, обновление и анимация всего этого количества врагов оставалось главным бутылочным горлышком даже после того, как команда попыталась решить проблему с помощью oblect pooling и animation instancing.

Это заставило технического директора студии Андреса Эрикссона обратить внимание на DOTS и сменить тип мышления с объектно-ориентированного на ориентированное на данные. «Ключевая идея, которая помогла осуществить этот сдвиг, заключались в том, что нужно было перестать думать об объектах и иерархиях объектов и начать думать о данных, о том, как они преобразуются и о том, как получать к ним доступ», — сказал он. Его слова значат, что не нужно строить архитектуру кода с прицелом на объекты реальной жизни, таким образом, чтобы она решала самую общую и абстрактную задачу. У него есть много советов для тех, кто также, как и он, столкнулся с изменением мировоззрения:

«Спросите себя, в чем заключается реальная проблема, которую вы пытаетесь решить, и какие данные важны для получения решения. Будете ли вы раз за разом одинаковым образом преобразовывать один и тот же набор данных? Сколько полезных данных вы можете уместить в одну строку кэша процессора? Если вы вносите изменения в существующий код, оцените сколько мусорных данных вы вносите в строку кэша. Можно ли разделить вычисления на несколько потоков или нужно использовать одиночный поток команд?»

Команда пришла к пониманию того, что сущности в Системе Компонентов Unity — это просто поисковые идентификаторы в потоках компонентов. Компоненты — это просто данные, в то время как системы содержат всю логику и отфильтровывают сущности с определенной сигнатурой, известные как архетипы. «Я думаю, что одним из озарений, которое помогло нам визуализировать наши задумки, было представление ECS в виде базы данных SQL. Каждый архетип — это таблица, в которой каждый столбец — это компонент, а каждая строка — уникальная сущность. По сути вы используете системы для создания запросов для этих таблиц архетипов и выполняете операции над сущностями», — говорит Андерс.

Знакомство с DOTS


Чтобы прийти к этому пониманию, он изучил документацию по системе Entity Component, примеры ECS и пример, который мы сделали совместно с Nordeus и представили на Unite Austin. Общие материалы об архитектуре, ориентированной на данные, также были очень полезны команде. «Доклад Майка Эктона об архитектуре, ориентированной на данные с CppCon 2014 — это именно то, что первым открыло нам глаза на этот способ программирования.»

Команда Far North опубликовала то, что они узнали в своем Dev Blog, в сентябре этого года они приехали в Копенгаген, чтобы рассказать о своем опыте перехода к подходу, ориентированному на данные, в Unity.

Эта статья основана на докладе, она более подробно объясняет специфику их реализации ECS, Системы задач C# и компилятора Burst. Еще команда Far North любезно поделилась большим количеством примеров кода из своего проекта.

Организация данных зомби


«Проблема, с которой мы столкнулись, заключалась в выполнении интерполяции перемещений и вращений для тысяч объектов на стороне клиента», говорит Андерс. Их изначальный объектно-ориентированный подходом заключался в создание абстрактного скрипта ZombieView, который унаследовал общий родительский класс EntityView. EntityView — это MonoBehaviour, присоединенный к GameObject. Он действует как визуальное представление игровой модели. Каждый ZombieView отвечал за обработку своего собственного перемещения и интерполяции вращения в своей функции Update.

Это звучит нормально, до того момента, пока вы не поймете, что каждая сущность располагается в памяти в произвольном месте. Это означает, что, если вы обращаетесь к тысячам объектов, ЦП должен доставать их из памяти по одному, и происходит это крайне медленно. Если вы складываете свои данные в аккуратные блоки, расположенные последовательно, процессор может кэшировать целую кучу данных одновременно. Большинство современных процессоров могут получать из кэша около 128 или 256 бит за один цикл.

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

[Serializable]
public struct PositionData2D : IComponentData
{
    public float2 Position;
}
 
 
[Serializable]
public struct HeadingData2D : IComponentData
{
    public float2 Heading;
}
 
[Serializable]
public struct TargetPositionData : IComponentData
{
    public float2 TargetPosition;
}

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

В проекте используются префабы для определения архетипов, поскольку для врагов требуется больше компонентов, а некоторые из них нуждаются в ссылках на GameObject. Работает это так, что вы можете обернуть данные вашего компонента в ComponentDataProxy, который превратит его в MonoBehaviour, который в свою очередь можно присоединить к префабу. Когда вы создаете экземпляр с помощью EntityManager и передаете префаб, он создает сущность со всеми данными компонентов, которые были прикреплены к префабу. Все данные компонента хранятся в 16-килобайтных чанках памяти, называемых ArchetypeChunk.

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

mhcnr9qjgpsr6lkej2l0ezq0jmi.png

«Одним из основных преимуществ чанков архетипов является то, что вам не нужно часто заново аллоцировать кучу при создании новых объектов, так как память уже была выделена заранее. Это означает, что создание сущностей представляет из себя запись данных в конец потоков компонентов внутри чанков архетипа. Единственный случай, когда необходимо выполнить аллоцирование кучи снова — это при создании сущности, которая не вписывается в границы чанка. В этом случае либо будет инициировано выделение нового чанка архетипа размером 16 КБ, либо, если есть пустой фрагмент того же архетипа, его можно использовать повторно. Затем данные для новых объектов будут записаны в потоки компонентов нового чанка», — объясняет Андерс.

Многопоточность ваших зомби


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

Следующим шагом было создание системы, которая отфильтровывала все сущности из всех блоков архетипов, имеющих компоненты PositionData2D, HeadingData2D и TargetPositionData.

Для этого Андерс и его команда создали JobComponentSystem и сконструировали свой запрос в функции OnCreate. Это выглядит примерно так:

private EntityQuery m_Group;

protected override void OnCreate()
{
	base.OnCreate();

	var query = new EntityQueryDesc
	{
		All = new [] 
		{
			ComponentType.ReadWrite(),
			ComponentType.ReadWrite(),
			ComponentType.ReadOnly()
		},
	};

	m_Group = GetEntityQuery(query);
}

Код объявляет запрос, который отфильтровывает все объекты в мире, имеющие позицию, направление и цель. Далее они хотели распланировать задания для каждого фрейма с помощью системы задач C#, чтобы распределить вычисления по нескольким рабочим потокам.

«Самое классное в системе задач C# то, что это та же система, которую Unity использует в своем коде, поэтому нам не нужно было беспокоиться о том, что исполняемые потоки блокируют друг друга, требуя одни и те же ядра процессора и вызывая проблемы с производительностью.», говорит Андерс.

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

Каждый фрейм новая задача UpdatePositionAndHeadingJob отвечает за обработку интерполяции позиций и поворотов врагов в игре.

Код для планирования заданий выглядит следующим образом:

protected override JobHandle OnUpdate(JobHandle inputDeps)
{
	var positionDataType       = GetArchetypeChunkComponentType();
	var headingDataType        = GetArchetypeChunkComponentType();
	var targetPositionDataType = GetArchetypeChunkComponentType(true);

	var updatePosAndHeadingJob = new UpdatePositionAndHeadingJob
	{
		PositionDataType = positionDataType,
		HeadingDataType = headingDataType,
		TargetPositionDataType = targetPositionDataType,
		DeltaTime = Time.deltaTime,
		RotationLerpSpeed = 2.0f,
		MovementLerpSpeed = 4.0f,
	};

	return updatePosAndHeadingJob.Schedule(m_Group, inputDeps);
}

Так выглядит сама задача:

public struct UpdatePositionAndHeadingJob : IJobChunk
{
    public ArchetypeChunkComponentType PositionDataType;
    public ArchetypeChunkComponentType HeadingDataType;

    [ReadOnly]
    public ArchetypeChunkComponentType TargetPositionDataType;

    [ReadOnly] public float DeltaTime;
    [ReadOnly] public float RotationLerpSpeed;
    [ReadOnly] public float MovementLerpSpeed;
}

Когда рабочий поток извлекает задание из своей очереди, он вызывает ядро ​​выполнения этого задания.

Вот как выглядит ядро ​​исполнения:

public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
	var chunkPositionData       = chunk.GetNativeArray(PositionDataType);
	var chunkHeadingData        = chunk.GetNativeArray(HeadingDataType);
	var chunkTargetPositionData = chunk.GetNativeArray(TargetPositionDataType);
		   
	for (int i = 0; i < chunk.Count; i++)
	{
		var target       = chunkTargetPositionData[i];
		var positionData = chunkPositionData[i];
		var headingData  = chunkHeadingData[i];

		float2 toTarget = target.TargetPosition - positionData.Position;
		float distance  = math.length(toTarget);

		headingData.Heading = math.select(
			headingData.Heading,
			math.lerp(headingData.Heading, 
					math.normalize(toTarget), 
					math.mul(DeltaTime, RotationLerpSpeed)),
			distance > 0.008
		);

		positionData.Position = math.select(
			target.TargetPosition,
			math.lerp(
				positionData.Position, 
				target.TargetPosition, 
				math.mul(DeltaTime, MovementLerpSpeed)),
			distance <= 1
		);

		chunkPositionData[i] = positionData;
		chunkHeadingData[i]  = headingData;
	}
}

«Вы можете заметить, что мы используем select вместо ветвления, это позволяет избавиться от эффекта, называемого неправильным предсказанием ветвей. Функция select оценит оба выражения и выберет то, которое соответствует условию, и, если ваши выражения не так уж сложны для вычисления, я бы порекомендовал использовать select, поскольку это зачастую дешевле, чем ждать, пока ЦП восстановится после неверного прогноза ветвления.», отмечает Андерс.

Увеличение производительности с Burst


Последний шаг преобразования DOTS для позиции противника и интерполяции курса — это включение компилятора Burst. Андерсу задача показалось довольно простой: «Поскольку данные располагаются в смежных массивах и поскольку мы используем новую библиотеку математики из Unity, все, что нам нужно было сделать, это добавить атрибут BurstCompile в нашу задачу».

[BurstCompile]
public struct UpdatePositionAndHeadingJob : IJobChunk
{
    public ArchetypeChunkComponentType PositionDataType;
    public ArchetypeChunkComponentType HeadingDataType;

    [ReadOnly]
    public ArchetypeChunkComponentType TargetPositionDataType;

    [ReadOnly] public float DeltaTime;
    [ReadOnly] public float RotationLerpSpeed;
    [ReadOnly] public float MovementLerpSpeed;
}


Компилятор Burst дает нам Single Instruction Multiple Data (SIMD); машинные инструкции, которые могут работать с несколькими наборами входных данных и создавать множество наборов выходных данных с помощью всего одной инструкции. Это помогает нам заполнить больше мест на 128-битной шине кэша правильными данными. Компилятор Burst в сочетании с удобной для кэша компоновкой данных и системой заданий позволили команде значительно повысить производительность. Вот таблица, которую они составили, замерив производительность после каждого шага преобразования.

bogu8ir1i0apb5cwuhrjfjyk-bk.png

Это означало, что Far North полностью избавились проблем, связанных с интерполяцией положения на стороне клиента и направлением зомби. Их данные теперь хранятся в удобном для кеширования виде, а строки кеша заполняются только полезными данными. Нагрузка распределяется на все ядра ЦП, а компилятор Burst выдает высокооптимизированный машинный код с SIMD-инструкциями.

Советы и рекомендации по DOTS от Far North Entertainment


  • Начните думать в терминах потоков данных, поскольку в ECS сущности представляют собой просто индексы поиска в параллельных потоках данных компонентов.
  • Представьте себе ECS как реляционную базу данных, в которой архетипы — это таблицы, компоненты — это столбцы, а сущности — это индексы в таблице (строке).
  • Организуйте свои данные в последовательные массивы, чтобы использовать кэш процессора и аппаратную предвыборку.
  • Забудьте о желании создавать иерархии объектов и попытках найти общее решение, прежде чем понять реальную проблему, которую вы пытаетесь решить.
  • Подумайте о сборке мусора. Избегайте избыточного аллоцирования кучи в критических для производительности областях. Вместо этого используйте новые нативные контейнеры Unity. Но будьте осторожны, вы должны справиться с очисткой вручную.
  • Осознайте стоимость ваших абстракций, остерегайтесь накладных расходов на вызов виртуальных функций.
  • Используйте все ядра ЦП при помощи системы задач C#.
  • Проанализируйте аппаратный уровень. Действительно ли компилятор Burst генерирует инструкции SIMD? Используйте Burst Inspector для проведения анализа.
  • Хватит тратить строки кэша в пустую. Думайте об упаковке данных в строки кэша, как об упаковке данных в пакеты UDP.

Главный совет, которым хочет поделиться Андерс Эрикссон — это более общий совет для тех, чей проект уже находится в разработке: «Попробуйте определить конкретные области в вашей игре, где у вас возникают проблемы с производительностью, и посмотрите, сможете ли вы применить DOTS конкретно в этой изолированной области. Вам не нужно менять всю базу кода!».

Планы на будущее


«Мы хотим использовать DOTS в других областях нашей игры, и мы были в восторге от анонсов на Unite про DOTS анимации, Unity Physics и Live Link. Мы хотели бы научиться преобразовывать больше игровых объектов в объекты ECS, и похоже, что Unity добилась значительных успехов в реализации этого», — заключает Андерс.
Если у вас есть дополнительные вопросы к команде Far North, мы рекомендуем вам присоединиться к их Discord!
Ознакомьтесь с плейлистом Unite Copenhagen DOTS, чтобы узнать, как другие современные игровые студии используют DOTS для создания великолепных высокопроизводительных игр, и как компоненты на основе DOTS, такие как DOTS Physics, новый Conversion Workflow, и компилятор Burst работают вместе.

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

© Habrahabr.ru