这篇文章将给大家讲解如何在Android系统上基于OpenGL ES 2.0来实现相机实时图片涂鸦效果,所涂内容跟随人脸出现、消失、移动、旋转及缩放,在这里,我们假设您:
在开始讲解之前,先简要介绍一下OpenGL ES 2.0的一些必要的基础知识,方便对文章的理解。
为方便讲解,以下只讲解二维的情况,在OpenGL使用中,我们主要会涉及到以下三个坐标系:
Shader就是OpenGL的着色器,分为顶点着色器(Vertex Shader)和片元着色器(Fragment Shader),这两个着色器都由一段小程序来实现,用OpenGL Shading Language编写,语法类似C语言,使用时将相应shader程序代码载入OpenGL即可。
OpenGL在把点绘到屏幕上之前,点会依次经过顶点着色器和片元着色器的处理。顶点着色器是处理顶点的位置、大小、旋转等操作,比如希望显示一个经过顺时针旋转90度、并放大1倍的纹理,可以在顶点着色器中编写相应的代码;片元着色器主要处理颜色操作,比如希望将一个纹理中某个区域的颜色变成红色,可以在片元着色器中编写相应的代码。
下面开始循序渐进地讲解涂鸦的实现,首先先来实现一个简单的框架:在相机预览的界面的中央画一个贴图。
先来定义一下Vertex Shader和Fragment Shader,这两个Shader是必不可少的。
Vertex Shader:
简要介绍一下这个Vertex Shader的含义,正如前文所说的,Vertex Shader的作用是对顶点进行一些位置、大小、旋转等变换操作,但在现在这个shader里,这些都没有涉及,只是一个最简单的Shader,各变量及其含义:
Fragment Shader:
同样,如前文所提到的,Fragment Shader主要处理颜色操作,各变量含义:
java关键代码
首先创建两个类,CameraView继承GLSurfaceView并实现SurfaceTexture.OnFrameAvailableListener接口,MyRenderer实现GLSurfaceView.Renderer接口,在CameraView的构造函数里做一些OpenGL必要的初始化:
值得一提的是setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY),OpenGL可以将渲染设置为每帧都自动渲染或者是你要求它渲染它才渲染,这里的GLSurfaceView.RENDERMODE_WHEN_DIRTY属于后者,在onFrameAvailable()回调里调用GLSurfaceView的requestRender()方法触发渲染,也就是触发onDrawFrame()的调用。
下面在MyRenderer类中,我们先将刚才的那两个Shader给Load进来:
然后在onSurfaceCreated中做一些变量的初始化:
其中IMAGE_POSITION_VERTEX是纹理图片的位置坐标数组,它的作用是确定要把纹理图片画在屏幕的什么地方,它里面的坐标值是对应世界坐标系中的坐标值,IMAGE_TEXTURE_VERTEX是纹理图片本身的顶点坐标数组,它的作用是确定要画这个纹理图片的什么部分,如下图所示:
IMAGE_POSITION_VERTEX所指定的位置即相当于上图中“绘制位置”,IMAGE_TEXTURE_VERTEX指定的纹理绘制部分即相当于上图中的“绘制部分”。
如果想把一个纹理图片的全部部分画在屏幕中央,可以将IMAGE_POSITION_VERTEX及IMAGE_TEXTURE_VERTEX取值如下:
这里注意一点,vertex点的取法是和画法有关的,这里采用的画法是GLES20.GL_TRIANGLE_FAN,如果采用其它画法,vertex点数组要作相应地调整,否则画面会错乱。
然后在onDrawFrame中绘制图片:
至此,我们有了一个简单的框架,可以在相机预览界面绘制一个图片了。
简介
下面来介绍涂鸦画布的创建以及将手指在屏幕上触摸的位置绘制贴图。 涂鸦画布是一个独立于相机预览帧的绘图区域,它的作用是可以将已绘制好的涂鸦暂存起来,否则因为相机预览帧每一帧都是新的,需要把之前绘制过的东西再重新绘制一次,即就算涂鸦结束了,每帧也都需要调用多次OpenGL绘制方法将之前涂鸦的内容绘制到相机预览帧上,否则在新的帧上就看不见之前涂的内容,示意图如下:
有了涂鸦画布后,就可以将涂鸦内容画到涂鸦画布上,然后对每一个新的相机预览帧,直接将整个画布画上去,将画布画上去只需要调用一次OpenGL绘图方法:
这里的画布实际上就是一个空的texture,创建方法和创建一个普通的texture是一个样的,即用GLES20.glGenTextures()来创建,然后进行一些初始化等操作:
为什么需要framebuffer?因为OpenGL默认是渲染到屏幕的,我们往画布上画东西并不希望马上显示出来,因为画布还要贴到脸上,之后再显示出来。
坐标变换
有了涂鸦画布之后,下一步就是如何将涂鸦的内容画到画布上。首先讨论坐标系的转换,引入画布之后,现在相关的坐标系又多了一个画布的坐标系,手指在屏幕上触摸之后,如何让图案最终在触摸的位置画出来呢?
手指在屏幕上触摸之后,onTouchEvent()中所得到的坐标是屏幕坐标系中的坐标,而相机有一个预览宽高的设置,这个宽高可以和屏幕宽高不一样,比如1080*1920的屏幕,相机的预览宽高可以设置为720*960,因此第一个坐标系的转换就是将屏幕坐标系中的触摸点坐标转换成与相机预览宽高相对应的坐标,相机预览的坐标系原点及x、y轴方向与屏幕坐标系相同:
得到了触摸点在相机预览画面中的坐标之后,下一步是转换成它在画布中的坐标,因为画布是跟随人脸移动、旋转及缩放的,因此这一步稍微有一点复杂,这里画布贴到人脸上采用的方案是将画布中心对准人脸的鼻尖位置(鼻尖坐标由人脸检测SDK得到),示意图如下:
可能有人会问,从图中看,屏幕中有些部分超出了画布,这部分是否能涂上去?是涂不上去的,只能涂在涂鸦画布上,因此实际使用的时候,会把涂鸦画布设置成比屏幕大一些,一般可以自己试一下,比如把手机放远,看看人脸缩小后画布要设置能多大还能覆盖屏幕,一般不用设置得太大,因为人脸缩得太小后,人脸通常也识别不出来了,这时候也不用担心画布被缩得太小了。
继续沿用之前的例子,前面是得到了触摸点在相机预览画面中的坐标是(200,400),它如何对应到涂鸦画面上面呢?这里的方法是先计算触摸点相对于人脸鼻尖的位置,因为涂鸦画布是将画布中心对准了人脸鼻尖位置,所以再通过算出来的相对位置转换成涂鸦画布上的对应位置,以保证它在涂鸦画布上还是手指触摸的那个地方。假设画布的实际尺寸设置为600*600,画布中心点坐标是(300,300),人脸鼻尖坐标是(360,320)先从简单的情况看起,假设画布贴上去之前,没有进行移动、旋转和缩放,那么将是:
以上是一种简单的情况,那么如果人脸先旋转了一下呢?这时画布也是跟着旋转了,这时的坐标如何转换?其实思路很简单,就是画的时候,计算点坐标时把它当作还没转的情况来计算,算出来后再转相应的角度就行了:
如何计算点(x,y)的值呢?有个神奇的公式,它可以计算一个点绕某个点逆时针旋转后的点坐标:
其中x、y是旋转前的点坐标,x0、y0是绕着旋转的点坐标,x’、y’是旋转后的点坐标,α是旋转角度。
下面来看看,如果人脸缩放了,如何计算正确的坐标,这里采取的方法是,当第一次把涂鸦画布贴到人脸上的时候,先记录人脸的初始宽度,之后的帧里再用当前人脸的宽度和记录的初始人脸宽度就行对比,从而得知人脸缩放的比例。人脸宽度的计算要依赖于人脸检测SDK,只需要用人脸检测出的人脸两边边的对应点相减就行了:
人脸缩放后,要保持触摸点转换成涂鸦画布上的正确位置,只需要把触摸点与人脸鼻尖点之间的差值相应地缩放就可以了:
这里有一点需要注意的是,假设涂鸦画布的实际尺寸是600*600,它随人脸进行缩放后,它的实际尺寸仍然是600*600,只不过显示的时候被缩放了,因此在将触摸点转换成涂鸦画布上的对应点时,仍要按涂鸦画布是600*600来计算。
另外,还可以给画布设置一个显示的缩放比例,这个是什么意思呢?之前说过,涂鸦画布在实际使用的时候,会设置成比屏幕大一些,以确保在人脸缩小后,画布不至于被跟着缩小至比屏幕还小,不然有些地方就涂不上去了,将涂鸦画布设大,可以把它的实际尺寸设大,也可以是把它进行显示放大,为什么需要进行显示放大?因为如果涂鸦画布实际尺寸设置得很大,相当于画布的分辨率很高,这样画出的东西就比较精细,从而耗时也会增加,而进行显示放大不会增加涂鸦画布的实际尺寸,只相当于把一个小的东西在显示时扯大了,会稍微变模糊一些。因此,可以将涂鸦画布的实际大小设置得适中一些,再进行适当地显示放大,来使得画布不至于被跟着缩小至比屏幕还小,同时又让画布的分辨不会过高而增加绘制耗时。
加上了涂鸦画布显示缩放比例后,坐标换转的计算逻辑也要相应地作修改,假设display_scale是设置的画布显示缩放比例,沿用之前的例子,如果画布被放大显示了,算出的点会有相应的偏移,调整示意图如下:
现在可以将手指在屏幕上触摸时在onTouchEvent()回调中所得到的触摸坐标正确地转换成涂鸦画布中的坐标了,那么如何在对应的坐标点画涂鸦图案呢?前面已经讲解了一个简单的绘图框架,现在实际就是确定一下前文所说的IMAGE_POSITION_VERTEX以及IMAGE_TEXTURE_VERTEX该如何取值。将一个贴图画到一个位置上,那么这张图的哪个部分对准到这个点上呢?为了解决这个问题,这里引入一个概念叫“锚点”,所谓锚点就是纹理图片上用于对准的点,如下图所示:
实际上,锚点的设置并不是OpenGL本身的功能,不过我们可以对IMAGE_POSITION_VERTEX稍作修改便可以指定自己想要的锚点,例如我们指定锚点为纹理贴图的中心:
至此,涂鸦画布的坐标系转换就讲完了
涂鸦画布的平移、旋转及缩放
下面这部分讲解如何实现涂鸦画布随人脸平移、旋转及缩放,前面提到过,Vertex Shader会对每个要画的点都调一次,因此对每个点做对应的变换,也就实现了对涂鸦画布的变换,平移、旋转及缩放都有对应地矩阵操作可以方便地实现,将这些操作写在Vertex Shader中对传进Vertex Shader中的点进行变换就行了。
以下均假设变换前的点为x0、y0,变换后的点为x、y。
平移变换:
其中Δx、Δy分别表示在x、y轴上的平移量。
旋转变换:
其中θ表示绕原点逆时针旋转的角度。 tips:如果希望绕某个特定点旋转,可以先作平移操作,让特定点在平衡后处于原点的位置,再进行旋转操作,旋转结束后再按原路平移回去,如下图所示:
缩放变换:
其中k1、k2分别表示x、y坐标的缩放比例。
至此,本文已接近尾声,总结一下几个关键点:
作者简介:kenneyqin(覃华峥),天天P图Android工程师