[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或者最小化窗體又窗體還原后,調用重繪函數。就是把內存中的相應的數據再次的拷貝到顯存中。所以繪制的圖片會消失。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM