WPF使用DrawingVisual可视化对象和Bresenham算法实现高性能绘图

 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绘制,数据量较大时,使用位图绘制,这样即使有锯齿,也观察不出。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值