【GoogleSamples】源码研究 - hello-gl2

简单介绍

hello-gl2项目是用来展示如何用jni的方式来使用OpenGL ES 2.0。所谓JNI的方式,是以Java代码为主,在Java代码中调用C++代码去实现功能。与之相对的,可以以C++代码为主,在C++代码中调用Java代码,这种方式笔者称之为NativeActivity方式(因为它需要用到NativeActivity类)。

这两种方式在显示流程上有区别。JNI方式需要在Java中创建上下文,选择表面配置;NativeActivity方式会直接进入C++代码,创建上下文和表面配置的工作都在C++代码中实现(teapots项目就是这种实现方式)。

工程实现思路

使用JNI绘制的流程是:在Activity中创建OpenGL ES显示要使用的context和surface,调用C++代码初始化OpenGL ES(init),然后在绘制函数中调用C++提供的绘制接口。

先看项目的文件结构:


  • GL2JNIActivity:主活动类文件。本项目中只有一个初始化的作用。
  • GL2JNILib:加载共享库的文件。用于导出C++中定义的函数。
  • GL2JNIView:GLSurfaceView类文件,用来显示界面。
  • gl_code.cpp:使用OpenGL ES实现渲染。

我们可以将这个流程分成两部分理解:

  • 第一部分:创建显示要用的context和surface。这一部分的实现是在Java代码中。
  • 第二部分:实现真正的绘制功能。这一部分的实现是在C++代码中。

我们先来看第一部分:创建显示要用的context和surface。

创建显示要用的context和surface

这一步,我们要用到一个非常重要的类GLSurfaceView。它本质上是一个视图,一个专门用来让OpenGL绘制的表面视图。我们知道,APP在主Activity启动的时候都需要设置一个视图(setContextView),平常情况下用普通视图就可以了,但是在使用OpenGL的情况下要使用GLSurfaceView视图。在本项目中,GL2JNIView就继承了GLSurfaceView,用于OpenGL绘制。

知识补完:Activity是一个组件,它用来显示和用户发生交互的界面,创建它的时候都需要创建一个视图(View)作为用户看到的东西。视图可以捕获用户的操作,与用户进行交互。可以将其类比成一个窗口,这有通过窗口才能显示并且与用户产生交互。所以,在Activity的onCreate函数中通常会有这么一行代码:setContentView(mView);

看代码最直观:

// 活动类(GL2JNIActivity文件)
public class GL2JNIActivity extends Activity {
    GL2JNIView mView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate (savedInstanceState);
        mView = new GL2JNIView(getApplication());
        setContentView (mView);
    }
...
}

// 视图(GL2JNIView文件)
public class GL2JNIView extends GLSurfaceView {
    private static String TAG = "GL2JNIView";
    private static final boolean DEBUG = false;

    public GL2JNIView (Context context) {
        super (context);
        init (false, 0, 0);
    }

    public GL2JNIView (Context context, boolean translucent, int depth, int stencil) {
        super (context);
        init (translucent, depth, stencil);
    }

    private void init (boolean translucent, int depth, int stencil) {
        /*  默认情况下,GLSurfaceView()创建一个RGB_565格式的不透明表面
            如果我们需要一个半透明(或透明)表面,我们要在这里改变表面的格式
            使用PixelFormat.TRANSLUCENT枚举值设置表面,是它成为一个32位
            带有alpha通道服务端
         */
        if (translucent) {
            this.getHolder ().setFormat (PixelFormat.TRANSLUCENT);
        }

        /*
            需要一个context工厂来实现2.0的渲染
            ContextFactory类在下面定义
            这个函数必须在setRenderer()函数之前调用
            如果不用这个函数,默认的context是不共享的,而且没有属性列表
            函数声明:void setEGLContextFactory (GLSurfaceView.EGLContextFactory factory)
         */
        setEGLContextFactory (new ContextFactory());

        /*
            我们需要选择一个EGLConfig来匹配我们生成的表面格式。这一步操作
            会在我们的自定义配置选择器中完成。查看下面定义的ConfigChooser类。
         */
        setEGLConfigChooser (translucent?
                             new ConfigChooser (8, 8, 8, 8, depth, stencil) :
                             new ConfigChooser (5, 6, 5, 0, depth, stencil));

        /* 设置渲染器 */
        setRenderer (new Renderer());
    }
...
}

Activity在创建的时候就生成了一个视图。这个视图就是继承自GLSurfaceView的GL2JNIView类。视图在初始化的时候主要做了3件事:

  • 1、创建了一个上下文工厂给EGL
  • 2、创建了一个配置选择器给EGL
  • 3、设置渲染器(这是必备的一步)

这里出现了一个新名词:EGL。在Android中进行OpenGL开发,我们总绕不开EGL。那么什么是EGL呢?

官方的解释是:

EGL is an interface between Khronos rendering APIs such as OpenGL ES or OpenVG and the underlying native platform window system.
It handles graphics context management, surface/buffer binding, and rendering synchronization and enables high-performance, accelerated, mixed-mode 2D and 3D rendering using other Khronos APIs.

翻译成人话就是:

EGL是OpenGL ES和本地窗口系统之间的接口。它负责处理图形上下文管理,表面/缓存绑定,同步渲染,高性能、加速、混合模式2D和3D渲染。这么多乱七八糟的功能说的云里雾里的,不容易理解,打个比方吧,OpenGL ES就像是一个画家,它只会画其他啥都不管。EGL就像是他的管家,要帮他安排什么时候画,在哪个画室画(类似上下文),画在哪张纸上(表面/缓存绑定),用一支好的画笔画(高性能)等等。

就因为OpenGL除了画啥都不管,其他事情只好交给EGL来做,EGL就是为了这个目的而存在的。

上下文工厂的作用就是创建和删除上下文:

   // 上下文工厂
    private static class ContextFactory implements GLSurfaceView.EGLContextFactory {
        private static int EGL_CONTEXT_CLIENT_VERSION = 0x3098;
        public EGLContext createContext (EGL10 egl, EGLDisplay display, EGLConfig eglConfig) {
            Log.w (TAG, "createOpenGL ES 2.0 context");
            checkEglError ("Before eglCreateContext", egl);
            /**
             * 属性列表指定的格式就是这样,必须以EGL_CONTEXT_CLIENT_VERSION开头,后面紧跟一个整数值表示OpenGL ES的版本号
             */
            int[] attrib_list = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL10.EGL_NONE};
            /**
             * 这是一个重度函数,它所做的事情非常重要
             * 函数的参数:
             * EGLDisplay display,  关联的显示器
             * EGLConfig config,    指定egl帧缓存的配置,这是上下文可以获取到的资源
             * EGLContext share_context,   要共享缓存给哪个EGL上下文。如果设置EGL_NO_CONTEXT表示要共享给谁
             * EGLint const * attrib_list   指定创建EGL所需的属性。只有EGL_CONTEXT_CLIENT_VERSION可以指定
             * eglCreateContext为当前渲染API创建了一个EGL渲染上下文。当前渲染API就是eglBindAPI绑定的东西。
             * 函数的返回值是一个上下文的句柄。这个上下文就可以用来渲染EGL绘制表面。如果创建失败,函数会
             * 返回一个EGL_NO_CONTEXT。
             */
            EGLContext context = egl.eglCreateContext(display, eglConfig, EGL10.EGL_NO_CONTEXT, attrib_list);
            checkEglError("After eglContextContext", egl);
            return context;
        }

        public void destroyContext (EGL10 egl, EGLDisplay display, EGLContext context) {
            egl.eglDestroyContext (display, context);
        }
    }

笔者在阅读代码的时候做了很多注释,这些注释帮助我更好地理解函数的作用,代码的功能,相信有很多读者也是这么做的。工厂函数中最重要的一行代码就是egl.eglCreateContexxt(display, eglConfig, EGL10.EGL_NO_CONTEXT, attrib_list)。它用来创建一个符合我们要求的上下文。该函数的具体参数请看代码中的注释,要是注释看的还不尽兴,官方文档肯定能满足你的要求了。

ConfigChooser类要实现选择一个需要配置的功能,这个配置指的是帧缓存的配置(笔者将其理解为表面配置)。首先需要人为确定要什么属性,然后查询硬件支持这些属性的配置有哪些,根据返回的配置,选择一个合适的使用:

private static class ConfigChooser implements GLSurfaceView.EGLConfigChooser {
        public ConfigChooser (int r, int g, int b, int a, int depth, int stencil){
            mRedSize = r;
            mGreenSize = g;
            mBlueSize = b;
            mAlphaSize = a;
            mDepthSize = depth;
            mStencilSize = stencil;
        }

        /*
            EGL 配置指定,被用于2.0渲染
            我们使用最小的尺寸(4位)给R/G/B,但是会在chooseConfig()中提供真实的匹配
         */
        private static int EGL_OPENGL_ES2_BIT = 4;
        private static int[] s_configAttribs2 = {
                EGL10.EGL_RED_SIZE, 4,
                EGL10.EGL_GREEN_SIZE , 4,
                EGL10.EGL_BLUE_SIZE, 4,
                EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
                EGL10.EGL_NONE
        };

        public EGLConfig chooseConfig (EGL10 egl, EGLDisplay display) {
            // 获取EGL最低配置
            int[] num_config = new int[1];
            /**
             * 这也是一个要重度用的函数。
             * 返回匹配指定属性的EGL帧缓存配置列表
             * 函数参数:
             * EGLDisplay display,      关联的显示器
             * EGLint const * attrib_list,      指定的属性
             * EGLConfig * configs,     输出的帧缓存配置列表
             * EGLint config_size,     帧缓存配置列表的大小
             * EGLint * num_config      符合条件的配置个数
             * 函数返回时,将所有的EGL帧缓存配置保存到configs中,config_size指定了配置列表最多可以接受多少个配置信息
             * num_config返回了符合条件的配置数量
             * 如果configs设置成NULL,config_size会被忽略
             * 一般使用的时候都是先将configs设置成NULL,调用一次函数,获得配置个数,再根据配置个数new一个相应大小的配置
             * 列表数组出来,再调用一次获取信息。
             *
             * 我们下面就是这么做的
             */
            // 获取满足指定属性的所有配置
            egl.eglChooseConfig (display, s_configAttribs2, null, 0, num_config);

            int numConfigs = num_config[0];
            if (numConfigs <= 0) {
                throw new IllegalArgumentException("No configs match configSpec");
            }

            // 分配,读取EGL最低配置
            EGLConfig[] configs = new EGLConfig[numConfigs];
            egl.eglChooseConfig(display, s_configAttribs2, configs, numConfigs, num_config);

            if (DEBUG){
                printConfigs (egl, display, configs);
            }

            return chooseConfig (egl, display, configs);
        }

        public EGLConfig chooseConfig (EGL10 egl, EGLDisplay display, EGLConfig[] configs) {
            for (EGLConfig config : configs) {
                int d = findConfigAttrib (egl, display, config, EGL10.EGL_DEPTH_SIZE, 0);
                int s = findConfigAttrib (egl, display, config, EGL10.EGL_STENCIL_SIZE, 0);

                // 检查深度合模板属性是否达到最低要求
                if (d < mDepthSize || s < mStencilSize) {
                    continue;
                }

                // rgba属性的检查
                int r = findConfigAttrib (egl, display, config, EGL10.EGL_RED_SIZE, 0);
                int g = findConfigAttrib (egl, display, config, EGL10.EGL_GREEN_SIZE, 0);
                int b = findConfigAttrib (egl, display, config, EGL10.EGL_BLUE_SIZE, 0);
                int a = findConfigAttrib (egl, display, config, EGL10.EGL_ALPHA_SIZE, 0);

                if (r == mRedSize && g == mGreenSize && b == mBlueSize && a == mAlphaSize)
                    return config;
            }
            return null;
        }

        private int findConfigAttrib (EGL10 egl, EGLDisplay display,
                                      EGLConfig config, int attribute, int defaultValue) {
            /**
             * 返回EGL帧缓存配置的信息
             * 函数的参数:
             * EGLDisplay display,      相关联的显示器
             * EGLConfig config,        要查询的配置
             * EGLint attribute,        需要返回的属性
             *  EGLint * value          返回值
             */
            if (egl.eglGetConfigAttrib (display, config, attribute, mValue)) {
                return mValue[0];
            }
            return defaultValue;
        }
...
}

