1. 项目概述:为什么要在Unity里用BMFont做艺术字
接手这个项目的时候,我面对的需求其实特别具体:游戏里有一套战斗飘字系统,要显示伤害数字、暴击提示、连击计数,还得配合一套定制的艺术字体风格。直接用Unity的Text组件挂TTF字体没问题,但有个致命缺陷——字体文件动辄10MB起步,而且你想在数字上做一些发光、描边、渐变的艺术效果,就得叠加好几层材质,DrawCall直接翻倍,移动端根本吃不消。
BMFont就是干这个的。它本质上是个位图字体工具,可以把我们想要使用的字符集合预先渲染成一张或者多张PNG图集,同时生成一个fnt格式的配置文件,里面记录了每个字符在图集中的坐标、尺寸、偏移量等信息。Unity端只需要一个自定义组件去解析这个fnt文件,用Mesh或者MeshRenderer把对应的字符图集区域绘制出来就行。
这套方案的优点很实在:加载快、内存小、渲染效率高,而且因为是预渲染的位图,你可以在BMFont里提前把艺术字的各种效果做好——比如发光、描边、阴影、渐变,甚至直接用美术设计的字形去生成图集,这比Shader处理要省事儿得多,也更适合在低端机上跑。
适合谁来参考这篇文章?主要是Unity开发中需要在UI、战斗飘字、标题Logo等位置使用自定义艺术字效果的开发者。不管你是初入行的新手,还是已经用过TextMeshPro的老手,只要对渲染性能和字体定制有要求,这套BMFont方案都值得你仔细看一遍。下面我把整个流程拆开讲,从工具选择到具体参数,再到Unity端的加载和调用,全部走一遍。
2. 工具选型与设计思路解析
2.1 为什么选BMFont而不是直接上TextMeshPro
很多人现在第一反应是用TextMeshPro,这个组件确实强大,支持SDF字体、动态字体、富文本标签,描边阴影都是内置的。但TMP有个比较尴尬的点:它的图集生成机制是自己控制的,虽然支持导入自定义图集,但如果你要做的是非常复杂的艺术字形——比如带纹理细节、手写风格、局部渐变的字,TMP的处理流程会变得很绕。你需要先做字体资产、再处理材质、再调节各类参数,而且一旦字形复杂,SDF的采样精度反而会下降。
BMFont的优势在于它的灵活性。它允许你把任何字形预先渲染成位图,包括复杂纹理,然后在运行时直接以四边形网格的方式绘制出来。这意味着美术给什么字形,你就能用什么字形,不需要经过额外的矢量转换。再加上BMFont生成的fnt文件非常直观,解析逻辑简单,老项目也很容易接入,没有历史包袱。
2.2 BMFont的几个关键版本差异
BMFont本身有多个版本,最经典的是Windows上的AngelCode BMFont,另一个是Hieroglyph。实际使用中这两者生成的fnt文件格式基本一致,Unity端的解析代码可以通用。
- AngelCode BMFont:老牌工具,功能稳定,支持字体参数设置、字符集过滤、导出PNG和fnt文件,最常用。
- Hieroglyph:功能更现代,内置了一些自动处理功能,比如自动检测文本中出现的字符,适合动态生成图集。
- Littera:网页版工具,不用下载,支持上传字体文件后在线生成位图字体,适合快速出预览。
我个人的习惯是先在本地的AngelCode BMFont里调好参数,确认字形效果,然后再把生成的图集和fnt文件放进Unity工程。虽然工具有点老,但它的PNG压缩和字符排列算法非常稳定,在微信小游戏和原生Android平台上跑起来都没问题。
2.3 Unity端的渲染方案选择
拿到图集和fnt文件之后,Unity端有两种渲染方案:UGUI下的自定义组件,或者直接用MeshRenderer。我的项目里用的是基于MeshRenderer的方案,原因是战斗飘字系统需要频繁创建和销毁文字对象,UGUI的Canvas重建开销在这种高频场景下会让性能波动比较明显。MeshRenderer配合MeshCombiner,可以批量合批多个文字Mesh,DrawCall几乎没有压力。
3. 核心细节解析与实操要点
3.1 理清fnt文件里最关键的数据结构
不管你用什么工具生成,fnt文件的内容万变不离其宗。这里挑几个核心字段解释一下,因为这些字段直接影响Unity端的解析代码:
char id=49 x=2 y=3 width=28 height=32 xoffset=0 yoffset=6 xadvance=30 page=0 chnl=0- id:字符的Unicode编码,比如数字“1”的id就是49。
- x、y:该字符纹理在图集里的左上角坐标(注意是像素坐标)。
- width、height:该字符在纹理中的尺寸。
- xoffset、yoffset:渲染该字符时,相对于基准点的偏移量,用于微调对齐。
- xadvance:该字符绘制完后,下一个字符的起始位置要向前推进多少像素,相当于字符间距。
除了char字段,fnt文件最前面的info和common字段也要注意。common里的lineHeight字段是行的总高度,scaleW和scaleH就是整张图集的宽高。这些参数是解析时计算UV坐标的基础,缺一不可。
提示:xoffset和yoffset这两个字段是很多新手忽略的坑。如果用系统字体生成中文时出现文字排列不齐,那基本都是这两个参数没有正确处理。
3.2 设置合适的Padding和字符间距
在BMFont的Font Settings中,有几个参数直接决定最终图集质量和渲染效果:
- Padding:我通常设置为4或者8。这个参数是在每个字符边缘留出的空白像素,防止字符在渲染时因为线性过滤产生边缘渗透。尤其在合批后做缩放动画时,Padding太小会出现明显的白边,太大又浪费图集空间。实测经验是:有描边效果的字,Padding至少8,否则描边像素容易被截断。
- Spacing:这个控制字符之间的间距,影响xadvance。要在工具里预留好,让字符之间留出一定的缓冲空间。
- Smooth:开启后会对字形边缘做抗锯齿处理,这个建议开启。生成的图集边缘过渡会更自然,渲染时看起来更平滑。
还有一个比较隐蔽的参数是Font size。这个值决定了生成的位图的分辨率。假设我们要在游戏里把数字显示到屏幕高度的1/10,那么字体大小至少设成屏幕像素高度的3到4倍,才能保证放大后不发虚。我的项目里直接用64号字体生成图集,效果比较均衡。
3.3 字符集的筛选策略
BMFont默认会生成字体文件里包含的所有字符,但中文环境下这样做图集会巨大,因为常用汉字就有好几千个。这里要讲一下我的筛选策略:
- 战斗飘字系统只需要数字、字母和少量标点符号,所以直接过滤掉所有中文。
- 如果要显示玩家名称,就把字符集限定为常用汉字的前3000个左右,再配合上数字和字母。
- 如果有动态输入需求,建议把字符集扩大,或者对不同使用场景生成多套图集,按需加载。
我自己在实际项目中做了一套工具,可以扫描策划配置表里所有会用到的字符串,自动提取字符集,再调用BMFont的命令行模式生成对应图集。这样能确保图集最大化利用,不会塞进用不到的字符。
4. 实操过程与核心环节实现
4.1 用AngelCode BMFont生成数字与符号图集
这里我用一套战斗飘字最常用的字符集来演示:数字0到9,加号、减号、百分号、暴击感叹号。具体步骤如下:
- 打开AngelCode BMFont,在
Options菜单里选择Font Settings。 - 在
Font下拉框里选择一个合适的字体,比如某种粗体圆体,注意这个字体文件必须已经安装到系统里。 - 设置
Size为64,Padding向上、下、左、右都设为8,Spacing都设为2。 - 在
Charset里选择Unicode,这样中文和特殊符号都能正确处理。 - 在
Font Settings窗口下方,有Select chars按钮,点击后可以选择字符范围。我这里选中Digits和Basic Latin部分,再把需要的特殊符号手动加上去。 - 回到主界面,选择
Options -> Save Bitmap Font as...,保存时选择png和xml格式,注意xml和fnt内容本质上一致,解析逻辑可以复用。
生成的目录里会出现一个.fnt文件和一个.png文件。这里建议直接看下fnt内容,确认字符ID和图集尺寸都正确,再进入下一环节。
4.2 Unity端导入与纹理设置
将生成的PNG和FNT文件一起导入Unity工程。这里有一个非常关键的细节:纹理类型必须设置为Sprite模式,并且关闭Generate Mip Maps。
- 关闭Mip Maps的原因:位图字体的图集尺寸不大,大多数情况下1024x1024以内,开Mip Map反而会增加内存,而且在小分辨率设备上MipMap采样会让字体边缘发虚。
- 纹理压缩格式:移动端用ASTC或ETC2,需要注意PNG本身如果带Alpha通道,压缩格式要选带Alpha的版本。我的做法是直接在Android平台下用ASTC 6x6,iOS下用ASTC 4x4,实测效果和内存都比较平衡。
- Filter Mode:如果你的图集会在旋转或缩放动画中使用,建议设为
Bilinear,如果只是静态显示,Point也可以,看需求。
注意:如果图集里的字符以后需要在Shader里采样做发光效果,那么纹理Wrap Mode一定要设为
Clamp,否则UV边缘采样会出问题。
4.3 C#解析FNT文件,生成字符映射表
拿到fnt文件后,我不建议直接去改Unity内置的Font类,那样处理起来比较绕。更直接的方法是自定义一个BitmapFontAsset类,把fnt里的数据解析成字典:
public class BitmapFontAsset { public Texture2D texture; public int lineHeight; public Dictionary<char, CharacterData> characters = new Dictionary<char, CharacterData>(); public void LoadFromFnt(string fntContent, Texture2D tex) { texture = tex; string[] lines = fntContent.Split('\n'); foreach (string line in lines) { if (line.StartsWith("char ")) { CharacterData data = new CharacterData(); data.id = GetIntValue(line, "id"); data.x = GetIntValue(line, "x"); data.y = GetIntValue(line, "y"); data.width = GetIntValue(line, "width"); data.height = GetIntValue(line, "height"); data.xoffset = GetIntValue(line, "xoffset"); data.yoffset = GetIntValue(line, "yoffset"); data.xadvance = GetIntValue(line, "xadvance"); data.page = GetIntValue(line, "page"); characters[(char)data.id] = data; } else if (line.StartsWith("common ")) { lineHeight = GetIntValue(line, "lineHeight"); } } } }重点看GetIntValue这个解析函数,因为fnt文件里的字段顺序不一定固定,所以不能靠Split后的固定索引去取,应该用正则或者逐字段匹配的方式来提取。
int GetIntValue(string line, string key) { string pattern = key + "="; int idx = line.IndexOf(pattern); if (idx >= 0) { int start = idx + pattern.Length; int end = line.IndexOf(' ', start); if (end < 0) end = line.Length; int value; if (int.TryParse(line.Substring(start, end - start), out value)) return value; } return 0; }这一步跑通之后,BitmapFontAsset就能正确描述整个字体图集了。接下来就是利用它来生成网格。
4.4 用Mesh渲染一行字符的完整流程
有了字符数据,生成文字Mesh就非常直接了。假设我现在要在屏幕上显示“1234”这串数字,处理流程是这样的:
- 遍历字符串里的每个字符。
- 从
characters字典里查对应的CharacterData。 - 根据当前的光标位置(起始为0),计算该字符的四个顶点的位置:
float charLeft = cursorX + data.xoffset; float charRight = charLeft + data.width; float charTop = -data.yoffset; float charBottom = charTop + data.height;这里需要注意,BMFont的坐标轴和Unity的UI坐标轴有区别。BMFont的y轴是向下增长的,所以yoffset要取负号。
- 计算UV坐标。已知图集尺寸是
scaleW和scaleH:
float u0 = (data.x) / (float)scaleW; float v1 = 1.0f - (data.y) / (float)scaleH; float u1 = (data.x + data.width) / (float)scaleW; float v0 = 1.0f - (data.y + data.height) / (float)scaleH;UV的v方向这里做了一次翻转,是因为Unity纹理坐标系的原点在左下角,而BMFont的原点在左上角。
- 把四个顶点、UV、颜色填充到Mesh的顶点数组、UV数组和颜色数组里。
- 光标位置累加
data.xadvance,继续处理下一个字符。 - 最后设置Mesh的三角形索引,两个三角形组成一个矩形面片。
这一套逻辑封装成BitmapTextRenderer类,对外只需要暴露一个string text属性和一个Color color属性,由外部脚本控制位置、缩放和旋转。
4.5 在战斗中实现出色的艺术字飘字效果
美术字的效果从哪里来?两种途径:一是字体本身就带描边和图案,二是运行时通过Shader增强。我这边推荐两步都用上:
- 第一步,在BMFont里选一个本来就比较粗的字体,比如圆体或者特粗黑体,生成图集时把“描边”效果通过字体的阴影或者轮廓功能做进去。
- 第二步,在Unity里给这个MeshRenderer挂一个简单的Shader,可以在图集采样后加上一个渐变叠加,或者外发光效果。
我的飘字系统最终效果是这样实现的:基础的伤害数字用白色图集字符Mesh渲染,但Shader里加了一个从黄色到红色的渐变叠层,然后用一个混合模式让它在命中瞬间闪一下光。因为图集是预先渲染好的位图,字符的边缘非常锐利,加上渐变之后就能呈现出一种很有“打击感”的艺术字效果,在低端机上帧率也不会掉。
5. 常见问题与排查技巧实录
5.1 字符显示为黑块或乱码
这个是最常见的坑。黑块基本说明UV算错了,或者纹理根本没有绑定到材质上。排查步骤:
- 检查材质上绑定的纹理是不是生成的PNG图集。
- 检查Shader的采样UV是不是和纹理方向一致,尤其注意v方向的翻转。
- 检查fnt文件里scaleW和scaleH是否和PNG的实际分辨率一致。很多时候是因为在BMFont里调整了图集尺寸,但fnt文件还是旧数据。
乱码问题则基本是字符集缺失。如果图集里没有包含那个字符,BMFont会生成一个?或者黑块来占位。解决方法是回到BMFont,重新勾选字符范围,重新生成图集。
5.2 字体边缘出现明显白边或黑边
白边多半是Padding设置太小的结果。在渲染缩放动画时,线性采样会取到相邻字符的边缘像素,看起来就像字外面镶了条白线。解决方法是加大Padding,或者把Filter Mode改为Point。但点采样又会让字体边缘有锯齿感,所以我的推荐组合是Padding为8配合Bilinear。
黑边则是抗锯齿没有处理好。BMFont里Smooth选项要打开,另外在从PSD导出PNG时要注意不要勾选“裁剪空白像素”,否则图集布局和fnt记录就对不上了。
5.3 中文显示时行距和字符间距错乱
这个问题的根源在于中文字符的xadvance和xoffset处理不一致。如果你的fnt文件是用系统字体生成的,中文字符的xadvance往往比实际字形宽度要大不少,于是拼接出来的文字间隔就会忽大忽小。
解决方法是在BMFont的Font Settings里把Glyph spacing(字符间距)设置为0,同时调整Padding为0来观察原始布局。如果问题依然存在,建议换用专门的中文字体工具重新生成,或者直接在生成的时候设置Outline thickness为0,等进入Unity后再用Shader做描边。
5.4 合批后字体颜色变化异常
因为BMFont生成的是带Alpha的彩色图集,如果Mesh的顶点颜色叠加了半透明白色,那晕染出来的效果和预期完全不同。排查方法很简单:在MeshRenderer组件上把材质改为Unlit/Transparent,先排除Shader输出颜色的干扰。如果一切正常,那就是Shader里对顶点颜色的处理不符合预期。
6. 性能优化与进阶扩展
6.1 减少DrawCall的合批策略
位图字体的核心性能优势就是合批容易。我采用的方案是:
- 把所有飘字对象统一放在同一个父节点下。
- 每个飘字都是一个独立的Mesh,但在每帧渲染前,用一个
MeshCombiner把它们合并成一个大的Mesh。 - 合并后只需要一个DrawCall,极大缓解了大量飘字同时出现时的性能压力。
需要注意,合批之后就无法对单个文字做独立旋转和缩放,所以这个方案更适合飘字效果里的“上浮”动画,如果是单个数字跳跃效果,建议保持独立Mesh。
6.2 图集动态扩容方案
如果你的游戏里有玩家自定义输入,比如聊天系统或者改名系统,字符集可能随时变化。我的方案是:
- 初始加载一个基础图集,包含常用汉字和符号。
- 当检测到新字符出现时,调用BMFont命令行或Runtime Font Baker,将新字符追加渲染到一张新的图集上。
- 更新BitmapFontAsset的字符映射表,同时通知所有正在显示的字体Mesh重新生成网格。
这个方法在高并发场景下要加锁,避免在更新映射表的同时有线程在读取。我在线上项目里实测,追加100个新字符的生成耗时大概在20毫秒左右,加载进度条可以轻松遮住。
6.3 与TMP的混合使用策略
这套BMFont方案不是要完全替代TMP。实际上,在复杂UI界面中,TMP的动态排版和富文本支持确实方便,而BMFont更适合一些固定风格的艺术字区域。
我个人的使用准则是:
- 标题、飘字、结算数字,用BMFont。
- 普通文本、对话、道具名,用TMP。
- 如果同一个界面里既有TMP又有BMFont,就用Canvas分组隔离,放在不同的层级,避免合批互相打断。
这样组合使用,既保证了关键视觉元素的艺术效果,又能利用TMP的便利性来降低开发成本。
7. 踩坑记录与个人经验总结
最后说说我在实际落地这套方案时踩过的一些坑,希望能帮后来者少走弯路。
第一个坑是纹理压缩格式。当时我把PNG直接设为RGBA32,在编辑器里跑着一切正常,到了低端Android机上出现了严重的采样边缘渗色。后来发现是ETC2压缩格式对半透明边缘的处理不够友好,换成ASTC之后明显改善。如果你的目标平台是WebGL或者微信小游戏,记得先确认平台支持的纹理压缩格式,不要直接在编辑器里导出后就觉得万事大吉。
第二个坑是在BMFont里用了加粗效果。我一开始图省事,直接在字体选择器里选了个粗体变体,生成出来的图集在高DPI屏幕上看起来还行,但一旦飘字放大到200%,边缘就开始发虚。后来换成原始字体,让美术在PS里对字形做加粗和描边处理,再用处理后的字形生成图集,放大效果稳多了。位图字体的质量上限,其实在生成图集前就定死了。
第三个坑和字符集有关。我最初把战斗飘字的字符集做得很小,只包含数字、加号、减号和感叹号。结果测试的时候发现,数字中间还会出现小数点的显示需求,暴击时还会出现“%”号。每次发现新字符都要重新生成一次图集,非常影响效率。后来我直接在生成工具里预留了一整套符号集合,宁可多占一点图集空间,也不想图省事导致返工。
第四个坑里有一个隐藏比较深的问题:不同触摸屏设备上,字体边缘清晰度差异很大。后来排查发现是纹理导入设置里的Max Size选项在起作用。很多高端机的屏幕宽度超过1080像素,如果图集导入时Max Size限制为1024,纹理就会被缩放采样,字迹自然就糊了。我这边统一把飘字图集的Max Size设为2048,同时控制图集内容不超过2048尺寸,这样在绝大多数设备上都能保持锐利。
最后再分享一个小技巧:在BMFont生成图集的时候,可以把文字颜色从白色改为浅灰,这样在Unity里通过顶点颜色可以方便地做颜色叠加而不损失亮度范围。如果直接用纯白生成,想让文字颜色变暗就得靠乘法,稍微不注意Alpha混合就会出现颜色偏移。这个小细节在实际调战斗UI颜色时帮了大忙。
这套BMFont + Unity生成艺术字的方案,整个链路下来核心就是“预渲染加Mesh绘制”这八个字。理解了这个本质,不管是制作飘字、Logo标题还是整屏的战斗伤害统计,你都能以极高的效率实现颇具质感的艺术字效果。如果以后你想在UI界面里加入更复杂的动态渐变或旋转效果,这套Mesh渲染方案也完全可以配合Shader一起扩展,伸展空间非常充足。