Android平台OpenGL SE Camera滤镜实现

96
andev009
2018.08.01 10:37* 字数 2084

完整代码在https://github.com/andev009/AndroidShaderDemo
本文是基础讲解,后面的文章给了一些图像特效原理和相机特效分析。
Camera滤镜本质上是通过OpenGL SE shader对图像进行处理,这里为了说明重点,忽略Camera部分,只看shader对图像进行处理部分,分成三个小Demo,一步步实现简单滤镜。相机部分在后面的文章里说。
一、单色四边形(查看SimpleRender.java)
Android 平台用GLSurfaceView当做画布,里面封装了和OpenGL的交互,在布局文件里直接放置GLSurfaceView就行了:

   <android.opengl.GLSurfaceView
        android:id="@+id/glsurfaceView"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:layout_gravity="center"
        />

然后需要给GLSurfaceView设置一个Render,Render有三个回调方法,当设置完Render后,GLSurfaceView内部会开启一个GL线程GLThread,具体查看GLSurfaceView的源码。

public class SimpleRender implements GLSurfaceView.Renderer {
    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        glClearColor(0.0f, 0.0f, 0.0f, 0.0f);//设置清屏颜色
    }

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        glViewport(0, 0, width, height);//设置视口尺寸
    }

    @Override
    public void onDrawFrame(GL10 gl) {
        glClear(GL_COLOR_BUFFER_BIT);//清屏指令
    }
}
glSurfaceView.setRenderer(simpleRender);//设置Render

我们要画四边形,就需要先定义一个四边形,四边形的属性有顶点坐标和顶点颜色两部分组成,OpenGL的坐标范围在[-1,1]之间,这里不考虑三维顶点坐标变换,直接用二维空间定义,每个顶点的颜色都是红色(1f, 0f, 0f):

public static final float CUBE[] = {
            -1.0f, -1.0f, 1f, 0f, 0f,//左下角
            1.0f, -1.0f, 1f, 0f, 0f,//右下角
            -1.0f, 1.0f, 1f, 0f, 0f,//左上角
            1.0f, 1.0f, 1f, 0f, 0f,//右上角
    };

定义好了顶点后,就需要将顶点数据传给OpenGL。这里需要说明的是java代码运行环境和OpenGL运行环境是不一样的,java代码不直接在硬件里运行,而是在虚拟机里运行。java还有垃圾回收机制。而OpenGL在硬件里运行,没有垃圾回收机制。java可以通过JNI机制来访问底层代码(c或c++),我们之后调用的android.opengl.GLES20包下的方法,本质上就是通过JNI来调用对应的c或c++代码来和OpenGL交互。
将顶点数据传给OpenGL通过ByteBuffer类的方法实现,这里为了使用方便封装成了一个类VertexArray:

public class VertexArray {    
    private final FloatBuffer floatBuffer;
    public VertexArray(float[] vertexData) {
        floatBuffer = ByteBuffer
            .allocateDirect(vertexData.length * BYTES_PER_FLOAT)//BYTES_PER_FLOAT = 4,float占4个字节
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()//分配完所需要的内存空间,转化成FloatBuffer
            .put(vertexData);//将vertexData 从虚拟机内存复制到native memory
    }
}

现在native memory有了顶点数据,下面就要告诉OpenGL怎么去画图形了。这里就涉及到OpenGL渲染管线的概念。我们只关注两点,一个是顶点处理(顶点坐标变换等),一个是片元处理(每像素最终显示什么颜色),这两部分的工作分别由vertex shader(顶点着色器)和fragment shader(片元着色器)处理。
这里的四边形顶点坐标和颜色数据都有,而且不需要做额外处理,直接传给fragment shader去处理就行了,所以vertex shader很简单,就是赋值:

//simple_vertex_shader.glsl
attribute vec4 a_Position;//attribute变量,表示只能在vertex shader中使用
attribute vec4 a_Color;
varying vec4 v_Color;//varying 变量,表示要传给fragment shader的数据
void main()
{
    v_Color = a_Color;//之前cube定义的顶点颜色,传给fragment shader
    gl_Position = a_Position;//之前cube定义的顶点坐标,最终显示的坐标给gl_Position,OpenGL用gl_Position做为最终的位置值
}

再定义fragment shader,在fragment shader里,只需要把
vertex shader传过来的颜色值显示出来:

//simple_fragment_shader.glsl
precision mediump float; //设置精度,vertex shader默认是highp
varying vec4 v_Color;                                       
void main()                         
{                               
    gl_FragColor = v_Color;  //  gl_FragColor就是最终的颜色值                           
}

定义好了vertex shader和fragment shader,使用前要经过加载,编译,链接三个阶段,可以看到shader语言和c语言很像,运行c语言也要经过这几个阶段。
1.加载
加载代码在TextResourceReader.java,就是把shader文件读出来存在String里。
2.编译
编译代码在ShaderHelper.java的compileShader方法里:

//创建shader对象
final int shaderObjectId = glCreateShader(type);//type分别是GL_VERTEX_SHADER或GL_FRAGMENT_SHADER
glShaderSource(shaderObjectId, shaderCode);//shaderCode就是加载的shader字符串,OpenGL会读shader并和shaderObjectId联系在一起
glCompileShader(shaderObjectId);//OpenGL编译读入的shader

3.链接
链接代码在ShaderHelper.java的linkProgram方法里:

//创建着色器程序对象
final int programObjectId = glCreateProgram();
// vertexShader关联到 program.
glAttachShader(programObjectId, vertexShaderId);
//fragmentShader关联到 program.
glAttachShader(programObjectId, fragmentShaderId);
// Link the two shaders together into a program.
glLinkProgram(programObjectId);

上面步骤处理完,就得到了programObjectId这个着色器程序对象,在绘制时,怎么处理顶点,颜色,都是programObjectId负责了。
准备工作做了那么多,下面开始真正画四边形了,在前面定义的Render回调方法onDrawFrame里画了。

@Override
    public void onDrawFrame(GL10 gl) {
        glClear(GL_COLOR_BUFFER_BIT);
        colorProgram.useProgram();
        vertexArray.setVertexAttribPointer(
                0,
                colorProgram.getPositionAttributeLocation(),
                POSITION_COMPONENT_COUNT,
                STRIDE);//绑定vertex shader的a_Color
        vertexArray.setVertexAttribPointer(
                POSITION_COMPONENT_COUNT,
                colorProgram.getColorAttributeLocation(),
                COLOR_COMPONENT_COUNT,
                STRIDE);//绑定vertex shader的a_Position
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
    }

colorProgram就是个封装的着色器程序对象,useProgram内部调用的OpenGL方法glUseProgram(program);program就是之前链接后生成的programObjectId。
前面vertex shader里有两个attribute变量a_Position和a_Color,vertexArray里的顶点数据要分别传给这两个变量,vertexArray.setVertexAttribPointer这两个方法就是做这件事的,具体查看代码,内部调用了glVertexAttribPointer。
OpenGL现在有了所有数据和绘制方法,最后一步让OpenGL按照GL_TRIANGLE_STRIP规则绘制顶点:

GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

运行后,显示:


red.png

二、单个纹理的四边形(查看SimpleTextureRender.java)
这次绘制的四边形带纹理,定义四边形不需要顶点颜色值了,但是需要纹理坐标。一般纹理图片的坐标原点在左上角,而OpenGL要求的纹理坐标原点在左下角,因此定义四边形纹理坐标时将y值1-翻转。

    public static final float CUBE[] = {//翻转顶点信息中的纹理坐标,统一用1去减
            -1.0f, -1.0f, 0f, 1f - 0f,
            1.0f, -1.0f, 1f, 1f -0f,
            -1.0f, 1.0f, 0f, 1f -1f,
            1.0f, 1.0f, 1f, 1f -1f,
    };

纹理的加载被封装成TextureHelper,由下面代码加载纹理:

   final int[] textureObjectIds = new int[1];
   glGenTextures(1, textureObjectIds, 0);//生成纹理对象
   final Bitmap bitmap = BitmapFactory.decodeResource(
            context.getResources(), resourceId, options);//读入drawable文件下的纹理 
   图片文件生成bitmap
   glBindTexture(GL_TEXTURE_2D, textureObjectIds[0]);//绑定纹理对象,意味着 
   以下操作都是对此纹理操作
   glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, 
  GL_LINEAR_MIPMAP_LINEAR);//过滤器
   glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);//过滤器
   texImage2D(GL_TEXTURE_2D, 0, bitmap, 0);//让OpenGL读入bitmap数据,并将数据copy到当前绑定的纹理对象去,当前纹理对象在这里是textureObjectIds。
  bitmap.recycle();//bitmap使用完,释放
  glBindTexture(GL_TEXTURE_2D, 0);//解绑当前纹理对象,不然以后操作还是这个纹理

SimpleTextureRender里的成员变量texture就是这次加载的纹理标识:

texture = TextureHelper.loadTexture(context, R.drawable.face);

因为使用了纹理坐标,前面的shader也要重写,参考simple_texture_vertex_shader和simple_texture_fragment_shader。在vertex shader里把之前的顶点颜色a_Color换成现在的纹理坐标a_TextureCoordinates,顶点坐标变量不变。fragment shader之前的着色器程序直接使用cube里的颜色,现在要使用纹理,重写的fragment shader代码如下:

precision mediump float;
uniform sampler2D u_TextureUnit;//代表纹理图片
varying vec2 v_TextureCoordinates;//纹理坐标
void main()
{
     gl_FragColor = texture2D(u_TextureUnit, v_TextureCoordinates);//获取纹理坐标对应的rgb颜色值
}

这里多了个uniform标识的变量u_TextureUnit,uniform变量也是外部传给vertex,fragment shader的,它类似c语言的常量,不能被修改。
texture2D函数的作用就是根据纹理坐标在纹理图上找对应的rgb颜色值,找到的颜色值最终赋值给gl_FragColor。
顶点定义好了,shader 准备好了,下面准备着色器程序了,这里封装了SimpleTextureShaderProgram,shader的加载,编译,链接和之前一样。多了个设置shader 里Uniform变量的方法:

public void setUniforms(int textureId) {
        // 设置当前纹理单元texture unit 0.
        glActiveTexture(GL_TEXTURE0);
        // 将纹理对象绑定上面的纹理单元.
        glBindTexture(GL_TEXTURE_2D, textureId);
        // 在shader里,让纹理采样器用texture unit 0这个单元的纹理
        glUniform1i(uTextureUnitLocation, 0);
    }

下面看SimpleTextureRender完整的绘图方法:

@Override
    public void onDrawFrame(GL10 gl) {
        glClear(GL_COLOR_BUFFER_BIT);

        simpleTextureShaderProgram.useProgram();//使用着色器程序
        simpleTextureShaderProgram.setUniforms(texture);

        vertexArray.setVertexAttribPointer(
                0,
                simpleTextureShaderProgram.getPositionAttributeLocation(),
                POSITION_COMPONENT_COUNT,
                STRIDE);//绑定顶点坐标值

        vertexArray.setVertexAttribPointer(
                POSITION_COMPONENT_COUNT,
                simpleTextureShaderProgram.getTextureCoordinatesAttributeLocation(),
                TEXTURE_COORDINATES_COMPONENT_COUNT,
                STRIDE);//绑定纹理坐标值
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
    }

可以看到和之前绘制单色四边形几乎一样,区别就是给shader设置一个纹理图片,把之前的颜色值换成了纹理坐标值。
运行程序后,显示:


texture.png

二、带多个纹理的四边形,滤镜效果(查看MultiTextureRender.java)

