How should I buffer drawn rectangles to improve performance (C#/.NET/WinForms/GDI+)

Viewed 218

What I'm doing

I am working on a C#/.NET 4.7.2/WinForms app that draws a significant number of filled rectangles on a form using Graphics.FillRectangle.

Currently, the rectangles are drawn in the form's Paint event. After all the rectangles are drawn, a crosshair is drawn based on mouse position.

Whenever the mouse moves, Invalidate is called on the form to force a repaint so that the crosshair appears in its new position.

The problem

This is inefficient because the rectangles don't change, only the crosshair position, yet the rectangles are being redrawn every time. The CPU usage during mouse move is significant.

What next

I believe that the solution to this problem is to draw the rectangles to a buffer first (outside of the Paint event). Then, the Paint event only needs to render the buffer plus draw a crosshair on top.

Since I am new to GDI+ and manual buffering, I am wary of going down the wrong track. Google searches reveal plenty of articles on manual buffering, but each article seems to take a different approach which adds to my confusion.

I would be grateful for suggested approaches that favour simplicity and efficiency. If there is an idiomatic .NET way of doing this — the way it's meant to be done — I'd love to know.

1 Answers

Here's a quick and easy solution that doesn't require any buffering. To replicate this, start with a fresh Windows Forms project. I only draw two rectangles, but you can have as many as you want.

If you create a new WinForms project with these two member variables and these two handlers, you will get a working sample.

First, a couple of member variables for your form:

private bool _started = false;
private Point _lastPoint;

The started flag will turn to true after the first mouse move. The _lastPoint field will track the point at which the last cross-hairs was drawn (that's mostly why _started exists).

The Paint handler will draw the cross hairs every time it's called (you'll see why this is ok with the MouseMove handler):

private void Form1_Paint(object sender, PaintEventArgs e)
{
    var graphics = e.Graphics;
    var clientRectangle = this.ClientRectangle;
    //draw a couple of rectangles
    var firstRectangle = clientRectangle;
    firstRectangle.Inflate(-20, -40);
    graphics.FillRectangle(Brushes.Aqua, firstRectangle);
    var secondRectangle = clientRectangle;
    secondRectangle.Inflate(-100, -4);
    graphics.FillRectangle(Brushes.Red, secondRectangle);

    //draw Cross-Hairs
    if (_started)
    {
        //horizontal
        graphics.DrawLine(Pens.LightGray, new Point(clientRectangle.X, _lastPoint.Y),
            new Point(ClientRectangle.Width + clientRectangle.X, _lastPoint.Y));
        //vertical
        graphics.DrawLine(Pens.LightGray, new Point(_lastPoint.X, clientRectangle.Y),
            new Point(_lastPoint.X, ClientRectangle.Height + clientRectangle.Y));
    }
}

Now comes the MouseMove handler. It's where the magic happens.

private void Form1_MouseMove(object sender, MouseEventArgs e)
{
    var clientRectangle = this.ClientRectangle;
    var position = e.Location;
    if (clientRectangle.Contains(position))
    {
        Rectangle horizontalInvalidationRect;
        Rectangle verticalInvalidationRect;
        if (_started)
        {
            horizontalInvalidationRect = new Rectangle(clientRectangle.X,
                Math.Max(_lastPoint.Y - 1, clientRectangle.Y), clientRectangle.Width, 3);
            verticalInvalidationRect = new Rectangle(Math.Max(_lastPoint.X - 1, clientRectangle.X),
                clientRectangle.Y, 3, clientRectangle.Height);
            Invalidate(horizontalInvalidationRect);
            Invalidate(verticalInvalidationRect);
        }

        _started = true;
        _lastPoint = position;

        horizontalInvalidationRect = new Rectangle(clientRectangle.X,
            Math.Max(_lastPoint.Y - 1, clientRectangle.Y), clientRectangle.Width, 3);
        verticalInvalidationRect = new Rectangle(Math.Max(_lastPoint.X, clientRectangle.X - 1),
            clientRectangle.Y, 3, clientRectangle.Height);
        Invalidate(horizontalInvalidationRect);
        Invalidate(verticalInvalidationRect);

    }
}

If the cursor is within the form, I do a bunch of work. First I declare two rectangles that I will be using for invalidate. The horizontal one will be a rectangle that fills the width of the client rectangle, but is only 3 pixels high, centered on the Y coordinate of the area that I want to invalidate. The vertical one is as high as the client rectangle, but only 3 pixels wide. It's centered on the X coordinate of the area that I want to invalidate.

When the Paint handler runs, it virtually paints the entire client area, but only the pixels in the total invalidated area actually get drawn on the screen. Anything outside the invalidate area is left alone.

So, when the mouse moves, I create two rectangles (one vertical, one horizontal) that surround where the last set of cross-hairs were (so that when the pixels in those rectangles are drawn (including the background), the old cross-hairs are effectively erased) and then I create two new rectangles surrounding where the current cross-hairs should go (causing the background and the new cross-hairs to be drawn).

You are going to want to learn about invalidation rectangles if you have a complicated drawing app. For example, when the form is resized, what you want to do is invalidate only the newly unveiled rectangle(s), so that the whole drawing doesn't need to be rendered.

This works, but picking a color (or a brush) for the cross-hairs so that they always show can be difficult. Using my other suggestion (that you draw the lines twice (one to erase, one to draw) using an INVERT (i.e. XOR) brush is faster, and it always shows.

Related