1. 程式人生 > >.NET實時2D渲染入門·動態時鐘

.NET實時2D渲染入門·動態時鐘

.NET實時2D渲染入門·動態時鐘

從小以來“坦克大戰”、“魂鬥羅”等遊戲總令我魂牽夢繞。這些遊戲的基礎就是2D實時渲染,以前沒意識,直到後來找到了Direct2D。我的2D實時渲染入門,是從這個動態時鐘開始的。

本文將使用我寫的“準遊戲引擎”FlysEngine完成。它是對Direct2D.NETSharpDX淺層次的封裝,隱藏了一些細節,簡化了一些呼叫。同時還保留了Direct2D的原汁原味。

本文的最終效果如下:

繪製動態時鐘

要繪製動態時鐘,需要有以下步驟:

  • 建立一個實時渲染視窗;
  • 畫一個圓圈,表示時鐘邊緣;
  • 在圓圈內等距離畫上60個分鐘刻度,其中12個比較長,為小時刻度;
  • 用不同粗細、不同長短、不同顏色的畫筆畫上時鐘、分鐘和秒鐘。

實時渲染視窗

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表示垂直同步,玩過遊戲的可能見過,這個設定可以在儘可能節省CPU/GPU

資源的同時得到最佳的呈現效果。

熟悉glut的肯定知道,這種寫法和glut非常像,執行效果如下:

注意:

RenderWindow其實繼承於System.Windows.Forms.Form,確實是基於“WinForm”,但實質卻和“拖控制元件”完全不一樣。“控制元件”是模態的,本身有狀態,但Direct2D是實時渲染,介面完全沒有狀態,需要動態每隔一個垂直同步時間(如1/60秒)全部清除,然後再重繪一次。

畫圓圈

RenderWindow簡單封裝了Direct2D,可以直接使用裡面的XResource屬性來訪問DirectX相關資源,包括:

  • Direct2D Factory
  • Direct2D DeviceContext
  • DirectWrite Factory
  • Transition Library 動畫庫
  • Animation Manager 動畫管理器
  • SwapChain
  • WIC ImagingFactory2

除此之外,還進一步封裝了以下元件,以簡化圖片、文字、顏色等呼叫和渲染:

  • TextFormatManager 簡化建立TextFormat
  • BitmapManager 簡化載入圖片
  • TextLayoutManager 簡化建立TextLayout
  • .GetColor(color) 方法,簡化使用顏色

這裡我們將使用Direct2D DeviceContext,這在COM中的名字叫ID2D1DeviceContext

回到Draw事件,它包含兩個引數:(RenderWindow sender, DeviceContext ctx)

  • 其中sender就是原視窗,可以用外層的form代替;
  • ctx引數就是D2D繪圖的核心,我們將圍繞它進行繪製。

要畫圓圈,得先算出一個能放下一個完整圓的半徑,並留下少許空間(5):

float r = Math.Min(ctx.Size.Width, ctx.Size.Height) / 2 - 5

然後呼叫ctx引數,使用黑色畫筆將圓畫出來,線寬為1/40半徑:

ctx.DrawEllipse(new Ellipse(Vector2.Zero, r, r), sender.XResource.GetColor(Color.Black), r/40);

執行效果如下:

可見圓只顯示了四分之一,要顯示完整的圓,必須將其“移動”到螢幕正中心,我們可以調整圓的引數,將中心點從Vector2.Zero改成new Vector2(ctx.Size.Width/2, ctx.Size.Height/2),或者用更簡單的辦法,通過矩陣變換:

ctx.Transform = Matrix3x2.Translation(ctx.Size.Width/2, ctx.Size.Height/2);

注意:“矩陣變換”這幾個字聽起來總令人聯想到“高數”,挺嚇人的。但實際是並不是非要知道線性程式碼基礎才能使用。首先只要知道它能完成任務即可,之後再慢慢理解也行。

有多種方法可以完成像平移這樣的任務,但通常來說使用“矩陣變換”更簡單,更不傷腦筋,尤其是多個物件,進行旋轉、扭曲等複雜、或者組合操作等,這些操作如果不使用“矩陣變換”會非常非常麻煩。

