RT Graphics Programming
Lesson 16 of 28

Part 2 · Vulkan implementation

16. An Image Is Not Its Memory

Create the storage image, obtain its requirements and bind memory before asking a shader to write a pixel.

Graphics glossary 28 terms mentioned in this lesson

Select any term for a clear definition. The return button brings you back to the exact term link you used.

An image is easy to imagine as a rectangular allocation of colour values. Vulkan separates that useful mental picture into several objects and queries. VkImage describes format, extent, usage, tiling and related properties. VkDeviceMemory supplies storage compatible with requirements reported after image creation. VkImageView describes how a range of that image will be interpreted when a shader or another operation accesses it.

Treating these as ceremony produces fragile code. A multiplication such as width times height times four can account for the minimum visible RGBA8 texel bytes, but it cannot replace vkGetImageMemoryRequirements. The implementation may require a larger size, a particular alignment and only some memory types. Keep the simple byte account because it explains the image; use the Vulkan query because it decides the allocation.

What you should be able to account for

  • Create an off-screen storage image with a format and usage that the selected physical device supports.
  • Obtain size, alignment and memory-type requirements before allocating and binding device memory.
  • Create an image view and explain why the shader receives the view rather than raw device memory.

Use an intermediate storage image

The compute shader will write an off-screen image. We do not assume that every swapchain image supports storage-image use. The intermediate image requests storage use for shader writes and transfer-source use so Lesson 18 can copy it towards presentation. This makes the resource contract explicit and keeps presentation capability separate from the ray-tracing output format.

VkFormat outputFormat = VK_FORMAT_R8G8B8A8_UNORM;

VkFormatProperties formatProperties{};
vkGetPhysicalDeviceFormatProperties(
    physicalDevice,
    outputFormat,
    &formatProperties);

if ((formatProperties.optimalTilingFeatures &
     VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT) == 0) {
    throw std::runtime_error("Selected storage format is unsupported");
}

VkImageCreateInfo imageInfo{
    .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
    .imageType = VK_IMAGE_TYPE_2D,
    .format = outputFormat,
    .extent = { width, height, 1 },
    .mipLevels = 1,
    .arrayLayers = 1,
    .samples = VK_SAMPLE_COUNT_1_BIT,
    .tiling = VK_IMAGE_TILING_OPTIMAL,
    .usage = VK_IMAGE_USAGE_STORAGE_BIT |
             VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
    .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
    .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED
};

VkImage outputImage = VK_NULL_HANDLE;
check(vkCreateImage(device, &imageInfo, nullptr, &outputImage));

VK_IMAGE_LAYOUT_UNDEFINED permits the previous contents to be discarded. It does not mean the shader may immediately write the image. Lesson 18 records a transition to VK_IMAGE_LAYOUT_GENERAL with the execution and memory dependency appropriate to the first use. Creation describes the resource; a command changes its device-use state.

Ask the image what memory it requires

Once the image exists, Vulkan reports its memory requirements. The size is the allocation range needed for binding. The alignment constrains the binding offset. memoryTypeBits identifies which physical-device memory types are compatible with this resource.

VkMemoryRequirements requirements{};
vkGetImageMemoryRequirements(device, outputImage, &requirements);

uint32_t findMemoryType(
    uint32_t allowedBits,
    VkMemoryPropertyFlags requiredProperties)
{
    VkPhysicalDeviceMemoryProperties memoryProperties{};
    vkGetPhysicalDeviceMemoryProperties(
        physicalDevice,
        &memoryProperties);

    for (uint32_t index = 0;
         index < memoryProperties.memoryTypeCount;
         ++index) {
        const bool allowed = (allowedBits & (1u << index)) != 0;
        const bool hasProperties =
            (memoryProperties.memoryTypes[index].propertyFlags &
             requiredProperties) == requiredProperties;

        if (allowed && hasProperties) {
            return index;
        }
    }
    throw std::runtime_error("No compatible memory type");
}

For this device-written image, require device-local memory. Do not also require host visibility merely because it would make debugging convenient. An implementation may expose a type with both properties, but the renderer must not eliminate suitable discrete devices by demanding a combination it does not actually need. Host-visible staging resources will have a different criterion.

Allocate and bind are separate operations

const uint32_t memoryTypeIndex = findMemoryType(
    requirements.memoryTypeBits,
    VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);

VkMemoryAllocateInfo allocationInfo{
    .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
    .allocationSize = requirements.size,
    .memoryTypeIndex = memoryTypeIndex
};

