Введение в XNA

Вершинные шейдеры

Разбить на страницы
Показывать лекцию целиком

Вот уже на протяжении трех лекций мы активно используем в приложениях вершинные и пиксельные шейдеры, однако их роль сводится к банальному пропусканию через себя исходных данных без какой-либо обработки или модификации. Такой подход трудно назвать оптимальным, ведь главное предназначение шейдеров – разгрузка центрального процессора компьютера путем освобождения его от рутины. Но для этого необходимо более детально изучить язык HLSL и получить представление об архитектуре графического процессора и языках ассемблера. В виду обширности этой темы данная лекция посвящена преимущественно программированию вершинных процессоров.

Примечание

Если вы немного подзабыли основы языка HLSL, можете еще раз пролистать раздел 2.3.

5.1. Математические вычисления в HLSL

Функциональность любого шейдера так или иначе связана с математическими расчетами, поэтому для начала мы научимся выполнять математические операции над типами языка HLSL.

5.1.1. Математические операторы

Математические операторы языка 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-битной точностью для каждого компонентаЭто утверждение верно только для вершинных шейдеров. В пиксельных шейдерах точность вычислений зависит от множества факторов (см. раздел 7.x).. Таким образом, замена в вышеприведенном коде типов float4 на half4 или double4 некоим образом не скажется на скорости или точности расчетовА вот использование целочисленных типов вроде int4 может привести к тому, что компилятор HLSL будет пытаться честно эмулировать целочисленные вычисления посредством 32-х битных типов с плавающей точкой (см. раздел 2.3.2)..

5.1.2. Работа с компонентами векторов

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;

5.1.3. Математические функции

В языке HLSL имеется множество математических функций для работы со скалярными и векторными типами: тригонометрические и гиперболические функции, вычисление скалярного и векторного произведения векторов и так далее. Полный список функций языка HLSL можно найти в приложении 4. Обратите внимание, что список доступных функций определяется используемым профилем.

Большинство функций HLSL транслируются в одну команду графического процессора. При этом, каждая команда графического процессора, как правило, выполняется за 1 такт. Поэтому рекомендуется как можно активнее использовать встроенные функции, а не изобретать велосипед. Например, выражение $$b=\frac {1}{\sqrt q}$$ можно записать как b=1.0/sqrt(a), либо как b=rsqrt(a). Первый вариант будет транслирован в две команды GPU (вычисление квадратного корня и деление), а второй - в одну. Нетрудно догадаться, что какой из них будет работать быстрее.

Примечание

Оптимизирующий компилятор HLSL, скорее всего, самостоятельно заменит выражение b=1.0/sqrt(a) на b=rsqrt(a). Однако в более сложных случаях у него может не хватить сообразительности, чтобы подобрать оптимальную замену.

5.1.4. Черно-белая закраска

В качестве демонстрации практического использования математических расчетов мы напишем простой эффект, преобразующий цвет примитивов в черно-белый с использованием следующего выражения:

$$l=\frac{(r+g+b}{3} r=l b=l $$

где

  • $$r, g, 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.1) предполагает, что человеческий глаз имеет одинаковую чувствительность к красному, зеленому и синему цвету. В действительности это не так - например, человеческий глаз значительно более чувствителен к зеленому цвету, чем к синему. Для учета этого факта национальным комитетом по телевизионным системам США ( NTSC ) было принято решение вычислять яркость по следующей формуле:

    $$l=0.299*r+0.587*g+0.114*b $$

    Создайте эффект, осуществляющий преобразование в черно-белый цвет с использованием формулы 5.4. В качестве отправной точки можно воспользоваться примером Ch05\Ex01. Готовое приложение находится в example.zip в каталоге Examples\Ch05\Ex02.

    5.2. NVIDIA FX Composer 2.0

    До сих пор мы создавали файлы эффектов .fx в обыкновенном текстовом редакторе. В принципе, в этом нет ничего плохого. В конце концов, некоторые разработчики создают .NET приложения в простых текстовых редакторах с последующей компиляцией полученного .cs -файла из командой строки компилятором C# ( csc.exe ). Однако по мере усложнения разрабатываемых проектов использование специализированных средств разработки становится все более актуальным. Как ни крути, та же IDE Visual Studio значительно облегчает процесс разработки благодаря умному редактору с подсветкой синтаксиса, технологии IntelliSense, интегрированному компилятору, отладчику и справочной системе.

    По мере изучения XNA наши эффекты будут становиться все сложнее, поэтому будет разумно заблаговременно подыскать интегрированную среду разработки эффектов. В действительности, наш выбор не велик - на рынке сейчас господствуют два бесплатных пакета для разработки эффектов: ATI RenderMonkey 1.6 и 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, GLSLOpenGL Shading Language (GLSL) – язык программирования шейдеров, используемый в API OpenGL и CgCg – язык программирования шейдеров, разработанный корпораций NVIDIA. Поддерживает как API DirectX, так и API OpenGL . Возможно, это слишком громко сказано, ведь FX Composer уступает Visual Studio 2005 практически по всем параметрам: удобству пользовательского интерфейса, технологии IntelliSense, документации и так далее. Кроме того, в текущей версии FX Composer имеется ощутимое количество багов. Впрочем, в этом нет ничего удивительного, если сравнить количество человеко-часов, затраченных на создание Visual Studio и FX Composer. Кроме того, NVIDIA FX Composer является абсолютно бесплатным, что позволяет закрыть глаза на многие недостатки - как известно, на халяву и уксус сладок.

    FX Composer 2.0 в первую очередь ориентирован на работу с файлами формата COLLADA версии 1.4.1, поэтому для понимания основных принципов организации пользовательского интерфейса полезно ознакомиться с основами этого формата.

    5.2.1. Формат COLLADA 1.4.1

    COLLADA (COLLAborative Design Activity) - это кроссплатформенный открытый формат, используемый для обмена данными между приложениями создания цифрового контента ( DCCDigital Content Creation (DCC) – создание цифрового контента ). Формат 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 OpenGL ESOpenGL ES – подмножество API OpenGL, используемое в встраиваемых системах: мобильных телефонах, игровых приставках и т.п .
  • <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.

    5.2.2. Знакомство с интерфейсом FX Composer 2.0

    Ну что ж приступим. Для начала установите FX Composer 2.0Инсталлятор и запустите его из меню Start (Start | All Programs | NVIDIA Corporation | FX Composer 2 | FX Composer 2). На рисунке 5.2 приведен внешний вид стартового экрана FX Compose сразу после установкиЧтобы придать скриншоту большую выразительность, я добавил в сцену чайник, создал несколько материалов и применил один из материалов к чайнику. В остальном же внешний вид приложения мало чем отличается от отображаемого при первом запуске . Рассмотрим основные элементы пользовательского интерфейса.

    В верхней части окна расположено главное меню FX Composer, под которым находится панель инструментов ( Standard Toolbar ) для быстрого доступа к наиболее важным пунктам меню. Как и во всех современных IDE панель инструментов легко конфигурируется с учетом предпочтений пользователя.

    Примечание

    В FX Composer 2 имеется подробное руководство пользователя, которое можно открыть щелчком на ссылке User Guide на панели Start Page, отображаемой при первом запуске FX Composer.

    В центре экрана расположены вкладки трех панелей: Start Page, Shader Library и 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.
  • Панель Assets предназначена для работы с различными группами контента, поддерживаемого форматом COLLADA.
  • Панель Project, содержащая иерархию всех файлов проекта, в целом аналогична окну Solution Explorer из Visual Studio 2005.
  • В правой верхней части окна расположена панель Properties, позволяющая задавать значения входных параметров эффектов и материалов с использованием интуитивно понятного интерфейса. Ниже расположена вкладка Render, которая позволяет опробовать созданный материал на тестовой сцене, визуализируемой с использованием API OpenGL или 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.

    5.2.3. Создание нового проекта

    Лучший способ изучить 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. Замените текст созданного по умолчанию эффекта кодом из листинга 5.1Немного погодя мы оценим, насколько хорошо компилятор 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.dae, сгенерированный 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-файла

    Если эффект изначально разрабатывался в 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... и укажите файл, который необходимо открыть. В результате в проект будет добавлен новый эффект, содержащий указанный файл, который теперь можно легко отредактировать и откомпилировать.

    5.2.4. Анализ производительности эффекта

    При разработке шейдеров начинающие разработчики часто оказываются в положении буриданова осла, когда одну и ту же функциональность можно реализовать различными способами и при этом не совсем ясно, какой из них будет иметь большую производительность. В подобных ситуациях трудно переоценить полезность панели Shader Perfomance, позволяющей быстро прикинуть быстродействие эффекта на различных видеокартах NVIDIA с учетом многочисленных версий драйверов.

    Чтобы получить представление о возможностях данной панели мы проанализируем производительность эффекта BlackAndWhite, созданного в разделе 5.2.3. Для начала в панели Assets щелкните правой кнопкой мыши на узле эффекта, который вы собираетесь проанализировать, и выберите команду контекстного меню Analyze Performance, после чего в нижней части окна появится панель Shader 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 и GPU, позволяющие выбрать версии драйверов 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 и выше, вам следует пометить все GPU семейств NV4x и G7x. Далее аналогичным образом укажите интересующие вас версии драйверов.

    (рис 5.11) Диалоговое окно Setting и Select Default GPUs

    После нажатия OK в во вкладке BlackAndWhite.fx панели Shader Perfomance появятся флажки, соответствующие указанным графическим процессорам. Выделите флажки GPU и драйверов, интересующие вас в данный момент, и нажмите кнопку Graph в верхней части окна. На экране появится диаграмма производительности вершинного шейдера на выбранных графических процессорах (рисунок 5.12).

    (рис 5.12) Производительность шейдера на различных графических процессорах

    По диаграмме легко можно оценить, будет ли являться производительность вершинного шейдера ограничивающим фактором. Допустим, наша сцена содержит 100.000 треугольников. Значит, пренебрегая прочими факторами можно предположить, что визуализация данного сцены на самой медленной видеокарте семейства GeForce6 (GeForce 6200) будет выполняться с частотой 150.000.000 / 100.000 = 1.500 кадров секунду. Таким образов в данном конкретном случае вершинный шейдер не станет узким местом даже на самых дешевых видеокартах семейства GeForce6.

    Просмотр ассемблерного кода шейдера

    Еще одной интересной возможностью панели Shader Performance является просмотр скомпилированного промежуточного кода шейдера. Это очень мощная функциональность, позволяющая оценить качество сгенерированного ассемблерного кода и увидеть ошибки, допущенные компилятором. Возможно, последнее утверждение покажется вам несколько надуманным, но это действительно так. Компилятор 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 Shader Compiler версии 9.12.589.0000, причем в качестве промежуточного языка был использован Vertex Shader 1.1. Все остальное для нас не более чем китайская грамота, так что для оценки качества сгенерированного кода нам потребуется ознакомиться с азами языков Vertex Shader.

    На этом первое знакомство с NVIDIA FX Composer 2. 0 можно считать оконченным.

    5.3. Введение в языки Vertex Shader

    Языки семейства Vertex Shader предназначены для программирования виртуальных вершинных процессоров, причем каждому языку соответствует своя модель виртуального вершинного процессора. Вообще в основу каждой модели виртуального вершинного процессора положен вполне определенный реальный вершинный процессор, однако на практике данная особенность не играет особой роли, ведь код для виртуального процессора все равно компилируется драйвером видеокарты в машинный код текущего процессора. Кроме того, все эти модели виртуальных процессоров построены на общих принципах. Так что вы вполне можете думать о языках Vertex Shade r как о вариациях на тему языка IL, заточенных под векторные процессоры.

    5.3.1. Регистры

    Любой виртуальный вершинный процессор содержит набор регистров, напоминающих регистры SSE процессоров архитектуры x86. Большинство регистров (но отнюдь не все) являются векторными регистрами, рассчитанными на хранение четырехмерных векторов в формате с плавающей точкой. Разрядность регистров может быть произвольной, но на практике обычно равна 128-ми битам, то есть на каждый компонент вектора, как правило, отводится 32-бита (рисунок 5.13).

    (рис 5.13) 128-битный векторный регистр (32 бита на компонент).

    Данные, с которыми работает виртуальный процессор можно разделить на три большие группы (рисунок 5.14):

  • Исходные данные, передающиеся в вершинный шейдер через регистры исходных данных (координаты вершин, текущее время и т.д.).
  • Промежуточные результаты, хранящиеся в регистрах общего назначения.
  • Итоговые результаты, передаваемые дальше по графическому конвейеру посредством соответствующих выходных регистров вершинного процессора.
  • (рис 5.14) . Структура графического процессора

    Набор регистров существенно варьируется от версии к версии, поэтому чтобы сделать материал менее запутанным мы сосредоточимся исключительно на версии 1.1.

    Примечание

    Виртуальные процессоры Vertex Shader 1.1 и Pixel Shader 1.1 практически один в один повторяют архитектуру вершинных и пиксельных процессоров видеокарты GeForce3 (NV20) .

    Регистры исходных данных

    Регистры исходных данных виртуального процессора делятся на две подгруппы (рисунок 5.15).

  • 16 регистров v0, v1… v15 с информацией, специфичной для текущей вершины ( Input Registers ).
  • Не менее 96-ти векторных константных регистров c0, c1 … c95 ... cN с информацией, общей для всех вершин ( Constant Float Registers ).
  • Оба типа регистров являются векторными регистрами, в которых хранятся 4 компонента с плавающей точкой. У всех современных графических процессоров разрядность этих регистров равна 128 бит, т.е. каждый компонент вектора является 32-х разрядных числом с плавающей точкой. Но эта разрядность не является фиксированной и может измениться у будущих GPU. Кроме того, различные GPU могут несколько по-разному обрабатывать такие граничные ситуации как переполнение или деление на нуль, поэтому код шейдеров не должен быть заточен под фиксированную разрядность 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 Размер визуализируемой точкиУправление размером отдельных точек будет рассмотрено в разделе 6.x.
    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);

    Количество константных регистров зависит от видеокарты, однако поддержка видеокартой Vertex Shader 1.1 гарантирует наличие не менее 96 константных регистров. Точное количество константных регистров вершинных процессоров текущей видеокарты может быть получено посредством метода GraphicsDeviceCapabilities.MaxVertexShaderConstants класса GraphicsDevice. В таблице 5.2 приведена информация о количестве константных регистров у наиболее распространенных видеокарт.

    Примечание

    Число физических константных регистров может несколько превышать значение, возвращаемое GraphicsDeviceCapabilities.MaxVertexShaderConstants. Дополнительные регистры, как правило, используются драйвером для внутренних нужд, например, при эмуляции фиксированного графического конвейера из прошлых версий DirectX.

    Количество константных регистров у некоторых GPU
    GPU Количество константных регистров у вершинных процессоров
    NV2x 96
    NV3x 256
    NV4x 256
    G7x 256
    R2xx 192
    R3xx 256
    R4xx 256
    Intel GMA 9xxВершинные шейдеры на Intel GMA 9xx и GMA 3000 выполняются посредством CPU. 8192
    Intel GMA 3000 8192

    Забегая вперед, стоит отметить, что значения константных регистров могут изменяться приложением, что делает их идеальным средством для передачи параметров в вершинный шейдер (см. раздел 5.4).

    Регистры общего назначения

    Регистры общего назначения используются для хранения операндов и результатов команд, а так же для адресации массива константных регистров. Виртуальный вершинный процессор Vertex Shader 1.1 предполагает наличие двух типов временных регистров (рисунок 5.16):

  • 12 временных векторных регистров r0, r1 … r11 для хранения промежуточных результатов вычислений ( Temporary Registers ).
  • Один скалярный адресный регистр a0, используемый для косвенной адресации константных регистров ( Address Register ).
  • (рис 5.16) Регистры общего назначения

    Временные регистры являются аналогом регистров SSE процессоров архитектуры x86, и поэтому вряд ли нуждаются в каких-либо комментариях. Адресный регистр хранит смещение, которое может применяться при обращении к константному регистру в режиме косвенной адресации. Например, если адресный регистр содержит значение 2, то при обращении в режиме косвенной адресации к константному регистру c5 в реальности произойдет обращение к регистру c7. В ассемблерном коде такое обращение будет выглядеть как c[a0.x + 5] .

    Примечание

    Примитивная косвенная адресация, используемая в шейдерах, довольно сильно напоминает косвенную адресацию первых программируемых калькуляторов вроде HP-11C.

    Регистры итоговых результатов

    Данная группа регистров используется для передачи результатов работы вершинного шейдера дальше по графическому конвейеру: сначала полученные результаты интерполируются вдоль поверхности примитива, а затем поступают на вход пиксельного шейдера. Так как первые версии вершинных и пиксельных шейдеров предполагалось применять только для визуализации примитивов с использованием незначительных вариаций классических алгоритмов, все выходные регистры Vertex Shader 1.1 являются специализированными и предназначены для хранения определенного типа данных (рисунок 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) Выходные регистры

    Первые GPU в точности следовали спецификации Vertex Shader 1.1, поэтому их выходные регистры были жестко заточены под хранение специализированных типов данных. Соответственно, любая попытка использования данных регистров не по прямому назначению была чревата различными побочными эффектами вроде переполнения или потери точности. Но по мере развития графических процессоров данная специализация становилась все более условной: все современные GPU начиная с NV4x и R5xx содержат универсальные выходные регистры, на которые отображаются выходные регистры виртуального вершинного процессора.

    Вероятно, вы уже обратили внимание, что названия и назначения выходных регистров удивительно напоминают семантики HLSL выходных данных вершинного шейдера. Это не случайно: исторически семантики предназначались именно для привязки выходных данных вершинного шейдера HLSL к регистрам виртуального вершинного процессора (таблица 5.3). И только потом, по мере развития GPU их функция свелась к банальной стыковке между собой выходных данных вершинных и входных данных пиксельных шейдеров. Таким образом, для написания на языке HLSL качественных шейдеров для старых GPU очень важно представлять себе архитектуру виртуального вершинного процессора и его физическую реализацию.

    Соответствие между выходными регистрами вершинного процессора и семантиками HLSL
    Регистр Семанитики
    oPos 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 FOG

    Возможно, сейчас у вас буквально рябит в глазах от обилия регистров вершинного процессора. В этом нет нечего страшного, ведь мы вовсе не собираемся учиться писать вершинные шейдеры на ассемблере. Нам требуется всего лишь научиться сносно читать ассемблерный код шейдера, сгенерированный компилятором HLSL, используя в качестве шпаргалки данный материал. В общем, научиться действовать по принципу "чукча не писатель, чукча читатель ".

    5.3.2. Команды

    Получив представление о регистрах, давайте познакомимся с форматом команд языка Vertex Shader 1.1. Если вы уже сталкивались с программированием процессоров архитектуры x86, то заметите некоторое сходство между ассемблерными командами языка Vertex Shader и командами процессоров x86. Все команды вершинного процессора имеют следующий синтаксис:

    op dst, src0 [, src1] [, src2]

    где

  • op - идентификатор команды.
  • dst - регистр назначения, в который записываются результаты команды.
  • src0, src1, src2 - регистры-операнды с исходными данными. Количество операндов варьируется от команды к команде.
  • Важной особенностью языка Vertex Shader 1.1 является жесткое ограничение на размер вершинного шейдера: число ассемблерных команд не может превышать 128. Это не так уж и много, поэтому разработчикам нередко приходится бороться буквально за каждую команду, чтобы втиснуть алгоритм в прокрустово ложе вершинного процессора.

    Чтобы получить представление о функциональных возможностях виртуального вершинного процессора, рассмотрим некоторые часто используемых команды вершинных шейдеров.

    MOV - Пересылка данных

    Начнем с команды пересылки из регистра в регистр, синтаксис которой очень сильно напоминает аналогичную команду процессоров семейства x86:

    mov dst, src

    где

  • dst - регистр приемник;
  • src - регистр источник.
  • Следующая команда копирует содержимое константного регистра c2 во временный регистр r5:

    mov r5, c2

    Язык Vertex Shader позволяет обращаться к отдельным компонентам векторного регистра: для этого после названия регистра необходимо поставить точку ". " и перечислить названия компонентов, к которым вы собираетесь обратиться. При этом допускается переставлять компоненты местами и многократно дублировать один и тот же компонент вектора. В общем, синтаксис очень напоминает синтаксис языка HLSL для доступа к отдельным компонентам вектора. Так же имеется возможность изменить знак компонентов регистра перед передачей в команду. Например, следующая команда занесет в регистр r5 лишь первые три компонента регистра c2 с измененными знаками, при этом первые два компонента будут переставлены местами:

    mov r5.xyz, -c2.yxz

    ADD – Сложение

    Команда add выполняет сложение двух регистров:

    add dst, src0, src1

    где

  • 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: frc r0.xy, r1.

    Команда frc в действительности является макрокомандой, которая разбивается на три команды. Принимая во внимание жесткие ограничения на длину вершинного шейдера, это весьма немаловажный нюанс. Многие вершинные процессоры поддерживают ее на аппаратном уровне, в результате чего при компиляции ассемблерного кода Vertex Shader 1.1 в микрокод вершинного процессора развернутый макрос frc может быть вновь заменен одной командой. Однако ограничение длины вершинного шейдера накладываются именно на число команд промежуточного кода Vertex Shader 1.1, поэтому даже если текущий вершинный процессор аппаратно поддерживает команду frc, при подсчете длины шейдера она все равно будет засчитана за три команды.

    RCP - вычисление 1/x

    Выполняет деление единицы на скалярный аргумент:

    rcp dst, src0

    где

    dst.xyzw=1/src0

    src должен быть скалярной величиной. Если src0 равен 0, в dst заносится максимальное значение с плавающей точкой, поддерживаемое данным GPU (обычно порядка $$10^38$$ ).

    Примечание

    GPU NVIDIA начиная с 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. Должна быть скалярной величиной.
  • Алгоритм работыВ Vertex Shader 2.0 команда EXPP претерпела серьезные изменения :

    $$dest.x = 2^floor(src0)\\ dest.y = src0 - floor(src0)\\ dest.z = 2^src\\ dest.w = 1 $$

    Примечание

    Побочные результаты работы команды часто используются, например, для нахождения дробной части числа.

    Значение компонента 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

    где

    dst.xyzw = 2src0

    Данная команда в действительности является макрокомандой, транслируемой в 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.

    Примечание

    Чтобы быстро найти информацию о незнакомой команде языка Vertex Shader откройте документацию по "неуправляемому " DirectX (Start | All Programs | Microsoft DirectX SDK | DirectX Documentation | DirectX SDK Documentation for C++) и введите во вкладке Index название интересующей вас команды. А для быстрого доступа к описанию всех команд и регистров интересующей вас версии Vertex Shader наберите во вкладке Index нужный идентификатор: vs_1_1, vs_2_0, vs_2_x или vs_3_0.

    5.3.3. Разбираем код простого шейдера

    Ну что ж, настало время попрактиковаться в использовании полученных знаний на практике. В качестве упражнения мы проанализируем ассемблерный код нашего старого знакомого – эффекта вершинной закраски 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

    Первая директива ассемблерного кода задает версию языка Vertex Shader, на котором написан эффект. В настоящее время поддерживаются 4 директивы c интуитивно понятными названиями, каждая из которых соответствует определенной версии языка Vertex Shader: vs11, vs20, vs2x и vs30. Нетрудно догадаться, что ассемблерный код нашего шейдера написан на версии 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 избавился от лишней временной переменной luminance, а так же заменил присвоение значений трем компонентам r, g, b одним скалярным присваиванием. Но заменить два сложения и унижение векторным произведением ему все же не хватило сообразительности.

    Продолжим анализ кода вершинного шейдера. Следующая команда mad может ввести начинающего разработчика в замешательство. Откуда она взялась, ведь HLSL -код вершинного шейдера не содержит чего-либо подобного? И что же она выполняет? Давайте немного подумаем. Данная команда madd использует в качестве аргументов координаты вершины из регистра v0 и компоненты константного регистра c0, а результат заносится в выходной регистр oPos, соответствующий выходным координатам вершины. Попробуем подставить в выражение, вычисляемое командой 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, его обработка на GPU NV4x и 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 на панели Shader Perfomance ) мы увидим, что время обработки вершины одним вершинным процессором сократилось с 7 до 3-х тактов, то есть в 2.3 раза. И если раньше видеокарта GeForce 7800 GTX могла обработать за 1 секунду 491.000.000 вершит, то теперь теоретическая производительность шейдера достигла немыслимого темпа 1.146.000.000 вершин в секунду.

    5.4. Передача параметров в эффект

    Предположим, что нам необходимо написать эффект, моделирующий визуализацию поверхности сквозь цветное стекло, пропускающее лишь часть света. Прозрачность стекла будет задаваться тремя коэффициентами, лежащими в диапазоне [0..1] и указывающими прозрачность стекла для красного, зеленого и синего компонентов цвета объекта. Если коэффициент равен 1, то стекло пропускает данный компонент цвета без изменений, если 0 - вообще не пропускает, а при промежуточных значениях 0..1 ослабляет яркость цветового компонента по мере уменьшения коэффициента. Итоговый цвет объекта определяется с использованием следующей формулы:

    $$c_r=k_r-o_r c_g=k_g-o_g c_b=k_b – o_b $$

    где

  • $$c_r, c_g, c_b$$ - итоговый цвет;
  • $$o_r, o_g, o_b$$ - исходный цвет объекта;
  • $$k_r, k_g, k_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 налету. Однако такой подход имеет ряд существенных недостатков: компиляция эффекта и загрузка его в GPU занимают заметное время, что неминуемо окажет отрицательное влияние на производительность приложения. Кроме того, такие динамически генерируемые эффекты очень трудоемко сопровождать и отлаживать.

    Поэтому разработчики языка 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 с ассемблерным кодом вершинного шейдера эффекта, полученный посредством вкладки Shader Perfomance:

    // 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, не затрагивая собственно код вершинного шейдера.

    Примечание

    Нетрудно догадаться, что максимальное количество параметров, принимаемых эффектом, ограничено и зависит от числа константных регистров. При этом следует помнить, что константы, используемые в эффекте, тоже неявно помещаются в константные регистры, уменьшая максимально число параметров, которые может принимать эффект.

    5.4.1. Работа с параметрами эффектов в XNA Framework

    В 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 являются просто константными регистрами GPU, содержимое которых можно трактовать по-разному в зависимости от ситуации. Например, значение цвета rgba можно трактовать как четырехмерный вектор, два двухмерных вектора или массив из четырех скалярных элементов.

    Примечание

    При некорректном обращении к параметру эффекта (например, при попытке записать трехмерный вектор в параметр 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 – гораздо удобнее получить исключение при загрузке приложения рядом с "проблемным методом ", чем где-то глубоко в дебрях приложения спустя несколько минут работы.

    5.5. Шейдерный фейерверк

    Итак, теперь вы уже знакомы с основами языков HLSL и Vertex Shader 1.1. Настало время опробовать полученные знания в более-менее сложном проекте. Ведь как гласит народная мудрость, теория без практики бесполезна, а практика без теории может быть даже вредна.

    В качестве отправной точки для приложения мы возьмем хранитель экрана из 4-й главы и поставим перед собой "сверхзадачу ": реализовать функциональность данного хранителя экрана, используя исключительно вершинные шейдеры. Иными словами, центральный процессор должен будет отсылать на видеокарту только команды "нарисовать диск " и "нарисовать искры ", а всю остальную работу по вращению диска и моделированию полета искр должен выполнять вершинный процессор GPU. Это весьма объемная и нетривиальная задача, поэтому мы разобьем ее на ряд более простых этапов, по мере реализации которых мы продолжим знакомиться с новыми возможностями HLSL и языка Vertex Shader 1.1.

    5.5.1. Моделирование вращения диска

    Код хранителя экрана, выполняющий поворот диска устроен очень просто: сначала приложение вычисляет текущий угол поворота диска, а затем рассчитывает новые координаты каждой вершины диска (листинг 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). Это позволит нам, если что-то пойдет не так, легко поставить точку останова в коде шейдера и проверить корректность входных параметров или выполнить трассировку "шейдера " по шагам с просмотром состояния переменныхВ DirectX SDK имеется утилита PIX for Windows, позволяющая выполнять трассировку ассемблерного кода шейдера. Использование данной утилиты будет рассмотрено в следующей главе .

    // Эмулятор эффекта вращения диска
    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 команд виртуального вершинного процессора. Несовпадение числа инструкций и команд обусловлено макро-инструкцией frc, разворачиваемой в три команды вершинного процессора. Окинуть одним взглядом структуру данной программы весьма проблематично, поэтому нам придется последовательно проследить выполнение программы по шагам, и попытаться понять, что же они выполняет каждая ее команда.

    Примечание

    Данный пример наглядно демонтирует, что короткий код вершинного шейдера вовсе не означает малый размер ассемблерной программы, и что ограничение максимальной длины программы Vertex Shader 1.1 в 128 инструкций не так уж и много.

    Смысл первой команды весьма очевиден - она вычисляет сумму a = input.pos.y + angle и заносит результат в компонент w временного регистра r0. Следующая команда умножает и складывает полученное значение переменной с "чудными " константами, но ее смысл нам пока не ясен. Логично предположить, что эти действия имеют какое-то отношение к вычислению значения тригометрических функций sin и cos (виртуальный процессор Vertex Shader 1.1 не имеет инструкций для расчета синуса и косинуса). Что ж, давайте просто запишем это выражение как есть, заменив компоненты константных регистров их численными значениями:

    $$r1.x = a \cdot 0.159154937 + 0.25\\ r1.y = a \cdot 0.159154937 + 0.5 $$

    Присмотревшись внимательно к константе 0.159154937 мы обнаружим, что это есть нечто иное, как единица деленная на удвоенное число "пи":

    $$r1.x=\frac{\alpha}{2\cdot \pi}+0.25\\ r1.y=\frac{\alpha}{2\cdot \pi}+0.5 $$

    Следующая команда вычисляет дробную часть выражения и заносит ее в регистр r0:

    $$r0.x=\{\frac{\alpha}{2\cdot \pi}+0.25\}\\ r0.y=\{\frac{\alpha}{2\cdot \pi}+0.5\} $$

    После обработки четвертой команды содержимое регистра r0 преобразится следующим образом:

    $$r0.x=\{\frac{\alpha}{2\cdot \pi}+0.25\}\cdot 6.28318548 - 3.14159274\\ r0.y=\{\frac{\alpha}{2\cdot \pi}+0.5\}\cdot 6.28318548 - 3.14159274 $$

    Нетрудно догадаться, что 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 в MathCad можно обнаружить, что они обладает двумя важными свойствами Они достаточно легко доказываются математически :

  • каким бы не было значение a, r0. y всегда находится в диапазоне $$[-\pi, +\pi)$$
  • $$\cos(a) = \cos(r0.y)$$
  • Следовательно, после выполнения второй, третьей и четвертой команд угол a преобразуется к диапазону $$[-\pi, +\pi)$$, при этом косинус угла остается неизменным. Зачем это надо? Логично предположить, что значение косинуса будет вычисляться путем разложения в ряд Тейлора, точность которого стремительно падает с ростом модуля аргумента. Так что данное преобразование позволяет значительно повысить точность вычисления функции cos(a) для больших аргументов. Кстати, если бы не это преобразование, то по мере вращения круга точность вычислений косинуса стремительно снижалась и, в конце концов, круг перестал бы корректно вращаться.

    C компонентом x регистра r0 все несколько запутаннее:

  • Значение r0.x находится в диапазоне $$[-\pi, +\pi)$$
  • $$\sin(a) = \cos(r0.x)$$
  • Очевидно, команды 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 :

    $$r1x=-2.52398507\cdot 10^{-7}\cdot ax^2+2.47609005\cdot 10^{-5}\\ r1y=-2.52398507\cdot 10^{-7}\cdot ay^2+2.47609005\cdot 10^{-5} $$

    Шестая команда умножает регистр r1 на $$ax^2$$ и складывает с константой -0.00138883968:

    $$r1x=(-2.52398507\cdot 10^{-7}\cdot ax^2+2.47609005\cdot 10^{-5})\cdot ах^2 - 0.00138883968\\ r1y=(-2.52398507\cdot 10^{-7}\cdot ay^2+2.47609005\cdot 10^{-5})\cdot а^22 - 0.00138883968 $$

    Следующие три команды продолжают выполнение серии последовательных умножений на $$ax^2 $$ и сложений с константами. В результате после выполнения десятой команды в регистре r0 оказывается следующие значение Чтобы сделать выражение удобочитаемым, число знаков в коэффициентах было уменьшено.:

    $$r0.x=((((-2.523\cdot 10^{07}\cdot ax^2+2.476\cdot 10^{-7})\cdot ax^2-0.001388)\cdot ax^2+0.0416)\cdot ax^2-0.5)\cdot ax^2+1\\ r0.y=((((-2.523\cdot 10^{07}\cdot ay^2+2.476\cdot 10^{-7})\cdot ay^2-0.001388)\cdot ay^2+0.0416)\cdot ay^2-0.5)\cdot ay^2+1 $$

    Чтобы понять смысл данного выражения раскроем скобки и упорядочим коэффициенты ax и ay по убыванию степени:

    $$r0.x=1-0.5\cdot ax^2+0.0416\cdot ax^4-0.001388\cdot ax^6+2.476\cdot 10^{-5}\cdot ax^8-2.523\cdot 10^{-7}\cdot ax^{10}\\ r0.y=1-0.5\cdot ay^2+0.0416\cdot ay^4-0.001388\cdot ay^6+2.476\cdot 10^{-5}\cdot ay^8-2.523\cdot 10^{-7}\cdot ay^{10} $$

    Ничего не напоминает? Правильно, это разложение функции cos(x) в ряд Тейлора до члена десятой степени:

    $$\cos(x)=1-\frac{x^2}{2!}+\frac{x^4}{4!}- \frac{x^6}{6!}+\frac{x^8}{8!}-\frac{x^{10}}{10!}+\dots+(-1)^n\cdot \frac{x^{2n}}{(2\cdot n!)!} $$

    Таким образом, ассемблерные команды с пятой по десятую вычисляют косинус угла. Соответственно, после выполнения десятой команды в компоненте y регистра r0 находится косинус угла, а в компоненте x -синус угла (как вы помните $$\sin(a) = \cos(ax), \cos(a) = \cos(ay)$$ ). При этом векторные регистры вершинного процессора позволили компилятору HLSL параллельно рассчитать значения обоих тригонометрических функций.

    Точность вычисления cos(x)

    Приблизительную оценку аппроксимации функции cos рядом Тейлора из пяти членов можно легко выполнить в том же MathCad, построив график модуля разницы между суммой пяти членов ряда Тейлора и встроенной функцией cos (рисунок 5.19). Как видно, по мере приближения модуля угла к $$\pi$$ абсолютная погрешность стремительно растет, достигая при угле равном $$\pi$$ величины порядка $$±2-10^-3 $$. Если такая точность недостаточна для ваших задач, можно попробовать уменьшить модуль значения аргумента cos, воспользовавшись тождеством $$\cos(x)=-\cos(x±\pi)$$.

    (рис 5.19) Оценка абсолютной погрешности вычисления косинуса посредством ряда Тейлора в Mathcad

    Остальной код весьма тривиален. Одиннадцатая команда умножает полученные значения синуса и косинуса на расстояние вершины до центра и записывает результат в компоненты x и y регистра oPos . Двенадцатая команда дописывает в компоненты z и w этого регистра значение 0 и 1. И, наконец, тринадцатая команда записывает в выходной регистр цвета цвет текущей вершины из регистра v1 .

    Рисунок 5.20 резюмирует весь вышеприведенный анализ кода, устанавливая соответствие между ассемблерным и HLSL кодом шейдера. Однако следует ясно осознавать, что это всего лишь код для виртуального вершинного процессора, который будет скомпилирован драйвером видеокарты в код для конкретного реального вершинного процессора. А архитектура физического вершинного процессора может иметь множество нюансов. Например, все вершинные процессоры современных видеокарт являются суперскалярными и могут запускать несколько ассемблерных инструкций за такт, в частности вершинные процессоры R3xx и R4xx могут выполнить за один такт одну векторную инструкцию над 1-4 компонентным вектором и одну скалярную инструкцию (если они, разумеется, не зависят друг от друга по данным). Поэтому оптимизирующий компилятор драйвера видеокарты может переставлять инструкции местами для достижения большего параллелизма.

    (рис 5.20) Соответствие между листингом Vertex Shader 1.1 и HLSL

    Кроме того, многие вершинные процессоры имеют расширенный набор инструкций по сравнению со спецификаций Vertex Shader 1.1 . Так вершинные процессоры R2xx могут аппаратно вычислять дробную часть числа, соответственно если драйвер видеокарты в процессе компиляции эффекта в микрокод вершинного процессора обнаружит последовательности инструкций Vertex Shader 1.1 , соответствующих макросу frc , то он заменит их одной встроенной командой. Другой пример: видеокарты R4xx и выше содержат инструкцию аппаратного вычисления синуса и косинуса угла в диапазоне $$-\pi....+\pi$$ поэтому драйвер автоматически подменит разложение в ряд Тейлора вызовом данных встроенных функций.

    Тем не менее, оптимизирующий компилятор драйвера не всесилен, поэтому чем качественнее код Vertex Shader 1.1 и чем меньше явных атавизмов он содержит (вроде вычисления скалярного произведения серией инструкций add и mul вместо единственной инструкции dp4 ), тем вероятней драйвер видеокарты сможет сгенерировать оптимальный код.

    Таким образом, при написании эффекта в FX Composer 2.0 в качестве главного критерия оптимальности шейдера должен выступать не промежуточный код на языке Vertex Shader 1.1 , а количество тактов графического процессора, затрачиваемых на обработку одной вершины. В частности на видеокарте 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) как входные параметры вершинного шейдера. Чтобы это стало возможным, необходимо выразить косинус суммы и синус суммы через синусы и косинусы слагаемых, воспользовавшись известными формулами из школьного курса тригонометрии:

    $$\sin(\alpha+\beta)=\sin(\alpha)-\cos(\beta)+\cos(\alpha)-\sin(\beta)\\ \cos(\alpha+\beta)=\cos(\alpha)-\cos(\beta)-\sin(\alpha)-\sin(\beta) $$

    Задание: проведите оптимизацию эффекта примера Ch05\Ex06 , реализовав вычисление тригометрических функций посредством выражения 5.5, и выноса большей части бессмысленных трудоемких расчетов за пределы вершинного шейдера. Используя NVIDIA FX Composer 2.0 , оцените потенциальный прирост производительности (который, скорее всего, окажется более чем трехкратным).

    Подсказка

    Имея вектор с предварительно рассчитанными значениями тригометрических функций, выражение 5.5 можно вычислить всего при помощи двух действий: по парного перемножения значения тригометрических функций и последующего суммирования результатов. Но так как вторая формула использует вычитание, для реализации ее через сумму вам потребуется передать в эффект значение -sin (angle) . При этом чтобы облегчить работу оптимизирующему компилятору HLSL, входные параметры sin (angle), cos (angle), -sin (angle) желательно передавать в шейдер как один трехмерный вектор.

    Если у вас возникнут трудности при выполнении данного задания, вы всегда можете ознакомиться с готовым решением, которое можно найти в каталоге \Examples\Ch05\Ex07 .

    5.5.2. Оператор if языка HLSL

    Следующий этап - перенос расчета полета искр в вершенный шейдер - является гораздо более сложным и запутанным, поэтому, чтобы восстановить силы мы сделаем небольшой привал и поговорим об особенностях оператора if языка HLSL применительно к профилю vs11 .

    Подобно подавляющему большинству языков программирования HLSL содержит конструкцию выбора if :

    if (логическое условие)
    {
    блок 1 
    }
    else 
    {
    блок 2 
    }

    В принципе, на этом раздел можно было бы окончить, если бы не одна маленький нюанс: язык Vertex Shader 1.1 не содержит команд для управления ходом выполнения программы (условные переходы, циклы и т.п.). Эта особенность имеет далеко идущие последствия. Когда в программе на C# используется конструкцию if , то центральный процессор будет каждый раз выполнять только одну из ветвей блока if , что позволяет значительно сократить объем вычислений и повысить производительность Следует отметить, что современные процессоры с длинными конвейерами достаточно болезненно реагируют на хаотичные непредсказуемые условные переходы, поэтому при использовании оператора if следует соблюдать осторожность 85. А вот компилятор 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 - архитектуру Very Long Instruction Word (VLIW) – архитектурная особенность процессора с явным параллелизмом, к которых команды состоят из микроинструкций, определяющих операцию для каждого функционального устройства процессора. Примером процессора с VLIW-архитектурой является Intel Itanium и содержит два исполнительных блока В действительности исполнительных блоков несколько больше, но ограниченная функциональность Vertex Shader 1.1 позволяет задействовать только эти два блока.: один блок векторных операций ( add, mul, madd и т.п.) и один блок скалярных операций ( rcp, sin, cos и т.п.). Следовательно, в идеальных условиях при отсутствии зависимостей по данным вершинный процессор 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)

    Теперь все встало на свои места: так как Vertex Shader 1.1 не содержит инструкции сравнения на равенство двух чисел, сообразительный компилятор HLSL реализовал проверку числа на равенство 0 через комбинацию инструкций mul и slt.

    Дополнительная информация

    Язык Vertex Shader 1.1 не содержит инструкции проверки двух чисел на равенство, так как потребность в ней возникает достаточно редко. Дело в том, что Vertex Shader 1.1 поддерживает только типы с плавающей точкой, сравнение которых является достаточно нестабильной операцией: достаточно малейшей ошибки в последнем разряде и два равных значения перестанут быть равными.

    Но в нашем случае мы можем не опасаться каких-либо последствий потери точности: данная особенность типов 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$$.

    Еще раз хочу обратить ваше внимание, что данные парадоксы возникают исключительно при работе с дробными числами, которые часто (но не всегда) не могут быть точно переставлены в двоичной системе. Целые же числа всегда могут быть точно переведены в двоичное представление, поэтому работа с ними осуществляется без потери точности 88. В частности если мы предварительно умножим a и b на 10, то значение 12-14 окажется в точности равно 168:

    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 время выполнения вершинного шейдера возросло на один такт).

    Разумеется, при условии достаточной разрядности мантиссы.

    5.5.3. Искры

    После небольшого лирического отступления перейдем к моделированию полета искр средствами вершинного шейдера. Для начала освежим в памяти алгоритм генерации новых искр. Логика работы хранителя экрана выполняется с фиксированным шагом timeStep . В течение каждого дискретного шага появляется случайное количество новых искр, но не более maxScintillaCount . Каждая искра имеет случайные координаты, а так же случайную угловую и прямолинейную скорости. Время жизни каждой искры равно StartTime , по прошествии которого искра считается потухшей и ее структура может использоваться для генерации новой вершины: новые искры замещают потухшие, и лишь при отсутствии свободных потухших искр информация добавляется в конец массива вершин.

    Данный алгоритм весьма проблематично реализовать в вершинном шейдере. Дело в том, что вершинный процессор обрабатывает вершины параллельно, независимо друг от друга, в результате чего вершинный шейдер не может получить информацию о состоянии других вершин. Ну а так как состояние шейдера не сохраняется между вызовами, а вернуть информацию из вершинного шейдера очень проблематично Для этого необходимо выполнить визуализацию во временное изображение (текстуру) и скопировать его из видеопамяти в оперативную память. Но так как данная операция является очень медленной, ее стараются использовать только в случае крайней необходимости , приложение не сможет отслеживать состояние каждой вершины и соответственно определять число потухших вершин, их индексы в массиве вершин и т.д. и т.п.

    Поэтому для генерации новых вершин нам придется разработать новый алгоритм, удовлетворяющим двум требованиям:

  • Постоянно изменяющиеся параметры должны быть общими для всех вершин, чтобы их можно было передавать через константные регистры.
  • Обработка вершин должна идти параллельно и независимо. При этом отсутствует возможность сохранения промежуточных значений между вызовами вершинного шейдера.
  • Будучи зажатыми в такие жесткие рамки, мы вряд ли сможем реализовать полноценный алгоритм генерации случайных искр со случайными параметрами. Поэтому мы просто заранее рассчитаем на некотором небольшом временном интервале время появления всех искр, их координаты, скорости, цвета и т.п., после чего будем проигрывать эту последовательность "по кругу " (рисунок 5.21). Таким образом, траектория движения точек будут повторяться через некоторое время, но если длительность одной итерации будет измеряться в десятках секунд, а число искр тысячами, то пользователь вряд ли сможет заметить какую-либо цикличность в поведении искр.

    (рис 5.21) Циклическая работа фейерверка

    Минусом данного подхода является необходимость расчета в каждом кадре всех искр массива вершин, включая потухшие искры, причем, чем сильнее продолжительность итерации превосходит время жизни вершины, тем выше будут накладные расходы. Хотя с другой стороны этот недостаток наверняка компенсируется огромной производительностью вершинного процессора Кроме того, в следующей главе мы примем ряд мер, призванных повысить эффективность обработки неиспользуемых вершин, в результате чего данный недостаток сведется к минимуму . Время, используемое при расчете траектории движения вершины, будет вычисляться кодом следующего вида:

    currentTime = time - vertexStartTime;
    localTime = currentTime % timeLoop;

    где

  • time - время, прошедшее с момента запуска приложения.
  • vertexStartTime - время появления вершины, отсчитываемое он начала итерации. После запуска хранителя экрана идет первая итерация, то есть время отсчитывается от нуля.
  • % - операция нахождения остатка от деления.
  • timeLoop - длительность итерации, то есть время, через которое полет вершины повторяется заново.
  • localTime - локальное время искры внутри итерации.
  • На рисунке 5.22 приведен график зависимости currentTime от time , построенный в Mathcad . Как видно, при запуске приложения время может быть равно отрицательному значению - это означает, что искра еще не появилась на экране. Далее по мере увеличения 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 $$

    где

  • $$tSpeed $$ - скорость вершины;
  • $$tSpeed_0 $$ - начальная скорость вершины;
  • $$tSlowing $$ - замедление вершины;
  • $$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$$:

    $$Distance_0=0 \cdot tSpeed_0-\frac{tSlowing \cdot 0^2}{2}+c=c $$

    Таким образом, константа C равна начальному положению точки и в результате мы получаем окончательную формулу:

    $$distance=distance_0+tSpeed_0 \cdot t-\frac{tSlowing \cdot t|^2}{2} $$

    Но это еще не все - выражение 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} $$

    где

  • $$localTime $$ - локальное время вершины;
  • $$tr $$ - локальное время с учетом остановки вершины по достижению скоростью нулевого значения;
  • $$angle $$ - угол поворота искры вокруг диска;
  • $$diskSpeed $$ - скорость вращения диска;
  • $$time $$ - текущее время;
  • $$angle_0 $$ - локальный угол поворота вершины вокруг диска (без учета вращения самого диска);
  • $$rSpeed_0 $$ - начальная уголовная скорость вершины (она меньше скорости диска);
  • $$rSlowing $$ - замедление вершины.
  • Искра вылетает все время из одного и того же места диска, но так как диск постоянно вращается, начальный угол поворота вершины все время оказывается разным. Такой подход имеет два достоинства: цвет вершины остается постоянным, а сами траектории движения вершин становятся более хаотичными. Для учета угла поворота диска в момент появления вершины используется слагаемое $$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 были переписаны с использованием схемы Горнера, позволяющей сократить число операций при вычислении степенного многочлена. Кроме того, такая запись хорошо ложится на команду mad вершинного процессора. Так же код эффекта содержит конструкцию 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 на GPU , мы будем визуализировать 300.000 искр. Так как ряд видеокарт (например, Intel GMA 9xx ) не могут визуализировать такое количество примитивов за один присест 92, приходится автоматически разбивать массив вершин на ряд "подмассивов " меньшего размера, поддерживаемых текущей видеокартой. Так же стоит отметить, что из-за эмуляции вершинных шейдеров диска и искр, они используют общий эффект и декларацию формата вершины.

    Код визуализации искр является достаточно тривиальным, если не считать того факта, что искры могут храниться в разных массивах вершин (листинг 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 кадров в секунду, что явно недостаточно для обеспечения плавной анимации. Но уверяю вас, что к концу шестой главы частота кадров будет измеряться в сотнях кадров в секунду, причем это прирост будет достигнут без какого-либо ухудшения качества изображения.

    Производительность примера Ch05\Ex10 на разных GPU
    Конфигурация компьютера 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, ATI Radeon x700 Pro, Windows XP Pro SP2, ASUS Driver 8.05 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
    ...
    } 
    }

    Анализ исходного кода эффекта

    Сейчас вы уже вполне неплохо освоились с языком Vertex Shader 1.1 , поэтому выполнять построчный анализ ассемблерного кода вряд ли имеет смысл. Вместо этого я сразу приведу отчет 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-ю). Это обусловлено тем, что язык Vertex Shader 1.1 не содержит инструкции вычисления остатка отделения, соответственно компилятору приходится эмулировать ее посредством скудного набора инструкций. Ниже приведена реконструкция алгоритма нахождения остатка от деления на языке 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; 
    }

    Отдельно стоит отметить нахождение дробной части числа, до сих выполнявшаяся посредством макроса frc . Но при анализе кода нахождения остатка дизассемблер не смог распознать эту операцию, что дало нам возможность воочию увидеть, что в действительности скрывается за макросом frc. Думаю, вы ожидали увидеть здесь все что угодно, только не команду expp, вычисляющую приближенное значение $$2^n$$. Правда компилятора интересует не само значение $$2^n$$, а побочный результат команды, заносящей в компонент y вектора-результата дробную часть числа ( a – floor(a) ). В целом же из всего вышесказанного следует вывод, что, несмотря на обманчиво простой вид, оператор % языка HLSL является очень "дорогой " операцией, соизмеримой по времени выполнения с вычислением тригонометрических функций.

    Чтобы максимально задействовать суперскалярную архитектуру современных вершинных процессоров, компилятор HLSL изменил их порядок следования, чтобы избавиться от зависимости соседних инструкций. Обратной стороной медали является сложность анализа кода: инструкции многих операторов HLSL перемешались между собой, а код строки float remainTime = liveTime – localTime, расположенной в начале эффекта, был перенесен компилятором ближе к концу шейдера.

    Еще одной любопытной особенностью является код оператора if, составное условное выражение которого содержит логическую операцию "и " – так как язык Vertex Shader 1.1 не поддерживает булевские типы и логические операции над ними, оператор эмулируется перемножением чисел с плавающей точкой.

    Оптимизация вершинного шейдера

    И, наконец, анализируя код строки float2 sCoord = input.pos.yz + t * (input.texcoord - t * slowing / 2.0f) мы обнаружим, что компилятор не смог догадаться предварительно вычислить значение константы slowing / 2.0f, что вылилось в один лишний оператор mul. Это дает нам основание предположить, что добавив в файл HLSL явное вычисление константы, мы сможем немного ускорить работу приложения. Но наверняка быть уверенным нельзя, ведь Vertex Shader 1.1 является всего лишь промежуточным кодом, впоследствии еще раз оптимизируемым компилятором драйвера.

    Ну что ж, рискнем. Основные фрагменты эффекта с модифицированным вершинным шейдером приведены в листинге 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.5.4. Анализ производительности приложения

    Настало время оценить эффект от переноса вычислений на видеокарту. На рисунке 5.23 приведена диаграмма, построенная в Excel по результатам измерения производительности примеров Ch05\Ex10 и Ch05\Ex12 на разных GPU.

    (рис 5.23) Производительность примеров Ch05\Ex10 и Ch05\Ex12 на разных GPU

    Как видно, на GeForce 7600GT и Radeon X700 Pr o перенос вычислений с CPU на GPU увеличил частоту кадров почти в 10 раз. На компьютере с интегрированным GPU i946GZ (GMA 3000) частота кадров тоже заметно увеличилась (в 3.5 раза), что на первый взгляд выглядит весьма странно: i946GZ не содержит аппаратного вершинного процессора, поэтому все вычисления по-прежнему выполняются силами центрального процессора.

    Данный парадокс обусловлен рядом факторов. Как известно, .NET приложения содержат множество вспомогательного кода для обнаружения различных внештатных ситуаций вроде переполнения или обращения к несуществующему элементу коллекции. Разумеется, этот код оказывает отрицательное влияние на производительность, усугубляемое многократным его выполнением в цикле. Кроме того, все современные процессоры еще со времен Pentium-III содержат специализированный векторные регистры SSE и набор векторных инструкций, отдаленно напоминающие ассемблерные команды языков Vertex Shader. Но язык C# и промежуточный язык IL не содержат векторных команд, что затрудняет распознавание векторных операций при компиляции JIT -компилятором IL -кода exe-файла в машинный код. В результате, итоговый машинный код практически не содержит SSE -инструкций и векторные блоки центрального процессора фактически простаивают.

    При использовании вершинных шейдеров все обстоит несколько иначе. На i946GZ и аналогичных GPU без аппаратных вершинных процессоров вершинные шейдеры эмулируются DirectX посредством специальной подсистемы Processor Specific Geometry Pipeline (PSGP). PSGP автоматически выполняет компиляцию вершинного шейдера в набор инструкций текущего CPU, задействовав весь потенциал данного процессора на 100%. Полученный код активно использует блоки SSE, параллельную обработку нескольких вершин всеми ядрами CPU и не содержит каких-либо ненужных промежуточных проверок "на всякий случай ". В результате он работает заметно быстрее по сравнению с аналогом на C#, что мы и наблюдаем.

    Итак, вершинные шейдеры позволяют значительно поднять производительность приложения. Но не стоит забывать, что это упреждение верно лишь при сравнении производительности C# и HLSL -кода, использующего одинаковый алгоритм. Центральный процессор предоставляет разработчику использовать значительно более гибкие алгоритмы, так что на практике все обстоит несколько сложнее. Но в любом случае, вершинные шейдеры позволяют разгрузить центральный процессор, освободив его ресурсы для других задач.

    Заключение

    В этой лекции мы познакомились с новыми возможностями языка HLSL применительно к программированию вершинных шейдеров: работе с отдельными компонентами вектора, математическими операторами, встроенными функциями, параметрами эффекта и особенностями оператора if. Так же была рассмотрена IDE для разработки шейдеров NVIDIA FX Composer 2.0, которая, учитывая рост сложности наших эффектов, пришлась как нельзя кстати. Учитывая, что вершинный шейдер выполняется для каждой вершины, число которых может измеряться сотнями тысяч, очень важно уделять внимание качеству кода и оптимизации вершинного шейдера. А для этого очень полезно иметь хотя бы поверхностное представление о том, что твориться под капотом HLSL, в частности о языках Vertex Shader. Поэтому мы изучили основы архитектуры виртуального процессора Vertex Shader 1.1 и его систему команд.

    Страницы:

    Вот уже на протяжении трех лекций мы активно используем в приложениях вершинные и пиксельные шейдеры, однако их роль сводится к банальному пропусканию через себя исходных данных без какой-либо обработки или модификации. Такой подход трудно назвать оптимальным, ведь главное предназначение шейдеров – разгрузка центрального процессора компьютера путем освобождения его от рутины. Но для этого необходимо более детально изучить язык HLSL и получить представление об архитектуре графического процессора и языках ассемблера. В виду обширности этой темы данная лекция посвящена преимущественно программированию вершинных процессоров.

    Примечание

    Если вы немного подзабыли основы языка HLSL, можете еще раз пролистать раздел 2.3.

    5.1. Математические вычисления в HLSL

    Функциональность любого шейдера так или иначе связана с математическими расчетами, поэтому для начала мы научимся выполнять математические операции над типами языка HLSL.

    5.1.1. Математические операторы

    Математические операторы языка 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-битной точностью для каждого компонентаЭто утверждение верно только для вершинных шейдеров. В пиксельных шейдерах точность вычислений зависит от множества факторов (см. раздел 7.x).. Таким образом, замена в вышеприведенном коде типов float4 на half4 или double4 некоим образом не скажется на скорости или точности расчетовА вот использование целочисленных типов вроде int4 может привести к тому, что компилятор HLSL будет пытаться честно эмулировать целочисленные вычисления посредством 32-х битных типов с плавающей точкой (см. раздел 2.3.2)..

    5.1.2. Работа с компонентами векторов

    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;

    5.1.3. Математические функции

    В языке HLSL имеется множество математических функций для работы со скалярными и векторными типами: тригонометрические и гиперболические функции, вычисление скалярного и векторного произведения векторов и так далее. Полный список функций языка HLSL можно найти в приложении 4. Обратите внимание, что список доступных функций определяется используемым профилем.

    Большинство функций HLSL транслируются в одну команду графического процессора. При этом, каждая команда графического процессора, как правило, выполняется за 1 такт. Поэтому рекомендуется как можно активнее использовать встроенные функции, а не изобретать велосипед. Например, выражение $$b=\frac {1}{\sqrt q}$$ можно записать как b=1.0/sqrt(a), либо как b=rsqrt(a). Первый вариант будет транслирован в две команды GPU (вычисление квадратного корня и деление), а второй - в одну. Нетрудно догадаться, что какой из них будет работать быстрее.

    Примечание

    Оптимизирующий компилятор HLSL, скорее всего, самостоятельно заменит выражение b=1.0/sqrt(a) на b=rsqrt(a). Однако в более сложных случаях у него может не хватить сообразительности, чтобы подобрать оптимальную замену.

    5.1.4. Черно-белая закраска

    В качестве демонстрации практического использования математических расчетов мы напишем простой эффект, преобразующий цвет примитивов в черно-белый с использованием следующего выражения:

    $$l=\frac{(r+g+b}{3} r=l b=l $$

    где

  • $$r, g, 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.1) предполагает, что человеческий глаз имеет одинаковую чувствительность к красному, зеленому и синему цвету. В действительности это не так - например, человеческий глаз значительно более чувствителен к зеленому цвету, чем к синему. Для учета этого факта национальным комитетом по телевизионным системам США ( NTSC ) было принято решение вычислять яркость по следующей формуле:

    $$l=0.299*r+0.587*g+0.114*b $$

    Создайте эффект, осуществляющий преобразование в черно-белый цвет с использованием формулы 5.4. В качестве отправной точки можно воспользоваться примером Ch05\Ex01. Готовое приложение находится в example.zip в каталоге Examples\Ch05\Ex02.

    5.2. NVIDIA FX Composer 2.0

    До сих пор мы создавали файлы эффектов .fx в обыкновенном текстовом редакторе. В принципе, в этом нет ничего плохого. В конце концов, некоторые разработчики создают .NET приложения в простых текстовых редакторах с последующей компиляцией полученного .cs -файла из командой строки компилятором C# ( csc.exe ). Однако по мере усложнения разрабатываемых проектов использование специализированных средств разработки становится все более актуальным. Как ни крути, та же IDE Visual Studio значительно облегчает процесс разработки благодаря умному редактору с подсветкой синтаксиса, технологии IntelliSense, интегрированному компилятору, отладчику и справочной системе.

    По мере изучения XNA наши эффекты будут становиться все сложнее, поэтому будет разумно заблаговременно подыскать интегрированную среду разработки эффектов. В действительности, наш выбор не велик - на рынке сейчас господствуют два бесплатных пакета для разработки эффектов: ATI RenderMonkey 1.6 и 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, GLSLOpenGL Shading Language (GLSL) – язык программирования шейдеров, используемый в API OpenGL и CgCg – язык программирования шейдеров, разработанный корпораций NVIDIA. Поддерживает как API DirectX, так и API OpenGL . Возможно, это слишком громко сказано, ведь FX Composer уступает Visual Studio 2005 практически по всем параметрам: удобству пользовательского интерфейса, технологии IntelliSense, документации и так далее. Кроме того, в текущей версии FX Composer имеется ощутимое количество багов. Впрочем, в этом нет ничего удивительного, если сравнить количество человеко-часов, затраченных на создание Visual Studio и FX Composer. Кроме того, NVIDIA FX Composer является абсолютно бесплатным, что позволяет закрыть глаза на многие недостатки - как известно, на халяву и уксус сладок.

    FX Composer 2.0 в первую очередь ориентирован на работу с файлами формата COLLADA версии 1.4.1, поэтому для понимания основных принципов организации пользовательского интерфейса полезно ознакомиться с основами этого формата.

    5.2.1. Формат COLLADA 1.4.1

    COLLADA (COLLAborative Design Activity) - это кроссплатформенный открытый формат, используемый для обмена данными между приложениями создания цифрового контента ( DCCDigital Content Creation (DCC) – создание цифрового контента ). Формат 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 OpenGL ESOpenGL ES – подмножество API OpenGL, используемое в встраиваемых системах: мобильных телефонах, игровых приставках и т.п .
  • <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.

    5.2.2. Знакомство с интерфейсом FX Composer 2.0

    Ну что ж приступим. Для начала установите FX Composer 2.0Инсталлятор и запустите его из меню Start (Start | All Programs | NVIDIA Corporation | FX Composer 2 | FX Composer 2). На рисунке 5.2 приведен внешний вид стартового экрана FX Compose сразу после установкиЧтобы придать скриншоту большую выразительность, я добавил в сцену чайник, создал несколько материалов и применил один из материалов к чайнику. В остальном же внешний вид приложения мало чем отличается от отображаемого при первом запуске . Рассмотрим основные элементы пользовательского интерфейса.

    В верхней части окна расположено главное меню FX Composer, под которым находится панель инструментов ( Standard Toolbar ) для быстрого доступа к наиболее важным пунктам меню. Как и во всех современных IDE панель инструментов легко конфигурируется с учетом предпочтений пользователя.

    Примечание

    В FX Composer 2 имеется подробное руководство пользователя, которое можно открыть щелчком на ссылке User Guide на панели Start Page, отображаемой при первом запуске FX Composer.

    В центре экрана расположены вкладки трех панелей: Start Page, Shader Library и 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.
  • Панель Assets предназначена для работы с различными группами контента, поддерживаемого форматом COLLADA.
  • Панель Project, содержащая иерархию всех файлов проекта, в целом аналогична окну Solution Explorer из Visual Studio 2005.
  • В правой верхней части окна расположена панель Properties, позволяющая задавать значения входных параметров эффектов и материалов с использованием интуитивно понятного интерфейса. Ниже расположена вкладка Render, которая позволяет опробовать созданный материал на тестовой сцене, визуализируемой с использованием API OpenGL или 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.

    5.2.3. Создание нового проекта

    Лучший способ изучить 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. Замените текст созданного по умолчанию эффекта кодом из листинга 5.1Немного погодя мы оценим, насколько хорошо компилятор 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.dae, сгенерированный 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-файла

    Если эффект изначально разрабатывался в 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... и укажите файл, который необходимо открыть. В результате в проект будет добавлен новый эффект, содержащий указанный файл, который теперь можно легко отредактировать и откомпилировать.

    5.2.4. Анализ производительности эффекта

    При разработке шейдеров начинающие разработчики часто оказываются в положении буриданова осла, когда одну и ту же функциональность можно реализовать различными способами и при этом не совсем ясно, какой из них будет иметь большую производительность. В подобных ситуациях трудно переоценить полезность панели Shader Perfomance, позволяющей быстро прикинуть быстродействие эффекта на различных видеокартах NVIDIA с учетом многочисленных версий драйверов.

    Чтобы получить представление о возможностях данной панели мы проанализируем производительность эффекта BlackAndWhite, созданного в разделе 5.2.3. Для начала в панели Assets щелкните правой кнопкой мыши на узле эффекта, который вы собираетесь проанализировать, и выберите команду контекстного меню Analyze Performance, после чего в нижней части окна появится панель Shader 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 и GPU, позволяющие выбрать версии драйверов 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 и выше, вам следует пометить все GPU семейств NV4x и G7x. Далее аналогичным образом укажите интересующие вас версии драйверов.

    (рис 5.11) Диалоговое окно Setting и Select Default GPUs

    После нажатия OK в во вкладке BlackAndWhite.fx панели Shader Perfomance появятся флажки, соответствующие указанным графическим процессорам. Выделите флажки GPU и драйверов, интересующие вас в данный момент, и нажмите кнопку Graph в верхней части окна. На экране появится диаграмма производительности вершинного шейдера на выбранных графических процессорах (рисунок 5.12).

    (рис 5.12) Производительность шейдера на различных графических процессорах

    По диаграмме легко можно оценить, будет ли являться производительность вершинного шейдера ограничивающим фактором. Допустим, наша сцена содержит 100.000 треугольников. Значит, пренебрегая прочими факторами можно предположить, что визуализация данного сцены на самой медленной видеокарте семейства GeForce6 (GeForce 6200) будет выполняться с частотой 150.000.000 / 100.000 = 1.500 кадров секунду. Таким образов в данном конкретном случае вершинный шейдер не станет узким местом даже на самых дешевых видеокартах семейства GeForce6.

    Просмотр ассемблерного кода шейдера

    Еще одной интересной возможностью панели Shader Performance является просмотр скомпилированного промежуточного кода шейдера. Это очень мощная функциональность, позволяющая оценить качество сгенерированного ассемблерного кода и увидеть ошибки, допущенные компилятором. Возможно, последнее утверждение покажется вам несколько надуманным, но это действительно так. Компилятор 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 Shader Compiler версии 9.12.589.0000, причем в качестве промежуточного языка был использован Vertex Shader 1.1. Все остальное для нас не более чем китайская грамота, так что для оценки качества сгенерированного кода нам потребуется ознакомиться с азами языков Vertex Shader.

    На этом первое знакомство с NVIDIA FX Composer 2. 0 можно считать оконченным.

    5.3. Введение в языки Vertex Shader

    Языки семейства Vertex Shader предназначены для программирования виртуальных вершинных процессоров, причем каждому языку соответствует своя модель виртуального вершинного процессора. Вообще в основу каждой модели виртуального вершинного процессора положен вполне определенный реальный вершинный процессор, однако на практике данная особенность не играет особой роли, ведь код для виртуального процессора все равно компилируется драйвером видеокарты в машинный код текущего процессора. Кроме того, все эти модели виртуальных процессоров построены на общих принципах. Так что вы вполне можете думать о языках Vertex Shade r как о вариациях на тему языка IL, заточенных под векторные процессоры.

    5.3.1. Регистры

    Любой виртуальный вершинный процессор содержит набор регистров, напоминающих регистры SSE процессоров архитектуры x86. Большинство регистров (но отнюдь не все) являются векторными регистрами, рассчитанными на хранение четырехмерных векторов в формате с плавающей точкой. Разрядность регистров может быть произвольной, но на практике обычно равна 128-ми битам, то есть на каждый компонент вектора, как правило, отводится 32-бита (рисунок 5.13).

    (рис 5.13) 128-битный векторный регистр (32 бита на компонент).

    Данные, с которыми работает виртуальный процессор можно разделить на три большие группы (рисунок 5.14):

  • Исходные данные, передающиеся в вершинный шейдер через регистры исходных данных (координаты вершин, текущее время и т.д.).
  • Промежуточные результаты, хранящиеся в регистрах общего назначения.
  • Итоговые результаты, передаваемые дальше по графическому конвейеру посредством соответствующих выходных регистров вершинного процессора.
  • (рис 5.14) . Структура графического процессора

    Набор регистров существенно варьируется от версии к версии, поэтому чтобы сделать материал менее запутанным мы сосредоточимся исключительно на версии 1.1.

    Примечание

    Виртуальные процессоры Vertex Shader 1.1 и Pixel Shader 1.1 практически один в один повторяют архитектуру вершинных и пиксельных процессоров видеокарты GeForce3 (NV20) .

    Регистры исходных данных

    Регистры исходных данных виртуального процессора делятся на две подгруппы (рисунок 5.15).

  • 16 регистров v0, v1… v15 с информацией, специфичной для текущей вершины ( Input Registers ).
  • Не менее 96-ти векторных константных регистров c0, c1 … c95 ... cN с информацией, общей для всех вершин ( Constant Float Registers ).
  • Оба типа регистров являются векторными регистрами, в которых хранятся 4 компонента с плавающей точкой. У всех современных графических процессоров разрядность этих регистров равна 128 бит, т.е. каждый компонент вектора является 32-х разрядных числом с плавающей точкой. Но эта разрядность не является фиксированной и может измениться у будущих GPU. Кроме того, различные GPU могут несколько по-разному обрабатывать такие граничные ситуации как переполнение или деление на нуль, поэтому код шейдеров не должен быть заточен под фиксированную разрядность 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 Размер визуализируемой точкиУправление размером отдельных точек будет рассмотрено в разделе 6.x.
    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);

    Количество константных регистров зависит от видеокарты, однако поддержка видеокартой Vertex Shader 1.1 гарантирует наличие не менее 96 константных регистров. Точное количество константных регистров вершинных процессоров текущей видеокарты может быть получено посредством метода GraphicsDeviceCapabilities.MaxVertexShaderConstants класса GraphicsDevice. В таблице 5.2 приведена информация о количестве константных регистров у наиболее распространенных видеокарт.

    Примечание

    Число физических константных регистров может несколько превышать значение, возвращаемое GraphicsDeviceCapabilities.MaxVertexShaderConstants. Дополнительные регистры, как правило, используются драйвером для внутренних нужд, например, при эмуляции фиксированного графического конвейера из прошлых версий DirectX.

    Количество константных регистров у некоторых GPU
    GPU Количество константных регистров у вершинных процессоров
    NV2x 96
    NV3x 256
    NV4x 256
    G7x 256
    R2xx 192
    R3xx 256
    R4xx 256
    Intel GMA 9xxВершинные шейдеры на Intel GMA 9xx и GMA 3000 выполняются посредством CPU. 8192
    Intel GMA 3000 8192

    Забегая вперед, стоит отметить, что значения константных регистров могут изменяться приложением, что делает их идеальным средством для передачи параметров в вершинный шейдер (см. раздел 5.4).

    Регистры общего назначения

    Регистры общего назначения используются для хранения операндов и результатов команд, а так же для адресации массива константных регистров. Виртуальный вершинный процессор Vertex Shader 1.1 предполагает наличие двух типов временных регистров (рисунок 5.16):

  • 12 временных векторных регистров r0, r1 … r11 для хранения промежуточных результатов вычислений ( Temporary Registers ).
  • Один скалярный адресный регистр a0, используемый для косвенной адресации константных регистров ( Address Register ).
  • (рис 5.16) Регистры общего назначения

    Временные регистры являются аналогом регистров SSE процессоров архитектуры x86, и поэтому вряд ли нуждаются в каких-либо комментариях. Адресный регистр хранит смещение, которое может применяться при обращении к константному регистру в режиме косвенной адресации. Например, если адресный регистр содержит значение 2, то при обращении в режиме косвенной адресации к константному регистру c5 в реальности произойдет обращение к регистру c7. В ассемблерном коде такое обращение будет выглядеть как c[a0.x + 5] .

    Примечание

    Примитивная косвенная адресация, используемая в шейдерах, довольно сильно напоминает косвенную адресацию первых программируемых калькуляторов вроде HP-11C.

    Регистры итоговых результатов

    Данная группа регистров используется для передачи результатов работы вершинного шейдера дальше по графическому конвейеру: сначала полученные результаты интерполируются вдоль поверхности примитива, а затем поступают на вход пиксельного шейдера. Так как первые версии вершинных и пиксельных шейдеров предполагалось применять только для визуализации примитивов с использованием незначительных вариаций классических алгоритмов, все выходные регистры Vertex Shader 1.1 являются специализированными и предназначены для хранения определенного типа данных (рисунок 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) Выходные регистры

    Первые GPU в точности следовали спецификации Vertex Shader 1.1, поэтому их выходные регистры были жестко заточены под хранение специализированных типов данных. Соответственно, любая попытка использования данных регистров не по прямому назначению была чревата различными побочными эффектами вроде переполнения или потери точности. Но по мере развития графических процессоров данная специализация становилась все более условной: все современные GPU начиная с NV4x и R5xx содержат универсальные выходные регистры, на которые отображаются выходные регистры виртуального вершинного процессора.

    Вероятно, вы уже обратили внимание, что названия и назначения выходных регистров удивительно напоминают семантики HLSL выходных данных вершинного шейдера. Это не случайно: исторически семантики предназначались именно для привязки выходных данных вершинного шейдера HLSL к регистрам виртуального вершинного процессора (таблица 5.3). И только потом, по мере развития GPU их функция свелась к банальной стыковке между собой выходных данных вершинных и входных данных пиксельных шейдеров. Таким образом, для написания на языке HLSL качественных шейдеров для старых GPU очень важно представлять себе архитектуру виртуального вершинного процессора и его физическую реализацию.

    Соответствие между выходными регистрами вершинного процессора и семантиками HLSL
    Регистр Семанитики
    oPos 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 FOG

    Возможно, сейчас у вас буквально рябит в глазах от обилия регистров вершинного процессора. В этом нет нечего страшного, ведь мы вовсе не собираемся учиться писать вершинные шейдеры на ассемблере. Нам требуется всего лишь научиться сносно читать ассемблерный код шейдера, сгенерированный компилятором HLSL, используя в качестве шпаргалки данный материал. В общем, научиться действовать по принципу "чукча не писатель, чукча читатель ".

    5.3.2. Команды

    Получив представление о регистрах, давайте познакомимся с форматом команд языка Vertex Shader 1.1. Если вы уже сталкивались с программированием процессоров архитектуры x86, то заметите некоторое сходство между ассемблерными командами языка Vertex Shader и командами процессоров x86. Все команды вершинного процессора имеют следующий синтаксис:

    op dst, src0 [, src1] [, src2]

    где

  • op - идентификатор команды.
  • dst - регистр назначения, в который записываются результаты команды.
  • src0, src1, src2 - регистры-операнды с исходными данными. Количество операндов варьируется от команды к команде.
  • Важной особенностью языка Vertex Shader 1.1 является жесткое ограничение на размер вершинного шейдера: число ассемблерных команд не может превышать 128. Это не так уж и много, поэтому разработчикам нередко приходится бороться буквально за каждую команду, чтобы втиснуть алгоритм в прокрустово ложе вершинного процессора.

    Чтобы получить представление о функциональных возможностях виртуального вершинного процессора, рассмотрим некоторые часто используемых команды вершинных шейдеров.

    MOV - Пересылка данных

    Начнем с команды пересылки из регистра в регистр, синтаксис которой очень сильно напоминает аналогичную команду процессоров семейства x86:

    mov dst, src

    где

  • dst - регистр приемник;
  • src - регистр источник.
  • Следующая команда копирует содержимое константного регистра c2 во временный регистр r5:

    mov r5, c2

    Язык Vertex Shader позволяет обращаться к отдельным компонентам векторного регистра: для этого после названия регистра необходимо поставить точку ". " и перечислить названия компонентов, к которым вы собираетесь обратиться. При этом допускается переставлять компоненты местами и многократно дублировать один и тот же компонент вектора. В общем, синтаксис очень напоминает синтаксис языка HLSL для доступа к отдельным компонентам вектора. Так же имеется возможность изменить знак компонентов регистра перед передачей в команду. Например, следующая команда занесет в регистр r5 лишь первые три компонента регистра c2 с измененными знаками, при этом первые два компонента будут переставлены местами:

    mov r5.xyz, -c2.yxz

    ADD – Сложение

    Команда add выполняет сложение двух регистров:

    add dst, src0, src1

    где

  • 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: frc r0.xy, r1.

    Команда frc в действительности является макрокомандой, которая разбивается на три команды. Принимая во внимание жесткие ограничения на длину вершинного шейдера, это весьма немаловажный нюанс. Многие вершинные процессоры поддерживают ее на аппаратном уровне, в результате чего при компиляции ассемблерного кода Vertex Shader 1.1 в микрокод вершинного процессора развернутый макрос frc может быть вновь заменен одной командой. Однако ограничение длины вершинного шейдера накладываются именно на число команд промежуточного кода Vertex Shader 1.1, поэтому даже если текущий вершинный процессор аппаратно поддерживает команду frc, при подсчете длины шейдера она все равно будет засчитана за три команды.

    RCP - вычисление 1/x

    Выполняет деление единицы на скалярный аргумент:

    rcp dst, src0

    где

    dst.xyzw=1/src0

    src должен быть скалярной величиной. Если src0 равен 0, в dst заносится максимальное значение с плавающей точкой, поддерживаемое данным GPU (обычно порядка $$10^38$$ ).

    Примечание

    GPU NVIDIA начиная с 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. Должна быть скалярной величиной.
  • Алгоритм работыВ Vertex Shader 2.0 команда EXPP претерпела серьезные изменения :

    $$dest.x = 2^floor(src0)\\ dest.y = src0 - floor(src0)\\ dest.z = 2^src\\ dest.w = 1 $$

    Примечание

    Побочные результаты работы команды часто используются, например, для нахождения дробной части числа.

    Значение компонента 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

    где

    dst.xyzw = 2src0

    Данная команда в действительности является макрокомандой, транслируемой в 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.

    Примечание

    Чтобы быстро найти информацию о незнакомой команде языка Vertex Shader откройте документацию по "неуправляемому " DirectX (Start | All Programs | Microsoft DirectX SDK | DirectX Documentation | DirectX SDK Documentation for C++) и введите во вкладке Index название интересующей вас команды. А для быстрого доступа к описанию всех команд и регистров интересующей вас версии Vertex Shader наберите во вкладке Index нужный идентификатор: vs_1_1, vs_2_0, vs_2_x или vs_3_0.

    5.3.3. Разбираем код простого шейдера

    Ну что ж, настало время попрактиковаться в использовании полученных знаний на практике. В качестве упражнения мы проанализируем ассемблерный код нашего старого знакомого – эффекта вершинной закраски 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

    Первая директива ассемблерного кода задает версию языка Vertex Shader, на котором написан эффект. В настоящее время поддерживаются 4 директивы c интуитивно понятными названиями, каждая из которых соответствует определенной версии языка Vertex Shader: vs11, vs20, vs2x и vs30. Нетрудно догадаться, что ассемблерный код нашего шейдера написан на версии 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 избавился от лишней временной переменной luminance, а так же заменил присвоение значений трем компонентам r, g, b одним скалярным присваиванием. Но заменить два сложения и унижение векторным произведением ему все же не хватило сообразительности.

    Продолжим анализ кода вершинного шейдера. Следующая команда mad может ввести начинающего разработчика в замешательство. Откуда она взялась, ведь HLSL -код вершинного шейдера не содержит чего-либо подобного? И что же она выполняет? Давайте немного подумаем. Данная команда madd использует в качестве аргументов координаты вершины из регистра v0 и компоненты константного регистра c0, а результат заносится в выходной регистр oPos, соответствующий выходным координатам вершины. Попробуем подставить в выражение, вычисляемое командой 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, его обработка на GPU NV4x и 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 на панели Shader Perfomance ) мы увидим, что время обработки вершины одним вершинным процессором сократилось с 7 до 3-х тактов, то есть в 2.3 раза. И если раньше видеокарта GeForce 7800 GTX могла обработать за 1 секунду 491.000.000 вершит, то теперь теоретическая производительность шейдера достигла немыслимого темпа 1.146.000.000 вершин в секунду.

    5.4. Передача параметров в эффект

    Предположим, что нам необходимо написать эффект, моделирующий визуализацию поверхности сквозь цветное стекло, пропускающее лишь часть света. Прозрачность стекла будет задаваться тремя коэффициентами, лежащими в диапазоне [0..1] и указывающими прозрачность стекла для красного, зеленого и синего компонентов цвета объекта. Если коэффициент равен 1, то стекло пропускает данный компонент цвета без изменений, если 0 - вообще не пропускает, а при промежуточных значениях 0..1 ослабляет яркость цветового компонента по мере уменьшения коэффициента. Итоговый цвет объекта определяется с использованием следующей формулы:

    $$c_r=k_r-o_r c_g=k_g-o_g c_b=k_b – o_b $$

    где

  • $$c_r, c_g, c_b$$ - итоговый цвет;
  • $$o_r, o_g, o_b$$ - исходный цвет объекта;
  • $$k_r, k_g, k_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 налету. Однако такой подход имеет ряд существенных недостатков: компиляция эффекта и загрузка его в GPU занимают заметное время, что неминуемо окажет отрицательное влияние на производительность приложения. Кроме того, такие динамически генерируемые эффекты очень трудоемко сопровождать и отлаживать.

    Поэтому разработчики языка 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 с ассемблерным кодом вершинного шейдера эффекта, полученный посредством вкладки Shader Perfomance:

    // 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, не затрагивая собственно код вершинного шейдера.

    Примечание

    Нетрудно догадаться, что максимальное количество параметров, принимаемых эффектом, ограничено и зависит от числа константных регистров. При этом следует помнить, что константы, используемые в эффекте, тоже неявно помещаются в константные регистры, уменьшая максимально число параметров, которые может принимать эффект.

    5.4.1. Работа с параметрами эффектов в XNA Framework

    В 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 являются просто константными регистрами GPU, содержимое которых можно трактовать по-разному в зависимости от ситуации. Например, значение цвета rgba можно трактовать как четырехмерный вектор, два двухмерных вектора или массив из четырех скалярных элементов.

    Примечание

    При некорректном обращении к параметру эффекта (например, при попытке записать трехмерный вектор в параметр 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 – гораздо удобнее получить исключение при загрузке приложения рядом с "проблемным методом ", чем где-то глубоко в дебрях приложения спустя несколько минут работы.

    5.5. Шейдерный фейерверк

    Итак, теперь вы уже знакомы с основами языков HLSL и Vertex Shader 1.1. Настало время опробовать полученные знания в более-менее сложном проекте. Ведь как гласит народная мудрость, теория без практики бесполезна, а практика без теории может быть даже вредна.

    В качестве отправной точки для приложения мы возьмем хранитель экрана из 4-й главы и поставим перед собой "сверхзадачу ": реализовать функциональность данного хранителя экрана, используя исключительно вершинные шейдеры. Иными словами, центральный процессор должен будет отсылать на видеокарту только команды "нарисовать диск " и "нарисовать искры ", а всю остальную работу по вращению диска и моделированию полета искр должен выполнять вершинный процессор GPU. Это весьма объемная и нетривиальная задача, поэтому мы разобьем ее на ряд более простых этапов, по мере реализации которых мы продолжим знакомиться с новыми возможностями HLSL и языка Vertex Shader 1.1.

    5.5.1. Моделирование вращения диска

    Код хранителя экрана, выполняющий поворот диска устроен очень просто: сначала приложение вычисляет текущий угол поворота диска, а затем рассчитывает новые координаты каждой вершины диска (листинг 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). Это позволит нам, если что-то пойдет не так, легко поставить точку останова в коде шейдера и проверить корректность входных параметров или выполнить трассировку "шейдера " по шагам с просмотром состояния переменныхВ DirectX SDK имеется утилита PIX for Windows, позволяющая выполнять трассировку ассемблерного кода шейдера. Использование данной утилиты будет рассмотрено в следующей главе .

    // Эмулятор эффекта вращения диска
    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 команд виртуального вершинного процессора. Несовпадение числа инструкций и команд обусловлено макро-инструкцией frc, разворачиваемой в три команды вершинного процессора. Окинуть одним взглядом структуру данной программы весьма проблематично, поэтому нам придется последовательно проследить выполнение программы по шагам, и попытаться понять, что же они выполняет каждая ее команда.

    Примечание

    Данный пример наглядно демонтирует, что короткий код вершинного шейдера вовсе не означает малый размер ассемблерной программы, и что ограничение максимальной длины программы Vertex Shader 1.1 в 128 инструкций не так уж и много.

    Смысл первой команды весьма очевиден - она вычисляет сумму a = input.pos.y + angle и заносит результат в компонент w временного регистра r0. Следующая команда умножает и складывает полученное значение переменной с "чудными " константами, но ее смысл нам пока не ясен. Логично предположить, что эти действия имеют какое-то отношение к вычислению значения тригометрических функций sin и cos (виртуальный процессор Vertex Shader 1.1 не имеет инструкций для расчета синуса и косинуса). Что ж, давайте просто запишем это выражение как есть, заменив компоненты константных регистров их численными значениями:

    $$r1.x = a \cdot 0.159154937 + 0.25\\ r1.y = a \cdot 0.159154937 + 0.5 $$

    Присмотревшись внимательно к константе 0.159154937 мы обнаружим, что это есть нечто иное, как единица деленная на удвоенное число "пи":

    $$r1.x=\frac{\alpha}{2\cdot \pi}+0.25\\ r1.y=\frac{\alpha}{2\cdot \pi}+0.5 $$

    Следующая команда вычисляет дробную часть выражения и заносит ее в регистр r0:

    $$r0.x=\{\frac{\alpha}{2\cdot \pi}+0.25\}\\ r0.y=\{\frac{\alpha}{2\cdot \pi}+0.5\} $$

    После обработки четвертой команды содержимое регистра r0 преобразится следующим образом:

    $$r0.x=\{\frac{\alpha}{2\cdot \pi}+0.25\}\cdot 6.28318548 - 3.14159274\\ r0.y=\{\frac{\alpha}{2\cdot \pi}+0.5\}\cdot 6.28318548 - 3.14159274 $$

    Нетрудно догадаться, что 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 в MathCad можно обнаружить, что они обладает двумя важными свойствами Они достаточно легко доказываются математически :

  • каким бы не было значение a, r0. y всегда находится в диапазоне $$[-\pi, +\pi)$$
  • $$\cos(a) = \cos(r0.y)$$
  • Следовательно, после выполнения второй, третьей и четвертой команд угол a преобразуется к диапазону $$[-\pi, +\pi)$$, при этом косинус угла остается неизменным. Зачем это надо? Логично предположить, что значение косинуса будет вычисляться путем разложения в ряд Тейлора, точность которого стремительно падает с ростом модуля аргумента. Так что данное преобразование позволяет значительно повысить точность вычисления функции cos(a) для больших аргументов. Кстати, если бы не это преобразование, то по мере вращения круга точность вычислений косинуса стремительно снижалась и, в конце концов, круг перестал бы корректно вращаться.

    C компонентом x регистра r0 все несколько запутаннее:

  • Значение r0.x находится в диапазоне $$[-\pi, +\pi)$$
  • $$\sin(a) = \cos(r0.x)$$
  • Очевидно, команды 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 :

    $$r1x=-2.52398507\cdot 10^{-7}\cdot ax^2+2.47609005\cdot 10^{-5}\\ r1y=-2.52398507\cdot 10^{-7}\cdot ay^2+2.47609005\cdot 10^{-5} $$

    Шестая команда умножает регистр r1 на $$ax^2$$ и складывает с константой -0.00138883968:

    $$r1x=(-2.52398507\cdot 10^{-7}\cdot ax^2+2.47609005\cdot 10^{-5})\cdot ах^2 - 0.00138883968\\ r1y=(-2.52398507\cdot 10^{-7}\cdot ay^2+2.47609005\cdot 10^{-5})\cdot а^22 - 0.00138883968 $$

    Следующие три команды продолжают выполнение серии последовательных умножений на $$ax^2 $$ и сложений с константами. В результате после выполнения десятой команды в регистре r0 оказывается следующие значение Чтобы сделать выражение удобочитаемым, число знаков в коэффициентах было уменьшено.:

    $$r0.x=((((-2.523\cdot 10^{07}\cdot ax^2+2.476\cdot 10^{-7})\cdot ax^2-0.001388)\cdot ax^2+0.0416)\cdot ax^2-0.5)\cdot ax^2+1\\ r0.y=((((-2.523\cdot 10^{07}\cdot ay^2+2.476\cdot 10^{-7})\cdot ay^2-0.001388)\cdot ay^2+0.0416)\cdot ay^2-0.5)\cdot ay^2+1 $$

    Чтобы понять смысл данного выражения раскроем скобки и упорядочим коэффициенты ax и ay по убыванию степени:

    $$r0.x=1-0.5\cdot ax^2+0.0416\cdot ax^4-0.001388\cdot ax^6+2.476\cdot 10^{-5}\cdot ax^8-2.523\cdot 10^{-7}\cdot ax^{10}\\ r0.y=1-0.5\cdot ay^2+0.0416\cdot ay^4-0.001388\cdot ay^6+2.476\cdot 10^{-5}\cdot ay^8-2.523\cdot 10^{-7}\cdot ay^{10} $$

    Ничего не напоминает? Правильно, это разложение функции cos(x) в ряд Тейлора до члена десятой степени:

    $$\cos(x)=1-\frac{x^2}{2!}+\frac{x^4}{4!}- \frac{x^6}{6!}+\frac{x^8}{8!}-\frac{x^{10}}{10!}+\dots+(-1)^n\cdot \frac{x^{2n}}{(2\cdot n!)!} $$

    Таким образом, ассемблерные команды с пятой по десятую вычисляют косинус угла. Соответственно, после выполнения десятой команды в компоненте y регистра r0 находится косинус угла, а в компоненте x -синус угла (как вы помните $$\sin(a) = \cos(ax), \cos(a) = \cos(ay)$$ ). При этом векторные регистры вершинного процессора позволили компилятору HLSL параллельно рассчитать значения обоих тригонометрических функций.

    Точность вычисления cos(x)

    Приблизительную оценку аппроксимации функции cos рядом Тейлора из пяти членов можно легко выполнить в том же MathCad, построив график модуля разницы между суммой пяти членов ряда Тейлора и встроенной функцией cos (рисунок 5.19). Как видно, по мере приближения модуля угла к $$\pi$$ абсолютная погрешность стремительно растет, достигая при угле равном $$\pi$$ величины порядка $$±2-10^-3 $$. Если такая точность недостаточна для ваших задач, можно попробовать уменьшить модуль значения аргумента cos, воспользовавшись тождеством $$\cos(x)=-\cos(x±\pi)$$.

    (рис 5.19) Оценка абсолютной погрешности вычисления косинуса посредством ряда Тейлора в Mathcad

    Остальной код весьма тривиален. Одиннадцатая команда умножает полученные значения синуса и косинуса на расстояние вершины до центра и записывает результат в компоненты x и y регистра oPos . Двенадцатая команда дописывает в компоненты z и w этого регистра значение 0 и 1. И, наконец, тринадцатая команда записывает в выходной регистр цвета цвет текущей вершины из регистра v1 .

    Рисунок 5.20 резюмирует весь вышеприведенный анализ кода, устанавливая соответствие между ассемблерным и HLSL кодом шейдера. Однако следует ясно осознавать, что это всего лишь код для виртуального вершинного процессора, который будет скомпилирован драйвером видеокарты в код для конкретного реального вершинного процессора. А архитектура физического вершинного процессора может иметь множество нюансов. Например, все вершинные процессоры современных видеокарт являются суперскалярными и могут запускать несколько ассемблерных инструкций за такт, в частности вершинные процессоры R3xx и R4xx могут выполнить за один такт одну векторную инструкцию над 1-4 компонентным вектором и одну скалярную инструкцию (если они, разумеется, не зависят друг от друга по данным). Поэтому оптимизирующий компилятор драйвера видеокарты может переставлять инструкции местами для достижения большего параллелизма.

    (рис 5.20) Соответствие между листингом Vertex Shader 1.1 и HLSL

    Кроме того, многие вершинные процессоры имеют расширенный набор инструкций по сравнению со спецификаций Vertex Shader 1.1 . Так вершинные процессоры R2xx могут аппаратно вычислять дробную часть числа, соответственно если драйвер видеокарты в процессе компиляции эффекта в микрокод вершинного процессора обнаружит последовательности инструкций Vertex Shader 1.1 , соответствующих макросу frc , то он заменит их одной встроенной командой. Другой пример: видеокарты R4xx и выше содержат инструкцию аппаратного вычисления синуса и косинуса угла в диапазоне $$-\pi....+\pi$$ поэтому драйвер автоматически подменит разложение в ряд Тейлора вызовом данных встроенных функций.

    Тем не менее, оптимизирующий компилятор драйвера не всесилен, поэтому чем качественнее код Vertex Shader 1.1 и чем меньше явных атавизмов он содержит (вроде вычисления скалярного произведения серией инструкций add и mul вместо единственной инструкции dp4 ), тем вероятней драйвер видеокарты сможет сгенерировать оптимальный код.

    Таким образом, при написании эффекта в FX Composer 2.0 в качестве главного критерия оптимальности шейдера должен выступать не промежуточный код на языке Vertex Shader 1.1 , а количество тактов графического процессора, затрачиваемых на обработку одной вершины. В частности на видеокарте 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) как входные параметры вершинного шейдера. Чтобы это стало возможным, необходимо выразить косинус суммы и синус суммы через синусы и косинусы слагаемых, воспользовавшись известными формулами из школьного курса тригонометрии:

    $$\sin(\alpha+\beta)=\sin(\alpha)-\cos(\beta)+\cos(\alpha)-\sin(\beta)\\ \cos(\alpha+\beta)=\cos(\alpha)-\cos(\beta)-\sin(\alpha)-\sin(\beta) $$

    Задание: проведите оптимизацию эффекта примера Ch05\Ex06 , реализовав вычисление тригометрических функций посредством выражения 5.5, и выноса большей части бессмысленных трудоемких расчетов за пределы вершинного шейдера. Используя NVIDIA FX Composer 2.0 , оцените потенциальный прирост производительности (который, скорее всего, окажется более чем трехкратным).

    Подсказка

    Имея вектор с предварительно рассчитанными значениями тригометрических функций, выражение 5.5 можно вычислить всего при помощи двух действий: по парного перемножения значения тригометрических функций и последующего суммирования результатов. Но так как вторая формула использует вычитание, для реализации ее через сумму вам потребуется передать в эффект значение -sin (angle) . При этом чтобы облегчить работу оптимизирующему компилятору HLSL, входные параметры sin (angle), cos (angle), -sin (angle) желательно передавать в шейдер как один трехмерный вектор.

    Если у вас возникнут трудности при выполнении данного задания, вы всегда можете ознакомиться с готовым решением, которое можно найти в каталоге \Examples\Ch05\Ex07 .

    5.5.2. Оператор if языка HLSL

    Следующий этап - перенос расчета полета искр в вершенный шейдер - является гораздо более сложным и запутанным, поэтому, чтобы восстановить силы мы сделаем небольшой привал и поговорим об особенностях оператора if языка HLSL применительно к профилю vs11 .

    Подобно подавляющему большинству языков программирования HLSL содержит конструкцию выбора if :

    if (логическое условие)
    {
    блок 1 
    }
    else 
    {
    блок 2 
    }

    В принципе, на этом раздел можно было бы окончить, если бы не одна маленький нюанс: язык Vertex Shader 1.1 не содержит команд для управления ходом выполнения программы (условные переходы, циклы и т.п.). Эта особенность имеет далеко идущие последствия. Когда в программе на C# используется конструкцию if , то центральный процессор будет каждый раз выполнять только одну из ветвей блока if , что позволяет значительно сократить объем вычислений и повысить производительность Следует отметить, что современные процессоры с длинными конвейерами достаточно болезненно реагируют на хаотичные непредсказуемые условные переходы, поэтому при использовании оператора if следует соблюдать осторожность 85. А вот компилятор 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 - архитектуру Very Long Instruction Word (VLIW) – архитектурная особенность процессора с явным параллелизмом, к которых команды состоят из микроинструкций, определяющих операцию для каждого функционального устройства процессора. Примером процессора с VLIW-архитектурой является Intel Itanium и содержит два исполнительных блока В действительности исполнительных блоков несколько больше, но ограниченная функциональность Vertex Shader 1.1 позволяет задействовать только эти два блока.: один блок векторных операций ( add, mul, madd и т.п.) и один блок скалярных операций ( rcp, sin, cos и т.п.). Следовательно, в идеальных условиях при отсутствии зависимостей по данным вершинный процессор 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)

    Теперь все встало на свои места: так как Vertex Shader 1.1 не содержит инструкции сравнения на равенство двух чисел, сообразительный компилятор HLSL реализовал проверку числа на равенство 0 через комбинацию инструкций mul и slt.

    Дополнительная информация

    Язык Vertex Shader 1.1 не содержит инструкции проверки двух чисел на равенство, так как потребность в ней возникает достаточно редко. Дело в том, что Vertex Shader 1.1 поддерживает только типы с плавающей точкой, сравнение которых является достаточно нестабильной операцией: достаточно малейшей ошибки в последнем разряде и два равных значения перестанут быть равными.

    Но в нашем случае мы можем не опасаться каких-либо последствий потери точности: данная особенность типов 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$$.

    Еще раз хочу обратить ваше внимание, что данные парадоксы возникают исключительно при работе с дробными числами, которые часто (но не всегда) не могут быть точно переставлены в двоичной системе. Целые же числа всегда могут быть точно переведены в двоичное представление, поэтому работа с ними осуществляется без потери точности 88. В частности если мы предварительно умножим a и b на 10, то значение 12-14 окажется в точности равно 168:

    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 время выполнения вершинного шейдера возросло на один такт).

    Разумеется, при условии достаточной разрядности мантиссы.

    5.5.3. Искры

    После небольшого лирического отступления перейдем к моделированию полета искр средствами вершинного шейдера. Для начала освежим в памяти алгоритм генерации новых искр. Логика работы хранителя экрана выполняется с фиксированным шагом timeStep . В течение каждого дискретного шага появляется случайное количество новых искр, но не более maxScintillaCount . Каждая искра имеет случайные координаты, а так же случайную угловую и прямолинейную скорости. Время жизни каждой искры равно StartTime , по прошествии которого искра считается потухшей и ее структура может использоваться для генерации новой вершины: новые искры замещают потухшие, и лишь при отсутствии свободных потухших искр информация добавляется в конец массива вершин.

    Данный алгоритм весьма проблематично реализовать в вершинном шейдере. Дело в том, что вершинный процессор обрабатывает вершины параллельно, независимо друг от друга, в результате чего вершинный шейдер не может получить информацию о состоянии других вершин. Ну а так как состояние шейдера не сохраняется между вызовами, а вернуть информацию из вершинного шейдера очень проблематично Для этого необходимо выполнить визуализацию во временное изображение (текстуру) и скопировать его из видеопамяти в оперативную память. Но так как данная операция является очень медленной, ее стараются использовать только в случае крайней необходимости , приложение не сможет отслеживать состояние каждой вершины и соответственно определять число потухших вершин, их индексы в массиве вершин и т.д. и т.п.

    Поэтому для генерации новых вершин нам придется разработать новый алгоритм, удовлетворяющим двум требованиям:

  • Постоянно изменяющиеся параметры должны быть общими для всех вершин, чтобы их можно было передавать через константные регистры.
  • Обработка вершин должна идти параллельно и независимо. При этом отсутствует возможность сохранения промежуточных значений между вызовами вершинного шейдера.
  • Будучи зажатыми в такие жесткие рамки, мы вряд ли сможем реализовать полноценный алгоритм генерации случайных искр со случайными параметрами. Поэтому мы просто заранее рассчитаем на некотором небольшом временном интервале время появления всех искр, их координаты, скорости, цвета и т.п., после чего будем проигрывать эту последовательность "по кругу " (рисунок 5.21). Таким образом, траектория движения точек будут повторяться через некоторое время, но если длительность одной итерации будет измеряться в десятках секунд, а число искр тысячами, то пользователь вряд ли сможет заметить какую-либо цикличность в поведении искр.

    (рис 5.21) Циклическая работа фейерверка

    Минусом данного подхода является необходимость расчета в каждом кадре всех искр массива вершин, включая потухшие искры, причем, чем сильнее продолжительность итерации превосходит время жизни вершины, тем выше будут накладные расходы. Хотя с другой стороны этот недостаток наверняка компенсируется огромной производительностью вершинного процессора Кроме того, в следующей главе мы примем ряд мер, призванных повысить эффективность обработки неиспользуемых вершин, в результате чего данный недостаток сведется к минимуму . Время, используемое при расчете траектории движения вершины, будет вычисляться кодом следующего вида:

    currentTime = time - vertexStartTime;
    localTime = currentTime % timeLoop;

    где

  • time - время, прошедшее с момента запуска приложения.
  • vertexStartTime - время появления вершины, отсчитываемое он начала итерации. После запуска хранителя экрана идет первая итерация, то есть время отсчитывается от нуля.
  • % - операция нахождения остатка от деления.
  • timeLoop - длительность итерации, то есть время, через которое полет вершины повторяется заново.
  • localTime - локальное время искры внутри итерации.
  • На рисунке 5.22 приведен график зависимости currentTime от time , построенный в Mathcad . Как видно, при запуске приложения время может быть равно отрицательному значению - это означает, что искра еще не появилась на экране. Далее по мере увеличения 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 $$

    где

  • $$tSpeed $$ - скорость вершины;
  • $$tSpeed_0 $$ - начальная скорость вершины;
  • $$tSlowing $$ - замедление вершины;
  • $$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$$:

    $$Distance_0=0 \cdot tSpeed_0-\frac{tSlowing \cdot 0^2}{2}+c=c $$

    Таким образом, константа C равна начальному положению точки и в результате мы получаем окончательную формулу:

    $$distance=distance_0+tSpeed_0 \cdot t-\frac{tSlowing \cdot t|^2}{2} $$

    Но это еще не все - выражение 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} $$

    где

  • $$localTime $$ - локальное время вершины;
  • $$tr $$ - локальное время с учетом остановки вершины по достижению скоростью нулевого значения;
  • $$angle $$ - угол поворота искры вокруг диска;
  • $$diskSpeed $$ - скорость вращения диска;
  • $$time $$ - текущее время;
  • $$angle_0 $$ - локальный угол поворота вершины вокруг диска (без учета вращения самого диска);
  • $$rSpeed_0 $$ - начальная уголовная скорость вершины (она меньше скорости диска);
  • $$rSlowing $$ - замедление вершины.
  • Искра вылетает все время из одного и того же места диска, но так как диск постоянно вращается, начальный угол поворота вершины все время оказывается разным. Такой подход имеет два достоинства: цвет вершины остается постоянным, а сами траектории движения вершин становятся более хаотичными. Для учета угла поворота диска в момент появления вершины используется слагаемое $$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 были переписаны с использованием схемы Горнера, позволяющей сократить число операций при вычислении степенного многочлена. Кроме того, такая запись хорошо ложится на команду mad вершинного процессора. Так же код эффекта содержит конструкцию 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 на GPU , мы будем визуализировать 300.000 искр. Так как ряд видеокарт (например, Intel GMA 9xx ) не могут визуализировать такое количество примитивов за один присест 92, приходится автоматически разбивать массив вершин на ряд "подмассивов " меньшего размера, поддерживаемых текущей видеокартой. Так же стоит отметить, что из-за эмуляции вершинных шейдеров диска и искр, они используют общий эффект и декларацию формата вершины.

    Код визуализации искр является достаточно тривиальным, если не считать того факта, что искры могут храниться в разных массивах вершин (листинг 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 кадров в секунду, что явно недостаточно для обеспечения плавной анимации. Но уверяю вас, что к концу шестой главы частота кадров будет измеряться в сотнях кадров в секунду, причем это прирост будет достигнут без какого-либо ухудшения качества изображения.

    Производительность примера Ch05\Ex10 на разных GPU
    Конфигурация компьютера 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, ATI Radeon x700 Pro, Windows XP Pro SP2, ASUS Driver 8.05 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
    ...
    } 
    }

    Анализ исходного кода эффекта

    Сейчас вы уже вполне неплохо освоились с языком Vertex Shader 1.1 , поэтому выполнять построчный анализ ассемблерного кода вряд ли имеет смысл. Вместо этого я сразу приведу отчет 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-ю). Это обусловлено тем, что язык Vertex Shader 1.1 не содержит инструкции вычисления остатка отделения, соответственно компилятору приходится эмулировать ее посредством скудного набора инструкций. Ниже приведена реконструкция алгоритма нахождения остатка от деления на языке 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; 
    }

    Отдельно стоит отметить нахождение дробной части числа, до сих выполнявшаяся посредством макроса frc . Но при анализе кода нахождения остатка дизассемблер не смог распознать эту операцию, что дало нам возможность воочию увидеть, что в действительности скрывается за макросом frc. Думаю, вы ожидали увидеть здесь все что угодно, только не команду expp, вычисляющую приближенное значение $$2^n$$. Правда компилятора интересует не само значение $$2^n$$, а побочный результат команды, заносящей в компонент y вектора-результата дробную часть числа ( a – floor(a) ). В целом же из всего вышесказанного следует вывод, что, несмотря на обманчиво простой вид, оператор % языка HLSL является очень "дорогой " операцией, соизмеримой по времени выполнения с вычислением тригонометрических функций.

    Чтобы максимально задействовать суперскалярную архитектуру современных вершинных процессоров, компилятор HLSL изменил их порядок следования, чтобы избавиться от зависимости соседних инструкций. Обратной стороной медали является сложность анализа кода: инструкции многих операторов HLSL перемешались между собой, а код строки float remainTime = liveTime – localTime, расположенной в начале эффекта, был перенесен компилятором ближе к концу шейдера.

    Еще одной любопытной особенностью является код оператора if, составное условное выражение которого содержит логическую операцию "и " – так как язык Vertex Shader 1.1 не поддерживает булевские типы и логические операции над ними, оператор эмулируется перемножением чисел с плавающей точкой.

    Оптимизация вершинного шейдера

    И, наконец, анализируя код строки float2 sCoord = input.pos.yz + t * (input.texcoord - t * slowing / 2.0f) мы обнаружим, что компилятор не смог догадаться предварительно вычислить значение константы slowing / 2.0f, что вылилось в один лишний оператор mul. Это дает нам основание предположить, что добавив в файл HLSL явное вычисление константы, мы сможем немного ускорить работу приложения. Но наверняка быть уверенным нельзя, ведь Vertex Shader 1.1 является всего лишь промежуточным кодом, впоследствии еще раз оптимизируемым компилятором драйвера.

    Ну что ж, рискнем. Основные фрагменты эффекта с модифицированным вершинным шейдером приведены в листинге 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.5.4. Анализ производительности приложения

    Настало время оценить эффект от переноса вычислений на видеокарту. На рисунке 5.23 приведена диаграмма, построенная в Excel по результатам измерения производительности примеров Ch05\Ex10 и Ch05\Ex12 на разных GPU.

    (рис 5.23) Производительность примеров Ch05\Ex10 и Ch05\Ex12 на разных GPU

    Как видно, на GeForce 7600GT и Radeon X700 Pr o перенос вычислений с CPU на GPU увеличил частоту кадров почти в 10 раз. На компьютере с интегрированным GPU i946GZ (GMA 3000) частота кадров тоже заметно увеличилась (в 3.5 раза), что на первый взгляд выглядит весьма странно: i946GZ не содержит аппаратного вершинного процессора, поэтому все вычисления по-прежнему выполняются силами центрального процессора.

    Данный парадокс обусловлен рядом факторов. Как известно, .NET приложения содержат множество вспомогательного кода для обнаружения различных внештатных ситуаций вроде переполнения или обращения к несуществующему элементу коллекции. Разумеется, этот код оказывает отрицательное влияние на производительность, усугубляемое многократным его выполнением в цикле. Кроме того, все современные процессоры еще со времен Pentium-III содержат специализированный векторные регистры SSE и набор векторных инструкций, отдаленно напоминающие ассемблерные команды языков Vertex Shader. Но язык C# и промежуточный язык IL не содержат векторных команд, что затрудняет распознавание векторных операций при компиляции JIT -компилятором IL -кода exe-файла в машинный код. В результате, итоговый машинный код практически не содержит SSE -инструкций и векторные блоки центрального процессора фактически простаивают.

    При использовании вершинных шейдеров все обстоит несколько иначе. На i946GZ и аналогичных GPU без аппаратных вершинных процессоров вершинные шейдеры эмулируются DirectX посредством специальной подсистемы Processor Specific Geometry Pipeline (PSGP). PSGP автоматически выполняет компиляцию вершинного шейдера в набор инструкций текущего CPU, задействовав весь потенциал данного процессора на 100%. Полученный код активно использует блоки SSE, параллельную обработку нескольких вершин всеми ядрами CPU и не содержит каких-либо ненужных промежуточных проверок "на всякий случай ". В результате он работает заметно быстрее по сравнению с аналогом на C#, что мы и наблюдаем.

    Итак, вершинные шейдеры позволяют значительно поднять производительность приложения. Но не стоит забывать, что это упреждение верно лишь при сравнении производительности C# и HLSL -кода, использующего одинаковый алгоритм. Центральный процессор предоставляет разработчику использовать значительно более гибкие алгоритмы, так что на практике все обстоит несколько сложнее. Но в любом случае, вершинные шейдеры позволяют разгрузить центральный процессор, освободив его ресурсы для других задач.

    Заключение

    В этой лекции мы познакомились с новыми возможностями языка HLSL применительно к программированию вершинных шейдеров: работе с отдельными компонентами вектора, математическими операторами, встроенными функциями, параметрами эффекта и особенностями оператора if. Так же была рассмотрена IDE для разработки шейдеров NVIDIA FX Composer 2.0, которая, учитывая рост сложности наших эффектов, пришлась как нельзя кстати. Учитывая, что вершинный шейдер выполняется для каждой вершины, число которых может измеряться сотнями тысяч, очень важно уделять внимание качеству кода и оптимизации вершинного шейдера. А для этого очень полезно иметь хотя бы поверхностное представление о том, что твориться под капотом HLSL, в частности о языках Vertex Shader. Поэтому мы изучили основы архитектуры виртуального процессора Vertex Shader 1.1 и его систему команд.

    Вернуться к учебному плану