netlib.narod.ru< Назад | Оглавление | Далее >

Так как работает шейдер?

Хватит картинок, давайте программировать. Писать шейдер вы будете в FX Composer (в качестве введения используйте главу 6). Вы будете использовать текстуры астероида, показанные ранее на рис. 7.8.

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

Запустите FX Composer и откройте файл шейдера NormalMapping.fx. Структура файла шейдера подобна файлу SimpleShader.fx, но вы используете больше аннотаций и вы увидите, что все последующие шейдеры в этой книге используют похожую структуру файла. Это упрощает работу с шейдерами из кода C#.

Базовая структура файла такова:

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

Откройте файл и пройдите через заголовок шейдера и параметры; они практически те же, что и в SimpleShader.fx из предыдущей главы. Изменился формат VertexInput, который выглядит похоже, но теперь ожидает передачи касательной. Раньше вы уже узнали о проблеме с файлами содержимого .x и .fbx, и она будет решена позже в этой главе. Сейчас предполагается, что у вас есть правильные данные касательных, которые будут использованы в шейдере. К счастью для вас, FX Composer всегда предоставляет правильные данные касательных для стандартных тестовых объектов (сферы, чайника, куба и т.д.).

// Входная структура данных вершины
// (используется здесь для ВСЕХ техник!)
struct VertexInput
{
  float3 pos      : POSITION;
  float2 texCoord : TEXCOORD0;
  float3 normal   : NORMAL;
  float3 tangent  : TANGENT;
};

В SimpleShader.fx вы просто сразу начали программировать, и, как только все заработало, больше не выполняли никакого рефакторинга кода. Это прекрасно, но чем больше шейдеров вы напишете, тем больше будете задумываться о повторном использовании компонентов. Один из способов сделать это — определить наиболее часто используемые методы непосредственно в файле шейдера:

// Общие функции
float4 TransformPosition(float3 pos)//float4 pos)
{
  return mul(mul(float4(pos.xyz, 1), world), viewProj);
} // TransformPosition(.)

float3 GetWorldPos(float3 pos)
{
  return mul(float4(pos, 1), world).xyz;
} // GetWorldPos(.)

float3 GetCameraPos()
{
  return viewInverse[3].xyz;
} // GetCameraPos()

float3 CalcNormalVector(float3 nor)
{
  return normalize(mul(nor, (float3x3)world));
} // CalcNormalVector(.)

// Get light direction
float3 GetLightDir()
{
  return lightDir;
} // GetLightDir()

float3x3 ComputeTangentMatrix(float3 tangent, float3 normal)
{
  // Вычисляем матрицу 3x3 преобразования из касательного пространства
  // в пространство объекта
  float3x3 worldToTangentSpace;
  worldToTangentSpace[0] =
    mul(cross(normal, tangent), world);
  worldToTangentSpace[1] = mul(tangent, world);
  worldToTangentSpace[2] = mul(normal, world);
  return worldToTangentSpace;
} // ComputeTangentMatrix(..)

Другим способом может быть сохранение методов или даже параметров и констант в отдельных файлах .fxh, подобных заголовочным файлам C++. Я не люблю этот подход, потому что FX Composer может показывать одновременно только один файл с исходным кодом, а на данном этапе вы, скорее всего, будете изменять довольно много кода.

Первая общая функция — TransformPosition, которая просто преобразует местоположение переданной в вершинный шейдер трехмерной вершины в экранное пространство. Она работает практически так же, как было описано в предыдущей главе, за исключением того, что у вас больше нет комбинированной матрицы worldViewProj. Вместо этого у вас есть матрица world, которая была также и в SimpleShader.fx, и новая матрица viewProj. После умножения world на viewProj вы снова получите worldViewProj, но есть причина, чтобы делать это не в коде а в шейдере, хотя это и обойдется вам в одну дополнительную инструкцию вершинного шейдера.

Отделив матрицу world от worldViewProj вам придется беспокоиться только об одной матрице, которая должна изменяться для каждого объекта, сетки или других трехмерных данных, передаваемых для визуализации. Чем больше данных вы передаете из программы в шейдер, тем больше времени это занимает, что может значительно замедлить ваш код визуализации, если вы будете снова и снова устанавливать параметры и слишком часто запускать шейдеры в одном кадре. Гораздо лучше один раз установить шейдер и затем визуализировать скопом тысячи объектов, просто меняя для каждого из них мировую матрицу. Вы можете еще дальше развить эту идею, используя массив мировых матриц для одновременного хранения 20 или даже больше матриц, и перебирая их все. Эта техника называется копированием экземпляров (instancing) и иногда используется в играх для дополнительной оптимизации производительности, когда есть много однотипных объектов с одним и тем же шейдером. В ранних версиях Rocket Commander я реализовал копирование экземпляров, но потребовалось много работы, чтобы оно работало во всех шейдерных моделях (1.1, 2.0 и 3.0) и при поддержке фиксированного конвейера функций. Впрочем, это не имело значения, поскольку производительность оказалась очень хорошей после другой оптимизации шейдеров.

Другие вспомогательные методы очень просты и вы должны быстро понять их работу, просто взглянув на них, за исключением последнего, который используется для построения матрицы касательного пространства, о которой я говорил. Снова взгляните на рис. 7.4 и 7.5, чтобы увидеть какой вектор чем является. CalculateTangentMatrix используется в вершинном шейдере (подобно всем другим вспомогательным методам), и ожидает получения вектора касательной и вектора нормали для каждой вершины. Вы получаете эти значения в структуре VertexInput. Вектор бинормали вычисляется путем векторного умножения нормали на вектор касательной. Вы можете воссоздать эту матрицу с помощью правой руки (помните главу 5?) — средний палец будет вектор нормали, указательный — касательной, а большой палец представляет бинормаль, которая является результатом векторного умножения среднего и указательного пальцев, и поэтому расположена под углом 90 градусов к каждому из них.

Эта матрица касательного пространства очень полезна для быстрого преобразования нормалей и направления света из мирового пространства в касательное, что необходимо для вычисления наложения нормалей в пиксельном шейдере. Причина, по которой вам нужна эта матрица касательного пространства, — необходимость обеспечить, чтобы все векторы были в одном и том же пространстве. Это пространство лежит непосредственно на поверхности полигона и ось Z направлена вверх, подобно вектору нормали, а оси X и Y описывают касательная и бинормаль. Использование касательного пространства — это самый простой и быстрый путь в пиксельный шейдер, и все, что вам надо сделать, расположить в правильном порядке бинормаль и касательную, которые используются для конструирования этой матрицы касательного пространства. Для левостороннего движка порядок и даже векторное произведение для бинормали придется сменить на противоположные. Используйте тестовые модули, чтобы определить, какой способ будет правильным; Вы можете также взглянуть на шейдеры из первоначальной игры Rocket Commander (левосторонней), чтобы увидеть ее отличия от версии XNA (правосторонней).

Вершинный шейдер и матрицы

Со всеми этими вспомогательными методами теперь должно быть очень просто написать вершинный шейдер. Прежде, чем начать заниматься этим, вы должны определить структуру VertexOutput. Я здесь буду исследовать только версию для пиксельных шейдеров 2.0, поскольку версия для пиксельных шейдеров 1.1 гораздо сложнее и использует много ассемблерного кода, что полностью выходит за рамки тем этой книги. Если вы действительно заинтересованы в поддержке пиксельных шейдеров 1.1, приобретите хорошую книгу по шейдерам, чтобы разобраться в деталях их функционирования и шейдерном языке ассемблера. Я рекомендую «Programming Vertex and Pixel Shader» или книги серий «GPU Gems» и «Shader X». В издании большинства из них участвовал общепризнанный эксперт по шейдерам Вольфганг Энджел, и вы изучите много шейдерных трюков, разработанных в последние годы многими профессионалами. Эта тема настолько велика, что родилась новая специальность: программист шейдеров.

Если у вас небольшая команда, или вы вообще все программируете самостоятельно, это может быть препятствием, поскольку изучить надо очень многое, а времени на это мало из-за стремительного развития технологий. Попытайтесь все упростить и используйте только простые шейдерные модели. Для некоторых игроков может быть плохо, что XNA не поддерживает фиксированный конвейер функций и шейдерную модель 4.0 из Direct3D 10, но это позволит сосредоточиться на создании пиксельных шейдеров для модели 2.0 (и, может быть, использовать также пиксельные шейдеры 3.0) и получить готовую игру.

В вашей выходной структуре, как всегда должна быть позиция в экранном пространстве, а также в пиксельный шейдер надо передать координаты текстур с рассеиваемыми цветами и картой нормалей, чтобы ими можно было воспользоваться. Обратите внимание, что в пиксельных шейдерах 1.1 приходится дублировать координаты текстуры, поскольку каждые входные данные текстуры вершины могут использоваться в пиксельном шейдере только один раз. Это одна из многих проблем пиксельных шейдеров 1.1; вы также не можете выполнить нормализацию или даже использовать функцию pow. Также сложно разархивировать сжатую карту нормалей обратно в обычный формат. Хорошо, что вам здесь больше не придется думать об этом.

