[C#技术参考]在PictureBox 中绘图防止闪烁的办法


开篇之前说点别的,马上年终了,好希望年终奖大大的,但是好像这次项目的展示很重要,所以这几天绷得比较近,但是真的没有感觉烦,就是害怕来不及。所以抓紧了。下面直接正题。说一下用到的东西,都是Google搜索来的,但是这些技术真的能用到自己的项目中,自己做的东西等过年回家没事慢慢总结,现在先学习一下别人的东西,也算作一个笔记吧。


我需要在窗体上进行图片的绘制,但是在实际的测试中发现了问题,那就是重绘的时候会发生闪烁,这个问题在初学C语言的课程中也遇到过,在程序绘制动画的高频率刷新的时候,也会产生闪烁,而那时候的解决办法,是对动画进行双缓冲(Double Buffering)处理。


在被双缓冲这个名词吓到之前,我们先来探讨下为什么重绘的时候会发生闪烁:
说道动画的原理大家都懂,就是利用了人眼的视觉残留(Visual staying)现象,当一副画面进入人眼成像后,并不会立刻消失,而是仍会保留一小段时间,于是当连续的图像以很高的速度切换的时候,人眼会看到动态的影响,而不是处于切换中的单个图像。
这个过程可以参考图1:

图1

当这三幅图片以一定频率直接切换的时候,人们就会看到A貌似是在向右方移动。

那么为什么我们依据这个原理来编程绘制动画的时候会出现闪烁呢?是因为计算机的速度太慢不够给力么?当然不是!

下面我们看一下图形图像是怎么显示在屏幕上的:

这是显示图像的硬件的结构图,我们平时用函数画线什么的,其实都是向显示缓冲区(显存)中写数据,当然了它是逻辑内存(CPU只能看到一块大的逻辑内存,其中包括主存也包括显存等)的一部分。显示器有自己的刷新频率,定期的以很快的速度把显示缓冲器中的数据显示到显示屏幕上。

注意:显示缓冲区和显示器是一起的,显示器只负责从显示缓冲区取数据显示。我们通常所说的在显示器上画一条直线,其实就是往显示缓冲区中写入数据。显示器通过不断的刷新(从显示缓冲区取数据),从而使显示缓冲区中的数据的改变及时的反映到显示器上。

如果我们要显示的内容很复杂,涉及到很大的CPU计算量,那么CPU要把整个图像写入到显示缓冲区就要一定的时间,但是显示器依然按照自己的速度把显存中的数据显示到屏幕上,这就出现了问题,显存中的数据明明不完整,但是还是会先到到了屏幕上,这就让人的视觉上出现闪烁的样子。

我们如果不加任何处理,就在画布Canvas上进行绘图,那么计算机的处理过程是这样的:只有上面的显示缓冲区,画布Canvas就在显示缓冲区中。

Step 1: 将Canvas以背景色填充(也就是清除Canvas上现有的内容)
Step 2: 在Canvas上按照要求绘制新的画面

那么这样的过程会对动画产生怎样的影响呢?请看图2:

图2

看出和图1的差别了吧?Step1相当于在原本连续的动画中嵌入了空白的画面,这个空白的画面由于和人眼中原本残留的图像反差非常大,所以便会破坏视觉残留产生的动画,给人的感觉就是,这个动画在不停的闪烁。

这种情况,用上面的物理存储结构解释就是:

1. CPU把字符A写到显存中,让它显示。

2. CPU取消A的显示,也就是把显存中存储A的那部分清零。

3. CPU在新的区域重新显示A字符。

注意:CPU只管往显存中写要显示的数据,显示器就会根据自己的刷新频率,把当前显存中的内容拿来显示,它不管里面是什么,不和CPU协商协调。所以当CPU清空画布的时候,显示器也把这个空白的画布显示了一下,导致在连续的动画中嵌入了空白画面,从而闪烁。

于是我们现在知道消除Step 1这个过程带来的影响,就能够避免在绘制的时候发生闪烁,不知道你会不会想,直接把Step 1略过不就得了?但我可以很负责任的告诉你……不行!如果只是单纯的略过Step 1,那么动画就会变成这样:

图3

这种情况就是:

1. CPU把字符A写到显存中,让它显示。

2. CPU在没有清除显存中数据的前提下,又在另外的区域写入了字符A,这时显示器就根据显存的内容刷新屏幕,就出现上面的情况。

在视觉上就成了一个拖着残像尾巴运动的动画,相信大家一定见过这个:

图4


那么我现在就可以解释双缓冲是怎样防止闪烁的了。
假如我们希望在屏幕S上展示动画,首先我们需要在内存中建立一个虚拟的画布C,然后我们所有的绘图操作都在C上进行,当绘制动画的一帧完毕后,我们啪唧~把C直接往S上一拍,这样就既不会出现拖尾巴,也不会出现Refresh时的短暂空白了,如下图所示,红色的框就代表那块虚拟的画布:

图5

用上面物理显示结构解释就是:

我们现在不直接在显存中绘制图像了,因为它会把我们的不完整的图形图像显示到屏幕上,我们在内存中建立一个缓冲区,虚拟一块画布,因为显示器不会把我们物理内存的东西刷新到屏幕上,所以不会显示不完整的图形图像。但是显示器只能根据显存的内容刷新屏幕,所以还得把内存缓冲区的数据一次性的拷贝到显存中,这种拷贝是很快的是一次性的,它不是先擦除原来的图形图像再显示新的,而是把整个显存作为一个整体,把里面的内容覆盖替换掉。所以也就不会出现上面所说的有空白帧的问题。


原理我们都知道了,但是实际应用的时候还是会遇到一些问题,这些问题涉及到C#本身的窗体的绘制机制,不过我们还是能够解决的。
当我们通过这种方法在pictureBox上绘图的时候,特别是pictureBox还存在背景图片的时候
。我们就会遇到问题:

// 初始化画板,在内存中建立一块虚拟画布
Bitmap image = new Bitmap(pictureBox1.ClientSize.Width, pictureBox1.ClientSize.Height);
// 初始化图形面板,获取这块内存画布的Graphics的引用
Graphics g = Graphics.FromImage(image);
 
//在这块内存画布上绘图
// 绘图部分 Begin
// ... ...
// 绘图部分 End

//将内存画布画到窗口上,实际就是把内存缓冲区中的数据一整个的挪到显存中
pictureBox1.CreateGraphics().DrawImage(image, 0, 0);

这个样子貌似没有什么大的问题了,因为我们这里的内存画布是透明的,所以在画布上绘制后,贴在pictureBox上,背景图片还是会展示出来,但是问题也就来了,由于pictureBox的绘制机制问题,如果我在pictureBox上贴一张透明的图,其效果就是这样的:(下面这张图就是上面的代码的运行结果)

图6

读到这里你是否感觉到自己已经读不下去了,哈哈,其实我也写不下去了,怎么着,画布一会透明一会不透明的,其实根本问题就一个,我们自定义的缓冲区往显示缓冲区中拷贝数据的时候,会把所有的数据替换掉还是保留原来的数据,再增加新的数据呢?

这个问题我也想了好一会,最后忽然明白了,其实内存的虚拟画布和我们的显示缓冲区的画布是一样的,它们都有背景色和前景色。当然了,如果我们不为它们设置背景色,那么背景色就是透明的。把一个背景色透明的只有前景色的画布贴到另一张画布上,当然会出现上面的情况。但是如果我们也给内存中的画布设置一个背景色,那么这个画布就不是透明的了,贴在显示缓冲器上就会出现前面说的情况。这种机制和我们生活常识中的也是一样的,所以程序的设计不是在创造,而是在学习自然、模仿生活。

其实这也就是我们上面说的step1:将Canvas以背景色填充,只不过我们这里填充的是自定义的缓冲区,不是现实缓冲区,这样的话就能避免空白的背景帧现实在屏幕上。我们先把背景和前景都画在自定义的缓冲区中,然后一次性的覆盖现实缓冲区。

注意:
原本的所有的画布都是透明的。
如果我们给自定义的缓冲区设置背景色(就是给透明的画布指定一种颜色或者图片之类的),那么就会使这块画布变得不透明,把它往现实缓冲器中贴的时候就会覆盖原来的内容。
如果我们没有给自定义的缓冲区设置背景色(没有把全部的缓冲区设置颜色),那么这个自定义的缓冲区就只有在前景部分不透明,其他的部分都是透明的,
把它往显示缓冲区贴的时候,只有前景部分能覆盖原显示缓冲区的数据,其余的部分保持原显存的数据内容。

所以回到上面所说的,如果我们直接把透明的带着移动过的A贴上去,那其实和直接忽略step1的效果是一样的。(这时你可能已经想到了,让内存的画布不透明啊,我们先不这么做)

如果我们想要将之前的内容去除,就不得不再使用pictureBox1.Refresh()方法,而这样的话,显然会导致闪烁,那么该怎么办呢?

我们这个问题所遇到的障碍就是不能影响pictureBox的背景图的展示,所以我们为何不把pictureBox的背景图片也提取出来,作为底层画布呢?
下面是我给出的解决方案:

// 初始化前景图片画布
Bitmap image = new Bitmap(pictureBox1.ClientSize.Width, pictureBox1.ClientSize.Height);
 
// 获取背景层
Bitmap bg = (Bitmap)pictureBox1.BackgroundImage;
 
// 初始化整个画布
Bitmap canvas = new Bitmap(pictureBox1.ClientSize.Width, pictureBox1.ClientSize.Height);
 
// 初始化图形面板
Graphics g = Graphics.FromImage(image);
Graphics gb = Graphics.FromImage(canvas);
 
// 绘图部分 Begin
// ... ...
// 在前景画布image上画图
// 绘图部分 End
 
gb.DrawImage(bg, 0, 0); // 先绘制背景层
gb.DrawImage(image, 0, 0); // 再绘制绘画层
 
pictureBox1.BackgroundImage = canvas; // 设置为背景层,就是在内存中建立了一张对应的画布,重新refresh就是再次的加载这个画布
 
//pictureBox1.Refresh();
//pictureBox1.CreateGraphics().DrawImage(canvas, 0, 0);

我在实际的使用中,出现了一个很小无知,就是原先图片放在了PictureBox中,并且设置了BackgroundImageLayout为Stretch,这样图片能很好的全部显示在其中,但是用上面的这两行代码以后出现了问题,背景图片不老老实实的呆在原来的样子,而是跑出去一部分:

gb.DrawImage(bg, 0, 0); // 先绘制背景层
gb.DrawImage(image, 0, 0); // 再绘制绘画层

查了好久才知道,是DrawImage()这个函数用的不恰当,它的意思是从画布的原点处按照图片的原始大小绘制,当图片很大的时候当然会溢出原来的容器控件,因为它不会自动的实现缩放,解决的方法是使用重载的其他的DrawImage()方法。

gb.DrawImage(bg, rect); // 先绘制背景层
gb.DrawImage(image, rect); // 再绘制绘画层

其中的rect就是绘图的区域,整个图像不能跑出这个区域,并且要全部的图像放进去。

pictureBox的Refresh()方法不会影响其背景层,我们将最后合成的画布直接贴在背景层上,这样再Refresh()就不会产生闪烁了。

同时,由于系统会自动重绘背景层,所以在窗口最小化或者被遮挡过后,绘制的图像也不会消失,相当同时于免去了手动重绘之苦。这样一来,困扰我们的闪烁问题就彻底的被解决啦!

补充一些PictureBox的刷新的知识点:

pictureBox可以通过pic.BackgroundImage设置其背景图片,同时可以通过pic.CreateGraphics().DrawLine等在图片上绘制东西。但是当调用Pic.Refresh()或窗体被最小化的时候,都会调用窗体的重绘方法。重绘之后,就没有了通过pic.CreateGraphics().DrawLine绘制的东西,只重新绘制了背景图片。

这一点的原理的解释:

通过pic.BackgroundImage的操作,首先在内存中建立一个图片的画布,然后把这个画布拷贝到显存的数据区,这样根据刷新的频率,不断的显示在显示器上。pic.CreateGraphics().DrawLine这个操作是在显存的画布上绘制图片,相当于数据写在了显存的数据区,当点击refresh或者最小化窗体又窗体还原后,调用重绘函数。就是把内存中的相应的数据再次的拷贝到显存中。所以绘制的图片会消失。

原文地址:https://www.cnblogs.com/stemon/p/4206974.html