Animation (C# / WinForm)

The method typically taught for WinForm apps, is to use the on-paint event. That’s because it receives the clipping region (area to be painted) enabling the app to reduce what is drawn, thereby improving performance. I used it in my FamilyTree chart software, which works like Visio with a virtual canvas – with a lot of text and lines you want to consider the clipping region.

But there are times when a simpler approach can be used, and that’s what the post below is about.

Is it more performant or less?

I don’t know, maybe you can benchmark it and let me know in the comments? I used it for Missile Command, Flappy Birds, Sine Waves, and they have a good frame rate. My development machine is an Intel Core i7-9750H CPU @ 2.60GHz/4GHz, with an NVIDIA RTX 2060, and it runs FlightSim at fairly high-res with a good frame rate.

Whilst performance is important, sometimes it’s not the only factor. Before anyone questions using GDI+, I am well aware of faster alternatives, just be thankful this is not another Python site.

My approach is as follows

Create a Windows Form.

Go to Toolbox

Drag a PictureBox onto the form and leave it with the default name of “pictureBox1

Set the pictureBox1.Dock = Fill

Drag a Timer onto the form and leave it with the default name of “timer1

It should look something like this:

View Code (F7)

Now we want to animate something, for that to happen we need to provide something that calls the animation code.

Let’s assume we want 10 frames a second that’s every 100ms. We set the timer1.Interval accordingly. The default for the timer is disabled which won’t do anything so we set .Enabled = true.

Lastly, we need the timer to call some code. Below are the guts of our animation app.

using System.Security.Cryptography;

namespace WinFormsAnimation
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            timer1.Interval = 100;
            timer1.Enabled = true;

            timer1.Tick += OnTickAnimate;
        }

        private void OnTickAnimate(object? sender, EventArgs e)
        {
            // animate here
        }
    }
}

At this point, we’ve not drawn anything.

We need to add the following to our OnTickAnimate() method. What we’re doing is creating an image the size of the pictureBox1, getting a handle to the bitmap (so we can draw on the bitmap), and then replacing the existing image in the pictureBox1 with the new bitmap. The “paint” is handled by the form as necessary.

 private void OnTickAnimate(object? sender, EventArgs e)
 {
   // create an "empty" image
   Bitmap bitmap = new(pictureBox1.Width, pictureBox1.Height); // do not wrap this with "using", we are assigning it to pictureBox1
  
   // to draw on the bitmap, you need a graphics object
   using Graphics graphics = Graphics.FromImage(bitmap);

   // draw 

   pictureBox1.Image?.Dispose(); // "?" because initially it is NULL
   pictureBox1.Image = bitmap; // show the drawn on image
 }

So let’s now draw varying filled circles of different colours to prove that it works.

 private void OnTickAnimate(object? sender, EventArgs e)
 {
   // create an "empty" image
   Bitmap bitmap = new(pictureBox1.Width, pictureBox1.Height); // do not wrap this with "using", we are assigning it to pictureBox1

   // to draw on the bitmap, you need a graphics object
   using Graphics graphics = Graphics.FromImage(bitmap);
   graphics.Clear(Color.Black);

   // draw 
   int size = RandomNumberGenerator.GetInt32(
       10, 
       Math.Min(pictureBox1.Width/2, pictureBox1.Height/2));

   using SolidBrush brush = new(
       Color.FromArgb(RandomNumberGenerator.GetInt32(0, 255), 
                      RandomNumberGenerator.GetInt32(0, 255), 
                      RandomNumberGenerator.GetInt32(0, 255), 
                      RandomNumberGenerator.GetInt32(0, 255)));

   graphics.FillEllipse(brush, 
                        pictureBox1.Width / 2 - size / 2, 
                        pictureBox1.Height / 2 - size / 2, 
                        size, size);

   pictureBox1.Image?.Dispose(); // "?" because initially it is NULL
   pictureBox1.Image = bitmap; // show the drawn on image
 }

When you run it, and you should see something like :

Filled circles painted 10 time a second

It works, without resorting to using the paint event. You’ve also got yourself a consistent frame rate for free.

You might not want to repaint everything in each frame (although chose to in Missile Command unlike the original).

The following will not work, nothing will change despite the initial image assignment:

 Bitmap bitmap;

 // create a new bitmap if first time, else edit the existing
 if (pictureBox1.Image is null) 
   bitmap = new(pictureBox1.Width, pictureBox1.Height); 
 else 
   bitmap = (Bitmap) pictureBox1.Image;

 // to draw on the bitmap, you need a graphics object
 using Graphics graphics = Graphics.FromImage(bitmap);

 // draw 
 int size = RandomNumberGenerator.GetInt32(
     10, 
     Math.Min(pictureBox1.Width/2,pictureBox1.Height/2));

 using SolidBrush brush = new(
     Color.FromArgb(RandomNumberGenerator.GetInt32(0, 255), 
                    RandomNumberGenerator.GetInt32(0, 255), 
                    RandomNumberGenerator.GetInt32(0, 255), 
                    RandomNumberGenerator.GetInt32(0, 255)));

 graphics.FillEllipse(
    brush, 
    pictureBox1.Width / 2 - size / 2, 
    pictureBox1.Height / 2 - size / 2, 
    size, size);
            
 if(pictureBox1.Image is null) pictureBox1.Image = bitmap;

