在 Unity 项目里做排行榜、计分器、货币飘字时,我经常面临一个看似简单的问题:怎么显示一坨不停变化的数字。最初我会直接拉一个 Text 组件,然后text.text = score.ToString(),原型阶段确实爽,但等数字多起来、滚动频繁起来,问题就藏不住了。后来我开始用 BmFont 把数字做成位图图集,让 Unity 的 UI 直接切换 Sprite,效果出奇地好。这篇文章就完整记录我怎么用 BmFont 制作 Unity 可用的图集数字,从字体导出到 Sprite 切片,再到滚动数字组件,一步不落,希望能帮到同样被动态数字折磨的朋友。
1. 为什么我要用 BmFont 做图集数字
1.1 动态数字渲染的痛点
在 Unity 里显示得分,第一反应基本都是创建一个 Text 组件,然后把 int 转成字符串塞进去。这个方案在原型阶段完全没问题,项目中期就开始难受了。动态字体首次创建字符图集要卡一下,每次数字变化都会触发字符缓存重建,频繁赋值字符串还会产生 GC 压力。如果你做的是一个实时滚动的伤害飘字系统,上百个 Text 同时改内容,瓶颈会非常明显。
像素风游戏更痛苦。普通 TTF 字体放大到整数倍,边缘经常出现灰色过渡像素,要不就糊成一团。你当然可以调 Font Settings,但引擎的字体渲染总归是黑盒,你不知道它到底给你生成成什么样。TextMeshPro 我也试过,确实强,但只为了显示 0-9 和几个符号,就要维护一套字体资源、调各种 Fallback,沉没成本不小。
所以我把思路切回最朴素的方案:把数字提前渲染成一张透明底图的 PNG,再用 BmFont 把每个数字的位置信息写到 FNT 文件里,Unity 侧按 FNT 切出若干 Sprite。数值显示从“文字排版”变成了“图片切换”,速度快、可控性强,而且在任何分辨率下只要缩放控制得当都非常锐利。
1.2 BmFont 方案到底解决什么问题
BmFont 是经典的位图字体生成工具,它的工作方式很像把字体变成贴图字典。你把一套 TTF 或 OTF 字体里需要的字符丢进去,它按指定字号、边距、间距把所有字形排布到一张或多张 PNG 上,同时生成一个文本描述文件 FNT,记录每个字符在贴图中的矩形坐标、偏移和前进量。
Unity 本身不会解析 FNT,但“制作图集数字”这一步只需要拿到那张 PNG 和对应坐标即可。坐标信息可以自己写脚本读,也可以照着数值在 Sprite Editor 里手动切。相比直接在 UI 上画 Text,图集数字有几个直接好处:
- 渲染路径极简,Image + Sprite 就是标准 UI 元素,材质和纹理统一,容易合批。
- 字符是设计阶段就确定的结果,运行时不存在字体缺失或样式不一致。
- 像素风游戏可以关闭抗锯齿,导出最锐利的点阵字形,避免引擎二次平滑。
缺点也很明确:需要提前做资产,换字体要重新导出。但对数字这种固定字符集来说,这个代价基本可以忽略。
1.3 BmFont 的本质:PNG + FNT
很多人一听位图字体觉得高大上,其实拆开看就两个文件。PNG 是字符图集,FNT 是字符索引表。FNT 里每一行char id=48 x=... y=... width=... height=...都告诉你某个字符在整张 PNG 的哪个矩形区域里。数字 0 的 ASCII 码是 48,数字 1 是 49,以此类推。理解了这一点,后面不管是手动切图还是写脚本,心里都有底。
2. 制作前需要想清楚的几件事
2.1 字符集规划比调字体参数更重要
做 BmFont 之前,先把需要的字符全部列出来。数字 0-9 肯定要;做倒计时需要冒号;显示负数必须加负号;金币可能显示到小数点后两位,那就需要小数点;排行榜里经常出现分数分隔符,比如12,345,逗号也要带。我一般默认的字符集是:
0123456789-.:/% ,
最后一个空格属于“占位符”,用在数字长度不定的时候保持排版稳定。少了任一符号,后面都要重新导出一遍并更新 Sprite 资源,很麻烦。所以第一步宁可多带一个符号,也别少带。
要注意字符集里不要混入全角数字或全角标点,否则输出后宽度和视觉效果都会跑偏。在 BmFont 的字符选择窗口里直接粘贴 ASCII 字符集,比一个个点选更稳妥。字符数量越少,图集越小,这也是位图字体的一大优势:用多少带多少,不要为了凑数把用不到的字母也塞进去。
2.2 字体源、尺寸和风格的选择
字体选型直接影响最终效果。BmFont 只是一个渲染工具,你导入的 TTF/OTF 该有什么版权还是有什么版权。个人练习随便找一款免费字体没问题,商业项目一定要确认可商用授权。这些年我用过不少开源像素字体,数字显示基本都够用。没有合适的像素字体,也可以用普通无衬线字体关掉平滑,再调大字号。
我的经验是:尺寸按实际显示尺寸的 2 倍导出。比如游戏里数字显示高度是 24 像素,BmFont 里就设置 48。这样 UI 缩小时还有冗余,放大时点对点采样也不至于糊成一团。当然图集会变大,但数字就那么十几个,通常一张 256×256 或 512×512 完全够用。如果项目是纯像素风,建议导出时把抗锯齿关掉,字体边缘保持硬边;如果走现代卡通或拟物风格,保留灰度抗锯齿,让 Unity 用 Bilinear 过滤。风格没有绝对答案,但一开始就要决定,因为后续 Sprite 的 Filter Mode、材质和阴影方案都受它影响。
2.3 输出参数速查:Padding、Spacing、Bit Depth
很多人在 BmFont 导出后发现 Unity 里字被裁边,或者出现黑边,问题大多出在导出参数。我常用的参数如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Size | 目标显示高度 × 2 | 给 UI 缩放留冗余 |
| Padding | 4 ~ 8 | 防止抗锯齿边缘被切,像素风可以设 2 |
| Spacing | 2 ~ 4 | 字符间距,影响后续文字整体密度 |
| Bit Depth | 32 | 必须,否则会丢透明通道 |
| Smoothing | 按风格 | 像素风关闭,非像素风开启 |
| Font descriptor | Text | 导出 FNT 用文本格式,方便解析 |
这里重点说 Bit Depth。BmFont 导出纹理时如果选了 8 位,输出的是 256 色索引图,透明通道经常变成黑色底,导入 Unity 后你会看到整张黑底图。选 32 位 RGBA 才能保证字形是透明背景。Padding 是每个字符矩形四周额外保留的像素,主要给抗锯齿边缘留出生空间,太小会裁掉笔画,太大则让字符间空隙变大且图集变大。
另外,导出的纹理尺寸尽量让 BmFont 输出一页,不要在打包设置里把单页限制得太小。字符拆到多张 PNG 后,Unity 合批时经常要切换纹理,会影响性能。
3. BmFont 完整导出流程
3.1 设置字体和字符集
我用的是 BmFont 常规版本,解压后直接运行 exe,不需要安装。启动后界面不算复杂,上方是菜单和预览区,左侧是字体设置。先点Options -> Font Settings,在字体选择里找到准备好的 TTF 或 OTF 文件,设置字号、是否加粗、字符间距等。
然后设置字符集。打开Edit -> Select Chars...,在输入框里粘贴刚才列的字符集,例如:
0123456789-.:/% ,点确定后,主预览区会显示所有被选中的字符。我会重点检查两点:一是有没有缺字符,二是数字 1 和数字 0 的宽度差异是否太大。如果数字 1 特别窄,说明这个字体不是等宽数字,后面做数字滚动时每个位之间会有轻微晃动。这种情况下我一般直接换“tabular figures”风格的字体,或者后续在 Unity 里统一固定每个数字槽的宽度。
3.2 导出设置和保存
在Options -> Export Options里设置:
- Bit depth 选 32。
- Padding 填 4。
- Spacing 填 2。
- Smoothing 根据你的游戏风格开或关。
- 字体描述文件格式选 Text。
- 纹理格式选 PNG。
设置完就可以保存。点File -> Save Bitmap Font As...,填一个文件名,比如score_number,保存后目录里会出现score_number.fnt和score_number_0.png。
有一个坑:BmFont 保存的 PNG 文件名可能带_0后缀,FNT 里的page行会记录具体文件名。不要手动改这两个文件的名字,否则后面脚本找不到贴图。我自己第一次就把 PNG 改成了“number.png”,结果还要手动改 FNT 里的引用,纯属自找麻烦。
3.3 导出后的 FNT 文件怎么看
用记事本打开 FNT,内容类似下面这样:
info face="pixel.ttf" size=64 bold=0 italic=0 charset="" stretchH=100 smooth=0 aa=0 padding=4,4,4,4 spacing=2,2 common lineHeight=72 base=60 scaleW=256 scaleH=128 pages=1 packed=0 page id=0 file="score_number_0.png" chars count=13 char id=48 x=4 y=4 width=33 height=47 xoffset=0 yoffset=8 xadvance=36 page=0 chnl=0 char id=49 x=41 y=4 width=18 height=47 xoffset=8 yoffset=8 xadvance=36 page=0 chnl=0关键信息都在char行里。id是字符 ASCII 码,x、y、width、height是字形在整张贴图里的像素矩形。注意这里的y是“从上往下”的坐标,而 Unity 的 Sprite 坐标系原点是左下角,后面写脚本时必须翻转。
xoffset和yoffset是绘制时相对基准线的偏移,xadvance是绘制完这个字符后笔头前进多少像素。做图集数字拼接时,一般可以用xadvance作为每个数字格子的宽度,这样字间距和原始排版保持一致。
4. Unity 导入与自动切图
4.1 贴图导入参数设置
把 PNG 和 FNT 一起放进 Assets 目录。先选中 PNG,在 Inspector 里设置:
- Texture Type 选择
Sprite (2D and UI)。 - Sprite Mode 选择
Multiple,如果使用自动脚本,脚本会帮你改。 - Pixels Per Unit 建议设为 100,这样用 SetNativeSize 时一个像素对应一个单位,方便控制尺寸。
- Generate Mip Maps 关闭。数字图集很小,根本不需要 Mipmap,还能省显存。
- Filter Mode 按风格选择。像素风用 Point,非像素风保留 Bilinear。
- Compression 建议选 None,数字图集很小,没必要额外压缩。
如果是用下面的 Editor 脚本自动切图,脚本会自动调整部分设置。如果手动切,也要先把 Sprite Mode 改成 Multiple。
4.2 借助 Editor 脚本读取 FNT 生成 Sprite
手动切片其实很烦,尤其字符多了以后。我习惯写一个小的 Editor 脚本,放在 Editor 文件夹下,选中 FNT 文件后右键执行,脚本会自动解析 FNT,把 PNG 切成对应的 Sprite 子资源。
using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEngine; public static class BmFontImporter { [MenuItem("Assets/BmFont Import")] public static void ImportFnt() { string fntPath = AssetDatabase.GetAssetPath(Selection.activeObject); if (string.IsNullOrEmpty(fntPath) || !fntPath.EndsWith(".fnt")) { Debug.LogError("请先选中一个 .fnt 文件"); return; } string dir = Path.GetDirectoryName(fntPath); string[] lines = File.ReadAllLines(fntPath); string pngFile = null; var metas = new List<SpriteMetaData>(); foreach (string line in lines) { if (line.StartsWith("page ")) { int firstQuote = line.IndexOf('"') + 1; int lastQuote = line.LastIndexOf('"'); pngFile = line.Substring(firstQuote, lastQuote - firstQuote); } else if (line.StartsWith("char id=")) { var fields = ParseFields(line); int id = int.Parse(fields["id"]); int x = int.Parse(fields["x"]); int y = int.Parse(fields["y"]); int w = int.Parse(fields["width"]); int h = int.Parse(fields["height"]); if (w <= 0 || h <= 0) continue; // BmFont 的 y 是从上到下,Unity Sprite 坐标是从下到上,所以翻转 Texture2D tex = AssetDatabase.LoadAssetAtPath<Texture2D>( Path.Combine(dir, pngFile).Replace("\\", "/")); if (tex != null) { y = tex.height - y - h; } var meta = new SpriteMetaData(); meta.name = "char_" + id.ToString(); meta.rect = new Rect(x, y, w, h); meta.pivot = new Vector2(0.5f, 0.5f); metas.Add(meta); } } if (pngFile == null) { Debug.LogError("FNT 文件里没有找到 page 行,是不是格式不对?"); return; } string pngPath = Path.Combine(dir, pngFile).Replace("\\", "/"); TextureImporter importer = AssetImporter.GetAtPath(pngPath) as TextureImporter; if (importer == null) { Debug.LogError("找不到贴图文件:" + pngPath); return; } importer.textureType = TextureImporterType.Sprite; importer.spriteImportMode = SpriteImportMode.Multiple; importer.mipmapEnabled = false; importer.filterMode = FilterMode.Point; importer.textureCompression = TextureImporterCompression.Uncompressed; importer.spritesheet = metas.ToArray(); AssetDatabase.ImportAsset(pngPath, ImportAssetOptions.ForceUpdate); Debug.Log($"BmFont 图集导入完成,共 {metas.Count} 个字符"); } static Dictionary<string, string> ParseFields(string line) { var dict = new Dictionary<string, string>(); string[] arr = line.Split(' '); for (int i = 1; i < arr.Length; i++) { string s = arr[i]; int eq = s.IndexOf('='); if (eq < 0) continue; string key = s.Substring(0, eq); string value = s.Substring(eq + 1).Trim('"'); dict[key] = value; } return dict; } }用法很简单:在 Project 窗口选中 FNT 文件,点击菜单Assets -> BmFont Import。脚本会把同目录的 PNG 切成多个 Sprite,名称类似char_48、char_49。展开 PNG 的导入预览,就能看到这些子资源。
这个脚本有个好处:BmFont 参数调整后,只要重新执行导入,图集就自动更新,不需要手动删了再切。如果你的 FNT 是二进制格式,脚本读不了,导出时记得选 Text 格式。
4.3 如果没有脚本,Sprite Editor 手动切图的兜底办法
有些朋友看到代码就头疼,也不是不能用最笨的办法。把 PNG 导入后设置为 Multiple,打开 Sprite Editor,用 Slice 的 Grid By Cell Size 功能切。网格大小参考 FNT 里最大的字符宽高,比如宽度取 36,高度取 50。切完可能有些格子多出透明区域,这不影响显示,只是图集空白多一点。
手动切图最大的问题是后续调整很麻烦。字符位置变了,你得重新切。所以我建议哪怕不熟悉编辑器扩展,也把上面的脚本复制进项目,一劳永逸。脚本就 100 多行,运行环境是 Unity 编辑器,不会打到发布包里。
5. 数字显示组件与滚动效果
5.1 最朴素的显示方式:每个数字一个 Image
图集切好的 Sprite 需要有人把它显示出来。最直接的办法是在 Canvas 下建一个数字容器,里面预分配若干个 Image,每个 Image 对应一个数字位。我用 HorizontalLayoutGroup 排布它们,每个 Image 的宽度用 FNT 里的xadvance,高度用lineHeight,这样切换数字时整个序列粗细几乎不变。
using UnityEngine; using UnityEngine.UI; public class SimpleNumberView : MonoBehaviour { public Image[] digits; public Sprite[] sprites; // 下标 0 对应数字 0,下标 1 对应数字 1,以此类推 public void Show(int value) { string str = value.ToString(); for (int i = 0; i < digits.Length; i++) { if (i < str.Length) { int index = str[i] - '0'; if (index >= 0 && index <= 9) { digits[i].sprite = sprites[index]; digits[i].enabled = true; } else { digits[i].enabled = false; } } else { digits[i].enabled = false; } } } }这个脚本假设 digits 数组从左到右排列。如果要把个位固定在最右侧,显示时从数组末尾开始赋值。正式项目里我更建议用下面通用的显示方案,把负号、小数点都一起处理。
5.2 支持负数和小数的通用显示方案
游戏里的数字往往不只是整数,伤害可能出现负数,金币可能带小数,倒计时还要冒号。这时可以做一个字符到 Sprite 的映射表,然后直接显示任意字符串。
using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; [Serializable] public class CharSpritePair { public char character; public Sprite sprite; } public class NumberDisplayer : MonoBehaviour { public Image[] slots; public CharSpritePair[] charSpritePairs; Dictionary<char, Sprite> map; void Awake() { map = new Dictionary<char, Sprite>(); foreach (var pair in charSpritePairs) { if (pair.sprite != null) map[pair.character] = pair.sprite; } } public void DisplayText(string text) { if (slots.Length == 0) return; for (int i = 0; i < slots.Length; i++) { if (i < text.Length && map.TryGetValue(text[i], out var spr)) { slots[i].sprite = spr; slots[i].gameObject.SetActive(true); } else { slots[i].gameObject.SetActive(false); } } if (text.Length > slots.Length) { Debug.LogWarning($"显示内容超出槽位数量:{text}"); } } public void DisplayInt(int value) { DisplayText(value.ToString()); } public void DisplayFloat(float value, int decimals = 2) { DisplayText(value.ToString("F" + decimals)); } }在 Inspector 里把切好的char_48到char_57分别拖给字符0到9,再把负号、小数点、冒号对应好。为了性能,数字长度变化时尽量不要频繁SetActive(false),因为 SetActive 会引起 Canvas 重建。更好的做法是准备一个透明占位 Sprite,所有槽位一直保持激活,切换时把对应槽位设成占位 Sprite。
5.3 数字滚动动画实现
图集数字最常见的应用场景是金币或得分滚动。原理就是协程里持续更新显示值,每帧切一次 Sprite。图片切换没有字体重建,所以速度很快。
using System.Collections; using UnityEngine; public class RollingNumberDisplayer : NumberDisplayer { int currentValue; public void RollTo(int target, float duration = 0.6f) { StopAllCoroutines(); StartCoroutine(RollCo(target, duration)); } IEnumerator RollCo(int target, float duration) { int from = currentValue; float timer = 0f; while (timer < duration) { timer += Time.deltaTime; float t = Mathf.Clamp01(timer / duration); currentValue = Mathf.RoundToInt(Mathf.Lerp(from, target, t)); DisplayInt(currentValue); yield return null; } currentValue = target; DisplayInt(target); } }如果你想让滚动看起来更有弹性,可以把t做一次缓动,比如t = 1 - Mathf.Pow(1 - t, 2),先快后慢。如果数字从 1 滚到 10000,中间数字会快速跳动,这是正常现象。想看起来更平滑,就缩短刷新间隔或用固定帧率更新,但这些都属于观感调优,不影响图集方案本身。
5.4 关于合批和性能的实测心得
所有数字 Sprite 都来自同一张 PNG,材质是默认 UI 材质,放在同一个 Canvas 下,Unity 会把这些相邻的 Image 合成批次。实测过 100 个数字 Image,只要中间不掺杂其他纹理的 UI 元素,DrawCall 能压到极低。这也是图集数字相比 Text 组件最大的优势。
要注意,同一个 Canvas 下如果有不同纹理的图集,会打断合批。数字区最好单独放一层,或者在面板设计时把数字节点和其他图标分开。Image 的 RaycastTarget 如果不需要点击,一定关掉,否则事件系统会多出额外开销。滚动动画里 60 帧每秒更新 6 个 Image,在现代硬件上完全没有压力。如果你觉得value.ToString()每次产生小字符串不划算,可以自己写 int 转 char 数组的工具方法,但大多数项目到不了这个瓶颈。
6. 常见坑与排查解答
6.1 数字发虚、白边、位置乱跳
我把常见现象整理成一个速查表:
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 数字发虚 | 原图字号太小,放大倍数太高 | BmFont 里字号做成显示尺寸的 2 倍 |
| 边缘白边 | Bit Depth 没有选 32,或 Padding 太小 | 重新导出选 32 位,Padding 至少 4 |
| 切换数字时跳动 | 字符框宽度不同,Slot 尺寸跟随变化 | 固定 Slot 的 RectTransform,开 preserveAspect |
| 导入后是黑底 | 导出的位深是 8 位 | 回 BmFont 改成 32 位导出 |
| Sprite 上下颠倒 | FNT 的 y 坐标没有翻转 | 用脚本时记得y = texHeight - y - h |
位置跳动这个问题最容易忽视。如果每个数字 Image 都调用SetNativeSize,数字 1 会特别窄,数字 0 会特别宽,切换瞬间整个数字串都在抖。我自己的习惯是给每个数字位一个固定宽度,宽度取 FNT 里的最大xadvance或者直接统一为某个值,然后 Image 开启preserveAspect。这样数字 1 两侧会有透明空隙,但整体宽度稳定,观感会好很多。
6.2 FNT 坐标和 Unity 坐标不一致问题
FNT 的坐标系原点在左上角,Unity 的纹理坐标原点在左下角。如果手动解析 FNT 创建 Sprite,一定要做 y 翻转。上面的 Editor 脚本已经处理了,但如果你在网上抄了别人的运行时解析代码,很可能漏掉这一步。检查方法很简单:导出的 Sprite 如果数字整体上下颠倒了,那就是 y 坐标的问题。
另外,FNT 里的char id不一定是连续的。如果字符集包含负号和小数点,id会对应 ASCII 中的45和46,而不是 48~57。脚本用字符名称char_45也能区分,但配置 NumberDisplayer 时别搞混。
6.3 图集数字的扩展玩法
这套流程不止能做数字。排行榜里的“第 1 名”前三名奖牌、冒号、分隔符、字母,都可以用同样方式做。回到 BmFont 修改字符集,生成新的 FNT/PNG,重新执行导入菜单,Sprite 数量就变了。如果你的项目需要中文,那不建议用 BmFont 做全量图集,中文字符太多,图集膨胀后加载和内存都扛不住。数字、英文、符号这类固定的小字符集才是位图字体的主场。
后续还可以把读取 FNT 的逻辑从 Editor 搬到运行时,用Sprite.Create动态创建 Sprite,避免工程里出现一堆子资源。但这会要求纹理开启 Read/Write,并且增加启动解析时间。我个人还是更喜欢 Editor 导入法,子资源可以直接进 AssetBundle,运行时零解析成本。
最后再分享一个小技巧:把 BmFont 工程文件、字体源文件、导出参数截图、Unity 导入脚本组成一个模板目录,新项目需要数字显示时直接整个复制过去,改改字体和字号就能跑。我踩过几次坑之后就把这套流程固化了,每次遇到高频数字刷新需求,都会第一时间回到图集方案。表面上多做了一个资产,实际上后续省下的调优时间远比这点制作成本多。