一看就懂的OpenGL ES教程——仿抖音滤镜的奇技淫巧之变换滤镜(实践篇)

1,882 阅读12分钟

通过阅读本文,你将获得以下收获:
1.如何写图像几何变换shader
2.如何给视频添加几何变换动画

上篇回顾

上一篇博文一看就懂的OpenGL ES教程——仿抖音滤镜的奇技淫巧之变换滤镜(理论基础篇)可以说给大家上了一节数学课,线性代数和三角函数都复习了不少,不知大家看得是不是很过瘾(枯燥)呢?但是变换对于图形渲染来说实在太重要了,不仅是做滤镜的时候需要,以后要做3维渲染将3维物体投影到二维平面的时候更是重中之重,所以这也是我上一篇博文那么啰嗦地解析数学细节的原因。现在理论基础打得差不多了,我们打开另一扇门,进入实战吧。毕竟纸上得来终觉浅,绝知此事要躬行。毕竟葵花宝典送到你手,但是你不练还是不会呀。

c645f5e2f002d45bb50212bd0fc8b67d.jpg

温馨提示:如果前面几篇博文都理解清楚了,那么今天的博文对于各位来说应该只是简单动动脑皮子的事情。

在OpenGL中做几何变换

在上一篇博文中一看就懂的OpenGL ES教程——仿抖音滤镜的奇技淫巧之变换滤镜(理论基础篇),最后得到2维图像的组合仿射变换矩阵

image.png

[10xt01yt001]\begin{bmatrix}1 & 0 & x_t \\ 0 & 1 & y_t \\ 0 & 0 & 1 \end{bmatrix} [cosϕsinϕ0sinϕcosϕ0001]\begin{bmatrix}\cos\phi & -\sin\phi & 0\\ \sin\phi & cos\phi & 0 \\ 0 & 0 & 1 \end{bmatrix} [a000b0001]\begin{bmatrix}a & 0 & 0\\ 0 & b & 0 \\ 0 & 0 & 1 \end{bmatrix} 三个变换矩阵的相乘结果。这里x_t、y_t分别为图像某个点在x和y轴方向的平移距离,ϕ\phi为图像某个点以原点为中心的逆时针旋转角度,a和b为图像某个点以原点为中心的在x和y轴方向上的缩放系数。

于是如果图像上的任何一个点对应的向量为[xaya1]\begin{bmatrix}x_a \\y_a \\1 \end{bmatrix} ,经过变换后的点为[xy1]\begin{bmatrix}x' \\y' \\1\end{bmatrix},则它们之间可以通过下面的矩阵乘法式子来表示:

image.png

既然变换是作用于图像上的点的,那么应该怎么作用于图像上的点才能完成对图像的几何变换呢?比如此时屏幕上的一个三角形的顶点是在a,b,c三个点上,如何对它往x,y方向分别平移xtx_tyty_t呢?

如何使用变换矩阵做变换

如果你已经阅读过本专栏之前的文章,想必你已经有答案了,那就是对顶点进行移动,所以只要将三角形的顶点都分别平移xtx_tyty_t即可。

那么如何改变顶点呢,我们知道顶点是由传进去的顶点数组确定的,所以首先,我们可以在客户端程序(不清楚客户端程序的可以看下一看就懂的OpenGL ES教程——再谈OpenGL工作机制先对顶点数组进行处理,计算出经过几何变换的顶点数组,然后再传入OpenGL中。

这样做可以是可以,但是弊端也是明显的:

  1. 需要对每个顶点进行处理,如果顶点数量很多,代码写起来可能很不方便。
  2. 如果是渲染几何变换的视频,相当于每一帧都要去做变换的计算,因为客户端程序是在cpu运行的,计算后的顶点数组又要经过总线传输到gpu中的,所以开销会很大。

既然这个方案有硬伤,那么肯定有另一种更好的方案了,那就是在shader里面处理变换计算

这个方案看起来就靠谱了不少:

  1. 一个是顶点数组只要传入一次就行了,所以cpu省了很多计算负担,总线开销也省了非常多。
  2. 另一个是变换的所有计算都放在shader即gpu中,计算速度更快。
  3. 还有一个是因为顶点着色器本身就是针对每个顶点的,所以我们都不需要针对顶点一个个去处理了,处理代码针对一个顶点写即可。

那么如何将变换矩阵传到shader里面呢?

将变换矩阵传到shader

如果看过前面的章节,应该知道shader有个表示矩阵的数据类型,名曰mat,在一看就懂的OpenGL ES教程——渲染宫崎骏动漫重拾童年就是用了mat来处理yuv转rgba的(还没看过这篇文章?还不赶紧去看~),同理,这里的变换矩阵也是用mat来处理,由于OpenGL是处理3D的,所以坐标系是3维的,加上齐次坐标(不清楚齐次坐标的请看一看就懂的OpenGL ES教程——仿抖音滤镜的奇技淫巧之变换滤镜(理论基础篇)中“齐次坐标”章节。),所以变换矩阵需要的是一个4*4的矩阵,即mat4。

那么,之前进行纹理渲染的顶点着色器就可以写成如下代码:


        #version 300 es
        //输入的顶点坐标,会在程序指定将数据输入到该字段
        //,如果传入的向量是不够4维的,自动将前三个分量设置为0.0,最后一个分量设置为1.0
        layout (location = 0) 
        in vec4 aPosition;
        
        //输入的纹理坐标,会在程序指定将数据输入到该字段
        layout (location = 1) 
        in vec2 aTextCoord;

        out vec2 vTextCoord;//输出的纹理坐标;
        uniform mat4 uMatrix;//变换矩阵

        void main() {
            //这里其实是将上下翻转过来(因为安卓图片会自动上下翻转,所以转回来)
            vTextCoord = vec2(aTextCoord.x, 1.0 - aTextCoord.y);
            //几何变换最关键的代码在这里,用变换矩阵乘上顶点坐标。注意,这里要用左乘
            gl_Position = uMatrix * aPosition;
        };

其实就是比原来多出一个mat4的uMatrix变量作为变换矩阵而已,在最后输出顶点坐标给OpenGL的时候,用该矩阵左乘原来传入的顶点坐标即可,所以此时输出的顶点坐标即为变换之后的顶点坐标

首先对于3维的变换来说,变换矩阵的推导和之前2维是几乎一样的,所以3维的变换矩阵的通用形式如下:

image.png

其中axa_x表示缩放、旋转、错切相关的变换,xtx_tyty_tztz_t表示平移变换。

平移变换

所以假如我们需要对图片x,y轴分别平移1个单位,则变换矩阵如下:

[1001010100100001]\begin{bmatrix}1 & 0 & 0 & 1 \\ 0 & 1 & 0 & 1 \\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1\end{bmatrix}

那么怎么从客户端程序传到shader中呢?因为变换矩阵用uniform类型定义,所以用uniform的方式传即可,而在OpenGL中,有一个方法叫glUniformMatrix4fv可以传入4*4的矩阵,在客户端程序可以通过数组来传入。则代码可以如下这样写:

//用一个16个元素的数组表示4*4矩阵,注意这里为列主序
float arr[16] = 
{
 1.0, 0.0, 0.0 ,0.0, //第一列   
 0.0, 1.0, 0.0 ,0.0, //第二列   
 0.0, 0.0, 1.0 ,0.0, //第三列     
 1.0, 1.0, 0.0 ,1.0 //第四列
 };

//获取uMatrix在shader中的位置引用
GLint uScaleMatrixLocation = glGetUniformLocation(program, "uMatrix");
//修改对应的shader变量uMatrix
glUniformMatrix4fv(uScaleMatrixLocation, 1, GL_FALSE, arr);

glUniformMatrix4fv()第一个参数即是熟悉的位置引用参数,第二个参数告诉需要传多少个矩阵,三个参数表示是否转置(OpenGL默认采用列主序,我们就采用列主序,所以设为GL_FALSE即可),最后一个参数就是传入的数组,这里OpenGL内部会将其按照矩阵来读取

这里要注意的就是,glUniformMatrix4fv方法传入的数组是列主序的,即每4个元素为一列。

运行下,看下效果:

translate.gif

可以看到,有点小荷才露尖尖角的味道,视频只露出了原来的左下角刚好四分之一了。注意这里平移1可不是平移1个像素,因为坐标是归一化的了,所以这里平移是一个单位:

image.png

所以原来视频的中心点被平移到屏幕右上角位置。

缩放变换

如果要缩放,比如x、y轴方向缩小为0.5倍,则矩阵的数组改为:

float arr[16] = 
{
 0.5, 0.0, 0.0 ,0.0, //第一列   
 0.0, 0.5, 0.0 ,0.0, //第二列   
 0.0, 0.0, 1.0 ,0.0, //第三列     
 0.0, 0.0, 0.0 ,1.0 //第四列
 };

运行下,看下效果:

scale.gif

视频果然沿着中心点缩小为0.5倍。

旋转变换

如果要旋转,比如逆时针旋转45度,那么代入之前得到的变换矩阵公式,数组可以写成如下:


float arr[16] = {
static_cast<float>(cos(45 * M_PI / 180))
, static_cast<float>(sin(45 * M_PI / 180))
, 0.0, 0.0, 
-static_cast<float>(sin(45 * M_PI / 180))
, static_cast<float>(cos(45 * M_PI / 180))
, 0.0, 0.0
, 0.0, 0.0, 1.0, 0.0
, 0.0, 0.0,0.0, 1.0};

其中“45 * M_PI / 180”表示对应的弧度,运行效果:

rotate.gif

可以看到整个视频被转起来了(让人不由自主把脖子扭斜,多看看这样的视频也是有利颈椎健康的)

组合变换

如果要组合变换呢,将上面3个数组综合起来即可,以下是先缩小到0.5倍,然后逆时针旋转45度,最后x,y方向分别平移0.5个单位的变换矩阵对应的组合变换矩阵

[10xt01yt001]\begin{bmatrix}1 & 0 & x_t \\ 0 & 1 & y_t \\ 0 & 0 & 1 \end{bmatrix} [cosϕsinϕ0sinϕcosϕ0001]\begin{bmatrix}\cos\phi & -\sin\phi & 0\\ \sin\phi & cos\phi & 0 \\ 0 & 0 & 1 \end{bmatrix} [a000b0001]\begin{bmatrix}a & 0 & 0\\ 0 & b & 0 \\ 0 & 0 & 1 \end{bmatrix} = [acosϕbsinϕxtasinϕbcosϕyt001]\begin{bmatrix}a\cos\phi & -b\sin\phi & x_t \\ a\sin\phi & b\cos\phi & y_t \\ 0 & 0 & 1 \end{bmatrix}

代入xt=0.5yt=0.5a=0.5,b=0.5,ϕ=45x_t = 0.5,y_t = 0.5,a=0.5,b = 0.5,\phi=45度

//这里要先计算出弧度
float theta = 45 * M_PI / 180;
float arr[16] = {
                  0.5f*cos(theta), -0.5f*sin(theta), 0.0, 0.0
                , 0.5f*sin(theta), 0.5f*cos(theta), 0.0, 0.0
                , 0.0, 0.0, 1.0, 0.0
                , 0.5, 0.5,0.0, 1.0 
                };

运行效果:

compose.gif

good,符合预期~

cfcb5949414db115e92de1a974c27f6f.gif

效果是出来了,但是你肯定会觉得这样手动计算出数组太麻烦。没关系,已经有人帮我们做好了,我们的体力将得到很好的解放。

glm库

OpenGL没有内建矩阵运算方法,所以一般是使用第三方库glm。OpenGL Mathematics (GLM) 是基于OpenGL着色语言(GLSL)规范的图形软件的C++数学库。具体介绍在这里OpenGL Mathematics (GLM),更详细的官方网址是glm Github

从Github下载到源码之后,解压之后把整个 glm 文件夹复制到你的项目所在文件夹下:

image.png

然后再CMakeList文件中添加glm的路径:

image.png

再对应源文件引入glm的头文件和名称空间

image.png

然后就可以在项目中使用glm了,glm的api也是顾名思义。

对矩阵的处理操作,比如缩放操作:

        //先创建一个单位矩阵
        mat4 scaleMatrix = glm::mat4(1.0f);
        //缩放系数
        float scale = 0.5;
        //vec3(scale)的3个分量分别乘以scaleMatrix的前三行,第四行齐次坐标不变
        //即3个分量分别是x、y、z的缩放系数
        mat4 resultMatrix = glm::scale(scaleMatrix, vec3(scale));
        //修改shader对应数值。glm::value_ptr(scaleMatrix)获取scaleMatrix的指针
        glUniformMatrix4fv(uScaleMatrixLocation, 1, GL_FALSE, glm::value_ptr(resultMatrix));

运行下看效果:

scale.gif

如果要完成上面说的先缩小到0.5倍,然后逆时针旋转ϕ \phi度,最后x,y方向分别平移0.5个单位,则代码如下:

    //x,y轴方向分别平移0.5
    scaleMatrix = glm::translate(scaleMatrix,vec3(0.5));
    //沿着(0,0,0)点逆时针旋转45度
    scaleMatrix = glm::rotate(scaleMatrix, glm::radians(45.0f),vec3(0.0f, 0.0f, 1.0f));
    //缩小到0.5倍
    scaleMatrix = glm::scale(scaleMatrix,vec3(0.5));
    //glm::value_ptr(scaleMatrix)获取scaleMatrix的指针
    glUniformMatrix4fv(uScaleMatrixLocation, 1, GL_FALSE, glm::value_ptr(scaleMatrix));

这里需要注意的是,这里代码是先平移,后旋转,然后缩放,实际执行的变换是反过来的,用数学表达式如下:

[100.5010.5001]\begin{bmatrix}1 & 0 & 0.5 \\ 0 & 1 & 0.5 \\ 0 & 0 & 1 \end{bmatrix} [cosϕsinϕ0sinϕcosϕ0001]\begin{bmatrix}\cos\phi & -\sin\phi & 0\\ \sin\phi & cos\phi & 0 \\ 0 & 0 & 1 \end{bmatrix} [0.50000.50001]\begin{bmatrix}0.5 & 0 & 0\\ 0 & 0.5 & 0 \\ 0 & 0 & 1 \end{bmatrix} [xy1]\begin{bmatrix}x \\y \\1 \end{bmatrix}

可以看出,按照调用顺序是矩阵从左到右相乘排列的,因为矩阵相乘满足结合律,所以可以看做最右边的矩阵最先一步右乘要做变换的点向量,然后下一个变换矩阵再右乘上一个变换的结果

运行下看效果:

compose.gif

和传入手动创建的数组一样的效果。

滤镜升级打怪

铂金

铂金开始进入新的高潮,因为引入了变换动画模式

缩放动画

scaleAnim.gif

是不是有点抖音内味了?

(由于掘金上传文件大小限制,这里的gif帧数太低,导致看起来有点鬼畜的感觉,理解万岁。。)

仔细一看,主要的动画逻辑是:先以均匀的速度放大,然后放大到一定程度后加速度缩小

关键代码在Java_com_example_openglstudydemo_YuvPlayer_loadYuvWithFilterEffect方法中, 在视频每帧的循环中处理:

        ...
        //每一轮缩放周期为总帧数的十分之一
        int scaleDuration = frameCount / 10;

        ...
        //这里取第i帧对应的缩放系数
        float scale = getTransformMatrix(scaleDuration, i);

        //vec3(scale)的3个分量分别乘以scaleMatrix的前三行,第四行齐次坐标不变
        mat4 resultMatrix = glm::scale(scaleMatrix, vec3(scale));
        glUniformMatrix4fv(uScaleMatrixLocation, 1, GL_FALSE, glm::value_ptr(resultMatrix));
float getTransformMatrix(int scaleDuration, int frame) {
    int remainder = frame % scaleDuration;
    LOGD("ScaleFilter onDraw remainder:%d", remainder);
    float ratio;
    
    //放大的时候是线性变换,即放大系数和时间成正比。算出每个周期的帧数占一个周期的比例
    if (remainder < scaleDuration / 2) {
        ratio = remainder * 1.0F / scaleDuration;
    } else {
        //缩小速度加速度增快
        ratio = static_cast<float>(pow(remainder * 1.0F / scaleDuration, 2));
    }

    //MAX_DIFF_SCALE是最大缩放倍数,值为1.5F
    float scale = MAX_DIFF_SCALE * ratio;
    //不要缩到比原图小
    if (scale < 1) {
        scale = 1;
    }
    LOGD("scale:%f", scale);
    return scale;

}

这里做动画肯定要获取一个一个动画周期的时间以及当前帧在一个动画周期中占的时间比。因为帧率是固定的,所以这里具体的时间比可以通过帧数比来算

具体逻辑就是每一轮缩放周期帧数为总帧数的十分之一,然后根据当前是第几帧,算出其对缩放周期帧数的余数,即算出当前帧在当前缩放周期的时间占比。

这里在一个周期内,前一半时间作为线性的速度放大,后一半时间进行加速度缩小。具体看代码注释,相信大家一定能够理解通透。

变换和其他滤镜的组合

compose_scale.gif

55696d256127ed731b7d689479e0353f.jpg

这个只要把两者结合起来就行了。

顶点着色器:


        #version 300 es
        //输入的顶点坐标,会在程序指定将数据输入到该字段
        //,如果传入的向量是不够4维的,自动将前三个分量设置为0.0,最后一个分量设置为1.0
        layout (location = 0) 
        in vec4 aPosition;
        
        //输入的纹理坐标,会在程序指定将数据输入到该字段
        layout (location = 1) 
        in vec2 aTextCoord;

        out vec2 vTextCoord;//输出的纹理坐标;
        uniform mat4 uMatrix;//变换矩阵

        void main() {
            //这里其实是将上下翻转过来(因为安卓图片会自动上下翻转,所以转回来)
            vTextCoord = vec2(aTextCoord.x, 1.0 - aTextCoord.y);
            //几何变换最关键的代码在这里,用变换矩阵乘上顶点坐标。注意,这里要用左乘
            gl_Position = uMatrix * aPosition;
        };

片段着色器:

#version 300 es

precision mediump float;
//纹理坐标
in vec2 vTextCoord;
//输入的yuv三个纹理
uniform sampler2D yTexture;//采样器
uniform sampler2D uTexture;//采样器
uniform sampler2D vTexture;//采样器
out vec4 FragColor;
void main() {
    //采样到的yuv向量数据
    vec3 yuv;
    //yuv转化得到的rgb向量数据
    vec3 rgb;

    vec2 uv = vTextCoord.xy;
    if (uv.x <= 0.5) {
        //当x小于0.5的时候,采样2倍x坐标的纹素颜色
        uv.x = uv.x * 2.0;
    }else{
        //当x大于0.5的时候,采样2倍x坐标减0.5的纹素颜色
        uv.x = (uv.x - 0.5) * 2.0;
    }
   
     if (uv.y <= 0.5) {
           //当y小于0.5的时候,采样2倍y坐标的纹素颜色  
           uv.y = uv.y * 2.0;
     }else{
           //当y大于0.5的时候,采样2倍y坐标减0.5的纹素颜色
           uv.y = (uv.y - 0.5) * 2.0;
     }
    //分别取yuv各个分量的采样纹理
    yuv.x = texture(yTexture, uv).r;
    yuv.y = texture(uTexture, uv).g - 0.5;
    yuv.z = texture(vTexture, uv).b - 0.5;
    rgb = mat3(
            1.0, 1.0, 1.0,
            0.0, -0.183, 1.816,
            1.540, -0.459, 0.0
    ) * yuv;
    FragColor = vec4(rgb, 1.0);
 };

如果看不懂这段片段着色器代码,可以看下一看就懂的OpenGL ES教程——仿抖音滤镜的各种奇技淫巧之基础滤镜的详细解释。

总结

今天在上一次详细解析几何变换原理的基础上进行了实战,从手动计算出数组进行单一的几何变换到组合变换,再到使用glm库进行变换,再到实现变换动画以及将变换动画和之前的滤镜进行结合,滤镜的世界也变得越来越有趣了,接下来,就将进入更丰富炫酷的滤镜了,让大家大饱眼福,大家请拭目以待~

项目代码

opengl-es-study-demo 不断更新中,欢迎各位来star~

参考:

GAMES101-现代计算机图形学入门-闫令琪

变换

Fundamentals of Computer Graphics, Fourth Edition

原创不易,如果觉得本文对自己有帮助,别忘了随手点赞和关注,这也是我创作的最大动力~

系列文章目录

体系化学习系列博文,请看音视频系统学习总目录

实践项目: 介绍一个自己刚出炉的安卓音视频播放录制开源项目 欢迎各位来star~

相关专栏:

C/C++基础与进阶之路

音视频理论基础系列专栏

音视频开发实战系列专栏

一看就懂的OpenGL es教程