Unity艺术字生成器:基于TMP与uGUI的动态Mesh效果实现 1. 项目概述为什么我们需要一个艺术字生成器在Unity项目里UI文字处理是个高频需求无论是游戏里的伤害数字、UI界面的标题还是运营活动的特殊文案都离不开对文字的个性化渲染。Unity自带的uGUI Text组件功能有限而TextMeshProTMP虽然强大但很多时候我们想要的效果比如给每个字符单独上色、添加动态扭曲、实现霓虹灯或金属质感或者让文字沿着曲线排列都不是开箱即用的功能。我最近在做一个赛博朋克风格的项目UI里需要大量动态、炫酷的艺术字效果。如果每个效果都让美术出图不仅资源管理麻烦动态修改内容也几乎不可能。于是我决定基于Unity 2022的API自己动手撸一个灵活、高效的艺术字生成器。这个生成器的核心目标是能够动态地将一串普通文本转换成由独立Mesh构成的、可高度自定义的“艺术字”并且要同时兼容传统的uGUI Canvas和更现代的TMP渲染管线让美术和程序都能方便地使用。2. 核心思路与架构设计从文本到艺术Mesh的旅程要手撸这样一个工具首先得想清楚它的工作流。一个艺术字生成器本质上是一个“文本解析 - 字形信息获取 - Mesh构建 - 效果应用”的管道。我的设计思路是它不应该是一个黑盒而应该是一个可插拔的框架。2.1 双引擎支持TMP与uGUI的桥梁项目标题里提到了支持TMP和uGUI这是两个不同的渲染世界。TMP基于SDF有向距离场字体渲染质量高自带丰富的富文本标签并且其底层TMP_Text类暴露了详细的字形几何信息。而传统的uGUI Text使用位图字体信息相对较少。我的策略是抽象出一个IArtTextProvider接口。对于TMP我实现一个TMPArtTextProvider它通过反射或包装TMP_Text.textInfo来获取每个字符的顶点、UV、三角形索引等核心数据。对于uGUI Text则实现一个LegacyTextProvider虽然信息少但我们可以通过TextGenerator来获取基本的字符位置和尺寸再结合字体纹理来近似构建。这样上层效果逻辑如扭曲、渐变只和IArtTextProvider打交道无需关心底层是TMP还是uGUI。这是实现兼容性的关键。2.2 数据流与组件职责整个生成器由几个核心组件构成ArtTextGenerator (主控制器)挂载在GameObject上负责配置文本源TMP或uGUI Text、选择效果组合、触发生成流程。IArtTextProvider (文本信息提供者)如前所述负责从源文本组件中提取原始的、未经处理的字形网格信息。ArtTextEffect (效果基类)所有具体效果如波浪、颜色渐变、爆炸分离的抽象基类。它定义了一个ModifyMesh方法接收原始的顶点、颜色等数组并按照效果逻辑进行修改。ArtTextMesh (网格容器与渲染器)负责管理最终生成的艺术字Mesh。它根据修改后的顶点数据创建或更新MeshFilter和MeshRenderer对于世界空间或CanvasRenderer对于UI空间并处理材质球的分配。数据流是这样的ArtTextGenerator从配置的源组件获取IArtTextProvider- 调用Provider获取原始网格数据 - 将原始数据依次传递给所有启用的ArtTextEffect进行修改 - 将最终数据交给ArtTextMesh进行渲染。2.3 性能考量动态合批与GPU Instancing艺术字意味着每个字符可能都是独立的Mesh如果一段话有几十个字就会产生几十个Draw Call这是不可接受的。因此生成器的另一个核心任务是动态合并。在ArtTextMesh中我不会为每个字符单独创建GameObject。相反我会将所有字符的顶点、三角形、UV等信息合并到一个大的Mesh中。这样无论文字多长最终只产生一个Draw Call假设使用同一个材质。对于需要独立变换如每个字符随机旋转的效果我们可以通过顶点色或UV2来传递变换参数在Shader中利用顶点ID进行个性化计算从而在保持合批的前提下实现丰富效果。这是利用Unity 2022 Shader Graph或自定义Shader的SV_VertexID节点可以轻松实现的。3. 核心实现一步步拆解艺术字生成理论说完了我们进入实战环节。我会以TMP作为主要数据源来讲解因为它的信息最全实现起来也最具代表性。3.1 第一步从TMP中提取字形信息TMP在内部生成Mesh后会将所有信息存储在TMP_TextInfo这个结构体中。这是我们获取数据的金矿。关键属性是TMP_TextInfo.characterInfo数组。// 在 TMPArtTextProvider 中 public ArtTextCharacterData[] GetCharacterData() { if (tmpText null || !tmpText.textInfo.characterCount 0) return null; TMP_TextInfo textInfo tmpText.textInfo; int charCount textInfo.characterCount; ArtTextCharacterData[] charDataArray new ArtTextCharacterData[charCount]; for (int i 0; i charCount; i) { TMP_CharacterInfo tmpCharInfo textInfo.characterInfo[i]; // 跳过空格等不可见字符 if (!tmpCharInfo.isVisible) continue; ArtTextCharacterData charData new ArtTextCharacterData(); charData.index i; charData.character tmpCharInfo.character; // 提取顶点位置注意TMP的顶点是本地空间相对于字符原点 Vector3[] verts new Vector3[4]; for (int j 0; j 4; j) { verts[j] tmpCharInfo.vertex_BL.position; // 实际需要根据j索引四个顶点 // 简化示意实际需从textInfo.meshInfo[tmpCharInfo.materialReferenceIndex].vertices获取 } charData.vertices verts; // 提取UV charData.uvs ... // 从 tmpCharInfo.uvs 获取 // 提取颜色顶点色 charData.colors ... // 从 textInfo.meshInfo[].colors32 获取 // 字符的原点用于计算偏移、旋转的中心 charData.origin (tmpCharInfo.topLeft tmpCharInfo.bottomRight) / 2f; // 字符的基线位置对于对齐效果很重要 charData.baseLine tmpCharInfo.baseLine; charDataArray[i] charData; } return charDataArray; }这里我定义了一个ArtTextCharacterData结构体用来封装一个字符的所有几何和样式信息。注意TMP的顶点数据是已经经过富文本标签如颜色、大小处理后的结果这为我们提供了很好的基础。注意直接访问TMP_Text的内部字段如m_textInfo在大部分情况下是可行的但为了更好的兼容性尤其是考虑到TMP未来版本可能的变化更稳妥的做法是通过其公共属性textInfo和公开方法如ForceMeshUpdate来确保数据是最新的。在获取数据前务必调用tmpText.ForceMeshUpdate()来强制TMP生成最新的网格信息。3.2 第二步设计可叠加的效果系统效果系统是这个艺术字生成器的灵魂。我采用链式处理模式每个效果都是一个独立的ScriptableObject这样可以在编辑器内灵活组合、调整参数并且效果资产可以复用。// ArtTextEffect 基类 public abstract class ArtTextEffect : ScriptableObject { public bool isEnabled true; [Range(0, 1)] public float intensity 1.0f; // 效果强度用于动画混合 // 核心方法修改网格数据 public abstract void ModifyMesh(ArtTextCharacterData[] charData, ref Vector3[] vertices, ref Color[] colors, ref Vector2[] uvs, int startCharIndex, int endCharIndex); // 可选每帧更新用于处理基于时间的动态效果 public virtual void UpdateEffect(float deltaTime) { } } // 一个具体的波浪效果示例 [CreateAssetMenu(fileName WaveEffect, menuName ArtText/Effects/Wave)] public class WaveEffect : ArtTextEffect { public float waveSpeed 1.0f; public float waveFrequency 5.0f; public float waveAmplitude 10.0f; public Vector2 waveDirection Vector2.up; private float _timeOffset 0f; public override void UpdateEffect(float deltaTime) { _timeOffset waveSpeed * deltaTime; } public override void ModifyMesh(ArtTextCharacterData[] charData, ref Vector3[] vertices, ref Color[] colors, ref Vector2[] uvs, int startCharIndex, int endCharIndex) { for (int i startCharIndex; i endCharIndex; i) { if (!charData[i].isVisible) continue; int vertexStartIndex charData[i].vertexIndex; // 假设charData里存储了该字符顶点在总数组中的起始索引 for (int v 0; v 4; v) // 每个字符4个顶点 { int idx vertexStartIndex v; Vector3 originalPos vertices[idx]; // 计算波浪偏移基于字符的X位置或世界位置和时间 float phase (originalPos.x * waveFrequency _timeOffset) * Mathf.PI * 2; Vector3 offset waveDirection.normalized * (Mathf.Sin(phase) * waveAmplitude * intensity); vertices[idx] originalPos offset; } } } }在ArtTextGenerator中我会维护一个ListArtTextEffect。在生成Mesh时按顺序遍历这个列表依次应用每个启用的效果。intensity参数非常有用你可以通过动画系统或代码动态调整它来实现效果的淡入淡出或切换。3.3 第三步构建与更新最终Mesh所有效果应用完毕后我们就得到了一组修改后的顶点、颜色等数据。接下来需要将它们组装成一个Unity可渲染的Mesh。// 在 ArtTextMesh 组件中 public void UpdateMesh(ArtTextCharacterData[] charData, Vector3[] finalVertices, Color[] finalColors, Vector2[] finalUVs) { if (_mesh null) { _mesh new Mesh(); _mesh.name ArtTextMesh; } else { _mesh.Clear(); } // 1. 计算总顶点数和三角形数 int totalVisibleChars charData.Count(c c.isVisible); int totalVertices totalVisibleChars * 4; int totalTriangles totalVisibleChars * 6; // 两个三角形 // 2. 分配数组这里假设finalVertices等数组已经是合并好的、仅包含可见字符的数据 Vector3[] vertices finalVertices; // 长度应为 totalVertices Color[] colors finalColors; Vector2[] uvs finalUVs; int[] triangles new int[totalTriangles]; // 3. 构建三角形索引每个字符两个三角面 int triIndex 0; int vertIndex 0; for (int i 0; i charData.Length; i) { if (!charData[i].isVisible) continue; // 四边形四个顶点的索引vertIndex, vertIndex1, vertIndex2, vertIndex3 // 第一个三角形 triangles[triIndex] vertIndex; triangles[triIndex] vertIndex 1; triangles[triIndex] vertIndex 2; // 第二个三角形 triangles[triIndex] vertIndex 2; triangles[triIndex] vertIndex 3; triangles[triIndex] vertIndex; vertIndex 4; } // 4. 赋值给Mesh _mesh.vertices vertices; _mesh.colors colors; _mesh.uv uvs; _mesh.triangles triangles; // 5. 重新计算法线和边界对于3D效果很重要 _mesh.RecalculateNormals(); _mesh.RecalculateBounds(); // 6. 将Mesh赋值给MeshFilter或CanvasRenderer if (meshFilter ! null) meshFilter.mesh _mesh; if (canvasRenderer ! null) canvasRenderer.SetMesh(_mesh); }这里有一个关键点顶点数据的合并。在效果处理阶段我们可能是以字符为单位进行修改。但在提交给Mesh之前我们需要将所有字符的顶点数据扁平化合并到几个大的一维数组vertices,colors,uvs中并相应地构建三角形索引数组。这个过程在ArtTextGenerator的ApplyEffectsAndBuildMesh方法里完成。3.4 第四步材质与Shader的配合生成的Mesh需要一个材质球来渲染。对于TMP源我们通常直接使用它原来的材质因为里面包含了正确的SDF Shader和字体图集。对于uGUI源则使用对应的Sprite Shader。但是如果我们想实现一些TMP默认Shader不支持的效果比如顶点动画、溶解边缘就需要自定义Shader。这时我们可以复制一份TMP的SDF ShaderUnity通常提供在TextMeshPro/Shaders/下然后加入我们自己的顶点/片元着色逻辑。例如为了实现每个字符独立的颜色偏移我们可以在顶点着色器中将字符索引charIndex通过UV2或顶点颜色color的某个通道如color.a传递进去然后在片元着色器中根据这个索引去查询一个颜色梯度图Gradient Texture。// 在自定义Shader的顶点着色器中示意 v2f vert (appdata_t v) { v2f o; // ... 原有的TMP顶点变换 o.vertex UnityObjectToClipPos(v.vertex); o.texcoord v.texcoord; o.color v.color; // 将字符索引0-1范围存入color的alpha通道 // _CharIndexOffset 是一个每字符的偏移量由C#脚本通过MaterialPropertyBlock设置 o.color.a v.texcoord1.x; // 假设我们将字符索引存在texcoord1.x return o; } // 在片元着色器中 fixed4 frag (v2f i) : SV_Target { // ... 原有的SDF文字采样 half4 faceColor i.color; // 从Gradient纹理中采样颜色采样坐标的U由字符索引决定 float charIndex i.color.a; half3 gradientColor tex2D(_GradientTex, float2(charIndex, 0.5)).rgb; faceColor.rgb * gradientColor; // ... 后续处理 return faceColor; }在C#端我们需要为每个字符计算其在Gradient纹理上的U坐标0到1之间并通过MaterialPropertyBlock或直接修改材质属性数组来传递这些数据。这是实现高性能、合批友好型个性化效果的标准做法。4. 实战实现一个“字符爆炸后归位”的动画效果光说不练假把式我们来实现一个具体且常用的效果输入一段文字每个字符先随机飞散到屏幕外然后依次飞回原位。这个效果会用到我们上面提到的所有组件。4.1 效果设计ExplosionAndReturnEffect首先创建一个新的ArtTextEffect叫ExplosionAndReturnEffect。[CreateAssetMenu(fileName ExplosionReturnEffect, menuName ArtText/Effects/Explosion and Return)] public class ExplosionAndReturnEffect : ArtTextEffect { [Header(爆炸参数)] public Vector2 explosionRadiusMinMax new Vector2(200f, 500f); public Vector2 explosionAngleRange new Vector2(0f, 360f); public AnimationCurve explosionMoveCurve AnimationCurve.EaseInOut(0, 0, 1, 1); [Header(归位参数)] public float returnStartDelay 0.5f; // 爆炸结束后多久开始归位 public float returnDuration 1.0f; // 单个字符归位时长 public float returnInterval 0.1f; // 字符归位的时间间隔 public AnimationCurve returnMoveCurve AnimationCurve.EaseInOut(0, 0, 1, 1); // 运行时状态 private float _timer 0f; private bool _isExploding true; private bool _isReturning false; private float[] _charReturnStartTimes; // 每个字符开始归位的时间 private Vector3[] _originalPositions; // 每个字符的原始位置中心点 private Vector3[] _explosionOffsets; // 每个字符的爆炸偏移量 }4.2 初始化与状态管理在效果被应用前我们需要初始化每个字符的随机状态。public override void OnEffectStart(ArtTextCharacterData[] charData) // 假设我们在基类添加了这个生命周期方法 { int count charData.Length; _originalPositions new Vector3[count]; _explosionOffsets new Vector3[count]; _charReturnStartTimes new float[count]; System.Random rand new System.Random(GetInstanceID()); // 用实例ID做种子保证可重复 for (int i 0; i count; i) { _originalPositions[i] charData[i].origin; // 计算随机爆炸方向和距离 float angle Mathf.Lerp(explosionAngleRange.x, explosionAngleRange.y, (float)rand.NextDouble()) * Mathf.Deg2Rad; float distance Mathf.Lerp(explosionRadiusMinMax.x, explosionRadiusMinMax.y, (float)rand.NextDouble()); _explosionOffsets[i] new Vector3(Mathf.Cos(angle), Mathf.Sin(angle), 0) * distance; // 计算归位开始时间爆炸结束后 基础延迟 索引间隔 _charReturnStartTimes[i] returnStartDelay i * returnInterval; } _timer 0f; _isExploding true; _isReturning false; }4.3 在ModifyMesh中应用变换这是效果的核心。我们需要根据当前时间_timer计算每个字符应该处的位置偏移然后应用到该字符的所有顶点上。public override void ModifyMesh(ArtTextCharacterData[] charData, ref Vector3[] vertices, ref Color[] colors, ref Vector2[] uvs, int startCharIndex, int endCharIndex) { if (!isEnabled || _originalPositions null) return; float currentTime _timer; for (int i startCharIndex; i endCharIndex; i) { if (!charData[i].isVisible) continue; int vertexStartIndex charData[i].vertexIndex; Vector3 charCenter _originalPositions[i]; Vector3 targetOffset Vector3.zero; if (_isExploding) { // 爆炸阶段从0移动到_explosionOffsets[i] float explodeProgress Mathf.Clamp01(currentTime / explosionDuration); float curveValue explosionMoveCurve.Evaluate(explodeProgress); targetOffset _explosionOffsets[i] * curveValue; // 检查爆炸是否结束 if (currentTime explosionDuration) { _isExploding false; _isReturning true; // 重置计时器为归位阶段计时 _timer 0f; currentTime 0f; } } if (_isReturning) { // 归位阶段从_explosionOffsets[i]移动回(0,0,0) float charReturnStartTime _charReturnStartTimes[i]; if (currentTime charReturnStartTime) { float returnProgress Mathf.Clamp01((currentTime - charReturnStartTime) / returnDuration); float curveValue returnMoveCurve.Evaluate(returnProgress); // 线性插值从爆炸偏移量插值到零向量 targetOffset Vector3.Lerp(_explosionOffsets[i], Vector3.zero, curveValue); } else { // 还没到该字符的归位时间保持爆炸偏移量 targetOffset _explosionOffsets[i]; } } // 将计算出的整体偏移应用到字符的每个顶点上 // 注意这里是在字符原始顶点位置相对于字符中心上叠加整体偏移 for (int v 0; v 4; v) { int idx vertexStartIndex v; // vertices[idx] 当前是相对于字符中心的本地坐标 // 我们需要先将其转换到以字符中心为原点的世界/画布坐标再加偏移再转回去简化处理 // 更简单的做法在ArtTextCharacterData中存储每个顶点相对于字符中心_charCenter的本地偏移量。 // 这里假设我们之前已经做了这个处理存储在了 charData[i].vertexLocalOffsets 中。 Vector3 localOffset charData[i].vertexLocalOffsets[v]; vertices[idx] charCenter localOffset targetOffset; } } } public override void UpdateEffect(float deltaTime) { _timer deltaTime; }4.4 在编辑器中的配置与预览将ExplosionAndReturnEffect资产拖入ArtTextGenerator的Effects List。在编辑器模式下我们可以通过一个小工具脚本让效果能够在不运行游戏的情况下预览。这需要让ArtTextGenerator在OnValidate或一个自定义的[Button]如果使用Odin Inspector等插件被点击时强制执行一次生成流程。[ExecuteInEditMode] public class ArtTextGenerator : MonoBehaviour { // ... 其他字段 public bool previewInEditMode false; #if UNITY_EDITOR private void OnValidate() { if (previewInEditMode !Application.isPlaying) { UnityEditor.EditorApplication.delayCall () { if (this ! null) GenerateArtText(); }; } } #endif }这样在Inspector中调整爆炸半径、曲线等参数时可以实时在Scene视图看到文字Mesh的变化极大提升了美术和设计的工作效率。5. 性能优化与疑难杂症排查一个功能强大的工具如果性能很差或者坑很多那就失去了实用价值。下面分享我在开发过程中踩过的坑和总结的优化经验。5.1 性能优化要点避免每帧完全重建Mesh除非文本内容或效果参数发生改变否则不要每帧都调用Clear()和重新分配数组。对于只有顶点位置变化的动画如波浪效果直接修改mesh.vertices数组并赋值回去比重建整个Mesh开销小得多。使用mesh.MarkDynamic()提示Unity这是一个频繁变化的网格有助于图形驱动优化。使用MaterialPropertyBlock进行批量个性化如果效果是通过Shader参数实现的如每个字符不同的颜色千万不要为每个字符创建单独的Material实例。应该使用MaterialPropertyBlock来为渲染器设置属性。对于合批后的单个Mesh我们可以传递一个数组属性如float4 _CharColors[100]但Shader中数组有长度限制。更通用的方法是使用纹理作为数据缓冲区将每个字符的属性颜色、偏移量等编码到一张Texture2D中在Shader中根据字符索引去采样。这样无论多少字符Draw Call都只有一个。控制顶点数量TMP的SDF字体每个字符默认是4个顶点。对于非常长的文本如段落顶点数会很多。如果性能吃紧可以考虑在非关键视觉区域使用简化效果或者对远处的文字使用LOD降低顶点复杂度但这需要修改TMP的生成过程比较复杂。对象池化ArtTextGenerator如果场景中需要频繁创建和销毁动态艺术字如战斗飘字一定要使用对象池来复用ArtTextGenerator组件和其下的Mesh避免频繁的GC Alloc。5.2 常见问题与解决方案问题现象可能原因解决方案文字渲染为粉色Missing Material艺术字Mesh使用的材质球丢失或Shader不匹配。检查ArtTextMesh中赋值的材质球。对于TMP源确保材质使用了正确的TMP SDF Shader变体如SDF Overlay。可以在运行时从源TMP组件复制材质artTextMesh.material new Material(tmpText.fontMaterial);。文字模糊或有锯齿1. 从TMP提取的UV或顶点数据有误导致采样错位。2. 自定义Shader中SDF相关参数如_GradientScale,_Sharpness未正确设置。1. 仔细核对从TMP_CharacterInfo提取顶点和UV的代码确保索引对应正确BL, TL, TR, BR。2. 从TMP材质中获取这些SDF属性并传递给自定义材质material.SetFloat(“_GradientScale”, tmpMaterial.GetFloat(“_GradientScale”));效果应用后文字位置错乱在ModifyMesh中修改顶点时参考坐标系混乱。原始顶点可能是局部坐标而偏移量是世界或屏幕空间。统一坐标系。建议在ArtTextCharacterData中存储每个顶点相对于字符中心点的本地偏移量(vertexLocalOffsets)。应用效果时先计算字符中心点的总偏移再叠加本地偏移量得到最终顶点世界坐标。合批失败Draw Call激增1. 为每个字符单独创建了MeshRenderer。2. 使用了不同的材质实例。3. 渲染顺序被打乱。1. 确保所有字符顶点合并到一个Mesh中。2. 所有字符使用同一个材质球个性化数据通过MaterialPropertyBlock或顶点数据传递。3. 确保所有艺术字生成器在同一个Canvas下并且层级设置正确。在UI Canvas中渲染深度错误艺术字Mesh的渲染顺序与原有UI元素错乱。对于UI渲染通过CanvasRenderer设置Mesh时要确保其sortingOrder或所属Canvas的sortOrder正确。可以设置canvasRenderer.transform.SetSiblingIndex()来控制它在UI层级中的绘制顺序。编辑器预览卡顿在OnValidate中频繁触发完整的GenerateArtText()且文本较长。添加防抖逻辑。使用EditorApplication.delayCall并设置一个标志位确保在一帧内只生成一次。对于复杂效果可以提供一个“手动预览”按钮而不是实时预览。5.3 一个关于“字符索引传递”的深度技巧在实现每个字符独立动画时如何让Shader知道当前片元属于哪个字符我前面提到了用顶点颜色或UV2的某个通道存储字符索引。这里有一个更稳健的实现细节在C#端当我们将所有顶点合并到一个大数组时我们需要为每个顶点计算一个“字符ID”0到1之间的浮点数。这个ID需要是逐顶点的而不是逐字符的因为一个字符有4个顶点。// 在合并顶点数据时 for (int charIdx 0; charIdx charData.Length; charIdx) { if (!charData[charIdx].isVisible) continue; for (int v 0; v 4; v) { int globalVertIdx currentVertexIndex; finalVertices[globalVertIdx] ...; finalColors[globalVertIdx] originalVertexColor; finalUV2[globalVertIdx] new Vector2((float)charIdx / totalCharCount, 0); // 用UV2.x存储归一化的字符索引 } }在Shader中我们就可以用uv2.x来区分字符。但注意在字符内部四个顶点之间这个值是相同的这保证了字符内部的颜色是一致的。如果我们想要在字符内部也有渐变可以结合uv.x或uv.y字符内的纹理坐标来实现。最后关于Unity 2022的新API这个项目大量使用了Mesh的API如SetVertices,SetUVs这些API一直很稳定。更值得关注的是Job System和Burst Compiler如果你处理的是超长文本比如歌词滚动可以考虑将顶点计算放到Job中并行处理这对性能提升将是巨大的。不过那又是另一个话题了。这个艺术字生成器框架已经足够应对项目中90%的炫酷文字需求从简单的颜色渐变到复杂的物理模拟都可以通过编写新的ArtTextEffect来快速实现。