与Bezier曲线的打断方法类似,B样条的打断利用了de Boor算法。并且结合B样条的强凸包性,我们可以推算出一种有效的B样条折线化方法。因此,本节是对前面几节内容的一个综合应用,后面,我的分享也主要面向应用。
1)B样条的打断
给定参数u,希望在u处将b样条分为两部分。这个过程与Bezier曲线的打断非常相似。只不过,Bezier的打断是在所有的控制点上进行“切角”,而对于\(u\in[u_k,u_{k+1})\),仅\(P_{k-p},…,P_k\)p+1个控制点被切角(其实Bezier曲线也是p+1个控制点…)。下图以三阶B样条为例,展示打断过程:
我分别用Q,R,S代表三次“切角”。de Boor算法最后计算出的C(u)即\(S_k\)。绿色的点\(P_{k-3},Q_{k-2},R_{k-1},S_{k}\)为打断后左边的B样条的新控制点,\(S_{k},R_{k},Q_{k},P_{k}\)为打断后右侧的B样条的新控制点。
我的C++版本的打断实现如下:
void BSpline::Subdeviding(double u,BSpline& sub_left,BSpline& sub_right) { //为了使用方便 const std::vector<double>& knots = m_vecKnots; const std::vector<Point>& cv = m_vecCVs; int p = m_nDegree; //查找 u 所在区间 int k = std::distance(knots.begin(),std::upper_bound(knots.begin(), knots.end(), u))-1; //保存de boor 算法迭代过程中生成的控制点 std::vector<Point> cv_left,cv_right; _subdeviding(u,k,knots,cv,cv_left,cv_right); //曲线在u处打断,生成新的控制点序列 假设原曲线的控制点序列为 P_0,...,P_k-p-1,P_k-p,...,P_k,P_k+1,...,P_n //打断后的新控制点序列为:左侧:P_0,....,P_k-p-1,cv_left[...],右侧 cv_right[...],P_k+1,...,P_n。 //sub_left sub_left.m_nDegree = p; //knots sub_left.m_vecKnots.resize(k+p+2); for (int i=0;i<k+1;++i) sub_left.m_vecKnots[i] = knots[i]; for (int i=0,j=k+1;i<p+1;++i,++j) sub_left.m_vecKnots[j] = u; //control vertex sub_left.m_vecCVs.resize(k+1); for (int i=0;i<k-p;++i) sub_left.m_vecCVs[i] = cv[i]; for (int i=0,j=k-p;i<p+1;++i,++j) sub_left.m_vecCVs[j] = cv_left[i]; //sub_right sub_right.m_nDegree = p; //knots sub_right.m_vecKnots.resize(knots.size()-k+p); for (int i=0;i<p+1;i++) sub_right.m_vecKnots[i] =u; for (int i=p+1,j=k+1;j<knots.size();++j,++i) sub_right.m_vecKnots[i]=knots[j]; //control_vertex sub_right.m_vecCVs.resize(cv.size()-k+p); for (int i=0;i<p+1;++i) sub_right.m_vecCVs[i] = cv_right[i]; for (int i=k+1,j=p+1;i<cv.size();++i,++j) sub_right.m_vecCVs[j] = cv[i]; } void BSpline::_subdeviding(double u,int k,const std::vector<double>& knots,const std::vector<Point>& cv, std::vector<Point>& cv_left,std::vector<Point>& cv_right) { int p= m_nDegree; //保存de boor 算法迭代过程中生成的控制点 cv_left.resize(p+1); cv_right.resize(p+1); //将 P_k-p,...,P_k拷贝到cv_left上面 std::copy(cv.begin() + k - p, cv.begin() + k + 1, cv_left.begin()); cv_right[p] = cv_left[p]; //de-boor 迭代p次 for (int r = 1; r <= p; ++r) { //i 从 k 到 k-p+1 for (int i = k,j=p; i >= k - p + r; --i,--j) { double alpha = u - knots[i]; double dev = (knots[i + p + 1 - r] - knots[i]); alpha = (dev !=0)? alpha/dev : 0; cv_left[j] = (1.0 - alpha)*cv_left[j - 1] + alpha * cv_left[j]; } cv_right[p-r] = cv_left[p]; } }
下图是打断算法对某一样条在\(u=3000\)处打断的效果。为了方便展示,两段打断后的样条曲线都经过了移动。
2)B样条的折线化
B样条的折线化也是最常用的功能之一。比如曲线转换成折线,曲线渲染等等都需要将用一系列离散点代表B样条曲线。因此也可以把这个过程称之为曲线的细分(Tesselation)。一种简单的(naive)想法是均匀细分,即将节点区间均匀的划分为k份,然后对新节点分别求值,算出曲线上的点代替原曲线。如下图示:
这种划分比较简单,但是缺点也比较明显,即参数的步长不好确定。步长过大对于弯曲的曲线容易造成折线折角过大,步长过小会造成数据冗余。因此,我们需要一个自适应的方法。回想B样条的一个性质是“强凸包性”,说的是B样条曲线严格包含在控制点构成的凸包之内。因此,我们可以通过凸包的形状预测曲线的形状。当凸包形状逼近直线时,曲线的形状也逼近直线。自适应算法采用“分而治之”的思路,递归的将曲线分成两部分做细分运算:
- 给定节点首尾 \(u_{beg},u_{end}\),计算所有控制点到首、尾控制点的最高高度,如果最高高度小于阈值d,那么曲线足够直,计算终止,输出折线化结果。
- 如果不满足阈值,将曲线在 \(u_{mid}=(u_{beg}+u_{end})/2\)处打断,分成两部分,并且分别将两个部分执行步骤1。
我的算法的C++的实现是:
void BSpline::Tesselation(double tolerance ,std::vector<Point>& points) { std::vector<Point> cv_copy = m_vecCVs; std::vector<double> knots_copy = m_vecKnots; _tesselation(cv_copy, knots_copy, points, tolerance, m_nDegree, m_vecKnots.size() - m_nDegree,0,m_vecCVs.size()-1); } void BSpline::_tesselation(std::vector<Point>& cv, std::vector<double>& knots, std::vector<Point>& points, double tolerance, int k_s, int k_e,int c_s,int c_e) { int p = m_nDegree; bool stright_enough = true; //计算弦高是否超过容差 for (int i = c_s + 1; i < c_e; ++i) { //点到直线距离公式,不给出实现了 if (PointLineDistance(cv[i], cv[c_s], cv[c_e]) > tolerance) { stright_enough = false; break; } } //满足要求,不进一步细分了 if (stright_enough) { //为了保证控制点不重复,设计的规则为[),但是对最后一个点例外。 //按照递归顺序,最后一段首先加入points int c_end = points.empty() ? c_e +1: c_e; points.insert(points.begin(),cv.begin()+c_s,cv.begin()+c_end); return; } //从节点中间打断 double u_mid = knots[k_s] + (knots[k_e] - knots[k_s]) / 2.0; //查找 u 所在区间 int k = std::distance( knots.begin(),std::upper_bound(knots.begin(), knots.end(), u_mid)) - 1; std::vector<Point> cv_left, cv_right; _subdeviding(u_mid,k,knots,cv,cv_left,cv_right); //节点区间新增p个u_mid knots.insert(knots.begin() + k+1, p,u_mid); //控制点替换 cv.insert(cv.begin() + k, p,Point()); for (int i = k - p, j = 0; j <p; ++j, ++i) cv[i] = cv_left[j]; for (int i = k, j = 0; j <= p; ++j, ++i) cv[i] = cv_right[j]; //两部分分别递归 //Note:后半部分在前半部分之前执行, //因为如果前半部分首先执行的化,后半部分的索引就发生改变了 _tesselation(cv, knots, points, tolerance, k + 1, k_e + p,k,c_e+p); _tesselation(cv, knots, points, tolerance, k_s, k + 1, c_s, k); }
下图是对某条3阶B样条曲线进行torlerance =1 的折线化局部效果,可以看到:折线在弯曲的地方点比较密,而平直的地方点比较少,所以具备较好的自适应的特点。
3)折线化算法分析:
上述的算法与AutoCAD 样条曲线“转换为多段线”功能的对比:同样一段样条曲线,在AutoCAD中,AutoCAD的样条曲线“转换为多段线”功能转出的多段线相对来说更加均匀,可能没有考虑到“自适应”问题。如下图,我使用AutoCAD“转换为多段线”功能与我计算的多段线在近似效果相近的程度下进行比较(AutoCAD使用0-99的相对精度,我使用“弦高”的概念,二者无法直接对照。AutoCAD使用精度90,我使用容差1,目测二者差别接近),我的效果是254个点,AutoCAD是267个点。
本文参考: Dartmouth College 某个关于样条曲线的ppt给出了大体的思想,但是网页链接找不到了。
大佬,能不能发一下完整版的代码。
请问NURBS曲线怎么打断呢