Эффективное программирование 3d-приложений с помощью Irrlicht и Jython. Часть 13

Сегодня мы поговорим о шейдерах. Кто такие шейдеры, зачем они нужны, какое у них прошлое и будущее. Начну я с давней истории о том, как появились первые микропроцессоры. Давным-давно (тогда, в 1971 г., микропроцессоры еще не были изобретены) компания intel получила заказ на разработку микросхемы калькулятора для какой-то японской компании. В принципе, калькулятор — это очень простая вещь: 2 числа, 4 операции, так что можно полагать, что intel набила себе руку на производстве таких продуктов, и у инженеров было множество свободного времени. И нет чтобы пить себе кофе и ничего не делать, так вот задумались они: а что если завтра нам придет заказ на калькулятор с 5-ю арифметическими операциями, а потом с 6-ю, а потом что-то еще? Так и на кофе времени не останется. А давайте мы сделаем такую универсальную микросхему, которая бы умела не только выполнять 4 операции, а делать все, что угодно — надо бы только для нее изобрести какой-то способ программирования. А так пришел заказ на очередной калькулятор, мы раз — и написали новую микропрограмму для нашей универсальной микросхемы, и можно снова ничего не делать. В общем, взяли и сделали. Потом, правда, одумались, что идея слишком хороша, чтобы ее использовать для производства только калькуляторов, выкупили патент и вскоре появился первый 4-разрядный микропроцессор Intel-4004.

Потом спустя еще какое-то время, когда на компьютере стали запускать не только нужные программы, но и вредные 3d-игры, мощностей центрального процессора стало не хватать (он же, бедный, должен считать еще и физику, и AI, и многое другое). Сначала хотели поместить внутрь компьютера два, три, а то и все десять микропроцессоров, но время было не то — технологии не отлажены, да и дороги слишком. Задумались разработчики, спросили программистов: а какие действия обычно выполняются при расчете и отрисовке 3d? Программисты подумали и сказали: мол, так и так, нужна нам функция рисования круга, треугольника и еще какой-то фигуры. И все, больше ничего не нужно, спросили инженеры? А то, ответили программисты, больше нам ничего не надо. Ладно, взяли и спаяли инженеры новый девайс, который очень-очень быстро умел рисовать круг, треугольник и еще какую- то фигуру (стоил он гораздо дешевле, чем тот же процессор — еще бы, делать-то он умел всего три действия). И назвали эту штуку 3d-ускоритель, да заодно с видеокартой соединили вместе. И все было бы хорошо, но вот пришли пользователи к программистам и сказали: а что это, мол, в ваших играх одни только треугольники и круги, давайте использовать еще и эллипс. Тут зачесали затылки программисты, как так эллипс — нет такой функции внутри этой железяки-видеоускорителя. Пошли к инженерам: мол, реализуйте, пожалуйста, новую функциональность. А те и рады: вот, держите, новая версия нашего ускорителя. А то, что новая игра не запускается на старом железе, ну так это ерунда — прогресс, как говорится, не ждет. Да вот еще проблема: на рынке конкуренция появилась, разные фирмы стали делать эти самые 3d-акселераторы. Только вот, наверное, ума на всех не хватило, так что у одних ускорителей круги рисуются не такие круглые, как у конкурентов, а у соседей, наоборот, треугольники какие-то кривые выходят. Так что нашим программистам и пользователям одна головная боль. Как игру так написать, чтобы она одинаково выглядела под разными ускорителями? Добро если бы, как в этой сказке, все рисование свелось к таким простым действиям, как круги и треугольники. Так нет, глубина цвета, глубина z-буфера, всякие встроенные эффекты, тени, фиксированное количество источников света, просадки по скорости в разных местах у разных ускорителей. В общем, разные были эти ускорители: в одних играх одни были быстрее в других играх — другие. Но к тому времени технологии, которые использовались для производства ускорителей, подросли, и стало возможным уже не зашивать аппаратно некоторый набор функций в микросхему, а создавать более или менее универсальные чипы. И к ним писать специальные микропрограммы, управляющие тем, как рисовать ту или иную фигуру или эффект. Конечно, графические процессоры самых современных видеокарт еще не настолько универсальны, как центральные процессоры, но все к тому идет. Современная видеокарта настроена на то, чтобы выполнять уже не фиксированные, а произвольные операции, причем массово и параллельно. До процессора, как я говорил, еще далеко, но перспектива чувствуется. С другой стороны, разработчики процессоров тоже не спят. Решили они повышать скорость работы не за счет подъема тактовых частот и игр с длиной конвейера, а за счет параллелизма. Как известно, в современном компьютере одновременно выполняется несколько задач, и делить на них время одного процессора получается крайне неэффективно из-за накладных затрат на переключение контекста выполнения, промахов в кэше и т.д. Так что когда каждый процесс и даже поток получит по собственному ядру, тогда и наступит всеобщее компьютерное счастье. Разве что программистам опять морока: надо учиться писать код, использующий возможности параллельного исполнения — об этом говорил еще в далеком 2005 г. Ричард Вирт на московском форуме Intel для разработчиков аппаратного и программного обеспечения (Intel Developer Forum).

