![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiI0gTMx81dsQWZ4lmZf1GLlpXazVmcvwFciV2dsQXYtJ3bm9CX9s2RkBnVHFmb1clWvB3MaVnRtp1XlBXe0xCMy81dvRWYoNHLwEzX5xCMx8FesU2cfdGLwMzX0xiRGZkRGZ0Xy9GbvNGLpZTY1EmMZVDUSFTU4VFRR9Fd4VGdsYTMfVmepNHLrJXYtJXZ0F2dvwVZnFWbp1zczV2YvJHctM3cv1Ce-cGcq5yM3gzN3gDNzQTO2cTO0EDNzYzXwEzN1cTM4AzLcBTMyIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.jpg)
作者:lyman
今天我們使用 OpenGL ES 來實作一個繪畫闆,主要介紹在 OpenGL ES 中繪制平滑曲線的實作方案。
首先看一下最終效果:
在 iOS 中,有很多種方式可以實作一個繪畫闆,比如我的另外一個項目 MFPaintView 就是基于 CoreGraphics 實作的。
然而,使用 OpenGL ES 來實作可以獲得更多的靈活性,比如我們可以自定義筆觸的形狀,這是其他實作方式做不到的。
我們知道,OpenGL ES 中隻有 點、直線、三角形 這三種圖元。是以,怎麼在 OpenGL ES 中繪制曲線,是我們第一個要解決的問題,也是最複雜的問題。
我們會使用比較大的篇幅來講解這個問題。至于繪畫闆的其他功能實作,并不是說不重要,隻是說其他的繪畫闆實作方式,也會有類似的邏輯,是以這部分會放在最後再簡單介紹一下。
一、怎麼繪制曲線
在 OpenGL ES 中繪制曲線的方式,就是 将曲線拆分成點序列來繪制 。
因為要繪制點,是以我們采取的是 點圖元 。即我們要把頂點資料當成 點 來繪制,并且每個點都要繪制出筆觸的紋理。關鍵步驟如下:
指定圖元類型:
1glDrawArrays(GL_POINTS, 0, self.vertexCount);
2複制代碼
頂點着色器:
1attribute vec4 Position;
2
3uniform float Size;
4
5void main (void) {
6 gl_Position = Position;
7 gl_PointSize = Size;
8}
9複制代碼
片段着色器:
1precision highp float;
2
3uniform float R;
4uniform float G;
5uniform float B;
6uniform float A;
7
8uniform sampler2D Texture;
9
10void main (void) {
11 vec4 mask = texture2D(Texture, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y));
12 gl_FragColor = A * vec4(R, G, B, 1.0) * mask;
13}
14
15複制代碼
這裡的關鍵點在于
gl_PointCoord
這個内置變量,當我們使用點圖元的時候,可以通過這個變量擷取到 目前像素在點圖元中的歸一化坐标 。
但是這個坐标的原點是在左上角,這和紋理坐标在豎直方向上是相反的。是以從紋理讀取顔色的時候,要做一個 y 坐标的轉換。
接下來,我們通過
UITouch
來擷取觸摸點的位置,然後算出歸一化的頂點坐标。
1- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
2 [super touchesMoved:touches withEvent:event];
3
4 [self addPointWithTouches:touches];
5}
6複制代碼
但是由于 iOS 系統觸摸事件的派發頻率有限,我們最終得到的隻能是稀疏的點。如下圖所示,每個觸摸點之間的間隔會比較大。
img
二、怎麼繪制密集的點
很容易想到,隻需要在兩個點之間,按照一定的密度進行插值,就可以繪制出連續的軌迹。
img
但是很明顯,我們的繪制結果是折線,并不平滑。
三、怎麼使曲線變平滑
解決點連接配接不平滑的問題,一般是使用貝塞爾曲線。這種方案在 MFPaintView 中也得到了很好的應用。
具體的做法是使用 兩個頂點間的中點 和 一個頂點 ,來構造一條貝塞爾曲線。如下圖,圖中的 3 個 紅點 被用來構造一條貝塞爾曲線。
img
于是,我們的問題就變成了 怎麼在 OpenGL ES 中繪制貝塞爾曲線 。相當于已知貝塞爾曲線的 3 個關鍵點,反向來求曲線上的點序列。
我們知道貝塞爾曲線的方程是
P = (1 - t)^2 * P0 + 2 * t * (1 - t) * P1 + t^2 * P2
,
t
是唯一的變量,其取值範圍是
0 ~ 1
。
是以我們可以采取線性取值的方式,每一條貝塞爾曲線取
n
個點(
n
是個确定的常量)。隻要依次往方程中代入
1 / n 、 2 / n 、 ... n / n
,就可以得到一個點序列。
img
先将
n
取一個比較小的值,這樣比較容易看出存在的問題。我們發現,點序列的間隔并不均勻。原因有兩個:
- 不同貝塞爾曲線的長度不一樣,使用同一個
值,算出來的點的疏密程度肯定不同。n
- 由于貝塞爾曲線随着
增長,曲線長度的增長并不是線性的。按照我們上面的算法,最終會得到的結果是 兩頭比較稀疏,中間比較密集 。t
四、怎麼生成均勻的點序列
貝塞爾曲線生成均勻的點序列,涉及到了一個經典的「貝塞爾曲線勻速運動」問題。
這個問題的推導和計算比較複雜。如果你有興趣,可以閱讀一下文末的兩篇文章。由于我還不能完全領悟,就不在這裡誤導大家了。
簡單來說,就是我們通過一系列的騷操作,封裝了一個方法,隻需要傳入貝塞爾曲線的 3 個關鍵點和筆觸尺寸,就可以擷取均勻的點序列。
1+ (NSArray <NSValue *>*)pointsWithFrom:(CGPoint)from
2 to:(CGPoint)to
3 control:(CGPoint)control
4 pointSize:(CGFloat)pointSize;
5複制代碼
下面我們固定貝塞爾曲線的 起始點 和 控制點,隻移動 終止點,來驗證一下這個方法是否可靠。
img
可以看到,在移動過程中,點和點的距離基本是保持一緻的,并且是均勻的。通過這個「神奇」的方法,我們終于畫出了平滑且均勻的曲線。
img
五、繪畫闆功能實作
終于講完了最麻煩的部分,接下來簡單介紹一下繪畫闆基本功能的實作。
1、顔色混合
在以往的例子中,我們在開始一次渲染之前,都會調用
glClear(GL_COLOR_BUFFER_BIT)
來清除畫布,因為我們不希望保留上次的渲染結果。
但是對于一個繪畫闆來說,我們要不斷地往畫布上畫東西,是以是希望保留上次結果的。是以,在繪制之前不能執行清除的操作。
另外,由于我們的畫筆可能是半透明的,是以新繪制的顔色需要和畫布上已經存在的顔色進行混合。是以在繪制開始之前,需要開啟混合選項。
1glEnable(GL_BLEND);
2glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
3複制代碼
2、筆觸調整
筆觸有 3 個屬性可以調整:顔色、尺寸、形狀。它們本質上都是對點圖元的調整,通過
uniform
變量的形式,将顔色、尺寸、紋理傳入着色器并應用。
3、橡皮擦
GLPaintView
在初始化的時候,需要傳入一個背景色參數,當使用者切換到橡皮擦功能的時候,内部隻是單純地将畫筆的顔色切換成背景色,于是就産生了橡皮擦的效果。
4、撤銷重做
撤銷重做功能需要依賴兩個棧來實作。我們把使用者的手指從 按下螢幕到離開螢幕 這一過程中産生的資料,定義為一個操作對象,這個操作對象儲存了歸一化後的點序列,以及點的屬性。
1@interface MFPaintModel : NSObject
2
3/// 筆刷尺寸
4@property (nonatomic, assign) CGFloat brushSize;
5/// 筆刷顔色
6@property (nonatomic, strong) UIColor *brushColor;
7/// 筆刷模式
8@property (nonatomic, assign) GLPaintViewBrushMode brushMode;
9/// 筆觸紋理圖檔檔案名
10@property (nonatomic, copy) NSString *brushImageName;
11/// 點序列
12@property (nonatomic, copy) NSArray<NSValue *> *points;
13
14@end
15複制代碼
撤銷重做的代碼實作大概像這樣子:
1- (void)undo {
2 if ([self.operationStack isEmpty]) {
3 return;
4 }
5 MFPaintModel *model = self.operationStack.topModel;
6 [self.operationStack popModel];
7 [self.undoOperationStack pushModel:model];
8
9 [self reDraw];
10}
11
12- (void)redo {
13 if ([self.undoOperationStack isEmpty]) {
14 return;
15 }
16 MFPaintModel *model = self.undoOperationStack.topModel;
17 [self.undoOperationStack popModel];
18 [self.operationStack pushModel:model];
19
20 [self drawModel:model];
21}
22複制代碼
需要注意的是,由于 撤銷操作 需要先清除畫布,是以每次都需要重繪。而 重做操作 可以利用上次繪制的結果,是以每次隻需要繪制一個步驟即可。
源碼
請到 GitHub 上檢視完整代碼。
https://github.com/lmf12/GLPaintView