Światła w grach 3D Autor: Mirosław Kozioł

Oświetlenie jest obecnie wbrew pozorom jednym z trudniejszych zagadnień przy tworzeniu silnika do gier 3D. Gdy zdecydujesz się na napisanie własnego systemu oświetlenia sceny to masz do wyboru oświetlenie liczone dla każdego punktu tekstury (ang. Pixel Lighting), mapy świateł (ang. Lightmap) lub punkty koloru (składowe koloru) (ang. Vertex Lighting).

 

1. Oświetlenie liczone dla każdego punktu tekstury (ang. Pixel Lighting)

Najprościej uzyskać dynamiczne oświetlenie liczone dla każdego punktu tekstury stosując mapy normalnych (ang. Normal Map). Większość obecnych akceleratorów jest w stanie dynamicznie wyznaczyć prawidłowe oświetlenie powierzchni dla której podamy mapę normalnych oraz ustawimy operację DotProduct3.

 

2. Mapy świateł (ang. Lightmap)

Mapy świateł są zwykłymi teksturami nakładanymi na ściany. Mapy świateł imitują prawidłowe oświetlenie ścian. Zasada wykorzystania map świateł opiera się na renderowania wielokątów z właściwą teksturą oraz dodatkowo z mapą oświetlenia. Stosowane są dwie metody nakładania map świateł:

 

Pierwsza to renderowanie dwukanałowe.

Metoda ta wykorzystywana jest z bibliotekami DirectX i OpenGL polega na równoczesnym nakładaniu wielu tekstur na ten sam wielokąt. Niestety wiele akceleratorów obsługuje tylko dwa lub cztery kanały dla tekstur co może mieć wpływ na sposób nanoszenia gdy oprócz mapy światła nanosimy jeszcze np. mapę nierówności czy mapę odbić. Jeżeli planujemy wykorzystywać więcej poziomów to albo sami musimy łączyć teksturę z mapą światła albo wykorzystać lepszy akcelerator graficzny. Struktura kanałów dla renderowania 2- kanałowego:

 

1 kanał -> tekstura

2 kanał -> mapa światła

 

Druga to podwójne renderowanie:

Metoda ta polega na dwukrotnym renderowaniu wielokąta z różną teksturą. Pierwszy raz renderujemy wielokąt z właściwą teksturą, drugi raz renderujemy ten sam wielokąt z mapą światła i ustawionym trybem mieszania kolorów (ang. alpha blending). Musimy pamiętać także o odpowiednim ustawieniu parametrów bufora głębokości (ang. z-buffer)

 

Jak liczymy mapy świateł? 

Mapy świateł możemy liczyć poprzez modyfikację współrzędnych mapowania (wzorce oświetlenia modyfikowane poprzez odległość światła od ściany) lub bezpośrednio wyliczając piksele.

Jeżeli wyliczamy piksele map świateł to mażemy zastosować dwie metody:

 

Pierwsza polega na wcześniejszym policzeniu map świateł np. metodą śledzenia promieni i zapisaniu ich w plikach. Najprościej wykorzystać wtedy gotowe programy generujące mapy świateł. Pamiętajmy że w takim przypadku światła dynamiczne musimy policzyć oddzielnie. Największą wadą tego podejścia jest długi czas wczytywania sceny oraz wielkość pliku z danymi.

 

Druga metoda polega na bezpośrednim liczeniu map świateł w programie. Metoda ta zapewnia od razu światła statyczne i dynamiczne. Liczenie punktów tekstury na bieżąco wymusza małe rozmiary map świateł oraz praktycznie eliminuje metodę śledzenia promieni. Uzyskujemy wtedy rozmyte i niewyraźne cienie. Dla dużych lokacji jest to praktycznie jedyna metoda generacji map świateł.

 

Poniżej przedstawiam jedną z metod liczenia map świateł:

Pierwszym krokiem przy kalkulacji powierzchni mapy światła jest znalezienie współrzędnych u i v wielokąta potrzebnych do poprawnego nałożenia jej na ścianę. u i v dla mapy światła nazwałem lu i lv dla odróżnienia od u i v tekstury.  N i D to współrzędne płaszczyzny na której leży wielokąt. Wierz to tablica wierzchołków wielokąta. IloscWierz to ilość wierzchołków w tej tablicy):

 

if (fabs(N.x) > fabs(N.y) && fabs(N.x) > fabs(N.z)) 

{

     Typ = 1;

     for(i=0;i<IloscWierz;i++)   

     {
            Wierz[i].lu = Wierz[i].y;
            Wierz[i].lv = Wierz[i].z;

      }
}
else if (fabs(N.y) > fabs(N.x) && fabs(N.y) > fabs(N.z))
        {
            Typ = 2;
 

          for(i=0;i<IloscWierz;i++)   

           {
                Wierz[i].lu = Wierz[i].x;
                Wierz[i].lv = Wierz[i].z;

            }
        }
        else
        {
            Typ = 3;

            for(i=0;i<IloscWierz;i++)   

            {
                Wierz[i].lu = Wierz[i].x;
                Wierz[i].lv = Wierz[i].y;

            }
        }

 

Po znalezieniu lu i lv musimy je odpowiednio znormalizować.

Przykładowy program normalizujący lu i lv:

 

minu = Wierz[0].lu;
minv = Wierz[0].lv;
maxu = Wierz[0].lu;
maxv = Wierz[0].lv;

for (i = 0; i <IloscWierz; i++)
{
    if (Wierz[i].lu < minu) minu = Wierz[i].lu;
    if (Wierz[i].lv < minv)  minv = Wierz[i].lv;
    if (Wierz[i].lu > maxu)  maxu = Wierz[i].lu;
    if (Wierz[i].lv > maxv) maxv = Wierz[i].lv;
}

du = maxu - minu;
dv = maxv - minv;

for (i = 0; i <IloscWierz; i++)
{
    Wierz[i].lu -= minu;
    Wierz[i].lv -= minv;
    Wierz[i].lu /= du;
    Wierz[i].lv /= dv;
}

 

Dla każdego wielokąta musimy zapamiętać wartości minu, minv, maxu i maxv oraz Typ.

Po normalizacji współrzędnych lu i lv wielokąta wyznaczamy trzy wektory potrzebne do kalkulacji mapy światła. Są to wektory uvvect, kieru oraz kierv. Wektory te służą do znalezienia pozycji każdego piksela mapy światła w przestrzeni 3D:

 

switch( Typ )
{
case 1: //YZ

vectx = - ( N.y * minu + N.z * minv + D ) / N.x;
uvvect = D3DVECTOR( vectx, minu, minv );

vectx = - ( N.y * maxu + N.z * minv + D ) / N.x;
vect1 = D3DVECTOR( vectx, maxu, minv);

vectx = - ( N.y * minu + N.z * maxv + D ) / N.x;
vect2 = D3DVECTOR( vectx, minu, maxv);

break;
case 2: //XZ

vecty = - ( N.x * minu + N.z * minv + D ) / N.y;
uvvect = D3DVECTOR( minu, vecty, minv);

vecty = - ( N.x * maxu + N.z * minv + D ) / N.y;
vect1 = D3DVECTOR( maxu, vecty, minv);

vecty = - ( N.x * minu + N.z * maxv + D ) / N.y;
vect2 = D3DVECTOR( minu, vecty, maxv);

break;
case 3: //XY

vectz = - ( N.x * minu + N.y * minv + D ) / N.z;
uvvect = D3DVECTOR( minu, minv,vectz);

vectz = - ( N.x * maxu + N.y * minv + D ) / N.z;
vect1 = D3DVECTOR( maxu, minv, vectz);

vectz = - ( N.x * minu + N.y * maxv + D ) / N.z;
vect2 = D3DVECTOR( minu, maxv, vectz);

break; 
}

kieru = vect1 - uvvect;
kierv = vect2 - uvvect;

 

Dla każdego wielokąta sceny zapamiętujemy te trzy wektory. Przed nadaniem każdego wielokąta do akceleratora należy sprawdzić czy policzyliśmy dla niego mapę światła. Jeżeli nie to należy wywołać procedurę liczenia mapy światła.

 

Schematyczny program liczenia każdego piksela mapy światła:

Wartości sze i wys to szerokość i wysokość mapy światła. Bardzo często przyjmowane są stałe wartości sze i wys wynoszące 8, 16 lub 32. Zmienna piksel_poz to pozycja piksela w przestrzeni 3D. Zmienne r, g, b to szukane składowe koloru piksela mapy światła

 

for(x = 0; x< sze;x++)
{
       for(y = 0; y < wys; y++)
       {
                du = x / sze;
                dv = y/ wys;

                piksel_poz = uvvect + kieru * du + kierv * dv;

                // tutaj powinien znaleźć się program wyznaczający wartości koloru (r, g, b) dla piksel_poz

                // kolor ten powinien być wypadkową koloru kolejnych świateł wpływających na

                // ścianę na którą nakładamy tą mapę światła

 

                lightmap[x + y * wys * 3]       =   (char)r;
                lightmap[x + y * wys * 3 + 1]  =  (char)g;
                lightmap[x + y * wys * 3 + 2]  =  (char)b;
       }
 }

 

Możemy również znając pozycję każdego piksela mapy światła w przestrzeni 3D rzucić promień do każdego źródła światła implementując w prosty sposób cienie.

 

Zalety i wady map świateł

Zalety:

Pozwalają szybko zrealizować oświetlenie w grach. Pozwalają na implementację efektów atmosferycznych i specjalnych w grze. Praktycznie nie spowalniają renderowania sceny.

 

Wady:

Duża liczba pikseli mapy świateł które musimy niepotrzebnie policzyć. W mapach świateł czasami występują niekorzystne efekty krawędziowe. Mapy świateł powinny mieć wielkość dobieraną do rozmiarów ścian na które są nakładane.

 

3. Punkty koloru (składowe koloru) (ang. Vertex Ligting)

Metoda ta opera się na wyznaczeniu n punktów na powierzchni wielokąta (wygenerowanych np. poprzez podział wielokąta) dla których modyfikujemy kolor. Kolor każdego punktu wyznaczamy korzystając z metody rzucania promieni lub przy pomocy wektorów normalnych. Po wyznaczeniu koloru punktów przesyłamy wygenerowane wielokąty do akceleratora lub budujemy z nich powierzchnię opartą na trójkątach lub dowolnych wielokątach i nadajemy ją do akceleratora.

 

Zalety:

Brak nadmiarowości. Brak efektów krawędziowych. Wspomaganie sprzętowe (np. rozmycie przy małej ilości punktów). Automatyczne dostosowanie do wielkości ściany. Prostota kodu.

 

Wady:

Znacząco spowalniają proces renderowania sceny na starszych akceleratorach przy dużej ilości punktów (wygenerowanych wielokątów).

  

 

 

 

Wszelkie prawa do serwisu posiada Komires Sp. z o. o.