Як декомпозувати та розробити гру помодульно з допомогою ECS



З минулого року Genesis тісно співпрацює з КПІ імені Ігоря Сікорського, створюючи освітні програми та інфраструктурні проєкти. Щоби майбутні інженери могли навчатися актуальним технологіям, з цього семестру в програмі зʼявилися три курси, створені спільно з топменеджерами та спеціалістами компаній з екосистеми Genesis: «Розробка мобільних ігор», «Архітектура високонавантажених систем» та «Розробка ігрових застосувань».


У межах одного з курсів Віктор Антоненко, Unity Lead в OBRIO розповів про композицію проєкту й розробку гри з допомогою архітектурного патерну Entity Component System (ECS), а також провів декомпозицію механіки на прикладі гри «Козаки» від української компанії GSC Game World. Публікуємо найважливіше з лекції.



Складові компоненти архітектури


Entity Component System (ECS) — це архітектурний патерн, який використовується здебільшого в геймдеві. Якщо в інших сферах розробки програмного забезпечення він застосовується рідко, то в ігровій сфері вважається одним із найпопулярніших. У лекції розглядається власна реалізація архітектури ECS. В інших джерелах деякі складові можуть мати інший зміст.


Основні складові архітектури ECS — це System, Entity, Component, State.


System (система) має список Entity (сутностей) і керує ними. Система містить механізми високого рівня й логіку, яка відповідає за зв’язки та взаємодію між Entity, а також прорахування цієї взаємодії.


Наприклад, один юніт атакує іншого в грі. У такому випадку в кожного юніта потенційно може бути своя логіка, яка матиме одну механіку прорахування: влучив/не влучив, наскільки один пошкодив іншого. За це відповідатиме одна система.


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


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


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


Також в ECS дуже легко створити механізм збереження гри — для цього лише потрібно зберегти стани всіх Entity у файл. Якщо потім завантажити його, стан гри відповідатиме моменту до виходу із неї.


Component (компонент) містить логіку, що стосується тільки одного об’єкту, працює з іншими його компонентами та часто є обгорткою механіки (переміщення, анімація тощо). Наприклад, механіка обгортки NavMesh або Animator Controller.



Коли є компонент, що обгортає NavMeshAgent, ви не робите прямі виклики з Entity або системи до NavMeshAgent, а тільки надсилаєте команду, що юніту треба переміститися із точки А в точку В. На рівні системи ви віддаєте наказ і знаєте, що юніт переміщується, а Entity знає, як переміщувати цей об’єкт — задати цільові координати, запустити анімацію.


State (стан) зв’язує необхідні системи між собою та відповідає за загальний стан гри. Часто виникає ситуація, коли система А потребує дані з системи В. Або при зміні стану в системі А потрібно передати дані в систему В. Наприклад, у бойовій системі один юніт знищив інший і отримав досвід та ресурси, за які відповідає інша система. Вони не взаємодіють між собою напряму, а лише мають відповідні події (events) та обробники для подій ззовні (handlers). Саме State задає ці звʼязки між системами та перериває їх, коли це потрібно.



Плюси архітектури ECS

  1. Упорядкованість рівнів ієрархії зв’язків. В ECS є чіткі рівні ієрархії різних об’єктів цієї архітектури та обмеження щодо їхніх зв’язків. Система зв’язується з Entity, а вони — зі своїми компонентами. Але Entity однієї системи не зв’язані між собою напряму. А компоненти різних Entity не повинні взаємодіяти навіть всередині однієї системи. Entity різних систем не мають ніяких звʼязків на своєму рівні.

  2. Гнучкість. Одна система мінімально пов’язана з іншою системою. Тому можна доповнювати щось в одній системі й не боятись, що зламаєте щось в іншій.

  3. Низька зв’язність коду й модульності — можна скопіювати модуль та інтегрувати його в іншу гру з мінімальними допрацюваннями.

  4. Розміщення об’єктів поруч у пам’яті. Посилання на Entity зберігаються в одному списку в системі, що дозволяє економити оперативну пам’ять. А якщо у грі дуже багато Entity, то ця економія значно посилить продуктивність гри.

  5. Зручність створення функції збереження завантаження. Усі Entity зберігаються у файл гри.



Мінуси ECS

  1. Міжсистемні виклики призводять до створення складних ланцюгів. Коли потрібно зв’язати одну систему з іншою, або, ще краще, Entity однієї системи призводять до змін в іншій, це відбувається наступним чином. Entity відправляє сигнал події про зміни. Система реагує й робить свою логіку, що пов’язана зі зміною стану цього Entity, після чого робить виклик події, на який підписана інша система на рівні State. Отже, виходить складний ланцюг викликів, щоби просто змінити, наприклад, баланс гравця після знищення іншого юніта, або нарахувати за нього бали для отримання «ачівок».

  2. При розвитку програми важко керувати зв’язками. Коли програма починає розростатися, потрібно шукати рішення, які допомагатимуть керувати всіма ланцюгами зв’язків. Інакше може вийти, що у вас сотні івентів, на які треба підписувати різні системи. Коли це стане занадто громіздким, буде складно оперувати та створювати нові зв’язки.

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



