OpenGL ES 2.0 —— 2D 图形绘制

 

前言

经过前面 GL 基础知识的学习, 这里就开始真正的实战了, OpenGL ES2.0 中关于绘制类型主要有如下分类

绘制类型 绘制方式
GL_POINTS 将传入的顶点坐标作为单独的点绘制
GL_LINES 将传入的坐标作为单独线条绘制,ABCDEFG六个顶点,绘制AB、CD、EF三条线
GL_LINE_STRIP 将传入的顶点作为折线绘制,ABCD四个顶点,绘制AB、BC、CD三条线
GL_LINE_LOOP 将传入的顶点作为闭合折线绘制,ABCD四个顶点,绘制AB、BC、CD、DA四条线。
GL_TRIANGLES 将传入的顶点作为单独的三角形绘制,ABCDEF绘制ABC,DEF两个三角形
GL_TRIANGLE_STRIP 将传入的顶点作为三角条带绘制,ABCDEF绘制ABC,BCD,CDE,DEF四个三角形
GL_TRIANGLE_FAN 将传入的顶点作为扇面绘制,ABCDEF绘制ABC、ACD、ADE、AEF四个三角形

这里我们就实战一下 2D 图形的绘制

一. 绘制线段

一) 顶点坐标和颜色

public class Line implements GLSurfaceView.Renderer {

    /**
     * 顶点坐标(世界坐标系)
     */
    private static final float[] LINE_COORS = {
            0.0f, 0.5f, 0.0f,   // start
            0.0f, -0.5f, 0.0f,  // end
    };

    /**
     * 图形颜色
     */
    private static final float[] LINE_COLOR = {
            0.63671875f,   // red
            0.76953125f,   // green
            0.22265625f,   // blue
            1.0f           // alpha
    };
    
}

可以看到我们声明了 Java 的顶点坐标和颜色, 不过 这个 java 类型的 float[] 是无法直接被 GL 使用的 , 因此我们需要在 Native 开辟一块空间, 把这个 float[] 中的数据拷贝进去

public class Line implements GLSurfaceView.Renderer {
    
    private final FloatBuffer mVertexBuffer;                  // 存储顶点数据的缓冲区域
  
    public Line() {
        // 在 Native 开辟一块空间, 用于存储顶点坐标
        mVertexBuffer = ByteBuffer.allocateDirect(LINE_COORS.length * 4)
                .order(ByteOrder.nativeOrder())    // use the device hardware's native byte order
                .asFloatBuffer();
        mVertexBuffer.put(LINE_COORS);             // add the coordinates to the FloatBuffer
        mVertexBuffer.position(0);                 // set the buffer to read the first coordinate
    }
    
}

在构造方法中, 创建了一个 FloatBuffer, 因为 GL 无法直接使用 Java 的数据, 因此需要将 java 数据 put 到 Native 中, 方便后续使用

接下来看看着色器代码的编写

二) 加载着色器代码

1. glsl 定义

public class Line implements GLSurfaceView.Renderer {
    
   
    /**
     * Vertex Shader - 支持矩阵变换的顶点着色器
     */
    private static final String VERTEX_SHADER_CODE =
            "attribute vec4 aPosition;\n" +
                    "uniform mat4 uMatrix;\n" +
                    "void main() {\n" +
                    // 根据 Clip Matrix, 对顶点进行变换, 将结果输出到图元装配
                    "    gl_Position = uMatrix * aPosition;\n" +
                    "}";
    /**
     * Fragment Shader - 简单的片元着色器
     */
    private static final String FRAGMENT_SHADER_CODE =
            "precision mediump float;" +
                    "uniform vec4 uColor;" +
                    "void main() {" +
                    "  gl_FragColor = uColor;" +
                    "}";
}
  • 顶点着色器
    • aPosition: 描述一个顶点坐标位置
      • 是一个 vec4 类型的向量, 限定符为 attribute, 需要从 java 代码中传入
    • uMatrix: 描述所有顶点的变换矩阵
      • 是一个 mat4 类型的矩阵, 限定符为 uniform, 需要从 java 代码传入
    • gl_Position: GL 内建输出变量, 将矩阵变换后的顶点位置输出到渲染管线的后续步骤中
  • 片元着色器
    • uColor: 描述当前片元的颜色, 由 java 传入
    • gl_FragColor: GL 内建输出变量, 描述当前片元的颜色值

2. glsl 加载

public class Line implements GLSurfaceView.Renderer {

