Vulkan Learning — 6. 3d модель. Буферы памяти и изображения

Итак, нам осталось отрисовать трехмерную модель. Прежде всего, посмотрим, что модель собой представляет (рис. 1).

Рис. 1 — 3d модель

Модель состоит из трех кусков данных:

  • Mesh. Модель по сути представляет собой множество треугольников. Каждый треугольник задается тремя вершинами (vertices), а каждая вершина характеризуется тремя координатами в в локальной системе координат нашей модели. Таким образом у нас должен быть массив вершин. Чтобы задать треугольник, нам надо указать три индекса (indices) в массиве вершин. Чтобы задать множество треугольников, нам нужно указать множество троек таких индексов. Таким образом, мы должны сформировать массив индексов. Indexed drawing — это не единственный, но наверное самый распространенный метод задания треугольников.
  • Material. Mesh — это только каркас (wireframe), который визуально воспринимается как силуэт или скелет объекта. Чтобы объект стал похож на реальный предмет, нужно натянуть на него некую шкурку, т. е. материал (Material). Материал должен задавать свойства поверхности объекта, такие как цвет (diffuse color), матовость или блескучесть (shininess), свечение — для источников света (emissive color), шероховатость (normal map и height map) и пр. Обычно все или некоторые из этих параметров поверхности имею различные значения в разных ее точках, поэтому задаются они текстурами — двумерными картинками, которые в итоге «натягиваются» на mesh.
  • World position & orientation. Модель еще нужно как-то расположить в системе координат сцены (world space). Расположение объекта задается матрицей преобразования — это матрица 4×4 (model-to-world matrix), которая обычно представляет комбинацию из преобразования трансляции и поворота. Матрица преобразования в этом случае — это четыре столбца, которые представляют собой позицию объекта в системе координат сцены (position) и три вектора, которые задают направления осей локальной системы координат модели в системе координат сцены (right, up, forward).

Типы памяти

Наша задача — разместить вышеперечисленные куски данных в памяти (это может быть внутренняя память видеокарты — GPU memory или оперативная память компьютера — system memory) и передать ссылки на них пайплайну (последнее мы делаем путем записи соответствующих команд в командный буфер, вроде vkCmdBindVertexBuffers, vkCmdBindIndexBuffer, vkCmdBindDescriptorSets). Оказывается, что разные куски данных следует размещать в разных типах памяти. Например, mesh мы размещаем во внутренней памяти видеокарты, поскольку это большой объем данных, который интенсивно читается графическим процессором. Текстуры тоже обычно размещаются в памяти видеокарты, при этом, поскольку они являются двумерными изображениями и отдельные их пиксели интенсивно считываются видеопроцессором, размещение текстур в памяти (image layout) может быть нетривиальным, т. е. представлять собой не просто двумерный массив пикселей, а быть оптимизировано для кэширования отдельных кусочков изображения (optimal tiling). А вот матрица model-to-world подвергается частым модификациям со стороны CPU, поэтому мы скорее поместим ее в оперативную память компьютера либо в память видеокарты, к которой CPU имеет непосредственный доступ.

Заметим, что ситуация более сложная, чем просто наличие двух видов памяти: GPU и system memory. Внутри этих двух памятей выделяются участки, обладающие различными свойствами. В Vulkan эти свойства представлены в виде перечисления VkMemoryPropertyFlags:

VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT Память наиболее эффективная с точки зрения доступа к ней GPU. Т. е. это попросту память видеокарты. Однако заметим, что GPU может иметь доступ и к системной памяти через механизм DMA, хотя это и менее эффективно.
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT Память, которая доступна для доступа CPU через так называемый mapped pointer (который получается вызовом функции vkMapMemory). Доступ осуществляется так, будто CPU имеет дело с системной памятью (это может быть так на самом деле, но может быть и не так, поскольку существует механизм, который позволяет CPU обращаться непосредственно к памяти видеокарты).
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT Означает, что не требуется вызов функции vkFlushMappedMemoryRanges для того, чтобы сделать изменения в памяти, сделанный CPU, видимыми для GPU и не требуется вызов функции vkInvalidateMappedMemoryRanges для того, чтобы сделать изменения в памяти, сделанные GPU, видимыми для CPU.
VK_MEMORY_PROPERTY_HOST_CACHED_BIT Чтение CPU из такой памяти более эффективно, однако такая память не всегда является host coherent.

Разобраться с видами памятей в Vulkan мне помогли следующие ссылки:

Из них я понял, что вся память в системе делится на кучи (heaps). Каждая куча может располагаться либо в памяти видеокарты (DEVICE_LOCAL) либо в системной памяти (non-DEVICE_LOCAL). Каждая куча поддерживает один или несколько типов памяти. Функция ниже выводит информацию о кучах и типах памяти на экран:

// Prints information about all memory types present in a specified physicalDevice.
// Each memory type has its own set of properties (device local, host visible, etc.).
// Each memory type belongs to some memory heap. There can be many memory heaps in
// the physicalDevice each of which can be either device-local or not device-local
// and have different size (in bytes).
inline void PrintMemoryInfo(
    const vk::PhysicalDevice& physicalDevice)
{
    auto memoryProperties = physicalDevice.getMemoryProperties();

    for (uint32_t i = 0; i < memoryProperties.memoryTypeCount; i++)
    {
        auto heapIndex = memoryProperties.memoryTypes[i].heapIndex;
        std::cout
            << "memory type index = " << i
            << " heap = " << heapIndex
            << " size = " << memoryProperties.memoryHeaps[heapIndex].size
            << " heapFlags = "
            << (memoryProperties.memoryHeaps[heapIndex].flags & vk::MemoryHeapFlagBits::eDeviceLocal ? "" : "not ") << "DEVICE_LOCAL"
            << " propertyFlags ="
            << (memoryProperties.memoryTypes[i].propertyFlags & vk::MemoryPropertyFlagBits::eDeviceLocal  ? " DEVICE_LOCAL"  : "")
            << (memoryProperties.memoryTypes[i].propertyFlags & vk::MemoryPropertyFlagBits::eHostVisible  ? " HOST_VISIBLE"  : "")
            << (memoryProperties.memoryTypes[i].propertyFlags & vk::MemoryPropertyFlagBits::eHostCoherent ? " HOST_COHERENT" : "")
            << (memoryProperties.memoryTypes[i].propertyFlags & vk::MemoryPropertyFlagBits::eHostCached   ? " HOST_CACHED"   : "")
            << std::endl;
    }
}

Например, на компьютере с процессором Intel со встроенным GPU получилось две кучи и три типа памяти:

memory type index = 0 heap = 0 size = 1107296256 heapFlags = DEVICE_LOCAL propertyFlags = DEVICE_LOCAL
memory type index = 1 heap = 1 size = 1107296256 heapFlags = DEVICE_LOCAL propertyFlags = DEVICE_LOCAL HOST_VISIBLE HOST_COHERENT
memory type index = 2 heap = 1 size = 1107296256 heapFlags = DEVICE_LOCAL propertyFlags = DEVICE_LOCAL HOST_VISIBLE HOST_COHERENT HOST_CACHED

А на компьютере с дискретной видеокартой NVIDIA получилось три кучи и пять типов памяти:

memory type index = 0 heap = 1 size = 34327236608 heapFlags = not DEVICE_LOCAL propertyFlags = 0
memory type index = 1 heap = 0 size =  8399093760 heapFlags = DEVICE_LOCAL     propertyFlags = DEVICE_LOCAL
memory type index = 2 heap = 1 size = 34327236608 heapFlags = not DEVICE_LOCAL propertyFlags = HOST_VISIBLE HOST_COHERENT
memory type index = 3 heap = 1 size = 34327236608 heapFlags = not DEVICE_LOCAL propertyFlags = HOST_VISIBLE HOST_COHERENT HOST_CACHED
memory type index = 4 heap = 2 size =   224395264 heapFlags = DEVICE_LOCAL     propertyFlags = DEVICE_LOCAL HOST_VISIBLE HOST_COHERENT

Желательно сориентироваться в том, для какого типа данных какой тип памяти подходит больше всего. Причем, если наиболее подходящий тип памяти (вариант 1) уже переполнен, можно выбрать другой тип, идущий вторым приоритетом (вариант 2):

GPU Data (meshes, textures, attachments/framebuffers) CPU->GPU Dataflow (uniform variables)
Вариант 1 Вариант 2 Вариант 1 Вариант 2
DEVICE_LOCAL + +
HOST_VISIBLE + + +
HOST_COHERENT неважно + +
HOST_CACHED

Buffers & Images

Итак, казалось бы, мы имеем распределение различных ресурсов по различным видам памяти. Но оказывается есть еще одно распределение, лежащее в другой плоскости: по layout’у памяти. Uniform variables, например, имеют т. н. линейный формат, т. е., например, если у нас есть структура

struct SomeData
{
    float x;
    float y;
    float z;
}

… то мы понимаем, что x расположен по смещению 0 относительно начала буфера памяти, y — по смещению 4, а z — по смещению 8.
Другое дело изображения (текстуры, фреймбуферы) — они тоже могут иметь линейный формат (linear tiling), когда адрес пикселя с координатами (x, y) вычисляется по формуле y * stride + x * pixelSize, где stride — размер строки пикселей в байтах, pixelSize — размер одного пикселя в байтах. Однако линейный формат для изображений менее эффективен с точки зрения доступа к соседним пикселям: дело в том, что пиксели расположенные друг под другом на изображении оказываются в линейном формате расположенными далеко друг от друга в памяти. Поэтому на практике применяется другой формат, когда всё изображение разбивается на множество более мелких изображений (поэтому tiling, tile — плитка, черепица), в которых пиксели находятся в памяти рядом друг с другом (optimal tiling). Кроме того, GPU еще и сжимает изображения для экономии пропускной способности шины данных — т. е. переводит их в определенный формат, который в Vulkan’овской терминологии называется layout’ом. Различие между tiling’ом и layout’ом — это темная история, в которой я не до конца разобрался, но вот некоторые ссылки, которые отчасти объясняют эти вещи:

Суть в том, что в итоге мы имеем два вида объектов для хранения ресурсов в памяти: Буферы и Изображения. И с каждым из них может быть связан участок системной либо видеопамяти (рис. 2).

Рис. 2 — Виды памяти для различных ресурсов в Vulkan

Еще пара важных слов об Image Layout и Image Tiling. Tiling’ов существует по сути два:

VK_IMAGE_TILING_LINEAR Неоптимален для работы GPU, пригоден только для работы как staging buffer, т. е. для копирования информации из одного места в другое (но не, например, в качестве текстуры или фреймбуфера). При этом, единственный формат, который доступен для чтения-записи CPU.
VK_IMAGE_TILING_OPTIMAL Оптимален для работы GPU, применяется для всех сценариев использования кроме случая, когда CPU должен читать-писать что-то в это изображение через mapped pointer.

Что касается Layout’а, то он является мутной штукой для программиста. Что мы знаем о нем, так это то, что если определенная стадия пайплайна работает с изображением, то это изображение должно в этот момент иметь определенный требуемый данной стадией пайплайна layout (который у каждой стадии свой: например, во время отрисовки пайпайном изображения (буфера цвета) из свопчейна, это изображение должно иметь layout VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, а во время отправки этого же изображения на экран (в Presentation Engine) — VK_IMAGE_LAYOUT_PRESENT_SRC_KHR). Таким образом, вообще говоря, изображение в ходе цикла отрисовки должно менять свои layout’ы (этим переформатированием изображения занимается GPU) — и мы должны указать, какой layout должно оно иметь на каждой стадии, что мы и делали при описании RenderPass’а). Изменение Layout’а (layout transition) выполняется GPU автоматически в соответствии с настройками в RenderPass’е, но иногда нужно выполнить это изменение не в ходе цикла отрисовки, а заранее — так например текстура непосредственно после ее создания имеет VK_IMAGE_LAYOUT_UNDEFINED; чтобы скопировать в нее изображение, нужно сначала перевести ее в VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; затем, чтобы она стала доступна для чтения пайплайном, нужно перевести ее в VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL. Для такого layout transition’а можно использовать командный буфер и добавить в него memory barrier (см. ниже).

Итак, для представления буферов и изображений я написал два класса: VkMemoryBuffer и VkImage2d. Здесь приведу лишь отдельные фрагменты псевдокода, а полный код смотрите в репозитории.

// A class that binds together two Vulkan notions: "Buffer" and "Memory".
class VkMemoryBuffer
{
    private:            
        vk::Buffer m_LocalBuffer;
        vk::DeviceMemory m_LocalMemory;

    public:
        VkMemoryBuffer(
            size_t bufferSize,
            vk::BufferUsageFlags usage, // Indicates for which purposes the data in the buffer is going to be used
            vk::MemoryPropertyFlags appMemoryRequirements) :
            m_LocalBuffer(),
            m_LocalMemory()
        {
            // CREATE BUFFER
            m_LocalBuffer = logicalDevice.createBuffer(
                vk::BufferCreateInfo
                {
                    .size = bufferSize,
                    .usage = usage,
                    // Buffers can be owned by a specific queue family or be shared between multiple at the same time
                    .sharingMode = vk::SharingMode::eExclusive
                });

            // ALLOCATE MEMORY FOR BUFFER
            // Our physical device can have multiple memory types on board.
            // But our buffer is only compatible with some types of our physical device's memory.
            // The VkMemoryRequirements struct has three fields:
            //     size: The size of the required amount of memory in bytes, may differ from bufferInfo.size.
            //     alignment: The offset in bytes where the buffer begins in the allocated region of memory, depends on bufferInfo.usage and bufferInfo.flags.
            //     memoryTypeBits: Bit field of the memory types that are suitable for the buffer.
            auto memoryRequirements = logicalDevice.getBufferMemoryRequirements(m_LocalBuffer);
            m_LocalMemory = logicalDevice.allocateMemory(
                vk::MemoryAllocateInfo
                {
                    .allocationSize = memoryRequirements.size,

                    // Index of memory type on Physical Device that has required bit flags
                    .memoryTypeIndex = FindMemoryTypeIndex(
                        physicalDevice,
                        memoryRequirements,
                        appMemoryRequirements)
                });

            // BIND MEMORY TO BUFFER
            logicalDevice.bindBufferMemory(m_LocalBuffer, m_LocalMemory,
                0); // The offset within the region of memory
        }

