让XNA显示中文
最近在研究XNA。XNA有一个(我们不太用得上的)招牌特性,就是它可以用于制作跨平台的游戏。这个跨平台允许你的游戏运行在Windows、Xbox360和Zune HD上。听起来是一个不错的主意,不过实现平台兼容性往往意味着要舍弃特定平台上的专属功能,比如我们今天要说的话题:字体。
虽然我们可以说在Windows平台上,XNA用DirectX实现,但是XNA没有使用任何DX中有关字体的功能(D3DFont),原因很简单,X360没有这玩意儿,Zune HD也没有。XNA内建的字体支持,是通过事先把文字渲染到贴图上来实现的。在开发阶段,你需要在你的游戏工程下的Content子工程里编写一个spritefont文件,指定使用的字体、字号以及要导入的字符范围等信息,大概如下所示:
<?xml version="1.0" encoding="utf-8"?> <XnaContent xmlns:Graphics="Microsoft.Xna.Framework.Content.Pipeline.Graphics"> <Asset Type="Graphics:FontDescription"> <FontName>Kootenay</FontName> <Size>14</Size> <Spacing>0</Spacing> <UseKerning>true</UseKerning> <Style>Regular</Style> <CharacterRegions> <CharacterRegion> <Start> </Start> <End>~</End> </CharacterRegion> </CharacterRegions> </Asset> </XnaContent>
这一段是XNA自动生成的代码,只需要在Content子工程中通过添加项对话框添加一个SpriteFont文件就可以了。这个文件导入了Kootenay字体的部分内容,从 到~。这是基本拉丁字符集的范围,显示英文是足够了。
这个spritefont会在工程编译之时被“编译”成一个xnb文件,也就是XNA资源的最常见格式——虽然我们很难看到里面究竟存的是什么东西。之后只需要将这个xnb加载为SpriteFont对象,就可以用它来写字了。给你的Game类增加一个私有成员:
private SpriteFont Font;
接下来在LoadContents方法中加上:
this.Font = Content.Load<SpriteFont>("SpriteFont1");
然后就可以在Draw方法里写字了:
spriteBatch.Begin(); spriteBatch.DrawString(this.Font, "Good day commander!", Vector2.Zero, Color.White); spriteBatch.End();
运行你的程序。看起来不错是吗?我们来试试把Good day commander!改成中文。我很确信你的程序一定会抛出一个ArgumentException异常,告诉你某某字符在这个SpriteFont中没有定义。很显然,因为我们之前在编写spritefont文件的时候,那个文件只定义了一些基本的拉丁字符。
好吧,我们来试试看,改一改spritefont文件。找到这么一段:
<CharacterRegion> <Start> </Start> <End>~</End> </CharacterRegion>
把它改成
<CharacterRegion> <Start> </Start> <End></End> </CharacterRegion>
在Unicode字符集中,编码为32到65536的字符都被囊括进来了,看起来很棒!按下F5,我们来看看效果……
我不知道你是不是足够有耐心,如果是的话,大概六个小时还是七个小时还是其他的一个什么时间之后,你的程序应该会带着一个好几十MB的xnb资源跑了起来。
XNA编译字体就是这么效率低下,而且那个巨大的资源文件一定会吃掉你大量的显存。虽然这样做给显示中文提供了可能,但这必然不是什么好办法,我们得另寻出路。
既然XNA给出的解决方案不能满足我们,我们能否求助于其他方案?
最先出现在脑海中的方法是使用GDI+。GDI+是绝大多数Windows用户界面的绘制引擎,显示几个汉字必然不在话下。但是我们不太好直接让GDI+向我们的游戏窗口写字,最靠谱的方式是用它把我们要写的文字画到一张贴图上,然后在游戏中渲染这张贴图。Clayman老师在博客上详细探讨了这种方案,有兴趣可以去看一看。
第二种方案是使用DirectX,假设我们可以拿到我们的游戏的DeviceContext,然后我们可以利用DX在上面写字……嗯,我没有试过,不过我觉得从裹得严严实实的XNA中拿到DeviceContext是一件很困难的事情。
以上两种方式,虽然都有一定的可行性,可是它们还是违背了XNA的初衷,做出了一个重大牺牲:无论是使用GDI+还是DX,我们的游戏都失去了跨平台的能力。不过如果你压根就不打算考虑这一点,那倒是很无所谓的了。
所以,如果要保留跨平台的特性,我们恐怕还是只有绕回原来的路子,毕竟如果要DIY从读取字体到渲染文字的全过程,工作量是非常大的。
让我们来明确一下目标。我们的目标是渲染中文文字,而且这一次,要具体到在不牺牲跨平台的特性下,以尽可能简便、尽可能高效的方式渲染中文文字。
无疑,XNA提供的方式是高效的,因为字型都被预先画到了贴图上,再需要渲染相应的文字时,无需再围绕着字体做更多的处理。但是把那么多字符全都放到一张贴图上,显然是极其低效的,这样不仅加载速度慢、要占用大量显存,还会让找字的过程变得复杂:每当要渲染一个字符时,都要从包含60,000多个字符的贴图中寻找特定的那一小块,那得多慢啊。不如我们来拆解一下渲染文字的整个过程:
- 加载庞大的字体贴图文件
- 获知要渲染一个字符串
- 对于这个字符串中的每一个字符,从字体贴图中寻找相应的贴图
- 在特定的位置画出这个字符的贴图
可以看出,性能的瓶颈在于第1步和第3步。对于第1步,我们必须想办法减小要加载的字体贴图文件的尺寸。减小字号是一个办法,字号越小,每个字符在贴图上占据的面积也就越小,贴图也就相应变小了。不过字号再小,贴图文件也逃脱不了几十MB的命运。那么,如果我们把这张大贴图打散成很多张小贴图,比如说每个字符一张贴图(这样就有60,000多张贴图了……),会怎么样?
这样问题也很明显。第一是小文件的读写性能肯定是要比大文件低的,每渲染一个文字就要读一个贴图文件,效率会相当低。第二,我们尚不知晓XNA的内部实现机制,不过一般来说,在渲染的过程中,切换贴图总是一个效率很低的操作。每渲染一个字符就要切换一张贴图,这也会产生新的性能瓶颈。如果多个文字处在同一张贴图上,渲染每个文字时,只需要切换纹理坐标到相应字符的位置,而不需要切换贴图,效率会得到很大提升。
所以我们的问题变成了在一张贴图和几万张贴图中间寻找一个平衡点。一方面,我们希望贴图的尺寸尽可能的小;另一方面,我们希望加载贴图和切换贴图的次数尽可能的少。
在Unicode字符集中,为汉字准备的位置有几万个。不过显然,这几万个汉字中,只有相当少的一部分会被经常用到,剩下的几乎只会在火星文中出现。那么,如果我们把常用汉字整理出来,然后再加上标点符号、拉丁字符之类的常用字符,放在一张贴图上,就可以在一个足够小的贴图上实现渲染多数字符的时候不需要切换贴图的效果了。这个主意看起来不错,所以我们马上发动Google,找到了一张汉字字频表。这张字频表来自北大CCL(汉语语言学研究中心),应该是比较权威的。它包括了九千余汉字的字频,应该是很够用了。我们就挑选这个表中的前3,000个汉字,定义为常用汉字吧(排在第3000位的“雍”字,在整个统计中的出现率已经低至万分之0.085了)。
怎么把它们编译进xnb文件?相信这个难不倒你。你当然不会傻兮兮地一个字一个字去查它们的Unicode编码,然后填到spritefont文件里啦。写一个程序,很快就可以搞定这个问题,这里就不赘述了。提示一下,在spritefont文件的<CharacterRegions>中,可以包含无数个<CharacterRegion>标签。
新的spritefont文件的尺寸有了显著的提升,由于有3,000多个字符,所以编译起来还是有点慢,不过编出来的xnb文件只有2MB(Droid Sans Fallback字体,14号),完全可以接受。用这个字体来渲染汉字,基本上是没有问题了。不过在字频表中3,000名开外的地方的字也还是蛮常见的,而且万一我们真的要渲染火星文怎么办?
我的解决方法是建立一个多层缓存结构。当需要渲染一个字符时,先从最常用的3,000个汉字里面找;找不到再从次常用的汉字里面找,还找不到就把整个Unicode字符集分块建立成若干个spritefont,逐个查找:
1级 |
最常用的字符 |
3000个常用汉字、标点符号、拉丁字符 |
2级 |
次常用的汉字 |
3000个次常用汉字 |
3级 |
不常用汉字 |
3000个不常用汉字 |
4级 |
拉丁字符全集 |
包括带有调号的拉丁字符集,支持法语、德语等 |
4级 |
CJK杂项字符 |
平假名、片假名、制表符等等中日韩字符元素 |
5级 |
汉字全集(5个文件) |
将U+4E00到U+9FFF中的所有汉字字符等分成5份。 |
6级 |
希腊和西里尔语字符 |
|
6级 |
其他 |
各种一辈子都用不上的字符 |
当然你可以根据你的需要调整这个结构。关于这个结构的优化,也有很多文章可做。我封装了一个CachedSpriteFont类,并对查找字符的过程做了充足的优化。比如说,我们要渲染一个很火星的字符,从1级查到4级都没有发现它的踪影,最后它出现在了5级中的最后一个文件。这样前前后后大概要遍历两三万个字符才能找到它,效率很是低下。我的解决办法是在整个缓存结构建立好之后,构建一张哈希表(SortedList<char, SpriteFont>),把应该使用哪个贴图来渲染某个特定字符的信息写进这张表里,之后只需要查表就好办了。不过,构建这张表也是比较缓慢的,在我这里大概需要8秒钟。所以我选择在构建好表之后把它序列化进一个磁盘文件中,下次只需要从这个文件中加载就可以了。
需要优化的地方还很多,不过这次我们可以完美地在XNA中显示中文了。如果完全使用我的方式,Droid Sans Fallback字体在14号下编译出的xnb贴图文件的总大小是18.5MB,不过我们几乎不会需要完全加载它们;而且经过充分的优化,渲染的速度是很快的,比起一个肆无忌惮地挥霍系统资源的游戏来说,完全可以忽略不计。