This translation is community contributed and may not be up to date. We only maintain the English version of the documentation. Read this tutorial in English
У цьому уроці ми створимо повноекранний ефект постобробки для кольорокорекції. Основний метод рендерингу, який ми використаємо, придатний для різних ефектів постобробки: розмиття, шлейфів, світіння, коригування кольорів тощо.
Передбачається, що ви вмієте працювати в редакторі Defold і маєте базове уявлення про шейдери GL та конвеєр рендерингу Defold. Якщо вам потрібно ознайомитися з цими темами, перегляньте наш посібник із шейдерів і посібник із рендерингу.
За стандартного скрипту рендерингу кожен візуальний компонент (component) — спрайт, карта плиток, ефект частинок, GUI тощо — рендериться безпосередньо в буфер кадру відеокарти. Потім апаратне забезпечення виводить цю графіку на екран. Безпосереднє малювання пікселів компонента виконує шейдерна програма GL. Defold постачається зі стандартною шейдерною програмою для кожного типу компонента, яка малює піксельні дані на екрані без змін. Зазвичай саме така поведінка вам і потрібна — зображення мають з’являтися на екрані такими, якими їх задумали.
Ви можете замінити шейдерну програму компонента на іншу, яка змінює піксельні дані або програмно створює цілком нові кольори пікселів. Як це зробити, пояснює урок із Shadertoy.
Тепер припустімо, що ви хочете відображати всю гру чорно-білою. Один із можливих способів — змінити окрему шейдерну програму для кожного типу компонента так, щоб кожен шейдер знебарвлював пікселі. Наразі Defold постачається з 6 вбудованими матеріалами та 6 парами вершинних і фрагментних шейдерних програм, тож це потребуватиме чимало роботи. Крім того, кожну подальшу зміну або новий ефект доведеться додавати до кожної шейдерної програми.
Значно гнучкіший підхід — виконувати рендеринг у два окремі етапи:

Цей метод дає змогу зчитати отримані візуальні дані та змінити їх перед виведенням на екран. Додавши шейдерні програми на другому етапі, ми легко можемо отримати повноекранні ефекти. Розгляньмо, як це налаштувати в Defold.
Нам потрібно змінити вбудований скрипт рендерингу та додати нову функціональність рендерингу. Стандартний скрипт рендерингу — добра відправна точка, тож почніть із його копіювання:
Відкрийте grade.render і встановіть для його властивості Script значення «/main/grade.render_script».

Відкрийте game.project і встановіть для Render значення «/main/grade.render».

