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

Jak wspomniano w samouczku Witaj Trójkącie, shadery są to małe programy, które działają na GPU. Programy te są uruchamiane dla każdego konkretnego etapu potoku graficznego. W podstawowym znaczeniu, shadery to nic więcej jak programy przekształcające dane wejściowe w dane wyjściowe. Shadery są również bardzo odizolowanymi programami, ponieważ nie mogą komunikować się ze sobą; Jedyną ich komunikacją są ich wejścia i wyjścia.

W poprzednim samouczku powierzchownie dotknęliśmy shaderów i tego jak ich właściwie używać. W tej części kursu, shadery zostaną wyjaśnione bardziej szczegółowo, a szczególnie skupimy się na języku w jakim są one pisane - OpenGL Shading Language (GLSL).

GLSL

Shadery są pisane w języku GLSL, który jest podobny do C. GLSL jest dostosowany do współpracy z programowaniem grafiki i zawiera przydatne funkcje, które są ukierunkowane na manipulację wektorami i macierzami.

Shadery zawsze zaczynają się deklaracją wersji, a następnie listą zmiennych wejściowych i wyjściowych, uniform’ów i funkcji main. Każdy punkt wejścia (ang. entry point) programu cieniującego znajduje się w funkcji main, gdzie przetwarzamy dowolne dane wejściowe i zapisujemy wyniki w odpowiednich zmiennych wyjściowych. Nie przejmuj się, jeśli nie wiesz, co to są uniform’y, dojdziemy do nich wkrótce.

Shader ma zazwyczaj następującą strukturę:

#version version_number

in type in_variable_name;  
in type in_variable_name;

out type out_variable_name;

uniform type uniform_name;

void main()  
{  
    // Przetwórz dane wejściowe  
    ...  
    // Zapisz przetworzone dane do zmiennych wyjściowych  
    out_variable_name = weird_stuff_we_processed;  
}

Kiedy mówimy konkretnie o Vertex Shader każda zmienna wejściowa jest również znana jako atrybut wierzchołka (ang. vertex attribute). Istnieje maksymalna liczba atrybutów wierzchołkowych, które możemy zadeklarować i ta liczba jest ograniczona przez sprzęt. OpenGL gwarantuje zawsze istnienie co najmniej szesnastu 4-komponentowych atrybutów wierzchołków, ale niektóre karty graficzne mogą zezwalać na więcej. Tą liczbę można pobrać, poprzez zapytanie OpenGL o wartość GL_MAX_VERTEX_ATTRIBS:

GLint nrAttributes;  
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);  
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

Ten kod często zwraca minimum, które wynosi 16. Pomimo tego, ta wartość powinna być więcej niż wystarczająca dla większości celów.

Typy

GLSL, podobnie jak inne języki programowania, posiada typy danych do określania na jakiego typu zmiennej chcemy pracować. GLSL zawiera większość podstawowych typów znanych z innych języków takich jak C: int, float, double, uint i bool. GLSL zawiera również dwa typy kontenerów, których będziemy dużo używali podczas ćwiczeń, mianowicie wektory i macierze. Macierze omówimy w późniejszym samouczku.

Wektory

Wektor w GLSL to pojemnik zawierający 1, 2, 3 lub 4 komponenty dla dowolnego z wcześniej wspomnianych typów podstawowych. Mogą przyjmować następującą postać (n oznacza liczbę elementów):

  • vecn: podstawowy wektor zawierający n komponentów typu float.
  • bvecn: wektor zawierający n komponentów typu bool.
  • ivecn: wektor zawierający n komponentów typu int.
  • uvecn: wektor zawierający n komponentów typu unsigned int.
  • dvecn: wektor zawierający n komponentów typu double.

Przez większość czasu będziemy używać podstawowej wersji vecn, ponieważ zmienne float są wystarczające do większości naszych celów.

Komponenty wektora można uzyskać za pośrednictwem vec.x, gdzie x jest pierwszym składnikiem wektora. Możesz użyć .x, .y, .z i .w, aby odpowiednio uzyskać dostęp do jego pierwszego, drugiego, trzeciego i czwartego składnika. GLSL umożliwia również używanie rgba w kontekście kolorów lub stpq dla współrzędnych tekstur, które uzyskują dostęp do tych samych komponentów.

