Textures, Samplers & Materials
The triangle can now wear an image. Texturing in Vulkan means a handful of new objects (a VkImage, a view, a , a descriptor set) and two places everyone trips: the image-layout transitions around the upload, and the color pipeline. We build all of it in C++ and Rust, and make both traps visible.
01A texture is a VkImage
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, typically a space-filling-curve order so a 2×2 quad's four texels sit close together, which you don't control or see (optimal tiling)[1].
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 (historically ~4096), which is exactly why sub-allocators like VMA exist.
02Upload & layout transitions
An optimal-tiled device image isn't in a layout you can memcpy into (and usually isn't host-visible). 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, and getting them wrong is the single most common texturing validation error[1].
UNDEFINED → TRANSFER_DST_OPTIMALbefore the copy. Transitioning fromUNDEFINEDis intentional: it discards old contents, which is fine because you're about to overwrite everything.TRANSFER_DST_OPTIMAL → SHADER_READ_ONLY_OPTIMALbefore sampling. Sampling an image still inTRANSFER_DSTorUNDEFINEDis the classic error:SHADER_READ_ONLY_OPTIMALis the only correct layout to sample from[4].
Watch the image move through the layouts. Toggle off the second transition to see the validation error you'd hit:
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, &allocCI, &image, &alloc, nullptr);
// Barrier 1: UNDEFINED -> TRANSFER_DST_OPTIMAL (before the copy)
barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
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);
vkCmdCopyBufferToImage(cmd, stagingBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion);
// 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 to_transfer = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.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]); }
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)
.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]); }
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 1.3 code can prefer synchronization-2 (vkCmdPipelineBarrier2), which folds stage and access into the barrier struct and lets you drop the TOP_OF_PIPE/BOTTOM_OF_PIPE-as-barrier-stage pattern shown here, which current best practice discourages in favor of precise stage masks.
03Samplers
A VkSampler is a separate object from the image, and it 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 or linear), the mipmap mode, the wrap modes, and .
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 fails on hardware that reports less. Also: "trilinear" is minFilter = LINEAR and mipmapMode = LINEAR; set only one and the mip seams stay.
The filtering widget runs on a receding plane, where minification artifacts actually appear. Cycle the modes and toggle mips:
04Mipmaps
are prefiltered half-size copies that fight 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].
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 must advertise SAMPLED_IMAGE_FILTER_LINEAR_BIT in its optimal tiling features, queried with vkGetPhysicalDeviceFormatProperties[6]. This matters because block-compressed (BCn) textures can't be blit-mip-generated (compressed formats don't advertise the linear-filter blit feature), so they ship with mips precomputed (cross-ref the Compression tutorial's BCn)[12]. Blit generation also needs TRANSFER_SRC usage and runs on a graphics-capable queue.
05Sampling in the shader
In the fragment shader, one call does filtering, mip selection, and addressing. The UV (texture coordinate) comes interpolated from the vertices:
#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 has migrated its shaders to Slang, but GLSL is fully valid and is what most ash projects still use. Don't sample inside divergent control flow if you rely on implicit-derivative mip selection (use textureLod/textureGrad there).
06Descriptor sets
A descriptor is how a shader reaches 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].
// 1. layout binding (matches "set=0, binding=1" in the shader)
VkDescriptorSetLayoutBinding b{};
b.binding = 1; b.descriptorCount = 1;
b.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
b.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
// 2. pool: SIZE IT for the type AND the set count or allocation fails (validation won't warn)
VkDescriptorPoolSize ps{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, MAX_FRAMES };
// 3. write: point the set at the view + sampler, with the layout it'll be in at draw
VkDescriptorImageInfo ii{};
ii.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
ii.imageView = textureImageView; ii.sampler = textureSampler;
VkWriteDescriptorSet w{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET };
w.dstSet = set; w.dstBinding = 1; w.descriptorCount = 1;
w.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; w.pImageInfo = ⅈ
vkUpdateDescriptorSets(device, 1, &w, 0, nullptr);
// 4. bind at draw
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &set, 0, nullptr);
let binding = vk::DescriptorSetLayoutBinding::default()
.binding(1).descriptor_count(1)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.stage_flags(vk::ShaderStageFlags::FRAGMENT);
let pool_sizes = [vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER).descriptor_count(MAX_FRAMES)]; // size it!
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; don't also set it
unsafe { device.update_descriptor_sets(&[write], &[]); }
unsafe { device.cmd_bind_descriptor_sets(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline_layout, 0, &[set], &[]); }
Pool sizing is validation-silent. If the pool lacks enough descriptors of the type or enough maxSets, allocation fails with OUT_OF_POOL_MEMORY, and the tutorial warns this is "a problem that the validation layers will not catch"[7]. 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 and is what bindless setups use, but it's more bookkeeping[8].
07sRGB vs linear
sRGB is a nonlinear transfer function (roughly gamma 2.2) used to store color. 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 the whole pipeline is linear end to end.
Use an sRGB format and a manual pow(color, 1/2.2) and you encode twice: washed-out, over-bright color. 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 image sampled correctly vs double-corrected:
08Bindless
"Bindless" is VK_EXT_descriptor_indexing, core in Vulkan 1.2. 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].
It's where modern, GPU-driven renderers go, and the 2D renderer and later material work will lean on it. The enabling features are descriptorIndexing, runtimeDescriptorArray (unsized arrays in the shader), UPDATE_AFTER_BIND and PARTIALLY_BOUND, plus non-uniform indexing via the nonuniformEXT(...) qualifier (without it, a divergent index is undefined per the Vulkan spec, not just slow). One scoping note: this is descriptor indexing, which is distinct from the newer, separate 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
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.
- 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. - AMD GPUOpen. Vulkan Memory Allocator. gpuopen.com.
vmaCreateImagefusing create+allocate+bind; the recommendedVMA_MEMORY_USAGE_AUTO; why per-resource allocation is the anti-pattern. - 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. - The Khronos Group. Vulkan Specification,
VkImageLayout. registry.khronos.org.TRANSFER_DST_OPTIMALandSHADER_READ_ONLY_OPTIMALdefinitions and required usage. - 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."
- The Khronos Group. Vulkan Specification,
VkFormatFeatureFlagBits. registry.khronos.org.SAMPLED_IMAGE_FILTER_LINEAR_BITand the blit feature bits. - 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.
- Victor Blanco. Vulkan Guide, "Textures" and "Descriptor Abstractions." vkguide.dev.
vmaCreateImageupload, combined vs separate descriptors ("separated can be faster but harder"). - The Khronos Group. Vulkan Specification,
vkUpdateDescriptorSets. registry.khronos.org.VUID-vkUpdateDescriptorSets-None-03047: don't update a set in pending use; external-sync requirement. - The Khronos Group / Joey de Vries. OpenGL Wiki "Image Format (sRGB)" and LearnOpenGL "Gamma Correction." learnopengl.com. Hardware sRGB→linear on read (before filtering) and linear→sRGB on write; the double-correction pitfall.
- 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).
- 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.
- 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. - ash (Rust). docs.rs/ash. Version 0.38:
::default()+ setters, lifetime-parameterized structs, anddescriptor_countinferred from theimage_infoslice.