如何运行时动态合并SDF字体减少DrawCall
解读
在国内中大型手游/元宇宙项目中,策划经常把“剧情对话、飘字、血条、装备名、富文本表情”全部塞进同一屏,导致同一帧里出现几十张不同的SDF图集,DrawCall瞬间飙到100+。面试官问“运行时动态合并SDF字体”,并不是让你解释BMFont怎么生成,而是考察三件事:
- 能否在纯运行时把散落在各张图集的字符无损合并成一张大图集;
- 合并后如何即时刷新UV、材质、TextMeshPro的Font Asset,而不重启场景;
- 能否在低端千元安卓机上30 ms内完成,且内存峰值<10 MB。
答不到这三点,基本会被判“只能离线合并,无法线上热更”。
知识点
- SDF(Signed Distance Field)图集本质:8 bit单通道,距离值0~255,放大不会糊,但依赖采样精度,合并时不能二次压缩。
- TextMeshPro 3.0+的动态字体贴图机制:
TMP_DynamicFontAtlas类在C#层维护一张RenderTexture,调用AddCharacterToTexture时会把新字符Blit进去;但默认只对同一Font Asset生效,跨Asset会新建RT,导致DC。 - Unity 2021.2以后新增的
FontAsset.MergeFontAssets接口:官方只给Editor,运行时 stripped,必须自己实现。 - 图集合并算法:
- 离线:MaxRects/Skyline;
- 运行时:用GPU Driven MaxRects,把每个字符的SDF数据当成一张小Texture2D,计算好xMin、xMax、yMin、yMax后,一次性
Graphics.CopyTexture到目标RenderTexture,避免CPU读回。
- TMP_SubMeshUI的合批规则:只有同一Material+同一Texture+同一Shader Keywords才能合批;合并后必须保证
fontAtlasTexture字段指向同一张RT,且materialHash相同。 - 安卓GLES3.0的RT尺寸上限:4096×4096,超过会Fallback到1024,需要动态分级(512/1024/2048)。
- IL2CPP裁剪陷阱:若使用反射访问
TMP_DynamicFontAtlas.m_AtlasTextures,需在link.xml中显式preserve该字段,否则Release包会闪退。
答案
分四步落地,代码量控制在300行以内,可在千元机<20 ms完成:
-
预分析阶段
在首包或资源更新时,把策划可能用到的所有SDF字符按Unicode区间打Tag,记录其原始FontAsset与GlyphIndex,并计算哈希值uint glyphHash = (fontAsset.GetInstanceID() << 16) | glyphIndex,用于运行时快速去重。 -
运行时合并
a. 创建一张RenderTexture rtMerged = new RenderTexture(2048, 2048, 0, RenderTextureFormat.R8),filterMode = FilterMode.Bilinear,useMipMap = false;
b. 维护一个List<GlyphRect> rects做GPU MaxRects,节点数据结构只用4个int,避免托管堆;
c. 当TMP_Text.OnPreRenderText触发新字符时,若glyphHash不在rtMerged中,则:- 用
FontAsset.fontAtlasTexture作为源,调用Graphics.CopyTexture(src, 0, 0, srcX, srcY, width, height, rtMerged, 0, 0, dstX, dstY),全程GPU端,0 CPU回读; - 把新UV写进
TMP_Glyph的x,y,width,height,并缓存到静态字典s_MergedGlyphLookup[glyphHash],下次直接复用;
d. 若RT空间不足,按LRU策略回收最久未用字符,调用GL.InvalidateState清掉对应UV,保证内存峰值<8 MB。
- 用
-
强制合批
合并完成后,把所有参与合并的FontAsset的atlasTexture字段通过反射指向rtMerged,并调用fontAsset.UpdateLookupTables();随后TMP_Text.SetAllDirty(),触发Rebuild()。此时所有TMP_SubMeshUI拿到的material.GetTexture("_MainTex")已是同一张RT,DrawCall直接降到1(同材质)。 -
设备分级与回退
在SystemInfo.maxTextureSize < 2048的低端机,自动降级到1024×1024,并降低SDF采样距离faceDilate=0.3->0.2,保证清晰度;若仍不足,则回退到“离线合并+静态图集”,保证兼容性。
关键代码片段(IL2CPP安全)
var fi = typeof(TMP_FontAsset).GetField("m_AtlasTextures", BindingFlags.NonPublic | BindingFlags.Instance);
if (fi != null)
{
var list = fi.GetValue(fontAsset) as List<Texture2D>;
list.Clear();
list.Add(rtMerged.colorBuffer); // 让TMP内部指向合并后RT
}
拓展思考
- 热更新场景:若使用
HybridCLR或lua,把“合并逻辑”下沉到C#层,避免il2cpp裁剪;同时把rtMerged序列化成byte[]存到persistentDataPath,下次启动直接LoadRawTextureData,可把冷启动时间再降50%。 - 与Addressable联动:把高频字符做成“常驻图集”打包到
addressable_font_common,低频字符走运行时合并,既减少包体,又保证DC可控。 - URP-SRP Batcher兼容:合并后材质若开启
Enable GPU Instancing,TMP的ShaderGUI会强制关闭SRP Batch;需要自定义Shader去掉#pragma multi_compile_instancing,才能享受SRP合批。 - WebGL2.0限制:RT最大仅1024×1024,且
Graphics.CopyTexture不支持Format.R8,需转RGBA8并手动swizzle,性能会掉30%,此时建议离线合并为主、运行时合并为辅。