照惯例,这是 libGDX 系列里的第五篇。前几篇我们把环境搭了起来,把窗口跑了起来,也用 SpriteBatch 画过最基础的贴图,到这一步画面已经算是有雏形了。可一旦想让屏幕上出现分数、血量、按钮文字,你就会很快意识到一个问题:SpriteBatch 只会画纹理,它不认识字母,更不认识汉字。libGDX 对这件事的标准答案,就是位图字体 BitmapFont 和配套的生成工具 Hiero。如果你之前完全没用过它们,这篇我会从原理讲到导出,再讲到代码绘制,最后把新手最容易踩的坑全部提前排掉。
1. 绕不开的字体问题:为什么 libGDX 不直接渲染 TTF
1.1 从"即时光栅化"到"贴图采样"的架构转变
刚接触 libGDX 的时候,我的第一反应和很多人一样:显示文字不简单吗?随便一个绘图框架里调个 drawString 不就行了?问题在于,传统桌面框架的字体渲染是系统图形栈干的活,每次调用都可能涉及字形查找、轮廓计算、抗锯齿光栅化、位图缓存。游戏里文字往往要跟贴图一起在批处理管道里提交,如果每个字都现场做一遍,CPU 压力会非常大,而且不同平台上渲染出来的效果还不一致。
所以游戏引擎普遍走了另一条路:把字形提前“拍照”成纹理,运行时做查表和贴图采样。BitmapFont 这个名字已经说明了一切——它不是矢量字体,也不是系统字体,而是一张位图。每个字符对应图集里的一个小矩形,绘制时把这个矩形抠出来往屏幕上一贴。整个过程没有字体轮廓计算,没有向量光栅化,性能接近普通贴图,跨平台颜色效果也完全可控。
这套设计带来的直接后果是:一套 BitmapFont 只能在它被生成的那个字号和效果下“安全运行”。你可以用代码去缩放它,但那本质上是贴图缩放,不是重新渲染字形。这也是很多新人对文字模糊、锯齿不满意的根源,后面代码部分我会专门展开讲。
1.2 fnt 加 png:一个管描述,一个管像素
BitmapFont 不是单文件,而是 .fnt 配合 .png。png 大家都懂,就是装着所有字符像素的纹理图集。fnt 文件则是一张“索引表”,打开后大概是这样的:
info face="myfont" size=64 bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=1 aa=1 padding=4,4,4,4 spacing=1,1 common lineHeight=64 base=50 scaleW=1024 scaleH=1024 pages=1 packed=0 page id=0 file="myfont.png" chars count=3326 char id=20320 x=59 y=128 width=45 height=45 xoffset=0 yoffset=14 xadvance=46 page=0 chnl=15看起来像配置文件,本质上是一本字典。char那一行里的id=20320是 Unicode 码位的十进制写法,十进制的 20320 就是十六进制的 0x4F60,正是“你”字。后面的x、y、width、height告诉你“你”字在 myfont.png 里位于哪个矩形区域,xoffset、yoffset是绘制时的偏移量,xadvance则表示画完这个字符后要往右推多少,画笔才能落到下一个字符的正确起点。
建议你们养成读 fnt 文件的习惯,因为后面排查“这个字为什么显示空白”的时候非常有用。你只需要打开 fnt 搜索这个字符的编码,就能确认它到底有没有被生成进去,比在代码里对着屏幕干瞪眼高效太多。另外 fnt 有纯文本和 XML 两种格式,Hiero 默认输出的是纯文本,libGDX 的解析器能自动判断,所以不用手动转格式。
2. 字体从哪里来:内置字体、FreeType、Hiero
2.1 内置 BitmapFont:调试够用,别当产品字体
libGDX 自己内置了一小套位图字体,代码里直接new BitmapFont()就能拿到,它实际上是一个 Arial 15 号字样的位图版本,覆盖了基本 ASCII 可打印字符。最适合的用途是调试信息:当前坐标、FPS、包围盒位置,这些内容你不需要精心设计,拉出来就能画。
但它的局限非常明显:没有中文字形,没有设计感,字号固定。如果你要在界面上显示“开始游戏”四个字,它就是四个空白。所以产品里真正面向玩家的字体,基本都要自己生成。
2.2 gdx-freetype:运行时从 TTF 现生成
libGDX 官方扩展里有一个 gdx-freetype,思路是在运行时读入 TTF/OTF 字体文件,按参数现生成 BitmapFont。核心代码是这个样子:
FreeTypeFontGenerator generator = new FreeTypeFontGenerator(Gdx.files.internal("fonts/main.ttf")); FreeTypeFontParameter parameter = new FreeTypeFontParameter(); parameter.size = 48; parameter.characters = FreeTypeFontGenerator.DEFAULT_CHARS; BitmapFont dynamicFont = generator.generateFont(parameter); generator.dispose();好处是灵活:字号、颜色、描边、阴影、字符集全部在运行时指定。坏处同样明显:生成一个字体实例需要时间,字号越大、字符越多越慢,而且每个 BitmapFont 都要记得调用 dispose。如果用户输入内容不可控,比如聊天框、玩家昵称,提前把所有汉字一次性生成既不现实,也可能让进入界面时明显卡顿。FreeType 的价值在于它允许按需生成,哪怕只是为了补一个新字符,你也可以现场生成一个小字号字体。
2.3 Hiero:把生成工作前移到开发期
Hiero 可以看成“开发期版的 FreeType”。你在编辑器里把字体文件、字号、字符集、描边阴影都配好,点一下导出,得到一套固定的 fnt 加 png,放到 assets 目录随包发布。运行时没有任何生成开销,加载和绘制的本质就是贴图。
选型逻辑其实很清楚,大多数项目都是混用:
| 方案 | 典型场景 | 特点 |
|---|---|---|
| 内置 BitmapFont | 调试信息、临时占位 | 零成本,无中文 |
| FreeType 运行时生成 | 用户输入、不可控文本 | 灵活但耗时,需释放资源 |
| Hiero 离线生成 | 固定 UI、HUD、已知文案 | 视觉可控,启动最快 |
我自己的项目习惯是:临时提示和动态输入用 FreeType 补,主界面、标题、按钮、计分板这类写死的内容全部走 Hiero。这个系列既然是讲基础,我重点把 Hiero 讲透,因为它把“位图字体到底是怎么拼出来的”展示得最清楚。
3. Hiero 实操:从零导出一套能显示中文的字体
3.1 工具跑起来,界面大致怎么认
从官方工具页拿到 Hiero 的 jar 包,本机装好 Java 运行环境,命令行启动即可:
java -jar hiero.jar较新的构建对 JDK 版本要求会高一些,如果启动没反应,优先检查 Java 版本,而不是怀疑代码。工具打开后是多区域编辑器,可以理解成四处:字体选择区、效果区、字形表、渲染预览区。第一次打开默认给了一套 ASCII 字形,能正常预览一串英文。
3.2 选字体文件而不是系统字体
重要的一步是在字体区选择自定义字体文件,也就是你项目里会打包的那个 .ttf,不要依赖系统字体列表。原因有两个:一是系统字体在当前电脑上看着没问题,换到另一台电脑可能就变了,Hiero 渲染结果不一致;二是你最终随包发布时本来就需要一个 ttf 字体文件,用这个文件生成,字形风格才能和游戏文案保持一致。
字号这里我有一个大原则:优先选大字号而不是刚好够用的字号。比如最终在屏幕上是 24 像素,你完全可以用 64 甚至 96 像素的字,运行时用代码缩小到 24。因为位图字体天生适合“大图缩小”,缩小是纹理采样降噪的过程,边缘会更平滑;反过来把小图放大,就是显而易见的马赛克。当然也别离谱到几百像素,图集很快就装不下了。
3.3 字符集导入:中文显示的关键一步
打开字形表页面,默认只有西文字符,这里要做两件事。
第一件,准备字符源。最简单的方式是准备一个 txt 文本文件,把游戏里所有会出现的文案都放进去,然后点 Import 导入。常用汉字 3500 字的表也可以直接合并进去,覆盖面够了以后,后期加文案基本不用重新导。要注意文本文件必须保存成 UTF-8 编码,否则导入后全是乱码。
第二件,校验预览。导入后在预览区输入中文,如果字表覆盖到位,预览区应该能看到完整字形。这里我见过很多人翻车:Hiero 明明导入了字符,运行出来还是空白,原因往往是字号太大、字形渲染越界,但字表里确实有这个 id。所以最后要回到字形表页确认多出的“你”字 id 确实存在。
没人愿意每次手工核对几千个汉字,所以推荐一个低成本保底法:游戏所有写死的文案单独放一个资源常量类或文案表,导出字体时就用这个文案表生成导入文本。以后改文案,重新生成字体资源就行。顺便强调:不要图省事一键选择整个 Unicode 范围,那里面几万汉字几乎全用不上,图集撑爆、内存爆炸、加载卡顿,完全是浪费。
3.4 效果与 padding:描边阴影不是白给的
Hiero 好玩的地方是字形效果可以叠加。我会在效果区添加一个 Border(描边)和一个 Shadow(阴影),描边宽度设到 2 到 4 像素,阴影向右下偏移几个像素,调整实时可见,可以反复试。
但千万不要漏掉渲染区里的 Padding 设置。位图字体在抠图时会因为描边和阴影向外扩展,超过 Padding 的空间会被当多余空白裁掉,结果就是描边断线、阴影缺角。经验做法是:描边宽度 3,阴影偏移 5,那 padding 至少给到 6 以上,宁可多给不要少给。实在拿不准就先设成 0 导一次,看边缘裁切效果,再回来加大重导,反正重复劳动成本很低。
3.5 导出后验尸:用 id 搜索代替肉眼
导出按钮生成的是一套 fnt 加 png。拿到文件后先别急着拷进项目,用任意文本编辑器打开 fnt,搜索id=20320。能找到,说明“你”在里面;找到但后面的坐标全是 0,说明字形没有被真正渲染;完全找不到,就是字符集压根没包含。这个方法每次换字体我都会用,三秒钟解决九成“中文不显示”的问题。
导出参数还有个小坑:默认会勾选一些性能优化项,比如整数坐标对齐,保持默认即可。渲染尺寸决定图集的边长,如果提示有字形没装下,优先压缩字符集或者减小字号,别一味开到 4096,移动端的纹理上限和内存压力都很现实。一个干净的字体资源,是后面所有 UI 工作的基础。
4. 代码落位:把文字画到屏幕的完整路径
4.1 最小加载示例与资源路径
Hiero 生成的文件放进 assets 下,比如assets/fonts/myfont.fnt和assets/fonts/myfont.png。libGDX 内部会按照 fnt 里记录的 page 文件名去找同目录的 png,所以这两个文件必须放在一起。加载代码是最简单的一行:
BitmapFont font = new BitmapFont(Gdx.files.internal("fonts/myfont.fnt"));绘制要放在 SpriteBatch 的 begin 和 end 之间,因为 BitmapFont 本身也是一种可批量绘制的纹理资源:
batch.begin(); font.draw(batch, "Hello libGDX", 100, 300); batch.end();如果你用 AssetManager 管理资源,可以改成assets.load("fonts/myfont.fnt", BitmapFont.class),用法差不多,引用计数和自动释放会省心很多。
4.2 大坑:draw 的 y 是基线,不是盒子顶边
很多第一次接触 BitmapFont 的人,代码写font.draw(batch, "Hello", 0, 100),然后发现文字位置比预期高了一点或矮了一点,改来改去都不对。因为这里传的 y 是字形基线,不是文字矩形框的顶边。想象一下老式英文本子上的那条横线,字是挂在横线上的,横线才是你传的 y 值,而不是字的顶部。
所以做 HUD 时,如果习惯按左上角排布,不能直接把Gdx.graphics.getHeight() - 20传进去当 y。一个常用的修正是先拿到字体的行高,再往下退:
float lineHeight = font.getData().lineHeight; float baselineY = Gdx.graphics.getHeight() - 20f - lineHeight; font.draw(batch, "左上角标题", 20f, baselineY);为什么用 lineHeight 而不是 capHeight?因为 lineHeight 是整行占位高度,按这个退能保证第一行文字不会超出你想要的矩形边界。最终效果以视觉为准,多试几次微调几像素完全正常。
4.3 对齐与换行:一个方法完成排版
做 UI 时更常用的是 draw 的另一个重载:
import com.badlogic.gdx.utils.Align; font.draw(batch, "很长很长的一段说明文字", 100, 200, 400, Align.center, true);这里的 400 是目标宽度,Align.center 是水平居中对齐,最后一个 true 表示按宽度自动换行。换成 Align.left、Align.right 就能快速切换左右对齐。libGDX 坐标系里 x 从左往右,y 从下往上,所以对齐方向和阅读习惯一致,不会遇到镜像问题。
这个重载内部会做文字的排版计算,缺点是每次调用都会重新排一次。如果一段文字固定不变,或者变化不频繁,把它缓存下来会更好,这就引出了 GlyphLayout。
4.4 GlyphLayout:给文字做一次预排版
GlyphLayout 可以理解成一个排版结果缓存对象。先把字符串丢给它,它会算出这段文字实际需要多宽多高:
GlyphLayout layout = new GlyphLayout(); layout.setText(font, "游戏里的玩家名"); float w = layout.width; float h = layout.height;拿到尺寸后,文本居中就很容易了。要在屏幕正中间画一行字,标准做法是:
float x = (Gdx.graphics.getWidth() - layout.width) / 2f; float y = (Gdx.graphics.getHeight() + layout.height) / 2f; font.draw(batch, layout, x, y);至于这个 y 为什么不是height/2 - ...,回到 4.2 的结论:draw 的 y 是基线,基线上方才是文字主体,用加号让主体落在中心附近是很实用的近似。实测如果发现偏了一点,手动减几像素微调即可。
在这个基础上,配合字体自带的颜色和缩放:
font.setColor(0.9f, 0.8f, 0.6f, 1f); font.getData().setScale(1.5f); font.draw(batch, layout, x, y);就能把颜色、位置、尺寸一次搞定。这里再提醒一句:setScale 放大的是贴图采样,放大太多会糊,所以前面才反复强调 Hiero 导出用大字号。
5. 别让文字拖垮帧率:性能与动态文本的取舍
5.1 绘制批次的问题
SpriteBatch 的核心理念是“同一张纹理尽量一起画”,切换纹理是相对重的操作。如果你的界面里有一堆文字加一堆 UI 图片,它们各自用不同纹理,批次就会变多。现代桌面和移动设备其实没那么敏感,但如果你在一帧里画了上千个文本实例,再叠加多个不同字体,帧耗时肉眼可见地上升。
一个现实层面的优化是:界面文字尽量复用同一套 BitmapFont,少建字体实例。初学阶段别急着把所有图片和字体塞进一个大图集,先把“一屏文字尽量不切换字体”这一点做好,收益已经很高。
5.2 静态文本用 BitmapFontCache
有些文本几乎不变,比如关卡名、血量上限、按钮文案。这种文本最浪费的操作是每帧重新排版。BitmapFontCache 会把排版结果以顶点数据的形式缓存下来,你只需要在内容变化时更新一次,之后每帧直接交给 SpriteBatch:
BitmapFontCache cache = new BitmapFontCache(font); cache.setText("固定文本:游戏开始", 100, 200); // render 循环里 cache.draw(batch);实测在模拟器上同时画几百个这样的缓存文本,帧耗时比每帧重新 GlyphLayout 少一大截。如果你在做排行榜、任务面板这种大量文本的界面,这个对象几乎是个必需品。
5.3 动态文本:FreeType 的按需生成还是要准备好
Hiero 这套方案有一个软肋:只能显示你已经生成过的字符。聊天框、玩家随便起的昵称、服务端下发的公告,这些内容没有办法提前知道,所以动态文本场景必须再拉回 FreeType。做法上不用一个字一个字生成,更常见的思路是启动时用常用字表生成一套兜底字体,运行时遇到没覆盖的字符,再用 FreeType 补生成一个小字集缓存,之后查缓存命中。这个方案复杂度不低,但刚开始学习时,先能跑通“FreeType 加载 ttf 并生成字”就行:
parameter.characters = FreeTypeFontGenerator.DEFAULT_CHARS + "中文,。!?";还有一个和性能强相关的点:位图字体的纹理不要每个界面各搞一份,一个字体文件在全局只加载一次,所有界面共享引用。写一个简单的字体管理器,或者直接交给 AssetManager,避免同一个 fnt 被反复 new 且从来没人释放。
5.4 纹理过滤与缩放锯齿
这里有个我特别容易忘记的细节:Hiero 导出的 png 进入 libGDX 后,默认纹理过滤不一定是想要的效果。放大文字出现马赛克,八成是这个问题。加载后加一行:
font.getRegion().getTexture().setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);这样缩放时 GPU 会做线性插值,边缘平滑很多。如果做像素风游戏,反而要换成 Nearest,保住锐利的方块感。还有setUseIntegerPositions(true)能让文字稳定在整数坐标,防止渲染时的字符闪烁,但会牺牲亚像素精度,做精细 UI 时记得权衡。
6. 这轮实操最容易翻车的六个细节
6.1 中文空白多半是字符集问题
代码写对、路径也对了,屏幕上文字全是空白或方块,九成是 fnt 里根本没有那个字符。别急着改代码,先打开 fnt 搜 id。比如要显示“你好”,搜id=20320和id=22909,两个都在,就知道字符集没问题;不在,就回到 Hiero 重新导入包含这些字的文本再导一次。
6.2 描边阴影被裁,padding 不够
这种问题最迷惑,因为文字主体看着是好的,只有描边和阴影的边角消失。它不是 bug,是渲染区给的 padding 小于效果的外扩范围。重新把 padding 设大,至少大于描边宽度加阴影偏移量,导出后基本能解决。
6.3 放大后全是毛刺
导出 32 号字,代码里缩放到 64 甚至 96,边缘一定惨不忍睹。正确路径是“大图缩小”或“原尺寸绘制”。设计阶段就把字号定在需要的 2 到 3 倍,运行时尽量缩小而不是放大。如果必须放大,配合 4.3 说的 Linear 过滤能缓解,但根治还是重导一套大字。
6.4 fnt 和 png 名不匹配
fnt 里写了page id=0 file="myfont.png",就得保证 assets 里确实有这个文件、同目录、同名。很多人从网上下载字体包,fnt 叫 A、png 却叫 B,直接 new BitmapFont 加载就会报找不到资源。遇到加载报错,先检查 fnt 文件头部的 page 行,不要只盯代码路径。
6.5 一次导入几万字符
全 Unicode 范围导入会直接生成超大图集,加载时间、内存、纹理上限一起爆。解决办法是控制字符集:正式文案加常用汉字加标点符号,一套字表覆盖绝大多数场景就够了。后续需要新增文案时,回到 Hiero 增量导入再导出,成本并不高。
6.6 资源释放与重复加载
new 出来的 BitmapFont 背后有纹理,不用了要 dispose。FreeType 生成的更要注意,generator 和生成的字体是两个对象,都要释放。我见过一个界面反复开关后内存明显上涨,查了半天就在这里。优先用 AssetManager 做统一管理,它能处理引用计数,同样的资源不会因为 load 两次就被释放掉。
写到这里,文本显示这条线基本就通了:位图字体的原理、三种来源方式、Hiero 的完整操作、代码绘制、性能取舍,最后是那些不试一次真的想不起来的细节。我个人体会最深的是“先看 fnt 再调代码”这套排查顺序,它让文字显示问题从玄学变成了查字典,也是这套机制最友好的地方。如果你动手过程中遇到这篇没提到的怪问题,建议也按 id 搜索的思路先自查一轮,大概率能自己找到答案。