Хабрахабр

Применяем мозаику Вороного, пикселизацию и геометрические маски в шейдерах для украшения сайта

image

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

Начинающие найдут здесь последовательность действий с советами и полезными замечаниями, а опытные фронтендеры возможно найдут себе пару идей для вдохновения. Так же, как и в прошлый раз, будет минимум теории и максимум практики и рассуждений на приземленном бытовом языке.

Чтобы не вводить их в недоумение последними фишками ES, мы намеренно ограничимся более традиционными синтаксическими конструкциями, понятными всем. Опрос в предыдущей статье показал, что тема WebGL-эффектов для сайтов может быть интересна не только верстальщикам, но и нашим коллегам других специализаций. И снова обращаю внимание читателей на то, что встроенные редакторы от CodePen влияют на производительность того, что в них выполняется.

Но приступим...

Шаблон для работы с шейдерами

Для тех, кто не читал предыдущую статью — мы сделали вот такой шаблон для работы с шейдерами:

Никаких лишних зависимостей и очень простой вершинный шейдер. В нем создается плоскость (в нашем случае квадрат), на которой "рисуется" картинка-текстура. Затем мы развивали этот шаблон, но сейчас начнем с момента, когда никакой логики во фрагментном шейдере еще нет.

Мозаика

Как мы вообще можем разбить нашу плоскость на кусочки? Мозаика — это разбитая на небольшие области плоскость, где каждая из областей заливается определенным цветом или, как в нашем случае, текстурой. Но это и так легко сделать с помощью SVG, приплетать к этой задаче WebGL и все уложнять на ровном месте совершенно ни к чему. Очевидно, что можно разбить ее на прямоугольники.

Есть один очень простой, но в то же время очень занятный подход к построению такого разбиения. Чтобы мозаика была интересной, в ней должны быть разные фрагменты, как по форме, так и по размеру. Идея примерно такая: Он известен как мозаика Вороного или разбиение Дирихле, а на википедии пишут, что еще Декарт использовал что-то подобное в далеком XVII веке.

  • Взять набор точек на плоскости.
  • Для каждой точки на плоскости найти ближайшую к ней точку из этого набора.
  • Собственно вот и все. Плоскость поделилась на многоугольные области, каждая из которых определяется одной из точек набора.

Есть разные алгоритмы генерации этого разбиения, мы же будем действовать в лоб, ибо расчет чего-то для каждой точки на плоскости — это как раз задача для шейдера. Наверное лучше показать этот процесс на практическом примере. Чтобы не загружать код примеров сделаем глобальную переменную для них. Для начала нам нужно сделать набор случайных точек.

function createPoints()
}

Данные глобальные, так что будем использовать модификатор uniform. Теперь нам нужно передать их в шейдеры. Казалось бы, 21 век на дворе, но тем не менее ничего не выйдет. Но здесь есть один тонкий момент: просто так передать массив мы не можем. В результате приходится передавать массив точек по одной штуке.

for (let i = 0; i < NUMBER_OF_POINTS; i++) { GL.uniform2fv(GL.getUniformLocation(PROGRAM, 'u_points[' + i + ']'), POINTS[i]);
}

Обычно в уроках по WebGL используют THREE.js и эта библиотека скрывает часть грязи в себе, как когда-то это делала jQuery в своих задачах, но стоит ее убрать, как реальность больно бьет по мозгам. Сегодня мы не раз столкнемся с подобными проблемами несоответствия ожидаемого и того, что есть в реальных браузерах.

Мы можем создавать массивы только фиксированной длины. Во фрагментном шейдере мы имеем переменную-массив для точек. Пусть для начала это будет 10 точек:

#define NUMBER_OF_POINTS 10 uniform vec2 u_points[NUMBER_OF_POINTS];

Подобное рисование различных геометрических примитивов часто применяется при отладке — их хорошо видно и сразу можно понять, что где находится и куда движется. Убедимся в работоспособности всего этого нарисовав круги на местах точек.

Это даст очевидные подсказки о том, как они работают, особенно если алгоритмы сложны для быстрого понимания без предварительной подготовки. Используйте "рисование" кругов, прямых и других ориентиров для невидимых объектов, на которых строятся анимации. Потом это все можно закомментировать и оставить для коллег — они скажут спасибо.

for (int i = 0; i < NUMBER_OF_POINTS; i++) { if (distance(texture_coord, u_points[i]) < 0.02) { gl_FragColor = WHITE; break; }
}

Давайте также добавим немного движения точкам. Хорошо. Коэффициенты поставлены также на глазок, просто чтобы немного замедлить их движение и уменьшить амплитуду колебаний. Пусть они двигаются по кругу для начала, мы потом еще вернемся к этому вопросу.

function movePoints(timeStamp) { if (timeStamp) { for (let i = 0; i < NUMBER_OF_POINTS; i++) { POINTS[i][0] += Math.sin(i * timeStamp / 5000.0) / 500.0; POINTS[i][1] += Math.cos(i * timeStamp / 5000.0) / 500.0; } }
}

Для будущих экспериментов нам пригодятся номера областей, на которые все поделится. Возвращаемся в шейдер. Так что мы находим ближайшую к текущему пикселю точку из набора и сохраняем номер этой точки — он же номер области.

float min_distance = 1.0;
int area_index = 0; for (int i = 0; i < NUMBER_OF_POINTS; i++) { float current_distance = distance(texture_coord, u_points[i]); if (current_distance < min_distance) { min_distance = current_distance; area_index = i; }
}

Для проверки работоспособности опять же покрасим все в яркие цвета:

gl_FragColor = texture2D(u_texture, texture_coord); gl_FragColor.g = abs(sin(float(area_index)));
gl_FragColor.b = abs(sin(float(area_index)));

Это с одной стороны добавляет немного случайности, а с другой стороны сразу дает нормализованный результат от 0 до 1, что очень удобно — у нас очень многие значения будут лежать именно в этих пределах. Сочетание модуля (abs) и ограниченных функций (в частности sin и cos) часто применяют при работе с подобными эффектами.

Это действие не несет особой полезной нагрузки, но посмотреть на результат все равно интересно. Также найдем точки, более-менее равноудаленные от нескольких точек из набора, и покрасим их.

int number_of_near_points = 0; for (int i = 0; i < NUMBER_OF_POINTS; i++) { if (distance(texture_coord, u_points[i]) < min_distance + EPSILON) { number_of_near_points++; }
} if (number_of_near_points > 1) { gl_FragColor.rgb = vec3(1.0);
}

Должно получиться что-то такое:

Но уже сейчас понятна общая концепция подобного разделения плоскости. Это пока черновой вариант, мы его еще будем дорабатывать.

Мозаика из фотографий

Для расширения кругозора и просто ради интереса можно с ним поиграть, но на реальном сайте стоило бы добавить еще пару фотографий и сделать мозаику из них. Понятное дело, что в чистом виде пользы от такого разбиения не очень много. Давайте немного переделаем функцию создания текстур, чтобы их было больше одной.

function createTextures() { for (let i = 0; i < URLS.textures.length; i++) { createTexture(i); }
} function createTexture(index) { const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => { const texture = GL.createTexture(); GL.activeTexture(GL['TEXTURE' + index]); GL.bindTexture(GL.TEXTURE_2D, texture); GL.pixelStorei(GL.UNPACK_FLIP_Y_WEBGL, true); GL.texImage2D(GL.TEXTURE_2D, 0, GL.RGB, GL.RGB, GL.UNSIGNED_BYTE, image); GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_WRAP_S, GL.CLAMP_TO_EDGE); GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_WRAP_T, GL.CLAMP_TO_EDGE); GL.texParameteri(GL.TEXTURE_2D, GL.TEXTURE_MIN_FILTER, GL.LINEAR); GL.uniform1i(GL.getUniformLocation(PROGRAM, 'u_textures[' + index + ']'), index); }; image.src = URLS.textures[index];
}

В шейдере у нас теперь массив из текстур: Ничего необычного не произошло, мы только заменили нули на параметр index и переиспользовали уже имеющийся код, чтобы загрузить три текстуры.

#define NUMBER_OF_TEXTURES 3 uniform sampler2D u_textures[NUMBER_OF_TEXTURES];

