Оптимізація коду на Unity. Користь співпрограм, керування об’єктами за допомогою масиву та підступність конкатенації рядків

Вітаю всіх поціновувачів ігрової індустрії. Мене все ще звати Роман і це друга частина тексту присвячена оптимізації на Unity, а саме коду. В цьому матеріалі можливо не буде таких «красивих» прикладів, як в оптимізації графіки, оскільки написання коду, який би він акуратний, систематизований чи крутий не був, не буде викликати в читача таких яскравих емоцій, як умовна демонстрація 3D моделі танка Abrams M1A2.

Саме тому в рамках цього тексту, я б хотів поділитись ще крім моїх прикладів та порад деякими підказками, чи то «фішками», при роботі з кодом, які можуть прискорити його написання. До того ж розписувати детально кожну техніку, вважаю не так цікаво і інформативно, як переглянути відео, де детально і з прикладами пояснюють її. Тому від себе додам мій умовний ТОП відео на ту чи іншу техніку в разі потреби.

Також, багато речей, які будуть наведені в даній статі вже довгий час «гуляють» просторами інтернету (форуми, Youtube, офіційна документація) і можуть бути не в новинку для вас. Це я до того, що винайденням нового велосипеда я на жаль тут займатись не буду, а може й на щастя, оскільки поширені практики оптимізації багато ким перевірені й доведені.

Перед початком треба акцентувати увагу ще на двох важливих речах:

  1. Не старайтесь «фанатично» з першого рядка коду здійснювати будь-які дії з оптимізації написаного коду. Ваша основна мета, як розробника, створити працюючий продукт та довести його до релізу. Здійснювати оптимізацію коду потрібно там, де є проблеми з продуктивністю (слабкі місця), які виникають в процесі створення продукту і які відстежуються в профайлері. Якщо в результаті переписання працюючого функціоналу, який споживає раціональну кількість ресурсів ви витратите, до прикладу, два дні, і результат оптимізації буде пришвидшення виконання в −0,02 мс. — це буде марна трата вашого дорогоцінного часу, який ви б могли витрати на інші важливіші речі.
  2. Використовуйте профайлер в процесі розробки. Не тільки в кінці, а саме в процесі роботи! Це дозволить вам відразу відстежувати проблемні місця, які є можливість виправити і протестувати на свіжу «пам’ять». Якщо ви випадково не знайомі з принципом роботи профайлера — ось посилання на офіційне відео Unity про принцип роботи з профайлером. Рекомендую ознайомитись!

Так, знову лірична частина завершена, наскільки мене вистачило, тому перейдемо до практичних пунктів.

Кешуйте компоненти

Перше і банальне, але водночас дуже важливе. Кешування компонентів, до яких ви часто будете звертатися в коді, зменшить накладні витрати на обчислення, допоможе оптимізувати використання пам’яті, зберігаючи та повторно використовуючи існуючі дані замість того, щоб створювати їх щоразу, коли вони потрібні. Кешування може відбуватись декількома способами:

За допомогою інспектора в редакторі. Все що вам потрібно зробити, це перетягнути потрібний компонент (у разі якщо він знаходиться в сцені) у відповідне поле, яке ви створили:

В методах Awake() або Start():

Чому саме в цих методах, може виникнути питання в початківці? Це обумовлено тим, як саме працює весь двигун Unity, і в якій послідовності в нього викликаються функції, коли ви умовно натиснете кнопку Play. Це насправді база (як донат на ЗСУ), яку обов’язково знати і враховувати, тому на всяк випадок залишу посилання на документацію по цьому питанню.

Tips: однією з цікавих функцій, які на жаль рідко використовуються розробниками є функція Reset(). Вона викликається, коли ви додаєте новий компонент до Gameobject в сцену.

Вона може бути корисна в різних сценаріях залежно від контексту. До прикладу, у вас є багато Gameobjects до яких в інспекторі додаєте компонент, який має серіалізоване або публічне поле, яке потрібно заповнити, переміщуючи компоненти в них. Якщо в Reset() прописати ось такий функціонал — отримаємо невелике пришвидшення роботи:


Не використовуйте GameObject.Find()

В процесі роботи з іншими розробниками, інколи мені приходиться стикатись з методом GameObject.Find() — це дуже погана практика використання з декількох основних причин:

  • GameObject.Find() шукає у всій сцені GameObject із вказаною назвою, що є дорогим в обчислювальному плані, особливо у великих сценах; вартість продуктивності пропорційно зростає зі збільшенням кількості GameObjects у сцені;
  • Метод покладається на порівняння рядків для ідентифікації GameObjects за назвою; порівняння рядків відбувається повільніше, ніж інші типи даних;
  • якщо існує кілька GameObjects з однаковою назвою, GameObject.Find() поверне першу, яку зустріне, яка може бути не такою, яку ви планували;
  • Зміна назви GameObject в інспекторі, до прикладу, вашим дизайнером негайно руйнує всю логіку виконання.

При кешуванні посилань під час ініціалізації краще використовувати FindObjectOfType або FindObjectsOfType. Навіть якщо вам потрібно знайти якийсь специфічний GameObject в сцені, кращою практикою буде створити порожній скрипт і викликати в методі Awake() функцію FindObjectOfType() на цей об’єкт.

Tips: В процесі написання коду, розробнику часто потрібно звертатись до основної камери (Main Camera) особливо при використанні методу ScreenPointToRay. В Unity для цих цілей реалізовано Camera.main.

Властивість Camera.main повертає першу камеру, позначену тегом «MainCamera». Для версій Unity до 2019.4.9 це реалізовано шляхом виклику FindGameObjectsWithTag. Результат не кешується, тому постійний доступ до цієї властивості може вплинути на продуктивність. Розгляньте можливість кешування камери в поле та встановлення значення в Start() або в Awake().

З Unity 2019.4.9, Camera.main стала набагато швидша, завдяки тому, що всередині Unity зберігає кеш GameObject екземплярів, які мають «MainCamera» тег і Camera.main поверне перший увімкнений об’єкт у цьому кеші. Хоча це набагато швидше, тепер це схоже на виклик GetComponent, і тому у разі частого використання основної камери все ще краще кешувати значення.

Використовуйте співпрограми

В Unity співпрограми часто використовуються для обробки завдань, які включають затримки або асинхронні операції без блокування основного потоку. Вони дозволяють виконувати завдання в кількох кадрах, тим самим, як би, розбиваючи «важкі» операції на декілька складових, які не «заморозять» вашу гру в певних моментах.

Cпівпрограми також слугують дуже корисним і зручним інструментом коли слід зачекати якийсь проміжок часу перед виконанням тієї чи іншої логіки, ніж реалізація очікування, до прикладу, яка може бути в методі Update().

Звісно в них, як і у всього в світі є свої недоліки. Тому якщо, через якусь причину Coroutines не в змозі надати вам тієї функціональності, якої ви потребуєте, то рекомендую звернути увагу на функцію сценаріїв C# під назвою Async/Await (P.s.: перекладені субтитри вам в допомогу).

Tips: Хоча yield не створює сміття, новий об’єкт WaitForSeconds — створює. Саме тому у разі використання фіксованого часу очікування рекомендується кешувати WaitForSeconds та повторно його використовувати.

До речі, співпрограму також можна запустити за допомогою метода Start(), як показано на зображенні вище.

Уникайте конкатенації рядків

Конкатенація рядків передбачає створення нового рядка кожного разу, коли ви об’єднуєте два рядки разом. Ця операція є відносно дорогою з погляду розподілу пам’яті та продуктивності.

Коли ви об’єднуєте рядки за допомогою таких операторів, як «+» або «string.Concat()», новий рядковий об’єкт створюється в пам’яті для зберігання результату, що своєю чергою створює додатковий тиск на garbage collector, якому прийдеться потім наводити більше порядку.

Пом’якшити цю проблему дозволяє клас StringBuilder. Він надає більш ефективний спосіб поступового створення рядків. Замість створення нового рядкового об’єкта кожного разу, коли ви об’єднуєте рядки, StringBuilder підтримує внутрішній буфер, до якого можна додавати символи або рядки. Розмір цього буфера можна динамічно змінювати за потреби, зменшуючи необхідність частого виділення пам’яті. Коли ви закінчите будувати рядок, ви можете викликати ToString() конвертуючи внутрішній буфер StringBuilder у звичайний рядковий об’єкт.

У разі частого використання кешованого StringBuilder з різними вхідними даними не забувайте викликати StringBuilder.Clear() для очищення буфера.

Керуйте об’єктами за допомогою масиву

Керування об’єктами в Unity через масиви, а не безпосередньо в методі Update() може мати кілька переваг, в першу чергу з причин того, як процесор обробляє логіку виконання. Цей метод звісно залежить в першу чергу від специфіки задач, які потрібно виконати. До прикладу, він чудово підійде до ситуації де ви створюєте юніти, в яких є різні індикатори (здоров’я, мани тощо) над ними. І вам потрібно щоб ці індикатори завжди дивились в бік гравця. Для цього ви могли зробити б наступне:

Але для набагато кращої продуктивності (я вже навіть не кажу про кращий менеджмент) цей же функціонал можна реалізувати наступним чином:

Доступ до цього умовного обробника можливо реалізувати в різний спосіб на ваш розсуд, до прикладу, через паттерн Singleton або функціонал Scriptableobject. Принцип роботи цієї техніки чудово з прикладами та цифрами продемонстрував Джейсон ось в цьому відео — обов’язково до перегляду!

Використовуйте події

Використання події часто ефективніші, ніж ручна перевірка умов і оновлення об’єктів у Update() кожного кадру. Тобто, коли ви використовуєте події, ви можете точно контролювати, які об’єкти чи компоненти відповідають на певні дії. Це означає, що лише об’єкти, які «підписані» на конкретні події, виконуватимуть свої пов’язані методи, тоді як в Update() усі об’єкти з прикріпленим сценарієм виконуватимуть свій код кожного кадру, незалежно від того, потрібен він чи ні.

Реалізація подій може бути різною — починаючи від С# Action з «підпискою» на початку виконання до створених подій спеціально під Unity — UnityEvent.

Головне, що потрібно пам’ятати при використані С# event, що так само, як ви робити «підписку», до прикладу в методі OnEnable(), так само, у випадку коли об’єкт знищується або він більше не потрібен — обов’язково потрібно зробити «відписку», до прикладу, у методі OnDisable().

Цей спосіб чудово розкриває свій потенціал в моєму улюбленому паттерні проектування під назвою "Observer«(Спостерігач). Якщо ще не знайомі, то наполегливо рекомендую ознайомитись з принципом його роботи. Ось в цьому відео наведено детально принцип роботи, який легко можна трансформувати під використання подій.

Стежте уважно за Debug

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

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

Рішенням, яким я користуюсь зазвичай є використання Platform Dependent Compilation.

Використовуйте паттерн ObjectPool

Тут описувати нічого не буду, оскільки на GameDev DOU є чудова стаття, як його використовувати — обов’язково до ознайомлення (в коментарях також для наочності рекомендую подивитись відео ще одного розробника, про цей паттерн).

Використовуйте GameObject.CompareTag()

Хоча я і займаю сторону порівняння об’єктів за допомогою інтерфейсів, інколи все ж таки трапляються випадки, коли корисно використати порівняння тегів. В цих випадках я рекомендую використовувати GameObject.CompareTag() і не використовувати GameObject.tag. Вся справа в тому, що порівняння за допомогою GameObject.tag по суті здійснює порівняння рядків, що в підсумку створює сміття. В той же час GameObject.CompareTag() спеціально розроблений для того, що обійти цю проблему.

Видаляйте пусті методи Unity типу Update()

В Unity рекомендується видаляти порожні методи за замовчуванням, наприклад Start() або Update() якщо ви не збираєтеся ними користуватися, оскільки кожен виклик методу супроводжується невеликими накладними витратами. Якщо Unity постійно викликає порожні методи, наприклад Update() або FixedUpdate(), це може мати незначний вплив на продуктивність, особливо у великих проектах із багатьма сценаріями.

Використовуйте багатопотоковість

Сьогодні практично всі пристрої (мобільні, ПК) володіють декількома процесорними ядрами для обрахунку операцій. То чому б не використовувати їх потенціал? Саме з цією метою Unity розробив і інтегрував систему DOTS.

Ця тема насправді дуже цікава і в той же час може заплутати пересічного розробника, який звик до ООП. Скажу навіть більше, сама Unity в рамках розробки своєї концепції DOTS, яка тягнеться ще з 2018 року, тільки в 2023 році нарешті випустили готову до виробництва систему ECS (Entity Component System).

На мій суб’єктивний погляд — це непогано, але й не все так добре. Але ця тема для більш великої дискусії. Що саме може зацікавити нас з вами, так це дві наявні системи, на яких працює концепція DOTS — системи Jobs та Burst. Їх перевагами є те, що вам не потрібно здійснювати, якісь зайві рухи чи змінювати налаштування проекту. Ви просто можете інтегрувати їх «міць» прямо у ваш проект.

Саме ці дві системи допоможуть вам забезпечити багатопотоковість в необхідних вам розрахунках. Вони, до прикладу, чудово підійдуть для написання коду при розрахунку постійних «важких» математичних операцій.

На перший погляд це виглядає трохи «складно». Але все що відбувається в цьому сценарії, це:

  • ініціація масиву даними;
  • створюєте спеціальні масиви для роботи з системою Jobs NativeArray;
  • передаєте їм дані раніше заповнених масивів;
  • передаєте дані заповнених NativeArray у створену вами struct SumJob;
  • заплановуєте виконання цього завдання;
  • очікуємо завершення;
  • здійснюємо дії з повернутими і розрахованими даними;
  • очищуємо NativeArray.

Так, хоча це і забезпечить нам багатопотоковість розрахунків, в деяких випадках це не гарантує нам покращення продуктивності. Річ у тім, що для створення і планування завдань також потрібний ресурс. Він зазвичай багатократно перекривається отриманим результатом, але лише в тому випадку, якщо завдання розрахунків «важке». До прикладу, як було наведено в категорії «Керуйте об’єктами за допомогою массива» користь від створення подібної системи буде вам в мінус. В моєму тесті, який я проводив з динамічно розширюваним функціоналом об’єктів до 1000 одиниць, які направлені в сторону гравця, система Jobs показувала себе гірше на 0,12 мс в порівнянні керування за допомогою звичайного массива. ЇЇ потужність вдалось відчути коли кількість об’єктів перетнила позначку в 3000 одиниць.

Якщо вас зацікавило, як можна використовувати дану систему ось не нове, але чудове відео про Unity Job System.

Перевірте чи увімкнений у вас Incremental Garbage Collector

Замість того, щоб створювати одну тривалу перерву для Garbage Collector під час виконання вашої програми, Incremental Garbage Collector використовує кілька, набагато коротких перерв, які розподіляють робоче навантаження на багато кадрів.

Використовуйте вбудовані методи Transform

При переміщенні об’єкта в сцені використовуйте Transform.SetPositionAndRotation (чи аналог локального переміщення Transform.SetLocalPositionAndRotation), щоб оновити позицію та обертання одночасно. Це дозволить уникнути додатковий витрат на подвійну зміну трансформації.

Використовуйте хеш-значення

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

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

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

Саме для того, щоб не виконувати подвійну роботу, як в плані продуктивності, так і читабельності коду використовуйте Animator.StringToHash при роботі з аніматором та Shader.PropertyToID при роботі з шейдерами чи матеріалами. Ці ідентифікатори так само легко можна передати в метод, як назву.

Використовуйте Physics.OverlapSphereNonAlloc()

Physics.OverlapSphere() дуже крутий і зручний метод для реалізації багатьох механік, починаючи від взаємодії з навколишнім середовищем закінчуючи реалізацією вибуху умовної гранати. Але завдяки тому, що він динамічно розподіляє пам’ять для зберігання отриманого масиву колайдерів, це погано впливає на продуктивність, особливо якщо його часто викликати.

Для пом’якшення цього навантаження було реалізовано Physics.OverlapSphereNonAlloc(). Цей метод виконує те ж завдання, що і Physics.OverlapSphere(), але з ключовою відмінністю: він повторно використовує існуючий масив для зберігання знайдених колайдерів, таким чином уникаючи динамічного розподілу пам’яті.

P.S.

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

Підписуйтеся на Telegram-канал @gamedev_dou, щоб не пропустити найважливіші статті і новини

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному4
LinkedIn


Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі