I’m building a video game engine using C++ as the coding language and Vulkan for graphics. I am trying to set up a generic renderer using Vulkan that is flexible and will render objects based on a vector that is supplied to it. The renderer will also handle the creation of the window using GLFW and use GLM for all relevant math calls. I am using the ASSIMP library to load 3d models and animations.

Here is some the code:

Engine.h:

#pragma once

#include "Window.h"
#include "Renderer.h"
#include "Scene.h"
#include <chrono>
#include <thread>
#include <vector>
#include <memory>

class Engine
{
public:
	Engine();
	~Engine();

	void Run();
	void Shutdown();

	int MaxFPS = 60;

private:

	void Initialize();
	void MainLoop();
	void Update(float deltaTime);
	void Render();

	Window window;
	Renderer renderer;
	Scene scene;
	std::vector<std::unique_ptr<Mesh>> meshes; // Unique pointer vector to store Mesh objects
	std::vector<std::unique_ptr<Material>> materials; // Unique pointer vector to store Material objects
	std::vector<GameObject> gameObjects; // Game object vector

	vk::UniqueDescriptorPool descriptorPool;
	bool pauseRender;
};

Material.h:

#pragma once

#include <vulkan/vulkan.hpp>
#include "Texture.h"
#include <glm/glm.hpp>
#include "Shader.h"
#include <stdexcept>
#include <memory> // Don’t forget to include <memory>
#include <array>
#include <iostream>


class Material {
public:
	Material();
	~Material();

	void Initialize(const std::string& vertShaderPath, const std::string& fragShaderPath, const std::string& texturePath, vk::Device device, vk::DescriptorSetLayout descriptorSetLayout, vk::PhysicalDevice physicalDevice, vk::CommandPool commandPool, vk::Queue graphicsQueue, vk::UniqueDescriptorPool& descriptorPool);
	void Cleanup();

	void LoadTexture(const std::string& filename, vk::Device device, vk::PhysicalDevice physicalDevice, vk::CommandPool commandPool, vk::Queue graphicsQueue);
	void LoadShaders(const std::string& vertFilename, const std::string& fragFilename, vk::Device device);

	void UpdateBufferBinding(VkBuffer newBuffer, vk::Device device, vk::DeviceSize devicesize);
	vk::DescriptorSet GetDescriptorSet() const;
	vk::PipelineLayout GetPipelineLayout() const;

	std::shared_ptr<Shader> GetvertexShader();
	std::shared_ptr<Shader> GetfragmentShader();
	void CreateDescriptorSet(VkBuffer uniformBuffer, vk::DeviceSize bufferSize, vk::UniqueDescriptorPool& descriptorPool);

	vk::Buffer mvpBuffer; // MVP buffer
	vk::DeviceMemory mvpBufferMemory; // MVP buffer memory

private:
	vk::Device device;
	vk::UniqueDescriptorSet descriptorSet;
	vk::PipelineLayout pipelineLayout;
	vk::DescriptorSetLayout descriptorSetLayout;

	std::shared_ptr<Shader> vertexShader;
	std::shared_ptr<Shader> fragmentShader;
	std::shared_ptr<Texture> texture;

	

	void CreatePipelineLayout(vk::DescriptorSetLayout descriptorSetLayout);
	void CleanupDescriptorSet();
	void CleanupDescriptorSetLayout();
	void CleanupPipelineLayout();
	void CleanupBufferAndMemory();
};

Scene.h:

#pragma once

#include <vector>
#include "GameObject.h"
#include "Camera.h"
#include "Renderer.h"

class Scene
{
public:
	Scene();
	~Scene();

	void Initialize();
	void Update(float deltaTime);
	void Render(Renderer& renderer);
	void Shutdown();

	void AddGameObject(GameObject* gameObject);
	Camera& GetCamera();

private:
	std::vector<GameObject*> gameObjects;
	Camera camera;
};

Material.cpp:

#include "Material.h"

Material::Material() = default;

Material::~Material() {
    Cleanup();
}

void Material::Initialize(const std::string& vertShaderPath, const std::string& fragShaderPath, const std::string& texturePath, vk::Device device, vk::DescriptorSetLayout descriptorSetLayout, vk::PhysicalDevice physicalDevice, vk::CommandPool commandPool, vk::Queue graphicsQueue, vk::UniqueDescriptorPool& descriptorPool) {
    this->device = device;
    this->descriptorSetLayout = descriptorSetLayout;

    // Load shaders and texture
    LoadTexture(texturePath, device, physicalDevice, commandPool, graphicsQueue);
    LoadShaders(vertShaderPath, fragShaderPath, device);
    // Update shader uniform buffers with modelMatrix, viewMatrix and projectionMatrix transforms
    struct MVP {
        glm::mat4 model;
        glm::mat4 view;
        glm::mat4 projection;
    } mvp;
    // Create MVP buffer
    VkDeviceSize bufferSize = sizeof(MVP);
    BufferUtils::CreateBuffer(device, physicalDevice, bufferSize, vk::BufferUsageFlagBits::eUniformBuffer,
        vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent, mvpBuffer, mvpBufferMemory);
    CreateDescriptorSet(mvpBuffer, sizeof(MVP), descriptorPool);

    // Create descriptor set and pipeline layout
    CreatePipelineLayout(descriptorSetLayout);
}

void Material::CreateDescriptorSet(VkBuffer uniformBuffer, vk::DeviceSize bufferSize, vk::UniqueDescriptorPool& descriptorPool) {
    vk::DescriptorSetAllocateInfo allocInfo{};
    allocInfo.setDescriptorPool(descriptorPool.get());
    allocInfo.setDescriptorSetCount(1);
    allocInfo.setPSetLayouts(&descriptorSetLayout);

    descriptorSet = std::move(device.allocateDescriptorSetsUnique(allocInfo)[0]);

    if (!descriptorSet) {
        throw std::runtime_error("Failed to allocate descriptor set!");
    }

    vk::DescriptorBufferInfo bufferInfo{};
    bufferInfo.buffer = uniformBuffer;
    bufferInfo.offset = 0;
    bufferInfo.range = bufferSize;

    vk::DescriptorImageInfo imageInfo{};
    imageInfo.imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
    imageInfo.imageView = texture->GetImageView();
    imageInfo.sampler = texture->GetSampler();

    std::array<vk::WriteDescriptorSet, 2> descriptorWrites{};

    descriptorWrites[0].setDstSet(descriptorSet.get());
    descriptorWrites[0].setDstBinding(0);
    descriptorWrites[0].setDstArrayElement(0);
    descriptorWrites[0].setDescriptorType(vk::DescriptorType::eUniformBuffer);
    descriptorWrites[0].setDescriptorCount(1);
    descriptorWrites[0].setPBufferInfo(&bufferInfo);

    descriptorWrites[1].setDstSet(descriptorSet.get());
    descriptorWrites[1].setDstBinding(1);
    descriptorWrites[1].setDstArrayElement(0);
    descriptorWrites[1].setDescriptorType(vk::DescriptorType::eCombinedImageSampler);
    descriptorWrites[1].setDescriptorCount(1);
    descriptorWrites[1].setPImageInfo(&imageInfo);

    device.updateDescriptorSets(static_cast<uint32_t>(descriptorWrites.size()), descriptorWrites.data(), 0, nullptr);
}

void Material::CreatePipelineLayout(vk::DescriptorSetLayout descriptorSetLayout) {
    vk::PipelineLayoutCreateInfo pipelineLayoutInfo{};
    pipelineLayoutInfo.setSetLayouts(descriptorSetLayout);

    pipelineLayout = device.createPipelineLayout(pipelineLayoutInfo);
}

void Material::Cleanup() {
    if (vertexShader) {
        Shader::cleanup(vertexShader.get());
    }

    if (fragmentShader) {
        Shader::cleanup(fragmentShader.get());
    }

    if (texture) {
        texture->Cleanup();
    }

    CleanupDescriptorSet();
    CleanupDescriptorSetLayout();
    CleanupPipelineLayout();
    CleanupBufferAndMemory();
}

void Material::LoadTexture(const std::string& filename, vk::Device device, vk::PhysicalDevice physicalDevice, vk::CommandPool commandPool, vk::Queue graphicsQueue) {
    texture = std::make_shared<Texture>();
    texture->LoadFromFile(filename, device, physicalDevice, commandPool, graphicsQueue);
}

void Material::LoadShaders(const std::string& vertFilename, const std::string& fragFilename, vk::Device device) {
    vertexShader = std::make_shared<Shader>();
    fragmentShader = std::make_shared<Shader>();
    vertexShader->loadFromFile(vertFilename, device, vk::ShaderStageFlagBits::eVertex);
    fragmentShader->loadFromFile(fragFilename, device, vk::ShaderStageFlagBits::eFragment);
}

void Material::UpdateBufferBinding(VkBuffer newBuffer, vk::Device device, vk::DeviceSize devicesize) {
    vk::DescriptorBufferInfo bufferInfo{};
    bufferInfo.buffer = newBuffer;
    bufferInfo.offset = 0;
    bufferInfo.range = devicesize;

    vk::DescriptorImageInfo imageInfo{};
    imageInfo.imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
    imageInfo.imageView = texture->GetImageView();
    imageInfo.sampler = texture->GetSampler();

    std::array<vk::WriteDescriptorSet, 2> descriptorWrites{};
    descriptorWrites[0].setDstSet(descriptorSet.get());
    descriptorWrites[0].setDstBinding(0);
    descriptorWrites[0].setDstArrayElement(0);
    descriptorWrites[0].setDescriptorType(vk::DescriptorType::eUniformBuffer);
    descriptorWrites[0].setDescriptorCount(1);
    descriptorWrites[0].setPBufferInfo(&bufferInfo);

    descriptorWrites[1].setDstSet(descriptorSet.get());
    descriptorWrites[1].setDstBinding(1);
    descriptorWrites[1].setDstArrayElement(0);
    descriptorWrites[1].setDescriptorType(vk::DescriptorType::eCombinedImageSampler);
    descriptorWrites[1].setDescriptorCount(1);
    descriptorWrites[1].setPImageInfo(&imageInfo);

    if (!descriptorSet) {
        throw std::runtime_error("Invalid descriptor set!");
    }
    device.updateDescriptorSets(static_cast<uint32_t>(descriptorWrites.size()), descriptorWrites.data(), 0, nullptr);
}

void Material::CleanupDescriptorSet() {
    descriptorSet.reset();
}

void Material::CleanupDescriptorSetLayout() {
    if (descriptorSetLayout != vk::DescriptorSetLayout{}) {
        device.destroyDescriptorSetLayout(descriptorSetLayout, nullptr);
        descriptorSetLayout = vk::DescriptorSetLayout{};
    }
}

void Material::CleanupPipelineLayout() {
    if (pipelineLayout != vk::PipelineLayout{}) {
        device.destroyPipelineLayout(pipelineLayout);
        pipelineLayout = vk::PipelineLayout{};
    }
}

void Material::CleanupBufferAndMemory() {
    if (mvpBuffer) {
        device.destroyBuffer(mvpBuffer);
        mvpBuffer = nullptr;
    }

    if (mvpBufferMemory) {
        device.freeMemory(mvpBufferMemory);
        mvpBufferMemory = nullptr;
    }
}


std::shared_ptr<Shader> Material::GetvertexShader() {
    return vertexShader;
}

std::shared_ptr<Shader> Material::GetfragmentShader() {
    return fragmentShader;
}

vk::DescriptorSet Material::GetDescriptorSet() const {
    return descriptorSet.get();
}

vk::PipelineLayout Material::GetPipelineLayout() const {
    return pipelineLayout;
}

GameObject.h:

#pragma once

#include <memory>
#include <glm/glm.hpp>
#include "Mesh.h"
#include "Material.h"
#include "Camera.h"
#include "Renderer.h"

class GameObject
{
public:
	GameObject();
	~GameObject();

	//void Initialize();
	void Initialize(Renderer& renderer, Mesh* mesh, Material* material);
	//void Initialize2(Renderer& renderer, vk::UniqueDescriptorPool& descriptorPool);
	void Update(float deltaTime);
	void Render(Renderer& renderer, const Camera& camera);
	void Shutdown();

	void SetPosition(const glm::vec3& position);
	void SetRotation(const glm::vec3& rotation);
	void SetScale(const glm::vec3& scale);

	Mesh* GetMesh();
	Material* GetMaterial();

private:
	glm::mat4 modelMatrix;
	glm::vec3 position;
	glm::vec3 rotation;
	glm::vec3 scale;

	//VkDeviceMemory mvpBufferMemory;
	//VkBuffer mvpBuffer;

	Mesh* mesh;
	Material* material;

	bool initialized = false;

	void UpdateModelMatrix();
};

Currently the rendering pipeline will pass the model, view and position matrices to the shader using a single uniform buffer. For added flexibility, would it make sense to split this into three separate buffers that can all then be stored and updated individually?