简单介绍
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?