Systemy cząsteczkowe -- część 1

15.06.2010

4. Pierwsze uruchomienie

Sprawdzimy jak (i czy) działa wszystko co dotąd stworzyliśmy. W tym celu zbudujemy emiter, który będzie generował kolorowe cząsteczki lecące od środka okna ku jego krawędziom. Implementacja metody Update takiego emitera może wyglądać następująco:

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
bool SampleEmiter::Update(ParticleSystem* ps, float dt)
{
        // zwiększenie akumulatora o ilość czasu, która minęła od ostatniej aktualizacji
        timeAccumulator += dt;
        
        // generowanie cząsteczek zależne od czasu
        while (timeAccumulator > timePerParticle)
        {
                // utworzenie cząsteczki
                Particle* particle = ps->CreateParticle();
 
                // uzupełnienie parametrów
                particle->position = Vec2D(320, 240);
                
                particle->velocity = ps->random.GetPointOnCircle(ps->random.GetFloat(20, 100));
                
                particle->current.color = ps->random.GetOpaqueColor();
                particle->current.angle = ps->random.GetAngle();
                particle->current.size = ps->random.GetFloat(2, 4);
 
                particle->change.color = ps->random.GetTransparentColor();
                particle->change.angle = ps->random.GetAngle();
                particle->change.size = ps->random.GetFloat(4, 32);
                particle->changeDuration = ps->random.GetFloat(1, 3);
 
                particle->CalculateChangePerSecond();
 
                // zmniejszenie wartości akumulatora
                timeAccumulator -= timePerParticle;
        }
 
        return true;
}

Najpierw prosimy system cząsteczkowy o stworzenie cząsteczki, a następnie uzupełniamy jej właściwości odpowiednimi wartościami. System cząsteczkowy natychmiast rejestruje cząsteczkę i będzie nią automatycznie zarządzał. Następnie dla generowanej cząsteczki wywołujemy metodę CalculateChangePerSecond (mówiliśmy o niej wcześniej). Generując 100 cząsteczek na sekundę otrzymamy taki efekt:

emiter_1

Wszystko byłoby dobrze, gdyby nie to, że zapomnieliśmy o jednym szczególe -- cząsteczki nie mają żadnej grafiki i wyświetlane są jako kolorowe kwadraty. Musimy więc wczytać jakąś teksturę i przypisać ją do naszego systemu cząsteczkowego. Aby otrzymać jak najwyższą wydajność, będziemy używać tylko jednej tekstury dla jednego systemu. Jest to pewne ograniczenie, jednak istnieją proste sposoby radzenia sobie z nim, np. atlasy tekstur. Tutaj będziemy po prostu zapisywać więcej niż jedną grafikę w teksturze (zajmiemy się tym dokładniej niżej). Tymczasowo dla naszej cząsteczki użyjemy takiej grafiki (w rzeczywistości tło tej grafiki jest przezroczyste, tutaj zostało pokazane jako szare, żeby było widać cząsteczkę):

texture_1

Gdy użyjemy jej w naszym systemie cząsteczkowym, dostaniemy taki efekt:

emiter_2

5. Więcej niż jedna tekstura

Teraz zajmiemy się problemem wspomnianym przy okazji poprzedniego emitera -- chcemy użyć więcej niż jednej tekstury dla systemu cząsteczkowego. Problem ten można rozwiązać za pomocą atlasów tekstur. Jest to metoda, w której wiele małych tekstur składamy w jedną dużą. Oprócz samej grafiki, taki atlas przechowuje informacje o tym jaki obszar zajmuje każda ze składowych tekstur. Atlasy można wczytywać z plików lub budować podczas działania aplikacji. Użyjemy teraz rozwiązania podobnego do atlasów (jednak bardziej prymitywnego), żeby wzbogacić nasz system cząsteczkowy o nowe możliwości.

Region w teksturze możemy opisać za pomocą takiej struktury:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// koordynaty fragmentu tekstury
struct UVRegion
{
        // lewy górny róg
        float u0, v0;
 
        // prawy dolny róg
        float u1, v1;
 
        UVRegion()
        {
        }
 
        UVRegion(float u0, float v0, float u1, float v1) 
                : u0(u0), v0(v0), u1(u1), v1(v1)
        {
        }
};

Struktura zajmuje 16 bajtów, więc nie opłaca się jej przechowywanie w cząsteczce -- dla wielu cząsteczek dane będą identyczne. Co więcej, nie wszystkie cząsteczki potrzebują korzystać z takiej struktury. Możemy zatem w cząsteczce przechowywać tylko wskaźnik do regionu. Powstaje pytanie: kto w takim razie powinien tworzyć i zwalniać instancje tej struktury? Najnaturalniejsze wydaje się, żeby była to klasa zarządzająca teksturą. Metody odpowiedzialne za tworzenie regionów opisanych powyżej mogą wyglądać wtedy tak:

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
// ustawienie parametrów regionu
const UVRegion* Texture::SetNamedRegion(const string& name, float u0, float v0, float u1, float v1)
{
        map<string, UVRegion*>::iterator it = regions.find(name);
 
        if (it != regions.end())
        {       
                // region jeszcze nie istnieje
                UVRegion* region = it->second;
 
                region->u0 = u0;
                region->v0 = v0;
                region->u1 = u1;
                region->v1 = v1;
 
                return region;
        }
        else
        {
                // modyfikacja istniejącego regionu
                UVRegion* region = new UVRegion(u0, v0, u1, v1);
 
                regions.insert(make_pair(name, region));
 
                return region;
        }
}
 
