如何在 C++ 中使用 Bezier 曲线和 QPainter 获得圆角形状:分步指南
已发表: 2022-03-11介绍
当前图形设计的趋势是在各种形状中使用大量圆角。 我们可以在许多网页、移动设备和桌面应用程序上观察到这一事实。 最值得注意的例子是应用程序按钮,用于在单击时触发某些操作。 它们通常用圆角绘制,而不是在角落中具有 90 度角的严格矩形。 圆角使用户界面感觉更流畅、更美观。 我对此并不完全相信,但我的设计师朋友告诉我。
用户界面的视觉元素是由设计师创建的,程序员只需要将它们放在正确的位置。 但是,当我们必须动态生成一个带有圆角的形状并且我们无法预加载它时会发生什么? 一些编程库提供了有限的功能来创建具有圆角的预定义形状,但通常它们不能用于更复杂的情况。 例如,Qt 框架有一个类QPainter
,用于绘制从QPaintDevice
派生的所有类,包括小部件、像素图和图像。 它有一个名为drawRoundedRect
的方法,顾名思义,它绘制一个带圆角的矩形。 但是如果我们需要更复杂的形状,我们必须自己实现它。 我们怎么能用一个多边形来做到这一点,一个由一组直线段包围的平面形状? 如果我们用铅笔在一张纸上绘制一个多边形,我的第一个想法是使用橡皮擦并删除每个角的一小部分线,然后用圆弧连接剩余的线段。 整个过程可以用下图来说明。
QPainter
类有一些名为drawArc
的重载方法,可以绘制圆弧。 它们都需要参数,这些参数定义了圆弧中心和大小、起始角度和圆弧长度。 虽然很容易确定非旋转矩形的这些参数的必要值,但当我们处理更复杂的多边形时,情况就完全不同了。 另外,我们必须对每个多边形顶点重复这个计算。 这种计算是一项冗长而繁琐的工作,人类在这个过程中容易出现各种计算错误。 然而,让计算机为人类工作是软件开发人员的工作,反之亦然。 所以,在这里我将展示如何开发一个简单的类,它可以将复杂的多边形变成圆角的形状。 此类的用户只需附加多边形顶点,其余的将由该类完成。 我用于这项任务的基本数学工具是贝塞尔曲线。
贝塞尔曲线
描述贝塞尔曲线理论的数学书籍和互联网资源有很多,所以我将简要概述相关性质。
根据定义,贝塞尔曲线是二维曲面上两点之间的曲线,其轨迹由一个或多个控制点控制。 严格来说,两点之间没有附加控制点的曲线也是贝塞尔曲线。 然而,由于这导致两点之间的直线,它不是特别有趣,也不是很有用。
二次贝塞尔曲线
二次贝塞尔曲线有一个控制点。 该理论认为,点P 0和P 2与控制点P 1之间的二次贝塞尔曲线定义如下:
B(t) = (1 - t) 2 P 0 + 2t(1 - t)P 1 + t 2 P 2 ,其中 0 ≤ t ≤ 1 (1)
因此,当t等于0时, B(t)将产生P 0 ,当t等于1时, B(t)将产生P 2 ,但在所有其他情况下, B(t)的值也将取决于P 1 。 由于表达式2t(1 - t)在t = 0.5处具有最大值,因此P 1对B(t)的影响最大。 我们可以将P 1视为一个假想的引力源,它将函数的轨迹拉向自身。 下图显示了一些二次贝塞尔曲线及其起点、终点和控制点的示例。
那么,我们如何使用贝塞尔曲线来解决我们的问题呢? 下图提供了解释。
如果我们想象删除一个多边形顶点和它周围的一小部分相连的线段,我们可以认为一个线段的终点是P 0 ,另一条线段的终点是P 2 ,删除的顶点是P 1 。 我们将二次贝塞尔曲线应用于这组点,瞧,有所需的圆角。
使用 QPainter 的 C++/Qt 实现
QPainter
类没有绘制二次贝塞尔曲线的方法。 虽然按照公式 (1) 从头开始实现它很容易,但 Qt 库确实提供了更好的解决方案。 还有另一个强大的 2D 绘图类: QPainterPath
。 QPainterPath
类是线和曲线的集合,可以在以后与QPainter
对象一起添加和使用。 有一些重载方法可以将贝塞尔曲线添加到当前集合中。 特别是,方法quadTo
将添加二次贝塞尔曲线。 曲线将从当前QPainterPath
点 ( P 0 ) 开始,而P 1和P 2必须作为参数传递给quadTo
。
QPainter
的方法drawPath
用于从QPainterPath
对象绘制直线和曲线的集合,该对象必须作为参数给出,并带有活动笔和画笔。
那么让我们看看类声明:
class RoundedPolygon : public QPolygon { public: RoundedPolygon() { SetRadius(10); } void SetRadius(unsigned int iRadius) { m_iRadius = iRadius; } const QPainterPath& GetPath(); private: QPointF GetLineStart(int i) const; QPointF GetLineEnd(int i) const; float GetDistance(QPoint pt1, QPoint pt2) const; private: QPainterPath m_path; unsigned int m_iRadius; };
我决定QPolygon
,这样我就不必自己实现添加顶点和其他东西。 除了将半径设置为一些合理的初始值的构造函数之外,该类还有另外两个公共方法:
-
SetRadius
方法将半径设置为给定值。 半径是每个顶点附近的直线长度(以像素为单位),圆角将被删除(或更准确地说,不绘制)。 -
GetPath
是所有计算发生的地方。 它将返回从添加到RoundedPolygon
的多边形点生成的QPainterPath
对象。
私有部分的方法只是GetPath
使用的辅助方法。
让我们看看实现,我将从私有方法开始:
float RoundedPolygon::GetDistance(QPoint pt1, QPoint pt2) const { float fD = (pt1.x() - pt2.x())*(pt1.x() - pt2.x()) + (pt1.y() - pt2.y()) * (pt1.y() - pt2.y()); return sqrtf(fD); }
这里不多解释,该方法返回给定两点之间的欧几里得距离。
QPointF RoundedPolygon::GetLineStart(int i) const { QPointF pt; QPoint pt1 = at(i); QPoint pt2 = at((i+1) % count()); float fRat = m_uiRadius / GetDistance(pt1, pt2); if (fRat > 0.5f) fRat = 0.5f; pt.setX((1.0f-fRat)*pt1.x() + fRat*pt2.x()); pt.setY((1.0f-fRat)*pt1.y() + fRat*pt2.y()); return pt; }
如果点按顺时针方向添加到多边形, GetLineStart
方法从最后一个图形计算点P 2的位置。 更准确地说,它将返回一个点,该点在朝向第(i+1)
个顶点的方向上远离第i
个顶点的m_uiRadius
像素。 当访问第(i+1)
个顶点时,我们要记住,在多边形中,最后一个顶点和第一个顶点之间还有一条线段,这使它成为一个封闭的形状,因此表达式(i+1)%count()
。 这也可以防止该方法超出范围并改为访问第一个点。 变量fRat
保存半径与第i
个线段长度之间的比率。 还有一个检查可以防止fRat
的值超过0.5
。 如果fRat
的值超过0.5
,那么两个连续的圆角会重叠,这会导致视觉效果不佳。

当从点P 1直线行驶到P 2并完成 30% 的距离时,我们可以使用公式0.7 • P 1 + 0.3 • P 2确定我们的位置。 一般来说,如果我们达到全距离的一小部分,并且α = 1表示全距离,则当前位置位于(1 - α) • P1 + α • P2处。
这就是GetLineStart
方法如何确定在第(i+1)
方向上距第i
个顶点的m_uiRadius
像素的点的位置。
QPointF RoundedPolygon::GetLineEnd(int i) const { QPointF pt; QPoint pt1 = at(i); QPoint pt2 = at((i+1) % count()); float fRat = m_uiRadius / GetDistance(pt1, pt2); if (fRat > 0.5f) fRat = 0.5f; pt.setX(fRat*pt1.x() + (1.0f - fRat)*pt2.x()); pt.setY(fRat*pt1.y() + (1.0f - fRat)*pt2.y()); return pt; }
此方法与GetLineStart
非常相似。 它为第(i+1)
个顶点而不是第i
个顶点计算点P 0的位置。 换句话说,如果我们为0
和n-1
之间的每个i
画一条从GetLineStart(i)
到GetLineEnd(i)
的线,其中n
是多边形中的顶点数,我们将得到具有擦除顶点的多边形及其附近的环境。
现在,主要的类方法:
const QPainterPath& RoundedPolygon::GetPath() { m_path = QPainterPath(); if (count() < 3) { qWarning() << "Polygon should have at least 3 points!"; return m_path; } QPointF pt1; QPointF pt2; for (int i = 0; i < count(); i++) { pt1 = GetLineStart(i); if (i == 0) m_path.moveTo(pt1); else m_path.quadTo(at(i), pt1); pt2 = GetLineEnd(i); m_path.lineTo(pt2); } // close the last corner pt1 = GetLineStart(0); m_path.quadTo(at(0), pt1); return m_path; }
在这个方法中,我们构建了QPainterPath
对象。 如果多边形没有至少三个顶点,我们将不再处理 2D 形状,在这种情况下,该方法会发出警告并返回空路径。 当足够多的点可用时,我们循环遍历多边形的所有直线段(线段数当然等于顶点数),计算圆角之间的每条直线段的起点和终点角落。 我们在这两个点之间画一条直线,在前一条线段的末端和当前的起点之间画一条二次贝塞尔曲线,以当前顶点的位置为控制点。 在循环之后,我们必须在最后一条线段和第一条线段之间用贝塞尔曲线封闭路径,因为在循环中我们画的直线比贝塞尔曲线多一条。
类RoundedPolygon
用法和结果
现在是时候看看如何在实践中使用这个类了。
QPixmap pix1(300, 200); QPixmap pix2(300, 200); pix1.fill(Qt::white); pix2.fill(Qt::white); QPainter P1(&pix1); QPainter P2(&pix2); P1.setRenderHints(QPainter::Antialiasing); P2.setRenderHints(QPainter::Antialiasing); P1.setPen(QPen(Qt::blue, 2)); P1.setBrush(Qt::red); P2.setPen(QPen(Qt::blue, 2)); P2.setBrush(Qt::red); RoundedPolygon poly; poly << QPoint(147, 187) << QPoint(95, 187) << QPoint(100, 175) << QPoint(145, 165) << QPoint(140, 95) << QPoint(5, 85) << QPoint(5, 70) << QPoint(140, 70) << QPoint(135, 45) << QPoint(138, 25) << QPoint(145, 5) << QPoint(155, 5) << QPoint(162, 25) << QPoint(165, 45) << QPoint(160, 70) << QPoint(295, 70) << QPoint(295, 85) << QPoint(160, 95) << QPoint(155, 165) << QPoint(200, 175) << QPoint(205, 187) << QPoint(153, 187) << QPoint(150, 199); P1.drawPolygon(poly); P2.drawPath(poly.GetPath()); pix1.save("1.png"); pix2.save("2.png");
这段源代码非常简单。 在初始化两个QPixmaps
和它们的QPainters
之后,我们创建一个RoundedPolygon
对象并用点填充它。 Painter P1
绘制正多边形,而P2
绘制从多边形生成的带有圆角的QPainterPath
。 两个生成的像素图都保存到它们的文件中,结果如下:
结论
我们已经看到,从多边形生成具有圆角的形状毕竟不是那么困难,尤其是如果我们使用良好的编程框架(例如 Qt)。 这个过程可以由我在这个博客中描述的作为概念证明的类来自动化。 但是,还有很多需要改进的地方,比如:
- 仅在选定顶点处制作圆角,而不是在所有顶点处制作圆角。
- 在不同的顶点制作具有不同半径的圆角。
- 实现一个方法,它生成一个圆角的折线(Qt 术语中的折线就像多边形,除了它不是封闭的形状,因为它缺少最后一个和第一个顶点之间的线段)。
- 使用
RoundedPolygon
生成位图,可以将其用作背景小部件蒙版来生成形状疯狂的小部件。 -
RoundedPolygon
类没有针对执行速度进行优化; 我保留它是为了更容易理解这个概念。 优化可能包括在将新顶点附加到多边形时计算大量中间值。 此外,当GetPath
即将返回对生成的QPainterPath
的引用时,它可以设置一个标志,指示对象是最新的。 对GetPath
的下一次调用将导致仅返回相同的QPainterPath
对象,而无需重新计算任何内容。 但是,开发人员必须确保在任何多边形顶点以及每个新顶点的每次更改时都清除此标志,这使我认为优化的类最好从头开始开发而不是派生来自QPolygon
。 好消息是,这并不像听起来那么困难。
总而言之, RoundedPolygon
类可以用作工具,只要我们想在运行中为我们的 GUI 添加设计器触摸,无需提前准备像素图或形状。