unity UGUI源码分析Text与TextMeshPro

Posted 非洲人╮(╯_╰)╭

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了unity UGUI源码分析Text与TextMeshPro相关的知识,希望对你有一定的参考价值。

这一篇博客用于分析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)=minSDF1(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对字体进行描边

 

UGUI源码分析Unity遮罩之Mask详细解读

博客园博文链接 https://www.cnblogs.com/iwiniwin/p/15131528.html
遮罩,顾名思义是一种可以掩盖其它元素的控件。常用于修改其它元素的外观,或限制元素的形状。比如ScrollView或者圆头像效果都有用到遮罩功能。本系列文章希望通过阅读UGUI源码的方式,来探究遮罩的实现原理,以及通过Unity不同遮罩之间实现方式的对比,找到每一种遮罩的最佳使用场合。

Unity UGUI主要提供两种遮罩,分别是MaskRect Mask 2D。在2D游戏开发中,可能还会用到Sprite Mask,虽然不是本文的重点,但后面也会提到。原本是希望将对各个遮罩的分析与对比整合在一篇文章中,但在书写过程中发现篇幅过长,因此只好拆分为三个部分。本篇文章是第一部分,专门解读Mask遮罩。另外两篇分别是

本文使用的源码与内置资源均基于Unity2019.4版本

Mask

查阅Unity的官方文档,对Mask有如下定义

遮罩不是可见的 UI 控件,而是一种修改控件子元素外观的方法。遮罩将子元素限制(即“掩盖”)为父元素的形状。因此,如果子项比父项大,则子项仅包含在父项以内的部分才可见。

也有简单提到Mask的实现原理

使用 GPU 的模板缓冲区来实现遮罩。第一个遮罩元素将 1 写入模板缓冲区。遮罩下面的所有元素在渲染时进行检查,仅渲染到模板缓冲区中有 1 的区域。 嵌套的遮罩会将增量位掩码写入缓冲区,这意味着可渲染的子项需要具有要渲染的逻辑和模板值。

是不是有些晦涩难懂?没关系,接下来的分析就是对这个实现原理的展开,每句话都会有对应的解读

模板缓冲?

要搞懂模板缓冲,先要了解模板测试。在渲染流水线的逐片元操作阶段,会有一个模板测试,可以作为一种丢弃片元的辅助方法(这里的片元可以简单理解为对应着一个像素),而要进行模板测试就要用到模板缓冲。每个像素/片段都可以有一个与之对应的模板值,就存储在模板缓冲中。

如果开启了模板测试,GPU会首先读取(使用读取掩码)模板缓冲区中该片元位置的模板值,然后将该值和读取到(使用读取掩码)的参考值进行比较,这个比较函数可以是由开发者指定的,例如小于时舍弃该片元,或者大于等于时舍弃。如果这个片元没有通过这个测试,该片元就会被舍弃。不管一个片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试结果来修改模板缓冲区,这个修改操作也是由开发者指定的。开发者可以设置不同结果下的修改操作,例如,在失败时模板缓冲区保持不变,通过时将模板缓冲区中对应位置的值加1等。

而Mask就是通过在渲染时,将其对应位置像素的模板值都置为特定值(不一定是1),然后当遮罩下的子元素渲染时,逐像素判断模板值是否为特定值,如果是特定值,就表示在遮罩范围内,可以显示。如果不是,则表示不在遮罩范围内,不显示。借用一张网上的图,很形象的描述了这种方式。

绿色矩形是遮罩区域,模板值都被写入为1,当渲染横着的红色矩形时,只有模板值为1的区域才会显示,非1的会被丢弃不会显示。从而实现了裁剪效果

源码

在了解了Mask的基本实现原理后,再来通过源码看看具体的实现方式

UGUI中所有可显示的图形都有一个基类,Graphic。比如Image和Text就是间接继承于Graphic的。Graphic定义了一个materialForRendering属性。它表示传递给CanvasRenderer,实际被用于渲染的材质。从这个属性的get访问器可以发现,在获取最终被用于渲染的材质时,会先依次调用这个GameObject上所有实现了IMaterialModifier接口组件的GetModifiedMaterial方法来修改最后返回的材质。

public virtual Material materialForRendering

    get
    
        var components = ListPool<Component>.Get();
        GetComponents(typeof(IMaterialModifier), components);

        var currentMat = material;
        for (var i = 0; i < components.Count; i++)
            currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat);
        ListPool<Component>.Release(components);
        return currentMat;
    

IMaterialModifier定义如下所示,也就是说其它组件可以通过实现IMaterialModifier接口来达到修改最终渲染所使用的材质的目的

public interface IMaterialModifier

    /// <summary>
    /// Perform material modification in this function.
    /// </summary>
    /// <param name="baseMaterial">The material that is to be modified</param>
    /// <returns>The modified material.</returns>
    Material GetModifiedMaterial(Material baseMaterial);

Mask组件就实现了IMaterialModifier接口,并通过这个接口返回了一个新材质,并通过这个新材质设置修改模板缓冲值

/// Stencil calculation time!
public virtual Material GetModifiedMaterial(Material baseMaterial)

    // ...
    var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
    var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
    if (stencilDepth >= 8)
    
        Debug.LogWarning("Attempting to use a stencil mask with depth > 8", gameObject);
        return baseMaterial;
    

    int desiredStencilBit = 1 << stencilDepth;
    
    // 第一部分
    // if we are at the first level...
    // we want to destroy what is there
    if (desiredStencilBit == 1)
    
        // CompareFunction.Always,始终通过,执行StencilOp.Replace操作,将模板缓冲中的值替换为(1 & 255)= 1
        var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);
        StencilMaterial.Remove(m_MaskMaterial);
        m_MaskMaterial = maskMaterial;

        var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
        StencilMaterial.Remove(m_UnmaskMaterial);
        m_UnmaskMaterial = unmaskMaterial;
        // 设置渲染器可使用的材质数量为1
        graphic.canvasRenderer.popMaterialCount = 1;
        // 设置渲染器使用的材质
        graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

        return m_MaskMaterial;
    
    // 第二部分
    // ...

GetModifiedMaterial的实现可以分两部分来看,上面的代码只列出了第一部分。简单起见,我们先只看第一部分,主要是if (desiredStencilBit == 1)语句块内代码,它是用于处理只有自身有Mask的简单情况的

  • 代码中的stencilDepth表示自身到Canvas之间Mask的个数,如果每层有多个Mask则只计一个。如果除了自身的Mask,再往上没有Mask了,则stencilDepth为0,如果再往上找到1个,stencilDepth为1,找到2个,stencilDepth为2,以此类推。
  • desiredStencilBit表示实际要写入模板缓冲的参考值。desiredStencilBit = 1 << stencilDepth。当stencilDepth >= 8时会打印警告,是因为模板值一般是8位的,desiredStencilBit将超出这个范围无法写入
  • 如果只是自身有Mask,再往上没有了。那stencilDepth就是0,desiredStencilBit就是1,此时通过StencilMaterial.Add获得一个新材质,并将这个材质返回,从而达到修改最终渲染使用材质的目的。StencilMaterial.Add方法具体实现如下所示,主要是对材质设置一些传入的参数。
public static Material Add(Material baseMat, int stencilID, StencilOp operation, CompareFunction compareFunction, ColorWriteMask colorWriteMask, int readMask, int writeMask)

    // ...
    var newEnt = new MatEntry();
    newEnt.count = 1;
    newEnt.baseMat = baseMat;
    newEnt.customMat = new Material(baseMat);
    newEnt.customMat.hideFlags = HideFlags.HideAndDontSave;
    newEnt.stencilId = stencilID;
    newEnt.operation = operation;
    newEnt.compareFunction = compareFunction;
    newEnt.readMask = readMask;
    newEnt.writeMask = writeMask;
    newEnt.colorMask = colorWriteMask;
    newEnt.useAlphaClip = operation != StencilOp.Keep && writeMask > 0;

    newEnt.customMat.name = string.Format("Stencil Id:0, Op:1, Comp:2, WriteMask:3, ReadMask:4, ColorMask:5 AlphaClip:6 (7)", stencilID, operation, compareFunction, writeMask, readMask, colorWriteMask, newEnt.useAlphaClip, baseMat.name);

    newEnt.customMat.SetInt("_Stencil", stencilID);
    newEnt.customMat.SetInt("_StencilOp", (int)operation);
    newEnt.customMat.SetInt("_StencilComp", (int)compareFunction);
    newEnt.customMat.SetInt("_StencilReadMask", readMask);
    newEnt.customMat.SetInt("_StencilWriteMask", writeMask);
    newEnt.customMat.SetInt("_ColorMask", (int)colorWriteMask);
    newEnt.customMat.SetInt("_UseUIAlphaClip", newEnt.useAlphaClip ? 1 : 0);

    if (newEnt.useAlphaClip)
        newEnt.customMat.EnableKeyword("UNITY_UI_ALPHACLIP");
    else
        newEnt.customMat.DisableKeyword("UNITY_UI_ALPHACLIP");

    m_List.Add(newEnt);
    return newEnt.customMat;

