From 8bd2b34f25719c989830a0d83e0f62871936405d Mon Sep 17 00:00:00 2001 From: Sascha Willems Date: Sat, 6 Dec 2025 11:29:22 +0100 Subject: [PATCH] Improve images chapter Use Vulkan-hpp enums, add explanations for important topics like staging, add notes in linear images improve wording, adjust code to be in line with what's actually used in the C++ files --- en/06_Texture_mapping/00_Images.adoc | 111 ++++++++++++++------------- 1 file changed, 59 insertions(+), 52 deletions(-) diff --git a/en/06_Texture_mapping/00_Images.adoc b/en/06_Texture_mapping/00_Images.adoc index ce7ccd01..6389aab6 100644 --- a/en/06_Texture_mapping/00_Images.adoc +++ b/en/06_Texture_mapping/00_Images.adoc @@ -17,10 +17,10 @@ Adding a texture to our application will involve the following steps: We've already worked with image objects before, but those were automatically created by the swap chain extension. This time we'll have to create one by ourselves. -Creating an image and filling it with data is similar to vertex buffer creation. +Creating an image and filling it with data is similar to vertex buffer creation but with some added complexity due to how GPUs handle images. We'll start by creating a staging resource and filling it with pixel data and then we copy this to the final image object that we'll use for rendering. -Although it is possible to create a staging image for this purpose, Vulkan also allows you to copy pixels from a `VkBuffer` to an image and the API for this is actually https://developer.nvidia.com/vulkan-memory-management[faster on some hardware]. -We'll first create this buffer and fill it with pixel values, and then we'll create an image to copy the pixels to. +Although it is possible to create a staging image for this purpose, Vulkan also allows you to directly copy pixels from a buffer to an image. That's less verbose, less limited and usually https://developer.nvidia.com/vulkan-memory-management[faster]. +For that we'll first create such a buffer that is accessible by the host and fill it with pixel values, and then we'll create an image to copy the pixels to. Creating an image is not very different from creating buffers. It involves querying the memory requirements, allocating device memory and binding it, just like we've seen before. @@ -30,16 +30,16 @@ Due to the way graphics hardware works, simply storing the pixels row by row may When performing any operation on images, you must make sure that they have the layout that is optimal for use in that operation. We've actually already seen some of these layouts when we specified the render pass: -* `VK_IMAGE_LAYOUT_PRESENT_SRC_KHR`: Optimal for presentation -* `VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL`: Optimal as attachment for writing colors from the fragment shader -* `VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL`: Optimal as source in a transfer operation, like `vkCmdCopyImageToBuffer` -* `VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL`: Optimal as destination in a transfer operation, like `vkCmdCopyBufferToImage` -* `VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL`: Optimal for sampling from a shader +* `vk::ImageLayout::ePresentSrcKHR`: Optimal for presentation +* `vk::ImageLayout::eColorAttachmentOptimal`: Optimal as attachment for writing colors from the fragment shader +* `vk::ImageLayout::eTransferSrcOptimal`: Optimal as source in a transfer operation, like `vkCmdCopyImageToBuffer` +* `vk::ImageLayout::eTransferDstOptimal`: Optimal as destination in a transfer operation, like `vkCmdCopyBufferToImage` +* `vk::ImageLayout::eShaderReadOnlyOptimal`: Optimal for sampling from a shader One of the most common ways to transition the layout of an image is a _pipeline barrier_. Pipeline barriers are primarily used for synchronizing access to resources, like making sure that an image was written to before it is read, but they can also be used to transition layouts. In this chapter we'll see how pipeline barriers are used for this purpose. -Barriers can additionally be used to transfer queue family ownership when using `VK_SHARING_MODE_EXCLUSIVE`. +Barriers can additionally be used to transfer queue family ownership when using `vk::SharingMode::eExclusive`. == Loading an image @@ -104,7 +104,10 @@ The pixels are laid out row by row with 4 bytes per pixel in the case of `STBI_r == Staging buffer -We're now going to create a buffer in host visible memory so that we can use `vkMapMemory` and copy the pixels to it. +Next we need to upload the image to the GPU for optimal access during shader reads. For that we're going to create a buffer in host visible memory that we can map to copy the pixels to. This buffer will be the source for copying that data to the GPU. + +NOTE: Images should always reside in GPU memory. Leaving them in host only visible memory would require the GPU to read data via the PCI interface for each frame, which has a much smaller bandwidth than the GPU's memory. That would cause a big performance impact. Staging is the process of getting image data into the GPU's memory. It is not always required, as devices may offer a memory type that's both host visible and device local. It's possible to skip staging on such configurations. + Add variables for this temporary buffer to the `createTextureImage` function: [,c++] @@ -113,7 +116,7 @@ vk::raii::Buffer stagingBuffer({}); vk::raii::DeviceMemory stagingBufferMemory({}); ---- -The buffer should be in host visible memory so that we can map it, and it should be usable as a transfer source so that we can copy it to an image later on: +The buffer should be in host visible memory (`eHostVisible`) so that we can map it. It should also be host coherent (`eHostCoherent`), to ensure the data written to it is immediately available (and not cached in some way). And it should be usable as a transfer source ('eTransferSrc') so that we can copy it to an image later on: [,c++] ---- @@ -149,7 +152,7 @@ vk::raii::Image textureImage = nullptr; vk::raii::DeviceMemory textureImageMemory = nullptr; ---- -The parameters for an image are specified in a `VkImageCreateInfo` struct: +The parameters for an image are specified in a `vk::ImageCreateInfo` struct: [,c++] ---- @@ -167,29 +170,29 @@ Our texture will not be an array and we won't be using mipmapping for now. Vulkan supports many possible image formats, but we should use the same format for the texels as the pixels in the buffer, otherwise the copy operation will fail. -The `tiling` field can have one of two values: +The `tiling` file specifies how texels are arranged in memory. Vulkan supports two fundamentally different modes for this: -* `VK_IMAGE_TILING_LINEAR`: Texels are laid out in row-major order like our `pixels` array -* `VK_IMAGE_TILING_OPTIMAL`: Texels are laid out in an implementation defined order for optimal access +* `vk::ImageTiling::eOptimal`: Texels are laid out in an implementation-dependent arrangement. This results in a more efficient memory access. +* `vk::ImageTiling::eLinear`: Texels are laid out in memory in row-major order, possibly with some padding on each row. -Unlike the layout of an image, the tiling mode cannot be changed at a later time. -If you want to be able to directly access texels in the memory of the image, then you must use `VK_IMAGE_TILING_LINEAR`. -We will be using a staging buffer instead of a staging image, so this won't be necessary. -We will be using `VK_IMAGE_TILING_OPTIMAL` for efficient access from the shader. +NOTE: Linear tiled images are very limited. They e.g. may only work for 2D images, can't be used for depth/stencil, can't have multiple mip levels or layers. GPU access is also much slower than for optimal tiled images. So there are very little use cases for them. + +Thus, we will be using `vk::ImageTiling::eOptimal` for efficient access from the shader. There are only two possible values for the `initialLayout` of an image: -* `VK_IMAGE_LAYOUT_UNDEFINED`: Not usable by the GPU and the very first transition will discard the texels. -* `VK_IMAGE_LAYOUT_PREINITIALIZED`: Not usable by the GPU, but the first transition will preserve the texels. +* `vk::ImageLayout::eUndefined`: Not usable by the GPU and the very first transition will discard the texels. +* `vk::ImageLayout::ePreinitialized`: Not usable by the GPU, but the first transition will preserve the texels. -There are few situations where it is necessary for the texels to be preserved during the first transition. -One example, however, would be if you wanted to use an image as a staging image in combination with the `VK_IMAGE_TILING_LINEAR` layout. +There are very few situations where it is necessary for the texels to be preserved during the first transition. +One example, however, would be if you wanted to use an linear tiled image as a staging image. In that case, you'd want to upload the texel data to it and then transition the image to be a transfer source without losing the data. -In our case, however, we're first going to transition the image to be a transfer destination and then copy texel data to it from a buffer object, so we don't need this property and can safely use `VK_IMAGE_LAYOUT_UNDEFINED`. + +In our case, however, we're first going to transition the image to be a transfer destination and then copy texel data to it from a buffer object, so we don't need this property and can safely use `vk::ImageLayout::eUndefined`. The `usage` field has the same semantics as the one during buffer creation. The image is going to be used as destination for the buffer copy, so it should be set up as a transfer destination. -We also want to be able to access the image from the shader to color our mesh, so the usage should include `VK_IMAGE_USAGE_SAMPLED_BIT`. +We also want to be able to access the image from the shader to color our mesh, so the usage should include `vk::ImageUsageFlagBits::eSampled`. The image will only be used by one queue family: the one that supports graphics (and therefore also) transfer operations. @@ -202,11 +205,11 @@ We won't be using it in this tutorial, so leave it to its default value of `0`. [,c++] ---- -image = vk::raii::Image( device, imageInfo ); +image = vk::raii::Image(device, imageInfo); ---- -The image is created using `vkCreateImage`, which doesn't have any particularly noteworthy parameters. -It is possible that the `VK_FORMAT_R8G8B8A8_SRGB` format is not supported by the graphics hardware. +The image is created using the `vk::raii::Image` constructor, which doesn't have any particularly noteworthy parameters. +It is possible that the `vk::Format::eR8G8B8A8Srgb,` format is not supported by the graphics hardware. You should have a list of acceptable alternatives and go with the best one that is supported. However, support for this particular format is so widespread that we'll skip this step. Using different formats would also require annoying conversions. @@ -215,13 +218,14 @@ We will get back to this in the depth buffer chapter, where we'll implement such [,c++] ---- vk::MemoryRequirements memRequirements = image.getMemoryRequirements(); -vk::MemoryAllocateInfo allocInfo( memRequirements.size, findMemoryType(memRequirements.memoryTypeBits, properties) ); -imageMemory = vk::raii::DeviceMemory( device, allocInfo ); -image.bindMemory(*imageMemory, 0); +vk::MemoryAllocateInfo allocInfo{.allocationSize = memRequirements.size, + .memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties)}; +imageMemory = vk::raii::DeviceMemory(device, allocInfo); +image.bindMemory(imageMemory, 0); ---- Allocating memory for an image works in exactly the same way as allocating memory for a buffer. -Use `vkGetImageMemoryRequirements` instead of `vkGetBufferMemoryRequirements`, and use `vkBindImageMemory` instead of `vkBindBufferMemory`. +Use the default `vk::raii::DeviceMemory` constructor, and use `bindMemory` on the `image`. This function is already getting quite large and there'll be a need to create more images in later chapters, so we should abstract image creation into a `createImage` function, like we did for buffers. Create the function and move the image object creation and memory allocation to it: @@ -234,12 +238,12 @@ void createImage(uint32_t width, uint32_t height, vk::Format format, vk::ImageTi .samples = vk::SampleCountFlagBits::e1, .tiling = tiling, .usage = usage, .sharingMode = vk::SharingMode::eExclusive }; - image = vk::raii::Image( device, imageInfo ); + image = vk::raii::Image(device, imageInfo); vk::MemoryRequirements memRequirements = image.getMemoryRequirements(); vk::MemoryAllocateInfo allocInfo{ .allocationSize = memRequirements.size, .memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties) }; - imageMemory = vk::raii::DeviceMemory( device, allocInfo ); + imageMemory = vk::raii::DeviceMemory(device, allocInfo); image.bindMemory(imageMemory, 0); } ---- @@ -322,7 +326,7 @@ void copyBuffer(vk::raii::Buffer & srcBuffer, vk::raii::Buffer & dstBuffer, vk:: } ---- -If we were still using buffers, then we could now write a function to record and execute `vkCmdCopyBufferToImage` to finish the job, but this command requires the image to be in the right layout first. +If we were still using buffers, then we could now write a function to record and execute `copyBufferToImage` to finish the job, but this command requires the image to be in the right layout first. Create a new function to handle layout transitions: [,c++] @@ -335,7 +339,7 @@ void transitionImageLayout(const vk::raii::Image& image, vk::ImageLayout oldLayo ---- One of the most common ways to perform layout transitions is using an _image memory barrier_. -A pipeline barrier like that is generally used to synchronize access to resources, like ensuring that a write to a buffer completes before reading from it, but it can also be used to transition image layouts and transfer queue family ownership when `VK_SHARING_MODE_EXCLUSIVE` is used. +A pipeline barrier like that is generally used to synchronize access to resources, like ensuring that a write to a buffer completes before reading from it, but it can also be used to transition image layouts and transfer queue family ownership when `vk::SharingMode::eExclusive` is used. There is an equivalent _buffer memory barrier_ to do this for buffers. [,c++] @@ -343,7 +347,7 @@ There is an equivalent _buffer memory barrier_ to do this for buffers. vk::ImageMemoryBarrier barrier{ .oldLayout = oldLayout, .newLayout = newLayout, .image = image, .subresourceRange = { vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1 } }; ---- `oldLayout` and `newLayout` specify the the layout transition. -It is possible to use `VK_IMAGE_LAYOUT_UNDEFINED` as `oldLayout` if you don't care about the existing contents of the image. +It is possible to use `vk::ImageLayout::eUndefined` as `oldLayout` if you don't care about the existing contents of the image. If you are using the barrier to transfer queue family ownership, then `oldLayout` and `newLayout` fields should be the indices of the queue families. They must be set to `VK_QUEUE_FAMILY_IGNORED` if you don't want to do this (not the default value!). @@ -352,12 +356,12 @@ The `image` and `subresourceRange` specify the image that is affected and the sp Our image is not an array and does not have mipmapping levels, so only one level and layer are specified. Barriers are primarily used for synchronization purposes, so you must specify which types of operations that involve the resource must happen before the barrier, and which operations that involve the resource must wait on the barrier. -We need to do that despite already using `vkQueueWaitIdle` to manually synchronize. +We need to do that despite already using `queue.waitIdle()` to manually synchronize. The right values depend on the old and new layout, so we'll get back to this once we've figured out which transitions we're going to use. [,c++] ---- -commandBuffer.pipelineBarrier( sourceStage, destinationStage, {}, {}, nullptr, barrier ); +commandBuffer.pipelineBarrier(sourceStage, destinationStage, {}, {}, nullptr, barrier); ---- All types of pipeline barriers are submitted using the same function. @@ -365,10 +369,10 @@ The first parameter after the command buffer specifies in which pipeline stage t The second parameter specifies the pipeline stage in which operations will wait on the barrier. The pipeline stages that you are allowed to specify before and after the barrier depend on how you use the resource before and after the barrier. The allowed values are listed in https://docs.vulkan.org/spec/latest/chapters/synchronization.html#synchronization-access-types-supported[this table] of the specification. -For example, if you're going to read from a uniform after the barrier, you would specify a usage of `VK_ACCESS_UNIFORM_READ_BIT` and the earliest shader that will read from the uniform as pipeline stage, for example `VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT`. +For example, if you're going to read from a uniform after the barrier, you would specify a usage of `vk::AccessFlagBits::eUniformRead` and the earliest shader that will read from the uniform as pipeline stage, for example `vk::PipelineStageFlagBits::eFragmentShader`. It would not make sense to specify a non-shader pipeline stage for this type of usage and the validation layers will warn you when you specify a pipeline stage that does not match the type of usage. -The third parameter is either `0` or `VK_DEPENDENCY_BY_REGION_BIT`. +The third parameter is either `0` or `vk::DependencyFlagBits::eByRegion`. The latter turns the barrier into a per-region condition. That means that the implementation is allowed to already begin reading from the parts of a resource that were written so far, for example. @@ -389,7 +393,7 @@ void copyBufferToImage(const vk::raii::Buffer& buffer, vk::raii::Image& image, u ---- Just like with buffer copies, you need to specify which part of the buffer is going to be copied to which part of the image. -This happens through `VkBufferImageCopy` structs: +This happens through `vk::BufferImageCopy` structs: [,c++] ---- @@ -409,11 +413,13 @@ Buffer to image copy operations are enqueued using the `vkCmdCopyBufferToImage` [,c++] ---- commandBuffer.copyBufferToImage(buffer, image, vk::ImageLayout::eTransferDstOptimal, {region}); +// Submit the buffer copy to the graphics queue +endSingleTimeCommands(*commandBuffer); ---- The fourth parameter indicates which layout the image is currently using. I'm assuming here that the image has already been transitioned to the layout that is optimal for copying pixels to. -Right now we're only copying one chunk of pixels to the whole image, but it's possible to specify an array of `VkBufferImageCopy` to perform many different copies from this buffer to the image in one operation. +Right now we're only copying one chunk of pixels to the whole image, but it's possible to specify an array of `vk::BufferImageCopy` to perform many different copies from this buffer to the image in one operation. == Preparing the texture image @@ -422,7 +428,7 @@ The last thing we did there was creating the texture image. The next step is to copy the staging buffer to the texture image. This involves two steps: -* Transition the texture image to `VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL` +* Transition the texture image to `vk::ImageLayout::eTransferDstOptimal` * Execute the buffer to image copy operation This is easy to do with the functions we just created: @@ -433,7 +439,7 @@ transitionImageLayout(textureImage, vk::ImageLayout::eUndefined, vk::ImageLayout copyBufferToImage(stagingBuffer, textureImage, static_cast(texWidth), static_cast(texHeight)); ---- -The image was created with the `VK_IMAGE_LAYOUT_UNDEFINED` layout, so that one should be specified as old layout when transitioning `textureImage`. +The image was created with the `vk::ImageLayout::eUndefined` layout, so that one should be specified as old layout when transitioning `textureImage`. Remember that we can do this because we don't care about its contents before performing the copy operation. To be able to start sampling from the texture image in the shader, we need one last transition to prepare it for shader access: @@ -476,12 +482,13 @@ if (oldLayout == vk::ImageLayout::eUndefined && newLayout == vk::ImageLayout::eT throw std::invalid_argument("unsupported layout transition!"); } -commandBuffer.pipelineBarrier( sourceStage, destinationStage, {}, {}, nullptr, barrier ); +commandBuffer.pipelineBarrier(sourceStage, destinationStage, {}, {}, nullptr, barrier); +endSingleTimeCommands(*commandBuffer); ---- As you can see in the aforementioned table, transfer writes must occur in the pipeline transfer stage. -Since the writings don't have to wait on anything, you may specify an empty access mask and the earliest possible pipeline stage `VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT` for the pre-barrier operations. -It should be noted that `VK_PIPELINE_STAGE_TRANSFER_BIT` is not a _real_ stage within the graphics and compute pipelines. +Since the writings don't have to wait on anything, you may specify an empty access mask and the earliest possible pipeline stage `vk::PipelineStageFlagBits::eTopOfPipe` for the pre-barrier operations. +It should be noted that `vk::PipelineStageFlagBits::eTransfer` is not a _real_ stage within the graphics and compute pipelines. It is more of a pseudo-stage where transfers happen. See https://docs.vulkan.org/spec/latest/chapters/synchronization.html#VkPipelineStageFlagBits[the documentation] for more information and other examples of pseudo-stages. @@ -490,12 +497,12 @@ The image will be written in the same pipeline stage and subsequently read by th If we need to do more transitions in the future, then we'll extend the function. The application should now run successfully, although there are of course no visual changes yet. -One thing to note is that command buffer submission results in implicit `VK_ACCESS_HOST_WRITE_BIT` synchronization at the beginning. -Since the `transitionImageLayout` function executes a command buffer with only a single command, you could use this implicit synchronization and set `srcAccessMask` to `0` if you ever needed a `VK_ACCESS_HOST_WRITE_BIT` dependency in a layout transition. +One thing to note is that command buffer submission results in implicit `vk::AccessFlagBits::eHostWrite` synchronization at the beginning. +Since the `transitionImageLayout` function executes a command buffer with only a single command, you could use this implicit synchronization and set `srcAccessMask` to `0` if you ever needed a `vk::AccessFlagBits::eHostWrite` dependency in a layout transition. It's up to you if you want to be explicit about it or not, but I'm personally not a fan of relying on these OpenGL-like "hidden" operations. -There is actually a special type of image layout that supports all operations, `VK_IMAGE_LAYOUT_GENERAL`. -The problem with it, of course, is that it doesn't necessarily offer the best performance for any operation. +There is actually a special type of image layout that supports all operations, `vk::ImageLayout::eGeneral`. +But unless using certain extensions, which we don't do in the tutorial, using the general layout might come with a performance penalty is it may disable certain optimizations on some GPUs. It is required for some special cases, like using an image as both input and output, or for reading an image after it has left the preinitialized layout. All the helper functions that submit commands so far have been set up to execute synchronously by waiting for the queue to become idle.