Рендеринг краплі з прозорістю і відбитками на OpenGL

У цій статті ми розглянемо як рендери краплі на 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*=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.0 f * x - prevX + accelX * dt2;
prevX += (x - prevX)*fric;
x = tmp;

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

Нам залишається тільки перевіряти відстань між краплями, обробляти зіткнення зі стінками і враховувати поверхневий натяг, щоб краплі не розтікалися.
З поверхневим натягом довелося схитрувати. Кожен кадр ми дивимося, які краплі стикаються один з одним, таким чином розділяючи їх на групи. Далі отримуємо центр кожної групи і трохи "тягнемо" кожну краплю до центру групи. Це дало цілком прийнятний і швидкий результат.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.