前言
写一个opengl es 3.0 + ndk 的绘画涂鸦项目,命名为白板哈哈哈,记录自己遇到的问题,顺便学到的知识整合一遍,算是对自己一段时间的总结。
项目地址:Whiteboard
如果对你有帮助,不妨点个start支持一下。感谢
效果图
调研如何绘制,具体思路?
主要调研的结果是方式有两种,一种是使用原生的 api 线条+drawPath 绘制贝塞尔曲线 而笔触纹理 可以通过 Canvas 生成圆点bitmap。
输出可以使用两个bitmap作为交换显示,形成一个双缓冲机制。
我之前写了一个具体的demo可以看看 https://github.com/laishujie/MyPaint
另一个就是今天的主角Opengl的方式实现,我们知道opengl可以绘制许多图元形状,三角(Triangle),线段(Line)等,其中有一个就是点(Point)。
在顶点着色器可以gl_PointSize设置点的大小
//顶点着色器
void main()
{
gl_Position = vec4(aPos, 1.0);
gl_PointSize = posiSize;
}
输出的话就这样,大概这样子
是一个正方形的形状,可以通过点贴上纹理,则需要使用内置变量gl_PointCoord来查询纹理中的纹素,使其填充,形成笔触, 它有另一名称叫做 ”点精灵“。
//片元着色器
#version 300 es
uniform sampler2D sprite_texture;
out vec4 FragColor;
void main()
{
FragColor = texture(sprite_texture, gl_PointCoord);
};
总得来讲就是Opengl 绘制点图元,点成线,笔触通过设置不同的纹理实现。
输出屏幕,显示设计
刚刚讲了原生输出方法可以通过bitmap双缓冲的机制显示。而opengl呢,默认的只有一个屏幕缓冲,它也是个双缓冲,一般的话都是通过
xxxSwapBuffers 交换缓冲区,显示到屏幕。 但我们有背景层+画笔层。所以可以通过FBO(帧缓冲)为每层分配一个缓冲区。
简单快速的解释下FBO,你用了这个FBO也就是
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
接下来的所有的读取和写入帧缓冲的操作,也就是opengl 绘制啊读取啥的,都会作用于当前绑定的帧缓冲,也叫离屏缓冲。
所以思路是这样的背景层+画笔层各一个缓冲区,最后在默认缓冲区合成图像
这里还涉及到一些混合的知识,跟看下面的橡皮檫内容一起说了,由于从FBO拿出来的纹理id是颠倒的,输出的时候要注意一下
画笔属性设置
大小
gl_PointSize 顶点着色器设置就好
颜色
通过片段着色器输出改变,看你的笔刷纹理图是什么颜色,目前的话一半的笔刷图有两种,一种是白色一种是黑色的纹理。
类似这样纹理图片ing
正片叠底说白了就是相乘,也就是两个颜色的每个通道相乘。
outColor=a*b
而滤色呢就是两个颜色都反相,相乘,然后再反相
outColor = 1 - (1-a)*(1-b)
所以他们都有这个特性看图把
原图素材来自于 https://www.bilibili.com/video/BV1jU4y1s7kt
想要深入了解的也可以看看这个视频
正片叠底方式
//片段着色器
#version 300 es
precision highp float;
uniform vec4 outColor;
out vec4 fragColor;
uniform sampler2D Texture;
float aTransparent;
void main (void) {
vec4 mask = texture(Texture, vec2(gl_PointCoord.x, gl_PointCoord.y));
fragColor = aTransparent * vec4(outColor.rgb, 1.0) * mask;
}
但大多数黑色的纹理,采用的是滤色的方式改变
//片段着色器
#version 300 es
precision mediump float;
out vec4 fragColor;
uniform sampler2D textureMap;
uniform vec4 outColor;
float outColorTransparent;
float aTransparent;
void main()
{
vec4 mask = vec4(0.);
mask = texture(textureMap, vec2(gl_PointCoord.x,gl_PointCoord.y));
outColorTransparent = outColor.a;
vec3 aTransparentColor=vec3(0.);
//如果当前纹理像素带透明度
if(mask.a<1.0){
//把透明度取出 在乘与要输出的透明度
aTransparent = mask.a * outColorTransparent;
aTransparentColor = mask.rgb;
//直接滤色后,在乘与要输出的透明度
fragColor = aTransparent *(vec4(1.0) - ((vec4(1.0)-outColor))*(vec4(1.0)-vec4(aTransparentColor,1.0)));
}
else{
//直接滤色后,在乘与要输出的透明度
fragColor = outColorTransparent * (vec4(1.0) - ((vec4(1.0)-vec4(outColor.rgb,1.0)))*(vec4(1.0)-mask));
}
}
透明度要最后做处理这个要记得,如果觉得不好调试,也推荐一个在线调试与编辑shader的网站吧。
橡皮檫
在原生的Android图层混合模式 我们知道有这个 PorterDuff.Mode
我们要的效果就是那个DesOut,而绘制叠加就是SrcOver
可以去Android的源码看看
Xfermodes 和 OpenGL Blend函数的映射关系
代码片段
// entry. For instance, gBlends[1] == gBlends[SkXfermode::kSrc_Mode]
const Blender kBlends[] = {
...
{ SkXfermode::kSrcOver_Mode, GL_ONE, GL_ONE_MINUS_SRC_ALPHA },
...
{ SkXfermode::kDstOut_Mode, GL_ZERO, GL_ONE_MINUS_SRC_ALPHA },
...
};
所以如果是橡皮檫的的话,混合模式为
glEnable(GL_BLEND);
//如果是橡皮檫
if (brushImageInfo != nullptr && brushImageInfo->outType == BrushInfo::ERASER)
glBlendFunc(GL_ZERO, GL_ONE_MINUS_SRC_ALPHA);
//绘制模式的混合
else
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
关于图层合并输出的混合模式也是
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
如果想在线预览效果,或者查看每个混合的效果 可以到这个网站看看
Visual glBlendFunc + glBlendEquation Tool
点的生成,笔触
点成线,快速的在屏幕移动,可下发的点是有限的,也就是会得到几个零散的点,要做的是要进行一定程度的密度插值,以及曲线的生成,我这里是采取了这个博主所使用的算法。
搬运过来发现还有些问题,没时间研究,等日后有空在重新写过这个算法吧,感兴趣的具体可以去看看
笔触的话,我这边是直接拿画吧的资源,发现有些笔触需要每个点需要进行不同方向的旋转绘制,比如说这个蜡笔
问题是如何给每个点进行旋转?我们绘制的时候会传入一批数据,如果外面传入旋转矩阵进行赋值,那么这一批数据都会一个旋转方向进行旋转,不符合要求,但也种不能一个点一个点的画吧。
所以根据调研的结果是,假如有一批顶点数据进来,那么每个顶点都会经过这个顶点着色器。执行的次数是这样的
如果这次绘制需要传入4个顶点,假如形成一个宽高为100的正方形,那顶点着色器只需要计算4次,而片元着色器需要执行100x100=10000次
那最终的方案是 在顶点着色器算出旋转矩阵传给到片段着色器即可达到每个点精灵都有不同的旋转方向
我们把之前的颜色大小跟旋转结合起来,片段着色器,和顶点着色器是这样的
//顶点
#version 300 es
layout(location = 0) in vec4 vPosition;
uniform float brushSize;
float timeStamp;
//旋转矩阵
out mat2 rotation_matrix;
//生成每次随机的变量
float random(float val)
{
return fract(sin(val * 12.9898) * 43756.5453123 );
}
void main()
{
timeStamp = random((vPosition.x+vPosition.y)) * 100.0;
float sin_theta = sin(timeStamp);
float cos_theta = cos(timeStamp);
rotation_matrix = mat2(cos_theta, sin_theta,
-sin_theta, cos_theta);
gl_Position = vPosition;
gl_PointSize = brushSize;
}
片段
//片段着色器
#version 300 es
precision mediump float;
out vec4 fragColor;
uniform sampler2D textureMap;
uniform vec4 outColor;
//需要输出的透明度
float outColorTransparent;
//当前纹理透明度
float aTransparent;
//接受顶点过来的旋转矩阵
in mat2 rotation_matrix;
//是否支持旋转
uniform float isSupportRotate;
void main()
{
vec4 mask = vec4(0.);
//为0代表不需要参与旋转
if(isSupportRotate==0.0){
mask = texture(textureMap, vec2(gl_PointCoord.x,gl_PointCoord.y));
}else{
//平移中心后在旋转
vec2 pt = gl_PointCoord - vec2(0.5);
mask = texture(textureMap, rotation_matrix * pt + vec2(0.5));
}
outColorTransparent = outColor.a;
vec3 aTransparentColor=vec3(0.);
//如果当前纹理像素带透明度
if(mask.a<1.0){
//把透明度取出 在乘与要输出的透明度
aTransparent = mask.a * outColorTransparent;
aTransparentColor = mask.rgb;
//直接滤色后,在乘与要输出的透明度
fragColor = aTransparent *(vec4(1.0) - ((vec4(1.0)-outColor))*(vec4(1.0)-vec4(aTransparentColor,1.0)));
}
else{
//直接滤色后,在乘与要输出的透明度
fragColor = outColorTransparent * (vec4(1.0) - ((vec4(1.0)-vec4(outColor.rgb,1.0)))*(vec4(1.0)-mask));
}
}
画布矩阵变换
除了监听触摸事件之外计算缩放平移值之外,还有的是矩阵的选择把. 有两种方案
- opengl + glm 矩阵计算,顶点着色器接受矩阵,作用与顶点坐标。
- 直接使用原生TextureView public void setTransform(Matrix transform) 的方法完成变换
这里我选择的是原生的方案。这里分别说下这两种方案的调研结果
opengl + glm
顶点着色器
void main(){
...
gl_Position = uMatrix * vPosition;
...
}
然后uMatrix通过 glm 计算然后赋值
ResultShader::mMatrix *=
(glm::translate(glm::vec3(dx, dy, 0.0f) *
glm::scale(glm::vec3(sc, sc, 1.0f)) *
glm::rotate(rotate, glm::radians(r), glm::vec3(0.0f, 0.0f, 1.0f)));
一帆风顺是吧,但就是简单的围绕z轴旋转的时候 ,会有问题
如果不做处理的话会变形,那我们通过mvp的矩阵变换,换一个视角看看,可以查看是啥回事,为什么
依附在纹理图片的两条线是x y 的坐标线, 而穿越纹理图片的则是传说中的z轴。 =v=!!!. 所以说换了个视角还是可以看到是ok的。
so,按道理只要旋转了之后重新把视角校对就可以了。
可以看看这篇文章:Opengl 旋转后 保存长宽比例不变
所以处理旋转矩阵需要重新投影就没事了
glm::mat4 transform;
transform = glm::rotate(glm::mat4(), glm::radians(z), glm::vec3(0.0f, 0.0f, 1.0f));
transform = glm::scale(transform, glm::vec3(1.0f, aspect_ratio, 1.0f));
// Projection matrix
glm::mat4 Projection =glm::ortho(-1.0f, 1.0f, -aspect_ratio, aspect_ratio, 0.1f, 0.0f) * transform;
mInitMatrix = Projection;
原生方式
由于没有3d的一说,TextureView.setTransform(Matrix transform) 即可,为什么会选择原生呢,一是简单,二呢当画布矩阵 发生改变时 需要做 坐标的映射。
比如说下方图的黑点
屏幕下发点可能是 200,400. 但我画布上对应点是10,20 这样子。所以需要对点进行矩阵映射,原生这边的做法
private fun transformCoordinate(event: MotionEvent?): FloatArray? {
if (event == null) return null
val dst = FloatArray(2)
if (mMatrix.isIdentity) {
dst[0] = event.x
dst[1] = event.y
return dst
}
mInverseMatrix.reset()
mMatrix.invert(mInverseMatrix)
mInverseMatrix.mapPoints(dst, floatArrayOf(event.x, event.y))
if (mWhiteSizeRect.contains(dst[0], dst[1])) {
return dst
}
return null
}
核心就是:进行逆矩阵操作,然后再判断是否落在原始bitmap矩形区域内。
而glm是没有这个mapPoints的方法,自己又懒得重新写一个233,所以直接用原生得了。=v=!!!
撤销返回
具体的思路的话,面向对象的思维,先把画笔,封装成对象. 然后两个队列保存撤销返回的对象数据。
当手指 up的时候copy当前画笔的所有属性,并保存在一个撤销队列里。
- 点击撤销:撤销队列顶部移除进入返回队列,这时清除画笔缓存区内容,循环撤销队列绘制。
- 点击返回:返回队列顶部移除进入撤销队列,直接绘制顶部的数据即可
至于为什么撤销要清除内容,因为使用混合的话会直接影响前面几笔的内容,只能清除重画。这里有个点需要注意,清除内容时,不要立刻输出到屏幕缓冲区,并调用SwapBuffer交换缓冲区,不然会有闪烁的情况出现。要等到最后一笔才做
保存相册
我这边是直接使用glReadPixels读取数据,这里有几个注意点。
- glBindFramebuffer(GL_FRAMEBUFFER, 0);一定要切换会屏幕缓冲区。
- 读取到内容如果不做处理会发现是颠倒的,这里要注意
- 要在gl线程执行
- 保存的时候,已有读写权限,但是 安卓10 或者是打包target版本大于等于29,记得在修改androidmanifest.xml文件 在 <application 标签里再添加一个属性
android:requestLegacyExternalStorage=“true” - 如果嫌慢,可以看看PBO的方式
void ResultShader::save(const char *savePath) {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
int width = surfaceWidth;
int height = surfaceHeight;
unsigned char *buffer = new unsigned char[surfaceHeight * surfaceWidth * 4];
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, buffer);
//翻转
stbi_flip_vertically_on_write(true);
//保存
if (!stbi_write_png(savePath, width, height, 4, buffer, 0)) {
LOGE("11111", "ERROR: could not write image");
}
delete buffer;
}
后台生命周期的恢复
在后台,surface 会有销毁的情况,回调初始化的时候,检查撤销的队列,有的话再画一遍即可。
总结
放一张自己画的图,20分钟的成果=v=!!!哈哈
能看到这的都是真爱=v=吧。 总的来说,初版就这样,该有的基本功能都有,很遗憾缺少了一个图层的功能,点生成算法还有用户体验上需要大改版。
但是最近要找工作,可能坑不知道啥时候填。还是那句话吧DONE IS BETTER THAN PERFECT,比完美更重要的是完成。
项目地址:https://github.com/laishujie/Whiteboard
对你有帮助的话,给个start,或者点个赞吧!!!