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

Расчет освещенности. Файлы эффектов

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

Расчет освещенности с помощью шейдеров

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

При однотонной закраске вычисляется один уровень интенсивности, который используется для закраски всего полигона. При использовании закраски этого типа будет проявляться эффект резкого перепада интенсивности на всех граничных ребрах объекта.

Метод закраски, который основан на интерполяции интенсивности (метод Гуро), позволяет устранить дискретность изменения интенсивности. Процесс закраски по методу Гуро осуществляется следующими шагами:

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

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

    struct VS_INPUT
    {
    	float4 position  : POSITION;
    	float3 normal    : NORMAL;
    };

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

    struct VS_OUTPUT
    {
    	float4 position  : POSITION;
    	float4 color     : COLOR0;
    };

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

    float4x4 WorldViewProj;
    float4x4 World;
    float4   vecLight;

    Главная функция вершинного шейдера должна выполнять следующие операции:

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

    VS_OUTPUT main_vs( VS_INPUT In )
    {
        VS_OUTPUT Out;
        Out.position = mul( In.position, WorldViewProj );
        float3 pos = mul( In.position, World );
        float3 light = normalize(vecLight-pos);
        float3 normal = normalize(mul( In.normal, World ));
        float4 green = {0.0f, 1.0f, 0.0f, 1.0f};
        Out.color = green*dot(light, normal);
        return Out;
    }

    Функция mul() производит умножение вектора на матрицу, функция normalize() осуществляет нормировку вектора, функция dot() вычисляет скалярное произведение двух векторов. Как видно из представленных строк кода, для каждой вершины мы должны определять вектор на источник света и преобразовывать нормаль, как показано ниже.

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

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

    struct VS_INPUT
    {
    	float4 position  : POSITION;
    	float3 normal    : NORMAL;
    };

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

    struct VS_OUTPUT
    {
    	float4 position  : POSITION;
    	float3 light     : TEXCOORD0;
    	float3 normal    : TEXCOORD1;
    };

    Следует заметить, что атрибуты вершины нормаль и вектор на источник определены как текстурные координаты размерности 3 (float3). Тело вершинного шейдера будет выглядеть следующим образом.

    VS_OUTPUT main_vs( VS_INPUT In )
    {
        VS_OUTPUT Out;
        Out.position = mul( In.position, WorldViewProj );
        float3 pos = mul( In.position, World );
        Out.light = normalize(vecLight-pos);
        Out.normal = normalize(mul( In.normal, World ));
        return Out;
    }

    Глобальные переменные будут такими же как и в предыдущем случае.

    float4x4 WorldViewProj;
    float4x4 World;
    float4   vecLight;

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

    float4 main_ps(float3 light : TEXCOORD0, float3 normal : TEXCOORD1) : COLOR0
    {
        float4 diffuse = {1.0f, 1.0f, 0.0f, 1.0f};
        return diffuse*dot(light, normal);  
    }

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

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

    В зависимости от вектора нормали интенсивность в каждом пикселе будет различной. Следует заметить, что вектор нормали (nx, ny, nz) и цвет (R, G, B) кодируется тройкой чисел. Но цвет кодируется тремя положительными величинами из отрезка [0, 1], тогда как компоненты вектора нормали могут принимать и отрицательные значения. Так как координаты нормализованного вектора лежат в диапазоне [-1, 1], то можно с помощью линейного преобразования отобразить отрезок [-1, 1] в отрезок [0, 1]. Для этого можно воспользоваться следующей формулой: N*0.5+0.5, где N –вектор нормали. Такой процесс кодировки проделывается для каждого компонента вектора. Обратное преобразование (отрезок [0, 1] отобразить в отрезок [-1, 1] ) может быть реализовано с помощью формулы 2*C–1, где C – значение цвета. Например, вектор (1, 0, 1) будет преобразован в тройку чисел (1, 0.5, 1) – светло-фиолетовый цвет. Для получения карты нормалей из исходного изображения имеются специальные программы и алгоритмы, например, существует плагин к PhotoShop’у, с помощью которого можно получить карту нормалей. Ниже представлен пример текстуры и соответствующая ей карта нормалей.

    Исходное изображение Карта нормалей

    Таким образом, для вычисления интенсивности в каждом пикселе необходимо:

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

    float4  Light;
    sampler tex0;
    sampler tex1;
    
    struct PS_INPUT
    {
      float2 uv0 : TEXCOORD0;
      float2 uv1 : TEXCOORD1;
      float4 color: COLOR0;
    };
    
    float4 Main (PS_INPUT input): COLOR0
    {
      float4 texel0 = tex2D(tex0, input.uv0);
      float4 texel1 = 2.0f*tex2D(tex1, input.uv1) - 1.0f;
      return texel0*dot(normalize(Light), texel1);
    };

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

    return texel0*dot(normalize(Light), texel1);
    return input.color*texel0*dot(normalize(Light), texel1);

    Файлы эффектов

    В состав библиотеки Direct3D входит набор средств, которые предоставляют некий оберточный механизм для работы с вершинными и пиксельными шейдерами, установкой текстурных состояний и константами. Данная возможность реализуется с помощью интерфейса ID3DXEffect. Данный "оберточный" интерфейс инкапсулирует в себе следующие особенности:

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

    float4x4 WorldViewProj;
    float4x4 World;
    float4   Light;
    
    texture Tex0 < string name = "texture.bmp"; >;
    
    
    struct VS_INPUT
    {
    	float4 position  : POSITION;
    	float3 normal    : NORMAL;
          float2 texcoord  : TEXCOORD0;
    };
    
    struct VS_OUTPUT
    {
    	float4 position  : POSITION;
    	float4 color     : COLOR0;
          float2 texcoord  : TEXCOORD0;
    };
    
    VS_OUTPUT main_vs( VS_INPUT In )
    {
        VS_OUTPUT Out;
        Out.position = mul( In.position, WorldViewProj );
        float3 pos = mul( In.position, World );
        float3 light = normalize(vecLight-pos);
        float3 normal = normalize(mul( In.normal, World ));
        float4 green = {0.0f, 1.0f, 0.0f, 1.0f};
        Out.texcoord  = In.texcoord;
        Out.color = green*dot(light, normal);
        return Out;
    }
    
    sampler Sampler = sampler_state
    {
    Texture = (Tex0);
    MipFilter = LINEAR;
    MinFilter = LINEAR;
    MagFilter = LINEAR;
    };
    
    struct PS_INPUT
    {
          float2 texcoord  : TEXCOORD0;
    	float4 color     : COLOR0;
    };
    
    float4 main_ps( PS_INPUT In ) : COLOR0
    {
       return tex2D(Sampler, In.texcoord ) * In.color;
    }
    
    technique tec0
    {
        pass p0
        {
          VertexShader = compile vs_1_1 main_vs();
          PixelShader = compile ps_1_1 main_ps();
        }
    }

    Как видно из представленного примера, файлы эффектов объединяют в себе вершинный, пиксельный шейдеры, а также различные настройки режима воспроизведения. Кроме того, файлы эффектов позволяют объединить ряд вариантов воспроизведения в одном файле. Такая модульность открывает широкие возможности по использованию одной программы на различных аппаратных решениях (компьютерах с различными техническими возможностями). Каждый файл эффектов может содержать один и более разделов technique, которые как раз и предназначены для различных способов реализации алгоритма воспроизведения. Каждый раздел techniques может содержать внутри себя один и более разделов rendering pass, которые объединяют в себе состояния устройства вывода, текстурные уровни, шейдеры. Следует заметить, что файлы эффектов не ограничены в использовании только программируемых элементов графического конвейера (шейдеров). Они также могут использоваться и в фиксированном конвейере для управления состояниями устройства вывода, источниками света, материала ми и текстурами. Применение нескольких разделов rendering pass позволяют получить различные эффекты визуализации, например, двухпроходный алгоритм построения тени с использованием буфера трафарета. Ниже представлен шаблон файла эффектов, в котором присутствует два раздела technique, плюс к этому второй раздел содержит два подраздела rendering pass.

    technique tec0
    {
        pass p0 // первый и единственный раздел rendering pass
        {
          // состояние устройства вывода, шейдеры, семплеры
        }
    }
    
    
    
    technique tec1
    {
        pass p0 // первый rendering pass
        {
          // состояние устройства вывода, шейдеры, семплеры
        }
    
        pass p1 // второй раздел rendering pass
        {
          // состояние устройства вывода, шейдеры, семплеры
        }
    }

    Для управления файлами эффектов из приложения необходимо воспользоваться методами интерфейса ID3DXEffect. Вначале необходимо объявить переменную интерфейсного типа ID3DXEffect, и возможно переменную для буфера ошибок.

    C++
    LPD3DXEFFECT Effect = NULL;
    LPD3DXBUFFER BufferErrors = NULL;
    Pascal
    var
      Effect: ID3DXEffect;
      BufferErrors: ID3DXBuffer;

    Для загрузки и компиляции файла эффектов предназначена функция D3DXCreateEffectFromFile(), которая имеет следующие параметры:

  • Ссылка на устройство вывода;
  • Имя файла эффекта;
  • Набор макро определений (может быть пустым);
  • Указатель на интерфейс ID3DXInclude для обработки включений в файле эффекта (может быть пустым);
  • Набор флагов компиляции (может быть нулем);
  • Указатель на интерфейс ID3DXEffectPool для обработки общих параметров в эффекте (может быть пустым);
  • Указатель на полученный результат;
  • Указатель на буфер ошибок.
  • C++
    D3DXCreateEffectFromFile( device, "effect.fx", NULL, NULL,
                              0, NULL, Effect, BufferErrors)
    Pascal
    D3DXCreateEffectFromFile(device, ' effect.fx ', nil, nil, 
                             0, nil, Effect, BufferErrors);

    Следующим шагом является установка активного раздела technique. Это реализуется с помощью вызова метода SetTechnique интерфейса ID3DXEffect, где в качестве параметра передается название раздела technique.

    C++ Effect->SetTechnique("tec0");
    Pascal
    Effect.SetTechnique('tec0');

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

    C++
    unsigned int numPasses = 0;
    
    Effect->Begin(numPasses, 0);
    for(unsigned int pass = 0; pass < numPasses; pass++)
    {
      Effect->BeginPass(pass);
      device->DrawPrimitive(...);
      Effect->EndPass();
    }
    Effect->End();
    Pascal
    var 
      numPasses: DWord;
      pass: DWord;
    ...
    Effect._Begin(@numPasses, 0);
    for pass:=0 to numPasses-1 do begin
      Effect.BeginPass(pass);
      Device.DrawPrimitive(...);
      Effect.EndPass;
    end;
    Effect._End;

    Также как и в программах, написанных на языке HLSL, в файлы эффектов можно передавать параметры. Установка значений параметров осуществляется через вызов методов SetXXX интерфейса ID3DXEffect. Ниже представлены основные методы передачи параметров в файл эффектов:

    SetBool()
    SetInt()
    SetMatrix()
    SetString()
    SetTexture()
    SetVector()

    Следует отметить еще одно достоинство файлов эффектов. Для работы с ними существует программа EffectEdit, которая поставляет вместе с DirectX SDK. Эта программа позволяет загружать файлы эффектов и осуществлять рендеринг сцены при установленных настройках графического конвейера (шейдеров, текстур, состояний устройства воспроизведения и др.). Кроме того, утилита EffectEdit может быть использована в качестве отладочного механизма ваших шейдерных программ.

    Полноэкранный режим воспроизведения

    До сих пор вывод всех сцен построения производился в окно (на форму). Библиотека Direct3D позволяет осуществить рендеринг в полноэкранном режиме ( Full Screen ). Для перехода в полноэкранный режим вывода примитивов необходимо лишь при заполнении структуры D3DPRESENT_PARAMETERS явно указать размеры заднего буфера ( BackBuffer ), установить флаг Windowed в значение FALSE. Также нужно указать режим переключения первичного и вторичного (заднего) буферов рендеринга и определить интервал смены буферов.

    C++
    D3DPRESENT_PARAMETERS params;
    …
    ZeroMemory( params, sizeof(params) );
    params.Windowed = FALSE;
    params.SwapEffect = D3DSWAPEFFECT_FLIP;
    params.BackBufferWidth = 1280;
    params.BackBufferHeight = 1024;
    params.BackBufferFormat = display.Format;
    params.PresentationInterval = D3DPRESENT_INTERVAL_IMMEDIATE;
    …
    Pascal
    var
    params: TD3DPresentParameters;
    …
    ZeroMemory( @params, SizeOf(params) );
    params.Windowed := False;
    params.SwapEffect := D3DSWAPEFFECT_FLIP;
    params.BackBufferWidth := 1280;
    params.BackBufferHeight := 1024;
    params.BackBufferFormat := display.Format;
    params.PresentationInterval := D3DPRESENT_INTERVAL_IMMEDIATE;
    …

    Единственно, что необходимо еще проделать – это организовать возможность выхода (закрытия) программы. Для этого можно, например, обработать событие системы WM_KEYDOWN. Полноэкранный режим довольно часто используют в играх, так как именно при таком режиме достигается максимальная производительность видеокарты. Можно воспользоваться функцией Win32 API GetSystemMetrics() для того, чтобы узнать текущее разрешение экрана.

    C++
    D3DPRESENT_PARAMETERS params;
    …
    params.BackBufferWidth = GetSystemMetrics(SM_CXSCREEN);
    params.BackBufferHeight = GetSystemMetrics(SM_CYSCREEN);
    Pascal
    var
    params: TD3DPresentParameters;
    …
    params.BackBufferWidth := GetSystemMetrics(SM_CXSCREEN);
    params.BackBufferHeight := GetSystemMetrics(SM_CYSCREEN);

    Обработка ошибок

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

    FAILED()
    SUCCEEDED()

    Принцип работы этих макросов очень прост и представлен ниже.

    if ( FAILED()) // ошибка при выполнении команды
    ...
    else // команда завершилась успешно

    Для второго макроса принцип действия будет противоположным.

    if (SUCCEEDED ()) // команда завершилась успешно 
    ...
    else // ошибка при выполнении команды

    Ниже приведены некоторые примеры обработки ошибок.

    C++
    LPDIRECT3D9 direct3d = NULL;
    ...
    if( FAILED( direct3d -> CreateDevice( … ) ) )
    {
      // обработка ошибки
    }
    Pascal
    var
      direct3d: IDirect3D9;
    ...
    if Failed ( direct3d.CreateDevice( … ) ) then
    begin
      // обработка ошибки
    end;

    Можно осуществлять анализ ошибок через дополнительную переменную.

    C++
    HRESULT hr;
    hr = direct3d -> CreateDevice( … );
    if( FAILED(hr) )
    {
    
    }
    Pascal
    var
      hr: HRESULT;
    …
    hr := direct3d.CreateDevice( … );
    if Failed(hr) then
    begin
    
    end

    И еще один способ обработки ошибок заключается в сравнении результата вызова функции с константой D3D_OK.

    C++
    if ( direct3d -> CreateDevice(  …  ) != D3D_OK )
    {
    
    }
    Pascal
    if direct3d.CreateDevice( … ) <> D3D_OK then 
    begin
    
    end;

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

    C++
    if ( D3DXCompileShaderFromFile( "shader.psh", NULL, NULL, "main",  
                                    "ps_2_0", 0, Code, BufferErrors,  
                                    NULL) != D3D_OK )
    {
    MessageBox(NULL, (const char*)BufferErrors->GetBufferPointer(),  
               "Ошибка в шейдере!!!", MB_OK | MB_ICONEXCLAMATION);
    }
    Pascal
    if D3DXCompileShaderFromFile('shader.psh', nil, nil, 'Main',
       'ps_2_0', 0, @Code, @BufferErrors, nil)  D3D_OK
    then begin
      MessageBox(0, BufferErrors.GetBufferPointer, 
         'Ошибка в шейдере!!!', 
                 MB_OK or MB_ICONEXCLAMATION);
    end;

    Такой информативный способ позволяет легко "увидеть" в какой именно строке шейдера допущена ошибка.

    Подсчет количества кадров в секунду

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

    C++
    count = 0;
    last_tick = GetTickCount();
    while(msg.message != WM_QUIT)
    {
      if(PeekMessage(msg, 0, 0, 0, PM_REMOVE))
      {
          TranslateMessage(Msg);
        DispatchMessage(Msg);
      }
      else
      {
        this_tick = GetTickCount();
        if(this_tick-last_tick>=1000)
        {
          last_tick=this_tick;
          fps=count;
          count=0;
          // вывод значения fps  
        }
        else count++
        Render();
      }
    }
    Pascal
    count:=0;
    last_tick:=GetTickCount();
    while Msg.message <> WM_QUIT do begin
      if PeekMessage(msg, 0, 0, 0, PM_REMOVE) then begin
        TranslateMessage(Msg);
        DispatchMessage(Msg);
      end else begin
        this_tick:=GetTickCount();
        if(this_tick-last_tick>=500) then begin
          last_tick:=this_tick;
          fps:=count;
          count:=0;
          // вывод значения fps  
        end
        else count:=count+1;
        Render;
      end;
    end;
    Страницы:

    Расчет освещенности с помощью шейдеров

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

    При однотонной закраске вычисляется один уровень интенсивности, который используется для закраски всего полигона. При использовании закраски этого типа будет проявляться эффект резкого перепада интенсивности на всех граничных ребрах объекта.

    Метод закраски, который основан на интерполяции интенсивности (метод Гуро), позволяет устранить дискретность изменения интенсивности. Процесс закраски по методу Гуро осуществляется следующими шагами:

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

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

    struct VS_INPUT
    {
    	float4 position  : POSITION;
    	float3 normal    : NORMAL;
    };

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

    struct VS_OUTPUT
    {
    	float4 position  : POSITION;
    	float4 color     : COLOR0;
    };

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

    float4x4 WorldViewProj;
    float4x4 World;
    float4   vecLight;

    Главная функция вершинного шейдера должна выполнять следующие операции:

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

    VS_OUTPUT main_vs( VS_INPUT In )
    {
        VS_OUTPUT Out;
        Out.position = mul( In.position, WorldViewProj );
        float3 pos = mul( In.position, World );
        float3 light = normalize(vecLight-pos);
        float3 normal = normalize(mul( In.normal, World ));
        float4 green = {0.0f, 1.0f, 0.0f, 1.0f};
        Out.color = green*dot(light, normal);
        return Out;
    }

    Функция mul() производит умножение вектора на матрицу, функция normalize() осуществляет нормировку вектора, функция dot() вычисляет скалярное произведение двух векторов. Как видно из представленных строк кода, для каждой вершины мы должны определять вектор на источник света и преобразовывать нормаль, как показано ниже.

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

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

    struct VS_INPUT
    {
    	float4 position  : POSITION;
    	float3 normal    : NORMAL;
    };

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

    struct VS_OUTPUT
    {
    	float4 position  : POSITION;
    	float3 light     : TEXCOORD0;
    	float3 normal    : TEXCOORD1;
    };

    Следует заметить, что атрибуты вершины нормаль и вектор на источник определены как текстурные координаты размерности 3 (float3). Тело вершинного шейдера будет выглядеть следующим образом.

    VS_OUTPUT main_vs( VS_INPUT In )
    {
        VS_OUTPUT Out;
        Out.position = mul( In.position, WorldViewProj );
        float3 pos = mul( In.position, World );
        Out.light = normalize(vecLight-pos);
        Out.normal = normalize(mul( In.normal, World ));
        return Out;
    }

    Глобальные переменные будут такими же как и в предыдущем случае.

    float4x4 WorldViewProj;
    float4x4 World;
    float4   vecLight;

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

    float4 main_ps(float3 light : TEXCOORD0, float3 normal : TEXCOORD1) : COLOR0
    {
        float4 diffuse = {1.0f, 1.0f, 0.0f, 1.0f};
        return diffuse*dot(light, normal);  
    }

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

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

    В зависимости от вектора нормали интенсивность в каждом пикселе будет различной. Следует заметить, что вектор нормали (nx, ny, nz) и цвет (R, G, B) кодируется тройкой чисел. Но цвет кодируется тремя положительными величинами из отрезка [0, 1], тогда как компоненты вектора нормали могут принимать и отрицательные значения. Так как координаты нормализованного вектора лежат в диапазоне [-1, 1], то можно с помощью линейного преобразования отобразить отрезок [-1, 1] в отрезок [0, 1]. Для этого можно воспользоваться следующей формулой: N*0.5+0.5, где N –вектор нормали. Такой процесс кодировки проделывается для каждого компонента вектора. Обратное преобразование (отрезок [0, 1] отобразить в отрезок [-1, 1] ) может быть реализовано с помощью формулы 2*C–1, где C – значение цвета. Например, вектор (1, 0, 1) будет преобразован в тройку чисел (1, 0.5, 1) – светло-фиолетовый цвет. Для получения карты нормалей из исходного изображения имеются специальные программы и алгоритмы, например, существует плагин к PhotoShop’у, с помощью которого можно получить карту нормалей. Ниже представлен пример текстуры и соответствующая ей карта нормалей.

    Исходное изображение Карта нормалей

    Таким образом, для вычисления интенсивности в каждом пикселе необходимо:

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

    float4  Light;
    sampler tex0;
    sampler tex1;
    
    struct PS_INPUT
    {
      float2 uv0 : TEXCOORD0;
      float2 uv1 : TEXCOORD1;
      float4 color: COLOR0;
    };
    
    float4 Main (PS_INPUT input): COLOR0
    {
      float4 texel0 = tex2D(tex0, input.uv0);
      float4 texel1 = 2.0f*tex2D(tex1, input.uv1) - 1.0f;
      return texel0*dot(normalize(Light), texel1);
    };

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

    return texel0*dot(normalize(Light), texel1);
    return input.color*texel0*dot(normalize(Light), texel1);

    Файлы эффектов

    В состав библиотеки Direct3D входит набор средств, которые предоставляют некий оберточный механизм для работы с вершинными и пиксельными шейдерами, установкой текстурных состояний и константами. Данная возможность реализуется с помощью интерфейса ID3DXEffect. Данный "оберточный" интерфейс инкапсулирует в себе следующие особенности:

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

    float4x4 WorldViewProj;
    float4x4 World;
    float4   Light;
    
    texture Tex0 < string name = "texture.bmp"; >;
    
    
    struct VS_INPUT
    {
    	float4 position  : POSITION;
    	float3 normal    : NORMAL;
          float2 texcoord  : TEXCOORD0;
    };
    
    struct VS_OUTPUT
    {
    	float4 position  : POSITION;
    	float4 color     : COLOR0;
          float2 texcoord  : TEXCOORD0;
    };
    
    VS_OUTPUT main_vs( VS_INPUT In )
    {
        VS_OUTPUT Out;
        Out.position = mul( In.position, WorldViewProj );
        float3 pos = mul( In.position, World );
        float3 light = normalize(vecLight-pos);
        float3 normal = normalize(mul( In.normal, World ));
        float4 green = {0.0f, 1.0f, 0.0f, 1.0f};
        Out.texcoord  = In.texcoord;
        Out.color = green*dot(light, normal);
        return Out;
    }
    
    sampler Sampler = sampler_state
    {
    Texture = (Tex0);
    MipFilter = LINEAR;
    MinFilter = LINEAR;
    MagFilter = LINEAR;
    };
    
    struct PS_INPUT
    {
          float2 texcoord  : TEXCOORD0;
    	float4 color     : COLOR0;
    };
    
    float4 main_ps( PS_INPUT In ) : COLOR0
    {
       return tex2D(Sampler, In.texcoord ) * In.color;
    }
    
    technique tec0
    {
        pass p0
        {
          VertexShader = compile vs_1_1 main_vs();
          PixelShader = compile ps_1_1 main_ps();
        }
    }

    Как видно из представленного примера, файлы эффектов объединяют в себе вершинный, пиксельный шейдеры, а также различные настройки режима воспроизведения. Кроме того, файлы эффектов позволяют объединить ряд вариантов воспроизведения в одном файле. Такая модульность открывает широкие возможности по использованию одной программы на различных аппаратных решениях (компьютерах с различными техническими возможностями). Каждый файл эффектов может содержать один и более разделов technique, которые как раз и предназначены для различных способов реализации алгоритма воспроизведения. Каждый раздел techniques может содержать внутри себя один и более разделов rendering pass, которые объединяют в себе состояния устройства вывода, текстурные уровни, шейдеры. Следует заметить, что файлы эффектов не ограничены в использовании только программируемых элементов графического конвейера (шейдеров). Они также могут использоваться и в фиксированном конвейере для управления состояниями устройства вывода, источниками света, материала ми и текстурами. Применение нескольких разделов rendering pass позволяют получить различные эффекты визуализации, например, двухпроходный алгоритм построения тени с использованием буфера трафарета. Ниже представлен шаблон файла эффектов, в котором присутствует два раздела technique, плюс к этому второй раздел содержит два подраздела rendering pass.

    technique tec0
    {
        pass p0 // первый и единственный раздел rendering pass
        {
          // состояние устройства вывода, шейдеры, семплеры
        }
    }
    
    
    
    technique tec1
    {
        pass p0 // первый rendering pass
        {
          // состояние устройства вывода, шейдеры, семплеры
        }
    
        pass p1 // второй раздел rendering pass
        {
          // состояние устройства вывода, шейдеры, семплеры
        }
    }

    Для управления файлами эффектов из приложения необходимо воспользоваться методами интерфейса ID3DXEffect. Вначале необходимо объявить переменную интерфейсного типа ID3DXEffect, и возможно переменную для буфера ошибок.

    C++
    LPD3DXEFFECT Effect = NULL;
    LPD3DXBUFFER BufferErrors = NULL;
    Pascal
    var
      Effect: ID3DXEffect;
      BufferErrors: ID3DXBuffer;

    Для загрузки и компиляции файла эффектов предназначена функция D3DXCreateEffectFromFile(), которая имеет следующие параметры:

  • Ссылка на устройство вывода;
  • Имя файла эффекта;
  • Набор макро определений (может быть пустым);
  • Указатель на интерфейс ID3DXInclude для обработки включений в файле эффекта (может быть пустым);
  • Набор флагов компиляции (может быть нулем);
  • Указатель на интерфейс ID3DXEffectPool для обработки общих параметров в эффекте (может быть пустым);
  • Указатель на полученный результат;
  • Указатель на буфер ошибок.
  • C++
    D3DXCreateEffectFromFile( device, "effect.fx", NULL, NULL,
                              0, NULL, Effect, BufferErrors)
    Pascal
    D3DXCreateEffectFromFile(device, ' effect.fx ', nil, nil, 
                             0, nil, Effect, BufferErrors);

    Следующим шагом является установка активного раздела technique. Это реализуется с помощью вызова метода SetTechnique интерфейса ID3DXEffect, где в качестве параметра передается название раздела technique.

    C++ Effect->SetTechnique("tec0");
    Pascal
    Effect.SetTechnique('tec0');

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

    C++
    unsigned int numPasses = 0;
    
    Effect->Begin(numPasses, 0);
    for(unsigned int pass = 0; pass < numPasses; pass++)
    {
      Effect->BeginPass(pass);
      device->DrawPrimitive(...);
      Effect->EndPass();
    }
    Effect->End();
    Pascal
    var 
      numPasses: DWord;
      pass: DWord;
    ...
    Effect._Begin(@numPasses, 0);
    for pass:=0 to numPasses-1 do begin
      Effect.BeginPass(pass);
      Device.DrawPrimitive(...);
      Effect.EndPass;
    end;
    Effect._End;

    Также как и в программах, написанных на языке HLSL, в файлы эффектов можно передавать параметры. Установка значений параметров осуществляется через вызов методов SetXXX интерфейса ID3DXEffect. Ниже представлены основные методы передачи параметров в файл эффектов:

    SetBool()
    SetInt()
    SetMatrix()
    SetString()
    SetTexture()
    SetVector()

    Следует отметить еще одно достоинство файлов эффектов. Для работы с ними существует программа EffectEdit, которая поставляет вместе с DirectX SDK. Эта программа позволяет загружать файлы эффектов и осуществлять рендеринг сцены при установленных настройках графического конвейера (шейдеров, текстур, состояний устройства воспроизведения и др.). Кроме того, утилита EffectEdit может быть использована в качестве отладочного механизма ваших шейдерных программ.

    Полноэкранный режим воспроизведения

    До сих пор вывод всех сцен построения производился в окно (на форму). Библиотека Direct3D позволяет осуществить рендеринг в полноэкранном режиме ( Full Screen ). Для перехода в полноэкранный режим вывода примитивов необходимо лишь при заполнении структуры D3DPRESENT_PARAMETERS явно указать размеры заднего буфера ( BackBuffer ), установить флаг Windowed в значение FALSE. Также нужно указать режим переключения первичного и вторичного (заднего) буферов рендеринга и определить интервал смены буферов.

    C++
    D3DPRESENT_PARAMETERS params;
    …
    ZeroMemory( params, sizeof(params) );
    params.Windowed = FALSE;
    params.SwapEffect = D3DSWAPEFFECT_FLIP;
    params.BackBufferWidth = 1280;
    params.BackBufferHeight = 1024;
    params.BackBufferFormat = display.Format;
    params.PresentationInterval = D3DPRESENT_INTERVAL_IMMEDIATE;
    …
    Pascal
    var
    params: TD3DPresentParameters;
    …
    ZeroMemory( @params, SizeOf(params) );
    params.Windowed := False;
    params.SwapEffect := D3DSWAPEFFECT_FLIP;
    params.BackBufferWidth := 1280;
    params.BackBufferHeight := 1024;
    params.BackBufferFormat := display.Format;
    params.PresentationInterval := D3DPRESENT_INTERVAL_IMMEDIATE;
    …

    Единственно, что необходимо еще проделать – это организовать возможность выхода (закрытия) программы. Для этого можно, например, обработать событие системы WM_KEYDOWN. Полноэкранный режим довольно часто используют в играх, так как именно при таком режиме достигается максимальная производительность видеокарты. Можно воспользоваться функцией Win32 API GetSystemMetrics() для того, чтобы узнать текущее разрешение экрана.

    C++
    D3DPRESENT_PARAMETERS params;
    …
    params.BackBufferWidth = GetSystemMetrics(SM_CXSCREEN);
    params.BackBufferHeight = GetSystemMetrics(SM_CYSCREEN);
    Pascal
    var
    params: TD3DPresentParameters;
    …
    params.BackBufferWidth := GetSystemMetrics(SM_CXSCREEN);
    params.BackBufferHeight := GetSystemMetrics(SM_CYSCREEN);

    Обработка ошибок

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

    FAILED()
    SUCCEEDED()

    Принцип работы этих макросов очень прост и представлен ниже.

    if ( FAILED()) // ошибка при выполнении команды
    ...
    else // команда завершилась успешно

    Для второго макроса принцип действия будет противоположным.

    if (SUCCEEDED ()) // команда завершилась успешно 
    ...
    else // ошибка при выполнении команды

    Ниже приведены некоторые примеры обработки ошибок.

    C++
    LPDIRECT3D9 direct3d = NULL;
    ...
    if( FAILED( direct3d -> CreateDevice( … ) ) )
    {
      // обработка ошибки
    }
    Pascal
    var
      direct3d: IDirect3D9;
    ...
    if Failed ( direct3d.CreateDevice( … ) ) then
    begin
      // обработка ошибки
    end;

    Можно осуществлять анализ ошибок через дополнительную переменную.

    C++
    HRESULT hr;
    hr = direct3d -> CreateDevice( … );
    if( FAILED(hr) )
    {
    
    }
    Pascal
    var
      hr: HRESULT;
    …
    hr := direct3d.CreateDevice( … );
    if Failed(hr) then
    begin
    
    end

    И еще один способ обработки ошибок заключается в сравнении результата вызова функции с константой D3D_OK.

    C++
    if ( direct3d -> CreateDevice(  …  ) != D3D_OK )
    {
    
    }
    Pascal
    if direct3d.CreateDevice( … ) <> D3D_OK then 
    begin
    
    end;

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

    C++
    if ( D3DXCompileShaderFromFile( "shader.psh", NULL, NULL, "main",  
                                    "ps_2_0", 0, Code, BufferErrors,  
                                    NULL) != D3D_OK )
    {
    MessageBox(NULL, (const char*)BufferErrors->GetBufferPointer(),  
               "Ошибка в шейдере!!!", MB_OK | MB_ICONEXCLAMATION);
    }
    Pascal
    if D3DXCompileShaderFromFile('shader.psh', nil, nil, 'Main',
       'ps_2_0', 0, @Code, @BufferErrors, nil)  D3D_OK
    then begin
      MessageBox(0, BufferErrors.GetBufferPointer, 
         'Ошибка в шейдере!!!', 
                 MB_OK or MB_ICONEXCLAMATION);
    end;

    Такой информативный способ позволяет легко "увидеть" в какой именно строке шейдера допущена ошибка.

    Подсчет количества кадров в секунду

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

    C++
    count = 0;
    last_tick = GetTickCount();
    while(msg.message != WM_QUIT)
    {
      if(PeekMessage(msg, 0, 0, 0, PM_REMOVE))
      {
          TranslateMessage(Msg);
        DispatchMessage(Msg);
      }
      else
      {
        this_tick = GetTickCount();
        if(this_tick-last_tick>=1000)
        {
          last_tick=this_tick;
          fps=count;
          count=0;
          // вывод значения fps  
        }
        else count++
        Render();
      }
    }
    Pascal
    count:=0;
    last_tick:=GetTickCount();
    while Msg.message <> WM_QUIT do begin
      if PeekMessage(msg, 0, 0, 0, PM_REMOVE) then begin
        TranslateMessage(Msg);
        DispatchMessage(Msg);
      end else begin
        this_tick:=GetTickCount();
        if(this_tick-last_tick>=500) then begin
          last_tick:=this_tick;
          fps:=count;
          count:=0;
          // вывод значения fps  
        end
        else count:=count+1;
        Render;
      end;
    end;
    Вернуться к учебному плану