OpenGL ES2.0 for Android

今年年初在金立实习时,小组负责的吸色应用需要一个配套的动态壁纸,正好第一版动态壁纸内容很少而且任务很独立,就理所应当的分配给我这个刚入职的实习生了。

当时就了解了动态壁纸的机制和一些实现方法,大致有三种办法:通过surfaceView在canvas上连续绘制,通过GLsurfaceView绘制的OpenGL机制和原生动态壁纸使用的RenderScript。RenderScript好像很多接口没有开放,而且文档与demo少的可怜,OpenGL接触下发现绘制简单的正方形都要花很大的功夫,所以第一版就用了最简单的办法。

可是效率上却出现了问题,滑动桌面会出现掉帧现象。最后只好硬着头皮买了本OpenGL ES2.0的书开始啃,经过几个版本的迭代和慢慢的学习,现在也算是小小的入了个门,趁着还没有忘光,赶紧记录下来。

OpenGL ES2.0概述

OpenGL ES(OpenGL for Embedded Systems)是OpenGL三维图形API的子集,针对手机、PDA和游戏主机等嵌入式设备而设计。OpenGL ES 1.0针对固定管线硬件的,OpenGL ES 2.0针对可编程管线硬件,可以认为完全是两套API。最新3.0版本也在Android4.3得到了支持,从源码上看完全扩展自2.0,GLSL语言有一些变化,暂不研究。

渲染管线:渲染管线也称为渲染流水线,是显示芯片内部处理图形信号相互独立的的并行处理单元。大意就是在GPU中经过一系列的处理后生成显示在屏幕上的画面。

es1.0渲染管线 es2.0渲染管线
两个版本渲染管线的区别

OpenGL ES2.0与1.0最大的区别就是引入了可编程渲染管线,其中的顶点着色器和片元着色器代替了以前的变换,光照,纹理,颜色求和等,需要自己编程实现,大大提升灵活性。顾名思义顶点着色器处理多边形的顶点,片元着色器则处理多边形内每个片元,类似于像素点。着色器中使用的是着色语言GLSL,具有跨平台的特性,虽然OpenGL与OpenGL ES的着色语言有一点区别,但android,ios和web还是可以通用的。

着色语言

着色语言源自C语言,提供了丰富的原生类型,如向量、矩阵等,还有大量的内建函数,这在处理3D图形时可以更加高效、易用。这里说的是OpenGL ES2.0的GLSL语言,3.0有了不少的扩展与改变,不过升级应该是比较容易的。2.0与3.0的区别可以从官方的快速参考卡片查看:

OpenGL ES2.0 OpenGL ES3.0

基本数据类型

  • 标量 GLSL中的标量含布尔、int和float,声明方法也跟一般的语言相似:

    bool b;
    int a = 15;
    int b = 0x3D;
    float f;
    float j, k = 2.56, l;
    
  • 向量 由2,3或4维标量组成:

    向量类型 说明 向量类型 说明
    vec2 包含两个浮点的向量 ivec2 包含两个整数的向量
    vec3 包含三个浮点的向量 bvec2 包含两个布尔的向量
    vec4 包含四个浮点的向量    

    访问向量的某个分量可以通过数组下标v[0]访问,起始索引为0。也可以通过.+分量名来访问,根据目的GLSL中有三套分量名,分别为颜色r,g,b,a,坐标x,y,z,w和纹理坐标s,t,p,q,使用的时候三套分量名是相同作用的,只要同时使用时,保证为同一套即可:

    aColor.r = 0.6;
    aPosition.y = 2;
    aTexture.t = 0.65;
    aColor.xyz = vec3(0.5, 0.2, 0.6);
    
  • 矩阵 在3D场景中,矩阵是十分重要的,平移,旋转或者缩放都是靠矩阵运行实现的。所以GLSL中原生支持矩阵类型和相应的矩阵计算。

    矩阵类型 说明
    mat2 2*2浮点矩阵
    mat3 3*3浮点矩阵
    mat4 4*4浮点矩阵

    矩阵可以看成由多个列向量组成,类似于二维数组,通过m[0]访问第一列向量,通过m[0][0]访问第一行第一列的值。

    从数学上看,矩阵可以看成多个列向量或者多个行向量组成,虽然两种选择功能相 同,但是后续的变换计算是有所不同的。GLSL中是列向量,所以在做变换时,变换 矩阵要左乘上位置坐标。

  • 采样器 一种特殊的基本数据类型,专门用来进行纹理采样的相关操作,我的理解采样器就是是一幅或一套纹理的引用,其值由宿主程序传入(Android即为Java):

    采样器类型 说明
    sampler2D 用于访问二维纹理
    sampler3D 用于访问三维纹理
    samplerCube 用于访问立方体贴图纹理
  • 结构体 类似于C语言中的结构体,声明方式同样使用struct关键字:

    struct vertex {
        vec3 position;
        vec3 color;
    }
    
    vertex v;   //声明vertex类型变量
    
  • 数组 跟C语言不太一样的是,数组声明的时候可以不指定长度,使用数组时也不用关心越界问题,编译器会自动创建适当大小的数组:

    vec3 position[];              //声明的时候可以不指定长度
    vec3 position[3]              //再次声明并制定长度后就不能再声明了
    position[3] = vec3(1.0, 1.0, 1.0);  //数组长度增长到4
    position[4] = vec3(1.0, 1.0, 1.0);  //数组长度增长到5
    
  • 空类型 使用void表示,用来声明不含返回值的函数,main函数就是例子。

