Использование шейдеров с помощью языка HLSL
До появления на свет восьмой версии библиотеки DirectX графический конвейер представлял собой некую модель "черного ящика", когда программист мог загружать в него исходные графические данные и настраивать фиксированное количество параметров (состояний). Такой фиксированный подход связывал руки разработчикам в реализации различных спецэффектов при программировании трехмерной графики. Данный недостаток был преодолен с появлением восьмой версии графической библиотеки DirectX. Основным нововведением в ней стало появление программируемых элементов графического конвейера. Были введены так называемые вершинные шейдеры для замены блока трансформации вершин и расчета освещенности, и пиксельные шейдеры для замены блока мультитекстурирования. Теперь программист мог сам задавать правила (законы) преобразования вершин трехмерной модели в вершинном шейдере и определять способы смешивания цвета пикселя и текстурных цветов. Таким образом вершинный шейдер представляет собой небольшую п рограмму (набор инструкци й), которая оперирует с вершинными атрибутами трехмерного объекта. Пиксельный шейдер предназначен для обработки элементарных фрагментов (пикселей). Ниже представлена схема графического конвейера, где показано какой этап обработки вершин заменяется вершинными шейдерами.
Изначально шейдеры писались на языке программирования, близкого к ассемблеру. С выходом девятой версии библиотеки DirectX появилась возможность создавать (программировать) шейдеры с использованием высокоуровневого языка программирования HLSL (High-Level Shader Language), разработанного компанией Microsoft. Преимущества высокоуровневого языка программирования перед низкоуровневым очевидны:
- Написание программ (кодирование) занимает меньше времени (можно посветить больше времени разработке алгоритма)
- Программы на языке HLSL более читабельны и удобнее в отладке.
- Компилятор HLSL создает более оптимизированный код чем программист.
- Возможность компилировать программу под любую версию шейдеров.
Рассмотрим сначала основные шаги использования вершинных шейдеров в библиотеке Direct3D с использованием языка HLSL.
Вообще говоря, вершинные шейдеры могут эмулироваться программным способом. Это означает, что вся обработка (обсчет) вершин будет производиться с помощью центрального процессора (CPU) компьютера. Программно это достигается путем указания в четвертом параметре функции создания устройства вывода, флага D3DCREATE_SOFTWARE_VERTEXPROCESSING. В случае если возможности видеокарты позволяют использование шейдеров, то указывается константа D3DCREATE_HARDWARE_VERTEXPROCESSING.
Первым шагом при работе в вершинными шейдерами необходимо задать формат вершины. Теперь это проделывается не через набор FVF флагов, а с помощью структуры D3DVertexElement9. Нужно заполнить массив типа D3DVertexElement9, каждый элемент которого представляет структуру, состоящую из шести полей. Первое поле указывает номер потока вершин, и как правило, здесь передается ноль, если используется один поток. Второе поле задает для атрибута вершины смещение в байтах от начала структуры. Так, например, если вершина имеет атрибуты позиции и нормали, то смещение для первого из них (позиции) будет 0, а для второго (нормаль) – 12, т.к. объем памяти для первого атрибута есть 3*4=12 байт. Третье поле определяет тип данных для каждого атрибута вершины. Наиболее часто используемые приведены ниже:
D3DDECLTYPE_FLOAT1 D3DDECLTYPE_FLOAT2 D3DDECLTYPE_FLOAT3 D3DDECLTYPE_FLOAT4 D3DDECLTYPE_D3DCOLOR.
Четвертое поле задает метод тесселяции (разбиения сложной трехмерной поверхности на треугольники). Здесь, как правило, передают константу D3DDECLMETHOD_DEFAULT. Пятое поле указывает на то, в качестве какого компонента планируется использовать данный вершинный атрибут. Наиболее используемые константы представлены ниже:
D3DDECLUSAGE_POSITION, D3DDECLUSAGE_NORMAL, D3DDECLUSAGE_TEXCOORD, D3DDECLUSAGE_COLOR.
И последнее, шестое поле определяет индекс для одинаковых типов вершинных атрибутов. Например, если имеется три вершинных атрибута, описанные как D3DDECLUSAGE_NORMAL, то для первого из них нужно задать индекс 0, для второго – 1, для третьего – 2.
Ниже приведен пример описания вершины, содержащей положение и цвет с помощью массива элементов D3DVertexElement9.
C++ | D3DVERTEXELEMENT9 declaration[] = { { 0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0 }, { 0, 12, D3DDECLTYPE_D3DCOLOR, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_COLOR, 0 }, D3DDECL_END() }; |
Pascal | declaration: array [0..2] of TD3DVertexElement9 = ( (Stream: 0; Offset: 0; _Type: D3DDECLTYPE_FLOAT3; Method: D3DDECLMETHOD_DEFAULT; Usage: D3DDECLUSAGE_POSITION; UsageIndex: 0), (Stream: 0; Offset: 12; _Type: D3DDECLTYPE_D3DCOLOR; Method: D3DDECLMETHOD_DEFAULT; Usage: D3DDECLUSAGE_COLOR; UsageIndex: 0), (Stream: $FF; Offset: 0; _Type: D3DDECLTYPE_UNUSED; Method: TD3DDeclMethod(0); Usage: TD3DDeclUsage(0); UsageIndex: 0) ); |
После описания формата вершины требуется получить указатель на интерфейс IDirect3DVertexDeclaration9. Это реализуется через вызов метода CreateVertexDeclaration() интерфейса IDirect3DDevice9. Первый параметр данного метода определяет массив элементов типа D3DVERTEXELEMENT9, второй аргумент – возвращаемый результат.
C++ | LPDIRECT3DVERTEXDECLARATION9 VertexDeclaration = NULL; device->CreateVertexDeclaration( declaration, &VertexDeclaration ); |
Pascal | var VertexDeclaration: IDirect3DVertexDeclaration9; ... device.CreateVertexDeclaration( @declaration, VertexDeclaration ); |
C++ | device->SetVertexDeclaration( VertexDeclaration ); |
Pascal | device.SetVertexDeclaration(VertexDeclaration); |
Первый параметр функции задает строку, в которой содержится имя файла вершинного шейдера.
Второй и третий параметры являются специфическими и, как правило, здесь передаются значения NULL.
Четвертый параметр – строка, определяющая название функции в шейдере или так называемая точка входа в программу.
Пятый параметр – строка, задающая версию шейдера. Для вершинных шейдеров указывают одну из следующих строковых констант: vs_1_1, vs_2_0, vs_3_0. Шестой параметр определяет набор флагов. Здесь могут быть переданы следующие константы:
D3DXSHADER_DEBUG – указание компилятору выдавать отладочную информацию;
D3DXSHADER_SKIPVALIDATION – указание компилятору не производить проверку кода шейдера на наличие ошибок;
D3DXSHADER_SKIPOPTIMIZATION – указание компилятору не производить оптимизацию кода шейдера. Можно указать значение ноль.
Седьмой параметр – переменная, типа ID3DXBuffer, которая содержит указатель на откомпилированный код шейдера.
Восьмой параметр – переменная, содержащая указатель на буфер ошибок и сообщений.
И последний, девятый параметр – переменная типа ID3DXConstantTable, в которую записывается указатель на таблицу констант. Через данный указатель производится "общение" с константами в шейдере.
Ниже приведен пример компиляции вершинного шейдера, хранящегося в файле vertex.vsh.
C++ | LPD3DXBUFFER Code = NULL; LPD3DXBUFFER BufferErrors = NULL; LPD3DXCONSTANTTABLE ConstantTable = NULL; ... D3DXCompileShaderFromFile( "vertex.vsh", NULL, NULL, "main", "vs_1_1", 0, &Code, &BufferErrors, &ConstantTable ); |
Pascal | var Code: ID3DXBuffer; BufferErrors: ID3DXBuffer; ConstantTable: ID3DXConstantTable; ... D3DXCompileShaderFromFile('vertex.vsh', nil, nil, 'main', 'vs_1_1', 0, @Code, @BufferErrors, @ConstantTable); |
C++ | LPD3DXBUFFER Code = NULL; LPDIRECT3DVERTEXSHADER9 VertexShader = NULL; ... device->CreateVertexShader( (DWORD*)Code->GetBufferPointer(), &VertexShader ); |
Pascal | var Code: ID3DXBuffer; VertexShader: IDirect3DVertexShader9; ... device.CreateVertexShader(Code.GetBufferPointer, VertexShader); |
И заключительный шаг – установка вершинного шейдера, реализуемая через вызов метода SetVertexShader() интерфейса IDirect3DDevice9. Как правило, данный метод вызывается в процедуре вывода сцены (Render).
C++ | LPDIRECT3DVERTEXSHADER9 VertexShader = NULL; ... device->SetVertexShader( VertexShader ); |
Pascal | var VertexShader: IDirect3DVertexShader9; ... device.SetVertexShader(VertexShader); |
- Секция глобальных переменных и констант
- Секция, описывающая входные данные вершины
- Секция, описывающая выходные данные вершины
- Главная процедура в шейдере (точка входа)
В секции глобальных переменных и констант описываются данные, которые не содержатся в вершинных атрибутах: матрицы преобразований, положения источников света и др. Ниже приведен пример описания глобальной матрицы и статичной переменной.
float4x4 WorldViewProj; static float4 col = {1.0f, 1.0f, 0.0f, 1.0f};
В секции, описывающей входные данные, определяется входная структура вершинных атрибутов. Например, для вершин, которые содержат позицию и цвет эта структура может выглядеть так.
struct VS_INPUT { float4 position : POSITION; float4 color0 : COLOR0; };
Аналогично определяется выходная структура данных шейдера.
struct VS_OUTPUT { float4 position : POSITION; float4 color0 : COLOR0; };
Используемые здесь семантические конструкции (POSITION и COLOR0) указывают на принадлежность того или иного атрибута вершины.
Так же как и в программах на языке C++, программа на языке HLSL должна иметь точку входа (главную процедуру). Здесь точка входа может быть описана следующим образом.
VS_OUTPUT main( VS_INPUT IN ) { VS_OUTPUT OUT; … return OUT; }
Вообще говоря, использование входных и выходных структур не является обязательным в языке HLSL. Можно использовать привычный для любого программиста подход передачи параметров без структур.
float4 main(in float2 tex0 : TEXCOORD0, in float2 tex1 : TEXCOORD1) : COLOR { return …; }
Разберем теперь, как осуществляется преобразование вершины в вершинном шейдере. Как мы уже знаем, трансформация вершины осуществляется путем умножения вектор-строки, описывающей компоненты вершины, на матрицу преобразования. В языке HLSL данный шаг осуществляется с помощью функции mul.
OUT.position = mul( IN.position, WorldViewProj );
Таким образом, каждая вершина трехмерной модели подвергается обработке с помощью данного преобразования, а результат передается дальше по конвейеру. Ниже приведен пример полного текста кода шейдера.
float4x4 WorldViewProj;
struct VS_INPUT { float4 position : POSITION; float4 color0 : COLOR0; ;
struct VS_OUTPUT { float4 position : POSITION; float4 color0 : COLOR0; };
VS_OUTPUT main( VS_INPUT IN ) { VS_OUTPUT OUT; OUT.position = mul( IN.position, WorldViewProj ); OUT.color0 = IN.color0; return OUT; }
Теперь необходимо рассмотреть каким образом происходит установка значений констант в шейдере из программы. Как мы уже видели, при вызове метода компиляции шейдера (D3DXCompileShaderFromFile), в последнюю переменную данной функции помещается ссылка на так называемую таблицу констант. Именно с помощью данного указателя и происходит присваивание значений константам в шейдере. Реализуется это с помощью вызова методов SetXXX интерфейса ID3DXConstantTable, где XXX – "заменяется" на следующие выражения: Bool, Float, Int, Matrix, Vector. Данные методы имеют три параметра: первый – указатель на устройство вывода, второй – наименование константы в шейдере, и третий – устанавливаемое значение. Так, например, установка значения для матрицы преобразования (WorldViewProj) в приведенном выше примере осуществляется следующим образом.
C++ | D3DXMATRIX matWorld, matView, matProj, tmp; D3DXMatrixPerspectiveFovLH( &matProj, D3DX_PI/4, 1.0f, 1.0f, 100.0f ); D3DXVECTOR3 positionCamera, targetPoint, worldUp; positionCamera = D3DXVECTOR3(2.0f, 2.0f, -2.0f); targetPoint = D3DXVECTOR3(0.0f, 0.0f, 0.0f); worldUp = D3DXVECTOR3(0.0f, 1.0f, 0.0f); D3DXMatrixLookAtLH(&matView, &positionCamera, &targetPoint, &worldUp); D3DXMatrixRotationY(&matWorld, angle); tmp = matWorld * matView * matProj; ConstantTable->SetMatrix( device, "WorldViewProj", &tmp ); |
Pascal | var matWorld, matView, matProj, tmp: TD3DMatrix; positionCamera, targetPoint, worldUp : TD3DXVector3; ... positionCamera:=D3DXVector3(2,2,-2); targetPoint:=D3DXVector3(0,0,0); worldUp:=D3DXVector3(0,1,0); D3DXMatrixLookAtLH(matView, positionCamera, targetPoint, worldUp); D3DXMatrixPerspectiveFovLH(matProj, PI/4, 1, 1, 100); D3DXMatrixRotationY(matWorld, angle); D3DXMatrixMultiply(tmp, matWorld, matView); D3DXMatrixMultiply(tmp, tmp, matProj); ConstantTable.SetMatrix(device, 'WorldViewProj', tmp); |
Ниже приведены примеры вызова каждого метода.
C++ | LPD3DXCONSTANTTABLE ConstantTable = NULL; bool b = true; ConstantTable->SetBool( device, "flag", b ); float f = 3.14f; ConstantTable->SetFloat( device, "pi", f ); int x = 4; ConstantTable->SetInt( device, "num", x ); D3DXMATRIX m; ... ConstantTable->SetMatrix( device, "mat", &m ); D3DXVECTOR4 v(1.0f, 2.0f, 3.0f, 4.0f); ConstantTable->SetVector( device, "vec", &v ); |
Pascal | var b: Boolean; f: Single; x: Integer; m: TD3DMatrix; v: TD3DXVector4; ConstantTable: ID3DXConstantTable; ... b := true; ConstantTable.SetBool(device, 'flag', b); f:=3.14; ConstantTable.SetFloat(device, 'pi', f); x := 4; ConstantTable.SetInt(device, 'num', x); m._11:=1; ... ConstantTable.SetMatrix(device, 'mat', m); v := D3DXVector4(1,2,3,4); ConstantTable.SetVector(device, 'vec', v); |
В качестве веса вершины пусть выступает значение координаты y, а матрицы и задают матрицы поворота вокруг оси OY на углы 30 и -30 градусов соответственно. Результат скручивания объекта (куба) по приведенной выше формуле показаны ниже.
При этом код вершинного шейдера будет выглядеть следующим образом.
float4x4 M1; float4x4 M2;
struct VS_INPUT { float4 position : POSITION; float4 color0 : COLOR0; };
struct VS_OUTPUT { float4 position : POSITION; float4 color0 : COLOR0; };
VS_OUTPUT main( VS_INPUT IN ) { VS_OUTPUT OUT; float4x4 m = (1-IN.position.y)*M1 + IN.position.y*M2; OUT.position = mul( IN.position, m ); OUT.color0 = IN.position; return OUT; }
Присутствующие в шейдере матрицы преобразования и устанавливаются через вызывающую программу с помощью таблицы констант.
C++ | D3DXMATRIX matWorld1, matWorld2, matView, matProj, M1, M2; LPD3DXCONSTANTTABLE ConstantTable = NULL; D3DXMatrixRotationY(&matWorld1, 30.0f*D3DX_PI/4); M1 = matWorld1 * matView * matProj; ConstantTable->SetMatrix( device, "M1", &M1 ); D3DXMatrixRotationY(&matWorld2, -30.0f*D3DX_PI/4); M2 = matWorld2 * matView * matProj; ConstantTable->SetMatrix( device, "M2", &M2 ); |
Pascal | var matWorld1, matWorld2, matView, matProj, M1, M2: TD3DMatrix; ConstantTable: ID3DXConstantTable; ... D3DXMatrixRotationY(matWorld1, 30*pi/180); D3DXMatrixMultiply(M1, matWorld1, matView); // M1 = matWorld1 * matView D3DXMatrixMultiply(M1, M1, matProj); // M1 = M1 * matProj ConstantTable.SetMatrix(device, 'M1', M1); D3DXMatrixRotationY(matWorld2, - 30*pi/180); D3DXMatrixMultiply(M2, matWorld2, matView); D3DXMatrixMultiply(M2, M2, matProj); ConstantTable.SetMatrix(device, 'M2', M2); |
Рассмотрим теперь необходимые шаги для работы с пиксельным шейдером. В отличие от вершинных шейдеров, пиксельные шейдеры не могут эмулироваться центральным процессором. Поэтому если видеокарта не поддерживает пиксельных шейдеров, значит обработка элементарных фрагментов (пикселей) будет производится по жестко заданному правилу.
Как мы уже говорили, пиксельный шейдер представляет собой небольшую программу (процедуру) для обработки каждого пикселя. Первым шагом необходимо объявить переменную интерфейсного типа IDirect3DPixelShader9, которая отвечает за работу пиксельного шейдера из программы.
C++ | LPDIRECT3DPIXELSHADER9 PixelShader = NULL; |
Pascal | var PixelShader: IDirect3DPixelShader9; |
C++ | LPD3DXBUFFER Code = NULL; LPD3DXBUFFER BufferErrors = NULL; LPD3DXCONSTANTTABLE ConstantTable = NULL; D3DXCompileShaderFromFile( "pixel.psh", NULL, NULL, "main", "ps_1_0", 0, &Code, &BufferErrors, &ConstantTable ); |
Pascal | var Code: ID3DXBuffer; BufferErrors: ID3DXBuffer; ConstantTable: ID3DXConstantTable; ... D3DXCompileShaderFromFile('pixel.psh', nil, nil, 'Main', 'ps_1_0', 0, @Code, @BufferErrors, @ConstantTable); |
C++ | LPDIRECT3DPIXELSHADER9 PixelShader = NULL; device->CreatePixelShader( (DWORD*)Code->GetBufferPointer(), &PixelShader ); |
Pascal | var PixelShader: IDirect3DPixelShader9; ... device.CreatePixelShader(Code.GetBufferPointer, PixelShader); |
C++ | device->SetPixelShader( PixelShader ); |
Pascal | device.SetPixelShader(PixelShader); |
struct PS_INPUT { float4 color: COLOR; };
struct PS_OUTPUT { float4 color : COLOR; };
PS_OUTPUT main (PS_INPUT input) { PS_OUTPUT output; output.color = input.color; return output; };
В данном случае пиксельный шейдер фактически просто "проталкивает" пиксель дальше по графическому конвейеру, не подвергая его никакой обработке. Рассмотрим несколько способов возможной обработки точек в пиксельном шейдере на примере плоского цветного треугольника.
"проталкивание" пикселя | output.color = input.color; | |
инвертирование цветов | output.color = 1-input.color; | |
увеличение яркости | output.color = 2*input.color; | |
уменьшение яркости | output.color = 0.5*input.color; | |
блокирование цветового канала | output.color = input.color; output.color.r = 0; | |
сложная обработка | output.color.r=0.5*input.color.r; output.color.g=2.0*input.color.g; output.color.b=input.color.b*input.color.b; |
sampler tex0;
struct PS_INPUT { float2 base : TEXCOORD0; };
struct PS_OUTPUT { float4 diffuse : COLOR0; };
PS_OUTPUT Main (PS_INPUT input) { PS_OUTPUT output; output.diffuse = tex2D(tex0, input.base); return output; };
В первой строке шейдера объявляется семплер (tex0). Операция выбора текселя из семплера называют семплированием. Следует заметить, что входная структура шейдера (PS_INPUT) содержит лишь текстурные координаты пикселя. Вообще говоря, в пиксельный шейдер можно передавать те данные, которые программист считает нужными (цвет вершины, вектор нормали, положение источника света и т.д.).
Например, ниже приводится пример, в котором входная структура пиксельного шейдера содержит цвет и двое текстурных координат.
struct PS_INPUT { float2 uv0 : TEXCOORD0; float2 uv1 : TEXCOORD1; float4 color : COLOR0; };
Пусть у нас вершина описана через положение на плоскости (преобразованная вершина), цвет и две текстурные координаты:
C++ | struct MYVERTEX { FLOAT x, y, z, rhw; DWORD color; FLOAT u1, v1; FLOAT u2, v2; } #define MY_FVF (D3DFVF_XYZRHW | D3DFVF_DIFFUSE | D3DFVF_TEX2); |
Pascal | type MyVertex = packed record x, y, z, rhw: Single; color: DWORD; u1,v1: Single; u2,v2: Single; end; const MY_FVF = D3DFVF_XYZRHW or D3DFVF_DIFFUSE or D3DFVF_TEX2; |
Текстура1 | Текстура2 | Закраска квадрата |
sampler tex0; sampler tex1;
struct PS_INPUT { float2 uv0 : TEXCOORD0; float2 uv1 : TEXCOORD1; float4 color: COLOR0; };
struct PS_OUTPUT { float4 diffuse : COLOR0; };
PS_OUTPUT Main (PS_INPUT input) { PS_OUTPUT output; float4 texel0 = tex2D(tex0, input.uv0); float4 texel1 = tex2D(tex1, input.uv1); output.diffuse = ...; return output; };
Некоторые способы взаимодействия двух этих поверхностей (текстуры и цветного квадрата) представлены в таблице.
output.diffuse = texel0*texel1; | |
output.diffuse = texel0*texel1+input.color; | |
output.diffuse = texel0+texel1*input.color; | |
output.diffuse = texel0*texel1*input.color; |