Table of Contents

Textured Quad

In this tutorial, you'll render a textured quad using an index buffer, a texture loaded from file, and a sampler. This introduces resource binding — connecting GPU resources like textures and samplers to shaders through resource layouts and tables.

Overview

This tutorial covers:

  • Using an index buffer to share vertices between triangles
  • Loading a texture from an image file
  • Creating a sampler with filtering and address modes
  • Defining a resource layout and resource table to bind resources to shaders
  • Using BindingHelper for cross-backend resource binding

The Renderer Class

Create the file Renderers/TexturedQuadRenderer.cs:

namespace ZenithTutorials.Renderers;

internal unsafe class TexturedQuadRenderer : IRenderer
{
    private const string ShaderSource = """
        struct VSInput
        {
            float3 Position : POSITION0;

            float2 TexCoord : TEXCOORD0;
        };

        struct PSInput
        {
            float4 Position : SV_POSITION;

            float2 TexCoord : TEXCOORD;
        };

        Texture2D texture;
        SamplerState sampler;

        PSInput VSMain(VSInput input)
        {
            PSInput output;
            output.Position = float4(input.Position, 1.0);
            output.TexCoord = input.TexCoord;

            return output;
        }

        float4 PSMain(PSInput input) : SV_TARGET
        {
            return texture.Sample(sampler, input.TexCoord);
        }
        """;

    private readonly Buffer vertexBuffer;
    private readonly Buffer indexBuffer;
    private readonly Texture texture;
    private readonly Sampler sampler;
    private readonly ResourceLayout resourceLayout;
    private readonly ResourceTable resourceTable;
    private readonly GraphicsPipeline pipeline;

    public TexturedQuadRenderer()
    {
        Vertex[] vertices =
        [
            new(new(-0.5f,  0.5f, 0.0f), new(0.0f, 0.0f)),
            new(new( 0.5f,  0.5f, 0.0f), new(1.0f, 0.0f)),
            new(new( 0.5f, -0.5f, 0.0f), new(1.0f, 1.0f)),
            new(new(-0.5f, -0.5f, 0.0f), new(0.0f, 1.0f))
        ];

        uint[] indices = [0, 1, 2, 0, 2, 3];

        vertexBuffer = App.Context.CreateBuffer(new()
        {
            SizeInBytes = (uint)(sizeof(Vertex) * vertices.Length),
            StrideInBytes = (uint)sizeof(Vertex),
            Flags = BufferUsageFlags.Vertex | BufferUsageFlags.MapWrite
        });
        vertexBuffer.Upload(vertices, 0);

        indexBuffer = App.Context.CreateBuffer(new()
        {
            SizeInBytes = (uint)(sizeof(uint) * indices.Length),
            StrideInBytes = sizeof(uint),
            Flags = BufferUsageFlags.Index | BufferUsageFlags.MapWrite
        });
        indexBuffer.Upload(indices, 0);

        texture = App.Context.LoadTextureFromFile(Path.Combine(AppContext.BaseDirectory, "Assets", "shoko.png"), generateMipMaps: true);

        sampler = App.Context.CreateSampler(new()
        {
            U = AddressMode.Clamp,
            V = AddressMode.Clamp,
            W = AddressMode.Clamp,
            Filter = Filter.MinLinearMagLinearMipLinear,
            MaxLod = uint.MaxValue
        });

        resourceLayout = App.Context.CreateResourceLayout(new()
        {
            Bindings = BindingHelper.Bindings
            (
                new() { Type = ResourceType.Texture, Count = 1, StageFlags = ShaderStageFlags.Pixel },
                new() { Type = ResourceType.Sampler, Count = 1, StageFlags = ShaderStageFlags.Pixel }
            )
        });

        resourceTable = App.Context.CreateResourceTable(new()
        {
            Layout = resourceLayout,
            Resources = [texture, sampler]
        });

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

        using Shader vertexShader = App.Context.LoadShaderFromSource(ShaderSource, "VSMain", ShaderStageFlags.Vertex);
        using Shader pixelShader = App.Context.LoadShaderFromSource(ShaderSource, "PSMain", ShaderStageFlags.Pixel);

        pipeline = App.Context.CreateGraphicsPipeline(new()
        {
            RenderStates = new()
            {
                RasterizerState = RasterizerStates.CullNone,
                DepthStencilState = DepthStencilStates.Default,
                BlendState = BlendStates.Opaque
            },
            Vertex = vertexShader,
            Pixel = pixelShader,
            ResourceLayout = resourceLayout,
            InputLayouts = [inputLayout],
            PrimitiveTopology = PrimitiveTopology.TriangleList,
            Output = App.FrameBuffer.Output
        });
    }

    public void Update(double deltaTime)
    {
    }

