这篇文章将给大家讲解如何在 Android 系统上基于 OpenGL ES 2.0 来实现相机实时图片涂鸦效果, 所涂内容跟随人脸出现消失移动旋转及缩放, 在这里, 我们假设您:
已经搭建好一个相机框架, 能够获得相机的预览图像
有了一个人脸检测的 SDK, 能够得到相机预览时每帧人脸在屏幕中的坐标及旋转角度
在开始讲解之前, 先简要介绍一下 OpenGL ES 2.0 的一些必要的基础知识, 方便对文章的理解
基础知识一: OpenGL 的坐标系
为方便讲解, 以下只讲解二维的情况, 在 OpenGL 使用中, 我们主要会涉及到以下三个坐标系:
屏幕坐标系
屏幕坐标系就是我们手机屏幕的坐标系, 以像素为单位, 左上角是坐标系原点, 即(0,0),x 的取值范围为 0~屏幕宽度, y 的取值范围为 0~屏幕高度, 详见下图:
世界坐标系
它是 OpenGL 内部的绘图区域的坐标系, xy 的取值范围都是 - 1~1, 坐标原点在绘图区域的中心, 见下图, 假设绿色区域是一个 OpenGL 的绘图区域:
纹理坐标系
就是纹理本身的坐标系, 坐标原点在纹理的左上角, s(x)t(y)的取值范围都是 0~1, 见下图, 假设
黄色区域是一个纹理贴图:
基础知识二: Shader
Shader 就是 OpenGL 的着色器, 分为顶点着色器 (Vertex Shader) 和片元着色器(Fragment Shader), 这两个着色器都由一段小程序来实现, 用 OpenGL Shading Language 编写, 语法类似 C 语言, 使用时将相应 shader 程序代码载入 OpenGL 即可
OpenGL 在把点绘到屏幕上之前, 点会依次经过顶点着色器和片元着色器的处理顶点着色器是处理顶点的位置大小旋转等操作, 比如希望显示一个经过顺时针旋转 90 度并放大 1 倍的纹理, 可以在顶点着色器中编写相应的代码; 片元着色器主要处理颜色操作, 比如希望将一个纹理中某个区域的颜色变成红色, 可以在片元着色器中编写相应的代码
相机实时图片涂鸦实现思路
下面开始循序渐进地讲解涂鸦的实现, 首先先来实现一个简单的框架: 在相机预览的界面的中央画一个贴图
Part1: 一个简单的框架
先来定义一下 Vertex Shader 和 Fragment Shader, 这两个 Shader 是必不可少的
Vertex Shader:
简要介绍一下这个 Vertex Shader 的含义, 正如前文所说的, Vertex Shader 的作用是对顶点进行一些位置大小旋转等变换操作, 但在现在这个 shader 里, 这些都没有涉及, 只是一个最简单的 Shader, 各变量及其含义:
a_Position
顶点数据, 代表了要画的每个顶点, 注意, 这里的 a_Position 只是一个点, 那么它如何能代表要画的每个顶点? 这是刚接触 Shader 时很容易会产生的疑惑之一, 实际上, Shader 代码会被 OpenGL 反复调用多次, 每画一个点就会调用一次, a_Position 就代表当前要画的点, 反复不停地调用, a_Position 就被赋上了不同顶点的值
a_TextureCoordinates
纹理坐标数据, 用于描述要画的纹理顶点, 在这里, 没有对它作任何处理, 直接赋给了 v_TextureCoordinates
v_TextureCoordinates
用于将 Vertex Shader 中接受到的纹理顶点数据传递到 Fragment Shader 中, 等会儿会看到在 Fragment Shader 中也有一个名字相同的变量
gl_Position
最终告诉 OpenGL 要画的顶点位置, 这里直接将 a_Position 赋给了它, 不作任何变换
Fragment Shader:
同样, 如前文所提到的, Fragment Shader 主要处理颜色操作, 各变量含义:
u_TextureUnit
java 层传递过来的纹理, 例如一张待绘制的图片
v_TextureCoordinates
这个就是刚才说的 Vertex Shader 中传递过来的, 其值就是 Vertex Shader 中的 a_TextureCoordinates
gl_FragColor
最终告诉 OpenGL 要画的顶点颜色, 这里 texture2D(u_TextureUnit, v_TextureCoordinates)是什么意思呢? 就是取 u_TextureUnit 纹理中的 v_TextureCoordinates 点, 而 v_TextureCoordinates 点又是 Vertex Shader 中传递过来的纹理的点, 所以相当于是在这个纹理中取对应的点, Fragment Shader 和 Vertex Shader 一样也是会反复地调用, 这样 gl_FragColor 就取到了这个纹理每一个点的颜色, 结果就是将这个纹理画了出来
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 中绘制图片:
至此, 我们有了一个简单的框架, 可以在相机预览界面绘制一个图片了
Part2: 涂鸦画布
简介
下面来介绍涂鸦画布的创建以及将手指在屏幕上触摸的位置绘制贴图
涂鸦画布是一个独立于相机预览帧的绘图区域, 它的作用是可以将已绘制好的涂鸦暂存起来, 否则因为相机预览帧每一帧都是新的, 需要把之前绘制过的东西再重新绘制一次, 即就算涂鸦结束了, 每帧也都需要调用多次 OpenGL 绘制方法将之前涂鸦的内容绘制到相机预览帧上, 否则在新的帧上就看不见之前涂的内容, 示意图如下:
有了涂鸦画布后, 就可以将涂鸦内容画到涂鸦画布上, 然后对每一个新的相机预览帧, 直接将整个画布画上去, 将画布画上去只需要调用一次 OpenGL 绘图方法:
这里的画布实际上就是一个空的 texture, 创建方法和创建一个普通的 texture 是一个样的, 即用 GLES20.glGenTextures()来创建, 然后进行一些初始化等操作:
为什么需要 framebuffer? 因为 OpenGL 默认是渲染到屏幕的, 我们往画布上画东西并不希望马上显示出来, 因为画布还要贴到脸上, 之后再显示出来
坐标变换
有了涂鸦画布之后, 下一步就是如何将涂鸦的内容画到画布上首先讨论坐标系的转换, 引入画布之后, 现在相关的坐标系又多了一个画布的坐标系, 手指在屏幕上触摸之后, 如何让图案最终在触摸的位置画出来呢?
手指在屏幕上触摸之后, onTouchEvent()中所得到的坐标是屏幕坐标系中的坐标, 而相机有一个预览宽高的设置, 这个宽高可以和屏幕宽高不一样, 比如 1080*1920 的屏幕, 相机的预览宽高可以设置为 720*960, 因此第一个坐标系的转换就是将屏幕坐标系中的触摸点坐标转换成与相机预览宽高相对应的坐标, 相机预览的坐标系原点及 xy 轴方向与屏幕坐标系相同:
得到了触摸点在相机预览画面中的坐标之后, 下一步是转换成它在画布中的坐标, 因为画布是跟随人脸移动旋转及缩放的, 因此这一步稍微有一点复杂, 这里画布贴到人脸上采用的方案是将画布中心对准人脸的鼻尖位置(鼻尖坐标由人脸检测 SDK 得到), 示意图如下:
可能有人会问, 从图中看, 屏幕中有些部分超出了画布, 这部分是否能涂上去? 是涂不上去的, 只能涂在涂鸦画布上, 因此实际使用的时候, 会把涂鸦画布设置成比屏幕大一些, 一般可以自己试一下, 比如把手机放远, 看看人脸缩小后画布要设置能多大还能覆盖屏幕, 一般不用设置得太大, 因为人脸缩得太小后, 人脸通常也识别不出来了, 这时候也不用担心画布被缩得太小了
继续沿用之前的例子, 前面是得到了触摸点在相机预览画面中的坐标是 (200,400), 它如何对应到涂鸦画面上面呢? 这里的方法是先计算触摸点相对于人脸鼻尖的位置, 因为涂鸦画布是将画布中心对准了人脸鼻尖位置, 所以再通过算出来的相对位置转换成涂鸦画布上的对应位置, 以保证它在涂鸦画布上还是手指触摸的那个地方假设画布的实际尺寸设置为 600*600, 画布中心点坐标是(300,300), 人脸鼻尖坐标是(360,320) 先从简单的情况看起, 假设画布贴上去之前, 没有进行移动旋转和缩放, 那么将是:
以上是一种简单的情况, 那么如果人脸先旋转了一下呢? 这时画布也是跟着旋转了, 这时的坐标如何转换? 其实思路很简单, 就是画的时候, 计算点坐标时把它当作还没转的情况来计算, 算出来后再转相应的角度就行了:
如何计算点 (x,y) 的值呢? 有个神奇的公式, 它可以计算一个点绕某个点逆时针旋转后的点坐标:
其中 xy 是旋转前的点坐标, x0y0 是绕着旋转的点坐标, xy 是旋转后的点坐标,α是旋转角度
下面来看看, 如果人脸缩放了, 如何计算正确的坐标, 这里采取的方法是, 当第一次把涂鸦画布贴到人脸上的时候, 先记录人脸的初始宽度, 之后的帧里再用当前人脸的宽度和记录的初始人脸宽度就行对比, 从而得知人脸缩放的比例人脸宽度的计算要依赖于人脸检测 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 中的点进行变换就行了
以下均假设变换前的点为 x0y0, 变换后的点为 xy
平移变换:
其中ΔxΔy 分别表示在 xy 轴上的平移量
旋转变换:
其中θ表示绕原点逆时针旋转的角度
tips: 如果希望绕某个特定点旋转, 可以先作平移操作, 让特定点在平衡后处于原点的位置, 再进行旋转操作, 旋转结束后再按原路平移回去, 如下图所示:
缩放变换:
其中 k1k2 分别表示 xy 坐标的缩放比例
至此, 本文已接近尾声, 总结一下几个关键点:
涂鸦画布的创建, 本质上是创建一个空的 texture 当作画板
坐标转换, 关系着涂鸦位置是否正确, 涉及到多个坐标系的转换, 一旦某步出错, 可能导致最后结果存在很大偏差
Vertext Shader 中平移旋转及缩放代码的编写, 本质上是套用变换矩阵
来源: https://cloud.tencent.com/developer/article/1035572