Программирование графических процессоров с использованием Direct3D и HLSL

Использование шейдеров с помощью языка HLSL. Графический процессор

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

Использование шейдеров с помощью языка 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 );

    Установка формата вершин без использования вершинных шейдеров производилась через вызов метода SetFVF(). Теперь же для этого предназначен метод SetVertexDeclaration() интерфейса IDirect3DDevice9. Как правило, данный метод вызывается в процедуре Render.

    C++ device->SetVertexDeclaration( VertexDeclaration );
    Pascal device.SetVertexDeclaration(VertexDeclaration);

    Следующий шаг – компиляция вершинного шейдера. Данный шаг реализуется с помощью вызова функции D3DXCompileShaderFromFile().

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

    Второй и третий параметры являются специфическими и, как правило, здесь передаются значения 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);

    Следующий шаг – получение указателя на откомпилированный код шейдера. Для этого используется метод CreateVertexShader() интерфейса IDirect3DDevice9. Метод имеет два параметра: указатель на буфер, в котором хранится скомпилированный код шейдера и переменная интерфейсного типа IDirect3DVertexShader9, в которую будет помещен результат вызова.

    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);

    Теперь разберем, что из себя представляет шейдер на языке HLSL. Вершинный шейдер есть не что иное, как обычный текстовый файл, содержащий программный код. Этот программный код можно разбить на несколько секций:

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

    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);

    В качестве примера использования вершинных шейдеров рассмотрим реализацию эффекта скручивания трехмерного объекта вдоль одной из координатных осей. Данный эффект осуществляется с помощью смешения двух матриц преобразования ( matrix blending ). Основная идея такого преобразования объекта, заданного своими вершинами, может быть выражена с помощью следующей формулы: $$v_{i}=(M_{1}(1-\alpha_{i})+M_{2} \alpha_{i})v_{i}$$, где $$v_{i}$$ - координаты вершины, $$\alpha_{i} [0,1]$$ - вес вершины, $$M_{1}M_{2}$$ - матрицы преобразования. Как правило, вес вершине $$\alpha_{i}$$ приписывается линейно изменяющийся вдоль одной из осей. В результате, на часть точек объекта большее влияние оказывает матрица $$M_{1}$$, на другую часть – матрица $$M_{2}$$. Пусть у нас в качестве объекта выступает единичный куб, состоящий из маленьких треугольников (их количество можно регулировать) и нижнее основание которого расположено в плоскости y=0, как показано на рисунке ниже.

    В качестве веса вершины пусть выступает значение координаты y, а матрицы $$M_{1}$$ и $$M_{2}$$ задают матрицы поворота вокруг оси 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;
    }

    Присутствующие в шейдере матрицы преобразования $$M_{1}$$ и $$M_{2}$$ устанавливаются через вызывающую программу с помощью таблицы констант.

    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;

    Компиляция пиксельного шейдера осуществляется также с помощью функции D3DXCompileShaderFromFile().

    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);

    Следующий шаг – получение указателя на откомпилированный код шейдера. Реализуется этот шаг вызовом метода CreatePixelShader() интерфейса IDirect3DDevice9.

    C++
    LPDIRECT3DPIXELSHADER9 PixelShader = NULL;
    
    device->CreatePixelShader( (DWORD*)Code->GetBufferPointer(),
                               PixelShader );
    Pascal
    var
      PixelShader: IDirect3DPixelShader9;
    ...
    device.CreatePixelShader(Code.GetBufferPointer, PixelShader);

    Следующий шаг заключается в установке пиксельного шейдера в функции рендеринга. Осуществляется это путем вызова метода SetPixelShader() интерфейса IDirect3DDevice9, где в качестве параметра передается указатель на пиксельный шейдер.

    C++ device->SetPixelShader( PixelShader );
    Pascal device.SetPixelShader(PixelShader);

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

    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;

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

    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;

    Графический процессор в задачах обработки изображений

    Проблема большого количества вычислений возникает в ряде задач многих направлений науки и техники. На сегодняшний день, когда с помощью компьютеров решаются, чуть ли не все задачи человечества, применение электронных вычислительных машин является естественно разумным шагом. Среди всего множества задач можно выделить некоторый спектр, для решения которых требуется от нескольких часов до нескольких дней машинного времени. Среди таких ресурсоемких в вычислительном плане задач и направлений можно отметить следующие: предсказание погоды, климата и глобальных изменений в атмосфере, генетика человека, астрономия, транспортные задачи, гидро- и газодинамика, управляемый термоядерный синтез, разведка нефти и газа, вычислительные задачи наук о мировом океане, распознавание изображений и синтез речи. Использование суперкомпьютеров с большим количеством независимо работающих параллельных процессоров и технологий высокопроизводительных вычислений может существенно снизить заявленные выше временные оценки. Такой подход является заведомо очень дорогим в финансовом плане и позволителен для узкого круга исследователей и ученых. Тем не менее, сейчас в области настольных персональных компьютеров начинают широко распространяться процессоры с несколькими независимыми ядрами, что позволяет решать уже некоторые задачи в параллельном режиме более широкому кругу обычных пользователей и специалистов. Однако количество ядер в таких процессорах ограничено, как правило, двумя либо четырьмя штуками, что не всегда дает особого приращения производительности. Но современный персональный компьютер в большинстве случаев может быть оснащен сегодня помимо мощного центрального процессора ( CPU - Central Processing Unit ) еще и современной видеокартой с графическим процессором ( GPU - Graphics Processing Unit ) производительностью несколько сотен миллиардов операций с плавающей точкой в секунду. Даже самые современные серверные процессоры далеки от такой производительности. Вообще изначально область применения графического процессора была просчет и отображение трехмерных сцен. Однако, с появлением видеокарт, позволяющих их программировать, круг вычислительных задач, решаемых с помощью графического процессора, существенно расширился. Поэтому возникает резонный вопрос. Почему бы не задействовать вычислительные ресурсы графического процессора для решения задач отличных от его "традиционных" графических? Как мы уже уяснили, обработка вершин и пикселей в графическом конвейере ведется в параллельном режиме. Количество параллельных блоков по преобразованию вершин зависит от модели графического процессора и может колебаться от 2 до 8 штук. Аналогично блок пиксельной обработки также функционирует в параллельном режиме, причем количество пиксельных блоков может быть от 4 до 48 штук в зависимости от типа видеокарты (на момент написания данных строк).

    В качестве области исследования и проведения вычислительных экспериментов рассмотрим точечные процессы цифровой обработки изображений. С цифровой обработкой изображений сталкиваются при решении многих научных и технических задач. Обработка изображений в широком смысле слова означает выполнение различных операций над многомерными сигналами, которыми изображения и являются. Цели, преследуемые при обработке изображений весьма различны и, как правило, зависят от конкретной решаемой задачи. Это может быть улучшение яркости или контраста в вашей домашней коллекции цифровых фотографий, получение монохромных (бинарных) изображений, обработка изображений сглаживающими фильтрами для удаления шумов и мелких искажений, выделение значимых признаков на изображении с применением в последствии алгоритмов распознавания, например, формы символов. Современная жизнь ставит ряд требований к подобным методам обработки. Одно из самых главных это высокая эффективность и скорость работы алгоритмов с использованием персонального компьютера. Традиционно обработкой изображений занимался центральный процессор системы. Для этого каждый элемент изображения (пиксель) подвергался некоторому, как правило, однотипному преобразованию. И в результате получалось, что для изображения размерами M на N пикселей требуется MxN операций процессора. При значительных размерах изображения этот объем вычислений может оказаться критическим для одного устройства обработки информации. Поэтому, чтобы снизить вычислительную нагрузку алгоритма вполне резонно воспользоваться идеями и методами параллельных вычислений. Однако привлечение дорогостоящих параллельных суперкомпьютеров в данной задаче не является критически необходимым. Подобный класс задач можно попытаться решить с помощью обычной современной видеокарты, стоимость которой на несколько порядков меньше любого вычислительного кластера. Под точечными процессами будем понимать набор алгоритмов, которые подвергают обработке каждый пиксель изображения независимо от остальных элементов. Примерами точечных процессов могут выступать: приведение цветного изображения к оттенкам серого цвета, увеличение/уменьшение яркости и контраста, негативное преобразование, пороговое отсечение, соляризация и др. Следует отметить, что перечисленные точечные процессы мы будем рассматривать для изображений в оттенках серого цвета, так называемых grayscale. В подобных изображениях присутствуют только 256 оттенков какого-либо основного цвета. Как правило, используются оттенки серого цвета так, что палитра цветов содержит 256 "плавноизменяющихся" от черного к белому цвету интенсивностей. При этом черный цвет кодируется нулем, белый – числом 255. Таким образом, точечный процесс можно представить как некую функцию, определенную на целочисленном дискретном множестве [0…255] с таким же множеством значений.

    Преобразование просветления увеличивает или уменьшает значение яркости каждого пикселя в отдельности. Пусть I(x,y) – значение яркости пикселя (x,y) в изображении I. Тогда операция просветления на языке формул может быть выражена следующим образом: I(x,y)= I(x,y)+b, где b – постоянная яркости. Если b>0, то яркость в изображении будет увеличиваться (просветление); если же b<0, то яркость будет уменьшаться (затемнение). Могут возникнуть случаи, при которых значение выражения I(x,y)+b выйдет за пределы отрезка [0…255]. В этом случае значение, вышедшее за границы отрезка приводят к значению ближайшей границы, т.е. либо к 0, либо к 255. Графически операцию просветления можно описать следующими графиками функций.

    Рассмотрим произвольное изображение I в оттенках серого цвета. Введем массив H, содержащий 256 элементов: H[0…255]. Каждый i-й элемент этого массива будет содержать количество пикселей в изображении I со значением интенсивности i. Если визуализировать массив H в виде графика функции, то получим так называемую гистограмму интенсивностей яркости исходного изображения I. Вид гистограммы позволяет получить представление об общей яркости изображения. Гистограмма интенсивности является информативным инструментом при анализе общей яркости в изображении. Способ получения гистограммы интенсивности изображения можно записать на алгоритмическом языке следующим образом.

    Цикл по x от 0 до ШиринаИзображения-1 
       Цикл по y от 0 до ВысотаИзображения-1
    	ТелоЦикла
    	    k = I(x,y);
    	    H[k] = H[k] + 1;
    	КонецТелоЦикла

    Ниже на рисунке приведены три гистограммы интенсивности различного класса.

    Гистограмма слева определяет изображения, в которых мало пикселей черного и белого цветов (низкоконтрастные изображения). Гистограмма посередине соответствует изображениям, в которых число черных и белых пикселей значительно превышает все остальные (высококонтрастные изображения). И гистограмма справа представляет изображения, в которых количество пикселей с различными интенсивностями цветов приблизительно одинаково (нормальноконтрастные изображения). Изменение контраста в изображении можно осуществить с помощью линейных преобразований. Для гистограмм, соответствующих низкоконтрастным изображениям, выделяют отрезок [Q1,Q2], на котором сосредоточена значительная часть интенсивностей. Затем данный отрезок [Q1,Q2] линейно отражают в отрезок [0…255] с помощью следующего преобразования: I(x,y)= 255*(I(x,y)-Q1)/(Q2-Q1). Аналогично можно получить формулу для уменьшения контраста в изображении. В этом случае преобразование будет иметь следующий вид: I(x,y)= R1+I(x,y)*(R2-R1)/255, где [R1,R2] – некий отрезок, в который отображается все множество значений интенсивностей. Графически операции увеличения и уменьшения контрастности в изображении можно представить следующим образом.

    Негативное преобразование инвертирует значения интенсивностей яркости так, что темные пиксели становятся светлыми и наоборот. Математически это задается довольно простой формулой:

    I(x,y)= 255- I(x,y), а графически это выглядит следующим образом.

    Пороговое отсечение (бинаризация) преобразует изображение в оттенках серого в изображение, в котором присутствует всего два цвета, как правило, белый и черный (бинарное). На языке формул бинаризация имеет такой вид: I(x,y)=0, если I(x,y)>p ; I(x,y)=255, если I(x,y)<p, где p – некоторый заданный порог. Графически операция порогового отсечения задается следующим образом.

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

    Получение изображения в оттенках серого из цветного также можно отнести к точечным процессам. Каждый пиксель цветного изображения представляет собой тройку байт, значения которых соответствуют весам красного, зеленого и синего цветов. Это так называемая цветовая модель RGB (Red, Green, Blue). Преобразование цветного изображения в оттенки серого осуществляется по следующей формуле:

    I=0.3*R+0.59*G+0.11*B, где I – значение интенсивности серого цвета, R, G, B - значения весов красного, зеленого и синего цветов соответственно.

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

    sampler tex0;
    
    struct PS_INPUT
    {
      float2 base : TEXCOORD0;
    };
    struct PS_OUTPUT
    {
      float4 diffuse : COLOR0;
    };
    PS_OUTPUT Main (PS_INPUT input)
    {
      PS_OUTPUT output;
      float4 col = tex2D(tex0, input.base);
      ...
      return output;
    };
    Исходное изображение
    Приведение к оттенкам серого цвета
    float4 col = tex2D(tex0, input.base);
    float4 lum = float4(0.3, 0.59, 0.11, 0);
    output.diffuse = dot(lum,col);
    Увеличение яркости
    float4 col = tex2D(tex0, input.base);
    float4 lum = float4(0.3, 0.59, 0.11, 0);
    output.diffuse = dot(lum,col)+0.2f;
    Уменьшение яркости
    float4 col = tex2D(tex0, input.base);
    float4 lum = float4(0.3, 0.59, 0.11, 0);
    output.diffuse = dot(lum,col)-0.2f;
    Увеличение контраста
    float4 col = tex2D(tex0, input.base);
      float4 lum = float4(0.3, 0.59, 0.11, 0);
      float gray = dot(lum,col);
      float Q1 = 0.2f;  float Q2 = 0.7f;
      if (gray > Q2) gray = 1.0f;
      else if (gray < Q1) gray = 0.0f;
      else gray = (gray - Q1)/(Q2-Q1);
      output.diffuse = gray;
    Уменьшение контраста
    float4 col = tex2D(tex0, input.base);
      float4 lum = float4(0.3, 0.59, 0.11, 0);
      float gray = dot(lum,col);
      float R1 = 0.2f;  float R2 = 0.7f;
      gray = R1+gray*(R2-R1);
      output.diffuse = gray;
    Пороговое отсечение (бинаризация)
    float4 col = tex2D(tex0, input.base);
      float4 lum = float4(0.3, 0.59, 0.11, 0);
      float gray = dot(lum,col);
      float p = 0.4f;
      if (gray > p) gray = 1.0f;
      else if (gray < p) gray = 0.0f;
      output.diffuse = gray;
    Негативное преобразование
    float4 col = tex2D(tex0, input.base);
      float4 lum = float4(0.3, 0.59, 0.11, 0);
      float gray = dot(lum,col);
      output.diffuse = 1.0f-gray;

    Рассмотрим теперь некоторые методы обработки изображения с использованием пространственных процессов. В этом случае элемент изображения получает новое значение на основе группы элементов, примыкающих к данному. Область (окрестность) примыкания представляет собой группу элементов изображения использующаяся в пространственных процессах. Как правило, область примыкания есть квадратная матрица нечетной размерности с центром в обрабатываемом элементе.

    Пространственная частота изображения – скорость изменения яркости по координатам. Говорят, что присутствует высокая частота в изображении, если яркость меняется очень сильно. Одной из центральных задач в обработке изображений является построение пространственного фильтра. Фильтр позволяет усилить или ослабить компоненты различной частоты. Пространственный фильтр – процесс, который способен выделить (подчеркнуть) компоненты определенной частоты. Двумерный фильтр устроен следующим образом. Берется матрица размером 3х3, 5х5, 7х7 и т.д. и на ней определяется некоторая функция. Упомянутая матрица называется окном или апертурой, а заданная на нем функция – весовой или функцией окна. Каждому элементу окна соответствует число, называемое весовым множителем. Совокупность всех весовых множителей и составляет весовую функцию. Нечетные размеры апертуры объясняются однозначностью определения центрального элемента. Фильтрация осуществляется перемещением окна (апертуры) фильтра по изображению. В каждом положении апертуры выполняются однотипные действия, которые определяют так называемый отклик фильтра. Весовая функция в процессе перемещения остается неизменной. В каждом положении окна происходит операция свертки – линейная комбинация значений элементов изображения: $$\left| \begin{array}{ccc} p_{1} p_{2} p_{3} \\ p_{4} p_{5} p_{6} \\ p_{7} p_{8} p_{9} \end{array}\right | \left | \begin{array}{ccc} k_{1} k_{2} k_{3} \\ k_{4} k_{5} k_{6} \\ k_{7} k_{8} k_{9} \end{array}\right | \sum_{i=1}^{9} p_{i}K_{i}=p_{5}^{'}$$, где $$p_{i}$$ - элементы области примыкания, $$k_{i}$$ - весовые множители, $$p_{5}^{'}$$ - новое значение пикселя. При каждом положении окна весовая функция поэлементно умножается на значение соответствующих пикселей исходного изображения и произведения суммируются. Полученная сумма называется откликом фильтра и присваивается тому пикселю нового изображения, который соответствует положению центра окна. Низкочастотный фильтр – процесс, который ослабляет высокочастотные компоненты и усиливает роль низкочастотных.

    Сглаживание изображения реализуется с помощью следующих ядер.

    $$H_{1}=\frac{1}{9}\left( \begin{array}{ccc} 1 1 1 \\ 1 1 1 \\ 1 1 1 \end{array} \right), H_{2}=\frac{1}{10}\left( \begin{array}{ccc} 1 1 1 \\ 1 2 1 \\ 1 1 1 \end{array} \right), H_{3}=\frac{1}{16}\left( \begin{array}{ccc} 1 2 1 \\ 2 4 2 \\ 1 2 1 \end{array} \right)$$

    Следует заметить, что общая яркость исходного изображения и результирующего будет одинаковой.

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

    $$H_{1}=\left( \begin{array}{ccc} -1 -1 -1 \\ -1 9 -1 \\ -1 -1 -1 \end{array} \right), H_{2}=\left( \begin{array}{ccc} 0 -1 0 \\ -1 5 -1 \\ 0 -1 0 \end{array} \right), \\ H_{3}=\left( \begin{array}{ccc} 1 -2 1 \\ -2 5 -2 \\ 1 -2 1 \end{array} \right)$$

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

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

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

    Метод усиления края по Лапласу не зависит от направления краев, и высвечиваются все направления. Ниже приведены три лапласиана.

    $$H_{1}=\left( \begin{array}{ccc} 0 1 0 \\ 1 -4 1 \\ 0 1 0 \end{array} \right), H_{2}=\left( \begin{array}{ccc} -1 -1 -1 \\ -1 5 -1 \\ -1 -1 -1 \end{array} \right),\\ H_{3}=\left( \begin{array}{ccc} 1 -2 1 \\ -2 4 -2 \\ 1 -2 1 \end{array} \right)$$

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

    $$H_{1}=\left( \begin{array}{ccc} -1 0 1 \\ -2 0 2 \\ -1 0 1 \end{array} \right), H_{2}=\left( \begin{array}{ccc} 1 2 1 \\ 0 0 0 \\ -1 -2 -1 \end{array} \right)$$

    Исходя из этих сверток, вычисляется величина и направление краев. В качестве отклика данного фильтра выступает величина $$\sqrt{P^{2}+Q^{2}}$$, где P и Q - отклики ядер $$H_{1}$$ и $$H_{2}$$ соответственно.

    Метод усиления края с помощью оператора Превита также использует два ядра:

    $$H_{1}=\left( \begin{array}{ccc} 1 0 -1 \\ 1 0 -1 \\ 1 0 -1 \end{array} \right), H_{2}=\left( \begin{array}{ccc} -1 -1 -1 \\ 0 0 0 \\ 1 1 1 \end{array} \right)$$

    Результат работы оператора Превита есть max{P,Q}, где P и Q - отклики ядер $$H_{1}$$ и $$H_{2}$$ соответственно.

    Метод преобразования реализующий эффект тиснения на изображении. Результирующее изображение выглядит как будто "выдавленным" или "вдавленным". Фильтр такого преобразования имеет вид:$$H_{1}=\left( \begin{array}{ccc} 0 1 0 \\ -1 0 1 \\ 0 -1 0 \end{array} \right).$$ К отклику ядра прибавляется константа яркости, как правило, это 128.

    Как нам известно, обращение к элементам текстуры в пиксельном шейдере производится с помощью текстурных координат. Левый верхний тексель имеет текстурные координаты (0,0), левый нижний – координаты (0,1), правый верхний – координаты (1,0), правый нижний – координаты (1,1)

    Задача состоит в том, чтобы для произвольного текселя изображения, имеющего текстурные координаты (u,v), определить значения текстурных координат восьми его соседей. Пусть у нас количество текселей в каждой строке будет W, а количество текселей в каждом столбце – H. В силу того, что тексели расположены равномерно (на одинаковом расстоянии друг от друга), можно вычислить шаг приращения du и dv в текстурных координатах по горизонтали и вертикали соответственно. Итак, $$du=\frac{1}{W-1},dv=\frac{1}{H-1}$$, где W и H – ширина и высота изображения соответственно. Например, для изображения, представленного выше, W = 20 пикселей, H = 9 пикселей, и шаг по горизонтали $$du=\frac{1}{19}$$, а шаг по вертикали $$dv=\frac{1}{8}$$. Таким образом, для произвольного текселя, имеющего текстурные координаты (u,v), текстурные координаты его восьми соседей будут следующие: (u-du, v-dv), (u, v-dv), (u+du, v-dv), (u-du, v), (u+du, v), (u-du, v+dv), (u, v+dv), (u+du, v+dv), как показано на приведенном ниже рисунке.

    Ниже приведен пример пиксельного шейдера, который реализует метод усиления границ на изображении с помощью оператора Собеля и метод тиснения, а также примеры изображений.

    sampler tex0;
    
    struct PS_INPUT
    {
      float2 base : TEXCOORD0;
    };
    
    struct PS_OUTPUT
    {
      float4 diffuse : COLOR0;
    };
    
    PS_OUTPUT Main (PS_INPUT input)
    {
      PS_OUTPUT output;
      const float W =320.0f; 
      const float H =240.0f;
      const float du=1.0f/(W-1); 
      const float dv=1.0f/(H-1);
      const float2 c[9] = {
                   float2(-du, -dv),   float2(0.0f, -dv),  float2(du, -dv),
                   float2(-du, 0.0f),  float2(0.0f, 0.0f), float2(du, 0.0f),
                   float2(-du, dv),    float2(0.0f, dv),   float2(du, dv)
      };
      
      float3 col[9];
      for (int i=0; i<9; i++) {
        col[i] = tex2D(tex0, input.base+c[i]);
      }
      
      float lum[9];
      float3 gray = (0.30f, 0.59f, 0.11f) ;
      for (int i=0; i<9; i++) {
        lum[i] = dot(col[i], gray);
      }
      
      float res1 = 0.0f;
      float res2 = 0.0f;
      const float sobel1[9] = { 1, 2, 1,  0, 0, 0, -1, -2, -1};
      const float sobel2[9] = {-1, 0, 1, -2, 0, 2, -1,  0,  1};
      const float tisnenie[9] = {0, 1, 0, -1, 0, 1, 0, -1, 0};
      for (int i=0; i<9; i++) {
        res1+=lum[i]*sobel1[i];
        res2+=lum[i]*sobel2[i];
        res+=lum[i]*tisnenie[i];
      }
      output.diffuse = sqrt(res1*res1+res2*res2);
      //output.diffuse = res+0.5f;
      return output;
    };
    Исходное изображение Оператор Собеля Метод тиснения
    Страницы:

    Использование шейдеров с помощью языка 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 );

    Установка формата вершин без использования вершинных шейдеров производилась через вызов метода SetFVF(). Теперь же для этого предназначен метод SetVertexDeclaration() интерфейса IDirect3DDevice9. Как правило, данный метод вызывается в процедуре Render.

    C++ device->SetVertexDeclaration( VertexDeclaration );
    Pascal device.SetVertexDeclaration(VertexDeclaration);

    Следующий шаг – компиляция вершинного шейдера. Данный шаг реализуется с помощью вызова функции D3DXCompileShaderFromFile().

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

    Второй и третий параметры являются специфическими и, как правило, здесь передаются значения 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);

    Следующий шаг – получение указателя на откомпилированный код шейдера. Для этого используется метод CreateVertexShader() интерфейса IDirect3DDevice9. Метод имеет два параметра: указатель на буфер, в котором хранится скомпилированный код шейдера и переменная интерфейсного типа IDirect3DVertexShader9, в которую будет помещен результат вызова.

    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);

    Теперь разберем, что из себя представляет шейдер на языке HLSL. Вершинный шейдер есть не что иное, как обычный текстовый файл, содержащий программный код. Этот программный код можно разбить на несколько секций:

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

    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);

    В качестве примера использования вершинных шейдеров рассмотрим реализацию эффекта скручивания трехмерного объекта вдоль одной из координатных осей. Данный эффект осуществляется с помощью смешения двух матриц преобразования ( matrix blending ). Основная идея такого преобразования объекта, заданного своими вершинами, может быть выражена с помощью следующей формулы: $$v_{i}=(M_{1}(1-\alpha_{i})+M_{2} \alpha_{i})v_{i}$$, где $$v_{i}$$ - координаты вершины, $$\alpha_{i} [0,1]$$ - вес вершины, $$M_{1}M_{2}$$ - матрицы преобразования. Как правило, вес вершине $$\alpha_{i}$$ приписывается линейно изменяющийся вдоль одной из осей. В результате, на часть точек объекта большее влияние оказывает матрица $$M_{1}$$, на другую часть – матрица $$M_{2}$$. Пусть у нас в качестве объекта выступает единичный куб, состоящий из маленьких треугольников (их количество можно регулировать) и нижнее основание которого расположено в плоскости y=0, как показано на рисунке ниже.

    В качестве веса вершины пусть выступает значение координаты y, а матрицы $$M_{1}$$ и $$M_{2}$$ задают матрицы поворота вокруг оси 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;
    }

    Присутствующие в шейдере матрицы преобразования $$M_{1}$$ и $$M_{2}$$ устанавливаются через вызывающую программу с помощью таблицы констант.

    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;

    Компиляция пиксельного шейдера осуществляется также с помощью функции D3DXCompileShaderFromFile().

    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);

    Следующий шаг – получение указателя на откомпилированный код шейдера. Реализуется этот шаг вызовом метода CreatePixelShader() интерфейса IDirect3DDevice9.

    C++
    LPDIRECT3DPIXELSHADER9 PixelShader = NULL;
    
    device->CreatePixelShader( (DWORD*)Code->GetBufferPointer(),
                               PixelShader );
    Pascal
    var
      PixelShader: IDirect3DPixelShader9;
    ...
    device.CreatePixelShader(Code.GetBufferPointer, PixelShader);

    Следующий шаг заключается в установке пиксельного шейдера в функции рендеринга. Осуществляется это путем вызова метода SetPixelShader() интерфейса IDirect3DDevice9, где в качестве параметра передается указатель на пиксельный шейдер.

    C++ device->SetPixelShader( PixelShader );
    Pascal device.SetPixelShader(PixelShader);

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

    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;

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

    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;

    Графический процессор в задачах обработки изображений

    Проблема большого количества вычислений возникает в ряде задач многих направлений науки и техники. На сегодняшний день, когда с помощью компьютеров решаются, чуть ли не все задачи человечества, применение электронных вычислительных машин является естественно разумным шагом. Среди всего множества задач можно выделить некоторый спектр, для решения которых требуется от нескольких часов до нескольких дней машинного времени. Среди таких ресурсоемких в вычислительном плане задач и направлений можно отметить следующие: предсказание погоды, климата и глобальных изменений в атмосфере, генетика человека, астрономия, транспортные задачи, гидро- и газодинамика, управляемый термоядерный синтез, разведка нефти и газа, вычислительные задачи наук о мировом океане, распознавание изображений и синтез речи. Использование суперкомпьютеров с большим количеством независимо работающих параллельных процессоров и технологий высокопроизводительных вычислений может существенно снизить заявленные выше временные оценки. Такой подход является заведомо очень дорогим в финансовом плане и позволителен для узкого круга исследователей и ученых. Тем не менее, сейчас в области настольных персональных компьютеров начинают широко распространяться процессоры с несколькими независимыми ядрами, что позволяет решать уже некоторые задачи в параллельном режиме более широкому кругу обычных пользователей и специалистов. Однако количество ядер в таких процессорах ограничено, как правило, двумя либо четырьмя штуками, что не всегда дает особого приращения производительности. Но современный персональный компьютер в большинстве случаев может быть оснащен сегодня помимо мощного центрального процессора ( CPU - Central Processing Unit ) еще и современной видеокартой с графическим процессором ( GPU - Graphics Processing Unit ) производительностью несколько сотен миллиардов операций с плавающей точкой в секунду. Даже самые современные серверные процессоры далеки от такой производительности. Вообще изначально область применения графического процессора была просчет и отображение трехмерных сцен. Однако, с появлением видеокарт, позволяющих их программировать, круг вычислительных задач, решаемых с помощью графического процессора, существенно расширился. Поэтому возникает резонный вопрос. Почему бы не задействовать вычислительные ресурсы графического процессора для решения задач отличных от его "традиционных" графических? Как мы уже уяснили, обработка вершин и пикселей в графическом конвейере ведется в параллельном режиме. Количество параллельных блоков по преобразованию вершин зависит от модели графического процессора и может колебаться от 2 до 8 штук. Аналогично блок пиксельной обработки также функционирует в параллельном режиме, причем количество пиксельных блоков может быть от 4 до 48 штук в зависимости от типа видеокарты (на момент написания данных строк).

    В качестве области исследования и проведения вычислительных экспериментов рассмотрим точечные процессы цифровой обработки изображений. С цифровой обработкой изображений сталкиваются при решении многих научных и технических задач. Обработка изображений в широком смысле слова означает выполнение различных операций над многомерными сигналами, которыми изображения и являются. Цели, преследуемые при обработке изображений весьма различны и, как правило, зависят от конкретной решаемой задачи. Это может быть улучшение яркости или контраста в вашей домашней коллекции цифровых фотографий, получение монохромных (бинарных) изображений, обработка изображений сглаживающими фильтрами для удаления шумов и мелких искажений, выделение значимых признаков на изображении с применением в последствии алгоритмов распознавания, например, формы символов. Современная жизнь ставит ряд требований к подобным методам обработки. Одно из самых главных это высокая эффективность и скорость работы алгоритмов с использованием персонального компьютера. Традиционно обработкой изображений занимался центральный процессор системы. Для этого каждый элемент изображения (пиксель) подвергался некоторому, как правило, однотипному преобразованию. И в результате получалось, что для изображения размерами M на N пикселей требуется MxN операций процессора. При значительных размерах изображения этот объем вычислений может оказаться критическим для одного устройства обработки информации. Поэтому, чтобы снизить вычислительную нагрузку алгоритма вполне резонно воспользоваться идеями и методами параллельных вычислений. Однако привлечение дорогостоящих параллельных суперкомпьютеров в данной задаче не является критически необходимым. Подобный класс задач можно попытаться решить с помощью обычной современной видеокарты, стоимость которой на несколько порядков меньше любого вычислительного кластера. Под точечными процессами будем понимать набор алгоритмов, которые подвергают обработке каждый пиксель изображения независимо от остальных элементов. Примерами точечных процессов могут выступать: приведение цветного изображения к оттенкам серого цвета, увеличение/уменьшение яркости и контраста, негативное преобразование, пороговое отсечение, соляризация и др. Следует отметить, что перечисленные точечные процессы мы будем рассматривать для изображений в оттенках серого цвета, так называемых grayscale. В подобных изображениях присутствуют только 256 оттенков какого-либо основного цвета. Как правило, используются оттенки серого цвета так, что палитра цветов содержит 256 "плавноизменяющихся" от черного к белому цвету интенсивностей. При этом черный цвет кодируется нулем, белый – числом 255. Таким образом, точечный процесс можно представить как некую функцию, определенную на целочисленном дискретном множестве [0…255] с таким же множеством значений.

    Преобразование просветления увеличивает или уменьшает значение яркости каждого пикселя в отдельности. Пусть I(x,y) – значение яркости пикселя (x,y) в изображении I. Тогда операция просветления на языке формул может быть выражена следующим образом: I(x,y)= I(x,y)+b, где b – постоянная яркости. Если b>0, то яркость в изображении будет увеличиваться (просветление); если же b<0, то яркость будет уменьшаться (затемнение). Могут возникнуть случаи, при которых значение выражения I(x,y)+b выйдет за пределы отрезка [0…255]. В этом случае значение, вышедшее за границы отрезка приводят к значению ближайшей границы, т.е. либо к 0, либо к 255. Графически операцию просветления можно описать следующими графиками функций.

    Рассмотрим произвольное изображение I в оттенках серого цвета. Введем массив H, содержащий 256 элементов: H[0…255]. Каждый i-й элемент этого массива будет содержать количество пикселей в изображении I со значением интенсивности i. Если визуализировать массив H в виде графика функции, то получим так называемую гистограмму интенсивностей яркости исходного изображения I. Вид гистограммы позволяет получить представление об общей яркости изображения. Гистограмма интенсивности является информативным инструментом при анализе общей яркости в изображении. Способ получения гистограммы интенсивности изображения можно записать на алгоритмическом языке следующим образом.

    Цикл по x от 0 до ШиринаИзображения-1 
       Цикл по y от 0 до ВысотаИзображения-1
    	ТелоЦикла
    	    k = I(x,y);
    	    H[k] = H[k] + 1;
    	КонецТелоЦикла

    Ниже на рисунке приведены три гистограммы интенсивности различного класса.

    Гистограмма слева определяет изображения, в которых мало пикселей черного и белого цветов (низкоконтрастные изображения). Гистограмма посередине соответствует изображениям, в которых число черных и белых пикселей значительно превышает все остальные (высококонтрастные изображения). И гистограмма справа представляет изображения, в которых количество пикселей с различными интенсивностями цветов приблизительно одинаково (нормальноконтрастные изображения). Изменение контраста в изображении можно осуществить с помощью линейных преобразований. Для гистограмм, соответствующих низкоконтрастным изображениям, выделяют отрезок [Q1,Q2], на котором сосредоточена значительная часть интенсивностей. Затем данный отрезок [Q1,Q2] линейно отражают в отрезок [0…255] с помощью следующего преобразования: I(x,y)= 255*(I(x,y)-Q1)/(Q2-Q1). Аналогично можно получить формулу для уменьшения контраста в изображении. В этом случае преобразование будет иметь следующий вид: I(x,y)= R1+I(x,y)*(R2-R1)/255, где [R1,R2] – некий отрезок, в который отображается все множество значений интенсивностей. Графически операции увеличения и уменьшения контрастности в изображении можно представить следующим образом.

    Негативное преобразование инвертирует значения интенсивностей яркости так, что темные пиксели становятся светлыми и наоборот. Математически это задается довольно простой формулой:

    I(x,y)= 255- I(x,y), а графически это выглядит следующим образом.

    Пороговое отсечение (бинаризация) преобразует изображение в оттенках серого в изображение, в котором присутствует всего два цвета, как правило, белый и черный (бинарное). На языке формул бинаризация имеет такой вид: I(x,y)=0, если I(x,y)>p ; I(x,y)=255, если I(x,y)<p, где p – некоторый заданный порог. Графически операция порогового отсечения задается следующим образом.

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

    Получение изображения в оттенках серого из цветного также можно отнести к точечным процессам. Каждый пиксель цветного изображения представляет собой тройку байт, значения которых соответствуют весам красного, зеленого и синего цветов. Это так называемая цветовая модель RGB (Red, Green, Blue). Преобразование цветного изображения в оттенки серого осуществляется по следующей формуле:

    I=0.3*R+0.59*G+0.11*B, где I – значение интенсивности серого цвета, R, G, B - значения весов красного, зеленого и синего цветов соответственно.

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

    sampler tex0;
    
    struct PS_INPUT
    {
      float2 base : TEXCOORD0;
    };
    struct PS_OUTPUT
    {
      float4 diffuse : COLOR0;
    };
    PS_OUTPUT Main (PS_INPUT input)
    {
      PS_OUTPUT output;
      float4 col = tex2D(tex0, input.base);
      ...
      return output;
    };
    Исходное изображение
    Приведение к оттенкам серого цвета
    float4 col = tex2D(tex0, input.base);
    float4 lum = float4(0.3, 0.59, 0.11, 0);
    output.diffuse = dot(lum,col);
    Увеличение яркости
    float4 col = tex2D(tex0, input.base);
    float4 lum = float4(0.3, 0.59, 0.11, 0);
    output.diffuse = dot(lum,col)+0.2f;
    Уменьшение яркости
    float4 col = tex2D(tex0, input.base);
    float4 lum = float4(0.3, 0.59, 0.11, 0);
    output.diffuse = dot(lum,col)-0.2f;
    Увеличение контраста
    float4 col = tex2D(tex0, input.base);
      float4 lum = float4(0.3, 0.59, 0.11, 0);
      float gray = dot(lum,col);
      float Q1 = 0.2f;  float Q2 = 0.7f;
      if (gray > Q2) gray = 1.0f;
      else if (gray < Q1) gray = 0.0f;
      else gray = (gray - Q1)/(Q2-Q1);
      output.diffuse = gray;
    Уменьшение контраста
    float4 col = tex2D(tex0, input.base);
      float4 lum = float4(0.3, 0.59, 0.11, 0);
      float gray = dot(lum,col);
      float R1 = 0.2f;  float R2 = 0.7f;
      gray = R1+gray*(R2-R1);
      output.diffuse = gray;
    Пороговое отсечение (бинаризация)
    float4 col = tex2D(tex0, input.base);
      float4 lum = float4(0.3, 0.59, 0.11, 0);
      float gray = dot(lum,col);
      float p = 0.4f;
      if (gray > p) gray = 1.0f;
      else if (gray < p) gray = 0.0f;
      output.diffuse = gray;
    Негативное преобразование
    float4 col = tex2D(tex0, input.base);
      float4 lum = float4(0.3, 0.59, 0.11, 0);
      float gray = dot(lum,col);
      output.diffuse = 1.0f-gray;

    Рассмотрим теперь некоторые методы обработки изображения с использованием пространственных процессов. В этом случае элемент изображения получает новое значение на основе группы элементов, примыкающих к данному. Область (окрестность) примыкания представляет собой группу элементов изображения использующаяся в пространственных процессах. Как правило, область примыкания есть квадратная матрица нечетной размерности с центром в обрабатываемом элементе.

    Пространственная частота изображения – скорость изменения яркости по координатам. Говорят, что присутствует высокая частота в изображении, если яркость меняется очень сильно. Одной из центральных задач в обработке изображений является построение пространственного фильтра. Фильтр позволяет усилить или ослабить компоненты различной частоты. Пространственный фильтр – процесс, который способен выделить (подчеркнуть) компоненты определенной частоты. Двумерный фильтр устроен следующим образом. Берется матрица размером 3х3, 5х5, 7х7 и т.д. и на ней определяется некоторая функция. Упомянутая матрица называется окном или апертурой, а заданная на нем функция – весовой или функцией окна. Каждому элементу окна соответствует число, называемое весовым множителем. Совокупность всех весовых множителей и составляет весовую функцию. Нечетные размеры апертуры объясняются однозначностью определения центрального элемента. Фильтрация осуществляется перемещением окна (апертуры) фильтра по изображению. В каждом положении апертуры выполняются однотипные действия, которые определяют так называемый отклик фильтра. Весовая функция в процессе перемещения остается неизменной. В каждом положении окна происходит операция свертки – линейная комбинация значений элементов изображения: $$\left| \begin{array}{ccc} p_{1} p_{2} p_{3} \\ p_{4} p_{5} p_{6} \\ p_{7} p_{8} p_{9} \end{array}\right | \left | \begin{array}{ccc} k_{1} k_{2} k_{3} \\ k_{4} k_{5} k_{6} \\ k_{7} k_{8} k_{9} \end{array}\right | \sum_{i=1}^{9} p_{i}K_{i}=p_{5}^{'}$$, где $$p_{i}$$ - элементы области примыкания, $$k_{i}$$ - весовые множители, $$p_{5}^{'}$$ - новое значение пикселя. При каждом положении окна весовая функция поэлементно умножается на значение соответствующих пикселей исходного изображения и произведения суммируются. Полученная сумма называется откликом фильтра и присваивается тому пикселю нового изображения, который соответствует положению центра окна. Низкочастотный фильтр – процесс, который ослабляет высокочастотные компоненты и усиливает роль низкочастотных.

    Сглаживание изображения реализуется с помощью следующих ядер.

    $$H_{1}=\frac{1}{9}\left( \begin{array}{ccc} 1 1 1 \\ 1 1 1 \\ 1 1 1 \end{array} \right), H_{2}=\frac{1}{10}\left( \begin{array}{ccc} 1 1 1 \\ 1 2 1 \\ 1 1 1 \end{array} \right), H_{3}=\frac{1}{16}\left( \begin{array}{ccc} 1 2 1 \\ 2 4 2 \\ 1 2 1 \end{array} \right)$$

    Следует заметить, что общая яркость исходного изображения и результирующего будет одинаковой.

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

    $$H_{1}=\left( \begin{array}{ccc} -1 -1 -1 \\ -1 9 -1 \\ -1 -1 -1 \end{array} \right), H_{2}=\left( \begin{array}{ccc} 0 -1 0 \\ -1 5 -1 \\ 0 -1 0 \end{array} \right), \\ H_{3}=\left( \begin{array}{ccc} 1 -2 1 \\ -2 5 -2 \\ 1 -2 1 \end{array} \right)$$

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

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

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

    Метод усиления края по Лапласу не зависит от направления краев, и высвечиваются все направления. Ниже приведены три лапласиана.

    $$H_{1}=\left( \begin{array}{ccc} 0 1 0 \\ 1 -4 1 \\ 0 1 0 \end{array} \right), H_{2}=\left( \begin{array}{ccc} -1 -1 -1 \\ -1 5 -1 \\ -1 -1 -1 \end{array} \right),\\ H_{3}=\left( \begin{array}{ccc} 1 -2 1 \\ -2 4 -2 \\ 1 -2 1 \end{array} \right)$$

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

    $$H_{1}=\left( \begin{array}{ccc} -1 0 1 \\ -2 0 2 \\ -1 0 1 \end{array} \right), H_{2}=\left( \begin{array}{ccc} 1 2 1 \\ 0 0 0 \\ -1 -2 -1 \end{array} \right)$$

    Исходя из этих сверток, вычисляется величина и направление краев. В качестве отклика данного фильтра выступает величина $$\sqrt{P^{2}+Q^{2}}$$, где P и Q - отклики ядер $$H_{1}$$ и $$H_{2}$$ соответственно.

    Метод усиления края с помощью оператора Превита также использует два ядра:

    $$H_{1}=\left( \begin{array}{ccc} 1 0 -1 \\ 1 0 -1 \\ 1 0 -1 \end{array} \right), H_{2}=\left( \begin{array}{ccc} -1 -1 -1 \\ 0 0 0 \\ 1 1 1 \end{array} \right)$$

    Результат работы оператора Превита есть max{P,Q}, где P и Q - отклики ядер $$H_{1}$$ и $$H_{2}$$ соответственно.

    Метод преобразования реализующий эффект тиснения на изображении. Результирующее изображение выглядит как будто "выдавленным" или "вдавленным". Фильтр такого преобразования имеет вид:$$H_{1}=\left( \begin{array}{ccc} 0 1 0 \\ -1 0 1 \\ 0 -1 0 \end{array} \right).$$ К отклику ядра прибавляется константа яркости, как правило, это 128.

    Как нам известно, обращение к элементам текстуры в пиксельном шейдере производится с помощью текстурных координат. Левый верхний тексель имеет текстурные координаты (0,0), левый нижний – координаты (0,1), правый верхний – координаты (1,0), правый нижний – координаты (1,1)

    Задача состоит в том, чтобы для произвольного текселя изображения, имеющего текстурные координаты (u,v), определить значения текстурных координат восьми его соседей. Пусть у нас количество текселей в каждой строке будет W, а количество текселей в каждом столбце – H. В силу того, что тексели расположены равномерно (на одинаковом расстоянии друг от друга), можно вычислить шаг приращения du и dv в текстурных координатах по горизонтали и вертикали соответственно. Итак, $$du=\frac{1}{W-1},dv=\frac{1}{H-1}$$, где W и H – ширина и высота изображения соответственно. Например, для изображения, представленного выше, W = 20 пикселей, H = 9 пикселей, и шаг по горизонтали $$du=\frac{1}{19}$$, а шаг по вертикали $$dv=\frac{1}{8}$$. Таким образом, для произвольного текселя, имеющего текстурные координаты (u,v), текстурные координаты его восьми соседей будут следующие: (u-du, v-dv), (u, v-dv), (u+du, v-dv), (u-du, v), (u+du, v), (u-du, v+dv), (u, v+dv), (u+du, v+dv), как показано на приведенном ниже рисунке.

    Ниже приведен пример пиксельного шейдера, который реализует метод усиления границ на изображении с помощью оператора Собеля и метод тиснения, а также примеры изображений.

    sampler tex0;
    
    struct PS_INPUT
    {
      float2 base : TEXCOORD0;
    };
    
    struct PS_OUTPUT
    {
      float4 diffuse : COLOR0;
    };
    
    PS_OUTPUT Main (PS_INPUT input)
    {
      PS_OUTPUT output;
      const float W =320.0f; 
      const float H =240.0f;
      const float du=1.0f/(W-1); 
      const float dv=1.0f/(H-1);
      const float2 c[9] = {
                   float2(-du, -dv),   float2(0.0f, -dv),  float2(du, -dv),
                   float2(-du, 0.0f),  float2(0.0f, 0.0f), float2(du, 0.0f),
                   float2(-du, dv),    float2(0.0f, dv),   float2(du, dv)
      };
      
      float3 col[9];
      for (int i=0; i<9; i++) {
        col[i] = tex2D(tex0, input.base+c[i]);
      }
      
      float lum[9];
      float3 gray = (0.30f, 0.59f, 0.11f) ;
      for (int i=0; i<9; i++) {
        lum[i] = dot(col[i], gray);
      }
      
      float res1 = 0.0f;
      float res2 = 0.0f;
      const float sobel1[9] = { 1, 2, 1,  0, 0, 0, -1, -2, -1};
      const float sobel2[9] = {-1, 0, 1, -2, 0, 2, -1,  0,  1};
      const float tisnenie[9] = {0, 1, 0, -1, 0, 1, 0, -1, 0};
      for (int i=0; i<9; i++) {
        res1+=lum[i]*sobel1[i];
        res2+=lum[i]*sobel2[i];
        res+=lum[i]*tisnenie[i];
      }
      output.diffuse = sqrt(res1*res1+res2*res2);
      //output.diffuse = res+0.5f;
      return output;
    };
    Исходное изображение Оператор Собеля Метод тиснения
    Вернуться к учебному плану