Wstęp

W tej części kursu OpenGL nauczymy się jak napisać prosty shader, który narysuje nam trójkąt w takim kolorze, w jakim tylko będziemy chcieli! Od tej części kursu nie będę umieszczał całych listingów kodu (z powodu na zbyt dużą objętość) tylko będę od razu przechodził do części, w której będę analizował kod. Tam też zamieszczę najważniejsze linijki lub fragmenty kodu, które zmieniły się od poprzedniej części. Kod do tej części można pobrać tutaj. Dodatkowo również od tej części kursu wszystko co bedziemy robić w OpenGL będzie korzystało z shader’ów jako z nowoczesnej metody programowania grafiki 3D. No to zaczynamy!

Wyjaśnienie kodu aplikacji

Od poprzedniej części kursu (Tutorial 03) w kodzie zmieniło się kilka rzeczy:

  • dodana została funkcja loadShader(std::string),
  • dodana została funkcja loadAndCompileShaderFromFile(GLint, std::string, GLuint&),
  • została zmieniona funkcja init() oraz render()
  • został dodany do projektu shader (właściwe to dwa shader’y - vertex i fragment), które zostały umieszczone w folderze Shaders.

Zacznijmy od początku (wyjaśnieniem shader’ów zajmę się na końcu). Funkcji loadShader(std::string) nie będę tłumaczyć, ponieważ jest ona związana ściśle z językiem C++ i zakładam, że wszyscy korzystający z tego kursu znają ten język w takim stopniu, by rozumieć wszystko to, co w tej funkcji jest zawarte. Służy ona po prostu do wczytania kodu shader’a do pamięci komputera i do przechowania go w zmiennej typu std::string.

Zanim przejdziemy do tłumaczenia funkcji loadAndCompileShaderFromFile(GLint, std::string, GLuint&), przejdźmy na chwilę do funkcji init(), w której zaszły pewne zmiany dotyczące shader’ów. Pierwszą nowością jest poniższa linijka:

/* Set clear color */  
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);  

Zapisuje ona w maszynie stanów OpenGL’a, kolor, którym ma być czyszczony bufor koloru. Bufor koloru musi być czyszczony przy każdym renderowaniu ramki dlatego w funkcji render() wywoływana jest instrukcja:

/* Clear the color buffer */  
glClear(GL_COLOR_BUFFER_BIT);  

Funkcja void glClearColor(GLfloat red, GLfloat green, GLfloat blue, GLfloat alpha) przyjmuje cztery parametry typu GLfloat i są to kolejne składowe RGBA - kolory czerowny, zielony, niebieski, alfa (odpowiada za przezroczystość). Te parametry przyjmują wartości z przedziału [0, 1] i kiedy podamy wartość z poza tego przedziału zostanie ona “przycięta”, by wpasować się w ten przedział (jeżeli podamy wartość 10.0f, zostanie ona zamieniona na wartość 1.0f, a gdy podamy wartość -8.0f, to zostanie ona zamieniona na wartość 0.0f).

Dzięki funkcji void glClearColor(GLfloat red, GLfloat green, GLfloat blue, GLfloat alpha) możemy kontrolować kolor “tła” naszej wirtualnej sceny. Dzieje się tak dlatego, że najpierw czyszczony jest bufor koloru na domyślny kolor (zdefiniowany za pomocą ww. funkcji), a na to jest nakładana nasza geometria, która może mieć zupełnie inny kolor/kolory.

Teraz możemy przejść do części związanej tylko i wyłącznie z shader’ami.

/* Shader init */  
GLuint programHandle = glCreateProgram();

if(programHandle == 0)  
{  
    fprintf(stderr, "Error creating program object.\n");  
}  

Na początku tworzymy uchwyt do “programu” za pomocą funkcji glCreateProgram(), do którego “podepniemy” shader’y. Jeżeli zwróci wartość zero, to znaczy, że coś się popsuło podczas tworzenia tego obiektu - nie zobaczymy nic na ekranie, ponieważ nasze shader’y nie będą działać. Kiedy jest to wartość różna od zera, to wszystko jest w porządku.

/* Shader load from file and compile */  
loadAndCompileShaderFromFile(GL_VERTEX_SHADER, "Shaders/basic.vert", programHandle);  
loadAndCompileShaderFromFile(GL_FRAGMENT_SHADER, "Shaders/basic.frag", programHandle);  

Dwa razy wywołana zostaje funkcja loadAndCompileShaderFromFile(GLint, std::string, GLuint&). Raz dla vertex shader’a i drugi raz dla fragment shader’a. Z poprzedniej lekcji wiemy, że jest to niezbędne minimum, jeżeli chcemy korzystać z możliwości oferowanych przez programy cieniujące. Ta funkcja korzysta z funkcji loadShader(std::string) do wczytania kodu shader’a z pliku (można kod trzymać również w tablicy char*, ale nie jest to wygodne jeżeli mamy do czynienia z shader’ami, które mają dużo linijek kodu oraz kiedy chcemy taki shader debugować).

GLuint shaderObject = glCreateShader(shaderType);

if(shaderObject == 0)  
{  
    fprintf(stderr, "Error creating %s.\n", fileName.c_str());  
    return;  
}  

Pierwszym zadaniem funkcji loadAndCompileShaderFromFile(…) jest stworzenie obiektu shader’a za pomocą funkcji glCreateShader(GLuint type). Argumentem tej funkcji jest typ shader’a jaki mamy zamiar kompilować. Może to być: GL_VERTEX_SHADER, GL_FRAGMENT_SHADER, GL_GEOMETRY_SHADER, GL_TESS_EVALUATION_SHADER lub GL_TESS_CONTROL_SHADER, GL_COMPUTE_SHADER. Obiekt shader’a przechowujemy w lokalnej zmiennej shaderObject, by sprawdzić czy udało się taki obiekt stworzyć - wszystko będzie w porządku jeżeli będzie to wartość różna od zera, w przeciwnym wypadku coś poszło nie tak i zostanie wyświetlony odpowiedni komunikat w konsoli.

std::string shaderCodeString = loadShader(fileName);

if(shaderCodeString.empty())  
{  
    printf("Shader code is empty! Shader name %s\n", fileName.c_str());  
    return;  
}

const char * shaderCode = shaderCodeString.c_str();  
const GLint codeSize = shaderCodeString.size();

glShaderSource(shaderObject, 1, &shaderCode, &codeSize);  

Następnie wczytywany jest kod shader’a, ze ścieżki podanej w argumencie fileName i zapisany do zmiennej shaderCode. Teraz trzeba ten kod załadować do obiektu shader’a. Do tego celu służy funkcja glShaderSource(…). Pierwszym argumentem jest obiekt shader’a, który chcemy stworzyć. Drugim parametrem jest liczba kodów, które chcemy skompilować (kompilujemy jeden kod na raz - stąd liczba 1). Trzeci argument to tablica łańcuchów znaków, która zawiera kody shader’ów. Nasza zawiera tylko jeden kod. Czwarty parametr jest to tablica, która zawiera długości łańcuchów znaków z trzeciego argumentu. Teraz kod shader’a został skopiowany do pamięci wewnętrznej OpenGL’a.

glCompileShader(shaderObject);

GLint result;  
glGetShaderiv(shaderObject, GL_COMPILE_STATUS, &result);

if(result == GL_FALSE)  
{  
    fprintf(stderr, "%s compilation failed!\n", fileName.c_str());

    GLint logLen;  
    glGetShaderiv(shaderObject, GL_INFO_LOG_LENGTH, &logLen);

    if(logLen > 0)  
    {  
        char * log = (char *)malloc(logLen);

        GLsizei written;  
        glGetShaderInfoLog(shaderObject, logLen, &written, log);

        fprintf(stderr, "Shader log: \n%s", log);  
        free(log);  
    }

    return;  
}  

Teraz możemy skompilować kod źródłowy naszego shader’a. W tym celu wywołujemy po prostu funkcję glCompileShader(GLuint) przekazując jako parametr obiekt shader’a, który ma zostać skompilowany. Proces kompilacji może się nie powieść dlatego kolejnym krokiem jest sprawdzenie poprawności kompilacji i w razie czego wyświetleniu log’a, który poinformuje nas, w którym miejscu w shader’ze popełniliśmy błąd. Do tego służy funkcja glGetShaderiv(…), która służy do pobierania różnych informacji o shader’ze. Póki co interesuje nas status kompilacji dlatego podajemy jako drugi argument wartość GL_COMPILE_STATUS. Pierwszym obiektem jest oczywiście obiekt shader’a, dla którego chcemy uzyskać daną informację, a trzecim parametrem jest zmienna, do której ma być zapisany status kompilacji (będzie w niej wartość GL_TRUE lub GL_FALSE zależnie od tego czy proces się powiódł czy nie). Jeżeli kompilacja się nie powiodła to wyświetlamy stosowną informację w konsoli i następnie wyświetlamy log informujący nas gdzie dokładnie popełniliśmy błąd.

glAttachShader(programHandle, shaderObject);  
glDeleteShader(shaderObject);  

Kolejnym krokiem jest podpięcie obiektu shader’a do “programu”, w którym będą przechowywane shader’y, które mają ze sobą współpracować. Kiedy podpięliśmy obiekt shader’a do programu, możemy go spokojnie skasować, by zwolnić pamięć.

/* Link */  
glLinkProgram(programHandle);  

Wracamy teraz do funkcji init(). Po udanej kompilacji shader’ów musimy poddać je procesowi linkowania (dokładniej linkujemy program shader’a). Ta operacja jest ważna z tego względu, że tworzone są połączenia między shader’ami - wyjście jednego shader’a jest łączone z odpowiednim wejściem drugiego, by możliwa była komunikacja i przesyłanie danych między nimi. Dodatkowo tworzone są połączenia między odpowiednimi wejściami/wyjściami shader’a z odpowiednimi lokacjami w środowisku OpenGL. Tak samo jak przy kompilacji, linkowanie może się nie powieść, dlatego sprawdzamy w sposób praktycznie identyczny status linkowania programu, z tym, że wykorzystywana jest funkcja glGetProgramiv(…).

/* Apply shader */  
glUseProgram(programHandle);  

Kiedy w naszym programie shader’y zostały poprawnie zlinkowane, możemy powiedzieć OpenGL’owi, że chcemy korzystać z danego zestawu shader’ów (programu), by rysował i kolorował obiekty tak jak zostało to zdefiniowane w kodzie shader’ów.

Wyjaśnienie kodu shader’ów

Na początek przyjrzymy się vertex shader’owi:

#version 400

layout (location = 0) in vec3 vertexPosition;

void main()  
{  
    gl_Position = vec4(vertexPosition.x, vertexPosition.y, vertexPosition.z, 1.0f);

    // Alternatively we can write:  
    // gl_Position = vec4(vertexPosition, 1.0f);  
    // and the effect will be exactly the same  
}  

Z poprzedniej części wiemy, że vertex shader przetwarza jeden wierzchołek na raz. Język GLSL (OpenGL Shading Language) jest zbliżony do języka C++ dlatego nie jest on taki straszny do przyswojenia i nauczenia się.

#version 440  

Jest to dyrektywa preprocesora, która mówi o tym, z jakiej wersji GLSL’a mamy zamiar korzystać (lub jest napisany dany shader). W tym wypadku jest to wersja 4.4 z lipca 2013.

layout (location = 0) in vec3 vertexPosition;  

Za pomocą kwalifikatora wejścia layout definiujemy pod jakim indeksem, shader ma “szukać” wektora wierzchołków, pod który wcześniej wysłaliśmy dane na temat pozycji wierzchołków. W Tutorialu 03 “włączaliśmy” tą lokalizację za pomocą funkcji glEnableVertexAttribArray(0), a za pomocą funkcji glVertexAttribPointer() mówiliśmy OpenGL’owi, pod jaki indeks ma wysłać dane wierzchołków. Takich atrybutów może być kilka i mogą to być: wartości koloru danego wierzchołka, koordynaty tekstury dla danego wierzchołka lub wektor normalny dla danego wierzchołka.

void main()  
{  
    gl_Position = vec4(vertexPosition.x, vertexPosition.y, vertexPosition.z, 1.0f);

    // Alternatively we can write:  
    // gl_Position = vec4(vertexPosition, 1.0f);  
    // and the effect will be exactly the same  
}  

W każdym shader’ze musi być zdefiniowana funkcja main(), która jest główną funkcją każdego programu cieniującego (oprócz niej mogą być zdefiniowane inne funkcje pomocnicze). Do wbudowanej zmiennej wyjściowej vertex shader’a gl_Position przypisujemy odpowiednie wartości z atrybutu wejściowego vertexPosition, by trójkąt miał takie współrzędne pozycji jakie zdefiniowaliśmy w programie. Zmienna gl_Position jest strukturą typu vec4, która reprezentuje wektor 4-wymiarowy. Zauważmy, żeby odwoływać się do kolejnych elementów z wektora 3-wymiarowego vertexPosition możemy korzystać z operatora “.” (kropka), tak jak w klasach lub strukturach C++.

By ułatwić sobie życie i skrócić męki pisania kodu, możemy skorzystać z dogodności GLSL’a i skorzystać z konstruktora vec4(vec3, float).

Zauważmy, że pod wartość w (ostatnia wartość w konstruktorze vec4) jest podawana wartość 1.0f.

Warto zapamiętać, że:

  • Jeżeli w == 1, to wektor v(x, y, z, 1) jest pozycją w przestrzeni.
  • Jeżeli w == 0, to wektor v(x, y, z, 0) jest kierunkiem w przestrzeni.

Jest to ważne w przypadku translacji. W przestrzeni możemy przesunąć punkt, ale czy możliwe jest przesunięcie kierunku? Raczej nie :)

#version 400

out vec4 fragColor;

void main()  
{  
    fragColor = vec4(0.0f, 0.0f, 0.0f, 1.0f);  
}  

W przypadku fragment shader’a wszystko wygląda podobnie jak przy vertex shader’ze z tą różnicą, że definiujemy własną zmienną wyjściową typu vec4, dla koloru fragmentu (stąd kwalifikator out). Do zmiennej fragColor przypisujemy kolor RGBA (czerwony, zielony, niebieski, alfa), którego kolejne komponenty przyjmują wartości z zakresu [0.0f, 1.0f]. W tym wypadku trójkąt będzie pokolorowany na czarno. Gdybyśmy chcieli zmienić kolor na czerwony wystarczy pierwszy komponent zamienić na wartość 1.0f.

Efekt całości powinien być następujący:

Czarny trójkąt

Zakończenie

To już wszystko na dzisiaj. W razie gdyby było coś nie jasne z dzisiejszej lekcji, proszę pisać do mnie na maila, bądź w komentarzach poniżej. W kolejnej lekcji przyjrzymy się zagadnieniu interpolacji kolorów, którą OpenGL robi automatycznie :) .

Kod źródłowy

Ćwiczenia

  1. Zmień kolor tła na niebieski.
  2. Zmień kolor trójkąta/kwadratu na zielony.

Dodatkowe źródła

  1. Więcej informacji w dokumentacji OpenGL
  2. Więcej informacji w dokumentacji GLSL