Частинки дуже схожі на 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/

http://www.gdcvault.com/search.php#&category=free&firstfocus=&keyword=Chris+Tchou’s%2BHalo%2BReach%2BEffects&conference_id=

Симуляція на GPU

Як було сказано вище, Ви можете симулювати рух частинок повністю на GPU. Ви будете все ще керувати життям своїх частинок на CPU, як мінімум, створювати їх.

У Вас є багато варіантів зробити це і жодний з них не є ціллю цього туторіалу. Я дам лишень декілька порад.

  • Використовуйте “Transform Feedback” (перетворення зворотного зв’язку). Це дозволить Вам зберегти результат вершинного шейдера в VBO, яке належить GPU. Зберігайте нову позицію в цьому VBO і в наступному фреймі використовуйте як стартову позицію і знову зберігайте там координати.
  • Теж ж саме, але без Transform Feedback - збережіть свої координати в текстуру і оновлюйте за допомогою “малювання в текстуру” (Render-To-Texture).
  • Використовуйте бібліотеки для GPU - CUDA, OpenCL які мають функції взаємодії з OpenGL.
  • Використовуйте шейдери обчислень - Compute Shader. Чисте рішення, але доступне тільки на нових GPU.

  • Зверніть увагу, що для простоти, ця реалізація сортує ParticleContainer після оновлення GPU буферів. Це робить частинки не повністю відсортованими (є затримка в один кадр), але це не дуже помітно. Ви можете це виправити розділивши головний цикл в два - Симуляція, Сортування і Оновлення.date.