// Выходная структура вершинного шейдера
struct VertexOutput_Specular20
{
  float4 pos          : POSITION;
  float2 texCoord     : TEXCOORD0;
  float3 lightVec     : TEXCOORD1;
  float3 viewVec      : TEXCOORD2;
};

Переменные lightVec и viewVec являются вспомогательными и делают чуть проще вычисления в пиксельном шейдере. Расчет освещения в основном тот же самый, что и в предыдущей главе, но на этот раз вы вычисляете все векторы в касательном пространстве, поскольку проще работать в нем, чем преобразовывать все векторы касательного пространства в мировое пространство. Это имеет смысл, поскольку у вас гораздо больше пикселей, чем вершин. Преобразование каждого пикселя со сложной матричной операцией в пиксельном шейдере будет слишком медленным, а преобразование вектора направления света и вектора взгляда в вершинном шейдере не требует много времени.

Взгляните на полный код вершинного шейдера. Важная часть здесь — использование матрицы worldToTangentSpace, вычисляемой в методе ComputeTangentMatrix, который вы видели ранее:

// Функция вершинного шейдера
VertexOutput_Specular20 VS_Specular20(VertexInput In)
{
  VertexOutput_Specular20 Out = (VertexOutput_Specular20) 0;
  Out.pos = TransformPosition(In.pos);
  // В пиксельных шейдерах 2.0 мы можем дважды использовать
  // координаты текстуры для карты рассеивания и карты нормалей
  Out.texCoord = In.texCoord;

  // Вычисляем матрицу 3 х 3 для преобразования
  // из касательного пространства в пространство объекта
  float3x3 worldToTangentSpace =
    ComputeTangentMatrix(In.tangent, In.normal);

  float3 worldEyePos = GetCameraPos();
  float3 worldVertPos = GetWorldPos(In.pos);

  // Преобразуем вектор освещения и передаем его как цвет
  // (зафиксированный от 0 до 1)
  // Для ps_2_0 не требуется фиксация от 0 до 1
  Out.lightVec = normalize(mul(worldToTangentSpace, GetLightDir()));
  Out.viewVec = mul(worldToTangentSpace, worldEyePos - worldVertPos);

  // И передаем все в пиксельный шейдер
  return Out;
} // VS_Specular20(.)

Пиксельный шейдер и оптимизация

Пиксельный шейдер получает выходные данные вершинного и вычисляет влияние освещения на каждый пиксель. Первая вещь, которую вы делаете здесь, — получение рассеиваемого цвета и карты нормалей. Карта рассеивания просто хранит RGB-значения и, возможно, альфа-значение, если вы используете альфа-смешивание (не прямо сейчас, но шейдер поддерживает такую возможность). Более сложный вызов получает вектор нормали из сжатой карты нормалей. Если вы помните способ, которым ранее строились сжатые карты нормалей с помощью утилиты NormalMapCompressor, то, возможно, предположите, что вам снова надо обменять красный канал и альфа-канал, чтобы сделать RGB-данные правильными, которые можно использовать как вектор XYZ. Первый шаг в этом — использование так называемого перемешивания компонентов (swizzle), которое берет данные RGBA или XYZW из текстуры или регистра шейдера и меняет порядок компонент. Например, ABGR меняет порядок RGBA на обратный. В нашем случае нам нужен только альфа-канал (x), зеленый (y) и синий (z) каналы, поэтому мы используем перемешивание компонентов .agb.

Затем мы используем описанную ранее формулу для преобразования значений цветов с плавающей точкой в компоненты вектора, путем вычитания 0.5 и деления на 0.5, что аналогично умножению на 2 и вычитанию 1 (поскольку 0.5 * 2 это 1). Чтобы исправить оставшиеся ошибки сжатия вы заново нормализуете вектор, что занимает одну дополнительную инструкцию пиксельного шейдера, но определенно стоит этого.

