Ссылка на первую статью из этой серии.

Быстрое вступление

Я долго думал какую тему выбрать на этот раз, и решил, что расскажу про некоторые фишки, которые помогут оптимизировать вашу игру. Особенно это будет актуально для новичков, потому что чаще всего первая игра оказывается игрой для смартфонов и планшетов. А на мобилках, сколько бы там ядер не оказывалось — 6 или 8, игры все ещё очень несбалансированные в плане потребления ресурсов. Код и его идея, которые будут приводится в этой статье являются немного более сложными для понимания чем те две строчки кода, которые я приводил в своей предыдущей публикации. Хотя, я не правильно выразился — код легко понять — но порог вхождения в это понимание будет чуть выше чем легко (для новичков конечно), придется посидеть минут 5.

Введение в идею

Как вы создаете объекты в Unity из префабов? Только Instantiate и никак иначе — другой функции там просто не существует.

Для тех кто не помнит или еще не знает, что такое Instantiate (), маленькая справка — *аргументы инстанции*. Вот вы их создаете (объекты), и создаете, иногда удаляете, потом опять создаете, и так в течении всего уровня. Это бьёт по оптимизации и существенно — почему? Потому что так написано на всех форумах Unity, а иногда проглядывается и в документации, а документацию надо слушаться. Как Instantiate бьет по про производительности?

Вот например такой код: Instantiate(bullet, transform.position, tranform.rotation);

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

Наверное, это долго, да. Представьте, что вы делаете раннер(где все объекты повторяются через каждые пять внутри-игровых метров) или просто шутер — катастрофа, если хотя-бы каждую секунду будет происходить инструкция, которую я описал выше. Что же делать? Использовать pooling объектов.

P.S.: Есть видео на оф. сайте Unity про то, что такое object pooling и как это осуществить. Можете посмотреть и его, если вы с английским языком на ты. Но я приведу ту же идею, но с более прозрачной реализацией этого пулинга (однако, она будет более емкой в плане строчек кода).

Поехали!

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

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

То-есть у меня в редакторе Unity это выглядит вот так (просто чтобы вы полностью поняли идею):



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

Сначала объясняю план действий, потом смотрим на коде:

1) В своем скрипте который будет работать с объектами пулинга, создаете List (объектов конечно);
2) При старте игры сразу создаете в цикле нужное кол-во объектов для пулинга. Причем в цикле на каждой итерации после создания одного экземпляра объекта отключаете его и добавляете в ваш созданный до этого List;
3) Переходите в скрипт объекта, который будет активировать ваши объекты в листе (например в скрипт, который управляет корабликом). Создаете связь с объектом который является вашим менеджером пулов( Или сделайте класс с пулами статичным, чтобы обращаться напрямую). И в нужный момент(например стрельба) вы в своей корутине(обычно корутины используются для осуществления стрельбы) вызываете функцию которая будет активировать ваш объект для пула с нужными параметрами. Все.

Теперь то же самое, но на практике. Представлен код менеджера пулов(только для одного объекта RedMissile).

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class BulletsContainer : MonoBehaviour {
   List<GameObject> missileRed=new List<GameObject>();
   // В переменную RedMissile в редакторе закидываете ваш префаб   
   public  GameObject RedMissile;
   // Переменная missileCount будет контролировать сколько максимально на экране может быть объектов
   public  int misisleCount;
   void Awake()
   {
     // То же самое, что я описал во втором пункте теоретического разбора выше 
     for(int i=0;i<missleCount;b++)
     {
	GameObject temp=(GameObject)Instantiate(RedMissile);
	temp.SetActive(false);
	missileRed.Add(temp);	
     }
   }
   public void LaunchEnemyRedMissile(GameObject Caller)
   {
      if (missileRed != null)
       {
	  for (int i=0; i<missileCount; i++) 
          {  
              // Проверяем неактивен-ли объект из листа в нашей сцене
              // Если активен, то идем по индексу в списке дальше, чтобы 
              // найти неактивный и активировать его
	      if (!misisleRed [i].activeInHierarchy) 
              {
                  // главное строчка тут - это " missileRed [i].SetActive (true); " - она обязательна
                  // остальные строчки могут быть другими в зависимости от вашей игры и того, 
                  // что вы пуллите, поэтому можете не обращать внимание на них. Но ниже 
                  // я объясню про каждую строку,что и для чего, просто чтобы вы лучше поняли
                  // как можно эту функцию расширять 
		  missileRed [i].SetActive (true);
		  missileRed [i].transform.eulerAngles =Caller.transform.eulerAngles;
		  missileRed [i].transform.position = Caller.transform.position;
		  missileRed [i].GetComponent<DisableObject> ().StartCoroutine ("AblePause");
		  break;
              // скобочки я поставил в ряд, просто чтобы не разводить много пустого места
	      }}}}}

