Contents

First Triangle

In this tutorial, you will build a .NET console application that opens a window and draws a colored triangle, starting from an empty project.

The tutorial has three checkpoints: an empty window, a solid background color, and the colored triangle. Run the application at each checkpoint before adding the next part.

1. Prerequisites

You need the .NET 10 SDK and basic C# knowledge. Your GPU and driver must support these graphics capabilities for your platform:

Platform Graphics support
Windows DirectX 12 with Shader Model 6.6
macOS Metal 4
Linux (X11 or XWayland) Vulkan 1.4 with VK_EXT_descriptor_heap

Zenith.NET renders into a native surface provided by the application. Silk.NET.Windowing creates the window and processes its events.

2. Project setup

Create a C# console project named FirstTriangle targeting .NET 10.

Add the following NuGet packages to the project:

  • Silk.NET.Windowing
  • Zenith.NET
  • Zenith.NET.Compiler
  • Zenith.NET.DirectX12
  • Zenith.NET.Metal
  • Zenith.NET.Vulkan

Zenith.NET provides the shared graphics and compute API; the three graphics API packages implement it. Zenith.NET.Compiler compiles Slang shaders for the selected API.

Enable Allow unsafe code (AllowUnsafeBlocks) in the project's build settings. Native calls in the window helper class and the vertex upload require unsafe code.

3. Window creation

Replace the contents of Program.cs with the code below:

using Silk.NET.Windowing;

IWindow window = Window.Create(WindowOptions.Default with
{
    API = GraphicsAPI.None,
    Title = "Zenith.NET - First Triangle"
});

window.Initialize();
window.Center();

window.Run();

window.Dispose();

GraphicsAPI.None tells Silk.NET not to create a graphics context; the application will create a Zenith.NET context later. Initialize() creates the native window and makes its window-system handles available. Center() places the window in the center of the screen. Run() processes events until the window closes, then execution continues to Dispose() to release it.

Build and run the application, then check that the window opens, moves, resizes, and closes. Its contents are not yet defined because no rendering commands have been submitted. Close the window before continuing.

4. Graphics context and presentation

Graphics context

A GraphicsContext connects the application to the GPU through the selected graphics API and provides methods to create resources for rendering.

Add the following using directives at the top of Program.cs:

using Zenith.NET;
using Zenith.NET.DirectX12;
using Zenith.NET.Metal;
using Zenith.NET.Vulkan;

Insert the context initialization after the using directives, before IWindow window:

GraphicsContext context;
if (OperatingSystem.IsWindows())
{
    context = GraphicsContext.CreateDirectX12(useValidationLayer: true);
}
else if (OperatingSystem.IsMacOS())
{
    context = GraphicsContext.CreateMetal(useValidationLayer: true);
}
else
{
    context = GraphicsContext.CreateVulkan(useValidationLayer: true);
}

context.ValidationMessage += static (_, args) => Console.WriteLine($"[{args.Severity}] {args.Message}");

Each branch requests validation for its graphics API. The handler prints any diagnostics reported through ValidationMessage.

Native surface

Download CocoaHelper.cs into the project. This helper class attaches the Metal layer to the macOS window.

Specify the native presentation target after window.Center() and before window.Run():

uint width = (uint)window.FramebufferSize.X;
uint height = (uint)window.FramebufferSize.Y;

Surface surface;
if (OperatingSystem.IsWindows())
{
    surface = Surface.Win32(window.Native!.Win32!.Value.Hwnd, width, height);
}
else if (OperatingSystem.IsMacOS())
{
    surface = Surface.Apple(CocoaHelper.CreateLayer(window.Native!.Cocoa!.Value), width, height);
}
else
{
    surface = Surface.Xlib(window.Native!.X11!.Value.Display, (nint)window.Native.X11.Value.Window, width, height);
}

The framebuffer is the window's image in pixels. Surface describes the native presentation target and its dimensions, and FramebufferSize gives the size in pixels. With display scaling, this can differ from the window's logical size.

The surface supplies the native handles needed by the selected graphics API: a Win32 window on Windows, an X11 display and window on Linux, or the attached Metal layer on macOS.

Swap chain

Create the swap chain immediately after the surface description:

SwapChain swapChain = context.CreateSwapChain(new()
{
    Surface = surface,
    Format = PixelFormat.B8G8R8A8UNorm
});

The SwapChain manages the images presented by the window. Drawable supplies the current image as a Texture, the API's image resource. After rendering, Present() requests display of that image and advances to the next drawable.

B8G8R8A8UNorm stores four 8-bit channels in blue, green, red, and alpha order. UNorm maps integer values 0–255 to values 0–1. Shader output remains a logical RGBA value; the format specifies its storage representation.

The swap chain depends on both the context and the window. Replace window.Dispose(); at the end of Program.cs with this disposal order:

swapChain.Dispose();
window.Dispose();

context.Dispose();

5. The first frame

Start by filling the window with a solid color, an operation called clearing. If the color appears, command submission and presentation are working. Then add the triangle's vertices and shaders.

Silk.NET invokes the render callback when a frame is due. Register the callback before window.Run():

window.Render += _ =>
{
    if (width is 0 || height is 0)
    {
        return;
    }

    CommandBuffer commandBuffer = context.GraphicsQueue.CommandBuffer();

    commandBuffer.Transition(swapChain.Drawable, default, TextureLayout.Undefined, TextureLayout.ColorAttachment);

    commandBuffer.BeginRenderPass([ColorAttachment.Clear(swapChain.Drawable, new(0.04f, 0.055f, 0.075f, 1.0f))], null);

    // Add the triangle draw commands here later.

    commandBuffer.EndRenderPass();

    commandBuffer.Transition(swapChain.Drawable, default, TextureLayout.ColorAttachment, TextureLayout.Present);

    commandBuffer.Submit().Wait();

    swapChain.Present();
};

Drawable preparation

A command queue receives recorded work for GPU execution. GraphicsQueue.CommandBuffer() returns a command buffer from the graphics queue with recording already in progress. The recorded commands define the GPU workload; execution begins only after submission.

A texture layout describes the kind of access an operation requires. The first Transition prepares the drawable for use as a color attachment. Here, default selects mip level 0 and array layer 0, the drawable's base image. Specifying Undefined allows the previous contents to be discarded because this pass clears the entire image.

Use the current drawable on each frame because presentation can advance to a different image.

Clear, submission, and presentation

A render pass groups drawing operations that write to the same attachments. In this pass, the swap chain's drawable receives the color output. ColorAttachment.Clear describes how to initialize that attachment with an RGBA clear color and preserve its contents when the pass ends. BeginRenderPass records the start of the pass with those settings.

The collection contains one color attachment. null specifies that the pass has no depth/stencil attachment.

BeginRenderPass sets the viewport and scissor to cover the whole attachment. The viewport maps rendering coordinates to pixel positions; the scissor limits which pixels drawing can affect.

After EndRenderPass, the second transition prepares the drawable for presentation. Submit() ends recording and submits the command buffer; Wait() blocks until that submission completes. Present() then requests display of the rendered image.

Resizing

The swap chain must follow changes to the framebuffer's size. Register a FramebufferResize callback after the render callback and before window.Run():

window.FramebufferResize += _ =>
{
    width = (uint)window.FramebufferSize.X;
    height = (uint)window.FramebufferSize.Y;

    if (width is 0 || height is 0)
    {
        return;
    }

    swapChain.Resize(width, height);
};

Listen for framebuffer-size changes, which are measured in pixels, rather than changes to the window's logical size. If minimizing the window sets either framebuffer dimension to zero, the resize callback leaves the swap chain unchanged. The render callback also returns while either dimension is zero, pausing rendering until the framebuffer has a usable size again.

The render callback waits for each submitted frame to finish before calling Present(). The resize callback can therefore replace the swap-chain images without interrupting work from an earlier frame. The Synchronization article explains these completion and resource-lifetime requirements in more detail.

Build and run the application again. The background should continue to fill the window when you resize it. Keep the render callback in place; the triangle will be drawn inside this pass.

6. Vertex data

Each of the triangle's three vertices has a position and a color. The GPU uses the positions to determine the triangle's shape and interpolates the colors across its surface.

Vertex representation

Add these using directives at the top of Program.cs for the vector types and the struct layout attribute:

using System.Numerics;
using System.Runtime.InteropServices;

Append the vertex declaration at the end of Program.cs, after the cleanup statements:

[StructLayout(LayoutKind.Sequential)]
file struct Vertex(Vector3 position, Vector4 color)
{
    public Vector3 Position = position;

    public Vector4 Color = color;
}

LayoutKind.Sequential keeps the fields in declaration order. Position contains three 32-bit floats and starts at byte offset 0; Color contains four and starts at byte offset 12. Each vertex therefore occupies 28 bytes. The input layout will describe this arrangement so the GPU can read the fields correctly.

Vertex positions and colors

Add the vertex array before window.Render += _ =>. Keep the subsequent resource initialization before this callback as well, in the order presented.

Vertex[] vertices =
[
    new(new(0.0f, 0.6f, 0.0f), new(1.0f, 0.2f, 0.15f, 1.0f)),
    new(new(0.6f, -0.5f, 0.0f), new(0.15f, 0.85f, 0.35f, 1.0f)),
    new(new(-0.6f, -0.5f, 0.0f), new(0.2f, 0.45f, 1.0f, 1.0f))
];

The three positions form the upper, lower-right, and lower-left corners. In this example, x and y use the range -1 to +1 across the image: 0 is the center, positive x points right, and positive y points up.

Each color has red, green, blue, and alpha components, with alpha set to 1. All three vertices have z = 0 and lie in the same plane.

Buffer allocation and upload

In Program.cs, add using Buffer = Zenith.NET.Buffer; to the using directives to distinguish the Zenith.NET buffer type from System.Buffer.

Store the vertices in a Buffer so the GPU can read them during drawing. Add the allocation and upload immediately after the array:

Buffer vertexBuffer;

unsafe
{
    vertexBuffer = context.CreateBuffer(new()
    {
        SizeInBytes = (uint)(sizeof(Vertex) * vertices.Length),
        StrideInBytes = (uint)sizeof(Vertex),
        Usages = BufferUsages.Vertex,
        Residency = MemoryResidency.CpuWriteOnly
    });

    fixed (Vertex* pointer = vertices)
    {
        vertexBuffer.Upload(0, new()
        {
            Pointer = (nint)pointer,
            SizeInBytes = (uint)(sizeof(Vertex) * vertices.Length)
        });
    }
}

The allocation size is sizeof(Vertex) * vertices.Length: three 28-byte vertices, or 84 bytes. BufferUsages.Vertex declares vertex-input access, while CpuWriteOnly permits direct initialization by the CPU. The descriptor's stride records the element size. The input layout defined below specifies how vertex fetching interprets each attribute.

Allocation does not initialize the buffer's contents. Upload copies the array to destination byte offset 0 using the source pointer and byte count supplied in the data description.

fixed prevents the garbage collector from moving the array while its address is in use. For this CPU-writable buffer, Upload maps the memory, copies the bytes, and unmaps it before returning, so the array only needs to remain pinned during the call. Upload these vertices once during initialization, and keep the buffer alive until rendering has finished.

Add vertexBuffer.Dispose(); immediately before swapChain.Dispose(); in the cleanup at the end of Program.cs.

7. Shader stages

Shaders are programs that run on the GPU. The vertex shader determines each vertex's position, and the fragment shader determines the color at each covered image location. Both stages will be written in one Slang file, with a separate entry point—the function where execution begins—for each stage.

Create Triangle.slang in the project root with these input and output structures:

struct VSInput
{
    float3 Position : POSITION0;

    float4 Color : COLOR0;
};

struct FSInput
{
    float4 Position : SV_POSITION;

    float4 Color : COLOR0;
};

VSInput defines the vertex shader's inputs. POSITION0 and COLOR0 identify the attributes by semantic; the C# field names alone do not establish this mapping.

FSInput connects the two shader stages. The vertex shader writes a clip-space position to SV_POSITION; the GPU uses this position to determine which part of the triangle is visible. The COLOR0 field carries the vertex color, which is interpolated across the triangle before reaching the fragment shader. Its type and semantic must agree between the two stages.

Vertex shading

Append the vertex entry point to Triangle.slang:

[shader("vertex")]
FSInput VSMain(VSInput input)
{
    FSInput output;
    output.Position = float4(input.Position, 1.0);
    output.Color = input.Color;

    return output;
}

The GPU runs this function for each input vertex. It adds a fourth position component, w = 1, and passes on the color. After vertex processing, the GPU divides x, y, and z by w to obtain normalized device coordinates. With w equal to 1, these values are unchanged.

Fragment shading

Append the fragment entry point to the same file:

[shader("fragment")]
float4 FSMain(FSInput input) : SV_TARGET
{
    return input.Color;
}

Rasterization determines where the triangle covers the image and generates fragments for those locations. Each fragment receives a color interpolated from the three vertex colors. FSMain returns this color, and SV_TARGET directs the result to the first color attachment.

Compiling the shaders

For Triangle.slang, set the file property Copy to Output Directory to Copy if newer. The build will then copy the shader beside the executable.

Return to Program.cs and add shader initialization after the vertex upload, before the render callback:

string shaderPath = Path.Combine(AppContext.BaseDirectory, "Triangle.slang");

Shader vertexShader = context.CreateShader(ZenithCompiler.CompileFromFile(context.GraphicsApi, shaderPath, "VSMain"));
Shader fragmentShader = context.CreateShader(ZenithCompiler.CompileFromFile(context.GraphicsApi, shaderPath, "FSMain"));

ZenithCompiler compiles the source for context.GraphicsApi, and CreateShader creates a shader object from the compiled description. The entry-point names VSMain and FSMain select the two functions defined in the Slang file.

Using AppContext.BaseDirectory loads the copied shader from the application's output directory, so launching the application from another working directory does not change where it looks for the file.

8. Vertex input layout

The GPU needs to know where each vertex attribute, such as position or color, is stored. The input layout supplies its format and byte offset. It also sets the stride: the number of bytes from one vertex to the next. Add this code after creating the shaders:

InputLayout inputLayout = new();
inputLayout.Add(new() { Format = ElementFormat.Float3, Semantic = ElementSemantic.Position });
inputLayout.Add(new() { Format = ElementFormat.Float4, Semantic = ElementSemantic.Color });

InputLayout.Add appends each attribute after the previous one and adds its size to the stride. The offsets come from the formats supplied here, rather than from inspection of the C# struct. For these two fields, the resulting layout is:

C# field Byte offset Format Shader input
Vertex.Position 0 Float3 (12 bytes) POSITION0
Vertex.Color 12 Float4 (16 bytes) COLOR0
Next vertex 28 Total stride: 28 bytes Next input record

The default SemanticIndex is zero, matching POSITION0 and COLOR0 in the shader. Position and color are stored together in one vertex buffer, so the pipeline uses one input layout at slot 0. Keep this layout consistent with the C# struct if its field order or padding changes.

9. Graphics pipeline

A GraphicsPipeline brings together the vertex layout, the two shaders, and the settings that determine how primitives are assembled and written to attachments.

  • TriangleList assembles each consecutive group of three vertices into a triangle.
  • The pipeline's color format must match the render-pass attachment, so it is taken from swapChain.Desc.Format. Count1 specifies one sample per pixel.
  • CullNone() disables face culling, so the triangle is drawn whether its vertices appear clockwise or counterclockwise.
  • DepthNone() disables depth testing and depth writes, consistent with the absence of a depth attachment.
  • Opaque() disables blending. The fragment shader's output therefore replaces the background color inside the triangle.

After the input layout, create the pipeline:

GraphicsPipeline pipeline = context.CreateGraphicsPipeline(new()
{
    VertexShader = vertexShader,
    FragmentShader = fragmentShader,
    InputLayouts = [inputLayout],
    PrimitiveTopology = PrimitiveTopology.TriangleList,
    AttachmentFormats = new()
    {
        ColorFormats = [swapChain.Desc.Format],
        SampleCount = SampleCount.Count1
    },
    RenderState = new()
    {
        Rasterizer = RasterizerState.CullNone(),
        DepthStencil = DepthStencilState.DepthNone(),
        Blend = BlendState.Opaque()
    }
});

vertexShader.Dispose();
fragmentShader.Dispose();

Shader objects may be released after pipeline creation. Keep the pipeline alive while rendering, and add pipeline.Dispose(); before vertexBuffer.Dispose(); in the cleanup.

10. Drawing commands

In the window.Render callback, replace // Add the triangle draw commands here later. with the following commands, between BeginRenderPass and EndRenderPass:

commandBuffer.SetPipeline(pipeline);
commandBuffer.SetVertexBuffer(vertexBuffer, 0, 0);

commandBuffer.Draw(3, 1, 0, 0);

SetPipeline selects the shaders and render state. Bind the pipeline before the vertex buffer so the pipeline's input layout can determine how vertex data is read.

SetVertexBuffer(vertexBuffer, 0, 0) binds the uploaded vertex data at byte offset 0 to input slot 0, corresponding to the single entry in InputLayouts.

The arguments to Draw are:

Argument Value Meaning
vertexCount 3 Read three vertices.
instanceCount 1 Draw one copy of the triangle.
firstVertex 0 Start with the first vertex in the buffer.
firstInstance 0 Start instance numbering at zero.

Keep the clear operation at the start of the pass so the area outside the triangle has a defined background color.

11. Running the application

Build and run the FirstTriangle project.

You should see a triangle on a dark background, with red at the top, green at the lower right, and blue at the lower left. Interpolation creates smooth transitions between the vertex colors across the triangle.

Resizing changes the image's pixel dimensions while the vertex coordinates stay the same. The viewport maps those coordinates to the new width and height, so the triangle stretches when the window's width-to-height ratio changes.

For each frame, get the current drawable, transition it for use as a color attachment, clear the background and draw the triangle, transition it for presentation, then submit, wait, and present.

When the window closes, Run returns and execution continues to the cleanup code. The render callback has waited for every submitted frame to finish, so the resources used by those commands can now be released. The complete cleanup after window.Run() is:

pipeline.Dispose();
vertexBuffer.Dispose();
swapChain.Dispose();
window.Dispose();

context.Dispose();

Release the swap chain before destroying its native window, and release the context after its resources. Submitted command buffers are recycled by the queue, and the drawable is managed by the swap chain, so neither needs a separate Dispose() call.

Search documentation

Search tutorials, concepts, samples, and the API reference.