Systemy cząsteczkowe -- część 1

15.06.2010

Systemy cząsteczkowe są techniką pozwalającą na uzyskanie ciekawych efektów graficznych bez dużego nakładu pracy. Za ich pomocą realizowane są efekty, takie jak ogień, dym, deszcz czy śnieg. W tym artykule postaram się opowiedzieć, czym są systemy cząsteczkowe i pokazać realizację kilku efektów.

Należy pamiętać, że systemy cząsteczkowe nie mają ściśle określonej definicji i można je zrealizować na wiele różnych sposobów. Niniejszy artykuł jest pierwszą częścią krótkiej serii, w której pokażemy jedną z takich realizacji. Zaczniemy od prostego systemu cząsteczkowego 2D, który z czasem będziemy rozbudowywać. Żeby wszystko było w miarę jasne, pominiemy na razie kwestię zarządzania pamięcią -- zajmiemy się tym dokładniej w kolejnych odsłonach artykułu. W pewnym momencie wskoczymy również w trzeci wymiar.

Do artykułu dołączony jest kod źródłowy w języku C++, korzystający z bibliotek SDL, OpenGL i Boost. Wszak by go zrozumieć, wystarczy podstawowa znajomość C++ i OpenGL. W artykule, dla zachowania czytelności, zostały przedstawione tylko kluczowe obiekty oraz metody (lub ich fragmenty).

Naszym celem będzie osiągnięcie efektu animowanego śniegu i ognia:

emiter_snowflakes emiter_fire

1. Czym właściwie jest system cząsteczkowy?

Pomyślmy przez chwilę o farbie w sprayu. Podczas malowania, z puszki wylatują cząsteczki farby -- mają one jakiś kierunek, rozmiar i prędkość. Parametry wszystkich cząsteczek są do siebie zbliżone, a różnice wynikają z pewnych losowych zaburzeń. W oparciu o tę ideę będziemy budować nasz system cząsteczkowy -- czyli coś, co będzie symulować cząsteczki. Ponieważ cząsteczki nie biorą się znikąd, w skład systemu wejdą też elementy, służące do ich tworzenia -- emitery (odpowiedniki puszki z farbą). Możliwe, że na początku taki opis wydaje się nico zawiły, jednak zapewniam, że po skończonej lekturze całość okaże się przejrzysta i intuicyjna.

2. Symulację czas zacząć

Nasza cyfrowa cząsteczka będzie charakteryzowała się zbiorem właściwości, które będziemy chcieli zmieniać w czasie. Na ekranie cząsteczka będzie reprezentowana za pomocą oteksturowanego kwadratu. Zacznijmy od takiej definicji:

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
// stan  cząsteczki
class ParticleState
{
public:
        Color color;
        float angle;
        float size;
};
 
// cząsteczka
class Particle
{
public:
        // pozycja
        Vec2D position;
 
        // prędkość
        Vec2D velocity;
 
        // aktualne parametry cząsteczki
        ParticleState current;
 
        // docelowe parametry cząsteczki 
        ParticleState change;
 
        // czas w jakim cząsteczka ma przejść w stan docelowy
        float changeDuration;
 
        // zamiana docelowych parametrów na to o ile mają się zmieniać
        void CalculateChangePerSecond();
        
private:
        friend class ParticleSystem;
 
        Particle* nextParticle;
        Particle* prevParticle;
};      

Stan cząsteczki to klasa składająca się z koloru, kąta i rozmiaru. Cząsteczka podczas tworzenia ma ustawiane dwa stany: początkowy (current) i docelowy (change). Pamiętamy też informacje o aktualnej pozycji i prędkości, dzięki czemu cząsteczka będzie mogła się poruszać. Aby ułatwić naszemu systemowi zarządzanie cząsteczkami, będziemy je trzymać na liście dwukierunkowej -- do tego właśnie służą wskaźniki nextParticle i prevParticle.

Ponieważ będziemy chcieli wykonywać jak najmniej niepotrzebnych operacji, od razu obliczymy, o jaką wartość zmienią się w ciągu sekundy wszystkie parametry cząsteczki:

1
2
3
4
5
6
void Particle::CalculateChangePerSecond()
{
        change.color = (change.color - current.color) / changeDuration;
        change.angle = (change.angle - current.angle) / changeDuration;
        change.size  = (change.size - current.size)   / changeDuration;
}

Dzięki temu aktualizacja systemu cząsteczkowego sprowadza się do prostej pętli:

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
void ParticleSystem::Update(float dt)
{
        Particle* p = firstParticle;
 
        while (p)
        {
                p->changeDuration -= dt;
 
                // cząsteczka w trakcie zmiany
                if (p->changeDuration >= 0)
                {
                        // aktualizacja parametrów
                        p->position += p->velocity * dt;
                        
                        p->current.color += p->change.color * dt;
                        p->current.angle += p->change.angle * dt;
                        p->current.size += p->change.size * dt;
 
                        p = p->nextParticle;
                }
                else
                {
                        // niszczenie cząsteczki
                        Particle* toDestroy = p;
                        
                        p = p->nextParticle;
 
                        DestroyParticle(toDestroy);
                }
        }
}

Jest to bardzo proste zachowanie, jednak pozwoli nam na osiągnięcie ciekawych efektów. System cząsteczkowy dostaje informacje o tym, ile czasu minęło od ostatniej aktualizacji i na tej podstawie uaktualnia wszystkie cząsteczki -- dzięki temu nie jesteśmy uzależnieni od prędkości działania naszego programu. Założenie, że aktualizacja odbywa się w równych odstępach czasu, mogłoby sprawić, że program działałby na różnych komputerach z różną prędkością.

3. Rysowanie cząsteczek

Rysowanie systemu cząsteczkowego jest elementem na który należy zwrócić szczególną uwagę. Najprostszym sposobem jest modyfikowanie macierzy GL_MODELVIEW w OpenGL dla każdej cząsteczki z osobna, żeby odpowiednio ustawić jej położenie, obrót i rozmiar. Niestety takie podejście nie jest dobre, ponieważ wymaga częstej komunikacji z kartą graficzną, co bardzo odbija się na wydajności. Znacznie lepszym rozwiązaniem jest przeliczenie dokładnej pozycji wszystkich cząsteczek ręcznie i przekazanie tak przygotowanych danych do karty graficznej.

Przeliczenie pozycji cząsteczki nie jest trudne. Spójrzmy na kwadrat w środku układu współrzędnych:

render_1render_2

Przyjmijmy, że kwadrat ma bok długości 2R -- jakie są zatem koordynaty punktu A w obu sytuacjach? Możemy to łatwo obliczyć:

Wiemy, że pozycja punktu na okręgu jednostkowym wyraża się wzorami x = cos(angle), y = sin(angle). Ponieważ przekątna jest pod kątem 45 stopni do boku kwadratu, musimy przesunąć się o ten kąt. Przekątna jest sqrt(2) razy dłuższa od boku kwadratu, co przekłada się na dodatkowe mnożenie przez tę wartość. Stąd wynikają wzory:

1
2
A.x = R * SQRT(2) * cos(angle - 45°);
A.y = R * SQRT(2) * sin(angle - 45°);

Ponieważ jesteśmy w środku układu współrzędnych, możemy prostym sposobem obliczyć koordynaty pozostałych wierzchołków:

render_3

Dzięki temu w łatwy sposób otrzymujemy koordynaty czterech wierzchołków kwadratu, które posłużą nam do rysowania -- wystarczy, że przesuniemy je o pozycję rysowanej cząsteczki.

Komunikacja z kartą graficzną jest dość wolna, ponieważ może wymagać oczekiwania aż karta graficzna zakończy aktualnie wykonywaną operację. Sterownik karty graficznej może też wprowadzać dodatkowe opóźnienia. W załączonej implementacji cząsteczki są rysowane za pomocą mechanizmu Vertex Array, domyślnie w pakietach do 512 cząsteczek na raz. Dzięki temu musimy komunikować się z kartą graficzną dość rzadko, co będzie przekładać się na dużą wydajność rysowania. Szczegóły implementacyjne można zobaczyć w załączonym kodzie. Innym rozwiązaniem jest skorzystanie z mechanizmu VBO (Vertex Buffer Object).

Jak już wcześniej wspomnieliśmy, w skład naszego systemu cząsteczkowego wejdą obiekty zwane emiterami. Ich zadaniem jest tworzenie nowych cząsteczek, więc to właśnie one są odpowiedzialne, za generowanie konkretnych efektów. Taki emiter może być umieszczony np. na górze płonącej pochodni, przy wylocie rury wydechowej samochodu itp. W naszym rozwiązaniu jako emiter będziemy implementować pochodne takiej klasy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// emiter cząsteczek
class ParticleEmiter
{
protected:
        // akumulator czasu
        float timeAccumulator;
 
        // co ile czasu ma być generowana cząsteczka
        float timePerParticle;
 
        ParticleEmiter(float particlesPerSecond) 
                : timeAccumulator(0), timePerParticle(1 / particlesPerSecond) 
        {
        }
public:
        // aktualizacja / generowanie cząsteczek
        virtual bool Update(ParticleSystem* ps, float dt) = 0;
        virtual ~ParticleEmiter() { }
};

Na podstawie wartości składowych tej klasy będziemy generować odpowiednią ilość cząsteczek podczas aktualizacji systemu cząsteczkowego. Metoda Update jako parametry przyjmuje system cząsteczkowy i ilość czasu, która minęła od ostatniej aktualizacji. Czas aktualizacji nie zawsze będzie wielokrotnością czasu, co jaki ma być generowana cząsteczka, dlatego musimy zapamiętać nadmiary czasu do użycia przy kolejnych aktualizacjach. Do tego służy akumulator czasu (timeAccumulator). Wartość zwracana przez metodę Update to informacja zwrotna dla systemu cząsteczkowego czy emiter ma zostać jeszcze w użyciu, czy powinien zostać zwolniony.

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com