Skip to content

Hello Triangle

In the last chapter we initialized core components of D3D11 and DXGI such as the Device and the SwapChain, but simply clearing the Window with some color is pretty boring.

This time we will be drawing our first triangle with a nice froge-like color.

The Pipeline

The fundamental part of all graphic APIs is the "Graphics Pipeline". Everything from a single triangle, textured frog or the whole Elden Ring map goes through this pipeline. It is a series of functions that either exist in hardware, can be configured or are fully programmable. It transforms everything we draw in 3D space to the 2D space that is our monitor.

All the steps in the graphics pipeline go from top to bottom and are shown below.

As you can see, each stage in the pipeline takes in the previous stage's output as the input, the rectangle blocks are pipeline stages that are not programmable but are configurable, while the rounded rectangle blocks are stages that are fully programmable. To draw most of the things throughout this series we will mostly need these stages: Input Assembler, Vertex Shader and the Pixel Shader, Output Merger.

The Vertex and Pixel shaders are fully programmable and we will write a very basic program for them.

The other two stages are not programmable but they are fairly easy to understand and configure:

  • the Input Assembler is responsible for processing the vertices in an eventual vertex buffer into the primitive topology of our choice, which in our case is a form of triangles, and sending this processed output to be processed again - but this time by our Vertex Shader.

  • The Output Merger is responsible for combining the values written by the pixel shader, may that be depth, color or other things, into the one or more render targets that we provide to the OM, we only have one render target for now.

Vertex Shader

The Vertex Shader is the stage where our vertices are processed however we want

The vertices are usually read from a Vertex Bufferusually since there are no hard rules in programming — we can always be inventive to create or read data differently on the fly! This all works since the vertex shader will be run however many times we tell it to run, which is specified in the first parameter of ID3D11DeviceContext::Draw() (more on this later), for instance if we call Draw(3, 0), the vertex shader will run 3 times.

An example of unique methods to acquire vertex data is with how the vertex buffer can be omitted, if we want to draw a full screen triangle by just hardcoding vertices in our vertex shader — removing the need of a vertex buffer.

Since we only want to draw a triangle, we do not need to do much processing in our vertex shader code, we can just provide the input vertices as the output.

Let's look at our basic vertex shader for this section:

Main.vs.hlsl

struct VSInput
{
    float3 position: POSITION;
    float3 color: COLOR0;
};

struct VSOutput
{
    float4 position: SV_Position;
    float3 color: COLOR0;
};

VSOutput Main(VSInput input)
{
    VSOutput output = (VSOutput)0;
    output.position = float4(input.position, 1.0);
    output.color = input.color;
    return output;
}

First off, we define 2 types, VSInput and VSOutput which represent the vertex shader's input and output.

There are two inputs, both of them are a float3. float3 is a type that holds 3 floating point numbers.

The first input is the position field (x y z as the 3 floats) the second one is the color field (r g b as the 3 floats).

We will send both of these over to the output of this stage, onto the pixel shader.

Notice how all our fields have a colon and some identifier attached to them, these are "semantics". Semantics that are preceded by SV are called "system-value semantics" and their meaning and usage is defined by D3D11. SV_Position for example means that the field position will be used by D3D11 as the actual output of the vertex shader.

Everything else are "user defined semantics" and their naming is up to us. These are used to pass data between shader stages.

Then we have our VSOutput, which has our vertices in the first field position and our color in the second field color.

if the SV_Position values are tried to be given a position outside the range of [-1.0, 1.0] they are clipped and we will not see them on screen. Due to this we need to create our own math to transform any coordinates we have to a normalized [-1.0, 1.0] This is going to be explored later in more depth under the name of Normalized Device Coordinates (NDC).

Finally, we have our main function, which in our case takes in a single parameter which is our input in the form of a VSInput struct, and returns our output in the form of a VSOutput. Since we do not do any processing, we simply make a new instance of VSOutput, initialize it all to 0 and forward our input position and color to the output.

Pixel Shader

The Pixel Shader is the stage (by default) where we set the pixels on our render target, it is invoked for each pixel that is covered by a triangle formed by previous shader(s)

We use this (Pixel Shader) stage generally to apply most of our shading techniques, from anything such as basic lighting, to textures, and more.

Since we did not specify any shader between the VS and the PS, our input here is the output of the VS

Let's look at our Pixel Shader now:

Main.ps.hlsl

struct PSInput
{
    float4 position: SV_Position;
    float3 color: COLOR0;
};

struct PSOutput
{
    float4 color: SV_Target0;
};

