Tutorials
Tutorials

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 парами вершинних і фрагментних шейдерних програм, тож це потребуватиме чимало роботи. Крім того, кожну подальшу зміну або новий ефект доведеться додавати до кожної шейдерної програми.

Значно гнучкіший підхід — виконувати рендеринг у два окремі етапи:

Ціль рендерингу

  1. Намалюйте всі компоненти як зазвичай, але в позаекранному буфері замість звичайного буфера кадру. Для цього малювання спрямовують у так звану ціль рендерингу (render target).
  2. Намалюйте квадратний полігон у буфері кадру й використайте піксельні дані, збережені в цілі рендерингу, як джерело текстури полігона. Також переконайтеся, що квадратний полігон розтягнуто на весь екран.

Цей метод дає змогу зчитати отримані візуальні дані та змінити їх перед виведенням на екран. Додавши шейдерні програми на другому етапі, ми легко можемо отримати повноекранні ефекти. Розгляньмо, як це налаштувати в Defold.

Налаштування власного рендерера

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

  1. Скопіюйте /builtins/render/default.render_script: у панелі Asset клацніть правою кнопкою миші default.render_script, виберіть Copy, потім клацніть правою кнопкою миші main і виберіть Paste. Клацніть правою кнопкою миші копію, виберіть Rename... і надайте їй відповідну назву, наприклад «grade.render_script».
  2. Створіть новий файл рендерингу з назвою /main/grade.render: клацніть правою кнопкою миші main у панелі Asset і виберіть New ▸ Render.
  3. Відкрийте grade.render і встановіть для його властивості Script значення «/main/grade.render_script».

    grade.render

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

    game.project

Тепер гру налаштовано на роботу з новим конвеєром рендерингу, який ми можемо змінювати. Щоб перевірити, чи рушій використовує нашу копію скрипту рендерингу, запустіть гру, внесіть у скрипт рендерингу зміну з видимим результатом, а потім перезавантажте скрипт. Наприклад, ви можете вимкнути малювання плиток і спрайтів, а тоді натиснути ⌘ + 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()

...
  1. Закоментуйте малювання предиката «tile», до якого входять усі спрайти та плитки. Цей рядок коду можна знайти приблизно на рядку 33 файлу скрипту рендерингу.

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

Малювання в позаекранну ціль

Тепер змінимо скрипт рендерингу так, щоб він малював у позаекранну ціль рендерингу замість буфера кадру. Спочатку потрібно створити ціль рендерингу:

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
  1. Задайте параметри колірного буфера для цілі рендерингу. Ми використовуємо цільову роздільну здатність гри.
  2. Створіть ціль рендерингу із заданими параметрами колірного буфера.

Тепер потрібно лише обгорнути початковий код рендерингу викликами 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
  1. Увімкніть ціль рендерингу. Відтепер кожен виклик render.draw() малюватиме в буфери нашої позаекранної цілі рендерингу.
  2. Увесь початковий код малювання в update() залишається без змін, окрім області перегляду, розмір якої встановлено відповідно до роздільної здатності цілі рендерингу.
  3. На цьому етапі всю графіку гри вже намальовано в ціль рендерингу. Тож час вимкнути її, перемкнувшись на стандартну ціль рендерингу.

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

Чим заповнити екран

Щоб намалювати на екрані пікселі з колірного буфера цілі рендерингу, нам потрібно підготувати щось, до чого можна застосувати ці піксельні дані як текстуру. Для цього ми використаємо плоску квадратну 3D-модель.

  1. Відкрийте main.collection і створіть новий ігровий об’єкт (game object) із назвою «grade».
  2. Додайте компонент Model до ігрового об’єкта «grade».
  3. Установіть для властивості Mesh компонента моделі файл quad.gltf, розташований у builtins/assets/meshes.

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

  1. Створіть новий матеріал із назвою grade.material: клацніть правою кнопкою миші main у панелі Asset і виберіть New ▸ Material.
  2. Створіть вершинну шейдерну програму з назвою grade.vp і фрагментну шейдерну програму з назвою grade.fp: клацніть правою кнопкою миші main у панелі Asset і виберіть New ▸ Vertex program та New ▸ Fragment program.
  3. Відкрийте grade.material і встановіть для властивостей Vertex program та Fragment program нові файли шейдерних програм.
  4. Додайте Vertex constant із назвою «view_proj» типу CONSTANT_TYPE_VIEWPROJ. Це матриця виду-проєкції, яку вершинна програма використовує для вершин квадрата.
  5. Додайте Sampler із назвою «original». Він використовуватиметься для вибірки пікселів із колірного буфера позаекранної цілі рендерингу.
  6. Додайте Tag із назвою «grade». У скрипті рендерингу ми створимо новий предикат рендерингу, що відповідатиме цьому тегу, щоб намалювати квадрат.

    grade.material

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

    властивості моделі

  8. Вершинну шейдерну програму можна залишити такою, якою її створено з базового шаблону:

     // 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;
     }
    
  9. У фрагментній шейдерній програмі замість того, щоб безпосередньо присвоювати 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
  1. Додайте новий предикат, що відповідає тегу «grade», який ми задали в 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
  1. Очистіть буфер кадру. Зауважте, що попередній виклик render.clear() впливає на ціль рендерингу, а не на буфер кадру екрана.
  2. Установіть розмір області перегляду відповідно до розміру вікна.
  3. Установіть одиничну матрицю виду. Це означає, що камера розташована в початку координат і дивиться прямо вздовж осі Z. Також установіть одиничну матрицю проєкції, щоб квадрат проєктувався пласко на весь екран.
  4. Призначте текстурному слоту 0 колірний буфер цілі рендерингу. У нашому grade.material семплер «original» розташований у слоті 0, тож фрагментний шейдер виконуватиме вибірку з цілі рендерингу.
  5. Намалюйте за створеним предикатом усі об’єкти, матеріал яких має тег «grade». Модель квадрата використовує grade.material, у якому задано цей тег, — отже, квадрат буде намальовано.
  6. Після малювання вимкніть текстурний слот 0, оскільки малювання з його використанням завершено.

Тепер запустімо гру й погляньмо на результат:

знебарвлена гра

Кольорокорекція

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

колірний куб

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

Для кожного пікселя:

  1. Знайдіть позицію його кольору в колірному кубі (за значеннями червоного, зеленого та синього).
  2. Зчитайте колір, збережений у цій позиції куба після кольорокорекції.
  3. Намалюйте піксель зчитаним кольором замість початкового.

Ми можемо зробити це у фрагментному шейдері:

  1. Виберіть значення кольору для кожного пікселя в позаекранному буфері.
  2. Знайдіть позицію кольору вибраного пікселя в колірному кубі після кольорокорекції.
  3. Установіть для вихідного кольору фрагмента знайдене значення.

кольорокорекція цілі рендерингу

Подання таблиці пошуку

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 — загальна кількість комірок. У нашому випадку номер комірки буде в діапазоні 015, де комірка 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 пікселів навколо точки вибірки.

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

Реалізація пошуку

Реалізуймо пошук у текстурі у фрагментному шейдері:

  1. Відкрийте grade.material.
  2. Додайте другий семплер із назвою «lut» (від lookup table — таблиця пошуку).
  3. Установіть для властивості Filter min значення FILTER_MODE_MIN_LINEAR, а для властивості Filter magFILTER_MODE_MAG_LINEAR.

    семплер таблиці пошуку

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

    таблиця пошуку на 16 кольорів

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

    таблиця пошуку моделі квадрата

  6. Нарешті, відкрийте 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>
     }
    
    1. Оголосіть семплер lut.
    2. Константи для максимального кольору (15, оскільки відлік починається з 0), кількості кольорів на канал, ширини та висоти текстури пошуку.
    3. Виберіть колір пікселя (названий px) із початкової текстури (колірного буфера позаекранної цілі рендерингу).
    4. Визначте, з якої комірки зчитати колір, за значенням синього каналу px.
    5. Обчисліть зміщення на половину пікселя, щоб зчитувати з центрів пікселів.
    6. Обчисліть зміщення X і Y у текстурі за значеннями червоного та зеленого в px.
    7. Обчисліть остаточну позицію вибірки в текстурі пошуку.
    8. Виберіть отриманий колір із текстури пошуку.
    9. Установіть для кольору текстури квадрата отриманий колір.

Наразі текстура таблиці пошуку просто повертає ті самі значення кольорів, які ми шукаємо. Це означає, що гра має рендеритися зі своїми початковими кольорами:

початковий вигляд світу

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

смуги в синьому градієнті

На синьому градієнті видно дуже неприємні смуги. Чому так?

Інтерполяція синього каналу

Смуги в синьому каналі виникають тому, що GL не може виконати інтерполяцію синього каналу під час зчитування кольору з текстури. Ми заздалегідь вибираємо певну комірку для зчитування за значенням синього кольору — і на цьому все. Наприклад, якщо синій канал містить будь-яке значення в діапазоні 0.4000.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~ вище — це дробова частина значення синього каналу, масштабованого до діапазону кольорів 015:

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;
}
  1. Визначте дві сусідні комірки для зчитування.
  2. Обчисліть дві окремі позиції пошуку — по одній для кожної комірки.
  3. Виберіть два кольори з позицій у комірках.
  4. Лінійно змішайте кольори відповідно до дробової частини cell, тобто масштабованого значення синього кольору.

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

синій градієнт без смуг

Кольорокорекція текстури пошуку

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

  1. Зробіть знімок екрана гри без ефектів.
  2. Відкрийте знімок екрана у своєму улюбленому редакторі зображень.
  3. Застосуйте будь-яку кількість коригувань кольору (яскравість, контраст, колірні криві, баланс білого, експозиція тощо).

світ у Affinity

  1. Застосуйте ті самі коригування кольору до файлу текстури таблиці пошуку (lut16.png).
  2. Збережіть файл текстури таблиці пошуку зі скоригованими кольорами.
  3. Замініть текстуру lut16.png, яку використовує ваш проєкт Defold, на текстуру зі скоригованими кольорами.
  4. Запустіть гру!

світ після кольорокорекції

Чудово!