// Функция пиксельного шейдера
float4 PS_Specular20(VertexOutput_Specular20 In) : COLOR
{
  // Выбираем данные из текстур
  float4 diffuseTexture = tex2D(diffuseTextureSampler, In.texCoord);
  float3 normalVector = (2.0 * tex2D(normalTextureSampler,
    In.texCoord).agb) - 1.0;
  // Нормализуем нормаль для исправления ошибок
  normalVector = normalize(normalVector);

  // Дополнительно нормализуем векторы
  float3 lightVector = In.lightVec; //не требуется: normalize(In.lightVec);
  float3 viewVector = normalize(In.viewVec);
  // Для ps_2_0 нам не надо распаковывать векторы от -1 до 1

  // Вычисляем угол источника света
  float bump = saturate(dot(normalVector, lightVector));
  // Коэффициент отражения
  float3 reflect = normalize(2 * bump * normalVector - lightVector);
  float spec = pow(saturate(dot(reflect, viewVector)), shininess);
  //return spec;

  float4 ambDiffColor = ambientColor + bump * diffuseColor;
  return diffuseTexture * ambDiffColor +
    bump * spec * specularColor * diffuseTexture.a;
} // PS_Specular20(.)

С вектором normalVector (который остается в касательном пространстве) вы можете выполнить все вычисления освещенности. Вектор освещения и вектор взгляда извлекаются из структуры VertexOutput_Specular20. Вектор освещения один и тот же для каждой вершины и пикселя, поскольку мы используем направленный источник света, но вектор взгляда может значительно изменяться, если мы близко к визуализируемому объекту. На рис. 7.11 снова показано, почему так важно заново нормализовать эти векторы взгляда, как это делалось в предыдущей главе. Для наложения нормалей это еще более важно, поскольку вы вычисляете освещение для каждого отдельного пикселя и векторы могут значительно варьироваться от пикселя к пикселю из-за изменений в карте нормалей.


Рис. 7.11

Рис. 7.11


Значение коэффициента падения bump вычисляется тем же самым способом, каким в предыдущей главе вы вычисляли рассеиваемый цвет. Для каждой нормали, направленной к источнику освещения, вы используете яркость цвета, и чем дальше она отклоняется от направления на источник, тем цвет становится темнее. И нормаль и вектор освещения находятся в касательном пространстве, так что основная формула остается той же самой. Вы можете посмотреть наш простой эффект освещения в файле SimpleShader.fx, а затем повторно реализовать его здесь вместе с наложением нормалей, что очень здорово, поскольку, хотя шейдер и стал сложнее, базовые вычисления освещения остались простыми и заменяемыми. Если вы взглянете на шейдер наложения нормалей и наложения параллакса в следующей главе, то увидите только одно место, где были сделаны модификации; остальная часть шейдера наложения нормалей осталась неизменной.

Последняя вещь, которую осталось сделать, — вычисление отражаемого компонента цвета перед тем, как совместить все воедино в итоговом цвете. У вас нет половинного вектора, зато присутствует вектор взгляда в касательном пространстве и вы знаете в каком направлении указывает нормаль. Код использует слегка упрощенную формулу, вычитая вектор освещения из удвоенного вектора нормали, чтобы сгенерировать псевдонормализованный половинный вектор. Выполнение тех же вычислений, что и в вершинном шейдере SimpleShader.fx сделало бы пиксельный шейдер слишком дорогостоящим, а шейдер наложения нормалей выглядит замечательно и с таким вычислением, так что в действительности не имеет значения, как мы извлекаем половинный вектор. Важен метод pow, который делает эффект отблесков меньше, и вы должны точно настроить значения блеска и specularColor для каждого материала, поскольку они очень сильно влияют на то, как будет выглядеть итоговый результат (рис. 7.12).


Рис. 7.12

Рис. 7.12


Объединение результатов в итоговый цвет может показаться странным. Первые две строки просты для понимания. Сначала вы смешиваете фоновый цвет с результатом умножения коэффициента падения на рассеиваемый цвет. В действительности данная операция занимает всего одну инструкцию шейдера и именно поэтому записана таким образом (это легко увидеть в ассемблерном коде и использовать тот же самый код для пиксельных шейдеров 1.1).

Затем отражаемый цвет умножается на значение spec, которое управляет бликами, а затем еще на коэффициент падения и альфа-значение diffuseColor. Коэффициент падения обеспечивает более темное изображение бликов, когда взгляд направлен в сторону от света, а альфа-значение diffuseColor помогает вам затемнять зеркальные блики, если текстура прозрачна (в Rocket Commander возможности альфа-смешивания не используются).

Вот и все, что вам надо знать, чтобы заставить шейлер наложения нормалей работать в FX Composer. Можете немного поэкспериментировать с ним — измените значения цветов и используйте другие текстуры и параметры. Теперь мы побеспокоимся о получении правильных данных касательных в приложении и возможности простого импорта фалов моделей, которые художник по моделям создаст для вас.


netlib.narod.ru< Назад | Оглавление | Далее >

Сайт управляется системой uCoz