Программируем графику на Direct3D 11 в среде .NET (часть 1)

в 22:43, , рубрики: .net, direct3d 11, DirectX, game development, Gamedev, метки: , , , ,

Предыдущая часть (0).

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

Процесс отрисовки

Чтобы видеокарта что-то могла показать, сначала в нее надо загрузить 3D-сцену. В простейшем случае, сцена представлена набором треугольников, каждый из которых имеет, соответственно, три вершины. Вершина может быть и общей для нескольких треугольников. Данные вершин и треугольников загружаются в видеокарту в виде массивов (буферов), минимально необходимая информация для буфера вершин — это координаты вершины в пространстве (X,Y,Z). И если мы хотим рисовать треугольники, нужен еще буфер индексов — перечисление номеров в загруженном нами буфере вершин, описывающих треугольники.

Рассмотрим простейший пример: мы хотим нарисовать один треугольник. Для упрощения, я изобразил его на плоскости, не используя Z координату.

Программируем графику на Direct3D 11 в среде .NET (часть 1)

Не очень ровный треугольник, но для примера сойдет. Таким образом, нам нужен буфер с координатами трёх вершин, пусть он будет такой: [(2;0;0),(-2;-1;0),(2;-1;0)]. Индексный буфер соответственно будет такой: [0,2,1]. Почему именно так, а не [0,1,2]? Все дело в том что для видеокарты у треугольника только одна «сторона». И если «смотреть» на треугольник с «обратной» стороны — он будет невидимым. Это определяется нормалью — вектором, перпендикулярным к плоскости треугольника. А направление нормали как раз и будет зависеть от порядка перечисления вершин в индексном буфере, поэтому их нужно перечислять «по часовой стрелке», если смотреть на треугольник с видимой стороны.

Усложним пример и добавим сбоку еще один треугольник:

Программируем графику на Direct3D 11 в среде .NET (часть 1)

Теперь вершинный буфер имеет вид: [(2;0;0),(-2;-1;0),(2;-1;0),(4;2;0)], а индексный буфер должен быть соответственно: [0,2,1,0,3,2]. Порядок перечисления вершин обоих треугольников с того ракурса, с которого мы смотрим на эти треугольники — по часовой стрелке, соответственно нормаль направлена в нашу сторону и мы их видим. Кстати, здесь у нас треугольники имеют две общие вершины, что существенно экономит память. Позже я расскажу, почему многие вершины в сцене, имеющие одинаковые координаты, часто не могут быть общими.

Для того, чтобы что-нибудь увидеть, нам необходимо иметь как минимум Vertex и Pixel шейдеры.

Шейдер — это программа, исполняемая графическим процессором видеокарты, описываемая в простейшем случае одной функцией.

Vertex shader — функция, преобразущая 3D координаты вершины и прочие связанные с ней данные, в координаты, например, пикселя на экране или данные для дальнейшней обработки Hull shader'ом (если это необходимо). Проще говоря, принимает на вход данные, относящиеся к вершине и что-то с ними делает. Это может быть полезно, например, для скелетной анимации — раньше ей занимался процессор, а теперь это может делать видеокарта.

Pixel shader — функция, принимающая на вход данные пикселя (координаты), которые в простейшем случае получились на выходе Vertex shader'а (или в более сложном случае, на выходе Geometry shader'а) и интерполированные атрибуты вершин, и вычисляющая цвет этого пикселя, принимая во внимание (если это необходимо) текстуру, освещение, заданный цвет, параметры отражения, и т.п. Вообщем это как раз то место, где происходит формирование красивой картинки, и что раньше делалось по жестким алгоритмам видеокарты, а теперь делается нами как угодно иначе.

Отличие программируемого конвейера Direct3D 11 от Direct3D 9

Итак, чем же отличается рендер Direct3D 10 и Direct3D 11 от Direct3D 9 и более ранних.
Отличие заключается в конструкции самого процесса рендеринга — конвейера. В видеокартах поколения DX9 этот процесс жестко определен видеокартой и повлиять на него было нельзя, называется это «fixed function pipeline». То есть мы никак не могли вмешаться в процесс, к примеру, наложения текстуры, можно только выбирать из доступных режимов. В видеокартах поколения DX10+ конвейер программируется разработчиком с помощью шейдеров, в которых можно взять весь процесс под свой контроль.

