1 DrawingVisual可视化对象介绍
DrawingVisual是WPF中一个轻量级绘图类,用于呈现形状、图像或文本。此类被视为轻量级,因为它不提供布局、输入、焦点或事件处理,从而提高其性能。
为了使用 DrawingVisual 对象,您需要为对象创建一个主机容器。
主机容器对象必须派生自FrameworkElement 类,该类提供 DrawingVisual 类不支持的布局和事件处理支持。
以下是WPF编程宝典的介绍:
WPF针对绘制大量图形元素的绘图密集型程序的解决方案是,使用低级的可视化层(visuallayer)模型。基本思想是将每个图形元素定义为一个Visual对象,Visual对象是极轻量级的要素,比Geometry对象或Path对象需要的开销更小。然后可使用单个元素在窗口中渲染所有可视化对象。
Visual类是抽象类,所以不能创建该类的实例。相反,需要使用继承自Visual类的某个类,包括UIElement类(该类是WPF元素模型的根)、Viewport3DVisual类(通过该类可显示3D内容)以及ContainerVisual类(包含其他可视化对象的基本容器)。但最有用的派生类是DrawingVisual类,该类继承自ContainerVisual类,并增加了支持"绘制"希望放置到可视化对象中的图形内容的功能。
为使用DrawingVisual类绘制内容,需要调用DrawingVisual.RenderOpen()方法。该方法返
回一个可用于定义可视化内容的 DrawingContext对象。
绘制完毕后,需要调用DrawingContext.Close()方法。下面是绘制图形的完整过程:
DrawingVisual visual = new DrawingVisual();
DrawingContext dc = visual.RenderOpen();
// (Perform drawing here.)
dc.Close ();
本质上,DrawingContext类由各种为可视化对象增加了一些图形细节的方法构成。可调用这
些方法来绘制各种图形、应用变换以及改变不透明度等。下表列出了DrawingContext类的方法。
| 名称 | 说明 |
|---|---|
| DrawLine() | 在指定的位置,使用指定的填充和轮廓绘制特定的形状。 |
| DrawRectangle() | 在指定的位置,使用指定的填充和轮廓绘制特定的形状。 |
| DrawRoundedRectangle() | 在指定的位置,使用指定的填充和轮廓绘制特定的形状。 |
| DrawEllipse() | 在指定的位置,使用指定的填充和轮廓绘制特定的形状。 |
| DrawGeometry() | 绘制更复杂的 Geometry 对象和 Drawing 对象 |
| DrawDrawing() | 绘制更复杂的 Geometry 对象和 Drawing 对象 |
| DrawText() | 在指定的位置绘制文本。通过为该方法传递 FormattedText 对象,可指定文本、字体、填充以及其他细节。如果设置了 FormattedText.MaxTextWidth 属性,可使用该方法绘制换行的文本 |
| DrawImage() | 在指定的区域 (由 Rect 对象定义) 绘制一幅位图图像 |
| DrawVideo() | 在特定区域绘制视频内容 (封装在 MediaPlayer 对象中)。 |
| Pop() | 翻转最后调用的 PushXxx () 方法。可使用 PushXxx () 方法暂时应用一个或多个效果,并且 Pop () 方法会翻转它们 |
| PushClip() | 将绘图限制在特定剪裁区域中。这个区域外的内容不被绘制 |
| PushEffect() | 为随后的绘图操作应用 BitmapEffect 对象 |
| PushOpacity() | 为了使后续的绘图操作部分透明,应用新的不透明设置或不透明掩码 |
| PushOpacityMask() | 为了使后续的绘图操作部分透明,应用新的不透明设置或不透明掩码 |
| PushTransform() | 设置将应用于后续绘制操作的 Transform 对象。可使用变换来缩放、移动、旋转或扭曲内容 |
2 使用DrawingVisual绘图
至此,我们了解了基本原理,让我们据此以一个示例实现绘图。该示例实现绘制频谱分析仪的频谱图。
1、创建一个类,可从Canvas继承。用于存储可视化对象。
同时该类应有Visual成员,我创建的程序之所以是创建的VisualCollection,属于Visual集合,原因在于,我会实现多个DrawingVisual。所以这里使用集合变量。
这里也可以使用List<Visual>。按照规范,以下2个重载VisualChildrenCount 和GetVisualChild都是必须要实现的。
public class DrawingLine : Canvas
{
private VisualCollection visuals;
protected override int VisualChildrenCount => visuals.Count;
protected override Visual GetVisualChild(int index)
{
return visuals[index];
}
}
2、可视化对象的创建。
我这里创建2个可视化对象,一个用于绘制坐标信息,一个用于绘制频谱线段。
private DrawingVisual CreatePolyLineVisual()
{
DrawingVisual drawingVisual = new DrawingVisual();
return drawingVisual;
}
private DrawingVisual CreatePolyLineVisualWithAxes()
{
DrawingVisual drawingVisual = new DrawingVisual();
return drawingVisual;
}
3、使用方法
XML文件需要添加命名空间。
xmlns:local="clr-namespace:WpfAppDrawVisual"
<Window x:Class="WpfAppDrawVisual.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfAppDrawVisual"
/>
<!-- 使用时,只需要创建DrawingLine对象即可。例如-->
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition Height="auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<local:DrawingLine x:Name="DrawLine" Grid.Row="0" Grid.Column="0" Margin="5" Background="Black">
</Grid>
4、使用例子
对于普通的线段绘制,只需要使用DrawingContext的DrawLine方法即可。
public DrawingLine()
{
visuals = new VisualCollection(this);
visuals.Add(CreatePolyLineVisual());
linePen.Freeze(); //冻结画笔,对于性能非常重要
}
假定normalizedPoints为需要绘制的像素点。
DrawingVisual? visual = visuals[0] as DrawingVisual;
DrawingContext dc = visual.RenderOpen();
for (int i = 0; i < normalizedPoints.Count - 1; i++)
{
dc.DrawLine(linePen, normalizedPoints[i], normalizedPoints[i + 1]);
}
dc.Close();
由于我们主要关注绘图性能,这里省略了坐标和栅格的绘制代码。
经过实测,当点数不多时,绘图性能好,但点数超过20000以上时(如果窗口较大,可能10000点就出现卡顿),可以看到明显的卡顿。

