First Triangle
1. Prerequisites
net10.0. Your GPU and driver must support the backend selected for your platform:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
2. Project setup
FirstTriangle targeting .NET 10.
-
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.
AllowUnsafeBlocks) in the project's build settings. Native calls in the window helper and the vertex upload require unsafe code.
3. Window creation
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.
4. Graphics context and presentation
Graphics context
GraphicsContext connects the application to the GPU through a backend and provides the methods used to create rendering resources.
Program.cs:
using Zenith.NET;
using Zenith.NET.DirectX12;
using Zenith.NET.Metal;
using Zenith.NET.Vulkan;
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}");
ValidationMessage are printed in the console.
Native surface
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);
}
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.
Swap chain
SwapChain swapChain = context.CreateSwapChain(new()
{
Surface = surface,
Format = PixelFormat.B8G8R8A8UNorm
});
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.
window.Dispose(); at the end of Program.cs with this disposal order:
swapChain.Dispose();
window.Dispose();
context.Dispose();
5. The first frame
_. Register the callback 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.
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.
Clear, submission, and presentation
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.
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.
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
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);
};
Present(). The resize callback can therefore replace the swap-chain images without interrupting work from an earlier frame. The
6. Vertex data
Vertex representation
Program.cs for the vector types and the struct layout attribute:
using System.Numerics;
using System.Runtime.InteropServices;
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
window.Render += _ =>
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))
];
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.
Buffer allocation and upload
Program.cs, add using Buffer = Zenith.NET.Buffer; to the using directives to distinguish the Zenith.NET buffer type from System.Buffer.
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)
});
}
}
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.
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.
vertexBuffer.Dispose(); immediately before swapChain.Dispose(); in the cleanup at the end of Program.cs. For larger static geometry,
7. Shader stages
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
Triangle.slang:
[shader("vertex")]
FSInput VSMain(VSInput input)
{
FSInput output;
output.Position = float4(input.Position, 1.0);
output.Color = input.Color;
return output;
}
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
[shader("fragment")]
float4 FSMain(FSInput input) : SV_TARGET
{
return input.Color;
}
FSMain returns this color, and SV_TARGET directs the result to the first color attachment.
Compiling the shaders
Triangle.slang, set the file property
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.
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
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:
|
|
|
|
|
|---|---|---|---|
Vertex.Position
|
0 |
Float3 (12 bytes) |
POSITION0
|
Vertex.Color
|
12 |
Float4 (16 bytes) |
COLOR0
|
|
|
28 |
|
|
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
GraphicsPipeline brings together the vertex layout, the two shaders, and the settings that determine how primitives are assembled and written to attachments.
-
TriangleListassembles 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.Count1specifies 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.
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();
pipeline.Dispose(); before vertexBuffer.Dispose(); in the cleanup.
10. Drawing commands
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.
Draw are:
|
|
|
|
|---|---|---|
vertexCount
|
3 |
|
instanceCount
|
1 |
|
firstVertex
|
0 |
|
firstInstance
|
0 |
|
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
FirstTriangle project.
Frame lifecycle and cleanup
Get the current drawable
↓
Transition to ColorAttachment
↓
Clear and draw the triangle
↓
Transition to Present
↓
Submit and wait
↓
Present
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();
Dispose() call.
Troubleshooting
|
|
|
|---|---|
|
|
|
|
|
|
|
|
Triangle.slang, entry-point spelling and the compiler's reported diagnostic. |
|
|
|
|
|
|
Program.cs.