Porting Quake 2 to Vulkan - the thought process

Almost 2 years after I made the initial commit, I came to realize that it might be a good idea to finally perform a brain-dump of sorts and share the thought process behind development of vkQuake2 - at least for the sake of knowledge sharing and being able to get back to it in the future if I get another idea for a project like this. I often regretted not writing down how things were progressing - now I finally decided to redeem myself!

The inspiration

At one point in time I got interested in the Vulkan API and wanted to do something useful in it. Up until then, I fiddled mainly with tiny projects of no bigger value and which didn't really pose any significant technical challenge. I believe the best way to learn new tech is to jump into deep water straight away and try to implement something (not overly) ambitious, so I started thinking about a project bigger than simply rendering a couple of cubes on the screen. One idea was to improve the game engine we develop at Huuuge Games - it was bound for a renderer update anyway and with emergence of MoltenVK it felt like a natural step to get Vulkan running both on Android and iOS simultaneously. Before going for a full engine rewrite, though, I figured it might be best to try something else to get a better idea on how to use Vulkan in a product that's well established and functioning - this is when I remembered vkQuake, a project developed by Axel Gneiting of iD Software.

The idea

Every now and then I get this odd feeling that I probably missed the golden times of game development which I consider late 80s and early to mid 90s of the 20th century. In perspective, it would've felt great to be part of the development team of all the great titles that defined various game genres - first person shooters being one of them. Having looked at vkQuake I figured I could pull something similar off - Quake 3 was already handled by a different developer, leaving Quake 2 as the natural option to go with.

To make life more interesting and the challenge spicier, I decided to reuse the original code released by iD Software and not modify any of the existing modern source ports - those usually add upgrades and elements which change the original experience significantly. What I felt like doing was to release something as faithful to the original as possible, so for the initial vkQuake2 idea I made several assumptions:

  • use the original window and input code from Quake 2 - no SDL-like libraries
  • keep all gameplay bugs unless they crash the system entirely
  • keep the the original, vanilla game look - visual upgrades are allowed but only if they can be turned off
  • primary goal is to make the Vulkan output look exactly the same as the one produced by OpenGL
  • use the excellent Vulkan Memory Allocator for memory handling, since that in itself is a complex topic and what I want to focus on is to get Quake 2 to render using Vulkan as quickly as possible

With that in mind, in mid-September 2018 I created the vkQuake2 repository and started cracking on the challenge!

The design

I wanted to start off with a clean project which produced no compiler warnings and simply worked out of the box in any configuration. Since I was working with a 20+ year old code, there were no guarantees that certain sections would not crash immediately or cause any other problems - one of them was the original software renderer. A lot of instabilities caused by the heavily optimized assembly code were likely caused by the modern compiler doing some extra work which broke the original flow. Since fixing things like that was beyond the scope of this project, I decided to entirely replace the software renderer with the KolorSoft implementation - the visuals were exactly the same with an added bonus of colored lighting. Granted, I broke the rule of staying faithful to the original but on the other hand, my main goal was to replicate the original OpenGL look by using Vulkan anyway! Several days and a couple of fixed compile warnings later I had on my hands fully working Quake 2 which was now ready for the actual project work. It was time to start thinking about the architecture.

