Precalculated Lit Textures - GooberMan/rum-and-raisin-doom GitHub Wiki

Precalculated Lit Textures

Remember how reads have been identified as a bad thing? Now imagine if you didn't have to make two reads for every output pixel.

The original Doom renderer

The original Doom ran in 256 colours. These colours are pre-defined by a palette. The chosen palette used shades of colours. Which is fantastic for art in general, hard splits between colour are only great if you're making a cartoony game.

The lighting system took complete advantage of this. An offline processor would take that palette and spit out a translation table, mapping a lower lighting level to the closest-match colour. This could be a darker shade of the source colour, or - more likely as it got darker - a greyscale colour. As textures were saved with palette indices instead of their actual colour, it was a simple matter of using a texel as an index in to the colormap of the current light level to get the correct value to write to the output hardware.

The problem on modern hardware

Two reads for every output pixel. Worse, it's an indirect read. This wasn't exactly ideal back in the day either, but you also didn't have gigabytes of available RAM to run Doom on. So now that memory for the datasizes here is not a concern, let's make it go BRRRR.

Reducing reads requires using 33 times more texture memory

One of my things is that I don't care if something uses a little bit more memory. If it makes it go faster it's probably justified - especially if you're trying to hit 60FPS minimum. Or, in other words, I don't care that a 128x128 texture will now use 528KiB of memory instead of 16KiB (I deal with 11MiB textures minimum in my day job). By doing so, we can reduce the two reads per output pixel down to one.

At the time a flat is loaded or a texture precomposited, we allocate enough space for an additional 32 copies of the texture. The original image remains unaltered, while the additional 32 copies take the original image and perform a colormap lookup for each entry in the colormap table. While there is 34 defined in data for Doom and Doom 2, only 33 are ever used.

The next step is altering the column and span functions to not do the colormap translation. That's easy, you can just delete the array access. Which programmer doesn't like deleting code?

Now you've got everything rendering at full bright. The next step after that is simple - the functions where you set the colormap, you instead want to set the source to be source + texturesize * colormapindex. Assuming you allocated your textures as a single buffer just like I do here.

Flats

Let's have a look at how flats are doing, since they are the easiest to get up and running.

That... really isn't as good as expected

That... really isn't as good as expected

The difference is negligible. A rounding error. It looks better on a machine with DDR3 RAM than on the development machine which uses DDR4 RAM, but it's still not the huge win you'd expect. Alright, so this really seems like flats just won't improve in performance until they're optimised to render column-wise instead of row-wise. Let's ignore them for now.

Textures

Textures require special care. There's two particular render paths that we need to pay attention to.

The first one is the simple one - composited textures. Every texture that has more than one patch in a column will get composited at runtime. These columns then get passed along to the normal column renderer, which doesn't care about column headers and just goes ahead and renders a block of memory with the provided height. So let's keep it simple - composite every texture that gets loaded. Discard whatever you read from the WAD file since it's all polluted with headers. Just give us binary blobs exactly like flats do.

Not so fast.

The second render path will make a mockery of the above approach. Middle textures on a linedef (ie bars, non-solid walls, etc) and sprites all require a "masked" render path. Basically, they need to skip pixels. The way this is done? Use those column headers to provide fragments to the column renderer and skip pixels that way.

This really doesn't fit in nicely with an 8-bit paletted pre-transformed texture buffer. None of those colours in the palette is designated transparent, and making assumptions to that effect will ruin your renderer in no time once you load up user-created content. You literally need one extra bit per pixel to specify transparency.

A full solution to this will come Soon(TM), but for now let's just get something up and running. We keep the original textures around so that they can be rendered masked, and we make a copy of the original column render function that retains the colormap lookup. It's not ideal, but it does mean that a large chunk of our screen will render with pre-lit textures.

What does that get us then? The following graph already has pre-lit flats turned on, and is simply toggling between pre-lit textures and original textures. Note that my performance test did change in between captures here, to a 2560x1600 back buffer. The differences are more visible, but will scale roughly the same.

Well that's more noticeable.

Well that's more noticeable.

It's still not a big gain though, isn't it? Even if you add the difference between flat rendering in to the mix, we're shaving a couple of percent off a frame at most. Interestingly, note the jump in the chart height because the first frame now performs all those pre-lit caching operations (a tiny data-fudge later makes the rest of the chart clearer without needing to write a new spreadsheet). Conclusion?

Worth it. For the same reason that transposing would have been worth it even with no performance gains - what it allows us to do next is where the renderer will really start going BRRRR!