Fully functional custom cross-platform c++ game engine for Windows and Nintendo Switch. Worked as part of a 20-man team.
My university allows students to choose which projects they would like to work on (to a certain extent). I chose to be part of the team that would create a custom engine in one semester. I worked on the Windows OpenGL renderer, as well as the Nintendo Switch port. Due to the NDA, I cannot disclose any detailed information about the Nintendo Switch.
Most important things I learned
- Implement “advanced” rendering techniques such as deferred rendering, FXAA, and screen-space ambient occlusion.
- Build a cross-platform engine that supports both Nintendo Switch as well as Windows 10 (single “export” button, no need to change the gameplay logic).
- Working in a big interdisciplinary team (20 team members: artists, designers, producers, and programmers).
|Role:||Graphics & Console Programmer|
|Skills:||C++, OpenGL, Nintendo Switch Programming|
|Software:||Perforce, RenderDoc, CMake, Nintendo Development Tools|
|Team size:||22 team members|
BUILDING A VOXEL GAME WITH A CUSTOM ENGINE
During the second and fourth trimester, we had to write a custom engine and build a game on top of it. Spoilers: we did not finish the game. But that is fine. This portfolio piece does not try to present a cool game. Instead, it will showcase the technical challenges and difficulties we faced while building an engine capable of playing games. I thoroughly enjoyed the process of building a full-fledged game engine with graphics, physics, editors, and other tools.
THE GOAL OF THE PROJECT
In the end, the goal was to build a custom game engine for both Windows as well as the Nintendo Switch. Because the Nintendo Switch platform details are under NDA, I will only talk about the Windows development. From now on, everything you read on this page is about the Windows platform (e.g. performance statistics, APIs, and load times).
The total time we had for this project was half a study year. This sounds like a lot, but in reality, we only had about 16 working weeks – or 112 hours per person – to work on this engine. The first 8 weeks were to be spent on building the engine (team of 8 people, all programmers), while the last 8 weeks were meant to be spent on building a working game (team of 18 people, programmers, artists, and designers).
We started out building a game engine with a specific target genre: voxel top-down twin-stick 2.5D shooter games. These type of games would work perfectly on both platforms. Because voxel data can be tricky to work with, we decided not to reinvent the wheel for our level editor. Instead, we decided to use MagicaVoxel as our voxel editing tool. The advantage of this was that we could provide artists with a solid modeling tool. Because honestly, why would they want to work with our custom tools when there is such a wonderful program out there?
Anyhow, 8 weeks pass and we end up with the following results:
The last 8 weeks of the project were spent on trying to make an actual game with more programmers, artists, and designers. This was the first time any of us had to work with such a large team. It was difficult for everybody at first to get used to such team composition. Especially communication was tricky, as you have to deal with a lot of people.We managed to overcome these challenges, but we already made too many mistakes. We tried changing the engine too much and as a result, failed to deliver a working game. This was a shame from a production point of view. However, I do not consider this project a failure. I see it as one of my better projects, as it has taught me a lot about teamwork, as well as helped me gain an in-depth understanding of game engines and the things that make them work.
SUMMARY OF MY TASKS
I took on the role as a graphics programmer. The engine team had 2 graphics programmers during the first 8 weeks, and 3 graphics programmers during the final 8 weeks. For the complete duration of the project, I have fulfilled the role of a graphics programmer.
The API of choice was OpenGL 4.4. We did think about using one of the lower level APIs such as Vulkan or DirectX12, but I did not see benefits in doing that besides doing it for the sake of learning the APIs. OpenGL suits our project perfectly and as long as we do not take advantage of the new things in Vulkan or DirectX12, there is no reason in choosing those over the existing “old” higher level APIs.
One of the first tasks I tried tackling was the rendering of voxels. When rendering voxels, there are generally two ways to do it: triangulation and ray marching. I chose the former, as it was easy to set-up and did not pose as big of a risk as the ray marching option. Up until this point, I had never done any ray marching, and since a team depends on the voxel renderer, I chose to play it safe. This decision had a big impact on the overall load time of the engine, as we had to do all the triangulation ourselves. We ended up writing a small parser to turn the human-readable file into compressed binary files, but it did not solve the load issues completely.
LIGHTING AND RENDERER SET-UP
Another task of mine was to build the lighting and the deferred renderer. The reason I went with a deferred renderer over a regular forward renderer was that we wanted to add lights to all bullets, enemies, and power-ups. Because a standard deferred renderer works great with lots of lights, it made sense to create one. Do not get me wrong, a rendering technique such as a clustered forward rendering would be perfectly capable of showing lots of lights in the scene, but a standard deferred renderer was less complex and better at handling lots of lights without the need for any spatial partitioning. And if we really needed the performance boost, we could always switch to something like a tile-based deferred renderer.
The in-game lighting was nothing fancy. It was simple Phong lighting with support for the three basic light types: point, spot, directional. It was not that difficult to implement this, but I had never really done a lot of lighting calculations, so it was a good learning opportunity for me.
Anti-aliasing is the process of blurring pixelated edges in a render. There are various techniques that will achieve this, but the one I chose to implement as FXAA (fast approximate anti-aliasing). And now you might be wondering why I did not simply enable MSAA (multisample anti-aliasing) as it is hardware accelerated and built-in in the graphics API… Well, remember the deferred renderer? MSAA does not work well with a deferred renderer. I could have done FSAA (fullscreen anti-aliasing), but I had never done FXAA and it seemed like a great learning opportunity.
Here is a close-up of FXAA in action:
The slider below shows the scene before and after the addition of FXAA. It may not seem like a big difference, but the softer edges really make the scene a lot more pleasing to look at.
Ambient occlusion makes the scene look a lot better. The technique is used to calculate how exposed each point in a scene is to ambient lighting. For instance, a painting on the wall will cast a dim shadow on the wall itself. This is what we try to simulate with ambient occlusion algorithms. It would be really expensive to actually simulate all light paths (path tracing), so we cheat a little…
I chose to implement SSAO (screen-space ambient occlusion) in the engine as a way to “cheaply” simulate the ambient occlusion. The effect can be applied as a post-processing effect, which is neat. The algorithm is fairly simple to implement and easy to convert from a fragment shader into a compute shader. My first implementation was quite funny, as I messed up the coordinate system and inverted the entire effect. Fixing it was easy and the result can be seen below, be sure to move the slider around to see what a difference SSAO makes in a scene!
My first implementation was in the fragment shader. While this worked fine, it took way too long to compute the ambient occlusion texture each frame. Sometimes it could take up to 40 milliseconds of the frame time. Our game had about 16 milliseconds of frame time to do everything, so 40 milliseconds was way too long. I converted the algorithm to a compute shader and managed to reduce the frame time to only 5 milliseconds. A massive optimization!
The slider above showed the difference between no ambient occlusion and ambient occlusion enabled, but the one below this paragraph shows the final shaded version of the scene and the underlying ambient occlusion texture. This texture is used in the shader to apply ambient occlusion to the scene. The camera is a bit far up, so you may be having a hard time seeing the ambient occlusion effect in the shaded image on the left.
The last major task I worked on is frustum culling. Frustum culling is the process of only rendering the objects that fall within the view frustum. Occlusion culling would have been a lot more performant, but frustum culling was easier to implement. And considering we did not have a lot of time left once I started working on culling, I decided that a simple frustum culling algorithm would suffice for this project.
There are various ways one can calculate which object is inside the frustum and which one is not. I chose to reconstruct the frustum planes from the view and project matrices. Then, I wrapped each game object in a bounding box. This turned the problem into a simple challenge: find a box – plane intersection.
It was a lot of fun to implement frustum culling. I had never done it before, so I learned a lot along the way. Next time, I would like to look into occlusion culling, as that culls a lot more aggressively than frustum culling, while still rendering all objects that need to be rendered.
CROSS-PLATFORM GAME ENGINE
This was the first time I built a proper game engine. In addition to this, it was also the first time I worked on a cross-platform game engine! I cannot go into the Nintendo Switch details, but I am proud to announce that the engine did – in fact – run on both Windows as well as a Nintendo Switch.
It was definitely tricky to build an engine for two platforms. Even more so because we did not initially expect to have to go cross-platform. The foundation of the engine was designed with only Windows in mind. Therefore, the Nintendo Switch port was tricky. Especially since we made an export similar to what all commercial engines have. This means you write code once and export a game for Windows as well as Nintendo Switch without any additional configuration.
Most of my projects were only ran for a couple of weeks. I never really had the time to look into fancy graphics programming concepts and techniques. Luckily, I was able to do research into frustum culling, ambient occlusion, and fast approximate anti-aliasing. Implementing these things into the engine has taught me a lot. I also learned about to properly use compute shaders (never used them before) when I had to optimize the screen-space ambient occlusion algorithm.
Since this was the first time any of us had to work with two platforms at the same time, we did not know how to properly abstract things. I made the codebase multi-platform by making use of OOP concepts, but the majority of the code was a mess (in my opinion). It was way more difficult to maintain a single codebase that had to work on two platforms than I initially thought it would be. Because the platforms are so different, it is very hard to reuse low-level code. And if you fail to abstract those kinds of things before they pollute the codebase, it will only become a bigger mess over time. It was a great learning experience, though.
At the start of the project, it was a bit awkward talking to people. Nobody really knew what others were capable of, and communication, in general, was quite bad. The team leads had to relay information on to the feature teams, but that took time and not all information would be conveyed properly. Lost of time was wasted on meetings and the like.
However, after addressing this in a team-wide meeting, we all actively tried to improve the way we communicate with others. I noticed that I started communicating with more people and did this more often. I shared screenshots and cool videos of what I was working on every week, and just kept people up-to-date in general. I learned how to clearly state what you want to do, what you need from others, and why things might not work out. We all filled out peer reviews (team members review each other’s performance) every three weeks, which showed me what I did right and what I could improve upon. This kind of feedback was invaluable, as it gave me a good understanding of my strengths and weaknesses within the team. It was great to see a point of improvement in one peer review and see it becoming a strength in the next review!
PROPER GAME ENGINE WORKFLOW
One of the things I never really thought about when starting this project was how difficult it is to get the artist and designer workflow just right. As a programmer, I am fine with doing things by hand via configuration files and whatnot. But when we tested our designs with real artists and designers, we quickly found out that we needed to improve the engine a lot. Especially when it came to user-friendlyness! We assigned two dedicated tool programmers to the level editor, object serialization, and other features such as gimzos, undo-redo, and more. These two people worked non-stop on tools for 8 weeks straight. Yet, the engine was not as user-friendly as we had hoped.
It really was one of those things you do not immediately think about when you create a tool you think is easy to use. But the testing, iterating, and fixing of small inconveniences really helps the team out a lot.