Оптимизация механики и графики в игре жанра «симулятор» на iOS

в 13:10, , рубрики: C#, c++, unity, unity3d, Блог компании Everyday Tools, движок, оптимизация, разработка игр, разработка мобильных приложений, разработка под iOS, разработка приложений, симуляторы, текстуры, юнити

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

Subway Simulator – серия игр-симуляторов метро. Самая первая версия игры, вышедшая в 2014 году, хоть и была довольно абстрактной, подтвердила спрос на продукт подобной тематики, причем довольно высокий — проект занял лидирующие позиции в своей нише практически сразу после запуска. Последующие апдейты и новые версии продукта были направлены на то, чтобы сделать Subway Simulator реалистичнее: моделирование поездов и станций вышло на новый уровень, а также появились «локализованные» версии игры, отображающие метрополитены Нью-Йорка, Пекина, Москвы и других городов. В данный момент суммарное число установок первой версии игры на iOS почти достигло миллионного значения. Одновременно игра становится доступна для других платформ.

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 1


Разрабатывая движок, основанный на симуляции движения в пространстве достаточно больших размеров, необходимо учитывать пределы памяти для адекватной работы на девайсе. В играх, требующих серьезного расхода ресурса, оптимизация становится определяющим фактором для пользовательского опыта. Именно с ее помощью можно обеспечить реалистичную, привлекательную картинку и плавный процесс геймплея. В данной статье речь пойдет о нашей работе над симулятором Subway Simulator 3D и различных типах оптимизации, которые применялись, чтобы свести расход памяти к минимуму без потери в качестве.

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

Принцип работы у него, по сути, такой же как и у классических раннеров.

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

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

В игре представлены блоки нескольких типов:

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

Суть работы движка:

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 2

Как видно из схемы, основным персонажем у нас является поезд. Соответственно, относительно него мы и выстраиваем путь. С поездом связаны, прежде всего, Route Controller, выстраивающий динамическую линию маршрута по которой движется состав. Сама линия строится по заранее подготовленным Transform'am в каждом блоке, причем каждый Transform расположен ровно посередине колеи рельс.

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

Реализация движения поезда

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

Имитировать движение поезда в полном соотвествии с законами физики было бы проблематично. Как минимум, нам пришлось бы учитывать трение WheelCollider'ов, которые в Unity не всегда ведут себя адекватно, особенно если речь идет о более сложных и крупных колесных транспортных средствах, чем просто машина. И это только один из множества факторов. Самое же серьезное препятствие состоит в том, что такие подробные просчеты физики дали бы слишком большую нагрузку на движок. Это плохо сказалось бы на производительности, да и багов бы прибавило.

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

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