Typ danych wektorowych umożliwia pewien interesujący i elastyczny sposób wyboru komponentów o nazwie swizzling. Swizzling pozwala na następującą składnię:

vec2 someVec;  
vec4 differentVec = someVec.xyxx;  
vec3 anotherVec = differentVec.zyw;  
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

Można użyć dowolnej kombinacji o maksymalnie 4 literach, aby utworzyć nowy wektor (tego samego typu), o ile oryginalny wektor zawiera te składniki; Nie można uzyskać dostępu do składnika .z dla na przykład vec2. Możemy również przekazywać wektory jako argumenty dla różnych konstruktorów wektora, zmniejszając liczbę wymaganych argumentów:

vec2 vect = vec2(0.5f, 0.7f);  
vec4 result = vec4(vect, 0.0f, 0.0f);  
vec4 otherResult = vec4(result.xyz, 1.0f);

Wektory są elastycznym typem danych, które można wykorzystać do wszystkich rodzajów wejść i wyjść. W tym kursie zobaczysz wiele przykładów, w jaki sposób możemy twórczo zarządzać wektorami.

Wejścia i wyjścia

Shadery to same w sobie ładne, małe, samodzielne programy, ale stanowią część pewnej całości i dlatego chcemy mieć możliwość definiowania wejść i wyjść dla poszczególnych shader’ów, dzięki czemu byśmy mogli przenosić nasze dane. GLSL zdefiniował specjalnie do tego celu słowa kluczowe in oraz out. Każdy moduł cieniujący może określić wejścia i wyjścia przy użyciu tych słów kluczowych i gdziekolwiek zmienna wyjściowa pasuje do zmiennej wejściowej następnego etapu cieniowania, to są one dalej przekazywane. Jednak Vertex i Fragment Shader są pod tym względem nieco inne.

Vertex Shader powinien otrzymać jakąś inną formę otrzymywania danych wejściowych, w przeciwnym wypadku byłby on nieefektywny. Vertex Shader otrzymuje dane wejściowe bezpośrednio z danych wierzchołkowych (Vertex Data). Aby określić jak zorganizowane są dane wierzchołkowe, określamy zmienne wejściowe z metadanymi lokalizacji, dzięki czemu możemy skonfigurować atrybuty wierzchołków na CPU. Widzieliśmy to w poprzednim tutorialu jako kwalifikator layout (location = 0). Vertex Shader wymaga więc dodatkowej specyfikacji dla wejść, dzięki czemu możemy je powiązać z danymi wierzchołków.

Można również pominąć specyfikację layout (location = 0) i zamiast tego wysłać zapytanie do OpenGL o lokalizacje atrybutów w kodzie za pośrednictwem glGetAttribLocation. Jednak wolałabym ustawiać je w Vertex Shader. Jest to łatwiejsze do zrozumienia i oszczędza Tobie (i OpenGL) pracę.

Innym wyjątkiem jest to, że Fragment Shader wymaga wyjściowej zmiennej finalnego koloru typu vec4, ponieważ Fragmenty Shader generują końcowy kolor fragmentu. Jeśli nie określiłbyś koloru wyjściowego w twoim Fragment Shader, OpenGL wyrenderuje Twój obiekt na czarno (lub biało).

Zatem jeśli chcemy wysyłać dane z jednego programu cieniującego do drugiego, musimy zadeklarować zmienne wyjściowe w shaderze, który je wysyła dalej i podobne dane wejściowe w shaderze, który te dane ma przyjąć. Kiedy typy i nazwy są takie same po obu stronach, OpenGL połączy ze sobą te zmienne, a następnie możliwa jest komunikacja między shaderami (odbywa się to podczas linkowania Program Object). Aby pokazać, jak to działa w praktyce, będziemy zmieniać shadery z poprzedniego samouczka, aby Vertex Shader decydował o kolorze zamiast Fragment Shader’a.

Vertex shader

#version 330 core  
layout (location = 0) in vec3 position; // Zmienna position ma atrybut lokalizacji równy 0

out vec4 vertexColor; // Zadeklaruj zmienną wyjściową koloru, która zostanie wysłana do FS

void main()  
{  
    gl_Position = vec4(position, 1.0); // Zauważ jak od razu przekazujemy vec3 do konstruktora vec4  
    vertexColor = vec4(0.5f, 0.0f, 0.0f, 1.0f); // Ustaw kolor na ciemno-czerwony  
}

Fragment shader

#version 330 core  
in vec4 vertexColor; // Zmienna wejściowa odebrana od VS (ten sam typ i nazwa)

out vec4 color;

void main()  
{  
    color = vertexColor;  
}

Możesz zauważyć, że zadeklarowaliśmy zmienną vertexColor jako wyjście typu vec4, które ustawiliśmy w Vertex Shader i zadeklarowaliśmy podobną zmienną vertexColor w Fragment Shader jako daną wejściową. Ponieważ obie mają ten sam typ i nazwę, to zmienna vertexColor w FS jest połączona ze zmienną vertexColor w VS. Ponieważ w VS ustawiamy kolor na ciemno-czerwony, końcowe fragmenty powinny również być ciemno-czerwone. Poniższy obraz przedstawia wyjściowy obrazek:

Shaders

Właśnie przesłaliśmy wartość z VS do FS! Dodajmy temu trochę smaczku i zobaczmy czy jesteśmy w stanie przesłać wartość koloru prosto z naszej aplikacji do FS!

Uniformy

Uniformy są kolejnym sposobem przekazywania danych z naszej aplikacji do shaderów znajdujących się na GPU, z tym, że uniformy są nieco inne w porównaniu do atrybutów wierzchołków. Przede wszystkim uniformy są globalne. Globalne oznacza, że zmienna uniform jest unikatowa dla każdego Program Object i można uzyskać do niej dostęp z dowolnego programu cieniującego. Po drugie, niezależnie od tego na jaką wartość ustawisz uniform, to zachowa tę wartości, dopóki nie zostanie ona zresetowana lub zaktualizowana.

Od tłumacza
Uniformy są to specjalne zmienne, które dostępne są w programach cieniujących. Ich wartości możemy ustawiać z poziomu aplikacji na CPU. Od atrybutów wierzchołków różnią się tym, że uniformy zachowują swoją wartość pomiędzy inwokacjami shader’ów - każda inwokacja np. Vertex Shader’a z reguły przeskakuje o jeden atrybut wierzchołka, co inwokację. Dzięki temu przetwarzany jest każdy wierzchołek z osobna; uniformy natomiast zachowują swoją wartość przez cały okres działania programu cieniującego w danej ramce. W kolejnej ramce może mieć już inną, nową wartość.

Aby zadeklarować uniform w GLSL, po prostu dodaj słowo kluczowe uniform przed typem zmiennej w programie cieniującym. Od tego momentu możemy użyć nowo utworzonego uniform’a w shaderze. Przyjrzyjmy się, czy tym razem możemy ustawić kolor trójkąta za pomocą tej specjalnej zmiennej:

#version 330 core  
out vec4 color;

uniform vec4 ourColor; // ustawiamy tą wartość w kodzie aplikacji OpenGL

void main()  
{  
    color = ourColor;  
}

W FS zadeklarowaliśmy uniform o typie vec4 i nazwie ourColor i ustawiliśmy finalny kolor fragmentu na wartość tego uniforma. Ponieważ uniformy są zmiennymi globalnymi, możemy je zdefiniować w dowolnym shaderze, więc nie wracać do VS, by dopisać tam jakiś kod, który przeniesie wartość uniforma do FS. Nie używamy uniforma w VS, więc nie ma potrzeby go tam definiować.

Jeśli zadeklarujesz uniform, który nie jest używany w żadnym miejscu kodu GLSL, kompilator automatycznie usunie zmienną z wersji skompilowanej, co jest często powodem kilku frustrujących błędów; pamiętaj o tym!

Uniform jest obecnie pusty; nie dodaliśmy żadnych danych do niego, więc spróbujmy to zrobić. Najpierw musimy znaleźć indeks/położenie uniforma w naszym programie cieniującym. Kiedy mamy indeks/lokalizację uniforma, możemy zaktualizować jego wartość. Zamiast przekazywać cały czas ten sam kolor dla FS, zaszalejemy i będziemy stopniowo zmieniać kolor w miarę upływu czasu:

GLfloat timeValue = glfwGetTime();  
GLfloat greenValue = (sin(timeValue) / 2.0) + 0.5;  
GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");  
glUseProgram(shaderProgram);  
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

Najpierw pobieramy czas w sekundach za pomocą funkcji glfwGetTime(). Następnie zmieniamy kolor w zakresie 0.0 - 1.0 przy użyciu funkcji sin i zapisujemy wynik w greenValue.

Następnie pobieramy lokalizację uniforma ourColor za pomocą glGetUniformLocation. Przekazujemy do tej funkcji Program Object i nazwę uniforma (którego lokalizację chcemy pobrać). Jeśli glGetUniformLocation zwróci wartość -1 to oznacza, że nie może znaleźć danej lokalizacji. Na koniec możemy ustawić wartość uniforma za pomocą funkcji glUniform4f. Zauważ, że znalezienie lokalizacji uniforma nie wymaga wcześniejszego aktywowania Program Object. Natomiast uaktualnienie uniforma wymaga wcześniejszego aktywowania programu (wywołując funkcję glUseProgram), ponieważ ustawia uniform na aktualnie aktywnym programie cieniującym.

Ponieważ OpenGL jest w swoim rdzeniu biblioteką C, to nie posiada on natywnej obsługi przeciążania typów, dlatego wszędzie tam, gdzie można wywołać funkcję z różnymi typami OpenGL definiuje nowe funkcje dla każdego typu; glUniform jest doskonałym tego przykładem. Funkcja wymaga określonego przyrostka dla typu uniformu, który ma zostać ustawiony. Kilka z możliwych przyrostków to:

  • f: funkcja oczekuje zmiennej typu float jako swoją wartość.
  • i: funkcja oczekuje zmiennej typu int jako swoją wartość.
  • ui: funkcja oczekuje zmiennej typu unsigned int jako swoją wartość.
  • 3f: funkcja oczekuje zmiennej 3 float’ów jako swoją wartość.
  • fv: funkcja oczekuje zmiennej typu wektora/tablicy float jako swoją wartość.

Zawsze, kiedy chcesz skonfigurować jakąś opcję OpenGL, wystarczy wybrać przeciążoną funkcję, która odpowiada Twojemu typowi. W naszym przypadku chcemy ustawiać 4 wartości typu float z osobna dla uniforma, więc przekazujemy nasze dane za pośrednictwem glUniform4f (pamiętaj, że mogliśmy również użyć wersji z fv).

Teraz, jak już wiemy, jak ustawić wartości uniformów, możemy użyć ich do renderowania. Jeśli chcemy, aby kolor stopniowo się zmieniał, to musimy zaktualizować tego uniforma dla każdej iteracji pętli gry (więc zmienia się raz na jedną klatkę), w przeciwnym razie trójkąt utrzymywałby jednolity kolor, jeśli tylko ustawimy tego uniforma. Więc obliczamy wartość greenValue i aktualizujemy uniform co jedną iterację:

while(!glfwWindowShouldClose(window))  
{  
    // input  
    processInput(window);

    // render  
    // wyczyść bufor koloru  
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);  
    glClear(GL_COLOR_BUFFER_BIT);

    // upewnij się, że aktywowałeś program object  
    glUseProgram(shaderProgram);

    // uaktualnij wartość uniforma  
    float timeValue = glfwGetTime();  
    float greenValue = sin(timeValue) / 2.0f + 0.5f;  
    int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");  
    glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

    // narysuj trójkąt  
    glBindVertexArray(VAO);  
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // zamień bufory i sprawdź zdarzenia I/O  
    glfwSwapBuffers(window);  
    glfwPollEvents();  
}

Kod jest stosunkowo prostym uaktualnieniem poprzedniego kodu. Tym razem uaktualnimy wartość uniforma dla każdej iteracji przed rysowaniem trójkąta. Jeśli uaktualniasz uniform poprawnie, powinieneś zobaczyć trójkąt, który stopniowo zmienia się z zielonego na czarny i z powrotem na zielony.

Jeżeli utknąłeś, sprawdź kod źródłowy tutaj.

Jak widać, uniformy są użytecznym narzędziem do ustawiania zmiennych, które mogą się zmieniać pomiędzy iteracjami renderowania, lub do wymiany danych między aplikacją a shaderami, ale co zrobić, jeśli chcemy ustawić kolor dla każdego wierzchołka? W takim przypadku musimy zadeklarować tyle uniformów, ile mamy wierzchołków. Lepszym rozwiązaniem byłoby dodanie większej liczby danych do atrybutów wierzchołków, co właśnie zrobimy.

Więcej atrybutów!

W poprzednim samouczku widzieliśmy, jak możemy wypełnić VBO, skonfigurować wskaźniki atrybutów wierzchołków i przechowywać je w VAO. Tym razem chcemy dodać dane o kolorach do danych wierzchołkowych. Zamierzamy dodawać dane o kolorach jako 3 wartości typu float do tablicy vertices. Do każdego z wierzchołków naszego trójkąta przypisujemy odpowiednio kolory czerwony, zielony i niebieski:

float vertices[] = {
     // pozycje          // kolory
     0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,   // dolny prawy
    -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,   // dolny lewy
     0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f    // górny 
};  

Ponieważ teraz mamy więcej danych do wysyłania do VS, konieczne jest jego dostosowanie tak, aby również otrzymywał wartość koloru dla każego wierzchołka. Należy zauważyć, że lokalizacja atrybutu color została ustawiona na 1 przy użyciu kwalifikatora layout:

#version 330 core  
layout (location = 0) in vec3 position; // zmienna position ma lokalizację 0  
layout (location = 1) in vec3 color; // zmienna color ma lokalizację 1

out vec3 ourColor; // przekaż kolor do FS

void main()  
{  
    gl_Position = vec4(position, 1.0);  
    ourColor = color; // ustaw ourColor na kolor wejściowy z atrybutu wierzchołka  
}

Ponieważ używamy zmiennej wyjściowej ourColor zamiast uniforma, musimy również dostosować FS:

#version 330 core  
in vec3 ourColor;  
out vec4 color;

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

Ponieważ dodaliśmy kolejny atrybut wierzchołka i zaktualizowaliśmy pamięć VBO musimy ponownie skonfigurować wskaźniki atrybutu wierzchołka. Zaktualizowane dane w pamięci VBO wyglądają teraz tak:

Przeplot danych dotyczących położenia i koloru w VBO, które mają być skonfigurowane za pomocą glVertexAttribPointer

Wiedząc obecny układ możemy zaktualizować format wierzchołków za pomocą glVertexAttribPointer:

// Atrybut pozycji  
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);  
glEnableVertexAttribArray(0);  
// Atrybut koloru  
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3* sizeof(GLfloat)));  
glEnableVertexAttribArray(1);

Pierwsze kilka argumentów funkcji glVertexAttribPointer powinny być zrozumiałe. Tym razem konfigurujemy atrybut wierzchołka w lokalizacji 1. Wartości kolorów mają rozmiar 3 float’ów i nie normalizujemy ich wartości.

Ponieważ mamy teraz dwa atrybuty wierzchołka, musimy ponownie obliczyć wartość stride. Aby uzyskać następną wartość atrybutu (np. następny składnik x wektora pozycji) w tablicy danych, należy przesunąć o 6 float’ów w prawo - trzy dla wartości pozycji i trzy dla wartości kolorów. Daje nam to wartość stride 6 razy większa niż float w bajtach (= 24 bajtów).
Również tym razem musimy określić offset. Dla każdego wierzchołka, atrybut pozycji jest pierwszy, dlatego deklarujemy przesunięcie 0. Atrybut koloru rozpoczyna zaraz za danymi położenia, dlatego wynosi 3 * sizeof(GLfloat) w bajtach (= 12 bajtów).

Uruchomienie aplikacji powinno pokazać następujący obraz:

Jeżeli utknąłeś, sprawdź kod źródłowy tutaj.

Obraz może nie być dokładnie taki, jakiego byś oczekiwał, ponieważ dostarczyliśmy tylko 3 kolory, a nie całą paletę kolorów, którą widzimy teraz. Jest to wynik czegoś, co nazywa się interpolacją fragmentu w Fragment Shader. Podczas renderowania trójkąta, etap rasteryzacji zazwyczaj generuje więcej fragmentów niż wierzchołków, które zostały wcześniej zdefiniowane. Następnie rasteryzer określa położenie każdego z tych fragmentów w oparciu o miejsce, w którym znajdują się na trójkącie.
Opierając się na tych pozycjach, interpoluje wszystkie zmienne wejściowe FS. Powiedzmy na przykład, że mamy linię, gdzie górny punkt ma zielony kolor, a dolny punkt niebieski kolor. Jeśli fragment shader jest uruchamiany na fragmencie, który znajduje się koło pozycji w 70% długości linii, to jego atrybutem wejściowym koloru, byłaby liniowa kombinacja koloru zielonego i niebieskiego; żeby być bardziej precyzyjnym: 30% niebieskiego i 70% zielonego.

To jest dokładnie to, co się stało w naszym trójkącie. Mamy 3 wierzchołki, a więc 3 kolory i prawdopodobnie trójkąt zawiera około 50000 fragmentów, gdzie fragment shader interpolował kolory wśród tych pikseli. Jeśli dobrze przyjrzymy się kolorom, które widzisz, wszystko ma sens: idąc od czerwonego do niebieskiego, kolor najpierw staje się fioletowy, a następnie na niebieski. Interpolacja fragmentów jest stosowana do wszystkich atrybutów wejściowych Fragment Shader’a.

Nasza własna klasa Shader

Pisanie, kompilowanie i zarządzanie shaderami może być dość kłopotliwe. Ostatnią rzeczą jaką zrobimy w temacie shaderów to sprawienie, żeby nasze życie stało się łatwiejsze. Zbudujemy klasę Shader, która czyta programy cieniujące z dysku, kompiluje i łączy je, sprawdza błędy i jest łatwa w użyciu. To także daje Ci trochę pojęcia, jak możemy opakować część wiedzy, której nauczyliśmy się do tej pory, w użyteczne, abstrakcyjne obiekty.

Utworzymy klasę Shader całkowicie w pliku nagłówkowym, głównie w celach edukacyjnych i przenośności. Zacznijmy od dołączenia wymaganych plików nagłówkowych i zdefiniujmy strukturę klasy:

#ifndef SHADER_H  
#define SHADER_H

#include <"glad/glad.h">// dołącz glad, by móc korzystać w wszystkich wymaganych przez OpenGL funkcji</glad>

#include <string>
#include <fstream>
#include <sstream>
#include <iostream>

class Shader  
{  
public:  
    // ID program object  
    unsigned int ID;

    // konstruktor czyta plik shadera z dysku i tworzy go  
    Shader(const GLchar* vertexPath, const GLchar* fragmentPath);  
    // aktywuj shader  
    void use();  
    // funkcje operujące na uniformach  
    void setBool(const std::string &name, bool value) const;  
    void setInt(const std::string &name, int value) const;  
    void setFloat(const std::string &name, float value) const;  
};

#endif

W górnej części pliku nagłówkowego użyliśmy kilku dyrektyw preprocesora. Użycie tych kilku linijek kodu (ang. include guard) informuje kompilator, aby dołączył i skompilował ten plik nagłówkowy, o ile jeszcze to się nie stało. Zapobiega to konfliktom linkera.

Klasa Shader zawiera identyfikator Program Object. Jego konstruktor wymaga ścieżek do plików kodu źródłowego Vertex i Fragment Shader’a, które możemy przechowywać na dysku jako proste pliki tekstowe. Dodatkowo, dodajemy kilka funkcji użytkowych, które ułatwią nam życie: use aktywuje program cieniujący, a wszystkie funkcje set… pobierają lokalizację uniforma i ustawiają jego wartość.

Czytanie z pliku

Używamy strumienia plików C ++ do odczytywania zawartości pliku i zapisania jej do kilku obiektów typu string:

Shader(const char* vertexPath, const char* fragmentPath)  
{  
    // 1. pobierz kod źródłowy Vertex/Fragment Shadera z filePath  
    std::string vertexCode;  
    std::string fragmentCode;  
    std::ifstream vShaderFile;  
    std::ifstream fShaderFile;  
    // zapewnij by obiekt ifstream mógł rzucać wyjątkami  
    vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);  
    fShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);  
    try  
    {  
        // otwórz pliki  
        vShaderFile.open(vertexPath);  
        fShaderFile.open(fragmentPath);  
        std::stringstream vShaderStream, fShaderStream;  
        // zapisz zawartość bufora pliku do strumieni  
        vShaderStream << vShaderFile.rdbuf();  
        fShaderStream << fShaderFile.rdbuf();  
        // zamknij uchtywy do plików  
        vShaderFile.close();  
        fShaderFile.close();  
        // zamień strumień w łańcuch znaków  
        vertexCode = vShaderStream.str();  
        fragmentCode = fShaderStream.str();  
    }  
    catch(std::ifstream::failure e)  
    {  
        std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;  
    }  
    const char* vShaderCode = vertexCode.c_str();  
    const char* fShaderCode = fragmentCode.c_str();  
    [...]

Następnie musimy skompilować i zlinkować shadery. Warto zauważyć, że sprawdzamy także, czy kompilacja/linkowanie powiodła się. Jeśli nie, to wydrukujemy błędy podczas kompilacji, które są niezwykle użyteczne podczas debugowania (w końcu i tak będziesz potrzebował tych logów):

// 2. skompiluj shadery  
unsigned int vertex, fragment;  
int success;  
char infoLog[512];

// Vertex Shader  
vertex = glCreateShader(GL_VERTEX_SHADER);  
glShaderSource(vertex, 1, &vShaderCode, NULL);  
glCompileShader(vertex);  
// wypisz błędy kompilacji, jeśli są jakieś  
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);  
if(!success)  
{  
    glGetShaderInfoLog(vertex, 512, NULL, infoLog);  
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;  
};

// podobnie dla Fragment Shader'a  
[...]

// Program Object  
ID = glCreateProgram();  
glAttachShader(ID, vertex);  
glAttachShader(ID, fragment);  
glLinkProgram(ID);  
// wypisz błędy linkowania, jeśli są jakieś  
glGetProgramiv(ID, GL_LINK_STATUS, &success);  
if(!success)  
{  
    glGetProgramInfoLog(ID, 512, NULL, infoLog);  
    std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;  
}

// usuń obiekty shader'ów, które są już powiązane  
// z Program Object - nie będą nam już potrzebne  
glDeleteShader(vertex);  
glDeleteShader(fragment);

Funkcja use jest bardzo prosta:

void use()  
{  
    glUseProgram(ID);  
}

Podobnie jak funkcje set… dla uniformów:

void setBool(const std::string &name, bool value) const  
{  
glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value);  
}  
void setInt(const std::string &name, int value) const  
{  
glUniform1i(glGetUniformLocation(ID, name.c_str()), value);  
}  
void setFloat(const std::string &name, float value) const  
{  
glUniform1f(glGetUniformLocation(ID, name.c_str()), value);  
} 

I tak mamy ukończoną klasę Shader. Użycie tej klasy jest dość łatwe; tworzymy obiekt typu Shader raz i od tego momentu po prostu możemy go używać:

Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");  
...  
while(...)  
{  
    ourShader.use();  
    ourShader.setFloat("someUniform", 1.0f);  
    DrawStuff();  
}

Tutaj kod źródłowy VS i FS został zapisany w dwóch plikach o nazwach shader.vs i shader.frag. Możesz dowolnie nazwać pliki shaderów; dla mnie osobiście, rozszerzenia .vs i .frag są dość intuicyjne.

Kod źródłowy możesz znaleźć tutaj. Używa on, naszej nowo utworzonej klasy Shader. Zauważ, że możesz kliknąć ścieżki plików cieniujących, aby otworzyć ich kod źródłowy.

Ćwiczenia

  • Zmień VS tak, by trójkąt wyświetlał się do góry nogami: rozwiązanie.
  • Określ przesunięcie poziome za pomocą uniforma i przesuń trójkąt w prawą stronę ekranu. W VS skorzystaj z wartości przesunięcia podanej w uniformie: rozwiązanie.
  • Prześlij pozycje wierzchołków z VS do FS za pomocą słowa kluczowego out i ustaw koloru fragmentu, tak by był on równy przekazanej pozycji wierzchołka (zobacz jak pozycje wierzchołka są interpolowane na trójkącie). Gdy udało się to zrobić, spróbuj odpowiedzieć na następujące pytanie: dlaczego lewy dolny róg naszego trójkąta jest czarny? rozwiązanie.