PSOutput Main(PSInput input)
{
    PSOutput output = (PSOutput)0;
    output.color = float4(input.color, 1.0);
    return output;
}

Here as well we have an input PSInput, and an output PSOutput.

Since we have not setup any other shaders in between the VS and the PS, the VS's output is the PS's input, the naming might be a bit confusing but that's the gist of it, PSInput should match the VSOutput in vertex shader, this isn't entirely required but not doing so is only advisable if you really know what you are doing.

Next we have our output, D3D11 gives us the possibility to write to multiple render targets, but we are not doing that, so we will just be writing a float4 as our output, which is an RGBA color.

Notice how we have another semantic string attached to the color field, this semantic string specifies which render target we want to be writing to, the 0 after SV_Target is the index of our render target, in our case, we have only one, so we write SV_Target0 or SV_Target.

D3D11 lets us write up to 8 render targets simultaneously from the same pixel shader, those come in handy when implementing more advanced shading techniques.

And lastly, our Main function. Following the same pattern as in the VS we have already: one input (VSInput) and output parameter (PSOutput).

we initialize PSOutput and set everything to 0 to then write the color we got from the input to our output.

Compiling shaders

Now that we wrote our shader code and saved it somewhere, we have to feed this

to the GPU, to do that we will use our D3DCompiler to compile!

First, we will declare some functions that will help us compile our shaders more quickly.

HelloTriangle.hpp

bool CompileShader(
    const std::wstring& fileName,
    const std::string& entryPoint,
    const std::string& profile,
    ComPtr<ID3DBlob>& shaderBlob) const;

[[nodiscard]] ComPtr<ID3D11VertexShader> CreateVertexShader(
    const std::wstring& fileName,
    ComPtr<ID3DBlob>& vertexShaderBlob) const;

[[nodiscard]] ComPtr<ID3D11PixelShader> CreatePixelShader(std::wstring& fileName) const;

In order, we have:

CompileShader: This function is the core for compiling shaders, it requires 3 input parameters:

  • fileName: is the path of the shader file we want to compile.
  • entryPoint: is the name of the function where the shader begins execution.
  • profile: which is basically the version of HLSL we want to use, the higher the profile number, the more features there are.

And one output parameter:

  • shaderBlob: the blob were our compiled code will be stored. A blob is just a fancy buffer which D3D11 can use for specific purposes.

Then: CreateVertexShader: This function helps us create specifically a ID3D11VertexShader, it only requires the shader path and a ID3DBlob.

We need to pass a blob ourselves because we will need the VS's blob later.

CreatePixelShader: It does the same thing that CreateVertexShader does, except we do not need to pass a ID3DBlob here.

Now that we know how our new members look, we will see how we implemented them.

HelloTriangle.cpp

First things first, let's see CompileShader:

CompileShader

bool HelloTriangleApplication::CompileShader(
    const std::wstring& fileName,
    const std::string& entryPoint,
    const std::string& profile,
    ComPtr<ID3DBlob>& shaderBlob) const
{
    constexpr UINT compileFlags = D3DCOMPILE_ENABLE_STRICTNESS;

    ComPtr<ID3DBlob> tempShaderBlob = nullptr;
    ComPtr<ID3DBlob> errorBlob = nullptr;
    if (FAILED(D3DCompileFromFile(
        fileName.data(),
        nullptr,
        D3D_COMPILE_STANDARD_FILE_INCLUDE,
        entryPoint.data(),
        profile.data(),
        compileFlags,
        0,
        &tempShaderBlob,
        &errorBlob)))
    {
        std::cout << "D3D11: Failed to read shader from file\n";
        if (errorBlob != nullptr)
        {
            std::cout << "D3D11: With message: " << 
            static_cast<const char*>(errorBlob->GetBufferPointer()) << "\n";
        }

        return false;
    }

    shaderBlob = std::move(tempShaderBlob);
    return true;
}

We start by creating two ID3DBlobs, we will need a temporary blob, where we will load our shader file and an error blob, which will contain our error messages, if any.

Then we call for D3DCompileFromFile, it requires quite a lot of parameters so let's go over them one by one in order:

  • pFileName: a UTF-8 string containing the file name of the shader we want to compile.
  • pDefines: optional, basically an array of macros that we want to define.
  • pInclude: optional, a pointer to a ID3DInclude object, it is useful to specify how to handle #include directives in shaders. It is common to just use D3D_COMPILE_STANDARD_FILE_INCLUDE, which is the default handler.
  • pEntrypoint: a string containing the name of the main function in the shader - Defaults to main if NULL
  • pTarget: a string containing the Shader Model version to use for this shader.
  • Flags1: the flags that changes how to compile our shaders, for example we pass D3DCOMPILE_ENABLE_STRICTNESS which makes the compiler stricter in judging our code and disables legacy syntax support.
  • Flags2: ignored, set to 0.
  • ppCode: output, a pointer to a ID3DBlob*, this is where our compiled code will be stored.
  • ppErrorMsgs: optional, output, a pointer to a ID3DBlob*, this is where the D3D compiler will store our errors, nullptr if everything went fine.

Then we do our usual checking, if there were errors, leave the output blob as is and print the error message contained in the blob. Otherwise, move the blob to our output parameter.

Now let's see CreateVertexShader and CreatePixelShader:

CreateVertexShader

HelloTriangleApplication::ComPtr<ID3D11VertexShader> HelloTriangleApplication::CreateVertexShader(
    const std::wstring& fileName,
    ComPtr<ID3DBlob>& vertexShaderBlob) const
{
    if (!CompileShader(fileName, "Main", "vs_5_0", vertexShaderBlob))
    {
        return nullptr;
    }

    ComPtr<ID3D11VertexShader> vertexShader;
    if (FAILED(_device->CreateVertexShader(
        vertexShaderBlob->GetBufferPointer(),
        vertexShaderBlob->GetBufferSize(),
        nullptr,
        &vertexShader)))
    {
        std::cout << "D3D11: Failed to compile vertex shader\n";
        return nullptr;
    }

    return vertexShader;
}

As you can see here we are using our helper function CompileShader to avoid repeating ourselves, we are specifying "Main" as the entry point of our vertex shader and "vs_5_0" as the Shader Model, which means "Vertex Shader Model 5.0".

After we get our blob successfully, we can create a vertex shader out of it with ID3D11Device::CreateVertexShader, it takes a pointer to a buffer with the compiled code and its size as the input. The resulting vertex shader is the last parameter which is our output.

And finally

CreatePixelShader

HelloTriangleApplication::ComPtr<ID3D11PixelShader> 
HelloTriangleApplication::CreatePixelShader(const std::wstring& fileName) const
{
    ComPtr<ID3DBlob> pixelShaderBlob = nullptr;
    if (!CompileShader(fileName, "Main", "ps_5_0", pixelShaderBlob))
    {
        return nullptr;
    }

    ComPtr<ID3D11PixelShader> pixelShader;
    if (FAILED(_device->CreatePixelShader(
        pixelShaderBlob->GetBufferPointer(),
        pixelShaderBlob->GetBufferSize(),
        nullptr,
        &pixelShader)))
    {
        std::cout << "D3D11: Failed to compile pixel shader\n";
        return nullptr;
    }

    return pixelShader;
}

Pretty much the same thing as CreateVertexShader, the only thing that changes is the profile parameter from "vs_5_0" to "ps_5_0", since we are not compiling a vertex shader now, we have to change this to the "Pixel Shader Model 5.0".

After all of this, we can now call these functions, in HelloTriangleApplication::Initialize() you should now add:

Initialize

ComPtr<ID3DBlob> vertexShaderBlob = nullptr;
_vertexShader = CreateVertexShader(L"Assets/Shaders/Main.vs.hlsl", vertexShaderBlob);
if (_vertexShader == nullptr)
{
    return false;
}

_pixelShader = CreatePixelShader(L"Assets/Shaders/Main.ps.hlsl");
if (_pixelShader == nullptr)
{
    return false;
}

We still have a vertexShaderBlob now, it will be useful to us later, in creating an input layout.

Input Layouts

We have successfully compiled our shaders now, we need one last thing, an Input Layout. An input layout, is basically the format we want to lay our vertices in our buffers.

Since all our vertices we want to give to the GPU must be tightly packed in the same buffer, the Input Assembler needs a way to make sense of our data, this is exactly what an input layout is for - it tells the GPU exactly how the memory in the buffer will be organized, and how it should be mapped to our expected vertex shader, vertex layout (VSInput in our case)

Let's see what input we expect in the vertex shader again:

struct VSInput
{
    float3 position: POSITION;
    float3 color: COLOR0;
};

The vertex shader expects per vertex: two vectors of 3 (4 byte) components.

We should then create an input layout exactly with this format:

First of all, creating a struct in our C++ source with the same field layout as our VSInput will make our life easier when imagining how 1 vertex will fit on the GPU one after each other.

