The Book of Shaders by Patricio Gonzalez Vivo & Jen Lowe

Bahasa Indonesia - Tiếng Việt - 日本語 - 中文版 - 한국어 - Español - Portugues - Français - Italiano - Deutsch - Русский - Polski - English


Вступ для тих, хто прийшов із JS

автор Nicolas Barradeau

Якщо ви JavaScript-розробник, швидше за все, ви будете трохи спантеличені, читаючи книгу. Дійсно, є багато відмінностей між маніпулюванням високорівневим JS і длубанням у менш високорівневих шейдерах. Проте, на відміну від більш низькорівневої мови асемблера, GLSL є людино-зрозумілою мовою, і я впевнений, що як тільки ви розберетеся з її особливостями, то швидко запрацюєте.

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

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

Введення для новачків

JavaScript чудово підходить для швидкого прототипування; ви накидуєте купу різноманітних нетипізованих змінних і методів, можете динамічно додавати та видаляти члени класів, оновлювати сторінку і швидко перевіряти чи вона працює. Потім можна внести нові зміни, оновити сторінку, повторити - легке життя. Тож ви можете поставити собі питання, у чому різниця між JavaScript і GLSL? Зрештою, обидва працюють у браузері, обидва використовуються для малювання купи прикольних штук на екрані, і в цьому сенсі JS легше використовувати.

Що ж, головна відмінність полягає в тому, що Javascript є інтерпретованою мовою, тоді як GLSL є компільованою. Скомпільована програма виконується нативно в ОС, є низькорівневою і загалом швидкою. Інтерпретована програма потребує для свого виконання віртуальну машину (VM), є високорівневою і відносно більш повільною.

Коли браузер (VM для JavaScript) виконує або інтерпретує фрагмент JS-коду, він ще не має уявлення про те, яка змінна чим являється і яка функція що робить (за винятком типізованих масивів). Тож він не може нічого оптимізувати наперед. Потрібен деякий час, щоб прочитати ваш код, щоб вивести, на основі використання, типи ваших змінних та методів і, коли це можливо, перетворити дещо з вашого коду на код асемблера, який виконуватиметься набагато швидше.

Це повільний, кропіткий і шалено складний процес. Якщо вас цікавлять деталі, я рекомендую подивитися, як працює рушій V8 у Chrome. Найгірше те, що кожен браузер оптимізує JS по-своєму, а процес прихований від вас. В цьому плані ви безсилі.

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

Це суворо, але це те, чим є шейдер: програмою, що компілюється для виконання на GPU. Не бійтеся! Компілятор який перевіряє правильність вашого коду, стане вашим найкращим другом. Приклади цієї книги та редактор коду дуже дружні до користувача. Вони підкажуть вам де і чому не вдалося скомпілювати вашу програму. Потім, після потрібних виправлень, коли шейдер буде готовий до компіляції, він миттєво зобразить результат своєї роботи. Це чудовий спосіб навчання, оскільки він дуже наочний і безпечний, бо насправді ви нічого не зможете зламати.

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

Готові?

Поїхали!

Сильні типи

перший результат пошуку для 'strong type' в Google Image на 20.05.2016

Коли ви приходите з JS або будь-якої іншої нетипізованої мови, типізація змінних являється для вас чужорідною концепцією, що стає найважчим кроком до GLSL. Типізація, як випливає з назви, означає, що вам потрібно надавати тип усім своїм змінним і функціям. Це фактично означає, що слів var або let більше не існує. Поліція думок GLSL стерла їх із загальної мови й ви більше не можете їх використовувати, тому що, ну... їх не існує.

Замість використання чарівного слова var вам доведеться явно вказати тип кожної змінної, яку ви використовуєте, тоді компілятор бачитиме лише ті об'єкти та примітиви, з якими він вміє ефективно поводитися. Мінус, що ви не можете використовувати ключове слово var і повинні явно вказувати всі типи, полягає в тому, що вам доведеться знати типи усіх змінних і знати їх добре. Будьте спокійні, їх небагато і вони досить прості (GLSL це вам не Java-фреймворк).

