阅读须知:
本文将介绍:
有时我们需要将文字环绕在圆弧上,比如你的美术突然给了你这么一个素材:
啪的一下很酷啊,但问题来了,应该怎么把文字贴上圆弧呢?
使用本文介绍的方法,结果是这样的:
可以看到,无论是中文字符还是英文字符,都保留了其原有的字符间距,并环绕在圆弧上
就算字符不足以撑满圆弧,字符的间距也不会分散:
但是注意,如果文本超过了1/3圆弧的位置,还是会肉眼看到字符存在差异:
而且这种差异会随着字符增加进一步明显,超过2/3时达到几乎不可用的状态。
建议使用的情况:
也就是这个做法最多只能用于半圆弧的文字排布;
如果需要全圆排布,可以参考现有脚本部分的第二个做法;
如果那个做法也无法满足你的需求,那你可能就需要再想办法写一个了涅。
最开始肯定不愿意自己写,上网找现成的脚本。
TextMeshPro官方的Sample Scene 25有一个弯曲文本的实现,但那个做法只改变了Y坐标与旋转,而X坐标没做修改,结果就是,如果文字长度并非远小于半径时,会得到这样的结果:
在靠近两侧的地方,文字间距呈灾难式递增。
建议使用的情况:
这是由TonyViT编写的脚本
它可以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:R2→R2 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)
P↦A′−A1(P−2A+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
B′−B 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)
P↦P′= def 2B+B′+A′−AB′−B(P−2A+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=2O−M 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=O−4r2−∣B−B′∣22r(2B+B′−O)
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=∣B−N∣=∣B′−N∣, 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}}
P′↦P′′= 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.
作为一个码农我唯一能做的就是测试一下顶不顶真了。
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
给定圆心、弧中点和线段长度就可以计算出半径及应向两端延伸的距离。进而利用坐标关系求出弧的两端点:
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;
}
}
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; }
///
/// 计算P1时会用到的中间变量
///
private Complex FactorForP { get; set; }
///
/// 根据给定圆上位置找出一条等长弧并映射直线上的点
/// 根据线段长度和圆半径决定优劣弧
///
/// 直线的端点1
/// 直线的端点1
/// 圆心
/// 映射圆弧在圆上的中点位置
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;
}
}
}
///
/// 根据给定的弧映射直线上的点
/// 所确定的圆心永远在自B向B1的右边
/// 需要指定优劣弧,默认为劣弧
///
/// 直线的端点1
/// 直线的端点1
/// 圆弧的端点1
/// 圆弧的端点2
/// 圆的半径
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);
}
}
///
/// 初始化除B以外的变量
///
/// 直线的端点1
/// 直线的端点1
/// 圆心
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);
}
}
}
using UnityEngine;
using TMPro;
namespace Scripts.Utilities
{
[ExecuteInEditMode]
public class TextProOnACircleCurve : MonoBehaviour
{
[SerializeField]
private float radius = 50;
///
/// The text component of interest
///
private TMP_Text m_TextComponent;
///
/// True if the text must be updated at this frame
///
private bool m_forceUpdate;
///
/// Awake
///
private void Awake()
{
m_TextComponent = gameObject.GetComponent<TMP_Text>();
}
///
/// OnEnable
///
private void OnEnable()
{
//every time the object gets enabled, we have to force a re-creation of the text mesh
m_forceUpdate = true;
}
///
/// Update
///
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();
}
}
}