burn-console.msh Part II - A working implementation

Sat, Nov 26, 2005 7-minute read

Now that we’ve generated the palette, the most complex part of the algorithm is actually behind us.  The remaining code implements the fire algorithm, and is fairly simple:

  1. Generate fire on the bottom row of the screen.
  2. Move the already existing fire upwards on the screen.  Rather than just move every cell of fire upwards, though, we take into account the fact that heat is always affected by nearby heat.  So the fire that we move upwards will actually be the average heat from its four neighboring cells.

Another technique that we implement is called “double buffering.”  If we were to update each cell on the screen as we compute it, the graceful effect of frame-by-frame animation would be destroyed.  It looks like the progressive text of a teleprompter, as compared to the subtle scrolling of credits at the end of a movie.  To combat this, we draw each new frame onto a pretend screen (which is one buffer.)  As soon as we’re finished our calculations, we bulk-update the real screen (which is the second buffer.)

The updated implementation is below, and also here: burn-console-1.working.msh.  The two new functions are updateBuffer and updateScreen, along with a little bit of new code in the main function.  The performance is atrocious, though.  I get about 0.4 frames per second on my computer, so we’ll work on improving that over the next two posts.  In fact, we’ll be in the mid 30s for frames per second by the time we’re done.

BTW – for a bit more visual realism, change your console font to either the 4x6 raster font, or the 5pt Lucida Console font.

###############################################################################
## burn-console.msh
##
## Create a fire effect in MSH, using the Console text buffer as the rendering
## surface.
##
## Great overview of the fire effect algorithm here: 
## http://freespace.virgin.net/hugo.elias/models/m_fire.htm
##
###############################################################################
function main
{
    ## Rather than a simple red fire, we'll introduce oranges and yellows
    ## by including Yellow as one of the base colours
    $colours = "Yellow","Red","DarkRed","Black"
    
    ## The four characters that we use to dither with, along with the 
    ## percentage of the foreground colour that they show
    $dithering = "█","▓","▒","░"
    $ditherFactor = 1,0.75,0.5,0.25
    
    ## Hold the palette.  We actually store each entry as a BufferCell,
    ## since we need to retain a foreground colour, background colour,
    ## and dithering character.
    $palette = new-object System.Management.Automation.Host.BufferCell[] 256
    
    ## Resize the console to 70, 61 so we have a consistent buffer
    ## size for performance comparison.
    $bufferSize = new-object System.Management.Automation.Host.Size 70,61
    $host.UI.RawUI.WindowSize = $bufferSize

    ## Retrieve some commonly used dimensions
    $windowWidth = $host.UI.RawUI.WindowSize.Width
    $windowHeight = $host.UI.RawUI.WindowSize.Height
    $origin = `
        new-object System.Management.Automation.Host.Coordinates 0,0
    $dimensions = `
        new-object System.Management.Automation.Host.Rectangle `
            0,0,$windowWidth,$windowHeight
    
    ## Create our random number generator
    $random = new-object Random
    $workingBuffer = new-object System.Int32[] ($windowHeight * $windowWidth)
    $screenBuffer = new-object System.Int32[] ($windowHeight * $windowWidth)
    
    clear-host
    
    ## Generate the palette
    generatePalette
    # displayPalette
    # return;

    ## Update the buffer, then update the screen until the user presses a key.  
    ## Keep track of the total time and frames generated to let us display
    ## performance statistics.
    $frameCount = 0
    $totalTime = time-expression {
        while(! $host.UI.RawUI.KeyAvailable)
        {
            updateBuffer
            updateScreen
            $frameCount++
        }
    }
    
    ## Clean up and exit
    $host.UI.RawUI.ForegroundColor = "Gray"
    $host.UI.RawUI.BackgroundColor = "Black"
    
    write-host
    write-host "$($frameCount / $totalTime.TotalSeconds) frames per second."
}

## Update a back-buffer to hold all of the information we want to display on
## the screen.  To do this, we first re-generate the fire pixels on the bottom 
## row.  With that done, we visit every pixel in the screen buffer, and figure
## out the average heat of its neighbors.  Once we have that average, we move
## that average heat one pixel up.
function updateBuffer
{
    ## Start fire on the last row of the screen buffer
    for($column = 0; $column -lt $windowWidth; $column++)
    {
        ## There is an 80% chance that a pixel on the bottom row will
        ## start new fire.
        if($random.NextDouble() -ge 0.20)
        {
            ## The chosen pixel gets a random amount of heat.  This gives
            ## us a lot of nice colour variation.
            $screenBuffer[($windowHeight - 2) * ($windowWidth) + $column] = `
                [int] ($random.NextDouble() * 255)
        }
    }
    
    $tempWorkingBuffer = $screenBuffer.Clone()
    
    ## Propigate the fire
    for($row = 1; $row -lt ($windowHeight - 1); $row++)
    {
        for($column = 1; $column -lt ($windowWidth - 1); $column++)
        {
            ## BaseOffset is the location of the current pixel
            $baseOffset = ($windowWidth * $row) + $column
    
            ## Get the average colour from the four pixels surrounding
            ## the current pixel
            $colour = $screenBuffer[$baseOffset]
            $colour += $screenBuffer[$baseOffset - 1]
            $colour += $screenBuffer[$baseOffset + 1]
            $colour += $screenBuffer[$baseOffset + $windowWidth]
            $colour /= 4.0

            ## Cool it off a little.  We apply uneven cooling, otherwise
            ## the cool dark red tends to stretch up for too long.
            if($colour -gt 70) { $colour -= 1 }
            if($colour -le 70) { $colour -= 3 }
            if($colour -lt 20) { $colour -= 1 }
            if($colour -lt 0) { $colour = 0 }

            ## Store the result into the previous row -- that is, one buffer 
            ## cell up.
            $tempWorkingBuffer[$baseOffset - $windowWidth] = `
                [int] [Math]::Floor($colour)
        }
    }
    
    $SCRIPT:workingBuffer = $tempWorkingBuffer
}

## Take the contents of our working buffer and blit it to the screen
## We do this in one highly-efficent step (the SetBufferContents) so that
## users don't see each individial pixel get updated.
function updateScreen
{
    ## Create a working buffer to hold the next screen that we want to
    ## create.
    $nextScreen = $host.UI.RawUI.GetBufferContents($dimensions)
    
    ## Go through our working buffer (that holds our next animation frame)
    ## and place its contents into the buffer that we will soon blast into
    ## the real RawUI
    for($row = 0; $row -lt $windowHeight; $row++)
    {
        for($column = 0; $column -lt $windowWidth; $column++)
        {
            $nextScreen[$row, $column] = `
                $palette[$workingBuffer[($row * $windowWidth) + $column]]
        }
    }
    
    ## Bulk update the RawUI's buffer with the contents of our next screen
    $host.UI.RawUI.SetBufferContents($origin, $nextScreen)
    
    ## And finally update our representation of the screen buffer to hold
    ## what actually is on the screen
    $SCRIPT:screenBuffer = $workingBuffer.Clone()
}