StencilMaterial本质上只是缓存材质的一个工具类,主要作用就是提供一个新的材质。再结合下面这句代码传入的参数。这个新材质起到的作用是始终通过模板测试(CompareFunction.Always),替换模板缓冲中的模板值(StencilOp.Replace)为1

var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);

对材质设置的参数,实际上是设置给Shader的,查看UI默认使用的Shader是UI/Default,这是Unity的内置Shader,源码可以在Unity官网下载,下载时选择"Built in shaders"

UI-Default.shader的部分源码如下所示,可以看到主要是利用Unity ShaderLab的模板语句来实现对模板缓冲区的一些操作,详细介绍可以点击这里查看,就不再赘述了

// Unity built-in shader source. Copyright (c) 2016 Unity Technologies. MIT license (see license.txt)

Shader "UI/Default"

    Properties
    
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" 
        _Color ("Tint", Color) = (1,1,1,1)

        _StencilComp ("Stencil Comparison", Float) = 8
        _Stencil ("Stencil ID", Float) = 0
        _StencilOp ("Stencil Operation", Float) = 0
        _StencilWriteMask ("Stencil Write Mask", Float) = 255
        _StencilReadMask ("Stencil Read Mask", Float) = 255

        _ColorMask ("Color Mask", Float) = 15

        [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
    

    SubShader
    
        // ...
        Stencil
        
            Ref [_Stencil]
            Comp [_StencilComp]
            Pass [_StencilOp]
            ReadMask [_StencilReadMask]
            WriteMask [_StencilWriteMask]
        
        // ...
    


到这里不难发现,Unity文档Mask原理描述中的第一句话就是对上面过程的一个概括

使用 GPU 的模板缓冲区来实现遮罩。第一个遮罩元素将 1 写入模板缓冲区。

接下来我们再来看被遮掩的对象,是怎样利用模板缓冲实现遮罩效果的

UGUI中所有可被遮掩的图形都有一个基类,MaskableGraphic,同样MaskableGraphic是继承于Graphic的。比如Image和Text就是继承于MaskableGraphic的。同理,MaskableGraphic也实现了IMaterialModifier接口来修改最终渲染使用的材质

public virtual Material GetModifiedMaterial(Material baseMaterial)

    var toUse = baseMaterial;

    if (m_ShouldRecalculateStencil)
    
        var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
        m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
        m_ShouldRecalculateStencil = false;
    

    // if we have a enabled Mask component then it will
    // generate the mask material. This is an optimization
    // it adds some coupling between components though :(
    if (m_StencilValue > 0 && !isMaskingGraphic)
    
        var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
        StencilMaterial.Remove(m_MaskMaterial);
        m_MaskMaterial = maskMat;
        toUse = m_MaskMaterial;
    
    return toUse;

  • 代码中的m_StencilValue表示在自身层级之上有多少个Mask,如果只有父节点有Mask组件,则m_StencilValue值为1
  • 可以看到它返回的新材质主要作用是,比较传入的参考值((1 << m_StencilValue) - 1)与模板缓冲中的值,如果相等就通过(CompareFunction.Equal),即使通过了模板测试也仍保留模板缓冲中的值(StencilOp.Keep)。
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
  • 当只有父节点有Mask组件时,(1 << m_StencilValue) - 1值即为1,与前面Mask组件提前设置的模板缓冲区的值相同,所以在Mask范围内的元素将能够通过模板测试,最终显示出来,未通过的将被裁剪无法显示出来

这里就对应了Unity文档Mask原理描述中的中间部分

遮罩下面的所有元素在渲染时进行检查,仅渲染到模板缓冲区中有 1 的区域。

实际上到这里,一个简单的,只有父节点有Mask的图形是怎样实现遮罩效果的,我们已经彻底搞清楚了,接下来,让我们来看看复杂点的情况

如果大家还没忘记的话,让我们回到Mask的GetModifiedMaterial实现(注意是Mask的哦~),查看它的第二部分,即if语句块后面的代码,他们是被用来处理嵌套Mask的

public virtual Material GetModifiedMaterial(Material baseMaterial)

    if (!MaskEnabled())
        return baseMaterial;

    var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
    var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
    if (stencilDepth >= 8)
    
        Debug.LogWarning("Attempting to use a stencil mask with depth > 8", gameObject);
        return baseMaterial;
    

    int desiredStencilBit = 1 << stencilDepth;

    // 第一部分
    // ...

    // 第二部分
    //otherwise we need to be a bit smarter and set some read / write masks
    var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
    StencilMaterial.Remove(m_MaskMaterial);
    m_MaskMaterial = maskMaterial2;

    graphic.canvasRenderer.hasPopInstruction = true;
    var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
    StencilMaterial.Remove(m_UnmaskMaterial);
    m_UnmaskMaterial = unmaskMaterial2;
    graphic.canvasRenderer.popMaterialCount = 1;
    graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

    return m_MaskMaterial;

  • 与第一部分不同的是,StencilMaterial.Add传入的参数不同,而这些不同就是处理嵌套Mask的关键。嵌套Mask是指除了自身Mask,层级再往上还有Mask。针对这种情况,传入的参考值是desiredStencilBit | (desiredStencilBit - 1),而不再固定是1了。这个值的实际含义是利用每一位是否是1来表示每一层是否有Mask。举个栗子,如果除了自身,再往上还能找到两个Mask,则stencilDepth为2,desiredStencilBit为8,二进制形式为100,经过计算传入的参考值是111,用每个1来分别表示,自身有Mask,第一层有,第二层有。这个参考值被Unity称之为增量位掩码
  • 这个增量位掩码正好可以与MaskableGraphic部分判断模板值是否相等时用到的(1 << m_StencilValue) - 1对应上
// Mask处理嵌套遮罩所用的新材质
var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));

