June 11th, 2023
Let's continue with our look at how to render stuff with DirectX 11 inside a WinUI 3.0 window. We're almost done with the setup now. After we create an input layout and define our primitive topology, we're ready to start making some draw calls.
Merely setting the device context to use the shaders isn't enough. We also need to create something called Input Layout to tell the device context how to use the shaders.
Input Layout is how we describe the format of our vertex data to the GPU. Each vertex can contain multiple pieces of information, like position, color, texture coordinates, etc. Input layout tells the GPU how to interpret the data of a single vertex.
For instance, let's say we have a vertex defined as: [X, Y, Z, R, G, B]
, where X, Y, Z are the position coordinates and R, G, B are the color components. Now, we need to tell the GPU that the first three components are position and the next three are color. That's what the input layout does.
While shaders do work with this data, they need to know in which order the data comes. Input layout is a way to establish a contract between your application and the vertex shader, so the shader knows where to find each attribute of the vertex.
The InputElementDescription
array describes the layout of our vertex data, in this case, the positions of the vertices. It's crucial to correctly match these descriptions with the vertex data passed into the shader.
InputElementDescription[] inputElements = new InputElementDescription[]
{
new InputElementDescription(
"POSITION",
0,
Format.R32G32B32_Float,
0,
0),
};
"POSITION"
is the semantic name of the input element. It's like a label that identifies the purpose of this element in the vertex data. In this case, it represents the position of each vertex in 3D space.0
is the semantic index. It's used when multiple input elements share the same semantic name. Since we only have one "POSITION"
input element, the index is set to 0.Format.R32G32B32_Float
specifies the format of the data for this input element. It tells the pipeline that the position data for each vertex is represented by three float values, one for the X coordinate, one for the Y coordinate, and one for the Z coordinate. These values are used to determine the position of the vertex in 3D space.0
is the byte offset. It tells the pipeline where this input element's data starts within the vertex buffer. Since we only have one input element and it's the first one, the offset is 0.0
is the input slot. It represents the input slot or the slot in the vertex buffer where the data for this input element is stored. In this case, we're using slot 0.private ID3D11InputLayout inputLayout;
public void CreateResources()
{
...
inputLayout = device.CreateInputLayout(inputElements, vertexShaderByteCode.Span);
}
public void SetRenderState()
{
...
deviceContext.IASetInputLayout(inputLayout);
}
We create an inputLayout
object using the device and the input element descriptions, coupled with the shader byte code. This object tells the GPU how to interpret the vertex data in respect to the input variables of the vertex shader.
Finally, we set the newly created input layout as active by calling IASetInputLayout
. Now, the input assembler knows how to process the vertex data before it's sent to the vertex shader.
Primitive Topology tells the GPU how to interpret the vertices we provide in the vertex buffer to form the basic shapes, known as primitives. Primitives are the simplest shapes that the GPU can understand and work with, like points, lines, and triangles. For example, if we use a triangle list (PrimitiveTopology.TriangleList
), the GPU will treat every three vertices as a separate triangle. So, the index buffer provides an order to read vertices from the vertex buffer. Then, the primitive topology tells the GPU how to group these vertices into shapes. So, if you set it as a triangle list, and you have indices like [0, 1, 2, 2, 3, 0]
, it means first use vertices 0, 1, 2 to form a triangle, then use vertices 2, 3, 0 to form another triangle.
So, we need IASetPrimitiveTopology
to interpret the input vertices. In our case, we're using PrimitiveTopology.TriangleList
.
deviceContext.IASetPrimitiveTopology(PrimitiveTopology.TriangleList);
This means our vertices will be grouped into separate triangles. Each set of three consecutive vertices will form a triangle shape. Triangles are versatile and can represent any shape, just like the building blocks of our graphics.
Let's finally put that Timer_Tick event to use. We could just do the drawing inside the event, but we might as well create a new method for it to keep things organized.
private void Timer_Tick(object sender, object e)
{
Draw();
}
private void Draw()
{
deviceContext.OMSetRenderTargets(renderTargetView);
}
We're finally at the Output Merger (OM) stage. It's the part responsible for blending and outputting the final rendered pixels to the render target. It combines the results of the previous stages, such as vertex processing, pixel shading, and rasterization, and applies various operations like blending, depth testing, and stencil testing to determine the final color and depth values for each pixel.
You might remember that Device Context in Direct3D is responsible for handling rendering. By calling OMSetRenderTargets(renderTargetView)
, we are setting the created render target view as the active render target for the output merger stage. This means that any subsequent rendering operations, such as drawing shapes or applying shaders, will be directed to the back buffer through the renderTargetView
. You are essentially telling the graphics device, "I want to draw things on this back buffer using this specific view."
private Color4 canvasColor;
public void InitializeDirectX()
{
canvasColor = new Color4(1.0f, 1.0f, 1.0f, 1.0f);
...
}
private void Draw()
{
...
deviceContext.ClearRenderTargetView(renderTargetView, canvasColor);
}
Next we need to clear the screen, and this is where we'll need Vortice.Mathematics
.
Vortice.Mathematics provides a set of mathematical functions and structures commonly used in computer graphics programming. It offers tools for working with vectors, matrices, quaternions, and other mathematical concepts relevant to graphics calculations.
The ClearRenderTargetView
method is like taking a paintbrush and covering the entire canvas with a specific color, in this case, the canvasColor
. It ensures that the canvas is cleared or reset to the specified color before any new drawing or rendering operations take place.
Since I don't want to be constantly setting the canvasColor variable, let's set its color at the beginning of the InitializeDirectX
method. The Color4
takes four floats: red, green, blue, and alpha. When all of them are at their maximum value (1.0f), it'll be rendered as solid white.
Finally, let's make a draw call. The number's 3, 0, 0. The area code is silent.
deviceContext.DrawIndexed(3, 0, 0);
The DrawIndexed
method is responsible for rendering geometry on the screen using the indices provided. The 3
specifies the number of indices to be used for drawing. In this case, it indicates that 3 indices will be used, because we are drawing just one triangle. The first 0
indicates that drawing should start from the first vertex in the vertex buffer. The second 0
is the value added to each index before accessing the vertex data. It is commonly used when drawing multiple objects with different vertex offsets. In this case, no additional offset is applied.
We just need one more thing.
Back when we created the SwapChain, I mentioned that we'll be making a Viewport in the future. The viewport defines the region of the screen where the rendered image will be displayed. It specifies the size, position, and depth range of the rendered output within that region.
private Viewport viewport;
public void CreateSwapChain()
{
...
viewport = new Viewport
{
X = 0.0f,
Y = 0.0f,
Width = (float)SwapChainCanvas.Width,
Height = (float)SwapChainCanvas.Height,
MinDepth = 0.0f,
MaxDepth = 1.0f
};
}
Here we set the X
, Y
, Width
, and Height
for the Viewport
on the screen. We want it to be the size of the SwapChainCanvas
and to start at the top-left corner. But what about the Depth? The depth range is specified as values between 0.0, representing the nearest distance (closest to the viewer), and 1.0, representsing the farthest distance (farthest from the viewer). By setting the depth range, you define the boundaries within which objects will be rendered based on their distance from the viewer. This all sounds very abstract right now and doesn't really matter in our current application, so we'll just set them to give us the maximum limits.
public void SetRenderState()
{
...
deviceContext.RSSetViewports(new Viewport[] { viewport });
}
Once the viewport is defined, it is set in the device context. By calling RSSetViewports
with an array holding our viewport
as the only element, the graphics system knows which portion of the screen to render on and at what depth range.
private void Draw()
{
...
swapChain.Present(1, PresentFlags.None);
}
When the rendering is complete, the Present
method is called on the swap chain to present the back buffer to the front buffer, making it visible on the screen. So, if we just tell the SwapChain to Present whatever's in the back buffer, we should get something on the screen.
Exactly what it promises on the tin. The cutting edge of what graphics adapters are capable of.
Now that we know the graphics pipeline is working, we can start to do more interesting things with it. And since it's hardware accelerated, we don't need to worry about the performance that much. But before we get too far ahead of ourselves, next time we'll take a look at debugging a little bit.
Visual Studio project:
d3dwinui3pt4.zip (21 KB)
D3DWinUI3 part 4 in GitHub