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. The application targets net10.0. Your GPU and driver must support the backend selected for your platform:

Platform Graphics backend
Windows DirectX 12
macOS Metal 4
Linux (X11 or XWayland) Vulkan 1.4

Zenith.NET renders into a native surface provided by the application. Silk.NET.Windowing creates the window and processes its events. On macOS, presentation uses a Metal layer, represented by CAMetalLayer.cs; the window helper attaches it to the window.

The Hello Triangle sample and its shared host provide reference implementations for this tutorial.

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 rendering API, and the three backend packages supply its platform implementations. Zenith.NET.Compiler compiles the Slang shaders introduced below for the selected backend. Silk.NET provides window creation and event processing.

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

3. Window creation

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

Replace the generated Program.cs with the code below. It uses top-level statements, which run in order without an explicit Main method. Each later section will show where to add its code:

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 leaves graphics-context creation to Zenith.NET. 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 a backend and provides the methods used to create rendering resources.

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 selects a backend for the current platform and requests validation. Messages from ValidationMessage are printed in the console.

Native surface

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 backend: 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 pipeline will use swapChain.Desc.Format to match this attachment.

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 single background color. This operation is called clearing. Once the background appears, the surface, command submission, and presentation are working together, ready for the triangle's geometry and shaders.

Silk.NET invokes the render callback when a frame is due. This example does not use the elapsed-time argument, so the parameter is named _. 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

GraphicsQueue.CommandBuffer() returns a command buffer ready for recording. Recording defines 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 where pixels can be written.

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: each new render pass sets its viewport and scissor from the resized attachment. 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;
}

file limits the type to Program.cs, and LayoutKind.Sequential keeps its 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))
];

Each entry constructs a Vertex; the two inner new expressions provide its Vector3 position and Vector4 color. The positions form the upper, lower-right, and lower-left corners. The vertex shader adds w = 1 as the fourth position coordinate. With w equal to 1, x and y are also normalized device coordinates: -1 and +1 mark the image boundaries, and 0 marks the center. Their interpretation is independent of pixel dimensions.

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. For larger static geometry, Resource Management discusses GPU-only storage and transfer uploads.

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 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 invokes this entry point for each input vertex. It adds the homogeneous coordinate w = 1 and forwards the color. After vertex processing, position is divided by w. Since w is 1 here, x, y, and z are unchanged and may be interpreted directly as normalized device coordinates.

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.

The shaders process vertices and fragments. The other pipeline settings control primitive assembly, face culling, depth testing, and blending:

  • 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. It must precede vertex binding because the backend interprets that binding using the pipeline's input layout.

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.

These calls record the draw commands. The existing Submit().Wait() submits them and waits for the GPU to finish; Present() then requests display of the completed image. 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.

Frame lifecycle and cleanup

Each frame follows this sequence:

Get the current drawable
          ↓
Transition to ColorAttachment
          ↓
Clear and draw the triangle
          ↓
Transition to Present
          ↓
Submit and wait
          ↓
Present

When the window closes, Run returns and execution continues to the cleanup code. Each frame has already waited for its submitted work 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.

Troubleshooting

Use console diagnostics and the three checkpoints to identify the failing stage:

Symptom Check
No window opens Confirm the .NET project builds and the window-only step runs in a desktop session. On Linux, check that X11 or XWayland is available.
Backend creation fails Check that the selected backend is supported by the device and installed graphics driver.
The clear-color step works, but shader loading fails Check the copied Triangle.slang, entry-point spelling and the compiler's reported diagnostic.
The background appears but the triangle does not Check the upload size, input layout, pipeline selection and the placement of the draw commands inside the render pass.
Vertex positions or colors are corrupted Check the 28-byte stride and the position/color offsets of 0 and 12.

The sample renderer and Slang shader provide a reference for the vertex data, input layout, and draw operation. That example separates windowing and submission into a shared host; this tutorial retains the application flow in Program.cs. Spinning Cube introduces indexed drawing, transformations, and depth testing.

Search documentation

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