Lekcja 10. Wczytywanie świata 3D i poruszanie się w nim.
Autor: Łukasz 'lmmilewski' Milewski
Oryginał: Moving In A 3D World (Lionel Brits)
Źródła: http://nehe.gamedev.net/data/lessons/vc/lesson10.zip

Ta lekcja została stworzona przez Lionel Brits (ßetelgeuse). Wyjaśnione są tylko te części kodu, które są zacytowane poniżej. Program nie będzie działał, jeżeli tylko skopiujesz poniższe linie kodu. Jeżeli chcesz wiedzieć gdzie umieścić każdą spośród tych linii ściągnij źródła i czytaj je równocześnie z tym tutorialem.

Witaj na lekcji 10. Umiesz już robić obracający się sześcian, kilka gwiazd. Znasz także podstawy programowania 3D. Ale zaczekaj! Nie zaczynaj pisać Quake'a IV. Obracające się sześciany nie będą zbyt ciekawymi przeciwnikami w Twojej grze ;-) W dzisiejszych czasach potrzebujesz dużego, skomplikowanego i dynamicznego świata 3D z sześcioma stopniami swobody, fantazyjnymi efektami jak lustra czy portale oraz wysokim wskaźnikiem FPS. Ta lekcja wyjaśnia jak stworzyć podstawową strukturę świata 3D, oraz jak się w nim poruszać.

Struktura danych

Można opisać świat 3D jako długą sekwencję liczb, jednak jest to dość trudne, a złożoność środowiska rośnie. Z tego powodu musimy podzielić dane na kategorie. Na szczycie struktury jest sektor. Każdy świat 3D jest zbiorem sektorów. Sektorem może być pokój, sześcian oraz jakakolwiek zamknięta przestrzeń.

typedef struct tagSECTOR         // Struktura sektora
{
    int numtriangles;         // Ilość trójkątów w sektorze
    TRIANGLE* triangle;         // Wskaźnik na tablicę trójkątów
} SECTOR;         // Niech struktura nazywa się SEKTOR

Sektor jest listą wielokątów, dlatego następną kategorią będą trójkąty (pozostaniemy przy trójkątach, bo bardzo łatwo je zaprogramować).

typedef struct tagTRIANGLE         // Nasza struktura dla trójkątów
{
    VERTEX vertex[3];         // Tablica trzech wierzchołków
} TRIANGLE;         // Niech struktura nazywa się TRIANGLE

Trójkąt jest zbudowany z wierzchołków, co prowadzi nas do kolejnej kategorii. Wierzchołek jest opisywany przez współrzędne w przestrzeni 3D oraz współrzędne tekstury. Te dane będą przekazywane do OpenGL.

typedef struct tagVERTEX         // Struktura dla wierzchołków
{
    float x, y, z;         // Współrzędne w świecie 3D
    float u, v;         // Współrzędne tekstury
} VERTEX;        

Wczytywanie plików

Trzymanie danych wewnątrz programu powoduje, że jest on statyczny i nudny. Można jednak wczytywać świat z dysku - daje to większą elastyczność. Możemy na przykład testować różne światy bez potrzeby ponownej kompilacji programu. Ponadto użytkownik może wymieniać światy i modyfikować je bez konieczności poznawania od podszewki naszego programu. Będziemy pamiętali dane w postaci tekstowej. Spowoduje to, że łatwiej będzie edytować plik świata, a kod stanie się krótszy i prostszy. Pliki binarne omówimy innym razem.

Pytanie brzmi: jak wyciągnąć dane z pliku? Najpierw tworzymy funkcję SetupWorld(). Deskryptor pliku trzymamy w polu filein. Otwieramy go do odczytu. Musimy zamknąć ten plik kiedy odczyt się zakończy. Spójrzmy na kod.

        // Wcześniej, w kodzie, musi być definicja: char* worldfile = "data\\world.txt";
void SetupWorld()         // Inicjuje świat
{
    FILE *filein;         // Plik z danymi
    filein = fopen(worldfile, "rt");         // Otwórz plik
    ...
    (tu wczytujemy dane)
    ...
    fclose(filein);         // Zamknij plik
    return;         // Powrót z funkcji
}

Kolejnym wyzwaniem jest odczytanie każdej linii z pliku do zmiennej. Można to zrobić na wiele sposobów. Jednym z problemów jest to, że nie wszystkie linie w pliku będą zawierały znaczące dane. Puste linie nie powinny zostać odczytane. Stwórzmy funkcję readstr(). Ta funkcja będzie czytała jedną, znaczącą linię tekstu. W pliku można także umieszczać komentarze - taka linia powinna rozpoczynać się znakiem / (slash).

void readstr(FILE *f,char *string)         // Wczytaj string
{
    do         // pętla
    {
        fgets(string, 255, f);         // Wczytaj jedną linię
    } while ((string[0] == '/') || (string[0] == '\n'));         // Sprawdź czy warto ją przetwarzać
    return;         // Powrót z funkcji
}

Teraz musimy wczytaj sektor danych. W tej lekcji zajmiemy się przypadkiem gdy jest tylko jeden sektor, ale zaimplementowanie silnika z obsługą większej ilości sektorów jest równie proste. Wróćmy do SetupWorld(). Program musi wiedzieć ile trójkątów jest w sektorze. W pliku z danymi, zdefiniujemy liczbę trójkątów następująco:

NUMPOLLIES n

A oto kod do wczytywania liczby trójkątów.

int numtriangles;         // Ilość trójkątów w sektorze
char oneline[255];         // String do trzymania danych
...
readstr(filein,oneline);         // Pobierz linię z danymi
sscanf(oneline, "NUMPOLLIES %d\n", &numtriangles);         // Wczytaj liczbę trójkątów

Reszta kodu czytającego dane jest bardzo podobna. Musimy jeszcze zainicjalizować sektor i wczytać do niego dane:

        // Wcześniej w kodzie jest deklaracja: SECTOR sector1;
char oneline[255];         // String do pamiętania danych
int numtriangles;         // Liczba trójkątów w sektorze
float x, y, z, u, v;         // Współrzędne wierzchołka i współrzędne tekstury
...
sector1.triangle = new TRIANGLE[numtriangles];         // Alokacja pamięci dla tablicy trójkątów
sector1.numtriangles = numtriangles;         // Definiujemy ilość trójkątów
for (int triloop = 0; triloop < numtriangles; triloop++)         // Pętla po wszystkich trójkątach
{
    for (int vertloop = 0; vertloop < 3; vertloop++)         // Pętla po wierzchołkach
    {
        readstr(filein,oneline);         // Wczytaj string danych
        sscanf(oneline, "%f %f %f %f %f", &x, &y, &z, &u, &v);
        sector1.triangle[triloop].vertex[vertloop].x = x;
        sector1.triangle[triloop].vertex[vertloop].y = y;
        sector1.triangle[triloop].vertex[vertloop].z = z;
        sector1.triangle[triloop].vertex[vertloop].u = u;
        sector1.triangle[triloop].vertex[vertloop].v = v;
    }
}

Każdy trójkąt w naszym pliku jest zapisany następująco:

X1 Y1 Z1 U1 V1

X2 Y2 Z2 U2 V2

X3 Y3 Z3 U3 V3

Wyświetlanie świata

Teraz możemy wczytać sektor do pamięci. Musimy jeszcze wyświetlić go na ekranie. Do tej pory robiliśmy rotacje i translacje, kamera jednak zawsze była w punkcie (0,0,0). Każdy dobry silnik 3D powinien pozwalać użytkownikowi chodzić po świecie 3D i go zwiedzać. Nasz także. Jednym ze sposobów jest poruszanie kamery i rysowanie świata 3D względem pozycji kamery. Jest to powolna metoda i trudna do zaprogramowania. My zrobimy to tak:

  1. Obróć i przesuń kamerę według komend użytkownika
  2. Obróć świat w przeciwnym kierunku niż kamerę (dając wrażenie, że kamera się obróciła)
  3. Przesuń świat w przeciwnym kierunku niż kamera się przesunęła (ponownie, dając odpowiednie wrażenie)

Jest to bardzo proste do zaimplementowania. Zacznijmy od punktu 1.

if (keys[VK_RIGHT])         // Czy prawa strzałka jest wciśnięta
{
    yrot -= 1.5f;         // Obróć scenę w lewo
}
if (keys[VK_LEFT])         // Czy lewa strzałka jest wciśnięta
{
    yrot += 1.5f;         // Obróć scenę w prawo
}
if (keys[VK_UP])         // Czy strzałka w górę jest wciśnięta
{
    xpos -= (float)sin(heading*piover180) * 0.05f;         // Przesuń się wzdłuż osi X według uwzględniając kierunek gracza.
    zpos -= (float)cos(heading*piover180) * 0.05f;         // Przesuń się wzdłuż osi Z według uwzględniając kierunek gracza.
    if (walkbiasangle >= 359.0f)         // Czy walkbiasangle>=359?
    {
        walkbiasangle = 0.0f;                    
    }
    else                                
    {
         walkbiasangle+= 10;         // Jeśli walkbiasangle < 359 to zwiększ o 10
    }
    walkbias = (float)sin(walkbiasangle * piover180)/20.0f;         // Powoduje, że postać sprawia wrażenie ruchu
}
if (keys[VK_DOWN])         // Czy strzałka w dół jest wciśnięta
{
    xpos += (float)sin(heading*piover180) * 0.05f;         // Przesuń się wzdłuż osi X według uwzględniając kierunek gracza.
    zpos += (float)cos(heading*piover180) * 0.05f;         // Przesuń się wzdłuż osi Z według uwzględniając kierunek gracza.
    if (walkbiasangle <= 1.0f)         // Czy walkbiasangle<=1?
    {
        walkbiasangle = 359.0f;                    
    }
    else                                
    {
        walkbiasangle-= 10;         // Jeśli walkbiasangle > 1 to zmniejsz o 10
    }
    walkbias = (float)sin(walkbiasangle * piover180)/20.0f;         // Powoduje, że postać sprawia wrażenie ruchu
}

To było proste. Kiedy prawa lub lewa strzałka jest wciśnięta to następuje rotacja wokół osi OY zostaje odpowiednio zmniejszona/zwiększona. Kiedy naciskamy strzałkę w przód/tył, to wyliczana jest nowa pozycja kamery. Wykorzystujemy funkcje sinus i cosinus aby obliczyć kierunek ruchu. Stała piover180 służy do zamiany stopni na radiany. Pytasz czym jest walkbias? Jest to przesunięcie w osi OY, które występuje gdy osoba idzie (głowa chodząca w górę i w dół, jakby utrzymywała się na powierzchni wody).

Teraz, gdy mamy już te zmienne, możemy zająć się krokami 2 i 3. Ponieważ nasz program jest prosty to zrobimy to w funkcji wyświetlającej scenę, gdyż nie warto tworzyć osobnej.

int DrawGLScene(GLvoid)         // Rysuje scenę
{
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);         // Wyczyść ekran i bufor głębokości (Z-Bufor)
    glLoadIdentity();         // Wyczyść aktywną macierz
    GLfloat x_m, y_m, z_m, u_m, v_m;         // Zmienne na współrzędne trójkąta i tekstury.
    GLfloat xtrans = -xpos;         // Przesunięcie gracza wzdłuż osi OX
    GLfloat ztrans = -zpos;         // Przesunięcie gracza wzdłuż osi OY
    GLfloat ytrans = -walkbias-0.25f;         // Ruch w osi OY dający wrażenie, że postać idzie
    GLfloat sceneroty = 360.0f - yrot;         // Obrót sceny
    int numtriangles;         // Ilość trójkątów.
    glRotatef(lookupdown,1.0f,0,0);         // Obrót tak aby można było patrzeć w górę i w dół
    glRotatef(sceneroty,0,1.0f,0);         // Obrót w zależności od kierunku w którym patrzy gracz
    
    glTranslatef(xtrans, ytrans, ztrans);         // Przesunięcie sceny w zależności od pozycji gracza.
    glBindTexture(GL_TEXTURE_2D, texture[filter]);         // Przypisz teksturę
    
    numtriangles = sector1.numtriangles;         // Odczytaj ilość trójkątów
    
    for (int loop_m = 0; loop_m < numtriangles; loop_m++)         // Pętla po wszystkich trójkątach
    {
        glBegin(GL_TRIANGLES);         // Rysuj trójkąty
            glNormal3f( 0.0f, 0.0f, 1.0f);         // Normalna wskazująca przód
            x_m = sector1.triangle[loop_m].vertex[0].x;         // Współrzędna X pierwszego punktu
            y_m = sector1.triangle[loop_m].vertex[0].y;         // Współrzędna Y pierwszego punktu
            z_m = sector1.triangle[loop_m].vertex[0].z;         // Współrzędna Z pierwszego punktu
            u_m = sector1.triangle[loop_m].vertex[0].u;         // Współrzędna U tekstury pierwszego punktu
            v_m = sector1.triangle[loop_m].vertex[0].v;         // Współrzędna V tekstury pierwszego punktu
            glTexCoord2f(u_m,v_m); glVertex3f(x_m,y_m,z_m);         // Ustaw współrzędne tekstury i wierzchołka
            
            x_m = sector1.triangle[loop_m].vertex[1].x;         // Współrzędna X pierwszego punktu
            y_m = sector1.triangle[loop_m].vertex[1].y;         // Współrzędna Y pierwszego punktu
            z_m = sector1.triangle[loop_m].vertex[1].z;         // Współrzędna Z pierwszego punktu
            u_m = sector1.triangle[loop_m].vertex[1].u;         // Współrzędna U tekstury drugiego punktu
            v_m = sector1.triangle[loop_m].vertex[1].v;         // Współrzędna V tekstury drugiego punktu
            glTexCoord2f(u_m,v_m); glVertex3f(x_m,y_m,z_m);         // Ustaw współrzędne tekstury i wierzchołka
            
            x_m = sector1.triangle[loop_m].vertex[2].x;         // Współrzędna X pierwszego punktu
            y_m = sector1.triangle[loop_m].vertex[2].y;         // Współrzędna Y pierwszego punktu
            z_m = sector1.triangle[loop_m].vertex[2].z;         // Współrzędna Z pierwszego punktu
            u_m = sector1.triangle[loop_m].vertex[2].u;         // Współrzędna U tekstury trzeciego punktu
            v_m = sector1.triangle[loop_m].vertex[2].v;         // Współrzędna V tekstury trzeciego punktu
            glTexCoord2f(u_m,v_m); glVertex3f(x_m,y_m,z_m);         // Ustaw współrzędne tekstury i wierzchołka
        glEnd();         // Koniec rysowania trójkątów
    }
    return TRUE;         // Powrót z funkcji
}

Voila! Co prawda to nie jest Quake, ale nie nazywamy się Carmack czy Abrash. Uruchamiając program możesz nacisnąć F, B, PgUp i PgDown, aby zobaczyć dodatkowe efekty. PgUp/PgDown przechyla kamerę w górę/dół. Załączona tekstura jest po prostu teksturą błota z ID mojej szkoły (o ile NeHe zdecydował się ją zatrzymać).

Więc teraz pewnie zastanawiasz się co dalej. Nie myśl nawet aby użyć tego kodu do wypasionego silnika 3D. Nie jest to zastosowanie do jakiego jest przystosowany ten kod. Zapewne będziesz chciał mieć więcej niż jeden sektor w swojej grze. Szczególnie jeżeli chcesz zaimplementować portale. Przydatne będą też wielokąty o innej liczbie wierzchołków niż 3.