Тепер гру налаштовано на роботу з новим конвеєром рендерингу, який ми можемо змінювати. Щоб перевірити, чи рушій використовує нашу копію скрипту рендерингу, запустіть гру, внесіть у скрипт рендерингу зміну з видимим результатом, а потім перезавантажте скрипт. Наприклад, ви можете вимкнути малювання плиток і спрайтів, а тоді натиснути ⌘ + R, щоб гаряче перезавантажити «зіпсований» скрипт рендерингу в запущеній грі:
...
render.set_projection(vmath.matrix4_orthographic(0, render.get_width(), 0, render.get_height(), -1, 1))
-- render.draw(self.tile_pred) -- <1>
render.draw(self.particle_pred)
render.draw_debug3d()
...
Якщо під час цієї простої перевірки спрайти й плитки зникнуть, ви знатимете, що гра виконує ваш скрипт рендерингу. Якщо все працює як очікується, скасуйте цю зміну скрипту рендерингу.
Тепер змінимо скрипт рендерингу так, щоб він малював у позаекранну ціль рендерингу замість буфера кадру. Спочатку потрібно створити ціль рендерингу:
function init(self)
self.tile_pred = render.predicate({"tile"})
self.gui_pred = render.predicate({"gui"})
self.text_pred = render.predicate({"text"})
self.particle_pred = render.predicate({"particle"})
self.clear_color = vmath.vector4(0, 0, 0, 1)
self.clear_color.x = sys.get_config_number("render.clear_color_red", 0)
self.clear_color.y = sys.get_config_number("render.clear_color_green", 0)
self.clear_color.z = sys.get_config_number("render.clear_color_blue", 0)
self.clear_color.w = sys.get_config_number("render.clear_color_alpha", 1)
self.view = vmath.matrix4()
local color_params = { format = graphics.TEXTURE_FORMAT_RGBA,
width = render.get_width(),
height = render.get_height() } -- <1>
local target_params = {[graphics.BUFFER_TYPE_COLOR0_BIT] = color_params }
self.target = render.render_target("original", target_params) -- <2>
end
Тепер потрібно лише обгорнути початковий код рендерингу викликами render.set_render_target() ось так:
function update(self)
render.set_render_target(self.target) -- <1>
render.set_depth_mask(true)
render.set_stencil_mask(0xff)
render.clear({[graphics.BUFFER_TYPE_COLOR0_BIT] = self.clear_color, [graphics.BUFFER_TYPE_DEPTH_BIT] = 1, [graphics.BUFFER_TYPE_STENCIL_BIT] = 0})
render.set_viewport(0, 0, render.get_width(), render.get_height()) -- <2>
render.set_view(self.view)
...
render.set_render_target(render.RENDER_TARGET_DEFAULT) -- <3>
end
render.draw() малюватиме в буфери нашої позаекранної цілі рендерингу.update() залишається без змін, окрім області перегляду, розмір якої встановлено відповідно до роздільної здатності цілі рендерингу.Це все, що нам потрібно зробити. Якщо зараз запустити гру, вона малюватиме все в ціль рендерингу. Але оскільки ми тепер нічого не малюємо в буфері кадру, на екрані буде лише чорне тло.
Щоб намалювати на екрані пікселі з колірного буфера цілі рендерингу, нам потрібно підготувати щось, до чого можна застосувати ці піксельні дані як текстуру. Для цього ми використаємо плоску квадратну 3D-модель.
main.collection і створіть новий ігровий об’єкт (game object) із назвою «grade».grade».quad.gltf, розташований у builtins/assets/meshes.Залиште ігровий об’єкт у початку координат без масштабування. Згодом, під час рендерингу квадрата, ми спроєктуємо його так, щоб він заповнював увесь екран. Але спочатку нам потрібні матеріал і шейдерні програми для квадрата:
grade.material: клацніть правою кнопкою миші main у панелі Asset і виберіть New ▸ Material.grade.vp і фрагментну шейдерну програму з назвою grade.fp: клацніть правою кнопкою миші main у панелі Asset і виберіть New ▸ Vertex program та New ▸ Fragment program.view_proj» типу CONSTANT_TYPE_VIEWPROJ. Це матриця виду-проєкції, яку вершинна програма використовує для вершин квадрата.original». Він використовуватиметься для вибірки пікселів із колірного буфера позаекранної цілі рендерингу.Додайте Tag із назвою «grade». У скрипті рендерингу ми створимо новий предикат рендерингу, що відповідатиме цьому тегу, щоб намалювати квадрат.

Відкрийте main.collection, виберіть компонент моделі в ігровому об’єкті «grade» і встановіть для його властивості Material значення «/main/grade.material».

Вершинну шейдерну програму можна залишити такою, якою її створено з базового шаблону:
// grade.vp
uniform mediump mat4 view_proj;
// positions are in world space
attribute mediump vec4 position;
attribute mediump vec2 texcoord0;
varying mediump vec2 var_texcoord0;
void main()
{
gl_Position = view_proj * vec4(position.xyz, 1.0);
var_texcoord0 = texcoord0;
}
У фрагментній шейдерній програмі замість того, щоб безпосередньо присвоювати gl_FragColor вибране значення кольору, виконаємо просте перетворення кольору. Передусім це потрібно, щоб переконатися, що на цьому етапі все працює як очікується:
// grade.fp
varying mediump vec4 position;
varying mediump vec2 var_texcoord0;
uniform lowp sampler2D original;
void main()
{
vec4 color = texture2D(original, var_texcoord0.xy);
// Desaturate the color sampled from the original texture
float grey = color.r * 0.3 + color.g * 0.59 + color.b * 0.11;
gl_FragColor = vec4(grey, grey, grey, 1.0);
}
Тепер у нас є модель квадрата з матеріалом і шейдерами. Залишилося лише намалювати її в буфері кадру екрана.
Нам потрібно додати предикат рендерингу до скрипту рендерингу, щоб мати змогу намалювати модель квадрата. Відкрийте grade.render_script і змініть функцію init():
function init(self)
self.tile_pred = render.predicate({"tile"})
self.gui_pred = render.predicate({"gui"})
self.text_pred = render.predicate({"text"})
self.particle_pred = render.predicate({"particle"})
self.grade_pred = render.predicate({"grade"}) -- <1>
...
end
grade.material.Після заповнення колірного буфера цілі рендерингу в update() ми налаштуємо вид і проєкцію так, щоб модель квадрата заповнювала весь екран. Потім використаємо колірний буфер цілі рендерингу як текстуру квадрата:
function update(self)
render.set_render_target(self.target)
...
render.set_render_target(render.RENDER_TARGET_DEFAULT)
render.clear({[graphics.BUFFER_TYPE_COLOR0_BIT] = self.clear_color}) -- <1>
render.set_viewport(0, 0, render.get_window_width(), render.get_window_height()) -- <2>
render.set_view(vmath.matrix4()) -- <3>
render.set_projection(vmath.matrix4())
render.enable_texture(0, self.target, graphics.BUFFER_TYPE_COLOR0_BIT) -- <4>
render.draw(self.grade_pred) -- <5>
render.disable_texture(0, self.target) -- <6>
end
render.clear() впливає на ціль рендерингу, а не на буфер кадру екрана.grade.material семплер «original» розташований у слоті 0, тож фрагментний шейдер виконуватиме вибірку з цілі рендерингу.grade.material, у якому задано цей тег, — отже, квадрат буде намальовано.Тепер запустімо гру й погляньмо на результат:

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

