OpenGL ES 3.1 Graphics Programming for Android Native - samrg123/JniTeapot GitHub Wiki

OpenGL ES 3.1 Graphics Programming for Android - Native

Table of contents:

  1. Overview of JNI

  2. 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 Gradle

  3. Setting 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 logger

  4. Initializing OpenGl ES 3.1
    4.1 Setting up Assertions
    4.2 Creating an EGL Context
    4.3 Creating a render thread

  5. Rendering a Triangle
    5.1 Writing the Vertex Shader
    5.2 Writing the Fragment Shader
    5.3 Creating a Gl Program

  6. Project Files



Section 1: Overview of JNI

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.




Section 2: Setting up a JNI Build System

In this section we'll incorporate a CMake build system into Android Studio that will compile native C++ code using the Clang compiler.


2.1 Creating an Empty Project

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]'.


2.2 Installing the Android NDK

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.


2.3 Setting up CMake

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.


2.4 Setting up Gradle

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 and build.gradle. You must modify app/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:


Section 3: Setting up a native Android App

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.


3.1 Setting up the Manifest file:

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 the application and activity tab should be red. Android Studio is just warning us that those Java files don't exist yet. We'll create them next.


3.2 Creating a Java Interface

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 the App.java file, just modify the content of the App interface. I'll be leaving out package 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();
    }
}

3.3 Creating a Native Interface

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 and JNICALL 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:


3.4 Setting up a native logger

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.




Section 4: Initializing OpenGl ES 3.1

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.


4.1 Setting up Assertions

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.


4.2 Creating an EGL Context

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;
}

4.3 Creating a Render Thread

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 the EGLContext 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:




Section 5: Rendering a Triangle

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.


5.1 Writing the Vertex Shader

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.


5.2 Writing the Fragment Shader

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.


5.3 Creating a GL Program

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 with glContext.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:




Section 6: Project Files

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.

Download Project Files

⚠️ **GitHub.com Fallback** ⚠️