Hello Triangle

Everything Direct3D draws starts out as points in 3D space, and everything it shows ends up as a grid of coloured pixels in a window. Turning one into the other is the job of the graphics pipeline: a chain of stages, each taking the previous one's output as its input. Roughly, the first half works out where things land on the screen, and the second half works out what colour every covered pixel becomes. In this chapter we walk the pipeline once, end to end, and get a triangle out of it.

Each stage does one narrow job, and does it for a great many vertices or pixels at once, which is why GPUs carry thousands of small cores. A few stages run programs we write ourselves. Those programs are shaders, and in Direct3D they are written in the High-Level Shading Language (HLSL), the counterpart of OpenGL's GLSL. The next chapter is about HLSL; here we only need two tiny shaders.

Here are Direct3D 11's stages in order. The ones in green run our shaders; the grey ones are fixed hardware we configure with state objects.

Inputassembler Vertexshader Hull, domaintessellator Geometryshader Rasterizer Pixelshader Outputmerger Render target(the back buffer) optional stages, dashed

OpenGL ships default shaders for nothing in modern profiles, and neither does Direct3D: a vertex shader and a pixel shader are the minimum before anything is drawn. Let's write both.

Vertex input

The vertex shader has to say where each vertex lands, and it says it in clip space. For now we skip every transformation and give the positions directly in the coordinates the screen uses at the end, normalized device coordinates (NDC): x runs from -1 on the left of the window to 1 on the right, y from -1 at the bottom to 1 at the top, and anything outside is clipped away.