Unity ECS


DOTS (Data-Oriented Technology Stack) — це preview asset Unity Team, який розробляють уже декілька років. Потенційно DOTS має повністю змінити підхід до роботи з Unity і створити нову парадигму розробки.


В основі DOTS лежить стек Unity ECS. Він дає доступ до використання Burst Compiler, який значно підвищує продуктивність роботи в редакторі Unity.


Робота в ньому значно відрізняється від того, що описано вище, навіть від звичної Unity. Такі речі, як використання простих компонентів, стають дуже важкими. Бо саме Unity ECS має цілий ряд обмежень, що можна робити, а чого не можна. Але фахівці, які використовують DOTS, відмічають значне зростання продуктивності своїх продуктів. Тож вони продовжують «їсти цей кактус».



Що говорять розробники Unity про Unity ECS: це архітектура, яка відділяє Entity, компоненти та поведінку системи. Отже, ми маємо дещо інакший підхід до розробки, коли Entity — це контейнер для компонентів. Компоненти — це тільки дані. А вся поведінка, навіть низькорівнева, виноситься в системи.


Ця архітектура фокусується на цих даних, системи читають компоненти з даними та трансформують їх у вхідні та вихідні State, а потім відправляють на конкретні Entity.

Unity ECS — цікава тема, але потрібно дуже багато часу та ресурсу, щоби перейти на таку розробку. А, маючи вже готовий проєкт, майже неможливо в економічному плані перейти на використання DOTS. Тож, у цій темі краще розібратися, створивши pet-проєкт у вільний час, або перед початком наступного проєкту.



Декомпозиція механіки на прикладі гри «Козаки»


Для правильної композиції ECS потрібно виділити системи, далі — Entity і компоненти цих систем та спроєктувати зв’язки між ними. Розберемося на прикладі гри «Козаки».



Що тут відбувається? Маємо три шахти, млин, поле пшениці навколо, селян, які збирають пшеницю, бойові юніти зі списами, офіцерів та барабанщика, кавалерію, собор і конюшню.


Які тут є механіки і обʼєкти:

  • шахта, млин і ресурси;

  • юніти (бойові й небойові);

  • побудова споруд;

  • створення юнітів у будівлях;

  • мінікарта.


Отже, маємо такі системи:


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


Юніти й будівлі. У кожного з них є стан. Будівля створюється на карті, а юніти — всередині будівлі. У них обох є HP (Health Points). Коли ці HP закінчуються, юніт знищується, а будівля на низькому рівні HP починає горіти. Як бачимо, відбувається певна сепарація: є спільні риси й окремі. Тобто маємо розгалуження навіть всередині спільних механік.


Накази юнітам і будівлям. Ми можемо віддавати накази всім юнітам переміщатись по карті. Але бойовий юніт не зможе збирати пшеницю або зайти в шахту для видобутку, а юніт-селянин може. Отже, ними керуватиме одна система, у них будуть схожі Entity, але вони відрізнятимуться між собою. Їхня поведінка буде записана в самій системі, як вона керуватиметься, як це виглядатиме на карті та в грі. Але кожен юніт має свій стан у кожен поточний момент.


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



Розглянемо часткову декомпозицію на прикладі системи ресурсів:

  1. Ресурси на мапі — дерева, каміння, пшениця, місце для шахт.

  2. Ресурсні будівлі — це млин, шахти та склади.

  3. Агенти збору ресурсів — це селяни. Але вони поводяться по-різному. Адже, перебуваючи всередині шахти, вони генеруватимуть ресурси в інший спосіб, ніж на полі з пшеницею, з деревом і камінням. Для дерева, пшениці та каміння агент збирає ресурси й несе до будівлі, де вони зараховуються на баланс.

  4. Баланс ресурсів. Містить перелік і кількість всіх ресурсів гравця і оновлюється, реагуючи на зміни.


Які зв’язки між системами при зборі ресурсів?

  1. Накази юніту будувати ресурсні споруди — шахти та млини.

  2. Витрата ресурсів із балансу — зовнішній виклик від інших систем для виконання деяких дій. Наприклад: запит на побудову будівлі, створення юніта, навіть постріл далекобійного юніта з мушкетом буде знімати залізо й вугілля.

  3. Коли ви будуєте млин, на мапі зʼявляється поле пшениці. Побудова інших споруджень — це інша система (механіка систем побудови). Але зерно, що зʼявилося, стосується системи збору ресурсів.

  4. У шахту та на млин потрібно відправляти юніти для збору ресурсів, віддаючи накази. Значить, система юнітів та керування ними повʼязана із системою збору ресурсів.

  5. Зарахування на баланс — це дії юнітів. Вони доносять зібране до будівлі, поновлюючи баланс. Це теж сторонній виклик для системи збору ресурсів.

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