26 октября 2022 26.10.22 2 328

Глава №1. Как мы сломали и починили Canvas

0

Мальчишки и девчонки!
A так же их родители!
Кринжовую архитектуру
Увидеть, не хотите ли?

Примерно вот такой мотив вертелся у меня в голове, когда я начал работать над материалом для данной статьи. На связи я, Радэус Усэус, и сегодня у нас первая статья из цикла «Как мы сломали и починили». В данном цикле мы разберем ключевые моменты разработки нашей игры. Попытаемся понять, что за безумие происходит в проекте и как с этим дальше жить.

Первым на наш операционный стол попадает Canvas. Честно признаться, когда я только думал о том, в каком порядке стоит разбирать темы, Canvas казался самым безболезненным вариантом для первой статьи. Почему? Потому что идеологически- Canvas это отдельная метавселенная. Он меньше всех связан с остальными аспектами игры и составить архитектуру так, что убрав, условную, кнопку прыжка, сломается вся игра… Ну не должно оно так работать.

как же я ошибался
как же я ошибался

Кто такой, этот ваш, canvas

Но прежде чем мы погрузимся в настоящий ад, давайте разберемся кто такой этот ваш Canvas. Холст (он же Canvas) - это абстрактное пространство, в котором производится настройка и отрисовка UI. Проще говоря, все наши кнопки, панельки, индикаторы, статистики – все это и есть Canvas.

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

С технической стороны, Canvas создает нагрузку на GPU, влияет на количество задействованных Batches( нагрузка на CPU) и создает расходную нагрузку за счет скриптов, которые с ним взаимодействуют.

В контексте данной статьи мы сделаем акцент именно на технической стороне вопроса, ибо удобство нашего Сanvas’а это отдельная тема. Мы сравним нагрузку на устройство до и после, на примере разберем как правильная организация сразу подсвечивает моменты для оптимизации процессов и убедимся в том, что расположение объектов в Hierarchy ( Иерархии ) имеет значение

Если кому-то будет интересно почитать про каждый элемент и о том, как это все работает «под капотом» то в конце статьи я оставлю ссылку на подробную статью об этом.

Организация Canvas’а

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

Выглядит понятно, не правда ли? Вот и я, когда его увидел спустя практически год, первые минут 10 сидел и не понимал зачем мы наняли Шеогората. Никакой организации, огромное количество элементов, которые не нужны от слова совсем. Особенно это заметно в EndOfLevelPanel. Для всех адекватных людей, которые не понимают, что тут происходит, объясняю. В нашей игре есть три состояния Canvas’a: игровая панель (она активна во время игры), панель Настроек (местная пауза и доступ к первичным настройкам) , панель Результатов (активируется в конце уровня и показывает результаты прохождения) . Поскольку все элементы разброса внутри канваса, в случае необходимости включить\выключить панель, из разных мест к разным элементам обращались разные классы. Внутри EndOfLevelPanel есть функциональные элементы (HomeButton, RestartButton и т. д.) и, как я их называю, «ленивые» (все points нужны, чтобы считать их положение и создать там префаб достижения; Achievement префабы тех самых достижений, видимо для того, чтобы не создавать новые, а просто передвинуть уже заранее «заряженные»).

Чем плох такой подход?

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

Что я предлагаю? Для начала нам необходимо сгруппировать элементы по принадлежности к той или иной панельке. Как мы говорили ранее, у нас их три (игровая, настройки и результаты), внутри каждой панельки сразу напрашивается еще два повторяющихся признака: кнопки (Button) и информация\индикаторы (Information).

Учитывая неймниги в случае дальнейших улучшений, normal станет good
Учитывая неймниги в случае дальнейших улучшений, normal станет good

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

Вторым шагом нам нужно удалить все, что нам не нужно. По большей части это касается панели результатов (некогда EndOfLevelPanel). Мы удаляем все заготовки под позиции и «заряженные» префабы Achievement (ниже я покажу как стоит их реализовать). Данной манипуляцией мы решаем еще одну проблему, а именно вес нашего Canvas’а.

Если говорить точнее в три раза (2.79).
Если говорить точнее в три раза (2.79).

На данном этапе можно было бы и остановиться, но есть еще один важный момент. Информация. В каждой из панелей есть пул необходимой информации. В старой версии каждый элемент самостоятельно занимался поиском нуной ему информации. Это приводило к тому, что условная информация о том, сколько раз слайм умер, хранилась в каждом элементе и если он умирал еще раз, игра должна была сама сообщить в каждый элемент о том, мол у нас +1 смерть. Чем это чревато? Как минимум тем, что где-то информация может не дойти и вуаля, у нас рассинхрон. Как его лечить? Ну, проверять все классы, которые работают с этим типом данных. И хорошо если у вас это будет сделано через Event, но если у вас как у нас… помянем. Для решения данной проблемы, я сделал вот такое хранилище:

using UnityEngine;

public class LevelInformation : MonoBehaviour
{
    [Header("Устанавливается в инспекторе")]
    [SerializeField] private SceneFinder  SceneFinder;
    [SerializeField] private Clock        Clock;
    [Header("Устанавливается динамически")]
    private string  sceneName;
    private Vector2 time;
    private Vector2 death;

    private LevelInformationDB DB = new LevelInformationDB();

    private void Awake() 
    {
        sceneName = SceneFinder.sceneName();
        LvlInfDB lvlInf = DB.GetLevelInformation(sceneName);

        sceneName   = lvlInf.canvasName;
        time        = new Vector2(0, lvlInf.time);
        death       = new Vector2(0, lvlInf.death);
    }
    private void Start() 
    {
        DeathEvent.S.eDeath += Death;
    }
    private void FixedUpdate() 
    {
        time.x = Clock.second;
    }
    public (string, Vector2, Vector2) GetSettingValues()
    {
        var result = (sceneName, time, death);
        return result;
    }
    public (Vector2, Vector2) GetGameValues()
    {
        var result = (time, death);
        return result;
    }
    private void Death()
    {
        death.x++;
    }
}

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

Организация графической составляющей

Прежде чем приступить к детальному рассмотрению работы каждой панельки по отдельности, нужно оптимизировать, еще один, очень важны момент. Как я уже ранее писал, Canvas жрет GPU и CPU. Чтобы уменьшить нагрузку нам следует использовать так называемые «графические атласы».

Что такое "графический атлас"? Атлас- это собранные в единый файл изображения. При помощи внутреннего функционала, изображения, внутри атласа, "разделяются" на множество отдельных спрайтов (изображений).

Подробнее об этом можно прочитать во все той же статье

Так как в Canvas’е используется графика, то её (графику) нужно отрисовывать. Чтобы узнать, как именно это происходит можно использовать Frame Debug (Windows – Analysis — Frame Debug) . В нашей старой реализации про атласы видимо не знали, поэтому все спрайты были по отдельности. Я сгруппирую их в один атлас и покажу, что мы от этого получим.

В старой версии, чтобы отрисовать панельку конца уровня требовалось 6 батчей (панель, кнопка звезды, звезда за уровень, звезда смерти, звезда времени, остальные кнопки), а с атласом он делает это за один батч (сразу всю графику) Не забывайте про атласы!

Использование атласов решит еще несколько неприятных моментов. Первый это настройка изображения. Если вы используете атлас, чтобы настроить ВСЕ элементы, вам нужно будет настроить только один спрайт, а не каждый по отдельности. Второй момент, используя атласы сложнее что-то потерять. Мы банально забыли про звезду для времени, просто из-за того, что она была в другой папке. Будь она в атласе вместе со всеми, такой проблемы бы не возникло.

Result Panel

Первой для рассмотрения предлагаю панель конца уровня. Выше я обещал рассказать как реализовать работу данной панели, а если конкретнее достижений. Для реализации данного функционала мы будем использовать один из элементов Canvas’а, а именно Scroll View (UI — Scroll View) . Данный компонент позволяет создавать «бездонные панели». Особенностью достижений в конце уровня является тот факт, что мы не знаем сколько их получит игрок. Он может получить как 10+, так и вовсе ни одного. В прошлом для этого у нас были заготовленные позиции под нужное количество. ВАЖНО. Уберите Романа Сакутина от экрана. Работало это вот так:

public void ShowAchievement()
        {
            for (int i = 0; i < list.Count; i++)
            {
                achievements[i].gameObject.SetActive(true);
                achievements[i].nameAch.text = 
AchievementTextContainer.GetNameForAchievement(list[i]);
                achievements[i].description.text = 
AchievementTextContainer.GetDescriptionForAchievement(list[i]);
                achievements[i].icon.sprite = 
AchievementTextContainer.GetIconForAchievement(list[i]);
                achievements[i].achievement = list[i];
                AchievementApi.GetInstance().SetAchievementShown(list[i], true, true);

                if (list.Count <= 3)
                {
                    achievements[0].transform.position = 
GameObject.Find("Point-1").transform.position;
                    achievements[1].transform.position = 
GameObject.Find("Point-2").transform.position;
                    achievements[2].transform.position = 
GameObject.Find("Point-3").transform.position;
                    achievementPanel.GetComponent<ScrollRect>().enabled = false;
                }
                if (list.Count == 2)
                {
                    achievements[0].transform.position = 
GameObject.Find("Point_2-1").transform.position;
                    achievements[1].transform.position = 
GameObject.Find("Point_2-2").transform.position;
                    achievementPanel.GetComponent<ScrollRect>().enabled = false;
                }

                if (list.Count == 4)
                {
                    achievements[0].transform.position = 
GameObject.Find("Point_3-1").transform.position;
                    achievements[1].transform.position = 
GameObject.Find("Point_3-2").transform.position;
                    achievements[3].transform.position = 
GameObject.Find("Point_3-4").transform.position;
                    achievementPanel.GetComponent<ScrollRect>().enabled = false;
                }
                if (list.Count == 5)
                {
                    achievements[0].transform.position = 
GameObject.Find("Point-1").transform.position;
                    achievements[1].transform.position = 
GameObject.Find("Point-2").transform.position;
                    achievements[2].transform.position = 
GameObject.Find("Point-3").transform.position;
                    achievements[3].transform.position = 
GameObject.Find("Point_4-1").transform.position;
                    achievements[4].transform.position = 
GameObject.Find("Point_4-2").transform.position;
                    achievementPanel.GetComponent<ScrollRect>().enabled = false;
                }
            }
        }
Уф, настольджи. Хотя нет, все еще кринж.
Уф, настольджи. Хотя нет, все еще кринж.

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

А теперь следите за руками. Scroll View позволяет автоматически (самостоятельно, без участия разработчика) распределять объекты, которые находятся у него в чалдах (ниже по иерархии). Все что нам остается. Просто создать объект в нужном месте, а именно:

private void InstantiateNewAch(int id, string name, string descr, Sprite icon)
    {
        GameObject achGO = Instantiate(achPref, contentPanel.transform) as GameObject;
        achGO.transform.name = $"ach#{id}";
        achGO.transform.localScale = new Vector3(1f, 1f, 1f);

        achGO.transform.GetChild(0).gameObject.GetComponent<Image>().sprite = icon;
        achGO.transform.GetChild(1).gameObject.GetComponent<Text>().text = name;
        achGO.transform.GetChild(2).gameObject.GetComponent<Text>().text = descr;
    }

И да. Я знаю GetChield в комбинации с GetComponent это плохо. Мы это исправим, когда будет перерабатывать достижения

Как настроить Scroll View я оставлю статью, ибо тема большая, а данный пост и так уже огромный.

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

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

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

С остальной информацией дела обстоят еще проще. Если раньше каждый элемент из разных мест сам собирал в себя информацию, а потом ставил её куда нужно, то мы будем использовать наше новое хранилище, а именно:

private void OnEnable() 
    {
        // прокинуть запрос, чтобы получить всю инфу
        var info = lvlInf.GetSettingValues();
        sceneNameText.text = info.Item1;

        bool timeStar = info.Item2.x < info.Item2.y ? true : false;
        timeText.text = $"{string.Format("{0:00}:{1:00}",
          Mathf.FloorToInt(info.Item2.x / 60), Mathf.FloorToInt(info.Item2.x % 60))} / 
          {string.Format("{0:00}:{1:00}",Mathf.FloorToInt(info.Item2.y / 60), 
          Mathf.FloorToInt(info.Item2.y % 60))}";

        timeText.color = timeStar ? Color.green : Color.red;

        bool deathStar = info.Item3.x < info.Item3.y ? true : false;
        deathStatsText.text = string.Format("{0:00} / {1:00}", 
            info.Item3.x, info.Item3.y);

        deathStatsText.color = deathStar ? Color.green : Color.red;

        StarController.SetStar(deathStar, timeStar);
    }

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

Setting Panel

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

