learnopengl. Урок 1.4 — Hello Triangle

У минулому уроці ми таки подужали відкриття вікна і примітивний користувальницький введення. У цьому уроці ми розглянемо всі ази виведення вершин на екран і скористаємося усіма можливостями OpenGL, начебто VAO, VBO, EBO для того, щоб вивести пару трикутників.
Зацікавлених прошу під кат.

Меню
1. Починаємо

  1. OpenGL
  2. Створення вікна
  3. Hello Window
  4. Hello Triangle


В OpenGL все знаходиться в 3D просторі, але при цьому екран і вікно — це 2D матрицю з пікселів. Тому велика частина роботи OpenGL — це перетворення 3D координат в 2D простір для відтворення на екрані. Процес перетворення 3D координат в 2D координати управляється графічним конвеєром OpenGL. Графічний конвеєр можна розділити на 2 великі частини: перша частина перетворює 3D координати в 2D координати, а друга частина перетворює 2D координати кольорові пікселі. У цьому уроці ми детально обговоримо графічний конвеєр і то як ми можемо його використовувати в плюс для створення красивих пікселів.
Є різниця між 2D координатами і пікселем. 2D координата — це дуже точне уявлення точки в 2D просторі, в той час коли 2D піксель — це приблизне розташування в межах вашого екрану/вікна.
Графічний конвеєр приймає набір 3D координат і перетворює їх в кольорові 2D пікселі на екрані. Цей графічний контейнер можна розділити на кілька етапів, де кожен етап — вимагає на вхід результат роботи минулого. Всі ці етапи вкрай спеціалізовані і можуть з легкістю виконуватися паралельно. З причини їх паралельної природи більшість сучасних GPU мають тисячі маленьких процесорів для швидкої обробки даних графічного конвеєра з допомогою запуску великої кількості маленьких програм на кожному етапі конвеєра. Ці маленькі програми називаються шейдерами.

Деякі з цих шейдерів можуть налаштовуватися розробником, що дозволяє нам писати власні шейдери для заміни стандартних. Це дає нам набагато більше можливостей тонкої настройки специфічних місць конвеєра, і саме через те, що вони працюють на GPU, дозволяє нам зберегти процесорний час. Шейдери пишуться на OpenGL Shading Language (GLSL) і ми поглибимося у нього в наступному уроці.

На зображенні нижче можна побачити приблизне уявлення всіх етапів графічного конвеєра. Сині частини описують етапи для яких ми можемо специфікувати власні шейдери.



Як ви бачите графічний конвеєр містить велику кількість секцій, де кожен займається своєю частиною обробки верхових даних повністю отрисованный піксель. Ми трохи опишемо кожну секцію конвеєра у спрощеному вигляді, щоб дати вам гарне уявлення про те, як працює конвеєр.

На вхід конвеєра передається масив 3D координат з яких можна сформувати трикутники, званого даними про вершинах; вершинні дані — це набір вершин. Вершина — це набір даних поверх 3D координати. Ці дані представляються використовуючи атрибути вершини, які можуть містити будь-які дані, але для спрощення будемо вважати, що вершина складається з 3D позиції і значення кольору.
Оскільки OpenGL хоче знати що скласти з переданої йому колекції координат і значень кольору, OpenGL вимагає вказати яку фігуру ви хочете сформувати з даних. Хочемо ми промалювати набір точок, набір трикутників або просто одну довгу лінію? Такі фігури називаються примітивами і передаються OpenGL під час виклику команд відтворення. Ось деякі з примітивів: GL_POINTS, GL_TRIANGLES та GL_LINE_STRIP.
Перший етап конвеєра — це вершинний шейдер, який приймає на вхід одну вершину. Основне завдання вершинного шейдерів — це перетворення 3D координат в інші 3D координати (про це трохи пізніше) і той факт, що ми маємо можливість зміни цього шейдера дозволяє нам виконувати деякі основні перетворення над значеннями вершини.

Збірка примітивів — це етап, що приймає на вхід всі вершини (або одну вершину, якщо обраний примітив GL_POINTS) з вершинного шейдера, які формують примітив і збирає з них сам примітив; у нашому випадку це буде трикутник.

Результат етапу зборки примітивів передається геометричному шейдеру. Він же в свою чергу на вхід приймає набір вершин, що формують примітиви і можуть генерувати інші фігури за допомогою генерації нових вершин для формування нових (або інших) примітивів. Наприклад в нашому випадку він згенерує другий трикутник крім даної фігури.

Результат роботи геометричного шейдера передається на етап растеризації, де результуючі примітиви будуть співвідноситися з пікселями на екрані, формуючи фрагмент для фрагментного шейдера. Перед тим як запуститься фрагментний шейдер, виконується вирізка. Вона відкидає всі фрагменти, які знаходяться поза полем зору, підвищуючи таким чином продуктивність.
Фрагмент в OpenGL — це всі дані, які потрібні OpenGL для того, щоб промалювати піксель.
Основна мета фрагментного шейдерів — це обчислення кінцевого кольору пікселя, а також це, найчастіше, етап, коли виконуються всі додаткові ефекти OpenGL. Найчастіше фрагментний шейдер містить всю інформацію про 3D сцені, яку можна використовувати для модифікації фінального кольору (типу освітлення, тіні, кольору джерела світла і т. д.).

Після того, як визначення всіх відповідних колірних значень буде закінчено, результат пройде ще один етап, який називається альфа тестування і змішування. Цей етап перевіряє відповідне значення глибини (і шаблону) (ми повернемося до цього пізніше) фрагмента і використовує їх для перевірки розташування фрагмента відносно інших об'єктів: спереду або ззаду. Цей етап також перевіряє значення прозорості і змішує кольори, якщо це необхідно. Таким чином під час відтворення множинних примітивів, результуючий колір пікселя може відрізнятися від кольору, обчисленого фрагментным шейдером.

Як ви можете бачити, графічний конвеєр досить складний і містить багато конфігурованих частин. Не дивлячись на це, ми переважно будемо працювати з вершинним і фрагментным шейдером. Геометричний шейдер не обов'язковий і часто залишається стандартним.

У сучасному OpenGL ви змушені задати як мінімум вершинний шейдер (на відеокартах не існує стандартного вершинного/фрагментного шейдера). З цієї причини найчастіше можуть виникнути складності при вивченні сучасного OpenGL, оскільки треба дізнатися досить велика кількість теорії перед тим як намалювати свій перший трикутник. В кінці цього уроку ви дізнаєтеся багато нового про графічному програмуванні.

Передача вершин
Для того, щоб щось промалювати для початку нам треба передати OpenGL дані про вершинах. OpenGL — це 3D бібліотека і тому всі координати, які ми повідомляємо OpenGL перебувають у тривимірному просторі (x, y і z). OpenGL не перетворює передані йому 3D координати в 2D пікселі на екрані; OpenGL тільки обробляє 3D координати в певному проміжку між -1.0 та 1.0 по всім 3 координатами (x, y і z). Всі такі координати називаються координатами, нормализованными під пристрій (або просто нормализованными).

Оскільки ми хочемо промалювати один трикутник ми повинні надати 3 вершини, кожна з яких знаходиться в тривимірному просторі. Ми визначимо їх в нормалізованому вигляді у GLfloat масиві.
GLfloat vertices[] = {
-0.5 f, -0.5 f, 0.0 f,
0.5 f, -0.5 f, 0.0 f,
0.0 f, 0.5 f, f 0.0
}; 

Оскільки OpenGL працює з тривимірним простором ми промальовуємо двомірний трикутник з координатою z дорівнює 0.0. Таким чином глибина трикутника буде однаковою і він буде виглядати двомірним.
Нормалізовані координати пристрою (Normalized Device Coordinates (NDC))
Після того, як вершинні координати будуть оброблені в вершинном шейдере, вони повинні бути нормалізовані в NDC, який представляє з себе маленький простір, де x, y і z координати знаходяться в проміжку від -1.0 1.0. Будь-які координати, які виходять за цю межу будуть відкинуті і не відображаються на екрані. Нижче ви можете побачити заданий нами трикутник:



На відміну від екранних координат, позитивне значення y вказує вгору, а координати (0, 0) це центр графа, замість верхнього лівого кута.

Ваші NDC координати будуть потім перетворені у координати екранного простору через Viewport з використанням даних, наданих через виклик glViewport. Координати екранного простору потім трансформуються у фрагменти і подаються на вхід фрагментному шейдеру.
Після визначення верхових даних потрібно передати їх в перший етап графічного конвеєра: вершинний шейдер. Це робиться наступним чином: виділяємо пам'яті на GPU, куди ми збережемо наші вершинні дані, вкажемо OpenGL як він повинен інтерпретувати передані йому дані і передамо GPU кількість переданих нами даних. Потім вершинний шейдер обробить таку кількість вершин, яке ми йому повідомили.

Ми керуємо цією пам'яттю через, так звані, об'єкти вершинного буфера (vertex buffer objects (VBO)), які можуть зберігати велику кількість вершин в пам'яті GPU. Перевага використання таких об'єктів буфера, що ми можемо посилати в відеокарту велика кількість наборів даних за один раз, без необхідності відправляти по одній вершині. Надсилання даних з CPU на GPU досить повільна, тому ми будемо намагатися відправляти як можна велику даних за один раз. Але як тільки дані виявляться в GPU, вершинний шейдер отримає їх практично миттєво.

VBO це наша перша зустріч з об'єктами, описаними в першому уроці. Також як і будь-який об'єкт в OpenGL, цей буфер має унікальний ідентифікатор. Ми можемо створити VBO за допомогою функції glGenBuffers:
GLuint VBO;
glGenBuffers(1, &VBO);

У OpenGL є велика кількість різних типів об'єктів буферів. Тип VBO GL_ARRAY_BUFFER. OpenGL дозволяє прив'язувати безліч буферів, якщо у них різні типи. Ми можемо прив'язати GL_ARRAY_BUFFER до нашого буферу з допомогою glBindBuffer:
glBindBuffer(GL_ARRAY_BUFFER, VBO); 

З цього моменту будь-який виклик, який використовує буфер, буде працювати з VBO. Тепер ми можемо викликати glBufferData для копіювання верхових даних в цей буфер.
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

glBufferData це функція, мета якої — копіювання даних користувача в зазначений буфер. Перший аргумент — це тип буфера в який ми хочемо скопіювати дані (наш VBO зараз прив'язаний до GL_ARRAY_BUFFER). Другий аргумент визначає кількість даних (в байтах), які ми хочемо передати буферу. Третій аргумент — це самі дані.

Четвертий аргумент визначає як ми хочемо, щоб відеокарта працювала з переданими їй даними. Існує 3 режими:
  1. GL_STATIC_DRAW: дані або ніколи не будуть змінюватися, або будуть змінюватися дуже рідко;
  2. GL_DYNAMIC_DRAW: дані будуть змінюватися досить часто;
  3. GL_STREAM_DRAW: дані будуть змінюватися при кожному відображенні.
Дані про позиції трикутника змінюватися не будуть і тому ми вибираємо GL_STATIC_DRAW. Якщо, приміром, у нас був би буфер, значення якого змінювалося дуже часто ми б використовували GL_DYNAMIC_DRAW або GL_STREAM_DRAW", надавши таким чином відеокарті інформацію, що дані цього буфера потрібно зберігати у пам'яті, найбільш швидкої на запис.

Зараз ми зберегли вершинні дані на GPU в об'єкт буфера, званого VBO.
Далі ми повинні створити верховий і фрагментний шейдери для фактичної обробки даних, що ж, приступимо.

Вершинний шейдер
Вершинний шейдер — один з програмування шейдерів. Сучасний OpenGL вимагає, щоб було задано верховий і фрагментний шейдери якщо ми хочемо щось намалювати, тому ми надамо два дуже простих шейдерів для відтворення нашого трикутника. У наступному уроці ми обговоримо шейдери більш докладно.

На початку ми повинні написати сам шейдер на спеціальному мовою GLSL (OpenGL Shading Language), а потім зібрати його, щоб програма могла з ним працювати. Ось код найпростішого шейдера:
#version 330 core

layout (location = 0) in vec3 position;

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

Як ви можете помітити GLSL дуже схожий на C. Кожен шейдер починається з установки його версії. З OpenGL версії 3.3 і вище версії GLSL збігаються версії OpenGL (приміром версія GLSL 420 збігається з OpenGL версії 4.2). Також ми явно вказали, що використовуємо core profile.

Далі ми вказали всі вхідні вершинні атрибути в вершинном шейдере з допомогою ключового слова in. Зараз нам треба працювати тільки з даними про позиції, тому ми вказуємо тільки один верховий атрибут. У GLSL є векторний тип даних, що містить від 1 до 4 чисел з плаваючою точкою. Оскільки вершини мають тривимірні координати і ми створюємо vec3 з назвою position. Також ми явно вказали позицію нашої змінної через layout (location = 0) пізніше ви побачите, навіщо ми це зробили.
Vector
У графічному програмуванні ми досить часто використовуємо математичну концепцію вектора, оскільки вона відмінно представляє позиції/напрямки в будь-якому просторі, а також володіє корисними математичними властивостями. Максимальний розмір вектора в GLSL — 4 елемента, а доступ до кожного з елементів можна отримати через vec.x, vec.y, vec.z та vec.w відповідно. Зауважте, що компонента vec.w не використовується в якості позиції в просторі (ми ж працює в 3D, а не в 4D), але вона може бути корисна при роботі з поділом перспективи (perspective division). Ми обговоримо вектора більш глибоко в наступному уроці.
Для позначення результату роботи вершинного шейдера ми повинні присвоїти значення визначеної змінної gl_Position, яка має тип vec4. Після закінчення роботи main функції, що б ми не передали в gl_Position, воно буде використано в якості результату роботи вершинного шейдера. Оскільки наш вхідний вектор тривимірний ми повинні перетворити його в чотиривимірний. Ми можемо зробити це просто передавши компоненти vec3 vec4, а компоненті w задати значення 1.0 f (Ми пояснимо чому так пізніше).

Цей вершинний шейдер, ймовірно, самий простий шейдер який тільки можна придумати, оскільки він не обробляє ніяких даних, а просто передає ці дані на вихід. У реальних додатках вхідні дані не нормалізовані, тому на початку їх потрібно нормалізувати.

Збірка шейдера
Ми написали вихідний код шейдерів (зберігається в C рядку), але щоб цим шейдерів міг користуватися OpenGL, його треба зібрати.

На початку ми повинні створити об'єкт шейдера. А оскільки доступ до створених об'єктів здійснюється через ідентифікатор ми будемо зберігати його в змінну з типом GLuint, а створювати його будемо через glCreateShader:
GLuint vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

Під час створення шейдера ми повинні вказати тип шейдера. Оскільки нам потрібен вершинний шейдер, ми вказуємо GL_VERTEX_SHADER.

Далі ми прив'язуємо вихідний код шейдера до об'єкта шейдерів та компілюємо його.
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

Функція glShaderSource в якості першого аргументу приймає шейдер, який потрібно зібрати. Другий аргумент описує кількість рядків. У нашому випадку рядок лише одна. Третій параметр — це сам вихідний код шейдера, а четвертий параметр ми залишимо в NULL.
Швидше за все ви захочете перевірити успішність складання шейдера. І якщо шейдер не було зібрано — отримати помилки, які виникли під час складання. Перевірка на наявність помилок відбувається наступним чином:
GLint success;
GLchar infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &status);