Но... Теперь мы можем использовать номер области, сохраненный ранее, для того, чтобы выбрать одну из трех текстур.

О наболевшем. Но перед этим хотелось бы сделать небольшое отступление. Современный Javascript (условно ES6+) — это приятный язык. О синтаксисе. Для творца — самое то. Он позволяет выражать свои мысли по мере их возникновения, не ограничивает рамками какой-то конкретной парадигмы программирования, некоторые моменты доделывает за нас и позволяет больше сосредоточиться на идее, чем на ее реализации. Чистый Си — это более строгий язык. Некоторые люди считают, что он дает слишком много свободы и переходят на TypeScript к примеру. Но тем не менее он все еще хорош. Он тоже многое позволяет, на нем можно нашаманить все, что угодно, но после JS он воспринимается немного неуклюжим, старомодным что ли. Мало того, что он на порядок строже, чем Си, так в нем еще отсутствуют многие привычные операторы и синтаксические конструкции. GLSL в том виде, в котором он существует в браузерах — это просто нечто. За тем ужасом, в который превращается код, бывает очень непросто раглядеть изначальный алгоритм. Это наверное самая большая проблема при написании более-менее сложных шейдеров для WebGL. Так вот: знание Си тут особо не поможет. Некоторые верстальщики думают, что пока они не выучили Си, путь к шейдерам для них закрыт. Мир безумия, динозавров и костылей. Здесь какой-то свой мир.

На ум приходит остаток от деления номера на количество текстур. Как можно выбрать одну из трех текстур имея одно число — номер области. Только оператора %, который руки сами уже пишут, здесь нет. Отличная идея. Впечатление от понимания этого факта хорошо описывает картинка:

image

Но оказывается, что она не принимает два целых числа, только дробные. Вы конечно скажете "да не проблема, есть же функция mod — возьмем ее!". Получаем тоже float, но нам нужен int. Ок, хорошо, делаем из них float. Приходится преобразовывать все обратно, иначе есть неиллюзорный шанс получить ошибку при компиляции.

int texture_index = int(mod(float(area_index), float(NUMBER_OF_TEXTURES)));

И это еще простая функция, а бывает, что получаются очень глубоко вложенные последовательности таких преобразований, в которых уже не совсем понятно, что происходит. И вот риторический вопрос: а может быть проще будет реализовать свою функцию остатка от деления целых чисел, чем пытаться собрать ее из стандартных методов?

Просто возьмем цвет нужного пикселя из выбранной текстуры и присвоим его переменной gl_FragColor. Ладно, оставим пока все как есть. Мы ведь уже делали это? Так? Нельзя использовать не-константу при обращении к массиву. И тут этот кот появляется еще раз. Ba-dum-tsss!!! А все, что мы рассчитали — это уже не константа.

Приходится делать что-то такое:

if (texture_index == 0) { gl_FragColor = texture2D(u_textures[0], texture_coord);
} else if (texture_index == 1) { gl_FragColor = texture2D(u_textures[1], texture_coord);
} else if (texture_index == 2) { gl_FragColor = texture2D(u_textures[2], texture_coord);
}

Даже оператора switch-case здесь нет, чтобы хоть как-то облагородить это безобразие. Согласитесь, такому коду прямая дорога на govnokod.ru, но тем не менее по-другому никак. Есть правда еще другой, менее очевидный костыль, решающий эту же задачу:

for (int i = 0; i < 3; i++) { if (texture_index == i) { gl_FragColor = texture2D(u_textures[i], texture_coord); }
}

Но с массивом текстур такое провернуть не получилось — в последнем Хроме вылезла ошибка, говорящая, что именно с массивом текстур так делать нельзя. Счетчики циклов, которые увеличиваются на единицу, компилятор может считать за константу. Угадайте, почему с одним массивом работает, а с другим — нет? С массивом чисел получалось. Самое забавное тут то, что результаты зависят и от используемой видеокарты, так что хитрые костыли, которые сработали на видеокарте от NVIDIA вполне могут внезапно сломаться на AMD. Если вы думали, что система приведения типов в JS полна магии — разберитесь в системе "константа — не константа" в GLSL.

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

Но, если мы хотим делать интересные вещи, нужно абстрагироваться от всего этого и продолжить. Грусть-печаль.

Но есть одна деталь: если точки подходят друг к другу очень близко, то происходит быстрый переход двух областей. На данный момент мы получили мозаику из фотографий. Нужно добавить какой-нибудь алгоритм, не позволяющий точкам сближаться. Это не очень красиво. Этот вариант не лишен недостатков, в частности он иногда приводит к небольшому дерганию точек, но во многих случаях его может быть достаточно, тем более, что расчетов здесь получается не очень много. Можно сделать простой вариант, при котором проверяются расстояния между точками и, если оно меньше некоторого значения, то мы их раздвигаем. Если вам будет интересно их реализовать, то все формулы вы без труда найдете в справочнике по физике для средней школы. Более продвинутыми вариантами были бы система движущихся зарядов и "паутинка", в которой пары точек соединены невидимыми пружинками.

for (let i = 0; i < NUMBER_OF_POINTS; i++) { for (let j = i; j < NUMBER_OF_POINTS; j++) { let deltaX = POINTS[i][0] - POINTS[j][0]; let deltaY = POINTS[i][1] - POINTS[j][1]; let distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); if (distance < 0.1) { POINTS[i][0] += 0.001 * Math.sign(deltaX); POINTS[i][1] += 0.001 * Math.sign(deltaY); POINTS[j][0] -= 0.001 * Math.sign(deltaX); POINTS[j][1] -= 0.001 * Math.sign(deltaY); } }
}

Не нужно быть великим математиком, чтобы понять, что количество расчетов дистанций будет просто невероятным, если мы сделаем не 10 точек, а 1000. Главная проблема этого подхода, а также того, который мы использовали в шейдере — сравнение всех точек со всеми. Поэтому его имеет смысл применять только для небольшого количества точек. Да даже 100 хватит, чтобы все тормозило.

Идея в том, чтобы в каждый квадрат поместить одну точку, а потом все сравнения проводить только с точками из соседних квадратов. Если мы хотим сделать такую мозаику для большого количества точек, то можно воспользоваться уже знакомым нам делением плоскости на одинаковые квадраты. Поэтому стоит десять раз подумать, прежде чем решать сделать у себя на сайте такую мозаику из большого количества фрагментов. Мысль хорошая, но эксперименты показали, что при большом количестве точек недорогие ноутбуки со встроенными видеокартами все равно не справляются.

У пользователей в основном будут именно они. Не будьте редисками, проверяйте быстродействие своих поделок не только на своей майнинг ферме, но и на обычных ноутбуках.

Разбиение плоскости на части по графику функции

Он уже не потребует больших вычислительных мощностей. Давайте посмотрим еще один вариант разделения плоскости на части. Полученная линия как раз поделит плоскость на две части. Основная идея в том, чтобы взять какую-нибудь математическую функцию и построить ее график. Заменяя X на Y мы сможем менять горизонтальный разрез на вертикальный. Если мы будем использовать функцию вида y = f(x), то получим деление в виде разреза. В таком случае получится не разрез на две части, а скорее вырезание дырки. Если взять функцию в полярных координатах, то потребуется переводить все в декартовы и обратно, но суть вычислений не изменится. Но мы посмотрим первый вариант.

Мы могли бы взять для этих целей синусоиду например, но это слишком скучно. Для каждого Y мы будем рассчитывать значение X, чтобы сделать вертикальный разрез. Лучше взять их сразу несколько штук и сложить.

Физики назвали бы такое сложение суперпозицией. Берем несколько синусоид, каждая из которых привязана к координате по Y и ко времени, и складываем их. Выносим это в отдельный макрос. Очевидно, что умножая весь результат на какое-то число, мы меняем амплитуду. Мы уже видели это в прошлой статье. Если умножать координату — параметр синуса, то будет меняться частота. Будет не лишним поиграть и со временем, отрицательный знак даст эффект движения линии в обратную сторону. Также выносим из формулы общий для всех синусоид модификатор частоты.