Теперь по поводу дополнительных строчек.

1) "missileRed [i].transform.eulerAngles =Caller.transform.eulerAngles;"

— Как вы видите я принимаю в аргументе функции объект Caller, думаю все могут догадаться, что это объект из которого была вызвана эта функция. Скорее всего вам придется часто так делать, чтобы активировать объект из пула в позиции вызывающего объекта, как это сделано в этой следующей строке "missileRed [i].transform.position = Caller.transform.position;".

2) " missileRed [i].GetComponent ().StartCoroutine («AblePause»);"

— так вам тоже придется часто делать. Дело в том, что каждый раз когда вы активируете объект из пула вы должны его деактивировать через какое-то время ( вы можете поставить много условий деактивации, например когда пуля сталкивается с кораблем игрока, она осуществляет, что-то типо:" this.gameobject.SetActive(false); "). Поэтому у меня на каждом объекте пула навешен скрипт который деактивирует его через определенное время. И еще раз : Не забывайте деактивировать объекты пула, иначе, в какой-то момент все ваши объекты окажутся активными и цикл их активации просто не будет выполняться!

Тогда код в кораблике (или любом вашем объекте, который вызывает функцию активации пула) будет таким:

//....Какой-нибудь код ..... 
// устанавливаете связь с объектом, который отвечает за пулинг, как вам больше нравится
// у меня например так:
   EnemiesContainer = GameObject.FindGameObjectWithTag ("ShipsGenerator");
   BulletsSystem = EnemiesContainer.GetComponent<BulletsContainer> ();
//....Какой-нибудь код ..... 
IEnumerator StartAttack()
 {
        float randomTime = Random.Range (cooldownMin, cooldownMax );
	yield return new WaitForSeconds (randomTime);
        // тут мы вызываем нашу функцию активации объекта в пуле, аргументом передаем себя
	BulletsSystem.LaunchEnemyRedMissile(this.gameObject);   
}
//....Какой-нибудь код ..... 

Заключение

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



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

