burn-console: Optimized, and Ready For Marshmallows

Sun, Dec 18, 2005 7-minute read

In the last article, I introduced a library function to help you invoke inline C# from your scripts.  Since we carefully profiled the burn-console script’s performance, we know exactly where to apply these inline performance optimizations: to the updateBuffer function, and the updateScreen function.

The completed script is included below. The inline optimizations bring us to about 30 - 40 frames per second, easily fast enough to provide smooth animation.

[Script download: http://www.leeholmes.com/projects/Burn-Console/Burn-Console.ps1]

###############################################################################
## Burn-Console.ps1
##
## Create a fire effect in PowerShell, 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
{
    write-debug "ENTER 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.
    [System.Management.Automation.Host.BufferCell[]] $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 = measure-command {
   
        while($true)
        {
            if([Console]::KeyAvailable)
            {

                $key = [Console]::ReadKey()
                if(($key.Key -eq 'Escape') -or
                    ($key.Key -eq 'Q') -or
                    ($key.Key -eq 'C'))
                {
                    break
                }
            }
       
            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."
    write-debug "EXIT"
}

## 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
{
    ## This function takes the most of our time, so we'll do it inline.
    ## Inputs:
    ## Window Height
    ## Window Width
    ## Screen Buffer
    ## Random Number Generator
    ## Output:
    ## Working Buffer
   
    [System.Collections.ArrayList] $inputs = `
        new-object System.Collections.ArrayList
    [void] $inputs.Add([int] $windowHeight)
    [void] $inputs.Add([int] $windowWidth)
    [void] $inputs.Add([int[]] $screenBuffer)
    [void] $inputs.Add([System.Random] $random)
   
    $code = @"
    public static Object UpdateBuffer(System.Collections.ArrayList arg)
    {
        // Unpack the inputs from our input object
        int windowHeight = (int) ((System.Collections.ArrayList) arg)[0];
        int windowWidth = (int) ((System.Collections.ArrayList) arg)[1];
        int[] screenBuffer = (int[]) ((System.Collections.ArrayList) arg)[2];
        Random random = (Random) ((System.Collections.ArrayList) arg)[3];
         
        // Start fire on the last row of the screen buffer
        for(int column = 0; column < windowWidth; column++)
        {
            // There is an 80% chance that a pixel on the bottom row will
            // start new fire.
            if(random.NextDouble() >= 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);
            }
        }
         
        int[] tempWorkingBuffer = (int[]) screenBuffer.Clone();
         
        // Propigate the fire
        int baseOffset = windowWidth + 1;
        for(int row = 1; row < (windowHeight - 1); row++)
        {
            for(int column = 1; column < (windowWidth - 1); column++)
            {
                // Get the average colour from the four pixels surrounding
                // the current pixel
                double colour =
                    (
                        screenBuffer[baseOffset] +
                        screenBuffer[baseOffset - 1] +
                        screenBuffer[baseOffset + 1] +
                        screenBuffer[baseOffset + windowWidth]
                     ) / 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 > 0)
                {
                    if(colour > 70)
                    {
                        colour -= 1;
                    }
                    else
                    {
                        colour -= 3;
                         
                        if(colour < 1)
                        {
                            colour = 0;
                        }
                        else if(colour < 20)
                        {
                            colour -= 1;
                        }
                    }
                }
 
                // Store the result into the previous row -- that is, one buffer
                // cell up.
                tempWorkingBuffer[baseOffset - windowWidth] = (int) colour;
                baseOffset ++;
            }
             
            baseOffset += 2;
        }
 
        return tempWorkingBuffer;
    }
"@
    $returnClass = Add-Type -MemberDefinition $code -Name BurnConsoleUtils1 -PassThru 
    $returned = $returnClass::UpdateBuffer($inputs)
    $SCRIPT:workingBuffer = $returned
}

## 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
{
    write-debug "ENTER updateScreen"
   
    ## This function takes up a lot of time, so we'll do it inline.
    ## Inputs:
    ## host.UI.RawUI
    ## palette
    ## workingBuffer
    ## origin
    ## dimensions
    ## windowHeight
    ## windowWidth
    ## Output:
    ## None
   
    [System.Collections.ArrayList] $inputs = `
        new-object System.Collections.ArrayList
    [void] $inputs.Add([System.Management.Automation.Host.PSHostRawUserInterface] $host.UI.RawUI)
    [void] $inputs.Add([System.Management.Automation.Host.BufferCell[]] $palette)
    [void] $inputs.Add([int[]] $workingBuffer)
    [void] $inputs.Add([System.Management.Automation.Host.Coordinates] $origin)
    [void] $inputs.Add([System.Management.Automation.Host.Rectangle] $dimensions)
    [void] $inputs.Add([int] $windowHeight)
    [void] $inputs.Add([int] $windowWidth)
   
    $code = @"
    public static void UpdateScreen(System.Collections.ArrayList arg)
    {
        System.Management.Automation.Host.PSHostRawUserInterface rawUI =
            (System.Management.Automation.Host.PSHostRawUserInterface)
                ((System.Collections.ArrayList) arg)[0];
        System.Management.Automation.Host.BufferCell[] palette =
            (System.Management.Automation.Host.BufferCell[])
                ((System.Collections.ArrayList) arg)[1];
        int[] workingBuffer =
            (int[]) ((System.Collections.ArrayList) arg)[2];
        System.Management.Automation.Host.Coordinates origin =
            (System.Management.Automation.Host.Coordinates)
                ((System.Collections.ArrayList) arg)[3];
        System.Management.Automation.Host.Rectangle dimensions =
            (System.Management.Automation.Host.Rectangle)
                ((System.Collections.ArrayList) arg)[4];
        int windowHeight = (int) ((System.Collections.ArrayList) arg)[5];
        int windowWidth = (int) ((System.Collections.ArrayList) arg)[6];
 
        // Create a working buffer to hold the next screen that we want to
        // create.
        System.Management.Automation.Host.BufferCell[,] nextScreen =
            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(int row = 0; row < windowHeight; row++)
        {
            for(int column = 0; column < windowWidth; column++)
            {
                nextScreen[row, column] = palette[workingBuffer[(row * windowWidth) + column]];
            }
        }
         
        // Bulk update the RawUI's buffer with the contents of our next screen
        rawUI.SetBufferContents(origin, nextScreen);
    }
"@

    $returnClass = Add-Type -MemberDefinition $code -Name BurnConsoleUtils2 -PassThru 
    $returnClass::UpdateScreen($inputs)

    ## And finally update our representation of the screen buffer to hold
    ## what actually is on the screen
    $SCRIPT:screenBuffer = $workingBuffer.Clone()

    write-debug "EXIT"
}

## 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.]
[Edit: Updated to work with PowerShell RTM and updated Invoke-Inline.ps1 script]