今天我们使用 OpenGL ES 来实现一个绘画板,主要介绍在 OpenGL ES 中绘制平滑曲线的实现方案。
首先看一下最终效果:
在 iOS 中,有很多种方式可以实现一个绘画板,比如我的另外一个项目 MFPaintView 就是基于 CoreGraphics 实现的。
然而,使用 OpenGL ES 来实现可以获得更多的灵活性,比如我们可以自定义笔触的形状,这是其他实现方式做不到的。
我们知道,OpenGL ES 中只有 点、直线、三角形 这三种图元。因此,怎么在 OpenGL ES 中绘制曲线,是我们第一个要解决的问题,也是最复杂的问题。
我们会使用比较大的篇幅来讲解这个问题。至于绘画板的其他功能实现,并不是说不重要,只是说其他的绘画板实现方式,也会有类似的逻辑,所以这部分会放在最后再简单介绍一下。
一、怎么绘制曲线
在 OpenGL ES 中绘制曲线的方式,就是 将曲线拆分成点序列来绘制 。
因为要绘制点,所以我们采取的是 点图元 。即我们要把顶点数据当成 点 来绘制,并且每个点都要绘制出笔触的纹理。关键步骤如下:
指定图元类型:
glDrawArrays(GL_POINTS, 0, self.vertexCount);
顶点着色器:
attribute vec4 Position;
uniform float Size;
void main (void) {
gl_Position = Position;
gl_PointSize = Size;
}
片段着色器:
precision highp float;
uniform float R;
uniform float G;
uniform float B;
uniform float A;
uniform sampler2D Texture;
void main (void) {
vec4 mask = texture2D(Texture, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y));
gl_FragColor = A * vec4(R, G, B, 1.0) * mask;
}
这里的关键点在于 gl_PointCoord
这个内置变量,当我们使用点图元的时候,可以通过这个变量获取到 当前像素在点图元中的归一化坐标 。
但是这个坐标的原点是在左上角,这和纹理坐标在竖直方向上是相反的。所以从纹理读取颜色的时候,要做一个 y 坐标的转换。
接下来,我们通过 UITouch
来获取触摸点的位置,然后算出归一化的顶点坐标。
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[super touchesMoved:touches withEvent:event];
[self addPointWithTouches:touches];
}
但是由于 iOS 系统触摸事件的派发频率有限,我们最终得到的只能是稀疏的点。如下图所示,每个触摸点之间的间隔会比较大。
二、怎么绘制密集的点
很容易想到,只需要在两个点之间,按照一定的密度进行插值,就可以绘制出连续的轨迹。
但是很明显,我们的绘制结果是折线,并不平滑。
三、怎么使曲线变平滑
解决点连接不平滑的问题,一般是使用贝塞尔曲线。这种方案在 MFPaintView 中也得到了很好的应用。
具体的做法是使用 两个顶点间的中点 和 一个顶点 ,来构造一条贝塞尔曲线。如下图,图中的 3 个 红点 被用来构造一条贝塞尔曲线。
于是,我们的问题就变成了 怎么在 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
,就可以得到一个点序列。
先将 n
取一个比较小的值,这样比较容易看出存在的问题。我们发现,点序列的间隔并不均匀。原因有两个:
- 不同贝塞尔曲线的长度不一样,使用同一个
n
值,算出来的点的疏密程度肯定不同。 - 由于贝塞尔曲线随着
t
增长,曲线长度的增长并不是线性的。按照我们上面的算法,最终会得到的结果是 两头比较稀疏,中间比较密集 。
四、怎么生成均匀的点序列
贝塞尔曲线生成均匀的点序列,涉及到了一个经典的「贝塞尔曲线匀速运动」问题。
这个问题的推导和计算比较复杂。如果你有兴趣,可以阅读一下文末的两篇文章。由于我还不能完全领悟,就不在这里误导大家了。
简单来说,就是我们通过一系列的骚操作,封装了一个方法,只需要传入贝塞尔曲线的 3 个关键点和笔触尺寸,就可以获取均匀的点序列。
+ (NSArray <NSValue *>*)pointsWithFrom:(CGPoint)from
to:(CGPoint)to
control:(CGPoint)control
pointSize:(CGFloat)pointSize;
下面我们固定贝塞尔曲线的 起始点 和 控制点,只移动 终止点,来验证一下这个方法是否可靠。
可以看到,在移动过程中,点和点的距离基本是保持一致的,并且是均匀的。通过这个「神奇」的方法,我们终于画出了平滑且均匀的曲线。
五、绘画板功能实现
终于讲完了最麻烦的部分,接下来简单介绍一下绘画板基本功能的实现。
1、颜色混合
在以往的例子中,我们在开始一次渲染之前,都会调用 glClear(GL_COLOR_BUFFER_BIT)
来清除画布,因为我们不希望保留上次的渲染结果。
但是对于一个绘画板来说,我们要不断地往画布上画东西,所以是希望保留上次结果的。因此,在绘制之前不能执行清除的操作。
另外,由于我们的画笔可能是半透明的,所以新绘制的颜色需要和画布上已经存在的颜色进行混合。因此在绘制开始之前,需要开启混合选项。
glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
2、笔触调整
笔触有 3 个属性可以调整:颜色、尺寸、形状。它们本质上都是对点图元的调整,通过 uniform
变量的形式,将颜色、尺寸、纹理传入着色器并应用。
3、橡皮擦
GLPaintView
在初始化的时候,需要传入一个背景色参数,当用户切换到橡皮擦功能的时候,内部只是单纯地将画笔的颜色切换成背景色,于是就产生了橡皮擦的效果。
4、撤销重做
撤销重做功能需要依赖两个栈来实现。我们把用户的手指从 按下屏幕到离开屏幕 这一过程中产生的数据,定义为一个操作对象,这个操作对象保存了归一化后的点序列,以及点的属性。
@interface MFPaintModel : NSObject
/// 笔刷尺寸
@property (nonatomic, assign) CGFloat brushSize;
/// 笔刷颜色
@property (nonatomic, strong) UIColor *brushColor;
/// 笔刷模式
@property (nonatomic, assign) GLPaintViewBrushMode brushMode;
/// 笔触纹理图片文件名
@property (nonatomic, copy) NSString *brushImageName;
/// 点序列
@property (nonatomic, copy) NSArray<NSValue *> *points;
@end
撤销重做的代码实现大概像这样子:
- (void)undo {
if ([self.operationStack isEmpty]) {
return;
}
MFPaintModel *model = self.operationStack.topModel;
[self.operationStack popModel];
[self.undoOperationStack pushModel:model];
[self reDraw];
}
- (void)redo {
if ([self.undoOperationStack isEmpty]) {
return;
}
MFPaintModel *model = self.undoOperationStack.topModel;
[self.undoOperationStack popModel];
[self.operationStack pushModel:model];
[self drawModel:model];
}
需要注意的是,由于 撤销操作 需要先清除画布,所以每次都需要重绘。而 重做操作 可以利用上次绘制的结果,所以每次只需要绘制一个步骤即可。
源码
请到 GitHub 上查看完整代码。
参考
获取更佳的阅读体验,请访问原文地址【Lyman's Blog】在 iOS 中使用 OpenGL ES 实现绘画板