从显示一张图片开始学习OpenGL ES

6,043

前言

网上很多介绍OpenGL ES的文章,但由于OpenGL ES内容太多,所以这些文章难免过于臃肿杂乱,很难抓住重点,对于初学者来说最后还是云里雾里。很多人(包括笔者本人)开始深入了解OpenGL ES是因为其涉及到实时滤镜的应用,通常都会参考开源框架GPUImage的实现。如果没有掌握基本的OpenGL Es的开发知识,很难弄懂其中代码缘由。

目前很流行的短视频特效处理也有涉及到OpenGL的应用,于是已经踩坑无数的笔者下决心让后来者少走弯路,以最实用的场景——显示一张图片开始学习OpenGL ES.

本文章适合初学Android OpenGL ES 2.0+,以及想要了解OpenGL实时滤镜实现原理的同学。

准备

在开始实现之前,先要讲一些基本的知识,也是OpenGL ES 2D\3D绘图的一些基本理论,这里我们只讲绘制一张图片后面需要用到的知识点

坐标系

OpenGL拥有独立的坐标系,没有任何变换前的初始坐标系为三维坐标系,x y z 取值范围都是 [-1, 1]:

由于我们绘制的是2D图片,因此可以简化为二维坐标系(只包含xy轴),坐标系的原点在窗口中央,x 轴向右,y 轴向上:

这时就有疑问了,我们的屏幕或显示窗口长宽的比例不是1:1(即不是正方形),怎么跟OpenGL的初始世界坐标系对应呢?如果我们没有指定投影比例,那么世界坐标系则会填充整个显示窗口,这样就会导致拉伸变形,比如把上面的三角形投射在窗口时的显示如下:

如果要指定投影比例就得应用到投影和矩阵变换,这里我们仍使用初始的世界坐标系,比如为了上面的三角形显示正常,根据拉伸比例改变绘制的顶点坐标即可。

顶点坐标

在OpenGL ES中,支持三种类型的绘制:点、直线以及三角形;由这三种图形组成其他所有图形,比如我们看到的圆滑的球体也是由一个个三角形组成的,三角形越多看上去越圆滑:

在绘制图形时我们需要提供相应的顶点位置,然后指定绘制方式去绘制这些顶点,以此呈现出我们想要的图形。

后面我们显示一张图片的时候也需要绘制由两个三角形组成的矩形,通过GL_TRIANGLE_STRIP 绘制方式(即每相邻三个顶点组成一个三角形,为一系列相接三角形构成)绘制:

纹理贴图(纹理映射)

我们需要显示的是一张图片,而上面一直说绘制图形。这就好比我们往墙上贴墙纸,首先得搭建好房子,然后决定墙纸的每个地方贴在墙上的哪个位置,这个过程在OpenGL的绘制过程中叫做纹理贴图,也叫纹理映射。

纹理贴图时涉及到UV坐标,所有的图像文件都是二维的一个平面,通过这个平面的UV坐标我们可以定位图象上的任意一个象素,在android的uv坐标的原点在左上角:

我们根据顶点的渲染顺序,定义每个顶点uv坐标,如下图是我们定义的四个顶点,绘制成一个矩形:

那么根据顶点的渲染顺序,定义每个顶点uv坐标:

指定好特定顶点对应的纹理坐标后,顶点与顶点间的其余部分会进行图像光滑插值处理,最后整张纹理就显示出来啦。

光栅化

光栅化就是把顶点数据转换为片元的过程。片元中的每一个元素对应于帧缓冲区中的一个像素。

把虚拟世界中的三维几何信息投影到二维屏幕上,由于目前的显示设备屏幕都是离散化的(有一个个的像素组成),因此需要把投影结果离散化,将其分解为一个个离散化的小单元,这些小单元称之为片元(片段,Fragment).

着色器

OpenGL ES2.0使用可编程渲染管线,既然是可编程,那就需要我们自己写着色器代码(GLSL),OpenGL中有顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)。

顶点着色器主要用来处理图形中每个顶点的最终位置。顶点数据由我们传进着色器,由于绘制图片不需要变换顶点,所以顶点着色器里面我们不需要特殊处理每个顶点。而片元着色器主要处理每个片元的最终颜色,这里我们只要根据传进来的贴图数据,进行纹理采样即可。

开始动手实现!

在Android系统中使用OpenGL需要涉及到两个最基本的的类,GLSurfaceView和GLSurfaceView.Renderer。

  • GLSurfaceView继承了SurfaceView类,它是专门用来显示OpenGL渲染的图形。可以这么理解,GLSurfaceView就是前面我们说的用来显示OpenGL图形的窗口。
  • GLSurfaceView.Renderer是GLSurfaceview的渲染器,通过GLSurfaceView.setRender()设置。
interface GLSurfaceView.Renderer {
	//在Surface创建的时候回调,可以在这里进行一些初始化操作
	public void onSurfaceCreated(GL10 gl, EGLConfig config);
	//在Surface尺寸改变的的时候回调,可以在这里设置窗口的大小
	public void onSurfaceChanged(GL10 gl, int width, int height);
	//绘制每一帧的时候回调
	public void onDrawFrame(GL10 gl);
}

这里需要特别说明,Render渲染器的回调是在一个单独的线程上执行的,因此我们进行OpenGL的相关操作也需要切换到该GL环境下的线程上,可以通过GLSurfaceView.queueEvent(Runnable)把操作放入GL环境的队列中,也可以自己控制队列,等待Render回调时再执行队列的操作。

代码如下:

public class GLShowImageActivity extends Activity {
    // 绘制图片的原理:定义一组矩形区域的顶点,然后根据纹理坐标把图片作为纹理贴在该矩形区域内。

    // 原始的矩形区域的顶点坐标,因为后面使用了顶点法绘制顶点,所以不用定义绘制顶点的索引。无论窗口的大小为多少,在OpenGL二维坐标系中都是为下面表示的矩形区域
    static final float CUBE[] = { // 窗口中心为OpenGL二维坐标系的原点(0,0)
            -1.0f, -1.0f, // v1
            1.0f, -1.0f,  // v2
            -1.0f, 1.0f,  // v3
            1.0f, 1.0f,   // v4
    };
    // 纹理也有坐标系,称UV坐标,或者ST坐标。UV坐标定义为左上角(0,0),右下角(1,1),一张图片无论大小为多少,在UV坐标系中都是图片左上角为(0,0),右下角(1,1)
    // 纹理坐标,每个坐标的纹理采样对应上面顶点坐标。
    public static final float TEXTURE_NO_ROTATION[] = {
            0.0f, 1.0f, // v1
            1.0f, 1.0f, // v2
            0.0f, 0.0f, // v3
            1.0f, 0.0f, // v4
    };

    private GLSurfaceView mGLSurfaceView;
    private int mGLTextureId = OpenGlUtils.NO_TEXTURE; // 纹理id
    private GLImageHandler mGLImageHandler = new GLImageHandler();

    private FloatBuffer mGLCubeBuffer;
    private FloatBuffer mGLTextureBuffer;
    private int mOutputWidth, mOutputHeight; // 窗口大小
    private int mImageWidth, mImageHeight; // bitmap图片实际大小

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_01);
        mGLSurfaceView = findViewById(R.id.gl_surfaceview);
        mGLSurfaceView.setEGLContextClientVersion(2); // 创建OpenGL ES 2.0 的上下文环境

        mGLSurfaceView.setRenderer(new MyRender());
        mGLSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY); // 手动刷新
    }

    private class MyRender implements GLSurfaceView.Renderer {

        @Override
        public void onSurfaceCreated(GL10 gl, EGLConfig config) {
            GLES20.glClearColor(0, 0, 0, 1);
            GLES20.glDisable(GLES20.GL_DEPTH_TEST); // 当我们需要绘制透明图片时,就需要关闭它
            mGLImageHandler.init();

            // 需要显示的图片
            Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.thelittleprince);
            mImageWidth = bitmap.getWidth();
            mImageHeight = bitmap.getHeight();
            // 把图片数据加载进GPU,生成对应的纹理id
            mGLTextureId = OpenGlUtils.loadTexture(bitmap, mGLTextureId, true); // 加载纹理

            // 顶点数组缓冲器
            mGLCubeBuffer = ByteBuffer.allocateDirect(CUBE.length * 4)
                    .order(ByteOrder.nativeOrder())
                    .asFloatBuffer();
            mGLCubeBuffer.put(CUBE).position(0);

            // 纹理数组缓冲器
            mGLTextureBuffer = ByteBuffer.allocateDirect(TEXTURE_NO_ROTATION.length * 4)
                    .order(ByteOrder.nativeOrder())
                    .asFloatBuffer();
            mGLTextureBuffer.put(TEXTURE_NO_ROTATION).position(0);
        }

        @Override
        public void onSurfaceChanged(GL10 gl, int width, int height) {
            mOutputWidth = width;
            mOutputHeight = height;
            GLES20.glViewport(0, 0, width, height); // 设置窗口大小
            adjustImageScaling(); // 调整图片显示大小。如果不调用该方法,则会导致图片整个拉伸到填充窗口显示区域
        }

        @Override
        public void onDrawFrame(GL10 gl) { // 绘制
            GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
            // 根据纹理id,顶点和纹理坐标数据绘制图片
            mGLImageHandler.onDraw(mGLTextureId, mGLCubeBuffer, mGLTextureBuffer);
        }

        // 调整图片显示大小为居中显示
        private void adjustImageScaling() {
            float outputWidth = mOutputWidth;
            float outputHeight = mOutputHeight;

            float ratio1 = outputWidth / mImageWidth;
            float ratio2 = outputHeight / mImageHeight;
            float ratioMax = Math.min(ratio1, ratio2);
            // 居中后图片显示的大小
            int imageWidthNew = Math.round(mImageWidth * ratioMax);
            int imageHeightNew = Math.round(mImageHeight * ratioMax);

            // 图片被拉伸的比例
            float ratioWidth = outputWidth / imageWidthNew;
            float ratioHeight = outputHeight / imageHeightNew;
            // 根据拉伸比例还原顶点
            float[] cube = new float[]{
                        CUBE[0] / ratioWidth, CUBE[1] / ratioHeight,
                        CUBE[2] / ratioWidth, CUBE[3] / ratioHeight,
                        CUBE[4] / ratioWidth, CUBE[5] / ratioHeight,
                        CUBE[6] / ratioWidth, CUBE[7] / ratioHeight,
                };

            mGLCubeBuffer.clear();
            mGLCubeBuffer.put(cube).position(0);
        }
    }
}

对于着色器的语法和相关使用,这里我不去赘述,我给的建议是,先了解顶点着色器和片元着色器的主要作用,然后在把这篇教程理解一遍后,对着色器感兴趣的话再去查找相关的资料。这里我们只是显示一张图片,使用的着色器代码很简单,都加了注释,不影响大家理解哈。

/**
 * 负责显示一张图片
 */
public class GLImageHandler {
    // 数据中有多少个顶点,管线就调用多少次顶点着色器
    public static final String NO_FILTER_VERTEX_SHADER = "" +
            "attribute vec4 position;\n" + // 顶点着色器的顶点坐标,由外部程序传入
            "attribute vec4 inputTextureCoordinate;\n" + // 传入的纹理坐标
            " \n" +
            "varying vec2 textureCoordinate;\n" +
            " \n" +
            "void main()\n" +
            "{\n" +
            "    gl_Position = position;\n" +
            "    textureCoordinate = inputTextureCoordinate.xy;\n" + // 最终顶点位置
            "}";

    // 光栅化后产生了多少个片段,就会插值计算出多少个varying变量,同时渲染管线就会调用多少次片段着色器
    public static final String NO_FILTER_FRAGMENT_SHADER = "" +
            "varying highp vec2 textureCoordinate;\n" + // 最终顶点位置,上面顶点着色器的varying变量会传递到这里
            " \n" +
            "uniform sampler2D inputImageTexture;\n" + // 外部传入的图片纹理 即代表整张图片的数据
            " \n" +
            "void main()\n" +
            "{\n" +
            "     gl_FragColor = texture2D(inputImageTexture, textureCoordinate);\n" +  // 调用函数 进行纹理贴图
            "}";

    private final LinkedList<Runnable> mRunOnDraw;
    private final String mVertexShader;
    private final String mFragmentShader;
    protected int mGLProgId;
    protected int mGLAttribPosition;
    protected int mGLUniformTexture;
    protected int mGLAttribTextureCoordinate;

    public GLImageHandler() {
        this(NO_FILTER_VERTEX_SHADER, NO_FILTER_FRAGMENT_SHADER);
    }

    public GLImageHandler(final String vertexShader, final String fragmentShader) {
        mRunOnDraw = new LinkedList<Runnable>();
        mVertexShader = vertexShader;
        mFragmentShader = fragmentShader;
    }

    public final void init() {
        mGLProgId = OpenGlUtils.loadProgram(mVertexShader, mFragmentShader); // 编译链接着色器,创建着色器程序
        mGLAttribPosition = GLES20.glGetAttribLocation(mGLProgId, "position"); // 顶点着色器的顶点坐标
        mGLUniformTexture = GLES20.glGetUniformLocation(mGLProgId, "inputImageTexture"); // 传入的图片纹理
        mGLAttribTextureCoordinate = GLES20.glGetAttribLocation(mGLProgId, "inputTextureCoordinate"); // 顶点着色器的纹理坐标
    }

    public void onDraw(final int textureId, final FloatBuffer cubeBuffer,
                       final FloatBuffer textureBuffer) {
        GLES20.glUseProgram(mGLProgId);
        // 顶点着色器的顶点坐标
        cubeBuffer.position(0);
        GLES20.glVertexAttribPointer(mGLAttribPosition, 2, GLES20.GL_FLOAT, false, 0, cubeBuffer);
        GLES20.glEnableVertexAttribArray(mGLAttribPosition);
        // 顶点着色器的纹理坐标
        textureBuffer.position(0);
        GLES20.glVertexAttribPointer(mGLAttribTextureCoordinate, 2, GLES20.GL_FLOAT, false, 0, textureBuffer);
        GLES20.glEnableVertexAttribArray(mGLAttribTextureCoordinate);
        // 传入的图片纹理
        if (textureId != OpenGlUtils.NO_TEXTURE) {
            GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
            GLES20.glUniform1i(mGLUniformTexture, 0);
        }

        // 绘制顶点 ,方式有顶点法和索引法
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); // 顶点法,按照传入渲染管线的顶点顺序及采用的绘制方式将顶点组成图元进行绘制

        GLES20.glDisableVertexAttribArray(mGLAttribPosition);
        GLES20.glDisableVertexAttribArray(mGLAttribTextureCoordinate);
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
    }
}

上面的代码中使用的OpenGlUtils类是封装的一个工具类,主要负责加载纹理id,以及加载着色器代码,这里不详细贴出代码细节(都是一些模板代码),感兴趣的同学待会可以在该文章对应的项目代码查看哦。

最终我们的图片就显示出来啦。

注意事项

  • 需要在GLSurfaceView中设置OpenGL的版本:
setEGLContextClientVersion(2); // 2.0

否则会报类似错误glDrawArrays is called with VERTEX_ARRAY client state disabled!

  • 操作跟GPU相关的接口时需要在GLSurfaceView渲染的线程里否则会报call to OpenGL ES API with no current context。比如获取纹理id不能在界面初始化时,需要在onSurfaceCreated之后

完整代码地址

github.com/1993hzw/Ope…

后话

OpenGL ES的初步介绍就到此为止了,虽然一直想尽量通俗简单地讲解,但整个写下来发现还是要涉及到很多东西,因此有不足的地方还望各位读者指正!其实上面讲的就是GPUImage这个开源库的核心原理,同时目前流行的短视频特效也是有不少涉及到OpenGL处理的,希望此文对大家学习OpenGL有些许帮助吧。

最后,谢谢大家的的支持!!!后面会根据这篇文章的反响,考虑是否需要继续写下一篇关于滤镜的实现(其实主要是通过编写着色器实现)。