Adventures in Compose - The Doom fire effect

Featured in Fragmented and jetc.dev.

One of the best books I’ve bought in the last few years is the Game Engine Black Book: Doom by Fabien Sanglard. As someone who grew up on games but has never really thought about how they’re made, it’s been a fascinating book to dip in and out of, and there’s some great bits of innovation detailed inside. There’s an axiom about the greatest innovations spawning from constraints, and the original DOOM team had some pretty serious constraints around memory and processing power - things that we generally take for granted these days. Reading about how they solved problems in this environment is fascinating, and I highly recommend the book.

The original PSX version of the DOOM fire effect

One of the things that’s mentioned is the fire effect from the original Playstation/PSX port. Fabien wrote in detail about it, and many people have tried to bring it to life in various programming languages. There’s a few Android examples out there, but I wanted to build a version anyway to challenge myself to do something I would never normally do. I spent a while tinkering with a custom View, and it worked but it wasn’t super satisfying. Then it occurred to me - why not see if it’s possible in Jetpack Compose?

The bare bones

Initially I started at the absolute basics - how do I draw on the screen in Compose? Turns out there’s a Canvas composable function, so we’ll start here by using drawRect:

@Composable
fun DoomCompose() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val offset = 300f
        drawRect(
            rect = Rect(
                left = 0f + offset,
                top = 0f + offset,
                right = size.width.value - offset,
                bottom = size.height.value - offset
            ),
            paint = Paint().apply {
                style = PaintingStyle.fill
                color = Color.Red
            }
        )
    }
}

Beautiful.

Adventures in looping

Easy enough. Next, I want to be able to update the state of the Canvas in an infinite loop - similar to how games tend to execute. For the sake of example, we’re just going to introduce some random variation into the bounds of the Rect. Historically, this sort of thing has been done in onDraw:

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    doSomeCalculation()
    canvas?.let { render(it) }
    postInvalidateDelayed(16)
}

Obviously this isn’t available in a Canvas composable function. Naively, I tried simply drawing within a loop, but this was a no-go:

@Composable
fun DoomCompose() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val handler = Handler()

        val propagate: Runnable = object : Runnable {
            override fun run() {
                try {
                    withSave {
                        drawRect()
                    }
                } finally {
                    handler.postDelayed(this, 16)
                }
            }
        }

        propagate.run()
    }
}

fun DrawScope.drawRect() {
    val offset = 300f + (Random.nextFloat() * 100)
    drawRect(
        rect = Rect(
            left = 0f + offset,
            top = 0f + offset,
            right = size.width.value - offset,
            bottom = size.height.value - offset
        ),
        paint = Paint().apply {
            style = PaintingStyle.fill
            color = Color.Red
        }
    )
}

This didn’t work at all. The loop does run, but the Rect is only rendered once. Handily, it turns out that there’s a Recompose composable, which exposes a recompose function to its children who can invoke it at any time to invalidate the View. Could this be what I was looking for?

@Composable
fun DoomCompose() {
    Recompose { recompose ->
        Canvas(modifier = Modifier.fillMaxSize()) {
            withSave {
                drawRect()
            }
        }

        val handler = Handler()

        val propagate: Runnable = object : Runnable {
            override fun run() {
                handler.postDelayed(this, 500)
                recompose()
            }
        }

        propagate.run()
    }
}

Again, this doesn’t work. drawRect is only called once whilst the Runnable behaves as you would expect. I’m assuming this is because Compose’s clever hash-based memoization doesn’t recognise any changes in the Canvas node, and therefore doesn’t bother drawing - even though the drawRect function is stochastic and isn’t the same every time. There might be a solution in here that I’m not seeing, but for now I think it’s time to ditch the Recompose function.

Modelling state

It seems obvious that the way to solve this is to pass some constantly changing state to the Composable which then forces it to re-render (exactly how Compose is intended to be used in the first place…). For this, we’ll require a @Model class:

@Model
data class DoomState(var offset: Float)

@Composable
fun DoomCompose(state: DoomState) {
    Canvas(modifier = Modifier.fillMaxSize()) {
        withSave {
            drawRect(state.offset)
        }
    }
}

