首页 / 资讯中心 / 文章详情

Unity游戏UI艺术字方案:BMFont位图字体从生成到接入全指南

Unity游戏UI艺术字方案:BMFont位图字体从生成到接入全指南 ★ FEATURED ARTICLE
做游戏UI的十有八九都遇到过这种场景策划甩过来一张艺术字参考图火焰字、金属字、像素风标题然后补一句“要能动态换文字”。用Unity自带的Text做吧描边阴影调半天出来还是一股“程序味”让美术一张张出图吧文字一多、换行一开、分辨率一适配直接就想去拆UI框架。我在几个项目里来回踩了一圈之后发现最稳的路线还是绕回到BMFont上。BMFont先把字体烘成“位图图集坐标表”再灌进Unity的文本系统美术可以完全控制每个字的纹理效果代码又依然能像操作普通文本一样做换行、对齐和动态赋值。这篇文章把我在BMFont参数配置、fnt文件解析、Unity侧接入三个环节里的实操记录完整写出来适合做UI的、搞客户端工具的以及想了解艺术字后期怎么维护的人参考。1. 为什么游戏UI的艺术字多半绕不开BMFont1.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等布局信息MaterialUnity内创建材质让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选TextUV坐标翻转等一切问题都留到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 faceSourceHanSansCN-Bold size64 bold0 italic0 charset unicode1 stretchH100 smooth1 aa1 padding4,4,4,4 spacing1,1 common lineHeight77 base64 scaleW1024 scaleH512 pages1 packed0 page id0 filefont_0.png chars count128 char id33 x704 y32 width20 height66 xoffset18 yoffset6 xadvance54 page0 chnl15 char id34 x740 y32 width46 height28 xoffset20 yoffset6 xadvance66 page0 chnl15 ... kerning count3 kerning first34 second33 amount-2common行里的lineHeight是整行高度base是基准线在行内的位置scaleW/scaleH是图集尺寸pages1表示只有一张图。char行里的id是字符的Unicode码点x/y/width/height是字符在图集中的像素矩形xoffset/yoffset是这个字符相对基准线要偏移多少xadvance则是该字符占用的水平推进距离也就是下一个字符的起始x坐标。page0表示字符在第0张图上。3.2 坐标原点之坑BMFont左上原点与Unity左下UVBMFont的图集坐标原点在左上角y轴向下增长Unity的UV坐标原点在左下角y轴向上增长。这个差异是Unity侧实现出现字符颠倒、错位的头号原因。所以做UV映射时不能直接用x / scaleW和y / scaleH必须做y翻转。以字符矩形为例图集里越靠下的像素对应到Unity UV里v坐标越小。公式是vBottom 1 - (y height) / scaleHvTop 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, widthuvLeft x / scaleWuvRight (x width) / scaleWy, heightuvBottom 1 - (y height) / scaleHuvTop 1 - y / scaleHxoffsetvertexMinX xoffsetxoffset widthvertexMaxX xoffset widthyoffset heightvertexMinY -(yoffset height)yoffsetvertexMaxY -yoffsetxadvanceadvance xadvance注意一个隐藏点width和height已经包含了Padding和描边产生的边距。也就是说字符的像素框可能比实际字形大这在绘图时会让字与字的间距视觉上变宽。如果觉得间距太大可以微调fnt里的xadvance但不要动width和xoffset否则采样位置会错。3.4 kerning逐对字距UGUI Text吃不到TMP能吃到fnt文件里可能还有kerning first34 second33 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 ListGlyphData(); 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 ListCharacterInfo(); 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填64Unity里就设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生成逻辑全套流程可以做成字符集变更重新烘PNGUnity侧自动生成Font资产全程不需要手摸工具。5. 进阶取舍TextMeshPro、多语言和运行时维护5.1 TMP方案更适合复杂UI的接入路线如果项目UI已经全面转向TextMeshProUGUI 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纹理大约是1MB2048x2048就是4MB。如果项目里同时存在四五套艺术字内存压力立刻上来。移动端可以把艺术字图集压缩成ETC2或ASTC。但艺术字往往带渐变和半透明压缩后颜色可能折损尤其是饱和度高的渐变区域。我的建议是先在真机上看放大后的边缘没有明显色块就压有折损就保留真彩但严格控制套数或者用运行时动态图集合并。高清适配方面如果UI有2倍缩放需求可以在BMFont生成时直接输出2倍字号Unity里文本组件的fontSize设成一半。这样图集采样密度更高但图集占用也更大需要在内存和清晰度之间权衡。5.4 字体授权与资源规范提前说清免得踩线艺术字源字体很多是商业字体美术手绘改型后可能涉及修改授权。团队在立项时就把字体授权条款列清楚尤其要确认“嵌入游戏资源”是否在授权范围里。字体这个东西版权方往往判得严上线前让法务来找你远比你主动规避难受。团队内部也要规范fnt和PNG的命名、目录、字符集维护人。同一套艺术字不要每人各烘各的否则美术风格满天飞字体资源掺在一起还容易相互覆盖。5.5 我目前的落地做法现在新做的UI基本都走TMP艺术字通过转换脚本转成TMP_FontAsset保留TMP的排版和字距控制。简单的数字和图标场景比如伤害飘字、货币数量为了控制开销会保留UGUI Custom Font。字符集每版本跑一次统计烘字时间控制在十分钟以内全部流程都放进构建脚本里。位图字体这套方案真正落地之后艺术字不再是个“一次性美术图”而是一种可持续维护的字体资产。美术能做到像素级控制代码又能保持文本灵活性两边各取所需这大概就是它在游戏UI里活跃了这么多年还不过时的原因。
阅读完成 · 觉得有帮助?
咨询建站