    public void Render()
    {
        CommandBuffer commandBuffer = App.Context.Graphics.CommandBuffer();

        commandBuffer.BeginRenderPass(App.FrameBuffer, new()
        {
            ColorValues = [new(0.1f, 0.1f, 0.1f, 1.0f)],
            Depth = 1.0f,
            Stencil = 0,
            Flags = ClearFlags.All
        }, resourceTable);

        commandBuffer.SetPipeline(pipeline);
        commandBuffer.SetResourceTable(resourceTable);
        commandBuffer.SetVertexBuffer(vertexBuffer, 0, 0);
        commandBuffer.SetIndexBuffer(indexBuffer, 0, IndexFormat.UInt32);
        commandBuffer.DrawIndexed(6, 1, 0, 0, 0);

        commandBuffer.EndRenderPass();

        commandBuffer.Submit(waitForCompletion: true);
    }

    public void Resize(uint width, uint height)
    {
    }

    public void Dispose()
    {
        pipeline.Dispose();
        resourceTable.Dispose();
        resourceLayout.Dispose();
        sampler.Dispose();
        texture.Dispose();
        indexBuffer.Dispose();
        vertexBuffer.Dispose();
    }
}

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

    public Vector2 TexCoord = texCoord;
}

Running the Tutorial

Run the application and select 2. Textured Quad from the menu:

dotnet run

Result

Textured Quad

Code Breakdown

Shader

The pixel shader samples a texture using UV coordinates:

private const string ShaderSource = """
    struct VSInput
    {
        float3 Position : POSITION0;

        float2 TexCoord : TEXCOORD0;
    };

    struct PSInput
    {
        float4 Position : SV_POSITION;

        float2 TexCoord : TEXCOORD;
    };

    Texture2D texture;
    SamplerState sampler;

    PSInput VSMain(VSInput input)
    {
        PSInput output;
        output.Position = float4(input.Position, 1.0);
        output.TexCoord = input.TexCoord;

        return output;
    }

    float4 PSMain(PSInput input) : SV_TARGET
    {
        return texture.Sample(sampler, input.TexCoord);
    }
    """;

Texture2D and SamplerState are declared as global resources. The pixel shader uses texture.Sample(sampler, uv) to fetch filtered texel colors.

Index Buffer

Instead of duplicating vertices, an index buffer references shared vertices:

Vertex[] vertices =
[
    new(new(-0.5f,  0.5f, 0.0f), new(0.0f, 0.0f)),
    new(new( 0.5f,  0.5f, 0.0f), new(1.0f, 0.0f)),
    new(new( 0.5f, -0.5f, 0.0f), new(1.0f, 1.0f)),
    new(new(-0.5f, -0.5f, 0.0f), new(0.0f, 1.0f))
];

uint[] indices = [0, 1, 2, 0, 2, 3];

Two triangles (indices 0,1,2 and 0,2,3) share vertices 0 and 2 to form the quad.

Texture and Sampler

The texture is loaded from a file with mipmaps generated automatically:

texture = App.Context.LoadTextureFromFile(Path.Combine(AppContext.BaseDirectory, "Assets", "shoko.png"), generateMipMaps: true);

sampler = App.Context.CreateSampler(new()
{
    U = AddressMode.Clamp,
    V = AddressMode.Clamp,
    W = AddressMode.Clamp,
    Filter = Filter.MinLinearMagLinearMipLinear,
    MaxLod = uint.MaxValue
});
Property Value Purpose
AddressMode.Clamp U, V, W Clamp UVs to [0,1] — no texture wrapping
Filter MinLinearMagLinearMipLinear Trilinear filtering for smooth sampling
MaxLod uint.MaxValue Allow all mipmap levels

Resource Binding

Resources are exposed to shaders through a layout and table:

resourceLayout = App.Context.CreateResourceLayout(new()
{
    Bindings = BindingHelper.Bindings
    (
        new() { Type = ResourceType.Texture, Count = 1, StageFlags = ShaderStageFlags.Pixel },
        new() { Type = ResourceType.Sampler, Count = 1, StageFlags = ShaderStageFlags.Pixel }
    )
});

resourceTable = App.Context.CreateResourceTable(new()
{
    Layout = resourceLayout,
    Resources = [texture, sampler]
});

BindingHelper.Bindings assigns the correct binding indices per backend. StageFlags controls which shader stages can access each resource.

Rendering

The render pass now receives the resourceTable, and uses indexed drawing:

commandBuffer.BeginRenderPass(App.FrameBuffer, new()
{
    ColorValues = [new(0.1f, 0.1f, 0.1f, 1.0f)],
    Depth = 1.0f,
    Stencil = 0,
    Flags = ClearFlags.All
}, resourceTable);

commandBuffer.SetPipeline(pipeline);
commandBuffer.SetResourceTable(resourceTable);
commandBuffer.SetVertexBuffer(vertexBuffer, 0, 0);
commandBuffer.SetIndexBuffer(indexBuffer, 0, IndexFormat.UInt32);
commandBuffer.DrawIndexed(6, 1, 0, 0, 0);

DrawIndexed(6, 1, 0, 0, 0) draws 6 indices (2 triangles), 1 instance.

Next Steps

Source Code

Tip

View the complete source code on GitHub: TexturedQuadRenderer.cs