В этой статье мы рассмотрим как рендерить капли на OpenGL и расчитывать на лету нормаль для отражения и прозрачности. А так же, что такое Metaballs, баги графических чипсетов и какие трюки оптимизации можно применить для 60 FPS на мобильных девайсах.
Содержание
Часть 1. Мобильный кроссплатформенный движок
Часть 2. Рендеринг UTF-8 текста с помощью SDF шрифта
Часть 3. Рендеринг капли с прозрачностью и отражениями
Metaballs
В основе рендеринга капель лежит техника Metaballs.
Текстом смысл этой техники передать сложно, поэтому покажу наглядно. Тем более, эта техника очень похожа на ту, что мы использовали с SDF шрифтами в предыдущей статье.
Итак открываем любой графический редактор:
- Рисуем размытой кистью точку.
- Добавляем еще несколько таких точек, чтобы они немного накладывались друг на друга.
- Выкручиваем уровни (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.
- На Adreno нельзя делать discard до того, как прочитаны все текстуры. А лучше вообще от discard отказаться.
- Для 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;