Skip to content

Textured Quad

This example demonstrates using KDGpu::Device::createTexture on a static image loaded from disk using the STB image header.

First, we include the STB image header, which will provide us with the necessary inputs to the KDGpu API:

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

Filename: textured_quad/textured_quad.cpp

We organize all of the STB-image-supplied values into one struct, using an RGBA KDGpu::Format.

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

And we have a function to populate the struct, with some per-platform implementation details. This function includes all of the STB image calls in this example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ImageData loadImage(const std::string &path)
{
    int texChannels;
    int _width = 0, _height = 0;
    std::string texturePath = path;
#ifdef PLATFORM_WIN32
    // STB fails to load if path is /C:/... instead of C:/...
    if (texturePath.rfind("/", 0) == 0)
        texturePath = texturePath.substr(1);
#endif
    auto _data = stbi_load(texturePath.c_str(), &_width, &_height, &texChannels, STBI_rgb_alpha);
    if (_data == nullptr) {
        SPDLOG_WARN("Failed to load texture {} {}", 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

Initialization

At scene initialization, we load and upload the texture, and create and upload the vertex buffer for the quad. The quad vertex buffer creation is unsurprising, but texture upload is a set of new function calls. Some settings to note:

  • We have no need to access the buffer after loading, so the memory usage is GPU-only.
  • We perform the texture upload with only one copy region which covers the whole texture. This is just boilerplate for us but a larger texture could make use of multiple copy regions.
  • The oldLayout and newLayout options offer a way to optimize the texture for different usecases. Check out KDGpu::TextureLayout to see the available layouts.
 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
35
36
37
38
    {
        // Load the image data and size
        ImageData image = loadImage(KDGpu::assetPath() + "/textures/samuel-ferrara-1527pjeb6jg-unsplash.jpg");

        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
        // clang-format off
        const std::vector<BufferTextureCopyRegion> regions = {{
            .textureSubResource = { .aspectMask = TextureAspectFlagBits::ColorBit },
            .textureExtent = { .width = image.width, .height = image.height, .depth = 1 }
        }};
        // clang-format on
        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);

        // Create a view and sampler
        m_textureView = m_texture.createView();
        m_sampler = m_device.createSampler();
    }

Filename: textured_quad/textured_quad.cpp

When initializing the graphics pipeline, we pass in a KDGpu::PrimitiveOptions to the primitive field, which in turn contains KDGpu::PrimitiveTopology field. The topology declares how the vertices will be interpreted as polygons. Another way to achieve this is with an index buffer. In our case, a quad is well described by an existing topology option, so we use that.

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

Filename: textured_quad/textured_quad.cpp

We also create a bindgroup with a TextureViewBinding resource for sampling this static texture:

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

Filename: textured_quad/textured_quad.cpp

Per-Frame Rendering Logic

Now that we have initialized everything properly, the render function is one of the simplest so far. We set the pipeline and buffer as usual, and set the bindgroup we just created.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
void TexturedQuad::render()
{
    auto commandRecorder = m_device.createCommandRecorder();

    m_opaquePassOptions.colorAttachments[0].view = m_swapchainViews.at(m_currentSwapchainImageIndex);
    auto opaquePass = commandRecorder.beginRenderPass(m_opaquePassOptions);
    opaquePass.setPipeline(m_pipeline);
    opaquePass.setVertexBuffer(0, m_buffer);
    opaquePass.setBindGroup(0, m_textureBindGroup);
    opaquePass.draw(DrawCommand{ .vertexCount = 4 });
    renderImGuiOverlay(&opaquePass);
    opaquePass.end();
    m_commandBuffer = commandRecorder.finish();

    const SubmitOptions submitOptions = {
        .commandBuffers = { m_commandBuffer },
        .waitSemaphores = { m_presentCompleteSemaphores[m_inFlightIndex] },
        .signalSemaphores = { m_renderCompleteSemaphores[m_inFlightIndex] }
    };
    m_queue.submit(submitOptions);
}

Filename: textured_quad/textured_quad.cpp

Also, be sure to actually sample from the texture in the 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/doc/shadersnippet.frag


Updated on 2024-12-22 at 00:01:24 +0000