All tutorials Mighty Professional
Build a Game Engine · Rendering: the 2D path (Vulkan)

Textures, Samplers & Materials

You load a PNG onto your triangle and it renders black, or shimmers as it recedes, or comes out washed out and too bright. Each has a specific cause in how Vulkan handles images. Texturing in Vulkan means a handful of new objects (a VkImage, a view, a that says how to filter it, a descriptor set that hands it to the shader) and two places people commonly trip: the image-layout transitions around the upload, and the color pipeline. This page builds all of it in C++ and Rust and makes both traps visible.

Time~55 min LevelSenior PrereqsHelpful, not required: Your First Triangle in Vulkan (device, pipeline, command buffers) and The GPU & the Graphics Pipeline (mip selection from derivatives). StackC++ (Vulkan) · Rust (ash)
◂ Build a Game Engine Phase 5 · Rendering: the 2D path (Vulkan) Next · The 2D Renderer ▸
What you'll be able to do by the end

01A texture is a VkImage

A bilinear texture read fetches a 2×2 block of neighboring texels for every pixel, so the GPU wants texels that are close on screen to be close in memory. That's why a texture is a VkImage, not a buffer. A buffer is a flat byte range; an image has a format, a tiling (how texels are arranged in memory), and a layout (a state the driver moves it between to optimize different accesses). The texture units sample fastest from an implementation-private swizzle, often a tiled or Morton (Z-order) arrangement that keeps texels close in memory when they're close in both x and y. You don't control or see it; that's optimal tiling[1].

Worked example

Take a 1024-wide RGBA8 texture, 4 bytes per texel. Stored row by row, a 4×4 block of texels spans from byte 0 to byte (3 × 1024 + 3) × 4 + 4 = 12,304: four separate 16-byte pieces, 4 KiB apart. In Morton order, the bits of x and y are interleaved into one index, so every aligned 4×4 block is 16 consecutive texels: one contiguous 64-byte run. The texture unit's 2×2 fetches then mostly hit memory it has already pulled in.

Tiling is not layout, and creation is not allocation

Two pairs people blur. Tiling (OPTIMAL vs LINEAR) is the memory arrangement; layout (VkImageLayout) is a usage-state for synchronization. And creating a VkImage gives you a handle with memory requirements; you then allocate and bind memory separately, two steps that VMA fuses into one call[2]. Don't call vkAllocateMemory once per texture: maxMemoryAllocationCount is finite (the spec only guarantees 4096[15]), so a level with 5,000 textures and one allocation each can fail on a conforming driver. That's exactly why sub-allocators like VMA exist: they allocate a few large blocks and hand out pieces of them.

02Upload & layout transitions

You have the decoded pixels in a CPU array and need them in the image. An optimal-tiled device image isn't in a layout you can memcpy into, and its memory usually isn't host-visible, meaning the CPU can't map and write it directly. So you upload through a staging buffer: copy pixels into a host-visible buffer, then vkCmdCopyBufferToImage into the image. Around that copy go two layout transitions[1], and getting them wrong is a common source of texturing validation errors.

Worked example

A 512 × 256 RGBA8 texture is 512 × 256 × 4 = 524,288 bytes. You map a 524,288-byte staging buffer and memcpy the pixels in. With bufferRowLength = 0, the copy assumes tightly packed rows, 512 × 4 = 2,048 bytes apart. If your image loader pads each row to 4,096 bytes, set bufferRowLength = 1024. The field counts texels, and 1024 texels × 4 bytes is the 4,096-byte pitch. Then barrier 1, the copy, barrier 2, and the image is ready to sample.

The widget steps the image through its layouts:

The staging buffer is on the left, the image on the right, and the label under the image is its current layout. Press Play upload (one step a second), then tick "skip the shader-read transition" and play it again. The image steps UNDEFINED → TRANSFER_DST_OPTIMAL (barrier), receives the buffer copy, then → SHADER_READ_ONLY_OPTIMAL (barrier) before the shader samples it. Skip that last barrier and steps 4 and 5 turn red, and the readout prints the validation error: the image is still in TRANSFER_DST_OPTIMAL while the descriptor promises SHADER_READ_ONLY_OPTIMAL. Validation reports the mismatch at submit; without validation the result is undefined, and it may even look fine on one GPU and break on another.
Create the image, stage the upload, transition twice
VkImageCreateInfo info{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
info.imageType = VK_IMAGE_TYPE_2D;
info.format = VK_FORMAT_R8G8B8A8_SRGB;                 // color texture -> sRGB (see §7)
info.extent = { width, height, 1 }; info.mipLevels = 1; info.arrayLayers = 1;
info.samples = VK_SAMPLE_COUNT_1_BIT;
info.tiling = VK_IMAGE_TILING_OPTIMAL;                 // opaque GPU layout
info.usage  = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;        // only UNDEFINED/PREINITIALIZED are legal here
// real code: vmaCreateImage(allocator, &info, &allocationInfo, &image, &allocation, nullptr);

VkImageMemoryBarrier barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER };
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;     // no queue-family ownership transfer
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.image = image;
barrier.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };   // mip 0, layer 0

// Barrier 1: UNDEFINED -> TRANSFER_DST_OPTIMAL (before the copy)
barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;            // "don't care": old contents are discarded
barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.srcAccessMask = 0; barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0,0,nullptr,0,nullptr,1,&barrier);

VkBufferImageCopy region{};                               // bufferRowLength 0 = tightly packed rows
region.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 };
region.imageExtent = { width, height, 1 };
vkCmdCopyBufferToImage(cmd, stagingBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region);

// Barrier 2: TRANSFER_DST_OPTIMAL -> SHADER_READ_ONLY_OPTIMAL (before sampling)
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0,0,nullptr,0,nullptr,1,&barrier);
let info = vk::ImageCreateInfo::default()
    .image_type(vk::ImageType::TYPE_2D)
    .format(vk::Format::R8G8B8A8_SRGB)             // color texture -> sRGB (see §7)
    .extent(vk::Extent3D { width, height, depth: 1 })
    .mip_levels(1).array_layers(1).samples(vk::SampleCountFlags::TYPE_1)
    .tiling(vk::ImageTiling::OPTIMAL)
    .usage(vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::SAMPLED)
    .initial_layout(vk::ImageLayout::UNDEFINED);     // only UNDEFINED/PREINITIALIZED legal
// real code: gpu-allocator or VMA owns create+bind

let color_range = vk::ImageSubresourceRange::default()
    .aspect_mask(vk::ImageAspectFlags::COLOR).level_count(1).layer_count(1);   // mip 0, layer 0
let to_transfer = vk::ImageMemoryBarrier::default()
    .old_layout(vk::ImageLayout::UNDEFINED)                // "don't care": old contents are discarded
    .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
    .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)      // no ownership transfer
    .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
    .image(image).subresource_range(color_range)
    .src_access_mask(vk::AccessFlags::empty()).dst_access_mask(vk::AccessFlags::TRANSFER_WRITE);
unsafe { device.cmd_pipeline_barrier(cmd, vk::PipelineStageFlags::TOP_OF_PIPE, vk::PipelineStageFlags::TRANSFER, vk::DependencyFlags::empty(), &[], &[], &[to_transfer]); }

let region = vk::BufferImageCopy::default()                 // buffer_row_length 0 = tightly packed rows
    .image_subresource(vk::ImageSubresourceLayers::default().aspect_mask(vk::ImageAspectFlags::COLOR).layer_count(1))
    .image_extent(vk::Extent3D { width, height, depth: 1 });
unsafe { device.cmd_copy_buffer_to_image(cmd, staging_buffer, image, vk::ImageLayout::TRANSFER_DST_OPTIMAL, &[region]); }

