
说到底做游戏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 faceMyFont size64 bold0 italic0 charset unicode1 common lineHeight64 base52 scaleW256 scaleH128 pages1 packed0 page id0 fileMyFont_0.png chars count13 char id48 x2 y6 width38 height52 xoffset2 yoffset8 xadvance40 page0 chnl15 char id49 x42 y6 width24 height52 xoffset3 yoffset8 xadvance28 page0 chnl15 char id50 x68 y6 width38 height52 xoffset2 yoffset8 xadvance40 page0 chnl15看懂这行比什么都重要。以char id48为例48是字符0的ASCII码。图集内横向位置x2、纵向位置y6字符在图集中的像素宽高是width38 height52。真正显示时字符相对基准线左移xoffset2像素、上移yoffset8像素同时光标向后推进xadvance40像素。page表示用第几张图集数字场景一般只有一张chnl15表示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 namen0引用某个数字。优点是和TMP排版体系完美融合彩色、描边、阴影都方便缺点是做动态数字串时需要自己拼接sprite标签操作繁琐而且要手动维护Sprite定义批量接入不够顺畅。第三种是我现在的主力方案自行解析fnt用自定义组件按字符几何信息渲染图集。听起来复杂其实写一次解析加一次渲染之后所有数字显示都走同一份数据间距、偏移、对齐全部可控还能顺手支持变色和描边。后续美术改风格替换PNG和fnt就完事。下面重点讲这套方案。3.3 解析fnt与构建渲染数据首先写一个简单的fnt解析类。核心思路是按行读取只关心common和char两类行。common里保存scaleW、scaleHchar行则拆出ASCII码和七项几何数据。public class BmFontData { public int scaleW; public int scaleH; public Dictionarychar, CharInfo chars new Dictionarychar, CharInfo(); public class CharInfo { public int x, y, width, height; public int xoffset, yoffset, xadvance; } }解析时逐行匹配把每行char id48 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都顺风顺水。