Доброго дня всем!

Мы с друзьями делаем инди-игру The 13th Sign, и недавно выпустили первый трейлер, он же - техническое демо. После чего в узких кругах возник широкий круг вопросов формата “как это работает”. Ниже - все детали реализации.

Концепция. Почему все так, как есть

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

Глобально варианта было два - частицы или sdf (signed distance field). Поскольку ранний прототип игры буквально строился на winapi-шной setPixel, мы решили остановиться на частицах. Таким образом, каждый элемент локации, персонаж - это вершинный шейдер.

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

Побочные плюсы такого подхода - не надо таскать за собой хайполи-модельки, код получается компактный (весь визуал трейлера пакуется в exe размером в 20 килобайт - привет демосценерам!). Быстрая загрузка, контроль диффа, не нужно возиться с Git LFS.

Арт

Все объекты сгенерированы с помощью нескольких атомарных операций.

Базовая геометрия - сфера, тор, плоскость и т.д.

float3 randomDir = hash(iID);
float3 spherePos = _Radius * normalize(randomDir + 0.00001);

Для придания заполняющего объема - снова умножаем результат на hash(iID); Аналогично, можно регулировать толщину стенок.

Рандом

float hash( float n ) { return frac(sin(n)*43758.5453);}

Perlin noise 

(иногда рекурсивный - шум от результата шума)

float noise( float3 x ) 
{
  float3 p = floor(x);
  float3 f = frac(x); 
  f = f * f * (3.0 - 2.0 * f);
  float n = p.x + p.y * 57.0 + 113.0 * p.z; 
  float a= lerp(lerp(lerp(hash(n + 0.0), hash(n + 1.0), f.x),
           lerp(hash(n + 57.0), hash(n + 58.0), f.x), f.y),
           lerp(lerp(hash(n + 113.0), hash(n + 114.0), f.x),
           lerp(hash(n + 170.0), hash(n + 171.0), f.x), f.y), f.z);
  return a - .5;
}

CellNoise

float cellnoise(float2 p)
{
  float2 st = p;
  float3 color = .0;
  st *= 3.;// Scale

  // Tile the space
  float2 i_st = floor(st);
  float2 f_st = frac(st);
  float m_dist = 1.;

  for (int y= -1; y <= 1; y++) 
  {
    for (int x= -1; x <= 1; x++) 
    {
      float2 neighbor = float2(x, y);
      float2 pt = random2(i_st + neighbor);
      pt = 0.5 + 0.5 * sin(0 + 6.2831 * pt);// Animate the point
      float2 diff = neighbor + pt - f_st;
      float dist = length(diff);
      m_dist = min(m_dist, dist);// Keep the closer distance
    }
  }

  color += m_dist;
  return color.x;
}

Выдавливание частиц на поверхность окружности 

или стягивание в центр

for (int i = 0; i < 32; i++)
{
  float4 j = (i + 1) * float4(11, 12, 13, 14) + time.x * .003;
  float3 hole = float3(sin(j.x), cos(j.y), sin(j.z) * cos(j.w));
  hole = normalize(hole) * (8.3 + .5 * sin((pos) + time.x * .01));
  dst = 3. / distance(pos, hole);
  pos - =sign(i % 3 - .5) * normalize(hole - pos) * pow(dst, 3);
}

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

Рендер частиц

Выбор был сделан в пользу варианта без хранения состояния (позиций и скоростей), все позиции считаются как pos=f(time) - это нужно, в том числе, и для отмотки времени, что является одной из геймплейных фич.

Первые тесты быстро выявили узкое место - это пиксельный шейдер, а точнее - филлрейт GPU. Даже в fullHD, не говоря уже про 4к. Причем, нагрузка зависит от камеры - игрок может встать внутри частиц так, что они будут размером с экран, причем с многократным наложением друг на друга. Этот факт делает свободное движение камеры почти невозможным - из 3-4 миллионов частиц пара сотен имеет очень большой шанс оказаться рядом, и отрисовка этой группы поставит на колени любую систему. Причем, эти несколько миллионов оказались недостаточны для визуально приемлемого результата - сцены выглядели пустыми, и при этом “шероховатыми”.

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

без low-frequency target
без low-frequency target
c low-frequency target
c low-frequency target

Решение было собрано из двух компонентов:

  1. Размер мелких частиц не учитывает проекцию. Т.е., они всегда рисуются одинакового размера, независимо от удаленности.

  2. Рендер больших частиц удален из основного прохода. Он, в зависимости от размера, рисуется в один из двух дополнительных таргетов. Размер первого - 1/4, второго - 1/16. Затем все три таргета композятся на посте простым сложением. Читаем из текстуры - с билинейной фильтрацией.

В ближайших планах поэкспериментировать с большим количеством таргетов и большей детализацией разброса размеров. Логика выбора таргета простая - чем больше частица (рисуется она как градиент от центра), тем большая дискретность, согласно Котельникову и Найквисту, допустима для корректного ото��ражения.

Таким образом, графика, показанная в трейлере, при 60 fps грузит мою ноутбучную 3060 примерно на 40% (при 50Вт питания). А значит, с одной стороны, есть шанс на запуск на встройке (Intel Xe сейчас немного не дотягивает до 60 кадров), а с другой - возможно кратное увеличение количества частиц для производительных систем.

Мы сделали тесты с переносом “распаковки” частицы через геометрический шейдер. Несмотря на их официальную пессимизацию (в сети буквально пишут - не используйте их, если можете обойтись), буст составил около 25 процентов.

Проблемы, решения и идеи развития

Избыточная яркость объектов при большой удаленности.

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

Лодирование

Формально, для такой системы можно манипулировать количеством частиц в зависимости от расстояния объекта до камеры. Но с точки зрения арта это не всегда выглядит корректно - зависит от зашитой внутрь математики. Кроме того, частицу лучше гасить плавно а не просто включать/выключать, что уже сложнее.

Отсечение

Frustrum culling для отдельных объектов можно реализовать просто, по bounding box. Но часто объекты получаются протяженные, не локализованные (например, спиральная галактика), а рисуются они так же, одним draw call’ом на объект.

Проблему можно решить классически, разбивкой объекта на части, рисуя по одному draw call’у на кластер. Вершинный шейдер принимает на вход генерации только instance ID и ему (шейдеру) можно скормить оффсет. То есть, параметризовать шейдер таким образом, чтобы он мог по запросу рассчитывать именно те вершины, которые принадлежат запрошенному кластеру. А далее, просто не посылать на отрисовку кластер, если его bounding box не попадает в пирамиду видимости.

frustrum culling
frustrum culling

Однако есть нюанс. Например, если базовой геометрией для объекта является сфера, ее можно рассчитать двумя способами:

1)

spherePos.x = _Radius  sin(phi)  cos(theta);
spherePos.y = _Radius * cos(phi); 
spherePos.z = _Radius  sin(phi)  sin(theta);

Где phi и theta рассчитаны из instanceID через uv

float u = (float)(iID % GridWidth) / (float)(GridWidth - 1);
float v = (float)(iID / GridWidth) / (float)(GridHeight - 1)
float theta = u  2.0  PI; 
float phi = v * PI;

2)

float3 randomDir = hash(iID);
float3 spherePos = _Radius * normalize(randomDir + 0.00001);

Во втором случае когерентности между instanceID и координатами точки не будет: соседние точки могут оказаться в произвольных местах. А значит, деление по кластерам невозможно. Кроме того, подозреваю, что кэшу растеризатора такой вариант тоже нравится меньше. Замеры производительности обоих вариантов пока-что не делал.

Еще одна идея из области работы с отсечением - научиться варьировать плотность частиц динамически, уменьшая ее в тех частях, которые не попадают в пирамиду видимости, в пользу видимых частей.

OIT (order-independent transparency)

То, что подходит для звездных скоплений, не совсем хорошо для туманностей, и совсем плохо - для персонажей. Задние “стенки” просвечивают, мешая глазу считывать мелкие детали геометрии. Решений напрашивается два - рендер силуэта полигональным рендером и использование его как depth-маски, для удаления невидимых частиц, или один из вариантов WOIT.

Время разработки

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

Фоновая компиляция шейдеров. 

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

В ближайшем будущем мы прекратим насиловать ssd, и перейдем на пересылку шейдерного кода через память - visual studio позволяет стороннему приложению забирать текст текущего редактируемого файла.

Слайдеры для числовых констант. 

Подбирать “красивые” коэффициенты меняя числ�� вручную - так себе идея. Поэтому было сделано следующее:

  • по нажатию mbutton движок через COM/envDTE получает имя текущего редактируемого в студии файла, строку и позицию курсора.

  • исходя из позиции курсора, получает число, находящееся под курсором или непосредственно рядом с ним

  • число уходит в слайдер, по изменению позиции слайдера через все тот же envDTE меняется текст в исходнике - и происходит перекомпиляция

Изначально, идея была в том, чтобы крутить константы колесиком мыши, используя ctrl и shift как модификаторы, но для ее реализации в контексте VS приходится вешать global hook на мышь и клавиатуру. Что, в частности, приводит к адским тормозам курсора при остановке на breakpoint’е или срабатывании exception’ов. Можно разрулить через запуск хука как отдельного процесса и обмен через shared memory, но, кроме это того, имеем интерфейсный конфликт с использованием колеса и клавиш в самой VS: user experience получается так себе.

Рефлексия константных буферов. 

Выбран подход “shader first”. Препроцессор, запускающийся по pre-build event, парсит шейдерный код и создает c++ файлы со структурами, наследующими имена и расположение элементов в констант буфере. В результате имеем доступ к константам по именам из cpp-кода “из коробки” - нажав ctrl+B.

Рефлексия с++ кода и изменение параметров “на лету”.

В visual studio есть замечательная фича “hot reload”, но, к сожалению, работает она медленно, а программа после нескольких изменений падает с ошибкой. Поэтому применен аналогичный шейдерной подсистеме подход. Изложу его упрощенно:

  • при первом запуске функция через std::source_location определ��ет местонахождение своего вызова в исходнике

  • из файла по этим “координатам” вычитываются параметры, переданные в функцию, и записываются в reflect структуру

  • дальнейшее чтение параметров происходит уже из этой структуры

  • по нажатию mbutton определяется позиция в файле

  • зная позицию, вычисляем индекс в reflect структуре

  • отображаем слайдер, его позицию пишем обратно в reflect структуру

  • не забываем синхронизировать с ней содержимое исходника 

  • в коде все это прячем под #if и используем только в режиме редактора

Ручное изменение параметров (текстом, без слайдера) работает аналогичным образом.

момент редактирования параметров
момент редактирования параметров

Кратко о том, почему в качестве хоста UI был выбрана текстовая форма, конкретно в виде Visual Studio. 

Первая причина - разработка движка дело хлопотное, причем 80% работы приходится на UI. В случае VS мы из коробки имеем undo/redo, git, навигацию, файловую систему, и так далее. Остается только допилить недостающее - контролы камеры, таймлайна, и т.д.

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

Детали реализации можно посмотреть здесь

Впечатлительным - переходить по ссылке с осторожностью! Там много плохого кода, экспериментальная площадка как никак. Вопросы можно задать здесь в комментах или в Телеграме - @lastshilling. 

Буду рад идеям, советам и конструктивной критике. И заходите к нам на огонек в группу https://t.me/The13Sign посмотреть на новую звездную пыль. Так же будем рады энтузиастам, которым интересно присоединиться к разработке.

Спасибо за внимание!