steps3D - Tutorials - Vulkan with classes

Vulkan с классами 3 - работа с текстурами

Давайте сейчас рассмотрим написание на Vulkan с использованием вспомогательных классов более сложного приложения - у нас будет честный 3D, uniform-буфер и текстура.

Для начала нам понадобится класс Texture, "заворачивающий" в себя текстуру. Он будет включать в себя как класс Image (инкапсуляция изображения на Vulkan), так и image view. Также для чтения из текстуры нам понадобится класс Sampler.

Класс Image

Также как мы завернули буфер в класс Buffer, будет удобно завернуть изображение Vulkan (VkImage) в класс Image. Как и буфер это не копируемый (но moveable) объект, значит его можно хранить в стандартных контейнерах.

У него есть метод clean очищающий объект (и уничтожающий все связанные с ним ресурсы), метод isOk возвращает статус изображения (можно ли его прямо сейчас использовать).

Также имеется ряд get-методов, возвращающих свойства изображения - getWidth, getHeight, getDepth, getMipmapLevels, getArrayLayers, getHandle (возвращающий соответствующий VkImage), getDevice. Метод getFormat возвращает формат изображения, метод hasDepth возвращает содержит ли это изображение значения глубины (т.е. изображение либо depth либо depth-stencil). Также есть методы hasStencil и isDepthStencil.

Для доступа к памяти, соответствующей изображению, можно использовать методы map и unmap, отвечающие за отображение памяти изображения в память CPU. Также для доступа к памяти служит метод copyFromBuffer.

void    copyFromBuffer   ( SingleTimeCommand& cmd, Buffer& buffer, uint32_t width, uint32_t height,
                            uint32_t depth = 1, uint32_t layers = 1, uint32_t mipLevel = 0 );

У класса Image имеется два метода create для создания изображения с заданными свойствами.

bool    create ( Device& dev, uint32_t w, uint32_t h, uint32_t d, uint32_t numMipLevels, VkFormat fmt, VkImageTiling tl,
                 VkImageUsageFlags usage, int mapping, VkImageLayout initialLayout = VK_IMAGE_LAYOUT_UNDEFINED );
 
bool    create ( Device& dev, ImageParams& info, int mapping );

Второй вариант метода create вместо явной передачи всех требуемых свойств изображения используем объект ImageParams, содержащий все эти свойства и позволяющих задавать их в удобной форме.

image.create ( ImageCreateInfo ( width, height ).setFormat ( myFmt ).setUsage ( usage ) );

Метод transitionLayout служит для перевода изображения из одного внутреннего представления - layout - (например, оптимизированного для копирования со стороны CPU) в другое (например, оптимизированного для чтения из шейдера).

void    transitionLayout ( SingleTimeCommand& cmd, VkFormat format, VkImageLayout oldLayout, VkImageLayout newLayout );

Класс Texture

На самом деле нам обычно нужно не изображение (image), а текстура, из которой можно читать в шейдере. Для этого нужно к изображению (image) добавить вид на изображение (image view), задающий способ интерпретации изображения. Для этого мы создадим класс Texture, содежащий внутри себя как объект класса Image, так и объект типа VkImageView.

Как и Image, данный класс будет не копируемым, но moveable. Метод clean будет освобождать все выделенные ресурсы, а набор get-методов буфет возвращать свойства текстуры.

Для создания текстуры (без загрузки в нее данных) мы будем использовать метод create. Методы load, loadCubemap и loadRaw отвечают за создание и загрузку данных (текселов) в изображение. Метод generateMipmaps будет строить все уровни mipmap-пирамиды по нулевому.

void    generateMipmaps ( SingleTimeCommand& cmd, VkFormat imageFormat, int32_t texWidth, int32_t texHeight, uint32_t mipLevels );
bool    load            ( Device& dev, const std::string& fileName, bool mipmaps = true, bool srgb = false );
bool    loadCubemap     ( Device& dev, const std::vector<const char *>& files, bool mipmaps = true, bool srgb = false );
bool    loadRaw         ( Device& dev, int w, int h, const void * ptr, VkFormat format, bool mipmaps = true );

Класс Sampler

В отличии от OpenGL в Vulkan для чтения из текстуры обязательно наличие объекта-сэмплера (sampler). Мы будем инкапсулировать этот объект в виде класса Sampler. Это также не копируемый но moveable объект, поддерживающий ряд методов set* для задания параметров сэмплера. Для всех задаваемых параметров есть значения по умолчанию, после того, как все параметры будут заданы, вызывается метод create для создания VkSampler.

class    Sampler
{
    VkDevice                device        = VK_NULL_HANDLE;
    VkSampler                sampler       = VK_NULL_HANDLE;
    VkFilter                minFilter     = VK_FILTER_NEAREST;
    VkFilter                magFilter     = VK_FILTER_NEAREST;
    VkSamplerAddressMode    addressModeU  = VK_SAMPLER_ADDRESS_MODE_REPEAT;
    VkSamplerAddressMode    addressModeV  = VK_SAMPLER_ADDRESS_MODE_REPEAT;
    VkSamplerAddressMode    addressModeW  = VK_SAMPLER_ADDRESS_MODE_REPEAT;
    VkBool32                anisotropy    = VK_FALSE;
    float                    maxAnisotropy = 1.0f;        // 16
    VkBool32                unnormalized  = VK_FALSE;
    VkSamplerMipmapMode        mipmapMode    = VK_SAMPLER_MIPMAP_MODE_LINEAR;
    float                    lodBias       = 0;
    float                    minLod        = 0;
    float                    maxLod        = 0;
    
public:
    Sampler () {}
    Sampler ( Sampler&& s )
    {
        std::swap ( device,        s.device        );
        std::swap ( sampler,       s.sampler       );
        std::swap ( minFilter,     s.minFilter     );
        std::swap ( magFilter,     s.magFilter     );
        std::swap ( addressModeU,  s.addressModeU  );
        std::swap ( addressModeV,  s.addressModeV  );
        std::swap ( addressModeW,  s.addressModeW  );
        std::swap ( anisotropy,    s.anisotropy    );
        std::swap ( maxAnisotropy, s.maxAnisotropy );
        std::swap ( unnormalized,  s.unnormalized  );
        std::swap ( mipmapMode,    s.mipmapMode    );
        std::swap ( lodBias,       s.lodBias       );
        std::swap ( minLod,        s.minLod        );
        std::swap ( maxLod,        s.maxLod        );
    }
    Sampler ( const Sampler& ) = delete;
    ~Sampler ()
    {
        clean ();
    }
    
    Sampler& operator = ( const Sampler& ) = delete;
 
    bool    isOk () const
    {
        return device != nullptr && sampler != VK_NULL_HANDLE;
    }
 
    VkSampler    getHandle () const
    {
        return sampler;
    }
    
    void    clean ()
    {
        if ( sampler != VK_NULL_HANDLE )
            vkDestroySampler ( device, sampler, nullptr );
        
        sampler = VK_NULL_HANDLE;
    }
 
    Sampler&    setMinFilter ( VkFilter filter )
    {
        minFilter = filter;
        return *this;
    }
    
    Sampler&    setMagFilter ( VkFilter filter )
    {
        magFilter = filter;
        return *this;
    }
    
    Sampler&    setAddressMode ( VkSamplerAddressMode u, VkSamplerAddressMode v, VkSamplerAddressMode w )
    {
        addressModeU = u;
        addressModeV = v;
        addressModeW = w;
        return *this;
    }
    
    Sampler&    setAnisotropy ( bool enable, float maxAniso = 0 )
    {
        anisotropy    = enable ? VK_TRUE : VK_FALSE;
        maxAnisotropy = maxAniso;
        return *this;
    }
    
    Sampler&    setNormalized ( bool flag )
    {
        unnormalized = flag ? VK_FALSE : VK_TRUE;
        return *this;
    }
    
    Sampler&    setMipmapMode ( VkSamplerMipmapMode mode )
    {
        mipmapMode = mode;
        return *this;
    }
    
    Sampler&    setMipmapBias ( float bias )
    {
        lodBias = bias;
        return *this;
    }
    
    Sampler&    setMinLod ( float v )
    {
        minLod = v;
        return *this;
    }
    
    Sampler&    setMaxLod ( float v )
    {
        maxLod = v;
        return *this;
    }
    
    void create ( Device& dev );
};

Само приложение

Прежде всего нам понадобится описания структур для содержимого uniform-буфера (struct Ubo) и для описания отдельной вершины выводимой геометрии (struct Vertex). Также нам понадобится инстанциировать функцию registerVertexAttrs для Vertex.

struct Ubo
{
    glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};

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

template <>
GraphicsPipeline&   registerVertexAttrs<Vertex> ( GraphicsPipeline& pipeline ) 
{
    return pipeline
        .addVertexAttr     ( 0, 0, VK_FORMAT_R32G32B32_SFLOAT, offsetof(Vertex, pos) )
        .addVertexAttr     ( 0, 1, VK_FORMAT_R32G32B32_SFLOAT, offsetof(Vertex, color) )
        .addVertexAttr     ( 0, 2, VK_FORMAT_R32G32_SFLOAT,    offsetof(Vertex, texCoord) );
}

Далее нам нужно задать сам массив вершин (vertices), задающий выводимую геометрию, и массив индексов (indices), задающий как строить треугольники из вершин.

const std::vector<Vertex> vertices = 
{
    {{-0.5f, -0.5f, 0.0f}, {1.0f, 0.0f, 0.0f},  {1.0f, 0.0f}},
    {{0.5f, -0.5f,  0.0f}, {0.0f, 1.0f, 0.0f},  {0.0f, 0.0f}},
    {{0.5f, 0.5f,   0.0f}, {0.0f, 0.0f, 1.0f},  {0.0f, 1.0f}},
    {{-0.5f, 0.5f,  0.0f}, {1.0f, 1.0f, 1.0f},  {1.0f, 1.0f}},

    {{-0.5f, -0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f}},
    {{0.5f, -0.5f,  -0.5f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f}},
    {{0.5f, 0.5f,   -0.5f}, {0.0f, 0.0f, 1.0f}, {0.0f, 1.0f}},
    {{-0.5f, 0.5f,  -0.5f}, {1.0f, 1.0f, 1.0f}, {1.0f, 1.0f}}
};

const std::vector<uint16_t> indices = 
{
    0, 1, 2, 2, 3, 0,
    4, 5, 6, 6, 7, 4
};

Теперь давайте посмотрим на начало описания главного класса приложения - ExampleWindow. Как и в предыдущем примере он содержит поля commandBuffers, descriptorSets, pipeline, renderPass. Однако теперь у нас будет два буфера - вершинный vertexBuffer и индексный indexBuffer.

Поскольку мы собираемся читать из текстуры в шейдере у нас будет объекты texture (сама текстура из которой мы будем читать) и sampler (объект, задающий как именно будет происходить чтение из текстуры).

Также нам понадобится uniform-буфер. Но здесь есть один момент, заметно отличающийся от того, что было у нас в OpenGL. В аналогичном примере на OpenGL у нас был всего один uniform-буфер. Но здесь у нас возможно будет происходить рендеринг сразу нескольких кадров (но при этом эти кадры будут соответствовать разным моментам времени).

А раз они соответствуют разным моментам времени, то uniform-буфера для них будут отличаться и поэтому нам нужен набор из нескольких uniform-буферов, по одному на каждый кадр.

class   ExampleWindow : public VulkanWindow
{
    std::vector<CommandBuffer>      commandBuffers;
    std::vector<DescriptorSet>      descriptorSets;
    std::vector<Uniform<Ubo>>       uniformBuffers;
    GraphicsPipeline                pipeline;
    Renderpass                      renderPass;
    Buffer                          vertexBuffer;
    Buffer                          indexBuffer;
    Texture                         texture;
    Sampler                         sampler;

Ниже приводятся добавленные в класс функции createUniformBuffers и freeUniformBuffers.

ExampleWindow ( int w, int h, const std::string& t ) : VulkanWindow ( w, h, t )
{
    vertexBuffer.create  ( device, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, vertices, Buffer::hostWrite );
    indexBuffer.create   ( device, VK_BUFFER_USAGE_INDEX_BUFFER_BIT,  indices,  Buffer::hostWrite );
    sampler.setMinFilter ( VK_FILTER_LINEAR ).setMagFilter ( VK_FILTER_LINEAR ).create ( device );
    texture.load         ( device, "textures/texture.jpg", false, false );
    createPipelines      ();
}

void    createUniformBuffers ()
{
    uniformBuffers.resize ( swapChain.imageCount() );
    
    for ( size_t i = 0; i < swapChain.imageCount (); i++ )
        uniformBuffers [i].create ( device, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT );
}

void    freeUniformBuffers ()
{
    uniformBuffers.clear ();
}

При создании множеств дескрипторов на нужно добавить в каждый из них дескрипторы на uniform-буфер и объединенный дескриптор на текстуру с сэмплером.

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

    for ( uint32_t i = 0; i < swapChain.imageCount (); i++ )
    {
        descriptorSets  [i]
            .setLayout        ( device, descAllocator, pipeline.getDescLayout () )
            .addUniformBuffer ( 0, uniformBuffers [i], 0, sizeof ( Ubo ) )
            .addImage         ( 1, texture, sampler )
            .create           ();
    }
}

При создании объекта-конвейера (pipeline) нам необходимо указать что мы будем использовать uniform-буфер (binding 0) и текстуру с сэмплером (binding 1).

virtual void    createPipelines () override 
{
    createUniformBuffers    ();
    createDefaultRenderPass ( renderPass );

    pipeline.setDevice ( device )
        .setVertexShader   ( "shaders/shader.4.vert.spv" )
        .setFragmentShader ( "shaders/shader.4.frag.spv" )
        .setSize           ( swapChain.getExtent () )
        .addVertexBinding  ( sizeof ( Vertex ) )
        .addVertexAttributes <Vertex> ()
        .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 ) )
        .setCullMode       ( VK_CULL_MODE_NONE               )
        .create            ( renderPass );          

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

    createDescriptorSets ();
    createCommandBuffers ( renderPass );
}

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

Слегка изменится и функция createCommandBuffers.

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

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

    for ( size_t i = 0; i < commandBuffers.size(); i++ )
    {
        commandBuffers [i].begin ().beginRenderPass ( RenderPassInfo ( renderPass ).framebuffer ( framebuffers [i] ).extent ( swapChain.getExtent () ).clearColor ().clearDepthStencil () )
            .pipeline ( pipeline )
            .bindVertexBuffers ( { {vertexBuffer, 0} } )
            .bindIndexBuffer ( indexBuffer )
            .addDescriptorSets ( { descriptorSets[i] } )
            .drawIndexed ( static_cast<uint32_t>(indices.size()) )
            .end ();
    }
}

Функция submit ничем не отличается от того, что было в предыдущем примере, за исключением того что в ней появился вызов updateUniformBuffer. Ее задача - для текущего момента времени обновить содержимое uniform-буфера.

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

void updateUniformBuffer ( uint32_t currentImage )
{
    float   time = (float)getTime ();
    auto    fwd  = glm::vec3(0.0f, 0.0f, 1.0f);

    uniformBuffers [currentImage]->model = glm::rotate(glm::mat4(1.0f), time * glm::radians(90.0f), fwd);
    uniformBuffers [currentImage]->view  = glm::lookAt(glm::vec3(2.0f, 2.0f, 2.0f), glm::vec3(0.0f, 0.0f, 0.0f), fwd);
    uniformBuffers [currentImage]->proj  = projectionMatrix ( 45, getAspect (), 0.1f, 10 );
}

Исходный код этого примера находится в github репозитории по адресу https://github.com/steps3d/vulkan-with-classes.git.