Как стать автором
Обновить

learnopengl. Урок 1.5 — Shaders

Время на прочтение14 мин
Количество просмотров137K

Мы уже упоминали шейдеры в предыдущем уроке. Шейдеры — это небольшие программы выполняемые на графическом ускорителе (далее будем использовать более распространенное название — GPU). Эти программы выполняются для каждого конкретного участка графического конвейера. Если описывать шейдеры наиболее простым способом, то шейдеры — это не более чем программы преобразующие входы в выходы. Шейдеры обычно изолированы друг от друга, и не имеют механизмов коммуникации между собой кроме упомянутых выше входов и выходов.


В предыдущем уроке мы кратко коснулись темы “поверхностных шейдеров” и того, как их использовать. В данном уроке мы рассмотрим шейдеры подробнее и в частности шейдерный язык OpenGL (OpenGL Shading Language).



Меню


  1. Начинаем
    1. OpenGL
    2. Создание окна
    3. Hello Window
    4. Hello Triangle
    5. Shaders

GLSL


Шейдеры (как упоминалось выше, шейдеры — это программы) программируются на C подобном языке GLSL. Он адаптирован для использования в графике и предоставляет функции работы с векторами и матрицами.


Описание шейдера начинается с указания его версии, далее следуют списки входных и выходных переменных, глобальных переменных (ключевое слово uniform), функции main. Функции main является начальной точкой шейдера. Внутри этой функции можно производить манипуляции с входными данными, результат работы шейдера помещается в выходные переменные. Не обращайте внимания на ключевое слово uniform, к нему мы вернемся позднее.


Ниже представлена обобщенная структура шейдера:


#version version_number

in type in_variable_name;
in type in_variable_name;

out type out_variable_name;

uniform type uniform_name;

void main()
{
  // Что-то делаем, вычисляем, экспериментируем, и т.п.
  ...
  // Присваиваем результат работы шейдера выходной переменной
  out_variable_name = weird_stuff_we_processed;
}

Входные переменные вершинного шейдера называются вершинными атрибутами. Существует максимальное количество вершин, которое можно передать в шейдер, такое ограничение накладывается ограниченными возможностями аппаратного обеспечения. OpenGL гарантирует возможности передачи по крайней мере 16 4-х компонентных вершин, иначе говоря в шейдер можно передать как минимум 64 значения. Однако стоит учитывать, что существуют вычислительные устройства значительно поднимает эту планку. Так или иначе, узнать максимальное количество входных переменных-вершин, передаваемых в шейдер, можно узнать обратившись к атрибуту GL_MAX_VERTEX_ATTRIBS.


GLint nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

В результат выполнения данного кода, в результате мы увидим цифру >= 16.


Типы


GLSL, как и любой другой язык программирования, предоставляет определенный перечень типов переменных, к ним относятся следующие примитивные типы: int, float, double, uint, bool. Также GLSL предоставляет два типа-контейнера: vector и matrix.


Vector


Vector в GLSL — это контейнер, содержащий от 1 до 4 значений любого примитивного типа. Объявление контейнера vector может иметь следующий вид (n — это количество элементов вектора):


vecn (например vec4) — это стандартный vector, содержащий в себе n значений типа float
bvecn (например, bvec4) — это vector, содержащий в себе n значений типа boolean
ivecn (например, ivec4) — это vector, содержащий в себе n значений типа integer
uvecn (нарпример, uvecn) — это vector, содержащий в себе n значений типа unsigned integer
dvecn (например dvecn) — это vector, содержащий в себе n значений типа double.


В большинстве случаев будет использоваться стандартный vector vecn.


Для доступа к элементам контейнера vector мы будем использовать следующий синтаксис vec.x, vec.y, vec.z, vec.w (в данном случае мы обратились ко всем элементам по порядку, от первого к последнему). Также можно итерироваться по RGBA, если вектор описывает цвет, или stpq если вектор описывает координаты текстуры.


P.S. Допускается обращение к одному вектору через XYZW, RGBA, STPQ. Не нужно воспринимать данную заметку как руководство к действию, пожалуйста.

P.P.S. Вообще, никто не запрещает итерироваться по вектору при помощи индекса и оператора доступа по индексу []. https://en.wikibooks.org/wiki/GLSL_Programming/Vector_and_Matrix_Operations#Components

