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

Мальчишки и девчонки! A так же их родители! Кринжовую архитектуру Увидеть, не хотите ли? Разбираемся как устроен Canvas, для чего он нужен и как его лучше не делать.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вторым шагом нам нужно удалить все, что нам не нужно. По большей части это касается панели результатов (некогда 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 и плодит Batches. Чтобы уменьшить нагрузку нам следует использовать так называемые «графические атласы».

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

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

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

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

Result Panel

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

Первой для рассмотрения предлагаю панель конца уровня. Выше я обещал рассказать как реализовать работу данной панели, а если конкретнее достижений. Для реализации данного функционала мы будем использовать один из элементов 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;
<p>Уф, настольджи. Хотя нет, все еще кринж.</p>

Уф, настольджи. Хотя нет, все еще кринж.

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

А теперь следите за руками. 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

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

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

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

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

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

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 связанных точек.

Папин бродяга, мамин симпатяга
Папин бродяга, мамин симпатяга

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

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

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

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

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

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

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

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

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

1515
10 комментариев

На канвас в конце статьи все еще больно смотреть. Ведь изменение каждого элемента канваса ведет к его полной перерисовке. И да, об этом сказано в статье от Banzai, на которую ссылается автор

1
Ответить

Интересный момент. Я на эту тему провел тест, мол как лучше. Как итог: если объекты выключены, то отрисовывать он их будет, так что выносить в три разных канваса смысла нет.

Ответить

"Что я предлагаю Мы сразу ставим спрайт, что звезда порлученна, а если нужно, мы заменяем на пустую."

Предлагаю сразу ставить нужный спрайт в первом проходе.

И также вынести в константы темплейты для string.Format и использовать уже их в рантайме

Ответить

Угадаю что это за движок, не заглядывая в тэги.

1
Ответить