- Подписка на печатную версию:
- Подписка на электронную версию:
- Подшивки старых номеров журнала (печатные версии)
LXF109:Игрострой
Материал из Linuxformat.
- Игрострой
Игрострой: Ни строчки кода! |
---|
Игрострой: Шейдеры |
---|
|
Содержание |
Нереальная реальность
- ЧАСТЬ 1 Пластмасса, сталь, огонь, вода, облака, свет и процедурные текстуры – что объединяет все эти понятия? Оказывается, шейдеры – и Андрей Прахов сейчас расскажет о них подробнее.
Шейдер! Многочисленная армия поклонников игр так или иначе знакома с этим термином. Строго говоря, программы на шейдерах используются не только в играх, но и в системах 3D-моделирования, визуализации, спецэффектах. Для обычного игрока это слово вызывает разве что ассоциации с чем-то загадочным, непостижимым и, отчасти, крутым. Но мы же с вами не только игроки, а поэтому давайте вместе разбираться, что это за зверь.
Не секрет, что возможности трехмерных ускорителей (GPU) в своей области на порядок выше программных эквивалентов. Это и понятно, так как процессоры современных видеоплат представляют собой специализированные чипы, ориентированные на выполнение задач, связанных с просчетом выводимой графики и только (правда, в последнее время инженерами компании NVIDIA ведутся интенсивные исследования по привлечению мощностей GPU к вычислениям игровой [и неигровой, – прим. ред.] физики). Однако для задействования этих функций необходимо писать код, понятный конкретным GPU. Ранее для этого использовался низкоуровневый язык, чем-то похожий на ассемблер. К счастью, в настоящее время необходимость в нем отпала, и разработчики различных графических API предоставили пользователям свои версии высокоуровневых шейдерных языков. Так как в Linux применяется исключительно OpenGL, то и мы будем изучать созданный для него язык GLSL [OpenGL Shading Language]. Естественно, это не единственный язык подобного рода: так, для DirectX существует HLSL, компания NVIDIA предлагает свой вариант – Cg, а Pixar – RenderMan.
Прежде чем приступить к работе над шейдерами, необходимо оговорить ту программную среду, в которой мы будем ваять свои шедевры. После недолгого размышления я откинул идею об использовании «чистого» API OpenGL, так как тогда нам пришлось бы изучать не столько шейдеры, сколько саму библиотеку. Поэтому для тестирования и прогона наших программ мы возьмем систему моделирования Blender. В этом случае мы получаем ощутимые плюсы в виде легкости написания кода и мощной базы для эффективного отображения задуманных эффектов. Для любителей «чистого» OpenGL будут предусмотрены специальные сноски с необходимыми пояснениями. Обратите внимание! Эти уроки только о GLSL; информация о Blender и OpenGL будет в рамках, строго необходимых для работы (см. врезки).
Что может дать использование шейдеров в наших программах? Вот небольшой список для ознакомления:
- Реалистичность отображения материалов (дерево, металл, пластик, краски);
- Природные явления (огонь, вода, облака, дым);
- Процедурные текстуры;
- Способы обработки изображений (яркость, контрастность, сепии, сглаживание, искривления и т.п.);
- Анимация;
- Реалистичность эффектов освещения, преломления.
Перечень далеко не полный. Не забывайте о том, что для вычисления и демонстрации подобных эффектов с хорошей скоростью центральный процессор просто не годится. Благодаря шейдерам появляется возможность освободить его для более интересных задач, скажем, просчета AI.
Выделяют два вида шейдеров: вершинный и фрагментный. Каждый из них выполняет определенные операции на соответствующем процессоре. Запомните! При изменении стандартной программы обработки какого-либо шейдера необходимо выполнить весь «затертый» функционал в своей реализации кода. Например, невозможно одновременно использовать стандартные функции преобразования вершины и нормали и свой код для вычисления освещения. Написанный вами шейдер должен уметь выполнять все три перечисленные операции. К счастью, в GLSL существуют команды-эквиваленты для замещения стандартных действий. Для более полного понимания происходящего обратите внимание на рис. 1, где схематично представлен конвейер обработки данных OpenGL.
- Вершинный шейдер [vertex shader] – это программа, выполняемая графическим процессором и обрабатывающая данные вершин конкретного примитива. Заметьте, что vertex shader работает исключительно с одной из вершин, не подозревая о существовании других. Таким образом, вы не можете добавлять или удалять вершины из существующего примитива. Для ускорения обработки современные GPU имеют до нескольких сотен соответствующих логических блоков.
Список возможных операций вершинного шейдера:
- Преобразование вершин и нормалей;
- Генерирование и преобразование текстурных координат;
- Настройки освещения;
- Взаимодействие с цветом материала.
После обработки данных всех вершин происходит сборка геометрических примитивов и их растеризация. Следующим этапом является работа фрагментного шейдера.
- Фрагментный шейдер [fragment shader] – это программа, выполняемая графическим процессором и обрабатывающая отдельные фрагменты, полученные при растеризации фигур. Подобно вершинным, фрагментные шейдеры также могут быть запущены параллельно.
Список возможных операций фрагментного шейдера:
- Доступ к текстурам и их наложение на модель;
- Наложение цветов;
- Масштабирование и смещение пиксела;
- Создание эффекта тумана.
Особенности GLSL
Программистам на C/C++, наверное, будет приятно узнать, что создатели GLSL отталкивались именно от их любимцев. Действительно, большинство правил синтаксиса, равно как и построения логических конструкций, напоминают указанные выше языки. Константы, операторы, выражения и предложения – понятия одинаковые и для Си, и для GLSL. Естественно, не стоит забывать, что шейдерный язык создавался исключительно для реализации графических алгоритмов и имеет свои характерные особенности.
Во-первых, забудьте об использовании строковых и символьных значений. GLSL – язык работы только с числами! Не поддерживаются короткие и длинные целые, а также беззнаковые. При указании значения переменной необходимо следить за соответствием типов. Так, выражение float a=0; является неверным и вызовет ошибку компилятора. Правильно будет: float a=0.0;.
В языке шейдеров отсутствуют какие-либо функции для работы с файлами и побитовые операции. Зато, по сравнению с C/C++, появились новые типы данных. Для векторов могут использоваться числа с плавающей запятой, булевы и целые. Так, для первых они называются: vec2, vec3, vec4 (два, три, четыре числа). Имеется доступ к отдельным значениям вектора – либо с помощью индексации, либо по именованным полям. К примеру, значения отдельных координат можно получить, присоединив к переменной .x, .y, .z. Допускается и смешивание: .xy.
Для работы с текстурной памятью был создан специальный тип переменной (дискретизатор), характерный для конкретного типа текстурной карты: sampler1D – доступ к одномерной текстурной карте; sampler2D – доступ к двухмерной текстурной карте, и т.д.
Существуют особые спецификаторы для управления входными и выходными данными шейдеров:
- attribute – переменные этого типа хранят часто изменяющиеся значения и используются для передачи данных от приложения к вершинному шейдеру. Доступны только для чтения. Массивы и структуры описывать ими нельзя;
- uniform – может содержать относительно редко изменяющиеся данные и использоваться для обоих типов шейдеров. В отличие от атрибутов, данный описатель можно задействовать для переменных всех типов. Заметьте, что имеется некоторое ограничение на количество имеющихся uniform-переменных, накладываемое реализацией GLSL и конкретным графическим ускорителем;
- varying – служат исключительно для передачи интерполированных данных от вершинного шейдера к фрагментному, причем фрагментный шейдер (в отличие от вершинного) не может изменять значение полученной varying-переменной. Эти описатели должны быть объявлены в обоих шейдерах и иметь одинаковые типы. Таким образом, как и uniform, они являются глобальными и объявляются до первого использования.
Естественно, предоставляется возможность работы с матрицами – наиболее удобным типом при выполнения линейных преобразований для чисел с плавающей запятой. Поддерживаются таблицы от 2х2 до 4х4 с соответствующим обозначением mat2...mat4. Работать с ними можно как с массивом, выбирая столбцы с помощью индексации.
Помимо сказанного, GLSL имеет встроенные переменные для доступа к состояниям OpenGL. Все они начинаются с префикса gl_. Так, для вершинного шейдера имеются переменные, позволяющие, к примеру, отследить текущее состояние источника света – gl_LightSource[номер источника], gl_Fog.color – цвета тумана, и т.д.
Язык шейдеров предоставляет в помощь программисту большой набор встроенных функций, таких как: тригонометрические (синус, косинус, тангенс...); геометрические (нормализация, нахождения расстояния и длины и др.); общие математические операции (округление, модуль числа и т.д.); специализированные для фрагментного шейдера; функции доступа к текстурной памяти и многие другие. Пользоваться ими не только можно, но и нужно. Дело в том, что большинство предлагаемых функций могут обрабатываться на аппаратном уровне GPU и существен- но ускорить вычисления. К сожалению, не всем функциям обеспечена такая поддержка, но их применение является хорошим тоном, хотя бы потому, что производители оборудования не стоят на месте, а значит, можно ожидать появления этой поддержки в будущем.
Первые шаги в неизведанное
Начнем с того, что каждый шейдер должен иметь свою функцию main(), объявленную как тип void – стандартная конструкция Си:
void main () { ... }
Попробуем написать простейший шейдер, который будет выводить объект и закрашивать его в красный цвет. Распределим задачи:
- Вершинный шейдер – трансформация вершины и запись полученного значения в глобальную переменную gl_Position;
- Фрагментный шейдер – заливка цветом полученного пикселя через переменную gl_FragColor.
Таким образом, получается, что вершинный шейдер у нас должен выполнять стандартный функционал, а вот фрагментный подвергается необходимым изменениям. Займемся сначала первым.
//GLSL vertex shader void main () { gl_Position = ftransform(); }
Ftransform() как раз и является той функцией, что позволяет частично замещать «затертый» функционал стандартного шейдера. Она возвращает результат трансформации вершин, выполняя операции того же плана, что и базовый функционал. Учтите, что все остальные преобразования – скажем, такие, как обработка освещения – произведены не будут. Но использование ftransform() гарантирует, по крайней мере, выполнение стандартного потока операций с использованием оптимизации на уровне GPU.
Для записи цвета обработанного пикселя во фрагментном шейдере служит переменная gl_FragColor, которая, так же как и gl_Position, является заранее объявленной. Тип переменной – vec4.
//GLSL fragment shader void main () { gl_FragColor = vec4 (1.0,0.0,0.0,1.0); }
Обратите внимание на значения, передаваемые переменной. Это не что иное, как раскладка цветовой палитры RGBA (red, green, blue, alpha) [красный, зеленый, синий, прозрачность]. Как уже говорилось ранее, GLSL позволяет получить доступ к отдельным значениям вектора с помощью именованных полей. Для этой цели, в данном случае, имеются зарезервированные буквы r, g, b, a. Например, фрагментный шейдер можно было написать так:
//GLSL fragment shader void main () { gl_FragColor.r = 1.0; gl_FragColor.gba = vec3 (1.0, 0.0, 1.0); }
Вот и все! Для проверки написанного кода просто загрузите с LXFDVD файл les1_1.blend и запустите движок клавишей P (рис. 2).
Действительно очень просто; но не очень зрелищно. Картинка будет смотреться гораздо выигрышнее, если научить шейдер реагировать на освещение модели. Существует немало способов и алгоритмов расчета освещения, и в дальнейшем этой теме будет посвящена целая статья. Сейчас же давайте рассмотрим самый простой способ работы со светом, а именно – диффузную модель освещения, когда луч, падающий на произвольную точку поверхности, равномерно рассеивается по всем направлениям (в рассматриваемом примере расчетная функция сознательно упрощена). Все необходимые вычисления будет производить вершинный шейдер.
Вначале необходимо вычислить координаты вершины в пространстве обзора. Для этого берем локальные координаты из переменной gl_Vertex и умножаем их на текущую матрицу объекта. Заметьте, так как один из операндов – матрица, а другой – вектор, то происходит математическое, а не покомпонентное умножение:
vec3 position = vec3 (gl_ModelViewMatrix * gl_Vertex);
Для вычисления самого рассеивания света нужно определить угол между нормалью к поверхности и лучом света (рис. 3). Добиться этого можно, если взять значения нормали и преобразовать их с помощью матрицы gl_NormalMatrix. Полученный вектор необходимо привести к единичной длине с помощью встроенной функции normalize:
vec3 norm = normalize (gl_NormalMatrix * gl_Normal);
Осталось только построить вектор из заданной точки до источника освещения:
vec3 lightvec = normalize (vec3 (lightPos) - position);
Для окончательного результата вычисления введем переменную varying outColor, которая будет хранить интерполированное значение цвета для фрагментного шейдера. Переменные этого типа должны быть определенны в обоих шейдерах одинаково. Помимо нее, нам еще понадобится константа inColor для хранения оригинального цвета объекта и uniform-переменная – для координат источника света. Следующая строка выполняет необходимые нам вычисления:
outColor = inColor * (max (dot (norm, lightvec), 0.0));
Так как может сложиться ситуация, когда источник света расположен за самим объектом, то применяется функция max для обеспечения нулевого значения рассеянного отражения при угле больше 90 градусов между направлением освещения и нормалью к поверхности. Вторая неизвестная вам функция dot выполняет скалярное произведения двух векторов.
Осталось выполнить трансформацию вершины, но на этот раз мы не будем вызывать функцию ftransform(), а выполним непосредственное перемножение координат вершины на проектную матрицу OpenGL (результат практически тот же):
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
И вот что в итоге у нас получилось:
//GLSL vertex shader void main () const vec4 inColor = vec4 (1.0, 0.0, 0.0, 1.0); uniform vec3 lightPos; varying vec4 outColor; { vec3 position = vec3 (gl_ModelViewMatrix * gl_Vertex); vec3 lightvec = normalize (vec3 (lightPos) – position); vec3 norm = normalize (gl_NormalMatrix * gl_Normal); outColor = inColor * (max (dot (norm, lightvec), 0.0)); gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; gl_Position = ftransform(); } //GLSL fragment shader varying vec4 outColor; void main () { gl_FragColor= outColor; }
Как видите, ничего сложного в работе с шейдерами нет, особенно когда многими учеными мужами уже были выведены необходимые нам формулы. Однако на следующем уроке мы займемся по-настоящему тяжелой и интересной работой с так называемыми процедурными текстурами. До встречи! LXF
А как это в Blender?
В большинстве случаев для программирования шейдеров можно обойтись предлагаемой ниже заготовкой:
##---------------------------------------------------------------- ## Shader Template.py ##---------------------------------------------------------------- import GameLogic ObjectList = GameLogic.getCurrentScene().getObjectList() # ------------------------------------- ShaderObjects = [ObjectList[‘OBCube’]] MaterialIndexList = [0] ##--------------------------------------------------------------- # ---------------------------------------------------------------- VertexShader = """ void main() { //shader } """ FragmentShader = """ void main() { //shader } """ def MainLoop (): for obj in ShaderObjects: mesh_index = 0 mesh = obj.getMesh(mesh_index) while mesh != None: # for each material in the mesh for mat in mesh.materials: if not hasattr(mat, "getMaterialIndex"): return mat_index = mat.getMaterialIndex() # find an index from the list found = 0 for i in range(len(MaterialIndexList)): if mat_index == MaterialIndexList[i]: found=1 break if not found: continue shader = mat.getShader() if shader != None: if not shader.isValid(): shader.setSource(VertexShader,FragmentShader,1) # set uniforms mesh_index += 1 mesh = obj.getMesh(mesh_index) ## call it MainLoop()
Рассмотрим ее подробнее. Первые две строки подключают необходимую библиотеку BGE и загружают в переменную ObjectList список существующих объектов в сцене. Все это стандартно, но обратите внимание на третью строку в листинге. Именно в ней указывается объект, для которого будет применяться шейдер. В моем тексте это ‘OBCube’. Необходимое имя объекта вы можете взять из меню ‘Object and Link ‘, нажав F7.
Строки ‘VertexShader’ и ‘FragmentShader’ указывают на начало программных блоков, написанных на GLSL. Все передаваемые в шейдер переменные uniform располагаются после строки ‘# set uniforms’. Список функций для имеющихся переменных GLSL таков:
Тип переменной | Функция инициализации | Комментарий |
---|---|---|
uniform vec1 (float) | setUniform1f (name, a) | Целочисленное значение |
uniform vec2 (float) | setUniform2f (name, a,b) | |
uniform vec3 (float) | setUniform3f (name, a,b,c) | |
uniform vec4 (float) | setUniform4f (name, a,b,c,d) | |
uniform vec1 (int) | setUniform1i (name, a) | Число с плавающей запятой |
uniform vec2 (int) | setUniform2i (name, a,b) | |
uniform vec3 (int) | setUniform3i (name, a,b,c) | |
uniform vec4 (int) | setUniform4i (name, a,b,c,d) |
Приведу пример. Пусть в вершинном шейдере используется переменная uniform vec3 lightPos. Соответственно, для ее установки служит следующая строка на Python: shader.setUniform3f(‘lightPos’, 0.2,0.2,0.2).
А как это в OpenGL?
Для создания и управления шейдерами в OpenGL имеется специальный набор функций, предоставляемый расширениями ARB_shader_objects, ARB_vertex_shader, ARB_fragment_shader. В целом, последовательный механизм действий выглядит следующим образом:
1 Создание пустого шейдерного объекта функцией glCreateShaderObjectARB (Glenum тип шейдера). Пример:
testVS = glCreateShaderObjectARB (GL_VERTEX_SHADER_ARB); testFS = glCreateShaderObjectARB (GL_FRAGMENT_SHADER_ARB);
2 Передача исходного кода шейдеров функцией glShaderSourceARB. Пример:
glShaderSourceARB(testVS, 1, &testvertex, NULL); glShaderSourceARB(testFS, 1, &testfragment, NULL);
3 Компиляция каждого из шейдеров:
glCompileShaderARB(testVS); glCompileShaderARB(testFS);
4 Создание программного объекта и присоединение шейдеров к нему:
shaderProg = glCreateProgramObjectARB(); glAttachObjectARB(shaderProg, testVS); glAttachObjectARB(shaderProg, testFS);
5 Компоновка функцией glLinkProgramARB:
glLinkProgramARB(shaderProg);
6 Установка программного объекта как текущего:
glUseProgramObjectARB(shaderProg);
7 Установка, если нужно, начальных значений переменных uniform:
glUniform3fARB(getUniLoc(shaderProg, ‘name’), первое значение, второе значение, третье значение);