learnopengl. Урок 1.5 — Shaders

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

GLSL
Шейдери (як згадувалося вище, шейдери — це програми) програмуються на C подібному мовою GLSL. Він адаптований для використання в графіці і надає функції для роботи з векторами та матрицями.
Опис шейдера починається з вказівки його версії, далі йдуть списки вхідних і вихідних змінних, глобальних змінних (ключове слово uniform), функції main. Функції main є початковою точкою шейдера. Усередині цієї функції можна проводити маніпуляції з вхідними даними, результат роботи шейдера поміщається у вихідні змінні. Не звертайте уваги на ключове слово uniform, до нього ми повернемося пізніше.
Нижче представлена узагальнена структура шейдера:
#version version_number

in type in_variable_name;
in type in_variable_name;

out type out_variable_name;

uniform type uniform_name;

void main()
{
// Що робимо, обчислюємо, експериментуємо, і т. п.
...
// Присвоюємо результат роботи шейдера вихідної змінної
out_variable_name = weird_stuff_we_processed;
}

Вхідні змінні вершинного шейдера називаються верховими атрибутами. Існує максимальна кількість вершин, яке можна передати в шейдер, таке обмеження накладається обмеженими можливостями апаратного забезпечення. OpenGL гарантує можливості передачі принаймні 16 4-х компонентних вершин, інакше кажучи шейдер можна передати як мінімум 64 значення. Однак варто враховувати, що існують обчислювальні пристрої значно піднімає цю планку. Так чи інакше, дізнатися максимальна кількість вхідних змінних-вершин, переданих в шейдер, можна дізнатися звернувшись до атрибуту GL_MAX_VERTEX_ATTRIBS.
GLint nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

результат виконання даного коду, в результаті ми побачимо цифру >= 16.
Типи
GLSL, як і будь-яка інша мова програмування, надає певний перелік типів змінних, до них належать такі примітивні типи: int, float, double, uint, bool. Також GLSL надає два типи-контейнера: vector і matrix.

Vector

Vector в GLSL — це контейнер, що містить від 1 до 4 значень будь-якого примітивного типу. Оголошення контейнера vector може мати наступний вигляд (n — кількість елементів вектора):
vecn (наприклад vec4) — це стандартний vector, що містить в собі n значень типу float
bvecn (наприклад, bvec4) — це vector, що містить в собі n значень типу boolean
ivecn (наприклад, ivec4) — це vector, що містить в собі n значень типу integer
uvecn (нарпример, uvecn) — це vector, що містить в собі n значень типу unsigned integer
dvecn (наприклад dvecn) — це vector, що містить в собі n значень типу double.
У більшості випадків буде використовуватися стандартний vector vecn.
Для доступу до елементів контейнера vector ми будемо використовувати наступний синтаксис vec.x, vec.y, vec.z, vec.w (в даному випадку ми звернулися до всіх елементів по порядку, від першого до останнього). Також можна итерироваться за RGBA, якщо вектор описує колір, або stpq якщо вектор описує координати текстури.
P. S. Допускається звернення до одного вектору через XYZW, RGBA, STPQ. Не потрібно сприймати дану замітку як керівництво до дії, будь ласка.

P. P. S. Взагалі, ніхто не забороняє итерироваться по вектору за допомогою індексу і оператора доступу за індексом []. https://en.wikibooks.org/wiki/GLSL_Programming/Vector_and_Matrix_Operations#Components
З вектора, при зверненні до даних через точку, можна отримати не тільки одне значення, але і цілий вектор, використовуючи наступний синтаксис, який називається swizzling
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

Для створення нового вектора ви можете використовувати до 4 символів одного типу або вектор, правило тільки одне — в сумі потрібно отримати необхідне нам кількість елементів, наприклад: для створення вектора з 4 елементів ми можемо використовувати два вектора довжиною в 2 елемента, або один вектор довжиною в 3 елемента і один літерал. Також для створення вектор з n елементів допускається зазначення одного значення, в цьому випадку всі елементи вектора приймуть це значення. Також допускається використання змінних примітивних типів.
vec2 vect = vec2(0.5 f, 0.7 f);
vec4 result = vec4(vect, 0.0 f, 0.0 f);
vec4 otherResult = vec4(result.xyz, 1.0 f);

Можна помітити, що вектор дуже гнучкий тип даних і його можна використовувати в ролі вхідних і вихідних змінних.
In і out змінні
Ми знаємо що шейдери — це маленькі програми, але в більшості випадків вони є частиною чогось більшого, з цієї причини в GLSL є in і out змінні, що дозволяють створити "інтерфейс" шейдера, що дозволяє отримати дані для обробки та передати результати викликає стороні. Таким чином кожен шейдер може визначити для себе вхідні і вихідні змінні використовуючи ключові слова in і out.
Вершинний шейдер був би вкрай неефективним, якщо б він не приймав ніяких вхідних даних. Сам по собі це шейдер відрізняється від інших шейдерів тим, що приймає вхідні значення безпосередньо з вершинних даних. Для того, щоб вказати OpenGL, як організовані аргументи, ми використовуємо метадані позиції, для того, щоб ми могли налаштовувати атрибути на CPU. Ми вже бачили цей прийом раніше: 'layout (location = 0). Вершинних шейдер, в свою чергу, вимагає додаткових специфікацій для того, щоб ми могли зв'язатися аргументи з вершинних даними.
Можна опустити layout (location = 0), і використовувати виклик glGetAttributeLocation для отримання розташування атрибутів вершин.
Ще одним винятком є те, що фрагментний шейдер (Fragment shader) повинен мати на виході vec4, інакше кажучи фрагментний шейдер повинен надати у вигляді результату колір у форматі RGBA. Якщо цього не буде зроблено, то об'єкт буде отрисован чорним або білим.
Таким чином, якщо перед нами стоїть завдання передачі інформації від одного шейдера до іншого, то необхідно визначити в передавальному шейдере out змінну такого типу, як і у in змінної у приймаючому шейдере. Таким чином, якщо типи і імена змінних будуть однакові з обох боків, то OpenGL з'єднає ці змінні разом, що дасть нам можливість обміну інформацією між шейдерами (це робиться на етапі компонування). Для демонстрації цього на практиці ми змінимо шейдери з попереднього уроку, таким чином, щоб вершинний шейдер надавав колір для фрагментного шейдера.
Vertex shader
#version 330 core
layout (location = 0) in vec3 position; // Встановлюємо позицію атрибута 0

out vec4 vertexColor; // Передаємо колір під фрагментний шейдер

void main()
{
gl_Position = vec4(position, 1.0); // Безпосередньо передаємо vec3 в vec4
vertexColor = vec4(0.5 f, 0.0 f, 0.0 f, 1.0 f); // Встановлюємо значення вихідної змінної у темно-червоний колір.
}

Fragment shader
#version 330 core
in vec4 vertexColor; // Вхідна змінна з вершинного шейдерів (те ж назву і той же тип)

out vec4 color;

void main()
{
color = vertexColor;
} 

У даних прикладах ми оголосили вихідний вектор з 4 елементів з іменем vertexColor в вершинном шейдере та ідентичний вектор з назвою vertexColor, але тільки як вхідний під фрагментном шейдере. У результаті вихідний vertexColor з вершинного шейдерів та вхідний vertexColor з фрагментного шейдера були з'єднані. Оскільки ми встановили значення vertexColor в вершинном шейдере, відповідне непрозорого бордовому (темно-червоного кольору), застосування шейдера до об'єкта робить його бордового кольору. Наступне зображення показує результат:
Результат
Ось і все. Ми зробили так, що значення вершинного шейдера було отримано фрагментным шейдером. Далі ми розглянемо спосіб передачі інформації шейдеру з нашої програми.

Uniforms

Uniforms (будемо називати їх формами) — це ще один спосіб передачі інформації від нашого додатка, що працює на CPU, до шейдеру, що працює на GPU. Форми трохи відрізняються від атрибутів вершин. Для початку: форми є глобальними. Глобальна змінна для GLSL означає наступне: Глобальна змінна буде унікальною для кожної шейдерної програми, і доступ до неї є у кожного шейдера на будь-якому етапі у цій програмі. Друге: значення форми зберігається до тих пір, поки воно не буде скинуто або оновлено.
Для оголошення форми в GLSL використовується спецификатор змінної unifrom. Після оголошення форми його можна використовувати в шейдере. Давайте подивимося як встановити колір нашого трикутника з використанням форми:
#version 330 core
out vec4 color;

uniform vec4 ourColor; // Ми встановлюємо значення цієї змінної в коді OpenGL.

void main()
{
color = ourColor;
} 

Ми оголосили змінну форми outColor типу вектора з 4 елементів в фрагментном шейдере та використовуємо її для установки вихідного значення фрагментного шейдера. Оскільки форма є глобальної змінної, то її оголошення можна проводити в будь-якому шейдере, а це значить що нам не потрібно передавати що-то з вершинного шейдера під фрагментний. Таким чином ми не оголошуємо форму в вершинном шейдере, т. до. ми її там не використовуємо.
Якщо ви оголошуєте форму і не використовуєте її в шейдерної програмі, то компілятор нишком видалить її, що може викликати деякі помилки, так що тримаєте цю інформацію в голові.
На даний момент форма не містить в собі корисних даних, т. до. ми їх туди не помістили, так давайте ж зробимо це. Для початку нам потрібно дізнатися індекс, інакше кажучи розташування, потрібного нам атрибута форми в нашому шейдере. Отримавши значення індексу атрибута ми зможемо вмістити туди необхідні дані. Щоб наочно продемонструвати працездатність цієї функції ми будемо міняти колір від часу:
GLfloat timeValue = glfwGetTime();
GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0 f, greenValue, 0.0 f, 1.0 f);

Спочатку ми отримуємо час роботи в секундах викликавши glfwGetTime(). Після ми змінюємо значення від 0.0 до 1.0 використовуючи функцію sin і записуємо результат в змінну greenValue.
Після ми запитуємо індекс форми ourColor використовуючи glGetUniformLocation. Дана функція приймає два аргументи: змінну програми-шейдерів та назва форми, визначеної в межах цієї програми. Якщо glGetUniformLocation повернув -1, це означає що такої форми з таким іменем не було знайдено. Останньому нашим дією є установка значення форми ourColor допомогою використання функції glUniform4f. Зауважте, що пошук індексу форми не вимагає попереднього виклику glUseProgram, але для оновлення значення форми спочатку необхідно викликати glUseProgram.
Так як OpenGL реалізований з використанням мови C, в якому немає перевантаження функцій, виклик функцій з різними аргументами неможливий, але в OpenGL визначені функції для кожного типу даних, які визначаються постфиксом функції. Нижче наведені деякі постфиксы:
f: функція приймає float аргумент;
i: функція приймає int аргумент;
ui: функція приймає unsigned int аргумент;
3f: функція приймає три аргументи типу float;
fv: функція приймає в якості аргументу вектор з float.
Таким чином, замість використання перевантажених функцій, ми повинні використовувати функцію, реалізація якої призначена для певного набору аргументів, на що вказує постфікс функції. У наведеному вище прикладі ми використовували функцію glUniform...() спеціалізовану для обробки 4 аргументів типу float, таким чином, повне ім'я функції було glUniform4f() (4f — чотири аргументи типу float).
Тепер, коли ми знаємо, як задавати значення формами, ми можемо використовувати їх у процесі візуалізації. Якщо ми хочемо змінювати колір з плином часу, то нам потрібно оновлювати значення форми кожну ітерацію циклу відтворення (інакше кажучи, колір буде змінюватися на кожному кадрі), інакше наш трикутник буде одного кольору якщо ми задамо колір тільки один раз. У наведеному нижче прикладі відбувається обчислення нового кольору трикутника і оновлення на кожній ітерації циклу візуалізації:
while(!glfwWindowShouldClose(window))
{
// Обробляємо події
glfwPollEvents();

// Вивід
// Очищаємо буферу кольору
glClearColor(0.2 f, 0.3 f, 0.3 f, 1.0 f);
glClear(GL_COLOR_BUFFER_BIT);

// Активуємо шейдерну програму
glUseProgram(shaderProgram);

// Оновлюємо колір форми
GLfloat timeValue = glfwGetTime();
GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUniform4f(vertexColorLocation, 0.0 f, greenValue, 0.0 f, 1.0 f);

// Малюємо трикутник
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
}

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

Повний вихідний код програми, яка творить такі дива можна подивитися тут.
Як ви вже помітили, форми — це дуже зручний спосіб обміну даними між шейдером і вашою програмою. Але що робити, якщо ми хочемо вибрати колір для кожної вершини? Для цього ми повинні оголосити стільки форм, скільки є вершин. Найбільш вдалим рішенням буде використання атрибутів вершин, що ми зараз і продемонструємо.
Більше атрибутів бога атрибутів!!!
У попередньому уроці ми бачили як заповнити VBO, налаштувати покажчики на аттрібути вершин і як зберігати це все в VAO. Тепер нам потрібно додати інформацію про квіти до даних вершин. Для цього ми створимо вектор з трьох елементів float. Ми призначимо червоний, зелений і синій кольори кожної з вершин трикутника відповідно:
GLfloat vertices[] = {
// Позиції // Кольору
0.5 f, -0.5 f, 0.0 f, 1.0 f, 0.0 f, 0.0 f, // Нижній правий кут
-0.5 f, -0.5 f, 0.0 f, 0.0 f, 1.0 f, 0.0 f, // Нижній лівий кут
0.0 f, 0.5 f, 0.0 f, 0.0 f, 0.0 f, 1.0 f // Верхній кут
}; 