    private int mProgram;
    
    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        mProgram = createProgram(VERTEX_SHADER_CODE, FRAGMENT_SHADER_CODE);
    }

    /**
     * 创建一个 OpenGL 程序
     *
     * @param vertexSource   顶点着色器源码
     * @param fragmentSource 片元着色器源码
     */
    public static int createProgram(String vertexSource, String fragmentSource) {
        // 分别加载创建着色器
        int vertexShaderId = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource);
        int fragmentShaderId = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
        if (vertexShaderId != 0 && fragmentShaderId != 0) {
            // 创建 OpenGL 程序 ID
            int programId = GLES20.glCreateProgram();
            if (programId == 0) {
                return 0;
            }
            // 链接上 顶点着色器
            GLES20.glAttachShader(programId, vertexShaderId);
            // 链接上 片段着色器
            GLES20.glAttachShader(programId, fragmentShaderId);
            // 链接 OpenGL 程序
            GLES20.glLinkProgram(programId);
            // 验证链接结果是否失败
            int[] status = new int[1];
            GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, status, 0);
            if (status[0] != GLES20.GL_TRUE) {
                // 失败后删除这个 OpenGL 程序
                GLES20.glDeleteProgram(programId);
                return 0;
            }
            return programId;
        }
        return 0;
    }
    
    /**
     * 编译着色器
     *
     * @param shaderType 着色器的类型
     * @param source     资源源代码
     */
    public static int compileShader(int shaderType, String source) {
        // 创建着色器 ID
        int shaderId = GLES20.glCreateShader(shaderType);
        if (shaderId != 0) {
            // 1. 将着色器 ID 和着色器程序内容关联
            GLES20.glShaderSource(shaderId, source);
            // 2. 编译着色器
            GLES20.glCompileShader(shaderId);
            // 3. 验证编译结果
            int[] status = new int[1];
            GLES20.glGetShaderiv(shaderId, GLES20.GL_COMPILE_STATUS, status, 0);
            if (status[0] != GLES20.GL_TRUE) {
                // 编译失败删除这个着色器 id
                GLES20.glDeleteShader(shaderId);
                return 0;
            }
        }
        return shaderId;
    }
    
}

以上是加载 GLSL 的通用代码, 加载之后便可以获取到 glsl 程序 id 了

三) 坐标系统转换

通过上面坐标的定义我们知道, 定义的坐标系为世界坐标, 因此我们需要将其转变为裁剪坐标, 才可以给顶点坐标系中的 uMatrix 赋值

public class Line implements GLSurfaceView.Renderer {
    
    /**
     * 描述变化的矩阵
     */
    private final float[] mViewMatrix = new float[16];        // 视图矩阵
    private final float[] mProjectionMatrix = new float[16];  // 投影矩阵
    private final float[] mClipMatrix = new float[16];        // 裁剪矩阵
    
    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        GLES20.glViewport(0, 0, width, height);
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        GLES20.glClearColor(1, 1f, 1f, 1.0f);
        // 1. 通过模型矩阵, 将坐标系从 局部空间 -> 世界空间
        // ...... 我们定义的初始坐标系便是世界坐标系
        // 2. 通过视图矩阵, 将坐标系从 世界空间 -> 观察空间
        Matrix.setLookAtM(
                mViewMatrix, 0,
                0, 0, 7.0f,              // 描述眼睛位置
                0f, 0f, 0f,              // 描述眼睛看向的位置
                0f, 1.0f, 0.0f           // 描述视线的垂线
        );
        // 3. 通过透视投影矩阵, 将坐标系从 观察空间 -> 裁剪空间
        float aspect = (float) width / height;
        Matrix.perspectiveM(
                mProjectionMatrix, 0,
                30,                      // 视角度数
                aspect,                  // 平面的宽高比
                3,                       // 近平面距离
                7                        // 远平面距离
        );
        // 4. 裁剪矩阵 = 投影矩阵 * 视图矩阵 * 模型矩阵
        Matrix.multiplyMM(mClipMatrix, 0, mProjectionMatrix,
                0, mViewMatrix, 0);
    }
    
}

可以看到矩阵的变换也比较简单, 所有的知识点都是 坐标系统 中描述过的, 关于透视投影的角度, 以及远近平面的距离, 可以自行调整

四) 绘制

public class Line implements GLSurfaceView.Renderer {
    
     @Override
    public void onDrawFrame(GL10 gl) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        GLES20.glClearColor(1, 1f, 1f, 1.0f);

        // 1. 在 OpenGL ES 的环境中添加可执行程序
        GLES20.glUseProgram(mProgram);

