Еще одна система частиц. Постмортем

в 8:18, , рубрики: Gamedev, particle system, shaders, Алгоритмы, высокая производительность, разработка под iOS

image

В сентябре этого года должна была выйти мобильная игра Titan World от Unstoppable – минского офиса Glu mobile. Проект отменили прямо перед мировым релизом. Но наработки остались, и наиболее интересными из них, с любезного разрешения хэдов студии Dennis Zdonov и Alex Paley, хотелось бы поделиться с общественностью.

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

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

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

Z-координата фрагмента высчитывалась для плоского партикла, как если бы мы рисовали сферу, а радиус этой сферы модулировался синусом от шума Перлина c учетом времени:

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

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

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

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

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

Дизайнеры сформировали список фич и нарисовали эскиз UI, основываясь на собственном опыте и практике работы с unity, unreal и after effects.

Доступные технологии

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

Что оставалось? Использовать vertex texture fetch и хранить позиции/ускорения в текстурах? Рабочий вариант, но память тоже почти закончилась, производительность у такого решения не самая оптимальная, да и архитектурной красотой результат не отличается.

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

Поиск подходов

Я начал с классификации задач, решаемых системой частиц, и поиска частных случаев. Получилось примерно следующее (кусочек реальной доки концепта из переписки с тимлидом):

“- Массивы партиклов/меш с циклическим движением. Нет процессинга позиции, все через уравнение движения. Применения – дым из труб, пар над водой, снег/дождь, объемный туман, качающиеся деревья, возможно частичное применение на нецикличных эффектах ака взрывы.

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

— Генерация частиц и процессинг скоростей. Самый универсальный и самый сложный/медленный вариант, см тек процессинга движения.”

Если коротко: есть разные партикловые эффекты, и некоторые из них можно реализовать проще, чем другие.

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

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

image
Среда разработки под 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, рождая новые частицы и удаляя старые. Фактически, чтобы получить хорошее быстродействие, необходимо написать толковый менеджер памяти. Но что будет, если просто не рисовать «мертвые» частицы?

Предположим, (для начала) что время-интервал испускания частиц и время жизни частицы – константа в рамках одного эмиттера.

image
Тогда мы можем умозрительно представить наш буфер (который содержит только 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’а.

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

С помощью восстановленных из 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 (растягивание частицы по вектору движения), ориентация частиц поперек вектора движения (это позволяет сделать, к примеру, сферу из частиц), изменение размера частицы согласно текущему времени ее жизни и десяток других мелочей.

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

image
Тест «сигаретный дым» работает с помощью распределения начальной скорости и ускорения по perlin noise.

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

Изначально мы планировали лишь менять цвет/прозрачность частицы в зависимости от её времени. Я добавил в пиксельный шейдер несколько алгоритмов.

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

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

Эволюция границ – при движении частицы в пространстве её границы (альфа-канал) модулируются комбинацией спотлайта и шума перлина, что дает динамику их перетекания, очень похожую на облака, дым и прочие fluid-эффекты.

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

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

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

image
Первые эксперименты с эволюцией границ.

Что дальше?

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

С технологической точки зрения тоже есть куда двигаться – сейчас, к примеру, в работе несколько эффектов разрушения каркасных объектов:

image

Открытым пока остается вопрос сортировки партиклов для альфа-блендинга: поскольку все считается аналитически в шейдере, для сортировки нет, собственно, даже самих входных данных. Зато есть большое поле для экспериментов!

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

Автор: 0xf0x

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js