游戏跨平台UI组件设计与开发
前言
我们在开发过程中通常会使用公司提供的各种公共组件,当我们在“得心应手”地使用这些组件、或者抱怨这些组件设计不尽如人意时,是否曾想过这些组件是怎么实现的?又或者当我们认为自己可以设计得更好的时候,是否付诸行动来将其设计得更好?
当我们越来越依赖使用各种组件的时候,它可能已经开始限制我们的思维了。“不重复造轮子”似乎成为产品开发程序员的箴言,但作为公共组件的使用者,应该尽量了解其实现的细节。“我们反对重复造轮子,但保留制造轮子的能力”——但矛盾的是,如果没有亲自动手从头制作一次,往往是不能真正领悟到其实现每一个环节的原理和难度。所以程序员不应该过于纠结自己是不是在重复造轮子,或者说“不重复造轮子”不应该沦为程序员懒惰的借口。
言归正传,说说UI组件的那些事儿。
随着ScaleForm在MMOG中的普及,越来越多的游戏公司开始采用Flash作为游戏界面开发的首选,而传统的UI界面库有逐渐被边缘化的趋势。尽管如此,在我们公司正在研发以及已经发布的各款游戏中,仍然选择了不同的UI组件来进行开发,如《御龙在天》和《QQ仙侠传》都使用了研发中心提供的TPF组件,RGame等游戏采用了ScaleForm进行开发,也有更多的游戏使用的是自行开发的UI库。另外,还有部分游戏在开源CEGUI库的基础上进行修改并运用。
在移动开发领域,ScaleForm本身也是具有多平台支持的特性;另外,Cocos2d-x以其轻量、跨平台以及完善的辅助工具,越来越受移动游戏开发者的青睐。
尽管如此,传统UI以其轻量、易于自定义以及无需付费等优点仍然在游戏开发领域占有一席之地,尤其是移动游戏开发领域。本篇文章将就如何开发一款跨平台的UI组件进行介绍,出于本人水平限制,文章可能无法全部涉及UI组件开发的每一个细节点,涉猎深度可能也会有限,但是本文会尽量将UI组件开发所需要用到的方方面面技术都作一一介绍。并在本文的最后提供一款简易的跨平台UI组件库源码以供探讨。
概要
我们平常在MMOG中所见的游戏界面,实际上就是一些通过图形引擎(D3D或者OpenGL)绘制在游戏场景中的普通2D矩形元素(颜色块、纹理图片或文字),特殊的地方在于,这些元素需要响应用户的鼠标或者键盘输入。当这些元素具备“对用户的输入做不同响应”这一属性的时候,我们就称之为“控件”,比如对话框需要响应鼠标拖动的消息,按钮需要响应鼠标移入和点击的消息,而更复杂的如编辑框控件需要接收用户的键盘输入的字符。“绘制元素+消息响应”就构成了游戏中常见的UI控件。
在Windows系统下,这类控件与我们通过CreateWindow创建的控件不同,游戏中自绘的控件没有自己的窗口句柄,当然也没有自己的消息循环,他们必须要依附于一个现有的系统窗口。
文章将从以下各个方面对UI组件开发相关技术进行介绍:
1. 界面布局配置方式与控件树
2. 多语言支持与字符编码概要
3. 控件的创建——介绍一种模版实现的泛型对象工厂
4. 控件树及控件如何来管理
5. UI的绘制——抽象绘制接口的设计、OpenGL和D3D中2D坐标系与屏幕像素坐标的映射、区域裁剪和绘制效率的问题等
6. 图片绘制模式——介绍三宫格与九宫格在图形绘制中的实现细节
7. 消息处理与控件事件脚本逻辑——用户输入消息的处理,以及控件事件处理脚本的使用、脚本与C++的相互调用
8. 中文输入与绘制——不同的系统下中文如何输入、FreeType在文本绘制中的实现
9. 重点控件的实现概要——介绍UI开发中最复杂的编辑框、滚动条、富文本控件的实现要点
10. 分辨率自适应与控件锚点初探
11. 跨平台的支持
12. 关于UI编辑器的那些事
设计与开发
一、界面布局的使用
如同Windows中使用rc资源文件、Android中使用XML格式的Layout文件来描述控件的属性一样,通常在游戏UI中也采用配置文件的方式来描述界面上的控件信息。
通过编辑布局文件,指定界面上需要摆放的控件的位置、大小、是否可见等信息,然后通过UI组件提供的接口加载这些布局文件,从中解析出控件信息,如控件类型、位置大小等属性,然后创建出对应的控件对象。
通常情况下,使用最多的布局文件格式是采用XML,另外也可以采用Lua、json、Python等脚本语言来描述。幻想世界采用的就是lua脚本来进行布局描述。
下面是分别是使用XML和lua脚本进行控件布局描述的示例:
图1. 界面布局实例
从图1可以看出,不管使用哪种脚本来进行描述,一般都是采用树形结构来组织游戏中的不同控件——游戏场景中总是只有一个根控件,根控件下有一些子控件,子控件下也可以再有子控件。以树形结构来组织控件结构清晰、便于管理,几乎是所有UI组件的选择。
另外,上面的示例也可以看出Lua布局与XML布局的差异:
1. 用Lua来描述控件简洁明了,而且控件响应事件处理函数的内容也可以直接写入控件属性描述段内,可以清楚地理解逻辑——如示例中的OnClick处理函数;而XML格式的布局相对于lua布局明显较为冗余,而且事件处理函数也只能与布局文件分离,需要以单独的文件来描述。
2. 但是使用类似lua之类的脚本语言来进行布局描述有一个致命的缺陷:难以支持可视化编辑器,当脚本加载到Lua State中后,其中的注释和函数无法再序列化为文本;而XML则不存在这个问题,利用现有开源xml解析库,均可以保证反序列化和序列化后与原文本一致。
通常采用XML来作为布局文件是较为通用的做法。为了解决使用XML时“事件处理函数与布局文件分离”的问题,可以参照Adobe Flex中mxml布局的做法:将脚本处理函数以“CDATA”节点的方式写入布局文件中。
二、多语言支持与字符编码
多语言支持是客户端开发中总是无法回避的话题。我们当然希望自己提供的组件能够在不同的语言环境下都能正确显示,所以无论源代码还是配置文件都应该避免使用本地字符集。
采用UTF-8编码是通常的做法。Android系统内核支持的是UTF-8编码,而Windows NT则采用的是UCS2编码(Little Endian),不过UCS2与UTF-8之间具有一一对应关系,可以很方便地相互转换而不依赖于代码页。
因此,我们选择采用UTF-8编码,同时需要遵循如下几个准则:
1. 程序源代码尽量采用UTF-8编码。但是由于编译器限制,可能编译器并不支持直接新建UTF-8格式的文件,这样的话,可以退而求其次,源代码采用本地字符集,但是应该避免在源代码中硬编码中文字符——即使源码采用UTF-8编码,在源码中硬编码中文字符也是不好的习惯;
2. UI组件提供的外部接口只接收UTF-8编码的字符串。为了避免编码之间的来回转换,控件对象中用到的字符串在内存中也应该尽量采用UTF-8编码格式来存储;
3. 各种配置文件均采用UTF-8编码。UI组件中最重要的配置文件就是布局文件了,布局文件中总是会出现一些字符:如Label标签控件的内容、Button按钮控件的Caption等等,这些字符可以是不同语言,可能是中文,也可能是日文等。为了使我们的布局支持不同的语言,除了需要把布局文件保存为UTF-8格式外,如果使用XML作为布局,还需要在XML头指定编码方式:
1. <?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
尽管如此,我们在具体代码的实现时仍难免会遇到字符集之间的转换问题。比如,Windows系统下,以RegisterClassA注册创建的窗口在接收外部输入法输入的中文时,将以DBMS方式传给消息处理函数,而以RegisterClassW创建的窗口,收到的字符消息则是以UCS-2方式的Unicode字符出现。UI组件在处理用户输入的时候需要将不同编码的字符转换为组件内部使用的统一编码。
另外,如果我们的UI组件在绘制文本时使用了FreeType库,FreeType在获取字模时需要接受UCS-2字符。
在字符编码之间在转换方面,推荐使用开源iconv库。iconv是一款支持不同编码字符之间互转的跨平台开源库,比如使用下面的iconv接口可以将UTF-8编码转换为Windows支持的宽字符串:
1. iconv_t cd = iconv_open("UCS-2LE", "UTF-8");
2. iconv(cd, &in, &src_len, &out, &dst_len);
三、控件的创建与泛型对象工厂
通过解析包含控件信息的xml布局文件后,我们获取了控件的各种信息,接下来就需要根据这些信息来创建控件对象了。从图1的布局例子我们可以看到,每个控件都有一个type属性,指定了当前控件的类型:是一个对话框Dialog还是一个按钮Button,我们需要根据type的值来决定,是new一个Dialog对象还是一个Button对象。
假如我们所有控件都有一个抽象父类IWidget,Button和Dialog是他的两个派生类。我们能想到最简单的办法是这样做:
1. IWidget *CreateControl(std::string type)
2. {
3. if(type == "Dialog")
4. return new Dialog;
5. else if(type == "Button")
6. return new Button;
7. else
8. return NULL;
9. }
但是,这种设计与面向对象设计的思想不符,而且扩展性不好:如果需要新增一个的控件,则需要新增一个else if分支。创建控件的代码一般位于组件的核心,我们不希望核心代码被频繁改动。
《C++设计新思维(Modern C++ Design)》第8章介绍了一种GoF23种设计模式之外的模式,即“泛型对象工厂”,下面将以最简单的方式将这种模式运用到UI控件的创建中。
1. typedef IWidget* (*CREATORFUNC) ();
2. std::map<td::string, CREATORFUNC> g_mapCreator;
3. template<typename T>
4. class ObjectImpl
5. {
6. public:
7. ObjectImpl(std::string type)
8. {
9. g_mapCreator.insert(std::make_pair(type, (CREATORFUNC)CreateObj))
10. }
11. ~ObjectImpl(){}
12. static T * CreateObj()
13. {
14. return new T;
15. }
16. };
17. #define REGISTER_CLASS(type) static ObjectImpl<type> type##_Obj(#type);
我们仍然假定所有的UI控件都继承自IWidget这个抽象类,上面的代码首先定义了一个全局map来保存“类型名称-创建函数指针”的映射,只要我们知道了控件的类型名称,就可以找到这个类型对应的创建函数,调用这个创建函数就可以得到我们所要的控件对象:
1. IWidget *CreateControl(std::string type)
2. {
3. map<string, CREATORFUNC>::iterator it = g_mapCreator.find(ID);
4. if(it != g_mapCreator.end())
5. {
6. CREATORFUNC pFunc = it->second;
7. return pFunc();
8. }
9. return NULL;
10. }
但是g_mapCreator中的内容是什么时候建立的呢?——ObjectImpl模板类完成了这个工作。当一个新的控件类作为ObjectImpl的模版参数时,其构造函数会自动将该控件类的创建地址注册到g_mapCreator中:
1. ObjectImpl<Dialog> Dialog_Obj("Dialog");
为了方便使用,我们用一个宏定义来代替他:
1. #define REGISTER_CLASS(type) static ObjectImpl<type> type##_Obj(#type);
如果要新增一个Dialog控件类,只需要在Dialog.cpp文件中加入宏定义REGISTER_CLASS(Dialog),当exe(或DLL)一加载,由于该宏定义了一个ObjectImpl<Dialog>类型的全局静态对象Dialog_Obj,会自动执行其构造函数,并在构造函数中进行“工厂注册”。
采用这种“对象工厂”,当需要新增一个控件时,只需要将控件的CPP添加到项目工程,而不用修改组件原有的其他任何代码。——当然这严格来说不能称之为“工厂”,因为看不到Factory类。
这种方式的“对象工厂”足够简单,但是上面的代码忽略了一个重要的问题:Dialog_Obj对象和g_mapCreator对象都是全局对象,且应该是位于不同的cpp中,我们不能保证g_mapCreator的初始化一定比Dialog_Obj早(这取决于编译器),这样就会导致:当Dialog_Obj对象在执行其构造函数的时候,g_mapCreator对象可能还没有被初始化,这一定会导致程序异常。
为了解决这个问题,《C++设计新思维(Modern C++ Design)》中采用了单件来对g_mapCreator进行了封装,保证在使用g_mapCreator时都能正确获取到。当然我们也可以用最简单的方法来做:
1. map<string, CREATORFUNC>* GetClassDeposit()
2. {
3. static map<string, CREATORFUNC> mapCompCreator;
4. return &mapCompCreator;
5. }
将之前代码中的所有g_mapCreator都替换为GetClassDeposit()就可以了。用这种最“屌丝”的方式,实现了与单件相同的功能,而不用进行复杂的面向对象封装。
四、控件树及控件管理模块
“控件管理模块”是UI组件中的核心管理模块,是对所有控件操作的入口。前面提到,UI组件中的各个控件是以树形结构来组织的,“控件管理模块”除了对该控件树进行管理以外,还需要负责解析布局文件、创建控件和生成控件树、管理“脚本虚拟机”和加载脚本文件等。
在游戏界面上,控件与控件之间具有层层包含关系,这种关系用树结构来组织是最合适不过了,除了可以让界面上各个的结构看来来更加清晰以外,还可以提高管理效率:对一批控件的操作往往可以通过直接操作他们公共的父控件就可以了。
“树”是数据结构中一种非常重要的非线性数据类型,但是树的类型却是五花八门,不同类型的树在代码实现方式上可能相差很大,而且树的遍历方式不同,在代码实现上的侧重点也有差异,这也许就是STL一类的常见标准模版库不对外提供“树容器”的原因吧。
UI组件中的树结构设计上和普通的树结构没有多大差异。为了满足在UI上的使用,我们创建的树结构至少应该提供如下功能:
1. 可以通过指定任意父节点来插入一个新的叶子节点
2. 根据节点ID能快速检索到该节点包含的数据
3. 能从其中一个节点快速获取其父节点、第一个子节点、第一个兄弟节点,以保证控件树能够在最大的效率下被遍历
4. 任意节点的删除功能,删除节点的同时删除其所有子节点
“控件管理模块”类加载布局文件后,通过对布局文件的解析,将获取的控件对象插入到控件树上。此后对所有控件的删除、移动、绘制等操作都在该控件树上进行。
此外,“控件管理模块”还具备管理“脚本虚拟机”的功能。“脚本虚拟机”提供了脚本的解析执行功能、C++执行脚本函数的功能,以及脚本回调C++的功能。关于脚本部分的实现细节,将在后面的部分做详细介绍。
五、UI的绘制
为了使我们的组件支持多个平台,那么绘制部分支持OpenGL自然是必不可少的。在移动平台下的OpenGL ES可以看作是OpenGL的“精简版本”,所以在Windows平台下使用OpenGL时,也应该尽量采用兼容OpenGL ES的API;另外,由于在windows平台下大多数游戏引擎都采用了Direct3D作为绘制底层,因此,UI组件在Windows平台下也应该支持D3D绘制。
绘制接口的抽象
一个完善的UI组件应该具有灵活的扩展性,除了体现在“控件类型易于扩展”以外,最重要的就是支持不同绘制引擎的扩展了。因此,需要对绘制接口进行抽象——UI组件的逻辑层不关心具体的绘制实现方式,而绘制层可以针对不同的图形引擎来具体实现。
对于UI组件来讲,需要对绘制层进行抽象封装的对象有:1. 设备、2. 纹理、3.字体。设备对象的封装提供常用的基本元素的抽象绘制接口;纹理对象的封装提供加载纹理、获取纹理句柄的抽象接口;而UI字体如果采用FreeType来绘制的话,则可以归结为纹理对象的封装。
2D坐标系的映射
一般操作系统在处理用户输入时,都将窗口左上角作为坐标系的原点,向右、向下方向分别作为X轴和Y轴的正向,这与OpenGL或者D3D的常用坐标系并不一致。为了避免逻辑处理上过于复杂,在处理用户输入和绘制时应该采用相同的坐标系。例如我们可以将图形绘制引擎的坐标系原点映射到窗口左上角。
在D3D中,提供了现有的D3D_XYZRHW格式的顶点来绘制2D图元,而OpenGL则需要自己来转换坐标系,转换的方法多种多样。通过如下方式设置投影矩阵,可以将绘制坐标系的原点映射到窗口左上角:
1. glMatrixMode(GL_PROJECTION);
2. glPushMatrix();
3. glLoadIdentity();
4. glOrthof(0, w, h, 0, 1 ,-1);
UI绘制结束后需要将投影矩阵恢复为游戏场景原来的矩阵:
1. glMatrixMode(GL_PROJECTION);
2. glPopMatrix();
UI的区域裁剪问题
在以“树形结构”组织的UI系统中,控件与控件之间存在有“父子”或“兄弟”关系,通常我们不希望子控件显示到父控件区域以外,子控件超出父控件区域应该被裁剪掉(特殊情况如具有popup属性的对话框控件除外);另外,控件中的内容也不应该超出控件区域,如文本信息。另外,裁剪掉不必要的区域还可以提高绘制效率。
有多种方式可以实现2D绘制下的区域裁剪,比如可以使用“裁剪测试”或定义“裁剪平面(ClipPlane)”,甚至可以直接用“重置视口”的方法来进行裁剪。
OpenGL下使用“裁剪测试”进行裁剪的方法(rcClip为裁剪矩形区域):
1. glEnable(GL_SCISSOR_TEST);
2. int viewport[4];
3. glGetIntegerv(GL_VIEWPORT, viewport);
4. glScissor(rcClip.x, viewport[3]-rcClip.y-rcClip.height, rcClip.width, rcClip.height);
使用“重置视口”的方式来进行裁剪的D3D代码如下:
1. D3DVIEWPORT9 viewPortNew;
2. viewPortNew.X = rcClip.x;
3. viewPortNew.Y = rcClip.y;
4. viewPortNew.Width = rcClip.width;
5. viewPortNew.Height = rcClip.height;
6. viewPortNew.MinZ = -1;
7. viewPortNew.MaxZ = 1;
8. m_pD3DDevice->SetViewport(&viewPortNew);
视口以外的绘制内容将会被裁剪掉。
D3D9中的0.5像素偏移问题
在D3D9中进行2D绘制时,如果将一个纹理贴图到一个矩形区域时,会发生图片模糊的现象,这就是D3D9文档中提到的纹理扭曲现象(texture distortion)。在计算纹理坐标时,需要偏移0.5个像素。
据说在D3D10中已经解决这了这一问题。下面两张图是做了0.5像素偏移和没有做0.5像素偏移的效果对比,可以看出效果上的明显差异。如果采用了FreeType提取字模生成的纹理贴图来绘制文字,有偏移与无偏移的效果差异会更加明显。
无0.5像素偏移 有0.5像素偏移
UI绘制效率问题
游戏开发者都希望UI组件在一帧中的绘制时间越少越好,这样可以预留更多的时间给游戏的逻辑,以下是对保证绘制效率方面一些简单的建议:
1. 减少顶点提交的次数,相同属性的顶点尽量同一批次提交,尽量一次画尽可能多的多边形;
2. 减少绘制过程中的参数切换,如纹理状态、混合模式等。由于UI绘制通常用到的绘制参数与游戏的可能不同,所以一般在绘制UI前设置好参数,绘制完后恢复,绘制过程中尽量不对参数进行修改;
3. 尽量减少纹理的切换,同一个界面下面的界面图片尽可能放在同一张纹理;
4. 能使用Alpha测试的情况下使用Alpha测试,而不使用Alpha混合;
5. 尽可能的把绘制操作和其他操作并行执行,性能也可以得到显著提高;
6. 文字要批量提交绘制,并且游戏中的字体字号尽量统一。
六、图片绘制模式
同一个游戏中,相同类型的控件通常都具有相同的风格,只是在大小规格上不同,如果针对不同规格而风格相同的控件都分别制作一张图片,显然会造成资源的极大浪费。所以,对于相同风格的控件,美术只需要制作一份资源,当大小不同时,由程序来自动处理。
最简单的处理当然是直接拉伸,但是普通的拉伸会造成视觉上的损失,当图片不规则且原始尺寸与控件大小差异很大时,拉伸会造成明显的锯齿。UI组件中流行的做法是采用三宫格或九宫格来处理,采用三宫格或九宫格方式来对控件进行贴图后,即使随意放大缩小控件,也可以保证纹理的边缘效果与原始图片一致,同时最大程度上节省了图片资源。
“九宫格拉伸”不同于普通的拉伸,这种拉伸是通过重新计算纹理坐标的方式,将原始图片“切割”成四份,每一份分别贴图到目标区域的四个角,而目标区域的其余空白部分则利用边缘像素进行填充:
从上图可见,使用九宫格后,控件大小可以任意缩放而不会影响贴图效果,这在游戏切换分辨率时UI界面的整体缩放应用中非常方便。
三宫格分为水平三宫格和竖直三宫格,相对就比较简单了,分别是在水平方向上切割和在竖直方向上切割。原理与九宫格一致。
由于三宫格和九宫格由于使用的是相同纹理,而且裁剪区域也相同,所以绘制时可以批量绘制,以减少顶点绘制的提交次数,从而保证绘制效率基本不受影响。
七、消息处理与事件脚本逻辑
UI组件中大多数控件都需要响应用户输入操作。比如Button控件需要响应鼠标点击消息、Dialog控件可能会响应鼠标按下拖动消息、Edit控件要获取按键消息等。
普通的消息处理流程如下:
一般的UI组件都会支持脚本系统——将控件的事件逻辑交由脚本来处理。更健壮的UI系统最好能同时支持C++回调注册,那么在触发脚本事件的同时还需要执行“回调注册函数”。
开发一套支持脚本系统的UI组件必然会有这两个方面的需求:1. C++调用脚本;2. 脚本调用到C++。而几乎所有的脚本系统都提供了这种双向调用的实现方案,如lua、python、java等等。
Lua脚本小巧、使用方便且效率高,是目前大多数游戏的首选。
Lua与C++的相互调用
当控件事件发生后,我们需要将事件执行到lua脚本中去,由用户在脚本中提供的逻辑来处理UI事件,这时候就需要在C++中调用脚本函数。关于如何通过C++来调用lua函数,《lua程序设计》一书25.4节中进行了详细的介绍,提供了lua函数调用、给lua函数传递参数、lua函数返回值的获取等具体实现方式,这里不做详细介绍。
当需要在lua脚本中调用控件接口的时候,tolua++为lua提供了提供了面向对象的特性,可以在lua脚本中调用控件对象的接口。tolua++的使用非常方便——引入tolua++后,需要编写pkg文件,该文本文件指定了哪些控件类是需要在lua中使用的类:
1. $cfile "IDialog.h"
同时需要将头文件加一些注释如下(注释部分):
1. #ifndef IDALOG_H
2. #define IDALOG_H
3. //tolua_begin
4. class IDialog
5. {
6. public:
7. virtual int GetID() = 0;
8. virtual void Show(bool bShow) = 0;
9. };
10. //tolua_end
11. #endif
然后通过tolua++提供的工具将pkg文件转换出c++代码文件,并添加到组件工程中。
一个成熟的UI组件系统一旦引入到游戏中,应该能做到在不修改任何C++代码的情况下,通过修改布局脚本完成界面设计、通过事件脚本完成所有的UI相关的业务逻辑。
Lua脚本调试
虽然游戏中引入Lua后,适应了频繁变化的游戏开发需求,但是也为程序的调试带来了麻烦,脚本中加入调试信息是最原始的解决办法。Decoda为Lua提供了调试方案,但是与VS的强大调试功能比起来还是差得很远。而且Decoda还有一个缺陷是,只有当脚本解析模块位于exe中,或者是通过静态链接的方式链接到exe的时候,Decoda的调试功能才能使用,如果脚本模块DLL是通过LoadLibrary的方式加载起来的,是无法调试的。
八、中文输入与绘制
文字处理一直以来都是UI组件开发中的重头戏,尤其是在处理中文字符(或其他语言)时。
中文输入
在Windows系统下,在一个窗口中进行字符输入时,窗口会收到WM_CHAR消息;但当输入的如果是中文字符,窗口收到的WM_CHAR消息情况有所不同(见“多语言支持与字符编码”一节):如果窗口是Unicode窗口,输入一个中文字符将给窗口发送一个WM_CHAR消息,中文字符的Unicode字符将以wParam随消息进入窗口消息队列;如果窗口是多字节窗口,输入的中文字符将会以多字节方式存放在wParam中,并以WM_IME_CHAR消息进入窗口的消息队列,缺省消息处理函数将把它拆分为两个字节,前后发送两个WM_CHAR消息放入消息队列,代表的字符取决于当前的代码页。
所以,UI组件在处理用户输入的中文字符时,如果收到的是两个WM_CHAR的多字节字符,处理起来就略微复杂一点:到一个WM_CHAR消息到达时,通过IsDBCSLeadByte来判断当前收到的字符是否是一个中文字符的第一个字节,如果是则等待下一个字符消息到达后再合并成一个完整中文字符,然后交给UI组件处理;否则按照英文字符直接交给UI组件。
中文绘制
D3DX中提供了ID3DXFont对象来绘制文字,在DX8以前这种绘制方式效率极为低下,所以在实际游戏中并不会使用。通常使用的文字绘制方式有两种:“贴图文字”和“FreeType反走样文字”。
“贴图文字”是将我们所要用到的文字预先生成在一张较大的图片上,使用的时候将其读取为一张纹理,记录下每个文字在纹理上的坐标,需要显示文字的时候以纹理贴图的方式来显示文字。由于中文GB2312包含了7000多个汉字和字符,这张图片通常会很大,但是80%的情况下我们仅仅使用常用的几百个汉字,所以大部分文字几乎不会被用到,造成了极大的浪费;另外,使用“贴图文字”方式来绘制文字,如果要支持多种不同的字体或字号,就得针对每种字体和字号都来制作这样一张大图片。
通过FreeType库来绘制文字,可以渲染反走样文字,文字边缘通过灰度的渐变来使得边缘更加平滑,这样游戏中的文字看起来更加漂亮。引入一份ttf文件,可以绘制出不同大小的文字。另外,还可以方便地渲染出描边、投影、加粗等文字特效。
Nehe教程Lession43介绍了这种绘制文字的方法:通过FreeType提取当前文字的字模位图信息,将该位图生成为一张纹理,渲染时创建一块与位图相同大小的绘制区域,并把位图纹理贴上去。使用FreeType提取某个文字的位图信息的方法是:
1. FT_Load_Char(m_FT_Face, ch,FT_LOAD_RENDER|FT_LOAD_FORCE_AUTOHINT|FT_LOAD_TARGET_NORMAL);
2. {
3. LITCHI_ASSERT(false && "FT_Load_Char Fail!");
4. }
5. FT_Bitmap& bitmap=m_FT_Face->glyph->bitmap;
6. for(int j=0; j < bitmap.rows ; j++)
7. {
8. for(int i=0; i < bitmap.width; i++)
9. {
10. if (FT_PIXEL_MODE_MONO == m_FT_Face->glyph->bitmap.pixel_mode)
11. {
12. unsigned char *pBuffer = m_FT_Face->glyph->bitmap.buffer
13. + (j*m_FT_Face->glyph->bitmap.pitch);
14. pBuf[(4*i + j * bitmap.width * 4) ] = 0xff;
15. pBuf[(4*i + j * bitmap.width * 4)+1] = 0xff;
16. pBuf[(4*i + j * bitmap.width * 4)+2] = 0xff;
17. pBuf[(4*i + j * bitmap.width * 4)+3] = pBuffer[i/8] & (0x80 >> (i&7)) ? 255 : 0;
18. }
19. else if(FT_PIXEL_MODE_GRAY == m_FT_Face->glyph->bitmap.pixel_mode)
20. {
21. unsigned char _vl = (i>=bitmap.width || j>=bitmap.rows) ?
22. 0 : bitmap.buffer[i + m_FT_Face->glyph->bitmap.pitch *j];
23. pBuf[(4*i + j * bitmap.width * 4) ] = 0xff;
24. pBuf[(4*i + j * bitmap.width * 4)+1] = 0xff;
25. pBuf[(4*i + j * bitmap.width * 4)+2] = 0xff;
26. pBuf[(4*i + j * bitmap.width * 4)+3] = _vl;
27. }
28. }
29. }
如上代码,在使用FreeType获取字模时需要注意,如果获取到的是点阵字体,需要按照二值位图的方式来取得字模信息,否则需要按照灰度图方式来获取。
是否针对每一个字符都需要创建一个纹理来保存其字模信息呢?显然,这样不断调用创建纹理的API会导致效率低下,而且文字绘制量大时,绘制时的频繁纹理切换也使绘制效率无法提高。一个优化方法是:我们可以只创建一块单字符大小的纹理,当需要绘制新的字符时,再将这个纹理修改为新字符的字模(OpenGL提供了glTexSubImage方法,D3D提供了对纹理表面进行修改的D3DXLoadSurfaceFromMemory函数)——通常修改现有纹理要比新创建一个纹理效率要高。
但这种方法并不能解决绘制大量文字时的纹理切换问题。下面就介绍一种FreeType在实际应用中可能更好的策略:
1. 针对不同字体创建一块较大的存放字模信息的内存纹理,比如512×512;
2. 当用到一个新的字符时,将字符的字模信息填充到纹理的空余区域,并记录下该文字在纹理中的坐标;
3. 如果用到的字符已经在纹理中,则根据坐标信息计算该字符的纹理坐标得到纹理;
4. 如果这张纹理已经没有空余区域,则用新字符将使用频率最小的字符替换掉。
在实际使用中如何计算“使用频率最小的字符”需要设计较为复杂的LRU逻辑,实现起来并不容易,所以一种简化的方法是:当遇到纹理中不存在的字符时,直接替换最后一个字符的字模信息——通常情况下最后才用到的字符基本上可以认为是使用频率较小的字符。
以下是将本文“前言”和“概要”内容(约1600个字符),按此方案生成的内存纹理:
由于使用这类方法需要针对不同字体、字号预创建一块内存纹理,所以当游戏中用到的字体类型较多时,可能会造成资源的浪费。这就需要对“字模纹理”的大小进行调整:对于使用较多的字体,如聊天字体等,可以创建512×512的较大字模纹理;对于较少使用的字体,创建的纹理可以较小,甚至可以只有一个字符的大小。
当然,在同一款游戏中,我们应该尽量采用统一的字体,还应该尽量保持文字大小的统一,这不仅可以保证美术风格的统一,也可以保证绘制的效率。
九、实现控件逻辑
设计出了UI组件的框架,并解决了上面的问题,余下的就只剩下控件逻辑开发了。普通的控件逻辑开发都非常简单,但几个重点控件除外:编辑框Edit控件、滚动条ScrollBar控件和富文本RichEdit控件。
Edit控件:Edit控件在游戏UI中被广泛使用,比如聊天输入,帐号输入等。与普通控件比较具有一些特殊的功能:支持文字选中背景、获取焦点后应该有编辑态的光标、左右按键时光标应该相应往左右移动一个字符,还要支持在当前光标位置插入删除字符。可编辑的Edit控件可以认为是Windows下一套UI组件中最复杂的控件。
Scrollbar控件:滚动条一般与其他控件绑定使用,当被绑定控件内容发生变化时,滚动条按钮需要发生相应变化;当滚动条按钮被拖动时,被绑定控件偏移需要对应移动。需要注意的是,移动平台下滚动条的操作与PC平台下滚动条使用有较大差异:PC上我们一般通过操作滚动条来控制内容的移动,而移动平台上用户通常只通过触屏拖动内容来带动滚动的变化。
RichEdit控件:富文本控件在游戏中也经常被用到,如聊天内容、Tips等。通常可以自定义一套简易的富文本标签,指定控件中所显示的字体的颜色、字体大小、图片内容等。
UI组件的框架一旦搭建好以后,控件的实现就非常简单了,多是一些上层逻辑开发,并没有复杂的知识点,所以本文就不做详细的介绍。
十、分辨率自适应与控件锚点
Windows游戏窗口通常需要支持不同的分辨率,移动平台下不同的移动设备也会有不同的屏幕大小。当游戏分辨率发生改变时,UI上的控件摆放方式及大小也要做对应的调整。UI中一般处理分辨率变化的方式有:
1. 基于绘制的缩放:当分辨率改变时,计下界面的缩放比率,在绘制UI元素时将所有元素做等比例的缩放。这样会导致控件的实际大小和控件中保存的大小属性不一致,所以需要在UI组件的消息入口处将鼠标消息做相应的“缩放”。需要注意的是,一般不会对文字绘制进行缩放,文字的缩放通常会导致字体模糊,当分辨率变化不大时采用相同的字体,变化较大时需要重置字号。
2. 基于控件的缩放:当父控件大小发生变化时,同时将自己的大小做等比例缩放。如果控件采用了九宫格的方式绘制,即使任意缩放也能达到很好的效果。
3. 设置控件对齐属性:Delphi和C++Builder等UI组件,以及android:RelativeLayout都采用了Align属性来指定控件与其父控件之间的关系,如靠下、靠右、填满等,父控件大小发生变化时,子控件保持与其父控件的对齐关系。
4. 控件锚点(Anchor):当以上策略都无法满足要求的时候,还可以采用控件锚点的方式——选择界面上任意一个点(这个点可以在整个控件树的任意控件上),指定本控件与锚点之间的关系,在分辨率改变时总是保持该锚点关系不变。
实际应用中常常会几种方式结合使用。
十一、跨平台的支持
跨平台支持的设计在前面的内容已经有所阐述,本节做一下简单总结。UI组件中需要进行跨平台支持处理的部分主要涉及如下几个方面:
1. 外部消息的处理
2. 底层绘制部分的跨平台支持
3. 文件I/O部分
4. 特殊控件操作上的差异
UI中的外部消息主要是鼠标消息,此外,Windows平台下还需要处理按键消息、移动平台下可能还需要支持“多点触摸”。
绘制部分由于采用了OpenGL ES来进行绘制,所以在Windows平台和Android平台上差异不大。
在Windows下的游戏,通常把游戏中用到的资源打包到虚拟文件系统,虚拟文件包放在磁盘上,游戏从虚拟文件系统读取文件;而Android的资源一般存在于APK包中(ZIP格式),游戏运行过程中需要从apk格式文件中获取文件,当然也可以将资源文件打包到虚拟文件系统,再将虚拟文件包打包到apk文件中,这样可以提高资源的安全性。
某些特殊控件在不同平台下操作控件习惯有所不同:例如,Windows下用户习惯拖拽ScrollBar控件的滚动条按钮,以滚动条的滑动带动内容的移动,而在移动平台下用户往往更习惯于直接拖拽ScrollBar中的内容,以内容带动滚动条的滑动。
十二、关于编辑器的那些事
有了完整的UI组件SDK并不等于组件的开发已经大功告成,UI编辑器对于实际游戏的开发非常重要,编辑器是否功能强大决定了UI组件的易用性。通常认为,UI编辑器的开发工作量应该至少占据整个UI组件开发工作量的50%以上。