如何在 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 添加設計器觸摸,無需提前準備像素圖或形狀。