這樣,即可將該圓“平移”至螢幕正中心,執行效果如下:

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個分鐘刻度和12個時鐘刻度,其中分鐘刻度較短,時鐘刻度較長。

刻度的一端是沿著圓的邊緣,另一端朝著圓的中心,邊緣位置可以通過sin/cos等三角函式計算出來……呃,可能早忘記了,不怕,我們有“矩陣變換”。

利用矩陣變換,可以非常容易地完成這項工作:

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);
}

執行效果如下:

注意:此處用到了矩陣乘法:

Matrix3x2.Rotation(MathF.PI * 2 / 60 * i) * 
Matrix3x2.Translation(ctx.Size.Width/2, ctx.Size.Height/2);

注意乘法是有順序的,這符合空間邏輯,可以這樣想想,先旋轉再平移,和先平移再旋轉顯然是有區別的。

然後再加上長時鐘,只需在原始碼基礎上加個判斷即可,如果i % 5 == 0,則為長時鐘,粗細設定為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)
    {   // 時鐘
        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);
    }
}

執行效果如下:

畫時、分、秒鐘

時、分、秒鐘是動態的,必須隨著時間變化而變化;其中時鐘最短、最粗,分鐘次之,秒鐘最細長,然後時鐘必須疊在分鐘和秒鐘之上。

用程式碼實現,可以先畫秒鐘、再畫分鐘和時鐘,即可實現重疊效果。還可以通過設定一定的透明度和不同的顏色,可以讓它們區分更明顯。

獲取當前時間可以通過DateTime.Now來完成,DateTime提供了時、分、秒和毫秒,可以輕鬆地計算各個指標應該指向的位置。

畫秒鐘的程式碼如下,顯示為藍色,長度為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);

效果如下:

依法炮製,可以畫出分鐘和時鐘:

// 分鐘
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);

// 時鐘
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);

效果如下:

優化

其實到了這一步,已經是一個完整的,可執行的時鐘了,但還能再優化優化。

半透明時鐘

首先可以設定一定的半透明度,使三根鍾重疊時不顯得很突兀,程式碼如下:

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引數為0.7(表示70%半透明)即可,效果如下:

時鐘兩端的尖角或者圓角

Direct2D可以很方便地控制繪製的線段兩端,有許多風格可供選擇,具體可以參見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用於做中心點,用Triangle用於做針尖,首先建立一個StrokeStyle物件:

using var clockLineStyle = new StrokeStyle(form.XResource.Direct2DFactory, new StrokeStyleProperties 
{ 
    StartCap = CapStyle.Round, 
    EndCap = CapStyle.Triangle, 
});

然後在呼叫ctx.DrawLine()時,將clockLineStyle引數傳入最後一個引數即可:

ctx.DrawLine(Vector2.Zero, new Vector2(0,-r*0.9f), form.XResource.GetColor(blue), r/50, clockLineStyle);

執行效果如下(可見有那麼點意思了):

平滑移動

Direct2D是實時渲染,我們不能浪費這實時二字帶來的好處。更何況顯示出來的時鐘也不太合理,因為當時時間是9:57,此時時鐘應該指向偏10點的位置。但現在由於忽略了這一分量,指向的是9點,這不符合實時的時鐘。

因此計算小時角度時,可以加入分鐘分量,計算分鐘角度時,可以加入秒鐘分量,計算秒鐘角度時,也可以加入毫秒的分量。程式碼只需將矩陣變換程式碼稍微變動一點點即可:

// 秒鐘
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);
// ...
// 時鐘
ctx.Transform =
    Matrix3x2.Rotation(MathF.PI * 2 / 12 * (time.Hour + time.Minute / 60.0f)) *
    Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2);

執行效果如下:

陰影效果

和邊緣刻度不一樣,時鐘多少是和視窗底層有距離的,因此怎麼說也會顯示一些陰影效果。這在Direct2D中也能輕易實現。程式碼會複雜一點,過程如下:

  • 先將建立一個臨時的Bitmap1;
  • 將時、分、秒鐘繪製到這個Bitmap中;
  • 建立一個ShadowEffect,傳入這個Bitmap的內容生成一個陰影貼圖;
  • 呼叫ctx.DrawImage()ShadowEffect先繪製;
  • 呼叫ctx.DrawBitmap()繪製最後真正的時、分、秒鐘。

注意這個過程的順序不能錯,否則可能出現陰影顯示的真實物體上的虛幻效果。

臨時的Bitmap1ShadowEffect可以在CreateDeviceSizeResourcesReleaseDeviceSizeResources事件中建立和銷燬:

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();
};

其中dpiDirect2DFactory.DesktopDpi.Width進行獲取。

先將ctxTarget屬性指定這個bitmap,但又同時儲存老的Target屬性用於稍後繪製:

var oldTarget = ctx.Target;
ctx.Target = bitmap;
ctx.BeginDraw();
{
    ctx.Clear(Color.Transparent);
    // 上文中的繪製時鐘部分...
}
ctx.EndDraw();

注意ctx.Clear(Color.Transparent);是有必要的,否則將出現重影:

這樣即可將時鐘單獨繪製到bitmap中,對這個bitmap生成一個陰影:

shadowEffect.SetInput(0, ctx.Target, invalidate: new RawBool(false));

最後進行繪製,繪製時記得順序:

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;
}

注意兩點:

首先,設定ctx.Transform = identity是有必要的,否則會上文的矩陣變換會一直保持作用;

然後兩次設定ctx.UnitMode = pixels/dips也是有必要的,因為此時的繪製相當於是圖片,按照預設的高DPI顯示會導致顯示模糊,因此顯示圖片時需要改成點對點顯示;

效果如下:

這個陰影預設是完全重疊的,現實中這種光線較小,加一點點平移效果可能會更好:

ctx.DrawImage(shadowEffect, new Vector2(r/20,r/20));

效果如下(顯然更逼真了):

更好的動畫

有些時鐘的秒確實是這樣動的,但我印象中兒時的記憶,秒是一格一格地動,它是每動一下,停頓一下再動的那種感覺。

為了實現這種感覺,我加入了Windows Animation Manager的功能,這也是COM元件的一部分,我的FlysEngine中稍微封裝了一下。使用時需要引入一個timer進行配合:

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中封裝的,可以在繪製呈現前執行一段更新邏輯的程式碼。

然後後面的繪製時,將獲取秒的矩陣變換引數改為secondPosition變數即可:

ctx.Transform = 
    Matrix3x2.Rotation(MathF.PI * 2 / 60 * secondPosition) * 
    Matrix3x2.Translation(ctx.Size.Width / 2, ctx.Size.Height / 2);

最後的執行效果如下:

看起來一切正常,但……如果經過分鐘滿時,會出現這種情況:

這是因為秒數從59秒到00秒的動畫,是一個遞減的過程(59->00),因此秒鐘反向轉了一圈,這明顯不對。

解決這個問題可以這樣考慮,如果當前是59秒,我們假裝它是-1秒即可,這時計算角度不會出錯,矩陣變換也沒任何問題,通過C# 8.0強大的switch expression功能,可以不需要額外語句,在表示式內即可解決:

secondVariable = form.XResource.CreateAnimation(secondPosition switch
{
    59 => -1, 
    var x => x, 
}, DateTime.Now.Second, 0.2f);

最後的最後,最終效果如下:

結語

說來這是我和我老婆的愛情故事。

記得6年前我老婆第一次來我出租房玩,然後……我給她感受了作為一個程式設計師的“浪漫”,花了一整個下午時間,把這個demo0開始做了出來給她看,不過那時我還在用C++。多年後和她說起這個入門demo,她仍記憶尤新。

本文中最終效果的程式碼,可以從我的github倉庫下載:
https://github.com/sdcb/blog-data/blob/master/2019/20191021-render-clock-using-dotnet/clock.linq

有了.NET,那些程式碼已經遠比當年簡單,我的確是從這個例子出發,做出了許多好玩的東西,以後有機會我會慢慢介紹,敬請期待。

喜歡的朋友 請關注我的微信公眾號:【DotNet騷操作】