Из вектора, при обращении к данным через точку, можно получить не только одно значение, но и целый вектор, используя следующий синтаксис, который называется swizzling


vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

Для создания нового вектора вы можете использовать до 4 литералов одного типа или вектор, правило только одно — в сумме нужно получить необходимое нам количество элементов, например: для создания вектора из 4 элементов мы можем использовать два вектора длиной в 2 элемента, или один вектор длиной в 3 элемента и один литерал. Также для создания вектор из n элементов допускается указание одного значения, в этом случае все элементы вектора примут это значение. Также допускается использование переменных примитивных типов.


vec2 vect = vec2(0.5f, 0.7f);
vec4 result = vec4(vect, 0.0f, 0.0f);
vec4 otherResult = vec4(result.xyz, 1.0f);

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


In и out переменные


Мы знаем что шейдеры — это маленькие программы, но в большинстве случаев они являются частью чего-то большего, по этой причине в GLSL есть in и out переменные, позволяющие создать “интерфейс” шейдера, позволяющий получить данные для обработки и передать результаты вызывающей стороне. Таким образом каждый шейдер может определить для себя входные и выходные переменные используя ключевые слова in и out.


Вершинный шейдер был бы крайне неэффективным, если бы он не принимал никаких входных данных. Сам по себе это шейдер отличается от других шейдеров тем, что принимает входные значения напрямую из вершинных данных. Для того, чтобы указать OpenGL, как организованы аргументы, мы используем метаданные позиции, для того, чтобы мы могли настраивать атрибуты на CPU. Мы уже видели этот прием ранее: 'layout (location = 0). Вершинных шейдер, в свою очередь, требует дополнительных спецификаций для того, чтобы мы могли связаться аргументы с вершинных данными.


Можно опустить layout (location = 0), и использовать вызов glGetAttributeLocation для получения расположения атрибутов вершин.

Еще одним исключением является то, что фрагментный шейдер (Fragment shader) должен иметь на выходе vec4, иначе говоря фрагментный шейдер должен предоставить в виде результата цвет в формате RGBA. Если этого не будет сделано, то объект будет отрисован черным или белым.


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


Vertex shader


#version 330 core
layout (location = 0) in vec3 position; // Устанавливаем позицию атрибута в 0

out vec4 vertexColor; // Передаем цвет во фрагментный шейдер

void main()
{
    gl_Position = vec4(position, 1.0); // Напрямую передаем vec3 в vec4
    vertexColor = vec4(0.5f, 0.0f, 0.0f, 1.0f); // Устанавливаем значение выходной переменной в темно-красный цвет.
}

Fragment shader


#version 330 core
in vec4 vertexColor; // Входная переменная из вершинного шейдера (то же название и тот же тип)

out vec4 color;

void main()
{
    color = vertexColor;
} 

В данных примерах мы объявили выходной вектор из 4 элементов с именем vertexColor в вершинном шейдере и идентичный вектор с названием vertexColor, но только как входной во фрагментном шейдере. В результате выходной vertexColor из вершинного шейдера и входной vertexColor из фрагментного шейдера были соединены. Т.к. мы установили значение vertexColor в вершинном шейдере, соответствующее непрозрачному бордовому (темно-красному цвету), применение шейдера к объекту делает его бордового цвета. Следующее изображение показывает результат:


Результат


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


Uniforms


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


Для объявления формы в GLSL используется спецификатор переменной unifrom. После объявления формы его можно использовать в шейдере. Давайте посмотрим как установить цвет нашего треугольника с использованием формы:


#version 330 core
out vec4 color;

uniform vec4 ourColor; // Мы устанавливаем значение этой переменной в коде OpenGL.

void main()
{
    color = ourColor;
}  

Мы объявили переменную формы outColor типа вектора из 4 элементов в фрагментном шейдере и используем ее для установки выходного значение фрагментного шейдера. Т.к. форма является глобальной переменной, то ее объявление можно производить в любом шейдере, а это значит что нам не нужно передавать что-то из вершинного шейдера во фрагментный. Таким образом мы не объявляем форму в вершинном шейдере, т.к. мы её там не используем.


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


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


