This is the Polish translation of Getting-started/Hello-Triangle article of learnopengl.com tutorial series.

W OpenGL wszystko znajduje się w przestrzeni 3D, ale ekran i okno aplikacji są dwuwymiarowymi tablicami pikseli, więc duża część pracy OpenGL polega na przekształcaniu wszystkich współrzędnych 3D we współrzędne 2D, które pasują do Twojego ekranu. Proces przekształcania współrzędnych 3D na współrzędne 2D jest zarządzany przez potok renderujący OpenGL. Potok renderujący można podzielić na dwie duże części: pierwsza transformuje współrzędne 3D na współrzędne 2D, a druga część przekształca współrzędne 2D w już pokolorowane piksele. W tym samouczku krótko omówimy potok renderowania oraz to, jak możemy go wykorzystać na naszą korzyść, aby utworzyć kilka fantazyjnych efektów graficznych.

Istnieje różnica między współrzędną 2D a pikselem. Współrzędna 2D jest bardzo precyzyjną reprezentacją punktu, informującą o tym gdzie znajduje się punkt w przestrzeni 2D, natomiast piksel 2D jest aproksymacją tego punktu ograniczonego rozdzielczością ekranu/okna.

Potok renderujący przyjmuje zbiór współrzędnych 3D i przekształca je w kolorowe piksele 2D na ekranie. Potok renderujący można podzielić na kilka etapów, gdzie każdy krok wymaga danych wyjściowych z poprzedniego etapu jako jego dane wejściowe. Wszystkie te kroki są wysoce wyspecjalizowane (posiadają jedną konkretną funkcję) i mogą być wykonywane równolegle. Ze względu na ich równoległość, większość dzisiejszych kart graficznych posiada tysiące małych rdzeni przetwarzania, które umożliwiają szybkie przetwarzanie danych w potoku renderującym, uruchamiając małe programy na GPU dla każdego etapu potoku. Te małe programy nazywane są programami cieniującymi (ang. shaders).

Niektóre z tych shaderów są konfigurowane przez programistę, co pozwala nam na napisanie własnych shaderów w celu zastąpienia domyślnych. Daje nam to dużo większą kontrolę nad konkretnymi częściami potoku, a ponieważ działają na GPU, mogą zaoszczędzić nam cenny czas CPU. Shadery są pisane w języku OpenGL Shading Language (GLSL) i będziemy go dokładniej analizować w następnym samouczku.

Poniżej znajdziesz abstrakcyjną reprezentację wszystkich etapów potoku graficznego. Należy zauważyć, że niebieskie sekcje przedstawiają sekcje, w których można “wstrzykiwać” własne shadery.

The OpenGL graphics pipeline with shader stages

Jak widać, potok graficzny zawiera dużą liczbę sekcji, które obsługują jedną konkretną część konwersji danych wierzchołkowych na w pełni wyrenderowany piksel. Krótko wyjaśnimy każdą część potoku w uproszczony sposób, aby uzyskać dobry pogląd tego, jak działa potok renderujący.

Jako dane wejściowe, do potoku graficznego przekazujemy, tablicę trzech współrzędnych 3D, która powinna utworzyć trójkąt. Tą tablicę nazwijmy Vertex Data; Ta tablica to zbiór wszystkich wierzchołków. Wierzchołek (ang. vertex) jest zasadniczo zbiórem współrzędnych 3D. Dane tego wierzchołka są reprezentowane za pomocą atrybutów wierzchołków (ang. vertex attributes), które mogą zawierać dowolne dane, ale dla uproszczenia, załóżmy, że każdy wierzchołek składa się tylko z pozycji 3D i pewnej wartości koloru.

Aby OpenGL mógł wiedzieć, co zrobić ze zbioru współrzędnych i wartości kolorów, OpenGL wymaga podpowiedzi, jakiego typu geometrię chcesz utworzyć za pomocą danych. Czy chcemy, aby dane były renderowane jako zbiór punktów, zbiór trójkątów czy może tylko jako jedna długa prosta? Te wskazówki nazywane są prymitywami i są podawane do OpenGL podczas wywołania dowolnego z poleceń rysowania (ang. draw call). Niektóre z podanych wskazówek to GL_POINTS, GL_TRIANGLES i GL_LINE_STRIP.

Pierwszą częścią potoku jest Vertex Shader, który pobiera jako dane wejściowe jeden wierzchołek. Głównym celem Vertex Shader’a jest przekształcenie współrzędnych 3D w inne współrzędne 3D (więcej o tym później). Dodatkowo pozwala nam również na pewne podstawowe przetwarzanie na wierzchołków.

Etap składania prymitywów (ang. primitive assembly) przyjmuje jako wejście wszystkie wierzchołki (lub wierzchołek, jeśli wybrano GL_POINTS) z Vertex Shader’a, który tworzy prymitywy i montuje wszystkie punkty w podany kształt; w tym przypadku trójkąt.

Wyjście z etapu składania prymitywów jest przekazywane do Geometry Shader. Shader geometrii przyjmuje jako dane wejściowe kolekcję wierzchołków, które tworzą prymityw i mają zdolność generowania innych kształtów, emitując nowe wierzchołki w celu stworzenia nowych (lub innych) prymitywów. W tym przypadku zostanie wygenerowany drugi trójkąt z podanego kształtu.

Wyjście Geometry Shader’a zostaje następnie przekazane do etapu rasteryzacji (ang. rasterization stage), w którym mapuje uzyskane prymitywy z odpowiadającymi im pikselami na finalnym ekranie, dając w rezultacie fragmenty dla Fragment Shader’a. Zanim Fragment Shader zostanie uruchomiony, wykonywane jest obcinanie (ang. clipping). Obcinanie odrzuca wszystkie fragmenty, które są poza obszarem renderowania, zwiększając tym samym wydajność rysowania.

Fragment w OpenGL to wszystkie dane, które są wymagane przez OpenGL, do wyrenderowania pojedynczego piksela.

Głównym celem Fragment Shader’a jest obliczenie końcowego koloru piksela i jest to zazwyczaj etap, w którym tworzy się wszystkie zaawansowane efekty OpenGL. Zazwyczaj Fragment Shader zawiera dane o scenie 3D, których można użyć do obliczania końcowego koloru piksela (takiego jak światła, cienie, kolor światła itp.).

Po ustaleniu wszystkich odpowiednich wartości koloru, obiekt końcowy przechodzi przez jeszcze jeden etap, który nazywamy jest testem głębokości (ang. depth test), testem alfy (ang. alpha test) i testem mieszania (ang. blending test). Etap ten sprawdza odpowiednią wartość głębokości (i szablonu; dojdziemy do tego później) fragmentu potrzebnej do sprawdzenia, czy powstały fragment znajduje się przed lub za innymi obiektami i czy powinien zostać odrzucony. Etap sprawdza także wartości alfa (wartości alfa definiują krycie/przezroczystość obiektu) i mieszania (ang. blending) dla obiektów. Tak więc, nawet jeśli w Fragment Shader jest obliczany kolor piksela, to ostateczny kolor może być zupełnie inny w przypadku renderowania wielu trójkątów.

Jak widać, potok graficzny jest całkiem złożoną całością i zawiera wiele konfigurowalnych części. Jednak w niemal wszystkich przypadkach musimy pracować tylko z Vertex i Fragment Shaderem. Geometry Shader jest opcjonalny i zazwyczaj pozostaje domyślnym programem cieniującym.

Nowoczesny OpenGL wymaga, abyśmy sami zdefiniowali co najmniej Vertex i Fragment Shader (nie ma domyślnych Vertex/Fragment Shaderów na GPU). Z tego powodu bardzo często trudno jest rozpocząć naukę nowoczesnego OpenGL, ponieważ wymagana jest duża wiedza, zanim będzie można wyrenderować pierwszy trójkąt. Gdy dojdziesz do końca tego rozdziału i wyrenderujesz swój pierwszy trójkąt, to będziesz posiadał znacznie większą wiedzę na temat programowania grafiki.

Vertex input

Aby rozpocząć rysowanie, musimy najpierw podać OpenGL dane wierzchołków. OpenGL jest biblioteką grafiki 3D, więc wszystkie współrzędne, które będziemy definiować będą w 3D układzie współrzędnych (współrzędne x, y i z). OpenGL nie przekształca wszystkich Twoich współrzędnych 3D na piksele 2D na ekranie; OpenGL przetwarza tylko współrzędne 3D, które znajdują się w określonym przedziale między-1.0 a 1.0 na wszystkich 3 osiach (x, y i z). Wszystkie współrzędne, w tym tak zwanym znormalizowanym układzie współrzędnych (ang. normalized device coordinates, NDC) będą widoczne na ekranie (a wszystkie współrzędne poza tym regionem nie będą).

Ponieważ chcemy renderować pojedynczy trójkąt, to musimy podać łącznie trzy wierzchołki, gdzie każdy wierzchołek posiada pozycję 3D. Zdefiniujemy je w znormalizowanym układzie współrzędnych (widocznym obszarze OpenGL) w tablicy GLfloat:

GLfloat vertices[] = {  
                        -0.5f, -0.5f, 0.0f,  
                         0.5f, -0.5f, 0.0f,  
                         0.0f,  0.5f, 0.0f  
                     };

Ponieważ OpenGL pracuje w przestrzeni 3D, to dlatego tworzymy trójkąt 2D z każdym wierzchołkiem o współrzędnej z równej 0.0. W ten sposób głębokość (ang. depth) trójkąta pozostaje taka sama, co sprawia, że wygląda jakby był w 2D.

Normalized Device Coordinates (NDC)
Kiedy współrzędne wierzchołków zostały przetworzone w Vertex Shader, powinny znajdować się w układzie współrzędnych znormalizowanych (NDC), czyli małej przestrzeni, gdzie wartości x, y i z mieszczą się w przedziale od -1.0 do 1.0. Wszystkie współrzędne poza tym zakresem zostaną odrzucone/przycięte i nie będą widoczne na ekranie. Poniżej widać trójkąt określony w NDC (ignorując oś z):
2D Normalized Device Coordinates as shown in a graph W przeciwieństwie do zwykłych współrzędnych ekranu dodatnia oś y wskazuje w górę, a współrzędne (0,0) znajdują się w centrum wykresu, zamiast w górnym lewym. W końcu chcesz, aby wszystkie (przekształcone) współrzędne znalazły się w tej przestrzeni współrzędnych, bo w przeciwnym razie nie będą widoczne.
Współrzędne NDC zostaną przekształcone we współrzędne ekranu (ang. screen space) za pomocą transformacji obszaru renderowania (ang. viewport transform) przy użyciu danych podanych w glViewport. Powstałe współrzędne ekranu są następnie przekształcane w fragmenty jako wejście do Fragment Shader’a.

Mając określone dane wierzchołków chcemy wysłać je jako dane wejściowe do pierwszego etapu potoku graficznego: Vertex Shader. Odbywa się to przez utworzenie pamięci na GPU, w której przechowujemy dane wierzchołków, ustawienie sposobu, w jaki OpenGL powinien interpretować pamięć i określić sposób wysłania danych do karty graficznej. Vertex Shader przetwarza tyle wierzchołków, ile mu powiemy podczas wywoływania operacji rysowania.

