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

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

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


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

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

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

Є префаб гравця (в даному випадку це склад поїзда, який підвантажується необхідна модель складу, та інші параметри — швидкість, знос, потужність, звук і т. д.).

Є блоки, або тайли, об'єктів, в кожному з яких є об'єкт для вкладення моделі, а також pivot'и нульовою координати моделі і протилежного кінця, за яким автоматично буде обчислюватися розташування спауна наступного блоку.

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

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

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



Як видно зі схеми, основним персонажем у нас є поїзд. Відповідно, щодо нього ми вибудовуємо шлях. З поїздом пов'язані, насамперед, 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.05 f || DeltaR < -0.05 f))
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;
}



З фізикою розібралися, переходимо до наступної проблеми. Створюючи движок, заснований на блоках, слід мати на увазі необхідність зчитувати дистанцію від об'єкта діючого блоку до поїзда. В ідеалі потрібно звести кількість операцій по обчисленню відстані до мінімуму.

Логіка роботи нашого движка передбачає, що в кожен конкретний момент дистанція розраховується тільки між потягом і найближчими до нього блоками. Інші тунелі і станції знаходяться в пулі (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 = Кватерніонів.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 = Кватерніонів.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.0 f, 0.0 f, 0.0 f); 
StationResp [0].transform.localRotation = Кватерніонів.identity; 
EndBuild.transform.position = new Vector3(StationResp [0].EndPos.position.x,StationResp [0].EndPos.position.y,StationResp [0].EndPos.position.z); 
EndBuild.transform.rotation = Кватерніонів.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; } 

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



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

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

Також дуже корисно виконувати компресію 2-хбитного стиснення для будь-яких дрібних об'єктів локації. З великими об'єктами це рішення не працює — можуть з'являтися видимі артефакти.

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



Компресія звуку і моделей
В рамках додаткової оптимізації ми стиснули всі звуки на максимальне значення — 1. Досвід тестування показав, що з мобільних девайсів практично не відчувається втрата якості звуку. Виставлення Mono-режиму всіх звукових файлів також знизило споживання пам'яті майже в півтора рази.

Для довідки: до довгих звуків краще застосовувати Decompress On Load, а до коротких — Compressed In Memory.



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

Середні показники станцій по полигонажу 25-30 тисяч полігонів і 12-20 тисяч полігонів у поїздів. Як ми говорили вище, додатково застосовувалася компресія текстур і жорстке обмеження по кількості матеріалу на об'єкт. Оскільки кількість текстур і моделей досить велика, ми відмовилися від прорахунків освітлення, зупинившись лише на заздалегідь підготовлених картах тіней в текстурах.

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






Станція метро «Смоленська», фото


Станція метро «Смоленська», скріншот

Висновок
При зміні движка гри зі статичних карт на динамічні з подгрузкой окремих блоків локації вдалося помітно знизити споживання пам'яті додатком під час роботи. Додаток тепер може запускатися на більш слабких девайсах і при цьому видавати цілком прийнятне кількість кадрів в секунду.

Запаковывание текстур і атласів в окремі спектри каналу RGB допомогло знизити вагу програми. Це особливо важливо для тих випадків, коли в додатках настільки багато текстур, що навіть наявність атласів вже не рятує. При розробці ігор жанру «симулятор» ця проблема стоїть особливо гостро, оскільки достовірність оточення вимагає максимальної кількості фотографічно точні елементів. В нашому випадку завдяки даному етапу стиснення і запаковывания вдалося зберегти в пристойному якості всі самі необхідні деталі в грі.

Що стосується реалізації механіки, то тут за рахунок динамічного підвантаження локації з'явилася можливість контролювати процес проходження геймплея гравцем і довжину сесій. На підставі даних про те, скільки в середньому користувачів грають на тому чи іншому рівні, можна виявити слабкі та сильні місця і розширити або урізати геймплей відповідним чином. Також завдяки динамічному движку з'явилася можливість у майбутньому додавати нові станції в движок прямо з сервера, без повноцінної перезбирання програми.

Сподіваємося, що запропоновані рішення будуть представляти для читачів інтерес. Спасибі за увагу!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.