如何运行时动态合并SDF字体减少DrawCall

解读

在国内中大型手游/元宇宙项目中,策划经常把“剧情对话、飘字、血条、装备名、富文本表情”全部塞进同一屏,导致同一帧里出现几十张不同的SDF图集,DrawCall瞬间飙到100+。面试官问“运行时动态合并SDF字体”,并不是让你解释BMFont怎么生成,而是考察三件事:

  1. 能否在纯运行时把散落在各张图集的字符无损合并成一张大图集
  2. 合并后如何即时刷新UV、材质、TextMeshPro的Font Asset,而不重启场景;
  3. 能否在低端千元安卓机上30 ms内完成,且内存峰值<10 MB。
    答不到这三点,基本会被判“只能离线合并,无法线上热更”。

知识点

  1. SDF(Signed Distance Field)图集本质:8 bit单通道,距离值0~255,放大不会糊,但依赖采样精度,合并时不能二次压缩。
  2. TextMeshPro 3.0+的动态字体贴图机制TMP_DynamicFontAtlas类在C#层维护一张RenderTexture,调用AddCharacterToTexture时会把新字符Blit进去;但默认只对同一Font Asset生效,跨Asset会新建RT,导致DC。
  3. Unity 2021.2以后新增的FontAsset.MergeFontAssets接口:官方只给Editor,运行时 stripped,必须自己实现。
  4. 图集合并算法
    • 离线:MaxRects/Skyline;
    • 运行时:用GPU Driven MaxRects,把每个字符的SDF数据当成一张小Texture2D,计算好xMin、xMax、yMin、yMax后,一次性Graphics.CopyTexture到目标RenderTexture,避免CPU读回。
  5. TMP_SubMeshUI的合批规则:只有同一Material+同一Texture+同一Shader Keywords才能合批;合并后必须保证fontAtlasTexture字段指向同一张RT,且materialHash相同。
  6. 安卓GLES3.0的RT尺寸上限:4096×4096,超过会Fallback到1024,需要动态分级(512/1024/2048)。
  7. IL2CPP裁剪陷阱:若使用反射访问TMP_DynamicFontAtlas.m_AtlasTextures,需在link.xml中显式preserve该字段,否则Release包会闪退。

答案

分四步落地,代码量控制在300行以内,可在千元机<20 ms完成:

  1. 预分析阶段
    在首包或资源更新时,把策划可能用到的所有SDF字符按Unicode区间打Tag,记录其原始FontAssetGlyphIndex,并计算哈希值uint glyphHash = (fontAsset.GetInstanceID() << 16) | glyphIndex,用于运行时快速去重。

  2. 运行时合并
    a. 创建一张RenderTexture rtMerged = new RenderTexture(2048, 2048, 0, RenderTextureFormat.R8)filterMode = FilterMode.BilinearuseMipMap = 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_Glyphx, y, width, height,并缓存到静态字典s_MergedGlyphLookup[glyphHash],下次直接复用;
      d. 若RT空间不足,按LRU策略回收最久未用字符,调用GL.InvalidateState清掉对应UV,保证内存峰值<8 MB。
  3. 强制合批
    合并完成后,把所有参与合并的FontAssetatlasTexture字段通过反射指向rtMerged,并调用fontAsset.UpdateLookupTables();随后TMP_Text.SetAllDirty(),触发Rebuild()。此时所有TMP_SubMeshUI拿到的material.GetTexture("_MainTex")已是同一张RT,DrawCall直接降到1(同材质)。

  4. 设备分级与回退
    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
}

拓展思考

  1. 热更新场景:若使用HybridCLRlua,把“合并逻辑”下沉到C#层,避免il2cpp裁剪;同时把rtMerged序列化成byte[]存到persistentDataPath,下次启动直接LoadRawTextureData,可把冷启动时间再降50%。
  2. 与Addressable联动:把高频字符做成“常驻图集”打包到addressable_font_common,低频字符走运行时合并,既减少包体,又保证DC可控。
  3. URP-SRP Batcher兼容:合并后材质若开启Enable GPU Instancing,TMP的ShaderGUI会强制关闭SRP Batch;需要自定义Shader去掉#pragma multi_compile_instancing,才能享受SRP合批。
  4. WebGL2.0限制:RT最大仅1024×1024,且Graphics.CopyTexture不支持Format.R8,需转RGBA8并手动swizzle,性能会掉30%,此时建议离线合并为主、运行时合并为辅