float time = u_time * SPEED;
float x = (sin(texture_coord.y * FREQUENCY) + sin(texture_coord.y * FREQUENCY * 2.1 + time) + sin(texture_coord.y * FREQUENCY * 1.72 + time * 1.121) + sin(texture_coord.y * FREQUENCY * 2.221 + time * 0.437) + sin(texture_coord.y * FREQUENCY * 3.1122 + time * 4.269)) * AMPLITUDE;

Для того, чтобы решить этот вопрос, нам нужно все умножить на коэффициенты, у которых наименьшее общее кратное очень большое. Сделав такие глобальные настройки для нашей функции мы столкнемся с проблемой повторения одного и того же движения через довольно небольшие промежутки времени. В данном случае мы не стали думать и взяли готовые числа из какого-то примера из интернета, но никто не мешает поэкспериментировать со своими значениями. Что-то похожее используется и в генераторе случайных чисел, помните?

Точнее сказать левее и правее, мы ведь все повернули: Остается только выбрать одну из двух текстур для точек выше нашего графика функции и вторую для точек ниже него.

if (texture_coord.x - 0.5 > x) { gl_FragColor = texture2D(u_textures[0], texture_coord);
} else { gl_FragColor = texture2D(u_textures[1], texture_coord);
}

Точнее их изображение на осциллографе. То, что мы получили, напоминает звуковые волны. Но работа со звуком — это тема для отдельной статьи. И действительно, мы могли бы вместо своих синусоид передавать данные из какого-нибудь звукового файла.

Маски

Просто здесь мы их делаем немного по-другому. Предыдущие примеры должны натолкнуть на вполне логичное замечание: все это похоже на работу масок в SVG (если вы с ними не работали — посмотрите примеры из статьи SVG-маски и вау-эффекты). Только плавных переходов еще не было. А в результате получается то же самое — какие-то области закрашиваются одной текстурой, какие-то другой. Так что давайте сделаем один.

Сделаем радиальный градиент с центром в месте расположения курсора и будем использовать его в качестве маски. Убираем все лишнее и возвращаем координаты мыши. Нам понадобится функция mix и какая-нибудь функция от расстояния. В этом примере поведение шейдера будет больше напоминать логику работы масок в SVG, чем в предыдущих примерах. В качестве функции от расстояния возьмем модуль синуса — она как раз будет давать плавное изменение значения между 0 и 1. Первая будет смешивать значения цветов пикселей из обеих текстур, принимая в качестве третьего параметра коэффициент (от 0 до 1), определяющий то, какое из значений будет преобладать в результате.

gl_FragColor = mix( texture2D(u_textures[0], texture_coord), texture2D(u_textures[1], texture_coord), abs(sin(length(texture_coord - u_mouse_position / u_canvas_size))));

Посмотим на результат: Собственно вот и все.

Главное преимущество по сравнению с SVG очевидно:

В отличии от SVG, здесь мы можем легко делать плавные градиенты по различным математическим функциям, а не собирать их из множества линейных градиентов.

Да и производительность на слабом железе скорее всего будет лучше. Если у вас более простая задача, не требующая таких плавных переходов или сложных форм, которые рассчитываются в процессе, то скорее всего ее будет проще реализовать без использования шейдеров. Выбирайте инструмент исходя из своих задач.

Для начала делаем круг, в котором текстура будет оставаться такой, какая она есть: В образовательных целях посмотрим еще один пример.

gl_FragColor = texture2D(u_textures[0], texture_coord); float dist = distance(texture_coord, u_mouse_position / u_canvas_size); if (dist < 0.3) { return;
}

А все остальное заполняем диагональными полосками:

float value = sin((texture_coord.y - texture_coord.x) * 200.0); if (value > 0.0) { gl_FragColor.rgb *= dist;
} else { gl_FragColor.rgb *= dist / 10.0;
}

Полезно помнить, что рисование диагональных линий обычно связано со сложением координат по X и по Y. Примемы все те же — умножаем параметр для синуса, чтобы увеличить частоту полосок; делим полученные значения на две части; для каждой из половин преобразуем цвет пикселей по-своему. Таким же образом можно использовать его и при геометрических трансформациях, мы скоро посмотрим это на примере пикселизации. Обратите внимание, что мы все также используем расстояние до курсора мыши при изменении цветов, тем самым создавая своеобразную тень. А пока взглянем на результат работы этого шейдера:

Просто и симпатично.

На многих сайтах из каталогов вроде Awwwards используют подобные эффекты именно в сочетании с видео. И да, если немного заморочиться, то можно делать текстуры не из картинок, а из кадров из видео (в сети много примеров, вы без труда с ними разберетесь), и применять все наши эффекты к ним.

Стоит помнить и еще одну мысль:

Мы можем брать картинку и использовать значения цветов ее пикселей в наших преобразованиях, будь то изменения других цветов, сдвиги в стороны или еще что-то, что придет вам в голову. Никто не мешает использовать одну из текстур в качестве маски.

Но вернемся к разбиению плоскости на части.

Пикселизация

Делим нашу плоскость на квадраты, таким же образом, как это было в примере с генератором шума, а затем для всех пикселей внутри каждого квадрата задаем один и тот же цвет. Этот эффект в некоторой степени очевиден, но в то же время он так часто встречается, что пройти мимо было бы неправильно. Для данного эффекта нам не нужны сложные формулы, так что просто складываем все значения и делим на 4 — количество углов у квадрата. Он получается в результате смешивания значений из углов квадрата, мы уже делали что-то подобное.

float block_size = abs(sin(u_time)) / 20.0;
vec2 block_position = floor(texture_coord / block_size) * block_size; gl_FragColor = ( texture2D(u_textures[0], block_position) + texture2D(u_textures[0], block_position + vec2(1.0, 0.0) * block_size) + texture2D(u_textures[0], block_position + vec2(0.0, 1.0) * block_size) + texture2D(u_textures[0], block_position + vec2(1.0, 1.0) * block_size) ) / 4.0;

Мы снова привязали один из параметров ко времени через модуль синуса, чтобы наглядно посмотреть, что происходит при его изменении.

Пиксельные волны

Напоследок мы еще раз применим уже известные нам приемы в одном месте, чтобы добавить волны в пример с пикселями.

float block_size = abs(sin( length(texture_coord - u_mouse_position / u_canvas_size) * 2.0 - u_time)) / 100.0 + 0.001;

К результату добавляем небольшую константу для того, чтобы не было нулевых размеров блоков. Используем модуль синуса, чтобы загнать все в пределы от 0 до 1; добавляем расстояние от текущего положения до курсора мыши, время, и подбираем немного коэффициентов, чтобы все это выглядело красиво.

Тогда получатся более натуральные волны. Это довольно своеобразные "пиксельные" волны, но точно также можно было бы взять сдвиги из предыдущей статьи и рассчитывать их, а не размеры блоков-пикселей. Простор для фантазии достаточно широкий. Также никто не мешает сделать несколько "точек волнения", волны из которых будут смешиваться, как мы смешивали синусы в одном из предыдущих примеров. Не стоит злоупотреблять с большим количеством вычислений. Единственное о чем следует помнить — это производительность.

Итоги

Дальнейшее изучение шейдеров в контексте двумерных эффектов будет сводиться ко всевозможным экспериментам и реализации своих фантазий или идей наших коллег-дизайнеров. Взяв за основу шаблон из введения в программирование шейдеров, мы реализовали несколько популярных эффектов, посмотрели идеи, которые в них заложены, повторили пару раз и добавили их к себе в копилку знаний. Поэтому на этом моменте мы остановимся с разбором двумерных эффектов. Подобные вещи уже сложно как-то систематизировать и представить в виде последовательности более-менее связанных примеров. Это одна из областей, в которых нет проверенных путей развития, так что вам придется много экспериментировать, чтобы начать свободно делать подобные вещи. Начинающим разработчикам хотелось бы пожелать терпения в изучении этой темы.

S.: Какие темы, связанные с WebGL (или с разработкой нестандартных сайтов в целом) на ваш взгляд стоило бы затронуть в статьях на Хабре? P. На какие темы стоило бы обратить внимание в первую очередь? Область эта достаточно широкая и, насколько я понимаю, не очень систематизированная.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»