Camera滤镜特效本质是将Camera的每一帧图像经过shader处理后再显示出来。shader处理图像时有各种图像处理算法,有的只是缩放原始图像的rgb值,有的是多图像颜色混合。这里给shader传入多张图片,看看混合后的效果。
首先四边形顶点数据不需要重新定义,之前的顶点坐标和纹理坐标就够了。
然后vertex shader也不用改,只接收顶点坐标和纹理坐标。
需要改动的是fragment shader,参考multi_texture_fragment_shader
这里传入了6张图片,当然shader里混合颜色时可以只使用部分几张。

precision mediump float;
uniform sampler2D u_TextureUnit0;//orgin
uniform sampler2D u_TextureUnit1;//edge
uniform sampler2D u_TextureUnit2;//hefemap
uniform sampler2D u_TextureUnit3;//hefemetal
uniform sampler2D u_TextureUnit4;//hefesoftlight
uniform sampler2D u_TextureUnit5;//hefegradientmap
varying vec2 v_TextureCoordinates;

void main()
{
  //   gl_FragColor = texture2D(u_TextureUnit0, v_TextureCoordinates);
     vec4 originColor = texture2D(u_TextureUnit0, v_TextureCoordinates);
     vec3 texel = texture2D(u_TextureUnit0, v_TextureCoordinates).rgb;
     vec3 edge = texture2D(u_TextureUnit1, v_TextureCoordinates).rgb;
     vec3 hefemap = texture2D(u_TextureUnit2, v_TextureCoordinates).rgb;
     texel = texel * edge;

     //texel = vec3(dot(vec3(0.3, 0.6, 0.1), texel));
     gl_FragColor = vec4(texel, 1.0f);
}

u_TextureUnit0代表上面单个纹理Demo运行显示的原始图像,一个美女。现在要在这个图像四周加渐变边框,边框是下面这个图,在shader中被u_TextureUnit1代表


3.png

shader中两张图片颜色混合的部分在这里:

texel = texel * edge;

edge是黑白图,白色是1,黑色是0,渐变是[0,1]之间的值,这样相乘后原始图x1的部分保持原来不变,x0的部分就变黑了,中间值就有渐变效果。
和之前一样,顶点数据,纹理坐标有了,shader也有了,该写着色器程序了,着色器程序这里封装成了MultiTextureShaderProgram,同样在构造方法里走了shader的加载,编译,链接。来看下和单张纹理图有什么不同。之前用的一个int变量持有单张纹理,现在有多张就使用一个数组:

private final int []uTextureUnitLocation;

同样setUniforms方法不像之前传一个纹理给shader,这里要传多个纹理:

public void setUniforms(int [] textureIDs, float strength) {
        for(int i = 0; i < textureIDs.length; i++){
            glActiveTexture(GL_TEXTURE0  + i);
            glBindTexture(GL_TEXTURE_2D, textureIDs[i]);
            glUniform1i(uTextureUnitLocation[i], i);
        }
    }

最后写render,这里封装的是MultiTextureRender,可以看到几乎和之前单个纹理的SimpleTextureShaderProgram一样,只是多了几个加载纹理的调用:

multiTextureShaderProgram = new MultiTextureShaderProgram(context);
        originTexture = TextureHelper.loadTexture(context, R.drawable.face);
        edgeTexture = TextureHelper.loadTexture(context, R.drawable.edgeburn);
        hefeMapTexture = TextureHelper.loadTexture(context, R.drawable.hefemap);
        hefemetalTexture = TextureHelper.loadTexture(context, R.drawable.hefemetal);
        hefesoftlightTexture = TextureHelper.loadTexture(context, R.drawable.hefesoftlight);
        hefegradientmapTexture = TextureHelper.loadTexture(context, R.drawable.hefegradientmap);

        textureIDs = new int[]{originTexture, edgeTexture, hefeMapTexture,
                hefemetalTexture, hefesoftlightTexture,hefegradientmapTexture};

运行后,显示


filter.png

可以看到图片周围混合了黑白渐变色。
还可以在fragment shader里加上注释的那行代码,

texel = vec3(dot(vec3(0.3, 0.6, 0.1), texel));

混合后图片可以变黑白色了。至于其他滤镜效果,就要写不同的算法混合了。

OpenGL
Web note ad 1