【Unity记录】【解析几何】令文本保持字符间距地环绕在圆弧上(将线段映射到圆弧上)

本文内容

阅读须知:

  • 本文注重介绍做法;原理只给出,不进行解释(因为我也不会涅😅)
  • 该做法只适合半圆弧内的映射,因为其并不是将直线上的点等距离地映射至圆弧上,但在半圆弧范围内,肉眼几乎无法感知距离的差异
  • 只考虑了单行的实现,多行文字如不加修改,感官上会稍微差一些

本文将介绍:

  • 利用复变函数 w = 1 / z w=1/z w=1/z将直线映射到圆弧上的数学方法(Matlab实现与C#实现)
  • 计算线段在圆上等长弧的两端点(C#实现)
  • 在Unity中应用该做法将TextMeshPro文本以圆弧形式排列渲染(C#实现)

最终效果

有时我们需要将文字环绕在圆弧上,比如你的美术突然给了你这么一个素材:
啪的一下很酷啊,但问题来了,应该怎么把文字贴上圆弧呢?

使用本文介绍的方法,结果是这样的:
在这里插入图片描述

可以看到,无论是中文字符还是英文字符,都保留了其原有的字符间距,并环绕在圆弧上
就算字符不足以撑满圆弧,字符的间距也不会分散:

但是注意,如果文本超过了1/3圆弧的位置,还是会肉眼看到字符存在差异:


而且这种差异会随着字符增加进一步明显,超过2/3时达到几乎不可用的状态。

建议使用的情况:
也就是这个做法最多只能用于半圆弧的文字排布;
如果需要全圆排布,可以参考现有脚本部分的第二个做法;
如果那个做法也无法满足你的需求,那你可能就需要再想办法写一个了涅。

实现方法

现有做法

TextMeshPro Sample Scene 25

最开始肯定不愿意自己写,上网找现成的脚本。
TextMeshPro官方的Sample Scene 25有一个弯曲文本的实现,但那个做法只改变了Y坐标与旋转,而X坐标没做修改,结果就是,如果文字长度并非远小于半径时,会得到这样的结果:

在靠近两侧的地方,文字间距呈灾难式递增。

建议使用的情况:

  • 文字只在圆弧1/4内

CurvedTextMeshPro

这是由TonyViT编写的脚本
它可以360°环形排布多行文本,但是这个做法必须手动设定每个字符的间隔,而摒弃了字符本身的间距设定。当使用一个设定应对不同的文字时,可能会出现重叠或分散的结果,比如:
按照中文手动调整好间距,显示正常:

如果此时换成英文,或改变文字,可能会出现下面的情况:

可见,英文字符的间距被明显拉大
建议使用的情况:

  • 需要360°文本环绕
  • 沿椭圆或其它函数图像环绕(没错,这个做法可以借助一些数学计算以其它曲线实现)
  • 文字的间距只要求均匀分布,对紧凑不做要求

本文做法

说白了就是想要保持字符本身的间距。以该角度入手,事实上相当于把线段沿圆弧附着。(想象一下胶带依附于中心轮的关系)
因此进一步分析就是:需要将线段映射到圆弧上。

均匀地映射肯定是最好,但本人才疏学浅,暂时没找到完全均匀映射的数学做法,只找到了一个在一定范围内可接受的方法。

线段圆弧映射

该做法利用复变函数 w = 1 / z w=1/z w=1/z将直线上的点映射到圆上。
具体的做法嘛……我也不会啊😭,我就一敲代码的……
但总之我在Stack Exchange上找到了一个大佬的解释:
原文链接

问题描述

I’m looking for a mapping f : R 2 → R 2 f: \mathbb{R}^2 \rightarrow \mathbb{R}^2 f:R2R2 that converts a line segment A A ′ A A^{\prime} AA with two end points A = ( x A , y A ) A=\left(x_A, y_A\right) A=(xA,yA) and A = ( x A ′ , y A ′ ) A=\left(x_{A^{\prime}}, y_{A^{\prime}}\right) A=(xA,yA) to an arc ⁡ B B ′ \operatorname{arc} B B^{\prime} arcBB with radius r r r centered at O O O, with end points B = ( x B , y B ) B=\left(x_B, y_B\right) B=(xB,yB) and B ′ = ( x B ′ , y B ′ ) B^{\prime}=\left(x_{B^{\prime}}, y_{B^{\prime}}\right) B=(xB,yB).

This mapping should map A A A to B B B and A ′ A^{\prime} A to B ′ B^{\prime} B. Does anyone know a closed-form relation for such mapping?

I know that mappings such as w = 1 / z w=1 / z w=1/z in complex plain can convert lines to circles. But I don’t know how to modify this to convert a particular line segment to an arc given the endpoints and radius.

解决方案

Treat everything as complex numbers, let P P P be a point on the line segment A A ′ A A^{\prime} AA. The map
P ↦ 1 A ′ − A ( P − A + A ′ 2 ) P \mapsto \frac{1}{A^{\prime}-A}\left(P-\frac{A+A^{\prime}}{2}\right) PAA1(P2A+A)

sends the line segment A A ′ A A^{\prime} AA to the line segment joining − 1 2 -\frac{1}{2} 21 to 1 2 \frac{1}{2} 21. Multiply B ′ − B B^{\prime}-B BB will rotate and scale the line segment to a parallel copy of B B ′ B B^{\prime} BB. After another translation, the map
P ↦ P ′ =  def  B + B ′ 2 + B ′ − B A ′ − A ( P − A + A ′ 2 ) P \mapsto P^{\prime} \stackrel{\text { def }}{=} \frac{B+B^{\prime}}{2}+\frac{B^{\prime}-B}{A^{\prime}-A}\left(P-\frac{A+A^{\prime}}{2}\right) PP= def 2B+B+AABB(P2A+A)
sends line segment A A ′ A A^{\prime} AA to line segment B B ′ B B^{\prime} BB.

To send line segment B B ′ B B^{\prime} BB to an arc joining them, you first figure out the mid point M M M of the circular arc B B ′ B B^{\prime} BB and let N = 2 O − M N=2 O-M N=2OM be the antipodal point of M M M on the circle holding the arc.
N = O − 2 r 4 r 2 − ∣ B − B ′ ∣ 2 ( B + B ′ 2 − O ) N=O-\frac{2 r}{\sqrt{4 r^2-\left|B-B^{\prime}\right|^2}}\left(\frac{B+B^{\prime}}{2}-O\right) N=O4r2BB2 2r(2B+BO)
If you perform a circle inversion with respect to N N N and radius R = ∣ B − N ∣ = ∣ B ′ − N ∣ R=|B-N|=\left|B^{\prime}-N\right| R=BN=BN, the point P ′ P^{\prime} P will get mapped to a point P ′ ′ P^{\prime \prime} P′′ on the circular arc joining B B ′ B B^{\prime} BB.
P ′ ↦ P ′ ′ =  def  N + R 2 P ˉ ′ − N ˉ P^{\prime} \mapsto P^{\prime \prime} \stackrel{\text { def }}{=} N+\frac{R^2}{\bar{P}^{\prime}-\bar{N}} PP′′= def N+PˉNˉR2
Please note that in denominator of above expression, you need to take complex conjugation for P ′ P^{\prime} P and N N N.

Matlab实现

作为一个码农我唯一能做的就是测试一下顶不顶真了。

O = 0i +0;
B = sqrt(2)/2 + sqrt(2)/2i;
B1 = sqrt(2)/2 - sqrt(2)/2i;
A1 = 2 + 1i;
A = 2 - 1i;
P = 2 - 0.5i;

BB1toBB1arc(CalculateN(B, B1, O, 1), B, AA1toBB1(A,A1,B,B1,P))

function P1 = AA1toBB1(a, a1, b, b1, P)
    P1 = (b+b1)/2+(b1-b)/(a1-a)*(P-(a+a1)/2);
end

function N = CalculateN(b, b1, o, r)
    N = o - 2 * r/sqrt(4*r^2-abs(b-b1)^2)*((b+b1)/2-o);
end

function P2 = BB1toBB1arc(n, b, P1)
    R = abs(b-n);
    P2 = n + R^2/(conj(P1)-conj(n));
    
end

寻找与线段等长的圆弧

给定圆心、弧中点和线段长度就可以计算出半径及应向两端延伸的距离。进而利用坐标关系求出弧的两端点:

C#代码

Vector2 O;
Vector2 MapMidPoint;
float Radius = (O - MapMidPoint).Length();

//求线段在圆弧上的端点(中点位置往圆两侧延伸各一半)
void CalculateEndPoints(float length)
{
    float lineHalfLength = length / 2;

    B = VectorAsComplex(CalculateEndPoint(true));
    B1 = VectorAsComplex(CalculateEndPoint(false));

    Vector2 CalculateEndPoint(bool clockwise)
    {
        var angle = MathF.Atan2(MapMidPoint.Y - O.Y, MapMidPoint.X - O.X);
        if (clockwise)
        {
            angle = angle - lineHalfLength / Radius;
        }
        else
        {
            angle = angle + lineHalfLength / Radius;
        }
        Vector2 result = new Vector2(O.X + Radius * MathF.Cos(angle), O.Y + Radius * MathF.Sin(angle));
        return result;
    }
}

线段圆弧映射(C#实现)

Line2CirArcTransformator 是进行映射的类,创造对象时就会预计算好所有参数,当以直线上的点P作为函数MapLinePoint(Vector2 p)的输入时,就会给出其在对应圆上的位置。
该类不只局限于Unity使用,在任何需要用到相关计算的场景都可以调用。

该脚本的另一个构造函数还支持指定弧上两端点进行映射。

using System;
using System.Numerics;

namespace MapLineSegmentToIsometricArc
{
    /* 求线段在某个圆中对应长的弧端点:
     * https://math.stackexchange.com/questions/275201/how-to-find-an-end-point-of-an-arc-given-another-end-point-radius-and-arc-dire
     * 在虚数空间中求线段在弧中的对应位置映射:
     * https://math.stackexchange.com/questions/3912758/a-mapping-that-converts-a-line-segment-to-an-arc
     */
    class Program
    {
        static void Main(string[] args)
        {
            const float ya = 4;
            //线段
            var A = new Vector2(2, -ya);
            var A1 = new Vector2(2, ya);
            //单位圆
            var O = new Vector2(0, 0);
            var B = new Vector2(MathF.Sqrt(2) / 2, MathF.Sqrt(2) / 2);
            var B1 = new Vector2(MathF.Sqrt(2) / 2, -MathF.Sqrt(2) / 2);

            var transformator = new Line2CirArcTransformator(A, A1, B, B1, 1); //向给定弧映射

            Console.WriteLine(transformator.MapLinePoint(new Vector2(2, ya)));
            Console.WriteLine(transformator.MapLinePoint(new Vector2(2, 0)));
            Console.WriteLine(transformator.MapLinePoint(new Vector2(2, -ya)));

            transformator = new Line2CirArcTransformator(A, A1, O, new Vector2(1, 0)); //找等长弧映射
            Console.WriteLine(transformator.MapLinePoint(new Vector2(2, ya)));
            Console.WriteLine(transformator.MapLinePoint(new Vector2(2, 0)));
            Console.WriteLine(transformator.MapLinePoint(new Vector2(2, -ya)));
        }
    }

    public class Line2CirArcTransformator
    {
        public Complex A1 { get; private set; }
        public Complex A { get; private set; }
        public Complex LineMidPoint { get; private set; }
        public Complex B { get; private set; }
        public Complex B1 { get; private set; }
        public Complex BBMidPoint { get; private set; }
        public Complex O { get; private set; }
        public double Radius { get; private set;}
        private Complex N { get; set; }
        private double InversionCircleRadius { get; set; }
        /// <summary>
        /// 计算P1时会用到的中间变量
        /// </summary>
        private Complex FactorForP { get; set; }

        /// <summary>
        /// <para>根据给定圆上位置找出一条等长弧并映射直线上的点</para>
        /// <para>根据线段长度和圆半径决定优劣弧</para>
        /// </summary>
        /// <param name="A">直线的端点1</param>
        /// <param name="A1">直线的端点1</param>
        /// <param name="O">圆心</param>
        /// <param name="MapMidPoint">映射圆弧在圆上的中点位置</param>
        public Line2CirArcTransformator(Vector2 A, Vector2 A1, Vector2 O, Vector2 MapMidPoint)
        {
            float Radius = (O - MapMidPoint).Length();
            this.Radius = Radius;
            float segmentLength = (A - A1).Length();
            CalculateEndPoints(segmentLength);

            Initialize(A, A1, O, segmentLength > MathF.PI * Radius);

            Console.WriteLine("B: " + B);
            Console.WriteLine("B1:" + B1);

            //求线段在圆弧上的端点(中点位置往圆两侧延伸各一半)
            void CalculateEndPoints(float length)
            {
                float lineHalfLength = length / 2;

                B = VectorAsComplex(CalculateEndPoint(true));
                B1 = VectorAsComplex(CalculateEndPoint(false));

                Vector2 CalculateEndPoint(bool clockwise)
                {
                    var angle = MathF.Atan2(MapMidPoint.Y - O.Y, MapMidPoint.X - O.X);
                    if (clockwise)
                    {
                        angle = angle - lineHalfLength / Radius;
                    }
                    else
                    {
                        angle = angle + lineHalfLength / Radius;
                    }
                    Vector2 result = new Vector2(O.X + Radius * MathF.Cos(angle), O.Y + Radius * MathF.Sin(angle));
                    return result;
                }
            }
        }

        /// <summary>
        /// <para>根据给定的弧映射直线上的点</para>
        /// <para>所确定的圆心永远在自B向B1的右边</para>
        /// <para>需要指定优劣弧,默认为劣弧</para>
        /// </summary>
        /// <param name="A">直线的端点1</param>
        /// <param name="A1">直线的端点1</param>
        /// <param name="B">圆弧的端点1</param>
        /// <param name="B1">圆弧的端点2</param>
        /// <param name="Radius">圆的半径</param>
        public Line2CirArcTransformator(Vector2 A, Vector2 A1, Vector2 B, Vector2 B1, float Radius, bool isMajorArc = false)
        {
            this.Radius = Radius;
            this.B = VectorAsComplex(B);
            this.B1 = VectorAsComplex(B1);
            Initialize(A, A1, CalculateO(), isMajorArc);

            Console.WriteLine("B: " + B);
            Console.WriteLine("B1:" + B1);

            //https://math.stackexchange.com/questions/1781438/finding-the-center-of-a-circle-given-two-points-and-a-radius-algebraically
            Vector2 CalculateO()
            {
                var xa = (B1.X - B.X) / 2;
                var ya = (B1.Y - B.Y) / 2;
                var a = MathF.Sqrt(xa * xa + ya * ya);
                var b = MathF.Sqrt(Radius * Radius - a * a);
                return new Vector2((B.X + B1.X) / 2 + b * ya / a, (B.Y + B1.Y) / 2 - b * xa / a);
            }
        }

        /// <summary>
        /// 初始化除B以外的变量
        /// </summary>
        /// <param name="A">直线的端点1</param>
        /// <param name="A1">直线的端点1</param>
        /// <param name="O">圆心</param>
        private void Initialize(Vector2 A, Vector2 A1, Vector2 O, bool isMajorArc)
        {
            //测试时发现需要调转个方向
            this.A1 = VectorAsComplex(A);
            this.A = VectorAsComplex(A1);
            this.O = VectorAsComplex(O);
            //this.MapMidPoint = VectorAsComplex(MapMidPoint);

            LineMidPoint = VectorAsComplex((A + A1) / 2);

            //B = VectorAsComplex(new Vector2(MathF.Sqrt(2)/2, MathF.Sqrt(2) / 2));
            //B1 = VectorAsComplex(new Vector2(MathF.Sqrt(2)/2, -MathF.Sqrt(2) / 2));
            BBMidPoint = (B + B1) / 2;
            N = CalculateN();
            InversionCircleRadius = (B - N).Magnitude;
            FactorForP = (B1 - B) / (this.A1 - this.A);

            Complex CalculateN()
            {
                return this.O  + (isMajorArc ? 1: -1) * 2 * Radius / Math.Sqrt(4 * Radius * Radius - Math.Pow((B - B1).Magnitude, 2)) * (BBMidPoint - this.O);
            }
        }

        private Complex VectorAsComplex(Vector2 vector) => new Complex(vector.X, vector.Y);

        public Vector2 MapLinePoint(Vector2 point)
        {
            var p = VectorAsComplex(point);
            // AA' to BB'
            Complex p1 = BBMidPoint + FactorForP * (p - LineMidPoint);
            Complex result = N + InversionCircleRadius * InversionCircleRadius / (Complex.Conjugate(p1) - Complex.Conjugate(N));
            return new Vector2((float)result.Real, (float)result.Imaginary);
        }
    }
}

Unity中实现TextMeshPro文字弯曲

  1. 引入上面的脚本(低版本Unity需要手动将MathF库改为UnityEngine.Mathf)
  2. 改写TextMeshPro Sample Scene 25的脚本
using UnityEngine;
using TMPro;

namespace Scripts.Utilities
{
    [ExecuteInEditMode]
    public class TextProOnACircleCurve : MonoBehaviour
    {
        [SerializeField]
        private float radius = 50;
        /// <summary>
        /// The text component of interest
        /// </summary>
        private TMP_Text m_TextComponent;

        /// <summary>
        /// True if the text must be updated at this frame 
        /// </summary>
        private bool m_forceUpdate;

        /// <summary>
        /// Awake
        /// </summary>
        private void Awake()
        {
            m_TextComponent = gameObject.GetComponent<TMP_Text>();
        }

        /// <summary>
        /// OnEnable
        /// </summary>
        private void OnEnable()
        {
            //every time the object gets enabled, we have to force a re-creation of the text mesh
            m_forceUpdate = true;
        }

        /// <summary>
        /// Update
        /// </summary>
        protected void Update()
        {
            //if the text and the parameters are the same of the old frame, don't waste time in re-computing everything
            if (!m_forceUpdate && !m_TextComponent.havePropertiesChanged)
            {
                return;
            }

            m_forceUpdate = false;

            //during the loop, vertices represents the 4 vertices of a single character we're analyzing, 
            //while matrix is the roto-translation matrix that will rotate and scale the characters so that they will
            //follow the curve
            Vector3[] vertices;
            Matrix4x4 matrix;

            //Generate the mesh and get information about the text and the characters
            m_TextComponent.ForceMeshUpdate();

            TMP_TextInfo textInfo = m_TextComponent.textInfo;
            int characterCount = textInfo.characterCount;

            //if the string is empty, no need to waste time
            if (characterCount == 0)
                return;

            //gets the bounds of the rectangle that contains the text 
            float boundsMinX = m_TextComponent.bounds.min.x;
            float boundsMaxX = m_TextComponent.bounds.max.x;


            var origin = new System.Numerics.Vector2(0, 0 - radius);
            var transformator = new Line2CirArcTransformator(
                new System.Numerics.Vector2(boundsMinX, 0),
                new System.Numerics.Vector2(boundsMaxX, 0),
                origin,
                new System.Numerics.Vector2(0, 0));

            //for each character
            for (int i = 0; i < characterCount; i++)
            {
                //skip if it is invisible
                if (!textInfo.characterInfo[i].isVisible)
                    continue;

                //Get the index of the mesh used by this character, then the one of the material... and use all this data to get
                //the 4 vertices of the rect that encloses this character. Store them in vertices
                int vertexIndex = textInfo.characterInfo[i].vertexIndex;
                int materialIndex = textInfo.characterInfo[i].materialReferenceIndex;
                vertices = textInfo.meshInfo[materialIndex].vertices;

                //Compute the baseline mid point for each character. This is the central point of the character.
                //we will use this as the point representing this character for the geometry transformations
                Vector3 offsetToMidBaseline = new Vector2((vertices[vertexIndex + 0].x + vertices[vertexIndex + 2].x) / 2, textInfo.characterInfo[i].baseLine);

                //remove the central point from the vertices point. After this operation, every one of the four vertices 
                //will just have as coordinates the offset from the central position. This will come handy when will deal with the rotations
                vertices[vertexIndex + 0] += -offsetToMidBaseline;
                vertices[vertexIndex + 1] += -offsetToMidBaseline;
                vertices[vertexIndex + 2] += -offsetToMidBaseline;
                vertices[vertexIndex + 3] += -offsetToMidBaseline;

                var result = transformator.MapLinePoint(new System.Numerics.Vector2(offsetToMidBaseline.x, offsetToMidBaseline.y));

                //calculate atan2 as if the origin of circle is (0,0)
                var rayResult = result - origin;
                float angle = Mathf.Atan2(rayResult.Y, rayResult.X); //we need radians for sin and cos

                matrix = Matrix4x4.TRS(new Vector3(result.X, result.Y, 0), Quaternion.AngleAxis(angle * Mathf.Rad2Deg - 90, Vector3.forward), Vector3.one);

                //apply the transformation, and obtain the final position and orientation of the 4 vertices representing this char
                vertices[vertexIndex + 0] = matrix.MultiplyPoint3x4(vertices[vertexIndex + 0]);
                vertices[vertexIndex + 1] = matrix.MultiplyPoint3x4(vertices[vertexIndex + 1]);
                vertices[vertexIndex + 2] = matrix.MultiplyPoint3x4(vertices[vertexIndex + 2]);
                vertices[vertexIndex + 3] = matrix.MultiplyPoint3x4(vertices[vertexIndex + 3]);
            }

            //Upload the mesh with the revised information
            m_TextComponent.UpdateVertexData();
        }
    }
}

  1. 在inspector中挂载该脚本到TextMeshProUGUI物体上,调整radius,取消再重新勾选以在editor中预览。
  2. 调整位置,直至文本贴合背景图片上的曲线,大功告成!
    在这里插入图片描述
  • 7
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值