OpenGL ES 3.1 Graphics Programming for Android Native - samrg123/JniTeapot GitHub Wiki
Setting up a JNI Build System
2.1 Creating an Empty Project
2.2 Installing the Android NDK
2.3 Setting up CMake
2.4 Setting up GradleSetting up a Native Android App
3.1 Setting up the Manifest File
3.2 Creating a Java Interface
3.3 Creating a Native Interface
3.4 Creating a Native loggerInitializing OpenGl ES 3.1
4.1 Setting up Assertions
4.2 Creating an EGL Context
4.3 Creating a render threadRendering a Triangle
5.1 Writing the Vertex Shader
5.2 Writing the Fragment Shader
5.3 Creating a Gl Program
The Java programming language is designed to be platform independent. Java achieves this by compiling code down to an intermediate machine code language called Byte Code. This byte code is then translated or recompiled into native machine code at runtime via a platform specific JVM (Java Virtual Machine)*. While there's been many improvements to JVM over the years, translating Byte Code at runtime is inherently slower than executing native machine code directly. Similarly, the platform independence of Java masks platform specific features effectively limiting the range of Java programs. To counteract these issues the Java language has defined a standard interface which allows JVM's to execute native machine code that comes bundled with a Java program. This interface is called the JNI(Java Native Interface).
* Android breaks the Java convention by compiling Byte Code into native code during app installation. This one time translation is performed by the Android Runtime (ART) which takes the place of the JVM and serves as a debugging and memory manager layer during the apps execution.
In this section we'll incorporate a CMake build system into Android Studio that will compile native C++ code using the Clang compiler.
If your haven't already, install and setup Android Studio. After the setup is complete create a new project with 'No Activity'
:
Name it JniDemo
and click Finish
:
Note: if you're using a different domain than
'eecs487'
then replace the package name:'com.eecs487.jnidemo'
with:'com.[YOUR DOMAIN].jnidemo'
. And whenever I refer to:'eecs487'
in the tutorial replace it with:'[YOUR DOMAIN]'
.
Next we'll need to download the Android Native Development Kit (NDK). The NDK includes a variety of tools such as the Clang compiler that will help us build and debug native Android Apps.
To download the NDK navigate to File->Settings->Appearance & Behavior->System settings->Android SDK
and click on the 'SDK Tools'
:
Click on 'Show Package Details'
in the lower right and check the latest boxes for 'NDK (Side by Side)'
and 'Cmake'
. Make note of the NDK version (you can copy the Version field) as we'll need it later. In my case the version is '21.3.6528147'
:
Click Apply
and follow the installation instructions (This may take a while depending on your Internet connection)
Note: if you have an Intel processor you may also want to check
'Intel x86 Emulator Accelerator (HAXM installer)'
which will speed up the android emulator emulator.
Now that we've installed CMake and the and Android NDK its time to set up a CMakeLists.txt
build file. This file will instruct CMake what files are included in our native code and how to compile them.
First Select 'Project Files'
view in the project sidebar:
Then navigate to app/src/main
and create a new directory called cpp
:
Create a new file in the cpp
directory called CMakeLists.txt
:
Modify CMakeLists.txt
to look like the following:
# setup C and C++ cmake project
cmake_minimum_required(VERSION 3.4.1)
project(JniDemo LANGUAGES C CXX)
# set up common compile options
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -std=c++2a -fno-exceptions -fno-rtti -fdeclspec -debug")
# build app library
set(SOURCES jniDemo.cpp)
add_library(${PROJECT_NAME} SHARED ${SOURCES})
set_target_properties(${PROJECT_NAME}
PROPERTIES
OUTPUT_NAME ${PROJECT_NAME}
)
## link app library
target_link_libraries(
${PROJECT_NAME}
log # link android log library
android # android jni internals
EGL # EGL library
GLESv3 # GL3.x library
)
Note:
jniDemo.cpp
will include the source code for our native app. We haven't created it yet.
Note: We've linked our source code with a lot of libraries. these libraries take the form of
lib[NAME].so
so linking to'log'
actually links to'liblog.so'
which is the library for'<android/log.h>'
. We will uses these libraries in the source code later, but I've decided to link them in this step for conciseness.
The last thing we need to do is inform Android Studio to use our CMakeLists.txt
file in the build processes. Android Studio uses Gradle to automate the apk build system. So we'll modify its configuration file to include our Cmake file.
Note: Gradle also handles apk build configurations properties that were previously handled in the
AndroidManifet.xml
file such as minimum and target API version.
First make sure that you are using the most recent version of Gradle. Go to File->Project Structure->Project
, select 'Gradle Version 6.6.1'
, and click apply:
Next, modify the 'app/build.gradle'
file to look like the following:
apply plugin: 'com.android.application'
android {
compileSdkVersion 30
buildToolsVersion "30.0.2"
defaultConfig {
applicationId "com.eecs487.jnidemo"
minSdkVersion 21 // Note: gles 3.1 requires sdk 21+, gles 3.2/vulkan 1.0 requires 24+, vulkan 1.1 requires 28+, egl 1.5 implemented in 29
targetSdkVersion 30
versionCode 1
versionName "1.0"
ndkVersion '21.3.6528147' // Note: make sure this matches your ndk version number from installing the NDK
externalNativeBuild {
cmake {
version '3.10.2'
arguments "-DANDROID_TOOLCHAIN=clang",
"-DANDROID_STL=c++_static" //Compile libc++ into the binary to avoid compatibility issues
}
}
}
//Note: This is confusing, but Cmake arguments must be defined in 'android.defaultConfig.externalNativeBuild' while
// the CMake path must be defined in 'android.externalNativeBuild' (they're different properties)
externalNativeBuild {
cmake {
path 'src/main/cpp/CMakeLists.txt'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
// Use the latest version of java
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "androidx.appcompat:appcompat:1.2.0"
}
Note: There are two build.gradle file:
app/build.gradle
andbuild.gradle
. You must modifyapp/build.gradle
, it should look similar to the code above.
Once modified you must re-sync Gradle with the current project. There should be a drop down notification prompting you to click 'sync now'
. Alternatively you can click on the picture of the elephant in the upper right toolbar:
Due to the nature of Android we cannot launch native app directly. Instead we must create a wrapper Java app that calls into our native code. This may seem undesirable at first, but it allows you to Initialize your app in Java and then call into lower level native code when needed which simplifies things like app layout.
In this section we'll setup the Android Manifest file. This file contains meta data about our app and instructs the Google Play store and Android operating system how to display and launch the app.
We'll modify the existing 'app/src/main/AndroidManifest.xml'
file to require support for OpenGL ES 3.1 and add an icon for our application in the app drawer:
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.eecs487.jnidemo"
>
<!-- Require support for OpenGL ES 3.1 -->
<uses-feature android:glEsVersion="0x00030001" />
<application
android:name="com.eecs487.jnidemo.JniApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
>
<!-- Note: 'android:configChanges' prevents the app from restarting
when the listed configurations change -->
<activity
android:name="com.eecs487.jnidemo.JniActivity"
android:label="@string/app_name"
android:configChanges="orientation|screenLayout|screenSize|keyboardHidden"
android:screenOrientation="sensorLandscape"
>
<!-- Make 'JniTeapotActivity' the main activity and add it to the app drawer -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Note: the
'android:name'
fields for theapplication
andactivity
tab should be red. Android Studio is just warning us that those Java files don't exist yet. We'll create them next.
JNI works by compiling our native code into a shared library (Windows calls these DLLs). This library is then loaded can then be loaded into memory and linked to native Java functions at runtime. To start out we'll create a static App
class that loads our native code's library during Java's static initialization.
Right click on the 'src/main/java/com/eecs487/jniDemo'
folder and create a new Java class:
Select 'Class'
from the drop down, name it App
, and press Enter:
Once created modify the App
class to look like the following:
import android.util.Log;
public class App {
// Load native library at static initialization
static final String kNativeLibrary = "JniDemo";
static {
System.loadLibrary(kNativeLibrary);
Log("Loaded native library: "+kNativeLibrary);
}
// function pointers to native code
static native void NativeInit();
static void Panic(String msg) {
Error(msg);
System.exit(1);
}
// Note: Android logging is a little tedious by default and doesn't include
// the logging call site so I've addeded some logging functions that do.
static void Log(String msg) { Log.i(LogPrefixStr("MSG"), msg); }
static void Warn(String msg) { Log.w(LogPrefixStr("WARN"), msg); }
static void Error(String msg) { Log.e(LogPrefixStr("ERROR"), msg); }
static String LogPrefixStr(String suffix) {
StackTraceElement trace[] = Thread.currentThread().getStackTrace();
// Note: trace[0] is native thread,
// trace[1] is java thread,
// trace[2] is LogPrefixStr,
// trace[3] is log call
// trace[4] is log call caller <- what we want
// Note: our app's messages can get easily lost in all the others
// so I've prefixed them with `=>` to make them easier to find
final int callerIndex = 4;
if(trace.length < callerIndex) {
return " => UnknownClass.UnknownMethod:UnknownLine - " + suffix;
}
StackTraceElement caller = trace[callerIndex];
return " => "+caller.getClassName()+"."+caller.getMethodName()+":"+caller.getLineNumber()+" - "+suffix;
}
}
Note: do not remove the generated
'package com.eecs487.jnidemo;'
line on the top of theApp.java
file, just modify the content of theApp
interface. I'll be leaving outpackage
statements for the sake of brevity. The complete project files can be found at the end of the tutorial if you'd like to download them.
Note:
NativeInit()
will be defined in native code in the next section.
Next well create an Android Application
wrapper class that won't be used for much now, but later will forward Android application events such as Application.onLowMemory
to our native code.
Create a Java class called JniApplication
in 'src/main/java/com/eecs487/jnidemo'
and define it like so:
import android.app.Application;
public class JniApplication extends Application {
public void onCreate() {
super.onCreate();
App.Log("Created application");
}
}
Finally we'll create an Android Activity
wrapper class which will handle the initializing our native code.
Create a Java class called JniActivity
in 'src/main/java/com/eecs487/jnidemo'
and define it like so:
import android.os.Bundle;
import android.app.Activity;
public class JniActivity extends Activity {
public void onCreate(Bundle savedState) {
super.onCreate(savedState);
App.Log("Created Activity");
App.NativeInit();
}
}
In this section we'll implement the native backend that gets called into by the Java wrapper
To start off, right click on the cpp
folder and create a new C++ source file and name it JniDemo.cpp
:
Java maps each native function with the following symbol: Java_[PACKAGE]_[CLASS]_[METHOD]
where [PACKAGE]
and [CLASS]
correspond to the Java package and class where the native function was declared and [METHOD]
refers to he native function name. In our example the only native function we've declared in Java is App.NativeInit
so we'll define Java_com_eecs487_jnidemo_App_NativeInit
in JniDemo.cpp
:
#include <jni.h> //JNI declarations
// Macro to remove boiler plate
#define JFunc(jMethod) JNIEXPORT JNICALL Java_com_eecs487_jnidemo_App_ ##jMethod
// disable C++ name mangling on export symbols
extern "C" {
//Note: this gets expanded into: 'void JNIEXPORT JNICALL Java_com_eecs487_jnidemo_App_NativeInit()'
void JFunc(NativeInit)(JNIEnv* env, jclass class_) {
}
}
Note: Because C doesn't support dots '.' in function names,
[PACKAGE]
replaces them with underscores '_'
Note:
JNIEXPORT
marks the function visible for library export andJNICALL
ensures that our JNI functions use a standard calling convention (this is normally just the standard C calling convention).
At this point you should be able to compile and run the app by clicking the run icon:
If you do you should be greeted with a blank white screen:
Note: you can drastically speed up the amount of time it takes to launch the app by turning off the native debug. Doing so will prevent your app from halting at breakpoints and inspecting variables, but is still often worth the time savings.
To turn off the native debugger click on the app drop-down:
![]()
And select'Java Only'
in the'Debugger'
tab:![]()
You can re-enable native debugging by selecting'Native Only'
or'Both'
from the drop-down.
If you Click on the 'Logcat'
tab in android studio you should be able to see the messages we printed:
You can also use the '=>'
sentinel to filter the logs and see only the ones we produced:
Now that the app is up and running and you know how to view messages logged from Java we can get working on setting up an interface for logging messages from within native code. For this we'll create a new C++ Header file
in the 'cpp'
folder. Name it 'log.h'
and add the following code:
#pragma once
#include <android/log.h>
enum LogLevel: char { LOG_LEVEL_MSG = 1, LOG_LEVEL_WARN, LOG_LEVEL_ERROR };
constexpr unsigned int LogType(LogLevel logLevel, char options = 0, short payload = 0) {
return (int(logLevel)<<24) | (int(options)<<16) | (payload & 0xFFFF);
}
constexpr android_LogPriority AndroidLogPriority(unsigned int logType) {
return (logType>>24 == LOG_LEVEL_ERROR) ? ANDROID_LOG_ERROR :
(logType>>24 == LOG_LEVEL_WARN) ? ANDROID_LOG_WARN :
ANDROID_LOG_INFO;
}
constexpr const char* LogLevelStr(unsigned int logType) {
return (logType>>24) == LOG_LEVEL_MSG ? "MSG" :
(logType>>24) == LOG_LEVEL_WARN ? "WARN" :
(logType>>24) == LOG_LEVEL_ERROR ? "ERROR" : "UNKNOWN";
}
#define STRINGIFY_(x) #x
#define STRINGIFY(x) STRINGIFY_(x)
#define CALL_SITE_FMT __FILE__ ":" STRINGIFY(__LINE__) " [%s]"
//Note: clang uses non-preprocessor string for __FUNCTION__ so we cant concat it with other string literals
#define LOG_FMT_(callSiteFmt, fmtStr) callSiteFmt " - %s: { " fmtStr " }\n"
#define LOG_ARGS_(funcStr, typeStr, ...) funcStr, typeStr, ##__VA_ARGS__
#define LogStr_(type, fmt, ...) __android_log_print(AndroidLogPriority(type), " => JNI Native Logger", fmt, __VA_ARGS__)
#define Log(fmt, ...) LogStr_(LogType(LOG_LEVEL_MSG), LOG_FMT_(CALL_SITE_FMT, fmt), LOG_ARGS_(__func__, LogLevelStr(LogType(LOG_LEVEL_MSG)), ##__VA_ARGS__))
#define Warn(fmt, ...) LogStr_(LogType(LOG_LEVEL_WARN), LOG_FMT_(CALL_SITE_FMT, fmt), LOG_ARGS_(__func__, LogLevelStr(LogType(LOG_LEVEL_WARN)), ##__VA_ARGS__))
#define Error(fmt, ...) LogStr_(LogType(LOG_LEVEL_ERROR), LOG_FMT_(CALL_SITE_FMT, fmt), LOG_ARGS_(__func__, LogLevelStr(LogType(LOG_LEVEL_ERROR)), ##__VA_ARGS__))
Note: The
#pragma once
line at the top of the file acts as an 'include guard' and prevents the header file from being included more than once throughout the project. For the sake of brevity we will be defining functions in header files for this project and if#pragma once
is omitted from them you will likely run into multiple definition compile errors.
With this code we can now output messages to Logcat using the Log
, Warn
, and Error
macros. These macros expect a printf
style format string and a variable number of arguments. For example calling Log("x:%d", 5)
will output the message 'x:5'
. We will use these logging methods extensively in the next section to make sure there are no errors while setting up an OpenGl context.
Congratulations! You've successfully implemented a native Android app. Now that we've finished setting up the Java-Native interface we can start working on creating an EGL context we can use to draw to the screen.
Note: OpenGL only defines a specification for how 3D objects should be rendered and doesn't say for how the operating system should handle GPU memory or draw images to the screen. Instead the Khonos group has defined a separate specification called EGL which serves as an interface between the operating system and OpenGL. EGL defines a way for how the operating system should manage GPU memory and draw images to the screen in a manor consistent with how OpenGL expects it.
There are many errors that may occur while creating an EGL context. To make sure that everything is initialized correctly we will add some more macros to the log.h
file that can be used to check whether or not an error occurred.
First we will add a macro that will be able to halt the debugger and print out an error message to Logcat if a given condition evaluates false:
#define RUNTIME_ASSERT(condition, msg, ...) { \
const bool conditionVal = condition; \
if(!conditionVal) { \
Error("\n\tRuntime assertion Failed {\n\t\tMSG: [" msg "]\n\t\tcondition: [" #condition "]\n\t}\n", ##__VA_ARGS__); \
__builtin_trap(); /*send SIGTRAP to debugger to cause break in execution*/ \
} \
}
Next we will add some macros that can check to see whether or not an EGL or OpenGL error has occurred:
#define GL_ASSERT_INDENT "\n\t\t\t"
#define GL_ASSERT_END GL_ASSERT_INDENT "}\n\t\t"
#define GlContextAssertNoError_(name, errorFunc, errorMsg, ...) { \
int error_; \
RUNTIME_ASSERT(!(error_ = errorFunc()), \
GL_ASSERT_INDENT name " failed {" \
GL_ASSERT_INDENT "\tMSG: " errorMsg \
GL_ASSERT_INDENT "\t" #errorFunc ": %d [0x%08x]" \
GL_ASSERT_END, \
##__VA_ARGS__, error_, error_); \
}
#define GlContextAssert_(name, errorFunc, condition, errorMsg, ...) { \
int error_; \
RUNTIME_ASSERT((condition) || (error_ = errorFunc(), false), \
GL_ASSERT_INDENT name " failed {" \
GL_ASSERT_INDENT "\tMSG: " errorMsg \
GL_ASSERT_INDENT "\t" #errorFunc": %d [0x%08x]" \
GL_ASSERT_END, \
##__VA_ARGS__, error_, error_); \
}
#define GlContextAssertValue_(name, errorFunc, value, trueValue, errorMsg, ...) { \
int error_; \
RUNTIME_ASSERT((value) == (trueValue) || (error_ = errorFunc(), false), \
GL_ASSERT_INDENT name " failed {" \
GL_ASSERT_INDENT "\tMSG: " errorMsg \
GL_ASSERT_INDENT "\t:" #errorFunc " %d [0x%08x]" \
GL_ASSERT_INDENT "\tvalue: %d [0x%08x]" \
GL_ASSERT_INDENT "\ttrueValue: %d [0x%08x]" \
GL_ASSERT_END, \
##__VA_ARGS__, error_, error_, value, value, trueValue, trueValue); \
}
#define EglAssertNoError(errorMsg, ...) GlContextAssertNoError_("EglAssertNoError", eglGetError, errorMsg, ##__VA_ARGS__)
#define GlAssertNoError(errorMsg, ...) GlContextAssertNoError_("GlAssertNoError", glGetError, errorMsg, ##__VA_ARGS__)
#define EglAssert(condition, errorMsg, ...) GlContextAssert_("EglAssert", eglGetError, condition, errorMsg, ##__VA_ARGS__)
#define GlAssert(condition, errorMsg, ...) GlContextAssert_("GlAssert", glGetError, condition, errorMsg, ##__VA_ARGS__)
#define EglAssertTrue(val, errorMsg, ...) GlContextAssertValue_("EglAssertTrue", eglGetError, val, EGL_TRUE, errorMsg, ##__VA_ARGS__)
#define GlAssertTrue(val, errorMsg, ...) GlContextAssertValue_("GlAssertTrue", glGetError, val, GL_TRUE, errorMsg, ##__VA_ARGS__)
Note: We use macros instead of functions for logging and assertions so that we can forward the line and function from where they were invoked.
With the ability to check whether or not an EGL and openGL error has occurred we are all clear to begin creating an EGL context. To do this we will create a c++ Header File
in the 'cpp'
folder and call it 'GlContext.h'
. Inside this header file we'll define a new GlContext
class to look like the following:
#pragma once
#include "log.h"
#include <EGL/egl.h>
#include <GLES3/gl31.h>
#include <android/native_window.h>
class GlContext {
public:
static constexpr GLuint kGlesMajorVersion = 3,
kGlesMinorVersion = 1;
static constexpr EGLint kRGBAChanelBitDepth = 4;
static constexpr EGLint kZBufferBitDepth = 24;
static constexpr EGLint kStencilBitDepth = 0;
static constexpr EGLint kMsaaSamples = 0;
static constexpr EGLint kSwapInterval = 0; // 0 for no-vsync, 1-for vsync, n-for vsync buffing
private:
ANativeWindow* nativeWindow = nullptr;
EGLDisplay eglDisplay = EGL_NO_DISPLAY;
EGLSurface eglSurface = EGL_NO_SURFACE;
EGLContext eglContext = EGL_NO_CONTEXT;
EGLConfig eglConfig = nullptr;
EGLint eglWidth, eglHeight;
};
Here the public constexpr
variables will be used to control what type of context is created and used throughout the project. Feel free to mess around with them once everything is up and running. The private variables will be used to store context state for the functions we'll write after this.
Note: As of the time of writing this the Android emulator only supports GLES 3.0 in Google Play images and GLES 3.1 in non Google Play images.
On a high level initializing EGL works by associating a display, surface, and render thread with a context. As you may have guessed a display represents a physical* device capable of showing an image. A surface represents a buffer that can draw to and read by the display. And the render thread is the thread currently writing to the surface. To help EGL accomplish these tasks a context bundles up all of our rendering settings and stores the current EGL state.
* Note: Although displays usually represent a physical device they can also represent a virtual device emulated in software. This could is especially useful when connecting displays over a network.
To assist the Initialization of EGL we'll define some private helper:
static inline
EGLDisplay GetEglDisplay() {
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
EGLint majorVersion, minorVersion;
EglAssertTrue(eglInitialize(display, &majorVersion, &minorVersion), "Failed to initialize egl display");
Log("Initialized EGL Display { display: %p, version: %d.%d }", display, majorVersion, minorVersion);
return display;
}
inline
EGLSurface CreateEglSurface(const EGLDisplay& display, const EGLConfig& config, int* outWidth, int* outHeight) {
EGLSurface surface = eglCreateWindowSurface(display, config, nativeWindow, NULL);
EglAssert(surface != EGL_NO_SURFACE, "Failed to create eglSurface { window: %p, display: %d, config: %d }", nativeWindow, display, config);
EglAssertTrue(eglSurfaceAttrib(display, surface, EGL_SWAP_BEHAVIOR, EGL_BUFFER_DESTROYED),
"Failed to set EGL_BUFFER_DESTROYED surface attribute { window %p, display: %d, config: %d, surface: %d }",
nativeWindow, display, config, surface);
eglQuerySurface(display, surface, EGL_WIDTH, outWidth);
eglQuerySurface(display, surface, EGL_HEIGHT, outHeight);
Log("Created EGL surface { window: %p, display: %d, config: %p, surface: %p, width: %u, height: %u}",
nativeWindow, display, config, surface, *outWidth, *outHeight);
return surface;
}
static inline
EGLConfig GetEglConfig(const EGLDisplay& display) {
static const EGLint attribs[] = {
EGL_RENDERABLE_TYPE, (kGlesMajorVersion >= 3 ? EGL_OPENGL_ES3_BIT : kGlesMajorVersion >= 2 ? EGL_OPENGL_ES2_BIT : EGL_OPENGL_ES_BIT),
EGL_RED_SIZE, kRGBAChanelBitDepth,
EGL_GREEN_SIZE, kRGBAChanelBitDepth,
EGL_BLUE_SIZE, kRGBAChanelBitDepth,
EGL_ALPHA_SIZE, kRGBAChanelBitDepth,
EGL_COLOR_BUFFER_TYPE, EGL_RGB_BUFFER,
EGL_DEPTH_SIZE, kZBufferBitDepth,
EGL_STENCIL_SIZE, kStencilBitDepth,
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
EGL_SAMPLE_BUFFERS, (kMsaaSamples ? 1 : 0), //Max of 1 msaa buffer allowed
EGL_SAMPLES, kMsaaSamples,
EGL_NONE
};
EGLConfig config;
EGLint numConfigs;
EglAssertTrue(eglChooseConfig(display, attribs, &config, 1, &numConfigs), "Failed to get compatible egl configuration { display: %d }", display);
EglAssert(numConfigs, "No compatible egl configuration { display: %d }", display);
Log("Selected EGL config { config: %p, display: %d, kRGBAChanelBitDepth: %d, kZBufferBitDepth: %d, kStencilBitDepth: %d, kMsaaSamples: %d }",
config, display, kRGBAChanelBitDepth, kZBufferBitDepth, kStencilBitDepth, kMsaaSamples );
return config;
}
static inline
EGLContext CreateAndBindEglContext(const EGLDisplay& display, const EGLConfig& config, const EGLSurface& surface) {
const EGLint contexAttribs[] = {
EGL_CONTEXT_MAJOR_VERSION, kGlesMajorVersion,
EGL_CONTEXT_MINOR_VERSION, kGlesMinorVersion,
EGL_NONE
};
EGLContext context = eglCreateContext(display, config, NULL, contexAttribs);
EglAssert(context != EGL_NO_CONTEXT, "Failed to make eglContext { display: %d, config: %p }", display, config);
// bind egl to our thread - needs to be done to set swap interval
EglAssertTrue(eglMakeCurrent(display, surface, surface, context), "Failed to bind egl surface to thread");
// set swap interval
EglAssertTrue(eglSwapInterval(display, kSwapInterval), "Failed to set swap interval { display: %p, context: %p, inverval: %d }", display, context, kSwapInterval);
Log("Created EGL context { display: %d, config: %p, context: %p }", display, config, context);
return context;
}
And the respective private methods to clean them up:
static inline
void FreeContext(const EGLDisplay& display, const EGLContext& context) {
EglAssertTrue(eglDestroyContext(display, context), "Failed to destroy EGL Context { display: %d, context: %p }", display, context);
}
static inline
void FreeSurface(const EGLDisplay& display, const EGLSurface& surface) {
EglAssertTrue(eglDestroySurface(display, surface), "Failed to destroy EGL Surface { display: %d, surface: %p }", display, surface);
}
static inline
void FreeDisplay(const EGLDisplay& display) {
EglAssertTrue(eglTerminate(display), "Failed to destroy Terminate EGL Display { display: %d }", display);
}
Here GetEglDisplay
returns the default display associated with the device.
GetEglConfig
returns a display configuration consistent with our public constexpr
variables.
CreateEglSurface
returns a surface that is formatted by the provided EGLConfig
and EGLDisplay
.
And CreateAndBindEglContext
creates a context associated with the provided EGLDisplay
, EGLSurface
, and EGLConfig
and attaches our current thread to it.
Now that we have defined all of the intermediate steps needed to initialize an EGL context we can combine them all into a single public method:
void Init(ANativeWindow* window) {
nativeWindow = window;
// Initialize EGL
eglDisplay = GetEglDisplay();
eglConfig = GetEglConfig(eglDisplay);
eglSurface = CreateEglSurface(eglDisplay, eglConfig, &eglWidth, &eglHeight);
eglContext = CreateAndBindEglContext(eglDisplay, eglConfig, eglSurface);
Log("Finished Initializing GLES version: %s", (const char*)glGetString(GL_VERSION));
}
We'll also define a public method that can clean up our context:
inline
void Shutdown() {
eglMakeCurrent(eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
FreeContext(eglDisplay, eglContext);
FreeSurface(eglDisplay, eglSurface);
FreeDisplay(eglDisplay);
}
Finally we'll add a robust public method that tells EGL to send our surface to the display:
// Returns false if context was recreated
bool SwapBuffers() {
if(eglSwapBuffers(eglDisplay, eglSurface) == EGL_TRUE) return true;
GLint error = eglGetError();
switch(error) {
case EGL_CONTEXT_LOST: //lost context due to memory purge
Warn("eglContext was purged from memory - Recreating");
FreeContext(eglDisplay, eglContext);
eglContext = CreateAndBindEglContext(eglDisplay, eglConfig, eglDisplay);
break;
case EGL_BAD_SURFACE:
Warn("eglSurface is invalid! - Recreating");
FreeSurface(eglDisplay, eglSurface);
eglSurface = CreateEglSurface(eglDisplay, eglConfig, &eglWidth, &eglHeight);
break;
// EGL_BAD_DISPLAY or EGL_NOT_INITIALIZED
default:
RUNTIME_ASSERT(error == EGL_BAD_DISPLAY || error == EGL_NOT_INITIALIZED, "Unknown EGL SwapBuffers error: %d", error);
Warn("eglDisplay is invalid! - Recreating");
FreeContext(eglDisplay, eglContext);
FreeSurface(eglDisplay, eglSurface);
FreeDisplay(eglDisplay);
Init(nativeWindow);
break;
}
eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext);
return false;
}
We cannot write the main render loop of our program inside of the 'jniDemo.cpp'
NativeInit
function because doing so would block NativeInit
from returning which in turn would block the Android Java thread. Instead we'll spin off a separate thread that will continually render frames until our app is closed by the user.
For now we'll include the following headers in 'jniDemo.cpp'
:
#include "GlContext.h"
#include <pthread.h>
#include <android/native_window_jni.h>
And create a simple pthread callback that continually clear the OpenGl back buffer with yellow and draw it to the screen:
void InitGlState() {
//set the screen's color clear value to yellow
glClearColor(1.f, 1.f, 0.f, 1.f);
Log("Initialized GLES state");
}
struct RenderThreadParams {
ANativeWindow* androidNativeWindow;
};
void* RenderThread(void* renderThreadParams_) {
RenderThreadParams* renderThreadParams = (RenderThreadParams*)renderThreadParams_;
GlContext glContext;
glContext.Init(renderThreadParams->androidNativeWindow);
InitGlState();
while(true) {
//clear the screen's color and depth buffer
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
//Draw the current frame to the screen
if(!glContext.SwapBuffers()) {
//reinitialize if SwapBuffers failed
InitGlState();
};
}
glContext.Shutdown();
return nullptr;
}
Note:
GlContext
must be initialized inside the render thread callback as it binds theEGLContext
to the calling thread.
You may have noticed that RenderThead
requires an ANativeWindow
pointer to be provided to initialize GlContext
. As the name implies, ANativeWindow
is a pointer to our app window in C++. We currently don't have access to it, but we can get a pointer by querying ANativeWindow_fromSurface
. This android library function takes in an jobject
referring to an Android Java Surface and returns a pointer to its underlying ANativeWindow
.
We'll first modify NativeInit
to take in a jobject
Android Surface
reference by modifying the C++ definition in 'jniDemo.cpp'
to look like:
void JFunc(NativeInit)(JNIEnv* env, jclass class_, jobject surface)
Then we modify the declaration in 'App.java'
to look like:
static native void NativeInit(Surface surface);
And finally, we'll modify JniActivity
in 'JniActivity.java'
to take control of our app window's surface and invoke NativeInit
with it:
public class JniActivity extends Activity
implements SurfaceHolder.Callback2 {
public void onCreate(Bundle savedState) {
super.onCreate(savedState);
App.Log("Created Activity");
//Tell android to use our surface callbacks for our app
getWindow().takeSurface(this);
}
@Override
public void surfaceRedrawNeeded(SurfaceHolder surfaceHolder) {
App.Log("Surface Redraw needed!");
}
@Override
public void surfaceCreated(SurfaceHolder surfaceHolder) {
App.Log("Surface Created!");
Surface surface = surfaceHolder.getSurface();
App.NativeInit(surface);
}
@Override
public void surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height) {
App.Log("Surface Changed!");
}
@Override
public void surfaceDestroyed(SurfaceHolder surfaceHolder) {
App.Log("Surface Destroyed!");
}
}
Now we have everything we need to spin off a separate render thread. To spin off a render thread we modify NativeInit
to do the following:
//Get ANativeWindow pointer associated with our surface
ANativeWindow* androidNativeWindow = ANativeWindow_fromSurface(env, surface);
//Use default thread attributes for our render thread
pthread_t thread;
pthread_attr_t threadAttribs;
RUNTIME_ASSERT(!pthread_attr_init(&threadAttribs), "Failed to Init threadAttribs");
//Spin off render thread
static RenderThreadParams renderThreadParams;
renderThreadParams = { .androidNativeWindow = androidNativeWindow };
pthread_create(&thread, &threadAttribs, RenderThread, &renderThreadParams);
At this point you should be able to compile and run the program. If you do you should be greeted with a bright yellow screen:
Congratulations! You've just finished arguably the hardest part of this tutorial. Now that we're done setting up the foundation for rending OpenGL we can focus on a more fun task such as drawing a triangle to the screen.
A vertex shader is a small program openGL executes on the GPU for every vertex drawn on the screen and is used to determine the 3D position of where vertices should be placed. The program is written in the GL Shading Language (GLSL) and loosely resembles C. Because this tutorial is focused on setting up a native Android app for GL rendering rather than using OpenGL, we won't go into the details of how to write advanced shaders and instead create a simple shader that will serve as a starting reference.
We'll add the following string inside RenderThread
serve as the source code to our vertex shader:
const char* kVertexShader = "#version 310 es\n"
"void main() {"
" const vec2[3] position = vec2[]("
" vec2(-.5, -.5),"
" vec2(+.5, -.5),"
" vec2(+.0, +.5)"
" );"
" gl_Position = vec4(position[gl_VertexID], 1., 1.);"
"}";
In this shader main
will be executed for each of the triangles vertices and gl_VertexID
is a global variable provided by OpenGL representing the current vertex being rendered. gl_Position
is another OpenGL provided variable that we explicitly set to tell OpenGL were to render the current vertex in 3D clip space. Clip Space x,y, and z coordinates spans the range [-1, 1] with any vertices outside of that range being off-screen.
Note: You may be wondering why
gl_Position
is a 4D number when the clip space coordinates are only 3D. The reason has to do with a limitation in matrix multiplication not supporting the division operator needed for perspective. You can read up more about it Here, but for now suffice it to say that the 4th dimensional component of gl_Position should be 1.
With the vertex shader out of the way its now time to write the fragment shader. Similar to the vertex shader, a fragment shader is a small program executed on the GPU which tells OpenGL how to render an object on screen. Instead of running per vertex though, the fragment shader runs per pixel* and tells OpenGL what color they should be.
*Note: The fragment shader is actually run per fragment which have a one-to-one mapping to pixels except in the case of anti-aliasing.
We'll add the following string inside RenderThread
serve as the source code to our fragment shader:
const char* kFragmentShader = "#version 310 es\n"
"out vec4 fragColor;"
""
"void main() {"
" fragColor = vec4(0, 1, 0, 1);" //in RGBA
"}";
In this shader main
will be executed for each pixel of the triangle and set fragColor
(the color of the pixel) to be green.
Now that we have the source code for our vertex and fragment shader written, it time to compile it down into an executable program that we can use to render a triangle on screen.
To get started we'll add the following methods to GlContext
to simplify the compilation process:
static inline
GLuint CreateShader(GLuint type, const char* source) {
GLuint shader = glCreateShader(type);
GlAssert(shader,
"Failed to create gl shader { "
GL_ASSERT_INDENT "\ttype: %d"
GL_ASSERT_INDENT "\tsource: ["
"\n%s\n"
GL_ASSERT_INDENT
GL_ASSERT_INDENT "\t]"
GL_ASSERT_INDENT "}",
type, source
);
glShaderSource(shader, 1, &source, NULL);
glCompileShader(shader);
GLint status;
static char glCompileErrorStr[1024];
GlAssertTrue((glGetShaderiv(shader, GL_COMPILE_STATUS, &status), status),
"Failed to compile gl shader {"
GL_ASSERT_INDENT "\ttype: %s [%d]"
GL_ASSERT_INDENT "\tsource: ["
"\n%.512s..." //Note: android logging caps out at 4k so 512 limit prevents chopping off info string
GL_ASSERT_INDENT "\t]"
GL_ASSERT_INDENT "\tInfo: %s"
GL_ASSERT_INDENT "}",
(type == GL_VERTEX_SHADER ? "VertexShader" : type == GL_FRAGMENT_SHADER ? "FragmentShader" : "Unknown"), type,
source,
(glGetShaderInfoLog(shader, sizeof(glCompileErrorStr), NULL, glCompileErrorStr), glCompileErrorStr)
);
return shader;
}
static
GLuint CreateGlProgram(const char* vertexSource, const char* fragmentSource) {
GLuint glProgram = glCreateProgram();
GlAssert(glProgram, "Failed to Create gl program");
GLuint glVertexShader = CreateShader(GL_VERTEX_SHADER, vertexSource),
glFragmentShader = CreateShader(GL_FRAGMENT_SHADER, fragmentSource);
glAttachShader(glProgram, glVertexShader);
glAttachShader(glProgram, glFragmentShader);
glLinkProgram(glProgram);
int status;
static char linkInfoStr[1024];
GlAssertTrue((glGetProgramiv(glProgram, GL_LINK_STATUS, &status), status),
"Failed to Link glProgram {"
GL_ASSERT_INDENT "\tglProgram: %d"
GL_ASSERT_INDENT "\tGL_LINK_STATUS: %d"
GL_ASSERT_INDENT "\tVertex Source ["
"\n%.256s...\n"
GL_ASSERT_INDENT "\t]"
GL_ASSERT_INDENT "\tFragment Source ["
"\n%.256s...\n"
GL_ASSERT_INDENT "\t]"
GL_ASSERT_INDENT "\tLinkInfo: %s"
GL_ASSERT_INDENT "}",
glProgram,
status,
vertexSource,
fragmentSource,
(glGetProgramInfoLog(glProgram, sizeof(linkInfoStr), NULL, linkInfoStr), linkInfoStr)
);
// clean up shader objects - already compiled into executable, no longer need the intermediate code sticking around
// Note: shaders must be detached first or else delete won't free them from memory
glDetachShader(glProgram, glVertexShader);
glDetachShader(glProgram, glFragmentShader);
glDeleteShader(glVertexShader);
glDeleteShader(glFragmentShader);
return glProgram;
}
Now we can call GlContext::CreateGlProgram
inside of RenderThread
to compile our vertex shader and fragment down into an executable:
GLuint triangleProgram = GlContext::CreateGlProgram(kVertexShader, kFragmentShader);
Note: Be sure to invoke
CreateGlProgram
after you have initialized glContext withglContext.Init
.
We can now use this program inside of our render loop to draw triangle for each frame. To do this add the following code between the glClear
and glContext.SwapBuffers
call:
//draw the triangle
glUseProgram(triangleProgram);
glDrawArrays(GL_TRIANGLES, 0, 3);
And Voila you're done! If all went well you should be able to compile and run the program to see a green triangle drawn on the screen:
Congratulations! If you made it this far You've likely completed the tutorial! If not, we've provided a complete set of the project files and source code used to create the tutorial. Hopefully they'll be of some debugging use for you.