private void OnEnable() 
    {
        // прокинуть запрос, чтобы получить всю инфу
        var info = lvlInf.GetSettingValues();
        sceneNameText.text = info.Item1;

        timeText.text = $"{string.Format("{0:00}:{1:00}",
          Mathf.FloorToInt(info.Item2.x / 60), Mathf.FloorToInt(info.Item2.x % 60))} / 
          {string.Format("{0:00}:{1:00}",Mathf.FloorToInt(info.Item2.y / 60), 
          Mathf.FloorToInt(info.Item2.y % 60))}";

        timeText.color = info.Item2.x < info.Item2.y ? Color.green : Color.red;

        deathStatsText.text = $"{info.Item3.x} / {info.Item3.y}";
        deathStatsText.color = info.Item3.x < info.Item3.y ? Color.green : Color.red;
    }

Game Panel

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

private void FixedUpdate() 
    {
        // прокинуть запрос, чтобы получить всю инфу
        var info = lvlInf.GetGameValues();
        timeImage.fillAmount = 1f - (info.Item1.x / info.Item1.y);
        // вот эту тему я бы наверное вынес в отдельный ивент, ибо оно редко обновляется
        deathStatsImage.fillAmount = 1f - (info.Item2.x / info.Item2.y);
    }

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

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

Папин бродяга, мамин симпотяга, у сцены FPS убивака
Папин бродяга, мамин симпотяга, у сцены FPS убивака

Чтобы этот красавец прыгнул, нужно, чтобы каждая точка получила импульс (да, он работает через RigidBody) . В прошлом для этого использовалась длинная цепочка команд, а именно:

Игрок нажимает на кнопку прыжка, кнопка сообщает классу, который хранит в себе ссылки на все точки, после чего каждая точка оценивает, может ли она сейчас прыгнуть (у нас двойной прыжок) и если да, то прыгает. Звучит не так плохо, но реализация, о да, тихий ужас. Для нас важен этап передачи этой команды. Сильного криминала в том, чтобы сообщать в другой класс-менеджер, что нужно прыгнуть, я не вижу. НО. Данный подход приводит к связанности кода. Это плохо. Именно поэтому, чтобы удалить посредника, решено добавить Event (Событие, на которое "подписываются" другие классы, а точнее методы(команды) других классов). Когда игрок нажимает на кнопку прыжка, то кнопка обрабатывает запрос. Если слайм может прыгнуть- мы прыгаем, если нет- селяви.

public void Jump()
    {
        JumpEvent.S.PlayerJump();
    }

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

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

Когда я делал эту статью, складывалось впечатление, будто я иду по минному полю. Ты убираешь что угодно, а это приводит к тому, что целые семейства классов просто ложатся насмерть. Куда сложнее было просто не плюнуть и не переписать с нуля (поверьте, я как мог пытался перерабатывать +- живые места. Увы их не много) Впереди еще много работы, даже то, что я представил в этой статье нужно будет в дальнейшем отшлифовывать. Но я не унываю, ведь изменение даже такой незначительной (на первый взгляд) вещи как Canvas уже дало свои плоды. Игра стала стабильнее, объем исполняемого файла сильно уменьшился, поскольку стало намного меньше изображений (да-да, атласы еще и этим помогают), а система стала более гибкой, что еще сыграет нам на руку в случае расширения. И да, чуть не забыл как выглядят новые панели:

Тут есть один тонкий момент. Пытливый читать, может заявить: "Так ничего не изменилось! Если ты выключишь объекты - будет перерисован весь канвас. Поэтому лучше выключать канвасы целиком".

Смотрите. У нас три панели. Они чайлды нашего канваса. Когда мы меняем панель, нам нужно отрисовать новую панель. Да, канвас будет перерисован, НО, нам в любом случае его перерисовывать, так как нет чего-то общего.

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

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

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

Впереди у нас статья о геймплейных моментах, о слайме, о графике и достижениях. Всех благ!

Как и обещал, полезные ссылки:


Лучшие комментарии

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

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

Еще забавно получилось по поводу метода FixedUpdate(), который вызывается 0.02 раза в секунду. Это как бы 1 раз в 50 секунд, если упростить число раз до целого минимального значения. Наверное все же метод вызывается 1 раз в 0.02 секунды.

1) Касательно 0.02 раз да, тут я накосячил, спасибо что обратил внимание!

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

это старый и новый вариант

Читай также