Вот уже на протяжении трех лекций мы активно используем в приложениях вершинные и пиксельные шейдеры, однако их роль сводится к банальному пропусканию через себя исходных данных без какой-либо обработки или модификации. Такой подход трудно назвать оптимальным, ведь главное предназначение шейдеров – разгрузка центрального процессора компьютера путем освобождения его от рутины. Но для этого необходимо более детально изучить язык HLSL и получить представление об архитектуре графического процессора и языках ассемблера. В виду обширности этой темы данная лекция посвящена преимущественно программированию вершинных процессоров.
Примечание
Если вы немного подзабыли основы языка HLSL, можете еще раз пролистать раздел 2.3.
Функциональность любого шейдера так или иначе связана с математическими расчетами, поэтому для начала мы научимся выполнять математические операции над типами языка HLSL.
Математические операторы языка HLSL частично повторяют операторы языка C. Поддерживаются операторы +, -, *, /, %, ++, --, +=, -=, *=, /= и %=. Эти операторы можно применять как над C. Во втором случае операции осуществляется покомпонентно над элементами векторов:
float4 a = {1, 2, 3, 4};
float4 b = {5, 6, 7, 8};
// Складываем два вектора. Результат равен {1+5, 2+6, 3+7, 4+8} =
{6, 8, 10, 12}
float4 c = a+b;
// Умножаем два вектора. Вектор d станет равен {1*5, 2*6, 3*7, 4*8} =
{5, 12, 21, 32}
float4 d = a*b;
Если в выражении одновременно используются скалярный и векторный тип, то операция выполняется над скалярным типом и каждым компонентом вектора:
float4 a = {1, 2, 3, 4};
// Вектор b станет равен {1*2, 2*2, 3*2, 4*2} = {2, 4, 6, 8}
float4 b = a*2;
Независимо от используемых типов вычисления всегда выполняются 32-битной точностью для каждого float4 на half4 или double4 некоим образом не скажется на скорости или точности
DirectX предоставляет множество способов доступа к компонентам вектора. Во-первых, программист может работать с компонентами вектора как с элементами массива. В этом случае компоненты номеруются с нуля, а доступ к ним осуществляется с использованием оператора []. Например, для вычисления среднего арифметического всех компонентов вектора можно воспользоваться следующим выражением:
float4 color = float4(0.2, 0.7, 0.5, 1.0); // avg будет присвоено значение 0.6 float avg = (color[0] + color[1] + color[2] + color[3])/4;
Так как векторы очень часто используются для хранения геометрических координат и информации о цвете, DirectX предоставляет программисту возможность обращаться к компонентам вектора как к полям структуры. К нулевому элементу вектора можно обращаться как полю x или r, первому – y или g, второму – z или b, третьему – w или a. Нетрудно догадаться, что идентификаторы x, y, z, w предназначены для работы с геометрическими координатами, а идентификаторы r, g, b, a – для работы с цветовыми каналами:
float avg = (color.r + color.g + color.b + color.a)/4;
или
float avg = (color.x + color.y + color.z + color.w)/4;
При выполнении операций над векторами часто возникает необходимость выделить из вектора некоторый подвектор или переставить компоненты вектора местами. Так как современные HLSL встроили непосредственно в сам язык возможность гибкой работы с компонентами вектора. Например, для создания нового вектора путем комбинации компонентов существующего вектора достаточно просто перечислить после оператора ". " (точка) необходимые компоненты:
// Создаем четырехмерный вектор
float4 a={1, 2, 3, 4};
// Присваиваем двухмерному вектору b нулевой и первый элементы
вектора a. Результирующее
// значение вектора b будет равно (1, 2)
float2 b=a.xy;
// Присваиваем вектору c значение {1, 1, 2}
float3 c=a.xxy;
// Переставляем координаты x, y, z местами. Результирующее
значение вектора a будет равно
// {3, 2, 1, 4}
a.xyz=a.zyx;
Примечание
Приложение должно трактовать компоненты вектора либо как цветовые каналы, либо как геометрические координаты. Комбинирование в одном выражении различных типов наименований запрещено. В частности, компилятор HLSL откажется компилировать выражение вроде a.rgzw, так как первые два компонента вектора трактуются как цвет, а вторые два – как координаты.
Другая любопытная особенность языка HLSL заключается в том, что скалярные типы фактически являются одномерными векторами. Это позволяет обращаться к скалярному типу как к массиву или структуре.
Например, код
float a=3; float4 v=float4(a, a, a, a);
можно переписать следующим образом:
float a=3; // Обращаемся к скалярному типу как к одномерному вектору float4 v=a.xxxx;
Присвоение всем компонентам вектора одного и того же значения является довольно распространенной операцией. Поэтому в HLSL предусмотрен специальный синтаксис для выполнения этой операции: при присвоении вектору
float a=3; // В вектор v будет занесено значение (3, 3, 3, 3) float4 v=a;
В языке HLSL имеется множество математических функций для работы со скалярными и векторными типами: тригонометрические и гиперболические функции, вычисление скалярного и HLSL можно найти в приложении 4. Обратите внимание, что список доступных функций определяется используемым профилем.
Большинство функций HLSL транслируются в одну команду графического процессора. При этом, каждая команда графического процессора, как правило, выполняется за 1 такт. Поэтому рекомендуется как можно активнее использовать встроенные функции, а не изобретать велосипед. Например, выражение $$b=\frac {1}{\sqrt q}$$ можно записать как b=1.0/sqrt(a), либо как b=rsqrt(a). Первый вариант будет транслирован в две команды (вычисление квадратного корня и деление), а второй - в одну. Нетрудно догадаться, что какой из них будет работать быстрее.
Примечание
Оптимизирующий компилятор HLSL, скорее всего, самостоятельно заменит выражение b=1.0/sqrt(a) на b=rsqrt(a). Однако в более сложных случаях у него может не хватить сообразительности, чтобы подобрать оптимальную замену.
В качестве демонстрации практического использования математических расчетов мы напишем простой эффект, преобразующий цвет примитивов в черно-белый с использованием следующего выражения:
$$l=\frac{(r+g+b}{3} r=l b=l $$где
Это преобразование можно вставить в вершинный или пиксельный шейдер. В первом случае, цвета вершин вначале будут преобразованы в черно-белый цвет, после чего полученные черно-белые значения будут интерполироваться вдоль поверхности примитива. Следовательно, при визуализации нашего квадрата с помощью примитивов PrimitiveType.TriangleStrip преобразование в черно-белый цвет будет выполнено четыре раза - по одному для каждой вершины.
При вынесении расчетов по формуле 5.1 в пиксельный шейдер, преобразование в черно-белый цвет будет выполняться уже при вычислении цвета каждого пикселя. Например, когда визуализируемый объект занимает 56% площади окна размером 640x480, преобразование в черно-белый цвет будет осуществляться 640·480·0.56=172032 раза. То есть, по сравнению с первым вариантом объем вычислений возрастет в 172000/4=43000 раз (!), что не может не сказаться на производительности приложения. При увеличении размера окна до 1280x960 эта цифра возрастет еще в четыре раза.
Таким образом, мы можем сформулировать одно простое правило - при написании эффекта необходимо стремиться вынести как можно больше операций из пиксельного в вершинный шейдер. Поэтому в нашем эффекте мы разместим преобразование в черно-белый цвет именно в вершинном шейдере (листинг 5.1).
struct VertexInput
{
float3 pos : POSITION;
float4 color : COLOR;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Выполняем преобразование цвета вершины в черно-белый
float luminance = (input.color.r+input.color.g+input.color.b)/3.0;
output.color.r = luminance;
output.color.g = luminance;
output.color.b = luminance; output.color.a
= input.color.a;
return output; }
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique BlackAndWhiteFill
{
pass p0
{
VertexShader = compile vs_1_1 MainVS();
PixelShader = compile ps_1_1 MainPS();
}
}
Наш первый вариант вершинного шейдера реализует выражение 5.1 в лоб без учета архитектурных особенностей современных графических процессоров и, соответственно, не является оптимальным. Например, ничто не мешает нам присвоить рассчитанное значение яркости сразу трем цветовым компонентам (листинг 5.2).
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Вычисляем яркость цвета и присваиваем ее красному,
зеленому и синему каналам output.color.rgb =
(input.color.r+input.color.g+input.color.b)/3.0; output.color.a =
input.color.a;
return output;
}
Примечание
Вполне вероятно, что оптимизирующий компилятор HLSL самостоятельно заменит в вершинном шейдере из листинга 5.1 три присваивания компонентам вектора $$r, g, b$$ на одну векторную операцию. А может и не заменит... Поэтому имеет смысл выработать привычку активного применять векторные выражения, не особо полагаясь на сообразительность оптимизирующего компилятора.
После этих улучшений код нашего вершинного шейдера выглядит довольно оптимально. Однако его все равно можно еще немного улучшить. Давайте раскроем скобки в выражении (5.1):
$$l=\frac{1}{3}*r+\frac{1}{3}*g+\frac{1}{3}*b $$Если внимательно на него посмотреть, можно заметить, что оно является результатом скалярного произведения двух трехмерных векторов:
$$l=(\overline{\frac{1}{3},\frac{1}{3},\frac{1}{3})*(\overline{r,g,b}) $$На первый взгляд выражение 5.3 кажется значительно более громоздким и вычислительно сложным по сравнению с выражением 5.1. Но это вовсе не так - любой современный графический процессор умеет аппаратно вычислять скалярное произведение векторов. Поэтому, если мы заменим выражение 5.1 на 5.3 и воспользуемся встроенной функцией dot (скалярное произведение), то производительность программы ощутимо возрастет (листинг 5.3).
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Вычисляем скалярное произведение векторов.
Второй параметрфункции dot автоматически
// преобразуется в трехмерный вектор (1/3.0, 1/3.0, 1/3.0)
output.color.rgb = dot(input.color.rgb, 1/3.0);
output.color.a = 1.0;
return output;
}
Чтобы опробовать полученный эффект в полевых условиях мы напишем приложение, визуализирующее квадрат с разноцветными вершинами в черно-белом режиме (рисунок 5.1). Так как код приложения не содержит ничего выдающегося, я лишь приведу фрагмент обработчика события Load (листинг 5.4), а остальные подробности при необходимости вы легко сможете найти в example.zip в каталоге Examples\Ch05\Ex01.

(рис 5.4) Квадрат, визуализированный с использованием черно-белого эффекта (рис 5.1) public partial class MainForm : Form { // Файл эффекта, имитирующего черно-белую закраску const string effectFileName = "Data\\BlackAndWhiteFill.fx"; GraphicsDevice device = null; PresentationParameters presentParams; VertexDeclaration decl = null; // Массив вершин VertexPositionColor[] vertices = null; Effect effect = null; bool closing = false; public MainForm() { InitializeComponent(); } private void MainFormLoad(object sender, EventArgs e) { // Создаем графическое устройство decl = new VertexDeclaration(device, VertexPositionColor. VertexElements); vertices = new VertexPositionColor[4]; // Заносим в массив вершин информацию о вершинах. Обратите внимание, что цвет вершин не // является черно-белым vertices[0] = new VertexPositionColor(new Vector3(-0.75f, -0.75f, 0.0f), XnaGraphics.Color.Green); vertices[1] = new VertexPositionColor(new Vector3(-0.75f, 0.75f, 0.0f), XnaGraphics.Color.YellowGreen); vertices[2] = new VertexPositionColor(new Vector3(0.75f, -0.75f, 0.0f), XnaGraphics.Color.White); vertices[3] = new VertexPositionColor(new Vector3(0.75f, 0.75f, 0.0f) XnaGraphics.Color.GreenYellow); // Загружаем и компилируем эффект } // Обработчики событий Paint, Resize, Closed и т.п. }
Формула (5.1) предполагает, что человеческий глаз имеет одинаковую чувствительность к красному, зеленому и синему цвету. В действительности это не так - например, человеческий глаз значительно более чувствителен к зеленому цвету, чем к синему. Для учета этого факта национальным комитетом по телевизионным системам США ( ) было принято решение вычислять яркость по следующей формуле:
Создайте эффект, осуществляющий преобразование в черно-белый цвет с использованием формулы 5.4. В качестве отправной точки можно воспользоваться примером Ch05\Ex01. Готовое приложение находится в example.zip в каталоге Examples\Ch05\Ex02.
До сих пор мы создавали файлы эффектов .fx в обыкновенном текстовом редакторе. В принципе, в этом нет ничего плохого. В конце концов, некоторые разработчики создают .NET приложения в простых текстовых редакторах с последующей компиляцией полученного .cs -файла из командой строки компилятором C# ( csc.exe ). Однако по мере усложнения разрабатываемых проектов использование специализированных средств разработки становится все более актуальным. Как ни крути, та же IDE Visual Studio значительно облегчает процесс разработки благодаря умному редактору с IntelliSense, интегрированному компилятору, отладчику и справочной системе.
По мере изучения XNA наши эффекты будут становиться все сложнее, поэтому будет разумно заблаговременно подыскать интегрированную среду разработки эффектов. В действительности, наш выбор не велик - на рынке сейчас господствуют два бесплатных пакета для разработки эффектов: и NVIDIA FX Composer 2.0. В данном курсе мы будем использовать NVIDIA FX Composer, так как он гораздо динамичнее развивается и очень хорошо интегрирован с инфраструктурой .NET Framework.
Примечание
Существенная часть NVIDIA FX Composer 2.0 написана на .NET. В частности, вы можете легко исследовать исходный код FX Composer 2.0 посредством .NET Reflector.
Что такое FX Composer 2.0? Если коротко, это аналог Visual Studio для разработки шейдеров с использованием таких языков, как HLSL, FX Composer уступает Visual Studio 2005 практически по всем параметрам: удобству пользовательского интерфейса, технологии IntelliSense, документации и так далее. Кроме того, в текущей версии FX Composer имеется ощутимое количество багов. Впрочем, в этом нет ничего удивительного, если сравнить количество человеко-часов, затраченных на создание Visual Studio и FX Composer. Кроме того, NVIDIA FX Composer является абсолютно бесплатным, что позволяет закрыть глаза на многие недостатки - как известно, на халяву и уксус сладок.
FX Composer 2.0 в первую очередь ориентирован на работу с файлами формата COLLADA версии 1.4.1, поэтому для понимания основных принципов организации пользовательского интерфейса полезно ознакомиться с основами этого формата.
COLLADA (COLLAborative Design Activity) - это кроссплатформенный открытый формат, используемый для обмена данными между приложениями создания цифрового контента ( COLLADA основан на XML и задается XSD -схемой. Это очень универсальный формат, способный хранить множество видов контента:
Кроме того, формат COLLADA позволяет сторонним разработчикам добавлять новые элементы XML, расширяя возможности формата почти до бесконечности.
Рассмотрим основы формата COLLADA на примере простого файла:
<?xml version="1.0"?> <COLLADA xmlns="http://www.collada.org/2005/11/ COLLADASchema" version="1.4.1"> <libraryimages> <image id="mycolor" name="mycolor"> <initfrom>data/defaultcolor.dds</initfrom> </image> </libraryimages> <libraryeffects> <effect id="BlinnEffect" name="Blinn Effect"> <profileCG platform="PC-OGL"> <include sid="Blinn" url="Data/Blinn.cg"/> </profileCG> <profileCG platform="PS3"> <include sid="Blinn" url="Data/Blinn.cg"/> </profileCG> <profileGLSL> <include sid="Blinn" url="Data/Blinn.glsl"/> </profileGLSL> <extra type="import"> <technique profile="NVimport"> <import url="Data/Blinn.fx" compileroptions="" profile="fx"/> </technique> </extra> </effect> </libraryeffects> <librarymaterials> <material id="BlinnMaterial" name="Blinn Material"> <instanceeffect url="#BlinnEffect"> <!--Описание параметров материала--> </instanceeffect> </material> </librarymaterials> </COLLADA>
Как видно, вся информация о контенте храниться в элементе <collada>
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1"> </COLLADA>
В элемент <collada> вложены элементы, соответствующие разным типам контента:
<libraryimages> - растровые изображения;<libraryeffects> - эффекты;<librarymaterials> - материалы;<librarycameras> - камеры;<librarylights> - источники света;<librarygeometries> - модели;Каждый эффект определяется посредством элемента <effect>, вложенного в <libraryeffects>:
<libraryeffects> <effect id="BlinnEffect" name="Blinn Effect"> </effect> </libraryeffects>
Атрибут id задает уникальный идентификатор эффекта, a name - название эффекта, отображаемое приложениями вроде FX Composer 2. Так как формат COLLADA не привязан к конкретной платформе или API, эффект может быть написан на разных языках для различных платформ. Это достигается посредством профилей: каждый эффект может содержать несколько профилей для разных API и платформ. Профили задаются элементами с названиями вида <profileXXX> , вложенными в элементы <effect> :
<profileCG> - эффект написан на языке Cg. Атрибут platform позволяет специфицировать платформу, для которой предназначен эффект: например, значение "PC-OGL" указывает, что эффект предназначен для API OpenGL на платформе PC, a "PS3 " - для платформы Playstation 3.<profileGLSL> - эффект написан на языке GLSL.<profileGLES> - эффект написан для API <profileCOMMON> - платформо-независимый эффект, близкий по функциональности к стандартному материалу ( Standard Material ) из 3ds Max.Внутри элемента <profile> размещается ссылка на файл эффекта, а так же при необходимости различные сведения об эффекте: перечень техник, проходов и т.п. Кстати, код эффекта при желании тоже можно разместить непосредственно в элементе <profile> посредством элемента <code>.
Наверняка вы заметили, что в вышеприведенном списке нет профиля для языка HLSL. Дело в том, что в текущей версии (1.4.1) формата COLLADA пока отсутствует поддержка языка HLSL. Однако благодаря расширяемости данного формата разработчики могут легко реализовать дополнительную функциональность посредством элемента <extra>, в частности FX Composer помещает ссылку на .fx -файл следующим образом:
<extra type="import"> <technique profile="NV_import"> <import url="Data/Blinn.fx" compiler_options="" profile="fx"/> </technique> </extra>
Как видно, ссылка на эффект размещается в пользовательском профиле fx.
Наряду с эффектами в файле формата COLLADA могут храниться материалы. Для понимания концепции материала рассмотрим простой пример. Допустим, мы разработали эффект, визуализирующий фрактал. Управляя входными параметрами эффекта, мы можем визуализировать фрактальное изображение мрамора, дерева, воды, лавы и т.п. Тогда для каждого типа изображения мы создадим отдельные материалы мрамора, дерева, воды и лавы, использующие общий эффект фрактала, но с разными параметрами. Грубо говоря, материал это набор параметров для некоторого эффекта.
Материалы определяются внутри элемента <library_materials> посредством элемента <material>. В элемент <material> в свою очередь вкладывается элемент <instance_effect>, атрибут url которого ссылается на эффект из уже знакомой нам секции <library_effects>, используемый материалом. Кроме того, в секции <instance_effect> размещаются значения различных параметров эффекта, формирующие уникальный внешний вид материала.
Элементы <library_effects> и <library_materials> со всеми вложенными элементами образуют подмножество формата COLLADA, известное как COLLADA FX. FX Composer 2 в первую очередь предназначен для работы именно с подмножеством COLLADA FX, остальные же элементы COLLADA поддерживаются в ограниченном объеме по мере необходимости. Например, FX Composer 2 может использовать трехмерные модели из файла формата COLLADA, однако возможности создания и редактирования трехмерных моделей весьма ограничены (но теоретически могут быть расширены посредством плагинов).
Не переживайте, если вы не поняли часть материала. Цель этого раздела – просто познакомить вас с основными принципами устройства файлов формата COLLADA, знание которых поможет быстрее освоиться с весьма запутанным интерфейсом FX Composer 2.0.
Ну что ж приступим. Для начала установите FX Composer Start (Start | All Programs | NVIDIA Corporation | FX Composer 2 | FX Composer 2). На рисунке 5.2 приведен внешний вид стартового экрана FX Compose сразу после
В верхней части окна расположено главное меню FX Composer, под которым находится панель инструментов ( Standard Toolbar ) для быстрого доступа к наиболее важным пунктам меню. Как и во всех современных IDE панель инструментов легко конфигурируется с учетом предпочтений пользователя.
Примечание
В FX Composer 2 имеется подробное руководство пользователя, которое можно открыть щелчком на ссылке на панели Start Page, отображаемой при первом запуске FX Composer.
В центре экрана расположены вкладки трех панелей: Start Page, и Editor.
Start Page, аналогичная одноименному окну из Visual Studio 2005: здесь отображается информация о недавно открытых проектах ( Recent ), ссылки на документацию ( Getting Started ), перечень типовых действий вроде создания нового проекта или эффекта ( Tasks ) и новости с сайта. Обязательно ознакомьтесь с документацией по FX Composer (ссылка User Guide в разделе Getting Started ).Shader Library содержит коллекцию материалов из онлайновой библиотеки материалов NVIDIA.Editor представляет собой текстовый редактор с
(рис 5.2) Стартовый экран FX Composer 2: 1 – главное меню и панель инструментов, 2 – панель Start Page , 3 – панель Materials, 4 – панель Properties, 5 – панель Render, 6 – панель Animation В левой части расположены вкладки трех панелей: Materials, Assets и Project.
Material, напоминающая редактор материалов из 3ds Max, позволяет работать с материалами, о которых мы уже говорили в разделе о формате COLLADA.COLLADA.Project, содержащая иерархию всех файлов проекта, в целом аналогична окну Solution Explorer из Visual Studio 2005.В правой верхней части окна расположена панель Properties, позволяющая задавать значения входных параметров эффектов и материалов с использованием интуитивно понятного интерфейса. Ниже расположена вкладка Render, которая позволяет опробовать созданный материал на тестовой сцене, визуализируемой с использованием API или DirectX (как вы помните, эффекты COLLADA могут содержать персональные профили для каждого API ).
В нижней части окна расположены панели Animation и Tasks.
Animation управляет ходом времени и используется в основном для тестирования анимированных материалов.Tasks отображаются сообщения об ошибках компиляции эффекта, т.е. она является аналогом окна Error List из Visual Studio.Все панели не являются фиксированными: их можно легко перетаскивать с места на место, попутно изменяя размер. При этом панели автоматически приклеиваются к краям окон, встраиваются в другие панели, короче ведут себя так же, как и аналогичные панели из Visual Studio. Дополнительные View (рисунок 5.3).
(рис 5.3) Список панелей FX Composer в меню View Примечание
Меню View содержит пункт Layouts, позволяющий гибко конфигурировать расположение панелей FX Composer. Предусмотрено четыре типовых расположения панелей (подпункты Artist, Authoring, Default, Turning ), кроме того предусмотрена возможность создания пользовательских конфигураций. Если вы вдруг перетащили панель куда-то не туда и не можете вернуть ее на прежнее место, просто выполните команду меню View | Layouts | Reset Layout.
Лучший способ изучить FX Composer 2.0 – начать его использовать на практике. В качестве упражнения мы создадим в FX Composer эффект черно-белой закраски. Переключитесь в FX Composer. Если вы уже экспериментировали с материалами и эффектами, то создайте новый проект, выполнив команду меню File | New | New Project. Отобразите вкладку Assets (рисунок 5.4). Как видно, эта вкладка содержит узлы, соответствующие наборам контента формата COLLADA, о которых мы немного поговорили в разделе 5.2.1. Например, узел Effects вкладки Assets соответствует элементу <library_effects>, узел Materials – элементу <library_materials> и т.п.
(рис 5.4) Вкладка Assets Чтобы добавить в проект новый эффект щелкните правой кнопкой мыши на узле Effects и в появившемся контекстом меню выберите пункт Add Effect... . На экране появится мастер создания нового эффекта (рисунок 5.5). Так как мы будем использовать исключительно язык HLSL, установите флажок только рядом с профилем HLSL FX. В поле Effect Name введите название эффекта (например, BlackAndWhite ). В нижней части окна можно указать название материала, создаваемого на базе данного эффекта, но так как материалы нам пока не нужны, мы не будем устанавливать данный флажок.
(рис 5.5) Мастер создания эффекта Перейдите к следующему диалоговому окну мастера Effect Wizard, нажав кнопку Next. Здесь вам потребуется указать шаблон, на основе которого будет создан эффект. Мы будем использовать шаблон Empty, который, как нетрудно догадаться, создает простейший эффект по умолчанию. В поле Name укажите название эффекта (например, BlackAndWhite.fx ), а в поле Location – каталог, в который будет помещен файл эффекта. Наконец, завершите создание эффекта нажатием кнопки Finish.
(рис 5.6) Создание файла эффекта Теперь разверните узел Effects. Если все было выполнено правильно, в узле Effects появится дочерний узел BlackAndWhite, инкапсулирующий эффект Collada, в который вложен собственно файл нашего эффекта BlackAndWhite.fx (рисунок 5.7).
Примечание
Если бы мы при создании эффекта выбрали наряду с .fx еще несколько профилей, то узел эффекта BlackAndWhite содержал бы несколько файлов эффектов с расширениями наподобие .cg или .glsl.
(рис 5.7) Узел созданного эффекта на панели Effects Чтобы открыть редактор кода выполните двойной щелчок левой кнопкой мыши на узле файла BlackAndWhite.fx. Замените текст созданного по умолчанию эффекта кодом из листинга HLSL смог выполнить оптимизацию этого некачественного кода HLSL, значительно облегчающую поиск опечаток. По завершению набора кода эффекта выполните его компиляцию посредством сочетания клавиш Ctrl + F7. Если эффект содержит ошибки, то в окне Tasks появится перечень ошибок (рисунок 5.8), а сама строка содержащая ошибку будет подсвечена.
Примечание
Чтобы видеть сообщения о ходе компиляции эффекта, откройте панель Output (рисунок 5.9) посредством команды главного меню View | Output.

(рис 5.9) Панель Task с информацией об ошибках в эффекте (рис 5.8) Панель Output с информацией о процессе компиляции В заключении не забудьте сохранить проект эффекта командой File | Save All.
Сохранив проект, запустите любой файловый менеджер и перейдите в каталог с проектом. Если вы сохранили проект и файл эффекта в одном и том же каталоге, то в нем будут находиться три файла:
Project.fxcproj - файл проекта FX Composer с информацией о настройках IDE и файлах входящих в проект. Имеет формат XML.Document1.dae - файл формата COLLADA с информацией о контенте.BlackAndWhite.fx - собственно файл эффекта.Текст файла Document1., сгенерированный FX Composer 2.0 на моем компьютере, приведен ниже:
<?xml version="1.0"?> <COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1"> <asset> <contributor> <author>Sergei Gaidukov</author> <authoringtool>NVIDIA FX Composer 2.0</authoringtool> <comments/> <copyright/> </contributor> <created>2007-06-04T16:39:20</created> <keywords>FXComposer, NVIDIA</keywords> <modified>2007-06-04T16:39:21</modified> <subject/> <title/> </asset> <libraryeffects> <effect id="Effect" name="BlackAndWhite"> <profile_COMMON> <technique sid="__fxc2_default"> <constant/> </technique> </profile_COMMON> <extra type="import"> <technique profile="NV_import"> <import url="BlackAndWhite.fx" compiler_options="" profile="fx"/> </technique> </extra> </effect> </library_effects> </COLLADA>
Как видно, в элемент <COLLADA> вложены два элемента: <asset> с информацией об авторе файла, времени его создания и приложении, в котором он был создан; и уже знакомый нам элемент <library_effects>. Последний содержит эффект с идентификатором Effect и именем BlackAndWhite, в котором определено два профиля: HLSL и COMMON.
Примечание
Чтобы файл формата COLLADA мог корректно обрабатываться любым приложением, он должен содержать профиль COMMON.
Если эффект изначально разрабатывался в FX Composer 2.0, то с его редактированием не возникнет проблем – достаточно просто открыть проект командой File | Open | Open Project... и продолжить работу. Но что делать, если необходимо подправить существующий .fx -файл? Так как организация проектов FX Composer 2.0 насквозь пронизана идеологией COLLADA, вы не можете просто так открыть существующий .fx-файл командой File | Open | Open File... – в этом случае вы потеряете возможность выполнять пробную компиляцию .fx -файла и, соответственно, не сможете обнаруживать синтаксические ошибки.
К счастью, эта особенность легко обходится: вы должны просто создать новый эффект на основе вашего .fx -файла. Для этого откройте вкладку Assets, щелкните правой мыши на узле Effects, выполните команду контекстного меню Add Effect From File... и укажите файл, который необходимо открыть. В результате в проект будет добавлен новый эффект, содержащий указанный файл, который теперь можно легко отредактировать и откомпилировать.
При разработке шейдеров начинающие разработчики часто оказываются в положении буриданова осла, когда одну и ту же функциональность можно реализовать различными способами и при этом не совсем ясно, какой из них будет иметь большую производительность. В подобных ситуациях трудно переоценить полезность панели , позволяющей быстро прикинуть быстродействие эффекта на различных видеокартах NVIDIA с учетом многочисленных версий драйверов.
Чтобы получить представление о возможностях данной панели мы проанализируем производительность эффекта BlackAndWhite, созданного в разделе 5.2.3. Для начала в панели Assets щелкните правой кнопкой мыши на узле эффекта, который вы собираетесь проанализировать, и выберите команду контекстного меню Analyze Performance, после чего в нижней части окна появится панель (рисунок 5.10), содержащая две вкладки: Startup Form и BlackAndWihite.fx. Вторая вкладка, как нетрудно догадаться, предназначена для анализа нашего эффекта, а первая используется преимущественно для загрузки новых эффектов в панель Analyze Performance.
(рис 5.10) Панель Shader Performance В левом верхнем углу вкладки BlackAndWhite.fx расположен переключатель между режимом анализа производительности единственного выбранного прохода эффекта ( Analyze a Pass ) и режимом сравнения производительности всех проходов эффекта ( Compare Passes ). Так как наш эффект содержит единственный проход, оба варианта будут практически эквивалентны.
Ниже расположены флажки списка техник Analyze a Pass можно выбрать только одну технику, а в режиме Compare Passes – соответственно несколько.p0. Еще ниже имеется выпадающий список для выбора анализируемого типа шейдера: вершинного или пиксельного. Пиксельный шейдер эффекта BlackAndWhite.fx, содержащий единственный оператор return, вряд ли нуждается в какой-либо оптимизации, поэтому мы будем анализировать вершинный шейдер. Наконец, в самом низу вкладки BlackAndWhite.fx находятся списки флажков Drivers и , позволяющие выбрать версии драйверов ForceWare и графические процессоры, на которых будет эмулироваться выполнение эффекта.
Указав всю требуемую информацию можно приступать к собственно исследованию производительности вершинного шейдера. Для анализа эффекта с учетом выбранных параметров необходимо нажать кнопку Run на панели в верхней части окна. Для просмотра результатов анализа в виде таблицы нажмите кнопку Table, после чего вы увидите информацию аналогичную рисунку 5.10. Как видно, при использовании видеокарты NVIDIA GeForce 7800 GTX с драйверами ForceWare 162.03 выполнение вершинного шейдера будет длиться 7 тактов, а всего за одну секунду всеми вершинными процессорами этой видеокарты будет обработано 491.000.000 вершин. Но следует учитывать, что эта астрономическое число отражает пиковую производительность без учета быстродействия остальных компонентов видеокарты, так что
Примечание
Кнопки Precision и Branches позволяют просмотреть более подробный отчет с учетом различной точности вычислений и сценариев выполнения условных переходов. Но в настоящее время эти возможности являются для нас избыточными: эффект BlackAndWhite.fx не содержит условных переходов, а точность вычислений вершинных шейдеров всегда равна 32-бита.
Кнопка Graph позволяет в наглядной форме сравнить производительность шейдера на разных видеокартах. Перед выполнением сравнения необходимо составить перечень интересующих вас видеокарт и драйверов. Для этого откройте командой главного меню Tools | Settings... диалоговое окно Settings с настройками FX Composer и в древовидном списке в левой части окна выделите узел Enviroment | ShaderPerf. Затем в левой части окна щелкните на кнопке "... " напротив опции DefaultSelectedGPUs и в появившемся диалогов окне Selected Default GPUs установите флажки напротив интересующих вас графических процессоров (рисунок 5.11). Например, если вы разрабатываете приложение для видеокарт семейства GeForce 6 и выше, вам следует пометить все семейств NV4x и G7x. Далее аналогичным образом укажите интересующие вас версии драйверов.
(рис 5.11) Диалоговое окно Setting и Select Default GPUs После нажатия OK в во вкладке BlackAndWhite.fx панели появятся флажки, соответствующие указанным графическим процессорам. Выделите флажки и драйверов, интересующие вас в данный момент, и нажмите кнопку Graph в верхней части окна. На экране появится диаграмма производительности вершинного шейдера на выбранных графических процессорах (рисунок 5.12).
(рис 5.12) Производительность шейдера на различных графических процессорах По диаграмме легко можно оценить, будет ли являться производительность вершинного шейдера ограничивающим фактором. Допустим, наша сцена содержит 100.000 треугольников. Значит, пренебрегая прочими факторами можно предположить, что визуализация данного сцены на самой медленной видеокарте семейства GeForce6 (GeForce 6200) будет выполняться с частотой 150.000.000 / 100.000 = 1.500 кадров секунду. Таким образов в данном конкретном случае вершинный шейдер не станет узким местом даже на самых дешевых видеокартах семейства GeForce6.
Еще одной интересной возможностью панели является просмотр скомпилированного промежуточного кода шейдера. Это очень мощная функциональность, позволяющая оценить качество сгенерированного ассемблерного кода и увидеть ошибки, допущенные компилятором. Возможно, последнее утверждение покажется вам несколько надуманным, но это действительно так. Компилятор HLSL в настоящее время значительно менее отлажен по сравнению с теми же компиляторами C++, а сами графические процессоры содержат множество ограничений. Например, компилятор может сгенерировать несколько отличный код от ожидаемого вами, в результате чего выполнение эффекта будет сопровождаться нежелательными эксцессами наподобие переполнения разрядной сетки в ходе промежуточных расчетов. В будущем вы практически гарантированно столкнетесь с подобными аномалиями, причем, чем меньшими возможностями обладает используемая видеокарта, тем более вероятно возникновение проблем.
Примечание
Это особенно актуально при написании пиксельных шейдеров для GeForce3 и GeForce4, регистры которых имеют ограниченную разрядность и рассчитаны на работу с числами в диапазоне от -1 до +1.
Чтобы увидеть ассемблерный код шейдера, сгенерированный компилятором HLSL, достаточно нажать кнопку ASM после чего во вкладке Editor появится вкладка BlackAndWhite_Asm.txt со следующим текстом:
################################################################ # Technique: BlackAndWhiteFill # Pass: p0 ################################################################ // // Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 vs_1_1 def c0, 0.333333343, 1, 0, 0 dcl_position v0 dcl_color v1 add r0.w, v1.y, v1.x add r0.w, r0.w, v1.z mul oD0.xyz, r0.w, c0.x mad oPos, v0.xyzx, c0.yyyz, c0.zzzy mov oD0.w, v1.w // approximately 5 instruction slots used
Но сейчас мы можем почерпнуть из этого отчета разве то, что он был создан компилятором Microsoft (R) D3DX9 версии 9.12.589.0000, причем в качестве промежуточного языка был использован . Все остальное для нас не более чем китайская грамота, так что для оценки качества сгенерированного кода нам потребуется ознакомиться с азами языков .
На этом первое знакомство с NVIDIA FX Composer 2. 0 можно считать оконченным.
Языки семейства предназначены для программирования виртуальных вершинных процессоров, причем каждому языку соответствует своя модель виртуального вершинного процессора. Вообще в основу каждой модели виртуального вершинного процессора положен вполне определенный реальный вершинный процессор, однако на практике данная особенность не играет особой роли, ведь код для виртуального процессора все равно компилируется драйвером видеокарты в машинный код текущего процессора. Кроме того, все эти модели виртуальных процессоров построены на общих принципах. Так что вы вполне можете думать о языках r как о вариациях на тему языка IL, заточенных под
Любой виртуальный вершинный процессор содержит набор регистров, напоминающих регистры SSE процессоров архитектуры x86. Большинство регистров (но отнюдь не все) являются векторными регистрами, рассчитанными на хранение четырехмерных векторов в формате с плавающей точкой. Разрядность регистров может быть произвольной, но на практике обычно равна 128-ми битам, то есть на каждый компонент вектора, как правило, отводится 32-бита (рисунок 5.13).
(рис 5.13) 128-битный векторный регистр (32 бита на компонент).Данные, с которыми работает виртуальный процессор можно разделить на три большие группы (рисунок 5.14):
(рис 5.14) . Структура графического процессора Набор регистров существенно варьируется от версии к версии, поэтому чтобы сделать материал менее запутанным мы сосредоточимся исключительно на версии 1.1.
Примечание
Виртуальные процессоры и практически один в один повторяют архитектуру вершинных и пиксельных процессоров видеокарты GeForce3 (NV20) .
Регистры исходных данных виртуального процессора делятся на две подгруппы (рисунок 5.15).
v0, v1… v15 с информацией, специфичной для текущей вершины ( Input Registers ).c0, c1 … c95 ... cN с информацией, общей для всех вершин ( Constant Float Registers ).Оба типа регистров являются векторными регистрами, в которых хранятся 4 компонента с плавающей точкой. У всех современных графических процессоров разрядность этих регистров равна 128 бит, т.е. каждый компонент вектора является 32-х разрядных числом с плавающей точкой. Но эта разрядность не является фиксированной и может измениться у будущих . Кроме того, различные могут несколько по-разному обрабатывать такие граничные ситуации как переполнение или деление на нуль, поэтому код шейдеров не должен быть заточен под фиксированную разрядность 32-бита на компонент.
В регистры v0, v1 … v15 заносятся атрибуты, специфичные для каждой вершины: информация о координатах вершины, ее цвете, размере точки, текстурных координатах и т.п. Связывание входного регистра с конкретным вершинным атрибутом осуществляется посредством специальных директив вида dclxxx, некоторые из которых перечислены в таблице 5.1. Компилятор HLSL отображает входные параметры вершинного шейдера на регистры v0 ... v15, таким образом, директивы dcl_xxx аналогичны семантикам входных параметров вершинного шейдера. В частности, если вы внимательно посмотрите на ассемблерный код, полученный посредством FX Composer в конце раздела 5.2.4, то обнаружите две директивы:
dcl_position v0 dcl_color v1
Совершенно очевидно, что эти строки являются результатом компиляции структуры входной информацией вершинного шейдера:
struct VertexInput
{
float3 pos : POSITION; float4
color : COLOR;
};
| Директива | Аналогичная семантика HLSL | Информация, которая будет заноситься во входной регистр |
|---|---|---|
dcl_position |
POSITION |
Координаты вершины |
dcl_color |
COLOR |
Информация о цвете вершины |
dcl_psize |
PSIZE |
Размер визуализируемой |
dcl_texcoord |
TEXCOORD |
Текстурные координаты вершины |
(рис 5.15) Регистры исходных данных Константные регистры, как следует из названия, используются для хранения различных констант. Задание константы осуществляется посредством директивы def:
def {константный регистр}, {компонент 0}, {компонент 1}, {компонент 2}, {компонент 3}
Например, следующая директива перед началом обработки вершин шейдером заносит в константный регистр c0 вектор (0.333333343, 1, 0, 0).
def c0, 0.333333343, 1, 0, 0
Код данной директивы взят из ассемблерного листинга эффекта черно-белой закраски. Нетрудно догадаться, что компонент вектора со значением 0.333333343 впоследствии используется компилятором для вычисления выражения (input.color.r+input.color.g+input.color.b)/3.0. Так же логично предположить, что компонент со значением 1 используется при добавлении четвертого компонента к координатам вектора:
output.pos = float4(input.pos, 1.0f);
Количество константных регистров зависит от видеокарты, однако поддержка видеокартой гарантирует наличие не менее 96 константных регистров. Точное количество константных регистров вершинных процессоров текущей видеокарты может быть получено посредством метода GraphicsDeviceCapabilities.MaxVertexShaderConstants класса GraphicsDevice. В таблице 5.2 приведена информация о количестве константных регистров у наиболее распространенных видеокарт.
Примечание
Число физических константных регистров может несколько превышать значение, возвращаемое GraphicsDeviceCapabilities.MaxVertexShaderConstants. Дополнительные регистры, как правило, используются драйвером для внутренних нужд, например, при эмуляции фиксированного графического конвейера из прошлых версий DirectX.
|
Количество константных регистров у вершинных процессоров |
|---|---|
NV2x |
96 |
NV3x |
256 |
NV4x |
256 |
G7x |
256 |
R2xx |
192 |
R3xx |
256 |
R4xx |
256 |
Intel GMA |
8192 |
Intel GMA 3000 |
8192 |
Забегая вперед, стоит отметить, что значения константных регистров могут изменяться приложением, что делает их идеальным средством для передачи параметров в вершинный шейдер (см. раздел 5.4).
Регистры общего назначения используются для хранения операндов и результатов команд, а так же для адресации массива константных регистров. Виртуальный вершинный процессор предполагает наличие двух типов временных регистров (рисунок 5.16):
r0, r1 … r11 для хранения промежуточных результатов вычислений ( Temporary Registers ).a0, используемый для косвенной адресации константных регистров ( Address Register ).
(рис 5.16) Регистры общего назначения Временные регистры являются аналогом регистров SSE процессоров архитектуры x86, и поэтому вряд ли нуждаются в каких-либо комментариях. Адресный регистр хранит смещение, которое может применяться при обращении к константному регистру в режиме косвенной адресации. Например, если адресный регистр содержит значение 2, то при обращении в режиме косвенной адресации к константному регистру c5 в реальности произойдет обращение к регистру c7. В ассемблерном коде такое обращение будет выглядеть как c[a0.x + 5] .
Примечание
Примитивная косвенная адресация, используемая в шейдерах, довольно сильно напоминает косвенную адресацию первых программируемых калькуляторов вроде HP-11C.
Данная группа регистров используется для передачи результатов работы вершинного шейдера дальше по графическому конвейеру: сначала полученные результаты интерполируются вдоль поверхности примитива, а затем поступают на вход пиксельного шейдера. Так как первые версии вершинных и пиксельных шейдеров предполагалось применять только для визуализации примитивов с использованием незначительных вариаций классических алгоритмов, все выходные регистры являются специализированными и предназначены для хранения определенного типа данных (рисунок 5.17):
oPos трансформированных координат вершины ( Position Register ).oD0 и oD1 цветов вершины ( Color Registers ). Изначально предполагалось, что регистр oD0 будет использоваться для хранения основного цвета вершины, а регистр oD1 - цвета блика.oT0, oT1, oT2, oT3, oT4, oT5, oT6, oT7 текстурных координат вершины ( Texture Coordinate Register ). Таким образом, с каждой вершиной может быть связано до восьми текстурных координат.oPts размера точки ( Point Size Register ). Используется для коррекции размера точки при визуализации массива точек ( PrimitiveType.PointList ).oFog, задающий плотность тумана в окрестностях данной вершины ( Fog Register ).
(рис 5.17) Выходные регистры Первые в точности следовали спецификации , поэтому их выходные регистры были жестко заточены под хранение специализированных типов данных. Соответственно, любая попытка использования данных регистров не по прямому назначению была чревата различными побочными эффектами вроде переполнения или потери точности. Но по мере развития графических процессоров данная специализация становилась все более условной: все современные начиная с NV4x и R5xx содержат универсальные выходные регистры, на которые отображаются выходные регистры виртуального вершинного процессора.
Вероятно, вы уже обратили внимание, что названия и назначения выходных регистров удивительно напоминают семантики HLSL выходных данных вершинного шейдера. Это не случайно: исторически семантики предназначались именно для привязки выходных данных вершинного шейдера HLSL к регистрам виртуального вершинного процессора (таблица 5.3). И только потом, по мере развития их функция свелась к банальной стыковке между собой выходных данных вершинных и входных данных пиксельных шейдеров. Таким образом, для написания на языке HLSL качественных шейдеров для старых очень важно представлять себе архитектуру виртуального вершинного процессора и его физическую реализацию.
| Регистр | Семанитики |
|---|---|
|
POSITION |
oD0 |
COLOR, COLOR0 |
oD1 |
COLOR1 |
oT0 |
TEXCOORD, TEXCOORD0 |
0T1 |
TEXCOORD1 |
oT2 |
TEXCOORD2 |
oT3 |
TEXCOORD3 |
oT4 |
TEXCOORD4 |
oT5 |
TEXCOORD5 |
oT6 |
TEXCOORD6 |
oT7 |
TEXCOORD7 |
oPts |
PSIZE |
oFog |
|
Возможно, сейчас у вас буквально рябит в глазах от обилия регистров вершинного процессора. В этом нет нечего страшного, ведь мы вовсе не собираемся учиться писать вершинные шейдеры на ассемблере. Нам требуется всего лишь научиться сносно читать ассемблерный код шейдера, сгенерированный компилятором HLSL, используя в качестве шпаргалки данный материал. В общем, научиться действовать по принципу "чукча не писатель, чукча читатель ".
Получив представление о регистрах, давайте познакомимся с форматом команд языка . Если вы уже сталкивались с программированием процессоров архитектуры x86, то заметите некоторое сходство между ассемблерными командами языка и командами процессоров x86. Все команды вершинного процессора имеют следующий синтаксис:
op dst, src0 [, src1] [, src2]
где
op - идентификатор команды.dst - регистр назначения, в который записываются результаты команды.src0, src1, src2 - регистры-операнды с исходными данными. Количество операндов варьируется от команды к команде.Важной особенностью языка является жесткое ограничение на размер вершинного шейдера: число ассемблерных команд не может превышать 128. Это не так уж и много, поэтому разработчикам нередко приходится бороться буквально за каждую команду, чтобы втиснуть алгоритм в прокрустово ложе вершинного процессора.
Чтобы получить представление о функциональных возможностях виртуального вершинного процессора, рассмотрим некоторые часто используемых команды вершинных шейдеров.
MOV - Пересылка данных
Начнем с x86:
mov dst, src
где
dst - регистр приемник;src - регистр источник.Следующая команда копирует содержимое константного регистра c2 во временный регистр r5:
mov r5, c2
Язык позволяет обращаться к отдельным компонентам векторного регистра: для этого после названия регистра необходимо поставить точку ". " и перечислить названия компонентов, к которым вы собираетесь обратиться. При этом допускается переставлять компоненты местами и многократно дублировать один и тот же компонент вектора. В общем, синтаксис очень напоминает синтаксис языка HLSL для доступа к отдельным компонентам вектора. Так же имеется возможность изменить знак компонентов регистра перед передачей в команду. Например, следующая команда занесет в регистр r5 лишь первые три компонента регистра c2 с измененными знаками, при этом первые два компонента будут переставлены местами:
mov r5.xyz, -c2.yxz
ADD – Сложение
Команда add выполняет сложение двух регистров:
add dst, src0, src1
где
dst - регистр приемник, в который заносится результат.src0 - регистр с первым слагаемым.src1 - регистр со вторым слагаемым.Действие команды можно описать выражением: . Например, следующая команда выполняет сложение содержимого регистров v0 и c0 и заносит результат в регистр r0 add r0, v0, c0.
SUB – Вычитание
Данная команда выполняет вычитание двух регистров:
sub dst, src0, src1
где
° dst = src0 - src1
Примечание
При описании команд, смысл аргументов которых вполне очевиден, я сразу буду приводить алгоритм их работы без расшифровки назначения аргументов.
К примеру, следующая команда вычитает из компонентов x и y регистра v2 компоненты z и w регистра v3 и заносит результат в компоненты y и z регистра r1:
sub r1.yz, v2.xy, v3.zw
MUL – Умножение
Перемножает два регистра:
mul dst, src0, src1
где
dst = src0 • src1
Следующая команда умножает все компоненты регистра c0 на компонент x регистра c1 и заносит результат в регистр r0:
mov r0, c0, c1.xxxx
MAD - умножение и сложение
Перемножает два регистра и прибавляет к полученному результату содержимое третьего регистра:
mad dst, src0, src1, src2
Где
dst = src0 • src1 + src2
Стоит отметить, что данная команда обычно выполняется значительно быстрее комбинации команд mul и add. Ниже приведен пример умножения регистра c0 на v1 с прибавлением к результату значения вектора c1. Результат заносится в регистр r0:
mad r0, c0, v1, c1
DP3 - скалярное произведение трехмерных векторов
Вычисляет скалярное произведение компонентов x, y, z двух векторов:
dp3 dst, src0, src1
Где
dst.xyzw = src0.x • src1.x + src0.y • src1.y + src0.z • src1.z
Например, следующая команда занесет во все компоненты регистра r1 результат скалярного произведения первых трех компонентов регистров v0 и r0:
dp3 r1, v0, r0
DP4 - скалярное произведение четырехмерных векторов
Вычисляет скалярное произведение содержимого двух регистров:
dp4 dst, src0, src1
где
dst.xyzw = src0.x • src1.x + src0.y • src1.y + src0.z • src1.z + src0.w • src1.w
Следующая команда занесет в первый компонент регистра r1 результат скалярного произведения всех четырех компонентов регистров v0 и r0:
dp4 r1.x, v0, r0
FRC - вычисление {x}
Возвращает дробную часть компонентов вектора:
frc dst, src0
где
dst = {src0}
Примечание
Обратите внимание, что {2.3}=3, но {- 2.3}=0.7
Результат может быть занесен только в компоненты y или xy регистра-приемника (запись в компонент x без y недопустима). Следующая команда заносит в компоненты xy регистра r0 дробные части соответствующих компонентов регистра r1: .
Команда в действительности является макрокомандой, которая разбивается на три команды. Принимая во внимание жесткие ограничения на длину вершинного шейдера, это весьма немаловажный нюанс. Многие вершинные процессоры поддерживают ее на аппаратном уровне, в результате чего при компиляции ассемблерного кода в микрокод вершинного процессора развернутый макрос , поэтому даже если текущий вершинный процессор аппаратно поддерживает команду , при подсчете длины шейдера она все равно будет засчитана за три команды.
RCP - вычисление 1/x
Выполняет деление единицы на скалярный аргумент:
rcp dst, src0
где
dst.xyzw=1/src0
src должен быть скалярной величиной. Если src0 равен 0, в заносится максимальное значение с плавающей точкой, поддерживаемое данным (обычно порядка $$10^38$$ ).
Примечание
начиная с NV3x поддерживают значения Floating-Point Specials: -Inf (минус бесконечность), +Inf (бесконечность со знаком плюс), NaN (результат не определен) и т.п. Соответственно, на NV3x и последующих процессорах результат 1.0/0.0 равен +Inf.
Следующая команда вычисляет 1/r1.w и заносит результат в r0.w:
rcp r0.w, r1.w
EXPP - вычисление 2x с точностью 2-3 знака после запятой
Возводит 2 в степень скалярного аргумента с точностью 2-3 знака после запятой:
expp dst, src0
где
dst - регистр приемник, в который заносится результат возведения в степень и побочные результаты. В компонент x заносится результат возведения в степень целочисленной части аргумента, в компонент y дробная часть аргумента, компоненту z присваивается результат возведения в степень, а компоненту w единица.src0 - степень, в которую возводится 2. Должна быть скалярной величиной.Алгоритм
Примечание
Побочные результаты работы команды часто используются, например, для нахождения дробной части числа.
Значение компонента z вычисляется с точностью 10 бит (2-3 знака после запятой).
Следующая команда вычисляет $$2^rl.w$$ и заносит его в r0.z. Остальные компоненты регистра не изменяются благодаря использованию маски .z.
rcp r0.z, r1.w
EXP - вычисление 2x с точностью 6-7 знаков после запятой
Возводит 2 в степень скалярного аргумента с точностью 21 бит (6-7 знаков после запятой):
expp dst, src0
где
Данная команда в действительности является макрокомандой, транслируемой в 10 инструкций. Поэтому перед ее использованием следует хорошенько подумать, а действительно ли вам так сильно необходима большая точность, чем у команды expp.
Следующая команда вычисляет 2^r1x и заносит его в r0.y:
expp r0.y, r1.x
MIN - определение минимальных значений компонентов
Выполняет покомпонентное сравнение двух аргументов и возвращает компоненты, имеющие минимальное значение.
min dst, src0, src1
где
dst - регистр-приемник, в который заносятся компоненты с минимальными значениями.src0 и src1 - вектора, компоненты которых сравниваются.Алгоритм работы:
dst = src0; if (src0.x > src1.x) dst.x=src1.x; if (src0.y > src1.y) dst.y=src1.y; if (src0.z > src1.z) dst.z=src1.z; if (src0.w > src1.w) dst.w=src1.w;
Следующая команда сравнивает компоненты w регистров r4 и r0, и заносит результат в компоненты x и y регистра r3: min r3.xy, r4.w, r0
MAX - определение максимальных значений компонентов
Выполняет покомпонентное сравнение двух аргументов и возвращает компоненты, имеющие максимальное значение.
max dst, src0, src1
где
dst - регистр-приемник, в который заносятся компоненты с минимальными значениями.src0 и src1 - вектора, компоненты которых сравниваются.Алгоритм работы:
dst = src0; if (src0.x > src1.x) dst.x=src0.x; if (src0.y > src1.y) dst.y=src0.y; if (src0.z > src1.z) dst.z=src0.z; if (src0.w > src1.w) dst.w=src0.w;
Следующая команда сравнивает все компоненты регистров r4 и r2, и заносит результат в регистр r1: max r1, r4, r2
SGE - сравнение "если больше или равно"
Покомпонентно сравнивает содержимое двух регистров и возвращает 1, если компонент первого аргумента больше второго или равен ему, и 0 в противном случае:
sge dst, src0, src1
где
dst - регистр приемник, в который заносится вектор с результатами сравнения.src0 и src1 - вектора, компоненты которых требуется сравнить.Алгоритм работы:
dst.xyzw = 0; if (src0.x >= src1.x) dst.x = 1; if (src0.y >= src1.y) dst.y = 1; if (src0.z >= src1.z) dst.z = 1; if (src0.w >= src1.w) dst.w = 1;
SLT - сравнение "если меньше"
Покомпонентно сравнивает два регистра и возвращает 1, если компонент первого аргумента меньше второго, и 0 в противном случае:
slt dst, src0, src1
где
dst - регистр приемник, в который заносится вектор с результатами сравнения.src0 и src1 - вектора, компоненты которых требуется сравнить.Алгоритм работы:
dst.xyzw = 0; if (src0.x< src1.x) dst.x = 1; if (src0.y < src1.y) dst.y = 1; if (src0.z < src1.z) dst.z = 1; if (src0.w < src1.w) dst.w = 1;
В принципе, знания вышеперечисленных команд вполне достаточно для чтения кода простых вершинных шейдеров. А при столкновении с незнакомыми командами вы всегда сможете найти их описание в документации DirectX.
Примечание
Чтобы быстро найти информацию о незнакомой команде языка откройте документацию по "неуправляемому " DirectX (Start | All Programs | Microsoft DirectX SDK | DirectX Documentation | DirectX SDK Documentation for C++) и введите во вкладке Index название интересующей вас команды. А для быстрого доступа к описанию всех команд и регистров интересующей вас версии наберите во вкладке Index нужный идентификатор: vs_1_1, vs_2_0, vs_2_x или vs_3_0.
Ну что ж, настало время попрактиковаться в использовании полученных знаний на практике. В качестве упражнения мы проанализируем ассемблерный код нашего старого знакомого – эффекта вершинной закраски BlackAndWhite.fx. Чтобы облегчить поиск соответствий между вершинным шейдером на языке HLSL и его ассемблерным кодом, я еще раз приведу код эффекта и листинг ассемблерного кода вершинного шейдера, полученного посредством FX Composer 2.0. Итак, код эффекта:
struct VertexInput
{
float3 pos : POSITION;
float4 color : COLOR;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
float luminance = (input.color.r+input.color.g+input.color.b)/3.0;
output.color.r = luminance;
output.color.g = luminance; output.color.b = luminance;
output.color.a = input.color.a;
return output;
}
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique BlackAndWhiteFill
{
pass p0
{
VertexShader = compile vs_1_1 MainVS(); PixelShader = compile
ps_1_1 MainPS();
}
}
И ассемблерный код вершинного шейдера:
vs_1_1 def c0, 0.333333343, 1, 0, 0 dcl_position v0 dcl_color v1 add r0.w, v1.y, v1.x add r0.w, r0.w, v1.z mul oD0.xyz, r0.w, c0.x mad oPos, v0.xyzx, c0.yyyz, c0.zzzy mov oD0.w, v1.w
Первая директива ассемблерного кода задает версию языка , на котором написан эффект. В настоящее время поддерживаются 4 директивы c интуитивно понятными названиями, каждая из которых соответствует определенной версии языка . Нетрудно догадаться, что ассемблерный код нашего шейдера написан на версии 1.1.
Ниже расположена директива def, которая заносит в константный регистр c0 вектор (0.333333343, 1, 0.0), компоненты которого будут использоваться инструкциями вершинного шейдера. Данная операция выполняется один раз перед началом визуализации с использованием шейдера и поэтому не влияет на производительность.
Следующие две директивы dclposition и dclcolor указывают, что координаты текущей вершины будут помещаться во входной регистры v0, а цвет вершины - в регистр v1.
Далее начинается собственно код вершинного шейдера. Первые две команды выполняют сложение трех цветовых компонентов цвета вершины и заносят результат в компонент w регистра r0. Третья команда умножает полученную сумму на содержимое компонента x регистра c0, равного 0.333333343, то есть фактически сумма делиться на 3. Итоговый результат заносится в компоненты x, y, z выходного регистра цвета oD0. Таким образом, первые три команды вершинного шейдера соответствуют следующему коду HLSL:
output.color.rgb = (input.color.r+input.color.g+input.color.b)/3.0;
Как видно, умный компилятор HLSL избавился от лишней временной переменной , а так же заменил присвоение значений трем компонентам r, g, b одним скалярным присваиванием. Но заменить два сложения и унижение
Продолжим анализ кода вершинного шейдера. Следующая команда mad может ввести начинающего разработчика в замешательство. Откуда она взялась, ведь HLSL -код вершинного шейдера не содержит чего-либо подобного? И что же она выполняет? Давайте немного подумаем. Данная команда madd использует в качестве аргументов координаты вершины из регистра v0 и компоненты константного регистра c0, а результат заносится в выходной регистр , соответствующий выходным координатам вершины. Попробуем подставить в выражение, вычисляемое командой mad значение компонентов константного регистра c0:
oPos = v0.xyzx * c0.yyyz + c0.zzzy = v0.xyzx * (1, 1, 1, 0) + (0, 0, 0, 1) = (v0.xyz, 0) + (0, 0, 0, 1)
Таким образом, команда mad соответствует нижеприведенной строке HLSL -кода:
output.pos = float4(input.pos, 1.0f);
Получается, компилятор нашел изящный способ реализации этого HLSL -кода: вместо прямолинейного кода из двух команд
mov oPos.xyz, v0.xyz mov oPos.w, c0.y
компилятор обошелся единственной командой mad.
Наконец, последняя команда mov заносит в альфа-канал выходного цветового регистра oD0 значение альфа-канала цвета вершины, т.е. соответствует строке
output.color.a = input.color.a
Оптимизируем вершинный шейдер
Итого код шейдера насчитывает 5 инструкций, и как мы выяснили в разделе 5.2.4, его обработка на и G7x обработка занимает 7 тактов. Настало время подумать, как можно улучшить производительность эффекта. Обратим внимание на два факта:
HLSL не смог заменить сложение компонентов цвета с последующим делением на 3 скалярным произведением. Значит, имеет смысл попробовать переписать код эффекта с использованием встроенной в HLSL функции dot.alpha blending ). Соответственно, оно абсолютно некритично к значению альфа-канала. Однако, как мы выяснили, присвоение значения альфа-каналу выливается в дополнительную команду. Поэтому данное присвоение можно безболезненно убрать, сократив код эффекта на одну команду.Код эффекта, написанный с учетом вышеуказанных данных рекомендацией, находится в листинге 5.5:.
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Значение альфа-канала не используется приложением,
поэтому нам все равно, что будет в него
// занесено в компонент a
output.color.rgba = dot(input.color.rgba, 1.0/3.0);
return output;
}
Ассемблерный данного эффекта, полученный посредством FX Composer 2.0, приведен ниже:
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 vs_1_1 def c0, 0.333333343, 1, 0, 0 dcl_position v0 dcl_color v1 dp4 oD0, v1, c0.x mad oPos, v0.xyzx, c0.yyyz, c0.zzzy // approximately 2 instruction slots used
Как видно, компилятор теперь использует инструкцию скалярного произведения dp4, а инструкция mov с копированием альфа-компонента цвета исчезла. Таким образом, анализ ассемблерного кода позволил нам внести в эффект небольшие косметические преобразования и сократить размер ассемблерного кода в 2.5 раза (с 5 до 2 команд). А выполнив повторный анализ производительности эффекта (нажав кнопку Run на панели ) мы увидим, что время обработки вершины одним вершинным процессором сократилось с 7 до 3-х тактов, то есть в 2.3 раза. И если раньше видеокарта GeForce 7800 GTX могла обработать за 1 секунду 491.000.000 вершит, то теперь теоретическая производительность шейдера достигла немыслимого темпа 1.146.000.000 вершин в секунду.
Предположим, что нам необходимо написать эффект, моделирующий визуализацию поверхности сквозь цветное стекло, пропускающее лишь часть света. Прозрачность стекла будет задаваться тремя коэффициентами, лежащими в диапазоне [0..1] и указывающими прозрачность стекла для красного, зеленого и синего компонентов цвета объекта. Если коэффициент равен 1, то стекло пропускает данный компонент цвета без изменений, если 0 - вообще не пропускает, а при промежуточных значениях 0..1 ослабляет яркость цветового компонента по мере уменьшения коэффициента. Итоговый цвет объекта определяется с использованием следующей формулы:
$$c_r=k_r-o_r c_g=k_g-o_g c_b=k_b – o_b $$где
Данные выражения очень легко реализуются в вершинном шейдере:
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Определяем коэффициенты пропускания разных цветов
const float4 filter = float4(0.2, 0.8, 0.5, 1.0);
// Вычисляем итоговый цвет вершин, используя векторную
операцию умножения
output.color = input.color * filter;
return output;
}
Данный вариант отлично работает в случае одного фиксированного фильтра. Но что делать, если в процессе работы приложения фильтр постоянно меняется? Теоретически, можно попробовать динамически генерировать код шейдера, задавая значение константы filter налету. Однако такой подход имеет ряд существенных недостатков: компиляция эффекта и загрузка его в занимают заметное время, что неминуемо окажет отрицательное влияние на производительность приложения. Кроме того, такие динамически генерируемые эффекты очень трудоемко сопровождать и отлаживать.
Поэтому разработчики языка HLSL предусмотрели специальный механизм для быстрого внесения изменений в эффекты "налету ". Техника очень проста: если при объявлении глобальной переменной указать ключевое слово uniform, то эта переменная будет доступна и прикладной программе, использующей эффект. Например, следующий код объявляет глобальную переменную filter, значение которой будет задаваться приложением:
uniform float4 circleColor;
Параметру можно указать значение по умолчанию, которое будет ему присваиваться сразу после загрузки эффекта из файла. Например:
uniform float4 circleColor = float4(0.5, 1.0, 0.8, 1.0);
Впрочем, ключевое слово uniform предполагается по умолчанию, поэтому его обычно не указывают -любая глобальная переменная является uniform -переменной. Антиподом uniform является ключевое слово static, которое скрывает глобальную переменную от программы. Например:
// Переменная circleColor скрыта от прикладной программы static float4 circleColor=float4(0.5, 1.0, 0.8, 1.0);
Примечание
Ключевое слово static так же применяется для объявления статических локальных переменных функции. В этом случае, его использование полностью аналогично языку C# за исключением маленького нюанса: при выходе из шейдера содержимое статических переменных теряется.
Никогда не забывайте указывать ключевое слово static для констант. Так как константы в отличие от входных параметров никогда не изменяются, это позволяет провести ряд дополнительных оптимизаций. Например, компилятор может заранее рассчитать все выражения, содержащие константы.
Реализовать эффект цветного полупрозрачного стекла с использованием параметра не составит труда (листинг 5.6).
// Параметр с коэффициентами прозрачности стекла float4
filter;
struct VertexInput
{
float3 pos : POSITION;
float4 color : COLOR;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
output.color = input.color * filter;
return output;
}
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique FilterFill
{
pass p0
{
VertexShader = compile vs_1_1 MainVS();
PixelShader = compile ps_1_1 MainPS();
}
}
Ниже приведен отчет NVIDIA FX Composer 2.0 с ассемблерным кодом вершинного шейдера эффекта, полученный посредством вкладки :
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000
//
// Parameters:
//
// float4 filter;
//
//
// Registers:
//
// Name Reg Size
// ----
// filter c0 1
//
//
// Default values:
//
// filter
// c0 = { 0, 0, 0, 0 };
//
vs_1_1
def c1, 1, 0, 0, 0
dcl_position v0
dcl_color v1
mul oD0, v1, c0
mad oPos, v0.xyzx, c1.xxxy, c1.yyyx
// approximately 2 instruction slots used
В комментариях перед ассемблерным кодом эффекта указано, что эффект содержит один параметр float4 filter и что компилятор отвел для хранения данного параметра константный регистр c0. При этом, так как мы не указали значение по умолчанию для данного параметра, он будет автоматически инициализироваться вектором (0, 0, 0, 0).
Таким образом, изменение значения параметра filter будет сводиться к модификации значения константного регистра c0, не затрагивая собственно код вершинного шейдера.
Примечание
Нетрудно догадаться, что максимальное количество параметров, принимаемых эффектом, ограничено и зависит от числа константных регистров. При этом следует помнить, что константы, используемые в эффекте, тоже неявно помещаются в константные регистры, уменьшая максимально число параметров, которые может принимать эффект.
В XNA Framework параметры эффекта хранятся в коллекции Parameters класса Effect:
public EffectParameterCollection Parameters { get; }
Доступ к элементам данной коллекции возможен как по индексу, так и по идентификатору параметра эффекта. Но на практике обычно используют второй вариант, так как он застрахован от таких непредвиденных ситуаций, как изменение числа параметров эффекта в будущих версиях эффекта:
public EffectParameter this[string name] { get; }
Примечание
Если эффект не содержит параметр с указанным именем, возвращается значение null.
Собственно параметр эффекта инкапсулируется классом EffectParameter, позволяющим читать и изменять значение эффекта посредством разнообразных типизированных методов SetValue и GetValueXXX. Ниже приведены определения некоторых методов GetValueXXX.
// Возвращает значение скалярного параметра HLSL типа float public float GetValueSingle(); // Возвращает значение массива параметров типа float: например, float[10]. Параметр // count указывает число элементов в массиве public float[] GetValueSingleArray(int count); // Возвращает значение параметра, являющегося двухмерным вектором (float2) public Vector2 GetValueVector2(); // Возвращает значение параметра, являющегося массивом двухмерных векторов (float2[]) public Vector2[] GetValueVector2Array(int count); // Возвращает значение параметра, являющегося трехмерным вектором (float3) public Vector3 GetValueVector3(); // Возвращает значение параметра, являющегося массивом трехмерных векторов (float3[]) public Vector3[] GetValueVector3Array(int count); // Возвращает значение параметра, являющегося четырехмерным вектором (float4) public Vector4 GetValueVector4(); // Возвращает значение параметра, являющегося массивом четырехмерным вектором (float4[]) public Vector4[] GetValueVector4Array(int count);
Такое обилие методов обусловлено тем, что с точки зрения XNA Framework параметры HLSL являются просто константными регистрами , содержимое которых можно трактовать по-разному в зависимости от ситуации. Например, значение цвета можно трактовать как четырехмерный вектор, два двухмерных вектора или массив из четырех скалярных элементов.
Примечание
При некорректном обращении к параметру эффекта (например, при попытке записать трехмерный вектор в параметр HLSL, являющийся четырехмерным вектором) генерируется исключение System.InvalidCastException.
Методы SetValueXXX приводить не имеет смысла, так как каждому методу GetValueXXX соответствует свой метод SetValue с аналогичным набором параметров. Например, парой для метода float GetValueSingle() является метод public void SetValue(float value).
В качестве примера использования параметров XNA Framework, в листинге 5.7 приведен код приложения, визуализирующего прямоугольник, видимый через цветное стекло, с возможностью изменения пользователем цвета стекла (рисунок 5.18). Визуализация осуществляется с использованием эффекта, созданного в предыдущем разделе.

(рис 5.7) Визуализация примитива через полупрозрачное стекло (рис 5.18) public partial class MainForm : Form { // Эффект, созданный в разделе 5.4 (листинг 5.6) const string effectFileName = "Data\\FilterFill.fx"; Effect effect = null; // Объект, инкапсулирующий параметр filter эффекта (цвет стекла) EffectParameter filterParam; private void MainFormLoad(object sender, EventArgs e) { // Загружаем и компилируем эффект в промежуточный код CompiledEffect compiledEffect; compiledEffect = Effect.CompileEffectFromFile (effectFileName, null, null, CompilerOptions.None, TargetPlatform.Windows); // Создаем объект эффекта effect = new Effect(device, compiledEffect.GetEffectCode(), CompilerOptions.NotCloneable, null); // Получаем объект EffectParameter, соответствующий параметру filter filterParam = effect.Parameters["filter"]; // Если параметр filter не существует, генерируем исключение Debug.Assert(filterParam != null, effectFileName + " : не найден параметр filter"); } // Обработчик нажатия панели, открывающий на экране диалоговое окно с выбором цвета стекла private void filterPanel_Click(object sender, EventArgs e) { if (colorDialog.ShowDialog() == DialogResult.OK) { // Изменяем цвет панели в соответствии с выбранным цветом filterPanel.BackColor = colorDialog.Color; xnaPanel.Invalidate(); } } private void xnaPanel_Paint(object sender, PaintEventArgs e) { ... // Изменяем значение цвет стекла. Так как в Windows Form значения компонентов цвета находится // в диапазоне 0..255, а в XNA Framework в диапазоне 0..1, нам приходится делить значения // компонентов на 255 filterParam.SetValue(new Vector4((float)filterPanel.BackColor.R / 255.0f, (float)filterPanel.BackColor.G / 255.0f, (float)filterPanel. BackColor.B / 255.0f, 1.0f)); // Визуализируем прямоугольник с использование эффекта effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.TriangleStrip, vertices, 0, vertices.Length - 2); pass.End(); } effect.End(); device.Present(); ... }
В принципе XNA Framework позволяет работать с параметрами с использованием следующего лаконичного синтаксиса:
effect.Parameters["filter"].SetValue(newColor);
Но, не смотря на кажущуюся простоту, эта практика весьма коварна: во-первых, увеличивается вероятность появления синтаксических ошибок в названии параметра, а во-вторых, замедляется выполнение программы, так как при каждом обращении к параметру XNA Framework вынужден выполнять поиск параметра по строке.
Поэтому обработчик события Load один раз выполняется поиск параметра filter, после чего вся работа с ним осуществляется уже посредством экземпляра класса EffectParameter. Во избежание проблем при модификации приложения после получения объекта EffectParameter вызывается метод Debug.Assert с проверкой ссылки на равенство null – гораздо удобнее получить исключение при загрузке приложения рядом с "проблемным методом ", чем где-то глубоко в дебрях приложения спустя несколько минут работы.
Итак, теперь вы уже знакомы с основами языков HLSL и . Настало время опробовать полученные знания в более-менее сложном проекте. Ведь как гласит народная мудрость, теория без практики бесполезна, а практика без теории может быть даже вредна.
В качестве отправной точки для приложения мы возьмем хранитель экрана из 4-й главы и поставим перед собой "сверхзадачу ": реализовать функциональность данного хранителя экрана, используя исключительно вершинные шейдеры. Иными словами, центральный процессор должен будет отсылать на видеокарту только команды "нарисовать диск " и "нарисовать искры ", а всю остальную работу по вращению диска и моделированию полета искр должен выполнять вершинный процессор . Это весьма объемная и нетривиальная задача, поэтому мы разобьем ее на ряд более простых этапов, по мере реализации которых мы продолжим знакомиться с новыми возможностями HLSL и языка .
Код хранителя экрана, выполняющий поворот диска устроен очень просто: сначала приложение вычисляет текущий угол поворота диска, а затем рассчитывает новые координаты каждой вершины диска (листинг 5.8).
// Определяем интервал времени, прошедший с момента
визуализации предыдущего кадра float delta =
(float)(currentTime - lastTime);
// Корректируем угол поворота диска diskAngle
+= diskSpeed * delta;
// Рассчитываем новые координаты вершин диска
diskVertices[0] = new VertexPositionColor
(new Vector3(0.0f, 0.0f, 0.0f),
XnaGraphics.Color.LightGray);
for (int i = 0; i <= slices; i++)
{
float angle = (float)i / (float)slices * 2.0f * (float)Math.PI;
float x = diskRadius * (float)Math.Sin(diskAngle + angle);
float y = diskRadius * (float)Math.Cos(diskAngle + angle);
byte red = (byte)(255 * Math.Abs(Math.Sin(angle * 3)));
byte green = (byte)(255 * Math.Abs(Math.Cos(angle * 2)));
diskVertices[i + 1] = new VertexPositionColor
(new Vector3(x, y, 0.0f), new XnaGraphics.Color(red,
green, 128));
};
Давайте внимательно рассмотрим этот код и прикинем, как его перенести в вершинный шейдер. Логично предположить, что код вне цикла не стоит выносить в вершинный шейдер, а вот собственно код цикла, выполняемый для каждой вершины диска, напротив, является идеальным кандидатом для переноса в вершинный шейдер. Но при этом следует учесть несколько нюансов:
angle для конкретной вершины всегда является константой. Поэтому значение переменной angle разумнее всего один раз рассчитать для каждой вершины и затем передавать в шейдер в качестве параметра.Vertex Shader 1.1 не поддерживает ветвления, поэтому нам придется рассчитывать параметры центральной вершины наравне с остальными, считая что она удалена от центра диска на нуль единиц.На следующем этапе мы должны определиться с информацией, передаваемой в вершинный шейдер и составить небольшую табличку наподобие таблицы 5.4. Информацию общую для всех вершин логично предавать через параметры шейдера, отображаемые на константные регистры. А вот информацию об удалении вершины от начала координат и угле ее локального поворота мы будем передавать через координаты вершины. Возможно, это вам покажется очень странным, но нечего противоестественного в этом нет - в разделе 5.3.1 говорилось, что атрибуты вершинны (координаты, цвет и т.п.) просто отображаются на входные регистры виртуального процессора v0, v1 … v15, а уж как трактовать информацию, хранимую в этих регистрах - это уже дело исключительно вершинного шейдера.
| Описание параметра | Аналогичная переменная из листинга 5.8 | Общий для всех вершин | Место хранения |
|---|---|---|---|
| Угол поворота всех вершин, меняющийся с течением времени | diskAngle |
Да | Входной параметр angle |
| Расстояние текущей вершины от центра диска | diskRadius |
Нет | Координата вершины X |
| Локальный угол поворота текущей вершины | Angle |
Нет | Координата вершины Y |
| Цвет текущей вершины | red/green |
Нет | Цвет вершины |
Прототип вершинного шейдера
В принципе, теперь можно приступать к написанию вершинного шейдера, но мы с этим делом немного повременим. Дело в том, что вершинные шейдеры достаточно капризны и трудоемки в плане отладки, а подобные сложные шейдеры мы еще никогда не писали. Поэтому для начала мы создадим на C# класс DiskEffect, эмулирующий функциональность нашего будущего вершинного шейдера (листинг 5.9). Это позволит нам, если что-то пойдет не так, легко поставить точку останова в коде шейдера и проверить корректность входных параметров или выполнить трассировку "шейдера " по шагам с просмотром состояния
// Эмулятор эффекта вращения диска
static class DiskEffect
{
// Параметр эффекта
public static float angle;
// Вершинный шейдер
// input - входная информация о вершине
// output - выходная информация о вершине
public static void VertexShader(VertexPositionColor[]
input, VertexPositionColor[]
output)
{
// Перебираем все вершины (в коде реального вершинного
шейдера цикла не будет, ведь он
// автоматически будет вызываться для каждой вершины for
(int i = 0; i < input.Length; i++)
{
// Вычисляем итоговый угол поворота вершины. Информация
об углах поворота вершины берется из
// параметра angle и координаты Y
float a = input[i].Position.Y + angle;
// Вычисляем координаты вершины. Расстояние вершины от
центра диска берется из координаты
X output[i].Position.X = input[i].Position.X * (float)Math.Sin(a);
output[i].Position.Y = input[i].Position.X * (float)
Math.Cos(a); output[i].Position.Z = 0;
// Цвет вершины проходит через вершинный шейдер без
изменений output[i].Color = input[i].Color;
}
}
}
Разумеется, применение подобного вершинного шейдера приведет к значительным изменениям в коде примера Ch04\Ex01 (прототипа хранителя экрана из четвертой лекции). Наиболее значимые фрагменты кода нового варианта приложения с подробными комментариями приведены в листинге 5.10.
public partial class MainForm : Form
{
// Обычный эффект для визуализации объектов. Пропускает
через себя информацию о вершинах без
// изменений. Вращение диска осуществляется посредством
класса-эмулятора вершинного шейдера const string
effectFileName = "Data\\ColorFill.fx";
// Число сегментов в диске
const int slices = 64;
// Скорость вращения диска
public const float diskSpeed = 3.0f;
// Радиус диска
public const float diskRadius = 0.018f;
GraphicsDevice device;
PresentationParameters presentParams;
VertexDeclaration diskDeclaration;
// Массив с информацией о вершинах диска
VertexPositionColor[] diskVertices = null;
// Массив с информацией о вершинах диска,
обработанных вершинным шейдеров. Используется
// исключительно для эмуляции работы вершинного шейдера
VertexPositionColor[] transformedDiskVertices = null;
Effect diskEffect = null;
Stopwatch stopwatch; bool closing = false;
private void MainFormLoad(object sender, EventArgs e)
{
// Создаем графическое устройство
device = new GraphicsDevice(GraphicsAdapter.
DefaultAdapter, DeviceType.Hardware,
this.Handle, options, presentParams);
// Декларация формата вершины
diskDeclaration = new VertexDeclaration(device,
VertexPositionColor.VertexElements);
// Создаем массив вершин диска
diskVertices = new VertexPositionColor[slices + 2];
// Создаем массив вершин диска, обработанных
вершинным шейдером (используется при эмуляции
// вершинного шейдера)
transformedDiskVertices = new VertexPositionColor[slices + 2];
// Заносим в массив вершин информацию о вершинах
диска (цвета, углы поворота и расстояния от
// центра)
diskVertices[0] = new VertexPositionColor(new
Vector3(0.0f, 0.0f, 0.0f),
XnaGraphics.Color.LightGray);
for (int i = 0; i <= slices; i++) {
float angle = (float)i / (float)slices * 2.0f *
(float)Math.PI; byte red = (byte)(255 *
Math.Abs(Math.Sin(angle * 3))); byte green = (byte)
(255 * Math.Abs(Math.Cos(angle * 2)));
// Заносим в массив информацию о текущей вершине
diskVertices[i + 1] = new VertexPositionColor(new
Vector3(diskRadius, angle,
0.0f), new XnaGraphics.Color(red, green, 128));
};
// Создаем эффект для визуализации объекта
diskEffect = new Effect(device, compiledEffect.GetEffectCode(),
CompilerOptions.NotCloneable, null);
// Создаем и запускаем таймер
stopwatch = new Stopwatch();
stopwatch.Start(); }
private void MainFormPaint(object sender, PaintEventArgs e)
{
// Вычисляем новый угол поворота диска и присваиваем
его "параметру эффекта "
float time = (float)stopwatch.ElapsedTicks /
(float)Stopwatch.Frequency; DiskEffect.angle =
diskSpeed * time;
// Выполняем "виртуальный вершинный шейдер "
DiskEffect.VertexShader(diskVertices, transformedDiskVertices);
// Задаем декларацию формата вершины
device.VertexDeclaration = diskDeclaration;
// Визуализируем диск
diskEffect.Begin();
for (int i = 0; i < diskEffect.CurrentTechnique.Passes.Count; i++)
{
EffectPass currentPass = diskEffect.CurrentTechnique.Passes[i] ;
currentPass.Begin();
// Используем трансформированные вершины
device.DrawUserPrimitives(PrimitiveType.TriangleFan,
transformedDiskVertices, 0, diskVertices.Length
- 2);
currentPass.End();
} diskEffect.End() ;
device.Present();
}
}
Готовое приложение находится в example.zip с книгой в каталоге Exampes\Ch05\Ex05.
Полноценный эффект
Отладив прототип эффекта можно приступать к реализации настоящего полноценного эффекта на языке HLSL. Используя в качестве шпаргалки листинг 5.9, написание вершинного шейдера не составит труда. Все что от нас требуется - убрать цикл перебора вершин и привести синтаксис в соответствии языку HLSL (листинг 5.11).
// Файл Disk.fx float angle;
struct VertexInput
{
float3 pos : POSITION;
float4 color : COLOR;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
float a = input.pos.y + angle;
output.pos.xy = input.pos.xx * float2(sin(a), cos(a));
output.pos.zw = float2(0.0, 1.0);
output.color = input.color;
return output; }
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique Disk
{
pass p0
{
VertexShader = compile vs11 MainVS(); PixelShader
= compile ps11 MainPS();
} }
Преобразование приложения тоже выполняется тривиально и сводится к удалению вспомогательного кода, эмулирующего вершинный шейдер: необходимо убрать ставшие ненужными класс DiskEffect и массив вершин, обработанных вершинным шейдером ( transformedDiskVertices ). А угол поворота теперь должен присваиваться непосредственно параметру эффекта angle. Основные фрагменты обновленного приложения приведены в листинге 5.12.
public partial class MainForm : Form
{
// Используем новый эффект
const string effectFileName = "Data\\Disk.fx";
Effect diskEffect = null;
// Объект EffectParameter, инкапсулирующий параметр
эффекта angle EffectParameter
angleParam = null;
private void MainForm_Load(object sender, EventArgs e)
{ ...
// Получаем объект EffectParameter, соответствующий
параметру эффекта angle angleParam =
diskEffect.Parameters["angle"];
Debug.Assert(angleParam != null, effectFileName + " :
не найден параметр angle");
}
private void MainForm_Paint(object sender, PaintEventArgs e)
{ ...
float time = (float)stopwatch.ElapsedTicks /
(float)Stopwatch.Frequency;
// Присваиваем угол поворота параметру angle эффекта
angleParam.SetValue(diskSpeed * time);
// Выполняем обычную визуализацию примитива
device.VertexDeclaration = diskDeclaration;
diskEffect.Begin();
for (int i = 0; i < diskEffect.CurrentTechnique.Passes.Count; i++)
{
EffectPass currentPass = diskEffect.CurrentTechnique.Passes[i];
currentPass.Begin();
device.DrawUserPrimitives(PrimitiveType.TriangleFan,
diskVertices, 0, diskVertices.Length –
2);
currentPass.End();
} diskEffect.End();
device.Present(); ...
} }
Как видно, обработчик события Paint лишь вычисляет новый угол поворота вершины и передает его в параметр эффекта шейдера. Собственно вращение вершин диска осуществляется только силами вершинного шейдера без какой-либо помощи со стороны C#- кода.
Анализ ассемблерного кода вершинного шейдера
Закончив создание эффекта самое время ознакомиться с ассемблерным кодом, сгенерированным
//
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000
//
// Parameters:
//
// float angle;
//
//
// Registers:
//
// Name Reg Size
// ----
// angle c0 1
//
//
// Default values:
//
// angle
// c0 = { 0, 0, 0, 0 };
//
vs_1_1
def c1, 0.159154937, 0.25, 0.5, -0.00138883968
def c2, 6.28318548, -3.14159274, -2.52398507e-007, 2.47609005e-005
def c3, 0.0416666418, -0.5, 1, 0 dclposition v0 dclcolor v1
add r0.w, v0.y, c0.x
mad r1.xy, r0.w, c1.x, c1.yzzw
frc r0.xy, r1
mad r0.xy, r0, c2.x, c2.y
mul r0.xy, r0, r0
mad r1.xy, r0, c2.z, c2.w
mad r1.xy, r0, r1, c1.w
mad r1.xy, r0, r1, c3.x
mad r1.xy, r0, r1, c3.y
mad r0.xy, r0, r1, c3.z
mul oPos.xy, r0, v0.x
mov oPos.zw, c3.xywz
mov oD0, v1
// approximately 15 instruction slots used
И что же мы видим? Параметру angle отведен входной регистр c0, но вот регистры c1, c2 и c3 почему-то содержат множество непонятных констант, а компактный код вершинного шейдера превратился в 13 ассемблерных инструкций, которые в действительности транслируются в 15 команд виртуального вершинного процессора. Несовпадение числа инструкций и команд обусловлено макро-инструкцией , разворачиваемой в три команды вершинного процессора. Окинуть одним взглядом структуру данной программы весьма проблематично, поэтому нам придется последовательно проследить выполнение программы по шагам, и попытаться понять, что же они выполняет каждая ее команда.
Примечание
Данный пример наглядно демонтирует, что короткий код вершинного шейдера вовсе не означает малый размер ассемблерной программы, и что ограничение максимальной длины программы в 128 инструкций не так уж и много.
Смысл первой команды весьма очевиден - она вычисляет сумму a = input.pos.y + angle и заносит результат в компонент w временного регистра r0. Следующая команда умножает и складывает полученное значение переменной с "чудными " константами, но ее смысл нам пока не ясен. Логично предположить, что эти действия имеют какое-то отношение к вычислению значения тригометрических функций sin и cos (виртуальный процессор не имеет инструкций для расчета синуса и косинуса). Что ж, давайте просто запишем это выражение как есть, заменив компоненты константных регистров их численными значениями:
Присмотревшись внимательно к константе 0.159154937 мы обнаружим, что это есть нечто иное, как единица деленная на удвоенное число "пи":
$$r1.x=\frac{\alpha}{2\cdot \pi}+0.25\\ r1.y=\frac{\alpha}{2\cdot \pi}+0.5 $$Следующая команда вычисляет дробную часть выражения и заносит ее в регистр r0:
После обработки четвертой команды содержимое регистра r0 преобразится следующим образом:
Нетрудно догадаться, что 3.14159274 - это число "пи", а 6.28318548 - число "пи" умноженное на два:
$$r0.x=2\cdot \pi\cdot \{\frac{\alpha}{2\cdot \pi}+0.25\}-\pi\\ r0.y=2\cdot \pi\cdot \{\frac{\alpha}{2\cdot \pi}+0.5\}-\pi $$На первый взгляд эти формулы могут показаться сущей несуразицей, но поэкспериментировав со значениями r0.y в можно обнаружить, что они обладает двумя важными
a, r0. y всегда находится в диапазоне $$[-\pi, +\pi)$$Следовательно, после выполнения второй, третьей и четвертой команд угол a преобразуется к диапазону $$[-\pi, +\pi)$$, при этом косинус угла остается неизменным. Зачем это надо? Логично предположить, что значение косинуса будет вычисляться путем cos(a) для больших аргументов. Кстати, если бы не это преобразование, то по мере вращения круга точность вычислений косинуса стремительно снижалась и, в конце концов, круг перестал бы корректно вращаться.
C компонентом x регистра r0 все несколько запутаннее:
r0.x находится в диапазоне $$[-\pi, +\pi)$$Очевидно, команды 2-4 используют известное тригонометрическое тождество $$\sin(x) = \cos(x-\frac{\pi}{2})$$. Не заглядывая вперед трудно наверняка сказать, зачем компилятор HLSL выполняет данное преобразования, но с большой долей вероятности можно предположить, что синус будет вычисляться через косинус.
Что ж, давайте введем условные обозначения для углов, преобразованных к диапазону, $$[-\pi, +\pi)$$ и продолжим анализ кода:
$$ax=r0.x=\{\frac{\alpha}{2\cdot \pi\}+0.25}\\ ay=r0.y=\{\frac{\alpha}{2\cdot \pi\}+0.5} $$Пятая команда возводит регистр r0 в квадрат, а шестая умножает и складывает его с константами и заносит результат в регистр r1 :
Шестая команда умножает регистр r1 на $$ax^2$$ и складывает с константой -0.00138883968:
Следующие три команды продолжают выполнение серии последовательных умножений на $$ax^2 $$ и сложений с константами. В результате после выполнения десятой команды в регистре r0 оказывается следующие
Чтобы понять смысл данного выражения раскроем скобки и упорядочим коэффициенты ax и ay по убыванию степени:
Ничего не напоминает? Правильно, это разложение функции cos(x) в ряд Тейлора до члена десятой степени:
Таким образом, ассемблерные команды с пятой по десятую вычисляют косинус угла. Соответственно, после выполнения десятой команды в компоненте y регистра r0 находится косинус угла, а в компоненте x -синус угла (как вы помните $$\sin(a) = \cos(ax), \cos(a) = \cos(ay)$$ ). При этом векторные регистры вершинного процессора позволили компилятору HLSL параллельно рассчитать значения обоих тригонометрических функций.
Точность вычисления cos(x)
Приблизительную оценку аппроксимации функции cos рядом Тейлора из пяти членов можно легко выполнить в том же , построив график модуля разницы между суммой пяти членов ряда Тейлора и встроенной функцией cos (рисунок 5.19). Как видно, по мере приближения модуля угла к $$\pi$$
(рис 5.19) Оценка абсолютной погрешности вычисления косинуса посредством ряда Тейлора в Mathcad Остальной код весьма тривиален. Одиннадцатая команда умножает полученные значения синуса и косинуса на расстояние вершины до центра и записывает результат в компоненты x и y регистра . Двенадцатая команда дописывает в компоненты z и w этого регистра значение 0 и 1. И, наконец, тринадцатая команда записывает в выходной регистр цвета цвет текущей вершины из регистра v1 .
Рисунок 5.20 резюмирует весь вышеприведенный анализ кода, устанавливая соответствие между ассемблерным и HLSL кодом шейдера. Однако следует ясно осознавать, что это всего лишь код для виртуального вершинного процессора, который будет скомпилирован драйвером видеокарты в код для конкретного реального вершинного процессора. А архитектура физического вершинного процессора может иметь множество нюансов. Например, все вершинные процессоры современных видеокарт являются суперскалярными и могут запускать несколько ассемблерных инструкций за такт, в частности вершинные процессоры R3xx и R4xx могут выполнить за один такт одну векторную инструкцию над 1-4 компонентным вектором и одну скалярную инструкцию (если они, разумеется, не зависят друг от друга по данным). Поэтому оптимизирующий компилятор драйвера видеокарты может переставлять инструкции местами для достижения большего параллелизма.
(рис 5.20) Соответствие между листингом Vertex Shader 1.1 и HLSL Кроме того, многие вершинные процессоры имеют расширенный набор инструкций по сравнению со спецификаций . Так вершинные процессоры R2xx могут аппаратно вычислять дробную часть числа, соответственно если драйвер видеокарты в процессе компиляции эффекта в микрокод вершинного процессора обнаружит последовательности инструкций , соответствующих макросу , то он заменит их одной встроенной командой. Другой пример: видеокарты R4xx и выше содержат инструкцию аппаратного вычисления синуса и косинуса угла в диапазоне $$-\pi....+\pi$$ поэтому драйвер автоматически подменит разложение в ряд Тейлора вызовом данных встроенных функций.
Тем не менее, оптимизирующий компилятор драйвера не всесилен, поэтому чем качественнее код и чем меньше явных атавизмов он содержит (вроде вычисления скалярного произведения серией инструкций add и mul вместо единственной инструкции dp4 ), тем вероятней драйвер видеокарты сможет сгенерировать оптимальный код.
Таким образом, при написании эффекта в FX Composer 2.0 в качестве главного критерия оптимальности шейдера должен выступать не промежуточный код на языке , а количество тактов графического процессора, затрачиваемых на обработку одной вершины. В частности на видеокарте GeForce 7800 GTX обработка одной вершины нашим вершинным шейдером занимает 20 тактов, а всего за одну секунду ее вершинный процессор теоретически может обработать 172.000.000 вершин. Анализ ассемблерного кода тоже весьма полезен, но в первую очередь как средство поиска проблемных мест в коде эффекта, нуждающегося в оптимизации. Но при этом не следует забывать об алгоритмической оптимизации приложения на макроуровне, иначе зациклившись на оптимизации нескольких локальных выражений вы рискуете не увидеть за деревьями леса. В частности, в следующем практическом упражнении демонстрируется, как использование знаний школьного курса тригонометрии позволяет значительно повысить производител
ьность приложения.
Практическое упражнение №5.1
Вершинный шейдер, выполняющий вращение диска, рассчитывает для каждой вершины значение тригометрических функций sin и cos . Следовательно, при визуализации множества дисков, содержащих тысячи вершин, нам придется рассчитать для каждого кадра значения тысяч тригонометрических функций. Учитывая, что вычисление тригонометрической функции является весьма трудоемким процессом, данное обстоятельство может негативным образом влиять на производительность.
Примечание
Графические процессоры G8x и R6xx содержат массив универсальных процессоров, которые могут выполнять код как вершинных, так и пиксельных шейдеров. Баланс между процессорами, выполняющих код вершинного шейдера и процессорами, выполняющих пиксельный шейдер, регулируется динамически в зависимости от загруженности соответствующих блоков видеокарты. При этом неоправданно сложный вершинный шейдер неминуемо "оттянет на себя " дополнительное количество универсальных процессоров и замедлит выполнение пиксельных шейдеров.
Обратим внимание на один нюанс. Аргумент функций sin и cos является уникальным для каждой вершины, причем он все время меняется. Но формируется он путем сложения двух компонентов:
float a = input.pos.y + angle;
При этом значение input.pos.y является постоянным для каждой вершины, а значение angle хотя и изменяется, но является общим для всех вершин. Таким образом, значения sin(input.pos.y) и cos(input.pos.y) вполне можно было бы рассчитать заранее в обработчике события Load и передавать в вершинный шейдер как координаты вершины, a sin(angle) и cos(angle) как входные параметры вершинного шейдера. Чтобы это стало возможным, необходимо выразить косинус суммы и синус суммы через синусы и косинусы слагаемых, воспользовавшись известными формулами из школьного курса тригонометрии:
Задание: проведите оптимизацию эффекта примера Ch05\Ex06 , реализовав вычисление тригометрических функций посредством выражения 5.5, и выноса большей части бессмысленных трудоемких расчетов за пределы вершинного шейдера. Используя NVIDIA FX Composer 2.0 , оцените потенциальный прирост производительности (который, скорее всего, окажется более чем трехкратным).
Подсказка
Имея вектор с предварительно рассчитанными значениями тригометрических функций, выражение 5.5 можно вычислить всего при помощи двух действий: по парного перемножения значения тригометрических функций и последующего суммирования результатов. Но так как вторая формула использует вычитание, для реализации ее через сумму вам потребуется передать в эффект значение -sin (angle) . При этом чтобы облегчить работу оптимизирующему компилятору HLSL, входные параметры sin (angle), cos (angle), -sin (angle) желательно передавать в шейдер как один трехмерный вектор.
Если у вас возникнут трудности при выполнении данного задания, вы всегда можете ознакомиться с готовым решением, которое можно найти в каталоге \Examples\Ch05\Ex07 .
Следующий этап - перенос расчета полета искр в вершенный шейдер - является гораздо более сложным и запутанным, поэтому, чтобы восстановить силы мы сделаем небольшой привал и поговорим об особенностях оператора if языка HLSL применительно к профилю vs11 .
Подобно подавляющему большинству языков программирования HLSL содержит конструкцию выбора if :
if (логическое условие)
{
блок 1
}
else
{
блок 2
}
В принципе, на этом раздел можно было бы окончить, если бы не одна маленький нюанс: язык не содержит команд для if , то центральный процессор будет каждый раз выполнять только одну из ветвей блока if , что позволяет значительно сократить объем вычислений и повысить HLSL при использовании профиля vs11 вынужден эмулировать условную конструкцию, генерируя код, выполняющий все ветви оператора с последующим комбинированием результатов. С
оответственно, в HLSL применение оператора if в принципе не может поднять производительность приложения.
В качестве примера попробуем "оптимизировать " вершинный шейдер, выполняющий закраску диска с использованием оператора if . Координаты вершины, распложенной в центре диска всегда неизменны, поэтому многие начинающие разработчики поддаются соблазну попытаться ускорить выполнение эффекта, отказавшись от трудоемкого расчета координат центральной вершины (листинг 5.13).
// Полный текст эффекта и готовое приложение находятся в каталоге Examples\Ch05\Ex08
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
if (input.pos.x != 0)
{
// Если вершина не является центральной, рассчитываем
ее координаты float a = input.pos.y + angle;
output.pos.xy = input.pos.xx \cdot float2(sin(a), cos(a));
}
else
// Если вершина расположена в центре круга, то ее
координаты всегда равны (0.0, 0.0) output.pos.xy = float2(0.0, 0.0);
output.pos.zw = float2(0.0, 1.0);
output.color = input.color;
return output; } Ниже приведен отчет FX Composer с
ассемблерным листингом
кода:
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000
//
// Parameters:
//
// float angle;
//
//
// Registers:
//
// Name Reg Size
// ----
// angle c0 1
//
//
// Default values:
//
// angle
// c0 = { 0, 0, 0, 0 };
//
vs_1_1
def c1, 0.159154937, 0.25, 0.5, -0.00138883968
def c2, 6.28318548, -3.14159274, -2.52398507e-007, 2.47609005e-005
def c3, 0.0416666418, -0.5, 1, 0
dcl_position v0
dcl_color v1
add r0.w, v0.y, c0.x
mad r1.xy, r0.w, c1.x, c1.yzzw
frc r0.xy, r1
mad r0.xy, r0, c2.x, c2.y
mul r0.xy, r0, r0
mad r1.xy, r0, c2.z, c2.w
mad r1.xy, r0, r1, c1.w
mad r1.xy, r0, r1, c3.x
mad r1.xy, r0, r1, c3.y
mad r0.xy, r0, r1, c3.z
mul r0.w, v0.x, v0.x
mul r0.xy, r0, v0.x
slt r0.w, -r0.w, r0.w
mul oPos.xy, r0, r0.w
mov oPos.zw, c3.xywz
mov oD0, v1
// approximately 18 instruction slots used
Для начала отметим, что количество ассемблерных команд возросло с 13 до 16, число микроинструкций с 15 до 18, а время выполнения шейдера с 20 до 21 тактов. Как видите, увеличение числа инструкций на 3 увеличило время выполнения шейдера всего на один такт. Эта "аномалия " имеет простое объяснение: каждый вершинный процессор G7x имеет VLIW - add, mul, madd и т.п.) и
один блок скалярных операций ( и т.п.). Следовательно, в идеальных условиях при отсутствии зависимостей по данным вершинный процессор G7x может за один такт запустить на выполнение одну векторную и скалярную операцию. Поэтому логично предположить, что драйвер NVIDIA при компиляции шейдера в микрокод вершинного процессора успешно спарил добавочные инструкции с остальными инструкциями шейдера. Хотя, конечно, нельзя исключить и влияние недокументированных особенностей микроархитектуры G7x . Таким образом, мы еще раз убедились, что количество инструкций без учета нюансов архитектуры вершинного процессора не может являться универсальным критерием производительности.
Перейдем к собственно ассемблерному коду вершинного шейдера. Первые 10 инструкций хорошо вам знакомы - они вычисляют сумму float a = input.pos.y + angle и значения тригометрических функций путем r0.x находится значение sin(a), а в r0.y значение cos(a).
Одиннадцатая инструкция возводит input.pos.x в квадрат. Двенадцатая инструкция умножает input.pos.x на вычисленные значения тригометрических функций, завершая тем самым вычисления значения input.pos.xx * float2(sin(a), cos(a)). Тринадцатая команда выполняет сравнение значений -input.pos.x-input.pos.x и +input.pos.x-input.pos.x по следующему алгоритму:
if (-input.pos.x* input.pos.x < input.pos.x* input.pos.x) r0.w=1 else r0.w=0
Нетрудно догадаться, что данный код всегда заносит в r 0.w значение 1, если input.pos.x неравен 0, и 0 в противном случае. То есть, грубо говоря, оно эквивалентно r0.w = (input.pos.x!=0)
Теперь все встало на свои места: так как не содержит инструкции сравнения на равенство двух чисел, сообразительный компилятор HLSL реализовал проверку числа на равенство 0 через комбинацию инструкций mul и slt.
Дополнительная информация
Язык не содержит инструкции проверки двух чисел на равенство, так как потребность в ней возникает достаточно редко. Дело в том, что поддерживает только типы с плавающей точкой, сравнение которых является достаточно нестабильной операцией: достаточно малейшей ошибки в последнем разряде и два равных значения перестанут быть равными.
Но в нашем случае мы можем не опасаться каких-либо последствий потери точности: данная особенность типов half/float/double проявляется исключительно при использовании дробных чисел и обусловлено тем, что большинство конечных десятичных дробей при переводе в двоичную систему становятся бесконечными двоичными дробями. Так как разрядная сетка мантиссы конечна, эту дробь не удается точно представить и последующие операции над такими урезанными бесконечными дробями приводят к накоплению ошибки. Чтобы убедиться в наличии данной проблемы достаточно провести небольшой вычислительный эксперимент в консольном приложении C#:
// Код примера расположен в Examples\Ch05\Ex09 class Program .
{
static void Main(string[] args)
{
float a = 1.2f;
float b = 1.4f;
float c = 1.68f;
float mul = a * b;
float delta = c - mul;
+ (double)a);
+ (double)b);
+ (double)c);
" + (double)mul);
= " + (double)delta);
Console.WriteLine("a = "
Console.WriteLine("b = "
Console.WriteLine("c = "
Console.WriteLine("sum =
Console.WriteLine("delta Console.ReadKey();
}
}
После выполнения примера на экране появится следующая информация:
a = 1,20000004768372 b = 1,39999997615814 c = 1,67999994754791 sum = 1,6800000667572 delta = -1,19209289550781E-07
Как видно, значения 1.2 и 1.4 не могут быть точно представлены в двоичной системе, в результате чего результат 1.68 - 1.2-1.4 оказался равен не нулю, а отрицательному числу -1.19-10-7 . Таким образом, с точки зрения компьютера $$1.68\ne 1.2-1.4$$.
Еще раз хочу обратить ваше внимание, что данные парадоксы возникают исключительно при работе с дробными числами, которые часто (но не всегда) не могут быть точно переставлены в двоичной системе. Целые же числа всегда могут быть точно переведены в двоичное представление, поэтому работа с ними осуществляется без потери
a = 12 b = 14 c = 168 sum = 168 delta = 0
Примечание
Некоторые новые графические процессоры, такие как G8x , содержат инструкцию, позволяющую выполнять покомпонентное сравнение четырехмерных векторов. Соответственно, драйвера при компиляции кода шейдера в микрокод подменяет последовательность инструкций вроде mul/slt одной инструкцией сравнения.
Четырнадцатая инструкция умножает рассчитанные координаты x и y вершины на результат сравнения input.pos.x с 0: если input.pos.x!=0 , то координаты остаются без изменения, а если input.pos.x==0 , то они обнуляются. Таким образом, условное выражение
if (input.pos.x != 0)
{
...
}
else
output.pos.xy = float2(0.0, 0.0);
реализуется последовательностью инструкций mul / slt / mul .
Резюмируем все вышесказанное. Компилятор HLSL успешно справился с реализацией оператора if без использования условных инструкций, но на производительности приложения это сказалось отрицательно, хотя и не фатально (на G7x время выполнения вершинного шейдера возросло на один такт).
Разумеется, при условии достаточной разрядности мантиссы.
После небольшого лирического отступления перейдем к моделированию полета искр средствами вершинного шейдера. Для начала освежим в памяти алгоритм генерации новых искр. Логика работы хранителя экрана выполняется с фиксированным шагом timeStep . В течение каждого дискретного шага появляется случайное количество новых искр, но не более maxScintillaCount . Каждая искра имеет случайные координаты, а так же случайную угловую и прямолинейную скорости. Время жизни каждой искры равно StartTime , по прошествии которого искра считается потухшей и ее структура может использоваться для генерации новой вершины: новые искры замещают потухшие, и лишь при отсутствии свободных потухших искр информация добавляется в конец массива вершин.
Данный алгоритм весьма проблематично реализовать в вершинном шейдере. Дело в том, что вершинный процессор обрабатывает вершины параллельно, независимо друг от друга, в результате чего вершинный шейдер не может получить информацию о состоянии других вершин. Ну а так как состояние шейдера не сохраняется между вызовами, а вернуть информацию из вершинного шейдера очень
Поэтому для генерации новых вершин нам придется разработать новый алгоритм, удовлетворяющим двум требованиям:
Будучи зажатыми в такие жесткие рамки, мы вряд ли сможем реализовать полноценный алгоритм генерации случайных искр со случайными параметрами. Поэтому мы просто заранее рассчитаем на некотором небольшом временном интервале время появления всех искр, их координаты, скорости, цвета и т.п., после чего будем проигрывать эту последовательность "по кругу " (рисунок 5.21). Таким образом, траектория движения точек будут повторяться через некоторое время, но если длительность одной итерации будет измеряться в десятках секунд, а число искр тысячами, то пользователь вряд ли сможет заметить какую-либо цикличность в поведении искр.
(рис 5.21) Циклическая работа фейерверка Минусом данного подхода является необходимость расчета в каждом кадре всех искр массива вершин, включая потухшие искры, причем, чем сильнее продолжительность итерации превосходит время жизни вершины, тем выше будут накладные расходы. Хотя с другой стороны этот недостаток наверняка компенсируется огромной производительностью вершинного
currentTime = time - vertexStartTime; localTime = currentTime % timeLoop;
где
time - время, прошедшее с момента запуска приложения.vertexStartTime - время появления вершины, отсчитываемое он начала итерации. После запуска хранителя экрана идет первая итерация, то есть время отсчитывается от нуля.timeLoop - длительность итерации, то есть время, через которое полет вершины повторяется заново.localTime - локальное время искры внутри итерации.На рисунке 5.22 приведен график зависимости currentTime от time , построенный в . Как видно, при запуске приложения время может быть равно отрицательному значению - это означает, что искра еще не появилась на экране. Далее по мере увеличения time локальное время так же линейно возрастает, пока не достигнет значения timeLoop , после чего оно сбрасывается до нуля и все повторяется сначала.
(рис 5.22) График зависимости локального времени искры от времени с момента запуска приложения Разобравшись с организацией массива вершин, займемся непосредственно моделированием полета искр. Как вы помните, в хранителе экрана из четвертой главы траектория движения искры складывается как композиция движения искры из центра диска по прямой с постепенным замедлением и движения по окружности вокруг диска с постоянно уменьшающейся угловой скоростью. Собственно расчет траектории выполнялся "в лоб " путем грубой аппроксимации с малым шагом времени delta :
// Корректируем скорость прямолинейного движения. При этом вершина не должна начинать // двигаться в противоположном направлении tSpeed = Math.Max(tSpeed - tSlowing * delta, 0.0f); // Корректируем скорость вращательного движения rSpeed = Math.Max(rSpeed - rSlowing * delta, 0.0f); // Изменяем расстояние искры от центра диска distance += Speed * delta; // Изменяем угол поворота искры вокруг диска angle += Speed * delta;
Так как этот алгоритм использует рекуррентные выражения, ссылающиеся на результаты предыдущих расчетов, он не может быть использован в вершинном шейдере – для этого потребуется сохранять результаты работы вершинного шейдера между визуализацией кадров, что весьма проблематично. Поэтому нам необходимо избавиться от реккурентности. В качестве основы возьмем выражение
$$tSpeed = tSpeed_0 - tSlowing \cdot t $$где
localtime ).Расстояние, пройденное вершиной, может быть найдено посредством суммирования значений $$(tSpeed_0 -tSlowing \cdot t) \cdot dt $$, где $$dt $$ - интервал времени, стремящийся к нулю. А это есть не что иное, как интеграл $$distance =\int (tSpeed_0 - tSlowing -t)-dt=t- tSpeed_0 -\frac{tSlowing \cdot t^2}{2}+c$$ где C - константа.
Значение этой константы можно легко определить из соображений, что при t=0 расстояние должно быть равно начальному положению точки $$distance_0$$:
Таким образом, константа C равна начальному положению точки и в результате мы получаем окончательную формулу:
Но это еще не все - выражение 5.6 построено на предположение, что по достижению скоростью искры значения 0 она начинает двигаться в противоположную сторону с возрастающей скоростью, в то время как наши искры по достижению нулевой скорости должны останавливаться на месте. Для учета данного обстоятельства мы должны ограничить значение времени величиной, при котором скорость становится равна 0:
$$td=min(localtime, \frac{tSpeed_0}{tSlowing}) distance=distance_0+tSpeed_0 \cdot td-\frac{tSlowing \cdot td^2}{2} $$где
td - локальное время искры, которое не может превышать значение, при котором скорость объекта становится отрицательной.Выражение для вычисления угла поворота вершины отличается от выражения расчета расстояния вершины от центра диска лишь несколькими нюансами, поэтому я сразу приведу готовый результат:
$$tr=min(localtime, \frac{tSpeed_0}{tSlowing}) angle=diskSpeed \cdot (time-localTime)+angle_0+tr \cdot rSpeed_0_\frac{rSlowing \cdot tr^2}{2} $$где
Искра вылетает все время из одного и того же места диска, но так как диск постоянно вращается, начальный угол поворота вершины все время оказывается разным. Такой подход имеет два достоинства: цвет вершины остается постоянным, а сами траектории движения вершин становятся более хаотичными. Для учета угла поворота диска в момент появления вершины используется слагаемое $$diskSpeed \cdot (time – localTime) $$, являющееся постоянным на протяжении всей жизни вершины (т.е. до следующей итерации).
Как видно, адаптация алгоритма с учетом специфики вершинного процессора может быть весьма нетривиальной задачей. Теперь можно приступать реализации данного алгоритма, но так как используемые формулы являются весьма запутанными и громоздкими, их не помешает для начала опробовать в классе-эмуляторе вершинного шейдера в C#. Кроме того, это позволит нам впоследствии оценить потенциальный прирост производительности, которого можно достичь при переносе вычислений в вершинный шейдер.
Прототип вершинного шейдера
Как и в случае с эффектом, вращающим диск, мы начнем с реализации класса, инкапсулирующего вершинный шейдер. Для начала определимся с параметрами, принимаемыми эффектом и способом их передачи ( таблица 5.5). В эффекте визуализации вращающегося диска входные параметры, уникальные для каждой вершины, передавались через координаты вершины. Но эффект визуализации искр содержит заметно больше входных параметров, поэтому нам придется передавать часть параметров через текстурные координаты. Применение текстурных координат никоим образом не сказывается точности передаваемых значений, ведь они, как и координаты и цвета вершин, проецируются компилятором HLSL на универсальные входные регистры v0, v1 … v15 . Код класса, эмулирующего работу вершинного шейдера, приведен в листинге 5.14.
| Описание параметра | Аналогичный параметр из формул предыдущего раздела | Общий для всех вершин | Место хранения |
|---|---|---|---|
| Текущее время | time |
Да | Входной параметр time |
| Длительность "итерации ", в течении которой движения искр не повторяются | timeLoop |
Да | Входной параметр timeLoop |
| Скорость вращения диска | diskSpeed |
Да | Входной diskSpeed |
| Цвет искры | - | Нет | Цвет вершины |
| Время появления искры | vertexStartTime |
Нет | Координата X |
| Начальное расстояние вершины от центра диска | distance0 |
Нет | Координата Y |
| Локальный угол поворота вершины | angle0 |
Нет | Координата Z |
| Начальная скорость удаления вершины от центра | tSpeed |
Нет | Текстурная координата X |
| Начальная угловая скорость вершины | rSpeed |
Нет | Текстурная координата Y |
static class FireworkEffect
{
// Константы с замедлениями искр. Общие для всех вершин
const float tSlowing = 0.105f;
const float rSlowing = 0.25f;
// Константа времени жизни искр
const float liveTime = 4.0f;
// Входные параметр time, timeLoop, diskSpeed
public static float time;
public static float timeLoop;
public static float diskSpeed;
// Код вершинного шейдера.
// input - входные данные вершины,
// output - выходные данные вершины
public static void VertexShader
(VertexPositionColorTexture[][] input,
VertexPositionColor[][] output)
{
// Перебираем все вершины (в реальном вершинном
шейдере этих циклов не будет). Так как при
// тестировании производительности число вершин
может превысить лимит примитивов, которые
// может визуализировать за один проход GPU Intel
GMA9xx, используется несколько массивов
// вершин
for (int j = 0; j < input.Length; j++)
{
for (int i = 0; i < input[j].Length; i++)
{
// Вычисляем время, прошедшее с первого появления искры
float currentTime = time - input[j][i].Position.X;
// Вычисляем локальное время, циклически
пробегающее от 0 до timeLoop
float localTime = currentTime % timeLoop;
// Определяем время, которое осталось существовать искре float
remainTime = liveTime - localTime;
// Ограничиваем величину локального времени,
чтобы искра останавливалась по достижению
// нулевой скорости
float td = Math.Min(localTime, input[j][i].
TextureCoordinate.X / tSlowing);
// Вычисляем расстояние вершины от центра диска
float distance = input[j][i].Position.Y + td *
(input[j][i].TextureCoordinate.X - td * tSlowing / 2.0f);
// Ограничиваем величину локального времени,
чтобы искра останавливалась по достижению
// нулевой угловой скорости
float tr = Math.Min(localTime, input[j][i].TextureCoordinate.Y /
rSlowing);
// Вычисляем текущий угол поворота искры вокруг диска
float angle = input[j][i].Position.Z + diskSpeed *
(time - localTime) +
tr * (input[j][i].TextureCoordinate.Y - tr * rSlowing / 2.0f);
// Вычисляем координаты вершины на основе
расстояния и угла поворота
output[j][i].Position.X = distance * (float)Math.Sin(angle);
output[j][i].Position.Y = distance * (float)Math.Cos(angle);
output [j ] [i] .Position. Z = 0.0f;
// Если искра появилась на экране, но еще не потухла
if ((currentTime >= 0) (remainTime > 0))
{
Vector4 color = input[j][i].Color.ToVector4();
// Определяем коэффициент прозрачности вершины
color.W = remainTime / liveTime;
output[j][i].Color = new XnaGraphics.Color(color);
}
else
{
output[j][i].Color = new XnaGraphics.Color(0, 0, 0, 0);
}
}
}
}
}
Коротко пробежимся по основным моментам программы. Информация о вершинах теперь хранится в структуре VertexPositionColorTexture , предоставляющей помимо знакомых нам полей Position и Color еще и поле TextureCoordinate , содержащее компоненты X и Y текстурных координат вершины. Выражения 5.7 и 5.8 были переписаны с использованием if , делающую невидимыми искры, которые согласно логике работы приложения еще не появились на экране.
Примечание
Кстати, HSLS реализует вычисление разложения функций sin и cos в ряд Тейлора посредством
Перейдем к обработчику события Load , выполняющего инициализацию массивов вершин с искрами (листинг 5.15).
public partial class MainForm : Form
{
// Эффект для простой закраски объектов
const string effectFileName = "Data\\ColorFill.fx";
const int slices = 64;
const float diskSpeed = 3.0f; const float
diskRadius = 0.018f;
// Число искр
const int fireworkVerticesCount = 300000;
// Движения искр будут повторяться через каждые 20 секунд
const float timeLoop = 20.0f;
// Минимальная скорость вершины
const float minSpeed = 0.3f;
// Максимальная скорость вершины
const float maxSpeed = 0.45f;
// Размер искры
const float pointSize = 1.0f;
// Декларация формата вершины. Диск и искры в режиме
эмуляции вершинного шейдера используют
// общий формат вершин
VertexDeclaration decl;
// Массивы вершин с искрами
VertexPositionColorTexture[][] fireworkVertices = null;
// Массивы вершин, обработанных эмулятором вершинного шейдера
VertexPositionColor[][] transformedFireworkVertices = null;
// Эффект, общий для диска и искр (в режиме эмуляции) Effect effect
= null;
Random rnd = new Random();
Stopwatch stopwatch;
bool closing = false;
// Счетчики FPS
// Временя, прошедшее с момента последнего вычисления
количества кадров в секунду
float lastTime = 0;
// Число кадров, визуализированных за это время
int frameCount = 0;
private void MainForm_Load(object sender, EventArgs e)
{
// Определяем число точек, которые может визуализировать
видеокарта за один присест
int maxVerticesCount = Math.Min(device.GraphicsDeviceCapabilities.
MaxVertexIndex,
device.GraphicsDeviceCapabilities.MaxPrimitiveCount);
// Определяем количество массивов вершин, которые потребуются
для визуализации
// fireworkVerticesCount вершин
int arrayCount = (int)Math.Ceiling((float)fireworkVerticesCount /
(float)maxVerticesCount);
// Создаем массивы вершин
fireworkVertices = new VertexPositionColorTexture[arrayCount][];
// Создаем массивы вершин, трансформированных вершинным шейдером
transformedFireworkVertices = new VertexPositionColor[arrayCount][];
// Перебираем вершины
for (int k = 0; k < fireworkVerticesCount; k++)
{
// Определяем индекс массива вершин, соответствующего текущей вершине
int j = k / maxVerticesCount;
// Определяем индекс текущей вершины в массиве вершин
int i = k % maxVerticesCount;
// Если мы перешли к новому массиву if (i == 0)
{
// Определяем количество оставшихся вершин
int remain = fireworkVerticesCount - j * maxVerticesCount;
// Число вершин в массиве не может превышать maxVerticesCount
remain = Math.Min(remain,
maxVerticesCount);
// Выделяем память для текущих массивов вершин
fireworkVertices[j] = new VertexPositionColorTexture[remain];
transformedFireworkVertices[j] = new
VertexPositionColor[remain];
}
// Вычисляем время появления вершины после запуска программы
fireworkVertices[j][i].Position.X = (float)rnd.NextDouble() * timeLoop;
// Определяем ее начальное удаление от центра диска
fireworkVertices[j][i].Position.Y = (float)rnd.NextDouble() *
diskRadius;
// Определяем начальный угол поворота вершины относительно диска
fireworkVertices[j][i].Position.Z = (float)rnd.NextDouble() * 2.0f *
(float) Math. PI;
// Определяем начальную линейную скорость вершины
fireworkVertices[j][i].TextureCoordinate.X = minSpeed +
(float)rnd.NextDouble() * (maxSpeed - minSpeed);
// Определяем начальную угловую скорость вершины
fireworkVertices[j][i].TextureCoordinate.Y = diskSpeed / 4.0f *
(1.0f + 0.01f * (float)rnd.NextDouble());
// Вычисляем цвет вершины
byte red = (byte)(255 * Math.Abs(Math.Sin(fireworkVertices[j][i].
Position.Z * ^ 3)) ) ;
byte green = (byte)(255 * Math.Abs(Math.Cos(fireworkVertices[j][i].
Position.Z * ^ 2) ) ) ;
fireworkVertices[j][i].Color = new XnaGraphics.Color
(red, green, 128, 255);
}
}
}
Чтобы иметь возможность наглядно оценить эффект переноса вычислений с CPU на , мы будем визуализировать 300.000 искр. Так как ряд видеокарт (например, Intel GMA 9xx ) не могут визуализировать такое количество примитивов за один
Код визуализации искр является достаточно тривиальным, если не считать того факта, что искры могут храниться в разных массивах вершин (листинг 5.16).
private void MainFormPaint(object sender, PaintEventArgs e)
{
// Настраиваем параметры GPU для визуализации
device.RenderState.CullMode = CullMode.None;
device. RenderState.AlphaBlendEnable = true;
device.RenderState.BlendFunction = BlendFunction.Add;
device.RenderState.SourceBlend = Blend.SourceAlpha;
device.RenderState.DestinationBlend = Blend.InverseSourceAlpha;
device.RenderState.PointSize = pointSize;
device.VertexDeclaration = decl;
// Определяем время, прошедшее с момента запуска приложения
float time = (float)stopwatch.ElapsedTicks /
(float)Stopwatch.Frequency;
// Настраиваем параметры эффекта, общие для всех вершин
FireworkEffect.time = time;
FireworkEffect.timeLoop = timeLoop;
FireworkEffect.diskSpeed = diskSpeed;
// Выполняем эмуляцию вершинного шейдера
FireworkEffect.VertexShader(fireworkVertices,
transformedFireworkVertices);
effect.Begin();
for (int i = 0; i < effect.CurrentTechnique.Passes.Count; i++)
{
EffectPass currentPass = effect.CurrentTechnique.Passes[i];
currentPass.Begin();
// Перебираем все массивы вершин
for (int j = 0; j < transformedFireworkVertices.Length; j++)
{ // Визуализируем текущий массив вершин
device.DrawUserPrimitives(PrimitiveType.PointList,
transformedFireworkVertices[j], 0,
transformedFireworkVertices[j].Length);
}
currentPass.End();
}
effect.End();
// Отключаем альфа-смешивание, которое не требуется
при визуализации диска
device.RenderState.AlphaBlendEnable = false;
float angle = diskSpeed * time;
// Выполняем эмуляцию вершинного шейдера диска
DiskEffect.angle = angle;
DiskEffect.VertexShader(diskVertices, transformedDiskVertices);
// Визуализируем диск
// Оканчиваем визуализацию кадра
device.Present();
// Увеличиваем счетчик кадров
frameCount++;
// Если прошла одна секунда
if (time - lastTime >= 1)
{
// Отображаем в заголовке формы текущий FPS
Text = ((float)frameCount / (time - lastTime)).ToString();
// Сбрасываем счетчики
lastTime = time; frameCount = 0;
}
}
Готовое приложение находится в example.zip в каталоге \Examples\Ch05\Ex10.
Результаты тестирования на компьютерах с видеокартами NVIDIA GeForce 7600GT и Intel GMA 9xx приведены в таблице 5.6. Так как конфигурация компьютеров заметно отличается, эти данные будут использоваться не для сравнения видеокарт между собой, а исключительно для оценки прироста производительности от внедрения вершинных шейдеров. Как видно, цифры сейчас колеблются в пределах 10 кадров в секунду, что явно недостаточно для обеспечения плавной анимации. Но уверяю вас, что к концу шестой главы частота кадров будет измеряться в сотнях кадров в секунду, причем это прирост будет достигнут без какого-либо ухудшения качества изображения.
| Конфигурация компьютера | FPS |
|---|---|
Intel Core2 Duo E6300, i945P, 2GB RAM, DDR2-667, GeForce 7600GT 256MB, Windows Vista Ultimate x64,
ForceWare 158.24 |
10,7 |
Intel Pentium-4 3.4GHz, i915P, 512MB RAM, |
6.3 |
Intel Core2 Duo E4300, i946GZ (GMA 3000), 2GB RAM DDR2-667, Windows Vista Ultimate x64, GMA Driver 7.14.10.1283 |
9.9 |
Примечание
Для корректного измерения производительности приложения при создании устройства свойство PresentationParameters.PresentationInterval должно быть установлено в PresentInterval.Immediate, в противном случае частота кадров будет зависеть от частоты вертикальной развертки монитора.
Вспомогательный метод загрузки эффекта из файла
После выноса расчетов полета искр в вершинный шейдер, наше приложение станет использовать два эффекта. Но вот незадача: код загрузки эффекта вместе со всеми обработчиками ошибок занимает более двадцати строк. В наших предыдущих примерах, использующих не более одного эффекта, это не было существенным недостатком. Однако при загрузке двух и более эффектов громоздкий код очень негативно скажется на читаемости кода, а так же затруднит дальнейшую модификацию приложения.
Это проблему можно изящно решить путем выноса кода загрузки эффекта в отдельный метод. Но, учитывая наши будущие приложения, будет разумнее поместить этот метод в отдельный класс Helper (листинг 5.17).
class Helper
{
// Класс исключения, которое генерируется при
возникновении проблем во время загрузки эффекта
public class LoadAndCompileEffectException : Exception
{
public LoadAndCompileEffectException(string message) : base(message)
{
}
}
// Загружает эффект из файла и выбирает наиболее подходящую технику.
При возникновении
// проблем генерирует исключение LoadEffectException.
public static Effect LoadAndCompileEffect(GraphicsDevice device,
string filename)
{
CompiledEffect compiledEffect;
try
{
compiledEffect = Effect.CompileEffectFromFile(filename, null,
null, CompilerOptions.None,
TargetPlatform.Windows); 1`
1
}
catch (IOException ex)
{
throw new LoadAndCompileEffectException(ex.Message);
}
if (!compiledEffect.Success)
{
throw new LoadAndCompileEffectException(String.Format
("Ошибка при компиляции эффекта: \r\n{0}",
compiledEffect.ErrorsAndWarnings));
}
Effect effect = new Effect(device, compiledEffect.GetEffectCode(),
CompilerOptions.NotCloneable, null);
if (!effect.CurrentTechnique .Validate())
{
throw new LoadAndCompileEffectException(String.Format
("Ошибка при валидации " +
"техники \"{0}\" эффекта \"{1}\"\n\rСкорее всего,
функциональность шейдера превышает " +
"возможности GPU", effect.CurrentTechnique.Name, filename));
}
return effect;
}
}
Полноценный эффект
Имея на руках код метода-эмулятора вершинного шейдера, написанного на C# с учетом специфики языка HLSL, создание эффекта не представляет какой-либо принципиальной сложности (листинг 5.18). Тем не менее, перевод C# -кода в HLSL не должен сводиться к механической трансляции - как-никак, HLSL содержит гибкие средства для векторных вычислений, позволяющие повысить качество промежуточного ассемблерного кода. В частности, мы можем значительно сократить объем вычислений, реализовав параллельный расчет текущего расстояния вершины от центра круга и угла поворота.
// Файл Firework.fx
//
// Константы tSlowing и rSlowing объединены в один двухмерный
вектор, что позволит
// распараллелить расчет расстояния от вершины от центра
и угла поворота вершины
static float2 slowing = {0.105, 0.25};
static float liveTime = 4.0;
float diskSpeed; float time;
float timeLoop;
struct VertexInput {
float3 pos : POSITION;
float4 color : COLOR;
float2 texcoord : TEXCOORD;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
float currentTime = time - input.pos.x; float localTime = currentTime
% timeLoop; float remainTime = liveTime - localTime;
// Расстояние от центра диска и угол поворота вершины
рассчитываются параллельно float2 t = min(localTime.xx,
input.texcoord / slowing); float2 sCoord = input.pos.yz + t *
(input.texcoord -t * slowing / 2.0f);
// Формула расчета угла поворота по сравнению с формулой
расчета расстояния от центра
// содержит один добавочный член
sCoord.y += diskSpeed * (time - localTime);
// Заменяем два умножения константы на sin и cos одним
умножением на вектор (sin, cos) output.pos.xy = sCoord.x *
float2(sin(sCoord.y), cos(sCoord.y)); output.pos.zw = float2(0.0, 1.0);
output.color.rgb = input.color.rgb;
if ((remainTime > 0) (currentTime >= 0))
{
output.color.a = remainTime / liveTime;
}
else
{
output.color.a = 0;
}
return output;
}
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique Firework
{
pass p0
{
VertexShader = compile vs_1_1 MainVS();
PixelShader = compile ps_1_1 MainPS();
}
}
Преобразование кода эффекта тоже весьма тривиально (листинг 5.19).
public partial class MainForm : Form
{
// Файл эффекта для визуализации вращающегося диска
const string diskEffectFileName = "Data\\Disk.fx";
// Файл эффекта для визуализации разлетающихся искр
const string fireworkEffectFileName = "Data\\Firework.fx";
// Эффект визуализации диска
Effect diskEffect = null;
// Объект, инкапсулирующий параметр angle эффекта диска
EffectParameter angleParam = null;
// Эффект визуализации искр
Effect fireworkEffect = null;
// Объекты, инкапсулирующие параметры эффекта искр:
diskSpeed, time, timeLoopParam
EffectParameter diskSpeedParam = null;
EffectParameter timeParam = null;
EffectParameter timeLoopParam = null;
private void MainFormLoad(object sender, EventArgs e)
{
// Так как вершины визуализируются без промежуточного
"эмулятора ", используется "родная
" // декларация формата вершин
fireworkDeclaration = new VertexDeclaration(device,
VertexPositionColorTexture.VertexElements);
try
{
// Загружаем эффекты
diskEffect = Helper.LoadAndCompileEffect(device,
diskEffectFileName);
fireworkEffect = Helper.LoadAndCompileEffect(device,
fireworkEffectFileName);
}
catch (Helper.LoadAndCompileEffectException ex)
{
// Обрабатываем исключительные ситуации загрузки и компиляции эффекта
closing = true;
MessageBox.Show(ex.Message, "Критическая ошибка", MessageBoxButtons.OK,
MessageBoxIcon.Error);
Application.Idle += new EventHandler(ApplicationIdle);
return; }
// Получаем объект, инкапсулирующий параметр angle
эффекта диска angleParam = diskEffect.Parameters["angle"];
Debug.Assert(angleParam != null, diskEffectFileName + " :
не найден параметр angle");
// Получаем объект, инкапсулирующий параметр diskSpeed эффекта искр
diskSpeedParam = fireworkEffect.Parameters["diskSpeed"];
Debug.Assert(diskSpeedParam != null, fireworkEffectFileName + " :
не найден параметр diskSpeed");
// Получаем объект, инкапсулирующий параметр time эффекта искр
timeParam = fireworkEffect.Parameters["time"] ;
Debug.Assert(timeParam != null, fireworkEffectFileName + " :
не найден параметр time") ;
// Получаем объект, инкапсулирующий параметр timeLoop эффекта искр
timeLoopParam = fireworkEffect.Parameters["timeLoop"];
Debug.Assert(timeLoopParam != null, fireworkEffectFileName + " :
не найден параметр timeLoop");
}
private void MainFormPaint(object sender, PaintEventArgs e)
{
float time = (float)stopwatch.ElapsedTicks / (float)Stopwatch.Frequency;
// Задаем значения параметров
timeParam. SetValue(time);
timeLoopParam.SetValue(timeLoop);
diskSpeedParam.SetValue(diskSpeed);
// Указывает декларацию формата вершин. Внимание!
Если вы при переходе к другому формату
// вершин и забудете подправить декларацию формата
вершины, то часть входных параметров
// вершины вроде текстурных координат будет содержать
"мусор ". Соответственно, эффект будет
// работать весьма странно, а самом худшем случае это
может привести к краху приложения и
// даже операционной системы.
device.VertexDeclaration = fireworkDeclaration;
// Визуализируем искры как обычно
fireworkEffect.Begin();
for (int i = 0; i < fireworkEffect.
CurrentTechnique.Passes.Count; i++)
{
EffectPass currentPass = fireworkEffect.
CurrentTechnique.Passes[i] ; currentPass.Begin();
for (int j = 0; j < fireworkVertices.Length; j++)
{
device.DrawUserPrimitives(PrimitiveType.PointList,
fireworkVertices[j], 4> 0, fireworkVertices [j] .Length) ;
}
currentPass.End();
}
fireworkEffect.End();
// Выполняем приготовления к визуализации диска
device.RenderState.AlphaBlendEnable = false;
angleParam.SetValue(diskSpeed * time);
// Не забываем изменить декларацию формата вершины
device.VertexDeclaration = diskDeclaration;
// Визуализируем диск
...
// Вычисляем FPS
...
}
}
Анализ исходного кода эффекта
Сейчас вы уже вполне неплохо освоились с языком , поэтому выполнять построчный анализ ассемблерного кода вряд ли имеет смысл. Вместо этого я сразу приведу отчет NVIDIA FX Composer 2.0 с ассемблерным листингом, разделенным комментариями на блоки, соответствующие тем или иным инструкциям.
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000
//
// Parameters:
//
// float diskSpeed;
// float time;
// float timeLoop;
//
//
// Registers:
//
// Name Reg Size
// ----
// diskSpeed c0 1
// time c1 1
// timeLoop c2 1
//
//
// Default values:
//
// diskSpeed
// c0 = { 0, 0, 0, 0 };
//
// time
// c1 = { 0, 0, 0, 0 };
//
// timeLoop
// c2 = { 0, 0, 0, 0 };
//
vs_1_1
def c3, 0.0416666418, -0.5, 1, 0 def c4, 4, 9.52380943,
0.104999997, 0.25 def c5, 0.5, 0.159154937, 0.25, -
0.00138883968
def c6, 6.28318548, -3.14159274, -2.52398507e-007,
2.47609005e-005 dcl_position v0 dcl_color
v1 dcl_texcoord v2 // float currentTime = time - input.pos.x;
1. add r2.w, -v0.x, c1.x
// Начало вычисления float localTime = currentTime % timeLoop
mul r0.w, r2.w, c2.x
add r1.w, c2.x, c2.x
sge r0.w, r0.w, -r0.w
mad r0.w, r0.w, r1.w, -c2.x
rcp r1.w, r0.w
mul r3.w, r2.w, r1.w
expp r4.y, r3.w
mov r1.w, r4.y
// Вычисление подвыражения input.texcoord / slowing из
// float2 t = min(localTime.xx, input.texcoord / slowing).
Деление на константу заменено
// умножением
10. mul r0.xy, v2, c4.yxzw
// Окончание вычисления float localTime = currentTime % timeLoop
11. mul r3.w, r0.w, r1.w
// Окончание вычисления float2 t = min(localTime.xx,
input.texcoord / slowing)
12. min r0.xy, r0, r3.w
// float2 sCoord = input.pos.yz + t * (input.texcoord -
t * slowing / 2.0f)
mul r1.xy, r0, c4.zwzw
mad r1.xy, r1, -c5.x, v2
mad r0.xy, r0, r1, v0.yzzw
// sCoord.y += diskSpeed * (time - localTime)
mad r3.w, r0.w, -r1.w, c1.x
mad r3.w, c0.x, r3.w, r0.y
// Начало вычисления output.pos.xy = sCoord.x * float2(sin(sCoord.y),
cos(sCoord.y))
mad r2.xy, r3.w, c5.y, c5.zxzw
frc r1.xy, r2
mad r1.xy, r1, c6.x, c6.y
mul r1.xy, r1, r1
mad r2.xy, r1, c6.z, c6.w
mad r2.xy, r1, r2, c5.w
// Вычисление подвыражения (currentTime >= 0) оператора if
24. sge r2.w, r2.w, c3.w
// Продолжение вычисления output.pos.xy = sCoord.x *
float2(sin(sCoord.y), cos(sCoord.y))
25. mad r2.xy, r1, r2, c3.x
// float remainTime = liveTime - localTime
26. mad r1.w, r0.w, -r1.w, c4.x
// Продолжение вычисления output.pos.xy = sCoord.x *
float2(sin(sCoord.y), cos(sCoord.y))
mad r2.xy, r1, r2, c3.y
mad r1.xy, r1, r2, c3.z
// Продолжение оператора if: вычисление подвыражения
(remainTime > 0)
29. slt r0.w, c3.w, r1.w
// output.color.a = remainTime / liveTime
30. mul r1.w, r1.w, c4.w
// Продолжение оператора if: окончание вычисления значения условия
// ((remainTime > 0) (currentTime >= 0))
31. mul r0.w, r2.w, r0.w
// Окончание вычисления выражения
// output.pos.xy = sCoord.x * float2(sin(sCoord.y), cos(sCoord.y))
// (умножение на sCoord.x)
32. mul oPos.xy, r0.x, r1
// Окончание блока if. Если условное выражение блока
if равно true, альфа компонент цвета
// остается без изменений, иначе обнуляется.
33. mul oD0.w, r1.w, r0.w
// output.pos.zw = float2(0.0, 1.0);
34. mov oPos.zw, c3.xywz
// output.color.rgb = input.color.rgb
35. mov oD0.xyz, v1
// approximately 37 instruction slots used
Пробежимся по наиболее интересным местам HLSL кода. Первым сюрпризом является трансляция вычисления выражения currentTime % timeLoop аж в целых 8 инструкций (с 2-й по 9-ю). Это обусловлено тем, что язык не содержит инструкции вычисления остатка отделения, соответственно компилятору приходится эмулировать ее посредством скудного набора инструкций. Ниже приведена реконструкция алгоритма нахождения остатка от деления на языке C#:
// Функция на языке C#, вычисляющая a%b. Написана
приближенно к алгоритму, используемому в
// HLSL
static float mod(float a, float b)
{
// Если частное (a/b) является отрицательным числом,
то изменяем знак у делителя (nb=-b)
float cmp;
if (a*b > -a*b) cmp
= 1;
else
cmp = 0;
float nb = cmp * (b + b) - b;
// Вычисляем частное
float div = a * (1.0f / nb);
// Находим дробную часть частного
float frac = div - (float)Math.Floor(div);
// Вычисляем остаток
float result = frac * nb;
return result;
}
Отдельно стоит отметить нахождение дробной части числа, до сих выполнявшаяся посредством макроса . Но при анализе кода нахождения остатка . Думаю, вы ожидали увидеть здесь все что угодно, только не команду expp, вычисляющую приближенное значение $$2^n$$. Правда компилятора интересует не само значение $$2^n$$, а побочный результат команды, заносящей в компонент y вектора-результата дробную часть числа ( a – floor(a) ). В целом же из всего вышесказанного следует вывод, что, несмотря на обманчиво простой вид, оператор % языка HLSL является очень "дорогой " операцией, соизмеримой по времени выполнения с вычислением тригонометрических функций.
Чтобы максимально задействовать HLSL изменил их порядок следования, чтобы избавиться от зависимости соседних инструкций. Обратной стороной медали является сложность анализа кода: инструкции многих операторов HLSL перемешались между собой, а код строки float remainTime = liveTime – localTime, расположенной в начале эффекта, был перенесен компилятором ближе к концу шейдера.
Еще одной любопытной особенностью является код оператора if, составное условное выражение которого содержит логическую операцию "и " – так как язык не поддерживает булевские типы и логические операции над ними, оператор эмулируется перемножением чисел с плавающей точкой.
Оптимизация вершинного шейдера
И, наконец, анализируя код строки float2 sCoord = input.pos.yz + t * (input.texcoord - t * slowing / 2.0f) мы обнаружим, что компилятор не смог догадаться предварительно вычислить значение константы slowing / 2.0f, что вылилось в один лишний оператор mul. Это дает нам основание предположить, что добавив в файл HLSL явное вычисление константы, мы сможем немного ускорить работу приложения. Но наверняка быть уверенным нельзя, ведь является всего лишь промежуточным кодом, впоследствии еще раз оптимизируемым компилятором драйвера.
Ну что ж, рискнем. Основные фрагменты эффекта с модифицированным вершинным шейдером приведены в листинге 5.20. Полный текст эффекта находится в example.zip в каталоге Examples\Ch05\Ex12.
static float2 slowing = {0.105, 0.25};
// Явно рассчитываем значение вспомогательной константы
static float2 slowing2 = slowing / 2.0f;
...
VertexOutput MainVS(VertexInput input)
{
...
float2 sCoord = input.pos.yz + t * (input.texcoord -t * slowing2);
...
}
Просмотр ассемблерного кода приложения даст вполне предсказуемые результаты: число команд ассемблерного листинга уменьшилось на одну (с 37 до 36), а вот время выполнения эффекта сократилось на целых 5 тактов (с 42 до 37) – вероятно удаление одной лишней команды позволило драйверу более эффективно распараллелить выполнение команд вершинного шейдера. В результате пиковая производительность эффекта на NVIDIA GeForce 7800 GTX увеличилась с 76.000.000 до 86.000.000 вершин в секунду, т.е. на 13%.
Таким образом, даже незначительные изменения в коде эффекта могут спровоцировать лавину изменений в финальном микрокоде шейдера для физического вершинного процессора, которые могут как усилить эффект от оптимизации HLSL -кода шейдера, так и свести ее на нет и даже снизить производительность.
Настало время оценить эффект от переноса вычислений на видеокарту. На рисунке 5.23 приведена диаграмма, построенная в Excel по результатам измерения производительности примеров Ch05\Ex10 и Ch05\Ex12 на разных .
(рис 5.23) Производительность примеров Ch05\Ex10 и Ch05\Ex12 на разных GPU Как видно, на GeForce 7600GT и Radeon X700 Pr o перенос вычислений с CPU на увеличил частоту кадров почти в 10 раз. На компьютере с интегрированным частота кадров тоже заметно увеличилась (в 3.5 раза), что на первый взгляд выглядит весьма странно: i946GZ не содержит аппаратного вершинного процессора, поэтому все вычисления по-прежнему выполняются силами центрального процессора.
Данный парадокс обусловлен рядом факторов. Как известно, .NET приложения содержат множество вспомогательного кода для обнаружения различных внештатных ситуаций вроде переполнения или обращения к несуществующему элементу коллекции. Разумеется, этот код оказывает отрицательное влияние на производительность, усугубляемое многократным его выполнением в цикле. Кроме того, все современные процессоры еще со времен Pentium-III содержат специализированный векторные регистры SSE и набор векторных инструкций, отдаленно напоминающие ассемблерные команды языков . Но язык C# и промежуточный язык IL не содержат векторных команд, что затрудняет распознавание векторных операций при компиляции JIT -компилятором IL -кода exe-файла в машинный код. В результате, итоговый машинный код практически не содержит SSE -инструкций и векторные блоки центрального процессора фактически простаивают.
При использовании вершинных шейдеров все обстоит несколько иначе. На i946GZ и аналогичных без аппаратных вершинных процессоров вершинные шейдеры эмулируются DirectX посредством специальной подсистемы Processor Specific Geometry Pipeline (PSGP). PSGP автоматически выполняет компиляцию вершинного шейдера в набор инструкций текущего CPU, задействовав весь потенциал данного процессора на 100%. Полученный код активно использует блоки SSE, параллельную обработку нескольких вершин всеми ядрами CPU и не содержит каких-либо ненужных промежуточных проверок "на всякий случай ". В результате он работает заметно быстрее по сравнению с аналогом на C#, что мы и наблюдаем.
Итак, вершинные шейдеры позволяют значительно поднять производительность приложения. Но не стоит забывать, что это упреждение верно лишь при сравнении производительности C# и HLSL -кода, использующего одинаковый алгоритм. Центральный процессор предоставляет разработчику использовать значительно более гибкие алгоритмы, так что на практике все обстоит несколько сложнее. Но в любом случае, вершинные шейдеры позволяют разгрузить центральный процессор, освободив его ресурсы для других задач.
В этой лекции мы познакомились с новыми возможностями языка HLSL применительно к программированию вершинных шейдеров: работе с отдельными компонентами вектора, математическими операторами, встроенными функциями, параметрами эффекта и особенностями оператора if. Так же была рассмотрена IDE для разработки шейдеров NVIDIA FX Composer 2.0, которая, учитывая рост сложности наших эффектов, пришлась как нельзя кстати. Учитывая, что вершинный шейдер выполняется для каждой вершины, число которых может измеряться сотнями тысяч, очень важно уделять внимание качеству кода и оптимизации вершинного шейдера. А для этого очень полезно иметь хотя бы поверхностное представление о том, что твориться под капотом HLSL, в частности о языках . Поэтому мы изучили основы архитектуры виртуального процессора и его систему команд.
Вот уже на протяжении трех лекций мы активно используем в приложениях вершинные и пиксельные шейдеры, однако их роль сводится к банальному пропусканию через себя исходных данных без какой-либо обработки или модификации. Такой подход трудно назвать оптимальным, ведь главное предназначение шейдеров – разгрузка центрального процессора компьютера путем освобождения его от рутины. Но для этого необходимо более детально изучить язык HLSL и получить представление об архитектуре графического процессора и языках ассемблера. В виду обширности этой темы данная лекция посвящена преимущественно программированию вершинных процессоров.
Примечание
Если вы немного подзабыли основы языка HLSL, можете еще раз пролистать раздел 2.3.
Функциональность любого шейдера так или иначе связана с математическими расчетами, поэтому для начала мы научимся выполнять математические операции над типами языка HLSL.
Математические операторы языка HLSL частично повторяют операторы языка C. Поддерживаются операторы +, -, *, /, %, ++, --, +=, -=, *=, /= и %=. Эти операторы можно применять как над C. Во втором случае операции осуществляется покомпонентно над элементами векторов:
float4 a = {1, 2, 3, 4};
float4 b = {5, 6, 7, 8};
// Складываем два вектора. Результат равен {1+5, 2+6, 3+7, 4+8} =
{6, 8, 10, 12}
float4 c = a+b;
// Умножаем два вектора. Вектор d станет равен {1*5, 2*6, 3*7, 4*8} =
{5, 12, 21, 32}
float4 d = a*b;
Если в выражении одновременно используются скалярный и векторный тип, то операция выполняется над скалярным типом и каждым компонентом вектора:
float4 a = {1, 2, 3, 4};
// Вектор b станет равен {1*2, 2*2, 3*2, 4*2} = {2, 4, 6, 8}
float4 b = a*2;
Независимо от используемых типов вычисления всегда выполняются 32-битной точностью для каждого float4 на half4 или double4 некоим образом не скажется на скорости или точности
DirectX предоставляет множество способов доступа к компонентам вектора. Во-первых, программист может работать с компонентами вектора как с элементами массива. В этом случае компоненты номеруются с нуля, а доступ к ним осуществляется с использованием оператора []. Например, для вычисления среднего арифметического всех компонентов вектора можно воспользоваться следующим выражением:
float4 color = float4(0.2, 0.7, 0.5, 1.0); // avg будет присвоено значение 0.6 float avg = (color[0] + color[1] + color[2] + color[3])/4;
Так как векторы очень часто используются для хранения геометрических координат и информации о цвете, DirectX предоставляет программисту возможность обращаться к компонентам вектора как к полям структуры. К нулевому элементу вектора можно обращаться как полю x или r, первому – y или g, второму – z или b, третьему – w или a. Нетрудно догадаться, что идентификаторы x, y, z, w предназначены для работы с геометрическими координатами, а идентификаторы r, g, b, a – для работы с цветовыми каналами:
float avg = (color.r + color.g + color.b + color.a)/4;
или
float avg = (color.x + color.y + color.z + color.w)/4;
При выполнении операций над векторами часто возникает необходимость выделить из вектора некоторый подвектор или переставить компоненты вектора местами. Так как современные HLSL встроили непосредственно в сам язык возможность гибкой работы с компонентами вектора. Например, для создания нового вектора путем комбинации компонентов существующего вектора достаточно просто перечислить после оператора ". " (точка) необходимые компоненты:
// Создаем четырехмерный вектор
float4 a={1, 2, 3, 4};
// Присваиваем двухмерному вектору b нулевой и первый элементы
вектора a. Результирующее
// значение вектора b будет равно (1, 2)
float2 b=a.xy;
// Присваиваем вектору c значение {1, 1, 2}
float3 c=a.xxy;
// Переставляем координаты x, y, z местами. Результирующее
значение вектора a будет равно
// {3, 2, 1, 4}
a.xyz=a.zyx;
Примечание
Приложение должно трактовать компоненты вектора либо как цветовые каналы, либо как геометрические координаты. Комбинирование в одном выражении различных типов наименований запрещено. В частности, компилятор HLSL откажется компилировать выражение вроде a.rgzw, так как первые два компонента вектора трактуются как цвет, а вторые два – как координаты.
Другая любопытная особенность языка HLSL заключается в том, что скалярные типы фактически являются одномерными векторами. Это позволяет обращаться к скалярному типу как к массиву или структуре.
Например, код
float a=3; float4 v=float4(a, a, a, a);
можно переписать следующим образом:
float a=3; // Обращаемся к скалярному типу как к одномерному вектору float4 v=a.xxxx;
Присвоение всем компонентам вектора одного и того же значения является довольно распространенной операцией. Поэтому в HLSL предусмотрен специальный синтаксис для выполнения этой операции: при присвоении вектору
float a=3; // В вектор v будет занесено значение (3, 3, 3, 3) float4 v=a;
В языке HLSL имеется множество математических функций для работы со скалярными и векторными типами: тригонометрические и гиперболические функции, вычисление скалярного и HLSL можно найти в приложении 4. Обратите внимание, что список доступных функций определяется используемым профилем.
Большинство функций HLSL транслируются в одну команду графического процессора. При этом, каждая команда графического процессора, как правило, выполняется за 1 такт. Поэтому рекомендуется как можно активнее использовать встроенные функции, а не изобретать велосипед. Например, выражение $$b=\frac {1}{\sqrt q}$$ можно записать как b=1.0/sqrt(a), либо как b=rsqrt(a). Первый вариант будет транслирован в две команды (вычисление квадратного корня и деление), а второй - в одну. Нетрудно догадаться, что какой из них будет работать быстрее.
Примечание
Оптимизирующий компилятор HLSL, скорее всего, самостоятельно заменит выражение b=1.0/sqrt(a) на b=rsqrt(a). Однако в более сложных случаях у него может не хватить сообразительности, чтобы подобрать оптимальную замену.
В качестве демонстрации практического использования математических расчетов мы напишем простой эффект, преобразующий цвет примитивов в черно-белый с использованием следующего выражения:
$$l=\frac{(r+g+b}{3} r=l b=l $$где
Это преобразование можно вставить в вершинный или пиксельный шейдер. В первом случае, цвета вершин вначале будут преобразованы в черно-белый цвет, после чего полученные черно-белые значения будут интерполироваться вдоль поверхности примитива. Следовательно, при визуализации нашего квадрата с помощью примитивов PrimitiveType.TriangleStrip преобразование в черно-белый цвет будет выполнено четыре раза - по одному для каждой вершины.
При вынесении расчетов по формуле 5.1 в пиксельный шейдер, преобразование в черно-белый цвет будет выполняться уже при вычислении цвета каждого пикселя. Например, когда визуализируемый объект занимает 56% площади окна размером 640x480, преобразование в черно-белый цвет будет осуществляться 640·480·0.56=172032 раза. То есть, по сравнению с первым вариантом объем вычислений возрастет в 172000/4=43000 раз (!), что не может не сказаться на производительности приложения. При увеличении размера окна до 1280x960 эта цифра возрастет еще в четыре раза.
Таким образом, мы можем сформулировать одно простое правило - при написании эффекта необходимо стремиться вынести как можно больше операций из пиксельного в вершинный шейдер. Поэтому в нашем эффекте мы разместим преобразование в черно-белый цвет именно в вершинном шейдере (листинг 5.1).
struct VertexInput
{
float3 pos : POSITION;
float4 color : COLOR;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Выполняем преобразование цвета вершины в черно-белый
float luminance = (input.color.r+input.color.g+input.color.b)/3.0;
output.color.r = luminance;
output.color.g = luminance;
output.color.b = luminance; output.color.a
= input.color.a;
return output; }
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique BlackAndWhiteFill
{
pass p0
{
VertexShader = compile vs_1_1 MainVS();
PixelShader = compile ps_1_1 MainPS();
}
}
Наш первый вариант вершинного шейдера реализует выражение 5.1 в лоб без учета архитектурных особенностей современных графических процессоров и, соответственно, не является оптимальным. Например, ничто не мешает нам присвоить рассчитанное значение яркости сразу трем цветовым компонентам (листинг 5.2).
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Вычисляем яркость цвета и присваиваем ее красному,
зеленому и синему каналам output.color.rgb =
(input.color.r+input.color.g+input.color.b)/3.0; output.color.a =
input.color.a;
return output;
}
Примечание
Вполне вероятно, что оптимизирующий компилятор HLSL самостоятельно заменит в вершинном шейдере из листинга 5.1 три присваивания компонентам вектора $$r, g, b$$ на одну векторную операцию. А может и не заменит... Поэтому имеет смысл выработать привычку активного применять векторные выражения, не особо полагаясь на сообразительность оптимизирующего компилятора.
После этих улучшений код нашего вершинного шейдера выглядит довольно оптимально. Однако его все равно можно еще немного улучшить. Давайте раскроем скобки в выражении (5.1):
$$l=\frac{1}{3}*r+\frac{1}{3}*g+\frac{1}{3}*b $$Если внимательно на него посмотреть, можно заметить, что оно является результатом скалярного произведения двух трехмерных векторов:
$$l=(\overline{\frac{1}{3},\frac{1}{3},\frac{1}{3})*(\overline{r,g,b}) $$На первый взгляд выражение 5.3 кажется значительно более громоздким и вычислительно сложным по сравнению с выражением 5.1. Но это вовсе не так - любой современный графический процессор умеет аппаратно вычислять скалярное произведение векторов. Поэтому, если мы заменим выражение 5.1 на 5.3 и воспользуемся встроенной функцией dot (скалярное произведение), то производительность программы ощутимо возрастет (листинг 5.3).
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Вычисляем скалярное произведение векторов.
Второй параметрфункции dot автоматически
// преобразуется в трехмерный вектор (1/3.0, 1/3.0, 1/3.0)
output.color.rgb = dot(input.color.rgb, 1/3.0);
output.color.a = 1.0;
return output;
}
Чтобы опробовать полученный эффект в полевых условиях мы напишем приложение, визуализирующее квадрат с разноцветными вершинами в черно-белом режиме (рисунок 5.1). Так как код приложения не содержит ничего выдающегося, я лишь приведу фрагмент обработчика события Load (листинг 5.4), а остальные подробности при необходимости вы легко сможете найти в example.zip в каталоге Examples\Ch05\Ex01.

(рис 5.4) Квадрат, визуализированный с использованием черно-белого эффекта (рис 5.1) public partial class MainForm : Form { // Файл эффекта, имитирующего черно-белую закраску const string effectFileName = "Data\\BlackAndWhiteFill.fx"; GraphicsDevice device = null; PresentationParameters presentParams; VertexDeclaration decl = null; // Массив вершин VertexPositionColor[] vertices = null; Effect effect = null; bool closing = false; public MainForm() { InitializeComponent(); } private void MainFormLoad(object sender, EventArgs e) { // Создаем графическое устройство decl = new VertexDeclaration(device, VertexPositionColor. VertexElements); vertices = new VertexPositionColor[4]; // Заносим в массив вершин информацию о вершинах. Обратите внимание, что цвет вершин не // является черно-белым vertices[0] = new VertexPositionColor(new Vector3(-0.75f, -0.75f, 0.0f), XnaGraphics.Color.Green); vertices[1] = new VertexPositionColor(new Vector3(-0.75f, 0.75f, 0.0f), XnaGraphics.Color.YellowGreen); vertices[2] = new VertexPositionColor(new Vector3(0.75f, -0.75f, 0.0f), XnaGraphics.Color.White); vertices[3] = new VertexPositionColor(new Vector3(0.75f, 0.75f, 0.0f) XnaGraphics.Color.GreenYellow); // Загружаем и компилируем эффект } // Обработчики событий Paint, Resize, Closed и т.п. }
Формула (5.1) предполагает, что человеческий глаз имеет одинаковую чувствительность к красному, зеленому и синему цвету. В действительности это не так - например, человеческий глаз значительно более чувствителен к зеленому цвету, чем к синему. Для учета этого факта национальным комитетом по телевизионным системам США ( ) было принято решение вычислять яркость по следующей формуле:
Создайте эффект, осуществляющий преобразование в черно-белый цвет с использованием формулы 5.4. В качестве отправной точки можно воспользоваться примером Ch05\Ex01. Готовое приложение находится в example.zip в каталоге Examples\Ch05\Ex02.
До сих пор мы создавали файлы эффектов .fx в обыкновенном текстовом редакторе. В принципе, в этом нет ничего плохого. В конце концов, некоторые разработчики создают .NET приложения в простых текстовых редакторах с последующей компиляцией полученного .cs -файла из командой строки компилятором C# ( csc.exe ). Однако по мере усложнения разрабатываемых проектов использование специализированных средств разработки становится все более актуальным. Как ни крути, та же IDE Visual Studio значительно облегчает процесс разработки благодаря умному редактору с IntelliSense, интегрированному компилятору, отладчику и справочной системе.
По мере изучения XNA наши эффекты будут становиться все сложнее, поэтому будет разумно заблаговременно подыскать интегрированную среду разработки эффектов. В действительности, наш выбор не велик - на рынке сейчас господствуют два бесплатных пакета для разработки эффектов: и NVIDIA FX Composer 2.0. В данном курсе мы будем использовать NVIDIA FX Composer, так как он гораздо динамичнее развивается и очень хорошо интегрирован с инфраструктурой .NET Framework.
Примечание
Существенная часть NVIDIA FX Composer 2.0 написана на .NET. В частности, вы можете легко исследовать исходный код FX Composer 2.0 посредством .NET Reflector.
Что такое FX Composer 2.0? Если коротко, это аналог Visual Studio для разработки шейдеров с использованием таких языков, как HLSL, FX Composer уступает Visual Studio 2005 практически по всем параметрам: удобству пользовательского интерфейса, технологии IntelliSense, документации и так далее. Кроме того, в текущей версии FX Composer имеется ощутимое количество багов. Впрочем, в этом нет ничего удивительного, если сравнить количество человеко-часов, затраченных на создание Visual Studio и FX Composer. Кроме того, NVIDIA FX Composer является абсолютно бесплатным, что позволяет закрыть глаза на многие недостатки - как известно, на халяву и уксус сладок.
FX Composer 2.0 в первую очередь ориентирован на работу с файлами формата COLLADA версии 1.4.1, поэтому для понимания основных принципов организации пользовательского интерфейса полезно ознакомиться с основами этого формата.
COLLADA (COLLAborative Design Activity) - это кроссплатформенный открытый формат, используемый для обмена данными между приложениями создания цифрового контента ( COLLADA основан на XML и задается XSD -схемой. Это очень универсальный формат, способный хранить множество видов контента:
Кроме того, формат COLLADA позволяет сторонним разработчикам добавлять новые элементы XML, расширяя возможности формата почти до бесконечности.
Рассмотрим основы формата COLLADA на примере простого файла:
<?xml version="1.0"?> <COLLADA xmlns="http://www.collada.org/2005/11/ COLLADASchema" version="1.4.1"> <libraryimages> <image id="mycolor" name="mycolor"> <initfrom>data/defaultcolor.dds</initfrom> </image> </libraryimages> <libraryeffects> <effect id="BlinnEffect" name="Blinn Effect"> <profileCG platform="PC-OGL"> <include sid="Blinn" url="Data/Blinn.cg"/> </profileCG> <profileCG platform="PS3"> <include sid="Blinn" url="Data/Blinn.cg"/> </profileCG> <profileGLSL> <include sid="Blinn" url="Data/Blinn.glsl"/> </profileGLSL> <extra type="import"> <technique profile="NVimport"> <import url="Data/Blinn.fx" compileroptions="" profile="fx"/> </technique> </extra> </effect> </libraryeffects> <librarymaterials> <material id="BlinnMaterial" name="Blinn Material"> <instanceeffect url="#BlinnEffect"> <!--Описание параметров материала--> </instanceeffect> </material> </librarymaterials> </COLLADA>
Как видно, вся информация о контенте храниться в элементе <collada>
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1"> </COLLADA>
В элемент <collada> вложены элементы, соответствующие разным типам контента:
<libraryimages> - растровые изображения;<libraryeffects> - эффекты;<librarymaterials> - материалы;<librarycameras> - камеры;<librarylights> - источники света;<librarygeometries> - модели;Каждый эффект определяется посредством элемента <effect>, вложенного в <libraryeffects>:
<libraryeffects> <effect id="BlinnEffect" name="Blinn Effect"> </effect> </libraryeffects>
Атрибут id задает уникальный идентификатор эффекта, a name - название эффекта, отображаемое приложениями вроде FX Composer 2. Так как формат COLLADA не привязан к конкретной платформе или API, эффект может быть написан на разных языках для различных платформ. Это достигается посредством профилей: каждый эффект может содержать несколько профилей для разных API и платформ. Профили задаются элементами с названиями вида <profileXXX> , вложенными в элементы <effect> :
<profileCG> - эффект написан на языке Cg. Атрибут platform позволяет специфицировать платформу, для которой предназначен эффект: например, значение "PC-OGL" указывает, что эффект предназначен для API OpenGL на платформе PC, a "PS3 " - для платформы Playstation 3.<profileGLSL> - эффект написан на языке GLSL.<profileGLES> - эффект написан для API <profileCOMMON> - платформо-независимый эффект, близкий по функциональности к стандартному материалу ( Standard Material ) из 3ds Max.Внутри элемента <profile> размещается ссылка на файл эффекта, а так же при необходимости различные сведения об эффекте: перечень техник, проходов и т.п. Кстати, код эффекта при желании тоже можно разместить непосредственно в элементе <profile> посредством элемента <code>.
Наверняка вы заметили, что в вышеприведенном списке нет профиля для языка HLSL. Дело в том, что в текущей версии (1.4.1) формата COLLADA пока отсутствует поддержка языка HLSL. Однако благодаря расширяемости данного формата разработчики могут легко реализовать дополнительную функциональность посредством элемента <extra>, в частности FX Composer помещает ссылку на .fx -файл следующим образом:
<extra type="import"> <technique profile="NV_import"> <import url="Data/Blinn.fx" compiler_options="" profile="fx"/> </technique> </extra>
Как видно, ссылка на эффект размещается в пользовательском профиле fx.
Наряду с эффектами в файле формата COLLADA могут храниться материалы. Для понимания концепции материала рассмотрим простой пример. Допустим, мы разработали эффект, визуализирующий фрактал. Управляя входными параметрами эффекта, мы можем визуализировать фрактальное изображение мрамора, дерева, воды, лавы и т.п. Тогда для каждого типа изображения мы создадим отдельные материалы мрамора, дерева, воды и лавы, использующие общий эффект фрактала, но с разными параметрами. Грубо говоря, материал это набор параметров для некоторого эффекта.
Материалы определяются внутри элемента <library_materials> посредством элемента <material>. В элемент <material> в свою очередь вкладывается элемент <instance_effect>, атрибут url которого ссылается на эффект из уже знакомой нам секции <library_effects>, используемый материалом. Кроме того, в секции <instance_effect> размещаются значения различных параметров эффекта, формирующие уникальный внешний вид материала.
Элементы <library_effects> и <library_materials> со всеми вложенными элементами образуют подмножество формата COLLADA, известное как COLLADA FX. FX Composer 2 в первую очередь предназначен для работы именно с подмножеством COLLADA FX, остальные же элементы COLLADA поддерживаются в ограниченном объеме по мере необходимости. Например, FX Composer 2 может использовать трехмерные модели из файла формата COLLADA, однако возможности создания и редактирования трехмерных моделей весьма ограничены (но теоретически могут быть расширены посредством плагинов).
Не переживайте, если вы не поняли часть материала. Цель этого раздела – просто познакомить вас с основными принципами устройства файлов формата COLLADA, знание которых поможет быстрее освоиться с весьма запутанным интерфейсом FX Composer 2.0.
Ну что ж приступим. Для начала установите FX Composer Start (Start | All Programs | NVIDIA Corporation | FX Composer 2 | FX Composer 2). На рисунке 5.2 приведен внешний вид стартового экрана FX Compose сразу после
В верхней части окна расположено главное меню FX Composer, под которым находится панель инструментов ( Standard Toolbar ) для быстрого доступа к наиболее важным пунктам меню. Как и во всех современных IDE панель инструментов легко конфигурируется с учетом предпочтений пользователя.
Примечание
В FX Composer 2 имеется подробное руководство пользователя, которое можно открыть щелчком на ссылке на панели Start Page, отображаемой при первом запуске FX Composer.
В центре экрана расположены вкладки трех панелей: Start Page, и Editor.
Start Page, аналогичная одноименному окну из Visual Studio 2005: здесь отображается информация о недавно открытых проектах ( Recent ), ссылки на документацию ( Getting Started ), перечень типовых действий вроде создания нового проекта или эффекта ( Tasks ) и новости с сайта. Обязательно ознакомьтесь с документацией по FX Composer (ссылка User Guide в разделе Getting Started ).Shader Library содержит коллекцию материалов из онлайновой библиотеки материалов NVIDIA.Editor представляет собой текстовый редактор с
(рис 5.2) Стартовый экран FX Composer 2: 1 – главное меню и панель инструментов, 2 – панель Start Page , 3 – панель Materials, 4 – панель Properties, 5 – панель Render, 6 – панель Animation В левой части расположены вкладки трех панелей: Materials, Assets и Project.
Material, напоминающая редактор материалов из 3ds Max, позволяет работать с материалами, о которых мы уже говорили в разделе о формате COLLADA.COLLADA.Project, содержащая иерархию всех файлов проекта, в целом аналогична окну Solution Explorer из Visual Studio 2005.В правой верхней части окна расположена панель Properties, позволяющая задавать значения входных параметров эффектов и материалов с использованием интуитивно понятного интерфейса. Ниже расположена вкладка Render, которая позволяет опробовать созданный материал на тестовой сцене, визуализируемой с использованием API или DirectX (как вы помните, эффекты COLLADA могут содержать персональные профили для каждого API ).
В нижней части окна расположены панели Animation и Tasks.
Animation управляет ходом времени и используется в основном для тестирования анимированных материалов.Tasks отображаются сообщения об ошибках компиляции эффекта, т.е. она является аналогом окна Error List из Visual Studio.Все панели не являются фиксированными: их можно легко перетаскивать с места на место, попутно изменяя размер. При этом панели автоматически приклеиваются к краям окон, встраиваются в другие панели, короче ведут себя так же, как и аналогичные панели из Visual Studio. Дополнительные View (рисунок 5.3).
(рис 5.3) Список панелей FX Composer в меню View Примечание
Меню View содержит пункт Layouts, позволяющий гибко конфигурировать расположение панелей FX Composer. Предусмотрено четыре типовых расположения панелей (подпункты Artist, Authoring, Default, Turning ), кроме того предусмотрена возможность создания пользовательских конфигураций. Если вы вдруг перетащили панель куда-то не туда и не можете вернуть ее на прежнее место, просто выполните команду меню View | Layouts | Reset Layout.
Лучший способ изучить FX Composer 2.0 – начать его использовать на практике. В качестве упражнения мы создадим в FX Composer эффект черно-белой закраски. Переключитесь в FX Composer. Если вы уже экспериментировали с материалами и эффектами, то создайте новый проект, выполнив команду меню File | New | New Project. Отобразите вкладку Assets (рисунок 5.4). Как видно, эта вкладка содержит узлы, соответствующие наборам контента формата COLLADA, о которых мы немного поговорили в разделе 5.2.1. Например, узел Effects вкладки Assets соответствует элементу <library_effects>, узел Materials – элементу <library_materials> и т.п.
(рис 5.4) Вкладка Assets Чтобы добавить в проект новый эффект щелкните правой кнопкой мыши на узле Effects и в появившемся контекстом меню выберите пункт Add Effect... . На экране появится мастер создания нового эффекта (рисунок 5.5). Так как мы будем использовать исключительно язык HLSL, установите флажок только рядом с профилем HLSL FX. В поле Effect Name введите название эффекта (например, BlackAndWhite ). В нижней части окна можно указать название материала, создаваемого на базе данного эффекта, но так как материалы нам пока не нужны, мы не будем устанавливать данный флажок.
(рис 5.5) Мастер создания эффекта Перейдите к следующему диалоговому окну мастера Effect Wizard, нажав кнопку Next. Здесь вам потребуется указать шаблон, на основе которого будет создан эффект. Мы будем использовать шаблон Empty, который, как нетрудно догадаться, создает простейший эффект по умолчанию. В поле Name укажите название эффекта (например, BlackAndWhite.fx ), а в поле Location – каталог, в который будет помещен файл эффекта. Наконец, завершите создание эффекта нажатием кнопки Finish.
(рис 5.6) Создание файла эффекта Теперь разверните узел Effects. Если все было выполнено правильно, в узле Effects появится дочерний узел BlackAndWhite, инкапсулирующий эффект Collada, в который вложен собственно файл нашего эффекта BlackAndWhite.fx (рисунок 5.7).
Примечание
Если бы мы при создании эффекта выбрали наряду с .fx еще несколько профилей, то узел эффекта BlackAndWhite содержал бы несколько файлов эффектов с расширениями наподобие .cg или .glsl.
(рис 5.7) Узел созданного эффекта на панели Effects Чтобы открыть редактор кода выполните двойной щелчок левой кнопкой мыши на узле файла BlackAndWhite.fx. Замените текст созданного по умолчанию эффекта кодом из листинга HLSL смог выполнить оптимизацию этого некачественного кода HLSL, значительно облегчающую поиск опечаток. По завершению набора кода эффекта выполните его компиляцию посредством сочетания клавиш Ctrl + F7. Если эффект содержит ошибки, то в окне Tasks появится перечень ошибок (рисунок 5.8), а сама строка содержащая ошибку будет подсвечена.
Примечание
Чтобы видеть сообщения о ходе компиляции эффекта, откройте панель Output (рисунок 5.9) посредством команды главного меню View | Output.

(рис 5.9) Панель Task с информацией об ошибках в эффекте (рис 5.8) Панель Output с информацией о процессе компиляции В заключении не забудьте сохранить проект эффекта командой File | Save All.
Сохранив проект, запустите любой файловый менеджер и перейдите в каталог с проектом. Если вы сохранили проект и файл эффекта в одном и том же каталоге, то в нем будут находиться три файла:
Project.fxcproj - файл проекта FX Composer с информацией о настройках IDE и файлах входящих в проект. Имеет формат XML.Document1.dae - файл формата COLLADA с информацией о контенте.BlackAndWhite.fx - собственно файл эффекта.Текст файла Document1., сгенерированный FX Composer 2.0 на моем компьютере, приведен ниже:
<?xml version="1.0"?> <COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1"> <asset> <contributor> <author>Sergei Gaidukov</author> <authoringtool>NVIDIA FX Composer 2.0</authoringtool> <comments/> <copyright/> </contributor> <created>2007-06-04T16:39:20</created> <keywords>FXComposer, NVIDIA</keywords> <modified>2007-06-04T16:39:21</modified> <subject/> <title/> </asset> <libraryeffects> <effect id="Effect" name="BlackAndWhite"> <profile_COMMON> <technique sid="__fxc2_default"> <constant/> </technique> </profile_COMMON> <extra type="import"> <technique profile="NV_import"> <import url="BlackAndWhite.fx" compiler_options="" profile="fx"/> </technique> </extra> </effect> </library_effects> </COLLADA>
Как видно, в элемент <COLLADA> вложены два элемента: <asset> с информацией об авторе файла, времени его создания и приложении, в котором он был создан; и уже знакомый нам элемент <library_effects>. Последний содержит эффект с идентификатором Effect и именем BlackAndWhite, в котором определено два профиля: HLSL и COMMON.
Примечание
Чтобы файл формата COLLADA мог корректно обрабатываться любым приложением, он должен содержать профиль COMMON.
Если эффект изначально разрабатывался в FX Composer 2.0, то с его редактированием не возникнет проблем – достаточно просто открыть проект командой File | Open | Open Project... и продолжить работу. Но что делать, если необходимо подправить существующий .fx -файл? Так как организация проектов FX Composer 2.0 насквозь пронизана идеологией COLLADA, вы не можете просто так открыть существующий .fx-файл командой File | Open | Open File... – в этом случае вы потеряете возможность выполнять пробную компиляцию .fx -файла и, соответственно, не сможете обнаруживать синтаксические ошибки.
К счастью, эта особенность легко обходится: вы должны просто создать новый эффект на основе вашего .fx -файла. Для этого откройте вкладку Assets, щелкните правой мыши на узле Effects, выполните команду контекстного меню Add Effect From File... и укажите файл, который необходимо открыть. В результате в проект будет добавлен новый эффект, содержащий указанный файл, который теперь можно легко отредактировать и откомпилировать.
При разработке шейдеров начинающие разработчики часто оказываются в положении буриданова осла, когда одну и ту же функциональность можно реализовать различными способами и при этом не совсем ясно, какой из них будет иметь большую производительность. В подобных ситуациях трудно переоценить полезность панели , позволяющей быстро прикинуть быстродействие эффекта на различных видеокартах NVIDIA с учетом многочисленных версий драйверов.
Чтобы получить представление о возможностях данной панели мы проанализируем производительность эффекта BlackAndWhite, созданного в разделе 5.2.3. Для начала в панели Assets щелкните правой кнопкой мыши на узле эффекта, который вы собираетесь проанализировать, и выберите команду контекстного меню Analyze Performance, после чего в нижней части окна появится панель (рисунок 5.10), содержащая две вкладки: Startup Form и BlackAndWihite.fx. Вторая вкладка, как нетрудно догадаться, предназначена для анализа нашего эффекта, а первая используется преимущественно для загрузки новых эффектов в панель Analyze Performance.
(рис 5.10) Панель Shader Performance В левом верхнем углу вкладки BlackAndWhite.fx расположен переключатель между режимом анализа производительности единственного выбранного прохода эффекта ( Analyze a Pass ) и режимом сравнения производительности всех проходов эффекта ( Compare Passes ). Так как наш эффект содержит единственный проход, оба варианта будут практически эквивалентны.
Ниже расположены флажки списка техник Analyze a Pass можно выбрать только одну технику, а в режиме Compare Passes – соответственно несколько.p0. Еще ниже имеется выпадающий список для выбора анализируемого типа шейдера: вершинного или пиксельного. Пиксельный шейдер эффекта BlackAndWhite.fx, содержащий единственный оператор return, вряд ли нуждается в какой-либо оптимизации, поэтому мы будем анализировать вершинный шейдер. Наконец, в самом низу вкладки BlackAndWhite.fx находятся списки флажков Drivers и , позволяющие выбрать версии драйверов ForceWare и графические процессоры, на которых будет эмулироваться выполнение эффекта.
Указав всю требуемую информацию можно приступать к собственно исследованию производительности вершинного шейдера. Для анализа эффекта с учетом выбранных параметров необходимо нажать кнопку Run на панели в верхней части окна. Для просмотра результатов анализа в виде таблицы нажмите кнопку Table, после чего вы увидите информацию аналогичную рисунку 5.10. Как видно, при использовании видеокарты NVIDIA GeForce 7800 GTX с драйверами ForceWare 162.03 выполнение вершинного шейдера будет длиться 7 тактов, а всего за одну секунду всеми вершинными процессорами этой видеокарты будет обработано 491.000.000 вершин. Но следует учитывать, что эта астрономическое число отражает пиковую производительность без учета быстродействия остальных компонентов видеокарты, так что
Примечание
Кнопки Precision и Branches позволяют просмотреть более подробный отчет с учетом различной точности вычислений и сценариев выполнения условных переходов. Но в настоящее время эти возможности являются для нас избыточными: эффект BlackAndWhite.fx не содержит условных переходов, а точность вычислений вершинных шейдеров всегда равна 32-бита.
Кнопка Graph позволяет в наглядной форме сравнить производительность шейдера на разных видеокартах. Перед выполнением сравнения необходимо составить перечень интересующих вас видеокарт и драйверов. Для этого откройте командой главного меню Tools | Settings... диалоговое окно Settings с настройками FX Composer и в древовидном списке в левой части окна выделите узел Enviroment | ShaderPerf. Затем в левой части окна щелкните на кнопке "... " напротив опции DefaultSelectedGPUs и в появившемся диалогов окне Selected Default GPUs установите флажки напротив интересующих вас графических процессоров (рисунок 5.11). Например, если вы разрабатываете приложение для видеокарт семейства GeForce 6 и выше, вам следует пометить все семейств NV4x и G7x. Далее аналогичным образом укажите интересующие вас версии драйверов.
(рис 5.11) Диалоговое окно Setting и Select Default GPUs После нажатия OK в во вкладке BlackAndWhite.fx панели появятся флажки, соответствующие указанным графическим процессорам. Выделите флажки и драйверов, интересующие вас в данный момент, и нажмите кнопку Graph в верхней части окна. На экране появится диаграмма производительности вершинного шейдера на выбранных графических процессорах (рисунок 5.12).
(рис 5.12) Производительность шейдера на различных графических процессорах По диаграмме легко можно оценить, будет ли являться производительность вершинного шейдера ограничивающим фактором. Допустим, наша сцена содержит 100.000 треугольников. Значит, пренебрегая прочими факторами можно предположить, что визуализация данного сцены на самой медленной видеокарте семейства GeForce6 (GeForce 6200) будет выполняться с частотой 150.000.000 / 100.000 = 1.500 кадров секунду. Таким образов в данном конкретном случае вершинный шейдер не станет узким местом даже на самых дешевых видеокартах семейства GeForce6.
Еще одной интересной возможностью панели является просмотр скомпилированного промежуточного кода шейдера. Это очень мощная функциональность, позволяющая оценить качество сгенерированного ассемблерного кода и увидеть ошибки, допущенные компилятором. Возможно, последнее утверждение покажется вам несколько надуманным, но это действительно так. Компилятор HLSL в настоящее время значительно менее отлажен по сравнению с теми же компиляторами C++, а сами графические процессоры содержат множество ограничений. Например, компилятор может сгенерировать несколько отличный код от ожидаемого вами, в результате чего выполнение эффекта будет сопровождаться нежелательными эксцессами наподобие переполнения разрядной сетки в ходе промежуточных расчетов. В будущем вы практически гарантированно столкнетесь с подобными аномалиями, причем, чем меньшими возможностями обладает используемая видеокарта, тем более вероятно возникновение проблем.
Примечание
Это особенно актуально при написании пиксельных шейдеров для GeForce3 и GeForce4, регистры которых имеют ограниченную разрядность и рассчитаны на работу с числами в диапазоне от -1 до +1.
Чтобы увидеть ассемблерный код шейдера, сгенерированный компилятором HLSL, достаточно нажать кнопку ASM после чего во вкладке Editor появится вкладка BlackAndWhite_Asm.txt со следующим текстом:
################################################################ # Technique: BlackAndWhiteFill # Pass: p0 ################################################################ // // Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 vs_1_1 def c0, 0.333333343, 1, 0, 0 dcl_position v0 dcl_color v1 add r0.w, v1.y, v1.x add r0.w, r0.w, v1.z mul oD0.xyz, r0.w, c0.x mad oPos, v0.xyzx, c0.yyyz, c0.zzzy mov oD0.w, v1.w // approximately 5 instruction slots used
Но сейчас мы можем почерпнуть из этого отчета разве то, что он был создан компилятором Microsoft (R) D3DX9 версии 9.12.589.0000, причем в качестве промежуточного языка был использован . Все остальное для нас не более чем китайская грамота, так что для оценки качества сгенерированного кода нам потребуется ознакомиться с азами языков .
На этом первое знакомство с NVIDIA FX Composer 2. 0 можно считать оконченным.
Языки семейства предназначены для программирования виртуальных вершинных процессоров, причем каждому языку соответствует своя модель виртуального вершинного процессора. Вообще в основу каждой модели виртуального вершинного процессора положен вполне определенный реальный вершинный процессор, однако на практике данная особенность не играет особой роли, ведь код для виртуального процессора все равно компилируется драйвером видеокарты в машинный код текущего процессора. Кроме того, все эти модели виртуальных процессоров построены на общих принципах. Так что вы вполне можете думать о языках r как о вариациях на тему языка IL, заточенных под
Любой виртуальный вершинный процессор содержит набор регистров, напоминающих регистры SSE процессоров архитектуры x86. Большинство регистров (но отнюдь не все) являются векторными регистрами, рассчитанными на хранение четырехмерных векторов в формате с плавающей точкой. Разрядность регистров может быть произвольной, но на практике обычно равна 128-ми битам, то есть на каждый компонент вектора, как правило, отводится 32-бита (рисунок 5.13).
(рис 5.13) 128-битный векторный регистр (32 бита на компонент).Данные, с которыми работает виртуальный процессор можно разделить на три большие группы (рисунок 5.14):
(рис 5.14) . Структура графического процессора Набор регистров существенно варьируется от версии к версии, поэтому чтобы сделать материал менее запутанным мы сосредоточимся исключительно на версии 1.1.
Примечание
Виртуальные процессоры и практически один в один повторяют архитектуру вершинных и пиксельных процессоров видеокарты GeForce3 (NV20) .
Регистры исходных данных виртуального процессора делятся на две подгруппы (рисунок 5.15).
v0, v1… v15 с информацией, специфичной для текущей вершины ( Input Registers ).c0, c1 … c95 ... cN с информацией, общей для всех вершин ( Constant Float Registers ).Оба типа регистров являются векторными регистрами, в которых хранятся 4 компонента с плавающей точкой. У всех современных графических процессоров разрядность этих регистров равна 128 бит, т.е. каждый компонент вектора является 32-х разрядных числом с плавающей точкой. Но эта разрядность не является фиксированной и может измениться у будущих . Кроме того, различные могут несколько по-разному обрабатывать такие граничные ситуации как переполнение или деление на нуль, поэтому код шейдеров не должен быть заточен под фиксированную разрядность 32-бита на компонент.
В регистры v0, v1 … v15 заносятся атрибуты, специфичные для каждой вершины: информация о координатах вершины, ее цвете, размере точки, текстурных координатах и т.п. Связывание входного регистра с конкретным вершинным атрибутом осуществляется посредством специальных директив вида dclxxx, некоторые из которых перечислены в таблице 5.1. Компилятор HLSL отображает входные параметры вершинного шейдера на регистры v0 ... v15, таким образом, директивы dcl_xxx аналогичны семантикам входных параметров вершинного шейдера. В частности, если вы внимательно посмотрите на ассемблерный код, полученный посредством FX Composer в конце раздела 5.2.4, то обнаружите две директивы:
dcl_position v0 dcl_color v1
Совершенно очевидно, что эти строки являются результатом компиляции структуры входной информацией вершинного шейдера:
struct VertexInput
{
float3 pos : POSITION; float4
color : COLOR;
};
| Директива | Аналогичная семантика HLSL | Информация, которая будет заноситься во входной регистр |
|---|---|---|
dcl_position |
POSITION |
Координаты вершины |
dcl_color |
COLOR |
Информация о цвете вершины |
dcl_psize |
PSIZE |
Размер визуализируемой |
dcl_texcoord |
TEXCOORD |
Текстурные координаты вершины |
(рис 5.15) Регистры исходных данных Константные регистры, как следует из названия, используются для хранения различных констант. Задание константы осуществляется посредством директивы def:
def {константный регистр}, {компонент 0}, {компонент 1}, {компонент 2}, {компонент 3}
Например, следующая директива перед началом обработки вершин шейдером заносит в константный регистр c0 вектор (0.333333343, 1, 0, 0).
def c0, 0.333333343, 1, 0, 0
Код данной директивы взят из ассемблерного листинга эффекта черно-белой закраски. Нетрудно догадаться, что компонент вектора со значением 0.333333343 впоследствии используется компилятором для вычисления выражения (input.color.r+input.color.g+input.color.b)/3.0. Так же логично предположить, что компонент со значением 1 используется при добавлении четвертого компонента к координатам вектора:
output.pos = float4(input.pos, 1.0f);
Количество константных регистров зависит от видеокарты, однако поддержка видеокартой гарантирует наличие не менее 96 константных регистров. Точное количество константных регистров вершинных процессоров текущей видеокарты может быть получено посредством метода GraphicsDeviceCapabilities.MaxVertexShaderConstants класса GraphicsDevice. В таблице 5.2 приведена информация о количестве константных регистров у наиболее распространенных видеокарт.
Примечание
Число физических константных регистров может несколько превышать значение, возвращаемое GraphicsDeviceCapabilities.MaxVertexShaderConstants. Дополнительные регистры, как правило, используются драйвером для внутренних нужд, например, при эмуляции фиксированного графического конвейера из прошлых версий DirectX.
|
Количество константных регистров у вершинных процессоров |
|---|---|
NV2x |
96 |
NV3x |
256 |
NV4x |
256 |
G7x |
256 |
R2xx |
192 |
R3xx |
256 |
R4xx |
256 |
Intel GMA |
8192 |
Intel GMA 3000 |
8192 |
Забегая вперед, стоит отметить, что значения константных регистров могут изменяться приложением, что делает их идеальным средством для передачи параметров в вершинный шейдер (см. раздел 5.4).
Регистры общего назначения используются для хранения операндов и результатов команд, а так же для адресации массива константных регистров. Виртуальный вершинный процессор предполагает наличие двух типов временных регистров (рисунок 5.16):
r0, r1 … r11 для хранения промежуточных результатов вычислений ( Temporary Registers ).a0, используемый для косвенной адресации константных регистров ( Address Register ).
(рис 5.16) Регистры общего назначения Временные регистры являются аналогом регистров SSE процессоров архитектуры x86, и поэтому вряд ли нуждаются в каких-либо комментариях. Адресный регистр хранит смещение, которое может применяться при обращении к константному регистру в режиме косвенной адресации. Например, если адресный регистр содержит значение 2, то при обращении в режиме косвенной адресации к константному регистру c5 в реальности произойдет обращение к регистру c7. В ассемблерном коде такое обращение будет выглядеть как c[a0.x + 5] .
Примечание
Примитивная косвенная адресация, используемая в шейдерах, довольно сильно напоминает косвенную адресацию первых программируемых калькуляторов вроде HP-11C.
Данная группа регистров используется для передачи результатов работы вершинного шейдера дальше по графическому конвейеру: сначала полученные результаты интерполируются вдоль поверхности примитива, а затем поступают на вход пиксельного шейдера. Так как первые версии вершинных и пиксельных шейдеров предполагалось применять только для визуализации примитивов с использованием незначительных вариаций классических алгоритмов, все выходные регистры являются специализированными и предназначены для хранения определенного типа данных (рисунок 5.17):
oPos трансформированных координат вершины ( Position Register ).oD0 и oD1 цветов вершины ( Color Registers ). Изначально предполагалось, что регистр oD0 будет использоваться для хранения основного цвета вершины, а регистр oD1 - цвета блика.oT0, oT1, oT2, oT3, oT4, oT5, oT6, oT7 текстурных координат вершины ( Texture Coordinate Register ). Таким образом, с каждой вершиной может быть связано до восьми текстурных координат.oPts размера точки ( Point Size Register ). Используется для коррекции размера точки при визуализации массива точек ( PrimitiveType.PointList ).oFog, задающий плотность тумана в окрестностях данной вершины ( Fog Register ).
(рис 5.17) Выходные регистры Первые в точности следовали спецификации , поэтому их выходные регистры были жестко заточены под хранение специализированных типов данных. Соответственно, любая попытка использования данных регистров не по прямому назначению была чревата различными побочными эффектами вроде переполнения или потери точности. Но по мере развития графических процессоров данная специализация становилась все более условной: все современные начиная с NV4x и R5xx содержат универсальные выходные регистры, на которые отображаются выходные регистры виртуального вершинного процессора.
Вероятно, вы уже обратили внимание, что названия и назначения выходных регистров удивительно напоминают семантики HLSL выходных данных вершинного шейдера. Это не случайно: исторически семантики предназначались именно для привязки выходных данных вершинного шейдера HLSL к регистрам виртуального вершинного процессора (таблица 5.3). И только потом, по мере развития их функция свелась к банальной стыковке между собой выходных данных вершинных и входных данных пиксельных шейдеров. Таким образом, для написания на языке HLSL качественных шейдеров для старых очень важно представлять себе архитектуру виртуального вершинного процессора и его физическую реализацию.
| Регистр | Семанитики |
|---|---|
|
POSITION |
oD0 |
COLOR, COLOR0 |
oD1 |
COLOR1 |
oT0 |
TEXCOORD, TEXCOORD0 |
0T1 |
TEXCOORD1 |
oT2 |
TEXCOORD2 |
oT3 |
TEXCOORD3 |
oT4 |
TEXCOORD4 |
oT5 |
TEXCOORD5 |
oT6 |
TEXCOORD6 |
oT7 |
TEXCOORD7 |
oPts |
PSIZE |
oFog |
|
Возможно, сейчас у вас буквально рябит в глазах от обилия регистров вершинного процессора. В этом нет нечего страшного, ведь мы вовсе не собираемся учиться писать вершинные шейдеры на ассемблере. Нам требуется всего лишь научиться сносно читать ассемблерный код шейдера, сгенерированный компилятором HLSL, используя в качестве шпаргалки данный материал. В общем, научиться действовать по принципу "чукча не писатель, чукча читатель ".
Получив представление о регистрах, давайте познакомимся с форматом команд языка . Если вы уже сталкивались с программированием процессоров архитектуры x86, то заметите некоторое сходство между ассемблерными командами языка и командами процессоров x86. Все команды вершинного процессора имеют следующий синтаксис:
op dst, src0 [, src1] [, src2]
где
op - идентификатор команды.dst - регистр назначения, в который записываются результаты команды.src0, src1, src2 - регистры-операнды с исходными данными. Количество операндов варьируется от команды к команде.Важной особенностью языка является жесткое ограничение на размер вершинного шейдера: число ассемблерных команд не может превышать 128. Это не так уж и много, поэтому разработчикам нередко приходится бороться буквально за каждую команду, чтобы втиснуть алгоритм в прокрустово ложе вершинного процессора.
Чтобы получить представление о функциональных возможностях виртуального вершинного процессора, рассмотрим некоторые часто используемых команды вершинных шейдеров.
MOV - Пересылка данных
Начнем с x86:
mov dst, src
где
dst - регистр приемник;src - регистр источник.Следующая команда копирует содержимое константного регистра c2 во временный регистр r5:
mov r5, c2
Язык позволяет обращаться к отдельным компонентам векторного регистра: для этого после названия регистра необходимо поставить точку ". " и перечислить названия компонентов, к которым вы собираетесь обратиться. При этом допускается переставлять компоненты местами и многократно дублировать один и тот же компонент вектора. В общем, синтаксис очень напоминает синтаксис языка HLSL для доступа к отдельным компонентам вектора. Так же имеется возможность изменить знак компонентов регистра перед передачей в команду. Например, следующая команда занесет в регистр r5 лишь первые три компонента регистра c2 с измененными знаками, при этом первые два компонента будут переставлены местами:
mov r5.xyz, -c2.yxz
ADD – Сложение
Команда add выполняет сложение двух регистров:
add dst, src0, src1
где
dst - регистр приемник, в который заносится результат.src0 - регистр с первым слагаемым.src1 - регистр со вторым слагаемым.Действие команды можно описать выражением: . Например, следующая команда выполняет сложение содержимого регистров v0 и c0 и заносит результат в регистр r0 add r0, v0, c0.
SUB – Вычитание
Данная команда выполняет вычитание двух регистров:
sub dst, src0, src1
где
° dst = src0 - src1
Примечание
При описании команд, смысл аргументов которых вполне очевиден, я сразу буду приводить алгоритм их работы без расшифровки назначения аргументов.
К примеру, следующая команда вычитает из компонентов x и y регистра v2 компоненты z и w регистра v3 и заносит результат в компоненты y и z регистра r1:
sub r1.yz, v2.xy, v3.zw
MUL – Умножение
Перемножает два регистра:
mul dst, src0, src1
где
dst = src0 • src1
Следующая команда умножает все компоненты регистра c0 на компонент x регистра c1 и заносит результат в регистр r0:
mov r0, c0, c1.xxxx
MAD - умножение и сложение
Перемножает два регистра и прибавляет к полученному результату содержимое третьего регистра:
mad dst, src0, src1, src2
Где
dst = src0 • src1 + src2
Стоит отметить, что данная команда обычно выполняется значительно быстрее комбинации команд mul и add. Ниже приведен пример умножения регистра c0 на v1 с прибавлением к результату значения вектора c1. Результат заносится в регистр r0:
mad r0, c0, v1, c1
DP3 - скалярное произведение трехмерных векторов
Вычисляет скалярное произведение компонентов x, y, z двух векторов:
dp3 dst, src0, src1
Где
dst.xyzw = src0.x • src1.x + src0.y • src1.y + src0.z • src1.z
Например, следующая команда занесет во все компоненты регистра r1 результат скалярного произведения первых трех компонентов регистров v0 и r0:
dp3 r1, v0, r0
DP4 - скалярное произведение четырехмерных векторов
Вычисляет скалярное произведение содержимого двух регистров:
dp4 dst, src0, src1
где
dst.xyzw = src0.x • src1.x + src0.y • src1.y + src0.z • src1.z + src0.w • src1.w
Следующая команда занесет в первый компонент регистра r1 результат скалярного произведения всех четырех компонентов регистров v0 и r0:
dp4 r1.x, v0, r0
FRC - вычисление {x}
Возвращает дробную часть компонентов вектора:
frc dst, src0
где
dst = {src0}
Примечание
Обратите внимание, что {2.3}=3, но {- 2.3}=0.7
Результат может быть занесен только в компоненты y или xy регистра-приемника (запись в компонент x без y недопустима). Следующая команда заносит в компоненты xy регистра r0 дробные части соответствующих компонентов регистра r1: .
Команда в действительности является макрокомандой, которая разбивается на три команды. Принимая во внимание жесткие ограничения на длину вершинного шейдера, это весьма немаловажный нюанс. Многие вершинные процессоры поддерживают ее на аппаратном уровне, в результате чего при компиляции ассемблерного кода в микрокод вершинного процессора развернутый макрос , поэтому даже если текущий вершинный процессор аппаратно поддерживает команду , при подсчете длины шейдера она все равно будет засчитана за три команды.
RCP - вычисление 1/x
Выполняет деление единицы на скалярный аргумент:
rcp dst, src0
где
dst.xyzw=1/src0
src должен быть скалярной величиной. Если src0 равен 0, в заносится максимальное значение с плавающей точкой, поддерживаемое данным (обычно порядка $$10^38$$ ).
Примечание
начиная с NV3x поддерживают значения Floating-Point Specials: -Inf (минус бесконечность), +Inf (бесконечность со знаком плюс), NaN (результат не определен) и т.п. Соответственно, на NV3x и последующих процессорах результат 1.0/0.0 равен +Inf.
Следующая команда вычисляет 1/r1.w и заносит результат в r0.w:
rcp r0.w, r1.w
EXPP - вычисление 2x с точностью 2-3 знака после запятой
Возводит 2 в степень скалярного аргумента с точностью 2-3 знака после запятой:
expp dst, src0
где
dst - регистр приемник, в который заносится результат возведения в степень и побочные результаты. В компонент x заносится результат возведения в степень целочисленной части аргумента, в компонент y дробная часть аргумента, компоненту z присваивается результат возведения в степень, а компоненту w единица.src0 - степень, в которую возводится 2. Должна быть скалярной величиной.Алгоритм
Примечание
Побочные результаты работы команды часто используются, например, для нахождения дробной части числа.
Значение компонента z вычисляется с точностью 10 бит (2-3 знака после запятой).
Следующая команда вычисляет $$2^rl.w$$ и заносит его в r0.z. Остальные компоненты регистра не изменяются благодаря использованию маски .z.
rcp r0.z, r1.w
EXP - вычисление 2x с точностью 6-7 знаков после запятой
Возводит 2 в степень скалярного аргумента с точностью 21 бит (6-7 знаков после запятой):
expp dst, src0
где
Данная команда в действительности является макрокомандой, транслируемой в 10 инструкций. Поэтому перед ее использованием следует хорошенько подумать, а действительно ли вам так сильно необходима большая точность, чем у команды expp.
Следующая команда вычисляет 2^r1x и заносит его в r0.y:
expp r0.y, r1.x
MIN - определение минимальных значений компонентов
Выполняет покомпонентное сравнение двух аргументов и возвращает компоненты, имеющие минимальное значение.
min dst, src0, src1
где
dst - регистр-приемник, в который заносятся компоненты с минимальными значениями.src0 и src1 - вектора, компоненты которых сравниваются.Алгоритм работы:
dst = src0; if (src0.x > src1.x) dst.x=src1.x; if (src0.y > src1.y) dst.y=src1.y; if (src0.z > src1.z) dst.z=src1.z; if (src0.w > src1.w) dst.w=src1.w;
Следующая команда сравнивает компоненты w регистров r4 и r0, и заносит результат в компоненты x и y регистра r3: min r3.xy, r4.w, r0
MAX - определение максимальных значений компонентов
Выполняет покомпонентное сравнение двух аргументов и возвращает компоненты, имеющие максимальное значение.
max dst, src0, src1
где
dst - регистр-приемник, в который заносятся компоненты с минимальными значениями.src0 и src1 - вектора, компоненты которых сравниваются.Алгоритм работы:
dst = src0; if (src0.x > src1.x) dst.x=src0.x; if (src0.y > src1.y) dst.y=src0.y; if (src0.z > src1.z) dst.z=src0.z; if (src0.w > src1.w) dst.w=src0.w;
Следующая команда сравнивает все компоненты регистров r4 и r2, и заносит результат в регистр r1: max r1, r4, r2
SGE - сравнение "если больше или равно"
Покомпонентно сравнивает содержимое двух регистров и возвращает 1, если компонент первого аргумента больше второго или равен ему, и 0 в противном случае:
sge dst, src0, src1
где
dst - регистр приемник, в который заносится вектор с результатами сравнения.src0 и src1 - вектора, компоненты которых требуется сравнить.Алгоритм работы:
dst.xyzw = 0; if (src0.x >= src1.x) dst.x = 1; if (src0.y >= src1.y) dst.y = 1; if (src0.z >= src1.z) dst.z = 1; if (src0.w >= src1.w) dst.w = 1;
SLT - сравнение "если меньше"
Покомпонентно сравнивает два регистра и возвращает 1, если компонент первого аргумента меньше второго, и 0 в противном случае:
slt dst, src0, src1
где
dst - регистр приемник, в который заносится вектор с результатами сравнения.src0 и src1 - вектора, компоненты которых требуется сравнить.Алгоритм работы:
dst.xyzw = 0; if (src0.x< src1.x) dst.x = 1; if (src0.y < src1.y) dst.y = 1; if (src0.z < src1.z) dst.z = 1; if (src0.w < src1.w) dst.w = 1;
В принципе, знания вышеперечисленных команд вполне достаточно для чтения кода простых вершинных шейдеров. А при столкновении с незнакомыми командами вы всегда сможете найти их описание в документации DirectX.
Примечание
Чтобы быстро найти информацию о незнакомой команде языка откройте документацию по "неуправляемому " DirectX (Start | All Programs | Microsoft DirectX SDK | DirectX Documentation | DirectX SDK Documentation for C++) и введите во вкладке Index название интересующей вас команды. А для быстрого доступа к описанию всех команд и регистров интересующей вас версии наберите во вкладке Index нужный идентификатор: vs_1_1, vs_2_0, vs_2_x или vs_3_0.
Ну что ж, настало время попрактиковаться в использовании полученных знаний на практике. В качестве упражнения мы проанализируем ассемблерный код нашего старого знакомого – эффекта вершинной закраски BlackAndWhite.fx. Чтобы облегчить поиск соответствий между вершинным шейдером на языке HLSL и его ассемблерным кодом, я еще раз приведу код эффекта и листинг ассемблерного кода вершинного шейдера, полученного посредством FX Composer 2.0. Итак, код эффекта:
struct VertexInput
{
float3 pos : POSITION;
float4 color : COLOR;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
float luminance = (input.color.r+input.color.g+input.color.b)/3.0;
output.color.r = luminance;
output.color.g = luminance; output.color.b = luminance;
output.color.a = input.color.a;
return output;
}
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique BlackAndWhiteFill
{
pass p0
{
VertexShader = compile vs_1_1 MainVS(); PixelShader = compile
ps_1_1 MainPS();
}
}
И ассемблерный код вершинного шейдера:
vs_1_1 def c0, 0.333333343, 1, 0, 0 dcl_position v0 dcl_color v1 add r0.w, v1.y, v1.x add r0.w, r0.w, v1.z mul oD0.xyz, r0.w, c0.x mad oPos, v0.xyzx, c0.yyyz, c0.zzzy mov oD0.w, v1.w
Первая директива ассемблерного кода задает версию языка , на котором написан эффект. В настоящее время поддерживаются 4 директивы c интуитивно понятными названиями, каждая из которых соответствует определенной версии языка . Нетрудно догадаться, что ассемблерный код нашего шейдера написан на версии 1.1.
Ниже расположена директива def, которая заносит в константный регистр c0 вектор (0.333333343, 1, 0.0), компоненты которого будут использоваться инструкциями вершинного шейдера. Данная операция выполняется один раз перед началом визуализации с использованием шейдера и поэтому не влияет на производительность.
Следующие две директивы dclposition и dclcolor указывают, что координаты текущей вершины будут помещаться во входной регистры v0, а цвет вершины - в регистр v1.
Далее начинается собственно код вершинного шейдера. Первые две команды выполняют сложение трех цветовых компонентов цвета вершины и заносят результат в компонент w регистра r0. Третья команда умножает полученную сумму на содержимое компонента x регистра c0, равного 0.333333343, то есть фактически сумма делиться на 3. Итоговый результат заносится в компоненты x, y, z выходного регистра цвета oD0. Таким образом, первые три команды вершинного шейдера соответствуют следующему коду HLSL:
output.color.rgb = (input.color.r+input.color.g+input.color.b)/3.0;
Как видно, умный компилятор HLSL избавился от лишней временной переменной , а так же заменил присвоение значений трем компонентам r, g, b одним скалярным присваиванием. Но заменить два сложения и унижение
Продолжим анализ кода вершинного шейдера. Следующая команда mad может ввести начинающего разработчика в замешательство. Откуда она взялась, ведь HLSL -код вершинного шейдера не содержит чего-либо подобного? И что же она выполняет? Давайте немного подумаем. Данная команда madd использует в качестве аргументов координаты вершины из регистра v0 и компоненты константного регистра c0, а результат заносится в выходной регистр , соответствующий выходным координатам вершины. Попробуем подставить в выражение, вычисляемое командой mad значение компонентов константного регистра c0:
oPos = v0.xyzx * c0.yyyz + c0.zzzy = v0.xyzx * (1, 1, 1, 0) + (0, 0, 0, 1) = (v0.xyz, 0) + (0, 0, 0, 1)
Таким образом, команда mad соответствует нижеприведенной строке HLSL -кода:
output.pos = float4(input.pos, 1.0f);
Получается, компилятор нашел изящный способ реализации этого HLSL -кода: вместо прямолинейного кода из двух команд
mov oPos.xyz, v0.xyz mov oPos.w, c0.y
компилятор обошелся единственной командой mad.
Наконец, последняя команда mov заносит в альфа-канал выходного цветового регистра oD0 значение альфа-канала цвета вершины, т.е. соответствует строке
output.color.a = input.color.a
Оптимизируем вершинный шейдер
Итого код шейдера насчитывает 5 инструкций, и как мы выяснили в разделе 5.2.4, его обработка на и G7x обработка занимает 7 тактов. Настало время подумать, как можно улучшить производительность эффекта. Обратим внимание на два факта:
HLSL не смог заменить сложение компонентов цвета с последующим делением на 3 скалярным произведением. Значит, имеет смысл попробовать переписать код эффекта с использованием встроенной в HLSL функции dot.alpha blending ). Соответственно, оно абсолютно некритично к значению альфа-канала. Однако, как мы выяснили, присвоение значения альфа-каналу выливается в дополнительную команду. Поэтому данное присвоение можно безболезненно убрать, сократив код эффекта на одну команду.Код эффекта, написанный с учетом вышеуказанных данных рекомендацией, находится в листинге 5.5:.
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Значение альфа-канала не используется приложением,
поэтому нам все равно, что будет в него
// занесено в компонент a
output.color.rgba = dot(input.color.rgba, 1.0/3.0);
return output;
}
Ассемблерный данного эффекта, полученный посредством FX Composer 2.0, приведен ниже:
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 vs_1_1 def c0, 0.333333343, 1, 0, 0 dcl_position v0 dcl_color v1 dp4 oD0, v1, c0.x mad oPos, v0.xyzx, c0.yyyz, c0.zzzy // approximately 2 instruction slots used
Как видно, компилятор теперь использует инструкцию скалярного произведения dp4, а инструкция mov с копированием альфа-компонента цвета исчезла. Таким образом, анализ ассемблерного кода позволил нам внести в эффект небольшие косметические преобразования и сократить размер ассемблерного кода в 2.5 раза (с 5 до 2 команд). А выполнив повторный анализ производительности эффекта (нажав кнопку Run на панели ) мы увидим, что время обработки вершины одним вершинным процессором сократилось с 7 до 3-х тактов, то есть в 2.3 раза. И если раньше видеокарта GeForce 7800 GTX могла обработать за 1 секунду 491.000.000 вершит, то теперь теоретическая производительность шейдера достигла немыслимого темпа 1.146.000.000 вершин в секунду.
Предположим, что нам необходимо написать эффект, моделирующий визуализацию поверхности сквозь цветное стекло, пропускающее лишь часть света. Прозрачность стекла будет задаваться тремя коэффициентами, лежащими в диапазоне [0..1] и указывающими прозрачность стекла для красного, зеленого и синего компонентов цвета объекта. Если коэффициент равен 1, то стекло пропускает данный компонент цвета без изменений, если 0 - вообще не пропускает, а при промежуточных значениях 0..1 ослабляет яркость цветового компонента по мере уменьшения коэффициента. Итоговый цвет объекта определяется с использованием следующей формулы:
$$c_r=k_r-o_r c_g=k_g-o_g c_b=k_b – o_b $$где
Данные выражения очень легко реализуются в вершинном шейдере:
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
// Определяем коэффициенты пропускания разных цветов
const float4 filter = float4(0.2, 0.8, 0.5, 1.0);
// Вычисляем итоговый цвет вершин, используя векторную
операцию умножения
output.color = input.color * filter;
return output;
}
Данный вариант отлично работает в случае одного фиксированного фильтра. Но что делать, если в процессе работы приложения фильтр постоянно меняется? Теоретически, можно попробовать динамически генерировать код шейдера, задавая значение константы filter налету. Однако такой подход имеет ряд существенных недостатков: компиляция эффекта и загрузка его в занимают заметное время, что неминуемо окажет отрицательное влияние на производительность приложения. Кроме того, такие динамически генерируемые эффекты очень трудоемко сопровождать и отлаживать.
Поэтому разработчики языка HLSL предусмотрели специальный механизм для быстрого внесения изменений в эффекты "налету ". Техника очень проста: если при объявлении глобальной переменной указать ключевое слово uniform, то эта переменная будет доступна и прикладной программе, использующей эффект. Например, следующий код объявляет глобальную переменную filter, значение которой будет задаваться приложением:
uniform float4 circleColor;
Параметру можно указать значение по умолчанию, которое будет ему присваиваться сразу после загрузки эффекта из файла. Например:
uniform float4 circleColor = float4(0.5, 1.0, 0.8, 1.0);
Впрочем, ключевое слово uniform предполагается по умолчанию, поэтому его обычно не указывают -любая глобальная переменная является uniform -переменной. Антиподом uniform является ключевое слово static, которое скрывает глобальную переменную от программы. Например:
// Переменная circleColor скрыта от прикладной программы static float4 circleColor=float4(0.5, 1.0, 0.8, 1.0);
Примечание
Ключевое слово static так же применяется для объявления статических локальных переменных функции. В этом случае, его использование полностью аналогично языку C# за исключением маленького нюанса: при выходе из шейдера содержимое статических переменных теряется.
Никогда не забывайте указывать ключевое слово static для констант. Так как константы в отличие от входных параметров никогда не изменяются, это позволяет провести ряд дополнительных оптимизаций. Например, компилятор может заранее рассчитать все выражения, содержащие константы.
Реализовать эффект цветного полупрозрачного стекла с использованием параметра не составит труда (листинг 5.6).
// Параметр с коэффициентами прозрачности стекла float4
filter;
struct VertexInput
{
float3 pos : POSITION;
float4 color : COLOR;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
output.pos = float4(input.pos, 1.0f);
output.color = input.color * filter;
return output;
}
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique FilterFill
{
pass p0
{
VertexShader = compile vs_1_1 MainVS();
PixelShader = compile ps_1_1 MainPS();
}
}
Ниже приведен отчет NVIDIA FX Composer 2.0 с ассемблерным кодом вершинного шейдера эффекта, полученный посредством вкладки :
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000
//
// Parameters:
//
// float4 filter;
//
//
// Registers:
//
// Name Reg Size
// ----
// filter c0 1
//
//
// Default values:
//
// filter
// c0 = { 0, 0, 0, 0 };
//
vs_1_1
def c1, 1, 0, 0, 0
dcl_position v0
dcl_color v1
mul oD0, v1, c0
mad oPos, v0.xyzx, c1.xxxy, c1.yyyx
// approximately 2 instruction slots used
В комментариях перед ассемблерным кодом эффекта указано, что эффект содержит один параметр float4 filter и что компилятор отвел для хранения данного параметра константный регистр c0. При этом, так как мы не указали значение по умолчанию для данного параметра, он будет автоматически инициализироваться вектором (0, 0, 0, 0).
Таким образом, изменение значения параметра filter будет сводиться к модификации значения константного регистра c0, не затрагивая собственно код вершинного шейдера.
Примечание
Нетрудно догадаться, что максимальное количество параметров, принимаемых эффектом, ограничено и зависит от числа константных регистров. При этом следует помнить, что константы, используемые в эффекте, тоже неявно помещаются в константные регистры, уменьшая максимально число параметров, которые может принимать эффект.
В XNA Framework параметры эффекта хранятся в коллекции Parameters класса Effect:
public EffectParameterCollection Parameters { get; }
Доступ к элементам данной коллекции возможен как по индексу, так и по идентификатору параметра эффекта. Но на практике обычно используют второй вариант, так как он застрахован от таких непредвиденных ситуаций, как изменение числа параметров эффекта в будущих версиях эффекта:
public EffectParameter this[string name] { get; }
Примечание
Если эффект не содержит параметр с указанным именем, возвращается значение null.
Собственно параметр эффекта инкапсулируется классом EffectParameter, позволяющим читать и изменять значение эффекта посредством разнообразных типизированных методов SetValue и GetValueXXX. Ниже приведены определения некоторых методов GetValueXXX.
// Возвращает значение скалярного параметра HLSL типа float public float GetValueSingle(); // Возвращает значение массива параметров типа float: например, float[10]. Параметр // count указывает число элементов в массиве public float[] GetValueSingleArray(int count); // Возвращает значение параметра, являющегося двухмерным вектором (float2) public Vector2 GetValueVector2(); // Возвращает значение параметра, являющегося массивом двухмерных векторов (float2[]) public Vector2[] GetValueVector2Array(int count); // Возвращает значение параметра, являющегося трехмерным вектором (float3) public Vector3 GetValueVector3(); // Возвращает значение параметра, являющегося массивом трехмерных векторов (float3[]) public Vector3[] GetValueVector3Array(int count); // Возвращает значение параметра, являющегося четырехмерным вектором (float4) public Vector4 GetValueVector4(); // Возвращает значение параметра, являющегося массивом четырехмерным вектором (float4[]) public Vector4[] GetValueVector4Array(int count);
Такое обилие методов обусловлено тем, что с точки зрения XNA Framework параметры HLSL являются просто константными регистрами , содержимое которых можно трактовать по-разному в зависимости от ситуации. Например, значение цвета можно трактовать как четырехмерный вектор, два двухмерных вектора или массив из четырех скалярных элементов.
Примечание
При некорректном обращении к параметру эффекта (например, при попытке записать трехмерный вектор в параметр HLSL, являющийся четырехмерным вектором) генерируется исключение System.InvalidCastException.
Методы SetValueXXX приводить не имеет смысла, так как каждому методу GetValueXXX соответствует свой метод SetValue с аналогичным набором параметров. Например, парой для метода float GetValueSingle() является метод public void SetValue(float value).
В качестве примера использования параметров XNA Framework, в листинге 5.7 приведен код приложения, визуализирующего прямоугольник, видимый через цветное стекло, с возможностью изменения пользователем цвета стекла (рисунок 5.18). Визуализация осуществляется с использованием эффекта, созданного в предыдущем разделе.

(рис 5.7) Визуализация примитива через полупрозрачное стекло (рис 5.18) public partial class MainForm : Form { // Эффект, созданный в разделе 5.4 (листинг 5.6) const string effectFileName = "Data\\FilterFill.fx"; Effect effect = null; // Объект, инкапсулирующий параметр filter эффекта (цвет стекла) EffectParameter filterParam; private void MainFormLoad(object sender, EventArgs e) { // Загружаем и компилируем эффект в промежуточный код CompiledEffect compiledEffect; compiledEffect = Effect.CompileEffectFromFile (effectFileName, null, null, CompilerOptions.None, TargetPlatform.Windows); // Создаем объект эффекта effect = new Effect(device, compiledEffect.GetEffectCode(), CompilerOptions.NotCloneable, null); // Получаем объект EffectParameter, соответствующий параметру filter filterParam = effect.Parameters["filter"]; // Если параметр filter не существует, генерируем исключение Debug.Assert(filterParam != null, effectFileName + " : не найден параметр filter"); } // Обработчик нажатия панели, открывающий на экране диалоговое окно с выбором цвета стекла private void filterPanel_Click(object sender, EventArgs e) { if (colorDialog.ShowDialog() == DialogResult.OK) { // Изменяем цвет панели в соответствии с выбранным цветом filterPanel.BackColor = colorDialog.Color; xnaPanel.Invalidate(); } } private void xnaPanel_Paint(object sender, PaintEventArgs e) { ... // Изменяем значение цвет стекла. Так как в Windows Form значения компонентов цвета находится // в диапазоне 0..255, а в XNA Framework в диапазоне 0..1, нам приходится делить значения // компонентов на 255 filterParam.SetValue(new Vector4((float)filterPanel.BackColor.R / 255.0f, (float)filterPanel.BackColor.G / 255.0f, (float)filterPanel. BackColor.B / 255.0f, 1.0f)); // Визуализируем прямоугольник с использование эффекта effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.TriangleStrip, vertices, 0, vertices.Length - 2); pass.End(); } effect.End(); device.Present(); ... }
В принципе XNA Framework позволяет работать с параметрами с использованием следующего лаконичного синтаксиса:
effect.Parameters["filter"].SetValue(newColor);
Но, не смотря на кажущуюся простоту, эта практика весьма коварна: во-первых, увеличивается вероятность появления синтаксических ошибок в названии параметра, а во-вторых, замедляется выполнение программы, так как при каждом обращении к параметру XNA Framework вынужден выполнять поиск параметра по строке.
Поэтому обработчик события Load один раз выполняется поиск параметра filter, после чего вся работа с ним осуществляется уже посредством экземпляра класса EffectParameter. Во избежание проблем при модификации приложения после получения объекта EffectParameter вызывается метод Debug.Assert с проверкой ссылки на равенство null – гораздо удобнее получить исключение при загрузке приложения рядом с "проблемным методом ", чем где-то глубоко в дебрях приложения спустя несколько минут работы.
Итак, теперь вы уже знакомы с основами языков HLSL и . Настало время опробовать полученные знания в более-менее сложном проекте. Ведь как гласит народная мудрость, теория без практики бесполезна, а практика без теории может быть даже вредна.
В качестве отправной точки для приложения мы возьмем хранитель экрана из 4-й главы и поставим перед собой "сверхзадачу ": реализовать функциональность данного хранителя экрана, используя исключительно вершинные шейдеры. Иными словами, центральный процессор должен будет отсылать на видеокарту только команды "нарисовать диск " и "нарисовать искры ", а всю остальную работу по вращению диска и моделированию полета искр должен выполнять вершинный процессор . Это весьма объемная и нетривиальная задача, поэтому мы разобьем ее на ряд более простых этапов, по мере реализации которых мы продолжим знакомиться с новыми возможностями HLSL и языка .
Код хранителя экрана, выполняющий поворот диска устроен очень просто: сначала приложение вычисляет текущий угол поворота диска, а затем рассчитывает новые координаты каждой вершины диска (листинг 5.8).
// Определяем интервал времени, прошедший с момента
визуализации предыдущего кадра float delta =
(float)(currentTime - lastTime);
// Корректируем угол поворота диска diskAngle
+= diskSpeed * delta;
// Рассчитываем новые координаты вершин диска
diskVertices[0] = new VertexPositionColor
(new Vector3(0.0f, 0.0f, 0.0f),
XnaGraphics.Color.LightGray);
for (int i = 0; i <= slices; i++)
{
float angle = (float)i / (float)slices * 2.0f * (float)Math.PI;
float x = diskRadius * (float)Math.Sin(diskAngle + angle);
float y = diskRadius * (float)Math.Cos(diskAngle + angle);
byte red = (byte)(255 * Math.Abs(Math.Sin(angle * 3)));
byte green = (byte)(255 * Math.Abs(Math.Cos(angle * 2)));
diskVertices[i + 1] = new VertexPositionColor
(new Vector3(x, y, 0.0f), new XnaGraphics.Color(red,
green, 128));
};
Давайте внимательно рассмотрим этот код и прикинем, как его перенести в вершинный шейдер. Логично предположить, что код вне цикла не стоит выносить в вершинный шейдер, а вот собственно код цикла, выполняемый для каждой вершины диска, напротив, является идеальным кандидатом для переноса в вершинный шейдер. Но при этом следует учесть несколько нюансов:
angle для конкретной вершины всегда является константой. Поэтому значение переменной angle разумнее всего один раз рассчитать для каждой вершины и затем передавать в шейдер в качестве параметра.Vertex Shader 1.1 не поддерживает ветвления, поэтому нам придется рассчитывать параметры центральной вершины наравне с остальными, считая что она удалена от центра диска на нуль единиц.На следующем этапе мы должны определиться с информацией, передаваемой в вершинный шейдер и составить небольшую табличку наподобие таблицы 5.4. Информацию общую для всех вершин логично предавать через параметры шейдера, отображаемые на константные регистры. А вот информацию об удалении вершины от начала координат и угле ее локального поворота мы будем передавать через координаты вершины. Возможно, это вам покажется очень странным, но нечего противоестественного в этом нет - в разделе 5.3.1 говорилось, что атрибуты вершинны (координаты, цвет и т.п.) просто отображаются на входные регистры виртуального процессора v0, v1 … v15, а уж как трактовать информацию, хранимую в этих регистрах - это уже дело исключительно вершинного шейдера.
| Описание параметра | Аналогичная переменная из листинга 5.8 | Общий для всех вершин | Место хранения |
|---|---|---|---|
| Угол поворота всех вершин, меняющийся с течением времени | diskAngle |
Да | Входной параметр angle |
| Расстояние текущей вершины от центра диска | diskRadius |
Нет | Координата вершины X |
| Локальный угол поворота текущей вершины | Angle |
Нет | Координата вершины Y |
| Цвет текущей вершины | red/green |
Нет | Цвет вершины |
Прототип вершинного шейдера
В принципе, теперь можно приступать к написанию вершинного шейдера, но мы с этим делом немного повременим. Дело в том, что вершинные шейдеры достаточно капризны и трудоемки в плане отладки, а подобные сложные шейдеры мы еще никогда не писали. Поэтому для начала мы создадим на C# класс DiskEffect, эмулирующий функциональность нашего будущего вершинного шейдера (листинг 5.9). Это позволит нам, если что-то пойдет не так, легко поставить точку останова в коде шейдера и проверить корректность входных параметров или выполнить трассировку "шейдера " по шагам с просмотром состояния
// Эмулятор эффекта вращения диска
static class DiskEffect
{
// Параметр эффекта
public static float angle;
// Вершинный шейдер
// input - входная информация о вершине
// output - выходная информация о вершине
public static void VertexShader(VertexPositionColor[]
input, VertexPositionColor[]
output)
{
// Перебираем все вершины (в коде реального вершинного
шейдера цикла не будет, ведь он
// автоматически будет вызываться для каждой вершины for
(int i = 0; i < input.Length; i++)
{
// Вычисляем итоговый угол поворота вершины. Информация
об углах поворота вершины берется из
// параметра angle и координаты Y
float a = input[i].Position.Y + angle;
// Вычисляем координаты вершины. Расстояние вершины от
центра диска берется из координаты
X output[i].Position.X = input[i].Position.X * (float)Math.Sin(a);
output[i].Position.Y = input[i].Position.X * (float)
Math.Cos(a); output[i].Position.Z = 0;
// Цвет вершины проходит через вершинный шейдер без
изменений output[i].Color = input[i].Color;
}
}
}
Разумеется, применение подобного вершинного шейдера приведет к значительным изменениям в коде примера Ch04\Ex01 (прототипа хранителя экрана из четвертой лекции). Наиболее значимые фрагменты кода нового варианта приложения с подробными комментариями приведены в листинге 5.10.
public partial class MainForm : Form
{
// Обычный эффект для визуализации объектов. Пропускает
через себя информацию о вершинах без
// изменений. Вращение диска осуществляется посредством
класса-эмулятора вершинного шейдера const string
effectFileName = "Data\\ColorFill.fx";
// Число сегментов в диске
const int slices = 64;
// Скорость вращения диска
public const float diskSpeed = 3.0f;
// Радиус диска
public const float diskRadius = 0.018f;
GraphicsDevice device;
PresentationParameters presentParams;
VertexDeclaration diskDeclaration;
// Массив с информацией о вершинах диска
VertexPositionColor[] diskVertices = null;
// Массив с информацией о вершинах диска,
обработанных вершинным шейдеров. Используется
// исключительно для эмуляции работы вершинного шейдера
VertexPositionColor[] transformedDiskVertices = null;
Effect diskEffect = null;
Stopwatch stopwatch; bool closing = false;
private void MainFormLoad(object sender, EventArgs e)
{
// Создаем графическое устройство
device = new GraphicsDevice(GraphicsAdapter.
DefaultAdapter, DeviceType.Hardware,
this.Handle, options, presentParams);
// Декларация формата вершины
diskDeclaration = new VertexDeclaration(device,
VertexPositionColor.VertexElements);
// Создаем массив вершин диска
diskVertices = new VertexPositionColor[slices + 2];
// Создаем массив вершин диска, обработанных
вершинным шейдером (используется при эмуляции
// вершинного шейдера)
transformedDiskVertices = new VertexPositionColor[slices + 2];
// Заносим в массив вершин информацию о вершинах
диска (цвета, углы поворота и расстояния от
// центра)
diskVertices[0] = new VertexPositionColor(new
Vector3(0.0f, 0.0f, 0.0f),
XnaGraphics.Color.LightGray);
for (int i = 0; i <= slices; i++) {
float angle = (float)i / (float)slices * 2.0f *
(float)Math.PI; byte red = (byte)(255 *
Math.Abs(Math.Sin(angle * 3))); byte green = (byte)
(255 * Math.Abs(Math.Cos(angle * 2)));
// Заносим в массив информацию о текущей вершине
diskVertices[i + 1] = new VertexPositionColor(new
Vector3(diskRadius, angle,
0.0f), new XnaGraphics.Color(red, green, 128));
};
// Создаем эффект для визуализации объекта
diskEffect = new Effect(device, compiledEffect.GetEffectCode(),
CompilerOptions.NotCloneable, null);
// Создаем и запускаем таймер
stopwatch = new Stopwatch();
stopwatch.Start(); }
private void MainFormPaint(object sender, PaintEventArgs e)
{
// Вычисляем новый угол поворота диска и присваиваем
его "параметру эффекта "
float time = (float)stopwatch.ElapsedTicks /
(float)Stopwatch.Frequency; DiskEffect.angle =
diskSpeed * time;
// Выполняем "виртуальный вершинный шейдер "
DiskEffect.VertexShader(diskVertices, transformedDiskVertices);
// Задаем декларацию формата вершины
device.VertexDeclaration = diskDeclaration;
// Визуализируем диск
diskEffect.Begin();
for (int i = 0; i < diskEffect.CurrentTechnique.Passes.Count; i++)
{
EffectPass currentPass = diskEffect.CurrentTechnique.Passes[i] ;
currentPass.Begin();
// Используем трансформированные вершины
device.DrawUserPrimitives(PrimitiveType.TriangleFan,
transformedDiskVertices, 0, diskVertices.Length
- 2);
currentPass.End();
} diskEffect.End() ;
device.Present();
}
}
Готовое приложение находится в example.zip с книгой в каталоге Exampes\Ch05\Ex05.
Полноценный эффект
Отладив прототип эффекта можно приступать к реализации настоящего полноценного эффекта на языке HLSL. Используя в качестве шпаргалки листинг 5.9, написание вершинного шейдера не составит труда. Все что от нас требуется - убрать цикл перебора вершин и привести синтаксис в соответствии языку HLSL (листинг 5.11).
// Файл Disk.fx float angle;
struct VertexInput
{
float3 pos : POSITION;
float4 color : COLOR;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
float a = input.pos.y + angle;
output.pos.xy = input.pos.xx * float2(sin(a), cos(a));
output.pos.zw = float2(0.0, 1.0);
output.color = input.color;
return output; }
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique Disk
{
pass p0
{
VertexShader = compile vs11 MainVS(); PixelShader
= compile ps11 MainPS();
} }
Преобразование приложения тоже выполняется тривиально и сводится к удалению вспомогательного кода, эмулирующего вершинный шейдер: необходимо убрать ставшие ненужными класс DiskEffect и массив вершин, обработанных вершинным шейдером ( transformedDiskVertices ). А угол поворота теперь должен присваиваться непосредственно параметру эффекта angle. Основные фрагменты обновленного приложения приведены в листинге 5.12.
public partial class MainForm : Form
{
// Используем новый эффект
const string effectFileName = "Data\\Disk.fx";
Effect diskEffect = null;
// Объект EffectParameter, инкапсулирующий параметр
эффекта angle EffectParameter
angleParam = null;
private void MainForm_Load(object sender, EventArgs e)
{ ...
// Получаем объект EffectParameter, соответствующий
параметру эффекта angle angleParam =
diskEffect.Parameters["angle"];
Debug.Assert(angleParam != null, effectFileName + " :
не найден параметр angle");
}
private void MainForm_Paint(object sender, PaintEventArgs e)
{ ...
float time = (float)stopwatch.ElapsedTicks /
(float)Stopwatch.Frequency;
// Присваиваем угол поворота параметру angle эффекта
angleParam.SetValue(diskSpeed * time);
// Выполняем обычную визуализацию примитива
device.VertexDeclaration = diskDeclaration;
diskEffect.Begin();
for (int i = 0; i < diskEffect.CurrentTechnique.Passes.Count; i++)
{
EffectPass currentPass = diskEffect.CurrentTechnique.Passes[i];
currentPass.Begin();
device.DrawUserPrimitives(PrimitiveType.TriangleFan,
diskVertices, 0, diskVertices.Length –
2);
currentPass.End();
} diskEffect.End();
device.Present(); ...
} }
Как видно, обработчик события Paint лишь вычисляет новый угол поворота вершины и передает его в параметр эффекта шейдера. Собственно вращение вершин диска осуществляется только силами вершинного шейдера без какой-либо помощи со стороны C#- кода.
Анализ ассемблерного кода вершинного шейдера
Закончив создание эффекта самое время ознакомиться с ассемблерным кодом, сгенерированным
//
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000
//
// Parameters:
//
// float angle;
//
//
// Registers:
//
// Name Reg Size
// ----
// angle c0 1
//
//
// Default values:
//
// angle
// c0 = { 0, 0, 0, 0 };
//
vs_1_1
def c1, 0.159154937, 0.25, 0.5, -0.00138883968
def c2, 6.28318548, -3.14159274, -2.52398507e-007, 2.47609005e-005
def c3, 0.0416666418, -0.5, 1, 0 dclposition v0 dclcolor v1
add r0.w, v0.y, c0.x
mad r1.xy, r0.w, c1.x, c1.yzzw
frc r0.xy, r1
mad r0.xy, r0, c2.x, c2.y
mul r0.xy, r0, r0
mad r1.xy, r0, c2.z, c2.w
mad r1.xy, r0, r1, c1.w
mad r1.xy, r0, r1, c3.x
mad r1.xy, r0, r1, c3.y
mad r0.xy, r0, r1, c3.z
mul oPos.xy, r0, v0.x
mov oPos.zw, c3.xywz
mov oD0, v1
// approximately 15 instruction slots used
И что же мы видим? Параметру angle отведен входной регистр c0, но вот регистры c1, c2 и c3 почему-то содержат множество непонятных констант, а компактный код вершинного шейдера превратился в 13 ассемблерных инструкций, которые в действительности транслируются в 15 команд виртуального вершинного процессора. Несовпадение числа инструкций и команд обусловлено макро-инструкцией , разворачиваемой в три команды вершинного процессора. Окинуть одним взглядом структуру данной программы весьма проблематично, поэтому нам придется последовательно проследить выполнение программы по шагам, и попытаться понять, что же они выполняет каждая ее команда.
Примечание
Данный пример наглядно демонтирует, что короткий код вершинного шейдера вовсе не означает малый размер ассемблерной программы, и что ограничение максимальной длины программы в 128 инструкций не так уж и много.
Смысл первой команды весьма очевиден - она вычисляет сумму a = input.pos.y + angle и заносит результат в компонент w временного регистра r0. Следующая команда умножает и складывает полученное значение переменной с "чудными " константами, но ее смысл нам пока не ясен. Логично предположить, что эти действия имеют какое-то отношение к вычислению значения тригометрических функций sin и cos (виртуальный процессор не имеет инструкций для расчета синуса и косинуса). Что ж, давайте просто запишем это выражение как есть, заменив компоненты константных регистров их численными значениями:
Присмотревшись внимательно к константе 0.159154937 мы обнаружим, что это есть нечто иное, как единица деленная на удвоенное число "пи":
$$r1.x=\frac{\alpha}{2\cdot \pi}+0.25\\ r1.y=\frac{\alpha}{2\cdot \pi}+0.5 $$Следующая команда вычисляет дробную часть выражения и заносит ее в регистр r0:
После обработки четвертой команды содержимое регистра r0 преобразится следующим образом:
Нетрудно догадаться, что 3.14159274 - это число "пи", а 6.28318548 - число "пи" умноженное на два:
$$r0.x=2\cdot \pi\cdot \{\frac{\alpha}{2\cdot \pi}+0.25\}-\pi\\ r0.y=2\cdot \pi\cdot \{\frac{\alpha}{2\cdot \pi}+0.5\}-\pi $$На первый взгляд эти формулы могут показаться сущей несуразицей, но поэкспериментировав со значениями r0.y в можно обнаружить, что они обладает двумя важными
a, r0. y всегда находится в диапазоне $$[-\pi, +\pi)$$Следовательно, после выполнения второй, третьей и четвертой команд угол a преобразуется к диапазону $$[-\pi, +\pi)$$, при этом косинус угла остается неизменным. Зачем это надо? Логично предположить, что значение косинуса будет вычисляться путем cos(a) для больших аргументов. Кстати, если бы не это преобразование, то по мере вращения круга точность вычислений косинуса стремительно снижалась и, в конце концов, круг перестал бы корректно вращаться.
C компонентом x регистра r0 все несколько запутаннее:
r0.x находится в диапазоне $$[-\pi, +\pi)$$Очевидно, команды 2-4 используют известное тригонометрическое тождество $$\sin(x) = \cos(x-\frac{\pi}{2})$$. Не заглядывая вперед трудно наверняка сказать, зачем компилятор HLSL выполняет данное преобразования, но с большой долей вероятности можно предположить, что синус будет вычисляться через косинус.
Что ж, давайте введем условные обозначения для углов, преобразованных к диапазону, $$[-\pi, +\pi)$$ и продолжим анализ кода:
$$ax=r0.x=\{\frac{\alpha}{2\cdot \pi\}+0.25}\\ ay=r0.y=\{\frac{\alpha}{2\cdot \pi\}+0.5} $$Пятая команда возводит регистр r0 в квадрат, а шестая умножает и складывает его с константами и заносит результат в регистр r1 :
Шестая команда умножает регистр r1 на $$ax^2$$ и складывает с константой -0.00138883968:
Следующие три команды продолжают выполнение серии последовательных умножений на $$ax^2 $$ и сложений с константами. В результате после выполнения десятой команды в регистре r0 оказывается следующие
Чтобы понять смысл данного выражения раскроем скобки и упорядочим коэффициенты ax и ay по убыванию степени:
Ничего не напоминает? Правильно, это разложение функции cos(x) в ряд Тейлора до члена десятой степени:
Таким образом, ассемблерные команды с пятой по десятую вычисляют косинус угла. Соответственно, после выполнения десятой команды в компоненте y регистра r0 находится косинус угла, а в компоненте x -синус угла (как вы помните $$\sin(a) = \cos(ax), \cos(a) = \cos(ay)$$ ). При этом векторные регистры вершинного процессора позволили компилятору HLSL параллельно рассчитать значения обоих тригонометрических функций.
Точность вычисления cos(x)
Приблизительную оценку аппроксимации функции cos рядом Тейлора из пяти членов можно легко выполнить в том же , построив график модуля разницы между суммой пяти членов ряда Тейлора и встроенной функцией cos (рисунок 5.19). Как видно, по мере приближения модуля угла к $$\pi$$
(рис 5.19) Оценка абсолютной погрешности вычисления косинуса посредством ряда Тейлора в Mathcad Остальной код весьма тривиален. Одиннадцатая команда умножает полученные значения синуса и косинуса на расстояние вершины до центра и записывает результат в компоненты x и y регистра . Двенадцатая команда дописывает в компоненты z и w этого регистра значение 0 и 1. И, наконец, тринадцатая команда записывает в выходной регистр цвета цвет текущей вершины из регистра v1 .
Рисунок 5.20 резюмирует весь вышеприведенный анализ кода, устанавливая соответствие между ассемблерным и HLSL кодом шейдера. Однако следует ясно осознавать, что это всего лишь код для виртуального вершинного процессора, который будет скомпилирован драйвером видеокарты в код для конкретного реального вершинного процессора. А архитектура физического вершинного процессора может иметь множество нюансов. Например, все вершинные процессоры современных видеокарт являются суперскалярными и могут запускать несколько ассемблерных инструкций за такт, в частности вершинные процессоры R3xx и R4xx могут выполнить за один такт одну векторную инструкцию над 1-4 компонентным вектором и одну скалярную инструкцию (если они, разумеется, не зависят друг от друга по данным). Поэтому оптимизирующий компилятор драйвера видеокарты может переставлять инструкции местами для достижения большего параллелизма.
(рис 5.20) Соответствие между листингом Vertex Shader 1.1 и HLSL Кроме того, многие вершинные процессоры имеют расширенный набор инструкций по сравнению со спецификаций . Так вершинные процессоры R2xx могут аппаратно вычислять дробную часть числа, соответственно если драйвер видеокарты в процессе компиляции эффекта в микрокод вершинного процессора обнаружит последовательности инструкций , соответствующих макросу , то он заменит их одной встроенной командой. Другой пример: видеокарты R4xx и выше содержат инструкцию аппаратного вычисления синуса и косинуса угла в диапазоне $$-\pi....+\pi$$ поэтому драйвер автоматически подменит разложение в ряд Тейлора вызовом данных встроенных функций.
Тем не менее, оптимизирующий компилятор драйвера не всесилен, поэтому чем качественнее код и чем меньше явных атавизмов он содержит (вроде вычисления скалярного произведения серией инструкций add и mul вместо единственной инструкции dp4 ), тем вероятней драйвер видеокарты сможет сгенерировать оптимальный код.
Таким образом, при написании эффекта в FX Composer 2.0 в качестве главного критерия оптимальности шейдера должен выступать не промежуточный код на языке , а количество тактов графического процессора, затрачиваемых на обработку одной вершины. В частности на видеокарте GeForce 7800 GTX обработка одной вершины нашим вершинным шейдером занимает 20 тактов, а всего за одну секунду ее вершинный процессор теоретически может обработать 172.000.000 вершин. Анализ ассемблерного кода тоже весьма полезен, но в первую очередь как средство поиска проблемных мест в коде эффекта, нуждающегося в оптимизации. Но при этом не следует забывать об алгоритмической оптимизации приложения на макроуровне, иначе зациклившись на оптимизации нескольких локальных выражений вы рискуете не увидеть за деревьями леса. В частности, в следующем практическом упражнении демонстрируется, как использование знаний школьного курса тригонометрии позволяет значительно повысить производител
ьность приложения.
Практическое упражнение №5.1
Вершинный шейдер, выполняющий вращение диска, рассчитывает для каждой вершины значение тригометрических функций sin и cos . Следовательно, при визуализации множества дисков, содержащих тысячи вершин, нам придется рассчитать для каждого кадра значения тысяч тригонометрических функций. Учитывая, что вычисление тригонометрической функции является весьма трудоемким процессом, данное обстоятельство может негативным образом влиять на производительность.
Примечание
Графические процессоры G8x и R6xx содержат массив универсальных процессоров, которые могут выполнять код как вершинных, так и пиксельных шейдеров. Баланс между процессорами, выполняющих код вершинного шейдера и процессорами, выполняющих пиксельный шейдер, регулируется динамически в зависимости от загруженности соответствующих блоков видеокарты. При этом неоправданно сложный вершинный шейдер неминуемо "оттянет на себя " дополнительное количество универсальных процессоров и замедлит выполнение пиксельных шейдеров.
Обратим внимание на один нюанс. Аргумент функций sin и cos является уникальным для каждой вершины, причем он все время меняется. Но формируется он путем сложения двух компонентов:
float a = input.pos.y + angle;
При этом значение input.pos.y является постоянным для каждой вершины, а значение angle хотя и изменяется, но является общим для всех вершин. Таким образом, значения sin(input.pos.y) и cos(input.pos.y) вполне можно было бы рассчитать заранее в обработчике события Load и передавать в вершинный шейдер как координаты вершины, a sin(angle) и cos(angle) как входные параметры вершинного шейдера. Чтобы это стало возможным, необходимо выразить косинус суммы и синус суммы через синусы и косинусы слагаемых, воспользовавшись известными формулами из школьного курса тригонометрии:
Задание: проведите оптимизацию эффекта примера Ch05\Ex06 , реализовав вычисление тригометрических функций посредством выражения 5.5, и выноса большей части бессмысленных трудоемких расчетов за пределы вершинного шейдера. Используя NVIDIA FX Composer 2.0 , оцените потенциальный прирост производительности (который, скорее всего, окажется более чем трехкратным).
Подсказка
Имея вектор с предварительно рассчитанными значениями тригометрических функций, выражение 5.5 можно вычислить всего при помощи двух действий: по парного перемножения значения тригометрических функций и последующего суммирования результатов. Но так как вторая формула использует вычитание, для реализации ее через сумму вам потребуется передать в эффект значение -sin (angle) . При этом чтобы облегчить работу оптимизирующему компилятору HLSL, входные параметры sin (angle), cos (angle), -sin (angle) желательно передавать в шейдер как один трехмерный вектор.
Если у вас возникнут трудности при выполнении данного задания, вы всегда можете ознакомиться с готовым решением, которое можно найти в каталоге \Examples\Ch05\Ex07 .
Следующий этап - перенос расчета полета искр в вершенный шейдер - является гораздо более сложным и запутанным, поэтому, чтобы восстановить силы мы сделаем небольшой привал и поговорим об особенностях оператора if языка HLSL применительно к профилю vs11 .
Подобно подавляющему большинству языков программирования HLSL содержит конструкцию выбора if :
if (логическое условие)
{
блок 1
}
else
{
блок 2
}
В принципе, на этом раздел можно было бы окончить, если бы не одна маленький нюанс: язык не содержит команд для if , то центральный процессор будет каждый раз выполнять только одну из ветвей блока if , что позволяет значительно сократить объем вычислений и повысить HLSL при использовании профиля vs11 вынужден эмулировать условную конструкцию, генерируя код, выполняющий все ветви оператора с последующим комбинированием результатов. С
оответственно, в HLSL применение оператора if в принципе не может поднять производительность приложения.
В качестве примера попробуем "оптимизировать " вершинный шейдер, выполняющий закраску диска с использованием оператора if . Координаты вершины, распложенной в центре диска всегда неизменны, поэтому многие начинающие разработчики поддаются соблазну попытаться ускорить выполнение эффекта, отказавшись от трудоемкого расчета координат центральной вершины (листинг 5.13).
// Полный текст эффекта и готовое приложение находятся в каталоге Examples\Ch05\Ex08
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
if (input.pos.x != 0)
{
// Если вершина не является центральной, рассчитываем
ее координаты float a = input.pos.y + angle;
output.pos.xy = input.pos.xx \cdot float2(sin(a), cos(a));
}
else
// Если вершина расположена в центре круга, то ее
координаты всегда равны (0.0, 0.0) output.pos.xy = float2(0.0, 0.0);
output.pos.zw = float2(0.0, 1.0);
output.color = input.color;
return output; } Ниже приведен отчет FX Composer с
ассемблерным листингом
кода:
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000
//
// Parameters:
//
// float angle;
//
//
// Registers:
//
// Name Reg Size
// ----
// angle c0 1
//
//
// Default values:
//
// angle
// c0 = { 0, 0, 0, 0 };
//
vs_1_1
def c1, 0.159154937, 0.25, 0.5, -0.00138883968
def c2, 6.28318548, -3.14159274, -2.52398507e-007, 2.47609005e-005
def c3, 0.0416666418, -0.5, 1, 0
dcl_position v0
dcl_color v1
add r0.w, v0.y, c0.x
mad r1.xy, r0.w, c1.x, c1.yzzw
frc r0.xy, r1
mad r0.xy, r0, c2.x, c2.y
mul r0.xy, r0, r0
mad r1.xy, r0, c2.z, c2.w
mad r1.xy, r0, r1, c1.w
mad r1.xy, r0, r1, c3.x
mad r1.xy, r0, r1, c3.y
mad r0.xy, r0, r1, c3.z
mul r0.w, v0.x, v0.x
mul r0.xy, r0, v0.x
slt r0.w, -r0.w, r0.w
mul oPos.xy, r0, r0.w
mov oPos.zw, c3.xywz
mov oD0, v1
// approximately 18 instruction slots used
Для начала отметим, что количество ассемблерных команд возросло с 13 до 16, число микроинструкций с 15 до 18, а время выполнения шейдера с 20 до 21 тактов. Как видите, увеличение числа инструкций на 3 увеличило время выполнения шейдера всего на один такт. Эта "аномалия " имеет простое объяснение: каждый вершинный процессор G7x имеет VLIW - add, mul, madd и т.п.) и
один блок скалярных операций ( и т.п.). Следовательно, в идеальных условиях при отсутствии зависимостей по данным вершинный процессор G7x может за один такт запустить на выполнение одну векторную и скалярную операцию. Поэтому логично предположить, что драйвер NVIDIA при компиляции шейдера в микрокод вершинного процессора успешно спарил добавочные инструкции с остальными инструкциями шейдера. Хотя, конечно, нельзя исключить и влияние недокументированных особенностей микроархитектуры G7x . Таким образом, мы еще раз убедились, что количество инструкций без учета нюансов архитектуры вершинного процессора не может являться универсальным критерием производительности.
Перейдем к собственно ассемблерному коду вершинного шейдера. Первые 10 инструкций хорошо вам знакомы - они вычисляют сумму float a = input.pos.y + angle и значения тригометрических функций путем r0.x находится значение sin(a), а в r0.y значение cos(a).
Одиннадцатая инструкция возводит input.pos.x в квадрат. Двенадцатая инструкция умножает input.pos.x на вычисленные значения тригометрических функций, завершая тем самым вычисления значения input.pos.xx * float2(sin(a), cos(a)). Тринадцатая команда выполняет сравнение значений -input.pos.x-input.pos.x и +input.pos.x-input.pos.x по следующему алгоритму:
if (-input.pos.x* input.pos.x < input.pos.x* input.pos.x) r0.w=1 else r0.w=0
Нетрудно догадаться, что данный код всегда заносит в r 0.w значение 1, если input.pos.x неравен 0, и 0 в противном случае. То есть, грубо говоря, оно эквивалентно r0.w = (input.pos.x!=0)
Теперь все встало на свои места: так как не содержит инструкции сравнения на равенство двух чисел, сообразительный компилятор HLSL реализовал проверку числа на равенство 0 через комбинацию инструкций mul и slt.
Дополнительная информация
Язык не содержит инструкции проверки двух чисел на равенство, так как потребность в ней возникает достаточно редко. Дело в том, что поддерживает только типы с плавающей точкой, сравнение которых является достаточно нестабильной операцией: достаточно малейшей ошибки в последнем разряде и два равных значения перестанут быть равными.
Но в нашем случае мы можем не опасаться каких-либо последствий потери точности: данная особенность типов half/float/double проявляется исключительно при использовании дробных чисел и обусловлено тем, что большинство конечных десятичных дробей при переводе в двоичную систему становятся бесконечными двоичными дробями. Так как разрядная сетка мантиссы конечна, эту дробь не удается точно представить и последующие операции над такими урезанными бесконечными дробями приводят к накоплению ошибки. Чтобы убедиться в наличии данной проблемы достаточно провести небольшой вычислительный эксперимент в консольном приложении C#:
// Код примера расположен в Examples\Ch05\Ex09 class Program .
{
static void Main(string[] args)
{
float a = 1.2f;
float b = 1.4f;
float c = 1.68f;
float mul = a * b;
float delta = c - mul;
+ (double)a);
+ (double)b);
+ (double)c);
" + (double)mul);
= " + (double)delta);
Console.WriteLine("a = "
Console.WriteLine("b = "
Console.WriteLine("c = "
Console.WriteLine("sum =
Console.WriteLine("delta Console.ReadKey();
}
}
После выполнения примера на экране появится следующая информация:
a = 1,20000004768372 b = 1,39999997615814 c = 1,67999994754791 sum = 1,6800000667572 delta = -1,19209289550781E-07
Как видно, значения 1.2 и 1.4 не могут быть точно представлены в двоичной системе, в результате чего результат 1.68 - 1.2-1.4 оказался равен не нулю, а отрицательному числу -1.19-10-7 . Таким образом, с точки зрения компьютера $$1.68\ne 1.2-1.4$$.
Еще раз хочу обратить ваше внимание, что данные парадоксы возникают исключительно при работе с дробными числами, которые часто (но не всегда) не могут быть точно переставлены в двоичной системе. Целые же числа всегда могут быть точно переведены в двоичное представление, поэтому работа с ними осуществляется без потери
a = 12 b = 14 c = 168 sum = 168 delta = 0
Примечание
Некоторые новые графические процессоры, такие как G8x , содержат инструкцию, позволяющую выполнять покомпонентное сравнение четырехмерных векторов. Соответственно, драйвера при компиляции кода шейдера в микрокод подменяет последовательность инструкций вроде mul/slt одной инструкцией сравнения.
Четырнадцатая инструкция умножает рассчитанные координаты x и y вершины на результат сравнения input.pos.x с 0: если input.pos.x!=0 , то координаты остаются без изменения, а если input.pos.x==0 , то они обнуляются. Таким образом, условное выражение
if (input.pos.x != 0)
{
...
}
else
output.pos.xy = float2(0.0, 0.0);
реализуется последовательностью инструкций mul / slt / mul .
Резюмируем все вышесказанное. Компилятор HLSL успешно справился с реализацией оператора if без использования условных инструкций, но на производительности приложения это сказалось отрицательно, хотя и не фатально (на G7x время выполнения вершинного шейдера возросло на один такт).
Разумеется, при условии достаточной разрядности мантиссы.
После небольшого лирического отступления перейдем к моделированию полета искр средствами вершинного шейдера. Для начала освежим в памяти алгоритм генерации новых искр. Логика работы хранителя экрана выполняется с фиксированным шагом timeStep . В течение каждого дискретного шага появляется случайное количество новых искр, но не более maxScintillaCount . Каждая искра имеет случайные координаты, а так же случайную угловую и прямолинейную скорости. Время жизни каждой искры равно StartTime , по прошествии которого искра считается потухшей и ее структура может использоваться для генерации новой вершины: новые искры замещают потухшие, и лишь при отсутствии свободных потухших искр информация добавляется в конец массива вершин.
Данный алгоритм весьма проблематично реализовать в вершинном шейдере. Дело в том, что вершинный процессор обрабатывает вершины параллельно, независимо друг от друга, в результате чего вершинный шейдер не может получить информацию о состоянии других вершин. Ну а так как состояние шейдера не сохраняется между вызовами, а вернуть информацию из вершинного шейдера очень
Поэтому для генерации новых вершин нам придется разработать новый алгоритм, удовлетворяющим двум требованиям:
Будучи зажатыми в такие жесткие рамки, мы вряд ли сможем реализовать полноценный алгоритм генерации случайных искр со случайными параметрами. Поэтому мы просто заранее рассчитаем на некотором небольшом временном интервале время появления всех искр, их координаты, скорости, цвета и т.п., после чего будем проигрывать эту последовательность "по кругу " (рисунок 5.21). Таким образом, траектория движения точек будут повторяться через некоторое время, но если длительность одной итерации будет измеряться в десятках секунд, а число искр тысячами, то пользователь вряд ли сможет заметить какую-либо цикличность в поведении искр.
(рис 5.21) Циклическая работа фейерверка Минусом данного подхода является необходимость расчета в каждом кадре всех искр массива вершин, включая потухшие искры, причем, чем сильнее продолжительность итерации превосходит время жизни вершины, тем выше будут накладные расходы. Хотя с другой стороны этот недостаток наверняка компенсируется огромной производительностью вершинного
currentTime = time - vertexStartTime; localTime = currentTime % timeLoop;
где
time - время, прошедшее с момента запуска приложения.vertexStartTime - время появления вершины, отсчитываемое он начала итерации. После запуска хранителя экрана идет первая итерация, то есть время отсчитывается от нуля.timeLoop - длительность итерации, то есть время, через которое полет вершины повторяется заново.localTime - локальное время искры внутри итерации.На рисунке 5.22 приведен график зависимости currentTime от time , построенный в . Как видно, при запуске приложения время может быть равно отрицательному значению - это означает, что искра еще не появилась на экране. Далее по мере увеличения time локальное время так же линейно возрастает, пока не достигнет значения timeLoop , после чего оно сбрасывается до нуля и все повторяется сначала.
(рис 5.22) График зависимости локального времени искры от времени с момента запуска приложения Разобравшись с организацией массива вершин, займемся непосредственно моделированием полета искр. Как вы помните, в хранителе экрана из четвертой главы траектория движения искры складывается как композиция движения искры из центра диска по прямой с постепенным замедлением и движения по окружности вокруг диска с постоянно уменьшающейся угловой скоростью. Собственно расчет траектории выполнялся "в лоб " путем грубой аппроксимации с малым шагом времени delta :
// Корректируем скорость прямолинейного движения. При этом вершина не должна начинать // двигаться в противоположном направлении tSpeed = Math.Max(tSpeed - tSlowing * delta, 0.0f); // Корректируем скорость вращательного движения rSpeed = Math.Max(rSpeed - rSlowing * delta, 0.0f); // Изменяем расстояние искры от центра диска distance += Speed * delta; // Изменяем угол поворота искры вокруг диска angle += Speed * delta;
Так как этот алгоритм использует рекуррентные выражения, ссылающиеся на результаты предыдущих расчетов, он не может быть использован в вершинном шейдере – для этого потребуется сохранять результаты работы вершинного шейдера между визуализацией кадров, что весьма проблематично. Поэтому нам необходимо избавиться от реккурентности. В качестве основы возьмем выражение
$$tSpeed = tSpeed_0 - tSlowing \cdot t $$где
localtime ).Расстояние, пройденное вершиной, может быть найдено посредством суммирования значений $$(tSpeed_0 -tSlowing \cdot t) \cdot dt $$, где $$dt $$ - интервал времени, стремящийся к нулю. А это есть не что иное, как интеграл $$distance =\int (tSpeed_0 - tSlowing -t)-dt=t- tSpeed_0 -\frac{tSlowing \cdot t^2}{2}+c$$ где C - константа.
Значение этой константы можно легко определить из соображений, что при t=0 расстояние должно быть равно начальному положению точки $$distance_0$$:
Таким образом, константа C равна начальному положению точки и в результате мы получаем окончательную формулу:
Но это еще не все - выражение 5.6 построено на предположение, что по достижению скоростью искры значения 0 она начинает двигаться в противоположную сторону с возрастающей скоростью, в то время как наши искры по достижению нулевой скорости должны останавливаться на месте. Для учета данного обстоятельства мы должны ограничить значение времени величиной, при котором скорость становится равна 0:
$$td=min(localtime, \frac{tSpeed_0}{tSlowing}) distance=distance_0+tSpeed_0 \cdot td-\frac{tSlowing \cdot td^2}{2} $$где
td - локальное время искры, которое не может превышать значение, при котором скорость объекта становится отрицательной.Выражение для вычисления угла поворота вершины отличается от выражения расчета расстояния вершины от центра диска лишь несколькими нюансами, поэтому я сразу приведу готовый результат:
$$tr=min(localtime, \frac{tSpeed_0}{tSlowing}) angle=diskSpeed \cdot (time-localTime)+angle_0+tr \cdot rSpeed_0_\frac{rSlowing \cdot tr^2}{2} $$где
Искра вылетает все время из одного и того же места диска, но так как диск постоянно вращается, начальный угол поворота вершины все время оказывается разным. Такой подход имеет два достоинства: цвет вершины остается постоянным, а сами траектории движения вершин становятся более хаотичными. Для учета угла поворота диска в момент появления вершины используется слагаемое $$diskSpeed \cdot (time – localTime) $$, являющееся постоянным на протяжении всей жизни вершины (т.е. до следующей итерации).
Как видно, адаптация алгоритма с учетом специфики вершинного процессора может быть весьма нетривиальной задачей. Теперь можно приступать реализации данного алгоритма, но так как используемые формулы являются весьма запутанными и громоздкими, их не помешает для начала опробовать в классе-эмуляторе вершинного шейдера в C#. Кроме того, это позволит нам впоследствии оценить потенциальный прирост производительности, которого можно достичь при переносе вычислений в вершинный шейдер.
Прототип вершинного шейдера
Как и в случае с эффектом, вращающим диск, мы начнем с реализации класса, инкапсулирующего вершинный шейдер. Для начала определимся с параметрами, принимаемыми эффектом и способом их передачи ( таблица 5.5). В эффекте визуализации вращающегося диска входные параметры, уникальные для каждой вершины, передавались через координаты вершины. Но эффект визуализации искр содержит заметно больше входных параметров, поэтому нам придется передавать часть параметров через текстурные координаты. Применение текстурных координат никоим образом не сказывается точности передаваемых значений, ведь они, как и координаты и цвета вершин, проецируются компилятором HLSL на универсальные входные регистры v0, v1 … v15 . Код класса, эмулирующего работу вершинного шейдера, приведен в листинге 5.14.
| Описание параметра | Аналогичный параметр из формул предыдущего раздела | Общий для всех вершин | Место хранения |
|---|---|---|---|
| Текущее время | time |
Да | Входной параметр time |
| Длительность "итерации ", в течении которой движения искр не повторяются | timeLoop |
Да | Входной параметр timeLoop |
| Скорость вращения диска | diskSpeed |
Да | Входной diskSpeed |
| Цвет искры | - | Нет | Цвет вершины |
| Время появления искры | vertexStartTime |
Нет | Координата X |
| Начальное расстояние вершины от центра диска | distance0 |
Нет | Координата Y |
| Локальный угол поворота вершины | angle0 |
Нет | Координата Z |
| Начальная скорость удаления вершины от центра | tSpeed |
Нет | Текстурная координата X |
| Начальная угловая скорость вершины | rSpeed |
Нет | Текстурная координата Y |
static class FireworkEffect
{
// Константы с замедлениями искр. Общие для всех вершин
const float tSlowing = 0.105f;
const float rSlowing = 0.25f;
// Константа времени жизни искр
const float liveTime = 4.0f;
// Входные параметр time, timeLoop, diskSpeed
public static float time;
public static float timeLoop;
public static float diskSpeed;
// Код вершинного шейдера.
// input - входные данные вершины,
// output - выходные данные вершины
public static void VertexShader
(VertexPositionColorTexture[][] input,
VertexPositionColor[][] output)
{
// Перебираем все вершины (в реальном вершинном
шейдере этих циклов не будет). Так как при
// тестировании производительности число вершин
может превысить лимит примитивов, которые
// может визуализировать за один проход GPU Intel
GMA9xx, используется несколько массивов
// вершин
for (int j = 0; j < input.Length; j++)
{
for (int i = 0; i < input[j].Length; i++)
{
// Вычисляем время, прошедшее с первого появления искры
float currentTime = time - input[j][i].Position.X;
// Вычисляем локальное время, циклически
пробегающее от 0 до timeLoop
float localTime = currentTime % timeLoop;
// Определяем время, которое осталось существовать искре float
remainTime = liveTime - localTime;
// Ограничиваем величину локального времени,
чтобы искра останавливалась по достижению
// нулевой скорости
float td = Math.Min(localTime, input[j][i].
TextureCoordinate.X / tSlowing);
// Вычисляем расстояние вершины от центра диска
float distance = input[j][i].Position.Y + td *
(input[j][i].TextureCoordinate.X - td * tSlowing / 2.0f);
// Ограничиваем величину локального времени,
чтобы искра останавливалась по достижению
// нулевой угловой скорости
float tr = Math.Min(localTime, input[j][i].TextureCoordinate.Y /
rSlowing);
// Вычисляем текущий угол поворота искры вокруг диска
float angle = input[j][i].Position.Z + diskSpeed *
(time - localTime) +
tr * (input[j][i].TextureCoordinate.Y - tr * rSlowing / 2.0f);
// Вычисляем координаты вершины на основе
расстояния и угла поворота
output[j][i].Position.X = distance * (float)Math.Sin(angle);
output[j][i].Position.Y = distance * (float)Math.Cos(angle);
output [j ] [i] .Position. Z = 0.0f;
// Если искра появилась на экране, но еще не потухла
if ((currentTime >= 0) (remainTime > 0))
{
Vector4 color = input[j][i].Color.ToVector4();
// Определяем коэффициент прозрачности вершины
color.W = remainTime / liveTime;
output[j][i].Color = new XnaGraphics.Color(color);
}
else
{
output[j][i].Color = new XnaGraphics.Color(0, 0, 0, 0);
}
}
}
}
}
Коротко пробежимся по основным моментам программы. Информация о вершинах теперь хранится в структуре VertexPositionColorTexture , предоставляющей помимо знакомых нам полей Position и Color еще и поле TextureCoordinate , содержащее компоненты X и Y текстурных координат вершины. Выражения 5.7 и 5.8 были переписаны с использованием if , делающую невидимыми искры, которые согласно логике работы приложения еще не появились на экране.
Примечание
Кстати, HSLS реализует вычисление разложения функций sin и cos в ряд Тейлора посредством
Перейдем к обработчику события Load , выполняющего инициализацию массивов вершин с искрами (листинг 5.15).
public partial class MainForm : Form
{
// Эффект для простой закраски объектов
const string effectFileName = "Data\\ColorFill.fx";
const int slices = 64;
const float diskSpeed = 3.0f; const float
diskRadius = 0.018f;
// Число искр
const int fireworkVerticesCount = 300000;
// Движения искр будут повторяться через каждые 20 секунд
const float timeLoop = 20.0f;
// Минимальная скорость вершины
const float minSpeed = 0.3f;
// Максимальная скорость вершины
const float maxSpeed = 0.45f;
// Размер искры
const float pointSize = 1.0f;
// Декларация формата вершины. Диск и искры в режиме
эмуляции вершинного шейдера используют
// общий формат вершин
VertexDeclaration decl;
// Массивы вершин с искрами
VertexPositionColorTexture[][] fireworkVertices = null;
// Массивы вершин, обработанных эмулятором вершинного шейдера
VertexPositionColor[][] transformedFireworkVertices = null;
// Эффект, общий для диска и искр (в режиме эмуляции) Effect effect
= null;
Random rnd = new Random();
Stopwatch stopwatch;
bool closing = false;
// Счетчики FPS
// Временя, прошедшее с момента последнего вычисления
количества кадров в секунду
float lastTime = 0;
// Число кадров, визуализированных за это время
int frameCount = 0;
private void MainForm_Load(object sender, EventArgs e)
{
// Определяем число точек, которые может визуализировать
видеокарта за один присест
int maxVerticesCount = Math.Min(device.GraphicsDeviceCapabilities.
MaxVertexIndex,
device.GraphicsDeviceCapabilities.MaxPrimitiveCount);
// Определяем количество массивов вершин, которые потребуются
для визуализации
// fireworkVerticesCount вершин
int arrayCount = (int)Math.Ceiling((float)fireworkVerticesCount /
(float)maxVerticesCount);
// Создаем массивы вершин
fireworkVertices = new VertexPositionColorTexture[arrayCount][];
// Создаем массивы вершин, трансформированных вершинным шейдером
transformedFireworkVertices = new VertexPositionColor[arrayCount][];
// Перебираем вершины
for (int k = 0; k < fireworkVerticesCount; k++)
{
// Определяем индекс массива вершин, соответствующего текущей вершине
int j = k / maxVerticesCount;
// Определяем индекс текущей вершины в массиве вершин
int i = k % maxVerticesCount;
// Если мы перешли к новому массиву if (i == 0)
{
// Определяем количество оставшихся вершин
int remain = fireworkVerticesCount - j * maxVerticesCount;
// Число вершин в массиве не может превышать maxVerticesCount
remain = Math.Min(remain,
maxVerticesCount);
// Выделяем память для текущих массивов вершин
fireworkVertices[j] = new VertexPositionColorTexture[remain];
transformedFireworkVertices[j] = new
VertexPositionColor[remain];
}
// Вычисляем время появления вершины после запуска программы
fireworkVertices[j][i].Position.X = (float)rnd.NextDouble() * timeLoop;
// Определяем ее начальное удаление от центра диска
fireworkVertices[j][i].Position.Y = (float)rnd.NextDouble() *
diskRadius;
// Определяем начальный угол поворота вершины относительно диска
fireworkVertices[j][i].Position.Z = (float)rnd.NextDouble() * 2.0f *
(float) Math. PI;
// Определяем начальную линейную скорость вершины
fireworkVertices[j][i].TextureCoordinate.X = minSpeed +
(float)rnd.NextDouble() * (maxSpeed - minSpeed);
// Определяем начальную угловую скорость вершины
fireworkVertices[j][i].TextureCoordinate.Y = diskSpeed / 4.0f *
(1.0f + 0.01f * (float)rnd.NextDouble());
// Вычисляем цвет вершины
byte red = (byte)(255 * Math.Abs(Math.Sin(fireworkVertices[j][i].
Position.Z * ^ 3)) ) ;
byte green = (byte)(255 * Math.Abs(Math.Cos(fireworkVertices[j][i].
Position.Z * ^ 2) ) ) ;
fireworkVertices[j][i].Color = new XnaGraphics.Color
(red, green, 128, 255);
}
}
}
Чтобы иметь возможность наглядно оценить эффект переноса вычислений с CPU на , мы будем визуализировать 300.000 искр. Так как ряд видеокарт (например, Intel GMA 9xx ) не могут визуализировать такое количество примитивов за один
Код визуализации искр является достаточно тривиальным, если не считать того факта, что искры могут храниться в разных массивах вершин (листинг 5.16).
private void MainFormPaint(object sender, PaintEventArgs e)
{
// Настраиваем параметры GPU для визуализации
device.RenderState.CullMode = CullMode.None;
device. RenderState.AlphaBlendEnable = true;
device.RenderState.BlendFunction = BlendFunction.Add;
device.RenderState.SourceBlend = Blend.SourceAlpha;
device.RenderState.DestinationBlend = Blend.InverseSourceAlpha;
device.RenderState.PointSize = pointSize;
device.VertexDeclaration = decl;
// Определяем время, прошедшее с момента запуска приложения
float time = (float)stopwatch.ElapsedTicks /
(float)Stopwatch.Frequency;
// Настраиваем параметры эффекта, общие для всех вершин
FireworkEffect.time = time;
FireworkEffect.timeLoop = timeLoop;
FireworkEffect.diskSpeed = diskSpeed;
// Выполняем эмуляцию вершинного шейдера
FireworkEffect.VertexShader(fireworkVertices,
transformedFireworkVertices);
effect.Begin();
for (int i = 0; i < effect.CurrentTechnique.Passes.Count; i++)
{
EffectPass currentPass = effect.CurrentTechnique.Passes[i];
currentPass.Begin();
// Перебираем все массивы вершин
for (int j = 0; j < transformedFireworkVertices.Length; j++)
{ // Визуализируем текущий массив вершин
device.DrawUserPrimitives(PrimitiveType.PointList,
transformedFireworkVertices[j], 0,
transformedFireworkVertices[j].Length);
}
currentPass.End();
}
effect.End();
// Отключаем альфа-смешивание, которое не требуется
при визуализации диска
device.RenderState.AlphaBlendEnable = false;
float angle = diskSpeed * time;
// Выполняем эмуляцию вершинного шейдера диска
DiskEffect.angle = angle;
DiskEffect.VertexShader(diskVertices, transformedDiskVertices);
// Визуализируем диск
// Оканчиваем визуализацию кадра
device.Present();
// Увеличиваем счетчик кадров
frameCount++;
// Если прошла одна секунда
if (time - lastTime >= 1)
{
// Отображаем в заголовке формы текущий FPS
Text = ((float)frameCount / (time - lastTime)).ToString();
// Сбрасываем счетчики
lastTime = time; frameCount = 0;
}
}
Готовое приложение находится в example.zip в каталоге \Examples\Ch05\Ex10.
Результаты тестирования на компьютерах с видеокартами NVIDIA GeForce 7600GT и Intel GMA 9xx приведены в таблице 5.6. Так как конфигурация компьютеров заметно отличается, эти данные будут использоваться не для сравнения видеокарт между собой, а исключительно для оценки прироста производительности от внедрения вершинных шейдеров. Как видно, цифры сейчас колеблются в пределах 10 кадров в секунду, что явно недостаточно для обеспечения плавной анимации. Но уверяю вас, что к концу шестой главы частота кадров будет измеряться в сотнях кадров в секунду, причем это прирост будет достигнут без какого-либо ухудшения качества изображения.
| Конфигурация компьютера | FPS |
|---|---|
Intel Core2 Duo E6300, i945P, 2GB RAM, DDR2-667, GeForce 7600GT 256MB, Windows Vista Ultimate x64,
ForceWare 158.24 |
10,7 |
Intel Pentium-4 3.4GHz, i915P, 512MB RAM, |
6.3 |
Intel Core2 Duo E4300, i946GZ (GMA 3000), 2GB RAM DDR2-667, Windows Vista Ultimate x64, GMA Driver 7.14.10.1283 |
9.9 |
Примечание
Для корректного измерения производительности приложения при создании устройства свойство PresentationParameters.PresentationInterval должно быть установлено в PresentInterval.Immediate, в противном случае частота кадров будет зависеть от частоты вертикальной развертки монитора.
Вспомогательный метод загрузки эффекта из файла
После выноса расчетов полета искр в вершинный шейдер, наше приложение станет использовать два эффекта. Но вот незадача: код загрузки эффекта вместе со всеми обработчиками ошибок занимает более двадцати строк. В наших предыдущих примерах, использующих не более одного эффекта, это не было существенным недостатком. Однако при загрузке двух и более эффектов громоздкий код очень негативно скажется на читаемости кода, а так же затруднит дальнейшую модификацию приложения.
Это проблему можно изящно решить путем выноса кода загрузки эффекта в отдельный метод. Но, учитывая наши будущие приложения, будет разумнее поместить этот метод в отдельный класс Helper (листинг 5.17).
class Helper
{
// Класс исключения, которое генерируется при
возникновении проблем во время загрузки эффекта
public class LoadAndCompileEffectException : Exception
{
public LoadAndCompileEffectException(string message) : base(message)
{
}
}
// Загружает эффект из файла и выбирает наиболее подходящую технику.
При возникновении
// проблем генерирует исключение LoadEffectException.
public static Effect LoadAndCompileEffect(GraphicsDevice device,
string filename)
{
CompiledEffect compiledEffect;
try
{
compiledEffect = Effect.CompileEffectFromFile(filename, null,
null, CompilerOptions.None,
TargetPlatform.Windows); 1`
1
}
catch (IOException ex)
{
throw new LoadAndCompileEffectException(ex.Message);
}
if (!compiledEffect.Success)
{
throw new LoadAndCompileEffectException(String.Format
("Ошибка при компиляции эффекта: \r\n{0}",
compiledEffect.ErrorsAndWarnings));
}
Effect effect = new Effect(device, compiledEffect.GetEffectCode(),
CompilerOptions.NotCloneable, null);
if (!effect.CurrentTechnique .Validate())
{
throw new LoadAndCompileEffectException(String.Format
("Ошибка при валидации " +
"техники \"{0}\" эффекта \"{1}\"\n\rСкорее всего,
функциональность шейдера превышает " +
"возможности GPU", effect.CurrentTechnique.Name, filename));
}
return effect;
}
}
Полноценный эффект
Имея на руках код метода-эмулятора вершинного шейдера, написанного на C# с учетом специфики языка HLSL, создание эффекта не представляет какой-либо принципиальной сложности (листинг 5.18). Тем не менее, перевод C# -кода в HLSL не должен сводиться к механической трансляции - как-никак, HLSL содержит гибкие средства для векторных вычислений, позволяющие повысить качество промежуточного ассемблерного кода. В частности, мы можем значительно сократить объем вычислений, реализовав параллельный расчет текущего расстояния вершины от центра круга и угла поворота.
// Файл Firework.fx
//
// Константы tSlowing и rSlowing объединены в один двухмерный
вектор, что позволит
// распараллелить расчет расстояния от вершины от центра
и угла поворота вершины
static float2 slowing = {0.105, 0.25};
static float liveTime = 4.0;
float diskSpeed; float time;
float timeLoop;
struct VertexInput {
float3 pos : POSITION;
float4 color : COLOR;
float2 texcoord : TEXCOORD;
};
struct VertexOutput
{
float4 pos : POSITION;
float4 color : COLOR;
};
VertexOutput MainVS(VertexInput input)
{
VertexOutput output;
float currentTime = time - input.pos.x; float localTime = currentTime
% timeLoop; float remainTime = liveTime - localTime;
// Расстояние от центра диска и угол поворота вершины
рассчитываются параллельно float2 t = min(localTime.xx,
input.texcoord / slowing); float2 sCoord = input.pos.yz + t *
(input.texcoord -t * slowing / 2.0f);
// Формула расчета угла поворота по сравнению с формулой
расчета расстояния от центра
// содержит один добавочный член
sCoord.y += diskSpeed * (time - localTime);
// Заменяем два умножения константы на sin и cos одним
умножением на вектор (sin, cos) output.pos.xy = sCoord.x *
float2(sin(sCoord.y), cos(sCoord.y)); output.pos.zw = float2(0.0, 1.0);
output.color.rgb = input.color.rgb;
if ((remainTime > 0) (currentTime >= 0))
{
output.color.a = remainTime / liveTime;
}
else
{
output.color.a = 0;
}
return output;
}
float4 MainPS(float4 color:COLOR):COLOR
{
return color;
}
technique Firework
{
pass p0
{
VertexShader = compile vs_1_1 MainVS();
PixelShader = compile ps_1_1 MainPS();
}
}
Преобразование кода эффекта тоже весьма тривиально (листинг 5.19).
public partial class MainForm : Form
{
// Файл эффекта для визуализации вращающегося диска
const string diskEffectFileName = "Data\\Disk.fx";
// Файл эффекта для визуализации разлетающихся искр
const string fireworkEffectFileName = "Data\\Firework.fx";
// Эффект визуализации диска
Effect diskEffect = null;
// Объект, инкапсулирующий параметр angle эффекта диска
EffectParameter angleParam = null;
// Эффект визуализации искр
Effect fireworkEffect = null;
// Объекты, инкапсулирующие параметры эффекта искр:
diskSpeed, time, timeLoopParam
EffectParameter diskSpeedParam = null;
EffectParameter timeParam = null;
EffectParameter timeLoopParam = null;
private void MainFormLoad(object sender, EventArgs e)
{
// Так как вершины визуализируются без промежуточного
"эмулятора ", используется "родная
" // декларация формата вершин
fireworkDeclaration = new VertexDeclaration(device,
VertexPositionColorTexture.VertexElements);
try
{
// Загружаем эффекты
diskEffect = Helper.LoadAndCompileEffect(device,
diskEffectFileName);
fireworkEffect = Helper.LoadAndCompileEffect(device,
fireworkEffectFileName);
}
catch (Helper.LoadAndCompileEffectException ex)
{
// Обрабатываем исключительные ситуации загрузки и компиляции эффекта
closing = true;
MessageBox.Show(ex.Message, "Критическая ошибка", MessageBoxButtons.OK,
MessageBoxIcon.Error);
Application.Idle += new EventHandler(ApplicationIdle);
return; }
// Получаем объект, инкапсулирующий параметр angle
эффекта диска angleParam = diskEffect.Parameters["angle"];
Debug.Assert(angleParam != null, diskEffectFileName + " :
не найден параметр angle");
// Получаем объект, инкапсулирующий параметр diskSpeed эффекта искр
diskSpeedParam = fireworkEffect.Parameters["diskSpeed"];
Debug.Assert(diskSpeedParam != null, fireworkEffectFileName + " :
не найден параметр diskSpeed");
// Получаем объект, инкапсулирующий параметр time эффекта искр
timeParam = fireworkEffect.Parameters["time"] ;
Debug.Assert(timeParam != null, fireworkEffectFileName + " :
не найден параметр time") ;
// Получаем объект, инкапсулирующий параметр timeLoop эффекта искр
timeLoopParam = fireworkEffect.Parameters["timeLoop"];
Debug.Assert(timeLoopParam != null, fireworkEffectFileName + " :
не найден параметр timeLoop");
}
private void MainFormPaint(object sender, PaintEventArgs e)
{
float time = (float)stopwatch.ElapsedTicks / (float)Stopwatch.Frequency;
// Задаем значения параметров
timeParam. SetValue(time);
timeLoopParam.SetValue(timeLoop);
diskSpeedParam.SetValue(diskSpeed);
// Указывает декларацию формата вершин. Внимание!
Если вы при переходе к другому формату
// вершин и забудете подправить декларацию формата
вершины, то часть входных параметров
// вершины вроде текстурных координат будет содержать
"мусор ". Соответственно, эффект будет
// работать весьма странно, а самом худшем случае это
может привести к краху приложения и
// даже операционной системы.
device.VertexDeclaration = fireworkDeclaration;
// Визуализируем искры как обычно
fireworkEffect.Begin();
for (int i = 0; i < fireworkEffect.
CurrentTechnique.Passes.Count; i++)
{
EffectPass currentPass = fireworkEffect.
CurrentTechnique.Passes[i] ; currentPass.Begin();
for (int j = 0; j < fireworkVertices.Length; j++)
{
device.DrawUserPrimitives(PrimitiveType.PointList,
fireworkVertices[j], 4> 0, fireworkVertices [j] .Length) ;
}
currentPass.End();
}
fireworkEffect.End();
// Выполняем приготовления к визуализации диска
device.RenderState.AlphaBlendEnable = false;
angleParam.SetValue(diskSpeed * time);
// Не забываем изменить декларацию формата вершины
device.VertexDeclaration = diskDeclaration;
// Визуализируем диск
...
// Вычисляем FPS
...
}
}
Анализ исходного кода эффекта
Сейчас вы уже вполне неплохо освоились с языком , поэтому выполнять построчный анализ ассемблерного кода вряд ли имеет смысл. Вместо этого я сразу приведу отчет NVIDIA FX Composer 2.0 с ассемблерным листингом, разделенным комментариями на блоки, соответствующие тем или иным инструкциям.
// Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000
//
// Parameters:
//
// float diskSpeed;
// float time;
// float timeLoop;
//
//
// Registers:
//
// Name Reg Size
// ----
// diskSpeed c0 1
// time c1 1
// timeLoop c2 1
//
//
// Default values:
//
// diskSpeed
// c0 = { 0, 0, 0, 0 };
//
// time
// c1 = { 0, 0, 0, 0 };
//
// timeLoop
// c2 = { 0, 0, 0, 0 };
//
vs_1_1
def c3, 0.0416666418, -0.5, 1, 0 def c4, 4, 9.52380943,
0.104999997, 0.25 def c5, 0.5, 0.159154937, 0.25, -
0.00138883968
def c6, 6.28318548, -3.14159274, -2.52398507e-007,
2.47609005e-005 dcl_position v0 dcl_color
v1 dcl_texcoord v2 // float currentTime = time - input.pos.x;
1. add r2.w, -v0.x, c1.x
// Начало вычисления float localTime = currentTime % timeLoop
mul r0.w, r2.w, c2.x
add r1.w, c2.x, c2.x
sge r0.w, r0.w, -r0.w
mad r0.w, r0.w, r1.w, -c2.x
rcp r1.w, r0.w
mul r3.w, r2.w, r1.w
expp r4.y, r3.w
mov r1.w, r4.y
// Вычисление подвыражения input.texcoord / slowing из
// float2 t = min(localTime.xx, input.texcoord / slowing).
Деление на константу заменено
// умножением
10. mul r0.xy, v2, c4.yxzw
// Окончание вычисления float localTime = currentTime % timeLoop
11. mul r3.w, r0.w, r1.w
// Окончание вычисления float2 t = min(localTime.xx,
input.texcoord / slowing)
12. min r0.xy, r0, r3.w
// float2 sCoord = input.pos.yz + t * (input.texcoord -
t * slowing / 2.0f)
mul r1.xy, r0, c4.zwzw
mad r1.xy, r1, -c5.x, v2
mad r0.xy, r0, r1, v0.yzzw
// sCoord.y += diskSpeed * (time - localTime)
mad r3.w, r0.w, -r1.w, c1.x
mad r3.w, c0.x, r3.w, r0.y
// Начало вычисления output.pos.xy = sCoord.x * float2(sin(sCoord.y),
cos(sCoord.y))
mad r2.xy, r3.w, c5.y, c5.zxzw
frc r1.xy, r2
mad r1.xy, r1, c6.x, c6.y
mul r1.xy, r1, r1
mad r2.xy, r1, c6.z, c6.w
mad r2.xy, r1, r2, c5.w
// Вычисление подвыражения (currentTime >= 0) оператора if
24. sge r2.w, r2.w, c3.w
// Продолжение вычисления output.pos.xy = sCoord.x *
float2(sin(sCoord.y), cos(sCoord.y))
25. mad r2.xy, r1, r2, c3.x
// float remainTime = liveTime - localTime
26. mad r1.w, r0.w, -r1.w, c4.x
// Продолжение вычисления output.pos.xy = sCoord.x *
float2(sin(sCoord.y), cos(sCoord.y))
mad r2.xy, r1, r2, c3.y
mad r1.xy, r1, r2, c3.z
// Продолжение оператора if: вычисление подвыражения
(remainTime > 0)
29. slt r0.w, c3.w, r1.w
// output.color.a = remainTime / liveTime
30. mul r1.w, r1.w, c4.w
// Продолжение оператора if: окончание вычисления значения условия
// ((remainTime > 0) (currentTime >= 0))
31. mul r0.w, r2.w, r0.w
// Окончание вычисления выражения
// output.pos.xy = sCoord.x * float2(sin(sCoord.y), cos(sCoord.y))
// (умножение на sCoord.x)
32. mul oPos.xy, r0.x, r1
// Окончание блока if. Если условное выражение блока
if равно true, альфа компонент цвета
// остается без изменений, иначе обнуляется.
33. mul oD0.w, r1.w, r0.w
// output.pos.zw = float2(0.0, 1.0);
34. mov oPos.zw, c3.xywz
// output.color.rgb = input.color.rgb
35. mov oD0.xyz, v1
// approximately 37 instruction slots used
Пробежимся по наиболее интересным местам HLSL кода. Первым сюрпризом является трансляция вычисления выражения currentTime % timeLoop аж в целых 8 инструкций (с 2-й по 9-ю). Это обусловлено тем, что язык не содержит инструкции вычисления остатка отделения, соответственно компилятору приходится эмулировать ее посредством скудного набора инструкций. Ниже приведена реконструкция алгоритма нахождения остатка от деления на языке C#:
// Функция на языке C#, вычисляющая a%b. Написана
приближенно к алгоритму, используемому в
// HLSL
static float mod(float a, float b)
{
// Если частное (a/b) является отрицательным числом,
то изменяем знак у делителя (nb=-b)
float cmp;
if (a*b > -a*b) cmp
= 1;
else
cmp = 0;
float nb = cmp * (b + b) - b;
// Вычисляем частное
float div = a * (1.0f / nb);
// Находим дробную часть частного
float frac = div - (float)Math.Floor(div);
// Вычисляем остаток
float result = frac * nb;
return result;
}
Отдельно стоит отметить нахождение дробной части числа, до сих выполнявшаяся посредством макроса . Но при анализе кода нахождения остатка . Думаю, вы ожидали увидеть здесь все что угодно, только не команду expp, вычисляющую приближенное значение $$2^n$$. Правда компилятора интересует не само значение $$2^n$$, а побочный результат команды, заносящей в компонент y вектора-результата дробную часть числа ( a – floor(a) ). В целом же из всего вышесказанного следует вывод, что, несмотря на обманчиво простой вид, оператор % языка HLSL является очень "дорогой " операцией, соизмеримой по времени выполнения с вычислением тригонометрических функций.
Чтобы максимально задействовать HLSL изменил их порядок следования, чтобы избавиться от зависимости соседних инструкций. Обратной стороной медали является сложность анализа кода: инструкции многих операторов HLSL перемешались между собой, а код строки float remainTime = liveTime – localTime, расположенной в начале эффекта, был перенесен компилятором ближе к концу шейдера.
Еще одной любопытной особенностью является код оператора if, составное условное выражение которого содержит логическую операцию "и " – так как язык не поддерживает булевские типы и логические операции над ними, оператор эмулируется перемножением чисел с плавающей точкой.
Оптимизация вершинного шейдера
И, наконец, анализируя код строки float2 sCoord = input.pos.yz + t * (input.texcoord - t * slowing / 2.0f) мы обнаружим, что компилятор не смог догадаться предварительно вычислить значение константы slowing / 2.0f, что вылилось в один лишний оператор mul. Это дает нам основание предположить, что добавив в файл HLSL явное вычисление константы, мы сможем немного ускорить работу приложения. Но наверняка быть уверенным нельзя, ведь является всего лишь промежуточным кодом, впоследствии еще раз оптимизируемым компилятором драйвера.
Ну что ж, рискнем. Основные фрагменты эффекта с модифицированным вершинным шейдером приведены в листинге 5.20. Полный текст эффекта находится в example.zip в каталоге Examples\Ch05\Ex12.
static float2 slowing = {0.105, 0.25};
// Явно рассчитываем значение вспомогательной константы
static float2 slowing2 = slowing / 2.0f;
...
VertexOutput MainVS(VertexInput input)
{
...
float2 sCoord = input.pos.yz + t * (input.texcoord -t * slowing2);
...
}
Просмотр ассемблерного кода приложения даст вполне предсказуемые результаты: число команд ассемблерного листинга уменьшилось на одну (с 37 до 36), а вот время выполнения эффекта сократилось на целых 5 тактов (с 42 до 37) – вероятно удаление одной лишней команды позволило драйверу более эффективно распараллелить выполнение команд вершинного шейдера. В результате пиковая производительность эффекта на NVIDIA GeForce 7800 GTX увеличилась с 76.000.000 до 86.000.000 вершин в секунду, т.е. на 13%.
Таким образом, даже незначительные изменения в коде эффекта могут спровоцировать лавину изменений в финальном микрокоде шейдера для физического вершинного процессора, которые могут как усилить эффект от оптимизации HLSL -кода шейдера, так и свести ее на нет и даже снизить производительность.
Настало время оценить эффект от переноса вычислений на видеокарту. На рисунке 5.23 приведена диаграмма, построенная в Excel по результатам измерения производительности примеров Ch05\Ex10 и Ch05\Ex12 на разных .
(рис 5.23) Производительность примеров Ch05\Ex10 и Ch05\Ex12 на разных GPU Как видно, на GeForce 7600GT и Radeon X700 Pr o перенос вычислений с CPU на увеличил частоту кадров почти в 10 раз. На компьютере с интегрированным частота кадров тоже заметно увеличилась (в 3.5 раза), что на первый взгляд выглядит весьма странно: i946GZ не содержит аппаратного вершинного процессора, поэтому все вычисления по-прежнему выполняются силами центрального процессора.
Данный парадокс обусловлен рядом факторов. Как известно, .NET приложения содержат множество вспомогательного кода для обнаружения различных внештатных ситуаций вроде переполнения или обращения к несуществующему элементу коллекции. Разумеется, этот код оказывает отрицательное влияние на производительность, усугубляемое многократным его выполнением в цикле. Кроме того, все современные процессоры еще со времен Pentium-III содержат специализированный векторные регистры SSE и набор векторных инструкций, отдаленно напоминающие ассемблерные команды языков . Но язык C# и промежуточный язык IL не содержат векторных команд, что затрудняет распознавание векторных операций при компиляции JIT -компилятором IL -кода exe-файла в машинный код. В результате, итоговый машинный код практически не содержит SSE -инструкций и векторные блоки центрального процессора фактически простаивают.
При использовании вершинных шейдеров все обстоит несколько иначе. На i946GZ и аналогичных без аппаратных вершинных процессоров вершинные шейдеры эмулируются DirectX посредством специальной подсистемы Processor Specific Geometry Pipeline (PSGP). PSGP автоматически выполняет компиляцию вершинного шейдера в набор инструкций текущего CPU, задействовав весь потенциал данного процессора на 100%. Полученный код активно использует блоки SSE, параллельную обработку нескольких вершин всеми ядрами CPU и не содержит каких-либо ненужных промежуточных проверок "на всякий случай ". В результате он работает заметно быстрее по сравнению с аналогом на C#, что мы и наблюдаем.
Итак, вершинные шейдеры позволяют значительно поднять производительность приложения. Но не стоит забывать, что это упреждение верно лишь при сравнении производительности C# и HLSL -кода, использующего одинаковый алгоритм. Центральный процессор предоставляет разработчику использовать значительно более гибкие алгоритмы, так что на практике все обстоит несколько сложнее. Но в любом случае, вершинные шейдеры позволяют разгрузить центральный процессор, освободив его ресурсы для других задач.
В этой лекции мы познакомились с новыми возможностями языка HLSL применительно к программированию вершинных шейдеров: работе с отдельными компонентами вектора, математическими операторами, встроенными функциями, параметрами эффекта и особенностями оператора if. Так же была рассмотрена IDE для разработки шейдеров NVIDIA FX Composer 2.0, которая, учитывая рост сложности наших эффектов, пришлась как нельзя кстати. Учитывая, что вершинный шейдер выполняется для каждой вершины, число которых может измеряться сотнями тысяч, очень важно уделять внимание качеству кода и оптимизации вершинного шейдера. А для этого очень полезно иметь хотя бы поверхностное представление о том, что твориться под капотом HLSL, в частности о языках . Поэтому мы изучили основы архитектуры виртуального процессора и его систему команд.
Для получения официальных документов о завершении программы дополнительного профессионального образования (удостоверения о повышении квалификации, дипломов о профессиональной переподготовке и MBA) необходимо предоставить:
Внимание! Вы можете не заказывать доставку бумажной версии официального документы, а скачать его в электронном виде и распечатать самостоятельно. Информация о выданном документе в течение 1 месяца загружается в Федеральную информационную систему «Федеральный реестр сведений о документах об образовании и (или) о квалификации, документах об обучении» - ФИС ФРДО.
Доступ на новый сайт осуществляется с использованием адреса электронной почты, который был указан вами при регистрации на "старом". Мы постарались перенести все ваши данные с прежнего ресурса, однако не исключена вероятность потери части информации.
При возникновении проблемы со входом, воспользуйтесь функцией сброса пароля
Если вы обнаружите несоответствия, пожалуйста, сообщите нам.