Урок, рассматривающий основы работы с Vertex Textures Fetch в XNA. Фактически это не один урок, а целых четыре, материала очень много, но интересно. Кто все же решит досконально пройти весь урок и набрать весь исходный код сам, небольшой совет – сохраняйте состояния проектов (делайте копии, используйте SVN и т.п.) перед каждой из частей и конце их, ибо замечены несколько прецедентов «отката» состояния проекта на предыдущее состояние. В тексте переведены все комментарии к исходным кодам как C#, так и HLSL, прилагаемые исходники взяты с англоязычного ресурса и там все комментарии на английском.
Вступление
Данная статья описывает четыре метода использования Вершинных Текстур (Vertex Textures) в XNA играх. Начнем мы с краткого описания вершинных текстур, что это такое и как ими пользоваться, затем реализуем четыре эффекта с их использованием, от простейшего до довольно сложных реализаций. В статье будет детально расписан каждый шаг написания программ и объяснено, почему выбрана именно эта реализация и какие техники могут быть применены для решения подобных задач. Некоторые части будут содержать дополнения с разъяснениями как использовать те или иные подходы реализации в других подобных задачах, даже если они не связаны с Vertex Texture Fetching (далее по тексту просто VTF).
Вершинные Текстуры
С момента появления программируемых графических процессоров в их архитектуру было изначально заложено, что вершинный и пиксельный шейдеры имеют различные характеристики. В шейдерной модели 3.0 были сделаны первые шаги для объединения их функциональности. DirectX 10 сделал последний шаг и объединил набор инструкций между всеми типами шейдеров: пиксельными (Pixel), вершинными (Vertex) и новыми геометрическими (Geometry) шейдерами. Основной темой данной статьи будет Vertex Texture Fetch (VTF), одна из фичей третьей шейдерной модели (Shader Model 3.0), которая в свою очередь послужила началом этой унификации. VTF позволяет нам читать информацию из текстуры в вершинный шейдер, почти так же, как это делает пиксельный шейдер. Чтобы использовать функциональность VTF Вам потребуется XBOX 360 (у которого унифицированная шейдерная архитектура) или графическая карта NVIDIA серии GeForce 6 или выше. Для владельцев видеоадаптеров от ATI, для достижения подобного результата применима технология R2VB – отрисовка в вершинный буфер (Render To Vertex Buffer), но я не знаю, как использовать данную технологию в XNA. Ассемблерная функция графического процессора для чтения текстуры в вершинный шейдер – «texldl», в HLSL, который и будет использоваться, ее аналогом является – «tex2Dlod». Параметры вызова - tex2Dlod( s, t ), где «s» - 2D текстурный самплер и «t» - четырехмерный вектор (float4). При чтении текстуры mipmap уровень указывается вручную, путем задания четвертого компонента вектора (t.w). В большинстве случаев используется 0 уровень (текстура будет считана, как есть), так что вызов будет выглядеть примерно следующим образом - tex2Dlod(textureSampler, float4(uv.xy,0,0)). Поведение вершинной текстуры аналогично текстурам в пиксельных шейдерах, за исключением:
Билинейная (Bilinear) и трилинейная (Trilinear) фильтрация не поддерживается на аппаратном уровне. Далее будет рассмотрено, как реализовать билинейную фильтрацию в вершинном шейдере.
Анизотропная (Anisotropic) фильтрация не поддерживается на аппаратном уровне.
Автоматический расчет mipmap уровня не производится и если необходимо, расчет нужно реализовывать самим.
Необходимые ресурсы и код
Две карты высот, .dds в формате R32F (каждый пиксель записан в 32 битный float и содержит информацию о высоте).
Примечание: видеокарты NVIDIA поколения 6 и 7 для VTF поддерживают только R32F и A32R32G32B32F форматы. Возможно GeForce 8 и XBOX 360 могут для VTF использовать и другие форматы, но у меня нет возможности проверить это.
Camera.cs является компонентом (GameComponent), который реализует игровую камеру, взят из примера Skinned Model Sample.
Используйте триггеры на контроллере или клавиши Z и X клавиатуры для зума камеры.
Правый стик или клавиши WASD для перемещения камеры.
Grid.cs класс для отрисовки сетки в плоскостях XZ.
DudeModel.zip будет использован в четвертой части.
Эти ресурсы будут использованы в данном уроке, и исходный код будет содержать ссылки на эти файлы. Grid.cs
Данный класс реализует геометрию сетки и будет использован в Heightmap Rendering, Terrain Morphing и Steps in Snow. Свойство CellSize отвечает, на сколько большими будут ячейки сетки, а свойство Dimension контролирует размерность сетки (сколько строк, столбцов). Каждая вершина содержит информацию о позиции, нормали и соответствующей текстурной координате. Таким образом, каждая вершина ссылается (мапится) на пиксель в текстуре и это будет использовано в вершинном шейдере для манипуляции вершинами. Механизм ассоциации между вершиной и пикселем текстуры, является ключевым при использовании VTF. Изначально высота для всех вершин установлена в 0, так что мы имеем идеально гладкую поверхность, в этом можно убедиться, посмотрев в функцию GenerateStructures (строка 46, кода Grid.cs).
Начнем мы с простого примера - отрисовки ландшафта, используя карту высот. Карта высот (heightmap) это изображение (оттенки серого), содержащее информацию о высотах. Чем темнее оттенок пикселя, тем ниже данный участок поверхности, соответственно, чем светлее, тем выше. Так как мы используем текстуру, которая содержит данные с плавающей точкой, то 0,0 – минимальная высота, а 1,0 – означает максимальную высоту. На самом деле построить ландшафт по карте высот можно и без использования вершинных текстур, просто прочитав карту высот на этапе загрузки и на основе ее данных построить буфер вершин. А так как эта операция производится только один раз (в момент загрузки), а не каждый кадр (в вершинном шейдере) использование вершинных текстур для решения подобных задач, в плане производительности приложения, несколько нелогично. Как бы то ни было, это является простейшей и относительно легкой в реализации демонстрацией использования VTF. Начнем с создания нового проекта (Windows или Xbox360) и добавим в проект файлы Camera.cs и Grid.cs. Затем идем в основной класс созданного проекта (Game1.cs) и добавляем пространство имен VTFTutorial в объявления using.
usingVTFTutorial;
Теперь добавляем две новые переменные в класс Game1: одну для камеры и одну для сетки. Инициализируем их и указываем параметры в конструкторе класса. Камера должна быть добавлена в список компонентов, т.к. является игровым компонентом и за изменения состояний камеры и ее обновление теперь будет отвечать компонентная модель XNA. Так же мы должна задать параметры сетки CellSize и Dimension, 4 и 256 соответственно. Вы можете проиграться с параметром CellSize и повыставлять ему различные параметры, просто ради спортивного интереса «А что будет?». О том, что произойдет если выставить другие параметры свойству Dimension, мы поговорим четь позже. Теперь в методе LoadGraphicsContent добавьте вызов grid.LoadGraphicsContent().
Итак, у нас есть геометрия, теперь нам необходимо создать файл эффекта (шейдера) который нам создаст и отресует ландшафт. Добавьте в проект новую папку с именем «Shaders», затем в эту папку добавьте новый текстовой файл (Add -> New Item...) с именем «VTFDisplacement.fx». Откройте его и приступим к написанию кода шейдера на языке HLSL (High Level Shading Language).
Нам необходимы 3 параметра матрицы, для матриц мира (world), вида (view) и проекции (projection). Затем мы должны будем добавить карту высот с именем displacementMap (карта смещения, так как именно в ней содержится информация о смещении вершин относительно плоскости XZ) и самплер дня нее. Самплер будет использоваться для чтения данных высоты из карты высот в вершинном шейдере. Для всех фильтров с самплере используется значение Point, т.к. линейный (Linear) и анизотропный (Anisotropic) не поддерживается для вершинных текстур. Даже если прописать другое значение для фильтров, никаких сообщений об ошибках выедено не будет, но использоваться все равно будет значение Point.
float4x4 world;// матрица мираfloat4x4 view;// матрица видаfloat4x4 proj;// матрица проекции// максимальная высота для ландшафтаfloat maxHeight =128;//текстура карты высотtexture displacementMap;// самплер для чтения карты высотsampler displacementSampler = sampler_state { Texture =<displacementMap>; MipFilter = Point; MinFilter = Point; MagFilter = Point; AddressU = Clamp; AddressV = Clamp;};
Далее создадим две структуры, одна будет описывать данные, поступающие на вход вершинному шейдеру, вторая данные на выходе из него. На вход мы будем подавать местоположение вершины (position) и ее текстурные координаты (uv), на выходе мы будем получать трансформированные вершины в пространстве, а так же будем передавать в пиксельный шейдер еще один «комплект» координат вершины в переменной worldPos для «раскрашивания» основываясь на данных высоты вершин. Текстурные координаты будем просто «туннелировать» через вершинный шейдер в пиксельный, оставляя без изменений.
struct VS_INPUT {float4 position :POSITION;float4 uv :TEXCOORD0;};struct VS_OUTPUT{float4 position :POSITION;float4 uv :TEXCOORD0;float4 worldPos :TEXCOORD1;};
Код вершинного шейдера выглядит следующим образом:
VS_OUTPUT Transform(VS_INPUT In){// инициализация выходной структуры VS_OUTPUT Out =(VS_OUTPUT)0;//расчет матрицы произведения View * Projectionfloat4x4 viewProj = mul(view, proj);//финальный расчет матриц для вывода на экран World * View * Projectionfloat4x4 worldViewProj= mul(world, viewProj);// Данная инструкция читает карту высот// в соответствии с текстурными координатами вершины// Примечание: мы передаем как параметр 0 уровень mipmap в tex2Dlod,// т.к. хотим чтобы изображение использовалось 1 к 1float height = tex2Dlod ( displacementSampler,float4(In.uv.xy,0,0));// После считывания данных высоты с текстуры// мы присваиваем это значение в y координаты вершины// и т.к. значение height лежит в диапазоне от 0 до 1,// то перемножаем с переменной maxHeight, которая в// свою очередь отвечает за максимальный подъем ландшафта In.position.y= height * maxHeight;//Передаем в пиксельный шейдер координаты вершины в мировом пространстве Out.worldPos= mul(In.position, world);//Расчитываем финальное местоположение вершины уже для вывода на экран Out.position= mul( In.position, worldViewProj);//Текстурные координаты передаем без изменений Out.uv= In.uv;return Out;}
Функция tex2Dlod читает данные высот из текстуры, использую текстурные координаты которые создаются в классе Grid.cs. Перед тем как координаты вершины будут перемножены со всеми матрицами для вывода на экран, мы подменим данные координаты Y, на этом этапе вершины будут выстроены по высоте в соответствии с данными карты высот и только потом произойдет преобразование вершин в экранные координаты. Все эти действия выполняет графический процессор.
Настало время позаботиться о пиксельном шейдере. Мы просто отрисовываем ландшафт раскрашивая в оттенки серого, более светлые тона наверху, темные внизу. Техника (technique) шейдера имеет всего один проход (pass), содержащий два наших шейдера. В параметрах компиляции указываем vs_3_0 и ps_3_0, т.к. VTF является функциональностью Shader Model 3.0.
После того как мы закончили с шейдером, давайте вернемся в класс Game. Создайте в проекте еще одну папку с именем Textures и добавьте в нее файл height1.dds из архива resources, установите для фала контент процессор Texture(mipmapped). Далее нам необходимо добавить в класс поля для шейдера (эффекта) и текстуры.
Effect gridEffect; Texture2D displacementTexture;
В методе LoadGraphicsContent загружаем эффект и текстуру.
В метод Draw добавляем код, устанавливающий параметры эффекта и код отрисовки сетки. Мы помещаем наш ландшафт в центр виртуального мира, поэтому матрицу мира (world) оставляем единичной (Identity), матрицы вида (view) и проекции (projection) получаем из компонента камеры.
На данном этапе наше приложение удачно скомпилируется и запустится, и вы увидите, что-то наподобие этого:
Пока что все выглядит замечательно, но давайте посмотрим что произойдет, если мы увеличим размер ландшафта, установите значения свойств grid.CellSize = 8, grid.Dimension = 512 и значение переменной maxHeight передаваемое в шейдер = 512. Получаем что-то подобное этому:
Смотрим и ужасаемся, откуда у нас такая «замечательная» лестница? Карта высот у нас имеет размер 256 на 256 пикселей, так что пока размерность сетки была 256, то каждый пиксель текстуры проецировался на одну вершину в сетке, и было все замечательно. Но после того как мы увеличили размер сетки, один пиксель стал проецироваться на 2 вершины, которые на самом деле должны иметь разную высоту, но получилось так, что они находятся на одной высоте. Если бы у нас была билинейная фильтрация, то графический процессор автоматически бы рассчитал среднее значение на основе данных 4 соседних пикселей, и поверхность была бы более сглаженной. Но так как вершинные текстуры не поддерживают фильтрации, то нам придется самим реализовывать билинейную фильтрацию в шейдере. Итак, давайте откроем файл VTFDisplacement.fx и добавим следующий код.
Данный код реализует билинейную фильтрацию, берется 4 пограничных пикселя от текущих текстурных координат и между ними производится интерполяция, таким образом, находится среднее значение высоты вершины.
Для иcпользования билинейной фильтрации в вершинном шейдере замените
Давайте наложим на наш ландшафт пару текстур. Наилучшим способом затекстурить поверхность, является смешать несколько текстур (например: песок, траву, скалы и снег) в пиксельном шейдере основываясь на весах вершин, которые рассчитываются из данных высот вершин. У Riemer`s есть отличный урок, как реализовать это, но в его реализации веса для смешивания текстур рассчитываются на CPU, на этапе загрузки карты высот, мы же будем это делать на GPU. Представленный далее код написан по материалам уроков Riemer`s.
Для начала в папку проекта Textures добавьте файлы sand.dds, grass.dds, rock.dds и snow.dds (примечание: для уменьшения размера скачиваемого архива эти файлы представлены в низком разрешении, наличие этих файлов в высоком разрешении заметно улучшит картинку в визуальном плане). Затем откройте файл шейдера и добавьте четыре параметра для текстур и самплеры.
Теперь надо добавить параметр в выходную структуру вершинного шейдера. Для каждой вершины на выходе мы будем задавать 4 значения веса для каждой из текстур (песок, трава, скала, снег). Итак, для вершин с малым значением высоты мы будем накладывать только текстуру песка, так что вес для песка будет равен 1, пока все остальные веса будет равны 0. По мере увеличения высоты мы должны будем сделать переход от песка к траве, так что вес песка будет уменьшаться, а все травы увеличиваться. Так как каждое значение веса лежит в промежутке от 0,0 до 1,0, мы можем запаковать все значения в 4 переменные типа float, а его в один float4 параметр. Новая выходная структура для вершинного шейдера будет выглядеть примерно следующим образом:
struct VS_OUTPUT{float4 position :POSITION;float4 uv :TEXCOORD0;float4 worldPos :TEXCOORD1;// вес, использующийся для мультитекстурированияfloat4 textureWeights :TEXCOORD2;};
В конец вершинного шейдера (до возвращения параметра return Out;) добавьте следующий код:
Каждый компонент вектора textureWeights содержит в себе вес для каждой из текстур, X – песок, Y – трава, Z – скала и W – снег. Для каждой из текстур задано значение на которой она начинает проявлять себя, затем достигает максимальной «видимости» и затухает, переходя в следующую текстуру. Последние инструкции нормализуют значения, чтобы сумма всех весов имела значение 1, иначе мы получим темные или светлые участки в местах перехода текстур.
Когда мы производим выборку в пиксельном шейдере, то текстурные координаты мы будем перемножать на специально подобранное значение (в нашем случае 8), это делается для повтора текстуры на все участки ландшафта. Если выбрать данное значение слишком маленьким, то уровень «натяжки» текстуры на ландшафт будет слишком большим и при близком рассмотрении будет заметна пикселизация. Если задать слишком большое значение, то повторение текстуры будет бросаться в глаза при просмотре с дальнего расстояния. Но вы свободны в выборе данного значения, можете посмотреть, к чему приведет его изменение. Техника называемая «detail texturing» может помочь в решении данной проблемы, она заключается в комбинировании с более детализированными текстурами при приближении к поверхности, но в данном уроке она рассматриваться не будет. И в завершении в пиксельном шейдере, мы считываем цвет со всех 4 текстур и на основе весов смешиваем их.
В итоге поверхность должна выглядеть примерно следующим образом:
Итак, в первом уроке мы рассмотрели, как использовать вершинные текстуры для построения ландшафта, используя карту высот и наложить на него текстуры при помощи мультитекстурирования, причем вся обработка велась на графическом процессоре. Также мы рассмотрели, как реализовать билинейную фильтрацию в вершинном шейдере. Прошу не обращать внимания на то, что мы все это сделали на GPU и пересчитывали каждый кадр, вместо того чтобы сделать все расчета один раз на CPU в момент загрузки, в следующей части мы рассмотрим, как реализовать морфинг (morph) ландшафта и как добавлять на него деформации. По большому счету всю эту функциональность можно реализовать и на CPU, но мы все сделали на GPU, оставив ресурсы центрального процессора для других задач, например: реализация логики геймплея, физики, AI и т.п.
Полный код этой главы можно скачать тут: Chapter1.rar