做游戏UI的,十有八九都遇到过这种场景:策划甩过来一张艺术字参考图,火焰字、金属字、像素风标题,然后补一句“要能动态换文字”。用Unity自带的Text做吧,描边阴影调半天,出来还是一股“程序味”;让美术一张张出图吧,文字一多、换行一开、分辨率一适配,直接就想去拆UI框架。我在几个项目里来回踩了一圈之后发现,最稳的路线还是绕回到BMFont上。BMFont先把字体烘成“位图图集+坐标表”,再灌进Unity的文本系统,美术可以完全控制每个字的纹理效果,代码又依然能像操作普通文本一样做换行、对齐和动态赋值。这篇文章把我在BMFont参数配置、fnt文件解析、Unity侧接入三个环节里的实操记录完整写出来,适合做UI的、搞客户端工具的,以及想了解艺术字后期怎么维护的人参考。
1. 为什么游戏UI的艺术字多半绕不开BMFont
1.1 艺术字的本质是“画出来的字”,不是“渲染出来的字”
普通TTF字体是矢量轮廓,引擎在运行时把轮廓填充成像素。而游戏里常见的金属拉丝、火焰流动、水晶纹理、立体倒角、长投影、特殊描边,这些效果本质上都是美术在画布上“画”出来的,属于像素图案。你如果指望运行时让引擎完全还原这些效果,基本等于让图形程序员重写一个字体渲染器,投入产出完全不成比例。
BMFont解决这个问题的思路很朴素:在进入Unity之前,就把一个个字符提前烘成带美术效果的位图,再按统一规格打包进一张大图;运行时文本引擎根据“哪个字、在图集的哪个位置、占多大”去图里裁剪显示。字还是那个字,但显示内容是一张查表取出来的图片,这就是位图字体的核心思路。美术把字画成什么样,游戏里显示出来就是什么样,不存在运行时偏差。
1.2 Unity自带Text做艺术字的三处硬伤
第一,描边和阴影是程序计算的,效果非常单薄。UGUI的Outline和Shadow组件本质上是对顶点网格做几次偏移叠加,不支持渐变、纹理填充,更做不到字内花纹。想用普普通通的TTF做出那种“笔画中间有流光”的标题字,不可能。
第二,动态字体渲染受平台、字号、字体hinting影响很大。同一个字体,在Windows上和Android上打开,像素排布都可能不同;把字号从32调到33,笔画粗细可能突然跳变。艺术字讲究的就是像素级一致性,这种不可控完全不能接受。
第三,美术无法控制单个字符的特殊造型。比如第一个字带个龙头花纹,第二个字是普通的,这种定制需求对动态字体来说没有任何可操作的入口。BMFont可以直接把美术做好的整套纹理图集接管过来,谁画的谁负责。
1.3 BMFont在Unity字体管线里的角色定位
BMFont在整个流程里做两件事:生成阶段把字体文件转成PNG图集和fnt描述文件;接入阶段再由Unity通过Custom Font或TextMeshPro把fnt里的信息变成文本组件能识别的字符映射。
需要注意的是,BMFont并不是做“所有美术字”的万能药。像游戏Logo、节日活动主标题这种完全固定的展示文字,用PS排一张大图挂Image组件反而效率更高。真正发挥BMFont价值的是那些“需要动态排版”的内容:多语言文本、伤害数字、货币数量、道具名、聊天内容。这些内容运行时可能变化,但又要保持艺术字风格,位图字体几乎是唯一选择。
2. 用BMFont生成艺术字图集:一份可以照着抄的配置单
2.1 先理清输出产物:一张图加一个文本文件
BMFont的输入是TTF或OTF字体文件,输出两个文件:一张PNG图集,包含所有字符的像素;一个.fnt描述文件,记录每个字符在图集中的位置、偏移、宽度等布局信息。fnt文件在导出时可选Text、Binary、XML三种格式,我强烈建议选Text。
选Text不是因为Binary不好,而是Text格式是纯文本,可以在任何编辑器和Unity的Inspector里直接打开检查。排查问题时,看一眼char行就能定位某个字是否收录、坐标是否越界,效率高很多。下面这张表是产出物的基本组成:
| 文件 | 类型 | 作用 |
|---|---|---|
| font_0.png | 位图 | 所有字符的像素集合,一张或多张 |
| font.fnt | 描述 | 字符编码、图集坐标、偏移、advance等布局信息 |
| Material(Unity内创建) | 材质 | 让UI组件能采样PNG纹理并正常显示 |
2.2 Font Settings里的重点参数
打开BMFont后,点击Font Settings,这里决定了字模的基础质量。
Font选择制作艺术字之前的“草稿字体”,比如思源黑体、站酷字体。Size是生成字号,不是磅值,是像素高度。UI项目建议64起步,如果字体曲线复杂或者需要高清2倍适配,直接设128。字号太低时,曲线边缘会出现明显锯齿,尤其是加了描边之后,锯齿会被放大。我见过有人为了省图集空间用24字号烘中文,一放大全是毛刺,后面又重烘了一次,反而浪费时间。
CharSet这里建议选Custom,然后手动维护字符串。不要图省事选Unicode全字符集,几万个字符会把图集撑爆,绝大多数根本没用到。把项目文案里出现过的字去重后喂进去就够了,中文常用字大约3000到3500个。
Smooth和AntiAlias默认开启即可,如果是像素风艺术字需要锐利边缘,再把这两个关掉。Padding一定不要设0,它给每个字四周留边距。带描边的字Padding至少4到6像素,带外发光和阴影的至少8到16像素。这个参数设小了,字的发光尾巴会被裁掉,而且BMFont不会给你任何提示,直接看图集边缘就知道。
2.3 Export Options与Options的导出细节
Export Options里Bit depth选32位(RGBA)。如果只是单色字,8位Alpha通道可以省内存,但艺术字几乎都是彩色渐变纹理,必须32位才能保留RGB信息。Channels按默认勾选RGBA,让PNG携带完整彩色通道。
POT选项务必勾选,它会把图集尺寸对齐到2的幂次,Unity导入对非2幂纹理的处理兼容性更好。纹理尺寸可以先给2048x2048,字符少的时候BMFont会自动缩小。Options面板里Output format选Text,UV坐标翻转等一切问题都留到Unity侧解析文章,导出端不需要特殊配置。
这里有个从实际项目里踩出来的经验:尽量控制输出为1页,也就是只生成一张PNG。字符越多,图集可能被拆成font_0.png、font_1.png多个页面。Unity Custom Font一个字体通常只绑定一个材质、一个主纹理,多页意味着要么合并纹理,要么做多套字体,非常麻烦。控制字符数量、调大图集尺寸,都往“单页”这个方向靠。
2.4 两种艺术字加工流程的取舍
流程A:先用BMFont生成基础字模并导出PNG,然后在Photoshop里对整张图集做图层样式或滤镜。比如给所有文字叠加金属纹理、渐变映射、噪点、阴影效果。处理完之后保存成新的PNG,换到Unity里时fnt坐标完全不变,字的布局信息依然准确。这种流程适合批量字符,一套样式统一应用,维护成本最低。
流程B:美术直接在Photoshop里排版固定标题,导出整张大图挂Image组件。这种方式对静态内容效率极高,样式自由度完全不受字体工具限制,但代价是没有文本能力,不能换字、不能响应本地化语言切换。
选择标准其实很清晰:需要动态排版的内容,比如多语言、货币数量、飘字伤害,走流程A;纯固定展示的内容,比如主界面Logo、特定活动标题,走流程B。混用的团队很多,并不冲突。
3. FNT文件解剖:坐标系统、字段含义和Unity换算
3.1 一个真实fnt文本长什么样
fnt文本格式并不复杂,但每个字段都对应Unity侧的一块数据。下面是一段真实的导出示例:
info face="SourceHanSansCN-Bold" 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=77 base=64 scaleW=1024 scaleH=512 pages=1 packed=0 page id=0 file="font_0.png" chars count=128 char id=33 x=704 y=32 width=20 height=66 xoffset=18 yoffset=6 xadvance=54 page=0 chnl=15 char id=34 x=740 y=32 width=46 height=28 xoffset=20 yoffset=6 xadvance=66 page=0 chnl=15 ... kerning count=3 kerning first=34 second=33 amount=-2common行里的lineHeight是整行高度,base是基准线在行内的位置,scaleW/scaleH是图集尺寸,pages=1表示只有一张图。char行里的id是字符的Unicode码点,x/y/width/height是字符在图集中的像素矩形,xoffset/yoffset是这个字符相对基准线要偏移多少,xadvance则是该字符占用的水平推进距离,也就是下一个字符的起始x坐标。page=0表示字符在第0张图上。
3.2 坐标原点之坑:BMFont左上原点与Unity左下UV
BMFont的图集坐标原点在左上角,y轴向下增长;Unity的UV坐标原点在左下角,y轴向上增长。这个差异是Unity侧实现出现字符颠倒、错位的头号原因。
所以做UV映射时,不能直接用x / scaleW和y / scaleH,必须做y翻转。以字符矩形为例,图集里越靠下的像素,对应到Unity UV里v坐标越小。公式是:
- vBottom = 1 - (y + height) / scaleH
- vTop = 1 - y / scaleH
同理,顶点坐标也要把BMFont的y方向取反。BMFont里yoffset是相对基准线向下为正,而Unity的UI顶点坐标向上为正,所以字符的垂直顶点范围要写成负数:
- minY = -(yoffset + height)
- maxY = -yoffset
有一段时间我接的字体总是整体往上飘,排查下来就是顶点y没取负号。这类问题在解析层处理一次就行,后面不要再在UI组件层面做补偿,否则每个使用点都要维护一套特例。
3.3 从char记录换算UV与顶点的完整公式
把fnt的char行转成Unity的CharacterInfo,需要以下对应关系:
| BMFont字段 | Unity侧换算 |
|---|---|
| x, width | uvLeft = x / scaleW,uvRight = (x + width) / scaleW |
| y, height | uvBottom = 1 - (y + height) / scaleH,uvTop = 1 - y / scaleH |
| xoffset | vertexMinX = xoffset |
| xoffset + width | vertexMaxX = xoffset + width |
| yoffset + height | vertexMinY = -(yoffset + height) |
| yoffset | vertexMaxY = -yoffset |
| xadvance | advance = xadvance |
注意一个隐藏点:width和height已经包含了Padding和描边产生的边距。也就是说,字符的像素框可能比实际字形大,这在绘图时会让字与字的间距视觉上变宽。如果觉得间距太大,可以微调fnt里的xadvance,但不要动width和xoffset,否则采样位置会错。
3.4 kerning逐对字距:UGUI Text吃不到,TMP能吃到
fnt文件里可能还有kerning first=34 second=33 amount=-2这种行,表示字符对之间需要额外调整水平距离。西文字体特别依赖这个,比如“AV”两个字的间距如果不调,视觉上会很松散。
但很遗憾,Unity原生UGUI的Custom Font方案不支持逐对字距,Text生成器只会读CharacterInfo.advance,没有地方容纳kerning表。如果在乎西文排版的紧凑度,要么手动在字符串里补空格,要么直接走TextMeshPro方案,TMP的字距表能真正落地。
4. Unity侧接入:Custom Font的完整实现
4.1 材质和Shader也要对上号
创建Custom Font后必须给它绑定材质,材质决定UI组件采样纹理的方式。我建议用UI/Default而不是GUI/Text Shader。
GUI/Text Shader在旧版本里对灰阶Alpha做了特殊处理,如果你塞进去的是RGBA彩色图,显示出来容易发灰变暗。UI/Default输出更直接,PNG里是什么颜色就是什么颜色。材质上的Color也要保持白色,因为Text组件的顶点颜色会跟纹理颜色相乘,一旦Color不是白,整张字都偏色。
纹理导入设置同样有关:Texture Type选Sprite (2D and UI)或者Default都可以,WrapMode选Clamp,避免字符间出现边缘采样渗色。如果图集里字符之间有大量空白,Clamp`能有效防止边缘出现白色或异色细线。
4.2 fnt解析:把文本转成CharacterInfo数组
这是整个Custom Font方案的核心。下面这段代码可以直接用在运行时,也可以挪进Editor脚本生成Font资产:
using System; using System.Collections.Generic; using UnityEngine; public static class BitmapFontImporter { private class GlyphData { public int id; public float x, y, width, height; public float xoffset, yoffset, xadvance; } public static Font BuildFont(Texture2D atlas, string fntText) { Font font = (Font)ScriptableObject.CreateInstance("Font"); Material mat = new Material(Shader.Find("UI/Default")); mat.mainTexture = atlas; font.material = mat; float lineHeight = 0; float baseLine = 0; float scaleW = 1; float scaleH = 1; var glyphs = new List<GlyphData>(); string[] lines = fntText.Split('\n'); foreach (var rawLine in lines) { string line = rawLine.Trim(); if (line.StartsWith("common")) { baseLine = FetchFloat(line, "base"); lineHeight = FetchFloat(line, "lineHeight"); scaleW = FetchFloat(line, "scaleW"); scaleH = FetchFloat(line, "scaleH"); } else if (line.StartsWith("char ")) { var glyph = new GlyphData { id = (int)FetchFloat(line, "id"), x = FetchFloat(line, "x"), y = FetchFloat(line, "y"), width = FetchFloat(line, "width"), height = FetchFloat(line, "height"), xoffset = FetchFloat(line, "xoffset"), yoffset = FetchFloat(line, "yoffset"), xadvance = FetchFloat(line, "xadvance") }; glyphs.Add(glyph); } } var charInfos = new List<CharacterInfo>(); foreach (var g in glyphs) { var ci = new CharacterInfo(); float uvLeft = g.x / scaleW; float uvRight = (g.x + g.width) / scaleW; float uvTop = 1f - g.y / scaleH; float uvBottom = 1f - (g.y + g.height) / scaleH; ci.uvBottomLeft = new Rect(uvLeft, uvBottom, 0, 0); ci.uvBottomRight = new Rect(uvRight, uvBottom, 0, 0); ci.uvTopLeft = new Rect(uvLeft, uvTop, 0, 0); ci.uvTopRight = new Rect(uvRight, uvTop, 0, 0); float minX = g.xoffset; float maxX = g.xoffset + g.width; float minY = -(g.yoffset + g.height); float maxY = -g.yoffset; ci.vertBottomLeft = new Rect(minX, minY, 0, 0); ci.vertBottomRight = new Rect(maxX, minY, 0, 0); ci.vertTopLeft = new Rect(minX, maxY, 0, 0); ci.vertTopRight = new Rect(maxX, maxY, 0, 0); ci.advance = g.xadvance; charInfos.Add(ci); } font.characterInfo = charInfos.ToArray(); font.ascent = (int)baseLine; font.descent = (int)(lineHeight - baseLine); font.lineHeight = (int)lineHeight; font.fontSize = (int)lineHeight; return font; } private static float FetchFloat(string line, string key) { string marker = key + "="; int idx = line.IndexOf(marker, StringComparison.Ordinal); if (idx < 0) return 0; idx += marker.Length; int end = idx; while (end < line.Length && line[end] != ' ' && line[end] != '\t') end++; float result = 0; float.TryParse(line.Substring(idx, end - idx), out result); return result; } }代码里最重要的就是UV翻转和顶点y取负。CharacterInfo在Unity 2020以上版本里uv和vert都是Rect类型,上面的写法兼容当前API;如果项目还是老版本,可能需要把这些赋值改回Vector2,翻一下对应Unity版本的脚本参考即可。
4.3 挂到Text组件上,怎么验证效果
拿到BuildFont返回的Font对象后,赋给UGUI Text的Font字段,再把Text的fontSize设成与BMFont生成时完全相同的值。比如BMFont里Size填64,Unity里就设64,这样字符以原始像素尺寸显示,和UI Canvas缩放比例换算最直接。
然后关闭Rich Text,开启RaycastTarget与否看需求,先跑一遍看效果。如果出现异常,按下面的表排查:
| 现象 | 原因 |
|---|---|
| 文字上下颠倒或镜像 | UV的y翻转没做对 |
| 文字整体下沉或上浮 | 顶点minY/maxY符号反了 |
| 字间距明显偏大 | 误把width当成xadvance使用 |
| 文字发灰变暗 | 材质选成了GUI/Text Shader,或材质颜色不是白色 |
| 出现空方块、空白占位 | 字符集里没有收录这个字 |
4.4 编辑器批量生成Font资产
运行时CreateFont会每帧或每次进入场景都创建Font对象,有内存和性能开销。更合理的做法是写一个Editor菜单脚本,在导入PNG和fnt时自动生成Font资产并保存到Assets目录。
大致思路是:读取.fnt文本,调用BuildFont生成Font对象,然后把Font对象和材质一起用AssetDatabase.CreateAsset保存,Texture也通过AssetImporter设置好导入参数。美术更新字库后一键刷新,团队成员拿到的都是同一个字体资产,配置统一。
如果连BMFont的烘字也想自动化,可以用bmfont的命令行参数,把配置文件和输出路径写在构建脚本里。配合上面Editor生成逻辑,全套流程可以做成:字符集变更,重新烘PNG,Unity侧自动生成Font资产,全程不需要手摸工具。
5. 进阶取舍:TextMeshPro、多语言和运行时维护
5.1 TMP方案:更适合复杂UI的接入路线
如果项目UI已经全面转向TextMeshPro,UGUI Custom Font方案就显得有些原始了。社区里常见的做法是用转换工具把fnt和PNG转成TMP_FontAsset,核心逻辑并不复杂:解析fnt的每个char记录,映射到TMP的glyphTable和characterTable,把PNG赋给atlasTexture,再处理一下TMP的glyphMetrics数据。
TMP方案的优势在于支持kern表、富文本、动态字号调节,以及更精细的排版控制。尤其打怪飘字、排行榜、聊天这种需要大量动态文本的场景,TMP的字距和行高控制明显比UGUI原生Text成熟。缺点是需要依赖转换脚本,而且TMP各个版本之间API差异挺大,不同版本转换代码可能不通用。
如果字符很少,只想在TMP文本里穿插几个特殊图标,也可以走Sprite Asset,用<sprite>标签引用。但这只适合图标、符号,不适合整段文字,维护成本太高。判断标准很简单:要不要整段文本都用艺术字风格,要就往TMP_FontAsset转换,不要才用Sprite。
5.2 动态换字与字符集规划,这是日常维护最大的坑
位图字体是预烘焙的,某个字没有收录,运行时就没有办法临时生成。中文3000多个常用字,加上英文数字标点,字符集管理稍微松懈就会出现“豆腐块”。
我的做法是项目里做一个字符统计工具,扫描策划配置表、代码字符串、多语言文件,把出现过的所有字符去重,生成待烘字符集。每次版本更新跑一遍统计,把新出现的字补进字符集,重烘一次图集。同时在运行时加一个检查脚本,Text赋值前遍历字符串,用font.HasCharacter(c)逐个检查,但凡有缺失字符就打出明确警告,提测之前就能发现。
这套机制做完,字体缺字问题基本从“上线后玩家反馈”提前到了“开发自测阶段”。不要全凭感觉维护字符集,等到发布会前一晚发现缺几百个字,那是真没法收拾。
5.3 纹理内存、移动端压缩与高清适配
一张512x512的RGBA纹理大约是1MB,2048x2048就是4MB。如果项目里同时存在四五套艺术字,内存压力立刻上来。
移动端可以把艺术字图集压缩成ETC2或ASTC。但艺术字往往带渐变和半透明,压缩后颜色可能折损,尤其是饱和度高的渐变区域。我的建议是:先在真机上看放大后的边缘,没有明显色块就压;有折损就保留真彩,但严格控制套数,或者用运行时动态图集合并。
高清适配方面,如果UI有2倍缩放需求,可以在BMFont生成时直接输出2倍字号,Unity里文本组件的fontSize设成一半。这样图集采样密度更高,但图集占用也更大,需要在内存和清晰度之间权衡。
5.4 字体授权与资源规范,提前说清免得踩线
艺术字源字体很多是商业字体,美术手绘改型后可能涉及修改授权。团队在立项时就把字体授权条款列清楚,尤其要确认“嵌入游戏资源”是否在授权范围里。字体这个东西版权方往往判得严,上线前让法务来找你,远比你主动规避难受。
团队内部也要规范fnt和PNG的命名、目录、字符集维护人。同一套艺术字不要每人各烘各的,否则美术风格满天飞,字体资源掺在一起还容易相互覆盖。
5.5 我目前的落地做法
现在新做的UI基本都走TMP,艺术字通过转换脚本转成TMP_FontAsset,保留TMP的排版和字距控制。简单的数字和图标场景,比如伤害飘字、货币数量,为了控制开销会保留UGUI Custom Font。字符集每版本跑一次统计,烘字时间控制在十分钟以内,全部流程都放进构建脚本里。
位图字体这套方案真正落地之后,艺术字不再是个“一次性美术图”,而是一种可持续维护的字体资产。美术能做到像素级控制,代码又能保持文本灵活性,两边各取所需,这大概就是它在游戏UI里活跃了这么多年还不过时的原因。