And in the Activity itself, we add the logic for looping the drawing infinitely:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
        val state = DoomState(300f + (Random.nextFloat() * 100))

        DoomCompose(state)

        val handler = Handler()

        val propagate: Runnable = object : Runnable {
            override fun run() {
                handler.postDelayed(this, 500)
                state.offset = 300f + (Random.nextFloat() * 100)
            }
        }

        propagate.run()
    }
}

It works!

Great. With some of the mechanics worked out, we can now start to think about how the heck we actually go about rendering some wicked 90’s graphics on the screen. Before we start on this though, we do a tiny bit of tidying up; the looping logic can be refactored into a composable function itself so that we’re not polluting the Activity and our DoomCompose function is entirely self contained:

@Composable
fun DoomCompose(
    state: DoomState = DoomState()
) {
    DoomCanvas(state) { canvas ->
        setupView(canvas, state)
    }
}

@Composable
fun DoomCanvas(
    state: DoomState
) {
    // Draw stuff
}

Building the fire

According to Fabien’s blog post, this is the pseudocode used to spread the fire:

function doFire() {
    for(x = 0 ; x < FIRE_WIDTH; x++) {
        for (y = 1; y < FIRE_HEIGHT; y++) {
            spreadFire(y * FIRE_WIDTH + x);
        }
    }
 }

 function spreadFire(src) {
    firePixels[src - FIRE_WIDTH] = firePixels[src] - 1;
 }

This seems pretty simple - we need to create an Array of values representing every pixel on screen, where the value represents the intensity of the fire in that instant. In every loop, we decrement this number to simulate the fire decaying as it rises. So for a starting point, let’s create another Array which we’ll use to map the fire intensity to actual colours. These are sampled from the blog post and the gutter preview in Android Studio turned out to be immensely satisfying to look at:

This is pretty good looking.

Next we need to gather some measurements from the Canvas we’ll be drawing on, so we have DoomCanvas pass back this information in a lambda, with a latch so that we only get this information once rather than on every recompose:

@Composable
fun DoomCanvas(
   doomState: DoomState,
   measurements: (Int, Int) -> Unit
) {
   var measured = false

   Canvas(modifier = Modifier.fillMaxSize()) {
      if (!measured) {
          measured = true
          measurements(
              size.width.value.toInt(),
              size.height.value.toInt()
          )
      }
      // Draw
}

Now that we have these measurements we can actually generate the pixel Array, where we initialise every pixel to an intensity of 0, ie a black pixel.

val arraySize = widthPixel * heightPixel

val pixelArray = IntArray(arraySize) { 0 }

Finally we add the fire source itself, which is a line of pixels at the bottom of the screen where the intensity is the maximum - in this case 36, or fireColors.size - 1 to avoid having a magic number floating about:

private fun createFireSource(
  firePixels: IntArray,
  widthPixel: Int,
  heightPixel: Int
) {
   val overFlowFireIndex = widthPixel * heightPixel

   for (column in 0 until.widthPixel) {
       val pixelIndex = (overFlowFireIndex - widthPixel) + column
       firePixels[pixelIndex] = fireColors.size - 1
   }
}

This is applied to the Array of pixels, and then we pass that Array back to the Compose function using a @Model:

@Model
data class DoomState(var pixels: List<Int> = emptyList())

You may have noticed that we’re using a List in the @Model, not the Array that we’re mutating. It turns out that an Array breaks the Compose compiler; this I imagine is due to the fact that an Array in a data class is a tricky thing and you need to override equals and hashcode. A nice little gotcha which is easily bypassed - we simply set pixels using pixelsArray.toList().

So we pass the pixels to the Canvas, and we iterate over them to draw them on screen using the drawRect function we were playing with earlier:

for (column in 0 until widthPixels) {
    // Don't update the bottom fire source row
    for (row in 0 until heightPixels - 1) {
        drawRect(
            rect = Rect(
                (column * pixelSize).toFloat(),
                (row * pixelSize).toFloat(),
                ((column + 1) * pixelSize).toFloat(),
                ((row + 1) * pixelSize).toFloat()
            ),
            paint = Paint().apply {
                val currentPixelIndex = column + (widthPixels * row)
                val currentPixel = firePixels[currentPixelIndex]
                color = fireColors[currentPixel]
            }
        )
    }
}

This gets us this neat looking effect which is kind of fire-like, but not what we want yet:

Getting closer…

According to the original blogpost, it’s just a case of adding some randomness in the fire propagation:

function spreadFire(src) {
  var rand = Math.round(Math.random() * 3.0) & 3;
  firePixels[src - FIRE_WIDTH ] = firePixels[src] - (rand & 1);
}

Alot more like it

This isn’t quite right when first propagating though:

Well, kinda

Back to the blog post; the fire can be spread left and right for more realism:

function spreadFire(src) {
   var rand = Math.round(Math.random() * 3.0) & 3;
   var dst = src - rand + 1;
   firePixels[dst - FIRE_WIDTH ] = firePixels[src] - (rand & 1);
}

In this instance we can actually represent wind direction as a sealed class and do some switching on it:

sealed class WindDirection {
    object Right : WindDirection()
    object Left : WindDirection()
    object None : WindDirection()
}

private fun updateFireIntensityPerPixel(
    currentPixelIndex: Int,
    firePixels: IntArray,
    widthPixel: Int,
    heightPixel: Int,
    windDirection: WindDirection
) {
    val bellowPixelIndex = currentPixelIndex + widthPixel
    if (bellowPixelIndex >= widthPixel * heightPixel) return

    val decay = floor(Random.nextDouble() * 3).toInt()
    val bellowPixelFireIntensity = firePixels[bellowPixelIndex]
    val newFireIntensity = when {
        bellowPixelFireIntensity - decay >= 0 -> bellowPixelFireIntensity - decay
        else -> 0
    }
    val newPosition = when (windDirection) {
        WindDirection.Right -> if (currentPixelIndex - decay >= 0) currentPixelIndex - decay else currentPixelIndex
        WindDirection.Left -> if (currentPixelIndex + decay >= 0) currentPixelIndex + decay else currentPixelIndex
        WindDirection.None -> currentPixelIndex
    }

    firePixels[newPosition] = newFireIntensity
}

And the results are pretty spot on:

Pretty great looking fire

So we have our complete implementation!

Looping back

Here’s a quick breakdown of how we got to this point in the final version:

  • We initialise our DoomCanvas function with an empty model, and have it return to its parent its own measurements in a lambda
  • Next we create an array of pixels using the width and the height, where the Int value represents the intensity of the flames
  • We initialise this array with intensity = 0, which maps to black
  • We also initialise this array with a single row of intensity = MAX, which maps to white. This becomes our fire source
  • Next we set up a loop using a Handler and on each iteration, we:
    • Calculate the propagation of each pixel, with some randomness added for realism
    • Calculate the new intensity of that pixel so that it decays away over time and fades from white, through orange to black
  • We then update the DoomState @Model class with the updated Array of pixels
  • Compose works its magic, recognises the change, and inside the Canvas we simply loop over the Array and call drawRect for each one, supplying a colour.

Further optimisations

There’s a few little bits and pieces that we can do here to further optimise the drawing code. As pointed out by Romain, the hardcoded 16ms Runnable loop is less than ideal. We can instead use the Choreographer class to hook into every frame refresh and remove our Handler/Runnable loop entirely:

val callback = object : Choreographer.FrameCallback {
    override fun doFrame(frameTimeNanos: Long) {
        calculateFirePropagation(pixelArray, canvas, windDirection)
        doomState.pixels = pixelArray.toList()

        Choreographer.getInstance().postFrameCallback(this)
    }
}

As you might have guessed from the code, the Choreographer only runs this callback once before removing it, so we simply re-register the callback within itself to listen to the next frame.

Rounding up

The final result

This was immensely good fun to build and very far outside of my normal wheelhouse. There is almost certainly a more efficient way of doing this in Compose with functionality that I couldn’t find, and if you’ve got any suggestions please chime in and feel free to open a PR if it makes sense. I’ve open-sourced the implementation here for those who want to see the complete picture and contribute. I’d definitely recommend tinkering with it, changing values here and there and getting a feel for how such relatively simple code can produce pretty cool looking results.

Thanks for reading; I’m off to download DOOM Eternal.