Możemy zarządzać tą pamięcią za pomocą tak zwanych Vertex Buffer Objects (VBO), które mogą przechowywać dużą liczbę wierzchołków w pamięci GPU. Zaletą korzystania z tych obiektów buforowych jest możliwość wysyłania dużych partii danych na kartę graficzną bez konieczności wysyłania danych pojedynczego wierzchołka za każdym razem. Wysyłanie danych na kartę graficzną z CPU jest stosunkowo wolne, więc gdziekolwiek możemy to wysłajmy jak najwięcej danych na raz. Gdy dane znajdują się w pamięci karty graficznej, to Vertex Shader ma prawie natychmiastowy dostęp do wierzchołków, co czyni go bardzo szybkim.

Obiekt VBO jest naszym pierwszym obiektem OpenGL (obiekty OpenGL omówiliśmy w części OpenGL. Podobnie jak każdy obiekt w OpenGL, ten bufor posiada unikatowy identyfikator. Możemy wygenerować bufor z identyfikatorem przy użyciu funkcji glGenBuffers:

GLuint VBO;  
glGenBuffers(1, &VBO);

OpenGL ma wiele typów obiektów buforowych. Typem bufora wierzchołków jest GL_ARRAY_BUFFER. OpenGL umożliwia nam powiązanie kilku buforów naraz, o ile mają inny ID. Możemy powiązać nowo utworzony bufor z docelowym typem GL_ARRAY_BUFFER za pomocą funkcji glBindBuffer:

glBindBuffer(GL_ARRAY_BUFFER, VBO);

Od tego momentu każde dowolne wywołanie buforowe (na typie GL_ARRAY_BUFFER) zostanie użyte do skonfigurowania aktualnie powiązanego bufora, którym jest nasze VBO. Następnie możemy wywołać funkcję glBufferData, która kopiuje poprzednio określone dane wierzchołkowe do pamięci bufora:

glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

glBufferData jest funkcją specjalnie przeznaczoną do kopiowania danych zdefiniowanych przez użytkownika do obecnie powiązanego bufora. Pierwszym argumentem jest typ bufora, do którego chcemy skopiować dane: bufor aktualnie powiązanego z typem GL_ARRAY_BUFFER. Drugi argument określa rozmiar danych (w bajtach), które chcemy przekazać do bufora; wystarczy proste wywołanie sizeof na tablicy danych wierzchołków. Trzecim parametrem są rzeczywiste dane, które chcemy wysłać.

Czwarty parametr określa, jak chcemy, aby karta graficzna zarządzała danymi. Może to robić na 3 sposoby:

  • GL_STATIC_DRAW: dane najprawdopodobniej nie zmienią się w ogóle lub bardzo rzadko.
  • GL_DYNAMIC_DRAW: dane prawdopodobnie zmienią się często.
  • GL_STREAM_DRAW: dane będą zmieniać się za każdym razem, gdy będą używane do rysowania.

Dane pozycji naszego trójkąta nie zmieniają się i pozostają takie same dla każdego wywołania renderującego, więc jego typem użycia powinien być najlepiej typ GL_STATIC_DRAW. Jeśli na przykład ktoś miałby bufor z danymi, które często się zmieniają, to typ użycia GL_DYNAMIC_DRAW lub GL_STREAM_DRAW może spowodować, że karta graficzna umieści dane w pamięci, która pozwala na szybszy zapis.

Od teraz przechowujemy dane wierzchołkowe w pamięci karty graficznej, zarządzanej przez obiekt buforu wierzchołkowego o nazwie VBO. Teraz chcemy utworzyć Vertex i Fragment Shader, które będą rzeczywiście przetwarzać te dane, więc zacznijmy je tworzyć.

Vertex shader

Vertex Shader jest jednym z programów cieniujących, który jest programowany przez nas. Współczesny OpenGL wymaga, aby przynajmniej utworzyć Vertex i Fragment Shader, jeśli chcemy coś wyrenderować. Dlatego pokrótce wprowadzimy i skonfigurujemy dwa bardzo proste shadery do rysowania naszego pierwszego trójkąta. W następnym samouczku będziemy bardziej szczegółowo omawiać shadery.

Pierwszą rzeczą, którą musimy zrobić, to napisać Vertex Shader w języku cieniującym GLSL (OpenGL Shading Language), a następnie skompilować, abyśmy mogli go używać w naszej aplikacji. Poniżej znajdziesz kod źródłowy bardzo prostego Vertex Shader’a w GLSL:

#version 330 core  
layout (location = 0) in vec3 position;

void main()  
{  
    gl_Position = vec4(position.x, position.y, position.z, 1.0);  
}

Jak widać, GLSL wygląda podobnie do C. Każdy program cieniujący zaczyna się od deklaracji jego wersji. Od wersji OpenGL 3.3 i wyższej numery wersji GLSL są zgodne z wersją OpenGL (na przykład wersja GLSL w wersji 420 odpowiada OpenGL w wersji 4.2). Jawnie mówimy w tym kodzie, że używamy funkcji profilu core.

Następnie deklarujemy wszystkie wejściowe atrybuty wierzchołków (ang. input vertex attributes) w Vertex Shaderze oznaczając je słowem kluczowym in. Teraz tylko interesują nas dane o pozycji, więc potrzebujemy tylko jednego atrybutu wierzchołka. GLSL posiada typy danych wektorowych, który zawiera od 1 do 4 komponentów typu float. Ponieważ każdy wierzchołek posiada współrzędną 3D, tworzymy zmienną wejściową vec3 o nazwie position. Dokładnie określamy również lokalizację tej zmiennej wejściowej za pomocą kwalifikatora layout (location = 0). Później zobaczysz, dlaczego potrzebujemy tej lokalizacji.

Wektor
W programowaniu grafiki, dość często używamy matematycznej koncepcji wektora, ponieważ w prosty sposób reprezentuje pozycje/kierunki w dowolnej przestrzeni i ma użyteczne właściwości matematyczne. Wektor w GLSL ma maksymalny rozmiar 4, a każda z jego wartości może być pobierana za pomocą vec.x, vec.y, vec.z i vec.w, gdzie każda z nich reprezentuje współrzędną w przestrzeni. Zauważ, że składnik vec.w nie jest używany jako pozycja w przestrzeni (mamy do czynienia z 3D, a nie 4D), ale jest używany do czegoś o nazwie dzielenie perspektywy (ang. perspective division). Wektory zostaną bardziej szczegółowo omówione w późniejszym samouczku.

Aby ustawić wyjście Vertex Shader’a, musimy przypisać dane położenia do predefiniowanej zmiennej gl_Position, która za kulisami jest typu vec4. Na końcu funkcji main, niezależnie od tego, jak ustawiamy gl_Position to będzie ona użyta jako wyjście Vertex Shader’a. Ponieważ nasza dana wejściowa jest wektorem o rozmiarze 3, musimy ją zrzutować na wektor o rozmiarze 4. Możemy to zrobić, wstawiając wartości vec3 wewnątrz konstruktora vec4 i ustawić jego składnik w na 1.0f (wyjaśnimy dlaczego jest tu taka wartość w późniejszym samouczku).

Aktualny Vertex Shader jest prawdopodobnie najprostszym programem cieniującym, który możemy sobie wyobrazić, ponieważ nie przetwarzaliśmy żadnych danych wejściowych i po prostu przekazaliśmy je do wyjścia programu cieniującego. W rzeczywistych aplikacjach, dane wejściowe zwykle nie są przekształcone do przestrzeni NDC, dlatego najpierw musimy przekształcić dane wejściowe we współrzędne leżące w widocznym obszarze OpenGL.

Kompilacja shader’a

Napisaliśmy kod źródłowy dla Vertex Shader’a (przechowywany w ciągu znakowym C), ale aby OpenGL mógł go używać, to musi skompilować go w czasie wykonywania programu korzystając z jego kodu źródłowego.

Pierwszą rzeczą, którą musimy zrobić, jest utworzenie obiektu shader’a (ang. shader object), oznaczonego ID. Dlatego tworzymy uchwyt o typie GLuint do tego obiektu i tworzymy go za pomocą funkcji glCreateShader:

GLuint vertexShader;  
vertexShader = glCreateShader(GL_VERTEX_SHADER);

Jako argument funkcji glCreateShader przekazujemy typ shader’a jaki chcemy utworzyć. Jako, że tworzymy Vertex Shader, to przekazujemy wartość GL_VERTEX_SHADER.

Następnie dołączamy kod źródłowy shader’a do shader object’a i go kompilujemy:

glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);  
glCompileShader(vertexShader);

Funkcja glShaderSource bierze jako pierwszy argument, obiekt shader’a, który ma skompilować. Drugi argument określa, ile łańcuchów znakowych przekazujemy jako kod źródłowy. W tym przypadku jest tylko jeden. Trzecim parametrem jest aktualny kod źródłowy shader’a. Czwarty parametr zostawmy póki co ustawiony na wartość NULL.

Prawdopodobnie chcesz sprawdzić, czy kompilacja zakończyła się sukcesem po wywołaniu funkcji glCompileShader i jeśli kompilacja nie powiodła się, to jakie błędy zostały znalezione, które możemy naprawić. Sprawdzenie błędów kompilacji odbywa się w następujący sposób:

GLint success;  
GLchar infoLog[512];  
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

Najpierw definiujemy zmienną int przechowującą informację o tym czy operacja się powiodła i bufor dla komunikatów o błędach (jeśli istnieją). Następnie sprawdzamy, czy kompilacja zakończyła się sukcesem za pomocą funkcji glGetShaderiv. Jeżeli kompilacja nie powiodła się, to powinniśmy pobrać wiadomość informującą o błędach za pomocą funkcji glGetShaderInfoLog i wyświetlić ją na ekranie.

if(!success)  
{  
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);  
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::end;  
}

Jeżeli nie wystąpiły żadne błędy podczas kompilowania Vertex Shader’a to został on poprawnie skompilowany.

Fragment Shader

Fragment Shader jest drugim i ostatnim programem cieniującym, który utworzymy w celu renderowania trójkąta. Fragment Shader zajmuje się obliczaniem kolorów dla pikseli. Aby nie komplikować, to Fragment Shader zawsze będzie wyświetlał kolor pomarańczowy.

Kolory w grafice komputerowej są reprezentowane jako tablica 4 wartości: składnik czerwony, zielony, niebieski i alfa (przezroczystość), powszechnie określany skrótem RGBA. Określając kolor w OpenGL lub GLSL ustawiamy siłę każdego elementu na wartość pomiędzy 0.0 a 1.0. Jeśli na przykład ustawimy kolor czerwony na 1.0f, a zielony na 1.0f otrzymamy mieszaninę obu kolorów i otrzymamy kolor żółty. Biorąc pod uwagę te 3 składniki kolorów, możemy wygenerować ponad 16 milionów kolorów!

#version 330 core  
out vec4 color;

void main()  
{  
    color = vec4(1.0f, 0.5f, 0.2f, 1.0f);  
}

Fragment Shader wymaga tylko jednej zmiennej wyjściowej i jest to wektor o rozmiarze 4, który definiuje końcowy kolor, który musimy sami obliczyć. Możemy zadeklarować wartości wyjściowe za pomocą słowa kluczowego out, które pojawia się przed zmienną color. Następnie przypisujemy kolor pomarańczowy do zmiennej color jako vec4 z wartością alfa wynoszącą 1.0 (1.0 oznacza całkowitą nieprzezroczystość).

Proces kompilacji Fragment Shader’a jest podobny do kompilacji Vertex Shader’a, ale tym razem używamy wartości GL_FRAGMENT_SHADER jako typ programu cieniującego:

GLuint fragmentShader;  
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);  
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);  
glCompileShader(fragmentShader);

Obydwa shadery są teraz skompilowane, a jedyną rzeczą, jaką należy zrobić, to powiązać oba shader object z program object, który można wykorzystać do renderowania.

Program Object

Program Object łączy w sobie zlinkowane ze sobą typy shaderów. Aby użyć niedawno skompilowanych shaderów, musimy je zlinkować dołączyć do Program Object, a następnie go aktywować przed renderowaniem obiektów. Aktywowany Program Object będzie użyty podczas wywoływania funkcji renderujących (ang. render calls).

Podczas linkowania shaderów w Program Object, wyjścia każdego shader’a są łączone z wejściami następnego programu cieniującego. Jest to również miejsce, w którym pojawią się błędy linkowania, jeśli dane wyjściowe i wejściowe nie pasują do siebie.

Tworzenie Program Object jest łatwe:

GLuint shaderProgram;  
shaderProgram = glCreateProgram();

Funkcja glCreateProgram tworzy Program Object i zwraca referencję (ID) do nowo utworzonego obiektu. Teraz musimy dołączyć wcześniej wygenerowane shadery do Program Object, a następnie je powiązać za pomocą funkcji glLinkProgram:

glAttachShader(shaderProgram, vertexShader);  
glAttachShader(shaderProgram, fragmentShader);  
glLinkProgram(shaderProgram);

Powyższy kod powinien być całkiem zrozumiały. Dołączamy shadery do programu i łączymy je za pomocą funkcji glLinkProgram.

Podobnie jak przy kompilacji programów cieniujących, możemy sprawdzić, czy linkowanie Program Object powiodło się czy nie i możemy pobrać odpowiednią wiadomość o błędach. Jednak zamiast używać funkcji glGetShaderiv i glGetShaderInfoLog używamy:

glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);  
if(!success) {  
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);  
    ...  
}

Wynikiem jest Program Object, który możemy aktywować, wywołując funkcję glUseProgram z nowo utworzonym programem jako argumentem:

glUseProgram(shaderProgram);

Każde wywołanie draw call’a po wywołaniu funkcji glUseProgram będzie korzystało z tego Program Object.

Nie zapomnij usunąć obiektów shaderów po powiązaniu ich z Program Object; już ich nie potrzebujemy:

glDeleteShader(vertexShader);  
glDeleteShader(fragmentShader);

Teraz mamy już przesłane dane wierzchołków do GPU i poinstruowaliśmy GPU, w jaki sposób powinien przetwarzać te dane w Vertex i Fragment Shader. Jesteśmy prawie przy końcu, ale jeszcze nie całkiem. OpenGL nie wie jeszcze, jak powinien interpretować dane wierzchołków, które zapisaliśmy w pamięci i jak powinien podłączyć dane wierzchołków do atrybutów Vertex Shader’a. Będziemy mili i powiemy OpenGL jak to zrobić.

Łączenie atrybutów wierzchołków (ang. Vertex Attributes)

Vertex Shader pozwala nam określić dowolne dane wejściowe w postaci atrybutów wierzchołków, a to pozwala na dużą elastyczność. A to oznacza, że musimy ręcznie określić, jaką część naszych danych wejściowych ma trafić do konkretnego atrybutu w Vertex Shader. Oznacza to, że musimy określić, w jaki sposób OpenGL powinien interpretować dane wierzchołków przed renderowaniem.

Nasze dane w buforze wierzchołków są ustawione w następujący sposób:

Ustawienie wskaźnika atrybutu Vertex lub OpenGL VBO

  • Dane pozycji są zapisywane jako 32-bitowe (4 bajtowe) wartości zmiennoprzecinkowe.
  • Każda pozycja składa się z 3 takich wartości.
  • Nie ma miejsca (lub innych wartości) pomiędzy każdym zestawem 3 wartości. Wartości są ściśle upakowane w tablicy.
  • Pierwsza wartość jest na początku buforu.

Dzięki tej wiedzy możemy powiedzieć OpenGL, jak należy interpretować dane wierzchołków (na każdy atrybut wierzchołka) używając funkcji glVertexAttribPointer:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);  
glEnableVertexAttribArray(0);

Funkcja glVertexAttribPointer ma kilka parametrów, więc prześledźmy je uważnie:

  • Pierwszy parametr określa atrybut wierzchołka, który chcemy skonfigurować. Pamiętaj, że określiliśmy lokalizację atrybutu position w Vertex Shader za pomocą layout (location = 0). Ustawia to położenie atrybutu wierzchołka na pozycji 0, a ponieważ chcemy przekazać dane do tego atrybutu wierzchołka, to przekazujemy jako parametr wartość 0.
  • Następny argument określa rozmiar atrybutu wierzchołka. Atrybutem wierzchołka jest vec3 - składa się z 3 wartości.
  • Trzeci argument określa typ danych, którym jest GL_FLOAT (typ vec* w GLSL składa się z wartości zmiennoprzecinkowych).
  • Następny argument określa, czy chcemy, aby dane zostały znormalizowane. Jeśli ustawimy to na wartość GL_TRUE to wszystkie dane, których wartość nie zawiera wartości w przedziale 0 (lub -1 dla wartości ze znakiem), a 1 to zostaną one zmapowane do tego przedziału. Ustawiamy to na wartość GL_FALSE.
  • Piąty argument jest znany jako skok (ang. stride) i mówi nam o tym, jaka jest przestrzeń pomiędzy kolejnymi zestawami atrybutów wierzchołków. Ponieważ następny zestaw danych dotyczących pozycji znajduje się dokładnie po 3 danych typu GLfloat, to ustawiamy tę wartość jako skok. Zauważ, że skoro wiemy, że tablica jest ściśle upakowana (nie ma miejsca pomiędzy kolejnymi wartościami atrybutów wierzchołków) to możemy też określić skok jako 0, aby OpenGL mógł sam określić skok (to tylko działa gdy wartości są szczelnie upakowane). Kiedy mamy więcej atrybutów wierzchołków, musimy sami dokładnie określić odstęp między każdym atrybutem wierzchołka. Jak to zrobić zobaczymy w późniejszym przykładzie.
  • Ostatni parametr jest typu GLvoid* i dlatego wymaga tego dziwnego rzutowania. Jest to offset oznaczający gdzie dane pozycji zaczynają się w buforze. Ponieważ dane pozycji znajdują się na początku tablicy danych, wartość ta wynosi 0. Później zbadamy ten parametr bardziej szczegółowo.

Każdy atrybut wierzchołka pobiera swoje dane z pamięci zarządzanej przez VBO, i z którego VBO pobiera dane (może być wiele VBO). Jest to określane przez aktualnie powiązane VBO z GL_ARRAY_BUFFER podczas wywołania funkcji glVertexAttribPointer. Ponieważ wcześniej zdefiniowany VBO był powiązany przed wywołaniem funkcji glVertexAttribPointer atrybut wierzchołka 0 jest teraz skojarzony z jego danymi wierzchołków.

Teraz, gdy określiliśmy, jak OpenGL powinien interpretować dane wierzchołków, należy również włączyć dany atrybut wierzchołka za pomocą funkcji glEnableVertexAttribArray przekazując jej jako argument lokalizację atrybutu wierzchołka; atrybuty wierzchołków są domyślnie wyłączone. Od tego momentu mamy wszystko skonfigurowane: zainicjowaliśmy dane wierzchołków w buforze przy użyciu Vertex Buffer Object, ustawiliśmy Vertex i Fragment Shader i powiedzieliśmy OpenGL, jak połączyć dane wierzchołków z danymi atrybutami w Vertex Shader. Rysowanie obiektu za pomocą OpenGL wygląda teraz tak:

// 0. Wypełnij VBO danymi wierzchołków  
glBindBuffer(GL_ARRAY_BUFFER, VBO);  
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);  
// 1. Ustaw wskaźniki atrybutu wierzchołka  
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);  
glEnableVertexAttribArray(0);  
// 2. Włącz program cieniujący, którego chcemy użyć do renderowania  
glUseProgram(shaderProgram);  
// 3. Narysuj obiekt  
someOpenGLFunctionThatDrawsOurTriangle();

Musimy powtórzyć ten proces za każdym razem, gdy chcemy narysować obiekt. Może to nie wyglądać na tak długi proces, ale wyobraź sobie, że mamy ponad 5 atrybutów wierzchołków i około 100 różnych przedmiotów (co nie jest rzadko spotykane). Powiązanie odpowiednich obiektów bufora i konfigurowanie wszystkich atrybutów wierzchołków dla każdego z tych obiektów szybko staje się uciążliwym procesem. Co by było, gdybyśmy mogli przechowywać wszystkie te konfiguracje stanów w jednym obiekcie i po prostu podpiąć ten obiekt do kontekstu, aby przywrócić jego stan?

Vertex Array Object

Vertex Array Object (również znane jako VAO) może być powiązany z kontekstem podobnie jak Vertex Buffer Object, a kolejne odwołania do atrybutu wierzchołka są przechowywane wewnątrz VAO. Ma to tę zaletę, że podczas konfigurowania wskaźników do atrybutów wierzchołków wystarczy tylko raz je ustawić i kiedy tylko chcemy narysować obiekt, możemy po prostu podpiąć do kontekstu odpowiednie VAO. To sprawia, że przełączanie pomiędzy różnymi danymi wierzchołkowymi i konfiguracjami atrybutów jest tak proste, jak podłączanie różnych VAO. Cały stan, który właśnie ustawimy, będzie przechowywany wewnątrz VAO.

Core OpenGL wymaga żebyśmy używali VAO, by wiedział co zrobić z naszymi wierzchołkami. Jeśli nie uda nam się podpiąć VAO, OpenGL najprawdopodobniej odmówi narysowania czegokolwiek.

VAO przechowuje następujące informacje:

  • Wywołania do funkcji glEnableVertexAttribArray lub glDisableVertexAttribArray - które atrybuty wierzchołków są włączone, a które nie.
  • Konfigurację atrybutów wierzchołków za pomocą funkcji glVertexAttribPointer.
  • Vertex Buffer Objects, które są skojarzone z odpowiednimi atrybutami wierzchołków, poprzez wywołanie fukcji glVertexAttribPointer.

Obraz działania VAO (Vertex Array Object) i jego zawartości w OpenGL

Proces generowania VAO wygląda podobnie do VBO:

GLuint VAO;  
glGenVertexArrays(1, &VAO);

Aby korzystać z VAO, wszystko co musisz zrobić, to powiązać VAO używając funkcjiglBindVertexArray. Od tego momentu powinniśmy powiązać/skonfigurować odpowiednie VBO i atrybuty wierzchołków. Jak tylko chcemy narysować obiekt, po prostu powiążemy VAO z kontekstem, które zawiera preferowane ustawienia, przed narysowaniem obiektu. W kodzie wyglądałoby to mniej więcej tak:

// ..:: Inicjalizacja (robione raz (o ile Twoje obiekty nie zmieniają się często)) :: ..  
// 1. powiąż Vertex Array Object  
glBindVertexArray(VAO);  
// 2. skopiuj naszą tablicę wierzchołków do VBO  
glBindBuffer(GL_ARRAY_BUFFER, VBO);  
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);  
// 3. ustaw wskaźniki do atrybutów wierzchołków  
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);  
glEnableVertexAttribArray(0);

[...]

// ..:: Kod renderujący (w pętli renderującej) :: ..  
// 4. narysuj obiekt  
glUseProgram(shaderProgram);  
glBindVertexArray(VAO);  
someOpenGLFunctionThatDrawsOurTriangle(); 

I to jest wszystko! Wszystko, co zrobiliśmy na ostatnich kilku milionach stron, prowadziło do tej chwili - VAO, które przechowuje naszą konfigurację atrybutów wierzchołków i których VBO ma użyć. Zwykle, gdy masz wiele obiektów, które chcesz narysować, najpierw generujesz/konfigurujesz wszystkie VAO (a więc wymagane VBO i atrybuty wierzchołków) i przechowujesz je do późniejszego użycia. W chwili, gdy chcemy narysować jeden z naszych obiektów, bierzemy odpowiednie VAO, wiążemy je z kontekstem, a następnie rysujemy obiekt.

Trójkąt, na który wszyscy czekaliśmy

Aby narysować wybrane obiekty, OpenGL udostępnia nam funkcję glDrawArrays, która rysuje prymitywy używając obecnie aktywnego programy cieniującego, poprzednio zdefiniowaną konfigurację atrybutów wierzchołków oraz dane wierzchołkowe w VBO (pośrednio powiązane przez VAO).

glUseProgram(shaderProgram);  
glBindVertexArray(VAO);  
glDrawArrays(GL_TRIANGLES, 0, 3);

Funkcja glDrawArrays przyjmuje jako pierwszy argument typ prymitywu OpenGL, który chcemy narysować. Od początku mówiliśmy, że chcemy narysować trójkąt (a nie lubię kłamać), więc przekazujemy wartość GL_TRIANGLES. Drugi argument określa indeks początkowy tablicy wierzchołków, którą chcielibyśmy narysować; Po prostu zostawiamy tu wartość 0. Ostatni argument określa, ile wierzchołków chcemy narysować - 3 (renderujemy tylko jeden trójkąt z naszych danych, czyli dokładnie 3 wierzchołki).

Teraz spróbuj skompilować kod i jeśli pojawią się jakieś błędy to je napraw. Gdy skompilujesz aplikację, powinieneś zobaczyć następujący wynik:

Obraz podstawowego trójkąta renderowanego w nowoczesnym OpenGL

Kod źródłowy całego programu można znaleźć tutaj.

Jeśli obraz końcowy nie wygląda tak samo, to prawdopodobnie zrobiłeś coś nie tak. Sprawdź więc cały kod źródłowy, zobacz, czy czegoś nie brakowało lub poproś o pomoc w sekcji komentarzy.

Element Buffer Object

Jest jedna rzecz, którą chciałbym omówić dotyczącą renderowania wierzchołków i jest to obiekt bufora elementów (ang. Element Buffer Object) skracany do EBO. Aby wyjaśnić, jak działają te obiekty, najlepiej jest podać przykład: przypuśćmy, że chcemy narysować prostokąt zamiast trójkąta. Możemy narysować prostokąt przy użyciu dwóch trójkątów (OpenGL działa głównie z trójkątami). Spowoduje to wygenerowanie następującego zestawu wierzchołków:

GLfloat vertices[] = {  
    // Pierwszy trójkąt  
     0.5f,  0.5f, 0.0f, // Prawy górny  
     0.5f, -0.5f, 0.0f, // Prawy dolny  
    -0.5f,  0.5f, 0.0f, // Lewy górny  
    // Drugi trójkąt  
     0.5f, -0.5f, 0.0f, // Prawy dolny  
    -0.5f, -0.5f, 0.0f, // Lewy dolny  
    -0.5f,  0.5f, 0.0f // Lewy górny  
};

Jak widać, jest kilka powtarzających się pozycji wierzchołków. Ustawiamy dwa razy dolny prawy i górny lewy! Jest to narzut 50%, ponieważ ten sam prostokąt można również określić za pomocą tylko 4 wierzchołków zamiast 6. To się pogorszy, gdy tylko będziemy mieli bardziej skomplikowane modele, które mają ponad 1000 trójkątów, gdzie będzie dużo więcej wierzchołków, które będą się dublować. Lepszym rozwiązaniem byłoby przechowywanie tylko tych wierzchołków, które się nie powtarzają, a następnie określenie kolejności, w jakiej chcemy narysować te wierzchołki. W tym przypadku musielibyśmy tylko zapisać 4 wierzchołki prostokąta, a następnie tylko określić, w jakim porządku chcielibyśmy je narysować. Czy nie byłoby wspaniale, gdyby OpenGL dostarczył nam taką funkcjonalność?

Na szczęście EBO działają dokładnie w ten wyżej opisany sposób. EBO to bufor, podobnie jak VBO, który przechowuje indeksy, których OpenGL używa do określenia, jakie wierzchołki ma narysować. Ten tak zwany indexed drawing (rysowanie indeksowe) jest właśnie rozwiązaniem naszego problemu. Aby rozpocząć, musimy najpierw określić (unikalne) wierzchołki i indeksy, aby tworzyły prostokąt:

GLfloat vertices[] = {  
     0.5f,  0.5f, 0.0f, // Prawy górny  
     0.5f, -0.5f, 0.0f, // Prawy dolny  
    -0.5f, -0.5f, 0.0f, // Lewy dolny  
    -0.5f,  0.5f, 0.0f  // Lewy górny  
};  

GLuint indices[] = { // Zauważ, że zaczynamy od 0!  
    0, 1, 3, // Pierwszy trójkąt  
    1, 2, 3 // Drugi trójkąt  
};

Możesz zauważyć, że przy używaniu indeksów, potrzebujemy tylko 4 wierzchołków zamiast 6. Następnie musimy utworzyć EBO:

GLuint EBO;  
glGenBuffers(1, &EBO);

Podobnie jak w przypadku VBO wiążemy EBO i kopiujemy do niego indeksy za pomocą glBufferData. Tak samo jak w przypadku VBO chcemy umieścić te wywołania po funkcji odpowiedzialnej za wiązanie. Tym razem jako typ buforu określamy GL_ELEMENT_ARRAY_BUFFER.

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);  
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

Zauważ, że teraz przekazujemy wartość GL_ELEMENT_ARRAY_BUFFER jako typ bufora. Ostatnią rzeczą jaką trzeba zrobić jest zastąpienie funkcji glDrawArrays funkcją glDrawElements żeby zaznaczyć, że chcemy rysować obiekt przy użyciu EBO. Kiedy używamy funkcji glDrawElements to OpenGL będzie rysował obiekty za pomocą indeksów, które są skojarzone z obecnie powiązanym EBO.

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);  
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

Pierwszy argument określa tryb, w jakim chcemy rysować, podobnie jak w przypadku funkcji glDrawArrays. Drugim argumentem jest liczba elementów, które chcielibyśmy narysować. Wyznaczyliśmy 6 indeksów, więc chcemy narysować łącznie 6 wierzchołków. Trzecim argumentem jest typ indeksów, które są typu GL_UNSIGNED_INT. Ostatni argument pozwala nam określić przesunięcie (ang. offset) w EBO (lub przekazać wskaźnik do tablicy indeksowej, ale tylko wtedy gdy, nie używasz EBO), więc po prostu zostawiamy to na wartość 0.

Funkcja glDrawElements bierze indeksy z EBO, który jest aktualnie powiązanego z typem GL_ELEMENT_ARRAY_BUFFER. Oznacza to, że musimy powiązać odpowiednie EBO za każdym razem, gdy chcemy renderować obiekt za pomocą indeksów, co wydaje się nieco kłopotliwe. Dzieje się tak dlatego, że VAO śledzi również EBO. EBO, który jest wiązany jest przechowywany jako obiekt VAO (o ile VAO zostało wcześniej powiązane z kontekstem). Wiązanie VAO automatycznie wiąże także EBO z kontekstem.

Obraz struktury VAO / tego, co przechowuje teraz również z powiązaniami EBO.

VAO śledzi wywołania glBindBuffer kiedy typem jest GL_ELEMENT_ARRAY_BUFFER. Oznacza to również, że równieź śledzi funkcje, które odwiązują EBO od kontekstu. Musisz uważać, żeby nie odwiązać EBO kiedy podłączone jest w tym momencie VAO. W przeciwnym razie spowoduje to, że Twoje EBO nie zostanie skonfigurowane.

Wynikowy kod inicjalizacji i rysowania wygląda następująco:

// ..:: Inicjalizacja :: ..  
// 1. powiąż Vertex Array Object  
glBindVertexArray(VAO);  
// 2. skopiuj naszą tablicę wierzchołków do VBO  
glBindBuffer(GL_ARRAY_BUFFER, VBO);  
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);  
// 3. skopiuj naszą tablicę indeksów do EBO  
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);  
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);  
// 4. ustaw wskaźniki do atrybutów wierzchołków  
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);  
glEnableVertexAttribArray(0);

[...]

// ..:: Kod renderujący (w pętli renderującej) :: ..  
glUseProgram(shaderProgram);  
glBindVertexArray(VAO);  
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)  
glBindVertexArray(0);

Uruchomienie programu powinno dawać obraz podobny do tego poniżej. Lewy obraz powinien wyglądać bardzo podobnie. Natomiast prawy obraz przedstawia prostokąt narysowany w trybie szkieletowym (ang. wireframe mode). Prostokąt szkieletowy pokazuje, że faktycznie składa się on z dwóch trójkątów.

Prostokąt narysowany za pomocą indeksowanego renderowania w OpenGL

Tryb szkieletowy
Aby narysować trójkąty w trybie szkieletowym, można skonfigurować sposób, w jaki OpenGL rysuje prymitywy za pomocą funkcji glPolygonMode(GL_FRONT_AND_BACK, GL_LINE). Pierwszy argument mówi, że chcemy zastosować ten tryb do przedniej i tylnej ścianki wszystkich trójkątów, a drugi parametr mówi, aby rysować je jako linie. Każde kolejne wywołanie rysowania spowoduje wyświetlenie trójkątów w trybie szkieletowym, dopóki nie przywrócimy go do wartości domyślnej, używając funkcji glPolygonMode(GL_FRONT_AND_BACK, GL_FILL).

Jeśli masz jakieś błędy, prześledź swoją pracę w tył i sprawdź, czy czegoś nie brakuje. Pełny kod źródłowy możesz znaleźć tutaj. Możesz też zadać jakiekolwiek pytanie w sekcji komentarzy poniżej.

Jeśli udało Ci się narysować trójkąt lub prostokąt tak, jak to zrobiliśmy, gratulacje, udało Ci się przebrnąć przez najtrudniejszą część nowoczesnego OpenGL: narysowanie pierwszego trójkąta. Jest to trudna sprawa, ponieważ wymagany jest duży zasób wiedzy, zanim będzie można narysować swój pierwszy trójkąt. Na szczęście pokonaliśmy tę barierę, a kolejne lekcje będą (mam nadzieję) znacznie łatwiejsze do zrozumienia.

Dodatkowe materiały

Ćwiczenia

Aby uzyskać naprawdę dobre zrozumienie omawianych tutaj zagadnień, podaję kilka ćwiczeń. Zalecam, aby je zrobić, zanim przejdziesz do kolejnego tematu, aby upewnić się, że masz dobrą znajomość tego, co się dzieje.

  • Spróbuj narysować dwa trójkąty obok siebie przy użyciu glDrawArrays dodając więcej wierzchołków do danych: rozwiązanie.
  • Teraz utwórz te same 2 trójkąty przy użyciu dwóch różnych VAO i VBO dla ich danych: rozwiązanie.
  • Utwórz dwa programy cieniujące, gdzie drugi program używa innego Fragment Shader’a, który wyświetla kolor żółty; Narysuj dwa trójkąty ponownie, gdzie drugi jest koloru żółtego: rozwiązanie.