        // 2. 设置顶点数据
        int attribHandle = GLES20.glGetAttribLocation(mProgram, "aPosition");
        GLES20.glEnableVertexAttribArray(attribHandle);// 启动顶点属性数组
        GLES20.glVertexAttribPointer(
                attribHandle,
                3,                             // 每个顶点的数量
                GLES20.GL_FLOAT,               // 顶点描述单位
                false,                         // 是否标准化
                3 * 4,                         // 顶点的跨幅, 即一个顶点占用的字节数
                mVertexBuffer                  // 顶点数据
        );
        // 3. 设置矩阵变化
        int matrixHandler = GLES20.glGetUniformLocation(mProgram, "uMatrix");
        GLES20.glUniformMatrix4fv(
                matrixHandler,
                1,
                false,
                mClipMatrix,
                0
        );
        // 4. 设置绘制时的颜色值
        int colorHandle = GLES20.glGetUniformLocation(mProgram, "uColor");
        GLES20.glUniform4fv(
                colorHandle,
                1,                // 颜色的数量
                LINE_COLOR,       // 具体的颜色
                0                 // 数据读取位置
        );
        // 5. 执行绘制
        GLES20.glDrawArrays(
                GLES20.GL_LINES,                             // 绘制类型
                0,                                           // 数据读取位置
                2                                            // 顶点数量
        );
        // 6.关闭可执行程序
        GLES20.glDisableVertexAttribArray(attribHandle);
    }
    
}

好的, 顶点的绘制流程, 主要有三个步骤

  • 设置要使用的 gl 程序
  • 为顶点着色器和片元着色器中的变量赋值
  • 调用 glDrawArrays 进行绘制操作
    • 这里使用的 GLES20.GL_LINES

五) 渲染展示

public class ShapeGLSurfaceView extends GLSurfaceView {

    private GLSurfaceView.Renderer mRenderer;

    public ShapeGLSurfaceView(Context context) {
        this(context, null);
    }

    public ShapeGLSurfaceView(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 设置 GL 版本号
        setEGLContextClientVersion(2);
        mRenderer = new Line();
        setRenderer(mRenderer);
        // 只有当数据改变时才渲染
        setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);
    }
    
}

好的, 可以看到这里调用了 setRenderer 方法为 GLSurfaceView 设置我们自定义的渲染器, 如此一来这个自定义 View 便会使用我们的 Renderer 进行图形渲染了

关于 GLSurfaceView 使用 Renderer 进行图形渲染的原理, 我们到 EGL 环境搭建的章节中再去详细阐述, 后面均使用 GLSurfaceView 进行渲染展示

其线段渲染的效果如下

绘制线段

二. 绘制三角形

有了上面绘制线段的经验, 绘制三角形便是手到擒来了

顶点坐标

public class Triangle implements GLSurfaceView.Renderer {

    /**
     * 顶点坐标(世界坐标系, 近似正三角形)
     */
    private static float[] TRIANGLE_COORS = {
            0.0f, 0.6f, 0.0f,   // top
            -0.5f, -0.3f, 0.0f, // bottom left
            0.5f, -0.3f, 0.0f   // bottom right
    };

}

这里定义了一个正三角形, 坐标系如下所示(忽略 Z 轴)

正三角形

其 glsl 代码和坐标系统的转换与线段一致, 接下来直接看看绘制的过程

绘制三角形

public class Triangle implements GLSurfaceView.Renderer {
    
    @Override
    public void onDrawFrame(GL10 gl) {
        ......
        // 5. 执行绘制
        GLES20.glDrawArrays(
                GLES20.GL_TRIANGLES,                        // 绘制类型
                0,                                          // 数据读取位置
                TRIANGLE_COORS.length / VERTEX_DIMENSION    // 顶点数量
        );
        ......
    }
    
}

可以看到我们只需要更改一下 glDrawArrays 中的绘制类型便可以绘制三角形了, 其绘制结果如下

GL 绘制三角形

三. 绘制矩形

OpenGL ES 是不支持直接绘制矩形的, 因此可以使用两个三角形拼接的方式来实现

顶点定义

public class Rectangle implements GLSurfaceView.Renderer {
    
    /**
     * 描述顶点坐标
     */
    private static final float[] SQUARE_VERTEX = {
            // 左半个三角形
            -0.5f, 0.5f, 0.0f,   // top left
            -0.5f, -0.5f, 0.0f,  // bottom left
            0.5f, -0.5f, 0.0f,   // bottom right
            // 右半个三角形
            -0.5f, 0.5f, 0.0f,   // top left
            0.5f, -0.5f, 0.0f,   // bottom right
            0.5f, 0.5f, 0.0f     // top right
    };
    
}

这里定义了一个正方形, 其坐标系如下(忽略 z 轴)

矩形坐标系

其 glsl 代码和坐标系统的转换与线段一致, 接下来直接看看绘制的过程