To do this we will use DirectXMath which has types that map perfectly to HLSL, both of our inputs are float3 in HLSL, which means that this translates to DirectX::XMFLOAT3

DirectX::XMFLOAT3 wraps 3 floats like an array, it is a class designed to work with DirectXMath

using Position = DirectX::XMFLOAT3;
using Color = DirectX::XMFLOAT3;

struct VertexPositionColor
{
    Position position;
    Color color;
};

The type aliases (using Position and using Color) help us make this code more readable to easily guess what is what type. The first field is our position vector and the second field is our color vector — notice it is exactly like our VSInput.

Now we can create our Input Layout Description using an array of D3D11_INPUT_ELEMENT_DESC.

constexpr D3D11_INPUT_ELEMENT_DESC vertexInputLayoutInfo[] =
{
    {
        "POSITION",
        0,
        DXGI_FORMAT::DXGI_FORMAT_R32G32B32_FLOAT,
        0,
        offsetof(VertexPositionColor, position),
        D3D11_INPUT_CLASSIFICATION::D3D11_INPUT_PER_VERTEX_DATA,
        0
    },
    {
        "COLOR",
        0,
        DXGI_FORMAT::DXGI_FORMAT_R32G32B32_FLOAT,
        0,
        offsetof(VertexPositionColor, color),
        D3D11_INPUT_CLASSIFICATION::D3D11_INPUT_PER_VERTEX_DATA,
        0
    },
};

Now let's make sense of why we have the following layout: (we will refer to 1 element of D3D11_INPUT_ELEMENT_DESC as a field)

{
    `SemanticName`,
    `SemanticIndex`,
    `Format`,
    `InputSlot`,
    `AlignedByteOffset`,
    `InputSlotClass`,
    `InstanceDataStepRate`
},
  • SemanticName: let's us refer to a particular field name (the string) after the colon in HLSL (recall POSITION inside float3 position: POSITION)

  • SemanticIndex: the index of each semantic, POSITION is equivalent to POSITION0, where the number at the end is our semantic index, so we will just pass in 0.

    POSITION1 in HLSL would have a semantic index of 1, etc.

  • Format: the format of this field, basically how many components there are and what type they are, a float3 in HLSL is a vector of 3 floats, each float is 4 bytes wide (or 32 bits), so the format here is DXGI_FORMAT_R32G32B32_FLOAT. DXGI_FORMAT chose R32 G32 B32 as an arbitrary component name, this has nothing to do with colors, we can store positions here since the type is FLOAT

  • InputSlot: we will see about this later.

  • AlignedByteOffset: the offset of this field, in bytes
  • InputSlotClass: The rate of input is either per-vertex or per-instance, we do not use instances right now since we are only drawing a triangle so we will set this to PER_VERTEX, and explain PER_INSTANCE in later lessons.
  • InstanceDataStepRate: this will be explained with PER_INSTANCE in later lessons, so for now this value is 0

Each element being sent to the GPU needs to be described on how they are laid out, therefore we have D3D11_INPUT_ELEMENT_DESC vertexInputLayoutInfo[] to describe the data layout.

You can think of each element in this array as describing one element in VSInput

  • POSITION is the first element (offset of 0). POSITION is also a float3 (4+4+4 = 12 bytes). Therefore the GPU expects the first 12 bytes of every vertex to be a float3 filled with POSITION data.
  • COLOR is after POSITION, meaning COLOR has an offset of 12 bytes. because COLOR is also a float3, it is also 12 bytes.

Therefore the GPU expects after the POSITION data, 12 bytes of float3 COLOR data.

Hopefully it makes a bit more sense now, all we have to do is create the input layout using this data:

if (FAILED(_device->CreateInputLayout(
    vertexInputLayoutInfo,
    _countof(vertexInputLayoutInfo),
    vertexShaderBlob->GetBufferPointer(),
    vertexShaderBlob->GetBufferSize(),
    &_vertexLayout)))
{
    std::cout << "D3D11: Failed to create default vertex input layout\n";
    return false;
}

As usual, we follow the same pattern, we pass in our vertexInputLayoutInfo that we just created and its size, we also need to pass our vertex blob pointer and size, and finally our output parameter which is our input layout.