private void Change()
{
target.position = Vector3.Lerp(target.position, currentPoint.position,  lerpSpeed * Time.deltaTime);
//Если расстояние до цели минимальное - меняем цель
if (Vector3.Distance (PivotTrain.position, target.position) < damper) {
//Делаем отклонение поезда относительно поворотов, если такие есть на нашем пути
float DeltaR = Roll - (currentPoint.rotation.z - previousPoint.rotation.z);
//Крен поезда только в заданных границах
if (!(DeltaR > 0.05f || DeltaR < -0.05f))
Roll = Mathf.Lerp(Roll, currentPoint.localRotation.z - previousPoint.localRotation.z, 10 * Time.deltaTime);
//Выполняем смену цели
NextCall ();
}

private void NextCall()
{
	//Обращаемся к последнему элементу, он же всегда первый в списке, так как после просчетов мы удаляем первый элемент
	if(!CurrentPoints[0].GetComponent<itemWay>().Last)
	previousPoint = currentPoint;
	CurrentPoints [0].GetComponent<itemWay> ().EndBlock ();
	CurrentPoints.Remove (CurrentPoints [0]);
	currentPoint = CurrentPoints [0];	
}

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

Главное при этом — чтобы соблюдалась зависимость анимации от скорости состава. Пример зависимости с учетом коэффициентов скорости поезда приводим ниже:

void FixedUpdate()
{
	speed = TrainEngine.Instance.speed;
	maxSpeed = TrainEngine.Instance.maxSpeed;
	tweenRot.dutation = (speed / maxSpeed) * 10;
	tweenRot.from.y = speed / (maxSpeed)/30;
	tweenRot.to.y = -tweenRot.from.y;
}

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 3

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

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

lenghtBegin — длина блока, после достижения которой мы можем его убрать и строить путь дальше
ItemType — тип блока

private void DistanceCheck()
{
	if ((Vector3.Distance(transform.position, player.transform.position) > lenghtBegin)) {
	//Отключаем рассчет
	ActiveBlock=false;
	Model.SetActive(false);
	//Возвращаем блок в кучу, чтобы не было повторений сразу
	WayController.Instance.ReturnBlock(ItemType);
	//Генерируем следующий блок
	WayController.Instance.GenerateWay();
	}
}

WayController.cs
//Определяем что строить дальше
	
public void GenerateWay()
	
{
		
	//Если длина не равна общей длине перегона то спауним еще один блок тоннеля
		
	if (CurrentLenght < LenghtTunnel) {
			
	RespawnTunnel ();
		
	}  else 
	{

	//Иначе спауним станцию
			
	RespawnStation();
		
	}

}


//Определяем что спаунить, прямой блок или блок с ротацией. Если с ротацией то сбрасываем счетчик

public void RespawnTunnel()
	
{
				
	countLinear++;
		
	if (countLinear > LenghtLinear) 
	{

		countLinear = 0;

		if (turnTunnel!=null)
	LoadTurn (Random.Range (0, turnTunnel.Count));
			else LoadLinear (Random.Range(0,frontTunnel.Count));	
		}  	else 
	{
		LoadLinear (Random.Range(0,frontTunnel.Count));	
		
	}
	
}

//Выгрузить линейные блоки на локу

public void LoadLinear(int num)
{
		
	if (RespList)
	 RespBlock.Add(frontTunnel [num]);
		
	//Меняем местоположение объекта в листах
		
	CurrentBlock.Add (frontTunnel [num]);
		
	//Меняем позицию блока к последней действующей и включаем объект
		CurrentBlock [CurrentBlock.Count-1].ChangePosition ();
		
	CurrentBlock [CurrentBlock.Count - 1].transform.SetParent (RootTransform);
		frontTunnel.Remove (frontTunnel [num]);

}
//При перестановке блока вперед - меняем позицию, ротацию - на те которые содержаться в пивоте последнего блока

	
public void ChangePosition()
	
{

	Model.SetActive (true);
		
	this.transform.position = new Vector3 (WayController.Instance.EndBuild.position.x, WayController.Instance.EndBuild.position.y, WayController.Instance.EndBuild.position.z);
	this.transform.rotation = Quaternion.Euler (WayController.Instance.EndBuild.eulerAngles.x,WayController.Instance.EndBuild.eulerAngles.y, WayController.Instance.EndBuild.transform.eulerAngles.z);
		
	rotationTile = EndPos.rotation.eulerAngles.y;
		WayController.Instance.BuildWay (EndPos.position,rotationTile);
		SetWayPoint ();
	
}

	

//Заносим точки маршрута из этого блока в общий лист по которому едет поезд
public void SetWayPoint()
	
{
		
	for (int i = 0; i < WayPoints.Count; i++)
			RouteController.Instance.CurrentPoints.Add (WayPoints [i]);
	
}



t = target.position - transform.position;
toRot = Quaternion.LookRotation(t);
transform.rotation = rot;

pos = transform.position + transform.forward * speed * Time.deltaTime;

//Сформированные данные отправляем компоненту RigidBody для просчета по физике

rbTrain.MovePosition (pos);

Далее скрипт Route Controller должен напрямую работать с Way Controller. Именно Way Controller и определяет список объектов, которые Route Controller будет обрабатывать и отдавать поезду как цель движения.

Главное удобство версии движка, которую мы используем, в том, что достаточно вбить необходимые значения, чтобы разом поменять характер движения на линии. Например, сделать перегоны между станциями длиннее или короче, если статистика покажет, что текущая продолжительность поездки не устраивает пользователей. При наличии новых моделей станций можно просто внести их в список моделей движка, а затем выставить начало и конец Pivot'ов в каждой.

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

Отладка метрики

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

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

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

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

public void RespawnStation()
{
StationResp [0].SetTriggers (false);
	
StationResp [0].transform.localPosition = new Vector3 (0.0f, 0.0f, 0.0f);
	
StationResp [0].transform.localRotation = Quaternion.identity;
	
EndBuild.transform.position = new Vector3(StationResp [0].EndPos.position.x,StationResp [0].EndPos.position.y,StationResp [0].EndPos.position.z);
	
EndBuild.transform.rotation = Quaternion.Euler (StationResp [0].EndPos.eulerAngles.x,StationResp [0].EndPos.eulerAngles.y, StationResp [0].EndPos.eulerAngles.z);
	
Train.transform.localPosition = new Vector3 (RespPoint.localPosition.x, RespPoint.localPosition.y, RespPoint.localPosition.z);
	
Train.transform.localRotation = RespPoint.localRotation;
WayController.Instance.RebuildBlocks ();

}

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

Компрессия текстур

Первые версии Subway Simulator включали в себя статичную локацию c заранее настроенными источниками света (в том числе и в Realtime). Довольно быстро стало понятно, что такая концепция работает лишь до тех пор, пока в игре не используются многополигональные модели и большое количество текстур, как для интерфейса, так и для локаций, поездов и людей. В противном же случае необходимо подобрать другой вариант, который обеспечивал бы хорошее качество при умеренных затратах памяти.

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

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

Каждую из текстур локации мы сжимали до 256х256, применяя сжатие RGB Compressed PRVTC 4 bit. А вот LightMaps (карты освещения) компоновались парами в одном изображении по отдельным каналам RGB.

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

void surf (Input IN, inout SurfaceOutputStandard o) {
			
fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
			
fixed4 light = tex2D (_Light, IN.uv2_Light);
			
fixed4 g = tex2D (_MainTex2, IN.uv_MainTex);
			
fixed a = (c.r+c.g+c.b)/3;
			
fixed3 r;
			
r.r = ((g.r+(c.r-a))*_R)+((g.g+(c.r-a))*_G)+((g.b+(c.r-a))*_B)+((g.a+(c.r-a))*_A);
		
r.g = ((g.r+(c.g-a))*_R)+((g.g+(c.g-a))*_G)+((g.b+(c.g-a))*_B)+((g.a+(c.g-a))*_A);
	
r.b = ((g.r+(c.b-a))*_R)+((g.g+(c.b-a))*_G)+((g.b+(c.b-a))*_B)+((g.a+(c.b-a))*_A);
o.Albedo = r.rgb;

o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
	
o.Emission = ((light.r*_N1)+(light.g*_N2)+(light.b*_N3)+(light.a*_N4))*r.rgb;
	
o.Alpha = c.a;
}


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

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 4 Оптимизация механики и графики в игре жанра «симулятор» на iOS - 5

Сверху представлен пример распределения текстур. Левое изображение хранит в себе только цвет, его мы сжимаем настолько, насколько это возможно (для текстур локаций среднего размера предел, как правило, как раз и составляет 256 пикселей). Правое же изображение хранит в себе контраст и LightMaps в трех каналах RGB.

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

Также очень полезно выполнять компрессию 2-хбитного сжатия для любых мелких объектов локации. С большими объектами это решение не работает — могут появляться видимые артефакты.

Скриншот ниже — пример неудачной попытки подобного сжатия: артефакты явно просматриваются в верхней части локации.

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 6

Компрессия звука и моделей

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

Для справки: к длинным звукам лучше применять Decompress On Load, а к коротким — Compressed In Memory.

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 7

Моделирование прототипов

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

Средние показатели станций по полигонажу 25-30 тысяч полигонов и 12-20 тысяч полигонов у поездов. Как мы говорили выше, дополнительно применялась компрессия текстур и жесткое ограничение по количеству материала на объект. Поскольку количество текстур и моделей достаточно большое, мы отказались от просчетов освещения, остановившись только на заранее подготовленных картах теней в текстурах.

Результат моделирования можно видеть ниже:

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 8

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 9

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 10
Станция метро «Новослободская», фото

Оптимизация механики и графики в игре жанра «симулятор» на iOS - 11
Станция метро «Новослободская», скриншот

Вывод

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

Запаковывание текстур и атласов в отдельные спектры канала RGB помогло снизить вес приложения. Это особо важно для тех случаев, когда в приложениях настолько много текстур, что даже наличие атласов уже не спасает. При разработке игр жанра «симулятор» эта проблема стоит особенно остро, так как достоверность окружения требует максимального количества фотографически точных элементов. В нашем случае благодаря данному этапу сжатия и запаковывания удалось сохранить в приличном качестве все самые необходимые детали в игре.

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

Надеемся, предложенные решения будут представлять для читателей интерес. Спасибо за внимание!

Автор: Everyday Tools

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js