Как-то раз возник у меня диспут с хабраюзером ZimM по поводу безшейдерного 2D движка: я утверждал, что для простых 2D игр шейдеры не обязательны, почти все эффекты можно сделать спрайтами, его же позиция была обратной. Я не раз в уме возвращался к этому спору и придумывал задачи, не реализуемые на первый взгляд без шейдеров, и именно решение одной такой задачи и привело к созданию игры, где игрок управляет жидкостью наклоном телефона.

Теория


Как можно догадаться по заголовку, задача состояла в рисовании жидкостей с помощью metaballs. Суть этой технологии — в нахождении множества точек, находящихся не далее некоторого расстояния от центра любого из мета-шаров — «капель», составляющих жидкость (точнее смотрите на wiki). Есть много разных вариантов их отрисовки, в т.ч и на CSS. Самый простой и эффективный метод сводится к тому, чтобы нарисовать окружности с обратно-квадратичной прозрачностью и в полученной картинке отбросить зоны с прозрачностью меньше 0.5, а оставшиеся закрасить одним цветом.

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



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

vec2 pos = texCoord.xy - vec2(0.5,0.5);
color.a = 0.25/dot(pos,pos);

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

Практика


А вот без шейдеров тот же подход, но на CPU выглядит сомнительно: создаем в памяти буфер, например, 1024х1024xRGBA, проходим по массиву с «каплями» жидкости и для каждого в квадрате со сторонами R*2+1 расставляем коэффициенты по формуле обратного квадрата расстояния от центра. Ну и затем пробегаем по готовому буферу и вычищаем RGBA значения, имитируя discard, закрашиваем сплошные зоны, а затем этот буфер отсылаем видеокарте. Выходит при радиусе R = 20 и 40 «каплях» надо каждый кадр делать 67240 вычислений + 1048576 итераций по массиву пикселей с дополнительной обработкой. Это не говоря о пересылке текстуры в 4Мб в видеопамять и надежде на частоту 60fps на мобильных устройствах.

Я ради эксперимента реализовал такую схему и получил тормоза даже на десктопном компьютере. Да еще и результат выглядел откровенно слабо: ступенчатые края, равномерный цвет заливки, слишком геометрично достоверные «спайки». Заодно я допустил классическую ошибку premature optimization — попробовал все операции делать с целочисленными координатами, что может и дало прирост скорости, но плохо сказалось на качестве и добавило «дерганность» в движение жидкости.

Именно размышления о целочисленный расчетах натолкнули меня на мысль, что я пошел не тем путем: возложил на CPU слишком много, хотя можно было часть работы перенести на GPU. Верным решением оказалось… уменьшение текстуры в несколько раз! Но теперь я решил использовать тот же самый эффект, который Valve использует для шрифтов и граффити — Signed Distance Field Alpha Magnification. Для тех, кто не сталкивался с этой технологией, в двух словах принцип такой: увеличение картинки не ухудшает качество зон с градиентами, т.е. если есть плавный переход от значения 0.0 до 1.0 то промежуток внутри него будет сохранять свою форму при любом масштабе, как на этой картинке:


Детальнее можно почитать тут.

В случае с жидкостями я сделал буфер 256х256 и оставил градиент на границе каждой «капли», немного масштабировав альфу — упрощенно, все ниже изначального значения 0.4 отбрасывается, выше 0.6 заполняется сплошным цветом, а там где был переход от 0.4 до 0.6, теперь переход от 0.0 до 1.0 (на деле, там ипользуется кубическая функция, см. код ниже). Радиус каждой «капли» я уменьшил до 5 пикселей, таким образом на каждый кадр пришлось 4840 вычислений и 65536 пикселей в буфере размером 256Кб. Такое уменьшение нагрузки позволило перейти на floating-point операции с довольно высокой точностью — для каждой «капли» обрабатывается регион в 11х11 пикселей и для каждого пикселя вычисляется расстояние до точной координаты «капли», а не до пикселя в центре региона. Результат отправляется на видеокарту через glTexSubImage2D с ALPHA_TEST в 0.5. GPU обрезает край капли визуально достаточно аккуратно, даже при масштабировании текстуры с жидкостью в 4-8 раз.



Вот код обработки, с которым была получена картинка выше:

Код
for (int i = 0; i < m_metaballs.Length; i++)
{
    int minX = (int)Math.Floor(m_metaballs [i].Position.X - s_radius);
    int minY = (int)Math.Floor(m_metaballs [i].Position.Y - s_radius);
    int maxX = (int)Math.Ceiling(m_metaballs [i].Position.X + s_radius);
    int maxY = (int)Math.Ceiling(m_metaballs [i].Position.Y + s_radius);

    for (int y = minY; y < maxY; y++)
        for (int x = minX; x < maxX; x++)
        {
            float dist =
                        (x - m_metaballs[i].Position.X) * (x - m_metaballs[i].Position.X) +
                        (y - m_metaballs[i].Position.Y) * (y - m_metaballs[i].Position.Y);

            if (dist < s_radiusSqrd)
            {
                dist = 1.0f - (dist * s_iradiusSqrd);
                int value = (int)(dist * dist * 256.0f);

                int index = (x + y * s_fieldWidth) * 4;

                m_field[index + 3] = (byte)NormalizeInt(m_field[index + 3] + value, 0, 255);

                // shift from top left
                int v = (int)((Math.Abs(x - minX) + Math.Abs(y - minY)) * 32.0f);

                // middle value
                m_field[index + 1] = (byte)NormalizeInt((m_field[index + 0] + v) /2, 0, 255);
                // max value
                m_field[index + 0] = (byte)NormalizeInt(Math.Max(m_field[index + 0], v), 0, 255);
                // metaball index
                m_field[index + 2] = (byte)(i + 1);
            }
        }
}

for (int i = 0; i < m_field.Length; i += 4)
{
    int a = m_field [i + 3];
    if (a > 40)
    {
        float na = a / 255.0f + 0.4f;
        na = (na * na * na);

        if (na > 0.8f)
            na = 0.8f;
        float nx = m_field [i + 0] / 32.0f;
        if (nx > 4)
            nx = 4;
        float ny = m_field [i + 1] / 32.0f;
        if (ny > 4)
            ny = 4;

        m_field [i + 0] = (byte)(25 * na + 30 * nx + 5 * ny);
        m_field [i + 1] = (byte)(100 * na + 30 * nx + 10 * ny);
        m_field [i + 2] = (byte)(150 * na + 30 * nx + 5 * ny);
        m_field [i + 3] = (byte)(255 * na);
    }
}


Еще немного шаманства понадобилось для придания жидкости более «объемного» вида с затемнением в верхнем левом углу и для отрисовки контура. Вот так выглядит финальный вариант, с блендингом GL_ONE/GL_ONE_MINUS_SRC_ALPHA:



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

Физика


В целом, жидкости получились визуально более-менее нормальными и тогда мне захотелось таки сделать из игры головоломку, на подобие Teeter. Я иногда использую в играх движок Farseer (порт Box2D на C#), потому когда нашел блог тов. QuantumYeti очень обрадовался и через какое-то время смог запустить его код. Все было бы хорошо, но жидкости просачивались сквозь мельчайшие щели между объектами и утекали за экран. В качестве быстрого фикса я набросал патч к движку, который к каждой вершине выпуклого полигона добавляет небольшое смещение по вектору нормали. Это решило большую часть проблем, потому я отдал прототип левел дизайнеру и ждал результата. Через несколько недель левел дизайнер начал жаловаться, что простые элементы обрабатываются относительно нормально, а вот на сложных кривых и на острых углах жидкость может застрять навсегда.

Это не казалось большой проблемой и походило просто на временный баг. Я копнул немного в код тов. QuantumYeti и понял, что все там довольно плохо, ведь это был скорее proof of concept, а не рабочий движок для жидкостей: сомнительная логика выполнения, константы в коде, хранение временных переменных в классе с публичными полями и прочие косяки. Но самое главное, логика столкновений с объектами была очень условная и не подходила для нашей задачи — при попадании частиц жидкости вовнутрь объекта их скорость обнулялась и они телепортировались в направлении вектора нормали к поверхности. Если при этом частица попадала в другой объект, то зависала навсегда и внешние силы и вязкость жидкости на нее больше не действовали. Частицы также считались точечными телами и для коллизий использовался метод TestPoint, потому они могли просачиваться в щели. Простые патчи тут не помогали, да и мой хак с увеличением объектов усугубил ситуацию, потому я решил перейти на другой движок физики.

Выбор пал на liquidfun, а точнее, на его полный C# порт sharpbox2d. Движок этот сделан ребятами из Google, достаточно хорош внутри, дает приятную скорость и динамику движения жидкостей. Порт на C# оказался чуточку хуже и в целом не законченным — он компилировался, но не работал, т.к. часто использовался Java подход, когда функция меняет экземпляр класса, но он не помечен словами ref или out и если при портировании превратился в struct, то логика работы нарушается. Я взялся за исправление этих проблем и через день имел рабочую версию движка (p.s. могу выложить в git, если кому-то она нужна), а затем адаптировал всю игру для работы с ним. Все было бы хорошо, но левел дизайнер поведение жидкости в новом движке охарактеризовал как «кусок теста, ползающий по намасленным стенкам». Какое-то время я игрался с параметрами, пока не понял, что толку от этого не будет — физически точный и качественный этот движок не позволял мне получить нужные параметры вязкости и плотности, которые в прототипе от QuantumYeti были выставлены «на глаз», вшитыми константами и приближенными формулами.

На этот момент я уже довольно хорошо разобрался с физикой жидкостей и мог условно понимать, как должны работать коллизии и почему предыдущий вариант не работал. В основу лег метод ray casting во время движения частицы. Несколько дней ушло на переделку одного маленького метода, но в целом конечный вариант меня устроил — жидкости больше не просачиваются, не прилипают к стенам и не прекращают внутренние движения, когда соприкасаются с объектами.

            RayCastInput input = new RayCastInput();
            input.Point1 = particle.oldPosition;
            input.Point2 = newPosition;
            input.MaxFraction = 1.0f;
            RayCastOutput output = new RayCastOutput();
/// ... skipped ...
            if (fixture.RayCast(out output, ref input, c))
            {
                Vector2 n = output.Normal;
                Vector2 p = (1 - output.Fraction) * input.Point1 + output.Fraction * input.Point2 + PUSHBACK * n;
                Vector2 v = (p - particle.position);
                        
                float ax = moveVector.X - v.X;
                float ay = moveVector.Y - v.Y;
                float fdn = ax * n.X + ay * n.Y;
                        
                antiGravity -= n * fdn;
            }

В ходе перебора всех фигур, с которыми есть (или будут в следующем кадре) пересечения, я формирую вектор antiGravity, направленный противоположно вектору движения.

Мой код FluidSystem.cs с некоторой зачисткой, но с неймспейсом, логикой и комментариями первого автора доступен тут: runserver.net/temp/FluidSystem.cs

Возможно, кто-то захочет использовать его в своих проектах или просто довести до ума и добавить к какому-либо движку.

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

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

Комментарии (11)


  1. creatio
    05.10.2015 18:08
    +1

    Интересная статья. Сам в своё время интересовался «лёгкой» симуляцией жидкостей в играх, хотя и не копал тему так глубоко, как вы.
    А где саму игру можно скачать?


    1. Nomad1
      05.10.2015 20:36

      Игра в маркетах под именем Colours of Magic или Цвета Магии. Ссылку не дам, чтобы не считали рекламой :)


  1. maaGames
    05.10.2015 18:20
    +2

    Без гифки или ютубика вижу только синее пятно, не похожее не жидкость.


    1. Nomad1
      05.10.2015 20:40
      +2

      Действительно, оно в статике не очень похоже, да и в динамике на реалистичность не претендует, такой себе, мультяшный вид разноцветной жижи )
      Ютубика с игрой я не делал, потому что при записи с экране не понятно, что происходит и куда наклоняют устройство. Вот есть видео из какого-то обзора, там наша игра в самом начале:
      www.youtube.com/watch?v=LaNd6l8eehc


  1. ZimM
    05.10.2015 22:54
    +1

    Помню-помню :) Игра действительно неплохая получилась. Но справедливости ради — на шейдерах ваша вода могла бы выглядеть гораздо лучше :P


    1. Nomad1
      06.10.2015 00:14

      Положа руку на сердце, я бы не стал её делать на шейдерах — это была бы рутинная задача, от которых я очень устал во время разработки одного своего проекта. Да, каюсь, была мечта сделать «мега-проект» с блекджеком и пр., потому меня теперь от шейдеров воротит :) Но в целом вышло прикольно. При чем, в основном из-за художника и левел-дизайнера, а не нашего с вами объекта спора )


      1. ZimM
        06.10.2015 00:16

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


  1. nomit
    06.10.2015 02:46

    p.s. могу выложить в git, если кому-то она нужна

    Я за то чтобы выложить, может сейчас она ни кому не нужна, но вот через какое то время может понадобится.
    А почему в основной(для C#) репозиторий не создать пулл реквест?


    1. Nomad1
      06.10.2015 09:58
      +1

      Сделал.
      github.com/gordonmcshane/sharpbox2d/pull/1
      Что интересно, судя по коммитам автора, для его задачи все работало и с struct, может потому что он не использовал жидкости?


      1. nomit
        06.10.2015 19:29

        спасибо :) возможно, в скором времени тоже глянуть эту библиотеку. есть одна идея как раз может пригодится.


  1. DoDius
    08.10.2015 19:55

    Спасибо большое!
    Как раз задумался о том, чтобы сделать «песочные часики» с жидкостями разной плотности, а тут такая полезная статья!