In terms of structure, Quake 2 renderers are organized as dynamic libraries - switching between them is essentially unloading the current DLL (in case of Windows) and loading the one which uses the desired API. It's a brilliantly simple solution which makes the functionality implementation completely independent from the application. It also scales very well, since new renderers only need to implement required interface functions and the entire code is self-contained in a separate library project. Plugging it all in is reduced to simply providing necessary function pointers during initialization (for more detailed article on how Quake 2 is constructed, I highly recommend reading an article on the topic by Fabien Sanglard).

   Quake 2
     |
     |--- ref_gl.dll (OpenGL)
     |--- ref_soft.dll (Software)
   (...)
     |--- (any arbitrary renderer)
   (...)
     |--- ref_vk.dll (Vulkan - this is what we're going to create)
Plain and simple! The necessary interface functions were also easy to find, since the game code comes with a dummy "null" system which provides entry points for all functions requiring implementation in specific libraries. This is not only limited to video handling but also takes care of input, CD playback and any other system-specific component.

The beginnings

My next goal was to create an empty ref_vk project. To get the boring part out of the way, I made sure the toggle was already available in the Video menu and also created the basic initialization code which I took from my earlier experiments with Vulkan. With this, I was able to verify that the thing actually worked by switching to the dummy (for now) Vulkan renderer, staring at the blank screen for a while and then blindly going back to OpenGL via a console command just to see what the text output was.

I next started working on debug features that would let me use the game’s console as a source of information while I was still not seeing any actual output on the screen. If you worked with Vulkan, you probably realized at some point that omitting mundane error checks very often leads to early frustration - it’s only half bad if the problem is caught by validation layers but if it goes by unnoticed, it could later on lead to some interesting misbehavior, like synchronization issues or random black screen. Having experienced that first hand in my early projects, I decided to discipline myself and check for correct return values when applicable - for this I created the VK_VERIFY macro:

// verify if VkResult is VK_SUCCESS
#ifdef _DEBUG
#define VK_VERIFY(x) { \
        VkResult res = (x); \
        if(res != VK_SUCCESS) { \
            ri.Con_Printf(PRINT_ALL, "VkResult verification failed: %s in %s:%d\n", QVk_GetError(res), __FILE__, __LINE__); \
            assert(res == VK_SUCCESS && "VkResult verification failed!"); \
        } \
}
#else
#    define VK_VERIFY(x) (void)(x)
#endif

This, however, was only a partial solution since I still wanted to see the error/info messages somehow. For this I decided to use the Windows-specific AllocConsole() and just output the in-game console content into it. Initially it was available only in debug builds but eventually I added a toggle for release builds as well - this turned out to be very helpful when trying to pinpoint some bugs on remote machines which I didn’t have access to.

With Vulkan initializing successfuly and necessary debugging solutions in place, I was finally ready to get into putting things on screen.


AllocConsole() at work with game console contents being outputted into it directly.

The implementation

How does one start creating a new renderer in a codebase he or she is not familiar with and the LOC count is significantly large? I decided to approach this problem in a somewhat bruteforce, yet effective, way - all rendering code was moved 1:1 from OpenGL implementation into the ref_vk project and one by one I started commenting out the code from all functions that had any rendering calls inside. Asserts proved invaluable here, since they saved me the time of manually following function calls and gave me the needed stacktrace with basically no additional effort on my side. This method very quickly found the sections that rendered text, background, images, models and sprites. In the span of 2 days I had everything identified, so I could finally start working on the actual implementation. In a matter of hours, I managed to put together code that would draw a gray rectangle in place of texture mapped graphics. First milestone achieved, even though the result didn't look appealing at all!


First ever screenshot of vkQuake2 - the gray rectangle represents the dropped down console background.