## Generates a palette of 256 colours.  We create every combination of 
## foreground colour, background colour, and dithering character, and then
## order them by their visual intensity.
##
## The visual intensity of a colour can be expressed by the NTSC luminance 
## formula.  That formula depicts the apparent brightness of a colour based on 
## our eyes' sensitivity to different wavelengths that compose that colour.
## http://en.wikipedia.org/wiki/Luminance_%28video%29
function generatePalette
{
    ## The apparent intensities of our four primary colours.
    ## However, the formula under-represents the intensity of our straight
    ## red colour, so we artificially inflate it.
    $luminances = 225.93,106.245,38.272,0
    $apparentBrightnesses = @{}

    ## Cycle through each foreground, background, and dither character
    ## combination.  For each combination, find the apparent intensity of the 
    ## foreground, and the apparent intensity of the background.  Finally,
    ## weight the contribution of each based on how much of each colour the
    ## dithering character shows.
    ## This provides an intensity range between zero and some maximum.
    ## For each apparent intensity, we store the colours and characters
    ## that create that intensity.
    $maxBrightness = 0
    for($fgColour = 0; $fgColour -lt $colours.Count; $fgColour++)
    {
        for($bgColour = 0; $bgColour -lt $colours.Count; $bgColour++)
        {
            for($ditherCharacter = 0; 
                $ditherCharacter -lt $dithering.Count; 
                $ditherCharacter++)
            {
                $apparentBrightness = `
                    $luminances[$fgColour] * $ditherFactor[$ditherCharacter] +
                    $luminances[$bgColour] *
                        (1 - $ditherFactor[$ditherCharacter])
                    
                if($apparentBrightness -gt $maxBrightness) 
                { 
                    $maxBrightness = $apparentBrightness 
                }
                    
                $apparentBrightnesses[$apparentBrightness] = `
                    "$fgColour$bgColour$ditherCharacter"
            }
        }
    }

    ## Finally, we normalize our computed intesities into a pallete of
    ## 0 to 255.  If a given intensity is 30% towards our maximum intensity,
    ## then it should be in the palette at 30% of index 255.
    $paletteIndex = 0
    foreach($key in ($apparentBrightnesses.Keys | sort))
    {
        $keyValue = $apparentBrightnesses[$key]
        do
        {
            $character = $dithering[[Int32]::Parse($keyValue[2])]
            $fgColour = $colours[[Int32]::Parse($keyValue[0])]
            $bgColour = $colours[[Int32]::Parse($keyValue[1])]
            
            $bufferCell = `
                new-object System.Management.Automation.Host.BufferCell `
                    $character,
                    $fgColour,
                    $bgColour,
                    "Complete"
                    
            $palette[$paletteIndex] = $bufferCell
            $paletteIndex++
        } while(($paletteIndex / 256) -lt ($key / $maxBrightness))
    }
}

## Dump the palette to the screen.
function displayPalette
{
    for($paletteIndex = 254; $paletteIndex -ge 0; $paletteIndex--)
    {
        $bufferCell = $palette[$paletteIndex]
        $fgColor = $bufferCell.ForegroundColor
        $bgColor = $bufferCell.BackgroundColor
        $character = $bufferCell.Character

        $host.UI.RawUI.ForegroundColor = $fgColor
        $host.UI.RawUI.BackgroundColor = $bgColor
        write-host -noNewLine $character
    }
    
    write-host
}

. main

[Edit: Monad has now been renamed to Windows PowerShell. This script or discussion may require slight adjustments before it applies directly to newer builds.]