let to_shader = vk::ImageMemoryBarrier::default()
    .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
    .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
    .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
    .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
    .image(image).subresource_range(color_range)
    .src_access_mask(vk::AccessFlags::TRANSFER_WRITE).dst_access_mask(vk::AccessFlags::SHADER_READ);
unsafe { device.cmd_pipeline_barrier(cmd, vk::PipelineStageFlags::TRANSFER, vk::PipelineStageFlags::FRAGMENT_SHADER, vk::DependencyFlags::empty(), &[], &[], &[to_shader]); }
What's intentionally missing

A synchronous one-shot command buffer with vkQueueWaitIdle stands in for a transfer queue with fences; no upload batching; mip generation is its own section (§4); no queue-family ownership transfer (needed if a dedicated transfer queue does the copy). The masks shown are the synchronization-1 form. New Vulkan 1.3 code can use synchronization-2 instead (vkCmdPipelineBarrier2, after enabling the synchronization2 feature in VkPhysicalDeviceVulkan13Features): stage and access masks move into each barrier struct, and the first barrier's source stage becomes VK_PIPELINE_STAGE_2_NONE instead of the TOP_OF_PIPE shown here.

03Samplers

The same brick texture can look crisp up close, blocky when magnified, or blurry in the distance, depending only on how it's read. Those rules live in a VkSampler, a separate object from the image that can be applied to any image. The Khronos tutorial is explicit that "this is different from many older APIs, which combined texture images and filtering into a single state"[3]. It holds the filtering and addressing rules: min/mag filter (nearest texel, or a linear blend of the nearest ones, for shrunk and enlarged textures), the mipmap mode, the wrap modes (what happens outside 0..1: repeat, clamp, mirror), and , extra samples along the direction a surface is stretched.

Worked example

One row of four texels holds the values 0, 100, 200 and 50, and a pixel samples at texel coordinate 1.75. Texel centers sit at 0.5, 1.5, 2.5 and 3.5. Nearest filtering takes texel 1 and returns 100. Linear filtering blends the two centers either side, 1.5 and 2.5, weighted by distance: 100 × 0.75 + 200 × 0.25 = 125. Bilinear does that in x and in y, across 4 texels. Now say a floor pixel's footprint is 1 texel wide and 8 texels long. Trilinear has to pick one mip for both directions, so it sizes for the long side, mip 3, and blurs the short side 8 times too much. 8× anisotropic picks mip 0 for the short side and takes 8 samples along the long side.

Anisotropy is an opt-in feature with a hard cap

Two validation rules bite here. samplerAnisotropy is an optional device feature you must enable at device creation (go back and turn it on); if it isn't enabled, anisotropyEnable must be false. And if it is on, maxAnisotropy must be clamped to maxSamplerAnisotropy from the device limits: hard-coding 16.0 is invalid on hardware that reports less. Also: "trilinear" is minFilter = LINEAR and mipmapMode = LINEAR. With mipmapMode = NEAREST you see bands where the mip level switches; with minFilter = NEAREST each level stays blocky.

The filtering widget samples a checker texture on a receding plane with a real software sampler (mip chain, bilinear and trilinear blends, 8× anisotropic probes), rendered at low resolution so each sample is visible:

The checkered floor runs to the horizon, and the label at the bottom names the current mode. Cycle the four modes, then untick mipmaps. The plane scrolls so aliasing moves (a still frame hides shimmer). With mips off, every mode shimmers and moirés in the distance. With mips, nearest and bilinear (both mipmapMode = NEAREST) show bands where the level switches; trilinear blends across them but goes gray too early at grazing angles, because it sizes one isotropic footprint for the pixel's longer axis and overblurs the shorter one. Anisotropic filtering picks the level from the shorter axis and spreads several probes along the longer one, so distant squares stay resolved.

04Mipmaps