// MaskableGraphic判断是否在遮罩内所用的新材质
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);

实际上这部分就对应了Unity文档Mask原理描述中的后两句话

嵌套的遮罩会将增量位掩码写入缓冲区,这意味着可渲染的子项需要具有要渲染的逻辑和模板值。

补充

最后还有几处地方觉得值得提一下

  1. StencilMaterial.Add传入参数的最后两个分别是readMask读取掩码和writeMask写入掩码,读取掩码不仅是在读取模板缓冲中的值时会与其相与,对于要比较的参考值也会相与

  2. 细心的同学可能会发现,Mask在获取新材质的时候,会多获取一个。这个材质实际是用来清除模板缓冲区的。以避免不要影响后续的渲染

    // 第一部分
    var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
    
    // 第二部分
    var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
    

    利用Unity的帧调试器也可以看到这个清除过程

  3. 为什么Mask可以实现圆形遮罩效果?

    众所周知,圆头像效果可以使用Mask实现,具体方式是使用一张只显示圆形,非圆形区域是透明像素的切图实现的。但这张切图实际上还是矩形的,根据上面的原理解读,矩形区域对应的模板值都会被Mask设置为特定值,从而使其下的子元素都能通过模板测试,是无法实现圆形裁剪的

    关键代码还是在UI-Default.shader中,它通过clip指令,将透明度低于0.001的片元都裁剪掉了,因此被裁剪的片元也就不会再设置对应的模板值了。UNITY_UI_ALPHACLIP宏定义是通过Shader参数_UseUIAlphaClip控制的,Mask获取的新材质会将该参数设置为true

    #ifdef UNITY_UI_ALPHACLIP
    clip (color.a - 0.001);
    #endif
    
  4. 关于SpriteMask

    Sprite Mask不属于UGUI的范围,Unity官方并没有将它开源,不过通过官方论坛我们可以了解到其实现原理也是利用了模版缓冲。
    不像Mask,只实现了Visible Inside Mask功能,SpriteMask不仅实现了Visible Inside Mask功能,也实现了Visible Outside Mask功能。在经过对Mask的原理分析以后,我们知道通过修改模板缓冲的比较函数是可以轻易的实现这种效果的,感兴趣的同学赶快动手试一下吧


参考

以上是关于unity UGUI源码分析Text与TextMeshPro的主要内容,如果未能解决你的问题,请参考以下文章

UGUI源码分析Unity遮罩之Mask详细解读

UGUI源码分析Unity遮罩之Mask详细解读

UGUI源码分析Unity遮罩之Mask详细解读

Unity3D - UGUI的手动搭建

[Unity] UGUI学习笔记

[Unity] UGUI学习笔记