Шейдеры в OpenGL
Всем доброго времени суток! Сегодня я расскажу о том, что такое шейдеры и почему мы должны их использовать при рисовании сцены.
Исходники для данной статьи [9]
На этом сайте уже есть статья про OpenGL. В ней было подробно описано — как создать простой проект с данной графической библиотекой без привлечения дополнительных библиотек. Только X11 и OpenGL.
В указанном проекте я нарисовал обекты сцены, вызывая функции рисования четырехугольников по отдельным вершинам. Такой способ изображения объектов удобен только если этих обектов не очень много.
А теперь представьте себе ситуацию, когда требуется изобразить 3D-модель, состоящую из тысячь полигонов (или многоугольников). В каждом полигоне три вершины (минимум). При таких условиях вызывать для каждой вершины функцию, задающую координаты, цвет и другие параметры, крайне невыгодно.
Для таких случаев в OpenGL предусмотрен механизм обработки массивов из вершин и их параметров. Всё, что нужно сделать — это загрузить координаты и другие параметры вершин в память видеокарты и задать программу обработки этих данных графическим процессором. Вышеназванная программа тоже будет храниться в памяти видеокарты и называться «шейдером».
Современные стандарты требуют, чтобы рисование даже самых простых обектов осуществлялось с помощью шейдеров.
Следует отметить, что в ранее описанном примере со стеной из кубиков мы не задаем каких-либо программ для графического процессора. Но, тем не менне, эти программы присутствуют и в этом случае. Разница в том, что мы не формируем их самостоятельно.
Ещё одно дополнение. Не следует понимать шейдер только как истоник каких-либо графических спецэффектов. Шейдер — это программа, способствующая отображению обектов сцены в нужном ключе. Спецэффекты — это одна из возможностей данного механизма.
Виды шейдеров
Шейдеры бывают нескольких видов. Мы будем использовать только два из них. Это vertex shader и fragment shader. Первый является первым и в цепочке обработки графической информации. Его задача — выполнить нужные нам преобразования с вершинами 3D-обекта, затем передать информацию следующему шейдеру в цепочке — fragment shaderу. Последний определяет цвет каждого пикселя. Спомощью первого шейдера можно, например, откорректировать координаты вершин в соответствии с матрицей проекции. А fragment shader поможет сформировать уникальный материал из нескольких текстур.
Но прежде чем перейти к написанию первого шейдера, необходимо познакомиться с объектом OpenGL, который называется Vertex Buffer Object (VBO). Я здесь сознательно избегаю дословного перевода подобных терминов на русский язык, так как в этом случае вам будет трудно связать материал с имеющимися руководствами на русском. Зато по англоязычным терминам легко найти информацию на том же khronos.org.
Vertex Buffer Object (VBO)
Чтобы сохранить массив из вершин 3D-объекта в памяти видеокарты, необходимо описать соответствующий фрагмент памяти. VBO как раз и решает эту задачу.
Поскольку мы работаем в данный момент с вершинами, создадим файл с именем vertexes.c. А чтобы не задумываться — какие заголовочные файлы содержат необходимые нам функции, я включил один и тот же набор во все исходники проекта:
/* Vertexes source code */ #ifndef VERTEXES_C #define VERTEXES_C #define GL_GLEXT_PROTOTYPES #include <GL/gl.h> #include <GL/glx.h> #include <GL/glu.h> #include <GL/glext.h> #include <GL/glcorearb.h>
Определение GL_GLEXT_PROTOTYPES сделает нужные нам функции из заголовочных файлов доступными. Далее определим функцию создания VBO:
unsigned int CreateVBO(float* verts, unsigned int size) { unsigned int VBO; glGenBuffers(1, &VBO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, size, verts, GL_STATIC_DRAW); return VBO; };
Здесь с помощью процедуры glGenBuffers мы создаем нужный объект, чей идентификатор помещается в переменную VBO. Именно с помощью подобных идентификаторов принято получать доступ ко многим объектам OpenGL. Первый параметр, равный 1, соответствует колличеству генерируемых объектов. Если вместо единицы записать 2, то мы получим массив из двух идентификаторов типа unsigned int. Далее процедурой glBindBuffer производится интерпретация данного объекта как массива из вершин. Кроме того, все последующие действия будут касаться его. Процедурой glBufferData мы производим заполнение массива данными. verts — это указатель на область памяти с исходными данными для заполнения, а size — это объем данных в байтах. Флаг GL_STATIC_DRAW говорит о том, что содержимое массива меняться не будет. Этот прием сделан для оптимизации работы видеокарты. В зависимости от того — статичный объект или динамичный (анимированный мэш, например) видеосистема определяет — в какую область памяти загружать информацию о вершинах.
Пока что, наш массив — это всего лишь упорядоченный набор чисел с плавающей точкой (float). Как интерпретировать эти данные? Здесь нам на помощь приходит следующий объект OpenGL — это Vertex Array Object (VAO).
Vertex Array Object (VAO)
В том же исходнике определим следующую функцию:
unsigned int CreateVAO(float* verts, unsigned int size) { unsigned int VAO; glGenVertexArrays(1, &VAO); glBindVertexArray(VAO); unsigned int VBO; VBO = CreateVBO(verts, size); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); return VAO; };
Создаем объект и получаем его идентификатор с помощью glGenVertexArrays, затем для дальнейших манипуляций с этим объектом вызываем glBindVertexArray. Используя нашу функцию CreateVBO, формируем буфер с вершинами и получаем его идентифокатор.
Тут может возникнуть вопрос: как после вызова CreateVBO текущим активным объектом остается VAO? Дело в том, что это объекты разных типов, если можно так выразиться. Объекты разных типов (как например, текстура и матрица проекции) могут быть активны одновременно.
Теперь о самой главной процедуре здесь — glVertexAttribPointer. Первый параметр — это номер, по которому шейдер может получить доступ к содержимому массива.
Воторой параметр говорит о том, что для каждой вершины имеется 3 компонента (координаты x, y и z).
Третий параметр — это тип каждого компонента (float).
Четвертый параметр — это флаг нормализации. Если вы храните координаты в целых числах, то их можно нормализовать и сохранить как float. То есть соотношения и знаки координат оставить прежними, а их конкретные значения выбрать в пределах от -1 до 1.
Пятый параметр — это так называемый stride, или количество байт, на которое нужно сместиться в памяти для перехода к данным следующей вершины. Так как мы храним только координаты, то количество байт соответствует значению 3 * (объем данных типа float).
Последний параметр — это смещение внутри блока данных для одной вершины. Не обращайте внимания на такое странное преобразование типов. Перед вами именно смещение. Дело в том, что внутри одного блока данных можно хранить не только координаты, но и любые другие данные. Например, цвет вершины. Тогда первые три числа в блоке — это координаты, а следующие 3 числа — это цвет. Для того, чтобы этот цвет получить, необходимо создать другой объект VAO и указать смещение внутри блока отличное от нуля.
Обратите внимание, что объект VBO, при этом, останется прежним.
Шейдеры
Создадим файл с именем shaders.c. В нём мы будем хранить процедуры для загрузки исходного кода шейдера и создания соответствующего объекта.
Набор заголовочных файлов будет тот же, что и в vertexes.c, но с добавлением stdio.h и stdlib.h, поскольку нам понадобятся функции чтения файлов и выделения памяти. Так как исходники для шейдеров — это обычные текстовые файлы, но с расширением .glsl, то необходимо создать функцию для загрузки этих файлов в память для последующей компиляции:
void* loadTextFile(char* fname) { FILE* fp = fopen(fname, "r"); if(!fp) { printf("Can't open file '%s'\n", fname); return NULL; }; fseek(fp, 0, SEEK_END); long int sz = ftell(fp); printf("File %s size: %d\n", fname, sz); fseek(fp, 0, SEEK_SET); void* buf = malloc(sz+1); fread(buf, 1, sz, fp); fclose(fp); ((char*)buf)[sz] = '\0'; return buf; };
Здесь всё просто. Открываем файл с именем из переменной fname для чтения, если такой существует. В противном случае выводим сообщение об ошибке и возвращаем NULL. Затем определяем размер файла за счет перемещения текущей позиции чтения в конец и получения текущего смещения. Затем возвращаем текущую позицию чтения в начало файла, выделяем память нужного объема и функцией fread запоняем память данными из файла. Закрываем файл. Объем выделенной памяти на 1 байт больше объема файла. Это требуется для формирования C-строки путем добавления нулевого байта в конец данного блока информации.
Теперь кратко о том, что нужно выполнить для создания полноценной шейдерной программы.
- Загрузить и скомпилировать vertex shader.
- Загрузить и скомпилировать fragment shader.
- Создать объект shader program.
- Подключить к shader program vertex shader и fragment shader.
- Произвести линковку шейдеров в рамках shader program.
- Удалить обекты vertex shader и fragment shader, так как после подключения и линковки они не требуются.
Компиляция шейдера
За первые два пункта будет отвечать следующая функция:
unsigned int CreateShader(char* fname, GLenum type) { char* msg; switch(type) { case GL_VERTEX_SHADER: msg = "Vertex"; break; case GL_FRAGMENT_SHADER: msg = "Fragment"; break; default: printf("Wrong shader type\n"); return 0; break; }; void* buf = loadTextFile(fname); unsigned int shader = glCreateShader(type); glShaderSource(shader, 1 , (const char**)&buf, NULL); glCompileShader(shader); int success; char inf[500]; glGetShaderiv(shader, GL_COMPILE_STATUS, &success); if(!success) { glGetShaderInfoLog(shader, 500, NULL, inf); printf("%s shader compilation failed: %s\n", msg, inf); }; free(buf); return shader; };
Первый параметр здесь — это имя файла с исходным кодом шейдера, второй — тип шейдера. В соответствии с типом шейдера мы выбираем часть сообщения, которое появится в случае ошибки компиляции. Так мы узнаем — с каким из шейдеров проблема. С помощью loadTextFile загружаем исходник в память. Используя glCreateShader создаем объект шейдера заданного типа в контексте OpenGL. glShaderSource позволяет загрузить исходник для созданного обекта. Я не буду останавливаться на каждом параметре. Скажу только, что 1 — это количество C-строк (тех, что оканчиваются на \0) исходного кода, расположенных в памяти подряд. У нас весь исходник представляет из себя одну большую C-строку. Третий параметр — это указатель на указатель на место в памяти с исходным кодом.
glShaderCompile компилирует загруженный исходный код. Далее следует узнать с какими результатами прошла компиляция. Прошла ли она успешно?
Для ответа на этот вопрос объявляем переменные success и inf. Первая перемення будет содержать статус компиляции (успешно или нет). Вторая перемення понадобится в случае неуспеха. В ней будут храниться сообщения компилятора.
Далее получаем статус компиляции и, в случае неуспеха, выводим сообщение компилятора.
Последние действия — это освобождение памяти, занимаемой содержимым текстового файла и возврат значения идентификатора объекта шейдера.
Создание shader program и линковка
Следующая функция использует предыдущие функции из данного исходнка и производит весь процесс формирования шейдерной программы.
unsigned int CreateShaderProgram(char* fvertex, char* ffragment) { unsigned int vertexShader = CreateShader(fvertex, GL_VERTEX_SHADER); unsigned int fragmentShader = CreateShader(ffragment, GL_FRAGMENT_SHADER); unsigned int shaderProgram = glCreateProgram(); glAttachShader(shaderProgram, vertexShader); glAttachShader(shaderProgram, fragmentShader); glLinkProgram(shaderProgram); int success; char inf[500]; glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success); if(!success) { glGetProgramInfoLog(shaderProgram, 500, NULL, inf); printf("Shader program linking error: %s\n", inf); }; glDeleteShader(vertexShader); glDeleteShader(fragmentShader); return shaderProgram; };
Эта функция очень похожа по структуре на CreateShader. Создаем объект shader program с помощью glCreateProgram, добавляем в него шейдеры вызывая glAttachShader, проводим линковку процедурой glLinkProgram. Процесс проверки результативности линковки идет по такому же принципу, как и проверка компиляции шейдеров.
Ошибки линковки могут возникать из-за ошибок в компиляции шейдеров или из-за несоответствия входных и выходных переменных в vertex shader и fragment shader.
На данном этапе все необходимые функции для отображения сцены подготовлены. Осталось прокомментировать изменения в остновном исходнике проекта.
Использование шейдеров
Для формирования сцены нам потребуются координаты вершин треугольника.
unsigned int shaderProgram = CreateShaderProgram(argv[1], argv[2]); float verts[] = { 0.0, 0.9, 0.0, 0.9, -0.9, 0.0, -0.9, -0.9, 0.0}; unsigned int VAO = CreateVAO(verts, sizeof(verts));
В строке 71 мы создаем шейдерную программу, передав в качестве аргументов имена файлов для vertex shader и fragment shader соответственно. Массив verts содержит координаты вершин треугольника. Далле определяем объект VAO, описывающий массив из вершин. Координаты выбраны таким образом, чтобы треугольник заполнил большую часть окна. Так как мы не применяем никаких преобразований для формирования нужной проекции и вида обекта, видимое пространство находится в пределах от -1 до 1 по осям x и y. Ось z расположена перпендикулярно поверхности экрана.
Здесь argv[1] и argv[2] — это первый и второй параметры коммандной строки. На тот случай, когда пользователь забудет их указать, добавим следующий код в начало функции main:
if(argc != 3) { printf("syntax: %s <vertex shader file> <fragment shader file>\n", argv[0]); return 0; }
В argv[0] храниться имя исполняемого файла нашего проекта.
Чтобы отрисовать сцену достаточно в основном цикле нашего приложения записать следующее:
glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glUseProgram(shaderProgram); glBindVertexArray(VAO); glDrawArrays(GL_TRIANGLES, 0, 3); glXSwapBuffers(dpy, win);
Заполняем фон черным цветом, выбираем шейдерную программу, подключаем массив из вершин, даем команду отрисовки вершин с указанием начального индекса массива и колличества вершин, делаем поверхность видимой.
Теперь осталось скомпилировать проект и запистаь исходный код для шейдеров.
vertex shader
Создадим файл с именем vertex_shader.glsl :
#version 300 es layout (location = 0) in vec3 aPos; out vec3 pos; void main() { pos = aPos; gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0); }
Здесь запись location = 0 как раз соответствует тому VAO объекту, который мы сформировали для треугольника, так как первый параметр процедуры glVertexAttribPointer был равен 0. Ключевое слово out говорит о том, что вектор pos является выходной переменной данного шейдера и его содержимое будет получено fragment-шейдером. В этом примере мы ни как не преобразовываем координаты вершин. Мы только передаем их дальше в цепочке шейдеров (shader pipeline).
fragment shader
Создадим файл с именем fragment_shader.glsl :
#version 300 es precision mediump float; out vec4 FragColor; in vec3 pos; void main() { FragColor = vec4(1.0, 0.0, 0.0, 1); }
Здесь FragColor — это перемення, которая будет содержать результат работы шейдера. В данном случае — цвет. Кроме того, данная переменная указана как выходная за счет ключевого слова out. Переменная pos указана как входная, не смотря на отсутствие её в теле main. Это сделано для правильной линковки шейдеров.
Версия указана в соответствии с версией OpenGL в моей ОС. Так как среди поддерживаемых вариантов был только 3.00 embed system, пришлось указать директиву precision mediump float. Так или иначе, общий смысл заключается в том, что мы заполняем треугольник красным цветом.
Более сложный пример шейдера, работа которого видна на первом рисунке, есть в исхониках [9] проекта.
Прекрасный материал!!! Коротко и по делу. Как раз начал изучать данную тему. Придется основательно посидеть, повникать, чтобы усвоить все вышесказанное. Спасибо огромное автору! Продолжай в том же духе. Удачи!
Спасибо за отзыв! Тема действительно интересная, и если возникнут какие-либо вопросы или замечания, то оставляйте их в комментариях.