This is the Polish translation of In-Practice/2D-Game/Particles article of learnopengl.com tutorial series.
Cząsteczka (ang. particle), widziana z perspektywy OpenGL, to maleńki kwadrat, który zawsze jest ustawiony przodem do kamery (billboarding) i (zwykle) zawiera teksturę z dużą ilością przezroczystości. Cząsteczka sama w sobie jest w zasadzie tylko sprite’em, których do tej pory tak intensywnie używamy, ale kiedy zbierzecie razem setki, a nawet tysiące tych cząsteczek, możecie stworzyć niesamowite efekty.
Podczas pracy z cząsteczkami zwykle występuje obiekt zwany
Pojedyncza cząstka często ma zmienną czas życia (ang. life), która powoli spada po wygenerowaniu cząstki. Kiedy jej czas życia jest mniejszy niż pewien próg (zwykle 0
),
struct Particle {
glm::vec2 Position, Velocity;
glm::vec4 Color;
GLfloat Life;
Particle()
: Position(0.0f), Velocity(0.0f), Color(1.0f), Life(0.0f) { }
};
Patrząc na przykład ognia, emiter cząsteczek prawdopodobnie generuje każdą cząstkę z pozycją bliską emiterowi i z prędkością w górę tak, aby każda cząstka poruszała się w pozytywnym kierunku y
. Wydaje się, że ma 3 różne regiony, więc prawdopodobnie daje cząstkom większą prędkość niż inne. Widzimy także, że im wyższa pozycja cząstki y
, tym mniej jasny/żółty staje się jej kolor. Po osiągnięciu przez cząsteczki określonej wysokości ich życie zostaje wyczerpane, a cząsteczki zostają zabite; nigdy nie docierają do gwiazd.
Możesz sobie wyobrazić, że dzięki takim systemom możemy tworzyć ciekawe efekty, takie jak ogień, dym, mgła, efekty magiczne, efekt wystrzału. W Breakout dodamy prosty generator cząstek dla piłki, aby wszystko wyglądało bardziej interesująco. Wyglądać to będzie mniej więcej tak:
Tutaj generator cząsteczek emituje każdą cząsteczkę z pozycji piłki, daje jej prędkość równą ułamkowi prędkości piłki i zmienia kolor cząstki w oparciu o czas jej życia.
Do renderowania cząstek użyjemy innego zestawu shaderów:
#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);
}
Fragment Shader:
#version 330 core
in vec2 TexCoords;
in vec4 ParticleColor;
out vec4 color;
uniform sampler2D sprite;
void main()
{
color = (texture(sprite, TexCoords) * ParticleColor);
}
Przyjmujemy standardowe atrybuty pozycji i tekstury na cząstkę, a także akceptujemy uniform offset i color, aby zmienić wynik wizualny per cząstka. Zauważ, że w Vertex Shader przeskalujemy kwadrat cząsteczek o 10.0f
; możesz również ustawić skalę jako uniform i kontrolować ją indywidualnie dla każdej cząstki.
Najpierw potrzebujemy listy cząstek, które następnie tworzymy z domyślnym konstruktorem struktury
GLuint nr_particles = 500;
std::vector<Particle> particles;
for (GLuint i = 0; i < nr_particles; ++i)
particles.push_back(Particle());
Następnie w każdej klatce tworzymy kilka nowych cząstek z wartościami początkowymi, a następnie dla każdej cząstki, która jest (nadal) żywa, aktualizujemy jej wartości.
GLuint nr_new_particles = 2;
// Add new particles
for (GLuint i = 0; i < nr_new_particles; ++i)
{
int unusedParticle = FirstUnusedParticle();
RespawnParticle(particles[unusedParticle], object, offset);
}
// Update all particles
for (GLuint i = 0; i < nr_particles; ++i)
{
Particle &p = particles[i];
p.Life -= dt; // reduce life
if (p.Life > 0.0f)
{ // particle is alive, thus update
p.Position -= p.Velocity * dt;
p.Color.a -= dt * 2.5;
}
}
Pierwsza pętla może wyglądać trochę zniechęcająco. Ponieważ cząstki giną z upływem czasu, chcemy odrodzić cząstki nr_new_particles w każdej klatce, ale ponieważ od początku zdecydowaliśmy, że całkowita ilość cząstek, które będziemy wykorzystywać, to nr_particles nie możemy po prostu wstawić nowych cząstek na koniec listy. W ten sposób szybko otrzymamy listę wypełnioną tysiącami cząsteczek, która nie jest zbyt efektywna, biorąc pod uwagę, że tylko niewielka część tej listy zawiera żywe cząstki.
Chcemy znaleźć pierwszą martwą cząstkę (życie < 0.0f
) i zaktualizować tę cząstkę jako nową odrodzoną cząstkę.
Funkcja
GLuint lastUsedParticle = 0;
GLuint FirstUnusedParticle()
{
// Search from last used particle, this will usually return almost instantly
for (GLuint i = lastUsedParticle; i < nr_particles; ++i){
if (particles[i].Life <= 0.0f){
lastUsedParticle = i;
return i;
}
}
// Otherwise, do a linear search
for (GLuint i = 0; i < lastUsedParticle; ++i){
if (particles[i].Life <= 0.0f){
lastUsedParticle = i;
return i;
}
}
// Override first particle if all others are alive
lastUsedParticle = 0;
return 0;
}
Funkcja przechowuje indeks ostatnio znalezionej martwej cząstki, ponieważ następna martwa cząstka będzie najprawdopodobniej tuż po tym ostatnim indeksie cząstek, więc najpierw szukamy począwszy od tego zapisanego indeksu. Jeśli nie znaleźliśmy żadnych martwych cząstek, po prostu wykonamy wolniejsze wyszukiwanie liniowe. Jeśli żadne cząstki nie są martwe, zwróć indeks 0
, co spowoduje nadpisanie pierwszej cząstki. Zauważ, że jeśli dojdzie do tego ostatniego przypadku, oznacza to, że cząstki są żywe zbyt długo, musisz odradzać mniej cząsteczek na klatkę i/lub po prostu nie masz wystarczającej ilości zarezerwowanych cząstek.
Następnie, po znalezieniu pierwszej martwej cząstki na liście, aktualizujemy jej wartości, wywołując
void RespawnParticle(Particle &particle, GameObject &object, glm::vec2 offset)
{
GLfloat random = ((rand() % 100) - 50) / 10.0f;
GLfloat rColor = 0.5 + ((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;
}
Ta funkcja po prostu resetuje życie cząstki do 1.0f
, losowo nadaje jej jasność (poprzez wektor koloru) zaczynając od 0.5
i przypisuje (nieco losową) pozycję i prędkość w oparciu o
Druga pętla w ramach funkcji aktualizacji iteruje po wszystkich cząstkach i dla każdej cząstki zmniejsza jej żywotność o zmienną czasową delta; w ten sposób życie każdej cząstki odpowiada sekundom. Następnie sprawdzamy, czy cząstka jest żywa, jeśli tak, zaktualizuj jej położenie i atrybuty koloru. Tutaj powoli zmniejszamy składową alfa każdej cząstki, aby wyglądało na to, że z czasem zanikają.
Pozostaje nam tylko wyrenderować cząstki:
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);
Tutaj, dla każdej cząstki, ustalamy jej wartość równa przesunięciu i kolorze, wiążemy teksturę i generujemy kwadraty. Interesujące są tutaj dwa wywołania funkcji GL_ONE_MINUS_SRC_ALPHA
używamy trybu mieszania GL_ONE
, który nadaje cząstkom bardzo ładny efekt
Ponieważ (podobnie jak większość innych części serii samouczków) lubimy porządkować wszystko, stworzono inną klasę o nazwie
Następnie w kodzie gry tworzymy taki generator cząstek i inicjalizujemy go za pomocą tej tekstury.
ParticleGenerator *Particles;
void Game::Init()
{
[...]
ResourceManager::LoadShader("shaders/particle.vs", "shaders/particle.frag", nullptr, "particle");
[...]
ResourceManager::LoadTexture("textures/particle.png", GL_TRUE, "particle");
[...]
Particles = new ParticleGenerator(
ResourceManager::GetShader("particle"),
ResourceManager::GetTexture("particle"),
500
);
}
Następnie zmieniamy funkcję
void Game::Update(GLfloat dt)
{
[...]
// Update particles
Particles->Update(dt, *Ball, 2, glm::vec2(Ball->Radius / 2));
[...]
}
Każda z cząstek korzysta z właściwości obiektu gry z obiektu piłki, tworząc 2 cząstki na każdą ramkę, a ich pozycje zostaną przesunięte w kierunku środka kuli. Na koniec renderujemy cząstki:
void Game::Render()
{
if (this->State == GAME_ACTIVE)
{
[...]
// Draw player
Player->Draw(*Renderer);
// Draw particles
Particles->Draw();
// Draw ball
Ball->Draw(*Renderer);
}
}
Zwróć uwagę, że renderujemy cząstki, zanim piłka zostanie wyrenderowana i po tym, jak inne elementy zostaną wyrenderowane, dzięki czemu cząstki znajdą się przed wszystkimi innymi elementami, ale pozostaną za piłką. Możesz znaleźć zaktualizowany kod klasy gry tutaj.
Jeśli teraz skompilujesz i uruchomisz swoją aplikację, powinieneś zobaczyć ślad cząsteczek podążający za piłką, tak jak widzieliśmy to na początku tego samouczka, nadając grze bardziej nowoczesny wygląd. System można również łatwo rozszerzyć, aby obsługiwał bardziej zaawansowane efekty, więc możesz poeksperymentować z generowaniem cząstek i sprawdzić, czy możesz wymyślić własne kreatywne efekty.