本期作者
刘孙瑞
哔哩哔哩高级开发工程师
钱爱娟
哔哩哔哩开发工程师
三角剖分
在前文中,我们拥有了对一个矢量图形的路径描述。在本文中我们会介绍,如何将已有的路径描述 (Path) 转化为,GPU可读取的三角形的顶点数据。整体处理过程如下图所示,路径作为输入,折线化模块首先使用折线近似曲线将其转化为复杂多边形,接着将复杂多边形简单化、简单多边形单调化、单调多边形三角化,最终得到一组三角形作为输出!
上图中相关的概念解释如下(可能与通常的定义略有差异):
简单多边形:边不相交的多边形
复杂多边形:除了简单多边形之外的多边形
单调多边形:如果一个多边形基于它的
折线化
在上一篇文章中提到,矢量图形是由折线、曲线以及填充规则描述的,我们需要完成的第一步则是化曲为直。
这一部分,主要介绍如何将前文中提到的曲线,包括二次贝塞尔曲线、三次贝塞尔曲线、二次有理贝塞尔曲线,使用多段折线进行拟合!
二次/三次贝塞尔曲线
对于
特别地,对于有拐点的三次贝塞尔曲线,会进一步检查其四分点,将拐点插入数据结构,继续进行二分:
二次有理贝塞尔曲线
折线化步骤:
使用
为迭代次数)个二次贝塞尔曲线近似二次有理贝塞尔曲线(参考论文[1])
对每个二次贝塞尔曲线使用上述二分逼近方法进行折线化
设具有相同控制点
重新参数化
代入可求出:
经过简单的求导,即可得到二次贝塞尔曲线
其中:
具体计算过程见上一章中有理De Casteljau算法部分的介绍!
第
当
总体上,使用二次贝塞尔曲线二分逼近二次有理贝塞尔曲线的算法伪代码如下:
对于
对于
得到第
上述算法的可视化表示如下:
复杂多边形的简单化
折线化后,我们得到由若干条折线组成的图形路径,即复杂多边形。我们首先需要求出所有交点,将相交的线段从交点处分裂开,把一个复杂的多边形拆分成多个简单的多边形。
求交点的过程中又可以细分为两个子问题:
已知两条线段如何求交点
二维平面内条线段如何求交点
我们接下来将分别进行讨论。
线段交点快速计算
给定两条线段:
具体计算步骤如下:
1. 先判断两个线段的包围盒 (bounding box) 是否相交,若不相交,则一定不存在交点;否则,继续执行下一步
2. 计算
3. 计算
4. 如果有以下情况,则两个线段不会有交点(只展示
5. 排除一定没有交点的情况后,添加必要的辅助线,即可求出交点坐标
交点
Bentley-Ottmann算法
Bentley-Ottmann算法[2],是一种以
明确目标:给定平面中
算法的核心是扫描线 (Sweep Line) 方法,假设使用水平扫描线
Dead 线段:这些线段完全位于扫描线
Active 线段:这些线段与扫描线
Sleeping 线段:这些线段完全位于扫描线
注意:本文中使用的扫描线算法均为水平扫描线,即从左向右扫描!
算法执行过程中主要维护两个数据结构:
X-结构:保存过渡点 (Transition points),按照
线段的端点:算法开始前已知
线段的交点:扫描期间被发现,并插入到X-结构
Y-结构:保存Active 线段,按照与扫描线
如果扫描线到达线段的过渡点,则Y-结构中Active 线段的顺序会发生变化!
在给出算法细节之前,需要一些定义。令
初始化:X-结构中存放
扫描过程:扫描线
扫描线遇到线段的左端点,例如
由于
在Y-结构中搜索扫描线
由于
2. 扫描线遇到线段的右端点,例如
在Y-结构中搜索扫描线
由于
测试
3. 扫描线遇到两个线段的交点,例如
在Y-结构中搜索扫描线
改变
测试
可以发现,在扫描过程中,我们保持以下三个不变性:
1. 对于扫描线
2. 对于扫描线
Sleeping 线段的所有端点
在
3. 对于扫描线
可以证明,在算法执行过程中这三个不变性都被正确维护了,即算法结束时所有的交点都会被找到!
简单多边形的单调化
复杂多边形简单化后,我们得到若干个简单多边形。接着,我们需要将每个简单多边形转化为若干个单调多边形,方便后续的三角化操作!
概念解释
在进行此部分讨论前,我们先定义一些概念:
x-单调链 (x-monotone chains):一个多边形的链,对于垂直于x轴的任意一条线,只有一条线段与之相交,那么这条链称为x-单调链。
同样我们可以按照类似的方式定义y-单调链或者任意方向的单调链。
x-单调多边形 (x-monotone polygon):如果一个多边形是简单多边形,且由两条x-单调链组成,那么我们称这个多边形为为x-单调多边形。
类似地,可以定义y-单调多边形。
有一类单调多边形比较特殊,如果单调多边形的一条单调链是一条直线,另一条是一个普通的单调链,我们将这种单调多边形称之为单调山(monotone mountain)。
x-单调山 (x-monotone mountain):由一条直线以及一条x-单调链组成。按照x-单调链处于扫描线前进方向的左右可以分为两种:
如果x-单调链在右,我们称之为R_mountain类型的单调山(后文简称R_mountain)
如果x-单调链在左,我们称之为L_mountain类型的单调山(后文简称L_mountain)
最后,我们对简单多边形中的顶点进行分类,以方便后续算法的描述:
图中阴影填充部分表示多边形内部!
单调化算法
相关定义
为了更清晰地描述算法过程,我们首先给出一些必要的符号定义和解释。
线段的左右侧单调多边形:一条线段
顶点的左右侧单调多边形:一个顶点
若
若
单调山中保存的边类型:我们定义两种不同的边类型,每个特定类型的单调山只保存单个类型的边组成的x-单调链,另外的一条直线不作保存,具体地:
R_mountain只包含Right_side类型的边组成的x-单调链
L_mountain只包含Left_side类型的边组成的x-单调链
当向R_mountain中添加Left_side类型的边时,意味着找到了另外的一条直线,我们只需添加一条对角线即可完成当前R_mountain的构建,大致过程如下所示(L_mountain类似):
单调多边形的插边规则:单调多边形的数据结构具有以下性质:
一个x-单调多边形,可以由一个或者多个x-单调山组成(链表形式存储)
两种类型的x-单调山交替排列
包含的每个x-单调山只保存x-单调链,而不保存另一条直线
当向单调多边形
若
若
R_mountain类型:直接向
L_mountain类型:则连接
插入Left_side类型边的过程类似!
下图示例中的单调多边形包含三个x-单调山,分别为:
L_mountain1:包含Left_side类型的边
R_mountain1:包含Right_side类型的边
L_mountain2:包含Left_side类型的边
其中
边的winding值定义:首先定义一条边的起始点start point和结束点end point,直观的解释为,先绘制的顶点为起始点,后绘制的顶点则为结束点。那么,如果一条边的起始点到结束点的x坐标单调增(x相等时,y单调减),则其winding值(简记为
边包含的顶点类型:一条边
具体过程
我们首先要思考一个问题,一个简单多边形为什么不是单调的?这里抽象的解释一下,不单调的原因是因为,简单多边形中可能存在汇合顶点(leftward cusp)与分裂顶点(rightward cusp)。我们只要将汇合顶点与分裂顶点消除掉,那么得到的多边形可以保证是单调的!
定理的证明可参考链接[3]。
与求交点的算法(Bentley-Ottmann算法)类似,这里仍使用扫描线 (Sweep Line) 思路。我们仍然要维护扫描线的基本元素,Dead 线段,Active 线段,Sleeping 线段。
在后文中,为了更直观地描述扫描过程,我们将以下图作为示例展开讨论 。图中对多边形进行了命名,后文中同一多边形与此图命名相同。图中为一个简单多边形被单调化后的结果,其被拆分成三个单调多边形,每个单调多边形又由多个单调山组成。
算法过程中维护的其他数据有所变化:
(1) X-结构:保存过渡点,按照
线段的端点:在多边形简单化的过程中已经求得的,所有线段的端点
(2) Y-结构:保存每个Active 线段的Left_mPoly(左边的单调多边形)和Right_mPoly(右边的单调多边形)
如上图,扫描线
初始化:X-结构中存放个
扫描过程:扫描线
我们首先需要找到顶点
例如,当扫描线
当扫描线
(1) Sleeping 线段变为 Active 线段时,
a. 先判断
<i>. 如果
我们以下图为例详细解释一下执行过程,扫描线从之前的
在移动扫描线之前
有一个以
在移动扫描线之后
发现
<ii>. 如果
下图为例详细解释一下执行过程,扫描线从之前的
在移动扫描线之前
有蓝色的单调多边形 mPoly_blue,其中包含三个单调山(blue1, blue2, blue3), blue1为L_mountain, blu2为R_mountain,blue3为L_mountain
有紫色的单调多边形 mPoly_purple,其中包含两个单调山(purple1, purple2), purple1为R_mountain, purple2为L_mountain
有橙色的单调多边形mPoly_orange ,其中包含一个单调山(orange1), orange1为R_mountain
在移动扫描线之后
发现
b. 枚举所有以
我们以下图为例详细解释一下执行过程,扫描线从之前的
在移动扫描线之前
有蓝色的单调多边形mPoly_blue,暂不包含任何单调山
在移动扫描线之后
枚举所有以
(2) Active 线段变为 Dead 线段 时,
a. 枚举
b. 如果
此步骤比较复杂,我们以下图为例详细解释一下执行过程,扫描线从之前的
在移动扫描线之前
有蓝色的单调多边形mPoly_blue,其中包含 一个L_mountain单调山(包含边
有紫色的单调多边形mPoly_purple,其中包含 一个R_mountain单调山(包含边
在移动扫描线之后
对于边
对于边
同时我们发现
填充规则传递
此算法执行完成过后,我们就将一个简单多边形,拆分成多个单调多边形(每个单调多边形包含一个或多个单调山)。但是这里还有一个问题需要解决。上文中我们只讨论了多边形的拆分,其实是并没有考虑这个拆分好的多边形是否应该被填充?单调化算法过程中也有一个问题,没有详细说明在新建单调多边形时winding值是如何计算的?
我们其实可以发现 winding number 的一个性质,每个单调多边形的 winding值(简记为
因此,在我们创建单调多边形时,新多边形的winding值定义为:
我们使用如下示例进行解释,对于按照图中箭头指示的五角星的绘制顺序,使用单调化算法可以得到以下结果:
Poly1的第一条边为
Poly2的第一条边为
Poly3的第一条边为
Poly4的第一条边为
Poly5的第一条边为
Poly6的第一条边为
得到每个单调多边形的winding值后,按照填充规则判断该单调多边形是否需要被填充:
若为非零缠绕规则:
若为奇-偶规则:
对于上述示例设置不同的填充规则可以得到以下不同的结果:
注意:我们只对需要填充的单调多边形进行下一节的三角剖分操作!
单调多边形的三角剖分
至此,我们得到了原始图形的单调多边形划分,其中每个单调多边形包含一个或多个单调山。最后,我们只需要对每个单调山进行三角化,即可提交给GPU进行绘制。
对于不同类型的单调山,依次遍历相邻三个顶点
若
若
若当前计算出的内角为凸,则直接剖出三角形
更直观地,单调多边形的三角剖分过程演示如下(以kRight单调山为例,kLeft类型同理):
小结
本章中我们讨论了矢量动画绘制中最关键的步骤之一 —— 三角剖分。下一章中,我们将根据生产环境中的一些特效场景(如路径如何绘制描边,如何绘制沿路径的渐变等)展开,讨论如何在三角剖分的基础上,针对不同的需求做相应的拓展,敬请期待!
参考文献
[1] Floater M. High-order approximation of conic sections by quadratic splines[J]. Computer Aided Geometric Design, 1995, 12(6): 617-637.
[2] Smid M. Computing intersections in a set of line segments: the Bentley-Ottmann algorithm[J]. 2003.
[3] https://tildesites.bowdoin.edu/~ltoma/teaching/cs3250-CompGeom/fall21/Lectures/L9-polygontriangulation/cg-polygontriangulation.pdf
[4] Computational Geoemtry: Algorithms & Applications.
[5] https://skia.org/docs/
以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!
往期精彩指路