3 使用DrawingVisual + Bresenham 线段算法高性能绘图
考虑到传统DrawingContext的DrawLine方法在绘制大量点,存在性能问题,尝试多种解决方案,如增加循环绘图时间、冻结画笔(如不冻结,少量点也会卡顿)等,无法彻底解决问题。网上查找资料,大多数遇到性能问题,都是使用第三方绘图库解决,如ScottPlot、OxyPlot等。这些库虽然性能强大,但是没有手绘灵活度高,比如后续添加marker标记,在图中任意位置添加文字等。后寻求AI帮助,经过对多个大模型提问,最终Cursor提供了最好的解决方案(去年年底时使用Cursor帮助实现的,那时免费用户也可以使用claude 3.5模型,不得不感叹claude模型在编码中的强悍,否则就没有这篇文章-_-||),即通过线段转换位图绘制。
因为WPF绘制image位图效率极高,所以可以先使用计算机图形学中的线段转位图算法,先将数据绘制到位图像素缓存区,再将image贴图即可。
关键代码如下:
3.1 位图创建:
这里使用了SizeChanged事件,每次大小改变时,需要重新创建位图对象。
private void DrawingLine_SizeChanged(object sender, SizeChangedEventArgs e)
{
_bitmap = new WriteableBitmap(
(int)ActualWidth,
(int)ActualHeight,
96,
96,
PixelFormats.Bgra32,
null);
}
3.2 数据准备:
public string SpectrumImageDataParallel()
{
if (normalizedPoints == null || normalizedPoints.Count < 2)
return "";
Stopwatch sw = Stopwatch.StartNew();
sw.Start();
int width = (int)ActualWidth;
int height = (int)ActualHeight;
unsafe
{
backBuffers = new byte[width * height * 4];
// 计算分块
int chunksCount = Environment.ProcessorCount;
//当数据点数小于50000时,不需要进行并行计算
int block = 50000;
if (normalizedPoints.Count <= block)
{
chunksCount = 1;
}
else
{
chunksCount = normalizedPoints.Count / block;
}
int blockWidth = normalizedPoints.Count / chunksCount + 1;
// 并行处理每个水平块
Parallel.For(0, chunksCount, blockIndex =>
{
int startPointIndex = blockWidth * blockIndex;
int endPointIndex = Math.Min(startPointIndex + blockWidth, normalizedPoints.Count - 1);
// 绘制该块内的线段
for (int i = startPointIndex; i < endPointIndex; i++)
{
fixed (byte* pTemp = backBuffers)
{
DrawLine(
normalizedPoints[i],
normalizedPoints[i + 1],
(IntPtr)pTemp,
width, height);
}
}
});
}
sw.Stop();
var str = sw.ElapsedMilliseconds.ToString();
return str;
}
/// <summary>
/// 使用Bresenham 算法绘制线段到像素显示。
/// </summary>
/// <param name="fromPt"></param>
/// <param name="toPt"></param>
/// <param name="backBuffer"></param>
/// <param name="width"></param>
/// <param name="height"></param>
private unsafe void DrawLine(Point fromPt, Point toPt, IntPtr backBuffer, int width, int height)
{
int x1 = (int)fromPt.X;
int x2 = (int)toPt.X;
int y1 = (int)fromPt.Y;
int y2 = (int)toPt.Y;
int dx = Math.Abs(x2 - x1);
int dy = Math.Abs(y2 - y1);
int sx = x1 < x2 ? 1 : -1;
int sy = y1 < y2 ? 1 : -1;
int err = dx - dy;
byte* ptr = (byte*)backBuffer;
//int color = unchecked((int)0xFFFFFF00);
var color = Color.FromArgb(255, 255, 255, 0);
int colorVal = (color.A << 24) | (color.R << 16) | (color.G << 8) | (color.B);
while (true)
{
if (x1 >= 0 && x1 < width && y1 >= 0 && y1 < height)
{
int offset = (y1 * width + x1) * 4;
*(int*)(ptr + offset) = colorVal;
}
if (x1 == x2 && y1 == y2) break;
int e2 = 2 * err;
if (e2 > -dy)
{
err -= dy;
x1 += sx;
}
if (e2 < dx)
{
err += dx;
y1 += sy;
}
}
}
3.3 绘制位图
然后使用DrawingContext的DrawImage方法绘制位图。
public string DrawFrequencyDomain()
{
DrawingVisual? visual = visuals[0] as DrawingVisual;
if (visual == null)
{
return "";
}
Stopwatch sw = Stopwatch.StartNew();
sw.Start();
int width = (int)ActualWidth;
int height = (int)ActualHeight;
_bitmap.Lock();
try
{
if (backBuffers.Length == width * height * 4)
{
Marshal.Copy(backBuffers, 0, _bitmap.BackBuffer, width * height * 4);
_bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
using(var dc=visual.RenderOpen())
{
dc.DrawImage(_bitmap, new Rect(0, 0, ActualWidth, ActualHeight));
}
}
else
{
Task.Delay(0);
}
}
finally
{
_bitmap.Unlock();
}
sw.Stop();
var str = sw.ElapsedMilliseconds.ToString();
return str;
}
实测效果,100000点,拖动,按钮点击等,完全无压力。

4 后续
使用线段转位图绘制,存在一个需要优化的地方,即没有抗锯齿功能。当然也可以继续改进算法实现抗锯齿,但是个人更建议,可以灵活处理,即数据量较小时,使用原始的DrawLine绘制,数据量较大时,使用位图绘制,这样即使有锯齿,也观察不出。
1137

被折叠的 条评论
为什么被折叠?