Now all we have to do is create a vertex buffer (do not worry it's really easy) and issue our first Draw command!

Error

Image showing stride and offset?

Vertex Buffers

Vertex Buffers might seem hard at first, but they're really nothing more than a buffer that resides in our device's memory, which means really fast access. This buffer will be then bound and read by the vertex shader.

Creating a vertex buffer is also really easy, first we have to make some data to put in our buffer, since we want to draw a triangle, we will be creating 3 vertices using our VertexPositionColor struct.

constexpr VertexPositionColor vertices[] =
{
    { Position{  0.0f,  0.5f, 0.0f }, Color{ 0.25f, 0.39f, 0.19f } },
    { Position{  0.5f, -0.5f, 0.0f }, Color{ 0.44f, 0.75f, 0.35f } },
    { Position{ -0.5f, -0.5f, 0.0f }, Color{ 0.38f, 0.55f, 0.20f } },
};

Remember, the position coordinates we have to give to the vertex shader must be in range [-1.0, 1.0] by the time the vertex shader stage ends, otherwise we will not be able to see that vertex - because of this we supply vertices as [-1.0, 1.0].

We are storing coordinates that form our triangle here: a Position and Color component per vertex

If you want you can try to visualize the triangle, take a piece of paper, draw a Cartesian Plane, draw 3 points and connect the dots with these coordinates.

Now that we have the data, let's store this on our vertex buffer:

D3D11_BUFFER_DESC bufferInfo = {};
bufferInfo.ByteWidth = sizeof(vertices);
bufferInfo.Usage = D3D11_USAGE::D3D11_USAGE_IMMUTABLE;
bufferInfo.BindFlags = D3D11_BIND_FLAG::D3D11_BIND_VERTEX_BUFFER;

D3D11_SUBRESOURCE_DATA resourceData = {};
resourceData.pSysMem = vertices;
if (FAILED(_device->CreateBuffer(
    &bufferInfo,
    &resourceData,
    &_triangleVertices)))
{
    std::cout << "D3D11: Failed to create triangle vertex buffer\n";
    return false;
}

We begin by filling a bufferInfo descriptor for our buffer, we specify how many bytes we want, since this buffer will never change, for the Usage we specify: D3D11_USAGE_IMMUTABLE (the buffer is unmodifiable), this lets D3D11 put this data as close as possible to the GPU, finally we specify how we want to use this buffer, we want this to be a vertex buffer, so for the BindFlags we give: D3D11_BIND_VERTEX_BUFFER.

And finally we create a resourceData, and populate the only field we care about: pSysMem, which is a pointer to our vertices which are currently in system RAM.

Then we issue the creation of the buffer using CreateBuffer, using the information we collected until now.

Drawing our triangle

Now, we have reached the moment of truth, in our Render function we will add a few things:

_deviceContext->IASetInputLayout(_vertexLayout.Get());

This sets the input layout we want to use.

_deviceContext->IASetVertexBuffers(
    0,
    1,
    _triangleVertices.GetAddressOf(),
    &vertexStride,
    &vertexOffset);

Then we go ahead and bind our vertex buffer.

_deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY::D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

This sets how the Input Assembler should interpret the vertex data, since we want to draw triangles, D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST is the right flag for us.

_deviceContext->VSSetShader(
    _vertexShader.Get(),
    nullptr,
    0);
_deviceContext->PSSetShader(
    _pixelShader.Get(),
    nullptr,
    0);

Setting the vertex and pixel shader here. I suggest to put PSSetShader after RSSetViewports, since it will maintain the pipeline order, it doesn't have any performance or correctness implications, it will just help you remember better which stage comes after which.

_deviceContext->Draw(3, 0);

And finally, tell the GPU to draw 3 vertices, this will invoke the vertex shader 3 times, and it will successfully process the 3 vertices we put in our vertex buffer.

Finally, let's review all the commands we issue in Render

_deviceContext->IASetInputLayout(_vertexLayout.Get());
_deviceContext->IASetVertexBuffers(
    0,
    1,
    _triangleVertices.GetAddressOf(),
    &vertexStride,
    &vertexOffset);
_deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY::D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
_deviceContext->VSSetShader(
    _vertexShader.Get(),
    nullptr,
    0);
_deviceContext->RSSetViewports(
    1,
    &viewport);
_deviceContext->PSSetShader(
    _pixelShader.Get(),
    nullptr,
    0);
_deviceContext->OMSetRenderTargets(
    1,
    _renderTarget.GetAddressOf(),
    nullptr);

As you can see, we go through the pipeline in an orderly fashion, and although we do not use all the stages, we can see the top-to-bottom execution of the stages, IA

(Input Assembler) -> VS (Vertex Shader) -> RS (Rasterizer Stage) -> PS (Pixel Shader) -> OM (Output Merger).

You should now be able to run this and see your first triangle!

Error

Provide picture of window with triangle

Project on GitHub

Next chapter