VkDeviceMemory outputMemory = VK_NULL_HANDLE;
check(vkAllocateMemory(
    device,
    &allocationInfo,
    nullptr,
    &outputMemory));

check(vkBindImageMemory(device, outputImage, outputMemory, 0));

The zero binding offset satisfies the alignment requirement because zero is aligned to every positive alignment. A production allocator will normally suballocate many resources from larger blocks to avoid excessive device allocations. The direct allocation here exposes the relationship before an allocator represents it on our behalf. We shall not confuse a teaching allocation strategy with a recommendation to allocate every final resource separately.

Create the shader's interpretation

A descriptor does not bind VkDeviceMemory to a shader. It references an image view. The view selects a format and subresource range from the already bound image. Our image has one colour mip level and one array layer, so the view remains deliberately simple.

VkImageViewCreateInfo viewInfo{
    .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
    .image = outputImage,
    .viewType = VK_IMAGE_VIEW_TYPE_2D,
    .format = outputFormat,
    .components = {
        VK_COMPONENT_SWIZZLE_IDENTITY,
        VK_COMPONENT_SWIZZLE_IDENTITY,
        VK_COMPONENT_SWIZZLE_IDENTITY,
        VK_COMPONENT_SWIZZLE_IDENTITY
    },
    .subresourceRange = {
        .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
        .baseMipLevel = 0,
        .levelCount = 1,
        .baseArrayLayer = 0,
        .layerCount = 1
    }
};

VkImageView outputView = VK_NULL_HANDLE;
check(vkCreateImageView(device, &viewInfo, nullptr, &outputView));

The format appears in both image and view because the view is a typed interpretation. Compatible reinterpretation rules exist, but this lesson gains nothing by using them. Matching formats gives us one fewer possibility when a storage-image write produces an unexpected channel.

Account for 640 by 360 RGBA8

The image contains 230,400 texels. Four eight-bit channels require at least 921,600 bytes of texel data. Suppose Vulkan reports requirements.size = 983,040, alignment = 256 and memory types 1, 3 and 5 as compatible. The allocation must use at least 983,040 bytes and one of those bits. Choosing memory type 2 because it is device local would still be invalid if bit 2 is absent from memoryTypeBits. Visible texel arithmetic explains content; the requirement decides binding.

Keep object lifetime in the correct order

The image view refers to the image, and the image is bound to memory. After all submitted use has completed, destroy the view, destroy the image and free the memory. Freeing memory while a bound image may still be used is not a shortcut. Waiting for the entire device to become idle before every ordinary destruction would be correct in a narrow shutdown path but ruin useful overlap during rendering. Later per-frame ownership will use fences to decide when resources are safe to reuse.

vkDeviceWaitIdle(device);
vkDestroyImageView(device, outputView, nullptr);
vkDestroyImage(device, outputImage, nullptr);
vkFreeMemory(device, outputMemory, nullptr);

Which number controls allocation?

Your 320 by 180 RGBA16F image contains 57,600 texels and eight bytes per texel, giving 460,800 visible texel bytes. Vulkan reports a memory requirement size of 524,288 bytes. Which size belongs in VkMemoryAllocateInfo?

The answer

Use 524,288 bytes because the queried requirement controls the allocation bound to the image. The 460,800-byte calculation remains useful for understanding the unpadded texel content and expected scaling, but it cannot overrule device alignment and storage requirements.

Official reference: the Khronos specification chapters on resources, device memory and storage images define the current requirements used here.

Make the resource account visible

Log the chosen format, extent, usage flags, minimum texel bytes, reported memory size, alignment, compatible type bits, selected type index and selected property flags. Create the image at two extents and compare which numbers scale exactly with texel count and which are rounded or constrained by the implementation. Then remove VK_IMAGE_USAGE_STORAGE_BIT and confirm that the object you created no longer satisfies the descriptor use planned for Lesson 17. Restore the flag before continuing.

Carry one complete resource forward

Retain the image, memory, view, format and extent together, but do not collapse them into one claim. The image describes and owns no allocation by itself. The memory has no shader interpretation by itself. The view cannot outlive the image it references. Lesson 17 will add a descriptor set layout and descriptor set that expose this view to one compute shader binding.

We still have no shader and no recorded work. We do have a complete storage resource whose creation, allocation, binding and interpretation can each be inspected. That is enough progress. Lesson 17 now constructs the resource interface and a compute pipeline without pretending that a pipeline object executes before a command is submitted.