解释TMP的SubMesh与材质实例化

解读

面试官抛出这个问题,通常不是想听“SubMesh 就是把字拆成几块”这种表面答案,而是考察候选人是否真正踩过 中文混排 + 表情 + 特效 + 动态字体 的坑,是否理解 DrawCall、内存、合批、热更新 在 TMP 管线里的连锁反应。国内项目普遍要求 iOS 低端机 60 FPS + 热更新不重启,如果 TMP 材质实例化策略不对,一次聊天弹窗就能让 DrawCall 暴涨到 100+,直接触发 GC 卡顿,面试时能把这一层讲清楚,才能体现“资深”二字。

知识点

  1. TMP 的 Mesh 生成管线:Text → Glyph → Quad → SubMesh
  2. SubMesh 的划分规则:材质槽位差异 触发拆分,同一槽位内连续 Quad 自动合并
  3. 材质槽位来源:
    • 默认材质(Font Asset 指定)
    • 富标签 <material> 索引
    • 精灵标签 <sprite> 索引
    • 颜色渐变 <gradient> 产生的临时材质
  4. 材质实例化时机:
    • 首次解析到富标签 → TMP 自动 Material.Instantiate
    • 运行时动态修改颜色/贴图 → 再次实例化
    • DontDestroyOnLoad 的 Canvas 忘记清理 → 泄漏到下一次场景
  5. 合批条件:同一 Texture + 同一 Material 实例 + 同一 Shader 参数
  6. 国内常见坑:
    • 美术把表情打成 512 张散图 → 每个表情一个 SubMesh,DrawCall 爆炸
    • 策划在聊天配置里写 <material=1> 但材质槽 1 为空 → TMP 回退实例化默认材质,产生 隐藏的一份冗余材质
    • 热更新 DLL 里动态创建 FontAsset 并替换默认材质 → 旧材质实例残留在 CanvasRenderer 的额外材质数组,导致 “字体替换了但颜色还残留”

答案

TMP 的 SubMesh 是 在同一个 TextMeshProUGUI 组件里,按“材质槽位”划分的连续三角形块。生成逻辑是:

  1. 解析文本时,每遇到一个 <material=index> 或 <sprite=index> 标签,就切换一次材质槽位;
  2. 网格构建阶段,把相同槽位的连续 Quad 写进同一段索引缓冲区,形成独立 SubMesh;
  3. 最终提交渲染时,每个 SubMesh 对应一次 Material 实例的 DrawCall

材质实例化发生在 第一次遇到非默认槽位 时:TMP 会 Material.Instantiate(Font Asset 的默认材质),并把新实例塞进 TMP_SubMeshInfo.material,保证 修改颜色、贴图、Stencil 参数 时不会影响原资源。
如果运行时继续动态修改该实例(例如代码里改颜色),不会再次实例化;但若重新赋值一个全新的 Material,TMP 会 再次 Instantiate,导致 内存与 DrawCall 双增

国内项目要减少实例化带来的开销,有三条实践经验:

  • 提前在 Font Asset 里配好所有材质槽位,避免运行时动态插值;
  • 把表情合并到一张 2048 图集,用 <sprite> 索引调用,确保所有 SubMesh 共享同一 Texture,可被 SRP Batcher 合批;
  • 场景卸载时主动调用 TMP_Text.DestroyMaterialInstances(),防止 DontDestroyOnLoad 的聊天窗把材质带到下一场景,造成 “字体已卸载但材质泄漏” 的闪退。

一句话总结:SubMesh 是 TMP 为了多材质需求自动拆分的网格段,而每一次拆分都伴随一次材质实例化;只有让“槽位数量 × 材质实例数量”最小化,才能把低端机的 DrawCall 压到个位数。

拓展思考

  1. 如果策划要求 “聊天超链接hover 时变色”,你会在 TMP 的哪一层做?
    提示:hover 只改颜色不改材质槽位,可直接 material.SetColor("_FaceColor", …)不会触发新实例化;但若为了做 外发光 而切换 Shader,则必须 预先在 Font Asset 里放两份材质槽位,否则运行时 Instantiate 会打破合批。
  2. Addressables 热更新 场景,FontAsset 与贴图都走远程加载,如何保证 旧材质实例不残留
    思路:监听 Addressables.Release 时,手动遍历 TMP_SubMeshInfo[],对 material.referenceCount == 0 的实例执行 DestroyImmediate,并 清空 CanvasRenderer 的 additionalMaterials 数组,否则下一次加载新字体时会出现 “紫块”“颜色错乱”