steps3D - Tutorials - Vulkan with classes-2

Vulkan with classes-2

Работа с дескрипторами

Также нам понадобится класс для работы со множествами дескрипторов (descriptor sets). Обратите внимание, что множества дескрипторов в Vulkan не создаются явно, а выделяются из специальных пулов. При создании такого пула нужно явно указать максимальное число дескрипторов для каждого типа. Понятно, что это довольно не удобно. Поэтому мы будем использовать специальный класс DescriptorAllocator, задачей которого будет выделение и освобождение множество дескрипторов. Внутри себя класс будет иметь массив пулов, организованных в два массива - активные пулы (из которых уже делались выделения) и свободные пулы. По мере необходимости класс создает новые пулы. Выделение множеств дескрипторов происходит через метод alloc, метод reset освобождает все выделенные ранее множества дескрипторов.

class DescriptorAllocator
{
public:
    struct PoolSizes
    {
        std::vector<std::pair<VkDescriptorType,float>> sizes =
        {
            { VK_DESCRIPTOR_TYPE_SAMPLER,                0.5f },
            { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 4.f  },
            { VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,          4.f  },
            { VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,          1.f  },
            { VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER,   1.f  },
            { VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER,   1.f  },
            { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,         2.f  },
            { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,         2.f  },
            { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, 1.f  },
            { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC, 1.f  },
            { VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT,       0.5f }
        };
    };

    void create ( Device& newDevice );  // start allocator
    void reset ();                      // reset all pools and move them to freePools
    void clean ();                      // destroy allocator
                                        // allocate descriptor set
    VkDescriptorSet alloc ( VkDescriptorSetLayout layout );

    void    setMultiplier ( VkDescriptorType type, float factor )
    {
        for ( auto& p : descriptorSizes.sizes )
            if ( p.first == type )
                p.second = factor;
    }

private:
    VkDescriptorPool pickPool ();       // pick appropriate pool for allocation

    VkDevice                        device      = VK_NULL_HANDLE;
    VkDescriptorPool                currentPool = VK_NULL_HANDLE;
    PoolSizes                       descriptorSizes;
    std::vector<VkDescriptorPool>   usedPools;        // active pools with allocated items
    std::vector<VkDescriptorPool>   freePools;  
};

Для инкапсуляции самих множеств дескрипторов мы будем использовать класс DescriptorSet, содержащий в себе ссылку на устройство, на DescriptorAllocator, само множество дескрипторов и его layout. С помощью метода setLayout мы задаем layout (структуру) множества дескрипторов.

class   DescriptorSet
{
    Device                            * device              = nullptr;
    DescriptorAllocator               * allocator           = nullptr;
    VkDescriptorSet                     set                 = VK_NULL_HANDLE;
    VkDescriptorSetLayout               descriptorSetLayout = VK_NULL_HANDLE;
    std::vector<VkWriteDescriptorSet>   writes;

public:
    DescriptorSet () = default;
    DescriptorSet ( DescriptorSet&& ) 
    { 
        fatal () << "DescriptorSet move c-tor called" << Log::endl; 
    }
    DescriptorSet ( const DescriptorSet& ) = delete;
    ~DescriptorSet ()
    {
        clean ();
    }

    VkDescriptorSet getHandle () const
    {
        return set;
    }

    void    clean ()
    {
        for ( auto& d : writes )
        {
            delete d.pBufferInfo;
            delete d.pImageInfo;
        }

        writes.clear ();
    }

    DescriptorSet&  setLayout (  Device& dev, DescriptorAllocator& descAllocator, const DescSetLayout& descSetLayout );

Метод create подготавливает множество дескрипторов к использованию (создавая соответствующий объект Vulkan).

void    create ()
{
    if ( set == VK_NULL_HANDLE )
        alloc ();
                
    vkUpdateDescriptorSets ( device->getDevice (), static_cast<uint32_t>(writes.size()), writes.data(), 0, nullptr );
}

Также у этого класса есть целая группа методов add*, добавляющих дескриптор определенного типа, ниже мы приведем некоторые из них.

DescriptorSet& addUniformBuffer ( uint32_t binding, Buffer& buffer, VkDeviceSize offset = 0, VkDeviceSize size = VK_WHOLE_SIZE );
DescriptorSet& addStorageBuffer ( uint32_t binding, Buffer& buffer, VkDeviceSize offset = 0, VkDeviceSize size = VK_WHOLE_SIZE );
DescriptorSet&  addImage           ( uint32_t binding, Texture& texture, Sampler& sampler );
DescriptorSet&  addSampler       ( uint32_t binding, Sampler& sampler );

Pipeline

Еще одним объектом Vulkan, который мы завернем в класс С++, будет объект-конвейер (pipeline). При этом на самом деле у нас будет два разных конвейера - графический (GraphicsPipeline) и вычислительный (ComputePipeline). Сам класс GraphicsPipeline инкапсулирует весь графический конвейер (со всеми его частями). Обычно для задания графического конвейера необходимо задать большое число вспомогательных структур, в которых задаются все свойства конвейера (в Vulkan для конвейера нет значений по умолчанию).

Ясно что это довольно неудобно. Поэтому мы инкапсулируем все свойства графического конвейера (GraphicsPipeline) и для большинства из них мы зададим значения по умолчанию - таким образом нужно будет явно задавать только небольшую часть этих свойств. Вместо приведения здесь полного кода класса GraphicsPipeline (который на самом деле очень велик и включает в себя ряд вспомогательных классов) мы приведем ниже просто пример его использования.

pipeline
    .setDevice ( device )
    .setVertexShader   ( "shaders/pbr.vert.spv" )
    .setFragmentShader ( "shaders/pbr.frag.spv" )
    .setSize           ( swapChain.getExtent () )
    .addVertexBinding  ( sizeof ( BasicVertex ) )
    .addVertexAttributes <BasicVertex> ()
    .addDescLayout     ( 0, DescSetLayout ()
        .add ( 0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,         VK_SHADER_STAGE_VERTEX_BIT   )
        .add ( 1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT )
        .add ( 2, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT )
        .add ( 3, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT )
        .add ( 4, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT ) )
    .setCullMode       ( VK_CULL_MODE_NONE               )
    .setFrontFace      ( VK_FRONT_FACE_COUNTER_CLOCKWISE )
    .create            ( renderPass );

Как видно из приведенного кода мы смогли сильно упростить работу по созданию и настройке графического конвейера и получить при этом гораздо более удобно-читаемый код.

Swap chain

Одним из существенных отличий Vulkan от OpenGL является так называемый swap chain. В OpenGL мы обычно считаем что у нас имеется двойная буферизация, т.е. у нас есть два буфера, в один из которых осуществляется рендеринг, а другой (точнее его цветовая часть) в это время показывается на экране. В действительности сейчас все немного не так. У нас есть набор изображений, в которые мы можем осуществлять рендеринг, причем в любой из них. Также любой из них может быть показан на экране (present).

Объект swap chain это и есть такой набор изображений. В любой момент времени мы можем запросить у него изображение для рендеринга (acquireNextImage). Также в любой момент времени мы можем сказать что изображение с заданным номером готово к показу на экране в заданной очереди (presentQueue). Для наилучшего качества показа анимации можно использовать несколько различных режимов показа (present mode).

Мы завернем все это в класс SwapChain. Кроме собственно массива изображений он будет содержать в себе набор примитивов синхронизации, служащих для синхронизации рендеринга и показа готовых изображений. Мы позволим задавать через параметр конструктора класса хотим ли мы использовать для изображений обычный формат RGB или же формат sRGB. В результате SwapChain запросит у устройства список поддерживаемых форматов и выберет из лучшей поддерживающий RGB/sRGB и способ показа.

VulkanWindow

Теперь из базовых классов, необходимых для создания простого приложения на Vulkan, у нас остается класс VulkanWindow. Этот класс инкапсулирует окно для рендеринга (аналогично классу GlutWindow, использованному во многих примерах на OpenGL) Поведения класса будет конфигурироваться двумя способами - через переопределение виртуальных методов и передачу в конструктор указатель на объект-политику (policy). Необходимость использования последнего связана с тем, что есть ряд операций, которые было бы правильно выполнить непосредственно в конструкторе класса. Но в этом случае мы лишаемся возможности управлять поведением через переопределение методов для унаследованных классов.

Именно поэтому эти действия вынесены в отдельный класс, указатель на который и передается в конструктор VulkanWindow. В этот класс вынесены методы pickDevice для выбора подходящего устройства (если их несколько), метод isDeviceSuitable проверяющий подходит ли данное устройства, validateLayers и deviceExtensions для получения списка слоев валидации и списка расширения для устройства.

struct  DevicePolicy
{
    DevicePolicy () = default;
    virtual ~DevicePolicy () = default;

        // pick appropriate physical device
    virtual VkPhysicalDevice    pickDevice ( VkInstance instance, std::vector<VkPhysicalDevice>& devices, VkSurfaceKHR surface ) const
    {
        for ( const auto& dev : devices )               // check every device
            if ( isDeviceSuitable ( dev, surface ) )    // use surface and checks for queue families
                return dev;

        return VK_NULL_HANDLE;
    }

        // check whether this device is ok
    virtual bool    isDeviceSuitable ( VkPhysicalDevice device, VkSurfaceKHR surface ) const
    {
        QueueFamilyIndices indices = QueueFamilyIndices::findQueueFamilies ( device, surface );

        return indices.isComplete ();
    }

    virtual std::vector<const char*> validationLayers () const
    {
        return { "VK_LAYER_KHRONOS_validation" };
    };

    virtual std::vector<const char*> instanceExtensions () const
    {
        return {};
    };

    virtual std::vector<const char*> deviceExtensions () const
    {
        return { VK_KHR_SWAPCHAIN_EXTENSION_NAME };
    };
};

Задачей класса VulkanWindow является создание окна (при помощи библиотеки GLFW, которая на самом деле поддерживает не только OpenGL, но и Vulkan), установка обработчиков сообщений, инициализация устройства (физического и логического), получение очередей для устройства и т.п. Также он отвечает за рендеринг анимаций, изменение размеров окна и расчет FPS. Внутри себя он содержит свойства, необходимые для создания экземпляра (instance), выделения дескрипторов, рендеринга и показа готовых изображений и многое другое.

Конструктор класса сразу создает окно с заданными свойствами и инициализирует необходимые объекты и ресурсы. Как и в классе GlutWindow данный класс содержит методы getCaption, setCaption, getWidth, getHeight, getAspect, getTime, setFullscreen и многие другие.

class   VulkanWindow
{
protected:
    enum
    {
        MAX_FPS_FRAMES = 5                          // maximum number of frames we use for FPS computing
    };

    std::string         appName      = "Vulkan application";
    std::string         engineName   = "No engine";
    DevicePolicy      * policy       = nullptr;
    GLFWwindow        * window       = nullptr;
    bool                hasDepth     = true;        // do we should attach depth buffer to FB
    bool                srgb         = false;
    uint32_t            currentImage = 0;
    Controller        * controller   = nullptr;
    bool                showFps      = false;
    bool                fullScreen   = false;
    int                 frame        = 0;           // current frame number
    float               frameTime [MAX_FPS_FRAMES]; // time at last 5 frames for FPS calculations
    float               fps;
    int                 savePosX, savePosY;         // save pos & size when going fullscreen
    int                 saveWidth, saveHeight;  
    int                 width, height;
    std::string         title;
    std::string         assetPath;                  // path to various assets

    Device                          device;
    VkDebugUtilsMessengerEXT        debugMessenger = VK_NULL_HANDLE;
    VkDebugReportCallbackEXT        msgCallback    = VK_NULL_HANDLE;
    VkSurfaceKHR                    surface        = VK_NULL_HANDLE;
    VkInstance                      instance       = VK_NULL_HANDLE;
    SwapChain                       swapChain;
    Texture                         depthTexture;   // may be empty
    DescriptorAllocator             descAllocator;

public:
    VulkanWindow ( int w, int h, const std::string& t, bool depth = true, DevicePolicy * p = nullptr ) : hasDepth ( depth )
    {
        if ( p == nullptr )
            p = new DevicePolicy ();

        initWindow ( w, h, t );
        initVulkan ( policy = p );
    }

    ~VulkanWindow ()
    {
        descAllocator.clean ();
        depthTexture.clean  ();
        clean ();
        delete policy;
    }

    void    setCaption ( const std::string& t )
    {
        title = t;
        glfwSetWindowTitle ( window, title.c_str () );
    }
    
    std::string getCaption () const
    {
        return title;
    }
    
    double  getTime () const    // return time in seconds 
    {
        return glfwGetTime ();
    }

    GLFWwindow * getWindow () const
    {
        return window;
    }

    uint32_t    getWidth () const
    {
        return swapChain.getExtent ().width;
    }
    
    uint32_t    getHeight () const
    {
        return swapChain.getExtent ().height;
    }

    VkExtent2D  getExtent () const
    {
        return swapChain.getExtent ();
    }

    SwapChain&  getSwapChain ()
    {
        return swapChain;
    }

    DescriptorAllocator&    getDescriptorAllocator ()
    {
        return descAllocator;
    }
    
    Texture&    getDepthTexture ()
    {
        return depthTexture;
    }

    void    setSize ( uint32_t w, uint32_t h )
    {
        glfwSetWindowSize ( window, width = w, height = h );
    }
    
    float   getAspect () const
    {
        return static_cast<float> ( getWidth () ) / static_cast<float> ( getHeight () );
    }

    void    setShowFps ( bool flag )
    {
        showFps = flag;
    }
    
    bool    isFullScreen () const
    {
        return fullScreen;
    }

    const std::string   getAssetsPath () const
    {
        return assetPath;
    }

    void    setAssetPath ( const std::string& path )
    {
        assetPath = path;
    }

Метод run отвечает за запуск рендеринга и анимации. Метод drawFrame отвечает за рендеринг очередного кадра, а метод submit отвечает за показ заданного кадра. Задача методов createPipelines и freePipelines является создание и уничтожение pipeline-объектов и объектов, связанных с ними.

Методы reshape, keyTyped, mouse* и idle это обработчики событий (аналогичные имеющимся в GlutWindow, обратите внимание, что есть небольшая разница в обработке событий от мыши и клавиатуры в связана с отличиями в API GLFW и freeglut).

Первое Vulkan-приложение на классах

Сейчас мы рассмотрим написание простейшего приложения с использованием описанных ранее классов. В этом приложении мы будем выводить всего один треугольник, без каких-либо текстур и матриц преобразования, для каждой вершины мы зададим координаты и цвет.

-- скриншот

#include    "VulkanWindow.h"

struct Vertex 
{
    glm::vec2 pos;
    glm::vec3 color;
};

template <>
GraphicsPipeline&   registerVertexAttrs<Vertex> ( GraphicsPipeline& pipeline ) 
{
    return pipeline
        .addVertexAttr ( 0, 0, VK_FORMAT_R32G32B32_SFLOAT, offsetof(Vertex, pos)   )        // binding, location, format, offset
        .addVertexAttr ( 0, 1, VK_FORMAT_R32G32B32_SFLOAT, offsetof(Vertex, color) );
}

class   ExampleWindow  final : public VulkanWindow
{
    std::vector<CommandBuffer>  commandBuffers;
    std::vector<DescriptorSet>  descriptorSets;
    GraphicsPipeline            pipeline;
    Renderpass                  renderPass;
    Buffer                      vertexBuffer;

public:
    ExampleWindow ( int w, int h, const std::string& t ) : VulkanWindow ( w, h, t )
    {
        const std::vector<Vertex> vertices = 
        {
            { {0.0f, -0.5f},  {1.0f, 0.0f, 0.0f}},
            { {0.5f,  0.5f},  {0.0f, 1.0f, 0.0f}},
            {{-0.5f,  0.5f},  {0.0f, 0.0f, 1.0f}}
        };

        vertexBuffer.create ( device, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, vertices, Buffer::hostWrite );

        createPipelines ();     
    }

    virtual void    createPipelines () override 
    {
            // default renderPass
        createDefaultRenderPass ( renderPass );

        pipeline
            .setDevice ( device )
            .setVertexShader   ( "shaders/shader.2.vert.spv" )
            .setFragmentShader ( "shaders/shader.2.frag.spv" )
            .setSize           ( swapChain.getExtent () )
            .addVertexBinding  ( sizeof ( Vertex ) )
            .addVertexAttributes <Vertex> ()
            .addDescLayout     ( 0, DescSetLayout () )
            .create            ( renderPass );

            // create before command buffers
        swapChain.createFramebuffers ( renderPass, depthTexture.getImageView () );      // m.b. depthTexture instead of getImageView ???

        createDescriptorSets ();
        createCommandBuffers ( renderPass );
    }

    virtual void    freePipelines () override
    {
        commandBuffers.clear ();
        pipeline.clean       ();
        renderPass.clean     ();
        descriptorSets.clear ();
        descAllocator.clean  ();
    }

    virtual void    submit ( uint32_t imageIndex ) override 
    {
        defaultSubmit ( commandBuffers [imageIndex] );
    }

    void    createDescriptorSets ()
    {
        descriptorSets.resize ( swapChain.imageCount () );

        for ( auto& desc : descriptorSets )
            desc.setLayout ( device, descAllocator, pipeline.getDescLayout () ).create ();
    }

    void    createCommandBuffers ( Renderpass& renderPass )
    {
        auto&   framebuffers = swapChain.getFramebuffers ();
        auto    size         = framebuffers.size ();

        commandBuffers = device.allocCommandBuffers ( (uint32_t)size );

        for ( size_t i = 0; i < size; i++ )
            commandBuffers [i].begin ().beginRenderPass ( RenderPassInfo ( renderPass ).framebuffer ( framebuffers [i] ).extent ( swapChain.getExtent () ).clearColor ( 0, 0, 0, 1 ).clearDepthStencil () )
                .pipeline          ( pipeline )
                .bindVertexBuffers (  { {vertexBuffer, 0} } )
                .addDescriptorSets ( {descriptorSets[i]} )
                .draw              ( 3 )
                .end               ();
    }
};

int main ( int argc, char * argv )
{
    return ExampleWindow ( 800, 600, "Vulkan example" ).run ();
}

Структура Vertex описывает формат используемой нами для рендеринга вершина, а шаблонная функция registerVertexAttrs регистрирует все атрибуты вершины в заданном графическом конвейере. Для каждого вершинного атрибута мы задаем его binding (к какому вершинному буферу он относится), его номер (в директиве layout в шейдере), формат и смещение внутри вершины.

Мы создадим новый класс ExampleWindow, унаследовав его от VulkanWindow. В нем нам понадобится буфер с вершинами (объекта класса Buffer), графический конвейер и проход рендеринга (объект класса RenderPass).

Как уже отмечалось в Vulkan в отличии от OpenGL у нас есть целый набор буферов, в которые осуществляется рендеринг. При этом возможна ситуация, когда мы еще не закончили рендеринг в один буфер, а уже приступаем к рендерингу в следующий. Это значит, что мы не можем изменять какие-то данные, используемые при рендеринге предыдущего кадра. Это значит, что у нас должен быть массив uniform/storage-буферов, массивов дескрипторов (поскольку они ссылаются на них), командных буферов (т.к. в них явно задается куда осуществляется рендеринг). Поэтому ряд объектов у нас представлены в нескольких экземплярах и хранятся в std::vector.

При создании окна или изменении его размеров мы вызываем метод createPipelines для подготовки объектов-конвейеров (и связанных с ними объектов) к работе.

virtual void    createPipelines () override 
{
        // default renderPass
    createDefaultRenderPass ( renderPass );

    pipeline
        .setDevice ( device )
        .setVertexShader   ( "shaders/shader.2.vert.spv" )
        .setFragmentShader ( "shaders/shader.2.frag.spv" )
        .setSize           ( swapChain.getExtent () )
        .addVertexBinding  ( sizeof ( Vertex ) )
        .addVertexAttributes <Vertex> ()
        .addDescLayout     ( 0, DescSetLayout () )
        .create            ( renderPass );

        // create before command buffers
    swapChain.createFramebuffers ( renderPass, depthTexture.getImageView () );      

    createDescriptorSets ();
    createCommandBuffers ( renderPass );
}

virtual void    freePipelines () override
{
    commandBuffers.clear ();
    pipeline.clean       ();
    renderPass.clean     ();
    descriptorSets.clear ();
    descAllocator.clean  ();
}

Нам потребуется объект прохода рендеринга, но мы можем использовать код его инициализации по умолчанию (это справедливо для большинства примеров) createDefaultRenderPass.

Далее мы создаем и настраиваем объект типа GraphicsPipeline, задавая для него все необходимые параметры и используя параметры по умолчанию.

После этого нам необходимо подготовить swap chain к рендерингу, создав необходимые изображения и виды для них, фреймбуферы и объекты синхронизации при помощи вызова createFramebuffers.

Далее мы создаем множество дескрипторов (через вызов createDescriptorSets) и командные буфера (через вызов createCommandBuffers).

Для создания множества дескрипторов мы просто задаем нужный размер массиву множеств дескрипторов (по числу изображений в swap chain) и для каждого из них задаем layout из графического конвейера.

void    createDescriptorSets ()
{
    descriptorSets.resize ( swapChain.imageCount () );

    for ( auto& desc : descriptorSets )
        desc.setLayout ( device, descAllocator, pipeline.getDescLayout () ).create ();
}

Более сложным является создание массива командных буферов - мы также начинаем с задания размера массива. После этого для каждого буфера мы начинаем запись в него (begin), начинаем проход рендеринга (beginRenderPass), задаем фреймбуфер, куда будет осуществляться рендеринг (метод framebuffer), задаем размер области рендеринга (extent), задаем цвет для очистки буфера цвета (clearColor) и значения для очистки буферов глубины и трафарета (clearDepthStencil), задаем используемый конвейер (pipeline), массив используемых вершинных буферов (bindVertexBuffers) и множества дескрипторов (addDescriptorSet) После этого мы осуществляем рендеринг треугольника (draw) и завершаем запись в буфер (end).

void    createCommandBuffers ( Renderpass& renderPass )
{
    auto&   framebuffers = swapChain.getFramebuffers ();
    auto    size         = framebuffers.size ();

    commandBuffers = device.allocCommandBuffers ( (uint32_t)size );

    for ( size_t i = 0; i < size; i++ )
        commandBuffers [i].begin ().beginRenderPass ( RenderPassInfo ( renderPass ).framebuffer ( framebuffers [i] )
                .extent ( swapChain.getExtent () ).clearColor ( 0, 0, 0, 1 ).clearDepthStencil () )
            .pipeline          ( pipeline )
            .bindVertexBuffers (  { {vertexBuffer, 0} } )
            .addDescriptorSets ( {descriptorSets[i]} )
            .draw              ( 3 )
            .end               ();
}

Для показа кадра с заданным номеров мы переопределяем метод submit (просто вызывая defaultSubmit) Также нам нужно переопределить метод freePipelines для освобождения конвейера и связанных с ним объектов. Обратите внимание, что при задании ряда объектов мы явно указываем размер области рендеринга. Это значит при при изменении размеров окна все эти объекты нужно пересоздать. Именно для этого и используется пара методов freePipelines/createPipelines.

virtual void    submit ( uint32_t imageIndex ) override 
{
    defaultSubmit ( commandBuffers [imageIndex] );
}

Все файлы для этого примера доступны на github - https://github.com/steps3d/vulkan-with-classes.git.