Inferno Game Engine
Open-source game engine, written in C++17, GLSL and Lua with the libraries EnTT, glad, GLFW, GLM, sol3 and stb, built using CMake. Repository at Gitea, GitHub or GitLab.
Video games have always been a hobby of mine, but what if I could combine my profession with this hobby? Then you get this project, a game engine!
Abstractions
Making a game engine is not an easy task, if the project has any chance of success it needs a solid foundation. Therefor, the main focus of the project thus far has been to work on solid, easy-to-use abstractions. The additional benefit of this approach is that I learned a lot about software design in the process.
Events
The library used for window and context creation and receiving input is GLFW, this is abstracted away in a "Window" wrapper class. This "Window" is created in the "Application" class, which also binds the callback function for event handling.
// Make it more readable
#define NF_BIND_EVENT(f) std::bind(&f, this, std::placeholders::_1)
m_window = std::make_unique<Window>();
m_window->setEventCallback(NF_BIND_EVENT(Application::onEvent));
The event callback is stored in a std::function.
inline void setEventCallback(const std::function<void(Event&)>& callback) { m_eventCallback = callback; }
Inside the "Window" wrapper, we first associate the wrapper to the internal "GLFWwindow" object.
glfwSetWindowUserPointer(m_window, this);
Then, it can set the appropriate callback functions. In this example, we set the position of the mouse. It gets the "Window" wrapper from the association, then creates an event object, which is passed on to the callback function.
// Mouse position callback
glfwSetCursorPosCallback(m_window, [](GLFWwindow* window, double xPos, double yPos) {
Window& w = *(Window*)glfwGetWindowUserPointer(window);
MousePositionEvent event(xPos, yPos);
w.m_eventCallback(event);
});
All we have to do to handle these incoming events is create an event dispatcher and call dispatch. The dispatcher simply checks if the current event is of the same type as the provided event and calls the function if this is the case.
void Application::onEvent(Event& e)
{
EventDispatcher dispatcher(e);
dispatcher.dispatch<MousePositionEvent>(NF_BIND_EVENT(Application::onMousePosition));
}
Logging
I wanted logging to work with both printf
and std::cout
style logging.
To make logging fast and easy to use, it also has to be compatible with user-defined types.
The class hierarchy looks like the following:
.
└── LogStream
└── BufferedLogStream
├── DebugLogStream
└── StringLogStream
"LogStream" is an abstract class with a pure virtual function named "write", to be used as the interface.
We need both variants of the write function in order to also print extended ASCII characters.
class LogStream {
public:
virtual void write(const char* characters, int length) const = 0;
virtual void write(const unsigned char* characters, int length) const = 0;
};
To extend the functionality of the "LogStream" class, we override the bitwise left shift <<
operator.
Support for user-defined types is added in the same way.
const LogStream& operator<<(const LogStream& stream, const std::string& value)
{
stream.write(value.c_str(), value.length());
return stream;
}
const LogStream& operator<<(const LogStream& stream, bool value)
{
return stream << (value ? "true": "false");
}
In order to keep I/O performance high, logging is done in a buffered manner. When a "BufferedLogStream" is created it allocates an array of 128 bytes in length on the stack, which you can then write to. If the buffer size is exceeded, the buffer grows automatically to the required size in chunks of 128, this time on the heap.
// Append to buffer
memcpy(buffer() + m_count, characters, length);
// Buffer is increased in chunks of 128 bytes
size_t newCapacity = (m_count + bytes + BUFFER_SIZE - 1) & ~(BUFFER_SIZE - 1);
The "DebugLogStream" class is used to display messages. It has two variables to configure this behavior, "newline" and "type", to indicate if a newline should be printed and the color of the message. When the object goes out of scope, it will print everything that was added to the buffer in one go.
fwrite(buffer(), 1, count(), stdout);
Now we get to the actual usage of the system. Using helper functions, printing colored and formatted messages is easy. Each helper function also has a non-newline variant as newlines are optional. They simply create a "DebugLogStream" and return it.
DebugLogStream dbg(bool newline);
DebugLogStream info(bool newline);
DebugLogStream warn(bool newline);
DebugLogStream danger(bool newline);
DebugLogStream success(bool newline);
dbg() << "Hello World! " << 2.5 << " " << true;
info() << "This will be printed in blue.";
The printf style logging is more complicated than the previous use case.
This is because the function needs to be able to process any number of arguments and of any type, this is accomplished using a template parameter pack.
We use recursion to loop through all the parameters in this pack, with an overload for when there is no parameter expansion.
Because the "DebugLogStream" is used for printing, all the type overloads are available to us, so we don't have to specify the type like usual with printf
.
void dbgln(Log type, bool newline);
void dbgln(Log type, bool newline, const char* format);
template<typename T, typename... P>
void dbgln(Log type, bool newline, const char* format, T value, P&&... parameters)
{
std::string_view view { format };
for(uint32_t i = 0; format[i] != '\0'; i++) {
if (format[i] == '{' && format[i + 1] == '}') {
DebugLogStream(type, false) << view.substr(0, i) << value;
dbgln(type, newline, format + i + 2, parameters...);
return;
}
}
}
This style of logging also has a bunch of helper functions to make using them quick and easy.
template<typename... P> void dbgln(const char* format, P&&... parameters) { dbgln(Log::None, true, format, std::forward<P>(parameters)...); }
template<typename... P> void infoln(const char* format, P&&... parameters) { dbgln(Log::Info, true, format, std::forward<P>(parameters)...); }
template<typename... P> void warnln(const char* format, P&&... parameters) { dbgln(Log::Warn, true, format, std::forward<P>(parameters)...); }
template<typename... P> void dangerln(const char* format, P&&... parameters) { dbgln(Log::Danger, true, format, std::forward<P>(parameters)...); }
template<typename... P> void successln(const char* format, P&&... parameters) { dbgln(Log::Success, true, format, std::forward<P>(parameters)...); }
dbgln("Hello {}, print anything! {} {}", "World", 2.5, true);
dangerln("This will print in {}.", "red");
Finally, "StringLogStream" is used to convert any supported type into a std::string
.
This is achieved by simply converting the buffer in "BufferedLogStream" to a string and setting the provided string to it when the object goes out of scope.
StringLogStream str(std::string* fill);
std::string result;
str(&result) << "Add " << "anything " << 2.5 << " " << false;
Shaders
"Shader" functionality is split into two classes, "Shader" and "ShaderManager".
The manager does exactly what the name would suggest, it manages the resources.
Using convenient functions you can add
, load
, check existence
, remove
shaders.
The shaders get stored in a hash table (hash map), with the key being its name and the value a std::shared_ptr
to the shader object.
Adding anything to the manager that has already been loaded will simply return the existing instance, to prevent duplication.
The other pairs "Texture/TextureManager", "Font/FontManager" and "Gltf/GltfManager" are structured similarly.
void add(const std::string& name, const std::shared_ptr<Shader>& shader);
std::shared_ptr<Shader> load(const std::string& name);
std::shared_ptr<Shader> load(const std::string& vertexSource,
const std::string& fragmentSource);
bool exists(const std::string& name);
void remove(const std::string& name);
void remove(const std::shared_ptr<Shader>& shader);
std::unordered_map<std::string, std::shared_ptr<Shader>> m_shaderList;
To construct a "Shader", only a name needs to be provided. It will then load, compile and link both the vertex and fragment shader files. Any errors like files not existing or GLSL syntax errors will be printed to the console.
// Get file contents
std::string vertexSrc = File::read(name + ".vert");
std::string fragmentSrc = File::read(name + ".frag");
// Compile shaders
uint32_t vertexID = compileShader(GL_VERTEX_SHADER, vertexSrc.c_str());
uint32_t fragmentID = compileShader(GL_FRAGMENT_SHADER, fragmentSrc.c_str());
// Link shaders
if (vertexID > 0 && fragmentID > 0) {
m_id = linkShader(vertexID, fragmentID);
}
An uniform is a global "Shader" variable, to set uniform data, there are functions for the most common used data types.
ASSERT is a wrapper for dbgln
, which can print any registered data types, explained in the Logging section.
int32_t findUniform(const std::string& name) const;
void setInt(const std::string& name, int value);
void setInt(const std::string& name, int* values, uint32_t count);
void setFloat(const std::string& name, float value) const;
void setFloat(const std::string& name, float v1, float v2, float v3, float v4) const;
void setFloat(const std::string& name, glm::vec2 value) const;
void setFloat(const std::string& name, glm::vec3 value) const;
void setFloat(const std::string& name, glm::vec4 value) const;
void setFloat(const std::string& name, glm::mat3 matrix) const;
void setFloat(const std::string& name, glm::mat4 matrix) const;
void bind() const;
void unbind() const;
int32_t Shader::findUniform(const std::string& name) const
{
int32_t location = glGetUniformLocation(m_id, name.c_str());
ASSERT(location != -1, "Shader could not find uniform '{}'", name);
return location;
}
void Shader::setInt(const std::string& name, int value)
{
// Set unifrom int
glUniform1i(findUniform(name), value);
}
In the renderer we only need to do the following.
The the
function in "ShaderManager" is a static function that gets the instance of the singleton.
m_shader = ShaderManager::the().load("assets/glsl/batch-quad");
m_shader->bind();
m_shader->setFloat("u_projectionView", cameraProjectionView);
m_shader->unbind();
Buffers
Rendering in OpenGL is done using a set of two buffers, the vertex buffer and the index buffer.
The vertex buffer is used to store geometry data, in the format of points.
From these points, you can construct triangles, which can actually be rendered.
The constructed triangles are stored as indexes to points in the index buffer, the simplest form of an index buffer of a single triangle looks like the following: [0, 1, 2]
.
When you have these two buffers set up correctly, you can draw a triangle.
void RenderCommand::drawIndexed(const VertexArray& vertexArray, uint32_t indexCount)
{
uint32_t count = indexCount ? indexCount : vertexArray.getIndexBuffer()->getCount();
glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_INT, nullptr);
}
To create a vertex buffer a lot of manual setup is needed.
This includes offset calculation, which is very prone to errors, so these steps are abstracted away in the "VertexBuffer" class.
A "VertexBuffer" object stores a "BufferLayout", which stores a std::vector
of "BufferElements".
"BufferElements" are what OpenGL calls vertex attributes, which allows us to specify any input we want, because of this we also have to specify how the data should be interpreted.
The "BufferElement" objects hold the variables type
, the types size
, offset
in the buffer and if the data is normalized
, these can be accessed via getter functions.
Now for the fun part of vertex buffers, the "BufferLayout", via a std::initializer_list
they can be constructed very easily.
BufferLayout::BufferLayout(const std::initializer_list<BufferElement>& elements)
: m_elements(elements)
{
calculateOffsetsAndStride();
}
void BufferLayout::calculateOffsetsAndStride()
{
m_stride = 0;
for (auto& element : m_elements) {
element.setOffset(m_stride);
m_stride += element.getSize();
}
}
Because of OpenGL's C-like API, rendering requires manual binding of the vertex attribute configurations and vertex buffers, which is a lot of boilerplate. In order to simplify this, a new concept was added called a vertex array object (also known as VAO), which is actually required in OpenGL's core profile. VAOs store the vertex attribute configuration and the associated vertex buffers, they are abstracted away in the "VertexArray" class which stores the "VertexBuffers" and an "IndexBuffer". When adding a "VertexBuffer" to a "VertexArray", it will set up the vertex attribute configuration.
void VertexArray::addVertexBuffer(std::shared_ptr<VertexBuffer> vertexBuffer)
{
const auto& layout = vertexBuffer->getLayout();
ASSERT(layout.getElements().size(), "VertexBuffer has no layout");
bind();
vertexBuffer->bind();
uint32_t index = 0;
for (const auto& element : layout) {
glEnableVertexAttribArray(index);
glVertexAttribPointer(
index,
element.getTypeCount(),
element.getTypeGL(),
element.getNormalized() ? GL_TRUE : GL_FALSE,
layout.getStride(),
reinterpret_cast<const void*>(element.getOffset()));
index++;
}
m_vertexBuffers.push_back(std::move(vertexBuffer));
unbind();
vertexBuffer->unbind();
}
The final usage looks like the following, notice how the layout is created easily using the std::initializer_list
in the setLayout
function.
// Create vertex array
m_vertexArray = std::make_shared<VertexArray>();
// Create vertex buffer
auto vertexBuffer = std::make_shared<VertexBuffer>(sizeof(QuadVertex) * vertexCount);
vertexBuffer->setLayout({
{ BufferElementType::Vec3, "a_position" },
{ BufferElementType::Vec4, "a_color" },
{ BufferElementType::Vec2, "a_textureCoordinates" },
{ BufferElementType::Float, "a_textureIndex" },
});
m_vertexArray->addVertexBuffer(vertexBuffer);
uint32_t indices[] { 0, 1, 2, 2, 3, 0 };
// Create index buffer
auto indexBuffer = std::make_shared<IndexBuffer>(indices, sizeof(uint32_t) * indexCount);
m_vertexArray->setIndexBuffer(indexBuffer);