茫茫多的代码,还是笔者省略后的。代码虽多,理解起来却不困难。最关键的就是chooseConfig函数,不是下面三个参数的,是上面两个参数的那个。在那个函数里,调用了两次eglChooseConfig。第一次是为了获取有多少个支持属性的配置,第二次是输出配置信息。很巧妙的一个方法,也不是第一次见了。三参chooseConfig函数就是遍历获取到的配置信息,找一个符合属性值要求的配置返回,这点没有什么困惑的。

    private static class Renderer implements GLSurfaceView.Renderer {
        public void onDrawFrame (GL10 unused) {
            GL2JNILib.step();
        }

        public void onSurfaceChanged (GL10 unused, int width, int height) {
            GL2JNILib.init (width, height);
        }

        public void onSurfaceCreated (GL10 gl, EGLConfig config) {
            // 什么都不做
        }
    }

上面的代码是实现一个GLSurfaceView.Renderer。这也是使用GLSurfaceView必要的一步操作。里面有三个函数需要重载,分别是:

  • onDrawFrame(绘制一帧的函数,在这里面调用C++中的绘制函数)
  • onSurfaceChanged(表面属性改变时调用到,例如横屏了,这里也要调用C++中的函数,初始化OpenGL)
  • onSurfaceCreated(表面创建时调用,虽然我们不用,担也必须实现)

这样,Java中的工作做好了,我们切换到C++那边。

实现真正的绘制功能

C++代码重点是在着色器部分。需要写好顶点着色器和片元着色器的代码,编译,链接,然后使用。

先来到写着色器代码环节:

auto gVertexShader =
        "attribute vec4 vPosition;\n"
                "void main() {\n"
                "  gl_Position = vPosition;\n"
                "}\n";

auto gFragmentShader =
        "precision mediump float;\n"
                "void main() {\n"
                "  gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0);\n"
                "}\n";

着色器采用GLSL语言编写,其语法和C语言非常类似。在正式的开发中,通常会写一个着色器加载函数,在文件中写着色器,然后通过加载函数加载进来,编译链接以供使用。本例出于示范目的,就没有做这一部分的功能。

加载着色器函数:

GLuint loadShader (GLenum shaderType, const char* pSource) {
    GLuint shader = glCreateShader (shaderType);
    if (shader) {
        glShaderSource (shader, 1, &pSource, NULL);
        glCompileShader (shader);
        GLint compiled = 0;
        glGetShaderiv (shader, GL_COMPILE_STATUS, &compiled);

        // 下面都是进行日志输出
        if (!compiled) {
            GLint infoLen = 0;
            glGetShaderiv (shader, GL_INFO_LOG_LENGTH, &infoLen);
            if (infoLen) {
                char* buf = (char*) malloc (infoLen);
                if (buf) {
                    glGetShaderInfoLog (shader, infoLen, NULL, buf);
                    LOGE ("Could not compile shader %d:\n%s\n", shaderType, buf);
                    free (buf);
                }
                glDeleteShader (shader);
                shader = 0;
            }
        }
    }
    return shader;
}

首先需要根据着色器类型创建着色器(glCreateShader),然后初始化着色器代码(glShaderSource),编译着色器(glCompileShader),编译后需要检查一下有没有编译成功(glGetShaderiv)。养成一个好习惯,编译后要检查是否编译成功。

创建着色器程序函数:

/**
 * 创建着色器程序
 * @param pVertexSource     顶点着色器源码
 * @param pFragmentSource   片段着色器源码
 * @return {GLuint}
 */
GLuint createProgram (const char* pVertexSource, const char* pFragmentSource) {
    GLuint vertexShader = loadShader (GL_VERTEX_SHADER, pVertexSource);
    if (!vertexShader) {
        return 0;
    }

    GLuint pixelShader = loadShader (GL_FRAGMENT_SHADER, pFragmentSource);
    if (!pixelShader) {
        return 0;
    }

    GLuint program = glCreateProgram ();
    if (!program) {
        LOGE ("Could not create Program\n");
        return 0;
    }

    glAttachShader (program, vertexShader);
    checkGlError ("glAttachShader");
    glAttachShader (program, pixelShader);
    checkGlError ("glAttachShader");
    glLinkProgram (program);
    GLint linkStatus = GL_FALSE;
    glGetProgramiv (program, GL_LINK_STATUS, &linkStatus);
    if (linkStatus != GL_TRUE) {
        GLint bufLength = 0;
        glGetProgramiv (program, GL_INFO_LOG_LENGTH, &bufLength);
        if (bufLength) {
            char* buf = (char*) malloc (bufLength);
            if (buf) {
                glGetProgramInfoLog (program, bufLength, NULL, buf);
                LOGE ("Could not link program:\n%s\n", buf);
                free (buf);
            }
        }
        glDeleteProgram (program);
        return 0;
    }

    return program;
}

着色器程序这个东西非常不好理解,因为在编程的过程中没有什么东西能和它对应上。说它是代码吧,它用的是已经编译过了的着色器。说它是库吧,为什么还要附加两个库,然后在链接?笔者至今还没有想出一个能匹配的模型,如果哪位读者有好的理解模型,请务必告诉我。

扯远了。着色器程序也是使用OpenGL渲染的一环,着色器编译好后需要链接到着色器程序上进行链接,链接完成之后的东西(这个着色器程序)才是真正可以用的。没什么弯弯绕绕的地方,就是要养成一个链接完后也检查是否成功的习惯。

接下来是设置图形系统的接口:

// 设置图形系统
bool setupGraphics (int w, int h) {
    printGLString("Version", GL_VERSION);
    printGLString("Vendor", GL_VENDOR);
    printGLString("Renderer", GL_RENDERER);
    printGLString ("Extensions", GL_EXTENSIONS);

    LOGI ("setupGraphics (%d, %d)", w, h);
    gProgram = createProgram (gVertexShader, gFragmentShader);
    if (!gProgram) {
        LOGE ("Could not create program.");
        return false;
    }
    gvPositionHandle = glGetAttribLocation(gProgram, "vPosition");
    checkGlError ("glGetAttribLocation");
    LOGI ("glGetAttribLocation (\"vPosition\") = %d\n", gvPositionHandle);

    glViewport (0, 0, w, h);
    checkGlError("glViewport");
    return true;
}

这个函数主要是用来初始化图形系统的,包括系统的状态的输出,创建着色器程序,保存着色器程序中vPosition的位置(用来在其他代码中进行赋值)。最重要的一点,初始化视口:glViewport做的工作。

最后,也是离Java代码最近的东西-渲染:

void renderFrame() {
    static float grey;
    grey += 0.01f;
    if (grey > 1.0f) {
        grey = 0.0f;
    }

    glClearColor (grey, grey, grey, 1.0f);
    checkGlError("glClearError");
    glClear (GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
    checkGlError ("glClear");

    glUseProgram (gProgram);
    checkGlError("glUseProgram");

    glVertexAttribPointer (gvPositionHandle, 2, GL_FLOAT, GL_FALSE, 0, gTriangleVertices);
    checkGlError("glVertexAttribPointer");
    glEnableVertexAttribArray(gvPositionHandle);
    checkGlError("glEnableVertexAttribArray");
    glDrawArrays (GL_TRIANGLES, 0, 3);
    checkGlError("glDrawArrays");
}

渲染表面的流程:1、清理表面。2、使用着色器。3、设置顶点属性。4、绘制。

对应到代码就是:1、glClearColor。2、glUseProgram。3、glVertexAttribPointer和glEnableVertexAttribArray。4、glDrawArray。

最后再看两个逗乐函数:

JNIEXPORT void JNICALL Java_com_android_gl2jni_GL2JNILib_init(JNIEnv * env, jobject obj,  jint width, jint height)
{
    setupGraphics(width, height);
}

JNIEXPORT void JNICALL Java_com_android_gl2jni_GL2JNILib_step(JNIEnv * env, jobject obj)
{
    renderFrame();
}

Renderer中调用的接口就是这两个函数,这两函数除了被Java调用一下也没啥实际作用了(话说回来,jni接口都是这样,所有的具体功能都是在别的代码中,jni接口只要调用具体功能函数就行了。这也算是中间层的宿命吧。)。

总结

两个部分的功能都非常明确,代码的逻辑也十分清晰,不愧是官方的示例代码。从这个思路走,即使不看代码,你也应该能“拼”出这个功能。

参考资料

官方示例:如何使用OpenGL ES?

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容