Normalized device coordinates, x and y from -1 to 1 (LearnOpenGL's diagram)

This picture is LearnOpenGL's and holds for Direct3D too, except in depth: Direct3D's z runs from 0 (near) to 1 (far), where OpenGL's runs from -1 to 1. That only starts to matter once we build a projection matrix in Coordinate Systems. The rasterizer then maps NDC to pixels with the viewport we give it, which here is simply the whole window.

Three corners of a triangle, z left at 0, written as one flat array of floats:

triangle_vertices: [9]f32 = {
    -0.9, -0.5, 0.0,
    -0.1, -0.5, 0.0,
    -0.5,  0.5, 0.0,
};

This array lives in our program's memory. The GPU wants it in its own, so we copy it into a buffer: a block of GPU memory that the pipeline can read from. In OpenGL that is a vertex buffer object, filled by glBufferData. In Direct3D a buffer is created in one call, with its contents passed in up front:

// An immutable buffer: filled once at creation, never written by the CPU again.
create_buffer: proc(device: *ID3D11Device, bind: UINT, data: *const void, size: u64) -> *ID3D11Buffer = {
    desc: D3D11_BUFFER_DESC = {
        .ByteWidth = cast(size, UINT),
        .Usage = D3D11_USAGE.D3D11_USAGE_IMMUTABLE,
        .BindFlags = bind,
    };
    init: D3D11_SUBRESOURCE_DATA = { .pSysMem = data };
    buffer: *ID3D11Buffer = null;
    ID3D11Device_CreateBuffer(device, desc&, init&, buffer&);
    return buffer;
}

This is the pattern for nearly every Direct3D object: describe it in a _DESC struct, naming only the fields that matter and leaving the rest zero, call a Create method on the device, get a pointer back. Two fields deserve a word.

triangle_vb: *ID3D11Buffer = create_buffer(device, D3D11_BIND_VERTEX_BUFFER, triangle_vertices[0]&, sizeof(triangle_vertices));
In rin, x& takes the address of x, so triangle_vertices[0]& is a pointer to the first float. The ID3D11Device_CreateBuffer(device, ...) spelling is how COM methods look from C: the object goes first, where C++ would write device->CreateBuffer(...).

Vertex shader

The smallest useful vertex shader takes the position and hands it on. Ours lives in hello_triangle.hlsl, next to the program:

float4 vs_main(float3 position : POSITION) : SV_Position
{
    return float4(position, 1.0);
}

An HLSL shader is an ordinary function, and we choose its name; vs_main is our convention for the vertex shader's entry point. The interesting parts are after the colons. These are semantics, names that tie a value to the world outside the function:

The fourth component, w, is set to 1. Why it exists, and what dividing by it does, comes with perspective in Coordinate Systems.

Compiling a shader

OpenGL compiles GLSL inside the driver while the program runs. Direct3D splits the job in two: a compiler that turns HLSL into bytecode, and the device, which turns bytecode into a shader object. The compiler is D3DCompile from d3dcompiler.dll. It can also run ahead of time, as fxc does, but compiling at startup keeps each lesson to its source files and lets us edit a shader without a separate build step.

compile_shader: proc(path: [*]const char, source: [*]u8, size: u64, entry: [*]const char, target: [*]const char) -> *ID3DBlob = {
    code: *ID3DBlob = null;
    errors: *ID3DBlob = null;
    if (D3DCompile(source, size, path, null, null, entry, target, 0, 0, code&, errors&) < 0) {
        printf("ERROR::SHADER::COMPILATION_FAILED\n%s\n", cast(ID3D10Blob_GetBufferPointer(errors), [*]const char));
        exit(1);
    }
    return code;
}

We pass the source text, the file name (used only in error messages), the entry point and a target: the kind of shader and the shader model, vs_5_0 for a vertex shader at Direct3D 11's level. The result comes back in an ID3DBlob, a plain buffer of bytes. On failure the errors blob holds the compiler's messages, with file and line, which we print before giving up.

Like most Direct3D calls, D3DCompile returns an HRESULT: negative means failure. Checking < 0 is what C's FAILED() macro does. These lessons check where failure is likely (shader compilation, device creation) and keep the rest uncluttered.

Pixel shader

The pixel shader decides the colour of each pixel the triangle covers. Colours are four floats, red, green, blue and alpha, each from 0 to 1. Ours returns one orange for everything:

float4 ps_main() : SV_Target
{
    return float4(1.0, 0.5, 0.2, 1.0);
}

SV_Target is the system value for "the colour written to render target 0". GLSL declares an out vec4 for this; HLSL marks the return value. Both entry points sit in the same file, so we compile it twice with different entry points and targets:

vs_code: *ID3DBlob = compile_shader(path, source, size, "vs_main", "vs_5_0");
ps_code: *ID3DBlob = compile_shader(path, source, size, "ps_main", "ps_5_0");

Shader objects

OpenGL links its shaders into one program object, and checks there that the outputs of one stage match the inputs of the next. Direct3D has no program object. Each stage is its own object, created from its bytecode, and bound to its own slot in the pipeline:

ID3D11Device_CreateVertexShader(device, ID3D10Blob_GetBufferPointer(vs_code), ID3D10Blob_GetBufferSize(vs_code), null, vertex_shader&);
ID3D11Device_CreatePixelShader(device, ID3D10Blob_GetBufferPointer(ps_code), ID3D10Blob_GetBufferSize(ps_code), null, pixel_shader&);

Mixing and matching is free: any vertex shader can be bound with any pixel shader, so long as what one outputs is what the other reads. Here the pixel shader reads nothing at all, so there is nothing to match yet.

Describing the vertices

The buffer is only bytes. The vertex shader wants a float3 called POSITION. Something has to say how one becomes the other: where each attribute starts in a vertex, what type it is, and which shader input it feeds. OpenGL says it with glVertexAttribPointer, and keeps the answer in a vertex array object. Direct3D says it with an input layout, built from a list of elements:

// One attribute: three floats, read as POSITION by the vertex shader.
layout: [1]D3D11_INPUT_ELEMENT_DESC = {
    { .SemanticName = "POSITION", .Format = DXGI_FORMAT.DXGI_FORMAT_R32G32B32_FLOAT },
};

Each element names the semantic it feeds and the format of the data: DXGI_FORMAT_R32G32B32_FLOAT is three 32-bit floats. The fields left out are zero, which is what we want here: semantic index 0, vertex buffer slot 0, starting at byte 0 of the vertex, and per-vertex rather than per-instance data (that last one returns in Instancing).

Three floats per vertex, tightly packed: stride 12 bytes, offset 0 (LearnOpenGL's diagram)

As in LearnOpenGL's picture, the positions are tightly packed: each vertex is 12 bytes, and the position is at offset 0. Where OpenGL puts the stride into the attribute pointer, Direct3D gives it when the buffer is bound, as we will see in the render loop.

The layout is created against the vertex shader's bytecode:

// The layout is created against the compiled vertex shader, which is the
// one place D3D checks that the two agree.
ID3D11Device_CreateInputLayout(device, layout[0]&, 1, ID3D10Blob_GetBufferPointer(vs_code), ID3D10Blob_GetBufferSize(vs_code), input_layout&);

That is the step OpenGL's linker did for us. Misspell POSITION on either side and creation fails here, at startup, rather than drawing garbage. The layout does not mention any buffer; it only describes vertices. Which buffer they come from is decided when drawing, so one layout serves every mesh with the same vertex format.

The triangle we've all been waiting for

Everything is created. What remains is, every frame, to tell the pipeline what to use and to ask it to draw. A Direct3D device context holds the pipeline's current state; each call below sets one piece of it, and stays set until changed.

ID3D11DeviceContext_IASetInputLayout(context, input_layout);
ID3D11DeviceContext_IASetPrimitiveTopology(context, D3D11_PRIMITIVE_TOPOLOGY.D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
ID3D11DeviceContext_VSSetShader(context, vertex_shader, null, 0);
ID3D11DeviceContext_PSSetShader(context, pixel_shader, null, 0);

ID3D11DeviceContext_IASetVertexBuffers(context, 0, 1, triangle_vb&, stride&, offset&);
ID3D11DeviceContext_Draw(context, 3, 0);

The prefixes give the stage each call configures: IA the input assembler, VS the vertex shader, PS the pixel shader, and further on RS for the rasterizer and OM for the output merger. The primitive topology, the GL_TRIANGLES argument of glDrawArrays, is state here too: every three vertices make one triangle. IASetVertexBuffers binds our buffer to slot 0, with the stride of 12 bytes and an offset of 0, and Draw draws 3 vertices starting at the first.

This sits in the render loop from Hello Window, between clearing the back buffer and presenting it. And there it is:

The orange triangle, and the rectangle beside it

The rectangle to its right is the next section's.

Index buffers

A rectangle is two triangles. Listed as triangles that is six vertices, two of them stored twice, because the triangles share a diagonal. On a rectangle that is a small waste; on a model where every vertex is shared by about six triangles, it is most of the data. The fix is to store each vertex once, and draw from a second list that says which vertices make each triangle. Those are indices, and they go in an index buffer (OpenGL's element buffer object):

rectangle_vertices: [12]f32 = {
    0.1, -0.5, 0.0,   // bottom left
    0.9, -0.5, 0.0,   // bottom right
    0.9,  0.5, 0.0,   // top right
    0.1,  0.5, 0.0,   // top left
};

rectangle_indices: [6]u32 = { 0, 1, 2, 0, 2, 3 };

Four vertices, six indices: triangle 0-1-2 is the bottom right half, 0-2-3 the top left. The index buffer is made by the same function with a different bind flag, and drawing swaps Draw for DrawIndexed:

rectangle_ib: *ID3D11Buffer = create_buffer(device, D3D11_BIND_INDEX_BUFFER, rectangle_indices[0]&, sizeof(rectangle_indices));
ID3D11DeviceContext_IASetVertexBuffers(context, 0, 1, rectangle_vb&, stride&, offset&);
ID3D11DeviceContext_IASetIndexBuffer(context, rectangle_ib, DXGI_FORMAT.DXGI_FORMAT_R32_UINT, 0);
ID3D11DeviceContext_DrawIndexed(context, 6, 0, 0);

DXGI_FORMAT_R32_UINT says each index is a 32-bit unsigned integer, matching the u32 array; 16-bit indices would use DXGI_FORMAT_R16_UINT and take half the space.

The input layout is reused unchanged, since the rectangle's vertices have the same format as the triangle's. In OpenGL each shape would get its own VAO, recording its buffers alongside its layout. In Direct3D the layout and the buffers are bound separately, so switching shapes is just binding the other buffers.

Wireframe mode

To see the two triangles, draw only their edges. OpenGL has glPolygonMode; Direct3D puts the fill mode in the rasterizer state, an immutable object made from a description like everything else. The lesson makes a second state for wireframe:

wire_desc: D3D11_RASTERIZER_DESC = raster_desc;
wire_desc.FillMode = D3D11_FILL_MODE.D3D11_FILL_WIREFRAME;
wire_state: *ID3D11RasterizerState = null;
ID3D11Device_CreateRasterizerState(device, wire_desc&, wire_state&);

and Tab chooses which one to bind each frame:

ID3D11DeviceContext_RSSetState(context, wireframe ? wire_state : raster_state);
The same shapes in wireframe; the rectangle's diagonal shows the two triangles

The diagonal across the rectangle is where its two triangles meet. Making state objects once and only binding them in the loop is the Direct3D way throughout: the expensive checking happens at creation, and switching between states is cheap.

The default rasterizer state in Direct3D culls clockwise triangles. These lessons instead set FrontCounterClockwise and turn culling off at startup, so triangles are listed counter-clockwise as in LearnOpenGL and nothing disappears unexpectedly. Face culling turns it back on.

If you got here with both shapes on screen, you have done the hardest part of graphics programming: the first triangle. Everything from here on adds to this, one piece at a time. The full program is here, and its shaders here.

Additional resources

Exercises

  1. Draw two triangles side by side with a single Draw call, by adding three more vertices to the array.
  2. Draw the same two triangles from two separate vertex buffers, binding each in turn.
  3. Add a second pixel shader entry point, ps_yellow, to the same .hlsl file, and draw one of the triangles with it. Only one more compile_shader and one more CreatePixelShader are needed.
Credit: this chapter follows LearnOpenGL's Hello Triangle by Joey de Vries, rewritten for Direct3D 11 and rin. LearnOpenGL's text and code are licensed CC BY-NC 4.0, and so is this page; the wording, code and screenshots here are new, and diagrams marked as LearnOpenGL's are its own.