Home

Awesome

Doom on Sokol

This is a port of the Doom shareware version to the cross-platform Sokol headers.

Web version: https://floooh.github.io/doom-sokol/

Forked from https://github.com/ozkl/doomgeneric

Also uses:

How to build

Prerequisites:

On Windows, Linux or macOS:

mkdir workspace
cd workspace
git clone https://github.com/floooh/doom-sokol
cd doom-sokol
./fips build
./fips run doom

To open the project in Visual Studio or Xcode, do this instead:

mkdir workspace
cd workspace
git clone https://github.com/floooh/doom-sokol
cd doom-sokol
./fips gen
./fips open

To build the web version (in the doom-sokol directory):

./fips setup emscripten
./fips set config wasm-make-release
./fips build
./fips run doom

Porting Notes

The project has been forked from the doomgeneric project which in turn is a fork of fbDoom. Doomgeneric adds callback functions for easier porting of the rendering-, input- and timing-code to new platforms. This was very useful to get started but in the end didn't help much because Doom (and Doomgeneric) depend on an "own the game loop" application model, while sokol_app.h is built around a frame callback model which required some changes in the Doom gameloop code itself. Eventually nearly all Doomgeneric callbacks ended up as empty stubs, and it probably would have made more sense to start with fbDoom, or even the original Doom source code.

The first step was to replace the main() function with the sokol_app.h application entry and callback functions.

The original main() function is in i_main.c, this source file has been removed completely. Instead the sokol_app.h entry function is in doomgeneric_sokol.c, along with all other sokol-port-specific code.

After sokol_main() is called, execution continues at the init() callback. This first initializes all sokol libraries:

Next, sokol-gfx resource objects are created which are needed for rendering the Doom framebuffer (more on that later).

Next, two asynchronous load operations are started to load the required data files (a WAD file and a soundfont file) into memory.

It's important to note that Doom itself isn't initialized yet, this is delayed until all data has finished loading.

Finally the 'application state' is set to 'loading', which concludes the sokol_app.h initialization function.

This is a good time to talk about the general application structure:

All sokol-port-specific state lives in a single nested data structure which is only accessible from within the doomgeneric_sokol.c source file.

The application goes through several states before running any actual Doom code:

Frame Slicing

The original Doom main() function calls the D_DoomMain() which doesn't return until the game quits. D_DoomMain() consists of a lot of initialization code and finally calls the D_DoomLoop() function, which has a while (1) { ... } loop.

The doomgeneric D_DoomLoop() looks a bit different but also has the infinite while loop at the end.

The actually important function within the while loop is TryRunTics() which has a tricky nested waiting loop.

And finally there's another ugly doubly-nested loop at the end of the D_Display() function which performs the screen transition 'wipe' effect.

Those nested loops are all bad for a frame callback application model which is throttled by the vsync instead of explict busy loops and need to be 'sliced' into per-frame code.

Let's start at the top:

These 3 hacks were enough to make Doom run within the frame callback application model.

Game tick timing now happens at the top in the sokol_app.h frame callback, and this is were I accepted a little compromise. The original Doom runs at a fixed 35Hz game tick rate (probably because 70Hz was a typical VGA display refresh rate in the mid-90s). Instead of trying to force the game to a 35Hz tick rate and accept slight stuttering because of skipped game tics on displays refresh rates that are not a multiple of 35Hz I allow the game to run slightly slow or fast, but never skip a display frame to guarantee a smooth frame rate. For instance on a 60Hz, 120Hz or 240Hz monitor the game will run slightly slow at a 30Hz game tick rate, while on an 80Hz monitor it will run slightly fast at 40Hz. Only on a 70Hz or 140Hz display it will run exactly right at 35Hz game tick rate.

File IO and WAD loading

There's a lot of not really relevant file IO in the original Doom code base for WAD file discovery, configuration files, savegames and some other unimportant things which I simply commented out or disabled otherwise.

The only really relevant file IO code is reading data from a single WAD file. This has been ported by first loading a WAD file asynchronously into memory before the game starts, and then replacing the C-runtime file IO functions with equivalent functions that work on a memory buffer instead of a filesystem file.

Interestingly, the Doom codebase already includes such a "memory filesystem" here, but doesn't appear to use it.

All WAD file accesses are also already wrapped through a jump table interface so that it was quite trivial to redirect WAD data loading from the C file IO functions to the already existing memio functions.

Rendering

Doom renders to a VGA Mode 13h framebuffer: 320x200 pixels with one byte per pixel, referencing a 256 entry color palette.

fbDoom converts the Mode13 framebuffer into an RGBA framebuffer with 32 bits per pixel, and doomgeneric replaces the Linux framebuffer write with a callback function.

In the Sokol port I'm skipping all the additional code in fbDoom and doomgeneric, and use sokol_gfx.h for the color palette lookup and rendering the resulting RGBA8 texture to the display.

Rendering is performed in two sokol_gfx.h render passes:

Sound

Sound support is split into two areas:

Doomgeneric simply ignores sound support, and fbDOOM and the original Linux DOOM implement sound effect support in a separate process, but I haven't found any signs of background music support there (but I haven't looked too hard either).

Mattias Gustavsson's doom-crt to the rescue!

Doom-crt implements proper background music support through a MUS parser written by Mattias, and the TinySoundFont library by Bernhard Schelling.

This is how sound support is implemented in Doom-Sokol:

When the Doom code needs to play a sound effect or start a new song, it will call one of the callback functions of the sound- or music-module.

The core of the sound effect code are the two functions snd_addsfx() and snd_mix().

The snd_addsfx() function is called when Doom needs to start a new sound effect. This will simply register the sound effect's wave table with a free voice channel. Finding a free voice channel (or stealing an occupied channel) already happened in Doom's generic sound effect code.

The function snd_mix() then simply needs to mix the active sound effects of all voice channels into a stereo sample stream.

Music support starts with loading a 'sound font' into memory and registering it with the tinysoundfont library.

Everything else is handled in the music module callback functions.

When Doom wants to start a new music track it first calls the RegisterSong callback, this simply stores a pointer and size to the MUS file data stored in memory.

Next the callback PlaySong is called. This registers the song data with the mus.h library.

Everything else happens in the mus_mix() function which plays the same role as the snd_mix() function, but for music. Its task is to generate a stereo sample stream by glueing the mus.h library which parses the MUS file data to the TinySoundFont library which 'realizes' MUS events and 'renders' a sample stream which is mixed into the previously generated sound effect sample stream.

The final missing piece of the sound code is the update_game_audio() function. This is called once per frame (not per game tick) by the sokol_app.h frame callback, generates the required number of stereo samples for sound effects and music, and finally pushes the generated stereo sample stream into sokol_audio.h for playback.