从某种意义上来说,做图形也好,做GUI也好,做编译器也好,大概都是一种情结。其实只要稍微想一想就知道,能把它们三者有机统一起来的,就只有游戏。我很久以前的确是为了想开发游戏才对编程产生兴趣的,而学习游戏开发占据了我前六年的时间。虽然现在不做了,不过偶尔总是会觉得手痒。但是做游戏没美工做不好怎么办呢?就只好写游戏代码了。但是没有资源写出来的游戏又不好玩,于是就只好写库。那写什么库呢,自然就只有渲染器、界面引擎和脚本引擎了。我的博客的大部分文章也是围绕着这三件事情建立起来,而且在中间不断切换的。
撒,所以今天就轮到GUI了。我一直很想做出一个自绘的GUI出来,无奈一直设计不出一个好的架构。后来尝试用原生API,但是却又很不喜欢MFC的设计,就尝试自己照着.NET Framework和Delphi那套VCL的样子
封装了一个控件库出来。无奈原生API细节无敌多,后来没做把所有的功能都全部做完。因此后来一段时间凡是需要界面我都直接用C#做。然后CEGUI出了,WPF和Silverlight也出了,我发现如今要做一个漂亮的GUI非自绘已经做不到了,所以我又做了一次尝试。
去年在美帝的时候曾经试图再设计一次,得到了一些结果。后来我发现其实根本没办法为GDI、DirectX、OpenGL和其它绘图设备抽象一个公用的接口,否则就会遭遇大量性能问题。因为在很多细节上,譬如说渲染文字,为了达到较高的性能,OpenGL和DirectX需要使用几乎相反的策略来做。因此这次我又换了一个方法,而且在
Vczh Library++ 3.0的Candidate目录下已经checkin了一个试验品。
我把一个自绘的GUI分成了下面若干个层次。
1、NativeWindow。NativeWindow表示的是一个顶层窗口的实现。譬如说我们想用Windows的窗口作为自绘窗口的顶层窗口(游戏里面的很多顶层窗口是绘制在游戏窗口里面的,所以顶层窗口并不一定是Windows的窗口)。
2、控件库。控件库包含了这个自绘GUI库的所有预定义控件。控件本身包含对用户输入的相应逻辑,但是每一个控件的绘制以及鼠标点中测试不在此范围内。
3、控件皮肤接口。每一个最终控件都会拥有一个控件皮肤接口。每当控件的状态发生了变化,控件会调用皮肤接口更新控件的当前状态。每当控件需要知道某一个点是否位于一个控件里面的时候,他也会去调用该控件的皮肤获得结果。因此控件皮肤接口包含了一切关于绘制(因此理所当然也就包含了点中测试)的逻辑。
为了达到最高的性能,一套皮肤的实现只能绑定在某种绘图设备上,也就是说缺省状态下一套为GDI设备设计出来的皮肤是不能直接使用在DirectX设备上面的。当然我这个框架的设计也是足够开放的,如果你非得用同一套代码来实现不同绘图设备上的皮肤,那么你是可以自己动手丰衣足食,做到给GDI和DirectX设计一个公共接口并插入我的GUI框架的(只不过这种做法一般情况下都会惨死)。
那么如何添加绘图设备呢?目前NativeWindow有一个基于Windows窗口的实现,并且NativeWindow的接口要求该实现在创建、销毁、接收到很多窗口事件的时候都调用某一个回调对象。我们可以通过注册一个全局回调对象或者具体窗口的回调对象来获得NativeWindow状态的变更。基于Windows窗口的NativeWindow实现还提供了一个额外函数,可以让你获得一个NativeWindow的HWND(但这个函数并不被控件库依赖)。现在我还实现了一个基于HWND+HDC的绘图设备,主要方法就是先注册全局回调对象,每当知道一个NativeWindow被创建了,我就会注册一个NativeWindow的回调对象,用来维护一个窗口里面的一块32位DIBSections位图缓冲区。窗口的大小如果变化了,我也会在适当的时候重新创建一块合适的缓冲区。不过为了避免每一次大小变化都会创建新的缓冲区,我创建的缓冲区的大小总会比窗口大一点。然后这个GDI绘图设备就暴露了一个函数,可以获得一个NativeWindow的HDC和WinGDIElementEnvironment。
WinGDIElementEnvironment是基于HWND+HDC的这一套实现上专有的、为了GDI皮肤设计出来的一个公共的资源库(譬如用来保存各种面向业务逻辑的pen啊brush什么的,比如说disable的时候什么颜色,选中的时候什么颜色等等)。如果你想设计一个基于HWND+DirectX的皮肤,那么类似WinGDIElementEnvironment的这套东西要重新做一次——因为为了达到相同的性能。具体细节相差太大。当然HWND+HDC上面可以有多套皮肤,WinGDIElementEnvironment是公用的。WinGDIElementEnvironment要求绘制是通过一个具体的WinGDIElement对象达到的,而一套皮肤可以有自己的一套WinGDIElement的实现。WinGDIElement被设计成面向业务的、一套皮肤的基本元素组成部分,譬如说按钮边框啦、焦点长方形啦、文字啦,而不是带有pen和brush的长方形啊,文字啊,各种乱七八糟的最低等级的绘图元素。举一个例子,按钮边框跟菜单边框很像,都可以用Rectangle来组成。但是Element里面就直接是按钮边框和菜单边框,而不是一个可以让你自由修改颜色的Rectangle。因为不同的控件要共享配色方案,而配色方案是由业务逻辑+空间状态的集合实现的,因此WinGDIElement还是一个比较高层次的概念。当一个WinGDIElement被渲染的时候,他会给你一个HDC,然后你根据被设置的状态来调用GDI函数绘制到HDC指向的32位DIBSections位图缓冲区上面。
那么,当我们使用HWND+HDC的实现,创建了一个布满了控件的窗口,那实际上是发生了什么事情呢?首先控件自己会组成一棵树。其次,控件的皮肤也会组成一棵树。现在就有控件树跟皮肤树两颗树了。控件树负责所有用户输入变更状态的逻辑部分,而皮肤树负责绘图和点中测试。而一个HWND+HDC实现的皮肤树,会在皮肤组合成树的时候,在底下又组合出了一颗WinGDIElement树。因此大局上就是:
控件树(负责相应输入变更状态)--> 皮肤树(负责储存控件状态的可视部分并决定什么时候需要刷新)-->WinGDIElement树(负责绘图整个窗口)
这个时候,如果我们仅仅需要简单的重新绘制窗口的话,那么控件树跟皮肤树都不需要被访问到,底层仅需要让WinGDIElement树重新绘制一遍即可。而WinGDIElement的粒度实际上也不小,因此不会每一个图元都有一个WinGDIElement从而使得创建出了一大堆对象的。
最后一个设计就是在什么时候才重绘窗口的问题。假设说我们现在收到了一个WM_KEYDOWN消息,最终传播到了控件树里面去,然后修改了10个控件上面的文字。每当你修改文字的时候实际上都需要重绘,那如何将无数次不可控的重绘合并成一次呢?SendMessage(WM_PAINT)是立刻执行的,所以Windows自带的合并WM_PAINT的方法在这个时候是无效的。我所采取的解决方法就是:反正控件树的所有消息来源都是从NativeWindow里面来的,那实际上控件树发出一个重绘请求的时候,我就会把NativeWindow的HWND实现里面的一个bool变量设成true,然后当NativeWindow每一个传播到控件树的消息结束传播之后,才读一次那个变量,如果是true,那么就调用WinGDIElement进行重绘并把变量设计成false。坏处是每一个传播到控件树的消息在处理完之后都必须检查是否需要重绘,好处是这个东西被封装在了NativeWindow的HWND实现里面里面,无论是控件树、皮肤树还是WinGDIElement树也好,都在也不需要关心绘图时机的事情了。
因为GUI被分割成了很多层,而且每一层的都关心业务逻辑的不同部分,所以他们都是可以被替换的。譬如说我们可以做成:
HWND+HDC实现:最普通的方法
HWND+DirectX:WPF和Silverlight地方法
单一HWND+多个虚拟窗口+DirectX:可以在游戏里面用
无论下面的绘图设备和窗口实现如何发生变化,GUI控件的逻辑部分都跟这些实现严格分离,因此不会受到影响。而且大部分情况下,我们是不需要拥有一个跨绘图设备的皮肤库的,譬如说游戏和应用程序,外表总不能做成一样的。对于那些需要同时在DirectX和OpenGL上面运行的程序(譬如说3dsmax),它已经有DirectX和OpenGL的公共接口了,因此这些软件可以利用它们的公共接口来实现GUI的绘图设备部分,从而在上面构造起来的皮肤自然是可以跨DirectX和OpenGL的。
这比起一年前作的GUI实现又进了一大步。上一次的GUI尝试为不同的绘图设备抽象一套公共接口,后来惨死。不知道这次实际上做出来的效果如何,拭目以待吧。
posted on 2011-04-29 19:50
陈梓瀚(vczh) 阅读(5600)
评论(13) 编辑 收藏 引用 所属分类:
2D