First Triangle
1. Prerequisites
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
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 graphics and compute API; the three graphics API packages implement it. Zenith.NET.Compiler compiles Slang shaders for the selected API.
AllowUnsafeBlocks) in the project's build settings. Native calls in the window helper class and the vertex upload require unsafe code.
3. Window creation
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.
4. Graphics context and presentation
Graphics context
GraphicsContext connects the application to the GPU through the selected graphics API and provides methods to create resources for rendering.
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.
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.
window.Dispose(); at the end of Program.cs with this disposal order:
swapChain.Dispose();
window.Dispose();
context.Dispose();
5. The first frame
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 from the graphics queue with recording already in progress. The recorded commands define 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 which pixels drawing can affect.
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;
}
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
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))
];
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.
7. Shader stages
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
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 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
[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. 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.
Draw are:
|
|
|
|
|---|---|---|
vertexCount
|
3 |
|
instanceCount
|
1 |
|
firstVertex
|
0 |
|
firstInstance
|
0 |
|
11. Running the application
FirstTriangle project.
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();
Dispose() call.