Начиная с этого урока все эффекты и техники, которые мы будем изучать, будут реализованы через шейдеры. Шейдеры - современный способ создания 3D графики. В некотором роде это шаг назад, поскольку большая часть 3D функционала ранее была представлена в виде фиксированных функций конвейера и требовала от разработчика только указать конфигурационные параметры (параметры света, значения вращения и т.д). Теперь же это должно быть реализовано программистом (через шейдеры), хотя этот подход даёт великолепную гибкость и возможность инноваций.
Конвейер OpenGL может быть зарисован как:
Вершинный процессор отвечает за выполнение вершинного шейдера для каждой вершины, проходящей через конвейер (количество которых определяется согласно параметрам в вызове отрисовки). Вершинный шейдер не знает топологии рендеринга примитивов. Кроме того, вы не можете отбрасывать вершины в вершинном процессоре. Каждая вершина попадает ровно 1 раз в процессор, трансформируется и продолжает своё движение далее.
Следующий этап - это геометрический процессор. На данный момент он завершает примитив (используя нужные вершины), а так же соседние вершины передаются в шейдер. Это позволяет получить дополнительную информацию около самой вершины. Геометрический шейдер, кроме того, имеет возможность изменять выходную топологию, которая выбирается в вызове отрисовки. Например, вы можете поставить список точек и создать 2 треугольника (т.е. квадрат) из каждой точки (метод называется биллбординг (billboarding)). Кроме того, вы можете выделить несколько вершин для каждого вызова геометрического шейдера, тем самым создать несколько примитивов в соответствии с топологией, которую вы выбрали.
Следующий этап в конвейере - это клипер. Это фиксированная функция с четкой задачей - она обрезает примитивы в окне, которое мы видели в предыдущем уроке. Так же она обрезает по ближним и дальним координатам Z оси. Существует возможность задать свой клипер. Позиции вершин, выживших после клипера, теперь отображаются на экран в соответствии с их топологией. Например, в случае треугольника это значит найти все точки внутри треугольника. Для каждой из них растеризатор вызывает фрагментный процессор. В нём вы имеете возможность определить цвет пикселя выбрав его из текстуры или используя любой метод, какой вам нравится.
Все эти три программируемых этапа (вершинный, геометрический и фрагментный процессоры) не обязательны. Если не привязать шейдер к ним, будут вызваны их функции по-умолчанию.
Управление шейдерами очень похоже на создание программы на C/C++. Во-первых, вы пишите текст шейдера и делаете его доступным для программы. Это может быть сделано через написание текста в виде массива символов внутри исходного кода или загружая его из внешнего файла (опять таки преобразовывая в массив символов). После вы компилируете шейдеры один за другим в шейдерный объект. Затем линкуете шейдеры в одну программу и загружаете её в GPU. Линковка шейдеров позволяет драйверу урезать и оптимизировать шейдеры. Например, в паре с вершинным шейдером, выдающем нормали, и пиксельным шейдером, который игнорирует их. В этом случае компилятор GLSL в драйвере может удалить функцию шейдера подсчета нормалей и получить более быстрый вершинный шейдер. Если позже шейдер будет слинкован с фрагментным шейдером, использующим нормали, тогда в разных программах будут различные шейдеры.
GLuint ShaderProgram = glCreateProgram();
Мы начинаем процесс разработки шейдеров через создание программного объекта. Позже мы слинкуем все шейдеры в этот объект.
GLuint ShaderObj = glCreateShader(ShaderType);
Мы создаём оба шейдера используя вызов выше. Один из них имеет тип GL_VERTEX_SHADER, а другой GL_FRAGMENT_SHADER. Процесс указания исходников шейдера и его компиляция одинаков для обоих типов.
const GLchar* p[1];
p[0] = pShaderText;
GLint Lengths[1];
Lengths[0]= strlen(pShaderText);
glShaderoSurce(ShaderObj, 1, p, Lengths);
Прежде чем скомпилировать объект шейдера необходимо указать его исходный код. Функция glShaderSource принимает тип шейдера как параметр и предоставляет гибкость в плане указания исходного кода шейдера. Код может быть разделён между несколькими символьными массивами и необходимо предоставить массив указателей к этим массивам, например целочисленный массив, где каждый слот содержит длину соответствующего массива символов. Для простоты мы используем единый массив символов для всего шейдера и мы используем только 1 слот для обоих указателей на исходник шейдера, а так же его длину. Второй параметр - это размерность обоих массивов (в нашем случае это 1).
glCompileShader(ShaderObj);
Скомпилировать шейдер очень просто…
GLint success;
glGetShaderiv(ShaderObj, GL_COMPILE_STATUS, &success);
if (!success) {
GLchar InfoLog[1024];
glGetShaderInfoLog(ShaderObj, sizeof(InfoLog), NULL, InfoLog);
fprintf(stderr, "Error compiling shader type %d: '%s'\n", ShaderType, InfoLog);
}
…хотя, как и ожидалось, вы будете часто получать ошибки компиляции. Часть кода выше получает статус компиляции и отображает все ошибки, обнаруженные компилятором.
glAttachShader(ShaderProgram, ShaderObj);
Наконец, мы присоединяем скомпилированный объект шейдера к объекту программы. Это очень похоже на указание листа объектов для линковки к Makefile. Так как мы не имеем makefile'a, мы эмулируем такое поведение программно. Только подсоединённые объекты могут стать частью линковки.
glLinkProgram(ShaderProgram);
После компиляции всех шейдеров и подсоединения их к программе мы наконец можем линковать их. Заметьте, после линковки программы вы можете избавиться промежуточных шейдерный объектов с помощью вызова glDeleteShader для каждого из них по отдельности.
glGetProgramiv(ShaderProgram, GL_LINK_STATUS, &Success);
if (Success == 0) {
glGetProgramInfoLog(ShaderProgram, sizeof(ErrorLog), NULL, ErrorLog);
fprintf(stderr, "Error linking shader program: '%s'\n", ErrorLog);
}
Заметьте, мы проверяем программные ошибки (например, ошибки линковки) немного по другому, по сравнению с ошибками шейдеров. Вместо glGetShaderiv мы используем glGetProgramiv, а вместо glGetShaderInfoLog - glGetProgramInfoLog.
glValidateProgram(ShaderProgram);
Возможно вы спрашиваете себя, зачем нам проверять программу после успешной линковки? Разница в том, что проверка линковки на ошибки основывается на комбинации шейдеров, в то время как вызов выше проверяет сможет ли программа запуститься с текущим состоянием конвейера. В больших приложениях с разнообразными шейдерами и состояниями OpenGL лучше проверить её перед отрисовкой. В нашей простой программе мы проверяем лишь раз. Хотя, вы можете осуществлять проверку только во время разработки приложения, а в финальной версии отключить.
glUseProgram(ShaderProgram);
Наконец, для использования отлинкованной программы шейдеров мы назначаем её для конвейера используя вызов выше. Эта программа сохранит эффект для всех вызовов отрисовки, пока вы не замените её другой или не запретите её использование напрямую функцией glUseProgram с параметром NULL. Если вы создадите шейдерную программу, содержащую только 1 тип шейдеров, тогда другие этапы будут использовать свою функциональность по-умолчанию.
Мы завершили проход по вызовам OpenGL, необходимых для управлением шейдерами. Остальная часть урока по содержанию вершинного и пиксельного шейдеров (хранятся в переменных 'pVS' и 'pFS').
#version 330
Данная строка говорит компилятору, что мы хотим использовать GLSL версии 3.3 . Если компилятор не поддерживает её, он сообщит об ошибке.
layout (location = 0) in vec3 Position;
Эта строка внутри вершинного шейдера. Она объявляет, что вершина содержит атрибут, являющийся вектором из 3 элементов типа floats, который будет иметь имя 'Position' внутри шейдера. 'Вершина содержит' означает, что для каждого вызова шейдера в GPU значения новой вершины будут поставляться из буфера. В первой части выражения, layout (location = 0), создаёт связь между названием атрибута и атрибутом в буфере. Это необходимо для ситуации, когда наша вершина имеет несколько атрибутов (позиция, нормали, координаты текстуры и т.д). Мы позволяем компилятору узнать какие атрибуты в вершине из буфера должны быть сопоставлены с объявленными атрибутами внутри шейдера. Для этого есть 2 пути. Мы можем просто напрямую задать их, как мы и сделали в сейчас (в 0). В этом случае мы можем использовать жестко заданные значения (как мы и сделали в первом параметре glVertexAttributePointer). Или мы можем пропустить его (и просто объявить 'in vec3 Position' в ) и затем запросить позиции во время работы приложения используя glGetAttribLocation. В этом случае мы должны задать возвращенное glVertexAttributePointer значение вместо использования жестко заданных чисел. Мы выбрали более простой способ, но для более сложных приложений лучше позволить компилятору вычислить индексы атрибутов и задать их во время исполнения программы. Это позволит интегрировать шейдеры из различных источников без адаптации их к разметки буфера.
void main()
Вы можете создать шейдер через линковку разнообразных шейдерных объектов. Хотя, только одна функция main может существовать для каждого типа шейдера (VS, GS, FS), она используется как входная точка в шейдер. Для примера, вы можете создать библиотеку с различными функциями света и линковать их к шейдеру, если ни одна из этих функций на названа 'main'.
gl_Position = vec4(0.5 * Position.x, 0.5 * Position.y, Position.z, 1.0);
Теперь мы делаем жестко заданные кодом трансформации для координат входящей вершины. Мы уменьшаем X и Y значения в 2 раза и оставляем Z без изменений. 'gl_Position' это специально построенная переменная, содержащая вершинные координаты (X, Y, Z и W компоненты). Растеризатор будет искать переменные и использовать их как позицию в экранном пространстве (используя дополнительные трансформации). Уменьшив X и Y вполовину означает, что мы увидим треугольник, в 4 раза меньше треугольника из предыдущего урока. Заметьте, мы задаём W значение 1.0. Это очень важно для отображения треугольника корректно. Получение проекции из 3D в 2D, на самом деле, состоит из 2 разделёных этапов. Во первых, необходимо перемножить все вершины на матрицу проекции (которую мы будем писать несколько уроков) и затем GPU автоматически выполняет преобразование перспективы для изменения атрибута позиции, прежде чем он попадёт в растеризатор. Это значит, что все компоненты gl_Position будут разделены на W. В этом уроке мы ничего не проектируем внутри вершинного шейдера, но этап преобразования перспективы относится к вещам, которые нельзя отключить. Любое значение gl_Position, которое мы отправим из шейдера, будет разделено на HW, используя его компонент W. Необходимо запомнить, что иначе мы не получим результат, который мы ожидали увидеть. Пытаясь избежать преобразования, мы назначаем W равным 1.0. Деление на 1.0 не даст никакого эффекта. Если всё правильно сделано, все три вершины (-0.5, -0.5), (0.5, -0.5) и (0.0, 0.5) попадут в растеризатор. Клиперу не потребуется ничего делать, поскольку все вершины внутри экрана. Эти вершины отображаются в координаты пространства экрана и растеризатор начинает проход через все вершины внутри треугольника. Для каждой точки запускается пиксельный шейдер. Следующий код взят из пиксельного шейдера:
out vec4 FragColor;
Обычно работа фрагментного шейдера - это определение цвета пикселя. Кроме того, пиксельный шейдер может отбросить пиксель совсем или изменить его Z значение (что повлияет на результат теста глубины Z). Выходной цвет задан переменной ниже. Четыре компонента - это R, G, B и A (для альфы). Значение будет получено растеризатором и в конечном счете записано в буфер кадра.
FragColor = vec4(1.0, 0.0, 0.0, 1.0);
В предыдущих уроках у нас не было фрагментного шейдера, поэтому всё рисовалось белым. Теперь мы задаём FragColor красным.