.NET实时2D渲染入门·动态时钟
前言
說來(lái)這是個(gè)我和我老婆的愛情故事。
從小以來(lái)“坦克大戰(zhàn)”、“魂斗羅”等游戲總令我魂?duì)繅?mèng)繞。這些游戲的基礎(chǔ)就是 2D實(shí)時(shí)渲染,以前沒意識(shí),直到后來(lái)找到了 Direct2D。我的 2D實(shí)時(shí)渲染入門,是從這個(gè) 動(dòng)態(tài)時(shí)鐘開始的。
本文將使用我寫的“準(zhǔn)游戲引擎” FlysEngine完成。它是對(duì) Direct2D和 .NET庫(kù) SharpDX淺層次的封裝,隱藏了一些細(xì)節(jié),簡(jiǎn)化了一些調(diào)用。同時(shí)還保留了 Direct2D的原汁原味。
本文的最終效果如下:
繪制動(dòng)態(tài)時(shí)鐘
要繪制動(dòng)態(tài)時(shí)鐘,需要有以下步驟:
- 創(chuàng)建一個(gè)實(shí)時(shí)渲染窗口; 
- 畫一個(gè)圓圈,表示時(shí)鐘邊緣; 
- 在圓圈內(nèi)等距離畫上?60個(gè)分鐘刻度,其中?12個(gè)比較長(zhǎng),為小時(shí)刻度; 
- 用不同粗細(xì)、不同長(zhǎng)短、不同顏色的畫筆畫上時(shí)鐘、分鐘和秒鐘。 
實(shí)時(shí)渲染窗口
using var form = new RenderWindow { ClientSize = new System.Drawing.Size(400, 400) }; form.Draw += (RenderWindow sender, DeviceContext ctx) => { ctx.Clear(Color.CornflowerBlue); }; RenderLoop.Run(form, () => form.Render(1, PresentFlags.None));其中 form.Render(1,...)中的 1表示垂直同步,玩過游戲的可能見過,這個(gè)設(shè)置可以在盡可能節(jié)省 CPU/GPU資源的同時(shí)得到最佳的呈現(xiàn)效果。
熟悉 glut的肯定知道,這種寫法和 glut非常像,執(zhí)行效果如下:
注意:
RenderWindow其實(shí)繼承于 System.Windows.Forms.Form,確實(shí)是基于“ WinForm”,但實(shí)質(zhì)卻和“拖控件”完全不一樣。“控件”是模態(tài)的,本身有狀態(tài),但 Direct2D是實(shí)時(shí)渲染,界面完全沒有狀態(tài),需要?jiǎng)討B(tài)每隔一個(gè)垂直同步時(shí)間(如 1/60秒)全部清除,然后再重繪一次。
畫圓圈
RenderWindow簡(jiǎn)單封裝了 Direct2D,可以直接使用里面的 XResource屬性來(lái)訪問 DirectX相關(guān)資源,包括:
- Direct2DFactory 
- Direct2DDeviceContext 
- DirectWriteFactory 
- TransitionLibrary?動(dòng)畫庫(kù) 
- AnimationManager?動(dòng)畫管理器 
- SwapChain 
- WICImagingFactory2 
除此之外,還進(jìn)一步封裝了以下組件,以簡(jiǎn)化圖片、文字、顏色等調(diào)用和渲染:
- TextFormatManager?簡(jiǎn)化創(chuàng)建?TextFormat 
- BitmapManager?簡(jiǎn)化加載圖片 
- TextLayoutManager?簡(jiǎn)化創(chuàng)建?TextLayout 
- .GetColor(color)?方法,簡(jiǎn)化使用顏色 
這里我們將使用 Direct2DDeviceContext,這在 COM中的名字叫 ID2D1DeviceContext。
回到 Draw事件,它包含兩個(gè)參數(shù):(RenderWindowsender,DeviceContextctx):
- 其中?sender就是原窗口,可以用外層的?form代替; 
- ctx參數(shù)就是?D2D繪圖的核心,我們將圍繞它進(jìn)行繪制。 
要畫圓圈,得先算出一個(gè)能放下一個(gè)完整圓的半徑,并留下少許空間( 5):
float r = Math.Min(ctx.Size.Width, ctx.Size.Height) / 2 - 5然后調(diào)用 ctx參數(shù),使用黑色畫筆將圓畫出來(lái),線寬為 1/40半徑:
ctx.DrawEllipse(new Ellipse(Vector2.Zero, r, r), sender.XResource.GetColor(Color.Black), r/40);執(zhí)行效果如下:
可見圓只顯示了四分之一,要顯示完整的圓,必須將其“移動(dòng)”到屏幕正中心,我們可以調(diào)整圓的參數(shù),將中心點(diǎn)從 Vector2.Zero改成 newVector2(ctx.Size.Width/2,ctx.Size.Height/2),或者用更簡(jiǎn)單的辦法,通過矩陣變換:
ctx.Transform = Matrix3x2.Translation(ctx.Size.Width/2, ctx.Size.Height/2);注意:“矩陣變換”這幾個(gè)字聽起來(lái)總令人聯(lián)想到“高數(shù)”,挺嚇人的。但實(shí)際是并不是非要知道線性代碼基礎(chǔ)才能使用。首先只要知道它能完成任務(wù)即可,之后再慢慢理解也行。
有多種方法可以完成像平移這樣的任務(wù),但通常來(lái)說使用“矩陣變換”更簡(jiǎn)單,更不傷腦筋,尤其是多個(gè)對(duì)象,進(jìn)行旋轉(zhuǎn)、扭曲等復(fù)雜、或者組合操作等,這些操作如果不使用“矩陣變換”會(huì)非常非常麻煩。
這樣,即可將該圓“平移”至屏幕正中心,執(zhí)行效果如下:
Draw方法完整代碼:
ctx.Clear(Color.CornflowerBlue); float r = Math.Min(ctx.Size.Width, ctx.Size.Height) / 2 - 5; ctx.Transform = Matrix3x2.Translation(ctx.Size.Width/2, ctx.Size.Height/2); ctx.DrawEllipse(new Ellipse(Vector2.Zero, r, r), sender.XResource.GetColor(Color.Black), r/40);畫刻度
刻度就是線條,共 60-12=48個(gè)分鐘刻度和 12個(gè)時(shí)鐘刻度,其中分鐘刻度較短,時(shí)鐘刻度較長(zhǎng)。
刻度的一端是沿著圓的邊緣,另一端朝著圓的中心,邊緣位置可以通過 sin/cos等三角函數(shù)計(jì)算出來(lái)……呃,可能早忘記了,不怕,我們有“矩陣變換”。
利用矩陣變換,可以非常容易地完成這項(xiàng)工作:
for (var i = 0; i < 60; ++i) { ctx.Transform = Matrix3x2.Rotation(MathF.PI * 2 / 60 * i) * Matrix3x2.Translation(ctx.Size.Width/2, ctx.Size.Height/2); ctx.DrawLine(new Vector2(r-r/30,0), new Vector2(r,0), form.XResource.GetColor(Color.Black),r/200); }執(zhí)行效果如下:
注意:此處用到了矩陣乘法:
Matrix3x2.Rotation(MathF.PI * 2 / 60 * i) * Matrix3x2.Translation(ctx.Size.Width/2, ctx.Size.Height/2);注意乘法是有順序的,這符合空間邏輯,可以這樣想想,先旋轉(zhuǎn)再平移,和先平移再旋轉(zhuǎn)顯然是有區(qū)別的。
然后再加上長(zhǎng)時(shí)鐘,只需在原代碼基礎(chǔ)上加個(gè)判斷即可,如果 i%5==0,則為長(zhǎng)時(shí)鐘,粗細(xì)設(shè)置為 r/100:
for (var i = 0; i < 60; ++i) { ctx.Transform = Matrix3x2.Rotation(MathF.PI * 2 / 60 * i) * Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2); if (i % 5 == 0) { // 時(shí)鐘 ctx.DrawLine(new Vector2(r - r / 15, 0), new Vector2(r, 0), form.XResource.GetColor(Color.Black), r/100); } else { // 分鐘 ctx.DrawLine(new Vector2(r - r / 30, 0), new Vector2(r, 0), form.XResource.GetColor(Color.Black), r/200); } }執(zhí)行效果如下:
畫時(shí)、分、秒鐘
時(shí)、分、秒鐘是動(dòng)態(tài)的,必須隨著時(shí)間變化而變化;其中時(shí)鐘最短、最粗,分鐘次之,秒鐘最細(xì)長(zhǎng),然后時(shí)鐘必須疊在分鐘和秒鐘之上。
用代碼實(shí)現(xiàn),可以先畫秒鐘、再畫分鐘和時(shí)鐘,即可實(shí)現(xiàn)重疊效果。還可以通過設(shè)置一定的透明度和不同的顏色,可以讓它們區(qū)分更明顯。
獲取當(dāng)前時(shí)間可以通過 DateTime.Now來(lái)完成, DateTime提供了時(shí)、分、秒和毫秒,可以輕松地計(jì)算各個(gè)指針應(yīng)該指向的位置。
畫秒鐘的代碼如下,顯示為藍(lán)色,長(zhǎng)度為 0.9倍半徑,寬度為 1/50半徑:
// 秒鐘 ctx.Transform = Matrix3x2.Rotation(MathF.PI * 2 / 60 * time.Second) * Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2); ctx.DrawLine(Vector2.Zero, new Vector2(0,-r*0.9f), form.XResource.GetColor(Color.Blue), r/50);效果如下:
依法炮制,可以畫出分鐘和時(shí)鐘:
// 分鐘 ctx.Transform = Matrix3x2.Rotation(MathF.PI * 2 / 60 * time.Minute) * Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2); ctx.DrawLine(Vector2.Zero, new Vector2(0, -r * 0.8f), form.XResource.GetColor(Color.Green), r / 35); // 時(shí)鐘 ctx.Transform = Matrix3x2.Rotation(MathF.PI * 2 / 12 * time.Hour) * Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2); ctx.DrawLine(Vector2.Zero, new Vector2(0, -r * 0.7f), form.XResource.GetColor(Color.Red), r / 20);效果如下:
優(yōu)化
其實(shí)到了這一步,已經(jīng)是一個(gè)完整的,可運(yùn)行的時(shí)鐘了,但還能再優(yōu)化優(yōu)化。
半透明時(shí)鐘
首先可以設(shè)置一定的半透明度,使三根鐘重疊時(shí)不顯得很突兀,代碼如下:
var blue = new Color(red: 0.0f, green: 0.0f, blue: 1.0f, alpha: 0.7f); ctx.DrawLine(Vector2.Zero, new Vector2(0,-r*0.9f), form.XResource.GetColor(blue), r/50);只需將原本的 Color.Blue等顏色改成自定義,并且指定 alpha參數(shù)為 0.7(表示 70%半透明)即可,效果如下:
時(shí)鐘兩端的尖角或者圓角
Direct2D可以很方便地控制繪制的線段兩端,有許多風(fēng)格可供選擇,具體可以參見 CapStyle枚舉:
public enum CapStyle { /// <unmanaged>D2D1_CAP_STYLE_FLAT</unmanaged> Flat, /// <unmanaged>D2D1_CAP_STYLE_SQUARE</unmanaged> Square, /// <unmanaged>D2D1_CAP_STYLE_ROUND</unmanaged> Round, /// <unmanaged>D2D1_CAP_STYLE_TRIANGLE</unmanaged> Triangle }此處我們將使用 Round用于做中心點(diǎn),用 Triangle用于做針尖,首先創(chuàng)建一個(gè) StrokeStyle對(duì)象:
using var clockLineStyle = new StrokeStyle(form.XResource.Direct2DFactory, new StrokeStyleProperties { StartCap = CapStyle.Round, EndCap = CapStyle.Triangle, });然后在調(diào)用 ctx.DrawLine()時(shí),將 clockLineStyle參數(shù)傳入最后一個(gè)參數(shù)即可:
ctx.DrawLine(Vector2.Zero, new Vector2(0,-r*0.9f), form.XResource.GetColor(blue), r/50, clockLineStyle);執(zhí)行效果如下(可見有那么點(diǎn)意思了):
平滑移動(dòng)
Direct2D是實(shí)時(shí)渲染,我們不能浪費(fèi)這實(shí)時(shí)二字帶來(lái)的好處。更何況顯示出來(lái)的時(shí)鐘也不太合理,因?yàn)楫?dāng)時(shí)時(shí)間是 9:57,此時(shí)時(shí)鐘應(yīng)該指向偏 10點(diǎn)的位置。但現(xiàn)在由于忽略了這一分量,指向的是 9點(diǎn),這不符合實(shí)時(shí)的時(shí)鐘。
因此計(jì)算小時(shí)角度時(shí),可以加入分鐘分量,計(jì)算分鐘角度時(shí),可以加入秒鐘分量,計(jì)算秒鐘角度時(shí),也可以加入毫秒的分量。代碼只需將矩陣變換代碼稍微變動(dòng)一點(diǎn)點(diǎn)即可:
// 秒鐘 ctx.Transform = Matrix3x2.Rotation(MathF.PI * 2 / 60 * (time.Second + time.Millisecond / 1000.0f)) * Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2); // ... // 分鐘 ctx.Transform = Matrix3x2.Rotation(MathF.PI * 2 / 60 * (time.Minute + time.Second / 60.0f)) * Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2); // ... // 時(shí)鐘 ctx.Transform = Matrix3x2.Rotation(MathF.PI * 2 / 12 * (time.Hour + time.Minute / 60.0f)) * Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2);執(zhí)行效果如下:
陰影效果
和邊緣刻度不一樣,時(shí)鐘多少是和窗口底層有距離的,因此怎么說也會(huì)顯示一些陰影效果。這在 Direct2D中也能輕易實(shí)現(xiàn)。代碼會(huì)復(fù)雜一點(diǎn),過程如下:
- 先將創(chuàng)建一個(gè)臨時(shí)的?Bitmap1; 
- 將時(shí)、分、秒鐘繪制到這個(gè)?Bitmap中; 
- 創(chuàng)建一個(gè)?ShadowEffect,傳入這個(gè)?Bitmap的內(nèi)容生成一個(gè)陰影貼圖; 
- 調(diào)用?ctx.DrawImage()將?ShadowEffect先繪制; 
- 調(diào)用?ctx.DrawBitmap()繪制最后真正的時(shí)、分、秒鐘。 
注意這個(gè)過程的順序不能錯(cuò),否則可能出現(xiàn)陰影顯示的真實(shí)物體上的虛幻效果。
臨時(shí)的 Bitmap1和 ShadowEffect可以在 CreateDeviceSizeResources和 ReleaseDeviceSizeResources事件中創(chuàng)建和銷毀:
Bitmap1 bitmap = null; Shadow shadowEffect = null; form.CreateDeviceSizeResources += (RenderWindow sender) => { bitmap = new Bitmap1(form.XResource.RenderTarget, form.XResource.RenderTarget.PixelSize, new BitmapProperties1(new PixelFormat(Format.B8G8R8A8_UNorm, SharpDX.Direct2D1.AlphaMode.Premultiplied), dpi, dpi, BitmapOptions.Target)); shadowEffect = new SharpDX.Direct2D1.Effects.Shadow(form.XResource.RenderTarget); }; form.ReleaseDeviceSizeResources += o => { bitmap.Dispose(); shadowEffect.Dispose(); };其中 dpi從 Direct2DFactory.DesktopDpi.Width進(jìn)行獲取。
先將 ctx的 Target屬性指定這個(gè) bitmap,但又同時(shí)保存老的 Target屬性用于稍后繪制:
var oldTarget = ctx.Target; ctx.Target = bitmap; ctx.BeginDraw(); { ctx.Clear(Color.Transparent); // 上文中的繪制時(shí)鐘部分... } ctx.EndDraw();注意 ctx.Clear(Color.Transparent);是有必要的,否則將出現(xiàn)重影:
這樣即可將時(shí)鐘單獨(dú)繪制到 bitmap中,對(duì)這個(gè) bitmap生成一個(gè)陰影:
shadowEffect.SetInput(0, ctx.Target, invalidate: new RawBool(false));最后進(jìn)行繪制,繪制時(shí)記得順序:
ctx.Target = oldTarget; ctx.BeginDraw(); { ctx.Transform = Matrix3x2.Identity; ctx.UnitMode = UnitMode.Pixels; ctx.DrawImage(shadowEffect); ctx.DrawBitmap(bitmap, 1.0f, InterpolationMode.NearestNeighbor); ctx.UnitMode = UnitMode.Dips; }注意兩點(diǎn):
首先,設(shè)置 ctx.Transform=identity是有必要的,否則會(huì)上文的矩陣變換會(huì)一直保持作用;
然后兩次設(shè)置 ctx.UnitMode=pixels/dips也是有必要的,因?yàn)榇藭r(shí)的繪制相當(dāng)于是圖片,按照默認(rèn)的高 DPI顯示會(huì)導(dǎo)致顯示模糊,因此顯示圖片時(shí)需要改成點(diǎn)對(duì)點(diǎn)顯示;
效果如下:
這個(gè)陰影默認(rèn)是完全重疊的,現(xiàn)實(shí)中這種光線較小,加一點(diǎn)點(diǎn)平移效果可能會(huì)更好:
ctx.DrawImage(shadowEffect, new Vector2(r/20,r/20));效果如下(顯然更逼真了):
更好的動(dòng)畫
有些時(shí)鐘的秒確實(shí)是這樣動(dòng)的,但我印象中兒時(shí)的記憶,秒是一格一格地動(dòng),它是每動(dòng)一下,停頓一下再動(dòng)的那種感覺。
為了實(shí)現(xiàn)這種感覺,我加入了 WindowsAnimationManager的功能,這也是 COM組件的一部分,我的 FlysEngine中稍微封裝了一下。使用時(shí)需要引入一個(gè) timer進(jìn)行配合:
float secondPosition = DateTime.Now.Second; Variable secondVariable = null; var timer = new System.Windows.Forms.Timer { Enabled = true, Interval = 1000 }; timer.Tick += (o, e) => { secondVariable?.Dispose(); secondVariable = form.XResource.CreateAnimation(secondPosition, DateTime.Now.Second, 0.2f); }; form.FormClosing += delegate { timer.Dispose(); }; form.UpdateLogic += (window, dt) => { secondPosition = (float)(secondVariable?.Value ?? 0.0f); };注意此處我使用了 UpdateLogic事件,這也是 FlysEngine中封裝的,可以在繪制呈現(xiàn)前執(zhí)行一段更新邏輯的代碼。
然后后面的繪制時(shí),將獲取秒的矩陣變換參數(shù)改為 secondPosition變量即可:
ctx.Transform = Matrix3x2.Rotation(MathF.PI * 2 / 60 * secondPosition) * Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2);最后的執(zhí)行效果如下:
看起來(lái)一切正常,但……如果經(jīng)過分鐘滿時(shí),會(huì)出現(xiàn)這種情況:
這是因?yàn)槊霐?shù)從 59秒到 00秒的動(dòng)畫,是一個(gè)遞減的過程( 59->00),因此秒鐘反向轉(zhuǎn)了一圈,這明顯不對(duì)。
解決這個(gè)問題可以這樣考慮,如果當(dāng)前是 59秒,我們假裝它是 -1秒即可,這時(shí)計(jì)算角度不會(huì)出錯(cuò),矩陣變換也沒任何問題,通過 C# 8.0強(qiáng)大的 switchexpression功能,可以不需要額外語(yǔ)句,在表達(dá)式內(nèi)即可解決:
secondVariable = form.XResource.CreateAnimation(secondPosition switch { 59 => -1, var x => x, }, DateTime.Now.Second, 0.2f);最后的最后,最終效果如下:
結(jié)語(yǔ)
記得6年前我老婆第一次來(lái)我出租房玩,然后……我給她感受了作為一個(gè)程序員的“浪漫”,花了一整個(gè)下午時(shí)間,把這個(gè) demo從 0開始做了出來(lái)給她看,不過那時(shí)我還在用 C++。多年后和她說起這個(gè)入門 demo,她仍記憶尤新。
本文中最終效果的代碼,可以從我的 github倉(cāng)庫(kù)下載:https://github.com/sdcb/blog-data/blob/master/2019/20191021-render-clock-using-dotnet/clock.linq
有了 .NET,那些代碼已經(jīng)遠(yuǎn)比當(dāng)年簡(jiǎn)單,我的確是從這個(gè)例子出發(fā),做出了許多好玩的東西,以后有機(jī)會(huì)我會(huì)慢慢介紹,敬請(qǐng)期待。
喜歡的朋友 請(qǐng)關(guān)注我的微信公眾號(hào):【DotNet騷操作】
總結(jié)
以上是生活随笔為你收集整理的.NET实时2D渲染入门·动态时钟的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: 那位标榜技术驱动的开发者去哪了?
- 下一篇: ASP.NET Core在 .NET C