A distant brick wall sparkles and crawls as the camera moves, because each screen pixel covers dozens of texels and a single sample picks one of them almost at random. are prefiltered half-size copies that fight this minification aliasing: when a surface recedes, one screen pixel covers many texels, and without a prefiltered level you get shimmer. The hardware picks the level from the texture-coordinate derivatives across the 2×2 quad (the GPU Pipeline tutorial's mip selection), and the sampler blends within and between levels. A full chain has floor(log2(max(w,h))) + 1 levels[5]: one for each halving of the longer side, plus the original.

Worked example

A 1024 × 512 texture gets floor(log₂ 1024) + 1 = 11 levels: 1024 × 512, 512 × 256, and so on down to 2 × 1 and 1 × 1, with the short side stopping at 1. In RGBA8 the base level is 2,097,152 bytes and the whole chain is 2,796,204 bytes, a third more. Each level is a quarter of the one above, so the extra always comes to about a third.

Generate offline, or watch the linear-blit gate

Two ways to get mips. Most production assets ship with mips baked into the file (KTX2/DDS); the Khronos tutorial notes runtime generation is "uncommon in practice"[5]. If you do generate them at runtime with a blit (vkCmdBlitImage) and a linear filter, the format's optimal-tiling features must include BLIT_SRC_BIT, BLIT_DST_BIT, and SAMPLED_IMAGE_FILTER_LINEAR_BIT, queried with vkGetPhysicalDeviceFormatProperties[6]. Block-compressed (BCn) textures fail that check: the textureCompressionBC feature guarantees sampling, linear filtering, and blit source, but not blit destination, since a blit can't encode compressed blocks[6]. So BCn textures ship with mips precomputed (cross-ref the Compression tutorial's BCn)[12]. Blit generation also needs TRANSFER_SRC usage on the image and must run on a graphics-capable queue.

05Sampling in the shader

All of that machinery is invisible from the shader. In the fragment shader, one call does filtering, mip selection, and addressing. The UV (texture coordinate, 0 to 1 across the image) comes interpolated from the vertices:

The fragment shader (GLSL)
#version 450
layout(location = 0) in  vec2 fragTexCoord;
layout(set = 0, binding = 1) uniform sampler2D texSampler;  // the combined image sampler
layout(location = 0) out vec4 outColor;
void main() {
    outColor = texture(texSampler, fragTexCoord);    // filter + mip + wrap in one call
}

The binding = 1 here must match the descriptor set layout binding number, and the set = 0 must match the set you bind at draw. Note: the Khronos tutorial now writes its shaders in Slang (with GLSL versions alongside); GLSL compiled with glslc is equally valid SPIR-V input. Don't sample inside divergent control flow if you rely on implicit-derivative mip selection (use textureLod/textureGrad there).

Worked example

On a 1024 × 512 texture, texture(texSampler, vec2(0.25, 0.75)) reads around texel (0.25 × 1024, 0.75 × 512) = (256, 384). A UV of (1.25, 0.75) reads the same place with a REPEAT wrap mode, because only the fractional part counts, and reads the last column, x = 1023, with CLAMP_TO_EDGE.

06Descriptor sets

The shader says set = 0, binding = 1, and something has to connect that slot to your image and sampler. A descriptor is that connection: a small GPU-side record pointing a shader binding at a resource. For a texture you use a combined image sampler descriptor, and the wiring is four steps plus the bind: a set layout, a pool, an allocated set written to point at your image+sampler, then bound at draw against the pipeline layout from the Vulkan triangle[7].

Layout → pool → allocate → write → bind (combined image sampler)
// 1. layout: one binding, matching "set = 0, binding = 1" in the shader
VkDescriptorSetLayoutBinding binding{};
binding.binding = 1; binding.descriptorCount = 1;
binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
VkDescriptorSetLayoutCreateInfo layoutInfo{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO };
layoutInfo.bindingCount = 1; layoutInfo.pBindings = &binding;
vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &setLayout);
// 2. pool: size it for the descriptor type AND the set count; overrunning either
//    can fail on one driver and quietly succeed on another
VkDescriptorPoolSize poolSizes[] = { { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, MAX_FRAMES } };
VkDescriptorPoolCreateInfo poolInfo{ VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO };
poolInfo.maxSets = MAX_FRAMES; poolInfo.poolSizeCount = 1; poolInfo.pPoolSizes = poolSizes;
vkCreateDescriptorPool(device, &poolInfo, nullptr, &pool);
// 3. allocate a set (one per frame in flight; one shown), then write it: the view,
//    the sampler, and the layout the image will be in at draw time
VkDescriptorSetAllocateInfo allocInfo{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO };
allocInfo.descriptorPool = pool; allocInfo.descriptorSetCount = 1; allocInfo.pSetLayouts = &setLayout;
vkAllocateDescriptorSets(device, &allocInfo, &set);
VkDescriptorImageInfo imageInfo{};
imageInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
imageInfo.imageView = textureImageView; imageInfo.sampler = textureSampler;
VkWriteDescriptorSet write{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET };
write.dstSet = set; write.dstBinding = 1; write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; write.pImageInfo = &imageInfo;
vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
// 4. bind at draw, against a pipeline layout built from setLayout
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &set, 0, nullptr);
// 1. layout: one binding, matching "set = 0, binding = 1" in the shader
let bindings = [vk::DescriptorSetLayoutBinding::default()
    .binding(1).descriptor_count(1)
    .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
    .stage_flags(vk::ShaderStageFlags::FRAGMENT)];
let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
let set_layout = unsafe { device.create_descriptor_set_layout(&layout_info, None)? };
// 2. pool: size it for the descriptor type AND the set count
let pool_sizes = [vk::DescriptorPoolSize::default()
    .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER).descriptor_count(MAX_FRAMES)];
let pool_info = vk::DescriptorPoolCreateInfo::default().max_sets(MAX_FRAMES).pool_sizes(&pool_sizes);
let pool = unsafe { device.create_descriptor_pool(&pool_info, None)? };
// 3. allocate a set (one per frame in flight; one shown), then write it
let set_layouts = [set_layout];
let alloc_info = vk::DescriptorSetAllocateInfo::default().descriptor_pool(pool).set_layouts(&set_layouts);
let set = unsafe { device.allocate_descriptor_sets(&alloc_info)? }[0];
let image_info = [vk::DescriptorImageInfo::default()
    .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)   // the layout at draw time
    .image_view(texture_image_view).sampler(texture_sampler)];
let write = vk::WriteDescriptorSet::default()
    .dst_set(set).dst_binding(1)
    .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
    .image_info(&image_info);            // descriptor_count inferred from the slice
unsafe { device.update_descriptor_sets(&[write], &[]); }
// 4. bind at draw, against a pipeline layout built from set_layout
unsafe { device.cmd_bind_descriptor_sets(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline_layout, 0, &[set], &[]); }
Two descriptor traps

Pool sizing is validation-silent. A material with three textures, one set per frame in flight, and 100 materials needs a pool with room for 100 × 2 = 200 sets and 200 × 3 = 600 combined image samplers. If the pool lacks enough descriptors of the type or enough maxSets, allocation may fail with VK_ERROR_OUT_OF_POOL_MEMORY, or the driver may quietly make room, so the same code can pass on one GPU and fail on another. The tutorial calls inadequate pools "a problem that the validation layers will not catch"[7]. In ash, the image_info setter also fills in descriptor_count from the slice length[14]. Don't update a set the GPU is using (VUID-vkUpdateDescriptorSets-None-03047): update before use, keep one set per frame-in-flight, or opt into update-after-bind[9]. A combined image sampler packs image+sampler in one descriptor; the separate SAMPLED_IMAGE + SAMPLER form "can be faster as there is less duplicated data," per vkguide citing GPU vendors, and is common in bindless setups, but it's more bookkeeping[8].

07sRGB vs linear

A texture blended halfway between black and white should look like mid-gray, and done naively it comes out too dark. The cause is that image files don't store light intensity directly. sRGB is a nonlinear transfer function, a curve from stored value to brightness (roughly gamma 2.2), used to store color with more of its precision in the dark tones. Sample a view with an sRGB format and the texture unit decodes sRGB→linear automatically, before filtering, so your shader gets linear values. Do lighting in linear. A swapchain in an sRGB format encodes linear→sRGB on write[10]. Get this right and every blend, filter, and lighting sum runs on linear values.

Worked example

The stored sRGB value 128 out of 255 decodes to a linear intensity of 0.216, not 0.5. So averaging a black texel (0) and a white texel (255) as stored numbers gives 128, which displays at about a fifth of white's intensity. Decode first and the average is 0.5 linear, which encodes back to sRGB 188. That's why the hardware decodes before it filters. The double-correction bug runs the other way: the widget below encodes values a second time with pow(v, 1/2.2), which turns the ramp's middle value 128 into 186 and the sphere's darkest color, 20, into 80.

The double-correction bug, and which textures are sRGB

Render to an sRGB swapchain and apply a manual pow(color, 1/2.2) in the shader and you encode twice: washed-out, over-bright color. (The mirror bug, decoding an sRGB texture by hand after the sRGB format already did, darkens instead.) Pick the format path or manual, never both. And not all textures are sRGB: color/albedo textures are sRGB, but data textures (normal maps, roughness/metallic, masks) must be UNORM (linear): decoding them through sRGB corrupts the values[11]. Because the hardware decodes before filtering, your bilinear and mip blends happen in linear space for free, which is the correct order.

The widget shows the same ramp and sphere encoded once versus twice:

The top bar is a black-to-white ramp and the ball is lit by a moving light. Switch between the two buttons. Correct: the sRGB texture format decodes on read and the sRGB swapchain encodes on write, so the ramp and the sphere's falloff look right. Add a manual pow(color, 1/2.2) on top and every value is encoded a second time: midtones jump up, the dark side lifts toward gray, and the image looks washed out. The fix is to delete one of the two encodes.

08Bindless

With hundreds of materials, binding a different descriptor set before every draw adds up to a lot of CPU work and state changes. In Vulkan, "bindless" usually means descriptor indexing (VK_EXT_descriptor_indexing, promoted to core in Vulkan 1.2 with its features still optional). Instead of rebinding a descriptor set per material, you bind one big array of texture descriptors once and index it in the shader with a per-draw integer[13]. With 500 materials, that's one bind for the frame instead of up to 500, and each draw passes its material's slot, say 37, so the shader reads textures[37].

GPU-driven renderers, where the GPU itself builds the draw list, build on it. The features to query and enable are runtimeDescriptorArray (unsized arrays in the shader), descriptorBindingPartiallyBound and descriptorBindingSampledImageUpdateAfterBind (which let you use the PARTIALLY_BOUND and UPDATE_AFTER_BIND binding flags), and shaderSampledImageArrayNonUniformIndexing for the nonuniformEXT(...) qualifier. Without that qualifier, an index that differs across the invocations of a draw is undefined behavior per the Vulkan spec, not just slow. One scoping note: this is descriptor indexing, which is distinct from the newer VK_EXT_descriptor_buffer; don't conflate them.

Wrong answers, and why: anisotropy and pool errors surface earlier and aren't layout complaints; albedo is correctly sRGB (data textures are the UNORM ones); and mips change aliasing, not brightness.

09Pitfalls

Sampling a wrong-layout imageForgot the → SHADER_READ_ONLY barrier. The classic texturing validation error.
Washed-out colorDouble sRGB: an sRGB swapchain plus a manual pow(1/2.2). Delete one encode.
Normal map looks wrongData textures must be UNORM, not sRGB. Only color/albedo is sRGB.
Anisotropy errors on some GPUsEnable the device feature and clamp maxAnisotropy to maxSamplerAnisotropy.
Bands where the mip level switchesmipmapMode NEAREST. Trilinear needs minFilter LINEAR and mipmapMode LINEAR, both.
Silent pool failureUndersized descriptor pool fails allocation; validation won't warn. Size per type and set.
Updating a set in useVUID-03047: update before use or one set per frame-in-flight.
vkAllocateMemory per texturemaxMemoryAllocationCount is finite; sub-allocate with VMA / gpu-allocator.

10What's next

The triangle is textured and the descriptor machinery is in place. Next, The 2D Renderer uses all of this to batch thousands of sprites from a texture atlas into a handful of draw calls, the last piece before the 2D-game capstone. The full path is on the series hub.

  1. Khronos. Vulkan Tutorial, "Images." docs.vulkan.org. VkImageCreateInfo, optimal-vs-linear tiling, the staging rationale, and the two layout-transition barriers with exact stage/access masks.
  2. AMD GPUOpen. Vulkan Memory Allocator. gpuopen.com. vmaCreateImage fusing create+allocate+bind; the recommended VMA_MEMORY_USAGE_AUTO; why per-resource allocation is the anti-pattern.
  3. Khronos. Vulkan Tutorial, "Image view and sampler." docs.vulkan.org. The sampler as a distinct object; filtering/addressing fields; anisotropy as an optional device feature capped by maxSamplerAnisotropy.
  4. The Khronos Group. Vulkan Specification, VkImageLayout. registry.khronos.org. TRANSFER_DST_OPTIMAL and SHADER_READ_ONLY_OPTIMAL definitions and required usage.
  5. Khronos. Vulkan Tutorial, "Generating Mipmaps." docs.vulkan.org. The level count, the blit loop, the linear-blit format-feature requirement, and "uncommon in practice to generate at runtime."
  6. The Khronos Group. Vulkan Specification, VkFormatFeatureFlagBits and VkPhysicalDeviceFeatures. registry.khronos.org; docs.vulkan.org. SAMPLED_IMAGE_FILTER_LINEAR_BIT and the blit feature bits; textureCompressionBC guarantees sampled, blit-source, and linear-filter support for BC formats, not blit-destination.
  7. Khronos. Vulkan Tutorial, "Combined image sampler" and "Descriptor pool." docs.vulkan.org. The layout/pool/write/bind flow and the "validation will not catch" pool-sizing warning.
  8. Victor Blanco. Vulkan Guide, "Textures" and "Descriptor Abstractions." vkguide.dev. vmaCreateImage upload, combined vs separate descriptors ("separated can be faster but harder").
  9. The Khronos Group. Vulkan Specification, vkUpdateDescriptorSets. registry.khronos.org. VUID-vkUpdateDescriptorSets-None-03047: don't update a set in pending use; external-sync requirement.
  10. The Khronos Group. Vulkan Specification, "Image Operations" (format conversion, including sRGB decode, runs before texel filtering). docs.vulkan.org. Joey de Vries. LearnOpenGL, "Gamma Correction." learnopengl.com. sRGB textures decoded on read, the sRGB framebuffer encoded on write, and gamma-correcting only once.
  11. Tomas Akenine-Möller, Eric Haines, Naty Hoffman, et al. Real-Time Rendering, 4th ed., ch. 5–6. realtimerendering.com. Minification aliasing, trilinear and anisotropic filtering, and the linear color pipeline (color sRGB, data linear).
  12. The Khronos Group / Binomial. KTX Developer Guide and Basis Universal. github.com/KhronosGroup. KTX2 carries BCn/ASTC textures with mips baked in; compressed textures ship precomputed mips.
  13. Khronos. Vulkan-Samples, "Descriptor indexing." docs.vulkan.org/samples. Bindless via VK_EXT_descriptor_indexing (core in 1.2): runtime arrays, update-after-bind, non-uniform indexing.
  14. ash (Rust). docs.rs/ash. Version 0.38: ::default() + setters, lifetime-parameterized structs, and descriptor_count inferred from the image_info slice.
  15. The Khronos Group. Vulkan Specification, "Limits" (required limits table). docs.vulkan.org. maxMemoryAllocationCount: 4096 minimum.

See also