仅供个人学习使用,请勿转载,勿用于任何商业用途。
为了平台兼容性,XNA并没有类似D3DFont的函数。XNA2.0增加了对bitmap font的支持,把需要显示的字体预处理到纹理上,作为sprite来显示。对印欧语系来说,文字只由少量字母组成, SpriteFont完全能满足需要。但对于中文,日语这样有上万字符的文字来说,显然就无法处理了,因为生成这些字符可能需要几十m的纹理。当然,如果你只需要1,2千字符显示固定的任务和菜单信息,SpriteFont还是可以胜任的。但如果程序允许用户输入,比如一个聊天系统,那么所显示的字符是无法预知,我们需要一些其他方法来显示字体。
通常为了增加功能,就需要牺牲通用性,这里的解决方案只能用于pc上的windows系统,我们将使用GDI+来辅助渲染字体。方法很简单,仍然基于bitmap font原理,只不过我们把需要的字符在程序运行时动态渲染到纹理上,最后的目标是得到一个类似:DrawString(text, font, x, y, color) 的方法。
最简单的方法是对于每一个需要绘制的字符串,我们先用GDI+把它绘制到一张透明纹理上,然后在把这张纹理渲染到XNA界面。可惜这个方法完全没有可操作性,与XNA相比,GDI+是非常非常缓慢的,以至少30fps来绘图几乎是不可能的,会极大降低程序性能。那应该怎么办呢?可以考虑把需要显示的字符”缓存”到一张纹理上,缓存之后,再绘制这个字符就不需要调用GDI+,也不需要纹理拷贝,以此提高渲染速度。
举个例子,比如我们要渲染“电子游戏如同戏剧、电影一样,也是一种综合艺术,并且是更高层次的综合艺术”这段文字,首先我们找出其中非重复的字符,注意,是非重复字符,以便进一步节约储存空间,然后用GDI+把各字符渲染到一张bitmap上,记录下每个字符在位图中的区域,然后把这样bitmap拷贝为一张texture2D纹理,接下来就可以把文字当作sprite来渲染了。这样,当显示这段文字时,只有第一帧需要使用GDI+,随后的帧里就像渲染普通2D sprite对象一样快。考虑到一般游戏都以30~60fps的速度渲,相对于第一种方法,是很大的进步。听起来很简单是把,那么看看如何来实现吧。下面并没有写出完全的代码,只是包含了重要步骤的概述和一些可能会遇到问题和需要注意的地方。
(运行时生成的字符缓存纹理)
XNAFont
首先需要Font类,这个类保存了要绘制的字体信息,包括字体族,字体大小,粗体,斜体和是否有下划线。基本相当于GDI+ Font类的一个wrapper。不直接使用Font原因是在可预见的将来,我们也许可能从GDI+迁移到WPF。为了区别,把自定义的Font类称作XNAFont,这里是我自己的XNAFont定义:
XNAFont是一个immutable类,所有成员都是只读的。后面将用XNAFont作为key来查询字符位置,因此我不希望XNAFont是可变的。此外同一字体,不同属性的XNAFont我们也认为是不同的,比如“16号宋体”和“24号宋体”的字形是不同的,所以理应分开。我们用一个Dictionary来记录同一字体下的已缓存字符,称作一个字体族:
Dictionary<char, Rectangle> charFamily
再用一个Dictionary来记录不同字体族的字体:
Dictionary<XNAFont, Dictionary<char, Rectangle>> charFamilies;
另外提一点,当实现XNAFont的GetHashCode方法时,本想直接使用Font类的GetHashCode,结果返回的值完全出乎意料:
new Font1("宋体", 18, FontStyle.Regular, GraphicsUnit.Pixel);
new Font2("微软雅黑", 18, FontStyle.Regular, GraphicsUnit.Pixel);
new Font3("Times New Roman", 16, FontStyle.Regular, GraphicsUnit.Pixel);
面的3个Font对象都将有相同的HashCode,bug?bug!
纹理大小
用几张和多大的纹理来储存缓存的字体呢?Wow中使用的是多张256×256大小的纹理,传说DX 8.0以后的D3DFont也是这样做的。我们当然也可以这样做,这意味着维护一个纹理链,除了在Dictionary中记录每个字体的位置外,还需要记录字体储存在哪张纹理,最后用sprite绘制时还需要对字符按照纹理排序,以减少batch的数量,获得最佳性能。听起来有些复杂。D3DFont使用256×256是因为需要在各级别的显卡上都工作正常,些古董级显卡最大只支持256的纹理。而对XNA程序来说,通常要求系统支持sm2.0,再加上最近几年GPU性能突飞猛进的发展,我们不妨使用稍大一点的纹理。512的如何?快速计算一下512的纹理大概可以保存440个24像素大小的字符,如果有多种不同大小的字体,还是可能很快就被填充满了。如果不使用多张纹理,很有可能需要频繁重建缓存,这显然不是我们所希望的,而使用多张纹理,又带来了前面提到的种种额外操作。在我的实现中,最后使用了单张1024×1024的纹理。1024的纹理可以储存1700多个24像素的字符,足以满足大多数游戏的需要。我随机抓取了一些文章和聊天做测试,发现约80%的字符都是重复的,这表示很久才有可能重建一次缓存,而且不需要排序就能保证DrawString内的所有字符可以在一个batch内完成。
当然,最坏的情况是一次DrawString中包含了1700个以上不同的24像素字符,这时,我们不得不把它拆分为多个较短的字符串来绘制。不过仔细分析一下,对于1024×768分辨率的游戏来说,那么多字符已经可以覆盖满整个显示区域了,我们不是在开发word之类的文字处理程序,看看wow和各种流行的游戏,同时显示在屏幕上的字符通常不超过300个,更不要说上千个不重复的字符了。进一步来说还可以在高层逻辑控制用户一次可以输入的字符数,这很正常,大多数游戏输入框都会有这样的限制。所以可以预料这种情况基本不会发生,如果实在不幸出现了这种情况,则作为异常抛出,同时忽略当前文本。你可能注意到了我一直在用24像素的字符距离,并不是说纹理中只能有一种字体,所有不同样式,大小的字符都将保存在这张纹理中。
接下来的问题是应该使用何种纹理呢? 很不幸,在.net中没有找到一种GDI+和XNA都能访问的纹理。我们不得不分别创建Texture2D和Bitmap对象,然后通过Lock来直接拷贝数据。不过这里仍然有优化的余地。GDI+纹理的大小不一定要等于XNA纹理的大小。在我的实现中,Bitmap的大小宽度和Texture2D相同,都为1024,而高度则为当前所绘制过的最大字体的高度。比如Bitmap初始化大小为1024×16,如果当前要向缓冲中加入24号的字体,则把Bitmap尺寸重建为1024×24。这样的缺点是拷贝数据时需要计算出具体的更新区域,但优点也是明显的,更小的数据占用,更少的数据拷贝。
你可能注意到上面代码中的xnaGraphics.Textures[0] = null觉得有些奇怪,这是必须的,当SetData时,纹理可能正在被GPU使用,这时是无法访问的,所以必须先解除当前纹理的使用,然后再填充数据,否则很有可能会得到一个异常。
字符定位
如何计算每个字符的位置呢?我们为Texture2D保存了两个值,记录着当前已经使用的区域。每次拷贝字符时,只需要通过这两个值,和所拷贝字符的宽度就能得到每个字符的最终位置。那么如何得到所拷贝字符的宽度呢?我们在创建字体时就指定了字体大小:
new Font("宋体", 18, FontStyle.Regular, GraphicsUnit.Pixel);
将创建18个像素大小的普通宋体字符。对中文系统来说,字符有全角,半角之分,半角字符的宽度是全角字符的一半。接下来,只要知道每个字符是全角还是半角,就能知道字符宽度。
encoding = Encoding.GetEncoding(“GB18030”)
encoding. GetByteCount()
GetByteCount()将返回每个字符所占用的字节数,1个字节为半角,2个字节的自然就是全角。注意,必须设置合适的代码页,才能得到正确字节数。
好了,看来我们已经解决了所有问题,可是如果我告诉你这样得到的字符宽度大多数时候都是不正确的,是不是会很惊奇。问题在哪里呢?原来对TTF字体来说,分为固定字宽和可变字宽的。固定字宽表示全角和半角字符的宽度都是固定的,而对可变字宽字体你会发现全角字符宽度总是相同的,但半角字符就不一定了,比如W和i所占的字宽不一样。也就是说我们上面的计算方法只对固定字宽的字体有效,比如宋体。更加严重的问题是以18为参数创建的字体实际全角宽度并不一定是18像素,常常会小于这个值……
看来最终不得不使用MeasureString。MeasureString实在是一个邪恶的函数,首先,和其他GDI+函数一样,它很慢。其次,MeasureString只接受string作为参数,这就意味着如果我们要测量每个字符的宽度,就要把所要绘制的string分割成数个子字符串,我们会创建garbage! 另外,切记必须用StringFormat.GenericTypographi枚举,否则GDI+会在字符串首尾添加一些额外的宽度。于是我们的算法就变为了:
MeasureString( (string)char);
graphics.DrawString(string);
copy bitmap data to texture2D;
好了,这次看来没错了吧。可惜,即使这样,大多数时候得到的字符宽度也是错误的。哪里还有错呢?继续研究,原来TTF字体的字符间距也分为固定和可变的两种, 这表示:
foreach char in string
stringSum += MeasureString( (string)char);
stringWidth = MeasureString(string);
大多数情况下,stringSum和stringWidth是不相等的…..晕了吧-_-# 。通常来说stringWidth要比stringSum小一些,GDI+会让可变字距的多个字符排列的更紧凑。既然这样再次调整代码:
{
MeasureString( (string)char);
graphics.DrawString((string)char);
}
好了,这次代码变的更加丑陋和低效,对,DrawString也是很慢的操作,多次调用DrawString远远比一次绘制完整个字符串慢的多。但无论如何,代码现在总算是可以正常工作了,考虑到这些代码只会在每次构建字符缓冲时才会执行,也算勉强可以接受。
还要注意的是MeasureString通常返回的是一个浮点数,而目前为止,我所讨论的所有坐标都是像素,0.x个像素是没意义的,如何来对这个值进行取舍呢?答案是使用Math.Round,他将浮点数取整为最接近的一个整数,你可以把他看作五舍六入。在实际测试中,效果很好。
最后,还需要注意对于全角和半角的空格,MeasureString返回的宽度均为0,所以要特殊处理,全角和半角空格对应的Unicode分别是12288和32.
讨论完宽度,那么字符的高度又如何呢?你可能已经猜到了,以18像素为参数创建的字体高度一定不会是18像素。究竟是多少,很不幸,GDI+中没有任何方法和属性能提供“精确”的字体高度。Font. Height的返回值是对字体上下留了一段额外间空间的高度。比如18像素的字体,Height可能是22。对对微软雅黑来说,字体有6个像素的额外空间,4像素在上部,2像素在下部。但是额外空间的距离有多少,如何分配的,在GDI+都无法获得。所以我们只能使用Font. Height作为字体的高度,虽然这样每一行可能浪费3,4像素的空间,总的来说还属于可接受范围。
另外还需要注意的是在用GDI+向位图绘制字体时一定要设置:
Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
否则字符将会有明显锯齿。
最终绘制
完成了以上所有步骤以后,最终的绘图就非常简单了:
我们甚至可以让DrawString接收char[]或者StringBuilder类型的文本进行绘制。此外,再添加一个DrawStringInRegion()也很容易,只需要在添加额外的一两个参数而已。由于字符已经变成了sprite,我们还可以指定任意的颜色,特效和所有对sprite有效的变换。
性能测试:
在Q6600,8800GT,4G内存的XP上,使用同一字体在随机位置绘制一个包含30个字符的字符串。迭代200次FPS在245帧左右,100次FPS在450帧左右,50次则增加到了700帧以上。即使对5w字的随机文本进行渲染,也几乎不需要重建纹理缓冲。
ps:
这几天加强了字体系统, 功能和性能都已经比较完善了:
1.支持任意字体和大小.
2. 支持string和StringBuilder
3. 支持从字符串中的某个指定位置开始绘制.
4. 支持x,y方向上的非均匀缩放
5. 支持2D空间内的旋转.
6. 支持任意颜色
7. 添加了DrawStringInRegion(), 在指定区域内显示文字,支持自动换行, 裁剪超出显示区域的文字. 暂时还不支持缩放和旋转.
8. 低内存占用,大约5mb左右
最终的接口只包含2个类,Font和FontSystem。非常容易集成到其他系统: