В этой статье мы рассмотрим как рендерить капли на OpenGL и расчитывать на лету нормаль для отражения и прозрачности. А так же, что такое Metaballs, баги графических чипсетов и какие трюки оптимизации можно применить для 60 FPS на мобильных девайсах.



Содержание


Часть 1. Мобильный кроссплатформенный движок
Часть 2. Рендеринг UTF-8 текста с помощью SDF шрифта
Часть 3. Рендеринг капли с прозрачностью и отражениями




Metaballs


В основе рендеринга капель лежит техника Metaballs.
Текстом смысл этой техники передать сложно, поэтому покажу наглядно. Тем более, эта техника очень похожа на ту, что мы использовали с SDF шрифтами в предыдущей статье.


Итак открываем любой графический редактор:




  1. Рисуем размытой кистью точку.
  2. Добавляем еще несколько таких точек, чтобы они немного накладывались друг на друга.
  3. Выкручиваем уровни (Levels) до нужного эффекта.

В самом простом виде Metaballs готов. Как наверное вы догадались, жидкость состоит из множества таких точек, к которым только остается прикрутить физику и добавить шейдер для красоты.


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



Подготовим OpenGL


Для рендеринга капель нам понадобится сперва ренедерить промежуточные этапы в текстуру. Это можно сделать с помощью FBO (Framebuffer Object). При чем можно использовать меньшую текстуру 1/2 или даже 1/4 от размера экрана. Качество от этого почти не пострадает.


width=половина ширины экрана;
height=половина высоты экрана;

//создаем FBO
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);

//создаем текстуру в которую будем рендерить
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);

//привязываем текстуру к FBO
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);

Далее для переключения на рендеринг в текстуру делаем:
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glClear(GL_COLOR_BUFFER_BIT);


А чтобы рендерить снова на экран:
glBindFramebuffer(GL_FRAMEBUFFER, 0);


Логично было бы использовать RGB текстуру без прозрачности для экономии ресурсов. И в большинстве случаев все будет хорошо. Но только не на андроидах с чипсетами Adreno. На редких девайсах в текстуру будет выводится шум или сплошной черный цвет. Поэтому лучше использовать формат GL_RGBA.


Рендер капель


Первым проходом рендерим все капли в текстуру с учетом их скорости и материала.
Ниже привожу псевдокод т.к. в оригинальном коде слишком много ссылок к специфическим ф-циям движка.


glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glClear(GL_COLOR_BUFFER_BIT);

bindShader(metaBalls);
for( кол-во капель ){
    //делаем расчеты для "хвоста" капли
    vx = скорость капли по Х;
    vy = скорость капли по Y;
    vLength = length(vx, vy);
    if(vLength > vMax) {
        //ограничиваем "хвост", чтобы при больших скоростях капли он не выходил за пределы градиента
        vx *= vMax/vLength;
        vy *= vMax/vLength;
    }
    setUniforms( vx, vy, капля.материал );
    renderQuadAt( капля.x, капля.y );
}

Должна получиться примерно такая картинка:



Внимательный читатель спросит:
"Почему красный цвет? От куда взялся зеленый? И что это за бордюры?"


Материал


Допустим вы хотите выводить капли нескольких материалов сразу. Причем так, чтобы они могли плавно смешиваться. Для этого вместе со скоростью мы так же передаем материал капли. Это обычный float от 0 до 1, который и будет означать переход от одного материала к другому. В RED канал текстуры мы записываем сам градиент, а в GREEN пишем материал.


Бордюр


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



Формулу можно записать примерно так: (текстура с каплями + RED) * BLUE.
Т.е. по размытым краям бордюра мы немного усиливаем капли, а непосредственно на самом бордюре наоборот капли убираем.


Попробуйте нарисовать сверху любую контрастную grayscale текстуру. Например такую:
Ваши капли вдруг начнут обтекать выступы на текстуре.
Конечно с физикой это никак не связано, это только визуальный трюк.


Главный шейдер


Теперь нам надо вывести один квад (два треугольника/полигона) размером с весь экран. В этом шейдере надо применить технику Metaballs так, чтобы получить немного размытые края. Это даст нам 3D эффект на краях капли.
Далее прочитаем соседние тексели градиента и рассчитаем нормаль для отражения, по которой возьмем значение из Matcap текстуры.



Ниже приведен почищенный код шейдера для лучшего восприятия:


//Для OpenGL ES нужно задавать дефолтный precision
#ifdef DEFPRECISION
precision mediump float;
#endif

varying mediump vec2 outTexCord;
//FBO текстура, в котороую мы рисовали капли
uniform lowp sampler2D tex0;
//Matcap текстура
uniform lowp sampler2D tex1;

#define limitMin 0.4
#define limitMax 1.6666
#define levels(a,b,c) (a-b)*c

void main(void){

    //Читаем текстуру с каплями и применяем уровни для получения Metaballs
    float tex = texture2D(tex0, outTexCord).r;
    float gradient = levels(tex, limitMin, limitMax);

    //Выходим если капли нет, но не на Adreno
    #ifndef ADRENO
    if(gradient<=0.0){
        discard;
    }
    #endif

    //Читаем соседние 4 текселя для построения нормали по градиенту
    vec2 step=vec2(0.002, 0.002);
    vec2 cord=outTexCord;

    cord.x+=step.x;
    float right=texture2D(tex0, cord).r;

    cord.x-=step.x*2.0;
    float left=texture2D(tex0, cord).r;

    cord+=step;
    float bottom=texture2D(tex0, cord).r;

    cord.y-=step.y*2.0;
    float top=texture2D(tex0, cord).r;

    //Приближенно строим нормаль по разнице градиента
    vec3 normal;
    normal.z=gradient;
    normal.x=(right-left)*(1.0-gradient);
    normal.y=(bottom-top)*(1.0-gradient);
    normal=normalize(normal);

    //Отражаем нормаль по вектору от центра экрана
    vec3 ref=vec3(outTexCord-0.5, 0.5);
    ref = normalize(reflect(ref, normal));

    //Читаем Matcap текстуру
    cord=(ref.xy+0.5)*0.5;    
    vec4 matcap=texture2D(tex1, cord);
    //Регулируем степень размытия края капли
    matcap.a*=min(1.0, gradient*10.0);

    //Если нужно, добавляем прозрачность
    matcap.a*=1.0-gradient*0.2;

    gl_FragColor = matcap;
}

Adreno


Больше всего проблем доставили именно Android девайсы с чипсетом Adreno.


  1. На Adreno нельзя делать discard до того, как прочитаны все текстуры. А лучше вообще от discard отказаться.
  2. Для FBO надо использовать только RGBA текстуру. На RGB будет выведен либо шум, либо черный цвет.

Нужен ли тогда вообще discard?
Да, нужен. Особенно прирост FPS заметен на планшетах, где большой филрейт и надо бороться за каждый пиксель.


Прозрачность


Для прозрачности используем простой трюк: чем ближе к краю капли, тем больше отклоняется нормаль, а значит прозрачность на краях уменьшается. Этакий максимально упрощенный Эффект Френеля.


MatCap


Всего лишь заменив текстуру Matcap, мы можем получить совершенно разные материалы — воду, серебро, золото, лаву, молоко, кофе и т.д.



Хорошо, что в интернете хватает бесплатных Matcap текстур. Правда стоит учесть, что на больших каплях центр текстуры будет очень растянут. Поэтому для хорошего результата придется перебрать довольно много Matcap текстур.


Несколько материалов


Помните, при рендеринге капель в FBO мы так же записывали значение материала?
Добавляем в шейдер небольшие изменения и получаем плавный переход из одного материала в другой.


//Так же берем GREEN канал
vec2 tex = texture2D(tex0, outTexCord).rg;
...
//В конце читаем из обеих Matcap текстур и смешиваем
vec4 matcap=mix( texture2D(tex1, cord), texture2D(tex2, cord), tex.g);

А так как значение материала у нас привязано к каждой отдельной капле, то меняя материал каждой капли по очереди, мы получим эффект перетекания или смешивания жидкостей.


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


Оптимизация


В текущем виде получить 60 FPS на всех девайсах не получится. Особенно тяжело шел главный шейдер на iPad2. Discard не спасал, хотя он срабатывал на 80% пикселей. Попробуем вообще избавиться от этих пустых 80%.


Разобьем экран на ячейки. Для меня оказался оптимальным размер ячейки screenWidth/20. Разбили квад на ячейки, составили индекс этих мелких квадратов. Затем нам остается только смотреть какие ячейки заполнены каплями (плюс добавлять соседние ячейки) и при любом изменении решетки обновлять индекс:


glBufferData(GL_ELEMENT_ARRAY_BUFFER, size, data, GL_DYNAMIC_DRAW);


Выводиться будут только ячейки действительно содержащие капли.


Физика


Физику затрону лишь вскользь т.к. это очень интересная, но обширная тема для одной статьи.
В основе лежит Интегрирование Верле с небольшими доработками. Все что касается Верле заключено в такой блок кода:


//fric - добавим трение, чтобы капли замедлялись
float dt2,tmp;
dt2=dt*dt;

tmp = 2.0f * x - prevX + accelX * dt2;
prevX += (x - prevX)*fric;
x = tmp;

tmp = 2.0f * y - prevY + accelY * dt2;
prevY += (y - prevY)*fric;
y = tmp;

Нам остается только проверять расстояние между каплями, обрабатывать столкновения со стенками и учитывать поверхностное натяжение, чтобы капли не растекались.


С поверхностным натяжением пришлось схитрить. Каждый кадр мы смотрим, какие капли соприкасаются друг с другом, таким образом разделяя их на группы. Далее получаем центр каждой группы и немного "тянем" каждую каплю к центру группы. Это дало вполне приемлемый и быстрый результат.




Плюшка!
Чтобы капля хорошо вписывалась в фон, стоит добавить тень. Получить ее мы можем так же в основном шейдере без особой нагрузки на GPU.


Для тени берем Metaballs чуть большего размера. На краях затемняем каплю, за краями получаем черный цвет и немного расширяем альфа маску. Примерно так:


float shadow = levels(tex, чуть больше радиус и размытие);
float body=min(1.0, gradient*10.0);
matcap.rgb*=min(1.0, body*5.0);
matcap.a*=body+shadow;

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


  1. Aler
    27.04.2016 12:51
    +3

    Отличная статья, спасибо за труд! Надеюсь, что не последняя


    1. Apetrus
      27.04.2016 12:58
      +2

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


      1. Leopotam
        27.04.2016 13:18

        Discard не спасал, хотя он срабатывал на 80% пикселей.

        Так и не должен был, все sgx / powervr-ы — tbdr: http://blog.imgtec.com/powervr/a-look-at-the-powervr-graphics-architecture-tile-based-rendering
        Те от дискарда ему делалось даже хуже из-за бранчинга во фрагментном шейдере.


        1. Apetrus
          27.04.2016 13:24

          Согласен, discard вообще стоит избегать. Проверял на всевозможных девайсах и именно в этом шейдере на iPad2 без дискарда FPS падал до 40. Все таки после discard идет 5-8 выборок из текстуры (в зависимости от кол-ва материала) и это перевешивало тормоза от бранчинга.


  1. slik
    27.04.2016 13:23
    +6

    Делал что-то похожее, было интересно разобраться как работают smoothed-particle hydrodynamics и marching cubes по отдельности, и потом совместить их, практической ценности мало :)

    Видео


    1. Apetrus
      27.04.2016 13:38
      +3

      У вас смотрю поверхностное натяжение довольно точно реализовано, здорово выглядит! На счет ценности — у нас из этого игра вышла :)

      Кстати вот капельки в динамике


    1. 6opoDuJIo
      27.04.2016 22:06

      Не думал что когда-нибудь это увижу: Unity3D и ubuntu


  1. MrShoor
    27.04.2016 19:06
    +1

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


  1. leorush
    27.04.2016 19:44
    +1

    Эх, когда же мощности мобильных устройств будет хватать на такую воду:
    http://madebyevan.com/webgl-water/


    1. Ronnie_Gardocki
      28.04.2016 06:48

      Еще вот такое есть — http://tympanus.net/Development/RainEffect/
      Тоже жутко прожорливое, но крутое.


  1. Nomad1
    27.04.2016 20:17
    +1

    Круто! Вариант с отражениями действительно весьма хорош и заметно превосходит мой бесшейдерный вариант. С другой стороны, у нас были разные задачи, потому и разные результаты. :) Не так давно делал рескин для tvOS версии, так пришлось добавлять тени и блики, потому что старый вариант под новую графику не подходил.

    Скрытый текст


    1. Apetrus
      27.04.2016 20:29
      +2

      Помню вашу игру и статью :) Рескин пошел игре заметно на пользу, поздравляю с выходом на tvOS!


  1. KyHTEP
    28.04.2016 08:58

    А точно discard дает прирост на мобильных GPU? На «взрослых» видяхах все с точностью наоборот. Так как мы не можем учитывать результат depth test. Не ясно дискарднется пиксель или нет.


    1. Apetrus
      28.04.2016 09:05

      Depth test в нашем случае отключен т.к. рендерим плоский квад. Сам discard не так страшен, как if(gradient<=0.0), в который он помещен. Однако после discard у нас еще идут 5-8 вызовов texture2D(), которые в сумме более тяжелые для GPU чем один if(). На глаз это никак не определить, нужны только тесты на девайсах. И именно тесты показали, что в этом шейдере от discard польза таки есть.


    1. MrShoor
      28.04.2016 20:41

      На мобильных GPU tile based рендер. И discard в общем дает еще большие тормоза, чем на десктопных картах. Но тут очень частный случай. Нет работы с глубиной + нет оверлапящихся треугольников. Так что вполне может быть что ускоряет.