На этом нашу сказку я завершаю. А вот краткие выводы. Шейдеры — это микропрограммы, написанные на некотором достаточно низкоуровневом языке и исполняющиеся (сюрприз) не на центральном процессоре компьютера, а на его графическом чипе. До появления шейдеров в 3d-ускорителях был реализован стандартный ограниченный набор функций. Очевидно, что задача создания универсального чипа (дающего возможность программистам создавать собственные эффекты) довольно сложна. Поэтому рынок прошел через ряд стадий развития чипов и версий шейдеров, которые ими поддерживались. Так, раньше шейдеры делились на пиксельные и вершинные (соответственно микропрограммы, выполнявшиеся для вершин треугольников или же отдельных их пикселей). Каждый тип шейдеров обрабатывался на своем специализированном чипе. Делились шейдеры по версиям, отличавшимся возможностями этого самого низкоуровневого языка (длиной инструкций, возможностью ветвления кода или циклов). Сейчас же очередной технологический этап, когда производители чипов научились делать универсальные шейдерные блоки (семейство видеокарт nvidia 8000). Универсальные блоки дали возможность грамотно балансировать нагрузку. Больше у вас нет ситуаций, когда простаивали свободные пиксельные блоки в то время, как не хватало вершинных, и наоборот. Также появился новый тип шейдеров — геометрические, способные изменять топологию просчитываемой модели. Очевидно, что вы не можете писать код для графического процессора с помощью какого-либо из универсальных языков (c|c++|pascal). Эти языки содержат большое количество избыточных конструкций, которые не нужны и нереализуемы для GPU (скажите, кому нужны, например, операторы ввода/вывода?). С другой стороны, так как GPU представляет собой специализированный (работающий с матрицами, векторами, цветом), а не универсальный чип (в отличие от CPU), то нам нужны встроенная поддержка в этом языке высокоуровневых математических действий. Особенностью шейдеров являются массовые параллельные вычисления — это означает, что вы должны представить шейдер в роли "черного ящика", у которого есть вход и выход. А этот черный ящик должен на основании входных данных что-то рассчитать и дать это на выход. Но, и это важно, никак нельзя определить, что мы обрабатываем в целом. Если у нас есть некоторый полигон для каждой вершины, которой срабатывает некоторый шейдер, то мы никак не можем узнать, где находятся другие вершины, что-либо поменять в них. Так, если у вас есть один материал, применяемый к стене и к потолку сцены, то определить, что именно вы обрабатываете, невозможно. На самом деле это и не надо (не важно, где находится материал или какая часть исходного объекта просчитывается, но обладает он одинаковыми наборами характеристик) — просто в начале это несколько непривычно.

Говоря о шейдерах, нельзя не упомянуть о графическом конвейере (рис. 1). Первая стадия работы конвейера — это найти все видимые полигоны модели, извлечь из них список вершин и передать их внутрь вершинного шейдера. В свою очередь, вершинный шейдер получает на вход одну (еще раз вспомните про массовость и оптимизацию параллельных вычислений) вершину и выполняет ее преобразование, в простейшем случае выполняется переход из видовой системы координат в экранную. Также вершинный шейдер может сгенерировать ряд дополнительной выходной информации — например, сведения о текстурных координатах или ориентации нормали. После того, как все три вершины треугольника были преобразованы, видеочип выполняет операцию растеризации и интерполяции, в ходе которой вычисляются все точки, лежащие в пределах спроецированного на экран треугольника. Для каждой из них вычисляется (с помощью интерполяции) значение ее характеристик. Так, если вершинный шейдер вернул для трех вершин их текстурные координаты, то здесь находят значения эти текстурных коордиант для всех точек треугольника. Затем полученная информация о каждом из пикселей поступает на вход пиксельному шейдеру, который и вычисляет конечный цвет пикселя. Именно здесь вы можете наложить на материал различные эффекты, туман, блики… Итак, в настоящий момент есть 3 языка, на которых можно писать шейдеры: HLSL (High Level Shader Language) от Microsoft, GLSL (OpenGL Shader Language) от SGI и Cg от NVidia. Есть еще и четвертый вариант — ассемблер, — но он слишком уж низкоуровневый и для вводной статьи о шейдерах откровенно не подходит. Естественно, три этих языка переводятся в машинные коды, и особой разницы в производительности быть не должно. Также очевидно, что код, написанный на Cg, будет работать и под картами ati. Все эти три языка очень похожи на язык c (классический c, без объектов, шаблонов). Все примеры далее я пишу на HLSL. Конечной целью сегодняшнего занятия будет загрузка в irrlicht некоторой модели с последующим применением к ней пиксельного шейдера. Очевидно, что никто не пишет шейдеры и сразу же не проверяет их работу в комплексе с остальными частями игры — для этого потребуется слишком много времени. Поэтому мы познакомимся со специальной средой разработки ATI Render Monkey. Я знаю три известных редактора шейдеров (все они бесплатны): ATI Render Monkey, FX Composer (от NVidia) и ShaderWorks XT.

Итак, вы установили и запустили ATI Render Monkey. Внизу расположено окно сообщений — в него помещается информация об ошибках, возникающих в ходе компиляции шейдера. По центру расположено окно предварительного просмотра. Там же находятся окна текстового редактора для вершинных и пиксельных шейдеров. Слева вы видите окно Workspace — в нем будет представлена в виде дерева вся информация о создаваемых нами наборах эффектов и связанных с ними ресурсах. Сначала создайте группу эффектов с помощью контекстного меню Add Effect Group -> Empty Effect Group. Затем в эту группу мы можем поместить собственно эффект. Снова через контекстное меню выбираете пункт Add Effect -> Direct X -> Direct X. На экране появится шар-заготовка, залитый сплошным красным цветом. Раскрыв дерево эффекта, вы видите следующие узлы: matViewProjection — это переменная, в которой хранится матрица преобразования из видовой СК в экранную СК. Следующий узел Stream Mapping. Дело в том, что в стандарте языка HLSL достаточно свободно отнеслись к тому, как именно будет выполняться вызов функции шейдера, передача ему параметров и возврат рассчитанной информации. Свободно — в смысле, что вы можете сами решить, какой поднабор информации будет возвращен из вершинного шейдера и, соответственно, какой набор параметров поступит на вход пиксельному шейдеру. Но для того, чтобы определить интерфейс вызова шейдера (как сказать GPU, что нам нужны вот эти данные и в таком порядке?), существуют специальные метки. Т.к., в конце концов, все наши действия сведутся к операциям с регистрами GPU, то очевидно, что мы не можем получить информацию, отличную от той, которая содержится в этих регистрах. У каждого регистра есть свое имя и назначение. По двойному клику на узле Stream Mapping откроется диалоговое окно, где можно указать формат входных данных для вершинного шейдера. Следующий узел — Model — очевидно, что это демонстрационная модель. Затем идет один (хотя может быть и несколько) узлов Pass0 — это узлы, задающие сведения о правилах, по которым выполняется текстурирование объекта. У нас одно правило, и, если его раскрыть, вы увидите подузлы: Model — ссылка на используемую модель объекта, Vertex Shader — по двойному клику на этом узле откроется окно редактора вершинного шейдера, Pixel Shader — аналогично: открывается редактор исходного текста пиксельного шейдера, и последний узел — Stream Mapping — ссылка на правило вызова шейдера.

Начнем с того, что заменим модель. Для этого используйте двойной клик по узлу Model (но не на уровне Pass0). В появившемся диалоговом окне выберите любой файл в формате 3ds, direct x, obj. В поставке с ATI Render Monkey идет множество моделей. Так, я выбрал файл ElephantBody.3ds, после чего в центральной области просмотра появился красивый красный слоненок. Давайте его немного раскрасим. Для этого откройте узел Pass0 -> Pixel Shader. Теперь вы видите исходный код шейдера. Правда, он похож на маленькую программку на c? Вверху редактора вы видите падающий список, откуда можно выбрать целевую версию шейдера, в которую будет компилироваться ваш код, а также можно произвольно выбрать имя главной функции шейдера.
float4 ps_main() : COLOR0{ return( float4( 1.0f, 0.0f, 0.0f, 1.0f ) );}

Итак, функция ps_main должна вернуть объект float4, в котором кодируется цвет пикселя слоненка. Float4 — это вектор, состоящий из компонент: red, green, blue, alpha. Обратите внимание на декларацию метки :COLOR0 после имени функции — это означает, что функция возвращает итоговый цвет именно с помощью оператора return. Можно декларировать функцию так (главное — пометить, где находится возвращаемая информация):
void ps_main(out float4 rez : COLOR0){ rez = float4( 1.0f, 1.0f, 0.0f, 1.0f ) ;}

Ключевое слово "out" перед объявлением аргумента функции говорит, что переменная rez является "выходной". Затем я сохранил файл, нажал F7 для перекомпиляции шейдера и увидел уже желтого слоненка. Хорошо, мы умеем закрашивать модель сплошным цветом, давайте теперь наложим на слоненка текстуру. Прежде всего, необходимо добавить на высшем уровне эффекта объект текстуры с помощью меню add Texture -> Add 2d Texture -> 2d Texture. Затем по двойному клику на появившемся узле my2DTexture с помощью диалогового окна выбора файла нужно указать собственно файл изображения текстуры. Затем надо добавить ссылку на объект my2DTexture внутри Pass0. Снова использую правую кнопку мыши и выбираю пункт add Texture Object. Созданная ссылка на текстуру изображена в виде перечеркнутого квадрата — мол, ошибка. Естественно, мы ведь еще не указали, какой именно объект текстуры будет привязан к этой ссылке. Снова правая кнопка мыши и пункт Reference Node -> my2Texture. Изображение желтого слоника пока не поменялось. Естественно, мы ведь не исправили код нашего пиксельного шейдера так, чтобы он обращался к некоторым пикселям файла изображения текстуры. Более того: а как узнать, какой именно пиксель следует брать? Нам нужно исправить узел Stream Mapping. По двойному клику открываете диалоговое окно со списком передаваемых вершинному шейдеру данных и добавляете новую переменную с меткой TEXCOORD и типом данных float2. Вернемся к узлу Pass0, откроем редактор вершинного шейдера и исправим его так:

float4x4 matViewProjection;
// здесь объявляется ссылка на матрицу преобразования СК
struct VS_INPUT {// объявляется структура данных, содержащая пару: координаты вершины и ее текстурные координаты
float4 Position : POSITION0;
float2 TextCoord : TEXCOORD;};
struct VS_OUTPUT {// аналогично мы объявляем выходную структуру данных
float4 Position : POSITION0;
float2 TextCoord : TEXCOORD;};

VS_OUTPUT vs_main( VS_INPUT Input ){
VS_OUTPUT Output;// здесь нам следует выполнить переменожение вектора координат вершины и матрицы преобразования СК для перехода в экранную СК
Output.Position = mul( Input.Position, matViewProjection );
// текстурные координаты оставляем без изменения
Output.TextCoord = Input.TextCoord;
return( Output );}
Последний шаг — исправление кода пиксельного шейдера. Там мы должны сослаться на объект текстуры — его имя Texture0 — и с помощью функции tex2D извлечь из текстуры пиксель в заданных координатах.
sampler Texture0;
float4 ps_main(float2 texCoord: TEXCOORD) : COLOR0{
return tex2D(Texture0, texCoord);}

В следующий раз я продолжу рассказ о создании шейдеров и о том, как их можно использовать внутри irrlicht.

black zorro, black-zorro@tut.by


Компьютерная газета. Статья была опубликована в номере 30 за 2007 год в рубрике программирование

©1997-2024 Компьютерная газета