GLfloat timeValue = glfwGetTime();
GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

Сначала мы получаем время работы в секундах вызвав glfwGetTime(). После мы изменяем значение от 0.0 до 1.0 используя функцию sin и записываем результат в переменную greenValue.


После мы запрашиваем индекс формы ourColor используя glGetUniformLocation. Данная функция принимает два аргумента: переменную программы-шейдера и название формы, определенной внутри этой программы. Если glGetUniformLocation вернул -1, это означает что такой формы с таким именем не было найдено. Последнем нашим действием является установка значения формы ourColor посредством использования функции glUniform4f. Заметьте, что поиск индекса формы не требует предварительного вызова glUseProgram, но для обновления значения формы сначала необходимо вызвать glUseProgram.


Так как OpenGL реализован с использованием языка C, в котором нет перегрузки функций, вызов функций с разными аргументами невозможен, но в OpenGL определены функции для каждого типа данных, которые определяются постфиксом функции. Ниже приведены некоторые постфиксы:


f: функция принимает float аргумент;
i: функция принимает int аргумент;
ui: функция принимает unsigned int аргумент;
3f: функция принимает три аргумента типа float;
fv: функция принимает в качестве аргумента вектор из float.


Таким образом, вместо использования перегруженных функций, мы должны использовать функцию, реализация которой предназначена для определенного набора аргументов, на что указывает постфикс функции. В приведенном выше примере мы использовали функцию glUniform…() специализированную для обработки 4 аргументов типа float, таким образом, полное имя функции было glUniform4f() (4f — четыре аргумента типа float).


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


while(!glfwWindowShouldClose(window))
{
    // Обрабатываем события
    glfwPollEvents();

    // Отрисовка
    // Очищаем буфер цвета
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    // Активируем шейдерную программу
    glUseProgram(shaderProgram);

    // Обновляем цвет формы
    GLfloat timeValue = glfwGetTime();
    GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
    GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
    glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

    // Рисуем треугольник
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    glBindVertexArray(0);
}

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



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


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


Больше атрибутов богу атрибутов!!!


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


GLfloat vertices[] = {
    // Позиции         // Цвета
     0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,   // Нижний правый угол
    -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,   // Нижний левый угол
     0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f    // Верхний угол
};    

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


#version 330 core
layout (location = 0) in vec3 position; // Устанавливаем позиция переменной с координатами в 0
layout (location = 1) in vec3 color;    // А позицию переменной с цветом в 1

out vec3 ourColor; // Передаем цвет во фрагментный шейдер

void main()
{
    gl_Position = vec4(position, 1.0);
    ourColor = color; // Устанавливаем значение цвета, полученное от вершинных данных
}   

Сейчас нам не нужна форма ourColor, но выходной параметре ourColor нам пригодится для передачи значения фрагментному шейдеру:


#version 330 core
in vec3 ourColor;
out vec4 color;

void main()
{
    color = vec4(ourColor, 1.0f);
}

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


Данные в памяти VBO


Зная текущую схему мы можем обновить формат вершин используя функцию glVertexAttribPointer:


// Атрибут с координатами
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// Атрибут с цветом
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3* sizeof(GLfloat)));
glEnableVertexAttribArray(1);

Первые несколько атрибутов функции glVertexAttribPointer достаточно просты. В данном примере мы используем вершинный атрибут с позицией 1. Цвет состоит из трех значений типа флота и нам не нужна нормализация.


Т.к. теперь мы используем два атрибута шейдеров, то нам следует пересчитать шаг. Для доступа к следующему атрибуту шейдера (следующий x вектора вершин) нам нужно переместиться на 6 элементов float вправо, на 3 для вектора вершин и на 3 для вектора цвета. Т.е. мы переместимся 6 раз вправо, т.е. на 24 байта вправо.


Теперь мы разобрались со сдвигами. Первым идет вектор с координатами вершины. Вектор со значением цвета RGB идет после вектора с координатами, т.е. после 3 * sizeof(GLfloat) = 12 байт.


Запустив программу вы можете увидеть следующий результат:


Результат


Полный исходный код, творящий это чудо можно посмотреть тут:


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


ООП в массы! Делаем свой класс шейдера


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


Начнем разработку нашего класса с объявления его интерфейса и пропишем во вновь созданный заголовочник все необходимые include директивы. В результат получаем нечто подобное:


#ifndef SHADER_H
#define SHADER_H

#include <string>
#include <fstream>
#include <sstream>
#include <iostream>

#include <GL/glew.h>; // Подключаем glew для того, чтобы получить все необходимые заголовочные файлы OpenGL

class Shader
{
public:
    // Идентификатор программы
    GLuint Program;
    // Конструктор считывает и собирает шейдер
    Shader(const GLchar* vertexPath, const GLchar* fragmentPath);
    // Использование программы
    void Use();
};

#endif

Давайте будем молодцами и будем использовать директивы ifndef и define, чтобы избежать рекурсивного выполнения директив include. Этот совет относится не к OpenGL, а к программированию на C++ в целом.


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


Считывание файла шейдера. Для считывания будем использовать стандартные потоки C++, помещая результат в строки:


Shader(const GLchar* vertexPath, const GLchar* fragmentPath)
{
    // 1. Получаем исходный код шейдера из filePath
    std::string vertexCode;
    std::string fragmentCode;
    std::ifstream vShaderFile;
    std::ifstream fShaderFile;
    // Удостоверимся, что ifstream объекты могут выкидывать исключения
    vShaderFile.exceptions(std::ifstream::badbit);
    fShaderFile.exceptions(std::ifstream::badbit);
    try 
    {
        // Открываем файлы
        vShaderFile.open(vertexPath);
        fShaderFile.open(fragmentPath);
        std::stringstream vShaderStream, fShaderStream;
        // Считываем данные в потоки
        vShaderStream << vShaderFile.rdbuf();
        fShaderStream << fShaderFile.rdbuf();       
        // Закрываем файлы
        vShaderFile.close();
        fShaderFile.close();
        // Преобразовываем потоки в массив GLchar
        vertexCode = vShaderStream.str();
        fragmentCode = fShaderStream.str();     
    }
    catch(std::ifstream::failure e)
    {
        std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
    }
    const GLchar* vShaderCode = vertexCode.c_str();
    const GLchar* fShaderCode = fragmentCode.c_str();
    [...]

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


// 2. Сборка шейдеров
GLuint vertex, fragment;
GLint success;
GLchar infoLog[512];

// Вершинный шейдер
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// Если есть ошибки - вывести их
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if(!success)
{
    glGetShaderInfoLog(vertex, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};

// Аналогично для фрагментного шейдера
[...]

// Шейдерная программа
this->Program = glCreateProgram();
glAttachShader(this->Program, vertex);
glAttachShader(this->Program, fragment);
glLinkProgram(this->Program);
//Если есть ошибки - вывести их
glGetProgramiv(this->Program, GL_LINK_STATUS, &success);
if(!success)
{
    glGetProgramInfoLog(this->Program, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}

// Удаляем шейдеры, поскольку они уже в программу и нам больше не нужны.
glDeleteShader(vertex);
glDeleteShader(fragment);

Ну а вишенкой на торте будет реализация метода Use:


void Use() { glUseProgram(this->Program); }  

А вот и результат нашей работы:


Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.frag");
...
while(...)
{
    ourShader.Use();
    glUniform1f(glGetUniformLocation(ourShader.Program, "someUniform"), 1.0f);
    DrawStuff();
}

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


Исходные коды программы с классом шейдера, класса шейдера, вершинного шейдера и фрагментного шейдера


Упражнения:


1. Модифицируйте вершинный шейдер так, чтобы в результате треугольник перевернулся: решение.


2. Передайте горизонтальное смещение с помощью формы и переместите треугольник к правой стороне окна с помощью вершинного шейдера: решение.


3. Передайте фрагментному шейдеру позицию вершины и установите значение цвета равное значению позиции (посмотрите, как позиция вершины интерполируется по всему треугольнику). После того, как сделаете это, постарайтесь ответить на вопрос, почему нижняя левая часть треугольника черная?: решение

Теги:
Хабы:
Всего голосов 24: ↑23 и ↓1+22
Комментарии13

Публикации

Истории

Работа

Программист C++
126 вакансий
QT разработчик
8 вакансий

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань