贝塞尔曲线文字路径

发布时间 2023-09-16 04:13:12作者: 池中物王二狗

译者注

这篇文章原本是之前翻译 《曲线艺术编程》系列第八章--贝塞尔曲线一章中引用的内容

作者提到过知识点可参考这篇文章以及优化和线性插值所以我也时分想仔细看一篇

在当时其实打开看过一眼,其中有看到导数部分,当时就怕翻译错了,所以我回头抽空复习了一下数学的导数部分。说实话,毕业后的工作与生活中从未用到过导数

毕竟我只是个小前端,而非搞图形学的。写写 UI 之类的真用不到导数相关的知识

不过仔细想想

其实以前看过一些开发游戏相关的书籍中其实一直都有高等数学相关的知识,只是自己选择性的忽略了,毕竟只是泛泛的看。

高中的导数我复习过了,反正应该能理解翻译这篇文章了。

对了,提醒一下,原文是 2009 年 5 月 29 号 要实的现文字效果确实需要这项技术

现在是 2023 年 ... 实现的效果其实现在我们在 web 用 svg 的 textPath 很容易实现

但原理还是很值得学习的

以下是译文开始

背景

不久前,我对文字特效比较好奇,探索的其中之一便是将文本延曲线排列实现类似擦除的特效。

为此我弄了个原型,尝试不同的解决方案。这篇文章记录了一此通用解决方案,作为我的工作回顾。

这些记录的解决方案可作为实现类似效果的工作手册。

我最开始用的是 C# 和 GDI+ 实现,当然这一实现方法也适用于其它框架。

弯曲的文本

给定一些任意的样条曲线,如何延曲线将文本绘制在上面且吸引人

image
期望实现的效果

在网上找了一圈,发现实现例子比较少,大部分实现的很糙,比如将字母单独延曲线进行线性旋转变幻,这样实现的结果非常生硬,不流畅。

更好的实现方法被收集在了一篇 1995 年的老文章内,作者是 ~Don Lancaster, 比较特殊的一点是非线性图形变幻(Nonlinear Graphic Transforms)。

牢记在这一领域我们是站在巨人的肩膀上。非常感谢 Don 这些年产出的非常优质且易理解的知识。

矢量文本

一般来讲要用 GDI+ 绘制文本,大部分人首先想到的就是 Graphics.DrawString() 类似这样的 API, 它可以用于提供字一个符串传进图形上下文(Graphics context)。结果是一堆像素信息,对于非线性变形没啥用处。

更好的方式是获取渲染文本包含可用于形变点的外边形矢量,可用于形变成我们期望的效果。幸运的是 GraphicsPath 提供了此功能方法,即便 API 比较别扭。

帮助我们理解 GraphicsPath 的一点是:GraphicsPath 是一个标准化的显示列表,持有非常少的矢量元信息。

就 GraphicsPath 而言它不关注像素信息, 直到它用 Pens 或 Brushes 渲染。

事实上,以下就是我们会用到的 GraphicsPath 元数据的完整列表:

  • Start: 定义路径起始点

  • Line: 线条。包含结束点,最后一条指令起始点作为最后一个点。

  • Bezier: 三阶贝塞尔曲线

  • Bezier3: 二阶贝塞尔曲线

  • CloseSubpath: 用于子路径的闭合

  • PathMarker: 定义一条路径的标记,只是标记了信息并不用于渲染

  • DashMode: 是否虚线模式渲染

当使用 GraphicsPath 进行绘制时,跟显示相关的方法仅有两个:线条和贝塞尔曲线。注意 MSDN 文档混淆了二阶与三阶样条曲线(想象一下! 官方文档居然进行错误引导)。

在实际使用中,在 GraphicsPath 上从未用到过二阶样条曲线(Bezier3)

其它形状怎么办?在最后椭圆可以被添加进 GraphicsPath, 它应该有椭圆元数据对吧?错!,如果一个椭圆被添加进了 GraphicsPath, 在内部它会用数条贝塞尔样条曲线模拟实现。

那么使用 GraphicsPath 添加文本呢?再次的,它会被简化为一堆点,线条,和贝塞尔曲线。这些都直接来源于字体描述,它也是基于矢量的。

比较好的一件事是在 GraphicsPath 内一堆绘制,可通过迭代获取内部用于绘制的完整的描述信息。

有了这些元信息就可重建这条路径。在处理过程中,这些点可用一些形变操作达到文本缠绕效果。

image
文本被添加进 一个 GraphicsPath 然后渲染出来且没有填充
所有这条路径的点被标记为红色(包含用于样条的控制点)

贝塞尔曲线

在此我第一次尝试,我决定使用贝塞尔样条定义文本的路径。GDI+ 支持三阶贝塞尔, 图形对象上有一些用于绘制的方法, 为数不多中的一个可用于获取元数据。

贝塞尔公式计算比较简单,它们可被用于摸拟其它曲线,通过控制点创建一条贝塞尔曲线练习获取一堆延贝塞尔曲线的点信息比较容易。更复杂的图形/曲线稍后会有。

获取贝塞尔曲线上的一个点 GDI+ 并未提供,所以搞懂三阶贝塞尔公式在此是必须的。我并不打算讲解背后的大量数学细节。此课题在Wikipedia 和其它地方有大量的资料可供查询。

贝塞尔曲线公式

好了,数学时间到!大多数贝塞尔曲线的几何形成图形应该像以下图

image

The geometric construction of a cubic Bézier (image from Wikipedia)

曲线由 P0-P3 共 4 个点控制。第一个和最后一个是曲线的起始与结束点, 另两个是点通常被称为 “手柄” 用于控制曲线形状。

四个点被连接成三条线断(图中灰色线)。

在控制点连接形成的线段上的三个点由参数 t 插值形成,在上面构成了两条绿色线断。

最后一条蓝色线断由前两条绿色插值线与 t 同样方式产生。

最后一个点是延最后这条线断插值产生,且这个点就 曲线 t 值 对应曲线上的点

两个标量之间的线性插值如下:

vlerp = v0 + ( v1 - v0 ) * t 

可用写个 2 个 2D 点之间的线性插值方法:

lerp(P0, P1, t) :
    xlerp = x0 + ( x1 - x0 ) * t 
    ylerp = y0 + ( y1 - y0 ) * t 
    return point (xlerp, ylerp)

上面的几何图形就可被以下方式表达:

P4 = lerp(P0, P1, t);
P5 = lerp(P1, P2, t);
P6 = lerp(P2, P3, t);
P7 = lerp(P4, P5, t);
P8 = lerp(P5, P6, t);
P9 = lerp(P7, P8, t);

P9 就是曲线上 t 对应的点

贝塞尔曲线也可被表达为8个系数的三阶多项式方程:(稍后我会解释如何通过计算从4个控制点获取8个系数)

x = At3 + Bt2 + Ct + D 
y = Et3 + Ft2 + Gt + H 

让 t 范围从 0 到 1, 多项式会产生曲线上的一个 x, y 坐标点。当值超范围此方法还会在某处继续产生无限的坐标点。x 与 y 的版本公式非常相似,只是系数不同。

那么 系数(A..H)从哪里来的?完整的数学解释在最后一节,现在我只会给出控制点转换到系数的公式。

给定样条 4 个控制点 P0 .. 03, 点的值为 (x0,y0) .. (x3,y3),系数则是:

A = x3 - 3 * x2 + 3 * x1 - x0
B = 3 * x2 - 6 * x1 + 3 * x0
C = 3 * x1 - 3 * x0
D = x0

E = y3 - 3 * y2 + 3 * y1 - y0
F = 3 * y2 - 6 * y1 + 3 * y0
G = 3 * y1 - 3 * y0
H = y0

如果有必要从系数转为控制点的话,下面是反转操作:

x0 = D;
x1 = D + C / 3
x2 = D + 2 * C / 3 + B / 3
x3 = D + C + B + A

y0 = H;
y1 = H + G / 3
y2 = H + 2 * G / 3 + F / 3
y3 = H + G + F + E

为了说明到目前为止所涵盖的内容,以下是一些伪代码,使用控制点 P0..P3:

// draw the Bézier using GDI+ function (g is Graphics object)
g.DrawBezier(Pens.Black,P0, P1, P2, P3);

// draw lines connecting the control points

g.DrawLine(redPenWithEndCap, P0,P1);
g.DrawLine(redPenWithEndCap, P2,P3);

[[compute coefficients A thru H as described above]]
[[用以上提到的方法计算出 A 到 H 系数]]

// draw 20 points, with a fixed increment for the parameter t
for (float t = 0; t <= 1; t += 0.05f)  
{
    x = At3 + Bt2 + Ct + D 
    y = Et3 + Ft2 + Gt + H 
    
    // call function that draws a filled rect at x,y coord.
    DrawBoxAtPoint(g, Color.Blue, x, y);  
}

image
伪代码输出黑色曲线,控制点由红线连接,20个计算出的点标为了蓝色

随着 t 从 0 - 1, 这些点延曲线从起始点至结束点“散布” 。如果用的点足够多,它可以很好的模拟绘制出贝塞尔曲线。

事实上,在内部,很多图形系统绘制贝塞尔曲线使用递归细分法,分割曲线至像素级别或足够小的短线绘制。

另一件值得关注的有趣的点是,尽管 t 的增长是定值的,根据它们处在曲线的位置,一些点却比另一些靠的更近。

任意两个连续的点与其它任意两个点之间可能拥有不同的弧长 “arc-lengths” (延曲线测量长度,而不是直线距离)。

可以把它想像成延曲线 “提速” 和 “减速” 时 rate 速率不同,这有助于帮助理解,尽管 t 值的增长是固定的。稍后还会再讨论这点

切线和垂线

为了让贝塞尔曲线公式应用于文本点, 这个 x 值必须首先格式化成 0..1 范围,这样才能被贝塞尔公式的 t 参数使用。

如果文本开始或接近 X 原点,且文本长度已知,则格式化非常简单:

xnorm =  x / textwidth

文本的 Y 坐标点需要从曲线上的点垂直投影(译者注:相当于法线呗)。为了实现,需要通过 t 计算出一个曲线点对应的一个垂直向量。可以通过曲线上点的切线获取,并将其旋转 90 度。

计算贝塞尔的切线向量比较简单,只要对贝塞尔多项式求导:

Vx = 3At2 + 2Bt + C 
Vy = 3Et2 + 2Ft + G 

我们可以通过线性代数对这个向量进行旋转 90 度, 或更简单的只需要交互这两项并将其中一项取负值。

如果 V = (x, y) 那么 Vrotate90 = (y, -x) 或 (-y, x), 具体用哪一种方式取决于你。

计算出一个向量垂直于贝塞尔曲线上一个点,这个点它由参数 t 求得。

在点的上面把垂直向量画出来,像下图这样:

image
原始垂直向量. 为了绘制已经被缩短了. (末端还添加了小箭头)

文本点的 Y 坐标需要调整到垂直向量方向,但是这些向量的长度其实不重要,重要的是方向。为了方便垂直向量可以格式化为标准向量,让它们都变为 1 单位的长度。一个向量拥有 x 和 y , 像下面这样做即可:

magnitude = sqrt( x2 + y2 )  // 距离公式
x = x / magnitude
y = y / magnitude
// 注意:magnitude 为 0,则 x,y 也为 0 或 undefined

image
20 个格式化垂直向量,由参数 t 定义
为了绘图,向量长度为 10 像素

对于首次接触使用贝塞尔曲线缠绕文本来说,这点数学知识足够了

首试:伸缩文本

在缠绕文本上首次尝试,给定贝塞尔控制点 P0..P3,还有图形上下文 g 绘制:

string text = "Some text to wrap";

[[ Calculate coefficients A thru H from the control points ]]

GraphicsPath textPath = new GraphicsPath();

// the baseline should start at 0,0, so the next line is not quite correct
path.AddString(text, someFont, someStyle, someFontSize, new Point(0,0));

RectangleF textBounds = textPath.GetBounds();
 
for (int i =0; i < textPath.PathPoints.Length; i++)
{
    PointF pt = textPath.PathPoints[i];
    float textX = pt.X;
    float textY = pt.Y;
    
    // normalize the x coordinate into the parameterized value
    // with a domain between 0 and 1.
    float t =  textX / textBounds.Width;  
       
    // calculate spline point for parameter t
    float Sx = At3 + Bt2 + Ct + D 
    float Sy = Et3 + Ft2 + Gt + H 
        
    // calculate the tangent vector for the point        
    float Tx = 3At2 + 2Bt + C 
    float Ty = 3Et2 + 2Ft + G 
    // rotate 90 or 270 degrees to make it a perpendicular
    float Px =   Ty
    float Py = - Tx
    
    // normalize the perpendicular into a unit vector
    float magnitude = sqrt(Px2 + Py2)
    Px = Px / magnitude
    Py = Py / magnitude
    
    // assume that input text point y coord is the "height" or 
    // distance from the spline.  Multiply the perpendicular vector 
    // with y. it becomes the new magnitude of the vector.
    Px *= textY;
    Py *= textY;
    
    // translate the spline point using the resultant vector
    float finalX = Px + Sx
    float finalY = Py + Sy
    
    // I wish it were this easy, actually need 
    // to create a new path.
    textPath.PathPoints[i] = new PointF(finalX, finalY);
}

// draw the transformed text path		
g.DrawPath(Pens.Black, textPath);

// draw the Bézier for reference
g.DrawBezier(Pens.Black, P0,  P1,  P2,  P3);

image

用控制点伪代码生成的结果

看起来不错,但还存在一些问题。文本中间挤在一起了,边上又比较松散。牢记 arc-length 在点之间是变化的,即便 t 增加是定死的。

我将通过 定增的 t 添加一些点,并盖在方向向量上用于显示它们是如何用于缠绕文本的:

image

如图贝塞尔的公式 arc-length 是非参数化的,问题就在于此。

当参数 t 定增时,计算得出的前后点之间 arc-lengths 是变化的。

贝塞尔的公式的这一特点,导致了文本在曲线上产生挤压或拉伸的效果。

此外算法还有另一个问题:文本总是被挤压或拉伸, 如下所示:

image

由于直接从0..1范围映射文本被挤压

image
由于直接从 0..1 范围映射文本被拉伸

我们期望更好的效果是参数 t 映射至文本 x 轴坐标不要试图将文本填满完整的贝塞尔曲线。为了实现这一目的, 曲线的 弧长 必须是已知的。问题又回到了起点。

解决文本长度问题(计算贝塞尔曲线长度)

解决提出的第二点问题(文本长度)比较简单,如果曲线的 弧长已知。

相比于映射文本真实宽度 0..1 对应到 t ,文本可以先绘制进“边界盒”(译者注:通常图形计算中的 AABB 中的 BB),边界盒的宽度是样条曲线的 弧长, 然后盒子的 x 坐标通过除以所有 弧长 的 x 坐标值后被缩放至 0..1。看图更容易理解:

image

上图的贝塞尔曲线 arc-length (弧长) 为 500 个单位,即边界盒的宽度也要 500 个单位(上半部分蓝色的)是为文本准备的,并缩放到 0..1 范围。

边界盒就像则以某种方式缠绕到了曲线上

在样条曲线上超出边界框部分的文本会被剪除掉。

然后,遗留的问题就剩如何计算贝塞尔曲线的 弧长了。在经过一翻研究后,我发现很明显没有一个标准的解决方案。

模拟 弧长的方法非常多,有一些解决方案涉及中等复杂度的微积分。而且最复杂和最精确的解决方案涉及迭代逼近法。

然而,有一种非常简单的估算 弧长方法:基于定增的 t 将曲线分割成一堆线段,然后统计所有线段的长度。它产生的结果是分割的越小,得到的模拟估算越准,它运用了贝塞尔曲线的特征输出点靠的越近曲线越平滑。

一些经验调查表明大约 100 份的分割仅会产生小于 0.001 的估算错误,这仅是 500 像素长的曲线的一半。用于我们手头的这点儿活来说精度足够了。

处理过程可以被可视化成在贝塞尔曲线上面画多条线段:

image
用蓝色的 4 段线去估算弧长精度不够

image
用 8 段的话精度就高多了

伪代码非常简单。有许多可优化之处,但基础的算法足够高效了,只要分割数不要太大。

int numberOfDivisions = 100;
int maxPoint = numberOfDivisions + 1;

// _formula.GetPoint(t) 通过 t 获取点坐标
// 点通过贝塞尔公式计算

PointF previousPoint = _formula.GetPoint(0);  // 曲线起点

float sum = 0;

for (int i = 1; i <= maxPoint; i++)
{
    PointF p = _formula.GetPoint( i / (float) maxPoint );
    sum += distance(previousPoint, p);
    previousPoint = p;
}

// 计算两点距离的方法:
float distance(PointF a, PointF b) { return sqrt( (bx - ax)2 + (by - ay)2 ); }

该代码可以很容易地被通用化到任何连续的单值参数化算法,它拥有固定的范围,返回多个点,比如椭圆公式或其它不同类型的样条曲线。

估算误差并动态地选择一个“理想的”除数是可能的。这可以通过细分来完成,直到线段长度低于某个阈值,或者通过比较线段的切线和曲线的切线,但我没有探索这两种想法。

我用固定数 100 似乎对当前的目的足够有效。

弧长参数化

即使文本宽度问题解决后,由于在标准化 t 值之间弧长还没有被标准化 文本被挤压和拉伸的问题还是存在。

image
该图说明了弧长参数化的缺乏。这个问题独立于另一个问题,但有一个相关的解决方案。
(垂直矢量旋转了180°,因此不会被遮挡)

解决该问题的方法被称为孤长参数化。其思想是将一个输入值 u 映射到贝塞尔公式的参数 t 上,并且将产生对于 u 一样标准化的等距弧长的点。

换句话说,若 u 的值是 0..1 ,它将映射到 t 的值,通过此方法产生的某个点值就是弧长距离起点的某个分数值。

举个例子,u 值如果为 0.25 , 它将总是会返回整条曲线弧长距离的 1/4。在此例中中间值 t 还是未知,必须通过某种映射方法计算得到。

我使用的实现方法(基于数学比我更好的朋友的建议)是 用一张孤长表近似。它保存了我们已讨论过的孤长公式计算的所有弧长。如果某条曲线弧长为 500,分为 100 份,表格将会显示如下:

  0.0
  5.2
 10.5
 15.7 
 ... (more points) ...
494.8
500.0

值索引对应参数 t 的某个值,如果列表包含了 101 个值,索引 0 包含了 t = 0.0 时的弧长, 索引 100 对应了 t=1.0 时孤长, 索引 50 对应了 t=0.5 时孤长。

如果表被存放在了名为 arcLength 的数组中,索引对应的 t 值即:

t = index / (float) (arcLengths.Length - 1)

有了这样一张表格,找到 t 对应的孤长就成为了可能。通过获取输入值 u , 计算期望的弧长( u * totalArcLength),然后在表中找到输入值对应的最大值,值大小于等于希望的弧长。可以用简单的遍历或更好的二分搜索。

如果恰好值等于弧长,输入值 u 对应的 t 值可以按之前那样算。通常情况下,找到的最大值会小于期望的弧长。在这种情况下,线性插值被用于输入值与下一个值之间估算 t 值。

假设使用了足够大的除数来创建表,那么这些点之间的距离将足够小,映射的误差可以忽略不计

以下的伪代码将演示这项技术:

float u = // 参数值 u , 范围 0 到 1 之间
float t; // 通过 u 要寻找的 t

float [] _arcLengths = // 预先计算好的贝塞尔曲线弧长列表

// u 对应的目标弧长
float targetArcLength = u * _arcLengths[ _arcLengths.Length - 1 ];

// 二分搜索
int index = IndexOfLargestValueSmallerThan(_arcLengths, targetArcLength)

// 如果精确匹配,返回基于精确 index 的 t
if (_arcLengths[index] == targetArcLength)
{
    t = index / (float) (arcLengths.Length - 1);
}
else  // 否则需要在两个点之间插值
{
    float lengthBefore = _arcLengths[index];
    float lengthAfter = _arcLengths[index+1];
    float segmentLength = lengthAfter - lengthBefore; // 两个点之间的距离

    // 决定在“前”和“后”点之间的位置
    float segmentFraction = (targetLength - lengthBefore) / segmentLength;
                          
    // add that fractional amount to t 
    // 加上额外的这部分分数 segmentFraction 给 t
    t = (index + segmentFraction) / (float) (_arcLengths.Length -1);
}

这个基本算法可以封装成通用的类用于处理弧长估算。以下就是使用此映射算法生成的图,表现好多了:

image
在弧长参数化后,注意标准化 u (标成蓝色的)现已均分在曲线上。(垂直向量旋转了 180 度以不被遮挡)

解决其它一些小问题

此算法仍然不完美。尤其是在长的水平线段在环绕在比较尖的曲线时字符会产生扭曲。除了最严重的情况外,这个问题可以通过迭代文本路径时将长线段分成更短的线段来解决。

image
字母 'T' 顶部有一条单独的横线,由于它处在比较尖的曲线部分它未能弯曲,

image
将线段分割成足够的小牺牲一部分性能的情况下可以改善显示效果

在一些例子中,这项技术可以从使用较短的贝塞尔曲线代替长的贝塞尔曲线中受益。然而,简单的使用贝塞尔曲线替换所有的线段是不够的,一些长线段上使用效果并不太好。

这就作为练习留给读者去寻找答案了

扩展至更复杂的路径

此项技术可以很简单的扩展到更复杂的任意路径。所要做的就是计算整条路径的弧长(通过累计所有部分的弧长),并全部参数化。

可将公式封装成类,其包含了多种子路径,并可以通过对总长度的反向插值来决定对特定参数使用哪个子路径,或者可以集成到弧长估算公式内。

image
文字在任意路径上的缠绕效果

这产生了一个潜在的新问题,特别是文本缠绕在另一个文本路径上。断点(路径停止点并重新开始往另外一处),如果字符或其它对象跨越这些点可能引起严重的错乱。

一个通用的算法需要考虑到这些,并且确保以合适方式处理这些问题,比如将字母推过这些断点处。很多字符包含了锐角弯曲和看起来效果不怎么好的过度包裹的边角(如上图所示)也得要处理

总结

此项扭曲文本(任意路径)的技术能得到相对自然流畅的显示效果。此算法允许曲线可以被实时计算表示,即使是在低端的现代硬件上。为了简化讲解,许多优化手段在此文中没有讨论。此技术可轻易的适用于任何拥有向量路径对象的图形框架(比如WPF,Cocoa,Postscript, 等)。巧妙的使用扭曲的矢量路径可以用于有趣的美学效果


原文 ~Warping Text to a Bézier curves


博客园: http://cnblogs.com/willian/
github: https://github.com/willian12345/