Тепер у нас є багато інформації для передачі її вершинних шейдеру, необхідно відредагувати шейдер так, щоб він отримував і вершини і кольору. Зверніть увагу на те, що ми встановлюємо розташування кольору в 1:
#version 330 core
layout (location = 0) in vec3 position; // Встановлюємо позиція змінної з координатами 0
layout (location = 1) in vec3 color; // А позицію змінної з кольором в 1

out vec3 ourColor; // Передаємо колір під фрагментний шейдер

void main()
{
gl_Position = vec4(position, 1.0);
ourColor = color; // Встановлюємо значення кольору, отримане від верхових даних
} 

Зараз нам не потрібна форма ourColor, але вихідний параметр ourColor нам знадобиться для передачі значення фрагментному шейдеру:
#version 330 core
in vec3 ourColor;
out vec4 color;

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

оскільки ми додали новий верховий параметр і оновили VBO пам'ять, нам потрібно налаштувати покажчики атрибутів вершин. Оновлені дані в пам'яті VBO виглядають наступним чином:
Дані в пам&#39;яті VBO
Знаючи поточну схему ми можемо оновити формат вершин використовуючи функцію glVertexAttribPointer:
// Атрибут з координатами
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// Атрибут з кольором
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3* sizeof(GLfloat)));
glEnableVertexAttribArray(1);

Перші кілька атрибутів функції glVertexAttribPointer досить прості. У даному прикладі ми використовуємо верховий атрибут з позицією 1. Колір складається з трьох значень типу флоту і нам не потрібна нормалізація.
оскільки тепер ми використовуємо два атрибута шейдерів, то нам слід перерахувати крок. Для доступу до наступного атрибуту шейдерів (наступний x вектора вершин) нам потрібно переміститися на 6 елементів float вправо, на 3 для вектора вершин і на 3 для вектора кольору. Тобто ми перемістимося 6 разів вправо, тобто на 24 байта вправо.
Тепер ми розібралися зі зрушеннями. Першим йде вектор з координатами вершини. Вектор зі значенням кольору RGB йде після вектора з координатами, тобто після 3 * sizeof(GLfloat) = 12 байт.
Запустивши програму ви можете побачити наступний результат:
Результат
Повний вихідний код, що чинить це диво можна подивитися тут:
Може здатися що результат не відповідає виконаній роботі, адже ми поставили лише три кольори, а не палітру, яку ми бачимо в результаті. Такий результат дає фрагментная інтерполяція фрагментного шейдера. При відображенні трикутника, на етапі растеризації, виходить набагато більше областей, а не тільки вершини, які ми використовуємо в якості аргументів шейдера. Растеризатор визначає позиції цих областей на основі їх положення на полігоні. На підставі цієї позиції відбувається інтерполяція всіх аргументів фрагментного шейдера. Припустимо, ми маємо просту лінію, з одного кінця вона зелена, з іншого вона синя. Якщо фрагментний шейдер обробляє область, яка знаходиться приблизно посередині, то колір цій області буде підібраний так, що зелений дорівнюватиме 50% від кольору, який використовується в лінії, і, відповідно, синій дорівнюватиме 50% відсотків від синього. Саме це і відбувається на нашому трикутнику. Ми маємо три кольори, три вершини, для кожної з яких встановлений один з кольорів. Якщо придивитися, то можна побачити, що червоний, при переході до синього, спочатку стає фіолетовим, що цілком очікувано. Фрагментная інтерполяція застосовується до всіх атрибутів фрагментного шейдера.
ООП в маси! Робимо свій клас шейдера
Код, що описує шейдер, виробляє його компіляцію, що дозволяє проводити настройку шейдера може бути досить громіздким. Так давайте ж зробимо наше життя трохи простіше, написавши клас зчитує наш шейдер з диска, що робить його компіляцію, лінковку, перевірку на помилки, ну і звичайно, має простий і приємний інтерфейс. Таким чином ООП допоможе нам инкапсулировать весь цей хаос всередині методів нашого класу.
Почнемо розробку нашого класу з оголошення його інтерфейсу і пропишемо у новостворений заголовочник всі необхідні директиви include. В результат отримуємо щось подібне:
#ifndef SHADER_H
#define SHADER_H

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

#include < GL/glew.h>; // Підключаємо glew для того, щоб отримати всі необхідні відмінності файли OpenGL