Це може здатися страшним, але загалом це не дуже відрізняється від того, що ви робите на JavaScript. Наприклад, якщо ви маєте boolean-змінну, то ви очікуєте, що вона зберігатиме лише true або false і нічого більше. Якщо змінна називається var uid = XXX;, то в ній вірогідно зберігається цілочисельне значення, а var y = YYY; може бути посиланням на значення з рухомою крапкою. Що ще краще, завдяки сильним типам ви не витрачатимете час на роздуми про те, чи X == Y (чи typeof X == typeof Y?, чи typeof X !== null && Y...). Ви просто знаєте це, а якщо ні, то компілятор знатиме напевно.

Ось скалярні типи (скаляр описує величину), які можна використовувати в GLSL: bool (булів тип), int (ціле число), float (число з рухомою крапкою). Є й інші типи, але не будемо поспішати. Наступний фрагмент показує, як оголосити vars (так, я використав заборонене слово) у GLSL:

// логічне значення
JS: var b = true;               GLSL: bool b = true;

// цілочисельне значення
JS: var i = 1;                  GLSL: int i = 1;

// числове значення з рухомою крапкою
JS: var f = 3.14159;            GLSL: float f = 3.14159;

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

void

Тип void приблизно відповідає null. Він використовується в якості типу, який має повертати метод, коли він нічого не повертає. Ви не можете призначати його змінним.

boolean

Як вам відомо, логічні значення здебільшого використовуються в умовних перевірках: "if (myBoolean == true) {...} else {...}". Якщо умовне розгалуження є звичайним підходом для CPU, то для паралельної природи GLSL це твердження є менш правдивим. Використання умовних галужень навіть не рекомендується у більшості випадків і у книзі показано кілька альтернативних методів для вирішення цього обмеження.

приведення типів

Як казав Боромир, "не можна просто взяти та скомбінувати типізовані примітиви". На відміну від JavaScript, GLSL не дозволить вам виконувати операції між змінними різних типів.

Наприклад, цей код:

int     i = 2;
float   f = 3.14159;

// спроба помножити ціле число на значення з рухомою крапкою
float   r = i * f;

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

// приведення типу цілочисельної змінної 'i' до float
float   r = float(i) * f;

Це як вдягти кота у шкіру жирафа і працюватиме належним чином (змінна r зберігатиме результат множення i на f).

Можна привести будь-який зі згаданих вище типів до будь-якого іншого. Зауважте, що приведення float до int поводитиметься як Math.floor(), оскільки видалятиме значення після рухомої крапки. Приведення float або int до bool поверне true, якщо змінна не дорівнює нулю.

конструктор

Типи змінних також є конструкторами класів для самих себе. Змінну float фактично можна розглядати як екземпляр класу float.

Наступні оголошення рівнозначно валідні:

int     i = 1;
int     i = int(1);
int     i = int(1.9995);
int     i = int(true);

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

Отже, ми познайомилися з трьома примітивними типами, без яких ви не зможете жити, але, звісно, GLSL має й інші.

Вектори

перший результат пошуку для 'vector villain' у Google Image на 20.05.2016

У Javascript, як і в GLSL, вам знадобляться складніші способи маніпуляції даними, ось де вектори стануть у пригоді. Я припускаю, що вам вже доводилося писати клас Point на JavaScript, для утримання значень x і y, що виглядав якось так:

// визначення класу:
var Point = function(x, y) {
    this.x = x || 0;
    this.y = y || 0;
}

// створення екземляру:
var p = new Point(100, 100);

Як ми щойно побачили, це ДУЖЕ неправильно на ДУЖЕ багатьох рівнях! По-перше, ключове слово var, далі жахливе this, потім знову нетипізовні значення x і y... Ні, це не запрацює в шейдерленді.

Натомість GLSL надає вбудовані структури даних для їх групування, а саме:

Ви відразу помітили, що для кожного примітиву є відповідний векторний тип. З показаного вище, ви можете зробити висновок, що bvec2 буде містити два значення типу bool, а vec4 — чотири значення float.

Також вектори вводять таку річ як вимірність або розмірність. Це не означає, що 2D-вектор використовується при малюванні 2D-графіки, а 3D-вектор при малюванні 3D-сцен. Ні! Що в такому разі буде представляти 4D-вектор? (ну насправді це називається тесерактом або гіперкубом)

Розмірність представляє кількість і тип компонентів або змінних, що зберігаються у векторі:

// створюємо двовимірний булів вектор
bvec2 b2 = bvec2(true, false);

// створюємо тривимірний цілочисельний вектор
ivec3 i3 = ivec3(0, 0, 1);

// створюємо чотиривимірний вектор з рухомою комою
vec4 v4 = vec4(0.0, 1.0, 2.0, 1.0);

b2 зберігає два різних булевих значення, i3 - 3 різні цілочисельні значення, а v4 - 4 різні значення з рухомою комою.

Але як звернутися до цих значень? У випадку скалярів відповідь очевидна: для "float f = 1.2;", змінна f містить значення 1.2. З векторами це трохи інакше і доволі красиво.

аксесори - доступи до елементів

Існують різні способи доступу до значень

// створимо чотиривимірний вектор типу float
vec4 v4 = vec4(0.0, 1.0, 2.0, 3.0);

отримати кожне з 4-х значень, можна наступним чином:

float x = v4.x;     // x = 0.0
float y = v4.y;     // y = 1.0
float z = v4.z;     // z = 2.0
float w = v4.w;     // w = 3.0

Просто і легко. Наведені нижче приклади показують інші еквівалентні способи доступу до цих даних:

float x =   v4.x    =   v4.r    =   v4.s    =   v4[0];     // x = 0.0
float y =   v4.y    =   v4.g    =   v4.t    =   v4[1];     // y = 1.0
float z =   v4.z    =   v4.b    =   v4.p    =   v4[2];     // z = 2.0
float w =   v4.w    =   v4.a    =   v4.q    =   v4[3];     // w = 3.0

Кмітливий читач міг помітити три речі:

Тож залежно від того, чи працюєте ви з дво- чи тривимірними координатами, кольором з альфа-каналом чи без нього, або просто з деякими довільними значеннями, ви можете вибрати найбільш відповідний тип і розмірність вектора. Зазвичай двовимірні координати та вектори (в геометричному сенсі) зберігаються як vec2, vec3 або vec4, кольори як vec3 або vec4, якщо вам потрібна непрозорість. Але, в цілому, обмежень на те як використовувати вектори немає. Наприклад, якщо ви хочете зберегти лише одне логічне значення в bvec4, це можливо, але буде марною витратою пам'яті.

Примітка: у шейдері значення кольорів (R, G, B, A) нормалізовані, тобто варіюються в діапазоні від 0 до 1, а не від 0 до 0xFF, тому для них краще використовувати тип float vec4, ніж цілочисельний тип ivec4.

Вже маємо хороший початок, але рушаймо далі!

змішування

З вектора можна отримати більше одного значення за раз. Скажімо, із vec4 вам потрібні лише значення X і Y. У JavaScript вам довелося б написати щось подібне:

var needles = [0, 1]; // розміщення 'x' і 'y' в нашій структурі даних
var a = [0, 1, 2, 3]; // наша структура даних 'vec4'
var b = a.filter(function(val, i, array) {
  return needles.indexOf(array.indexOf(val)) != -1;
});
// b = [0, 1]

// або більш буквально:
var needles = [0, 1];
var a = [0, 1, 2, 3]; // структура даних 'vec4'
var b = [a[needles[0]], a[needles[1]]]; // b = [0, 1]

Виглядає потворно. У GLSL ви можете отримати ці дані так:

// створюємо 4D-вектор типу float
vec4 v4 = vec4(0.0, 1.0, 2.0, 3.0);

// і одночасно отримуємо лише X та Y
vec2 xy = v4.xy; // xy = vec2(0.0, 1.0);

Що це щойно сталося?! Коли ви об'єднуєте аксесори, GLSL елегантно повертає підмножину значень, які ви запросили, у найбільш відповідному векторному форматі. Тож тут вектор — це структура даних із довільним доступом, схожий на масив як у JavaScript. Таким чином, ви можете не тільки отримати підмножину ваших даних, але і вказати їх порядок, у якому вони вам потрібні. Наступний приклад змінює порядок отримання компонентів вектора:

// створюємо чотиривимірний вектор типу float: R,G,B,A
vec4 color = vec4(0.2, 0.8, 0.0, 1.0);

// отримуємо компоненти кольору в порядку A,B,G,R
vec4 backwards = color.abgr; // backwards = vec4(1.0, 0.0, 0.8, 0.2);

І, звичайно, ви можете повернути той самий компонент кілька разів:

// створюємо чотиривимірний вектор типу float: R,G,B,A
vec4 color = vec4(0.2, 0.8, 0.0, 1.0);

// отримуємо vec3 з компонентами GAG на основі каналів G і A з початкового кольору
vec3 GAG = color.gag; // GAG = vec4(0.8, 1.0, 0.8);

Це надзвичайно зручно для об'єднання частин вектору, виділення лише rgb-каналів із кольору RGBA тощо.

перевантаження

У розділі типів я згадував про конструктор і можливість перевантаження. Для тих, хто не знає, перевантаження оператора або функції означає 'зміну поведінки зазначеного оператора або функції залежно від операндів/аргументів'. В JavaScript немає перевантаження, тому спочатку воно може здатися трохи дивним, але я впевнений, що як тільки ви звикнете до нього, то будете дивуватися, чому це не реалізовано в JS (коротка відповідь - типізація).

Найпростіший приклад перевантаженого оператора виглядає так:

vec2 a = vec2(1.0, 1.0);
vec2 b = vec2(1.0, 1.0);
// перевантажене додавання
vec2 c = a + b;     // c = vec2(2.0, 2.0);

ЩО? Отже, можна додавати сутності, які не є числами?!

Саме так. Також це стосується всіх операторів (+, -, * і /), але це тільки початок. Розглянемо наступний фрагмент:

vec2 a = vec2(0.0, 0.0);
vec2 b = vec2(1.0, 1.0);
// перевантажений конструктор
vec4 c = vec4(a, b);         // c = vec4(0.0, 0.0, 1.0, 1.0);

Ми створили vec4 з двох vec2. Таким чином новий vec4 використав a.x і a.y як Xі Y компоненти та b.x і b.y як Z і W компоненти для вектора c.

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

vec4 a = vec4(1.0, 1.0, 1.0, 1.0);
vec4 a = vec4(1.0); // x, y, z, w - усі дорівнюють 1.0
vec4 a = vec4(v2, float, v4); // vec4(v2.x, v2.y, float, v4.x);
vec4 a = vec4(v3, float); // vec4(v3.x, v3.y, v3.z, float);
тощо

Єдине про що ви повинні попіклуватися, так це про передачу достатньої кількості аргументів для заповнення вектору.

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

більше типів

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

Ми також можемо використовувати масиви. Звичайно, їх потрібно типізувати й вони мають свої особливості у порівнянні з JS:

Це не спрацює:

int values[3] = [0, 0, 0];

А ось це спрацює:

int values[3];
values[0] = 0;
values[1] = 0;
values[2] = 0;

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

struct ColorStruct {
    vec3 color0;
    vec3 color1;
    vec3 color2;
}

Ви можете створити та отримати значення colors наступним чином:

// ініціалізуємо структуру деякими значеннями
ColorStruct sandy = ColorStruct(
    vec3(0.92, 0.83, 0.60),
    vec3(1., 0.94, 0.69),
    vec3(0.95, 0.86, 0.69)
);

// отримуємо доступ до значень із структури
sandy.color0 // vec3(0.92, 0.83, 0.60)

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

вирази та умови

Структури даних корисні, але нам може знадобитися можливість для повторення дії або виконання умовних перевірок. На щастя для нас, синтаксис дуже близький до JavaScript. Умова виглядає так:

if (condition) {
    // true
} else {
    // false
}

Звичайни цикл for:

const int count = 10;
for (int i = 0; i <= count; i++) {
    // do something
}

Приклад циклу з ітератором типу float:

const float count = 10.;
for (float i = 0.0; i <= count; i += 1.0) {
    // do something
}

Зауважте, що count потрібно визначити як константу. Це означає перед змінною потрібно додати кваліфікатор const, який ми розглянемо трохи згодом.

Нам також доступні оператори break і continue:

const float count = 10.;
for (float i = 0.0; i <= count; i += 1.0) {
    if (i < 5.) continue;
    if (i >= 8.) break;
}

Зауважте, що на деяких типах пристроїв break не працює належним чином і завчасно не перериває виконання циклу.

Загалом, кількість ітерацій має бути якомога меншою, та і в цілому бажано уникати використання циклів і умовних галужень.

кваліфікатори

Окрім типів змінних, GLSL використовує кваліфікатори. Коротко кажучи, кваліфікатори допомагають повідомити компілятору призначення змінних. Наприклад, деякі дані для GPU можуть бути надані тільки від CPU і називаються атрибутами та уніформами. Атрибути використовуються у вершинних шейдерах, а уніформи можна використовувати як у вершинних, так і у фрагментних шейдерах. Існує також кваліфікатор variying, який використовується для передачі змінних від вершинного шейдеру до фрагментного.

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

uniform vec2 u_resolution;

Бачите, що ми тут зробили? Ми додали кваліфікатор uniform перед типом змінної. Це означає, що змінна, яка відповідає за роздільну здатність полотна з яким ми працюємо, передається шейдеру з CPU. Ширина полотна зберігається в x, а висота в y-компоненті даного 2D-вектора.

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

Те саме стосується нашої змінної count, яка слугувала обмеженням для циклу for:

const float count = 10.;
for ( ... )

Коли ми використовуємо кваліфікатор const, компілятор не дає змогу перезаписувати значення такої змінної, інакше вона не була б константою.

У сигнатурах функцій можуть використовуватися 3 додаткові кваліфікатори: in, out та inout. У JavaScript передані до функції значення скалярних аргументів доступні лише для читання. Якщо ви змінюєте їхні значення всередині функції, то ці зміни не застосовуються до змінної поза функцією.

function banana(a) {
    a += 1;
}

var value = 0;
banana(value);
console.log(value); // 0 - значення за межами функції не змінилося

За допомогою кваліфікаторів перед аргументами ви можете вказати їх поведінку:

Переписаний метод banana у GLSL виглядає так:

void banana(inout float a) {
    a += 1.;
}

float A = 0.;
banana(A); // тепер A = 1.;

Це дуже відрізняється від JS і є досить потужною можливістю, але вам не обов'язково вказувати кваліфікатори аргументів. За замовчуванням вони доступні лише для зчитування.

простір і координати

Останнє зауваження: у DOM і Canvas 2D ми звикли, що вісь Y спрямована 'вниз'. Це має сенс у контексті DOM, оскільки відповідає способу розгортання вебсторінки: панель навігації вгорі, а контент прокручується донизу. У полотні WebGL вісь Y перевернута і вказує 'вгору'.

Це означає, що початок координат, точка (0, 0), знаходиться в нижньому лівому куті контексту WebGL, а не у верхньому лівому куті, як у 2D Canvas. Координати текстур також дотримуються цього правила, що спочатку може бути контрінтуїтивним.

Ось і все!

Звісно, ми б могли більше заглибитися в різноманітні концепції, але, як згадувалося раніше, цей розділ написаний як просте введення для новачків. Тут вже написано достатньо для того, щоб за деякий час переварити нові знання, але з терпінням і практикою ця мова ставатиме для вас все більш природною.

Сподіваюся, цей матеріал був корисним для вас. А тепер як щодо початку вашої подорожі основною частиною книги?