burn-console: Optimized, and Ready For Marshmallows

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]

 

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
###############################################################################
## 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)
                {
                 &#
160;  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]];
 &#
160;          }
        }
         
        // 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]

3 Responses to “burn-console: Optimized, and Ready For Marshmallows”

  1. Jim V writes:

    This is a great demo of what can be done in PowerShell – seemingly almost anything.

    Lee has done another excercise in teh impossible. Too bad it won’t work in PowerShell V1 RTW. I set out to fix it but there are many textual issues with copying from the web page. We get many junk chars.

  2. Jim V writes:

    See reference
    <a href="http://www.scriptinganswers.com/forum2/forum_posts.asp?TID=2691&PID=14841#14841">ScriptingAnswers</a&gt;

  3. André writes:

    I get this error in Powershell V1-V3:

    You must provide a value expression on the right-hand side of the ‘-‘ operator.
    At C:\Users\joe\Documents\burn-console.ps1:337 char:65
    + for($paletteIndex = 254; $paletteIndex -ge 0; $paletteIndex- <<<< )
    + CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException
    + FullyQualifiedErrorId : ExpectedValueExpression

    Any idea?

    thanks!

Leave a Reply