I want to demonstrate why it may not always be an anti-pattern even with OOP design. I'll be using an online tutorial that I have worked on several times.
Sometimes namespaces
are preferred for free functions, however, there may be times that you don't want to use a namespace
. One reason could be naming collisions or you don't want the user of your library to use using namespace xyz;
exposing them to that scope. If you have a faculty of functions that are all related or all work on a single class object then you might want to consider having a non-instantiable class with static methods only.
Take, for example, I have gone through implementing this tutorial a few times to become familiar with the Vulkan API
. In my first implementation, I followed it precisely from the site. The author has one superclass that contains just about everything within the program in it besides a couple of global variables, helper structs, and a couple of freestanding functions. There were almost 50 member variables, 25 - 30 functions with nearly 1000 lines of code.
On my second attempt, I started from a clean solution and stayed with the source implementation but I broke it into multiple classes each responsible for their own types having only a few functions each. This gave me some trouble due to pass pointers around, object lifetime, concurrency, synchronization, etc. and how Vulkan handles its procedures.
You have to be explicit with every single thing when writing a Vulkan App since the API's core was written and designed around C bindings with full C++ support, it later started to support other languages, It took some research and troubleshoot on how to pass around objects from one class to another without the Validation Layers triggering some kind of error or the application crashing due to either a segfault or an unhandled exception, etc., but I eventually got it to work.
This time around, I'm still currently working on it. I've decided to take a different approach. I started doing research for this exact concept which led me here as I was just looking for some insight. This attempt at writing a Vulkan APP, I'm doing exactly what was asked of this question. Before I give reasons to why I'm doing it this way, or why it might be better, I'm going to show pseudo-class interfaces from my first two attempts. Then I'll show some of my current implementations and finish with why it may not always be a bad design. Now onto the examples.
Vulkan Tutorial v1
// main.cpp
#include "App.h"
int main() {
// create an instance of app
try {
app.run();
} catch { exception ) {
// log message return failure
}
return success;
}
// App.h
#pragma once
// include GLFW - Vulkan Header, GLM header
// include stl-libraries vector, string, iostream, etc...
// some const globals values
// some structs
// a couple of freestanding functions, callbacks, functions pointers, and a static function
class App {
public:
void run() { initWindow(); initVulkan(); mainLoop(); cleanUp(); }
private:
// about 30 - 50 member variables
GLFWwindow* window;
VkInstance instance;
VkDebugUtilsMessengerEXT debugMessenger;
VkSurfaceKHR surface;
VkPhysicalDevice physicalDevice;
VkDevice logicalDevice;
VkQueue graphicsQueue;
VkQueue presentQueue;
VkSwapchainKHR swapchain;
VkFormat swapchainImageFormat;
VkExtent2D swapchainExtent;
std::vector<VkImage> swapchainImages;
std::vector<VkImageView> swapchainImageViews;
std::vector<VkFramebuffer> swapchainFramebuffers;
VkRenderPass renderPass;
VkDescriptorSetLayout descriptorSetLayout;
VkPipelineLayout pipelineLayout;
VkPipeline graphicsPipeline;
VkCommandPool commandPool;
VkImage depthImage;
VkDeviceMemory depthImageMemory;
VkImageView depthImageView;
VkImage textureImage;
VkDeviceMemory textureImageMemory;
VkImageView textureImageView;
VkSampler textureSampler;
std::vector<Vertex> vertices;
std::vector<uint32_t> indices;
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;
std::vector<VkBuffer> uniformBuffers;
std::vector<VkDeviceMemory> uniformBuffersMemory;
vkDescriptorPool descriptorPool;
std::vector<VkDescriptorSet> descriptorSets;
std::vector<VkCommandBuffer> commandBuffers;
std::vector<VkSemaphore> imageAvaiableSemaphores;
std::vector<VkSemaphore> renderFinishedSemaphores;
std::vector<VkFence> inFlightFences;
std::vector<VkFence> imagesInFlight;
size_t currentFrame = 0;
bool framebufferResized = false;
void initWindow();
static void framebufferResizeCallback(params);
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createRenderPass();
createDescriptorSetLayout();
createGraphicsPipeline();
createCommandPool();
createDepthResources();
createFramebuffers();
createTextureImage();
createTextureImageView();
createTextureSampler();
loadModel();
createVertexBuffer();
createIndexBuffer();
createUniformBuffers();
createDescriptorPool();
createDescriptorSets();
createCommandBuffers();
createSyncObjects();
}
void mainLoop() {
while(condition) {
pollEvents();
drawFrame();
}
vkDeviceWaitIdle(device);
}
void cleanSwapChain() {
// destroy certain resources
// free others
}
void cleanup() {
cleanupSwapchain();
// destroy everything else in proper order
}
void recreateSwapchain() { .... }
// all of the function calls above in initVulkan
void populateDebugMessengerCreateInfo(params);
void setupDebugMessenger();
VkFormat findSupportedFormat(params);
VkFormat findDepthFormat();
bool hasStencilComponent(param);
void createImage(params);
void transitionImageLayout(params);
void copyBufferToImage();
void createBuffer(params);
VkCommandBuffer beginSingleTimeCommands();
void endSingleTimeCommands(param);
void copyBuffer(params);
uint32_t findMemoryType(params);
void updateUniformBuffers(param);
VkShaderModule createShaderModule(param);
VkSurfaceFormatKHR chooseSwapSurfaceFormat(param);
VkPresentModeKHR chooseSwapPresentMode(param);
VkExtent2D chooseSwapExtent(param);
SwapChainSupportDetails querySwapChainSupport(param);
isDeviceSuitable(param);
bool checkDeviceExtensionSupport(param);
QueueFamilyIndices findQueuFamilies(param);
std::vector<const char*> getRequiredExtensions();
bool checkValidationSupport();
static std::vector<char> readFiled(param);
};
These are just the member variables with a few functions declarations and I didn't even show their definitions and the rest of the functions that are used within those functions. Now imagine the line count of all of those functions... some of those functions are 100+ lines long or longer! This made the single App class very bulky, responsible for everything, and was cumbersome navigating through code to find errors...
Vulkan Tutorial v2
As for version 2. I just broke all of this code into about a dozen different classes... one for the devices (physical, logical, surface, window*, debug...), a class for the swap chain and its related objects... a class for the pipeline its layout... a class for the shaders. A another to create the command pool, command buffers, descriptor pools, and descriptor sets... another for all of the buffers such as buffer, index, vertex, buffer memory, etc. A class for the Textures, and one for Model, one for the depth image and image views, etc...
This was easier to navigate through the code, but it also generated a lot of class dependencies and took awareness of object lifetimes, passing around pointers, etc...
Now, this brings me to my current implementation as it looks something like this:
Vulkan Tutorial v3
I now have 2 projects instead of 1.
Project 1 - The main application is quite simple...
// main.cpp
#include "App.h"
int main() {
try {
App app;
app.run("window title", size{x,y});
}
}
// App.h
#include "VRXApp.h"
class App : public VRXApp {
private:
// a couple of members "window title", "program title" for now...
// eventually it will container more members such as settings for the user
// members for doing logic, controlling animations or performing actions, etc...
};
// App.cpp
// member function implementations... only about 3 to 4 right now...
Project 2 Engine - static lib
// VRXApp.h
#include "VRXEngine."
class VRXApp {
private:
// a few members
protected:
std::unqiue_ptr<VRXEngine> engine;
/// constructor so as to not be able to declare a VRXApp directly, must be inherited from...
public:
// a few public methods... some virtual, some purely virtual, some not...
};
And now we come to the entire point of this demonstration... It is these 2 classes that will illustrate the design here.
// VRX Devices.h
#pragma once
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/vec4.hpp>
#include <glm/mat4x4.hpp>
#include <algorithm>
#include <cstring>
#include <exception>
#include <iostream>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <sstream>
#include <vector>
#ifdef NDEBUG
const bool enableValidationLayers = false;
#else
const bool enableValidationLayers = true;
#endif
namespace vrx {
constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2;
const std::vector<const char*> validationLayers = {
"VK_LAYER_KHRONOS_validation"
};
const std::vector<const char*> deviceExtensions = {
VK_KHR_SWAPCHAIN_EXTENSION_NAME
};
struct QueueFamilyIndices {
std::optional<uint32_t> graphicsFamily;
std::optional<uint32_t> presentFamily;
bool isComplete() {
return graphicsFamily.has_value() && presentFamily.has_value();
}
};
struct SwapChainSupportDetails {
VkSurfaceCapabilitiesKHR capabilities;
std::vector<VkSurfaceFormatKHR> formats;
std::vector<VkPresentModeKHR> presentModes;
};
class VRXDevices {
protected:
VRXDevices() {} // or just make it public and assign it to delete
public:
// Instance
static void createInstance(params);
// Validation & Support
static bool checkValidationSupport();
static std::vector<const char*> getRequiredExtensions();
// Layers and Debugging
static void setupDebugMessenger(params);
static VkResult createDebugUtilsMessengerEXT(params);
static void populateDebugMessengerCreateInfo(params);
static void destroyDebugUtilsMessengerEXT(params);
static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(params);
// Devices (Phyiscal, Logical, Surface, Queue Families & Device Support)
static QueueFamilyIndices findQueueFamilies(params);
static void pickPhysicalDevice(params);
static bool isDeviceSuitable(params);
static bool checkDeviceExtensionSupport(params);
static void createLogicalDevice(params);
static void createSurface(params);
// Swap Chain & Image Views
static VkSurfaceFormatKHR chooseSwapSurfaceFormat(params);
static VkPresentModeKHR chooseSwapPresentMode(params);
static VkExtent2D chooseSwapExtent(params);
static SwapChainSupportDetails querySwapChainSupport(params);
static void createSwapChain(params);
static void createImageViews(params);
static void createFrameBuffers(params);
// Pipelines
static void createRenderPass(params);
static void createPipeline(params);
// Command Pools, Command Buffers, Semaphores and Fences
static void createCommandPool(params);
static void createCommandBuffers(params);
static void createSyncObjects(params);
};
} // namespace vrx
I have just gotten to the point of being able to draw the triangle and to resize the screen... The vertices are still fixed in the shader... I still have yet to implement the vertex, index, and uniform buffers, textures, model loading, etc... Yes, there are quite a lot of parameters being passed to these functions, but this current design pattern within this specific context... does make the code look more elegant, readable, and manageable... I'll get to the technical side in just a bit, but here is what my Engine header looks like...
//VRX Engine.h
#pragma once
#include "VRX Devices.h"
namespace vrx {
class VRXEngine {
public:
const std::vector<std::string_view> shaderFilenames{ "vert.spv", "frag.spv" };
private:
GLFWwindow* window_{ nullptr };
glm::ivec2 windowSize_;
VkInstance instance_;
std::vector<VkExtensionProperties> extensionProps_;
VkDebugUtilsMessengerEXT debugMessenger_;
VkSurfaceKHR surface_;
VkPhysicalDevice physicalDevice_{ VK_NULL_HANDLE };
VkDevice device_;
VkQueue graphicsQueue_;
VkQueue presentQueue_;
VkSwapchainKHR swapChain_;
VkFormat swapChainImageFormat_;
VkExtent2D swapChainExtent_;
std::vector<VkImage> swapChainImages_;
std::vector<VkImageView> swapChainImageViews_;
std::vector<VkFramebuffer> swapChainFramebuffers_;
VkRenderPass renderPass_;
VkPipelineLayout pipelineLayout_;
VkPipeline graphicsPipeline_;
VkCommandPool commandPool_;
std::vector<VkCommandBuffer> commandBuffers_;
std::vector<VkSemaphore> imageAvailableSemaphores_;
std::vector<VkSemaphore> renderFinishedSemaphores_;
std::vector<VkFence> inFlightFences_;
std::vector<VkFence> imagesInFlight_;
size_t currentFrame_{ 0 };
bool framebufferResized_ = false;
public:
static void framebufferResizeCallback(GLFWwindow* window, int with, int height) {
auto app = reinterpret_cast<VRXEngine*>(glfwGetWindowUserPointer(window));
app->framebufferResized_ = true;
}
void createWindow(GLFWwindow* window, glm::ivec2 size) {
window_ = window; windowSize_ = size;
glfwSetWindowUserPointer(window, this);
glfwSetFramebufferSizeCallback(window, framebufferResizeCallback);
}
void initVulkan(std::string_view app_name, std::string_view engine_name, glm::ivec3 app_version = glm::ivec3(1, 0, 0), glm::ivec3 engine_version = glm::ivec3(1, 0, 0));
void cleanup();
void reportExtensions();
void renderFrame();
void recreateSwapchain();
void cleanupSwapchain();
// accessors functions for each member... both object and pointer versions.
};
} // namespace vrx
Here I can show almost the entire engine.cpp file as it is not overburdened with all of the boilerplate initialization and creation code, etc... It has all of the functionality of the details that it is currently truly responsible for.
// VRX Engine.cpp
#include "VRX Engine.h"
#include "VRX Devices.h"
namespace vrx {
void VRXEngine::initVulkan(params) {
VRXDevices::createInstance(params);
VRXDevices::setupDebugMessenger(params);
VRXDevices::createSurface(params);
VRXDevices::pickPhysicalDevice(params);
VRXDevices::createLogicalDevice(params);
VRXDevices::createSwapChain(params);
VRXDevices::createImageViews(params);
VRXDevices::createRenderPass(params);
VRXDevices::createPipeline(params);
VRXDevices::createFrameBuffers(params);
VRXDevices::createCommandPool(params);
VRXDevices::createCommandBuffers(params);
VRXDevices::createSyncObjects(params);
}
void VRXEngine::cleanup() {
cleanupSwapchain();
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
vkDestroySemaphore(device_, renderFinishedSemaphores_[i], nullptr);
vkDestroySemaphore(device_, imageAvailableSemaphores_[i], nullptr);
vkDestroyFence(device_, inFlightFences_[i], nullptr);
}
vkDestroyCommandPool(device_, commandPool_, nullptr);
vkDestroyDevice(device_, nullptr);
if (enableValidationLayers) {
VRXDevices::destroyDebugUtilsMessengerEXT(instance_, debugMessenger_, nullptr);
}
vkDestroySurfaceKHR(instance_, surface_, nullptr);
vkDestroyInstance(instance_, nullptr);
}
void VRXEngine::cleanupSwapchain() {
for (auto framebuffer : swapChainFramebuffers_) {
vkDestroyFramebuffer(device_, framebuffer, nullptr);
}
vkFreeCommandBuffers(device_, commandPool_, static_cast<uint32_t>(commandBuffers_.size()), commandBuffers_.data());
vkDestroyPipeline(device_, graphicsPipeline_, nullptr);
vkDestroyPipelineLayout(device_, pipelineLayout_, nullptr);
vkDestroyRenderPass(device_, renderPass_, nullptr);
for (auto imageView : swapChainImageViews_) {
vkDestroyImageView(device_, imageView, nullptr);
}
vkDestroySwapchainKHR(device_, swapChain_, nullptr);
}
void VRXEngine::recreateSwapchain() {
int width = 0;
int height = 0;
glfwGetFramebufferSize(window_, &width, &height);
while (width == 0 || height == 0) {
glfwGetFramebufferSize(window_, &width, &height);
glfwWaitEvents();
}
vkDeviceWaitIdle(device_);
cleanupSwapchain();
VRXDevices::createSwapChain(params);
VRXDevices::createImageViews(params);
VRXDevices::createRenderPass(params);
VRXDevices::createPipeline(params);
VRXDevices::createFrameBuffers(params);
VRXDevices::createCommandBuffers(params);
}
void VRXEngine::renderFrame() {
vkWaitForFences(device_, 1, &inFlightFences_[currentFrame_], VK_TRUE, UINT64_MAX);
uint32_t imageIndex;
VkResult result = vkAcquireNextImageKHR(device_, swapChain_, UINT64_MAX, imageAvailableSemaphores_[currentFrame_], VK_NULL_HANDLE, &imageIndex);
if (result == VK_ERROR_OUT_OF_DATE_KHR) {
recreateSwapchain();
return;
} else if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) {
throw std::runtime_error("failed to acquire swap chain image!");
}
if (imagesInFlight_[imageIndex] != VK_NULL_HANDLE) {
vkWaitForFences(device_, 1, &imagesInFlight_[imageIndex], VK_TRUE, UINT64_MAX);
}
imagesInFlight_[imageIndex] = inFlightFences_[currentFrame_];
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
VkSemaphore waitSemaphores[] = { imageAvailableSemaphores_[currentFrame_] };
VkPipelineStageFlags waitStages[] = { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = waitSemaphores;
submitInfo.pWaitDstStageMask = waitStages;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffers_[imageIndex];
VkSemaphore signalSemaphores[] = { renderFinishedSemaphores_[currentFrame_] };
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores;
vkResetFences(device_, 1, &inFlightFences_[currentFrame_]);
if (vkQueueSubmit(graphicsQueue_, 1, &submitInfo, inFlightFences_[currentFrame_]) != VK_SUCCESS) {
throw std::runtime_error("failed to submit draw command buffer!");
}
VkPresentInfoKHR presentInfo{};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = signalSemaphores;
VkSwapchainKHR swapChains[] = { swapChain_ };
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = swapChains;
presentInfo.pImageIndices = &imageIndex;
result = vkQueuePresentKHR( presentQueue_, &presentInfo);
if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR || framebufferResized_) {
framebufferResized_ = false;
recreateSwapchain();
} else if (result != VK_SUCCESS) {
throw std::runtime_error("failed to present swap chain image!");
}
currentFrame_ = (currentFrame_ + 1) % MAX_FRAMES_IN_FLIGHT;
}
} // namespace vrx
Now, as for the implementation details, VRXDevices
can not be declared as an object, it's constructor and destructor are deleted. You can not do this:
VRXDevices vrxDevices; // This will fail to compile! There is NO SUCH OBJECT
Now, all of the static functions is this class have static binding
and static linkage
and there will only ever be 1 declared and defined function regardless of what translation unit they are found in, also this type of class has no member variables. Now, these types of classes could also contain constexpr type name value
that are related to the class of functions!
Here the user is forced to use scope resolution operator to use them such as
VRXDevices::createSwapchain(args...);
If these were just standalone functions wrapped in a namespace such as
namespace vrx {
namespace VRXDevices {
void createSwapchain(args...);
}
}
Then a user can easily do this:
using namespace vrx::VRXDevices;
then
createSwapchain();
however, maybe not with this function, but with some other functions there could end up being naming collisions and or resolution problems especially if they start to use aliases... the idea here is the restrict the user for using the using directive on a namespace. Now if I had a bunch of free-standing math type functions such as sqrt, min, max, ceil, floor, etc... then yeah I'd just put them into a namespace... You put values in, it calculates and gives a result back... but not in this case as this is kind of "domain" specific so-to-speak.
Another thing about the concept of wrapping these functions into a nondeclarative class that has complete static binding is that down the road when I start to work on doing multiple shaders and I need multiple pipelines... I'll eventually refactor this
like I did in my second version by grouping similar functions such as keeping all of the swap chain stuff together, all of the pipeline stuff together all of the buffers together, the command pools and command buffers, the descriptor pools & descriptor sets, etc... then when I need to start using templates, especially variadic templates... I might have something like:
template<typename... Args>
class VRXShaders {
public:
enum Type { BLOOM, BLUR, REFLECT, ETC...};
VRXShaders() = delete;
~VRXShaders() = delete;
static VkShaderModule createShader( Type type, args... );
};
You can not template a namespace... and although you could have a variadic function template, you wouldn't be able to make it have static linkage unless if you put it into a class. Now, when you delete the constructor and the destructor, the class is no longer considered an object, and the user will have to be forced to use the scope resolution operator. This binds related functions to a specific scope resolution.
I do not believe that it is an anti-pattern at all. Now, I'm not trying to claim that this is the perfect solution for every scenario because that is not always the case. Sometimes you might need RAII, you might need CRTP, you might need SFINAE, you might need Polymorphism it all depends on the task at hand. There are places that this kind of structure is very useful, it doesn't affect debugging at all, it keeps the code clean and concise, it helps to make it readable and also expresses intent.
Now can people abuse this technique? Yes, they can and when that happens it can become an issue and possibly a problem. The only major problem here would be when someone goes to refactor it, they would have to move functions, change the resolution to its new scope, change the parameter passing, reorganize things due to change in object lifetimes, etc. So when this is abused, it can become a nuisance and create headaches for some.
I wouldn't say "never" do this, but I also wouldn't say "do this on everything and everywhere". My suggestion on this exact topic is to use this when and where it is appropriate for the problem domain...
You see there are Class Objects
such as:
class Foo {
public:
int x();
explicit Foo(int val) : x{val} {}
int me() const { return x; }
}
Foo foo(420);
auto what = foo.me();
And there are Classes of Functions
struct vec2 {
float x;
float y;
vec2() : x{0}, y{0} {}
vec2(float a, float b) : x{a}, y{b} {}
auto operator[](uint16_t idx) {
if (idx >= 1) return y;
else return x;
}
}
class transformations {
transformations();
public:
static vec2 translate(vec2, vec2);
static vec2 translate(vec2, float);
static vec2 translate(float, vec2);
static vec2 rotate(vec2, float);
static vec2 rotate(float, vec2);
static vec2 scale(vec2, float);
static vec2 scale(float, vec2);
static float dot(vec2, vec2);
static vec3 cross(vec2, vec2);
};
There are particular use cases for this technique when certain types of patterns show themselves. It is not so much about should you or shouldn't you use this pattern, it is more about knowing where, when, and how to use them within the appropriate context while possessing a good balance of using other design patterns that fit each problem accordingly!
I demonstrated that OPP can still be used and not violated with the aide of this technique via the VRXEngine
class along with the static binding
functions that initialize and create the internal components of the VRXEngine
class.
When a specific function needs to modify its internal members after creation, that's when you'd need or prefer to have a member function. You can see that within the context of the cleanupSwapChain()
, recreateSwapChain()
and renderFrame()
as these modify the internal members after creation. All of the static
functions within the scope of the non-instantiable VRXDevice
class only create, populate, or initializes the VRXEngine::member
's values before use.