Частица (англ. «particle») в OpenGL — это крошечный 2D-прямоугольник, который своей лицевой стороной всегда обращен к камере и (обычно) содержит текстуру, значительная часть которой является прозрачной. Частица сама по себе фактически является обыкновенным спрайтом. Объединяя сотни или даже тысячи подобных частиц, мы можем получить самые разнообразные и удивительные эффекты.
Когда речь заходит про создание системы частиц, то, как правило, подразумевается существование некоторого объекта, называемого излучателем частиц или генератором частиц, непрерывно порождающего новые частицы в точке своего расположения. Если бы такой генератор порождал, например, крошечные, светящиеся частицы с дымчатой текстурой, яркость свечения которых бы убывала с ростом расстояния до их генератора, то мы бы получили эффект огня:
Чаще всего, каждая отдельно взятая частица имеет ограниченное время своей жизни, медленно убывающее с момента её порождения. Как только данное значение становится меньше определенного порога (обычно 0
), мы убиваем частицу, чтобы затем, когда появится следующая, заменить её на новую. Излучатель частиц управляет всеми своими порожденными частицами и изменяет их поведение в зависимости от их атрибутов. Частица обычно имеет следующие атрибуты:
1 2 3 4 5 6 7 8 |
struct Particle { glm::vec2 Position, Velocity; glm::vec4 Color; float Life; Particle() : Position(0.0f), Velocity(0.0f), Color(1.0f), Life(0.0f) { } }; |
Глядя на пример с огнем можно заметить, что генератор, вероятно, порождает каждую свою частицу в точке, близкой к точке расположения самого генератора, и с направленным вверх вектором скорости. Также можно предположить, что у него есть 3 различные области, поэтому, судя по всему, одним частицам придается более высокая скорость, а другим — более низкая. Вдобавок, видно, что чем больше значение y-координаты частицы, тем менее желтым или ярким становится её цвет. После того, как частицы достигают определенной высоты, время их жизни исчерпывается, и они погибают; никогда не достигая звезд.
Только представьте себе, с помощью вот таких систем частиц мы можем создавать самые разнообразные и интересные эффекты: огонь, дым, туман, магические эффекты, следы выстрелов и т.д. Чтобы наша игра выглядела немного интереснее, мы добавим в нее простой генератор частиц, непрерывно следующий за мячиком:
Заметно, как генератор частиц порождает каждую свою частицу в точке текущего положения мяча, придает ей некоторую скорость, и изменяет цвет частицы в зависимости от оставшегося времени её жизни.
Для рендеринга частиц мы будем использовать новый набор шейдеров:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#version 330 core layout (location = 0) in vec4 vertex; // <vec2 position, vec2 texCoords> out vec2 TexCoords; out vec4 ParticleColor; uniform mat4 projection; uniform vec2 offset; uniform vec4 color; void main() { float scale = 10.0f; TexCoords = vertex.zw; ParticleColor = color; gl_Position = projection * vec4((vertex.xy * scale) + offset, 0.0, 1.0); } |
Фрагментный шейдер:
1 2 3 4 5 6 7 8 9 10 11 |
#version 330 core in vec2 TexCoords; in vec4 ParticleColor; out vec4 color; uniform sampler2D sprite; void main() { color = (texture(sprite, TexCoords) * ParticleColor); } |
Мы определяем стандартный набор атрибутов частицы, состоящий из координат её местоположения и соответствующих текстурных координат, а также переменную смещения offset
и uniform-переменную цвета color
, для того, чтобы влиять на выходной результат каждой частицы. Обратите внимание, что в вершинном шейдере задающий размеры частицы прямоугольник умножается на 10.0f
; вы также можете определить переменную scale
как uniform-переменную, тем самым получая возможность индивидуально контролировать размеры каждой частицы.
Итак, переходим к непосредственному созданию частиц. Для начала, нам потребуется подготовить список, состоящий из элементов (вышеупомянутого) типа Particle:
1 2 3 4 5 |
unsigned int nr_particles = 500; std::vector<Particle> particles; for (unsigned int i = 0; i < nr_particles; ++i) particles.push_back(Particle()); |
Затем в каждом кадре мы порождаем несколько новых частиц с предопределенными начальными значениями. Для каждой частицы, которая (все еще) жива, мы также обновляем эти значения:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
unsigned int nr_new_particles = 2; // Добавляем новые частицы for (unsigned int i = 0; i < nr_new_particles; ++i) { int unusedParticle = FirstUnusedParticle(); RespawnParticle(particles[unusedParticle], object, offset); } // Обновляем все частицы for (unsigned int i = 0; i < nr_particles; ++i) { Particle &p = particles[i]; p.Life -= dt; // уменьшаем время жизни if (p.Life > 0.0f) { // если частица еще жива, то обновляем её значения p.Position -= p.Velocity * dt; p.Color.a -= dt * 2.5f; } } |
Первый цикл может вас немного напугать. Поскольку частицы со временем умирают, то мы будем порождать новые частицы в количестве nr_new_particles
штук каждый кадр, но поскольку бесконечно долго создавать новые частицы у нас не получится (т.к. быстро закончится память компьютера), то порождаем только максимум nr_particles
штук. Если бы мы добавили все новые частицы в конец списка, то он быстро бы заполнился тысячами частиц. Это не очень эффективно, учитывая, что только небольшая часть этого списка содержит еще живые частицы.
Поэтому, необходимо найти первую «отжившую свое» частицу (время жизни < 0.0f
) и обновить её в виде новой возрожденной частицы.
Функция FirstUnusedParticle() пытается найти первую «отжившую» частицу и возвращает её индекс:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
unsigned int lastUsedParticle = 0; unsigned int FirstUnusedParticle() { // Поиск по последней использованной частице. Как правило, результат возвращается почти мгновенно for (unsigned int i = lastUsedParticle; i < nr_particles; ++i) { if (particles[i].Life <= 0.0f){ lastUsedParticle = i; return i; } } // В противном случае выполняем линейный поиск for (unsigned int i = 0; i < lastUsedParticle; ++i) { if (particles[i].Life <= 0.0f){ lastUsedParticle = i; return i; } } // Переопределяем первую частицу, если все остальные живы lastUsedParticle = 0; return 0; } |
Функция сохраняет найденный индекс последней отжившей частицы. Поскольку следующая «отжившая» частица, скорее всего, будет сразу после сохраненного индекса, то мы сначала ищем по данному индексу. Если мы не нашли мертвых частиц первым способом, то далее просто выполняем более медленный линейный поиск. Если ни одна из частиц не является отжившей, то возвращается индекс 0
, что приведет к перезаписи первой частицы. Обратите внимание, что если функция достигает последнего случая, то это означает, что ваши частицы живут слишком долго; вам нужно будет порождать меньше частиц за кадр и/или резервировать большее количество частиц.
Затем, как только первая «отжившая свое» частица найдена в списке, мы обновляем её значения, вызывая функцию RespawnParticle(), которая в качестве своих параметров принимает ссылку на саму частицу, ссылку на объект типа GameObject и вектор смещения:
1 2 3 4 5 6 7 8 9 |
void RespawnParticle(Particle &particle, GameObject &object, glm::vec2 offset) { float random = ((rand() % 100) - 50) / 10.0f; float rColor = 0.5f + ((rand() % 100) / 100.0f); particle.Position = object.Position + random + offset; particle.Color = glm::vec4(rColor, rColor, rColor, 1.0f); particle.Life = 1.0f; particle.Velocity = object.Velocity * 0.1f; } |
Данная функция просто сбрасывает время жизни частицы до 1.0f
, задает случайное значение (от 0.5f
и выше) яркости (через цветовой вектор), а также присваивает случайные значения положения и скорости в зависимости от данных объекта object
.
Второй цикл в рамках функции обновления проходится по всем частицам и для каждой уменьшает время её жизни на переменную дельта-времени (dt
); таким образом, время жизни каждой частицы в точности соответствует секунде (секундам), которую ей изначально разрешено прожить, умноженной на некоторый скаляр. Затем мы проверяем, жива ли частица, и если да, то обновляем её положение и цветовые атрибуты. Мы также медленно уменьшаем альфа-компонент каждой частицы, в результате чего создается впечатление, что они медленно исчезают с течением времени.
Остается только визуализировать частицы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
glBlendFunc(GL_SRC_ALPHA, GL_ONE); particleShader.Use(); for (Particle particle : particles) { if (particle.Life > 0.0f) { particleShader.SetVector2f("offset", particle.Position); particleShader.SetVector4f("color", particle.Color); particleTexture.Bind(); glBindVertexArray(particleVAO); glDrawArrays(GL_TRIANGLES, 0, 6); glBindVertexArray(0); } } glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); |
В вышеприведенном фрагменте кода мы для каждой частицы задаем смещение и значение uniform-переменной цвета, связываем текстуру и визуализируем 2D-прямоугольник. Что интересно здесь отметить, так это два вызова функции glBlendFunc(). При рендеринге частиц вместо заданного по умолчанию режима смешивания GL_ONE_MINUS_SRC_ALPHA
мы используем (аддитивный) режим GL_ONE
, который придает частицам очень аккуратный эффект свечения. Этот режим является наиболее предпочтительным режимом смешивания для сцены (из начала данного урока) с визуализацией огня, так как «свечение» огня сильнее непосредственно в центре пламени, где находится большинство частиц.
Поскольку нам (как уже не раз было упомянуто в предыдущих частях) нравится организованный код, то создадим еще один класс под названием ParticleGenerator, содержащий весь функционал, который мы только что описали.
#Класс ParticleGenerator
Заголовочный файл — particle_generator.h:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
#ifndef PARTICLE_GENERATOR_H #define PARTICLE_GENERATOR_H #include <vector> #include <glad/glad.h> #include <glm/glm.hpp> #include "shader.h" #include "texture.h" #include "game_object.h" // Представление отдельно взятой частицы и её состояния struct Particle { glm::vec2 Position, Velocity; glm::vec4 Color; float Life; Particle() : Position(0.0f), Velocity(0.0f), Color(1.0f), Life(0.0f) { } }; // ParticleGenerator действует как контейнер для рендеринга большого количества частиц, // многократно "порождая" и обновляя частицы и "убивая" их через заданный промежуток времени class ParticleGenerator { public: // Конструктор ParticleGenerator(Shader shader, Texture2D texture, unsigned int amount); // Обновляем все частицы void Update(float dt, GameObject& object, unsigned int newParticles, glm::vec2 offset = glm::vec2(0.0f, 0.0f)); // Рендерим все частицы void Draw(); private: // Состояние std::vector<Particle> particles; unsigned int amount; // Состояние рендеринга Shader shader; Texture2D texture; unsigned int VAO; // Инициализация буферов и вершинных атрибутов void init(); // Возвращаем индекс первой незадействованной в данный момент частицы (при Life <= 0.0f) или 0, если таких частиц в данный момент нет unsigned int firstUnusedParticle(); // "Возрождение" частиц void respawnParticle(Particle& particle, GameObject& object, glm::vec2 offset = glm::vec2(0.0f, 0.0f)); }; #endif |
Файл реализации — particle_generator.cpp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
#include "particle_generator.h" ParticleGenerator::ParticleGenerator(Shader shader, Texture2D texture, unsigned int amount) : shader(shader), texture(texture), amount(amount) { this->init(); } void ParticleGenerator::Update(float dt, GameObject& object, unsigned int newParticles, glm::vec2 offset) { // Добавляем новые частицы for (unsigned int i = 0; i < newParticles; ++i) { int unusedParticle = this->firstUnusedParticle(); this->respawnParticle(this->particles[unusedParticle], object, offset); } // Обновляем все частицы for (unsigned int i = 0; i < this->amount; ++i) { Particle& p = this->particles[i]; p.Life -= dt; // уменьшаем время жизни if (p.Life > 0.0f) { // если частица жива, то обновляем её p.Position -= p.Velocity * dt; p.Color.a -= dt * 2.5f; } } } // Рендеринг всех частиц void ParticleGenerator::Draw() { // Используем аддитивный режим смешивания для придания эффекта свечения glBlendFunc(GL_SRC_ALPHA, GL_ONE); this->shader.Use(); for (Particle particle : this->particles) { if (particle.Life > 0.0f) { this->shader.SetVector2f("offset", particle.Position); this->shader.SetVector4f("color", particle.Color); this->texture.Bind(); glBindVertexArray(this->VAO); glDrawArrays(GL_TRIANGLES, 0, 6); glBindVertexArray(0); } } // Не забываем сбросить режим смешивания к изначальным настройкам glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); } void ParticleGenerator::init() { // Настройка свойств меша и атрибутов unsigned int VBO; float particle_quad[] = { 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f }; glGenVertexArrays(1, &this->VAO); glGenBuffers(1, &VBO); glBindVertexArray(this->VAO); // Заполнение буфера меша glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(particle_quad), particle_quad, GL_STATIC_DRAW); // Настройка атрибутов меша glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); glBindVertexArray(0); // По умолчанию создаем частицы в количестве "this->amount" штук for (unsigned int i = 0; i < this->amount; ++i) this->particles.push_back(Particle()); } // Сохраняем индекс последней использованной частицы (для быстрого доступа к следующей использованной частицы) unsigned int lastUsedParticle = 0; unsigned int ParticleGenerator::firstUnusedParticle() { // Сначала проводим поиск, начиная с последней использованной частицы (как правило, результат возвращается почти мгновенно) for (unsigned int i = lastUsedParticle; i < this->amount; ++i) { if (this->particles[i].Life <= 0.0f) { lastUsedParticle = i; return i; } } // В противном случае выполняем линейный поиск for (unsigned int i = 0; i < lastUsedParticle; ++i) { if (this->particles[i].Life <= 0.0f) { lastUsedParticle = i; return i; } } // Все частицы еще "живые", поэтому перезаписываем первую (обратите внимание, что если программа // неоднократно попадает в данный вариант событий, то следует зарезервировать больше частиц) lastUsedParticle = 0; return 0; } void ParticleGenerator::respawnParticle(Particle& particle, GameObject& object, glm::vec2 offset) { float random = ((rand() % 100) - 50) / 10.0f; float rColor = 0.5f + ((rand() % 100) / 100.0f); particle.Position = object.Position + random + offset; particle.Color = glm::vec4(rColor, rColor, rColor, 1.0f); particle.Life = 1.0f; particle.Velocity = object.Velocity * 0.1f; } |
В коде игры мы создаем генератор частиц и инициализируем его с помощью следующей текстуры:
Код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
ParticleGenerator *Particles; void Game::Init() { [...] ResourceManager::LoadShader("shaders/particle.vs", "shaders/particle.frag", nullptr, "particle"); [...] ResourceManager::LoadTexture("textures/particle.png", true, "particle"); [...] Particles = new ParticleGenerator( ResourceManager::GetShader("particle"), ResourceManager::GetTexture("particle"), 500 ); } |
Затем мы изменим функцию Game::Update(), добавив вызов функции Particles->Update() генератора частиц:
1 2 3 4 5 6 7 8 |
void Game::Update(float dt) { [...] // Обновление частиц Particles->Update(dt, *Ball, 2, glm::vec2(Ball->Radius / 2.0f)); [...] } |
В результате этого, каждый кадр будут порождаться по 2 частицы, и их положение будет смещено к центру мяча. Последний шаг — это рендеринг частиц:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
void Game::Render() { if (this->State == GAME_ACTIVE) { [...] // Визуализация ракетки игрока Player->Draw(*Renderer); // Визуализация частиц Particles->Draw(); // Визуализация мяча Ball->Draw(*Renderer); } } |
Обратите внимание, что сначала мы визуализируем частицы, а уже после них — мяч. Таким образом, частицы в конечном итоге оказываются впереди всех других объектов, но позади мяча.
Если бы вы сейчас скомпилировали и запустили свое приложение, то увидели бы хвост из частиц, следующих за мячом, точно так же, как это было описано в начале урока, придавая игре более современный вид. Представленная система частиц может быть легко расширена до более продвинутых эффектов, поэтому не стесняйтесь экспериментировать и проявлять творческий подход, придумывая свои собственные эффекты.
GitHub / Часть №8: Создание системы частиц в игре «Breakout» на C++/OpenGL — Исходный код