Ще одна система частинок. Постмортем

У вересні цього року повинна була вийти мобільна гра Titan World від Unstoppable – мінського офісу Glu mobile. Проект скасували прямо перед світовим релізом. Але напрацювання залишилися, і найцікавішими з них, з люб’язного дозволу скінхедів студії Dennis Zdonov і Alex Paley, хотілося б поділитися з громадськістю.

У березні 2018 року ми з тимлидом провели нараду, на якій обговорювали, чим мені зайнятися далі: код фонового був закінчений, а нових фіч і спецефектів у планах не було. Логічним вибором бачилося переписати з нуля систему частинок – по всім тестам вона давала найбільші осідання в продуктивності, плюс зводила з розуму дизайнерів своїм інтерфейсом (текстовий config-файл) і вкрай мізерними можливостями.

Слід зазначити, що більшу частину часу команда працювала над грою в режимі «завтра реліз», тому всі підсистеми я писав, по-перше, намагаючись не зламати те, що вже працює, а по-друге, з коротким циклом розробки. Зокрема, велика частина ефектів, на які штатна система була не здатна, робилася під фрагментном шейдере, не зачіпаючи код основний.

Обмеження на кількість частинок (матриці трансформації для кожної частинки формувалися на cpu, висновок – через инстансинг gl-экстеншном ios) змусило написати, зокрема, шейдер, який «эмулировал» великий масив частинок, базуючись на аналітичному поданні форми об’єктів, і компоузился з простором, підсовуючи у depth-буфер фейкові дані.

Z-координата фрагмента вираховувалася для плоского партикла, як якщо б ми малювали сферу, а радіус цієї сфери модулировался синусом від шуму Перлина з урахуванням часу:

r=.5+.5*sin(perlin(specialUV)+time)

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


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

Постановка задачі

Що хотілося отримати на виході? Ми йшли, скоріше, від обмежень, з якими намучилися на попередній системі частинок. Ситуацію погіршував той факт, що бюджет кадру був практично вичерпаний, причому на слабких девайсах (зразок ipad air) по повній був завантажений як піксельний, так і вертексный конвеєр. Тому хотілося в результаті отримати максимально продуктивну систему, нехай навіть і трохи обмеживши функціональність.

Дизайнери сформували список фіч і намалювали ескіз UI, грунтуючись на власному досвіді і практиці роботи з unity, unreal та after effects.

Доступні технології

В силу legacy і обмежень, спущених головним офісом, ми були обмежені opengl es 2. Таким чином, технології начебто transform feedback, що використовуються в сучасних particle systems, були недоступні.

Що залишалося? Використовувати vertex texture fetch і зберігати позиції/прискорення в текстурах? Робочий варіант, але пам’ять теж майже закінчилася, продуктивність такого рішення не найоптимальніша, так і архітектурною красою результат не відрізняється.

До цього часу я прочитав безліч статей про реалізації систем частинок на gpu. Переважна більшість містило яскравий заголовок («мільйони частинок на мобільному gpu, з преферансом і поетесами»), проте реалізація зводилася до прикладів простих, хоча і цікаво виглядають емітерів/атракторів, і в цілому була майже непридатною для реального застосування у грі.
Максимальну користь принесла ця стаття: автор вирішував реальну задачу, а не робив «сферичні партиклы у вакуумі». Цифри бенчмарків з цієї статті і результати профілювання дозволили заощадити багато часу на етапі проектування.

Пошук підходів

Я почав з класифікації задач, розв’язуваних системою частинок, і пошуку приватних випадків. Вийшло приблизно наступне (шматочок реальної доки концепту з листування з тимлидом):
“- Масиви партиклов/меш з циклічним рухом. Немає процесингу позиції, все через рівняння руху. Застосування – дим з труб, пар над водою, сніг/дощ, об’ємний туман, гойдаються дерева, можливо часткове застосування на нецикличных ефекти ака вибухи.

Читайте також  Як технології маніпулюють вашим розумом: погляд ілюзіоніста і експерта з етики дизайну Google

— Стрічки. Формування vb за події, процесинг тільки на гпу (постріли променями, польоти за фіксованою (?) траєкторії зі слідом). Може, злетить варіант з передачею юніформ старт-фініш координат і побудовою стрічки по vertexID. з т. з. рендера хрест з френелем як на директлайтах + uvscroll.

— Генерація частинок і процесинг швидкостей. Самий універсальний і найскладніший/повільний варіант, см тек процесингу руху.”
Якщо коротко: є різні партикловые ефекти, і деякі з них можна реалізувати простіше, ніж інші.

Ми вирішили розбити задачу на кілька ітерацій – від простого до складного. Прототипування робилося на моєму движку/редакторі під windows/directx11 в силу того, що швидкість такої розробки була на кілька порядків вище. Проект компилировался за пару секунд, а шейдери і зовсім редагувалися «на льоту» і компілювати в тлі, відображаючи результат в реальному часі і не вимагаючи ніяких додаткових рухів тіла начебто натискання кнопочок. Той, хто збирав великі проекти зв’язкою macbook/xcode, думаю, зрозуміє причини такого рішення.

Всі приклади коду будуть взяті саме з windows-прототипу.


Середовище розробки під windows.

Реалізація

Перший етап – статичний висновок масиву партиклов. Нічого складного: заводимо vertex bufffer, заповнюємо квадами (пишемо правильні uv для кожного квад), а vertex id шиємо в «додатковий» uv. Після чого в шейдере за vertex id виходячи з налаштувань емітера формуємо позиції партиклов, а по uv відновлюємо екранні координати.

Якщо vertex_id доступний нативно, можна зовсім обійтися без буфера і без uv для відновлення екранних координат (як зрештою і зроблено в windows-версії).

Шейдер:

struct VS_INPUT
{
...
uint v_id:SV_VertexID;
...
}

//float index = input.uv2.x/6.0;//читання vertex_id з буфера
index = floor(input.v_id/6.0);//читання vertex_id 
float2 map[6]={0,0,1,0,1,1,0,0,1,1,0,1};
float2 quaduv=map[frac(input.v_id/6.0)*6];

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

Наступним завданням стала реалізація fade in/fade out для такої системи. Партиклы не повинні з’являтися нізвідки і зникає в нікуди. У класичній реалізації системи частинок ми процессим буфер програмно засобами cpu, народжуючи нові частинки і видаляючи старі. Фактично, щоб отримати гарну швидкодію, необхідно написати тлумачний менеджер пам’яті. Але що буде, якщо просто не малювати «мертві» частинки?

Припустимо, (для початку) що час-інтервал випромінювання частинок і час життя частинки – константа в рамках одного емітера.


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

pCount = round (prtPerSec * LifeTime / 60.0);
pCountT = floor (prtPerSec * EmissionEndTime / 60.0);
pCount=min (pCount, pCountT);

а в шейдере розрахувати час виходячи із index і time (час, що минув зі старту ефекту)

pTime=time-index/prtPerSec;

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

Малювати частинки з pTime менше нуля нам не потрібно – вони ще не народилися. Те ж саме відноситься до часток, у яких сума часу життя і поточного часу перевищить час кінця емісії. В обох випадках ми не будемо нічого малювати, занулив розмір частки та/або відкинувши її за екран. Такий підхід дасть невеликий оверхед на фазах fadein/fadeout, зберігши максимальну продуктивність у фазі sustain.

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

Читайте також  Дослідження файлової системи HDD відеореєстратора моделі QCM-08DL

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

З допомогою відновлених з vertex_id uv ми отримаємо вже чотири точки (точніше, зрушимо кожну з точок квад в потрібну нам сторону), на чому вертексный шейдер, виконавши проектування, завершить свою роботу.

p.xy+=(quaduv-.5);

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

Нарощуємо функціонал

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

Ідея полягала в тому, щоб при народженні нової частинки записувати позицію емітера в вертексный буфер. Оскільки кількість таких частинок невелика, накладні витрати повинні бути мінімальними.

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

Складність виникла з синхронізацією часу на cpu і gpu. Необхідно було забезпечити, щоб апдейт буфера був зроблений саме тоді, коли «нова», зациклена частинка буде знаходитися у своєму стартовому положенні. Тобто стосовно до кільцевому буферу потрібно синхронізувати межі регіону апдейта з часом роботи емітера.

Я переніс hlsl код в с++, для тіста написав переміщення емітера за Ліссажу, і все це раптово запрацювало. Однак час від часу система «плювалася» однією або кількома частками, вистрілюючи їх в довільному напрямку, не вчасно видаляти їх або створюючи нові в довільних місцях.

Проблема зважилася аудитом точності підрахунку часу в движку і паралельно перевіркою дельти часу при записі нового положення емітера – таким чином, щоб обновився весь незатронутый на попередній ітерації ділянку буфера. Це було потрібно ще й для роботи системи в умовах вимушеного desync’a – раптова просадка fps не повинна була ламати ефект, тим більше, що для різних девайсів наша гра фіксувала різний fps згідно з продуктивністю – 60/30/20.

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

Приблизно в цей час напарник вже зробив «рибу» редактора, достатню для тестування системи, і виписав шаблони/api для інтеграції системи в наш движок.

Я портувати весь код під ios/opengl, інтегрував і нарешті зробив реальні тести ефектів на реальному девайсі. Стало ясно, що система не тільки працює, але і придатна для продакшну. Залишалося закінчити UI редактора і відполірувати код до стану «не страшно віддати в реліз завтра».

Ми вже навіть зібралися писати менеджер пам’яті, щоб не виділяти/знищувати буфер (який зберігав vertex_id, uv, позицію і початковий вектор частинки) для кожного нового ефекту з динамічним емітером, як в голову постукала ще одна ідея.

Мені не давав спокою сам факт існування вертекс буфера в цій системі. Він явно виглядав у ній архаїзмом, «спадщиною темних століть фіксованого конвеєра». Роблячи тестові ефекти на windows-прототипі, я подумав про те що рух емітера завжди плавне і завжди відбувається набагато повільніше, ніж рух частинок. Більш того, при великій кількості частинок оновлення позиції приводить до того, що в сотні частинок записуються одні і ті ж дані. Рішення виявилося простим: заведемо фіксований масив, в який потрапить «історія» положення емітера, нормалізована за час життя частинки. А на gpu проинтерполируем дані. Після цього в ios/gles2 версії зникла необхідність у динамічних буферах (залишився тільки загальний статичний – для реалізації vertex_id), а в windows/dx11 версії буфери зникли взагалі завдяки нативному vertex_id і можливості d3d api прийняти на малювання null замість посилання на вертексный буфер.

Читайте також  Нарешті з'явилася задача, яку зможуть вирішити тільки квантові комп'ютери

Таким чином, win-версія системи, за сучасними мірками, не споживає пам’ять взагалі, скільки б часток ми не захотіли вивести на екран. Тільки невеликий константный буфер з параметрами, буфер позицій/базисів (60 пар векторів виявилося достатньо, з запасом, для будь-яких випадків), і, при необхідності, текстури. Виміри продуктивності показують швидкість роботи, близьку до синтетичних тестів.

Крім того, «хвіст» у ефекти на зразок sparks став виглядати набагато природніше, так як інтерполяція дозволила прибрати дискретизацію по кадрах і таким чином емітер змінював позицію плавно, ніби виклики відтворення виконувалися з частотою сотні герц.

Features

Крім базової функціональності польоту частинки (швидкість, прискорення, тяжіння, опір середовища) нам було необхідно деяку кількість функціонального «жиру».
В результаті були реалізовані motion blur (розтягування частинки по вектору руху), орієнтація частинок впоперек вектора руху (це дозволяє зробити, наприклад, сферу з частинок), зміна розміру частки відповідно до поточного часу її життя і десяток інших дрібниць.

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


Тест «сигаретний дим» працює з допомогою розподілу початкової швидкості і прискорення за perlin noise.

Піксельний конвеєр

Спочатку ми планували лише змінювати колір/прозорість частинки залежно від її часу. Я додав у піксельний шейдер кілька алгоритмів.

Ротація кольору текстури – спрощено, sin(color+time). Дозволяє в деякій мірі імітувати ефект перестановок з AfterEffects.

Фейковое освітлення – модуляція кольору частинки градієнтом у світових координатах, поза залежності від кута повороту частинки.

Еволюція кордонів – при русі частинки в просторі її межі (альфа-канал) модулюються комбінацією спотлайта і шуму перлина, що дає динаміку їх перетікання, дуже схожу на хмари, дим та інші fluid-ефекти.

Псевдокод шейдера:

b=perlin(uv);//шум перліна, uv отримані з світових координат частинки
a=saturate(1-length(input.uv.xy-.5)*2);//світлове пляма з м'якими межами
a-=abs(a-b);//"димні", нерівні межі

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


Перші експерименти з еволюцією кордонів.

Що далі?

Незважаючи на вже готовий до роботи і інтегрований в движок редактор, дизайнери не встигли зробити на ньому жодного ефекту – проект закрили. Тим не менше, немає перешкод до того, щоб використати ці напрацювання де-небудь ще – наприклад, зробити роботу на демопати Revision.

З технологічної точки зору теж є куди рухатися – зараз, наприклад, у роботі кілька ефектів руйнування каркасних об’єктів:

Відкритим поки що залишається питання сортування партиклов для альфа-блендінга: оскільки всі вважається аналітично в шейдере, для сортування ні, власне, навіть самих вхідних даних. Зате є велике поле для експериментів!

За час розробки Titan World в графічної частини гри було застосовано ще чимало трюків, але про це як-небудь наступного разу.

P. S. Покопатися в исходниках альфи движка можна тут. Приклади знаходяться в папці release/samples, основні керуючі клавіші пробіл, альт|контрол+миша. Шейдери лежать прямо в fxp файлах, їх код доступний через вікно редактора.

Степан Лютий

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

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *