OpenGL i Grafika 3D

Informacja

To jest bardzo eksperymentalna wersja skryptu, większość tekstu na ten moment jest wygenerowana przez LLM na podstawie moich rozwiązań zadań i notatek do nich.

Wprowadzenie do OpenGL: gl_lib.py

Dzisiaj przenosimy się w trzeci wymiar. Do wyświetlania grafiki będziemy używać biblioteki OpenGL, a konkretnie pomocniczej biblioteki gl_lib.py, która upraszcza interakcję z systemem.

W grafice 3D wszystko budujemy z trójkątów. Każdy punkt (wierzchołek) w przestrzeni jest opisywany przez współrzędne jednorodne (rzutowe), czyli cztery liczby \((x, y, z, w)\).

Ważne

Do folderu z rozwiązaniami dzisiejszych zadań pobierz bibliotekę gl_lib.py stąd. Jest tam też działająca biblioteka do macierzy z funkcją mat_mul, gdybyś nie miał jej z wcześniejszych zajęć. Gdyby nie działała, potrzebujesz biblioteki PyOpenGL, zainstaluj ją (i zależności):

sudo apt install libglut-dev
python3 -m venv _env
source _env/bin/activate

Dlaczego cztery współrzędne?

Używamy czterech współrzędnych zamiast trzech z dwóch głównych powodów:

  1. Translacja jako mnożenie: Pozwala to na zapisanie przesunięcia (translacji) jako operacji mnożenia macierzy, co ujednolica wszystkie przekształcenia geometryczne.

  2. Perspektywa i rzutowanie: Czwarta współrzędna, \(w\), odgrywa kluczową rolę w rzutowaniu obiektów 3D na płaski ekran 2D.

Współrzędną \(w\) możemy interpretować jako odwrotność skali punktu. Aby przejść z zapisu jednorodnego \((x, y, z, w)\) do standardowych współrzędnych 3D \((x', y', z')\), wykonujemy dzielenie: $\(x' = \frac{x}{w}, \quad y' = \frac{y}{w}, \quad z' = \frac{z}{w}\)$

  • Jeśli \(w = 1\), to współrzędne \((x, y, z)\) odpowiadają bezpośrednio pozycji w przestrzeni.

  • Jeśli zwiększymy \(w\) (np. \(w=2\)), to po podzieleniu przez nie, punkt „skurczy się” i znajdzie bliżej środka układu współrzędnych (efekt oddalenia).

  • Jeśli \(w\) dąży do zera, punkt oddala się do nieskończoności. W grafice komputerowej punkty o \(w=0\) reprezentują kierunki (wektory), które nie mają konkretnej pozycji, a jedynie zwrot.

Większość naszych dzisiejszych macierzy będzie zachowywać \(w=1\), ale warto pamiętać, że to właśnie manipulacja tą czwartą liczbą pozwala uzyskać efekt perspektywy (im coś jest dalej, tym bardziej „ściska się” w stronę środka ekranu).

Układ współrzędnych

OpenGL używa specyficznego układu współrzędnych:

  • Wszystkie widoczne obiekty muszą mieścić się w przedziale \([-1, 1]\) dla każdej z osi \(x, y, z\). Punkty poza tym obszarem zostaną „obcięte”.

  • \(x\) rośnie w prawo, oś \(y\) rośnie w górę (podobnie jak w kartezjańskim układzie 2D).

  • \(z\) określa głębię. Im mniejsza (bardziej ujemna) wartość \(z\), tym przedmiot jest bliżej widza. Obiekty o mniejszym \(z\) będą zasłaniać te o większym \(z\) (jest to tzw. z-buffering).

Przeanalizuj plik example.py, aby zobaczyć, jak tworzone są kształty i jak działa prosta animacja. Zwróć uwagę na to, który trójkąt zasłania pozostałe.

Wymagania z poprzednich zajęć

Do realizacji dzisiejszych zadań niezbędna będzie Twoja implementacja z poprzednich warsztatów. Upewnij się, że w folderze z zadaniami znajduje się plik matrices.py zawierający:

  • Klasę Matrix: obsługującą tworzenie macierzy, pobieranie i ustawianie wartości elementów (get, set) oraz przechowującą wymiary (nrows, ncols).

  • Funkcję mat_mul(A, B): wykonującą mnożenie macierzy przez macierz lub macierzy przez wektor (pamiętaj, że nasza klasa Vector również udostępnia metody nrows, ncols i get).

W dzisiejszych zadaniach będziemy używać głównie macierzy o wymiarach \(4 \times 4\) oraz wektorów \(4 \times 1\) (opakowanych w klasę Vector gl_lib).

Dokumentacja biblioteki gl_lib.py

Zanim przejdziemy do zadań, zapoznajmy się z interfejsem biblioteki, z której będziemy korzystać.

Klasa Vector

Klasa ta reprezentuje punkt lub wektor w przestrzeni 4D. Posiada pola x, y, z, w. Co istotne, klasa ta została zaprojektowana tak, aby mogła być traktowana jako macierz o wymiarach \(4 \times 1\), co pozwala na bezpośrednie używanie jej w operacjach mnożenia macierzy:

  • nrows() — zwraca liczbę wierszy (zawsze 4).

  • ncols() — zwraca liczbę kolumn (zawsze 1).

  • get(row, col) — pozwala pobrać wartość danej współrzędnej (gdzie row to kolejno 0 dla x, 1 dla y, itd.).

Klasa Triangle

Będziemy z niej korzystać do rysowania:

  • Tworzenie: t = Triangle(v1, v2, v3, rgb(r, g, b)) - gdzie v1,v2,v3 to obiekty klasy Vector, a r,g,b to liczy od 0 do 255 oznaczające składowe koloru trójkąta (czerwoną, zieloną i niebieską).

  • Rysowanie t.draw().

  • Dostęp do wierzchołków: t.p1, t.p2, t.p3.

Przykład rysowania trójkąta:

# Lista trzech wierzchołków tworzy jeden trójkąt
Triangle(
    Vector(-0.5, -0.5, 0.10),
    Vector(+0.5, -0.5, 0.10),
    Vector( 0.0,  0.5, 0.10),
    rgb(255, 0, 0)  # czerwony
).draw()

Funkcja set_render_function(render_function, width, height)

Rejestruje Twoją funkcję, która będzie odpowiedzialna za rysowanie każdej klatki animacji. Funkcja ta zostanie wywołana przez bibliotekę automatycznie wiele razy na sekundę.

Twoja funkcja renderująca (np. def render(time):) przyjmuje jeden parametr — upływ czasu w sekundach od momentu uruchomienia programu. Możesz go użyć do tworzenia animacji (np. uzależniając kąt obrotu od czasu).

Analiza przykładu example.py

Uruchom plik example.py i przyjrzyj się wynikowi. Program ten ilustruje kilka kluczowych zasad działania OpenGL. Poniżej znajduje się pełny kod przykładu z komentarzami:

from gl_lib import Vector, Triangle, set_render_function, rgb, run
import math

def anim(T):
    # Czerwony trójkąt - statyczny, środek głębi (z = 0.1)
    # Znajduje się "pomiędzy" niebieskim a żółtym.
    Triangle(
        Vector(-0.5, -0.5, 0.10),
        Vector(+0.5, -0.5, 0.10),
        Vector( 0.0,  0.5, 0.10),
        rgb(255, 0, 0)
    ).draw()

    # Żółty trójkąt - animowany
    # Zmienia swoją współrzędną Z jednego z wierzchołków w czasie (funkcja sin).
    # Dzięki temu "przebija" się przez czerwony trójkąt.
    Triangle(
        # Te dwa wierzchołki mają bardziej dodatnie współrzędne - są bliżej dalej
        Vector(-0.5, -0.5, 0.15),
        Vector(+0.5, -0.5, 0.15),
        # Rusza się
        Vector( 0.0,  0.4, 0.05 + 0.1 * math.sin(T)),
        rgb(255, 255, 0)
    ).draw()

    # Niebieski trójkąt - najbliżej widza (z = -0.1)
    # W OpenGL mniejsze Z oznacza obiekt bliżej monitora.
    # Niebieski trójkąt zawsze zasłania pozostałe w tym przykładzie.
    Triangle(
        Vector(-0.25, -0.25, -0.1),
        Vector(+0.25, -0.25, -0.1),
        Vector( 0.0,  0.2, -0.1),
        rgb(0, 0, 255)
    ).draw()

if __name__ == "__main__":
    # Rejestrujemy funkcję anim i otwieramy okno 800x600
    set_render_function(anim, 800, 600)
  1. Układ współrzędnych: Zwróć uwagę, że wszystkie wierzchołki mają współrzędne z przedziału \([-1, 1]\).

  2. Z-buffering (kolejność rysowania):

    • Niebieski trójkąt ma współrzędną \(z = -0.1\).

    • Czerwony trójkąt ma \(z = 0.1\).

    • Ponieważ \(-0.1 < 0.1\), niebieski trójkąt znajduje się bliżej monitora i zasłania czerwony, mimo że w kodzie mógłby być zdefiniowany wcześniej lub później.

  3. Przenikanie obiektów: Żółty trójkąt zmienia swoją współrzędną \(z\) wierzchołka w czasie. Możesz zaobserwować, jak „przechodzi” on przez czerwony trójkąt — w miejscach, gdzie jego \(z\) jest mniejsze od \(0.1\), jest widoczny z przodu, a gdy \(z\) staje się większe, znika „za” czerwonym kształtem.

Reprezentacja obiektów 3D

Obiekty w 3D są reprezentowane przez listę trójkątów.

Informacja

Możesz (bardzo słusznie) zastanawiać się, dlaczego narysowany kwadrat nie jest kwadratowy. Powodem jest omówione wcześniej ograniczenie, że wszystkie rysowane obiekty muszą znajdować się w sześcianie (clipping space). Sześcian jest mapowany na okienko programu, które nie jest prostokątne, w więc kształt kwadratu jest zniekształcony przez rozmiar okna. Na razie możesz to „naprawić” (jeśli Ci to przeszkadza) zmieniając wywołanie funkcji run(), żeby okno było kwadratowe. W przyszłości (w dalszej części skryptu, hopefully) dowiesz się, jak naprawić to na poziomie przekształceń nakładanych na wyświetlane obiekty.

Przekształcenia w 3D

Podobnie jak w 2D, możemy używać macierzy do przesuwania i skalowania obiektów. Dzięki temu możemy zdefiniować jeden model (np. drzewo) i rysować go wielokrotnie w różnych miejscach i rozmiarach.

Macierz translacji (przesunięcia) o wektor \([t_x, t_y, t_z]\) ma postać: $\(T = \begin{pmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{pmatrix}\)$

Macierz skalowania o współczynnik \(s\) (wzdłuż wszystkich osi): $\(S = \begin{pmatrix} s & 0 & 0 & 0 \\ 0 & s & 0 & 0 \\ 0 & 0 & s & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}\)$

Modelowanie sześcianu

Sześcian ma 8 wierzchołków i 6 ścian. Każda ściana kwadratowa musi zostać podzielona na dwa trójkąty.

Informacja

Możesz się zastanawiać, dlaczego każdy z sześcianów jest narysowany tak, jakby był widziany dokładnie na wprost, i nie widać w nim żadnych ścian poza przednią. Rzut perspektywiczny również należy do rzeczy, które rozwiązuje się przy pomocy przekształcania obiektów na scenie przez odpowiednią macierz i jest (będzie) to opisane w dalszej części skryptu.

Obroty w 3D

Obroty w 3D wykonujemy wokół jednej z osi układu współrzędnych.

Wokół osi X: $\(R_x(\alpha) = \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & \cos\alpha & -\sin\alpha & 0 \\ 0 & \sin\alpha & \cos\alpha & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}\)$

Wokół osi Y: $\(R_y(\alpha) = \begin{pmatrix} \cos\alpha & 0 & \sin\alpha & 0 \\ 0 & 1 & 0 & 0 \\ -\sin\alpha & 0 & \cos\alpha & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}\)$

Wokół osi Z: $\(R_z(\alpha) = \begin{pmatrix} \cos\alpha & -\sin\alpha & 0 & 0 \\ \sin\alpha & \cos\alpha & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}\)$

Składanie przekształceń - Układ Słoneczny

Największą zaletą macierzy jest możliwość ich składania. Jeśli chcemy, aby Księżyc krążył wokół Ziemi, a Ziemia wokół Słońca, możemy to osiągnąć przez odpowiednie mnożenie macierzy pozycji.