Skip to content

Textured Quad

This example shows the complete workflow for texture mapping in Vulkan: loading image data from disk, creating GPU textures, configuring samplers for filtering and wrapping, and binding textures to shaders for sampling. It introduces the fundamentals of texture coordinates, texture views, and combined image samplers.

The example uses the KDGpuExample helper API for simplified setup.

textured_quad.png

Overview

What this example demonstrates:

  • Loading image files using the STB Image library
  • Creating GPU textures from CPU image data
  • Texture samplers with filtering and wrapping modes
  • Combined image-sampler descriptors for shader access
  • Texture coordinate mapping to geometry

Use cases:

  • Applying images, photos, or artwork to 3D models
  • Implementing material systems with albedo/diffuse maps
  • Creating textured UI elements or 2D sprites
  • Foundation for advanced texturing (normal maps, PBR materials)

Vulkan Requirements

  • Vulkan Version: 1.0+
  • Extensions: None (core texture sampling functionality)
  • Device Features: Standard sampler support (available on all devices)

Key Concepts

Textures in Vulkan:

In Vulkan, rendering with textures requires several components:

  1. Texture Image (VkImage): GPU-side memory holding the pixel data
  2. Texture View (VkImageView): Describes how to interpret the image data (format, mip levels, array layers)
  3. Sampler (VkSampler): Defines how to sample/filter the texture (linear, nearest, anisotropic)
  4. Descriptor Binding: Makes the texture+sampler accessible in shaders

Unlike OpenGL's glBindTexture, Vulkan separates the image data from the sampling configuration, allowing different sampling modes for the same texture.

Texture Coordinates:

Texture coordinates (UVs) specify which part of the texture maps to each vertex. Coordinates range from (0,0) at the bottom-left to (1,1) at the top-right. Vertex shader passes UVs to fragment shader where they're interpolated and used to sample the texture.

Texture Sampling:

Samplers control how texels (texture pixels) are read when texture coordinates fall between pixel centers:

  • Nearest: Sharp, pixelated - picks closest texel
  • Linear: Smooth - interpolates between neighboring texels
  • Wrapping modes: Repeat, clamp, mirror - control behavior outside [0,1] range

For more on Vulkan textures: https://registry.khronos.org/vulkan/specs/1.3-extensions/man/html/VkImage.html

Implementation

Loading Image Data:

This example uses the STB Image library to load JPEG/PNG files:

1
2
#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>

Filename: textured_quad/textured_quad.cpp

1
2
3
4
5
6
7
struct ImageData {
    uint32_t width{ 0 };
    uint32_t height{ 0 };
    uint8_t *pixelData{ nullptr };
    DeviceSize byteSize{ 0 };
    Format format{ Format::R8G8B8A8_UNORM };
};

Filename: textured_quad/textured_quad.cpp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ImageData loadImage(KDUtils::File &file)
{
    int texChannels;
    int _width = 0, _height = 0;

    if (!file.open(std::ios::in | std::ios::binary)) {
        SPDLOG_LOGGER_CRITICAL(KDGpu::Logger::logger(), "Failed to open file {}", file.path());
        throw std::runtime_error("Failed to open file");
    }

    const KDUtils::ByteArray fileContent = file.readAll();
    std::vector<uint32_t> buffer(fileContent.size() / 4);

    auto _data = stbi_load_from_memory(
            fileContent.data(), fileContent.size(), &_width, &_height, &texChannels, STBI_rgb_alpha);

    if (_data == nullptr) {
        SPDLOG_WARN("Failed to load texture {} {}", file.path(), stbi_failure_reason());
        return {};
    }
    SPDLOG_DEBUG("Texture dimensions: {} x {}", _width, _height);

    return ImageData{
        .width = static_cast<uint32_t>(_width),
        .height = static_cast<uint32_t>(_height),
        .pixelData = static_cast<uint8_t *>(_data),
        .byteSize = 4 * static_cast<DeviceSize>(_width) * static_cast<DeviceSize>(_height)
    };
}

Filename: textured_quad/textured_quad.cpp

STB Image provides a simple interface to decode various image formats. The loadImage() function returns pixel data in RGBA format along with dimensions.

Creating and Uploading the Texture:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
    {
        // Load the image data and size
        auto imageFile = KDGpuExample::assetDir().file("textures/samuel-ferrara-1527pjeb6jg-unsplash.jpg");
        ImageData image = loadImage(imageFile);

        const TextureOptions textureOptions = {
            .type = TextureType::TextureType2D,
            .format = image.format,
            .extent = { .width = image.width, .height = image.height, .depth = 1 },
            .mipLevels = 1,
            .usage = TextureUsageFlagBits::SampledBit | TextureUsageFlagBits::TransferDstBit,
            .memoryUsage = MemoryUsage::GpuOnly,
            .initialLayout = TextureLayout::Undefined
        };
        m_texture = m_device.createTexture(textureOptions);

        // Upload the texture data and transition to ShaderReadOnlyOptimal
        const std::vector<BufferTextureCopyRegion> regions = {
            {
                    .textureSubResource = { .aspectMask = TextureAspectFlagBits::ColorBit },
                    .textureExtent = { .width = image.width, .height = image.height, .depth = 1 },
            },
        };
        const TextureUploadOptions uploadOptions = {
            .destinationTexture = m_texture,
            .dstStages = PipelineStageFlagBit::AllGraphicsBit,
            .dstMask = AccessFlagBit::MemoryReadBit,
            .data = image.pixelData,
            .byteSize = image.byteSize,
            .oldLayout = TextureLayout::Undefined,
            .newLayout = TextureLayout::ShaderReadOnlyOptimal,
            .regions = regions
        };
        uploadTextureData(uploadOptions);

Filename: textured_quad/textured_quad.cpp

Key options:

  • TextureUsageFlagBits::SampledBit: Texture will be sampled in shaders
  • TextureUsageFlagBits::TransferDstBit: Texture can receive uploaded data
  • MemoryUsage::GpuOnly: Texture stays on GPU (best performance)
  • TextureLayout transitions: Undefined → ShaderReadOnlyOptimal for efficient sampling

The upload uses a staging buffer internally (handled by the helper API) to transfer data from CPU to GPU memory.

Creating Texture View and Sampler:

1
2
        m_textureView = m_texture.createView();
        m_sampler = m_device.createSampler();

Filename: textured_quad/textured_quad.cpp

The texture view acts as a "window" into the texture image. The sampler uses default settings which provide:

  • Mag/Min Filter: Linear interpolation (smooth)
  • Wrap Mode: Repeat (tile the texture if UVs go outside [0,1])
  • Anisotropy: Disabled (can be enabled for better quality at oblique angles)

For custom sampler settings, see VkSamplerCreateInfo.

Binding Texture to Shaders:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    const BindGroupOptions bindGroupOptions = {
        .layout = bindGroupLayout,
        .resources = {
                {
                        .binding = 0,
                        .resource = TextureViewSamplerBinding{ .textureView = m_textureView, .sampler = m_sampler },
                },
        },
    };
    m_textureBindGroup = m_device.createBindGroup(bindGroupOptions);

Filename: textured_quad/textured_quad.cpp

The TextureViewSamplerBinding creates a combined image sampler descriptor. This makes both the texture data and sampling configuration available to the fragment shader as a single descriptor.

In the fragment shader:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
layout(location = 0) in vec2 texCoord;

layout(location = 0) out vec4 fragColor;

layout(set = 0, binding = 0) uniform sampler2D colorTexture;

void main()
{
    vec3 color = texture(colorTexture, texCoord).rgb;
    fragColor = vec4(color, 1.0);
}

Filename: textured_quad/textured_quad.frag

Primitive Topology:

This example renders a quad using triangle strip topology:

1
2
3
        .primitive = {
                .topology = PrimitiveTopology::TriangleStrip,
        },

Filename: textured_quad/textured_quad.cpp

Triangle strip connects vertices in sequence: 0-1-2 forms a triangle, then 1-2-3 forms the next. This is more efficient than indexed triangles for simple strips. The quad uses 4 vertices with UVs mapping the full texture:

  • Vertex 0: position(-1, 1), UV(0, 1) - Bottom-left
  • Vertex 1: position(1, 1), UV(1, 1) - Bottom-right
  • Vertex 2: position(-1, -1), UV(0, 0) - Top-left
  • Vertex 3: position(1, -1), UV(1, 0) - Top-right

Performance Notes

  • Texture Formats: R8G8B8A8_UNORM is universally supported but 4 bytes per pixel. Consider compressed formats (BC, ASTC) for larger textures
  • Mipmapping: This example doesn't use mipmaps. For textures viewed at varying distances, generate mipmaps to improve performance and quality
  • Anisotropic Filtering: Improves quality when viewing textures at oblique angles, costs minimal performance on modern GPUs
  • GPU-Only Memory: Using MemoryUsage::GpuOnly gives best performance since GPU can optimize memory layout

See Also

Further Reading


Updated on 2026-03-31 at 00:02:07 +0000