基本语法

大部分语法都是跟C语言类似的,像变量声明、初始化,变量的作用域,运算符,if/else、for、while流程控制等都几乎是一样的,主要提一下不一样的地方。

  • 系统许多的内建变量都是以_gl为开头的,所以用户自定义的变量不要使用这个做开头。
  • 向量、矩阵初始化时各个元素既可以是字面常量也可以是变量:

    float a = 1.0;
    vec2 va = vec2(1.0, 1.0);              
    vec3 vb = vec3(v2, a);
    mat2 ma = mat2(1.0, 1.0, 1.0, a); 
    mat2 mb = mat2(va, 1.0, a);   
    mat2 mc = mat2(1.0);           
    
  • 通过.可以混合选择向量的分量,并且可以重新排列:

    vec4 color = vec4(0.2, 0.2, 0.3, 0.2);
    vec3 temp1 = color.agr;
    vec4 temp2 = color.aagg;
    vec3 temp3;
    temp3.yxz = color.rgr;         
    

    同时使用时必须使用同一套分量名,像color.xa就是错误的用法。左值混合选择时不能有重复的分量名,但顺序可以改变,右值则可以任意搭配。

  • GLSL中对类型的匹配十分严格,没有类型自动转换的功能,左值右值的类型必须完全相同,类型的强制转换需要通过类似于构造函数的方式:

    float f = 1;          // 类型不匹配,会产生编译错误
    float f1 = 1.0;       
    bool b = bool(f1);    // 浮点转换为布尔,该构造函数会把非0值转换为false,0值转换为true
    float f2 = float(b);  // 布尔转换为浮点,会把true转换为1.0,false转换为0.0        
    
  • 函数参数的修饰符:缺省为in修饰符,修饰输入参数,相当于在函数体中使用的是参数的拷贝,跟一般的函数参数一样;out修饰符,修饰输出参数,类似于传入了指针或引用,在函数体给变量赋值会改变该变量的值,不能是字面常量;inout具有输入输出两种功能。

  • 指定变量的精度:通过lowpmediumphighp作为限定符修饰变量就可以指定变量的精度。同一个着色器中所有相关类型都用一个精度可以在着色器第一句使用precision <精度> <类型>

限定符

要想正确使用GLSL,限定符的意义和所修饰的变量如何使用是很重要的:

限定符类型 使用限制 说明
attribute 只能用于顶点着色器,只能修饰浮点标量、浮点向量以及矩阵 ,只读 一般用于每个顶点不同的量,如顶点位置、颜色等
uniform 是只读的,使用范围和修饰变量没有限制 一般用于对同一组顶点有相同的量,如变换矩阵、光源位置等
varying 片元着色器的varying变量为只读,可以修饰浮点标量、浮点向量、矩阵以及包含这些元素的数组 用于从顶点着色器传递到片元着色器的量
const 无限制 用于声明常量

