Немного теории
Для понимания работы шейдеров, нужно хорошо ориентироваться в том, как видеокарта строит изображение. Общая структура визуализации 3D объекта на экране изображена на рисунке ниже:
Для начала разберемся в таком понятии, как Graphics Rendering Pipeline. Это конвейер, этапы которого проходит видеокарта для построения финального изображения. Окунемся немного в историю. Первые компьютеры использовали программный рендеринг. Всеми просчетами занимался центральный процессор. И конвейер выглядел следующим образом:
Самые первые 3D-ускорители использовали так называемый Fixed-Function Pipeline. Из названия следует, что он был фиксированным и строго последовательным. Вмешаться в построение картинки было невозможно. Сейчас мы рассмотрим все этапы этого конвейера. В дальнейшем нам это пригодится.
- Входные данные. В качестве входных параметров видеокарта получает объект в виде отдельных вершин с множеством атрибутов. Например положение вершины в пространстве, её цвет, нормаль, текстурные координаты и другие.
- Трансформации и освещение. На этом этапе над объектом производятся геометрические операции (перемещение, вращение, масштаб). Здесь же производится расчет освещенности сцены. Для каждой вершины вычисляются значения освещенности исходя из расположения и типа источников света, а также параметров, характеризующих поверхность объекта (отражения, поглощения).
- Триангуляция. На этом этапе вертексы объединяются в треугольники.
- Растеризация. Цель этого этапа рассчитать цвет пикселей на основе тех данных, которые были уже подготовлены. Так как мы имеем информацию только о цвете вершин, то для получения цвета пикселя мы линейно интерполируем значение между значениями цветов соответствующих вершин.
- Попиксельная обработка. На этом этапе происходит раскраска пикселей. Входными данными являются данные предыдущего уровня. Также здесь мы можем применять дополнительные эффекты к пикселам. Например текстурирование.
- Формирование готового изображения. Теперь мы должны построить финальный кадр. На этом этапе учитываются данные Z-буфера для того, чтобы определить какой объект находится ближе к камере. Также здесь производится альфа-тест. Слой за слоем объекты «наносятся» на финальное изображение. Так же здесь же могут быть применены пост-эффекты. После чего готовый кадр помещается в Frame Buffer.
Итак, мы обзорно рассмотрели этапы Fixed-Function Pipeline. Как мы видим повлиять на финальное изображение мы можем лишь выбрав определенные опции определенных этапов, но написать, например, свою модель освещения мы не можем. В те времена приходилось довольствоваться тем, что поддерживал конкретный видеоадаптер. Так было до появления первых видеокарт с аппаратной поддержкой DirectX 8.0 – 8.1. Начиная с этого момента можно было писать программы для обработки вертексов и пикселей. Конвейер для такой модели показан на рисунке ниже.
В будущем планируется сделать все этапы конвейера программируемыми.
Теперь, зная как видеокарта строит изображение, стоит немного рассказать о 3D-объектах. Модель это совокупность вершин, связей между ними, а также материалов, анимаций и пр. У вершин есть атрибуты. Например, UV-координаты. Они показывают где расположена эта вершина на текстурной развертке. Материал определяет внешний вид объекта и включает ссылку на шейдер, используемый для визуализации геометрии или частиц. Таким образом эти компоненты без материала отображаться не будут.
Шейдер – это программа для одной из ступеней графического конвейера. Все шейдеры можно разделить на две группы: вершинные и фрагментарные (пиксельные). В Unity3D есть упрощенный подход к написанию шейдеров – Surface Shader. Это просто более высокий уровень абстракции. При компиляции Surface шейдера компилятор создаст шейдер, состоящий из вершинного и пиксельного. Язык в Unity3D свой. Называется ShaderLab. Он поддерживает вставки на CG и HLSL.
Практика
В качестве примера рассмотрим написание шейдера, который накладывает на объект diffuse текстуру, карту нормалей, карту отражений (опираясь на Cubemap), и производит отсечение пикселей по альфа-каналу в diffuse текстуре.
Для начала рассмотрим общий синтаксис ShaderLab. Даже если мы пишем шейдер на CG или HLSL, нам все равно нужно знать синтаксис ShaderLab, для того, чтобы мы могли устанавливать параметры нашего шейдера в инспекторе.
Shader "Group/SomeShader"
{
// properties that will be seen in the inspector
Properties
{
_Color ("Main Color", Color) = (1,0.5,0.5,1)
}
// define one subshader
SubShader
{
Pass
{
}
}
}
Fallback "Diffuse"
}
Первым ключевым словом идет Shader. После него в кавычках указывается имя шейдера. Причем можно указать через ‘/’ путь, где будет лежать шейдер в выпадающем меню при настройке материала в редакторе. После этого идет описание параметров Properties { }, которые будут видны в инспекторе и с которыми cможет взаимодействовать пользователь.
Каждый шейдер в Unity3D содержит в своем теле как минимум один Subshader. Когда необходимо отобразить геометрию, движок ищет необходимый шейдер и использует первый Subshader из списка, который может обработать видеокарта. Это сделано для того, чтобы один и тот же шейдер мог корректно отображаться на различных видеокартах, поддерживающих разные шейдерные модели. Ключевое слово Pass { } определяет блок инструкций одного прохода. Шейдер может содержать от одного до нескольких проходов. Использование нескольких проходов может быть полезно например в случае оптимизации шейдеров для старого железа или для достижения особых эффектов (outline, toon shading и др).
Если Unity3D в теле шейдера не нашла ни одного SubShader’a, который корректно может отобразить геометрию, используется откат к другому шейдеру, объявленному после Fallback инструкции. В примере выше будет использован Diffuse шейдер, если видеокарта не сможет корректно отобразить текущий.
Теперь рассмотрим конкретный пример.
Shader "Example/Bumped Reflection Clip"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_BumpMap ("Bumpmap", 2D) = "bump" {}
_Cube ("Cubemap", CUBE) = "" {}
_Value ("Reflection Power", Range(0,1)) = 0.5
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Cull Off
CGPROGRAM
#pragma surface surf Lambert
struct Input
{
float2 uv_MainTex;
float2 uv_BumpMap;
float3 worldRefl;
INTERNAL_DATA
};
sampler2D _MainTex;
sampler2D _BumpMap;
samplerCUBE _Cube;
float _Value;
void surf (Input IN, inout SurfaceOutput o)
{
float4 tex = tex2D (_MainTex, IN.uv_MainTex);
clip (tex.a - 0.5);
o.Albedo = tex.rgb;
o.Normal = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap));
float4 refl = texCUBE (_Cube, WorldReflectionVector (IN, o.Normal));
o.Emission = refl.rgb * _Value * refl.a;
}
ENDCG
}
Fallback "Diffuse"
}
Поле Properties cодержит четыре переменные, которые будут отображены в инспекторе Unity3D.
_MainTex – в скобках указано имя, отображаемое в инспекторе, тип и значение по умолчанию.
_MainTex и _BumpMap – текстуры, _Cube – кубомапа для отражений, _Value – ползунок степени эффекта отражений.
Тег { «RenderType» = «Opaque» } помечает шейдер как непрозрачный. Это влияет на очередь отрисовки.
Cull Off указывает, что в шейдере не будет производится отсечения по направлениям нормалей. Существуют три варианта отсечение полигонов: нормали которых направлены от камеры, в камеру, и без отсечения. Последний вариант означает то, что мы будем видеть полигон с двух сторон.
При написании кода мы будем использовать вставку на языке CG. Вставка обрамляется двумя ключевыми словами: CGPROGRAM, ENDCG.
#pragma surface surf Lambert – объявление функции surface-шейдера и дополнительные параметры. В данном случае функция называется surf, а в качестве дополнительных параметров указана модель освещения по Ламберту.
Теперь рассмотрим входную структуру. Все возможные переменные входной структуры можно посмотреть в справке. Мы же рассмотрим только те, что использовали.
Переменные uv_MainTex и uv_BumpMap – это UV координаты, необходимые шейдеру для правильного наложения текстур на объект. Эти переменные обязательно должны называться так же, как и названия переменных текстур с префиксом uv_ или uv2_ для первого и второго каналов соответственно. worldRefl и INTERNAL_DATA используются для отражений.
Теперь рассмотрим функцию шейдера surf.
Первым шагом мы получаем четырехкомпонентный вектор, в котором хранится информация о цвете пикселя текстуры. Переменная tex будет хранить информацию о нем. После чего функцией clip мы указываем, какие пиксели мы пропускаем при рендеринге. Т.к. отсекаем мы по информации, которая хранится в альфа-канале, то в параметрах указываем tex.a.
После отсечения мы производим наложение нашей основной текстуры на объект следующей строчкой:
o.Albedo = tex.rgb;
Переменная o это наша выходная структура. Все её поля можно посмотреть в справке по SurfaceShaders.
Следующим шагом мы применяем карту нормалей:
o.Normal = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap));
После чего производим наложение отражения:
float4 refl = texCUBE (_Cube, WorldReflectionVector (IN, o.Normal));
o.Emission = refl.rgb * _Value * refl.a;
Тут стоит отметить, что полученную информацию об отражении мы умножаем на _Value, чтобы в инспекторе мы могли управлять степенью эффекта.
Ну и в конце не забываем дописать Fallback на случай того, если видеокарта не сможет корректно отобразить этот шейдер.
А вот то, что у нас получилось:
Автор: Nick_Field