From that point on all missing features just kept being implemented as I replaced consecutively hit asserts with proper rendering code. What specifically caught my attention was the fact that the overall architecture of the renderer in Quake 2 required no drastic flow modifications on my part in order for Vulkan to work. That is not to say that the current implementation in vkQuake2 is the most efficient one and to make it work even faster some further modifications would have to be made (see Adam Sawicki's lecture on porting to Vulkan for a great reference), however for the purpose of this project it would be just busy work that would not produce any noticable performance improvement. I spent the next 3 months adding features to make the initial renderer whole: texture mapping, multisampling, mesh rendering, scrap textures, skyboxes and more. By the end of the year I had a working game ready. On December 21st, the first version of vkQuake2 was finally released to the public.

The reception

The following morning I woke up to 100+ notifications on Twitter - the project got attention and for the next couple of days articles about it started popping up on the Internet. With a wider user base, I was receiving first bug reports - some of them requiring blind patching basing on the official specifications, since problems occured only on some configurations (hello AMD!). I quickly came to appreciate the output from validation layers which helped me deal with intricacies of specific GPU drivers on different systems. It took me nearly another month handle community reported problems but the end result was satisfying - I got a product that was working across all tested systems!

The increased attention also gave me a motivation boost to implement additional features (still being true to my initial assumptions) along with considering full support for Linux and MacOS. That last step required adding proper 64-bit target (first releases of vkQuake2 were 32-bit only) since that was the only variant of Vulkan SDK available on those systems. Admittedly, this was a mundane job at first, focusing on fixing pointer arithmetics and somewhat cryptic compiler errors or warnings. Out of the two systems, Linux was the obvious first choice since it already had some (outdated but still) support for building and running the game.

Suffice to say, Linux code didn't age as well as the code for Windows. Software renderer was removed completely since it was no longer possible to build it due to removed dependencies and OpenGL was janky at best. Sound was nonexistent due to some weird OSS incompabilities, so what I got at first was a silent Vulkan-based version of Quake 2. I managed to fix that problem fairly easily by porting the ALSA driver from a Linux Quake 2 port by Icculus. I decided not to pursue getting software and OpenGL to working state since I considered it unnecessary in the long run. In due time, I also discovered how different drivers from different vendors (Linux vs Windows) behave on the same system - performance varied, sometimes even drastically and one system had tendency to manifest specific errors (for example an outdated swapchain and the like) which never occured on the other system.

Getting vkQuake2 to work on Linux did in fact open my eyes to system discrepencies and allowed me to fix some specific synchronization errors which I would not be able to detect on Windows.

Adding MacOS support was an interesting experience in itself. The good part was that some of the Linux code could be reused with only some very system-specific calls needing replacement. Another aspect was that I had to learn how to properly setup, create and refresh a Metal window - something I hadn't done before and which would usually be solved by SDL or a similar library. An interesting experience, though not something I would normally do in my day work. Still, I think it's worth to go through it at least once to get a good understanding of how the underlying tech works. Once the window creation was ready, I launched the game and was immediately welcomed by something unexpected:

As I quickly discovered, Metal has no support for triangle fans, so I had to replace all their occurences with triangle lists. Having the graphics part out of the way (or so I thought), there was still the matter of sound support - ALSA was out of the question and I didn't really feel like going through entire CoreAudio documentation to make it work. Help came from an unexpected place - rather than looking for a compatible Quake 2 port, I found a fully working and interface-compatible driver in... Quake 3 Arena source code, which I integrated with just minor tweaks. It was all fun and games and at that point I felt like my work was done. That is, until I enabled validation layers for the first time and noticed that all I got was a black screen.

Having initially pinpointed the source of the problem to validation layers being on or off, I started suspecting that in fact there could be a problem in the layers themselves, which I eventually reported on GitHub. The LunarG team started suspecting potential issues and ran an investigation - something which in the end turned out to be a simple synchronization problem which manifested exclusively on MacOS. Another case in point that validation layers - while being an excellent help - are still not perfect and subtle errors like that can go unnoticed for a very long time. With a simple fix applied, the MacOS version was ready to go.

So what happened next? I considered the project feature complete and started thinking about some final improvements to make the experience 100% complete. One thing I was especially missing was the underwater view warp seen in the software renderer which for some reason (I suspect performance at the time) was left unimplemented in OpenGL. This was slightly more involved, since it required adding additional render passes and making sure that synchronization is setup correctly.

With help of RenderDoc and simple code analysis, I managed to fix that problem fairly quickly and by porting my ShaderToy effect I got the result I wanted:

While I'm mentioning RenderDoc - if you develop a fairly complex Vulkan application, by all means utilize the debug markers. I can't imagne getting around resource analysis without knowing what is what exactly. Highly recommended!

With that last feature done, vkQuake2 now entered what I call the phase of infinite polish and "just one more fix" - a stage that is difficult to finish unless you realize that the final work will never be perfect. This is not a bad thing, as it always leaves some room for improvements and also makes for a nice motivator to get back to the project later in the future. This was true in my case, since over the span of following months I occasionally got back to either fix some minor bugs or add slightly bigger things, like the exclusive fullscreen mode support. There is also the matter of interaction with the community, without which finding some of the problems would never be possible. It's a priceless experience which I would not want to give up!