Хотя в Direct3D 9 и реализован программируемый конвейер, он в то же время имеет очень много зависимостей от фиксированного конвейера, и апи Direct3D 9 нагроможден устаревшими функциями. Начиная с Direct3D 10 фиксированный конвейер исключен полностью, и новый стандарт Shader Model 4 полностью переработан. Это означает, что теперь мы не можем просто загрузить буфер вершин, загрузить текстуру, установить параметры освещения, и дать команду отрендерить. Нам нужны шейдеры для каждой стадии этого процесса. Поначалу это кажется сложным, но если вы только начинаете изучать Direct3D 11, или ваша задача очень простая, можно взять шейдеры, эмулирующие классический фиксированный конвейер. При этом процесс рендера будет происходить так же как и на Direct3D 9 видеокарте, но вы будете иметь перед собой исходник и возможность вмешаться в любое место этого процесса, а так же будете использовать современное, простое и понятное апи.

Кстати, на D3D11-железе D3D9-игры примерно так и работают — драйвер тоже имеет шейдеры, эмулирующие фиксированный конвейер.

В Direct3D 11 можно вмешаться не только в финальный процесс вычисления цвета пикселя (пиксельный шейдер). Вот иллюстрация конвейера D3D11:

Программируем графику на Direct3D 11 в среде .NET (часть 1)

Теперь кратко о каждой стадии, кроме вершинных и пиксельных шейдеров, которые мы уже рассмотрели:

  • Input Assembler — читает данные из буферов, которые мы загрузили, по указанному нами описанию, и собирает из них геометрические примитивы — точки, вершины, треугольники, которые будут передаваться на вход Vertex шейдерам.

Далее три необязательные стадии, нужные для использования тесселяции, которую мы рассматривать подробно не будем:

  • Hull shader — преобразует данные, полученные от Vertex shader'а в контрольные точки кривых безье для дальнейшней тесселяции.
  • Tesselator stage — фиксированная функция конвейера, генерирует промежуточные точки на поверхностях, описанных Hull shader'ом, с учетом заданного уровня детализации.
  • Domain shader — генерирует вершины из результатов работы Hull shader'а и Tesselator'а.

Далее еще одна необязательная стадия:

  • Geometry shader — принимает на вход примитив (точка, линия или треугольник) и генерирует новые вершины. Выход этого шейдера может быть направлен на Rasterizer или в вершинный буфер.

И наконец:

  • Rasterizer — отсекает невидимые вершины, осуществляет перспективную трансформацию и создает проекцию видимых примитивов на экран. Затем вызывает для каждого получившегося пикселя Pixel shader.
  • Output Merger — определяет, какие пиксели будут видимыми, а какие нет (перекрыты другими) с помощью буфера глубины (он же Z-буфер) а так же определяет окончательно цвет накладывающихся пикселей, если какие-либо из них являются полупрозрачными.

Рисуем треугольник

Во времена, когда шейдеры только-только придумали, их код можно было писать только на ассемблере GPU. Это долго и больно, и мы так делать не будем. Сегодня мы можем писать код шейдеров на языке HLSL (для DirectX), GLSL (для OpenGL) и Nvidia Cg (собирается как под DirectX, так и под OpenGL). Все они имеют в основе C-подобный синтаксис. Так как мы работаем только с DirectX, наш выбор очевиден — HLSL, лишняя прослойка в виде Cg нам ни к чему.

Напишем простейшие шейдеры для нашего треугольника (вершинный и пиксельный, остальные не требуются, чтобы что-то увидеть):

// Описываем тип данных на входе вершинного шейдера
struct VertexShaderInput
{
	float4 position : POSITION;
};

// Описываем тип данных на входе пиксельного шейдера
struct PixelShaderInput
{
	float4 position : SV_POSITION;
};

