2

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.

Michal Cihelka
  • 187
  • 1
  • 9
  • 2
    I've never done this in C#, but back in the days of really slow computers, I wrote some Windows (16 & 32 bit) drawing apps. Two possible solutions. Draw lines using INVERT BitBlt brushes (not sure how to do this in C#). The idea is that you draw the line inverting the background colors. When you want to move the line, you redraw the same line again (inverting them back) and draw a new line. The other way to do this is to only invalidate rectangles that contain the areas of the cross hair lines (you need to combine a rectangle around the old line with a rectangle that contains the new line) – Flydog57 May 14 '21 at 23:18
  • Might have better luck drawing the rectangles using LockBits. There's crazy overhead saving an image with GDI objects compared to doing it yourself. If you look through my answers from the last side month there's one that does exactly that, but on mobile so it's hard to locate – pinkfloydx33 May 14 '21 at 23:24
  • 1
    Draw your shapes on the DC of a Control that supports DoubleBuffering. It's usually a PictureBox or a (non-System) flat Label; don't use Panels or other containers, including Forms. Then invalidate only the rectangle that contains the cross hairs. When you get the hang of it, you can use a second layer and draw the cross hairs on this layer instead of the surface used to draw the rectangles. A simple layer is another Controls that supports double-buffering. This has limitations. [...] – Jimi May 15 '21 at 00:00
  • You need to handle manually one or more [BufferedGraphics](https://learn.microsoft.com/en-us/dotnet/api/system.drawing.bufferedgraphics) objects to have better performance. -- As a note, the example that you find in the Docs is just that, an example. If you use it as a starting point, keep this in mind. – Jimi May 15 '21 at 00:04
  • 1
    I would not redraw all those rectangles onto the form (which should be DoubleBuffered in any case) but draw them into a Bitmap when new ones are added. In the MOuseMove draw only the Crosshair.. – TaW May 15 '21 at 04:30
  • @TaW Thanks - and just to clarify, to render the `Bitmap` to the Form, would you use `Graphics.DrawImage` in the `Paint` event? – Michal Cihelka May 15 '21 at 04:41
  • Maybe, probably not. It really depends on when, why and how the Graphics will change. Paint is needed for stuff that changes a lot and needs to show updated graphics all the time. You should tell us more about the requirements, e.g. will the rectangles change e.g. color, size or position? If so: All of them (like in a rotating view) or just a few (as in a few more covering a map..)? Stuff that doesn't change and just builds up can be added to the Bitmap when needed. One can also combine the two and show the mass in a Btimap and only Paint the last few rectangles.. – TaW May 15 '21 at 06:47
  • 1
    A discussion of the two is [here](https://stackoverflow.com/questions/27337825/picturebox-paintevent-with-other-method/27341797#27341797) - Oh, and no don't render the bitmap all the time but leave it to the system by making it the Image BackgroundImage etc of a suitable control, e.g. a PictueBox or a Label (non-autosize)! – TaW May 15 '21 at 06:48

1 Answers1

3

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.

Flydog57
  • 6,851
  • 2
  • 17
  • 18
  • Thank you, this is a great help. I did not consider invalidating rectangles, so that's a great insight. I will need to buffer the rectangles, because in my app the number of rectangles drawn could be anywhere up to 50,000. Then, instead of drawing rectangles in `Paint`, I'll just render the buffer. But, my early tests suggest that rendering a buffer is an expensive operation too. Ideally, I'd like to render a portion of the buffer based on `ClipRectangle` provided by the `Paint` event. Which is a problem - `BufferedGraphics` can't do that, but I could roll something based on `Bitmap` – Michal Cihelka May 15 '21 at 01:35
  • 1
    You'd be surprised how effective very careful management of invalidation regions can be. The app that I used to work on was like a more sophisticated Visio (it was for engineering design). We had a very responsive, non-flickering app with not 1000s of graphical elements, but hundreds. But, it ran on mid- to late- 90s PCs (originally 16-bit, later 32-bit). When you'd pick up an item and drag it around the screen, it might have a dozen invalidation rectangles associated with it. If the selection was too complicated, we'd fall back on doing repeated XOR immediate drawing on mouse move – Flydog57 May 15 '21 at 02:18
  • 1
    Oh! It all just clicked! Of course you're right, back in the 486 days I remember there were some really impressive apps for Win 3.1 doing a lot of drawing and clipping, and they were fast. So... I just did a test. I set the clipping region on `Graphics` in the `Paint` event, to cover only the old and new crosshair position, and guess what - drawing 50,000 rectangles from scratch is lightning quick now. Why? Clearly, GDI doesn't bother with anything that falls outside of the clip region. Since like 98% of the rectangles are outside the clip region, it doesn't have much to do after all. Amazing. – Michal Cihelka May 15 '21 at 04:01
  • 1
    @MichalCihelka: I figured out how to do this the other way (using XOR drawn lines). The call is `ControlPaint.DrawReversibleLine` (there's also a way to draw a reversible rectangle). You draw it once it shows, you draw the same line a second time, it disappears. It's tricky though - the coordinates are in Screen coordinates (not Windows or ClientRect coordinates). You also have to remember to erase the last lines drawn whenever you are done with them. You don't do this in the `Paint` handler, instead, you do it directly in the `MouseMove` handler. Maybe someday I'll finish a demo 4 your – Flydog57 May 17 '21 at 20:40