2D Game Engine

Description

Nutella is a simple game engine I developed with help from these tutorials in C++. It was a project on a much larger scale than almost anything I had done before, and was my way to learn about the components of a game engine. It was also an exercise in software design, as there were many components that must work well individually and cooperatively.

The largest feature by far is the renderer, though there is also architecture in place for input handling, performance profiling, and more. Additionally, I built a custom physics engine as a separate module that links this library this project compiles to.

Source code is available here.

Features

Technologies:

  • OpenGL rendering with GLFW windowing library
  • Immediate mode GUI using Dear ImGui

Features:

  • Custom Rendering Objects abstracted from OpenGL
    • classes for Vertex Buffer, Index Buffer, Vertex Array, Texture, and Shader graphics primitives
    • designed in a library agnostic manner - can be re-implemented for e.g. Vulkan or DirectX
  • Dispatch based Event System
    • Detects and handles both key and mouse input events
    • Detects and handles window events such as resize, focus change, and close
  • Intuitive orthographic camera that can be moved, rotated, and zoomed
    • Controlled by input events using event system
  • Integrated performance profiler
    • Writes performance data to json in format usable by chrome://tracing
    • Automatically compiled out of distribution builds

How I Used the Tutorials

When starting this project, I had never written a game engine, and was unaware of many of the components that were needed. Because of this, I wanted to have some form of guide to make sure I didn’t get too far off track, or miss anything important. However, I also wanted to maintain the independence of this project, and make sure I actually challenged myself and learned something. I researched several different tutorials, but ultimately settled on one that I felt allowed me to balance these two goals.

In the tutorials I used, each video loosely followed a standard format: the first section would describe the problem to be solved and why it was important, while the second part would show the solution. After the problem was explained, I would pause the video and design my own solution. Once I was confident that my solution was as good as I could make it, I would finish the video and compare my code to that of the tutorial.

If there were obvious advantages to the tutorial’s solution over my own, I would go back and implement that one. Because of this, there are significant parts of the project that are very similar to the code presented in the tutorial. However, I have understood and learned from each of these sections of code. If I felt that my solution was better, or that the tradeoffs were reasonable, I would keep my solution and move on to the next video.

One area where I felt that my solution was better than the tutorial was in abstracting the renderer code. My solution could implement all of the same functionality as the tutorials, but was also more user friendly. In addition to the tutorial functionality, I created a Sprite struct that allowed a client to render an image with a transform, tint, and repeat mode with just a couple lines of code. I also had a much more straightforward way of managing which Vertex Buffers and Index Buffers are associated with each other.

By contrast, the tutorial’s event system was much better than my own, and allowed me to learn several features of the language I was previously unaware of. It used std::function to pass functions as arguments to other functions, which was used to create a dispatch system that was more elegant than the callback based approach I had used. Additionally, macros were used to automatically define metadata about the event types, preventing the need to create a large inheritance tree for classes that are essentially the same.

I also implemented some features completely independently from the tutorials. For example, my build system is set up to easily allow client applications to link with Nutella in their build process, something that is not possible with the tutorial’s engine. I was spurred to do this so that I could more easily work on my physics engine with the rendering tools I had developed for Nutella.

What I’m most proud of: Renderer

Rendering is a very important part of any game engine, and Nutella is no exception. For basically as long as I can remember, I have wondered how computers actually know what to display on the screen. Because of that, it was incredibly satisfying for me to finally dig in and really understand what goes on “under the hood”.

Buffers and Shaders: Before this project, I had seen and vaguely understood the idea of 3D models, where a set of points can draw out an object by “connecting the dots”. But this idea started making much more sense once I saw how Vertex Buffers store the list of dots, Index Buffers describe the order to connect the dots in, and shaders decide how to color in the enclosed area. With a solid understanding of the underlying tools, however, I could go even further.

Circles: Vertex Buffers are technically able to store any data, not just a list of points. This comes in handy when, for example, trying to draw a circle. Circles can be approximated by a set of points, but to make the approximation better, we have to add more points, which increases overhead in every step of the rendering pipeline. If instead of a list of points, we send a Vertex Buffer with just a center and a radius, we can write a custom shader to draw the circle defined by this information. Not only does this decrease the amount of information we have to send through each step, it also guarantees our approximation will be accurate to the individual pixel level.

Abstraction: Once I had a good grasp of the tools that were available, I created a set of custom wrapper classes around these graphics primitives. These classes simplify common operations and make them more intuitive to use. I also added a class hierarchy, so that I can implement the same wrapper classes around another rendering library in the future. Overall, the renderer is powerful, flexible, and useful, and I am very proud of it.

What was most challenging: Build System

As I have mentioned, this project is of a larger scale than any I have done before. Additionally, C++ has inherent difficulties in building projects that I had not seen in any other language I had worked with. Handling dependencies, include directories, and linking formats for libraries were all things I had to overcome, and had never really dealt with before.

Compiler Commands: I started essentially at the bottom: by learning individual compiler commands. I created many tiny demo projects to find a way to organize source, header, and object files in an unobtrusive and logically reasonable way. Each of these projects I would compile by hand, sometimes creating a bash script to automate longer commands or sequences of commands.

Makefiles: Next, I discovered the existence of Makefiles. I returned to each of my demo projects, and updated my build commands to work in a Makefile. There were many benefits to using Makefiles over simple shell scripts, from incremental compilation to dependency ordering. However, I still felt that things could be more streamlined. I especially did not enjoy having to update my Makefiles every time I added a new source file.

Meta-Makefiles: I finally ended up using a system called premake. Premake is highly abstracted, configurable in lua, and can generate Makefiles automatically. Creating a new project was as simple as specifying source directory, include directory, and the desired output format. I could also create build dependencies, so that some projects would be built before others. At this point, I finally felt comfortable with building arbitrary C++ projects, and stopped worrying if my code wouldn’t compile because I was using the wrong build commands. This was a challenge that I did not anticipate before starting this project, but I am glad it happened, because I now have a much better understanding of the C++ build process.