说到底,做游戏UI或客户端开发的,迟早会碰上一次类似的需求:界面上要显示分数、倒计时、伤害飘字,美术却给了一组手绘风格的像素数字,让你“必须用这套图,不能用系统字体”。之前我接到这个需求时第一反应是直接找动态字体渲染,结果被性能和观感双重打脸。后来老老实实用BmFont把数字烘焙成图集,再在Unity里做了一套解析和动态显示方案,才算真正把这事做顺。这篇文章就把从BmFont配置到Unity接入的完整链路讲清楚,适合刚接触位图字体、以及想给自己项目加一套自定义数字图集的开发者参考。内容偏实操,每一步都说透。
1. 为什么用BmFont做数字图集
1.1 位图字体和系统字体怎么选
绝大多数Unity项目默认用的是动态字体:你塞一个TTF进去,运行时引擎自己采样轮廓、生成纹理,想显示什么字符都能显示。省事是真省事,但有两个绕不开的问题。第一是风格不可控,TTF的笔画形状和美术给的UI风格很难完全对上,更别提手绘数字、故障风格数字、赛博像素字体这些特殊外观;第二是运行时开销,动态字体要把字形提前栅格化到一张共享纹理上,用得越多,图集越碎,管理起来越麻烦,还容易在低端机上出现fallback掉字。
位图字体则完全相反。它把需要的字符预先用美术设定好的样式渲染成一张PNG图集,再把每个字符在图集里的坐标、宽高、偏移量写进一份fnt描述文件。运行时不采样字体轮廓,直接按索引取图块拼出来,渲染路径短、风格完全一致、没有字体回退问题。代价是要提前规划好字符集,换字体就得重新烘焙。对“数字”“冒号”“小数点”“负号”这十余个字符来说,这个代价几乎可以忽略不计。
对项目而言,数字图集其实是最适合走位图字体路线的场景之一。字符数量少,图集尺寸可以压得很小;样式高度统一,分数、计时、飘字都能共用一套素材;频繁刷新也不会产生动态字体那样的纹理分配开销。只要美术给出一组PNG数字或指定一个TTF风格,BmFont就能把它变成一张标准的Sprite图集。
1.2 BmFont在整个流程里的位置
BmFont本身是位图字体生成工具,比较老牌,但一直能打。它读入一个字体文件,按照你指定的字符范围、字号、内边距、渲染模式,把每个字符单独渲染到一张透明PNG上,同时输出一份记录几何信息的fnt文件。这份fnt里最关键的是每个字符的x、y、width、height、xoffset、yoffset、xadvance这几个字段,这七个数字加在一起,就是Unity侧定位字符的唯一依据。
完整使用链路由三段组成:先整理字符集并配置BmFont导出参数,得到PNG加fnt;再把PNG导入Unity设置成合适的精灵参数;最后解析fnt并把它用在你需要的显示组件里。只要把这三段走通,以后美术改数字样式,只需替换资源重新烘焙,代码侧完全不用动。这也是它比手动抠图拼Sprite灵活的地方。
2. BmFont配置导出:参数决定成败
2.1 先准备字符集,再碰工具
很多新用户上手BmFont第一步就栽在字符上:默认是全字符集,一导出就是一张大图,数字混在一堆字母和符号里,图集浪费严重,查找也不方便。我的建议是先建一个文本文件,里面只放你需要的字符,比如:
0123456789.:-+这个文件就是你的字符集白名单。在BmFont里可以通过字符选择功能把不用的字符全部清掉,只保留这份白名单里的内容。如果你用的是1.14版本,可以点Edit菜单里的字符选择入口,或直接在字符面板里手动批量删掉多余项。清理干净后,后面导出图集才够干净。
字符集里尽量把可能用到的符号一次备齐。小数点和负号很容易漏,冒号在倒计时里会用到,如果做金额增减可能还要加+、$、%。宁可在图集里多放两三个字符,也不要在项目上线后因为缺符号再去烘焙一次。注意-和–是不同字符,BmFont按Unicode区分,你要哪个就写哪个。
2.2 字号、内边距、间距这三个参数怎么定
BmFont主窗口左侧是字体设置,Size就是渲染字号,单位是像素。这里有个常见误解:字号越大图集越清晰。对位图字体来说,真正决定最终清晰度的是“在UI上实际显示的像素尺寸”和“图集中字号”的倍率关系。假设UI里数字占屏幕高度30像素,那字号做到32或64就行;如果你做了2倍超采样,把字号开到128也行,但至少要保证显示时按接近原大小的整数倍缩放,避免半像素插值带来的模糊。
接下来是Padding。这个参数控制每个字符周围额外留多少透明像素。通常设1到2像素就够了。它的作用有二:一是防止相邻字符在渲染时边缘互相牵连,尤其是加了描边或投影的字体,没Padding就会出现黑色描边串到旁边字符里的情况;二是给smooth边缘留一点过渡缓冲,让图集采样时不至于切到相邻字模。喜欢把字体效果拉到极限的话,Padding设2更稳妥。
Spacing在BmFont里控制的是导出时字符之间的空隙,同时也影响fnt中xadvance的基准表现。如果你希望数字最终显示得紧凑一些,Spacing可以设1;如果担心UI里数字串读起来太挤,就设2到3。这个值不是多多益善,因为UI侧拼接时还要考虑对齐,导出阶段间距拉得太大,后续想缩小只能改图重烘焙。一个折中办法是导出的xadvance取偏紧的值,实际显示时在Unity侧按需增加间距,灵活性更高。
Smoothing项默认是开的,它会对字符边缘做抗锯齿。做像素风数字时记得关掉,否则边缘会发灰,失去锐利的像素感。普通手绘数字或圆润字体则建议保留平滑,观感更好。Outline和Shadow这类增强选项可以在BmFont里直接做进图集,但我的经验是尽量在美术侧或Unity Shader里做,因为BmFont自带描边选项可控性有限,而且会影响Padding的判断,容易在导出后出现边缘被切的情况。
2.3 导出格式与fnt文件结构
在Options的Export Options里,输出格式选Text,图集位图选PNG。Text格式的fnt是可读的,方便写解析脚本,调试时也能直接用文本工具查看坐标。试过二进制格式,解析起来麻烦,没有额外性能收益,不推荐。
点导出后会生成两个文件,一个是xx.fnt,一个是xx_0.png。打开fnt文件,内容大致长这样:
info face="MyFont" size=64 bold=0 italic=0 charset="" unicode=1 common lineHeight=64 base=52 scaleW=256 scaleH=128 pages=1 packed=0 page id=0 file="MyFont_0.png" chars count=13 char id=48 x=2 y=6 width=38 height=52 xoffset=2 yoffset=8 xadvance=40 page=0 chnl=15 char id=49 x=42 y=6 width=24 height=52 xoffset=3 yoffset=8 xadvance=28 page=0 chnl=15 char id=50 x=68 y=6 width=38 height=52 xoffset=2 yoffset=8 xadvance=40 page=0 chnl=15看懂这行比什么都重要。以char id=48为例,48是字符0的ASCII码。图集内横向位置x=2、纵向位置y=6,字符在图集中的像素宽高是width=38 height=52。真正显示时,字符相对基准线左移xoffset=2像素、上移yoffset=8像素,同时光标向后推进xadvance=40像素。page表示用第几张图集,数字场景一般只有一张;chnl=15表示RGBA四个通道都有效,如果做单通道字体,后面写代码时要注意。
这份fnt是Unity侧一切几何计算的来源,任何字符错位、间距异常,最终都要回到这几列数据查根因。所以我的习惯是导出后第一时间打开fnt检查scaleW、scaleH是否和PNG实际尺寸一致,再随便挑两个数字的坐标验证一下是否正确对应。
3. Unity接入:导入、解析、渲染打通链路
3.1 PNG导入设置不能抄默认值
把PNG拖进Unity工程后,第一件事是改导入设置。数字图集是UI素材,要把Texture Type改为Sprite (2D and UI)。如果后续打算在运行时用Sprite.Create裁切,就还要勾选Read/Write Enabled,否则脚本里读取像素数据会报错。Alpha Is Transparency勾上,避免边缘出现黑圈。Generate Mip Maps关掉,UI数字在屏幕上不需要Mipmap,开着反而增加内存和采样开销。
Mesh Type建议选Full Rect。默认的Tight会根据透明像素收紧网格,对普通精灵没问题,但数字图集的字符形状不规则,Tight网格在UGUI里的包围盒计算容易出意外,干扰排版定位。Full Rect让每个字符都是一个完整的矩形,逻辑清晰。
图集尺寸一般很小,256×128或256×256就够装下十来个字符。不要为了“防模糊”把字号拉到512再导出一张1024大图,位图字体只有在整数倍缩放下才锐利,盲目加大字号和贴图尺寸,既浪费内存又不见得清晰。
3.2 三种接入方案怎么选
Unity侧拿到fnt和PNG后,有三条路可走,选择依据是场景复杂度。
第一种是使用UGUI的自定义字体。在Project窗口创建Custom Font,把PNG指定为Font Texture,再把字符表填好。这个方案最轻量,能让Text组件直接输出数字,但问题在于Unity的自定义字体并不读取fnt里的xadvance偏移,它只会按你手动指定的字符宽度去排版,数字间距容易失控,调整起来非常别扭。适合字符等宽且不要求精密控制的场景,不推荐作为主力方案。
第二种是TextMeshPro的Sprite Asset。在TMP里可以建立图集精灵,然后在富文本中用<sprite name="n0">引用某个数字。优点是和TMP排版体系完美融合,彩色、描边、阴影都方便;缺点是做动态数字串时需要自己拼接<sprite>标签,操作繁琐,而且要手动维护Sprite定义,批量接入不够顺畅。
第三种是我现在的主力方案:自行解析fnt,用自定义组件按字符几何信息渲染图集。听起来复杂,其实写一次解析加一次渲染,之后所有数字显示都走同一份数据,间距、偏移、对齐全部可控,还能顺手支持变色和描边。后续美术改风格,替换PNG和fnt就完事。下面重点讲这套方案。
3.3 解析fnt与构建渲染数据
首先写一个简单的fnt解析类。核心思路是按行读取,只关心common和char两类行。common里保存scaleW、scaleH,char行则拆出ASCII码和七项几何数据。
public class BmFontData { public int scaleW; public int scaleH; public Dictionary<char, CharInfo> chars = new Dictionary<char, CharInfo>(); public class CharInfo { public int x, y, width, height; public int xoffset, yoffset, xadvance; } }解析时逐行匹配,把每行char id=48 x=...这类字段用正则或字符串拆分提取出来。解析完成后,渲染侧就能通过字符查坐标。要注意的是fnt中的y是从图集顶部开始计的,而Unity Sprite的Rect是以左下角为原点,所以取字符矩形时要做一次翻转,否则数字会上下颠倒、错位。
Rect rect = new Rect( info.x, texture.height - info.y - info.height, info.width, info.height);拿到Rect后,就可以为每个需要显示的数字创建Sprite,或者直接在自定义Mesh上拼UV。如果只是UI上一个计分数字,我是用Sprite.Create把单字符抠成Sprite,放在Image里逐个排版。实际项目中因为需要频繁动态刷新倒计时,我更推荐直接继承MaskableGraphic重写OnPopulateMesh,一次性把整串数字构建成网格,既省去字符节点开销,又能整体控制颜色和排列。
3.4 网格构建逻辑避坑
构建网格的逻辑不复杂:从左往右遍历字符串,对每个字符查fnt数据,画一个宽度为info.width、高度为info.height的矩形,矩形左上角相对基准点的位置由xoffset、yoffset决定,画完后当前绘制起点前进xadvance像素。唯一要注意的是等宽数字和比例数字混排时,负号、小数点会让数字串宽度不稳定,所以对齐前要先用完整字符串把总宽度算出来,再整体平移,这样才能保证居中或右对齐结果正确。
例如显示“12.5”这样一个字符串,小数点宽度通常只有几个像素,如果不按fnt里的xadvance推进而是假设每个字符等宽,小数点就会压到旁边的数字上。fnt数据里每个字符的advance都不同,比例排版才自然。
这套渲染方式不受UGUI字体系统限制,颜色可以直接用顶点色控制。要让数字在UI里变色,只需修改Mesh顶点颜色,不必做多套颜色图集。想要描边或阴影,也纯粹是Shader层面的工作,BmFont导出的白色字符在乘法着色下能灵活适配各种UI状态。
4. 实战扩展:飘字、倒计时和多尺寸适配
4.1 伤害飘字和计分器的套路
把上一节的网格组件封装好之后,伤害飘字就是最典型的应用场景。飘字的特点是显示时间短、刷新频繁、字符长度变化大。用BmFont数字图集,实际上可以做到完全不分配GC。解析好的fnt字典是静态的,网格数据在OnPopulateMesh里复用,每次更新字符串只是修改顶点位置和UV,没有临时对象生成。这是动态字体很难做到的地方。
飘字实现时还要考虑排序和层级。UGUI框架下,飘字一般是单独的Canvas或专门的SortingGroup,数字组件本身只需要暴露一个SetValue(string value)方法,内部重新计算所有顶点就行。对位移动画,常规做法是直接驱动RectTransform位置,或者把网格组件挂到粒子节点下,让飘字跟随粒子一起飞,同时插入随机扰动。由于数字组件不依赖字体纹理之外的资源,多个飘字实例共用同一张图集,合批非常顺畅。
4.2 多尺寸适配:一套图集不够怎么办
位图字体最怕被非整数倍缩放。UI里同一个数字,有时在商店界面是48像素,战斗飘字要72像素,结算面板又要32像素。如果只烘焙一档字号,放大缩小都会出现边缘模糊,小倍率又显得过细。
实际上多尺寸适配不外乎两条路。第一条是准备多档图集,按实际显示尺寸精确匹配或近似匹配;第二条是让数字始终按整数倍缩放,比如字号统一烘焙为32,需要64就放大2倍,需要96就放大3倍,但不接受1.7这种小数倍率。如果美术数字是标准像素风,第二种做法效果最好;如果是平滑抗锯齿手绘数字,放大插值带来的柔化通常在UI观感里不算致命,可以接受。
多档图集要在BmFont里分别烘焙,比如32号、64号各导出一份,命名带上尺寸后缀,运行时根据当前UI尺寸选择合适的图集。Unity侧解析器和渲染组件不必改动,只要在切换图集时重新绑定fnt数据和纹理即可。实际项目里我一般准备两档,小号用于常规文字,大号用于飘字或主角血量显示,Cover大多数场景。
4.3 自动化:用编辑器脚本批量烘焙
BmFont本身是GUI工具,手动点导出一次没问题,但数字样式改个描边颜色、换套配色,再手工烘焙就太慢了。合理的做法是用命令行或脚本控制BmFont导出。BmFont支持通过配置文件传参启动,把字符集、字号、Padding、输出路径都写进配置文件,每次只需要更新字体文件再跑一条命令,Unity侧的资源刷新流程再配合Editor脚本重新导入fnt和PNG。
在Unity里,还可以写一个AssetPostprocessor,当新的fnt和PNG文件被导入时自动做校验,比如检查fnt的scaleW和PNG实际尺寸是否一致,检查所有在数字白名单里的字符是否都存在。任何不一致都直接在Console抛错误,避免游戏运行时才发现某个数字缺失。
这套自动化看着花哨,实际价值在于让美术可以独立迭代数字风格。美术改完TTF或交付PNG后,跑一遍批处理,Unity工程里刷新一下就能看到效果,不需要开发者手动同步fnt和Sprite坐标,协作效率提升非常明显。
5. 踩坑实录与排查速查表
5.1 数字串在一起或间隙过大
最常遇到的是数字之间间隙忽大忽小。出现这个问题的第一嫌疑就是xadvance。如果BmFont导出时Spacing设得过大,fnt里所有字符的advance值都会被拉大,Unity侧拼接起来自然松散。如果只有个别字符间隙异常,检查是不是字符集里混入了全角字符或不同Unicode编码的同形字符。空格、不可见字符占用advance但显示为空白,也会造成似有似无的间距。
实际操作中我建议在Unity侧保留一个“额外间距”参数,初始为0。因为BmFont导出的advance是设计值,UI排版时可能存在其它因素,比如描边向外扩张、数字需要更宽松的呼吸感。有了动态加成,就不用反复重新烘焙图集。
5.2 数字高度不对齐
单个数字看起来正常,拼成“123”后上下跳动,问题多数出在yoffset和base对齐逻辑上。fnt里每个数字的矩形高度可能不同,比如1通常比0窄且高,但其基准线是一致的。渲染时必须让所有字符使用同一个基准Y坐标,并按各自的yoffset向上微调,不能简单地把每个字符都从网格顶部对齐。
我早期做这套组件时犯过一个低级错误:直接把字符串遍历时生成的字符网格垂直居中,结果数字串显示歪歪扭扭。后来改成统一按fnt的lineHeight和base字段计算位置,才彻底解决。简单说,所有字符共享同一行顶边位置,具体每个字符的上下偏移都严格从fnt读取,不要自己加居中逻辑。
5.3 图集出现白边或边缘发灰
这类问题几乎都是图集采样越界导致的。PNG图集里各字符之间虽然有透明间隙,但当显示尺寸缩得太小时,GPU采样会跨过间隙采到相邻字符的边缘,形成白边或颜色污染。解决办法是导出时确保Padding不小于2,并且Unity侧采样时不要使用Trilinear或过于激进的Filter模式。UI图集一般用Bilinear即可,像素风格字体则切换为Point滤波。
此外还要检查Alpha Is Transparency是否开启,以及Shader是否对Alpha边缘做了预乘处理。如果美术交付的PNG已经带上黑色背景或描边,在BmFont里调整Padding就无效了,需要回到素材源头,确认交付的是带透明通道的RGBA图。
5.4 Canvas缩放模式下变模糊
Canvas的Canvas Scaler设为Scale With Screen Size时,UI元素会按参考分辨率缩放。位图数字如果放大到非整数倍就会发虚。排查时先确认显示区域的最终像素尺寸与图集的字号是否成整数倍关系。如果两者差异较大,调节参考分辨率或改用更匹配的图集档位,比调整导入设置和Shader更有效。
还有个小细节是UI纹理的Generate Mip Maps。很多人觉得关掉Mipmap会让缩小显示变差,其实UI数字走的是Canvas的网格渲染,缩小显示时引擎已经做了相当于Mipmap的处理,开着Mipmap反而会因为选择低清层导致数字笔划变淡。实践下来,数字图集关掉Mipmap是更稳的选择。
最后再分享一个小技巧。BmFont导出的fnt和PNG在Unity里算一对资源,时间久了容易失联。我会在fnt导入后写一行校验逻辑,用AssetDatabase.LoadAssetAtPath检查同名的PNG是否存在,并把fnt的scaleW和PNG的width做比对。如果人工手动改过图集尺寸,这里立刻就能发现。数字图集看起来是个小功能,但它牵涉到工具链、资源规范、渲染管线和界面适配好几个环节,把这些环节全部理顺,后面做任何带自定义数字的UI都顺风顺水。