To “change” the existing image we have to copy it and then modify it. And at the end, we have to swap the image.

 Bitmap bitmap;

 // create a new bitmap if first time, else edit the existing
 if (pictureBox1.Image is null) 
   bitmap = new(pictureBox1.Width, pictureBox1.Height); 
 else 
   bitmap = new(pictureBox1.Image);

 // to draw on the bitmap, you need a graphics object
 using Graphics graphics = Graphics.FromImage(bitmap);

 // draw 
 int size = RandomNumberGenerator.GetInt32(
     10, 
     Math.Min(pictureBox1.Width/2,pictureBox1.Height/2));

 using SolidBrush brush = new(
     Color.FromArgb(RandomNumberGenerator.GetInt32(20, 50), 
                    RandomNumberGenerator.GetInt32(0, 255), 
                    RandomNumberGenerator.GetInt32(0, 255), 
                    RandomNumberGenerator.GetInt32(0, 255)));

 graphics.FillEllipse(
    brush, 
    RandomNumberGenerator.GetInt32(10,pictureBox1.Width-20) - size / 2, 
    RandomNumberGenerator.GetInt32(10,pictureBox1.Height -20) - size / 2, 
    size, size);

 pictureBox1.Image?.Dispose();
 pictureBox1.Image = bitmap;

It’s doing what we asked (preserving the last frame), as we now see lots of different colour circles being added:

Lots of semi-transparent circles of varying size

We can speed it up (assuming your CPU & GPU are fast) by changing the timer1.Interval smaller; a value of 20 = 50 fps.

A word of warning, if your drawing is slower than the desired fps, something has to give. Don’t make the mistake of using async or thread for the drawing part, or you’re in a dangerous risk of an error.

Using this approach you’re not going to get the performance Microsoft Flight Simulator, Halo or other amazing games, but it’s a very simple way that I find useful when playing with ML and trying to visualise things.

I invariably use a Parallel.ForEach() for invoking the neural network and moving objects, then draw serially.

Optimising Performance

  • In some circumstances, you will find your UI has a background that is static. Drawing it every time is avoidable. In this scenario, draw the background onto the same size bitmap. Then create the bitmap from the static image. Behind the scenes, it will copy the pixels as a memory copy (very fast).
  • Another scenario is when part of the image is static, you can either do the above approach or you can create a smaller image at start-up and use graphics.DrawImageUnscaled() to instantly inject it. I do this for the parallax scrolling for my ML Flappy Birds.

Example of the first scenario: I wanted a static background with 1000 random coloured/sized rectangles. To render it takes 200-300 milliseconds. You wouldn’t want to be doing that in the animation code. Cache the result, and it’s a 1-off cost, with instant animation:

Circles painted on top of 1000 rectangles

The modified parts of the code look like this: (these are additional lines of code, not a replacement for the previous code).

...

static Bitmap s_backgroundImage;


public Form1()
{
   ...
   DrawComplicatedSlowBackground(); // we'll draw 1000 overlapping rectangles
}

private void DrawComplicatedSlowBackground()
{
   // to draw on the bitmap, you need a graphics object
   using Graphics graphics = Graphics.FromImage(s_backgroundImage);

   // draw 1000 rectangles to s_backgroundImage
   for(int i=0;i<1000;i++)
   {
      using SolidBrush brush = new(
          Color.FromArgb(RandomNumberGenerator.GetInt32(20, 50), 
                         RandomNumberGenerator.GetInt32(0, 255), 
                         RandomNumberGenerator.GetInt32(0, 255), 
                         RandomNumberGenerator.GetInt32(0, 255)));

      graphics.FillRectangle(
          brush, 
          RandomNumberGenerator.GetInt32(0, pictureBox1.Width / 2), 
          RandomNumberGenerator.GetInt32(0, pictureBox1.Height / 2), 
          RandomNumberGenerator.GetInt32(10, pictureBox1.Width), 
          RandomNumberGenerator.GetInt32(10, pictureBox1.Height));
   }

   graphics.Flush(); // s_backgroundImage complete
}     


private void OnTickAnimate(object? sender, EventArgs e)
{
  // clone the background rectangles onto the image
  Bitmap bitmap = new(s_backgroundImage); // NEVER wrap with a "using", as this will be transferred to the PictureBox
   
  // rest of drawing here
  // ...
}

Things for you to try

  • You could create a sub-class of PictureBox that has a .Draw() method passing a lambda expression (with the graphics object) encapsulating the new bitmap creation and with the image switchover.
  • You could write your code to manipulate the bitmap in memory directly (lock > modify > unlock) to eke out better performance (like I do with the cars and GRO scraping)

Did it work for you? Do you have an even better way? If so, please let me know by adding a comment.

Leave a Reply

Your email address will not be published. Required fields are marked *