FishPlayer

一个喜欢摸鱼的废物

0%

TextMeshPro图标笔记+简易图集工具

前言

交互希望能在界面上比较方便的显示 🍌*5 这样的效果。好在当前的项目使用TMP,只需要把图标打成一个资源赋给TMP文本组件,就可以在文本中显示图标。

为了让交互策划能够自行调整图标使用的范围以及质量,我随手搓了一个小配置来做到这个事情。
当前我们的美术资源还没有做规范整理,所以项目中存在着尺寸不一致的图标,但对于文本上使用的图标来说,尺寸大概是固定的,所以图集里的图标尺寸也应该要固定下来。
最最开始的时候想的是直接写一个双线性采样线跑跑,做个可以用的程度,但是不是因为我对需求的描述不太好,问Copilot并没有给出我要的做法,我又懒得自己写(其实是加班加傻了,用Texture2D.GetPixelBilinear()可以),于是又开谷歌搜一下,很快就在论坛里找到一个比较有趣的做法。

这个思路是这样的:
1 以我们缩放的目标尺寸创建RT,作为渲染目标
2 把需要缩放的贴图用这个RT渲染一次
3 新的贴图直接从渲染结果中取色
4 非常方便,而且渲染时可以指定贴图filter模式,便于得到质量OK的图图

这些其实都不难,东西交手给交互那边以后才发现一个真正的难题,那就是要教会他们如何去调整这个图标在文本里的对其方式和尺寸。
这部分工作以前是导师做的,没想到现在也轮到我了,快速查阅资料后,算是大概明白了。

图标在文本中的显示与对其

我比较偷懒,没有找到官中的解说,所以用自己的话来大概描述一下,可能会有错,但是能够大概讲明如何调整(这部分笔记我会同步到项目的笔记中给交互同学看)。

基准线

任何的字形,图标,对于TMP来说,都是一小块网格。所以他们的摆放的方式都是一致的。
TMP使用文本“基准线(baseline)”来作为字形(也包括图标,后面就不多区分了)摆放位置的参照。基准线可以认为是个桌面,大多数东西都应该“放在”桌面上。
当然,实际上并不是所有的字形都会很刚好的底部紧贴基准线。

以这张图为例,能很明显看到文本以及图标下方有一条线,这个就是基准线。除了基准线以外,还有上升线(ascentline)和下降线(descentline)。
可以粗略的认为,上升线可以粗略认为是字形可以达到的最高点,下降线是可以达到的最低点。

FaceInfo

FaceInfo 的作用就是告知一些用于计算字形的度量值。
其中比较重要的属性:

  • PointSize 告知用于采样图标的尺寸(只有这个数值有效时,其它属性值才有效)
  • BaseLine 用于修改图标实际的baseline

通过以下的代码可以知道,当pointSize未被正确设置时,TMP组件会采用当前文本组件字体配置里的PointSize来计算图标显示的大小。
为了图标显示的质量以及方便后续的调整,我强烈建议填写FaceInfo。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

if (m_currentSpriteAsset.m_FaceInfo.pointSize > 0)
{
float spriteScale = m_currentFontSize / m_currentSpriteAsset.m_FaceInfo.pointSize * m_currentSpriteAsset.m_FaceInfo.scale * (m_isOrthographic ? 1 : 0.1f);
currentElementScale = sprite.m_Scale * sprite.m_Glyph.scale * spriteScale;
elementAscentLine = m_currentSpriteAsset.m_FaceInfo.ascentLine;
baselineOffset = m_currentSpriteAsset.m_FaceInfo.baseline * fontScale * m_fontScaleMultiplier * m_currentSpriteAsset.m_FaceInfo.scale;
elementDescentLine = m_currentSpriteAsset.m_FaceInfo.descentLine;
}
else
{
float spriteScale = m_currentFontSize / m_currentFontAsset.m_FaceInfo.pointSize * m_currentFontAsset.m_FaceInfo.scale * (m_isOrthographic ? 1 : 0.1f);
currentElementScale = m_currentFontAsset.m_FaceInfo.ascentLine / sprite.m_Glyph.metrics.height * sprite.m_Scale * sprite.m_Glyph.scale * spriteScale;
float scaleDelta = spriteScale / currentElementScale;
elementAscentLine = m_currentFontAsset.m_FaceInfo.ascentLine * scaleDelta;
baselineOffset = m_currentFontAsset.m_FaceInfo.baseline * fontScale * m_fontScaleMultiplier * m_currentFontAsset.m_FaceInfo.scale;
elementDescentLine = m_currentFontAsset.m_FaceInfo.descentLine * scaleDelta;
}

字形属性

字形的本质也可以粗略地认为就是一张图(贴图上的某一块)。
所以其属性大概包括以下地信息:

  • 如何在自行贴图中获取这一块图
  • 字形的布局度量,关系着显示的尺寸(并不是贴图中那一块的大小!!!)
  • 额外方位参数(BX,BY,ADV),关系着字形在文本中排布时的尺寸和空间

图标的摆放

最后就是实际图标地摆放了,还要了解的一个概念就是枢轴点(pivot),这个是图标摆放的关键。
枢轴位置定义了图标的原点。字形显示时,在垂直为位置会把枢轴稳放在基准线的高度上,在水平位置上则是放在 前一个 字形定义的尾部。
我强烈建议把图标的枢轴点设置为左下角,这是为了方便调整。

然后这里就发生了我们最终会遇到的问题了。图标往往看起来没有居中。
这个时候,结合上面的信息我们知道,图标拜访时会把轴枢点放在基准线上,把轴枢点设为左下角,图标的底部就会在基准线上。
为了让图标居中,我们可以在FaceInfo中直接调整该图标集的基准线偏移,又或是在字形表底部的全局偏移输入框中调整Y偏移让其直接把偏移值应用到每一个图标字形上。

图集生成代码

最后就是代码了,不太完整,凑合能用吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
namespace UI.Editor
{
/// <summary> A shortcut to creatre TMP sprite asset </summary>
public class TextMeshProIconPacker : ScriptableObject
{
[Header("Result Config")]
[SerializeField,]
private string _textureOutPutName = "TempIconAtlas";
[SerializeField,]
private string m_spawnedAtlasPath;
[SerializeField,]
private TMP_SpriteAsset m_target;

[Header("Sprite Pack")]
[SerializeField]
private Sprite[] _spriteToPack;
[SerializeField]
private int _maxSize = 512;
[SerializeField]
private int _iconSize = 64;
[SerializeField]
private int _padding = 2;
[SerializeField]
private float _iconScale = 1.2f;

[SerializeField]
private SpriteRect[] m_spriteRects;

[MenuItem("Assets/UI/CreateTMPSpriteConfigTemplate")]
private static void InternalCreateTestAsset()
{
TextMeshProIconPacker asset = ScriptableObject.CreateInstance<TextMeshProIconPacker>();
// TODO Change this path
string assetPath = "Assets/TMPSpriteConfig/TMPSpriteConfigTemplate.asset";
UnityEditor.AssetDatabase.CreateAsset(asset, assetPath);
UnityEditor.AssetDatabase.SaveAssets();
}

[ContextMenu("Create Sprite Atlas")]
public void PackSpritesForTMP_EditorOnly()
{
if (null == _spriteToPack || 0 == _spriteToPack.Length)
{
return;
}
// grab all sprite as textures
Texture2D[] sourceTextures = GatherTextures();
FixImportSettings(sourceTextures, true);
Texture2D[] texture2Pack = GetAdjustedT2D(sourceTextures);

// create sprite rects for atlas
Texture2D texture = new Texture2D(_maxSize, _maxSize, TextureFormat.RGBA32, false);
Rect[] rects = texture.PackTextures(texture2Pack, _padding, _maxSize);
m_spriteRects = new SpriteRect[rects.Length];
for (int i = 0; i < rects.Length; i++)
{
m_spriteRects[i] = new SpriteRect()
{
name = texture2Pack[i].name,
spriteID = GUID.Generate(),
rect = ConvertToPixelRect(texture.width, texture.height, rects[i]),
alignment = SpriteAlignment.BottomLeft, // it's fine to put it here, cuz we gonna manulla adjust it later :D
};
}

EditorUtility.SetDirty(this);
AssetDatabase.SaveAssetIfDirty(this);

string path = AssetDatabase.GetAssetPath(this);
string folderPath = Path.GetDirectoryName(path);
string pngPath = $"{folderPath}/{_textureOutPutName}Atlas.png";

// save sprite atlas
Texture2D decopmpresseTex = DeCompress(texture);
byte[] bytes = decopmpresseTex.EncodeToPNG();
File.WriteAllBytes(pngPath, bytes);
int temp = name.Length + 6;
m_spawnedAtlasPath = path.Substring(0, path.Length - temp) + $"{_textureOutPutName}Atlas.png";

// revert sprite readable
FixImportSettings(sourceTextures, false);
for (int i = 0, length = texture2Pack.Length; i < length; i++)
{
Texture.DestroyImmediate(texture2Pack[i]);
}
texture2Pack = null;
AssetDatabase.Refresh();

TextureImporter textureImporter = AssetImporter.GetAtPath(pngPath) as TextureImporter;
textureImporter.textureType = TextureImporterType.Sprite;
textureImporter.spriteImportMode = SpriteImportMode.Multiple;
textureImporter.SaveAndReimport();

textureImporter = AssetImporter.GetAtPath(pngPath) as TextureImporter;
SpriteDataProviderFactories factory = new();
factory.Init();
ISpriteEditorDataProvider dataProvider = factory.GetSpriteEditorDataProviderFromObject(textureImporter);
dataProvider.InitSpriteEditorDataProvider();
dataProvider.SetSpriteRects(m_spriteRects);

// Note: This section is only for Unity 2021.2 and newer
// Register the new Sprite Rect's name and GUID with the ISpriteNameFileIdDataProvider
ISpriteNameFileIdDataProvider spriteNameFileIdDataProvider = dataProvider.GetDataProvider<ISpriteNameFileIdDataProvider>();
List<SpriteNameFileIdPair> nameFileIdPairs = spriteNameFileIdDataProvider.GetNameFileIdPairs().ToList();
nameFileIdPairs.AddRange(m_spriteRects.Select(spriteRect => new SpriteNameFileIdPair(spriteRect.name, spriteRect.spriteID)));
spriteNameFileIdDataProvider.SetNameFileIdPairs(nameFileIdPairs);

dataProvider.Apply();
textureImporter.SaveAndReimport();
}

[ContextMenu("Generate asset by sprite atlas")]
public void ApplyAtlasToAsset()
{
string path = AssetDatabase.GetAssetPath(this);
string folderPath = Path.GetDirectoryName(path);
string pngPath = $"{folderPath}/{_textureOutPutName}Atlas.png";

if (null == m_target)
{
m_target = ScriptableObject.CreateInstance<TMP_SpriteAsset>();
m_target.name = _textureOutPutName;
m_target.spriteInfoList = new();
AssetDatabase.CreateAsset(m_target, $"{folderPath}/{_textureOutPutName}.asset");
}
else
{
// seens like we dun need to destroy it
//Material matToDestroy = m_target.material;
//m_target.material = null;
//Material.DestroyImmediate(matToDestroy);
}
m_target.spriteSheet = AssetDatabase.LoadAssetAtPath<Texture2D>(pngPath);
AddDefaultMaterial(m_target);

Type hackType = typeof(TMP_SpriteAssetMenu);
MethodInfo[] hackMethods = hackType.GetMethods(BindingFlags.NonPublic | BindingFlags.Static);
for (int i = 0, length = hackMethods.Length; i < length; i++)
{
MethodInfo hackMethod = hackMethods[i];
ParameterInfo[] paramInfos = hackMethod.GetParameters();
if (1 == paramInfos.Length && typeof(TMP_SpriteAsset) == paramInfos[0].ParameterType)
{
hackMethod.Invoke(null, new object[] { m_target }); // actually this wont work, I have to manully press "Update info" on the TextMeshProSpriteAsset :D
break;
}
}

List<TMP_SpriteCharacter> characters = m_target.spriteCharacterTable;
for (int i = 0, length = characters.Count; i < length; i++)
{
TMP_SpriteCharacter character = characters[i];
character.scale = _iconScale;
}

EditorUtility.SetDirty(m_target);
AssetDatabase.SaveAssetIfDirty(m_target);
}

[ContextMenu("Clear result")]
public void Clear()
{
if (null != m_target)
{
string taragetath = AssetDatabase.GetAssetPath(m_target);
AssetDatabase.DeleteAsset(taragetath);
}
if (!string.IsNullOrEmpty(m_spawnedAtlasPath))
{
AssetDatabase.DeleteAsset(m_spawnedAtlasPath);
}
m_target = null;
m_spawnedAtlasPath = string.Empty;
m_spriteRects = null;
}

private Texture2D[] GatherTextures()
{
List<Texture2D> resultTexture = new List<Texture2D>();
for (int i = 0, length = _spriteToPack.Length; i < length; i++)
{
Sprite existSprite = _spriteToPack[i];
string assetPath = AssetDatabase.GetAssetPath(existSprite);
if (string.IsNullOrEmpty(assetPath))
{
continue;
}
Texture2D loadedT2D = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
resultTexture.Add(loadedT2D);
}
return resultTexture.ToArray();
}

private void FixImportSettings(Texture2D[] textures, bool readable)
{
for (int i = 0, length = textures.Length; i < length; i++)
{
Texture2D texture = textures[i];
string path = AssetDatabase.GetAssetPath(texture);
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;

if (importer == null) continue;
importer.isReadable = readable;
importer.SaveAndReimport();
}
}

private Rect ConvertToPixelRect(int width, int height, Rect uvRect)
{
float pixelLeft = uvRect.x * width;
float pixelTop = uvRect.y * height;
float pixelWidth = uvRect.width * width;
float pixelHeight = uvRect.height * height;
return new Rect(pixelLeft, pixelTop, pixelWidth, pixelHeight);
}

private Texture2D DeCompress(Texture2D source)
{
RenderTexture renderTex = RenderTexture.GetTemporary(
source.width,
source.height,
0,
RenderTextureFormat.Default,
RenderTextureReadWrite.Linear);

Graphics.Blit(source, renderTex);
RenderTexture previous = RenderTexture.active;
RenderTexture.active = renderTex;
Texture2D readableText = new Texture2D(source.width, source.height);
readableText.ReadPixels(new Rect(0, 0, renderTex.width, renderTex.height), 0, 0);
readableText.Apply();
RenderTexture.active = previous;
RenderTexture.ReleaseTemporary(renderTex);
return readableText;
}

private void AddDefaultMaterial(TMP_SpriteAsset spriteAsset)
{
Shader shader = Shader.Find("TextMeshPro/Sprite");
Material material = new Material(shader);
material.SetTexture(ShaderUtilities.ID_MainTex, spriteAsset.spriteSheet);

spriteAsset.material = material;
material.hideFlags = HideFlags.HideInHierarchy;
AssetDatabase.AddObjectToAsset(material, spriteAsset);
}

private Texture2D[] GetAdjustedT2D(Texture2D[] source)
{
Texture2D[] result = new Texture2D[source.Length];
for (int i = 0, length = source.Length; i < length; i++)
{
Texture2D after = GPUTextureScaler.GetScaledResult(source[i], _iconSize, _iconSize);
result[i] = after;
}
return result;
}

}

// from https://discussions.unity.com/t/how-to-resize-scale-down-texture-without-losing-quality/810223/14
public class GPUTextureScaler
{
/// <summary>
/// Returns a scaled copy of given texture.
/// </summary>
/// <param name="tex">Source texure to scale</param>
/// <param name="width">Destination texture width</param>
/// <param name="height">Destination texture height</param>
/// <param name="mode">Filtering mode</param>
public static Texture2D GetScaledResult(Texture2D src, int width, int height, FilterMode mode = FilterMode.Trilinear)
{
Rect texR = new(0, 0, width, height);
DoGPUScale(src, width, height, mode);

//Get rendered data back to a new texture
Texture2D result = new(width, height, TextureFormat.ARGB32, true);
result.Reinitialize(width, height);
result.ReadPixels(texR, 0, 0, true);
result.name = src.name;
return result;
}

/// <summary>
/// Scales the texture data of the given texture.
/// </summary>
/// <param name="tex">Texure to scale</param>
/// <param name="width">New width</param>
/// <param name="height">New height</param>
/// <param name="mode">Filtering mode</param>
public static void Scale(Texture2D tex, int width, int height, FilterMode mode = FilterMode.Trilinear)
{
Rect texR = new(0, 0, width, height);
DoGPUScale(tex, width, height, mode);

// Update new texture
tex.Reinitialize(width, height);
tex.ReadPixels(texR, 0, 0, true);
tex.Apply(true); //Remove this if you hate us applying textures for you :)
}

// Internal unility that renders the source texture into the RTT - the scaling method itself.
private static void DoGPUScale(Texture2D src, int width, int height, FilterMode fmode)
{
//We need the source texture in VRAM because we render with it
src.filterMode = fmode;
src.Apply(true);

//Using RTT for best quality and performance. Thanks, Unity 5
RenderTexture rtt = new(width, height, 32);

//Set the RTT in order to render to it
Graphics.SetRenderTarget(rtt);

//Setup 2D matrix in range 0..1, so nobody needs to care about sized
GL.LoadPixelMatrix(0, 1, 1, 0);

//Then clear & draw the texture to fill the entire RTT.
GL.Clear(true, true, new Color(0, 0, 0, 0));
Graphics.DrawTexture(new Rect(0, 0, 1, 1), src);
}
}
}

参考资料

查了好一些资料,其实都没有完全看完,相信看完之后对字形会有更多的了解

https://zhuanlan.zhihu.com/p/627054602
https://discussions.unity.com/t/scaling-inline-sprites-using-rich-text-tags-causes-weird-alignment/752114/4
https://discussions.unity.com/t/understanding-baseline-alignment-in-textmesh-pro/836821
https://discussions.unity.com/t/misaligned-emojis-sprites/688473
https://www.youtube.com/watch?app=desktop&v=hlatyQC-xIg&t=5m
https://menma.top/unity/2161.html
https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2/manual/FontAssetsLineMetrics.html