Для початку ми оголошуємо число для визначення успішності складання та контейнер для зберігання помилок (якщо вони з'явилися). Потім ми перевіряємо успішність з допомогою glGetShaderiv. Якщо збірка провалиться — то ми зможемо отримати повідомлення про помилки з допомогою glGetShaderInfoLog і вивести цю помилку:
if(!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}

Після цього, якщо не виникло ніяких помилок компіляції — то шейдер буде зібраний.

Фрагментний шейдер
Фрагментний шейдер — це другий і останній шейдер, який нам знадобиться, щоб промалювати трикутник. Фрагментний шейдер займається обчисленням кольорів пікселів. В ім'я простоти наш фрагментний шейдер буде виводити тільки помаранчевий колір.
Колір в комп'ютерній графіці представляється як масив з 4 значень: червоний, зелений, синій і прозорість; така компонентна база називається RGBA. Коли ми задаємо колір в OpenGL або в GLSL ми задаємо величину кожного компонента між 0.0 і 1.0. Якщо, наприклад, ми встановимо величину червоного і зеленого компонентів у 1.0 f, то ми отримаємо суміш цих кольорів — жовтий. Комбінація з 3 компонентів дає близько 16 мільйонів різних кольорів.
#version 330 core

out vec4 color;

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

Фрагментний шейдер на вихід вимагає тільки значення кольору, що є 4 компонентним вектором. Ми можемо вказати вихідну змінну за допомогою ключового слова out, а назвемо ми цю змінну color. Потім ми просто встановлюємо значення цієї змінної vec4 з непрозорим помаранчевим кольором.

Процес складання фрагментного шейдера аналогічний збірці вершинного, тільки потрібно вказати інший тип шейдера: GL_FRAGMENT_SHADER:
GLuint fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

Обидва шейдера були зібрані і тепер залишилося тільки зв'язати їх в програму, щоб ми могли використовувати їх при відображенні.

Шейдерна програма
Шейдерна програма — це об'єкт, що є фінальним результатом комбінації декількох шейдерів. Для того, щоб використовувати зібрані шейдери їх потрібно з'єднати в об'єкт шейдерної програми, а потім активувати цю програму при візуалізації об'єктів, і ця програма буде використовуватися при виклику команд відтворення.

При з'єднанні шейдерів в програму, вихідні значення одного шейдера зіставляються з вхідними значеннями іншого шейдера. Ви також можете отримати помилки під час з'єднання шейдерів, якщо вхідні і вихідні значення не збігаються.

Створити програму дуже просто:
GLuint shaderProgram;
shaderProgram = glCreateProgram();

Функція glCreateProgram створює програму і повертає ідентифікатор цієї програми. Тепер нам треба приєднати наші зібрані шейдери до програми, а потім зв'язати їх з допомогою glLinkProgram:
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

Цей код цілком описує сам себе. Ми приєднуємо шейдери до програми, а потім зв'язуємо їх.
Також як і зі складанням шейдера ми можемо отримати успішність зв'язування та повідомлення про помилку. Єдина відмінність — замість glGetShaderiv та glGetShaderInfoLog ми використовуємо:
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
If (!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
...
}
Для використання створеної програми треба викликати glUseProgram:
glUseProgram(shaderProgram);

Кожен виклик шейдерів та отрисовочных функцій буде використовувати наш об'єкт програми (і відповідно наші шейдери).

Ах так, не забудьте видалити створені шейдери після зв'язування. Вони нам більше не знадобляться.
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

На даний момент ми передали GPU вершинні дані і вказали GPU як їх обробляти. Ми майже закінчили. OpenGL ще знає, як представити вершинні дані в пам'яті і як сполучати вершинні дані в аттрібути вершинного шейдера. Що ж, приступимо.

Зв'язування верхових атрибутів.
Вершинний шейдер дозволяє нам вказати будь-які дані в кожен атрибут вершини, але це не означає, що нам доведеться вказувати який елемент даних відноситься до якого атрибуту. Це означає, що ми повинні повідомити як OpenGL повинен інтерпретувати вершинні дані перед відображенням.

Формат нашого вершинного буфера наступний:



  • Інформація про позиції зберігається в 32 бітному (4 байта) значення з плаваючою точкою;
  • Кожна позиція формується з 3 значень;
  • Не існує ніякого роздільника між наборами з 3 значень. Такий буфер називається щільно упакованим;
  • Перше значення в переданих даних — це початок буфера.
Знаючи ці особливості ми можемо повідомити OpenGL як він повинен інтерпретувати вершинні дані. Робиться це за допомогою функції glVertexAttribPointer:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);

У функції glVertexAttribPointer має трохи параметрів, давайте швидко пробіжимося по них:
  • Перший аргумент описує якийсь аргумент шейдера ми хочемо налаштувати. Ми хочемо специфікувати значення аргументу position, позиція якого була зазначена наступним чином: layout (location = 0).
  • Наступний аргумент описує розмір аргументу в шейдере. Оскільки ми використовували vec3 то ми вказуємо 3.
  • Третій аргумент описує тип даних. Ми вказуємо GL_FLOAT, оскільки vec шейдере використовують числа з плаваючою точкою.
  • Четвертий аргумент вказує необхідність нормалізувати вхідні дані. Якщо ми вкажемо GL_TRUE, то всі дані будуть розташовані між 0 (-1 для знакових значень) і 1. Нам нормалізація не потрібно, тому ми залишаємо GL_FALSE;
  • П'ятий аргумент називається кроком і описує відстань між наборами даних. Ми також можемо вказати крок рівний 0 і тоді OpenGL вирахує крок (працює тільки з щільно упакованими наборами даних). Як виручити істотну користь від цього аргументу ми розглянемо пізніше.
  • Останній параметр має тип GLvoid* і тому вимагає таке дивне приведення типів. Це зміщення початку даних у буфері. У нас буфер не має зміщення і тому ми вказуємо 0.
Кожен атрибут вершини отримує значення з пам'яті, керованої VBO, яка в даний момент є прив'язаною до GL_ARRAY_BUFFER. Відповідно якби ми викликали glVertexAttribPointer з іншим VBO — то вершинні дані були взяті з іншого VBO.
Після того, як ми повідомили OpenGL як він повинен інтерпретувати вершинні дані ми повинні включити атрибут з допомогою glEnableVertexAttribArray. Таким чином ми передамо вершинних атрибуту позицію аргументу. Після того як ми все налаштували ми ініціалізувати вершинні дані в буфері з допомогою VBO, встановили верховий і фрагментний шейдер і повідомили OpenGL як зв'язати вершинний шейдер і вершинні дані. Відтворення об'єкта в OpenGL буде виглядати якось так:
// 0. Копіюємо масив з вершинами в буфер OpenGL
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. Потім встановимо покажчики на вершинні атрибути
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
// 2. Використовуємо нашу програму шейдерну
glUseProgram(shaderProgram);
// 3. Тепер вже промальовуємо об'єкт
someOpenGlFunctionThatDrawsOutTriangle();

Ми повинні повторювати цей процес при кожному відображенні об'єкта. Здається що це не дуже складно, але тепер уявіть, що у вас більше 5 верхових атрибутів і щось в районі 100 різних об'єктів. І відразу постійна установка цих конфігурацій для кожного об'єкта стає дикої рутиною. Ось би який-небудь спосіб для зберігання всіх цих станів і що нам треба було б тільки прив'язатися до якогось стану для відтворення…

Vertex Array Object
Об'єкт вершинного масиву (VAO) може бути також прив'язаний як і VBO і після цього всі наступні виклики верхових атрибутів будуть зберігатися в VAO. Перевага цього методу в тому, що нам потрібно налаштувати атрибути лише один раз, а всі наступні рази буде використана конфігурація VAO. Також такий метод спрощує зміну верхових даних і конфігурацій атрибутів простим прив'язуванням різних VAO.
Core OpenGL вимагає щоб ми використовували VAO для того щоб OpenGL знав як працювати з нашими вхідними вершинами. Якщо ми не вкажемо VAO, OpenGL може відмовитися намалювати що-небудь.
VAO зберігає наступні виклики:

  • Виклики glEnableVertexAttribArray або glDisableVertexAttribArray.
  • Конфігурація атрибутів, виконана через glVertexAttribPointer.
  • VBO асоційовані з верховими атрибутами з допомогою glVertexAttribPointer


Процес генерації VAO дуже схожий на генерацію VBO:
GLuint VAO;
glGetVertexArrays(1, &VAO);

Для того, щоб використовувати VAO все що вам треба зробити — це прив'язати VAO з допомогою glBindVertexArray. Тепер ми повинні налаштувати/прив'язати необхідні VBO і покажчики на атрибути, а в кінці відв'язати VAO для подальшого використання. І тепер, кожен раз коли ми хочемо промалювати об'єкт ми просто пов'язуємо VAO з необхідними нам налаштуваннями перед відображенням об'єкта. Виглядати це все має приблизно наступним чином:
// ..:: Код ініціалізації (виконується один раз (якщо, звичайно, об'єкт не буде часто змінюватися)) :: .. 
// 1. Прив'язуємо VAO
glBindVertexArray(VAO);
// 2. Копіюємо наш масив вершин у буфер для OpenGL
glBindBuffer(GL_ARRAY_BUFFER, VBO); 
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); 
// 3. Встановлюємо покажчики на вершинні атрибути 
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0); 
//4. Отвязываем VAO
glBindVertexArray(0); 

[...] 

// ..:: Код відтворення (в ігровому циклі) :: ..
// 5. Промальовуємо об'єкт
glUseProgram(shaderProgram); 
glBindVertexArray(VAO); 
someOpenGLFunctionThatDrawsOurTriangle(); 
glBindVertexArray(0); 

OpenGL відв'язування об'єктів це звичайна справа. Як мінімум, просто для того, щоб випадково не зіпсувати конфігурацію.
От і все! Все, що ми робили протягом мільйонів сторінок підводив нас до цього моменту. VAO, зберігає вершинні атрибути і необхідний VBO. Найчастіше, коли у нас є множинні об'єкти для малювання ми на початку генеруємо і конфігуруємо VAO і зберігаємо їх для подальшого використання. І коли треба буде промалювати один з наших об'єктів ми просто використовуємо збережений VAO.

Трикутник, якого ми так чекали
Для показу наших об'єктів OpenGL надає нам функцію glDrawArrays. Вона використовує активний шейдер і встановлений VAO для відтворення зазначених примітивів.
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);

Функція glDrawArrays приймає в якості першого аргументу OpenGL примітив, який потрібно промалювати. Оскільки ми хочемо промалювати трикутник і так як ми не хочемо брехати вам, ми вказуємо GL_TRIANGLES. Другий аргумент вказує початковий індекс масиву з вершинами, який ми хочемо промалювати, ми просто залишимо 0. Останній аргумент вказує кількість вершин для малювання, нам потрібно промалювати 3 (довжина одного трикутника — 3 вершини).

Тепер можна зібрати і запустити написаний код. Ви побачите наступний результат:



Вихідний код можна знайти на тут.

Якщо ваш результат відрізняється то, ймовірно, ви помилились. Порівняйте ваш код з поданим вище вихідним кодом.

Element Buffer Object
Останнє про що ми сьогодні поговоримо по темі відтворення вершин — це element buffer objects (EBO). Для того, щоб пояснити, що це і як працює краще навести приклад: припустимо, що нам треба промалювати не трикутник, а чотирикутник. Ми можемо промалювати чотирикутник з допомогою 2 трикутників (OpenGL в основному працює з трикутниками). Відповідно треба буде оголосити наступний набір вершин:
GLfloat vertices[] = {
// Перший трикутник
0.5 f, 0.5 f, 0.0 f, // правий Верхній кут
0.5 f, -0.5 f, 0.0 f, // Нижній правий кут
-0.5 f, 0.5 f, 0.0 f, // Верхній лівий кут
// Другий трикутник
0.5 f, -0.5 f, 0.0 f, // Нижній правий кут
-0.5 f, -0.5 f, 0.0 f, // Нижній лівий кут
-0.5 f, 0.5 f, 0.0 f // Верхній лівий кут
};

Як ви можете зауважити: ми два рази вказали нижню праву і ліву верхню вершину. Це не дуже раціональне використання ресурсів, оскільки ми можемо описати прямокутник 4 вершинами замість 6. Проблема стає ще більш вагомою, коли ми маємо справу з великими моделями у яких може бути більше 1000 трикутників. Саме правильне рішення цієї проблеми — це зберігати тільки унікальні вершини, а потім окремо вказувати порядок в якому ми хочемо щоб вироблялася відтворення. В нашому випадку нам би треба було зберігати тільки 4 вершини, а потім визначити порядок, в якому їх треба промалювати. Було б чудово, якби OpenGL надавав таку можливість.

На щастя EBO це саме те, що нам потрібно. EBO — це буфер, начебто VBO, але він зберігає індекси, які OpenGL використовує, щоб вирішити яку вершину промалювати. Це називається відображення по індексам (indexed drawing) і є вирішенням зазначеної проблеми. На початку нам треба буде вказати унікальні вершини та індекси для відтворення їх як трикутників:
GLfloat vertices[] = {
0.5 f, 0.5 f, 0.0 f, // правий Верхній кут
0.5 f, -0.5 f, 0.0 f, // Нижній правий кут
-0.5 f, -0.5 f, 0.0 f, // Нижній лівий кут
-0.5 f, 0.5 f, 0.0 f // Верхній лівий кут
};
GLuint indices[] = { // Пам'ятайте, що ми починаємо з 0!
0, 1, 3, // Перший трикутник
1, 2, 3 // Другий трикутник
}; 

Як ви можете помітити, нам знадобилося лише 4 вершини замість 6. Потім нам треба створити EBO:
GLuint EBO;
glGenBuffers(1, &EBO);

Також як і з VBO ми прив'язуємо EBO і копіюємо індекси в цей буфер через glBufferData. Також, як і з VBO ми поміщаємо виклики між командами зв'язування та розв'язування (glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0), тільки в цей раз типом буфера є GL_ELEMENT_ARRAY_BUFFER.
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); 

Зауважте, що зараз ми передаємо GL_ELEMENT_ARRAY_BUFFER в якості цільового буфера. Останнє що нам залишилося зробити — це замінити виклик glDrawArrays на виклик glDrawElements для того, щоб вказати, що ми хочемо промалювати трикутники з буфера з індексами. Коли використовується glDrawElements проводиться вивід з прив'язаного в даний момент EBO:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

Перший аргумент описує примітив, який ми хочемо промалювати, також як і у glDrawArrays. Другий аргумент — це кількість елементів, яке ми хочемо промалювати. Ми вказали 6 індексів, тому ми передаємо функції 6 вершин. Третій аргумент — це тип даних індексів, в нашому випадку — це GL_UNSIGNED_INT. Останній аргумент дозволяє задати нам зміщення в EBO (або передати сам масив з індексами, але при використанні EBO так не роблять), тому ми просто вказуємо 0.

Функція glDrawElements бере індекси з поточного прив'язаного до GL_ELEMENT_ARRAY_BUFFER EBO. Це означає, що якщо ми повинні кожен раз прив'язувати різні EBO. Але VAO вміє зберігати і EBO.



VAO зберігає виклики glBindBuffer, якщо метою є GL_ELEMENT_ARRAY_BUFFER
. Це також означає, що він зберігає і виклики відв'язування, так що переконайтеся, що ви не відв'язали ваш EBO перед тим як відв'язати VAO, інакше у вас взагалі не буде прив'язаного EBO.

В результаті ви отримаєте приблизно такий код:
// ..:: Код ініціалізації :: ..
// 1. Прив'язуємо VAO
glBindVertexArray(VAO);
// 2. Копіюємо наші вершини в буфер для OpenGL
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. Копіюємо наші індекси в буфер для OpenGL
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 3. Встановлюємо покажчики на вершинні атрибути
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0); 
// 4. Отвязываем VAO (НЕ EBO)
glBindVertexArray(0);

[...]

// ..:: Код відтворення (в ігровому циклі) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
glBindVertexArray(0);

Після запуску програма повинна видати наступний результат. Ліве зображення виглядає саме так, як ми і планували, праве зображення — це прямокутник отрисованный у режимі wireframe. Як можна побачити цей чотирикутник побудований з 2 трикутників.
Режим Wireframe
Для того щоб промалювати ваші трикутники в цьому режимі, вкажіть OpenGL, як потрібно примітиви з допомогою glPolygonMode(GL_FRONT_AND_BACK, GL_LINE). Перший аргумент вказуємо, що ми хочемо перетворювати передню і задню частини всіх трикутників, а другий аргумент, що ми хочемо перетворювати тільки лінії. Для того, щоб повернутися до початкової конфігурації — викличте glPolygonMode(GL_FRONT_AND_BACK, GL_FILL).
Якщо у вас виникли які-небудь проблеми, пробіжіться по уроку, можливо ви щось забули. Також ви можете звіриться з кодом.

Якщо у вас все вийшло — то вітають, ви тільки що пройшли через одну з найскладніших частин вивчення сучасного OpenGL: висновок першого трикутника. Ця частина така складна, оскільки вимагає певний набір знань перед тим як з'явиться можливо промалювати перший трикутник. Благо ми вже пройшли через це і наступні уроки мають бути простіше.

Додаткові ресурси
  • antongerdelan.net/hellotriangle: Anton Gerdelan's take on rendering the first triangle.
  • open.gl/drawing: Alexander Overvoorde's take on rendering the first triangle.
  • antongerdelan.net/vertexbuffers: some extra insights into vertex buffer objects.


Вправи
Для закріплення вивченого запропоную кілька вправ:
  1. Спробуйте промалювати 2 трикутника один за іншим з допомогою glDrawArrays з допомогою додавання більшої кількості вершин. Решение
  2. Створіть 2 трикутника з використанням 2 різних VAO і VBO. Решение
  3. Створіть другий фрагментний шейдери і щоб він виводив жовтий колір. І зробіть так, щоб другий трикутник був жовтого кольору. Решение
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.