// Вершинный шейдер
// на входе вершина из буфера
// на выходе вершина обработанная, готовая для пиксельного шейдера
PixelShaderInput SimpleVertexShader( VertexShaderInput input )
{
	PixelShaderInput output = (PixelShaderInput)0;
	
	// не делаем с вершиной ничего
	output.position = input.position;
	
	return output;
}

// Пиксельный шейдер
// на входе вершина обработанная вершинным шейдером
// на выходе цвет результирующего пикселя, в виде RGBA
float4 RedPixelShader( PixelShaderInput input ) : SV_Target
{
	// Возвращаем красный цвет для любого пикселя
	return float4(1.0, 0.0, 0.0, 0.0);
}

// определяем последовательность шейдеров для нашего эффекта
technique10 SimpleRedRender
{
	// первый проход, он же последний
	pass P0
	{
		// устанавливаем вершинный шейдер с компиляцией под Shader Model 4.0
		SetVertexShader( CompileShader( vs_4_0, SimpleVertexShader() ) );

		// устанавливаем пиксельный шейдер с компиляцией под Shader Model 4.0
		SetPixelShader( CompileShader( ps_4_0, RedPixelShader() ) );
	}
}

И код самой программы приведу целиком, так как он небольшой и обильно прокомментирован.

using System;
using System.Collections.Generic;
using SharpDX;
using SharpDX.D3DCompiler;
using SharpDX.Direct3D;
using SharpDX.Direct3D11;
using SharpDX.DXGI;
using SharpDX.Windows;
using Buffer = SharpDX.Direct3D11.Buffer;
using Device = SharpDX.Direct3D11.Device;
using Resource = SharpDX.Direct3D11.Resource;

namespace DirectX.Lesson1
{
    class Program
    {
        static void Main(string[] args)
        {
            // В этот список мы будем добавлять все созданные по ходу дела ресурсы
            // дабы не забыть их освободить
            var unmanagedResources = new List<IDisposable>();

            // Создадим окно. В классе RenderForm, описанном в SharpDX, уже реализован MessagePump,
            // так что это избавит нас от лишней рутины и позволит сконцентрироваться на главном.
            var form = new RenderForm("Example");

            // Опишем параметры SwapChain - набора буферов для отображения результирующей картинки
            var desc = new SwapChainDescription()
                           {
                               BufferCount = 1,
                               ModeDescription = new ModeDescription(
                                   form.ClientSize.Width,
                                   form.ClientSize.Height,
                                   new Rational(0, 1),
                                   Format.R8G8B8A8_UNorm),
                               IsWindowed = true,
                               OutputHandle = form.Handle,
                               SampleDescription = new SampleDescription(1, 0),
                               SwapEffect = SwapEffect.Discard,
                               Usage = Usage.RenderTargetOutput
                           };

            // Инициализируем устройство и SwapChain
            // И получим контекст устройства
            Device device;
            SwapChain swapChain;
            
            Device.CreateWithSwapChain(
                DriverType.Hardware,
                DeviceCreationFlags.None,
                new[] {FeatureLevel.Level_10_0},
                desc,
                out device,
                out swapChain);
            
            var context = device.ImmediateContext;
            
            unmanagedResources.Add(device);
            unmanagedResources.Add(swapChain);
            unmanagedResources.Add(context);

            // Игнорируем все события окна
            var factory = swapChain.GetParent<Factory>();
            factory.MakeWindowAssociation(form.Handle, WindowAssociationFlags.IgnoreAll);
            unmanagedResources.Add(factory);

            // Получаем буфер для рендеринга итоговой картинки
            var backBuffer = Resource.FromSwapChain<Texture2D>(swapChain, 0);
            unmanagedResources.Add(backBuffer);

            // Устанавливаем этот буфер как наш выход для рендера
            var renderView = new RenderTargetView(device, backBuffer);
            unmanagedResources.Add(renderView);

            // Скомпилируем эффект из файла в байткод
            ShaderBytecode bytecode = ShaderBytecode.CompileFromFile("shader.fx", "fx_5_0");
            unmanagedResources.Add(bytecode);

            // Загрузим скомпилированный эффект в видеокарту
            var renderEffect = new Effect(device, bytecode);
            unmanagedResources.Add(renderEffect);
            
            // Выберем технику рендера (у нас она одна)
            // Мы можем описать в файле эффекта несколько техник,
            // например для разного уровня аппаратной поддержки
            var renderTechnique = renderEffect.GetTechniqueByName("SimpleRedRender");
            unmanagedResources.Add(renderTechnique);

            // Выберем входную сигнатуру данных первого прохода
            var renderPassSignature = renderTechnique.GetPassByIndex(0).Description.Signature;
            unmanagedResources.Add(renderPassSignature);

            // Определяем формат вершинного буфера для входной сигнатуры первого прохода
            var inputLayout = new InputLayout(
                device,
                renderPassSignature,
                new[]
                    {
                        // Собственно у нас весь буфер состоит из элементов одного типа - 
                        // позиции вершины.
                        new InputElement("POSITION", 0, Format.R32G32B32_Float, 0, 0),
                    });
            unmanagedResources.Add(inputLayout);

            // Создадим вершинный буфер из трех вершин для одного треугольника
            // Я использовал числа меньшие, чем в статье, чтобы влезло на экран
            var vertices = Buffer.Create(device, BindFlags.VertexBuffer, new[]
                                  {
                                      new Vector3(0.0f, 0.5f, 0f),
                                      new Vector3(-0.5f, -0.5f, 0f),
                                      new Vector3(0.5f, -0.5f, 0f)
                                  });
            unmanagedResources.Add(vertices);

            // Создадим индексный буфер, описывающий один треугольник
            var indices = Buffer.Create(device, BindFlags.IndexBuffer, new uint[] {0, 2, 1});
            unmanagedResources.Add(indices);

            // Установим формат вершинного буфера
            context.InputAssembler.InputLayout = inputLayout;

            // Установим тип примитивов в индексом буфере - список треугольников
            context.InputAssembler.PrimitiveTopology = PrimitiveTopology.TriangleList;

            // Установим текущим вершинный буфер
            context.InputAssembler.SetVertexBuffers(0, new VertexBufferBinding(vertices, 12, 0));

            // Установим текущим индексный буфер с описанием треугольника, и укажем тип данных индекса (32-бит unsigned int)
            context.InputAssembler.SetIndexBuffer(indices, Format.R32_UInt, 0);

            // Устаналиваем вьюпорт (координаты и размер области вывода растеризатора)
            context.Rasterizer.SetViewports(new Viewport(0, 0, form.ClientSize.Width, form.ClientSize.Height, 0.0f, 1.0f));

            // Установим RenderTargetView в который рендерить картинку
            context.OutputMerger.SetTargets(renderView);

            // Цикл, пока не закроем окно
            RenderLoop.Run(form, () =>
            {
                // Очистим RenderTargetView темно-синим цветом
                // Почему не черный - когда черный цвет, не всегда понятно, действительно ли на экран ничего
                // не выводится, или у нас некорректно работает пиксельный шейдер и черные объекты сливаются с фоном.
                // Если же фон не черный - мы всегда увидим, если что-то вообще рендерится.
                context.ClearRenderTargetView(renderView, Colors.DarkSlateBlue);

                // Вызовем команду отрисовки индексированного примитива для каждого прохода,
                // описанного в эффекте (в данном случае он у нас только один)
                for (int i = 0; i < renderTechnique.Description.PassCount; i++)
                {
                    // Установим текущим описание прохода под номером i нашего эффекта
                    renderTechnique.GetPassByIndex(i).Apply(context);

                    // Отрендерим индексированный примитив, используя установленные ранее буферы и проход
                    // 3 - это количество индексов из буфера, то есть в нашем случае все.
                    context.DrawIndexed(3, 0, 0);
                }

                // Отобразим результат (переключим back и front буферы)
                swapChain.Present(0, PresentFlags.None);
            });

            // Освободим ресурсы
            foreach (var unmanagedResource in unmanagedResources)
            {
                unmanagedResource.Dispose();
            }
        }
    }
}

Продолжение следует.

Автор: lucas_iv

Поделиться

* - обязательные к заполнению поля