Частинки/інстанціювання
Частинки дуже схожі на 3д білборди. Але є суттєві відмінності:
- зазвичай їх дуже багато
- вони рухаються
- вони з’являються і помирають
- вони напівпрозорі
Всі ці відмінності приносять певні проблеми. Цей туторіал покаже, один з шляхів вирішення їх. Але існує багато інших.
Частинки, їх багато!
Спершу може здатись, що для того, що б намалювати безліч частинок, можна просто використати код з попереднього туторіалу і викликати glDrawArrays
для кожної частинки. Це дуже погана ідея, тому що це значить, що весь Ваш блискучий GTX з 512+ мікропроцесорів буде повністю зайнятий малюванням quad (квада???) (точніше, буде тільки один процесор працювати, що значить втрату 99% ефективності). Тоді Ви будете малювати наступний білборд і це буде те саме.
Чесно кажучи, нам потрібен спосіб намалювати всі частинки одночасно.
Є багато способів зробити це, ось три з них:
- Згенерувати один VBO, який містить всі ці частинки разом. Просто, ефективно, працює кругом.
- Використовувати геометричний шейдер. Та не в цьому туторіалі, в основному тому, що 50% комп’ютерів не підтримують це.
- Використовувати інстанціювання (instancing, інший переклад - дублювання). Не на всіх комп’ютерах доступно, але доступно на більшості.
В цьому туторіалі ми розглянемо 3 варіант, тому що це гарний баланс між продуктивністю і доступністю. Також, якщо цей спосіб не спрацює, то дуже легко додати реалізацію першого.
Інстанціювання
“Інстанціювання” означає, що ми маємо один базовий меш (в нашому випадку - простий чотирикутник з двох трикутників), але багато копій (інстансів) його на екрані.
Технічно, це робиться за допомогою декількох буферів:
- деякі з них описують базовий меш
- деякі описують властивості кожного інстанса (дубліката) базового меша.
У Вас є багато-багато варіантів того, що можна додати в кожний буфер. В нашому простому випадку, буде наступне:
- Один буфер для вершин меша. Без індексного буферу, це 6 елементів
vec3
, що утворюють два трикутники, що в свою чергу утворюють чотирикутник. - Один буфер центрів частинок.
- Один буфер кольорів частинок.
Це дуже стандартні буфери. Вони створюються наступним чином:
// Цей VBO містить 4 вершини для однієї частинки
// Завдяки інстанціюванню, вони будуть спільними для всіх частинок.
static const GLfloat g_vertex_buffer_data[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
-0.5f, 0.5f, 0.0f,
0.5f, 0.5f, 0.0f,
};
GLuint billboard_vertex_buffer;
glGenBuffers(1, &billboard_vertex_buffer);
glBindBuffer(GL_ARRAY_BUFFER, billboard_vertex_buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(g_vertex_buffer_data), g_vertex_buffer_data, GL_STATIC_DRAW);
// Цей VBO містить позиції і розміри кожної частинки
GLuint particles_position_buffer;
glGenBuffers(1, &particles_position_buffer);
glBindBuffer(GL_ARRAY_BUFFER, particles_position_buffer);
// Починаємо з пустого (NULL) буферу - він буде оновлюватись кожний кадр.
glBufferData(GL_ARRAY_BUFFER, MaxParticles * 4 * sizeof(GLfloat), NULL, GL_STREAM_DRAW);
// Цей VBO містить кольори кожної частинки
GLuint particles_color_buffer;
glGenBuffers(1, &particles_color_buffer);
glBindBuffer(GL_ARRAY_BUFFER, particles_color_buffer);
// Починаємо з пустого (NULL) буферу - він буде оновлюватись кожний кадр.
glBufferData(GL_ARRAY_BUFFER, MaxParticles * 4 * sizeof(GLubyte), NULL, GL_STREAM_DRAW);
Це самий звичайний спосіб створення буферів. Тепер код оновлення на кожний кадр:
// Оновлення буферів, які OpenGL буде використовувати для малювання.
// Існують досить складні способи для потокової передачі даних з CPU до GPU,
// але це за межами даного туторіалу.
// http://www.opengl.org/wiki/Buffer_Object_Streaming
glBindBuffer(GL_ARRAY_BUFFER, particles_position_buffer);
glBufferData(GL_ARRAY_BUFFER, MaxParticles * 4 * sizeof(GLfloat), NULL, GL_STREAM_DRAW); // Буфер ні до чого не прив'язаний, типовий спосіб покращити потокову передачу. Деталі нижче
glBufferSubData(GL_ARRAY_BUFFER, 0, ParticlesCount * sizeof(GLfloat) * 4, g_particule_position_size_data);
glBindBuffer(GL_ARRAY_BUFFER, particles_color_buffer);
glBufferData(GL_ARRAY_BUFFER, MaxParticles * 4 * sizeof(GLubyte), NULL, GL_STREAM_DRAW); // Буфер ні до чого не прив'язаний, типовий спосіб покращити потокову передачу. Деталі нижче
glBufferSubData(GL_ARRAY_BUFFER, 0, ParticlesCount * sizeof(GLubyte) * 4, g_particule_color_data);
Цей код теж стандартний. Перед малювання ми прив’язуємо наступним чином:
// перший буфер атрибутів: вершини
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, billboard_vertex_buffer);
glVertexAttribPointer(
0, // атрибут. Немає причини, чому тут саме нуль, але повинно бути таке ж як і в шейдері, layout.
3, // розмір
GL_FLOAT, // тим
GL_FALSE, // нормалізовано?
0, // stride
(void*)0 // зміщення в буфері
);
// другий буфер атрибутів: позиція центрів частинок
glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, particles_position_buffer);
glVertexAttribPointer(
1, // атрибут. Немає причини, чому тут саме одиниця, але повинно бути таке ж як і в шейдері, layout.
4, // розмір : x + y + z + size => 4
GL_FLOAT, // тип
GL_FALSE, // нормалізовано?
0, // stride
(void*)0 // зміщення в буфері
);
// 3 буфер атрибутів: колір частинок
glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, particles_color_buffer);
glVertexAttribPointer(
2, // атрибут. Немає причини, чому тут саме двійка, але повинно бути таке ж як і в шейдері, layout.
4, // size : r + g + b + a => 4
GL_UNSIGNED_BYTE, // тип
GL_TRUE, // нормалізовано? *** ТАК, це значит, що unsigned char[4] буде доступне як vec4 (floats) в шейдері
0, // stride
(void*)0 // зміщення в буфері
);
І цей код теж самий звичайний. Та різниця з’являється при малюванні. Тепер замість glDrawArrays
(чи glDrawElements
, якщо використовується індексний буфер), Ви будете використовувати glDrawArrraysInstanced
/ glDrawElementsInstanced
, який еквівалентний виклику glDrawArrays
N
раз (N
- це останній параметр, в нашому випадку ParticlesCount
):
glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, ParticlesCount);
Але дещо відсутнє тут. Ми не повідомили OpenGL, який буфер буде базовим мешем, а який для інших копій. Це можна зробити за допомогою glVertexAttribDivisor
. Ось повний код з коментарями:
// Ця функція використовується в парі з glDrawArrays*Instanced*.
// Перший параметр буфер атрибутів, про який ми говоримо.
// Другий параметр визначає "швидкість, з якою атрибути просуваються, коли частинки обробляються групами"
// http://www.opengl.org/sdk/docs/man/xhtml/glVertexAttribDivisor.xml
glVertexAttribDivisor(0, 0); // вершини частинок: завжди використовуйте ті самі 4 вершини -> 0
glVertexAttribDivisor(1, 1); // позиція: одна на чотирикутник (центр) -> 1
glVertexAttribDivisor(2, 1); // колір: один на чотирикутник -> 1
// Малюємо частинки !
// Це малює багато разів маленький triangle_strip (який виглядає як чотирикутник)
// Це все еквівалентно наступному:
// for(i in ParticlesCount) : glDrawArrays(GL_TRIANGLE_STRIP, 0, 4),
// але швидше
glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, ParticlesCount);
Як Ви маєте змогу помітити, інстанціювання дуже універсальне, тому що Ви можливо передати ціле число через AttribDivisor
. Наприклад, glVertexAttribDivisor(2, 10)
означає, що 10 послідовних елемента (частинки) будуть мати однаковий колір.
Так в чому справа ?
Справа в тому, що тепер потрібно оновлювати невеликий буфер кожний фрейм (центри частинок) і не потрібно оновлювати великий меш. В нашому випадку це в 4 рази менше даних для передачі!
Життя та смерть
В протилежність більшості об’єктів на сцені, частинки народжуються і помирають з дуже великою швидкістю. Нам потрібен швидкий спосіб для отримання нових частинок і їх знищення, щось краще, ніж new Particle()
.
Створення нових частинок
Для цього у нас буде великий контейнер з частинками:
// структура для збереження частинок на CPU
struct Particle{
glm::vec3 pos, speed;
unsigned char r,g,b,a; // Колір
float size, angle, weight;
float life; // Час, який залишився жити частинці. якщо менше нуля - частинка мертва і не використовується
};
const int MaxParticles = 100000;
Particle ParticlesContainer[MaxParticles];
Тепер потрібно навчитись створювати нову частинку. Ця функція буквально виконує лінійний пошук в ParticlesContainer
, що виглядає поганою ідеєю, але пошук починається з останнього відомого місця, отже функція часто повертає результат миттєво:
int LastUsedParticle = 0;
// Знайти частинку в ParticlesContainer яка ще не використовувалась
// (тобто у неї життя (life) < 0);
int FindUnusedParticle(){
for(int i=LastUsedParticle; i<MaxParticles; i++){
if (ParticlesContainer[i].life < 0){
LastUsedParticle = i;
return i;
}
}
for(int i=0; i<LastUsedParticle; i++){
if (ParticlesContainer[i].life < 0){
LastUsedParticle = i;
return i;
}
}
return 0; // Всі частинки зайняті, перезапишемо саму першу
}
Тепер ми можемо заповнити ParticlesContainer[particleIndex]
потрібними значеннями для “life”, “color”, “speed” та “position”. Подивіться в код для деталей, Ви можете роботи тут практично що завгодно. Єдина цікава річ - яку кількість частинок ми можемо генерувати кожний фрейм, це залежить від програми, тому, скажімо, нехай буде 10000 нових частинок на секунду (так, це досить багато):
int newparticles = (int)(deltaTime*10000.0);
єдине, що потрібно обмежити це якимось фіксованим числом:
// Створюємо 10 нових частинок кожну мілісекунду
// але обмежимо це 16мс (бо 60 fps) - тобто не більше 160 частинок за раз.
// Якщо відразу створити на один довгий фрейм (1 сек),
// newparticles буде велике і наступний фрейм буде іще довшим (потрібен час на обробку).
int newparticles = (int)(deltaTime*10000.0);
if (newparticles > (int)(0.016f*10000.0))
newparticles = (int)(0.016f*10000.0);
Видалення старих частинок
Тут є хитрість, дивіться далі =)
Основний цикл симуляції
ParticlesContainer
містить активні і “мертві” частинки, а ось буфер, який відправляється до GPU повинен містити тільки живі частинки.
Тому ми будемо ітеруватись по списку частинок, перевіряти, чи вони живі чи мають вмерти, і якщо все добре, то додамо трішки гравітації і скопіюємо до GPU буферу.
// Симулюємо всі частинки
int ParticlesCount = 0;
for(int i=0; i<MaxParticles; i++){
Particle& p = ParticlesContainer[i]; // для спрощення
if(p.life > 0.0f){
// Зменшуємо час життя
p.life -= delta;
if (p.life > 0.0f){
// Симуляція простої фізики - тільки гравітація, ніяких колізій
p.speed += glm::vec3(0.0f,-9.81f, 0.0f) * (float)delta * 0.5f;
p.pos += p.speed * (float)delta;
p.cameradistance = glm::length2( p.pos - CameraPosition );
//ParticlesContainer[i].pos += glm::vec3(0.0f,10.0f, 0.0f) * (float)delta;
// Заповнюємо GPU буфер
g_particule_position_size_data[4*ParticlesCount+0] = p.pos.x;
g_particule_position_size_data[4*ParticlesCount+1] = p.pos.y;
g_particule_position_size_data[4*ParticlesCount+2] = p.pos.z;
g_particule_position_size_data[4*ParticlesCount+3] = p.size;
g_particule_color_data[4*ParticlesCount+0] = p.r;
g_particule_color_data[4*ParticlesCount+1] = p.g;
g_particule_color_data[4*ParticlesCount+2] = p.b;
g_particule_color_data[4*ParticlesCount+3] = p.a;
}else{
// Частинки, які тільки закінчили своє життя, будуть розташовані в кінці буферу завдяки SortParticles();
p.cameradistance = -1.0f;
}
ParticlesCount++;
}
}
Ось що у нас тепер є. Практично готово, але є проблема…
Сортування
Як було пояснено в Туторіалі 10, Вам потрібно сортувати напівпрозорі об’єкти від найдальших до найближчих, що б вони правильно “змішались”.
void SortParticles(){
std::sort(&ParticlesContainer[0], &ParticlesContainer[MaxParticles]);
}
Тепер, std::sort
потребує фукнції, яка зможе порівняти дві частинки і визначити, в якому порядку вони будуть в контейнері. Це можна зробити за допомогою Particle::operator<
:
// представлення частинки в "процесорі"
struct Particle{
...
bool operator<(Particle& that){
// Сортуємо в зворотньому порядку : частинки, що розташовані далі малюються в першу чергу
return this->cameradistance > that.cameradistance;
}
};
Це призведе до того, що ParticleContainer
буде відсортовано і частинки будуть відображатись правильно*:
Йдемо далі
Анімовані частинки
Ви можете анімувати текстури Ваших частинок, використовуючи атлас текстур. Додамо “вік частинки” до даних про її позицію і в шейдері розрахуємо UV координати як ми це робили в туторіалі про 2D шрифти. Атлас текстур виглядає наступним чином:
Обробка декількох систем частинок
Якщо Вам потрібно більше ніж одна система частинок, у Вас є два варіанти - використовувати один ParticleContainer
чи по одному контейнеру частинок на кожну систему.
Якщо у Вас один контейнер для всіх частинок, то це дасть змогу відсортувати їх ідеально. Головна проблема полягає в тому, що Вам потрібно буде використовувати одну й ту ж текстуру для всіх частинок, що є великою проблемою. Це може бути вирішено використанням атласу текстур (одна велика текстура з всіма потрібними текстурами і правильне використання UV координат), але це не дуже зручно редагувати і використовувати.
Якщо ж у Вас один контейнер на одну систему частинок, частинки будуть сортуватись тільки в всередині контейнеру - якщо дві системи перетинаються, будуть різноманітні артефакти. Але, можливо, для Вашого випадку це не буде проблемою.
Звичайно, можна використовувати гібридні рішення - декілька систем частинок, кожна з яким містить маленький і керований атлас.
Гладкі частинки
Дуже швидко Ви побачите один спільний артифакт - коли Ваші частинки перетинають “певну геометрію”, то з’являється неприємний “ліміт”:
(картинка взята тут http://www.gamerendering.com/2009/09/16/soft-particles/ )
Звичайне рішення подібної проблеми - перевіряти, чи не знаходиться поточний фрагмент неподалік від Z-буферу. Якщо так - то фрагмент зникає.
Однак, Вам потрібно буде взяти зразки з Z-буфера, що неможливо для “нормального” Z-буферу. Вам потрібно намалювати Вашу сцену в render target. Інший варіант - можна скопіювати Z-буфер з одного фреймбуферу в інший за допомогою glBlitFramebuffer
.
http://developer.download.nvidia.com/whitepapers/2007/SDK10/SoftParticles_hi.pdf
Покращення швидкості заповнення (fillrate)
Є один фактор, який максимально обмежує можливості на сучасних графічних процесора - швидкість заповнення - максимальна кількість фрагментів (пікселів), яку можна записати за 16.6 мс що б отримати 60 кадрів на секунду.
Це зазвичай є проблемою, тому що частинки зазвичай вимагають велику швидкість заповнення - Ви можете перемальовувати один фрагмент десять раз, кожний раз з іншими частинками і Ви не хочете артефактів, згаданих раніше.
Посеред всіх фрагментів, які записуються, є багато таких, які повністю не потрібні - вони на межі. Текстури Ваших частинок дуже часто повністю прозорі на краях, але меш частинки все рівно буде малюватись - і оновлювати буфер кольору з таким самим значенням як і до цього.
Ця невелика утиліта розраховує меш (який Ви будете малювати за допомогою glDrawArraysInstanced
), який щільно прилягає до текстури:
http://www.humus.name/index.php?page=Cool&ID=8 . На сайті Еміля Персона є багато інших захоплюючих статей.
Фізика частинок
У якийсь момент, Вам захочеться що б Ваші частинки взаємодіяли з Вашим світом. Наприклад, Ви хочете, що б частинки відскакували від поверхні землі.
Ви можете просто запустити raycast для кожної частинки з поточної позиції до наступної, ми вивчили як це зробити в Picking tutorials. Але це дуже “дорого”, це дуже складно робити для кожної частинки кожний фрейм.
В залежності від Вашої програми, Ви можете реалізувати апроксимацію геометрії за допомогою площин і raycast на цих площинах тільки. Або Ви можете використовувати справжній raycast, але зробити кеш і апроксимацію можливих колізій.
А є абсолютно інша техніка - використання вже готового Z-буферу як дуже грубу апроксимацію (видимої) геометрії і частинок, що стикаються. Це “цілком достатньо” і швидко, але буде потрібно це все зробити на GPU, так як неможливо отримати доступ до Z-буферу з CPU (як мінімум швидкий доступ), і як наслідок, цей спосіб достатньо складний.
Ось декілька посилань про цю технологію:
http://www.altdevblogaday.com/2012/06/19/hack-day-report/
Симуляція на GPU
Як було сказано вище, Ви можете симулювати рух частинок повністю на GPU. Ви будете все ще керувати життям своїх частинок на CPU, як мінімум, створювати їх.
У Вас є багато варіантів зробити це і жодний з них не є ціллю цього туторіалу. Я дам лишень декілька порад.
- Використовуйте “Transform Feedback” (перетворення зворотного зв’язку). Це дозволить Вам зберегти результат вершинного шейдера в VBO, яке належить GPU. Зберігайте нову позицію в цьому VBO і в наступному фреймі використовуйте як стартову позицію і знову зберігайте там координати.
- Теж ж саме, але без Transform Feedback - збережіть свої координати в текстуру і оновлюйте за допомогою “малювання в текстуру” (Render-To-Texture).
- Використовуйте бібліотеки для GPU - CUDA, OpenCL які мають функції взаємодії з OpenGL.
-
Використовуйте шейдери обчислень - Compute Shader. Чисте рішення, але доступне тільки на нових GPU.
- Зверніть увагу, що для простоти, ця реалізація сортує
ParticleContainer
після оновлення GPU буферів. Це робить частинки не повністю відсортованими (є затримка в один кадр), але це не дуже помітно. Ви можете це виправити розділивши головний цикл в два - Симуляція, Сортування і Оновлення.date.