Skip to content

Textures

Sample 05

Samples/Textures ยท builds on basic rendering.

This sample draws a textured quad. It covers loading an image, uploading it to the GPU, generating mip maps and sampling it in the shader.

Texture coordinates

The shader needs to know where on the image each vertex sits, which is what texture coordinates describe. The Vertex structure already has them, so only the input assembler gains another attribute:

.withAttribute(2, BufferFormat::XY32F, offsetof(Vertex, TextureCoordinate0), AttributeSemantic::TextureCoordinate, 0)

In the fragment shader, the texture and its sampler are two separate bindings:

ConstantBuffer<CameraData> camera : register(b0, space0);
Texture2D diffuseMap              : register(t1, space0);
SamplerState diffuse              : register(s2, space0);

FragmentData PSMain(VertexData input)
{
    FragmentData fragment;
    fragment.Depth = input.Position.z;
    fragment.Color = diffuseMap.Sample(diffuse, input.TextureCoordinate) * input.Color;
    return fragment;
}

The sampler describes how the image is read: how it is filtered between pixels and mip levels, and what happens outside its bounds.

Creating the texture

The image file is loaded with stb_image, and the texture is created with six mip levels:

auto imageData = ImageDataPtr(::stbi_load("assets/logo_quad.tga", &width, &height, &channels, STBI_rgb_alpha), ::stbi_image_free);

if (imageData == nullptr)
    throw std::runtime_error("Texture could not be loaded: \"assets/logo_quad.tga\".");

texture = device.factory().createTexture("Texture", Format::R8G8B8A8_UNORM, Size2d(width, height), ImageDimensions::DIM_2, 6, 1,
    MultiSamplingLevel::x1, ResourceUsage::AllowWrite | ResourceUsage::TransferDestination | ResourceUsage::TransferSource);

The usage flags say what the texture is used for: it is written by the mip map generation (AllowWrite), and it is both the destination of the upload and the source when the mip levels are generated from each other.

Barriers and layouts

Unlike buffers, images have a layout that describes how the GPU currently stores them, and each layout only allows certain kinds of access. LiteFX keeps this manageable with two rules (see the default state in the synchronization guide):

  • A newly created image has no defined layout. Treat it as if it were in ImageLayout::Undefined, so its first transition starts from there.
  • An image that is not currently in use is in ImageLayout::Common. Work that needs a different layout transitions the image there and back into Common when it is done.

Common already allows copies and sampling, so the texture can stay in it for its whole life. Only the first transition is needed, which takes the new texture out of Undefined. A barrier performs that transition, and at the same time orders the commands around it:

UniquePtr<TBarrier> barrier = device.buildBarrier()
    .waitFor(PipelineStage::None)
    .toContinueWith(PipelineStage::Transfer)
    .blockAccessTo(*texture, ResourceAccess::TransferWrite)
    .transitionLayout(ImageLayout::Undefined, ImageLayout::Common)
    .whenFinishedWith(ResourceAccess::None);

commandBuffer->barrier(*barrier);
commandBuffer->transfer(imageData.get(), texture->size(0), *texture);

Read as a sentence: wait for nothing, continue with transfer commands, and block their write access to the texture until it has been transitioned from Undefined into Common. Nothing has to be waited for, because the texture is used for the first time in this command buffer, and a submission only starts after the previous one on the same queue has finished.

After the upload, the smaller mip levels are generated from the largest one. The engine provides a Blitter for this, which records the necessary dispatches and barriers. Like everything the engine does, it expects the image in Common and leaves it there:

auto blitter = Blitter<TBackend>::create(device);
blitter->generateMipMaps(*texture, *commandBuffer);

Mip map generation is a compute workload in DirectX, so the command buffer is created on the graphics queue here, not on the transfer queue. The synchronization guide covers this case in more detail under textures with mip maps.

The sampler

The sampler is created separately from the texture, and the same sampler can be used with many textures:

sampler = device.factory().createSampler("Sampler", FilterMode::Linear, FilterMode::Linear,
    BorderMode::Repeat, BorderMode::Repeat, BorderMode::Repeat, MipMapMode::Linear,
    0.f, std::numeric_limits<Float>::max(), 0.f, 16.f);

Linear filtering interpolates between neighboring pixels and mip levels, Repeat tiles the image outside its bounds, and the last argument enables anisotropic filtering, which keeps steeply viewed surfaces sharp.

Texture and sampler share the descriptor set with the camera, since all three are written once and then stay unchanged:

auto staticBindings = staticBindingLayout.allocate({ { 0, *cameraBuffer }, { 1, *texture }, { 2, *sampler } });