    public:
        virtual ~VkMemoryBuffer()
        {
            logicalDevice.destroyBuffer(m_LocalBuffer);
            logicalDevice.freeMemory(m_LocalMemory);
        }

    public:
        size_t Size() const { return m_BufferSize; }
        const vk::Buffer& Buffer() const { return m_LocalBuffer; }
        const vk::DeviceMemory& LocalMemory() const { return m_LocalMemory; }
};

/// <summary>
/// Copies data from one memory type to another using
/// temporary created command buffer.
/// </summary>
inline void CopyBuffer(
    const vk::Buffer& source,
    const vk::Buffer& destination,
    size_t srcOffset,
    size_t dstOffset,
    size_t size)
{
    // Data are copied from one memory type to another
    // through the execution of a copy command that must be
    // placed in a command buffer.
    auto commandBuffer = BeginCommandBuffer();
    {
        vk::BufferCopy copyRegion
        {
            .srcOffset = srcOffset,
            .dstOffset = dstOffset,
            .size = size
        };

        commandBuffer.copyBuffer(
            source,      // src
            destination, // dst
            1, // region count
            &copyRegion); // regions
    }
    EndAndSubmitCommandBuffer(commandBuffer);
}

Суть в том, что есть объект vk::Buffer, который служит ссылкой на буфер и, например, позволяет копировать данные из одного буфера в другой. И есть память vk::DeviceMemory, которую нужно выделять (allocate) при создании буфера и освобождать (free) при его уничтожении. Объект vk::Buffer нужно еще привязать (bind) к памяти vk::DeviceMemory. Отметим, что при создании буфера указывается параметр vk::BufferUsageFlags, от которого зависят требования к типу памяти (в частности ее выравнивание).

Теперь рассмотрим изображение. Изображение обладает более сложной и частично скрытой от наших глаз структурой. У него есть длина, ширина, формат пикселя. Также имеется объект vk::Image, который служит ссылкой на изображение и тоже позволяет, например, скопировать данные из буфера памяти (staging buffer) в изображение. И есть память vk::DeviceMemory, которую, как и в случае с буфером, надо привязать (bind) к изображению vk::Image. Но также имеется еще один объект vk::ImageView, который используется для ссылки на изображение при создании дескрипторов и attachment’ов (да не просто ссылки, а ссылки, которая имеет определенный интерфейс и раскрывает определенный аспект изображения — об этом речь шла в одной из прошлых заметок).

// Binds together three Vulkan notions: "image", "memory" and "image view".
class VkImage2d final
{
    private:
        uint32_t m_Width;
        uint32_t m_Height;
        vk::Format m_ImageFormat;
        vk::Image m_Image;
        vk::DeviceMemory m_ImageMemory;
        vk::ImageView m_ImageView;

    public:
        VkImage2d(
            uint32_t width,
            uint32_t height,
            vk::Format format,
            vk::ImageTiling tiling,
            vk::ImageUsageFlags useFlags,
            vk::MemoryPropertyFlags memoryPropFlags,
            vk::ImageAspectFlags aspectFlags) :
            m_Width(width),
            m_Height(height),
            m_ImageFormat(format),
            m_IsDestroyed(false)
        {
            // CREATE IMAGE STRUCTRE
            m_Image = logicalDevice.createImage(
                vk::ImageCreateInfo
                {
                    .imageType = vk::ImageType::e2D,             // Type of image (1D, 2D, 3D)
                    .format = format,
                    .extent = vk::Extent3D {width, height, 1u},  // width, height, depth
                    .mipLevels = 1,                              // Number of mipmap levels
                    .arrayLayers = 1,                            // Number of layers in image array
                    .samples = vk::SampleCountFlagBits::e1,
                    .tiling = tiling,                            // How image data should be arranged in memory
                    .usage = useFlags,
                    .initialLayout = vk::ImageLayout::eUndefined // Layout of image data on creation
                });

            // ALLOCATE MEMORY
            auto memoryRequirements = logicalDevice.getImageMemoryRequirements(m_Image);
            m_ImageMemory = logicalDevice.allocateMemory(
                vk::MemoryAllocateInfo
                {
                    .allocationSize = memoryRequirements.size,
                    .memoryTypeIndex = FindMemoryTypeIndex(
                        physicalDevice,
                        memoryRequirements,
                        memoryPropFlags)
                });

            // BIND IMAGE AND MEMORY
            logicalDevice.bindImageMemory(
                m_Image,
                m_ImageMemory,
                0); // offset to memory

            // CREATE IMAGE VIEW
            m_ImageView = CreateImageView(
                logicalDevice,
                m_Image,
                format,
                aspectFlags);
        }

    public:
        ~VkImage2d()
        {
            logicalDevice.destroyImageView(m_ImageView);
            logicalDevice.destroyImage(m_Image);
            logicalDevice.freeMemory(m_ImageMemory);
        }

    public:
        uint32_t width() const { return m_Width; }
        uint32_t height() const { return m_Height; }
        vk::Format ImageFormat() const { return m_ImageFormat; }
        const vk::Image& Image() const { return m_Image; }
        const vk::DeviceMemory& ImageMemory() const { return m_ImageMemory; }
        const vk::ImageView& ImageView() const { return m_ImageView; }
};

/// <summary>
/// Copies data from one memory type to another using
/// temporary created command buffer.
/// </summary>
inline void CopyBufferToImage(
    const vk::Buffer& source,
    const vk::Image& destination,
    size_t srcOffset,
    vk::Offset3D dstOffset,
    uint32_t width,
    uint32_t height)
{
    // Data are copied from one memory type to another
    // through the execution of a copy command that must be
    // placed in a command buffer.
    auto commandBuffer = BeginCommandBuffer();
    {
        // https://www.khronos.org/registry/vulkan/specs/1.2-extensions/man/html/VkMemoryBufferImageCopy.html
        vk::BufferImageCopy imageRegion
        {
            .bufferOffset = srcOffset,
            .bufferRowLength = 0,
            .bufferImageHeight = 0,
            .imageSubresource = vk::ImageSubresourceLayers
            {
                .aspectMask = vk::ImageAspectFlagBits::eColor, // Which aspect of image to copy
                .mipLevel = 0,       // Mipmap level to copy
                .baseArrayLayer = 0, // Starting array layer (if array)
                .layerCount = 1      // Number of layers to copy (if array)
            },
            .imageOffset = dstOffset, // x, y, z
            .imageExtent = { width, height, 1 }
        };

        commandBuffer.copyBufferToImage(
            source,
            destination,
            // The layout of the image subresources of dstImage specified in pRegions at the time this command is executed.
            // This means that the image MUST have this layout so as the copy operation to succeed.
            // To put it another way, command buffer "expects" the image to have this layout.
            vk::ImageLayout::eTransferDstOptimal,
            imageRegion);
    }
    EndAndSubmitCommandBuffer(commandBuffer);
}

Pipeline Barriers and Layout Transitions

Как уже было упомянуто, текстуры требуют изменений layout’а при их создании. Выполняется это при помощи добавления в командный буфер ImageMemoryBarrier’а. Барьер — это объект, который устанавливает зависимости между между командами, которые выполняются внутри очереди (очередей) команд. Команды на самом деле выполняются отнюдь не в том порядке, в котором они были записаны в командный буфер (out-of-order execution), поэтому, если нужно, чтобы некий набор команд выполнился раньше, чем начнет выполняться другой набор, нужно явно задать эту так называемую execution dependency при помощи барьера. Однако, в силу наличия множества кэшей в GPU, выполнение команд в определенном порядке отнюдь не означает что если операция 1 записала данные в память, то операция 2 может эти данные прочитать — чтобы это стало возможным нужно задать memory dependency всё через тот же барьер. Попутно барьер еще и задает трансформацию layout’а изображения — именно этим мы и пользуемся. Чтобы разобраться с барьерами, прочитайте следующие ресурсы:

Итак, вот код функции, которая изменяет layout изображения:

// Change image layout.
inline void TransitionImageLayout(
    const vk::Image& image,
    vk::ImageLayout oldLayout,
    vk::ImageLayout newLayout)
{
    auto commandBuffer = BeginCommandBuffer();
    {
        // Memory barrier describes what stages of a pipeline depend on other stages finishing first.
        // Beteween the two pipeline stages we also can perform transitions between layouts of images.
        // So the transition, if we've defined it, takes place after the first pipeline stage and before
        // the second pipeline stage.

        vk::ImageMemoryBarrier imageMemoryBarrier
        {
            .oldLayout = oldLayout,
            .newLayout = newLayout,
            .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, // Queue family to transition from
            .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, // Queue family to transition to
            .image = image, // Image being accessed and modified as part of barrier
            .subresourceRange = vk::ImageSubresourceRange
            {
                .aspectMask = vk::ImageAspectFlagBits::eColor, // The aspect of image being altered
                .baseMipLevel = 0,   // 1st mipmap level to start alteration on
                .levelCount = 1,     // Number of mipmap levels to alter
                .baseArrayLayer = 0, // 1st layer to start alterations on (if array)
                .layerCount = 1      // Number of layers to alter (if array)
            }
        };

        vk::PipelineStageFlags src_stage{};
        vk::PipelineStageFlags dst_stage{};

        // If transitioning from new (undefined) image to image ready to receive data...
        if (oldLayout == vk::ImageLayout::eUndefined && newLayout == vk::ImageLayout::eTransferDstOptimal)
        {
            imageMemoryBarrier.srcAccessMask = {}; // Transition is allowed to start after any point at the 1st pipeline stage
            imageMemoryBarrier.dstAccessMask = vk::AccessFlagBits::eTransferWrite; // Transition must occur before we attempt to transfer-write (copy data to the image) at the 2nd pipeline stage

            src_stage = vk::PipelineStageFlagBits::eTopOfPipe;
            dst_stage = vk::PipelineStageFlagBits::eTransfer;
        }
        // If transitioning from transfer destination to shader readable...
        else if (oldLayout == vk::ImageLayout::eTransferDstOptimal && newLayout == vk::ImageLayout::eShaderReadOnlyOptimal)
        {
            imageMemoryBarrier.srcAccessMask = vk::AccessFlagBits::eTransferWrite; // The 1st stage has written data into image
            imageMemoryBarrier.dstAccessMask = vk::AccessFlagBits::eShaderRead;    // The 2nd stage reads texels from image

            src_stage = vk::PipelineStageFlagBits::eTransfer;
            dst_stage = vk::PipelineStageFlagBits::eFragmentShader;
        }

        commandBuffer.pipelineBarrier(
            /*srcStageMask*/ src_stage, // 1st pipeline stage
            /*dstStageMask*/ dst_stage, // 2nd pipeline stage
            /*dependencyFlags*/ vk::DependencyFlags{},
            /*memoryBarriers*/ nullptr,
            /*bufferMemoryBarriers*/ nullptr,
            /*imageMemoryBarriers*/ imageMemoryBarrier);
    }
    EndAndSubmitCommandBuffer(commandBuffer);
}

Host-Visible Buffer & Mapped Pointer

Итак, если мы вернемся к рисунку 2, то мы увидим, что у нас есть два вида буферов памяти: host-visible и device-local. Host-visibility означает, что наш CPU может получить непосредственный доступ к памяти через т. н. mapped pointer, т. е. просто через указатель, который указывает на некий участок в адресном пространстве CPU. Правда сначала нужно «спроецировать» (map) буфер на это адресное пространство. Ниже показан класс HostVisibleBuffer, который наследует классу VkMemoryBuffer и в который добавлены несколько методов как раз для проецирования буфера на адресное пространство CPU. Спроецировав буфер, мы получаем указатель, который можем использовать например для копирования данных из оперативной памяти в буфер или наборот.

class HostVisibleBuffer : public VkMemoryBuffer
{
    public:
        HostVisibleBuffer(
            const VkContext& vkContext,
            size_t bufferSize,
            vk::BufferUsageFlags usage) :
            VkMemoryBuffer(
                vkContext,
                bufferSize,
                /*usage*/ usage,
                // CPU-visible and not needing flushing
                /*appMemoryRequirements*/ vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent)
        {}

    public:
        // Copies data from memory to buffer
        void set(size_t offset, size_t size, const void* srcData)
        {
            // MAP MEMORY -> COPY DATA -> UNMAP MEMORY
            void* data = Map(offset, size);
            {
                std::memcpy(
                    data,    // destination
                    srcData, // source
                    size);   // size
            }
            Context().LogicalDevice.unmapMemory(LocalMemory());
        }

        void* Map(size_t offset, size_t size)
        {
            void* data;
            Context().LogicalDevice.mapMemory(
                LocalMemory(),
                offset, // offset
                size,   // size
                vk::MemoryMapFlags{}, // flags
                &data);
            return data;
        }

        void Unmap()
        {
            Context().LogicalDevice.unmapMemory(LocalMemory());
        }
};

Gpu Buffer & Staging Buffer

С host-visible буфером дело обстоит довольно просто, поскольку обращение с ним по сути мало чем отличается от обращения к оперативной памяти (например, для копирования данных в буфер мы используем стандартную функцию из библиотеки языка Си memcpy). Другое дело — device-local буфер. Определенно можно сказать, что он находится в памяти видеокарты и притом эта память не может быть спроецирована на адресное пространство CPU. Для записи данных в такой буфер приходится, во-первых, создавать отдельный host-visible буфер (он называется staging buffer) и помещать в него данные для записи, а затем посылать GPU некие команды копирования данных из host-visible буфера в device-local буфер, для чего эти команды нужно записать в некий командный буфер и отправлять на выполнение в некую очередь команд. Чтение данных из device-local буфера выглядит аналогично: мы создаем staging buffer и посылаем GPU команды копирования из device-local буфера в этот staging buffer. Ну а уж со staging buffer’ом мы работаем через mapped pointer. Всё это специфическое поведение device-local буфера я поместил в класс под названием GpuBuffer:

// Represents a buffer object that stores its data in local GPU memory.
// Such kind of buffer fits well for data that don't need to be changed very frequently by the host CPU,
// because reading-writing those data by the CPU involves moving data between conventional memory and GPU memory
// and this is not very efficient.
class GpuBuffer : public VkMemoryBuffer
{
    public:
        GpuBuffer(
            const VkContext& vkContext,
            size_t bufferSize,
            vk::BufferUsageFlags usage) :
            VkMemoryBuffer(
                vkContext,
                bufferSize,
                // Buffer can be a data transfer destination (from a staging buffer)
                /*usage*/ vk::BufferUsageFlagBits::eTransferDst | usage,
                // CPU-visible and not needing flushing
                /*appMemoryRequirements*/ vk::MemoryPropertyFlagBits::eDeviceLocal)
        {}

    public:
        /// <summary>
        /// Copies data from host memory to GPU memory.
        /// This operation consumes time and memory resources, so
        /// it's recommended that you use it rarely.
        /// </summary>
        /// <param name="source">Pointer to the source buffer in host memory</param>
        /// <param name="offset">Offset to the GPU buffer at which data will be copied</param>
        /// <param name="size">Size of data being copied in bytes</param>
        void setData(
            _In_ _Notnull_ const void* source,
            size_t offset,
            size_t size)
        {
            util::Requires::That(
                source != nullptr &&
                size <= Size() &&
                offset >= 0 &&
                offset + size <= Size(),
                FUNCTION_INFO);

            // *****************************************************
            // ***             CREATE STAGING BUFFER             ***
            // *****************************************************
            auto stagingBuffer = CreateStagingBuffer_Src(Context(), size);

            stagingBuffer.set(
                /*offset*/ 0,
                /*size*/ size,
                /*srcData*/ source);

            // *****************************************************
            // *** COPY DATA FROM STAGING BUFFER TO LOCAL BUFFER ***
            // *****************************************************
            // Data are copied from the host memory to the GPU memory
            // through the execution of a copy command that must be
            // placed in a command buffer.
            CopyBuffer(
                Context(),
                /*srcBuffer*/ stagingBuffer.Buffer(),
                /*dstBuffer*/ Buffer(),
                /*srcOffset*/ 0,
                /*dstOffset*/ offset,
                /*size*/ size);

            // Once we have copied data from the host memory to
            // the GPU memory, we no longer need staging buffer.
            stagingBuffer.Destroy();
        }

        /// <summary>
        /// Copies data from host memory to GPU memory.
        /// This operation consumes time and memory resources, so
        /// it's recommended that you use it rarely.
        /// </summary>
        /// <param name="source">Pointer to the source buffer in host memory</param>
        void setData(
            _In_ _Notnull_ const void* source)
        {
            setData(source, 0, Size());
        }

        /// <summary>
        /// Copies data from GPU memory to host memory.
        /// This operation consumes time and memory resources, so
        /// it's recommended that you use it rarely.
        /// </summary>
        /// <param name="destination">Pointer to the destination buffer in host memory</param>
        /// <param name="offset">Offset to the GPU buffer at which data will be taken</param>
        /// <param name="size">Size of data being copied in bytes</param>
        void* getData(
            _Out_ _Notnull_ void* destination,
            size_t offset,
            size_t size)
        {
            util::Requires::That(
                destination != nullptr &&
                size <= Size() &&
                offset >= 0 &&
                offset + size <= Size(),
                FUNCTION_INFO);

            // *****************************************************
            // ***             CREATE STAGING BUFFER             ***
            // *****************************************************
            auto stagingBuffer = CreateStagingBuffer_Dst(Context(), size);

            // *****************************************************
            // *** COPY DATA FROM LOCAL BUFFER TO STAGING BUFFER ***
            // *****************************************************
            // Data are copied from the GPU memory to the host memory
            // through the execution of a copy command that must be
            // placed in a command buffer.
            CopyBuffer(
                Context(),
                /*srcBuffer*/ Buffer(),
                /*dstBuffer*/ stagingBuffer.Buffer(),
                /*srcOffset*/ offset,
                /*dstOffset*/ 0,
                /*size*/ size);

            // MAP MEMORY -> COPY DATA -> UNMAP MEMORY
            void* mapped_mem = stagingBuffer.Map();
            {
                std::memcpy(
                    destination,   // destination
                    mapped_mem,    // source
                    Size()); // size
            }
            stagingBuffer.Unmap();

            // Once we have copied data from the host memory to
            // the GPU memory, we no longer need staging buffer.
            stagingBuffer.Destroy();

            return destination;
        }
};

Images: Textures & Attachments

Что касается изображений, то все они могут быть представлены одним классом, который я назвал VkImage2d. Например, текстура — это ни что иное как изображение. Однако именно при создании текстуры есть нюанс — мы ведь должны скопировать в нее изображение из оперативной памяти. А для этого у текстуры должен быть определенный layout, а именно VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL. В свою очередь, после того, как мы скопировали изображение в текстуру, ее будет считывать (sampl’ить) наш pipeline, а для этого текстура должна иметь layout VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL. Все эти трансформации layout’а отражены в функции CreateTexture:

// Create a texture
VkImage2d CreateTexture(
    const VkContext& vkContext,
    const util::Image& image)
{
    VkImage2d texture(
        physicalDevice,
        logicalDevice,
        image.width(),
        image.height(),
        /*format*/ vk::Format::eR8G8B8A8Unorm,
        /*tiling*/ vk::ImageTiling::eOptimal,
        /*useFlags*/ vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled,
        /*propFlags*/ vk::MemoryPropertyFlagBits::eDeviceLocal,
        /*aspectFlags*/ vk::ImageAspectFlagBits::eColor),

    HostVisibleBuffer imageStagingBuffer(
        image.size(), /*usage*/ vk::BufferUsageFlagBits::eTransferSrc);
    imageStagingBuffer.set(image.data());

    TransitionImageLayout(
        vkContext,
        texture.Image(),
        /*oldLayout*/ vk::ImageLayout::eUndefined,
        /*newLayout*/ vk::ImageLayout::eTransferDstOptimal);

    CopyBufferToImage(
        vkContext,
        /*source*/ imageStagingBuffer.Buffer(),
        /*destination*/ texture.Image(),
        /*srcOffset*/ 0,
        /*dstOffset*/ vk::Offset3D {0, 0, 0},
        texture.width(),
        texture.height());

    TransitionImageLayout(
        vkContext,
        texture.Image(),
        /*oldLayout*/ vk::ImageLayout::eTransferDstOptimal,
        /*newLayout*/ vk::ImageLayout::eShaderReadOnlyOptimal);

    return texture;
}

Что же касается буфера цвета и буфера глубины, то они являются у нас attachment’ами, ссылки на которые содержатся в структурах под названием Framebuffer, ссылки на которые в свою очередь указываются при записи команд отрисовки в командный буфер (это тема следующей заметки). Layout’ы attachment’ов указываются в структуре RenderPass, которую мы рассматривали ранее. Переходы между layout’ами выполняются автоматически. Поэтому создание буферов цвета и глубины суть просто создание объектов VkImage2d с передачей некоторых специфических параметров в конструктор этого класса:

// Create depth buffer (it is usually mandatory for any 3d-rendering)
inline VkImage2d CreateDepthBuffer(
    const vk::PhysicalDevice& physicalDevice,
    const vk::Device& logicalDevice,
    const vk::Extent2D& imageExtent,
    vk::ImageUsageFlags usage = vk::ImageUsageFlagBits{})
{
    return VkImage2d(
        physicalDevice,
        logicalDevice,
        imageExtent.width,
        imageExtent.height,
        /*format*/ FindDepthFormat(physicalDevice),
        /*tiling*/ vk::ImageTiling::eOptimal,
        /*useFlags*/ vk::ImageUsageFlagBits::eDepthStencilAttachment | usage,
        /*propFlags*/ vk::MemoryPropertyFlagBits::eDeviceLocal,
        /*aspectFlags*/ vk::ImageAspectFlagBits::eDepth);
}

// Create color buffer (for example, for deferred rendering).
inline VkImage2d CreateColorBuffer(
    const vk::PhysicalDevice& physicalDevice,
    const vk::Device& logicalDevice,
    const vk::Extent2D& swapChainExtent,
    vk::ImageUsageFlags usage = vk::ImageUsageFlagBits{})
{
    return VkImage2d(
        physicalDevice,
        logicalDevice,
        swapChainExtent.width,
        swapChainExtent.height,
        /*format*/ FindColorBufferFormat(physicalDevice),
        /*tiling*/ vk::ImageTiling::eOptimal,
        /*useFlags*/ vk::ImageUsageFlagBits::eColorAttachment | usage,
        /*propFlags*/ vk::MemoryPropertyFlagBits::eDeviceLocal,
        /*aspectFlags*/ vk::ImageAspectFlagBits::eColor);
}

Дополним нашу объектную модель буферами памяти:

Рис. 3 — Объектная модель

На этом всё. Мы рассмотрели где хранятся данные о трехмерных объектах. Теперь нам осталось узнать, как эти данные пристыковать к пайплайну. Это делается через определенные команды, которые записываются в командный буфер, о чем мы и поговорим в следующей заметке.

Добавить комментарий

Ваш адрес email не будет опубликован.