这一篇博客用于分析Text的内容的更新机制,并分析text mesh pro。
首先我们分析Text的文字是如何渲染出来的。
PupulateWithErrors方法会根据字符串生成顶点数据。其实Text会根据所给定的字符串生成相关的图集,然后对图集进行采样就可以渲染出文字了。由于TextGenerator没有开源,我们从unity UI优化文档上可以找到相关步骤。
protected override void OnPopulateMesh(VertexHelper toFill)
{
if (font == null)
return;
// We don't care if we the font Texture changes while we are doing our Update.
// The end result of cachedTextGenerator will be valid for this instance.
// Otherwise we can get issues like Case 619238.
m_DisableFontTextureRebuiltCallback = true;
Vector2 extents = rectTransform.rect.size;
var settings = GetGenerationSettings(extents);
cachedTextGenerator.PopulateWithErrors(text, settings, gameObject);
// Apply the offset to the vertices
IList<UIVertex> verts = cachedTextGenerator.verts;
float unitsPerPixel = 1 / pixelsPerUnit;
int vertCount = verts.Count;
// We have no verts to process just return (case 1037923)
if (vertCount <= 0)
{
toFill.Clear();
return;
}
Vector2 roundingOffset = new Vector2(verts[0].position.x, verts[0].position.y) * unitsPerPixel;
roundingOffset = PixelAdjustPoint(roundingOffset) - roundingOffset;
toFill.Clear();
if (roundingOffset != Vector2.zero)
{
for (int i = 0; i < vertCount; ++i)
{
int tempVertsIndex = i & 3;
m_TempVerts[tempVertsIndex] = verts[i];
m_TempVerts[tempVertsIndex].position *= unitsPerPixel;
m_TempVerts[tempVertsIndex].position.x += roundingOffset.x;
m_TempVerts[tempVertsIndex].position.y += roundingOffset.y;
if (tempVertsIndex == 3)
toFill.AddUIVertexQuad(m_TempVerts);
}
}
else
{
for (int i = 0; i < vertCount; ++i)
{
int tempVertsIndex = i & 3;
m_TempVerts[tempVertsIndex] = verts[i];
m_TempVerts[tempVertsIndex].position *= unitsPerPixel;
if (tempVertsIndex == 3)
toFill.AddUIVertexQuad(m_TempVerts);
}
}
m_DisableFontTextureRebuiltCal
Unity内置的Text组件可以很方便地用于在UI中显示栅格化的文本字形。但是,在使用Text时有很多大家不了解却又经常遇到的与性能相关的因素。当想UI添加文本时,要始终记得——文本字形是作为独立的面片(quad)进行渲染的,每个字符都是一个面片。这些面片通常都含有大量的空白区域围绕着字形,空白区域的大小取决于字形的形状,在放置文本时很容易就会无意中破坏其他UI元素的批处理。
UI文本的网格重建是个重点问题。当Text组件发生变化时,必须重新计算用于显示实际文本的多边形。当Text组件或它的任意级别的父节点被禁用或启用时,也需要进行重新计算。
在含有大量文字标签的UI上,这一行为可能导致问题,例如排行榜页面和统计数据页面。因为在Unity中,最常见的显示和隐藏UI的方法是启用/禁用含有UI的GameObject,含有大量文本组件的UI通常在显示时会导致帧率降低。
那么Text时如何生成图集的?
当全部可现实字符集很大或者在运行时期不确定时,可以用动态字体来显示文本。在Unity的实现中,这些字体在运行时根据Text组件中出现的字符构建一个字形图集(glyph atlas)。
被加载的每个不同的Font对象会维护它自己的纹理集,即使它与其他字体属于同一个字体族。例如,在一个文本控件中使用Arial字体,并且将字体样式(Font Style)设置为粗体(Bold),在另一个文本控件中使用Arial Bold字体,这两个控件会产生一样的输出,但是Unity会维护两个不同的纹理集——一个给Arial,另一个给Arial Bold。
从性能角度看,要理解的最重要的一件事就是,动态字体为每种不同的结合(尺寸、样式&字符)在其纹理集中维护了一个字形。也就是说,如果一个UI中含有两个Text组件,都显示了字符“A”,那么:
- 如果两个Text组件尺寸相同,那么字体图集中会有一个字形。
- 如果两个Text组件尺寸不同,那么字体图集中会有两个不同尺寸的字母“A”。
- 如果一个Text组件的样式是粗体而另一个不是,那么字体图集中会含有一个粗体的“A”和一个普通的“A”。
当使用动态字体的Text对象遇到了没有被栅格化到字体纹理集中的字形时,必须重建字体纹理集。如果新的字形能够加入当前图集,那么将其加入图集并重新上传到图形设备。但是,如果当前的图集太小,那么系统会尝试重建图集。这通过两步完成。
第一步,以相同的大小重建图集,只使用当前在活动的Text组件上显示的字形。这包含了父画布活动(active)但是禁用了CanvasRenderer的Text组件。如果系统成功地将当前使用的所有字形填充进新的图集中,将会栅格化此图集,不再继续进行第二步。
第二步,如果当前使用的字形不能填充进同样大小的图集中,那么会以当前图集大小的短维乘2来创建一个更大的图集。例如,一个512x512的图集或被扩充到512x1024的图集。
因为上述的算法,动态字体集只会在创建时增长一次大小。考虑到重建纹理集的开销,必须时期在重建时最小。这可以通过两种方式实现:
如果可以,使用非动态字体并预先配置对想要使用的字形集的支持。在使用具有良好的字符集约束的UI上,这样做通常效果很好,例如,只是用Latin/ASCII字符并且尺寸范围小的UI。
如果必须支持极其大量的字符,例如整个Unicode集合,那么字体必须设为动态。为了避免可预见的性能问题,使用Font.RequestCharactersInTexture在启动时填充字体字形集。
注意,每个发生变化的Text组件会单独触发字体集重建。当布置极大量Text组件时,将组件内容中全部的不重复字符收集起来并填充进字体集可能有利于提高性能。这样做能够确保字形集只需要重建一次,而不是每次出现新字形时都重建。
另一点需要注意的是,当触发字体集重建时,所有不在当前活动的Text组件中的字符都不会包含进新的图集中。
TextMeshPro Text
TextMeshPro(TMP)可以作为Unity中已有的文本组件(例如TextMesh和UI Text)的替代方案。TMP使用Signed Distance Field(有向距离场,SDF)作为其首选文本渲染管线,使其可以在任意尺寸和分辨率下清晰的渲染文本。使用一系列自定义的着色器来提升SDF文本渲染的能力后,TMP可以简单的通过修改材质属性来动态地改变视觉效果,例如,放大、外边框、软阴影等,并且可以通过创建材质预设来保存这些效果,在以后重新调用。
下面简述一下啊SDF的原理以及一些简单的应用。TMP基于SDF可以显著提升抗锯齿效果,并且实现一些特效也十分简单。
百度百科上符号距离函数(sign distance function),简称SDF,又可以称为定向距离函数(oriented distance function),在空间中的一个有限区域上确定一个点到区域边界的距离并同时对距离的符号进行定义:点在区域边界内部为正,外部为负,位于边界上时为0。也就是说SDF记录着当前像素点距离某一个区域的最小距离(这个区域我们可以理解为文字,一就是说可以假设像素值为0的点在区域内,像素值为255的点在区域外)。
那么如何生成SDF呢?根据定义SDF记录着当前像素点距离某一个区域的最小距离,最简单的一种办法就是遍历每一个像素点,找到每一个像素点到某一个区域的最小距离。但是这种方法复杂度太高了,对每一个像素点都需要遍历整张图像,设像素点的数量为n,那么总体时间复杂度为n2
可以利用动态规划去减少计算量。状态转移方法可以很容易列出来。
如果当前像素点 img(i,j)的值小于128的时候那么SDF(i,j)=0;
如果当前像素点 img(i,j)的值大于128的时候那么
SDF(i,j)=min{ SDF(i,j-1)+1 SDF(i,j+1)+1 SDF(i-1,j)+1 SDF(i+1,j)+1.414 SDF(i-1,j-1)+1 SDF(i+1,j-1)+1.414 SDF(i+1,j+1)+1.414 SDF(i-1,j+1)+1.414}
边界值需要特殊处理,很容易知道图像的四周边界是不会包含文字的,所以可以假设SDF(边界)=C。C是一个常数。
但是这个状态转移方程需要知道每一个点四周的8个点,一个动态规划是解决不了这个问题的,因为SDF(i,j)需要依赖SDF(i-1,j),而SDF(i-1,j)又需要依赖SDF(i,j),循环依赖了无解。
因此可以考虑使用两次动态规划求解。
第一个动态规划
SDF1(i,j)=min{ SDF1(i-1,j-1)+1.414 SDF1(i-1,j+1)+1.415 SDF1(i-1,j)+1 SDF1(i,j-1)+1}
第二个动态规划
SDF2(i,j)=min{ SDF2(i+1,j-1)+1.414 SDF2(i+1,j+1)+1.414 SDF2(i+1,j)+1 SDF2(i,j+1)+1}
最后SDF(i,j)=min{SDF1(i,j),SDF2(i,j)}这样就解决问题了。
但是上面求出来的SDF的值全为正数,只能统计区域外距离区域边界的距离,那么这么统计区域内部距离区域边界的距离呢?
这个很简单,我们只需要对 255-img再计算一次正向SDF即可。
最后双向SDF=SDF(img)-SDF(255-img)
我们在这里给出了Python代码计算SDF:
from skimage import io
import numpy as np
def dpLU(img):
SDF=[[0 for i in range(len(img[0]))]for j in range(len(img))]
for i in range(len(img)):
SDF[i][0]=128 if img[i][0]>128 else 0
SDF[i][-1] = 128 if img[i][-1] > 128 else 0
for i in range(len(img[0])):
SDF[0][i]=128 if img[0][i]>128 else 0
SDF[-1][i] = 128 if SDF[-1][i] > 128 else 0
for i in range(1, len(img) - 1):
for j in range(1,len(img[0])-1):
if img[i][j]<128:
SDF[i][j]=0
else:
SDF[i][j]=min(SDF[i-1][j]+1,SDF[i-1][j+1]+1.414,SDF[i-1][j-1]+1.414,SDF[i][j-1]+1)
return SDF
def dpRB(img):
SDF=[[0 for i in range(len(img[0]))]for j in range(len(img))]
for i in range(len(img)):
SDF[i][0]=128 if img[i][0]>128 else 0
SDF[i][-1] = 128 if img[i][-1] > 128 else 0
for i in range(len(img[0])):
SDF[0][i]=128 if img[0][i]>128 else 0
SDF[-1][i] = 128 if SDF[-1][i] > 128 else 0
for i in range(len(img)-2, 0,-1):
for j in range(len(img[0])-2,0,-1):
if img[i][j]<128:
SDF[i][j]=0
else:
SDF[i][j]=min(SDF[i+1][j]+1,SDF[i+1][j+1]+1.414,SDF[i+1][j-1]+1.414,SDF[i][j+1]+1)
return SDF
def GeneratorSDF():
img=io.imread("xxx.bmp")[:,:,1]
SDF1,SDF2=dpLU(img),dpRB(img)
SDF_img1 = [[0 for i in range(len(img[0]) )] for j in range(len(img))]
for i in range(len(SDF1)):
for j in range(len(SDF1[0])):
SDF_img1[i][j] = min(SDF1[i][j], SDF2[i][j])
img=255-img
SDF1, SDF2 = dpLU(img), dpRB(img)
SDF_img2 = [[0 for i in range(len(img[0]) )] for j in range(len(img))]
for i in range(len(SDF1)):
for j in range(len(SDF1[0])):
SDF_img2[i][j]=min(SDF1[i][j],SDF2[i][j])
SDF=np.array(SDF_img1)-np.array(SDF_img2)
SDF=SDF+128
SDF=SDF.astype(np.uint8)
io.imsave("SDF.png",SDF)
if __name__ == '__main__':
GeneratorSDF()
最后我们再给出一个简单的示例:
我们自己手写了一个“你”字,大小为128*128
然后生成SDF
把原图进行四倍上采样后
而对SDF进行上采样然后在进行阈值分割得到,可以看出效果明显更好了。
还可以用SDF对字体进行描边