У цьому колірному кубі можна знайти будь-який колір, який може відображатися на екрані. Основна ідея кольорокорекції — використовувати такий куб, але зі зміненими кольорами, як тривимірну таблицю пошуку.
Для кожного пікселя:
Ми можемо зробити це у фрагментному шейдері:

Open GL ES 2.0 не підтримує 3D-текстури, тож нам потрібно знайти інший спосіб подання тривимірного колірного куба. Поширений спосіб — розрізати куб уздовж осі Z (синього кольору) та розмістити зрізи поруч один з одним у двовимірній сітці. Кожен із 16 зрізів містить сітку розміром 16⨉16 пікселів. Ми збережемо її в текстурі, з якої фрагментний шейдер зможе читати дані за допомогою семплера:

Отримана текстура містить 16 комірок (по одній для кожної інтенсивності синього кольору), а в кожній комірці — 16 відтінків червоного вздовж осі X і 16 відтінків зеленого вздовж осі Y. Текстура подає весь колірний простір RGB із 16 мільйонів кольорів за допомогою лише 4096 кольорів — усього 4 біти глибини кольору. За більшістю мірок це вкрай мало, але завдяки одній можливості графічного обладнання GL ми можемо повернути дуже високу точність кольору. Розгляньмо, як саме.
Щоб знайти колір, потрібно перевірити синій компонент і визначити, з якої комірки взяти значення червоного та зеленого. Формула для пошуку комірки з потрібним набором червоно-зелених кольорів проста:
cell = \left \lfloor{B \times (N - 1)} \right \rfloor
Тут B — значення синього компонента від 0 до 1, а N — загальна кількість комірок. У нашому випадку номер комірки буде в діапазоні 0–15, де комірка 0 містить усі кольори зі значенням синього компонента 0, а комірка 15 — усі кольори зі значенням синього компонента 1.
Наприклад, значення RGB (0.63, 0.83, 0.4) міститься в комірці з усіма кольорами, у яких значення синього дорівнює 0.4, тобто в комірці номер 6. Знаючи це, легко знайти остаточні координати текстури за значеннями зеленого й червоного:

Зауважте, що значення червоного та зеленого (0, 0) потрібно вважати центром нижнього лівого пікселя, а значення (1.0, 1.0) — центром верхнього правого пікселя.
Ми зчитуємо від центра нижнього лівого пікселя до центра верхнього правого, щоб пікселі поза поточною коміркою не впливали на вибране значення. Про фільтрацію читайте нижче.
Під час вибірки за цими координатами текстури ми бачимо, що потрапляємо точно між 4 пікселями. То яке значення кольору GL поверне для цієї точки?

Відповідь залежить від того, як ми налаштували фільтрацію семплера в матеріалі.
Якщо для фільтрації семплера задано NEAREST, GL поверне значення кольору найближчого пікселя (позиція округлюється вниз). У наведеному вище випадку GL поверне значення кольору в позиції (0.60, 0.80). Для нашої 4-бітної текстури пошуку це означає квантування значень кольорів до всього 4096 кольорів.
Якщо для фільтрації семплера задано LINEAR, GL поверне інтерпольоване значення кольору. GL змішає колір на основі відстані до пікселів навколо позиції вибірки. У наведеному вище випадку GL поверне колір, що складається на 25% із кольору кожного з 4 пікселів навколо точки вибірки.
Отже, завдяки лінійній фільтрації ми усуваємо квантування кольорів і отримуємо дуже добру точність кольору з досить малої таблиці пошуку.
Реалізуймо пошук у текстурі у фрагментному шейдері:
grade.material.lut» (від lookup table — таблиця пошуку).Установіть для властивості Filter min значення FILTER_MODE_MIN_LINEAR, а для властивості Filter mag — FILTER_MODE_MAG_LINEAR.

Завантажте наведену нижче текстуру таблиці пошуку (lut16.png) і додайте її до свого проєкту.

Відкрийте main.collection і встановіть для властивості текстури lut завантажену текстуру пошуку.

Нарешті, відкрийте grade.fp, щоб додати підтримку пошуку кольорів:
varying mediump vec4 position;
varying mediump vec2 var_texcoord0;
uniform lowp sampler2D original;
uniform lowp sampler2D lut; // <1>
#define MAXCOLOR 15.0 // <2>
#define COLORS 16.0
#define WIDTH 256.0
#define HEIGHT 16.0
void main()
{
vec4 px = texture2D(original, var_texcoord0.xy); // <3>
float cell = floor(px.b * MAXCOLOR); // <4>
float half_px_x = 0.5 / WIDTH; // <5>
float half_px_y = 0.5 / HEIGHT;
float x_offset = half_px_x + px.r / COLORS * (MAXCOLOR / COLORS);
float y_offset = half_px_y + px.g * (MAXCOLOR / COLORS); // <6>
vec2 lut_pos = vec2(cell / COLORS + x_offset, y_offset); // <7>
vec4 graded_color = texture2D(lut, lut_pos); // <8>
gl_FragColor = graded_color; // <9>
}
lut.px) із початкової текстури (колірного буфера позаекранної цілі рендерингу).px.px.Наразі текстура таблиці пошуку просто повертає ті самі значення кольорів, які ми шукаємо. Це означає, що гра має рендеритися зі своїми початковими кольорами:

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

На синьому градієнті видно дуже неприємні смуги. Чому так?
Смуги в синьому каналі виникають тому, що GL не може виконати інтерполяцію синього каналу під час зчитування кольору з текстури. Ми заздалегідь вибираємо певну комірку для зчитування за значенням синього кольору — і на цьому все. Наприклад, якщо синій канал містить будь-яке значення в діапазоні 0.400–0.466, конкретна величина не впливає на результат — ми завжди вибиратимемо остаточний колір із комірки номер 6, де синій канал має значення 0.400.
Щоб отримати вищу роздільну здатність синього каналу, ми можемо реалізувати інтерполяцію самостійно. Якщо значення синього лежить між значеннями двох сусідніх комірок, ми можемо виконати вибірку з обох комірок, а потім змішати кольори. Наприклад, якщо значення синього дорівнює 0.420, потрібно виконати вибірку з комірки номер 6 і з комірки номер 7, а потім змішати кольори.
Отже, потрібно зчитати дані з двох комірок:
cell_{low} = \left \lfloor{B \times (N - 1)} \right \rfloor
і:
cell_{high} = \left \lceil{B \times (N - 1)} \right \rceil
Потім ми вибираємо значення кольору з кожної з цих комірок і лінійно інтерполюємо кольори за формулою:
color = color_{low} \times (1 - C_{frac}) + color_{high} \times C_{frac}
Тут color~low~ — колір, вибраний із комірки з меншим номером (лівішої), а color~high~ — колір, вибраний із комірки з більшим номером (правішої). Функція GLSL mix() виконує цю лінійну інтерполяцію за нас.
Значення C~frac~ вище — це дробова частина значення синього каналу, масштабованого до діапазону кольорів 0–15:
C_{frac} = B \times (N - 1) - \left \lfloor{B \times (N - 1)} \right \rfloor
У GLSL також є функція, яка повертає дробову частину значення. Вона називається frac(). Остаточна реалізація у фрагментному шейдері (grade.fp) досить проста:
varying mediump vec4 position;
varying mediump vec2 var_texcoord0;
uniform lowp sampler2D original;
uniform lowp sampler2D lut;
#define MAXCOLOR 15.0
#define COLORS 16.0
#define WIDTH 256.0
#define HEIGHT 16.0
void main()
{
vec4 px = texture2D(original, var_texcoord0.xy);
float cell = px.b * MAXCOLOR;
float cell_l = floor(cell); // <1>
float cell_h = ceil(cell);
float half_px_x = 0.5 / WIDTH;
float half_px_y = 0.5 / HEIGHT;
float r_offset = half_px_x + px.r / COLORS * (MAXCOLOR / COLORS);
float g_offset = half_px_y + px.g * (MAXCOLOR / COLORS);
vec2 lut_pos_l = vec2(cell_l / COLORS + r_offset, g_offset); // <2>
vec2 lut_pos_h = vec2(cell_h / COLORS + r_offset, g_offset);
vec4 graded_color_l = texture2D(lut, lut_pos_l); // <3>
vec4 graded_color_h = texture2D(lut, lut_pos_h);
// <4>
vec4 graded_color = mix(graded_color_l, graded_color_h, fract(cell));
gl_FragColor = graded_color;
}
cell, тобто масштабованого значення синього кольору.Повторний запуск гри з тестовою текстурою тепер дає значно кращий результат. Смуги в синьому каналі зникли:

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

lut16.png).lut16.png, яку використовує ваш проєкт Defold, на текстуру зі скоригованими кольорами.
Чудово!