Drawing a grid of images with WPF
I'm trying to draw a grid of images/icons with WPF. The grid dimensions will vary but will typically range from 10x10 to 200x200. The user should be able to click on cells, and some cells will need to update (change image) 10-20 times per second. The grid should be able to grow and shrink in all four directions, and it should be able to switch to a different "slice" of the 3D structure it represents. My goal is to find a suitably efficient method for drawing the grid given those requirements.
My current implementation uses a WPF Grid
. I generate row and column definitions at runtime and populate the grid with Line
(for the gridlines) and Border
(for the cells, since they're currently just on/off) objects at the appropriate row/column. (The Line
objects span all the way across.)
While expanding the grid (holding down Num6) I found that it draws too slowly to redraw on every operation, so I modified it to simply add a new ColumnDefinition
, Line
and set of Border
objects for each column of growth. That solved my growth issue, and a similar tactic could be used to make shrinking fast as well. For updating individual cells mid-simulation, I could simply store references to the cell objects and change the displayed image. Even changing to a new Z-level could be improved by only updating cell contents instead of rebuilding the entire grid.
However, before I could make all of those optimizations, I ran into another problem. Whenever I mouse over the grid (even at slow/normal speeds) the application's CPU usage spikes. I removed all event handlers from the grid's child elements, but that had no effect. Finally, the only way to keep CPU usage in check was to set IsHitTestVisible = false
for the Grid
. (Setting this for every child element of the Grid
did nothing!)
I believe that using individual controls to build my grid is too intensive and inappropriate for this application, and that using WPF's 2D drawing mechanisms might be more efficient. I'm a beginner to WPF, though, so I'm seeking advice on how to best achieve this. From what little I've read, I might use a DrawingGroup
to compose each cell's image together onto a single image for display. I could then use a click event handler for the entire image and compute the coordinates of the c开发者_如何学Clicked cell by the mouse location. That seems messy, though, and I just don't know if there's a better way.
Thoughts?
Update 1:
I took a friend's advice and switched to using a Canvas
with a Rectangle
for each cell. When I first draw the grid, I store references to all the Rectangle
in a two-dimensional array, and then when I update the grid contents, I simply access those references.
private void UpdateGrid()
{
for (int x = simGrid.Bounds.Lower.X; x <= simGrid.Bounds.Upper.X; x++)
{
for (int y = simGrid.Bounds.Lower.Y; y <= simGrid.Bounds.Upper.Y; y++)
{
CellRectangles[x, y].Fill = simGrid[x, y, ZLevel] ? Brushes.Yellow : Brushes.White;
}
}
}
Drawing the grid initially seems faster, and subsequent updates are definitely faster, but there are still a few problems.
No matter how small the area I mouse over is, CPU usage still spikes whenever I mouse over the grid when it has more than a few hundred cells.
Updates are still too slow, so when I hold down the up arrow key to change the Z-level (a common use case) the program freezes for seconds at a time and then appears to jump 50 Z-levels at once.
Once the grid holds ~5000 cells, updates take on the order of one second. This is prohibitively slow, and 5000 cells fits within typical use cases.
I haven't yet tried the UniformGrid
approach because I think it may exhibit the same problems I've already encountered. I might give it a try once I've exhausted a few more options, though.
Your Question
Let's rephrase your question. These are your problem constraints:
- You want to draw a grid of dynamic sizes
- Each cell changes on/off rapidly
- Grid sizes change rapidly
- There are a large number of cells (i.e. the grid dimensions are not trivial)
- You want all these changes to occur with a fast frame rate (e.g. 30fps)
- The positioning and layout of the grid and cells are deterministic, simple and not very interactive
Judging from these constraints, you can immediately see that you're using the wrong approach.
Reqruiement: Fast Refresh of Deterministic Positions with Little Interactivity
Fast refresh frame rate + many changes per frame + large number of cells + one WPF object per cell = dissaster.
Unless you have very fast graphics hardware and a very fast CPU, your frame rate is always going to suffer with increases in grid dimensions.
What your problem dictates is more like a video game or a CAD drawing program with dynamic zooming. It is lesss like a normal desktop application.
Immediate Mode vs. Retained Mode Drawing
In other words, you want "immediate mode" drawing, not "retained mode" drawing (WPF is retained mode). That is because your constraints do not require much of the functionalities provided by treating each cell as a separate WPF object.
For example, you won't need layout support because each cell's position is deterministic. You won't need hit-testing support because, again, positions are deterministic. You won't need container support, because each cell is a simple rectangle (or an image). You won't need complex formatting support (e.g. transparency, rounded borders etc.) because nothing overlap. In other words, there is no benefit to use a Grid (or UniformGrid) and one WPF object per cell.
Concept of Immediate Mode Drawing to Buffer Bitmap
In order to achieve the frame rate you required, essentially you'll be drawing to a large bitmap (which covers the whole screen) -- or a "screen buffer". For your cells, simply draw to this bitmap/buffer (perhaps using GDI). Hit testing is easy as the cell positions are all deterministic.
This method will be fast because there is only one object (the screen buffer bitmap). You can either refresh the entire bitmap for each frame, or update only those screen positions that change, or an intelligent combination of these.
Notice that although you are drawing a "grid" here, you don't use a "Grid" element. Choose your algorithm and your data structures based on what your problem constraints are, not what it looks like to be the obvious solution -- in other words, a "Grid" may not be the right solution for drawing a "grid".
Immediate Mode Drawing in WPF
WPF is based on DirectX, so essentially it is already using a screen buffer bitmap (called the back-buffer) behind the scene.
The way you to use immediate mode drawing in WFP is to create the cells as GeometryDrawing's (not Shape's, which is retained mode). GemoetryDrawing is usually extremely fast because GemoetryDrawing objects map directly to DirectX primitives; they are not laid out and tracked individually as Framework Elements, so they are very light-weight -- you can have a large number of them without adversely affecting performance.
Select ths GeometryDrawing's into a DrawingImage (this is essentially your back-buffer) and you get a fast-changing image for your screen. Behind the scene, WPF does exactly what you expect it to do -- i.e. draw each rectangle that changes onto the image buffer.
Again, do not use Shape's -- these are Framework Elements and will incur significant overheads as they participate in layout. For instance, DO NOT USE Rectangle, but use RectangleGeometry instead.
Optimizations
Several more optimizations you may consider:
- Reuse GeometryDrawing objects -- just change position and size
- If the grid has a maximum size, pre-create the objects
- Only modify those GeometryDrawing objects that changed -- so WPF won't unnecessarily refresh them
- Fill the bitmap in "stages" -- that is, for different zoom levels, always update to a grid that is much larger than the previous one, and use scaling to scale it back. For example, move from a 10x10 grid directly to a 20x20 grid, but scale it back by 55% to show 11x11 squares. This way, when zooming from 11x11 all the way to 20x20, your GeometryDrawing objects are never changed; only the scaling on the bitmap is changed, making it extremely fast to update.
EDIT: Do Frame by Frame Rendering
Override OnRender
as suggested in the answer awarded the bounty for this question. Then you essentially draw the entire scene on a canvas.
Use DirectX for Absolute Control
Alternatively, consider using raw DirectX if you want absolute control over each frame.
You can write you own custom control (based on Canvas, Panel, etc...) and override OnRender, like this:
public class BigGrid : Canvas
{
private const int size = 3; // do something less hardcoded
public BigGrid()
{
}
protected override void OnRender(DrawingContext dc)
{
Pen pen = new Pen(Brushes.Black, 0.1);
// vertical lines
double pos = 0;
int count = 0;
do
{
dc.DrawLine(pen, new Point(pos, 0), new Point(pos, DesiredSize.Height));
pos += size;
count++;
}
while (pos < DesiredSize.Width);
string title = count.ToString();
// horizontal lines
pos = 0;
count = 0;
do
{
dc.DrawLine(pen, new Point(0, pos), new Point(DesiredSize.Width, pos));
pos += size;
count++;
}
while (pos < DesiredSize.Height);
// display the grid size (debug mode only!)
title += "x" + count;
dc.DrawText(new FormattedText(title, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, new Typeface("Arial"), 20, Brushes.White), new Point(0, 0));
}
protected override Size MeasureOverride(Size availableSize)
{
return availableSize;
}
}
I can successfully draw and resize a 400x400 grid with this on y laptop (not a competition machine...).
There are more fancy and better ways of doing this (using StreamGeometry on the DrawingContext), but this is at least a good test workbench.
Of course, you'll have to override the HitTestXXX methods.
I think you will struggle dealing with that many elements, If a only a small number were visible the Virtualizing Canvas control here might help, but that only helps with scrolling. To have that many cells visible at the same time you will probably have to draw to a bitmap in one way or another.
Here is an example where a VisualBrush of a cell is tiled and then each cell is toggled using a OpacityMask. The approach below is quite neat as only one pixel per cell is needed; the elements can be any size and you don't need complex code blitting the cell contents to a bitmap.
The sample creates a 1000*1000 grid and there are 3 cell types if you only need two the code could be simplified further and a lot of the loops removed. Updates were fast (3ms for 200*200, 100ms for 1k*1k), scrolling works as expected and adding zoom shouldn't be too difficult.
<Window ... >
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="25*" />
<RowDefinition Height="286*" />
</Grid.RowDefinitions>
<Button Click="Button_Click" Content="Change Cells" />
<ScrollViewer Grid.Row="1" ScrollViewer.HorizontalScrollBarVisibility="Auto">
<Grid x:Name="root" MouseDown="root_MouseDown" />
</ScrollViewer>
</Grid>
</Window>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Loaded += new RoutedEventHandler(MainWindow_Loaded);
}
const int size = 1000, elementSize = 20;
void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
var c = new[] { Brushes.PowderBlue, Brushes.DodgerBlue, Brushes.MediumBlue};
elements = c.Select((x, i) => new Border
{
Background = x,
Width = elementSize,
Height = elementSize,
BorderBrush = Brushes.Black,
BorderThickness = new Thickness(1),
Child = new TextBlock
{
Text = i.ToString(),
HorizontalAlignment = HorizontalAlignment.Center
}
}).ToArray();
grid = new int[size, size];
for(int y = 0; y < size; y++)
{
for(int x = 0; x < size; x++)
{
grid[x, y] = rnd.Next(elements.Length);
}
}
var layers = elements.Select(x => new Rectangle()).ToArray();
masks = new WriteableBitmap[elements.Length];
maskDatas = new int[elements.Length][];
for(int i = 0; i < layers.Length; i++)
{
layers[i].Width = size * elementSize;
layers[i].Height = size * elementSize;
layers[i].Fill = new VisualBrush(elements[i])
{
Stretch = Stretch.None,
TileMode = TileMode.Tile,
Viewport = new Rect(0,0,elementSize,elementSize),
ViewportUnits = BrushMappingMode.Absolute
};
root.Children.Add(layers[i]);
if(i > 0) //Bottom layer doesn't need a mask
{
masks[i] = new WriteableBitmap(size, size, 96, 96, PixelFormats.Pbgra32, null);
maskDatas[i] = new int[size * size];
layers[i].OpacityMask = new ImageBrush(masks[i]);
RenderOptions.SetBitmapScalingMode(layers[i], BitmapScalingMode.NearestNeighbor);
}
}
root.Width = root.Height = size * elementSize;
UpdateGrid();
}
Random rnd = new Random();
private int[,] grid;
private Visual[] elements;
private WriteableBitmap[] masks;
private int[][] maskDatas;
private void UpdateGrid()
{
const int black = -16777216, transparent = 0;
for(int y = 0; y < size; y++)
{
for(int x = 0; x < size; x++)
{
grid[x, y] = (grid[x, y] + 1) % elements.Length;
for(int i = 1; i < maskDatas.Length; i++)
{
maskDatas[i][y * size + x] = grid[x, y] == i ? black : transparent;
}
}
}
for(int i = 1; i < masks.Length; i++)
{
masks[i].WritePixels(new Int32Rect(0, 0, size, size), maskDatas[i], masks[i].BackBufferStride, 0);
}
}
private void Button_Click(object sender, RoutedEventArgs e)
{
var s = Stopwatch.StartNew();
UpdateGrid();
Console.WriteLine(s.ElapsedMilliseconds + "ms");
}
private void root_MouseDown(object sender, MouseButtonEventArgs e)
{
var p = e.GetPosition(root);
int x = (int)p.X / elementSize;
int y = (int)p.Y / elementSize;
MessageBox.Show(string.Format("You clicked X:{0},Y:{1} Value:{2}", x, y, grid[x, y]));
}
}
By continuing with the Canvas
approach, it looks like if you could draw the grid lines quickly, you could omit all the empty squares and drastically reduce the total number of elements onscreen, depending on the density of what you are doing. Anyway, to draw the grid lines quickly you can use a DrawingBrush
like this:
<Grid>
<Grid.Background>
<DrawingBrush x:Name="GridBrush" Viewport="0,0,20,20" ViewportUnits="Absolute" TileMode="Tile">
<DrawingBrush.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="#CCCCCC">
<GeometryDrawing.Geometry>
<RectangleGeometry Rect="0,0 20,1"/>
</GeometryDrawing.Geometry>
</GeometryDrawing>
<GeometryDrawing Brush="#CCCCCC">
<GeometryDrawing.Geometry>
<RectangleGeometry Rect="0,0 1,20"/>
</GeometryDrawing.Geometry>
</GeometryDrawing>
</DrawingGroup>
</DrawingBrush.Drawing>
</DrawingBrush>
</Grid.Background>
</Grid>
which results in this effect:
If you want the cells to be the same size, I think the UniformGrid might the best fit. In that way you will not have to worry about setting sizes in code.
If you implement, I would be very interested in the results.
I suggest to you write a custom Panel for this, writing this sould be simple as you just need to override MeasureOverride and ArrangeOverride methods. Based on the no of rows/column You can allocate available size to each cell. This should give you better performance than the grid, also if you want to optimize it even further you can also implement virtualization in the panel.
I did it this way when I had to create a roll matrix which has to display some text information instead of images and number of row/column varied. Here is an example of how to write a custom panel
http://blogs.msdn.com/b/dancre/archive/2005/10/02/476328.aspx
Let me know if you want me to share the code I wrote with you.
Going to make a few guesses here:
- Use the Canvas approach.
- Disable hit testing on the Canvas to keep mouseover CPU from going crazy.
- Track your changes separately from the UI. Only change the Fill property on elements that have changed since the last update. I'm guessing that the slow updates are due to updating thousands of UI elements and the subsequent re-rendering of everything.
精彩评论