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
В этом руководстве мы создадим полноэкранный post effect для цветокоррекции. Базовый прием рендеринга, который здесь используется, подходит и для многих других пост-эффектов: blur, trails, glow, корректировки цвета и так далее.
Предполагается, что вы уже немного знакомы с редактором Defold и в общих чертах понимаете GL shaders и render pipeline движка. Если нет, сначала стоит посмотреть руководство по Shader и руководство по Render.
В стандартном render script каждый визуальный компонент — sprite, tilemap, particle effect, GUI и так далее — рисуется напрямую в frame buffer видеокарты. Дальше железо выводит изображение на экран. За отрисовку пикселей отвечает GL shader program. Для каждого типа компонента Defold поставляет стандартный шейдер, который просто выводит изображение без изменений.
Обычно это именно то, что нужно. Но если вы хотите, например, сделать всю игру черно-белой, модифицировать отдельный шейдер для каждого типа компонента неудобно. В Defold есть несколько встроенных материалов и наборов vertex/fragment-программ, и поддерживать это вручную быстро становится тяжело. Урок Shadertoy tutorial показывает, как делать подобные изменения в шейдерах.
Гораздо гибче разделить рендеринг на два шага:

Так мы получаем возможность сначала собрать итоговую картинку, а затем модифицировать ее перед выводом на экран. Именно на этом шаге и удобно реализовывать полноэкранные эффекты.
Нам нужно изменить встроенный render script и добавить туда новую логику. Удобнее всего начать с копии стандартного:
*/builtins/render/default.render_script*: в Asset view щелкните по default.render_script, выберите Copy, затем вставьте копию в папку main и переименуйте, например, в grade.render_script.*/main/grade.render* через New ▸ Render.Откройте grade.render и укажите для свойства Script значение /main/grade.render_script.

Откройте game.project и в поле Render укажите /main/grade.render.

Теперь игра будет запускаться с новым render pipeline, который можно изменять. Чтобы быстро проверить, что движок использует именно ваш render script, можно временно отключить отрисовку tile/sprite и сделать hot reload:
...
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()
...
tile, в который входят sprite и tilemap.Если после этого спрайты и тайлы пропали, значит игра действительно использует вашу копию render script.
Теперь изменим render script так, чтобы он рисовал в off-screen render target, а не сразу в frame buffer. Для начала target нужно создать:
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, 0)
self.clear_color.x = sys.get_config("render.clear_color_red", 0)
self.clear_color.y = sys.get_config("render.clear_color_green", 0)
self.clear_color.z = sys.get_config("render.clear_color_blue", 0)
self.clear_color.w = sys.get_config("render.clear_color_alpha", 0)
self.view = vmath.matrix4()
local color_params = { format = render.FORMAT_RGBA,
width = render.get_width(),
height = render.get_height() } -- <1>
local target_params = {[render.BUFFER_COLOR_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({[render.BUFFER_COLOR_BIT] = self.clear_color, [render.BUFFER_DEPTH_BIT] = 1, [render.BUFFER_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() теперь рисуют в off-screen target.Если запустить игру на этом этапе, на экране будет черный экран: теперь вся графика рисуется не напрямую на экран, а в off-screen buffer.
Чтобы нарисовать содержимое color buffer render target на экране, нам нужен объект, который можно текстурировать этими пикселями. Для этого возьмем плоскую квадратную 3D-модель.
main.collection и создайте игровой объект grade.quad.gltf из builtins/assets/meshes.Оставьте объект в начале координат и без масштабирования. Позже мы настроим такую проекцию, чтобы quad заполнял весь экран. Но сначала нужны material и shader programs:
grade.material.grade.vp и fragment shader grade.fp.grade.material и укажите в нем новые shader-файлы.view_proj типа CONSTANT_TYPE_VIEWPROJ.original.Добавьте Tag с именем grade.

Откройте main.collection, выберите model-компонент объекта grade и задайте для него материал /main/grade.material.

Vertex shader можно оставить почти стандартным:
// 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;
}
Во fragment shader вместо прямого вывода sampled color временно сделаем простую десатурацию:
// 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);
}
Теперь у нас есть quad-модель, material и shaders. Осталось нарисовать ее в frame buffer.
Добавим в render script новый predicate для quad-модели. Откройте 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, который мы задали в grade.material.После того как render target будет заполнен, в update() нужно настроить view/projection так, чтобы quad занял весь экран, а color buffer target использовался как его texture:
function update(self)
render.set_render_target(self.target)
...
render.set_render_target(render.RENDER_TARGET_DEFAULT)
render.clear({[render.BUFFER_COLOR_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, render.BUFFER_COLOR_BIT) -- <4>
render.draw(self.grade_pred) -- <5>
render.disable_texture(0, self.target) -- <6>
end
0.grade, то есть наш quad.Запустите игру, и вы увидите результат:

Цвет задается тремя компонентами: количеством красного, зеленого и синего. Все экранные цвета можно представить как точки внутри цветового куба:

Идея color grading проста: взять такой цветовой куб, но с уже измененными цветами, и использовать его как 3D lookup table.
Для каждого пикселя:
В нашем fragment shader это означает:

Open GL ES 2.0 не поддерживает 3D textures, поэтому 3D color cube придется представить иначе. Обычно его режут на слои по оси Z (синий канал) и раскладывают эти слои рядом в двухмерной texture. Каждый из 16 слоев содержит сетку 16⨉16 пикселей:

Получившаяся texture содержит 16 ячеек, по одной на каждый уровень синего, а внутри каждой ячейки по оси X идут красные значения, а по оси Y — зеленые. Формально это всего 4096 цветов, но благодаря линейной фильтрации видеокарта может восстановить намного лучшую точность.
Чтобы найти нужный цвет, сначала смотрим на синий канал и определяем, в какой ячейке его искать:
cell = \left \lfloor{B \times (N - 1)} \right \rfloor
Здесь B — значение синего компонента, а N — общее количество ячеек. В нашем случае номер ячейки будет в диапазоне 0–15, где ячейка 0 содержит все цвета с синим компонентом 0, а ячейка 15 — все цвета с синим компонентом 1.
Например, RGB-цвет (0.63, 0.83, 0.4) находится в ячейке с синим значением 0.4, то есть это ячейка 6. Дальше по значениям red и green можно вычислить окончательные texture coordinates:

Важно читать значения (0, 0) как находящиеся в центре нижнего левого пикселя, а значения (1.0, 1.0) как находящиеся в центре верхнего правого пикселя, чтобы значения за пределами текущей ячейки не влияли на выборку. Подробнее о фильтрации ниже.
Именно поэтому чтение ведется от центра нижнего левого пикселя до центра верхнего правого: так соседние ячейки не влияют на выборку.
Когда мы семплируем в этих конкретных координатах, то оказываемся ровно между четырьмя пикселями. Так какое значение цвета выдаст GL в этой точке?

Ответ зависит от того, какую filtering мы указали для sampler в material.
Если filtering = NEAREST, GL вернет значение ближайшего пикселя. В приведенном выше случае GL вернет значение в позиции (0.60, 0.80). Для нашей lookup texture с 4 bit это означает, что цвет будет квантован до 4096 значений.
Если filtering = LINEAR, GL вернет interpolated значение. GL смешает цвет на основе расстояния до пикселей вокруг точки выборки. В приведенном выше случае GL вернет цвет, который на 25% состоит из каждого из 4 пикселей вокруг точки выборки.
Используя линейную фильтрацию, мы убираем квантование цвета и получаем очень хорошую точность даже из довольно небольшой lookup table.
Сделаем lookup прямо во fragment shader:
grade.material.lut.Установите Filter min = FILTER_MODE_MIN_LINEAR и Filter mag = FILTER_MODE_MAG_LINEAR.

Скачайте текстуру lookup table lut16.png и добавьте ее в проект.

Откройте main.collection и укажите texture lut для модели.

Затем откройте grade.fp и добавьте поддержку lookup:
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 из исходной текстуры.Сейчас lookup texture просто возвращает те же цвета, что мы ищем, поэтому игра должна выглядеть как раньше:

Но под поверхностью есть проблема. Посмотрите, что происходит, если добавить sprite с тестовым синим градиентом:

Появляются заметные полосы. Почему?
Проблема в том, что GL не может интерполировать синий канал при чтении цвета из texture. Мы заранее выбираем конкретную ячейку по синему значению, и на этом все. Например, если значение синего канала находится в диапазоне 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(). Финальная реализация во fragment shader (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 в вашем проекте на новую версию.
Готово.