前言
说来这是个我和我老婆的爱情故事。
从小以来“坦克大战”、“魂斗罗”等游戏总令我魂牵梦绕。这些游戏的基础就是 2D
实时渲染,以前没意识,直到后来找到了 Direct2D
。我的 2D
实时渲染入门,是从这个 动态时钟
开始的。
本文将使用我写的“准游戏引擎” FlysEngine
完成。它是对 Direct2D
和 .NET
库 SharpDX
浅层次的封装,隐藏了一些细节,简化了一些调用。同时还保留了 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
相关资源,包括:
Direct2DFactory
Direct2DDeviceContext
DirectWriteFactory
TransitionLibrary
动画库AnimationManager
动画管理器SwapChain
WICImagingFactory2
除此之外,还进一步封装了以下组件,以简化图片、文字、颜色等调用和渲染:
TextFormatManager
简化创建TextFormat
BitmapManager
简化加载图片TextLayoutManager
简化创建TextLayout
.GetColor(color)
方法,简化使用颜色
这里我们将使用 Direct2DDeviceContext
,这在 COM
中的名字叫 ID2D1DeviceContext
。
回到 Draw
事件,它包含两个参数:(RenderWindowsender,DeviceContextctx)
:
其中
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
改成 newVector2(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()
绘制最后真正的时、分、秒钟。
注意这个过程的顺序不能错,否则可能出现阴影显示的真实物体上的虚幻效果。
临时的 Bitmap1
和 ShadowEffect
可以在 CreateDeviceSizeResources
和 ReleaseDeviceSizeResources
事件中创建和销毁:
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
进行获取。
先将 ctx
的 Target
属性指定这个 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));
效果如下(显然更逼真了):
更好的动画
有些时钟的秒确实是这样动的,但我印象中儿时的记忆,秒是一格一格地动,它是每动一下,停顿一下再动的那种感觉。
为了实现这种感觉,我加入了 WindowsAnimationManager
的功能,这也是 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
强大的 switchexpression
功能,可以不需要额外语句,在表达式内即可解决:
secondVariable = form.XResource.CreateAnimation(secondPosition switch
{
59 => -1,
var x => x,
}, DateTime.Now.Second, 0.2f);
最后的最后,最终效果如下:
结语
记得6年前我老婆第一次来我出租房玩,然后……我给她感受了作为一个程序员的“浪漫”,花了一整个下午时间,把这个 demo
从 0
开始做了出来给她看,不过那时我还在用 C++
。多年后和她说起这个入门 demo
,她仍记忆尤新。
本文中最终效果的代码,可以从我的 github
仓库下载:https://github.com/sdcb/blog-data/blob/master/2019/20191021-render-clock-using-dotnet/clock.linq
有了 .NET
,那些代码已经远比当年简单,我的确是从这个例子出发,做出了许多好玩的东西,以后有机会我会慢慢介绍,敬请期待。
喜欢的朋友 请关注我的微信公众号:【DotNet骚操作】