class Shader
{
public:
// Ідентифікатор програми
GLuint Program;
// Конструктор зчитує і збирає шейдер
Shader(const GLchar* vertexPath, const GLchar* fragmentPath);
// Використання програми
void Use();
};

#endif

Давайте будемо молодцями і будемо використовувати директиви ifndef і define, щоб уникнути рекурсивного виконання директиви include. Ця рада відноситься не до OpenGL, а до програмування на C++ в цілому.
І так, наш клас буде зберігати в собі свій ідентифікатор. Конструктор шейдера буде приймати в якості аргументів покажчики на масиви символів (інакше кажучи текст, а в контексті класу, доречніше буде сказати — шлях до файлу з вихідним кодом нашого шейдера), що містять шлях до файлів, що містять верховий і фрагментний шейдери, представлені звичайним текстом. Також додамо утилітарну функцію Use, наочно демонструє переваги використання класів-шейдерів.
Зчитування файлу шейдера. Для зчитування будемо використовувати стандартні потоки C++, поміщаючи результат в рядки:
Shader(const GLchar* vertexPath, const GLchar* fragmentPath)
{
// 1. Отримуємо вихідний код шейдера з filePath
std::string vertexCode;
std::string fragmentCode;
std::ifstream vShaderFile;
std::ifstream fShaderFile;
// Переконаємося, що ifstream об'єкти можуть викидати виключення
vShaderFile.exceptions(std::ifstream::badbit);
fShaderFile.exceptions(std::ifstream::badbit);
try 
{
// Відкриваємо файли
vShaderFile.open(vertexPath);
fShaderFile.open(fragmentPath);
std::stringstream vShaderStream, fShaderStream;
// Зчитуємо дані потоки
vShaderStream << vShaderFile.rdbuf();
fShaderStream << fShaderFile.rdbuf(); 
// Закриваємо файли
vShaderFile.close();
fShaderFile.close();
// Перетворюємо потоки в масив GLchar
vertexCode = vShaderStream.str();
fragmentCode = fShaderStream.str(); 
}
catch(std::ifstream::failure e)
{
std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
}
const GLchar* vShaderCode = vertexCode.c_str();
const GLchar* fShaderCode = fragmentCode.c_str();
[...]

Тепер нам потрібно скомпілювати і слинковать наш шейдер (давайте будемо робити все добре, і зробимо функціонал, що повідомляє нам про помилку при компіляції і лінкування шейдера. Несподівано,… але це дуже корисно в процесі налагодження):
// 2. Збірка шейдерів
GLuint vertex, fragment;
GLint success;
GLchar infoLog[512];

// Вершинний шейдер
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// Якщо є помилки - вивести їх
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if(!success)
{
glGetShaderInfoLog(vertex, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};

// Аналогічно для фрагментного шейдера
[...]

// Шейдерна програма
this->Program = glCreateProgram();
glAttachShader(this->Program, vertex);
glAttachShader(this->Program, fragment);
glLinkProgram(this->Program);
//Якщо є помилки - вивести їх
glGetProgramiv(this->Program, GL_LINK_STATUS, &success);
if(!success)
{
glGetProgramInfoLog(this->Program, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}

// Видаляємо шейдери, оскільки вони вже в програму і нам більше не потрібні.
glDeleteShader(vertex);
glDeleteShader(fragment);

Ну а вишенькою на торті буде реалізація методу Use:
void Use() { glUseProgram(this->Program); } 

А ось і результат нашої роботи:
Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.frag");
...
while(...)
{
ourShader.Use();
glUniform1f(glGetUniformLocation(ourShader.Program, "someUniform"), 1.0 f);
DrawStuff();
}

У цьому прикладі ми помістили наш вершинний шейдер в файл shader.vs, а фрагментний шейдер в файл shader.frag. В принципі, ви можете задати власну конвенцію іменування файлів, особливої ролі це не зіграє, але робіть це обережно, зберігаючи семантику.
вихідний код програми з класом шейдера, класи шейдера, вершинного шейдера і фрагментного шейдера
Вправи:
1. Модифікуйте вершинний шейдер так, щоб в результаті трикутник перекинувся: решение.
2. Передайте горизонтальне зміщення з допомогою форми та перемістіть трикутник на правій стороні вікна за допомогою вершинного шейдера: решение.
3. Передайте фрагментному шейдеру позицію вершини і встановіть значення кольору рівне значенню позиції (подивіться, як позиція вершини що інтерполюється по всьому трикутнику). Після того, як зробите це, постарайтеся відповісти на питання, чому нижня ліва частина трикутника чорна?: решение
Джерело: Хабрахабр

0 коментарів

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