OpenGL (część 3) – renderowanie trójkątów

Wszystkie obiekty generowane przez OpenGL znajdują się w przestrzeni 3D. Jednak ekran komputera jest płaski – reprezentuje przestrzeń 2D. Dlatego niemałą częścią OpenGL jest transformacja współrzędnych trójwymiarowych na dwuwymiarową matrycę komputera. Dokonuje się to poprzez potok graficzny (ang. graphics pipeline) OpenGL, który wykonuje dwa ważne zadania – najpierw przekształca współrzędne 3D na 2D, a następnie współrzędne 2D na piksele.

Potok graficzny można podzielić na kilka kroków, z których każdy wymaga danych wyjściowych z poprzedniego. Wszystkie te kroki są wysoce wyspecjalizowane (mają jedną określoną funkcję) i mogą być łatwo wykonywane równolegle. Ze względu na ich równoległy charakter, dzisiejsze karty graficzne mają tysiące małych rdzeni przetwarzających, które szybko przetwarzają dane w potoku graficznym, uruchamiając małe programy na GPU na każdym etapie rurociągu. Te małe programy nazywane są shaderami.

Niektóre z tych shaderów mogą być konfigurowane przez programistę, co pozwala nam pisać własne shadery, aby zastąpić istniejące domyślne shadery. Daje nam to znacznie dokładniejszą kontrolę nad określonymi częściami potoku, a ponieważ działają one na GPU, mogą również zaoszczędzić nam cennego czasu procesora. Shadery są napisane w języku OpenGL Shading Language (GLSL) i zajmiemy się tym bardziej w kolejnych odcinkach.

Potok graficzny zawiera dużą liczbę sekcji, z których każda obsługuje jedną konkretną część konwersji danych wierzchołków na w pełni renderowany piksel. Pokrótce wyjaśnimy każdą część potoku w uproszczony sposób, aby uzyskać dobry przegląd jego działania.

Jako dane wejściowe do potoku graficznego przechodzimy do listy trzech współrzędnych 3D, które powinny tworzyć trójkąt w tablicy zwanej tutaj danymi wierzchołków; dane o wierzchołkach to zbiór wierzchołków. Wierzchołek jest w zasadzie zbiorem danych na współrzędną 3D. Dane tego wierzchołka są reprezentowane za pomocą atrybutów wierzchołków, które mogą zawierać dowolne dane, które chcielibyśmy, ale dla uproszczenia załóżmy, że każdy wierzchołek składa się tylko z pozycji 3D i pewnej wartości koloru.

Jednak aby OpenGL wiedział, co zrobić z twoją kolekcją współrzędnych i wartości kolorów, OpenGL wymaga wskazania, jakie typy renderowania chcesz utworzyć za pomocą danych. Czy chcemy, aby dane były wyświetlane jako zbiór punktów, kolekcja trójkątów, a może tylko jedna długa linia? Te funkcje są nazywane prymitywami i są przekazywane do OpenGL podczas wywoływania dowolnego polecenia rysowania. Niektóre z tych funkcji to: GL_POINTS, GL_TRIANGLES i GL_LINE_STRIP.

Wprowadzanie wierzchołków
Aby zacząć rysować coś, musimy najpierw dać OpenGL dane wejściowe wierzchołków. OpenGL jest biblioteką grafiki 3D, więc wszystkie współrzędne określone w OpenGL są w 3D (współrzędne x, y i z). OpenGL nie przekształca wszystkich współrzędnych 3D na piksele 2D na ekranie; OpenGL przetwarza współrzędne 3D tylko wtedy, gdy znajdują się w określonym zakresie między -1.0 a 1.0 na wszystkich 3 osiach (x, y i z). Wszystkie współrzędne w tym tak zwanym znormalizowanym zakresie współrzędnych urządzenia będą widoczne na ekranie (a wszystkie współrzędne poza tym regionem nie będą).

Ponieważ chcemy renderować pojedynczy trójkąt, chcemy określić łącznie trzy wierzchołki, z których każdy znajduje się w przrestrzeni. Definiujemy je w znormalizowanych współrzędnych urządzenia (widoczny obszar OpenGL) w tablicy typu float:

float vertices[] = {
    -1.0f, -1.0f, 0.0f,
     1.0f, -1.0f, 0.0f,
     0.0f,  1.0f, 0.0f
};  

Ponieważ OpenGL działa w przestrzeni 3D, renderujemy trójkąt 2D z każdym wierzchołkiem o współrzędnej Z równej 0,0. W ten sposób głębokość trójkąta pozostaje taka sama, dzięki czemu wygląda jak 2D.

Z zdefiniowanymi danymi wierzchołków chcielibyśmy wysłać je jako dane wejściowe do pierwszego procesu potoku graficznego: modułu cieniującego wierzchołków. Odbywa się to poprzez tworzenie pamięci na GPU, w której przechowujemy dane wierzchołków, konfigurowanie sposobu interpretacji pamięci przez OpenGL i określanie sposobu wysyłania danych do karty graficznej. Następnie moduł cieniujący wierzchołków przetwarza z wierzchołka tyle wierzchołków, o których mówimy.

Zarządzamy tą pamięcią za pomocą tzw. Obiektów bufora wierzchołków (VBO), które mogą przechowywać dużą liczbę wierzchołków w pamięci GPU. Zaletą korzystania z tych obiektów buforowych jest to, że możemy wysyłać duże partie danych jednocześnie do karty graficznej bez konieczności wysyłania danych wierzchołkiem w czasie. Wysyłanie danych do karty graficznej z procesora jest stosunkowo powolne, więc gdziekolwiek możemy, próbujemy wysłać jak najwięcej danych jednocześnie. Gdy dane znajdą się w pamięci karty graficznej, moduł cieniowania wierzchołków ma niemal natychmiastowy dostęp do wierzchołków, co czyni go niezwykle szybkim.

Obiekt bufora wierzchołków jest naszym pierwszym wystąpieniem obiektu OpenGL, jak to omówiliśmy w samouczku OpenGL. Tak jak każdy obiekt w OpenGL, ten bufor ma unikalny identyfikator odpowiadający temu buforowi, więc możemy wygenerować jeden z identyfikatorem bufora za pomocą funkcji glGenBuffers:

unsigned int VBO;
glGenBuffers(1, &VBO); 

OpenGL ma wiele typów obiektów buforowych, a typ bufora obiektu bufora wierzchołków to GL_ARRAY_BUFFER. OpenGL pozwala nam powiązać kilka buforów jednocześnie, o ile mają inny typ bufora. Możemy powiązać nowo utworzony bufor z celem GL_ARRAY_BUFFER za pomocą funkcji glBindBuffer:

glBindBuffer(GL_ARRAY_BUFFER, VBO);

Od tego momentu wszelkie wywołania buforów, które wykonamy (w celu GL_ARRAY_BUFFER), zostaną użyte do skonfigurowania aktualnie powiązanego bufora, którym jest VBO. Następnie możemy wywołać funkcję glBufferData, która kopiuje wcześniej zdefiniowane dane wierzchołków do pamięci bufora:

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

Funkcja glBufferData służy do kopiowania danych zdefiniowanych przez użytkownika do aktualnie powiązanego bufora. Jego pierwszym argumentem jest typ bufora, do którego chcemy skopiować dane: obiekt bufora wierzchołków aktualnie powiązany z celem GL_ARRAY_BUFFER. Drugi argument określa rozmiar danych (w bajtach), które chcemy przekazać do bufora; wystarczy prosty rozmiar danych wierzchołków. Trzeci parametr to rzeczywiste dane, które chcemy wysłać.

Czwarty parametr określa, w jaki sposób karta graficzna ma zarządzać danymi. Może to przyjąć 3 formy:

GL_STATIC_DRAW: dane najprawdopodobniej nie zmienią się wcale lub bardzo rzadko.
GL_DYNAMIC_DRAW: dane mogą się znacznie zmienić.
GL_STREAM_DRAW: dane będą się zmieniać za każdym razem, gdy zostaną narysowane.


Dane pozycji trójkąta nie zmieniają się i pozostają takie same dla każdego wywołania renderowania, więc jego typ użycia powinien być najlepiej GL_STATIC_DRAW. Na przykład, gdybyśmy mieli bufor z danymi, które mogą się często zmieniać, typ użycia GL_DYNAMIC_DRAW lub GL_STREAM_DRAW zapewnia, że ​​karta graficzna umieści dane w pamięci, co pozwoli na szybsze zapisywanie.

Cieniowanie wierzchołków (Vertex Shader)

Vertex Shader jest jednym z shaderów programowalnych przez ludzi takich jak my. Współczesny OpenGL wymaga przynajmniej skonfigurowania modułu cieniującego wierzchołków i fragmentów, jeśli chcemy wykonać renderowanie, więc krótko przedstawimy shadery i skonfigurujemy dwa bardzo proste shadery do rysowania naszego pierwszego trójkąta. W następnym samouczku omówimy bardziej szczegółowo shadery.

Pierwszą rzeczą, którą musimy zrobić, jest napisanie modułu cieniującego wierzchołków w języku shader GLSL (OpenGL Shading Language), a następnie skompilowanie tego modułu cieniującego, abyśmy mogli go użyć w naszej aplikacji. Poniżej znajdziesz kod źródłowy bardzo podstawowego modułu cieniującego wierzchołków w GLSL:

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

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

Jak widać, GLSL wygląda podobnie do C. Każdy shader zaczyna się od deklaracji jego wersji. Ponieważ OpenGL 3.3 i wyższe numery wersji GLSL pasują do wersji OpenGL (na przykład wersja GLSL 420 odpowiada wersji OpenGL 4.2). Mówimy też wyraźnie, że używamy funkcji profilu podstawowego.

Następnie deklarujemy wszystkie atrybuty wierzchołków wejściowych w cieniującym wierzchołku ze słowem kluczowym in. W tej chwili zależy nam tylko na danych pozycji, więc potrzebujemy tylko jednego atrybutu wierzchołka. GLSL ma typ danych wektorowych, który zawiera 1 do 4 pływaków w oparciu o jego cyfrę postfix. Ponieważ każdy wierzchołek ma współrzędną 3D, tworzymy zmienną wejściową vec3 o nazwie aPos. Określamy również położenie zmiennej wejściowej za pomocą układu (lokalizacja = 0), a później zobaczysz, dlaczego będziemy potrzebować tej lokalizacji.

Aby ustawić wyjście vertex shadera, musimy przypisać dane pozycji do predefiniowanej zmiennej gl_Position, która jest vec4 za kulisami. Na końcu funkcji głównej, cokolwiek ustawimy gl_Position na, zostanie użyte jako wyjście modułu cieniującego wierzchołków. Ponieważ nasze dane wejściowe są wektorem wielkości 3, musimy rzucić je do wektora o rozmiarze 4. Możemy to zrobić, wstawiając wartości vec3 wewnątrz konstruktora vec4 i ustawiając jego komponent w na 1.0f (wyjaśnimy dlaczego w późniejszy samouczek).

Kompilowanie shadera

Napisaliśmy kod źródłowy modułu cieniującego wierzchołków (przechowywanego w ciągu znaków C na górze pliku kodu), ale aby OpenGL używał modułu cieniującego, musi dynamicznie kompilować go w czasie wykonywania z kodu źródłowego.

Pierwszą rzeczą, którą musimy zrobić, jest utworzenie obiektu modułu cieniującego, do którego ponownie odwołuje się identyfikator. Przechowujemy więc moduł cieniujący jako niepodpisany int i tworzymy shader za pomocą glCreateShader:

unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

Podajemy typ modułu cieniującego, który chcemy utworzyć jako argument dla glCreateShader. Ponieważ tworzymy moduł cieniujący wierzchołka, przekazujemy go w GL_VERTEX_SHADER.

Następnie dołączamy kod źródłowy modułu cieniującego do obiektu modułu cieniującego i kompilujemy moduł cieniujący:

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

Funkcja glShaderSource przenosi obiekt shadera do kompilacji jako pierwszy argument. Drugi argument określa, ile łańcuchów przekazujemy jako kod źródłowy, czyli tylko jeden. Trzeci parametr to rzeczywisty kod źródłowy modułu cieniującego wierzchołków i możemy zostawić czwarty parametr na NULL.

Jeśli podczas kompilowania modułu cieniującego wierzchołków nie wykryto błędów, jest on teraz kompilowany.

Fragment shadera
Fragment shader to drugi i ostatni shader, który stworzymy do renderowania trójkąta. Shader fragmentu polega na obliczaniu koloru wyjściowego pikseli. Aby zachować prostotę, shader fragmentu będzie zawsze wyświetlał czerwony kolor.

Shader fragmentu wymaga tylko jednej zmiennej wyjściowej i jest to wektor o rozmiarze 4, który określa ostateczny wynik koloru, który powinniśmy sami obliczyć. Możemy zadeklarować wartości wyjściowe za pomocą słowa kluczowego out, które natychmiast nazywamy FragColor. Następnie po prostu przypisujemy vec4 do wyjścia koloru jako pomarańczowy kolor o wartości alfa 1,0 (1,0 jest całkowicie nieprzezroczysty).

#version 330 core
out vec4 FragColor;

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

Proces kompilowania cieniowania fragmentów jest podobny do cieniowania wierzchołków, chociaż tym razem używamy stałej GL_FRAGMENT_SHADER jako typu modułu cieniującego:

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

Program Shader

Obiekt programu cieniującego to połączona ostateczna wersja wielu shaderów. Aby użyć niedawno skompilowanych shaderów, musimy połączyć je z obiektem programu shader, a następnie aktywować ten program cieniujący podczas renderowania obiektów. Aktywne shadery programu cieniującego zostaną użyte, gdy wywołamy wywołania renderowania.

Podczas łączenia shaderów z programem łączy wyjścia każdego modułu cieniującego z wejściami następnego modułu cieniującego. Tutaj również otrzymasz błędy łączenia, jeśli twoje wyjścia i wejścia nie są zgodne.

Tworzenie obiektu programu jest łatwe:

unsigned int shaderProgram;
shaderProgram = glCreateProgram();

Funkcja glCreateProgram tworzy program i zwraca odwołanie do identyfikatora nowo utworzonego obiektu programu. Teraz musimy dołączyć wcześniej skompilowane shadery do obiektu programu, a następnie połączyć je z glLinkProgram:

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

Łączenie atrybutów wierzchołków

Moduł cieniujący wierzchołków pozwala nam określić dowolne dane wejściowe w postaci atrybutów wierzchołków i chociaż pozwala to na dużą elastyczność, oznacza to, że musimy ręcznie określić, jaka część naszych danych wejściowych idzie do którego atrybutu wierzchołka w cieniującym wierzchołku. Oznacza to, że musimy określić, jak OpenGL powinien interpretować dane wierzchołków przed renderowaniem.

Teraz, kiedy określiliśmy, w jaki sposób OpenGL ma interpretować dane wierzchołków, powinniśmy także włączyć atrybut wierzchołka z glEnableVertexAttribArray, podając jako argument jego położenie 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 obiektu bufora wierzchołków, ustawiliśmy moduł cieniujący wierzchołków i fragmentów i powiedzieliśmy OpenGL, jak połączyć dane wierzchołków z atrybutami wierzchołków shadera wierzchołków. Rysowanie obiektu w OpenGL wyglądałoby teraz mniej więcej tak:

// 1 - kopiuj tablicę wierzchołków do bufora
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 2 - teraz ustaw atrybuty wskaźników wierzcholkow
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  
// 3 - uzyj shadera do renderowania 
glUseProgram(shaderProgram);
// 4 - rysuj obiekt 
someOpenGLFunctionThatDrawsOurTriangle(); 

Musimy powtarzać ten proces za każdym razem, gdy chcemy narysować obiekt. Może nie wygląda to tak bardzo, ale wyobraź sobie, że mamy ponad 5 atrybutów wierzchołków i może 100 setek różnych obiektów (co nie jest rzadkością). Powiązanie odpowiednich obiektów bufora i skonfigurowanie wszystkich atrybutów wierzchołków dla każdego z tych obiektów szybko staje się uciążliwym procesem. Co się stanie, jeśli w jakiś sposób będziemy mogli przechowywać wszystkie te konfiguracje stanów w obiekcie i po prostu związać ten obiekt, aby przywrócić jego stan?

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

Aby narysować nasze wybrane obiekty, OpenGL zapewnia nam funkcję glDrawArrays, która rysuje prymitywy przy użyciu aktualnie aktywnego modułu cieniującego, poprzednio zdefiniowanej konfiguracji atrybutów wierzchołków i danych wierzchołków VBO (pośrednio związanych przez VAO).

glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
Efekt działania programu
#include <glad/glad.h>
#include <GLFW/glfw3.h>

#include <iostream>

void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void processInput(GLFWwindow *window);

// wymiary okna
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;

const char *vertexShaderSource = "#version 330 core\n"
    "layout (location = 0) in vec3 aPos;\n"
    "void main()\n"
    "{\n"
    "   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
    "}\0";
const char *fragmentShaderSource = "#version 330 core\n"
    "out vec4 FragColor;\n"
    "void main()\n"
    "{\n"
    "   FragColor = vec4(1.0f, 0.0f, 0.0f, 1.0f);\n"
    "}\n\0";

int main(){
    
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

    // inicjalizacja okna
    GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "Pierwszy trójkąt", NULL, NULL);
    if (window == NULL){
        std::cout << "Failed to create GLFW window" << std::endl;
        glfwTerminate();
        return -1;
    }
    glfwMakeContextCurrent(window);
    glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

    // glad: load all OpenGL function pointers
    // ---------------------------------------
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        std::cout << "Failed to initialize GLAD" << std::endl;
        return -1;
    }


    // build and compile our shader program
    // ------------------------------------
    // vertex shader
    int vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
    glCompileShader(vertexShader);
    // check for shader compile errors
    int success;
    char infoLog[512];
    glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
        std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
    }
    // fragment shader
    int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);
    // check for shader compile errors
    glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
        std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
    }
    // link shaders
    int shaderProgram = glCreateProgram();
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    glLinkProgram(shaderProgram);
    // check for linking errors
    glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
    if (!success) {
        glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
        std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
    }
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);

    // set up vertex data (and buffer(s)) and configure vertex attributes
    // ------------------------------------------------------------------
    float vertices[] = {
        -1.0f, -1.0f, 0.0f, // left
         1.0f, -1.0f, 0.0f, // right
         0.0f,  1.0f, 0.0f  // top
    }; 

    unsigned int VBO, VAO;
    glGenVertexArrays(1, &VAO);
    glGenBuffers(1, &VBO);
    // bind the Vertex Array Object first, then bind and set vertex buffer(s), and then configure vertex attributes(s).
    glBindVertexArray(VAO);

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

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    // note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute's bound vertex buffer object so afterwards we can safely unbind
    glBindBuffer(GL_ARRAY_BUFFER, 0); 

    // You can unbind the VAO afterwards so other VAO calls won't accidentally modify this VAO, but this rarely happens. Modifying other
    // VAOs requires a call to glBindVertexArray anyways so we generally don't unbind VAOs (nor VBOs) when it's not directly necessary.
    glBindVertexArray(0); 


    // uncomment this call to draw in wireframe polygons.
    //glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);

    // render loop
    // -----------
    while (!glfwWindowShouldClose(window))
    {
        // input
        // -----
        processInput(window);

        // render
        // ------
        glClearColor(0.5f, 0.5f, 0.5f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        // draw our first triangle
        glUseProgram(shaderProgram);
        glBindVertexArray(VAO); // seeing as we only have a single VAO there's no need to bind it every time, but we'll do so to keep things a bit more organized
        glDrawArrays(GL_TRIANGLES, 0, 3);
        // glBindVertexArray(0); // no need to unbind it every time 
 
        // glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.)
        // -------------------------------------------------------------------------------
        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    // optional: de-allocate all resources once they've outlived their purpose:
    // ------------------------------------------------------------------------
    glDeleteVertexArrays(1, &VAO);
    glDeleteBuffers(1, &VBO);

    // glfw: terminate, clearing all previously allocated GLFW resources.
    // ------------------------------------------------------------------
    glfwTerminate();
    return 0;
}

// process all input: query GLFW whether relevant keys are pressed/released this frame and react accordingly
// ---------------------------------------------------------------------------------------------------------
void processInput(GLFWwindow *window)
{
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);
}

// glfw: whenever the window size changed (by OS or user resize) this callback function executes
// ---------------------------------------------------------------------------------------------
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
    // make sure the viewport matches the new window dimensions; note that width and 
    // height will be significantly larger than specified on retina displays.
    glViewport(0, 0, width, height);
}

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *