a-simple-triangle / Part 26 - Vulkan load meshes

The next Vulkan implementation we will author is to load our static mesh asset files. To do this we will need to learn a bit about Vulkan buffers which are the data structures that will represent the vertices and indices of our mesh data inside Vulkan. We followed a similar approach in our OpenGL implementation where we took the basic mesh data from our ast::Mesh class and fed it into OpenGL specific buffers.

The (rather terse) Vulkan doco for buffers is here: https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkBuffer.html.

Specifically in this article we will:


Vulkan buffer

We will be using Vulkan buffers to represent the vertices and indices of our static mesh assets and in the next article to help us load textures.

To create a buffer we will first construct a temporary staging buffer to hold our asset data and act as a data source for a device local buffer - the device local buffer is ultimately the one we will use, discarding the staging buffer along the way.

A Vulkan buffer actually doesn’t hold any data itself - instead we must bind it to an accompanying device memory object which we ourselves create. Our staging buffer also requires us to manually perform our own memory copy operations on its backing memory to populate its data whereas our device local buffer will instead require us to invoke a command buffer asking Vulkan to perform the memory data operations for us.

We will kick off by authoring a new class to encapsulate a Vulkan buffer object. Create the files vulkan-buffer.hpp and vulkan-buffer.cpp in the Vulkan source folder. Edit the header file with the following:

#pragma once

#include "../../core/graphics-wrapper.hpp"
#include "../../core/internal-ptr.hpp"
#include "vulkan-command-pool.hpp"
#include "vulkan-device.hpp"
#include "vulkan-physical-device.hpp"

namespace ast
{
    struct VulkanBuffer
    {
        VulkanBuffer(const ast::VulkanPhysicalDevice& physicalDevice,
                     const ast::VulkanDevice& device,
                     const vk::DeviceSize& size,
                     const vk::BufferUsageFlags& bufferFlags,
                     const vk::MemoryPropertyFlags& memoryFlags,
                     const void* dataSource);

        const vk::Buffer& getBuffer() const;

        static ast::VulkanBuffer createDeviceLocalBuffer(const ast::VulkanPhysicalDevice& physicalDevice,
                                                         const ast::VulkanDevice& device,
                                                         const ast::VulkanCommandPool& commandPool,
                                                         const vk::DeviceSize& size,
                                                         const vk::BufferUsageFlags& bufferFlags,
                                                         const void* dataSource);

    private:
        struct Internal;
        ast::internal_ptr<Internal> internal;
    };
} // namespace ast

Apart from the physicalDevice and device arguments in the constructor we also need:

The getBuffer function will be needed later but really just acts as a proxy to the internally stored Vulkan buffer.

The createDeviceLocalBuffer function is a little interesting in that it is a static function which we haven’t really written many of in this series. It is basically a convenience factory function which takes in all the configuration for a buffer that should end up in device local memory and orchestrates all the staging guff to produce a device local buffer.

Now edit vulkan-buffer.cpp to start the implementation - we’ll do it a chunk at a time:

#include "vulkan-buffer.hpp"

using ast::VulkanBuffer;

namespace
{
    vk::UniqueBuffer createBuffer(const ast::VulkanDevice& device,
                                  const vk::DeviceSize& size,
                                  const vk::BufferUsageFlags& bufferFlags)
    {
        vk::BufferCreateInfo info{
            vk::BufferCreateFlags(),     // Flags
            size,                        // Size
            bufferFlags,                 // Buffer usage flags
            vk::SharingMode::eExclusive, // Sharing mode
            0,                           // Queue family index count
            nullptr};                    // Queue family indices

        return device.getDevice().createBufferUnique(info);
    }

    vk::UniqueDeviceMemory allocateMemory(const ast::VulkanPhysicalDevice& physicalDevice,
                                          const ast::VulkanDevice& device,
                                          const vk::Buffer& buffer,
                                          const vk::MemoryPropertyFlags& memoryFlags)
    {
        vk::MemoryRequirements memoryRequirements{
            device.getDevice().getBufferMemoryRequirements(buffer)};

        uint32_t memoryTypeIndex{
            physicalDevice.getMemoryTypeIndex(memoryRequirements.memoryTypeBits, memoryFlags)};

        vk::MemoryAllocateInfo info{
            memoryRequirements.size, // Allocation size
            memoryTypeIndex};        // Memory type index

        return device.getDevice().allocateMemoryUnique(info);
    }
} // namespace

struct VulkanBuffer::Internal
{
    const vk::UniqueBuffer buffer;
    const vk::UniqueDeviceMemory deviceMemory;

    Internal(const ast::VulkanPhysicalDevice& physicalDevice,
             const ast::VulkanDevice& device,
             const vk::DeviceSize& size,
             const vk::BufferUsageFlags& bufferFlags,
             const vk::MemoryPropertyFlags& memoryFlags,
             const void* dataSource)
        : buffer(::createBuffer(device, size, bufferFlags)),
          deviceMemory(::allocateMemory(physicalDevice, device, buffer.get(), memoryFlags))
    {
        // Take the buffer and the allocated memory and bind them together.
        device.getDevice().bindBufferMemory(buffer.get(), deviceMemory.get(), 0);

        // Take the datasource and copy it into our allocated memory block.
        if (dataSource)
        {
            void* mappedMemory{device.getDevice().mapMemory(deviceMemory.get(), 0, size)};
            std::memcpy(mappedMemory, dataSource, static_cast<size_t>(size));
            device.getDevice().unmapMemory(deviceMemory.get());
        }
    }
};

VulkanBuffer::VulkanBuffer(const ast::VulkanPhysicalDevice& physicalDevice,
                           const ast::VulkanDevice& device,
                           const vk::DeviceSize& size,
                           const vk::BufferUsageFlags& bufferFlags,
                           const vk::MemoryPropertyFlags& memoryFlags,
                           const void* dataSource)
    : internal(ast::make_internal_ptr<Internal>(physicalDevice, device, size, bufferFlags, memoryFlags, dataSource)) {}

const vk::Buffer& VulkanBuffer::getBuffer() const
{
    return internal->buffer.get();
}

ast::VulkanBuffer VulkanBuffer::createDeviceLocalBuffer(const ast::VulkanPhysicalDevice& physicalDevice,
                                                        const ast::VulkanDevice& device,
                                                        const ast::VulkanCommandPool& commandPool,
                                                        const vk::DeviceSize& size,
                                                        const vk::BufferUsageFlags& bufferFlags,
                                                        const void* dataSource)
{
    // TODO: Patience padawan!
}

Buffer

We’ll start with the const vk::UniqueBuffer buffer field which is constructed through the createBuffer free function:

namespace
{
    vk::UniqueBuffer createBuffer(const ast::VulkanDevice& device,
                                  const vk::DeviceSize& size,
                                  const vk::BufferUsageFlags& bufferFlags)
    {
        vk::BufferCreateInfo info{
            vk::BufferCreateFlags(),     // Flags
            size,                        // Size
            bufferFlags,                 // Buffer usage flags
            vk::SharingMode::eExclusive, // Sharing mode
            0,                           // Queue family index count
            nullptr};                    // Queue family indices

        return device.getDevice().createBufferUnique(info);
    }

Creating a buffer shell is fairly straight forward, really just populating a vk::BufferCreateInfo object and asking our logical device to make one.

Device memory

The const vk::UniqueDeviceMemory deviceMemory field represents the backing memory object that will be bound to the buffer. The device memory is allocated during construction, but not populated with any data:

namespace
{
    ...

    vk::UniqueDeviceMemory allocateMemory(const ast::VulkanPhysicalDevice& physicalDevice,
                                          const ast::VulkanDevice& device,
                                          const vk::Buffer& buffer,
                                          const vk::MemoryPropertyFlags& memoryFlags)
    {
        vk::MemoryRequirements memoryRequirements{
            device.getDevice().getBufferMemoryRequirements(buffer)};

        uint32_t memoryTypeIndex{
            physicalDevice.getMemoryTypeIndex(memoryRequirements.memoryTypeBits, memoryFlags)};

        vk::MemoryAllocateInfo info{
            memoryRequirements.size, // Allocation size
            memoryTypeIndex};        // Memory type index

        return device.getDevice().allocateMemoryUnique(info);
    }
} // namespace

We use the buffer argument to ask the logical device to identify what kind of memory the buffer requires. This will be influenced by whether the buffer specified it should have device local memory or not:

vk::MemoryRequirements memoryRequirements{
    device.getDevice().getBufferMemoryRequirements(buffer)};

Once we know the requirements of the buffer’s memory we use the getMemoryTypeIndex function of our existing physical device class to discover what pool of memory in the hardware should be used:

uint32_t memoryTypeIndex{
    physicalDevice.getMemoryTypeIndex(memoryRequirements.memoryTypeBits, memoryFlags)};

Both of those bits of information can then be used to describe how we need the logical device to allocate the memory, which we subsequently do:

vk::MemoryAllocateInfo info{
    memoryRequirements.size, // Allocation size
    memoryTypeIndex};        // Memory type index

return device.getDevice().allocateMemoryUnique(info);

Important: I’ll just mention again, allocating the device memory object does not actually put any data into it - that’s something we have to do ourselves.

We next need to bind the allocated device memory to the buffer and if we were given a pointer to a datasource, copy it into the device memory object. We do this inside the body of the Internal constructor.

The binding is performed by asking the logical device to bindBufferMemory, passing the buffer, the memory and the offset in the memory object to bind to. The offset enables the possibility of having a single memory object which can be a shared data source for different buffers, though we won’t be using this feature:

device.getDevice().bindBufferMemory(buffer.get(), deviceMemory.get(), 0);

We will only be copying the data source if it isn’t null hence the if statement. This will be important when we implement the static factory function where we want the staging buffer to copy the data source into its backing memory, but the device local buffer should not as it will be populated via a command buffer operation using the staging buffer as a source instead.

Assuming we have a non null datasource, the memory is mapped through the mapMemory function of our logical device, effectively mounting it for us to use. This is followed by whatever standard mechanisms we choose to copy the data from the dataSource into the mappedMemory - we are using std::memcpy. When we are finished we must unmapMemory to unmount it again:

if (dataSource)
{
    void* mappedMemory{device.getDevice().mapMemory(deviceMemory.get(), 0, size)};
    std::memcpy(mappedMemory, dataSource, static_cast<size_t>(size));
    device.getDevice().unmapMemory(deviceMemory.get());
}

Note: If our buffer was specified to use device local memory, we would not be permitted to perform the memory mapping and copying operations which is the whole reason for needing to stage device local buffers.

Create device local factory function

The buffer class is actually complete however to avoid writing duplicated boilerplate code whenever we want a device local buffer we will offer the createDeviceLocalBuffer static factory function to encapsulate the manufacturing of them. We start off by passing in all the required collaborators and configurations:

ast::VulkanBuffer VulkanBuffer::createDeviceLocalBuffer(const ast::VulkanPhysicalDevice& physicalDevice,
                                                        const ast::VulkanDevice& device,
                                                        const ast::VulkanCommandPool& commandPool,
                                                        const vk::DeviceSize& size,
                                                        const vk::BufferUsageFlags& bufferFlags,
                                                        const void* dataSource)
{

First off we spin up a new instance of our buffer class which will be our staging buffer:

ast::VulkanBuffer VulkanBuffer::createDeviceLocalBuffer(...)
{
    ast::VulkanBuffer stagingBuffer{
        physicalDevice,
        device,
        size,
        vk::BufferUsageFlagBits::eTransferSrc,
        vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
        dataSource};

Take note of the following important arguments for creating the staging buffer:

Next we create the device local buffer which will later be returned as the result of this function:

ast::VulkanBuffer VulkanBuffer::createDeviceLocalBuffer(...)
{
    ...

     ast::VulkanBuffer deviceLocalBuffer{
        physicalDevice,
        device,
        size,
        vk::BufferUsageFlagBits::eTransferDst | bufferFlags,
        vk::MemoryPropertyFlagBits::eDeviceLocal,
        nullptr};

Note the following differences to the staging buffer:

Now that we have both a staging buffer whose backing memory is populated with the actual data, and the device local buffer which has allocated device memory but hasn’t populated it, we use a command buffer to ask Vulkan to perform the transfer operation taking the staging memory and writing it into the device local memory:

ast::VulkanBuffer VulkanBuffer::createDeviceLocalBuffer(...)
{
    ...

    vk::UniqueCommandBuffer commandBuffer{commandPool.beginCommandBuffer(device)};

    vk::BufferCopy copyRegion{
        0,     // Source offset
        0,     // Destination offset
        size}; // Size

    commandBuffer->copyBuffer(stagingBuffer.getBuffer(), deviceLocalBuffer.getBuffer(), 1, &copyRegion);

    commandPool.endCommandBuffer(commandBuffer.get(), device);

Finally we return the device local buffer which should now be fully formed and populated with the correct data. The staging buffer will be destroyed as it goes out of scope of the function call:

ast::VulkanBuffer VulkanBuffer::createDeviceLocalBuffer(...)
{
    ...

    return deviceLocalBuffer;
}

That’s it for our buffer class - we now have a way to generate Vulkan buffers and to manufacture a buffer that is automatically migrated into device local memory.


Vulkan mesh

Our Vulkan mesh embodies a similar concept to our OpenGL mesh that we authored some time ago. Its responsibilities will be to create and own the lifecycle of the vertex and index data for a loaded mesh asset with a Vulkan implementation. We already have a basic ast::Mesh class which is agnostic of OpenGL and Vulkan which we will use as the input for constructing a Vulkan mesh. Before we create the Vulkan mesh class we’ll make a small detour back to our basic mesh class to enhance it.

Edit core/mesh.hpp and add the following two new function signatures:

namespace ast
{
    struct Mesh
    {
        ...

        const uint32_t& getNumVertices() const;

        const uint32_t& getNumIndices() const;

These two functions will just make it easier to know how many vertices and indices the mesh has. Now edit core/mesh.cpp and update the Internal structure to hold and initialise the number of vertices and indices:

struct Mesh::Internal
{
    const std::vector<ast::Vertex> vertices;
    const uint32_t numVertices;
    const std::vector<uint32_t> indices;
    const uint32_t numIndices;

    Internal(const std::vector<ast::Vertex>& vertices, const std::vector<uint32_t>& indices)
        : vertices(vertices),
          numVertices(static_cast<uint32_t>(vertices.size())),
          indices(indices),
          numIndices(static_cast<uint32_t>(indices.size())) {}
};

Note the addition of the numVertices and numIndices fields along with their initialisers. Scroll to the bottom of the file and add the two public function implementations as well:

const uint32_t& Mesh::getNumVertices() const
{
    return internal->numVertices;
}

const uint32_t& Mesh::getNumIndices() const
{
    return internal->numIndices;
}

Close the mesh class then create vulkan-mesh.hpp and vulkan-mesh.cpp in the Vulkan source folder. Edit vulkan-mesh.hpp with the following:

#pragma once

#include "../../core/graphics-wrapper.hpp"
#include "../../core/internal-ptr.hpp"
#include "../../core/mesh.hpp"
#include "vulkan-command-pool.hpp"
#include "vulkan-device.hpp"
#include "vulkan-physical-device.hpp"

namespace ast
{
    struct VulkanMesh
    {
        VulkanMesh(const ast::VulkanPhysicalDevice& physicalDevice,
                   const ast::VulkanDevice& device,
                   const ast::VulkanCommandPool& commandPool,
                   const ast::Mesh& mesh);

        const vk::Buffer& getVertexBuffer() const;

        const vk::Buffer& getIndexBuffer() const;

        const uint32_t& getNumIndices() const;

    private:
        struct Internal;
        ast::internal_ptr<Internal> internal;
    };
} // namespace ast

Our Vulkan mesh takes a regular mesh object as its input source along with a few Vulkan components needed to initialise itself with. We expose the vertex buffer, the index buffer and how many indices the Vulkan mesh has which will all be used in a later article in the rendering pipeline.

Edit vulkan-mesh.cpp with the following:

#include "vulkan-mesh.hpp"
#include "vulkan-buffer.hpp"
#include <vector>

using ast::VulkanMesh;

namespace
{
    ast::VulkanBuffer createVertexBuffer(const ast::VulkanPhysicalDevice& physicalDevice,
                                         const ast::VulkanDevice& device,
                                         const ast::VulkanCommandPool& commandPool,
                                         const ast::Mesh& mesh)
    {
        return ast::VulkanBuffer::createDeviceLocalBuffer(physicalDevice,
                                                          device,
                                                          commandPool,
                                                          sizeof(ast::Vertex) * mesh.getNumVertices(),
                                                          vk::BufferUsageFlagBits::eVertexBuffer,
                                                          mesh.getVertices().data());
    }

    ast::VulkanBuffer createIndexBuffer(const ast::VulkanPhysicalDevice& physicalDevice,
                                        const ast::VulkanDevice& device,
                                        const ast::VulkanCommandPool& commandPool,
                                        const ast::Mesh& mesh)
    {
        return ast::VulkanBuffer::createDeviceLocalBuffer(physicalDevice,
                                                          device,
                                                          commandPool,
                                                          sizeof(uint32_t) * mesh.getNumIndices(),
                                                          vk::BufferUsageFlagBits::eIndexBuffer,
                                                          mesh.getIndices().data());
    }
} // namespace

struct VulkanMesh::Internal
{
    const ast::VulkanBuffer vertexBuffer;
    const ast::VulkanBuffer indexBuffer;
    const uint32_t numIndices;

    Internal(const ast::VulkanPhysicalDevice& physicalDevice,
             const ast::VulkanDevice& device,
             const ast::VulkanCommandPool& commandPool,
             const ast::Mesh& mesh)
        : vertexBuffer(::createVertexBuffer(physicalDevice, device, commandPool, mesh)),
          indexBuffer(::createIndexBuffer(physicalDevice, device, commandPool, mesh)),
          numIndices(mesh.getNumIndices()) {}
};

VulkanMesh::VulkanMesh(const ast::VulkanPhysicalDevice& physicalDevice,
                       const ast::VulkanDevice& device,
                       const ast::VulkanCommandPool& commandPool,
                       const ast::Mesh& mesh)
    : internal(ast::make_internal_ptr<Internal>(physicalDevice, device, commandPool, mesh)) {}

const vk::Buffer& VulkanMesh::getVertexBuffer() const
{
    return internal->vertexBuffer.getBuffer();
}

const vk::Buffer& VulkanMesh::getIndexBuffer() const
{
    return internal->indexBuffer.getBuffer();
}

const uint32_t& VulkanMesh::getNumIndices() const
{
    return internal->numIndices;
}

The vertexBuffer field is an instance of our new buffer class populated with the vertex data of the mesh via the createVertexBuffer function. In the createVertexBuffer function you can see that we are using our new static factory function to generate a buffer, specifying a usage flag of vk::BufferUsageFlagBits::eVertexBuffer to indicate that the buffer can be used in a pipeline as a source for vertex data. We also pass in the vector of vertex objects from the origin mesh as the byte data to fill the buffer with.

The indexBuffer field is another instance of our buffer class but with a usage flag of vk::BufferUsageFlagBits::eIndexBuffer to indicate that it can be used as a pipeline source of indices. Of course we feed the vector of indices into the factory function as the byte data to use.

We also snag the numIndices from the source mesh and expose it publicly as we’ll need it later during rendering.

The rest of the class is mostly just boilerplate.


Update asset manager

Our Vulkan asset manager is responsible for loading and caching mesh objects much the same as our OpenGL asset manager. We need to make a small adjustment to the header file for the Vulkan asset manager as creating a Vulkan mesh requires us to provide a command pool which we hadn’t needed before. Edit vulkan-asset-manager.hpp and update the header includes to give access to our command pool class as well as our new Vulkan mesh class:

#include "../../core/asset-manifest.hpp"
#include "../../core/internal-ptr.hpp"
#include "vulkan-command-pool.hpp"
#include "vulkan-device.hpp"
#include "vulkan-mesh.hpp"
#include "vulkan-physical-device.hpp"
#include "vulkan-render-context.hpp"

Now edit the signature of the loadAssetManifest function to include a command pool argument and also add a new function getStaticMesh that will allow us to look up a cached Vulkan mesh instance:

namespace ast
{
    struct VulkanAssetManager
    {
        ...

        void loadAssetManifest(const ast::VulkanPhysicalDevice& physicalDevice,
                               const ast::VulkanDevice& device,
                               const ast::VulkanRenderContext& renderContext,
                               const ast::VulkanCommandPool& commandPool,
                               const ast::AssetManifest& assetManifest);

        const ast::VulkanMesh& getStaticMesh(const ast::assets::StaticMesh& staticMesh) const;

Note: We don’t need to add the command pool argument to the reloadContextualAssets because our Vulkan mesh instances won’t be affected by Vulkan lifecycle changes therefore won’t need to be reloaded.

Jump over to vulkan-asset-manager.cpp to add the implementation. Start off by introducing a new createMesh free function in the anonymous namespace which is able to construct a new Vulkan mesh object:

namespace
{
    ...

    ast::VulkanMesh createMesh(const ast::VulkanPhysicalDevice& physicalDevice,
                               const ast::VulkanDevice& device,
                               const ast::VulkanCommandPool& commandPool,
                               const ast::assets::StaticMesh& staticMesh)
    {
        std::string meshPath{ast::assets::resolveStaticMeshPath(staticMesh)};

        ast::log("ast::VulkanAssetManager::createMesh", "Creating static mesh from " + meshPath);

        return ast::VulkanMesh(physicalDevice,
                               device,
                               commandPool,
                               ast::assets::loadOBJFile(meshPath));
    }
} // namespace

The great news is that we can recycle all the mesh loading code that we authored a long time ago - this was always the intended outcome when we decided to create the ast::Mesh abstraction so I think that decision paid off.

We start by resolving the file path of the mesh asset itself:

std::string meshPath{ast::assets::resolveStaticMeshPath(staticMesh)};

We then do a bit of logging output so we can see what assets are loading (probably should have done this in the OpenGL asset manager too) then return a new ast::VulkanMesh object, using the ast::assets::loadOBJFile function to build the ast::Mesh source object.

Next we move down to the Internal structure and add a another hash map to be our cache for static mesh objects:

struct VulkanAssetManager::Internal
{
    std::unordered_map<ast::assets::Pipeline, ast::VulkanPipeline> pipelineCache;
    std::unordered_map<ast::assets::StaticMesh, ast::VulkanMesh> staticMeshCache;

The loadAssetManifest function inside the Internal structure is already configured to load all the pipelines it finds in the asset manifest argument. We can add some more code to this function to do the same thing for static meshes, note that we have also added the commandPool argument:

struct VulkanAssetManager::Internal
{
    ...

    void loadAssetManifest(const ast::VulkanPhysicalDevice& physicalDevice,
                           const ast::VulkanDevice& device,
                           const ast::VulkanRenderContext& renderContext,
                           const ast::VulkanCommandPool& commandPool,
                           const ast::AssetManifest& assetManifest)
    {
        ...

        for (const auto& staticMesh : assetManifest.staticMeshes)
        {
            if (staticMeshCache.count(staticMesh) == 0)
            {
                staticMeshCache.insert(std::make_pair(
                    staticMesh,
                    ::createMesh(physicalDevice, device, commandPool, staticMesh)));
            }
        }
    }

The introduction of the command pool argument will give us syntax errors, so we need to also add the command pool argument to the loadAssetManifest public function implementation and forward it to the internal structure:

void VulkanAssetManager::loadAssetManifest(const ast::VulkanPhysicalDevice& physicalDevice,
                                           const ast::VulkanDevice& device,
                                           const ast::VulkanRenderContext& renderContext,
                                           const ast::VulkanCommandPool& commandPool,
                                           const ast::AssetManifest& assetManifest)
{
    internal->loadAssetManifest(physicalDevice, device, renderContext, commandPool, assetManifest);
}

Finally we add the implementation of the getStaticMesh public function which simply returns the static mesh in the hash map cache for the type we are interested in:

const ast::VulkanMesh& VulkanAssetManager::getStaticMesh(const ast::assets::StaticMesh& staticMesh) const
{
    return internal->staticMeshCache.at(staticMesh);
}

Update Vulkan context and run

We are almost done, one more command pool argument to fix in the vulkan-context.cpp file within the Internal structure:

struct VulkanContext::Internal
{
    ...

    void loadAssetManifest(const ast::AssetManifest& assetManifest)
    {
        assetManager.loadAssetManifest(physicalDevice, device, renderContext, commandPool, assetManifest);
    }

Save your changes and run the program. You should see the following output - the screenshot is of my Windows machine but all the other platforms would show something similar:

If you resize the window to cause a Vulkan lifecycle change you can observe that although the pipeline is reloaded, the meshes are not:

Note the additional log output where the pipeline is loaded a second time:

ast::VulkanAssetManager::createPipeline: Creating pipeline: default

Summary

That’s static mesh loading out of the way for Vulkan - next stop is loading textures!

The code for this article can be found here.

Continue to Part 27: Vulkan load textures.

End of part 26