來翻新老專案 (4) - MapleLooker - AnimationView 動畫組件

這次要翻新的是動畫元件 AnimationView

AnimationView 的用途是播放簡單影格動畫
由於 WPF 本身的繪圖機制跟 WinForm 不一樣,一度讓我失去方向
最後用了一個感覺不太優,但是簡單有效的方法來實做 WPF 版本的 AnimationView 元件

原本的 AnimationView 實作方式

早期使用 WinForm 撰寫的 AnimationView 是採用 GDI+ 繪圖搭配 Thread 定時更新畫面

因為 WinForm 控制項重繪的效率不好,當時常用的作法是使用雙重緩衝區搭配一個渲染執行緒定時更新畫面
使用 sleep 控制渲染執行緒的更新速率,讓控制項在不使用太多 CPU 的情況下,以大約 60 FPS 的速度下穩定渲染畫面

WinForm 與 WPF 繪製的不同之處

原本我在 WPF 也想走 GDI+ 這條路,結果 Google 好久都沒找到什麼直接渲染 WPF 控制項方式
後來就慢慢去嘗試 Google 到的其他渲染方式

在寫這篇的時候去找了一下官方資料
發現官方在 WPF 圖形轉譯概觀 有提及「保留模式圖形 (Retain Mode Graphics)」

與 WinForm 直接使用 Graphics 渲染的的直接模式相比,WPF 使用的保留模式僅定義繪圖資料
當 WPF 需要渲染的時候,會使用這些定義好的繪圖資料進行渲染並呈現

Win32 直接模式轉譯流程:
Win32 直接模式

WPF 保留模式轉譯流程:
WPF 保留模式

一開始我覺得很不習慣,畢竟以前都是想到就畫,現在卻要用其他方式來渲染,完全沒有方向
實做當時我還沒去翻官方文件,而是直接在 StackOverflow 找一些範例,然後去做實驗

WPF 的繪圖事件 CompositionTarget.Rendering

WPF 有一個事件叫 CompositionTarget.Rendering ,它會在圖形轉譯期間觸發事件
相當於以前在 WinForm 使用的渲染迴圈,只要註冊這個事件,接著實做渲染就可以了

下面就是大概的架構

public AnimationView() {
InitializeComponent();

// 註冊渲染事件
CompositionTarget.Rendering += this.FrameUpdate;
}

private void FrameUpdate(object sender, EventArgs e) {
// 渲染事件只在有動畫時才會觸發
if (this.IsLoading || this.frames == null || this.frames.Count == 0) {
return;
}

AnimationFrame frame = this.frames?[this.currentIndex];

// 切換影格...

// 影格渲染...

this.stopwatch.Restart();
}

迴圈內透過 stopwatch 紀錄經過時間,並在適當的時間切換影格做渲染

渲染部份,一開始我使用的是 WritableBitmap,畢竟我還是挺偏好 GDI+ 那種直接渲染的方式
但很快就踢到鐵板,這東西比想像中還難用 😥

最後使用 CanvasImage 的方式將每個 Frame 的圖片顯示在上面,並根據時間切換圖片和位置達到影格動畫的效果
老實說呈現的效果比想像的還要好

最初的嘗試: WriteableBitmap

WriteableBitmap 是可以直接寫入像素資料的 Bitmap 類別
一開始想說這個正是我要的東西,試了之後才發現跟我想的不太一樣

正如 WriteableBitmap 這個名子,它就是可以繪製的 Bitmap
類似 WinForm BitmapLockBitsWriteableBitmap 也可以使用指標直接操作像素內容,可以說功能幾乎一模一樣

原本想說只要能把圖片繪製上去就好了,所以直接框住範圍把像素資料複製到 WriteableBitmap

// 將圖片的像素複製到 buffer,這邊都使用 argb32 格式
int stride = (int)(4 * frame.Size.X);
byte[] buffer = new byte[(int)(stride * frame.Size.Y)];
frame.Image.CopyPixels(buffer, stride, 0);

// 將圖片像素寫入 writeableBitmap 中
Int32Rect rect = new Int32Rect((int)(-frame.Origin.X), (int)(-frame.Origin.Y), (int)frame.Size.X, (int)frame.Size.Y);
writeableBitmap.WritePixels(rect, buffer, stride, 0);

當我一測試,WPF 直接給我一個大大的 OverflowException

溢位錯誤

顯然我渲染的時候沒辦法把像素渲染到外面,這表示我需要做額外的處理才能把圖片繪製到特定範圍內
因為覺得後續的處理流程太過麻煩,所以果斷放棄這個方法

最終方案: Canvas + Image

這個方法其實算是 WPF 標準的渲染方式
主要透過 Canvas 可以自由設定 UI 位置的特性,搭配 Image 渲染圖片來達成效果

每次渲染我都會執行下面的步驟:

// 清除 canvas 的圖片
this.canvas.Children.Clear();

// 設定 image 的圖片大小、位置
Vector center = new Vector(this.canvas.ActualWidth, this.canvas.ActualHeight) / 2;
Vector offset = center - frame.Origin;
Image image = new();
image.Source = frame.Image;
image.Width = frame.Size.X;
image.Height = frame.Size.Y;
Canvas.SetLeft(image, (int)offset.X);
Canvas.SetTop(image, (int)offset.Y);

// 把圖片加入 Canvas 上
this.canvas.Children.Add(image);

其實就是一直清空 Canvas 再建立新的 Image 丟到 Canvas 做刷新而已
一開始使用這個方法感覺挺怪的,因為 WinForm 本身這樣做不只效果差,效率也很低
實際使用效率不差,而且好寫很多

了解一點保留模式的東西之後,就比較好理解這塊在做什麼了
其實每次更新不停刷新 Image 的部份,就是在更新 Visual Objects,後續渲染則是交給 WPF 去處理
比起原本 WinForm 不停刷新控制項重新渲染,WPF 這樣做比較不會吃太多 CPU

大概是因為在渲染迴圈內不停建立 Image 的關係,渲染時的 CPU 使用率還是偏高,這部份還需要做一些優化

其他雜項的實做與優化

原本WinForm 的版本其實還有做滑鼠拖動的功能
這邊我只要將 ImageIsEnabled 設定成 false,然後在 Canvas 主體註冊事件來處理就好

<Canvas
x:Name="canvas"
ClipToBounds="True"
MouseLeftButtonDown="OnCanvasLeftMouseDown"
MouseMove="OnCanvasMouseMove"
MouseLeftButtonUp="OnCanvasLeftMouseUp"
MouseRightButtonDown="OnCanvasRightMouseDown"
Focusable="True"
KeyDown="OnCanvasKeyDown"
Background="#01000000"
>
</Canvas>

後面還額外做了按鍵控制,實現了暫停以及重設動畫位置與影格的功能
並且透過設定 ImageOpacityWidthHeight 實現動畫的透明與縮放

最後附上完成品,龍咆哮的動圖 :)

龍咆哮

WPF 的 AnimationView 跟原版比起來再更好一些,但 CPU 的使用量應該能再優化

插曲: .NET 的 DeflateStream 雷點

測試的時候,發現讀取出來的圖片只有一部分區塊成功渲染

圖片被截斷

原本以為是 .NET Windows 組件的 Bitmap 出了什麼問題
Google 很久都沒有找到相關資料,後來意外發現 DeflateStream 多了一個 ReadExactly 方法,才循線找到一個 .NET 6 的重大改動

楓谷的圖片有經過壓縮,因此需要使用 DeflateStreamRead 方法來讀取資料
由於 .NET 6 做了一項更動,使得 Read 可能只讀取一部分的資料
這導致原本在 .Net Framework 好好的程式轉到 .NET 之後直接壞掉
如果要像之前一樣使用 Read 讀取所有資料,則要使用 ReadExactly 方法才能讀取正確長度的資料

最初還沒想到是壓縮的問題,還好有發現多了一個沒看過的函式,才知道問題所在

結語

現在 MapleLooker 已經把 UI 翻新的差不多了
剩下一個 WinForm 組件就是 ComparerResult,用於比較兩份 WZ 的差異
由於這個功能本身並不完全,所以不會納入翻新的計畫,下次會開始對整份程式做一些優化

以現在的進度來看,MapleLooker 的翻新快要告一段落了
雖然這個專案非常的小,其實沒多少東西能改,但是因為我在裡面投入的時間不多,所以前前後後還是花了 2 個月的時間
整體來說,這翻新寫起來還蠻愉快的,看到 WinForm 的元件成功轉成 WPF 元件挺有成就感的

原本這篇文章的初稿亂到我自己都看不懂 🙃
大概是因為我沒有在開發期間紀錄自己的思路並截圖,很多圖片跟程式都是後來才補上
花了一些時間改寫之後,讀起來才感覺順一點