主要使用方法在下一章节再谈。

  • attribute限定符: 属性修饰符,修饰的变量用来接收渲染管线传递进顶点着色器的当前顶点的各种属性值,顶点坐标、法向量、颜色、纹理坐标等。其变量的值由宿主程序批量传入,管线经过基本处理(可见渲染管线图的第一步)再传入顶点着色器。有多少个顶点就会执行几次顶点着色器,各个顶点的处理在GPU中都是并发的,大大提升渲染速,一些使用上的限制见上表。

  • uniform限定符: 一致变量限定符,一致变量指对于同一组顶点都是相同的量。其值也是由宿主程序传入,使用没有什么限制,也比较好理解,是用户向GLSL传递自己数据最常用的方法。可以指变换矩阵、光源坐标、采样器等。

  • varying限定符: 易变变量用于沟通顶点着色器与片元着色器,可以把顶点着色器中的值传入到片元着色器中。顶点与片元的数量明显不相同的,这里易变变量的工作原理用到了插值,每个片元受所有顶点的影响。在OpenGL ES中,我接触到的只是简单的线性插值。举个简单的例子就是知道(0,0),(1,0)两个顶点坐标,我们会理所应当的知道这个线段上所有点(片元)的坐标,这就是通过两个顶点的值以及比例进行的线性插值,三角形或者多边形也是这个道理。

在3.0的GLSL中,废弃了attribute和varying,进而使用in和out限定符代替,attribute 和片元着色器中varying使用in代替,顶点着色器中varying使用out代替。

内建变量

不需要声明即可直接使用,分为输出变量和输入变量,输出变量可以在着色器赋值,进而传入到渲染管线中,输入变量为只读变量,常用的内建变量并不多:

内建变量 变量类型 使用位置 说明
gl_Position vec4,输出 顶点着色器 把经过变换的点写入该变量,传入渲染管线中进行后续处理,最常用
gl_PointSize float,输出 顶点着色器 缺省值为1,单位为像素。点的大小,只有绘制方式为点的时候才有意义
gl_FragCoord vec4,输入 片元着色器 该片元在窗口中的位置,单位为像素
gl_FrontFacing bool,输入 片元着色器 该片元的朝向,正面为true。朝向是由三角形的卷绕方向决定的。
gl_FragColor vec4,输出 片元着色器 写入该片元的颜色值,传入渲染管线进行后续处理
gl_FragData vec4数组,输出 片元着色器 与gl_FragColor类似,不过写入时要给出下标

内置函数

GLSL内置了大量内置函数,通常都是以最优方式实现,有的甚至直接硬件支持,可以从上面的快速参考卡片中查看。包含大量数学函数:三角函数、指数函数、几何函数、向量矩阵函数等,这些可以自己开发编写,但是往往效率低下,使用内置函数可以更高效更方便的做图像处理。还有一些没有办法自己编写的函数,如纹理采样函数:

vec4 texture2D(sampler2D sampler, vec2 coord); 

通过采样器指定到一幅纹理,在通过坐标获得某一位置的颜色值,这个函数在需要进行纹理贴图时会用到。

基本程序结构

一套program含一个顶点着色器和一个片元着色器,最基础的不含复杂图形计算的着色器其实是很简单的:

顶点着色器:

uniform mat4 u_Matrix; // 最终变换矩阵

attribute vec3 a_Position; // 顶点坐标,由宿主程序传进
attribute vec2 a_TextureCoordinates; // 纹理坐标,由宿主程序传入
varying vec2 v_TextureCoord; // 易变变量,把纹理坐标传递给片元着色器

void main() //每个着色器必须有一个main函数
{                           
    v_TextureCoord = a_TextureCoordinates; // 把宿主程序传入的纹理坐标写入易变变量
    gl_Position = u_Matrix * vec4(a_Position,1); // 求出变换后顶点的坐标位置,写入gl_Position,传入渲染管线进行后续处理
}    

片元着色器:

precision mediump float; // 浮点的精度
uniform sampler2D u_TextureUnit; // 采样器
varying vec2 v_TextureCoord; // 易变变量,由顶点着色器传入的纹理坐标

void main() {
    gl_FragColor = texture2D(u_TextureUnit, v_TextureCoord); // 内置的采样函数,通过采样器指定的纹理和纹理坐标,得到当前的片元颜色写入gl_FragColor,传入渲染管线进行后续处理
}

基本变换的数学知识

齐次坐标

前面也提到,3D的运动需要运用到矩阵的知识,基本变换是通过将表示点的坐标的向量与特定的变换矩阵相乘。基于矩阵的变换时,三维空间的点要表示成齐次坐标的形式,即把三维向量由四维向量表示,未变换时,多出来的分量w一般为1。相应的变换矩阵也是4*4矩阵,这样在齐次坐标下就很容易对三维的点进行各种变换。我们也可以理解为各个变换其实是改变了坐标系。

基本变换矩阵

平移变换的基本矩阵: \(M=\begin{pmatrix} 1 & 0 & 0 & m_x \\\\ 0 & 1 & 0 & m_y \\\\ 0 & 0 & 1 & m_z \\\\ 0 & 0 & 0 & 1 \end{pmatrix}\)

缩放变换的基本矩阵: \(M=\begin{pmatrix} S_x & 0 & 0 & 0 \\\\ 0 & S_y & 0 & 0 \\\\ 0 & 0 & S_z & 0 \\\\ 0 & 0 & 0 & 1 \end{pmatrix}\)

旋转矩阵较复杂,不过使用起来跟其他变换没有什么区别: \(M=\begin{pmatrix} cos\\theta + (1 - cos\\theta)u_x^2 & (1 - cos\\theta){u_y}{u_x} - sin\\theta{u_y} & (1 - cos\\theta){u_z}{u_y} + sin\\theta{u_y} & 0 \\\\ (1 - cos\\theta){u_x}{u_y} + sin\\theta{u_z} & cos\\theta + (1 - cos\\theta)u_y^2 & (1 - cos\\theta){u_z}{u_y} - sin\\theta{u_x} & 0 \\\\ (1 - cos\\theta){u_x}{u_z} - sin\\theta{u_y} & (1 - cos\\theta){u_y}{u_z} + sin\\theta{u_x} & cos\\theta + (1 - cos\\theta)u_z^2 & 0 \\\\ 0 & 0 & 0 & 1 \end{pmatrix}\)

要想得到变换后的点,只要在点坐标的纵向量左乘上变换矩阵即可,要想连续做多个变换,继续级联左乘变换矩阵:

\[MP=\begin{pmatrix} 1 & 0 & 0 & m_x \\\\ 0 & 1 & 0 & m_y \\\\ 0 & 0 & 1 & m_z \\\\ 0 & 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} P_x \\\\ P_y \\\\ P_z \\\\ 1 \end{pmatrix}= \begin{pmatrix} P_x + m_x\\\\ P_y + m_y\\\\ P_z + m_z\\\\ 1 \end{pmatrix}\]

摄像机观察矩阵

根据摄像机位置坐标,目标点坐标,up向量九参数生成的矩阵。缺省情况其实就是乘以单位矩阵,也就是原点为相机位置,z轴负方向为视线,y轴正方向为up。可以理解乘以观察矩阵后,坐标系就会转换为以摄像机为原点的特定坐标系。观察矩阵不同,最后物体呈现的画面也会截然不同。摄像机坐标系与九参数的对应关系:目标点减去摄像机位置得到的视线向量为z轴负方向,视线向量与up向量叉乘后得到x轴正方向。

投影矩阵

投影矩阵可以根据近平面左右边位置,上下边位置,近平面远平面与视点的距离六参数生成。投影矩阵会定义视景体,在视景体中的物体才会被看到,显示在最后的画面中。投影矩阵分为正交矩阵和透视矩阵,其中透视矩阵会产生与实际情况相符的“近大远小”的效果。一般情况使用时,近平面应该关于视线中心对称,这样的矩阵形式会简化不少。

正交矩阵简化版: \(M=\begin{pmatrix} \frac{2}{w} & 0 & 0 & 0\\\\ 0 & \frac{2}{h} & 0 & 0 \\\\ 0 & 0 & \frac{1}{f - n} & -\frac{n}{f - n} \\\\ 0 & 0 & 0 & 1 \end{pmatrix}\)

