Программирование игр, создание игрового движка, OpenGL, DirectX, физика, форум
GameDev.ru / Программирование / Статьи / Кое-что о размытии изображения с помощью шейдеров.

Кое-что о размытии изображения с помощью шейдеров.

Автор:

Эта статья познакомит читателя с основными техниками размытия изображения при помощи GLSL шейдеров. Данные техники широко применяется в играх для создания таких эффектов как засветка (bloom), глубина резко изображаемого пространства (DOF), эфект движения на большой скорости итд.

Введение
Двухпроходные алгоритмы
  Усреднение соседних пикселей
  Треугольный закон распределения
  Нормальный закон распределения

Введение

title | Кое-что о размытии изображения с помощью шейдеров.

По сути для того, чтобы размыть изображение необходимо каким-то образом усреднить значения цвета каждого пикселя изображения с цветами его соседей в пределах некоторого диапазона. Этот диапазон будем называть радиусом размытия. Очевидным решением будет следующее: сложить значения цвета для каждого пикселя, расположенного не дальше, чем радиус размытия от текущего, по X и Y координате, а затем поделить их на квадрат двух радиусов размытия плюс единица (так как мы слева, справа, сверху и снизу выбираем пиксели на расстоянии не больше чем радиус + центральные пиксели). Однако давайте посчитаем, сколько же пикселей нам придется получить из изображения. Результатом работы вот такого кода:

int value = 0;
for (int y = -r; y <= R; ++y)
 for (int x = -r; x <= R; ++x)
  ++x;

будет значение, равное (1+2•r) (1+2•r), где r — радиус размытия. Для радиуса размытия = 10 это получается 441 выборка! Это огромная сложность, которую не везде можно использовать. Именно для упрощения этой сложности и придумали двухпроходные алгоритмы размытия. Суть таких алгоритмов состоит в том, что изображение размывается сначала по горизонтали (или по вертикали) и сохраняется во временное изображение, после чего полученное изображение размывается в другом направлении. В данном случае мы получим сложность (1+2•R) + (1+2•R). Для радиуса размытия = 10 мы получаем 42 выборки на пиксель, а это почти в R раз меньше.

Также, при размытии изображения следует учитывать веса пикселей. При их вариации можно получить различный результат. Так, если все пиксели будут иметь одинаковый вес, мы получим усредненное изображение. Если же придать центральным пикселям больше веса чем крайним, то изобажение будет более «сконцентрированно». Для вариации весов можно использовать различные законы распределения, известных из математической статистики.

Итак, рассмотрим основные двухпроходные алгоритмы размытия. Для примера возьмем вот такую исходную картинку:

sample0 | Кое-что о размытии изображения с помощью шейдеров.

Двухпроходные алгоритмы

Усреднение соседних пикселей

Это наиболее простой алгоритм. Используем равномерый закон распределения. В этом случае все пиксели будут иметь одинаковый вес. Положим радиус размытия равным десяти:

uniform | Кое-что о размытии изображения с помощью шейдеров.

магическое число 21 является суммой всех весов соседних пикселей (конечно же для радиуса размытия равного 10). На это число нам нужно будет поделить сумму цветов соседних пикселей. Как видно, сумма центрального и соседних пикселей (с каждой из сторон) в точности равняется значению 2*R + 1. Таким образом нет необходимости высчитывать эту сумму.

Рассмотрим реализацию данного алгоритма на шейдерном языке GLSL

uniform sampler2D source_image;
uniform vec3 texel_radius;

in vec2 TexCoord;
out vec4 FragColor;

void main()
{
 float radius = texel_radius.z;

 vec4 value = texture(source_image, TexCoord);
 float totalScale = 2.0 * radius + 1.0;
 
 float x = 1.0;
 while (x <= radius)
 {
  vec2 dudv = texel_radius.xy * x;

  value += texture(source_image, TexCoord - dudv) +
           texture(source_image, TexCoord + dudv);

  x += 1.0;
 }

 FragColor = value / totalScale;
}

Входными параметрами являются сэмплер для исходного изображения и трехкомпонентный вектор, который содержит в себе шаг размытия и радиус. Шагом размытия в данном случае будет (1.0 / (ширина изображения); 0.0) для горизонтального размытия и (0.0; 1.0 / (высота изображения)) для вертикального. В дальнейших реализациях будем использовать ту же терминологию. После двух проходов размытое изображение будет выглядеть вот так:

sample_uniform | Кое-что о размытии изображения с помощью шейдеров.

Треугольный закон распределения

Следующим по сложности алгоритмом является алгоритм, использующий закон распределения Симпсона. В данном законе распределения веса соседних точек на текстуре, с увеличением расстояния до центральной точки, убывают линейно. Как и раньше, положим радиус размытия равным десяти. Так же как и раньше нам необходимо будет найти сумму всех весов соседних пикселей текстуры и поделить финальный результат на неё. Для этого, положим вес центрального пискеля равным (r+1). В таком случае веса соседних пикселей будут изменяться в соответствии с функцией f(x) (на картинке ниже). Веса крайних точек в этом случае будут равны единице.

triangular | Кое-что о размытии изображения с помощью шейдеров.

Сумма весов всех пикселей в данном случае будет выражаться формулой (R+1)2.

Реализация данного алгоритма на GLSL приведена ниже:

uniform sampler2D source_image;
uniform vec3 texel_radius;

in vec2 TexCoord;
out vec4 FragColor;

void main()
{
 float r = texel_radius.z;

 float totalScale = 1.0 + r;
 vec4 value = texture(source_image, TexCoord) * totalScale;

 float x = 1.0;
 while (x <= r)
 {
  vec2 dudv = texel_radius.xy * x;
  float scale = 1.0 + r - x;
  value += scale * (texture(source_image, TexCoord - dudv) +
                    texture(source_image, TexCoord + dudv));
  x += 1.0;
 }

 FragColor = value / totalScale / totalScale;
}

Результат обработки изображения этим алгоритмом будет выглядеть вот так:

sample_triangular | Кое-что о размытии изображения с помощью шейдеров.

Нормальный закон распределения

Следующим алгоритмом размытия, который мы рассмотрим будет так называемое размытие по Гауссу. Именно оно используется в фотошопе. Суть его заключается в использовании нормального закона распределения. Обычно нормальный закон распределения включает в себя два параметра: математическое ожидание и дисперсию. Для нашего случая будем считать математическое ожидание равным нулю. В этом случае закон распределения включает только один параметр — дисперсию. Такой упрощенный закон распределения показан на рисунке ниже (функция f(x)). Так как в данном законе используется дисперсия величины, а не радиус, то воспользуемся правилом трех сигм. Оно гласит о том, что в диапазон -3σ...+3σ попадает 99.73% всех величин из распределения. Положим радиус равным десяти, в этом случае σ будет равна 10 / 3 = 3 (дробную часть не учитываем, так как мы делаем шаг на целое число пикселей в сторону от центрального).

Следует заметить, что в данном алгоритме сумма весов всех соседних пикселей будет близка к единице. То есть нам не придется делить или умножать полученный результат на какое-либо число. Но также не следует забывать, что в выбранный предел попадает не 100% величин, а только 99.73%. Это означает что сумма будет меньше единицы и финальное изображение будет затемняться. Чтобы устранить этот недостаток увеличим изменим вес центральной точки на (1.0 - [сумма весов соседних]) и в итоге получим сумму весов всех пикселей равной единице.

normal | Кое-что о размытии изображения с помощью шейдеров.

Рассмотрим код данного алгоритма на GLSL:

uniform sampler2D source_image;
uniform vec3 texel_radius;

in vec2 TexCoord;
out vec4 FragColor;

#define M_PI 3.1415926535897932384626433832795

float gauss(float x, float sigma)
{
 float x_sqr = x * x;
 float sigma_sqr = sigma * sigma;
 float sqrt_value = 1.0 / sqrt(2.0 * M_PI * sigma_sqr);
 float exp_value = exp( -x_sqr / (2.0 * sigma_sqr) );
 return sqrt_value * exp_value;
}

void main()
{
 float r = texel_radius.z;
 float sigma = r / 3.0;

 float sum = 0.0;
 vec4 value = vec4(0.0);

 float x = 1.0;
 while (x <= r)
 {
  float currentScale = gauss(x, sigma);
  sum += 2.0 * currentScale;

  vec2 dudv = texel_radius.xy * x;
  value += currentScale * (texture(source_image, TexCoord - dudv) +
                     texture(source_image, TexCoord + dudv) );
  x += 1.0;
 }

 value += texture(source_image, TexCoord) * (1.0 - sum);
 
 FragColor = value;
}

Как видим, здесь выполняется очень много лишних инструкций. Каждый раз пересчитываются константы и т.д. Давайте немного оптимизируем этот код:
1) вынесем сигма из под корня и вычислим константу 1.0 / sqrt(2.0 * PI),
2) так как сигма у нас не меняется, следовательно не меняется все выражение перед экспонентой, а так же множитель при x в выражении внутри экспоненты, следовательно все это можно посчитать один раз и вынести «за скобки»,
3) избавимся от лишней операции деления на три, подставив тройку в выражения.

После проделанных операций получим вот такой вот шейдер

uniform sampler2D source_image;
uniform vec3 texel_radius;

in vec2 TexCoord;
out vec4 FragColor;

#define INV_SQRT_2PI_X3 1.1968268412042980338198381798031

void main()
{
 float r = texel_radius.z;

 float exp_value = -4.5 / r / r;
 float sqrt_value = INV_SQRT_2PI_X3 / r;

 float sum = 0.0;
 vec4 value = vec4(0.0);

 float x = 1.0;
 while (x <= r)
 {
  float currentScale = exp(exp_value * x * x);
  sum += currentScale;

  vec2 dudv = texel_radius.xy * x;
  value += currentScale * (texture(source_image, TexCoord - dudv) +
                     texture(source_image, TexCoord + dudv) );
  x += 1.0;
 }

 float correction = 1.0 / sqrt_value - 2.0 * sum;
 value += texture(source_image, TexCoord) * correction;
 
 FragColor = value * sqrt_value;
}

Такой оптимизацией мы не только уменьшили размер шейдера, но и увеличили скорость примерно в 1.15 раза (тестировалось при радиусе размытия = 50). Это, согласитесь, немного, но полезно :)

Результат размытия по Гауссу будет выглядеть следующим образом:

sample_normal | Кое-что о размытии изображения с помощью шейдеров.

Положим радиус размытия равным 30 и сравним результат с размытием в фотошопе:

sample_normal_ps | Кое-что о размытии изображения с помощью шейдеров. sample_normal_ps_diff | Кое-что о размытии изображения с помощью шейдеров.
Первое изображение, размытое с радиусом 30 пикселей, второе — разница между приведенным алгоритмом и фильтром фотошопа

28 сентября 2013

#blur, #postprocess

2001—2017 © GameDev.ru — Разработка игр