绘制

public class Rectangle implements GLSurfaceView.Renderer {
    
    @Override
    public void onDrawFrame(GL10 gl) {
        ......
        // 5. 执行绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 6);
        ......
    }
    
}

好的, 可以看到同样是 GLES20.GL_TRIANGLES, 只不过其绘制的顶点数为 6

GL 绘制矩形

四. 绘制圆形

圆形的绘制比起绘制矩形要相对困难一些, 矩形可以由两个三角形拼成, 圆形我们该如何绘制呢?

从 OpenGL ES2.0 可绘制类型中, 我们可以看到 GL_TRIANGLE_FAN 这样一个属性, 即扇面绘制

  • 将圆形分割成多个扇面, 当分割的粒度足够细时, 便可以得到一个圆形

顶点定义

这里我们以将圆形分割成 8 个三角形来举例

顶点定义

从上图可以看到, 我们将圆形分割成 8 个三角形, 一共出现了 9 个顶点

  • 圆心: O
  • 圆环: A B C D E F G H

我们使用 GL_TRIANGLE_FAN 来进行绘制, 将顶点按照 O A B C D E F G H 压入, 可以绘制出 OAB, OBC, OCD, ODE, OEF, OFG, OGH 这 7 个三角形, 少了一个 OHA, 因此还需要再压入一个 A 顶点, 完成闭合操作

GL 需要的顶点为 O A B C D E F G H A 共 10 个, 因此将圆形分割成 n 个扇面, 则需要 n + 2 个顶点

我们将这个过程还原成代码, 如下所示

public class Circle implements GLSurfaceView.Renderer {
    
    /**
     * 描述圆形扇面的数量
     */
    private static final int SPLIT_NUM = 8;

    /**
     * 描述顶点坐标
     * <p>
     * 圆心 + {@link #SPLIT_NUM} 个三角形({@link #SPLIT_NUM} 个顶点) + 闭合时的顶点
     * 共 {@link #SPLIT_NUM}+2 个顶点
     * <p>
     * 每个顶点为 x, y(忽略 z 坐标)
     */
    private final float[] mCircleVertexes = new float[(SPLIT_NUM + 2) * 2];
    
    public Circle() {
        // 填充圆心
        mCircleVertexes[0] = 0;
        mCircleVertexes[1] = 0;
        // 填充圆环上的顶点
        final float radius = 0.8f;
        final float angle = (float) (2 * Math.PI / SPLIT_NUM);
        for (int i = 0; i < SPLIT_NUM; i++) {
            float curAngle = angle * i;
            // 计算 x 坐标
            mCircleVertexes[2 * i + 2] = (float) (radius * Math.cos(curAngle));
            // 计算 y 坐标
            mCircleVertexes[2 * i + 1 + 2] = (float) (radius * Math.sin(curAngle));
        }
        // 补充最后一个顶点
        mCircleVertexes[mCircleVertexes.length - 2] = (float) (radius * Math.cos(0));
        mCircleVertexes[mCircleVertexes.length - 1] = (float) (radius * Math.sin(0));
        ......
    }
    
}

其 glsl 代码和坐标系统的转换与线段一致, 接下来直接看看绘制的过程

绘制代码

public class Circle implements GLSurfaceView.Renderer {
    
     @Override
    public void onDrawFrame(GL10 gl) {
        ......
        // 2. 设置顶点数据
        int attribHandle = GLES20.glGetAttribLocation(mProgram, "aPosition");
        GLES20.glEnableVertexAttribArray(attribHandle);// 启动顶点属性数组
        GLES20.glVertexAttribPointer(
            attribHandle, 
            2,                         // 因为顶点定义时忽略了 z 轴, 因此每个坐标数据量为 2 
            GLES20.GL_FLOAT, 
            false,
            2 * 4,                    // 同理
            mVertexBuffer
        );
        ......
        // 5. 执行绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN, 0, SPLIT_NUM + 2);
        // 6.关闭可执行程序
        GLES20.glDisableVertexAttribArray(attribHandle);
    }
    
}

好的分割成 8 个扇面的效果如下

分割成 8 个扇页

我们将扇面的数量增加到 180 个效果如下

分割成 180 个扇页

可以看到, 基本上无法分辨出锯齿感, 若是增加到 360/720 显然会更加细腻

总结

通过上面图形的绘制, 对 OpenGL ES 的 2D 绘制有了个基本的了解, 在绘制圆形的同时, 意外的了解到了抗锯齿的原理, 可以说相当有趣了

关于图形的绘制就到这里, 接下来学习一下应用更加广泛的纹理绘制, 这也是自定义相机的必修课程

参考文献