透视矩阵简化版: \(M=\begin{pmatrix} \frac{2n}{w} & 0 & 0 & 0\\\\ 0 & \frac{2n}{h} & 0 & 0 \\\\ 0 & 0 & \frac{f}{f - n} & -\frac{fn}{f - n} \\\\ 0 & 0 & 1 & 0 \end{pmatrix}\)

其中w,h,n,f分别为近平面宽度,高度,近平面与视点的距离,远平面与视点的距离。

实现3D运动的三角形

现在就需要在Android端进行开发了,效果如图:

3D运动的三角形

demo地址

整体的变换过程

在敲代码前,还是要先了解在OpenGL ES中,一个3D物体是经过一系列怎么的变换,最终产生显示在屏幕上的2D画面。所有变换的完整流程:

变换流程

物体空间:要绘制3D物体所在的原始坐标系代表的空间。 世界空间:3D物体最终要摆放位置坐标对应的坐标系代表的空间。 摄像机空间:以观察点为原点的特定坐标系代表的空间,其中z轴负方向为视线方向,x轴正方向为视线与up方向的叉乘。 剪裁空间:摄像机空间中只有在视景体内的物体才能被观察到,这部分就是剪裁空间。 标准设备空间:对剪裁空间进行透视除法,使三个坐标均在-1~1之间。 实际窗口空间:设备屏幕上的一块矩形区域,以像素为单位。

生成渲染器Renderer,并重写三个回调方法

首先别忘了加权限:

<uses-feature android:glEsVersion="0x00020000" android:required="true" />
  • 创建GlSurfaceView实例,并设置相关参数:

    mGlSurfaceView = new MySurfaceView(this);
    
    setContentView(R.layout.main_layout);
    
    public class MySurfaceView extends GLSurfaceView {
    
        public MySurfaceView(Context context) {
            super(context);
            setEGLContextClientVersion(2); // 设置版本为ES 2.0
            setEGLConfigChooser(new MSConfigChooser()); // 配置设置,包括颜色模式、抗锯齿等
            setPreserveEGLContextOnPause(true); // 到后台后保留context,不释放资源
            setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY); // 设置渲染模式,CONTINUOUSLY模式会自动刷新画面,WHEN_DIRTY模式需要主动调用刷新
        }
    }
    
  • 为GlSurfaceView设置渲染器Renderer并重写三个回调方法onSurfaceCreated()onSurfaceChanged()onDrawFrame()

    mRenderer = new MyRenderer(this);
    setRenderer(mRenderer); // GlSurfaceView的方法
    
    public class MyRenderer implements GLSurfaceView.Renderer {
        @Override
        public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
            // 初始化,链接着色器生成program等
        }
    
        @Override
        public void onSurfaceChanged(GL10 gl10, int width, int height) {
            GLES20.glViewport(0, 0, width, height);
    
            // 设置投影矩阵,相机位置等
        }
    
        @Override
        public void onDrawFrame(GL10 gl10) {
            GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
            GLES20.glClearColor(0, 0 , 0 ,1);
                
            // 绘制的相关代码
        }
    }
    

链接顶点片元着色器生成program

onSurfaceCreated()方法中可以做一些初始化的事情,包括把我们刚才写的着色器读取、编译、链接,链接成功得到相应的program后,我们就可以向渲染管线批量传入数据。封装好相应工具类后,获得program我们只需要一行代码:

mProgram = ShaderHelper.buildProgram("顶点着色器代码段", "片元着色器代码段"); // 代码段的读取也要封装成工具类
public static int buildProgram(String vertexShaderSource,
    String fragmentShaderSource) {
    int program;

    int vertexShader = compileVertexShader(vertexShaderSource); // 编译着色器
    int fragmentShader = compileFragmentShader(fragmentShaderSource);
    program = linkProgram(vertexShader, fragmentShader); // 链接

    return program;
}

private static int compileShader(int type, String shaderCode) {
    final int shaderObjectId = GLES20.glCreateShader(type); // type为顶点或者片元

    GLES20.glShaderSource(shaderObjectId, shaderCode); // 还应该有相应的出错判断
    GLES20.glCompileShader(shaderObjectId);

    return shaderObjectId;
}

public static int linkProgram(int vertexShaderId, int fragmentShaderId) {
    final int programObjectId = glCreateProgram();

    GLES20.glAttachShader(programObjectId, vertexShaderId);
    GLES20.glAttachShader(programObjectId, fragmentShaderId);
    GLES20.glLinkProgram(programObjectId);

    return programObjectId;
}

设置投影矩阵和摄像机矩阵

onSurfaceChanged()当画面变化时回调,比如第一次显示画面、横屏转竖屏等情况。所以在这里我们需要重新设置视口大小、投影矩阵和相机位置:

Matrix.setLookAtM(  // 生成观察矩阵原生api
    mVMatrix,       // 存储生成的矩阵元素的float数组
    0,              // 起始偏移量
    cx, cy, cz,     // 摄像机位置
    tx, ty, tz,     // 目标点位置
    upx, upy, upz); // up向量
Matrix.frustumM(    // 生成透视矩阵原生api,正交矩阵类似
    mProjMatrix,    // 存储生成的矩阵元素的float数组
    0,              // 起始偏移量
    left, right, bottom, top, // near面的上下左右   
    near, far);     // near,far面距视点的距离
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
    GLES20.glViewport(0, 0, width, height); // 视口就是画面要显示的大小,原点在左下角

    float ratio = (float) width / height; // 近平面的宽高比要与视口相同,否则画面就会被拉伸失真
    MatrixState.setProjectFrustum(-ratio, ratio, -1, 1, 2, 2000); 
    setCameraPostion();
}

获得GLSL变量引用

onDrawFrame()就是绘制每一帧的回调了,变化和绘制的相关代码都会写在这里。首先要想向渲染管线传入数据,我们要先获得GLSL中相关变量的引用,这就用到了之前链接成功的program。当然获得引用的过程可以在链接的时候就完成,并把相应传入数据的方法封装在一个类中:

 //通过program和变量名获得变量引用
int uMatrixLocation = GLES20.glGetUniformLocation(mProgram, "u_Matrix");
int aPositionLocation = GLES20.glGetAttribLocation(mProgram, "a_Position");

数据批量传入渲染管线

获得引用后就要把我们要传入的数据(一般为float数组)转换为底层可识别的FloatBuffer

public static FloatBuffer getFloatBuffer(float[] vertexData) {
    FloatBuffer floatBuffer = ByteBuffer
            .allocateDirect(vertexData.length * 4)
            .order(ByteOrder.nativeOrder())  
            .asFloatBuffer()
            .put(vertexData);

    return floatBuffer;
}

要使用某套program时要先GLES20.glUseProgram(mProgram);,激活该套program。之后就可以通过变量的引用和转换后的FloatBuffer传入到渲染管线,不过传入uniform变量比较简单:

// 传入attribute变量
protected void setVertexAttribPointer(FloatBuffer floatBuffer,
        int attributeLocation, int componentCount) {
    floatBuffer.position(0);
    glVertexAttribPointer(attributeLocation, componentCount, GL_FLOAT,
            false, componentCount * 4, floatBuffer);
    glEnableVertexAttribArray(attributeLocation);
}
// 传入uniform变量
public void setMatrix(float[] matrix) {
    GLES20.glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0)
}

得到最终变换矩阵过程:

// 先做基本变换,再转换为摄像机矩阵,最后乘投影矩阵
public static float[] getFinalMatrix() {
    Matrix.multiplyMM(mMVPMatrix, 0, mVMatrix, 0, currMatrix, 0);
    Matrix.multiplyMM(mMVPMatrix, 0, mProjMatrix, 0, mMVPMatrix, 0);
    return mMVPMatrix.clone();
}

开始绘制

把每个顶点的数据传入渲染管线后就可以绘制了:

GLES20.glDrawArrays(GL_TRIANGLES, 0,  mVertexes.length / 3); // 三个参数分别是:绘制方式,绘制起始点,共绘制多少个点

在OpenGL ES中最多只能画三角形,所以不管多么复杂的图形都是由三角形组成的,glDrawArrays参数中绘制方式也只有点、线、三角形:

绘制方式 绘制方式
绘制方式

odroid搭建Android实验环境过程

Published on September 18, 2018

Java并发编程知识总结

Published on August 26, 2018