P.P.S.: Спасибо за внимание! Есть вопросы? Пишите в комменты — отвечу обязательно.

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


  1. EndUser
    12.10.2015 17:44
    +1

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


    1. GoodAlex
      12.10.2015 18:00

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


      1. EndUser
        12.10.2015 23:38

        Я представляю ситуацию так:
        * тактика с разнообразием моделей юнитов в 500;
        * использующих разнообразие моделей оружия тоже примерно 500, половина пулемёты, любой из которых может на уровень положить, скажем, 200-500 пуль.
        * тактическая картина может включать единомоментно порядка 50 моделей юнитов и 50 моделей оружия.
        * в кругу визуального контакта 10-100 юнитов, часть из которых применяет своё оружие.
        Вот такой вопрос: что вы посоветуете рациональным образом «пулить», в каких количествах?


    1. kraidiky
      12.10.2015 23:14

      Используем в своём проекте такую же штуковину. Дело в том, что инстанцирование префаба операция раз в 30 медленнее, чем сменить UV координаты и сделать объект видимым. Поэтому я держу несколько пулов, в каждом лежат обезличенные префабы. Когда надо добавить на экран спрайт из этого атласа из пула вынимается объект, включается в дерево отображения и выставляются координатки для тестуры или какое ещё там поведение на инициализации нужно. Получается штук 5-6 разных бассейнов на игру.

      На сцене у меня порой возникает по 2-3 сотни объектов почти одновременно и при этом я забыл про тормоза инстанцирования.

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


      1. EndUser
        12.10.2015 23:48

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

        То есть вы рекомендуете всё разнообразие пуль и снарядов держать в одном бассеине, и при выстреле заполнять пулю своими индивидуальными баллистическими параметрами? Понял. Спасибо!..

        Юниты в игре появляются согласно тактике: захотел — появился, захотел — ушёл.

        Простой пример — осень 1943. На сцене доступны Focke Wulf 190 A-6/R2, Focke Wulf 190 A-6/R6, Focke Wulf 190 A-5, Junkers 88 A-4, Junkers 188 E-1, Spitfire 9c, Spitfire LF 9c, Spitfire HF 8c, Mosquito B Mk. 4, Mosquito B Mk. 6 и так далее.

        А вообще разнообразие включает, соответственно, Spitfire 1..22, Mosquito 2..18 и так далее. Понятно, что осенью 1943 недоступны Spitfire 14, Spitfire 22, Focke Wulf 190 D-9…

        Итого примерно 500 моделей. Ну и порядка 500 моделей оружия для них. Некоторые, вроде 190 A-5 и 190 A-6 настолько мало различимы визуально, что могут при жадности представляться одной графмоделью.

        Теперь мне осталось понять что делать с пулом юнитов.


        1. kraidiky
          13.10.2015 00:04

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

          А дальше получается что-то вроде:
          var item = poolController.Get(PrefabName);
          item.decorate(imgDescription);

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

          А ещё я реализовал полезную штуку — что когда если в кадре к пулингМенеджеру не обращались, он про запас создаёт по одному префаб, пока их сотня в резерве не накопится.


          1. EndUser
            13.10.2015 00:17

            Взял к размышлениям.
            Спасибо!


  1. Dywar
    12.10.2015 22:09
    +1

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

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

    Очень хорошо про оптимизацию рассказано на курсе 2D и 3D игр на MVA.
    Если кто пожелает, переведите для читателей статью:
    Unity3D Best Practices

    PS меня немного раздражает то что по правилам Unity (из инструкции) нельзя использовать конструкторы классов (те которые наследуются от MonoBehaviour) и студия ругается на методы вроде Update() которые никто не вызывает, переменные которые якобы нигде не инициализируются (это надо делать в методе Start()) и т.д., эти предупреждения в коде от которых хотелось бы избавиться не трогая настройки VS. Плагин для работы с Unity3D почему то этого не делает. Видел ли кто переделанные правила проверки кода от Microsoft переделанные для Unity?


    1. GoodAlex
      12.10.2015 22:28

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


  1. AlmazDelDiablo
    13.10.2015 00:07

    При реализации пулов сталкивался с такой проблемой, что при деактивации анимированного объекта, его аниматор (в моей ситуации — анимация смерти, т.е. полного исчезновения объекта) не откатывалась, из-за чего пришлось вводить довольно костыльную штуку, что я сначала отключаю у объекта его рендеринг, откатываю анимацию (что в Юнити тоже не очень просто, ибо для этого нужно запустить анимацию на один кадр и остановить), а затем уже деактивирую сам GameObject. Не находили ли вы решения этой проблемы?

    PS: а вообще, пулинг объектов — это крайне полезная вещь. Например, в моём проекте используется сборка уровня из отдельных блоков (похоже на МайнКрафт, только кубики значительно больше), причём беспрерывно: часть уровня за спиной уничтожается, а перед игроком собирается вновь. В итоге, на сцене постоянно присутствует около четырёх—пяти сотен 3D-объектов, задействованных при расчёте физики; благодаря динамическому батчину и пулингу всего, что только можно — игра с включённым попиксельным (forward) рендерингом не лагает на Андроид-устройстве 2012-го года.


    1. GoodAlex
      13.10.2015 00:27

      Возможно решение с анимацией лежит где-то около параметра «Has exit time» в аниматоре. Или можно сделать запуск анимации смерти по булевому значению, то есть как происходит смерть -> параметр_умер=true -> анимация проигрывается -> деактивация -> при активации параметр_умер=false(стрелочку в аниматоре направить при false на default state анимацию ).Попробуйте так как-нибудь.


  1. BasmanovDaniil
    13.10.2015 17:11
    +1

    missleRed[i].transform.eulerAngles = Caller.transform.eulerAngles;
    

    Не используйте так eulerAngles, юнити все повороты хранит в кватернионах, когда вы вызываете eulerAngles на самом деле происходит следующее:

    public Vector3 eulerAngles
    {
        get
        {
            return this.rotation.eulerAngles;
        }
        set
        {
            this.rotation = Quaternion.Euler(value);
        }
    }
    

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

    missleRed[i].GetComponent<DisableObject>().StartCoroutine("AblePause");
    

    Запускать корутины по строке — плохая практика, если вы переименуете метод, то у вас где-то в другом месте что-то сломается, и вы об этом не скоро узнаете. И лучше спрятать корутину внутри класса DisableObject, а наружу отдать метод, который её сам запускает, тогда контроль над корутиной останется у класса.

    float randomTime = Random.Range(cooldownMin, cooldownMax + 1);
    

    А зачем вы добавляете единицу?


    1. GoodAlex
      13.10.2015 17:42

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