// pobranie regionu
const UVRegion* Texture::GetNamedRegion(const string& name)
{
        map<string, UVRegion*>::iterator it = regions.find(name);
 
        if (it != regions.end())
        {
                // region istnieje
                return it->second;
        }
        else
        {
                // region nie istnieje -- zwracamy region obejmujący
                // całą teksturę
                return &DefaultRegion;
        }
}
 
// zwolnienie wszystkich regionów
void Texture::ReleaseNamedRegions()
{
        map<string, UVRegion*>::iterator it;
 
        for (it = regions.begin(); it != regions.end(); it++)
        {
                delete it->second;
        }
 
        regions.clear();
}

Obiekt zarządzający teksturą, podczas niszczenia, automatycznie zwolni regiony, które zostały za jego pomocą stworzone -- dzięki temu unikniemy wycieków pamięci.

Dodatkowo, jeżeli zostaniemy poproszeni o region, który nie został jeszcze stworzony to zwracamy region domyślny obejmujący całą teksturę.

Emiter, wykorzystujący teksturę z wieloma cząsteczkami, będzie emitował spadające płatki śniegu. Jak wiadomo, na świecie nie ma dwóch identycznych płatków śniegu. My jednak zadowolimy się czterema ich rodzajami (szare tło znów dodane dla czytelności):

texture_2

Teraz musimy oznaczyć w jakiś sposób regiony, możemy to zrobić po wczytaniu tekstury:

1
2
3
4
5
6
TextureRef snowflakeParticle = Texture::Load("snowflake_particle.png");
 
snowflakeParticle->SetNamedRegion("type1", 0.0f, 0.0f, 0.5f, 0.5f);
snowflakeParticle->SetNamedRegion("type2", 0.5f, 0.0f, 1.0f, 0.5f);
snowflakeParticle->SetNamedRegion("type3", 0.0f, 0.5f, 0.5f, 1.0f);
snowflakeParticle->SetNamedRegion("type4", 0.5f, 0.5f, 1.0f, 1.0f);

Sposób ten nie jest wygodny, ale można łatwo dodać wczytywanie informacji o regionach z pliku -- dzięki temu można by je modyfikować bez potrzeby rekompilacji projektu. Jeżeli czytelnik ma ochotę, może dodać tę funkcjonalność w ramach prostego ćwiczenia.

Mając przygotowaną teksturę z wyznaczonymi regionami, możemy przekazać ją do konstruktora tworzonego emitera -- będzie mógł pobrać stworzone wcześniej regiony i na nich operować:

1
2
3
4
5
6
7
8
SnowflakeEmiter(float particlesPerSecond, const TextureRef& texture) 
        : ParticleEmiter(particlesPerSecond), type(0)
{
        regions[0] = texture->GetNamedRegion("type1");
        regions[1] = texture->GetNamedRegion("type2");
        regions[2] = texture->GetNamedRegion("type3");
        regions[3] = texture->GetNamedRegion("type4");
}

Płatki śniegu będą zaczynały swoją wędrówkę po ekranie ponad jego górną krawędzią. Następnie, jak można się domyślać, będą opadać. Będziemy losować ich rozmiar, sposób obracania i kierunek spadania. Typ płatka śniegu będzie zależał od używanego obszaru tekstury:

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
bool SnowflakeEmiter::Update(ParticleSystem* ps, float dt)
{
        timeAccumulator += dt;
 
        while (timeAccumulator > timePerParticle)
        {
                Particle* particle = ps->CreateParticle();
 
                particle->position.x = ps->random.GetFloat(0, 640);
                particle->position.y = -20;
 
                particle->velocity = ps->random.GetVec2D(-10, 10, 100, 150);
                
                particle->current.color = Color(1, 1, 1, 1);
                
                particle->current.angle = ps->random.GetAngle();
                particle->current.size = ps->random.GetFloat(16, 32);
 
                particle->change.color = Color(1, 1, 1, 1);
                particle->change.angle = particle->current.angle + ps->random.GetFloat(3.1415f / 4, 3.1415f);
                particle->change.size = ps->random.GetFloat(8, 16);
                particle->changeDuration = ps->random.GetFloat(4, 8);
 
                particle->CalculateChangePerSecond();
 
                // wybór odpowiedniego regionu
                particle->uvRegion = regions[type];
 
                type = (type + 1) % 4;
 
                timeAccumulator -= timePerParticle;
        }
 
        return true;
}

Jedyna większa różnica w porównaniu z poprzednimi emiterami, to użycie składowej uvRegion dla cząsteczki. Dzięki temu wybierane są odpowiednie obszary, a efekt wygląda tak:

emiter_snowflakes

W przypadku, gdy nie ustawimy uvRegion dla cząsteczki, automatycznie wybierany jest cały obszar.

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com