Let me show how you can use GraalVM to create an OpenGL app in Java. This is made possible by the native-image tool that helps GraalVM produce truly native binaries capable of interacting with native libraries directly, without any JNI overhead. ⚡️

Introduction

GraalVM has been trending a lot in the Java ecosystem recently. It has a remarkable capability of compiling Java bytecode to native binaries ahead-of-time, an issue that many projects had tried to solve in the past. But that isn't the only fascinating thing. GraalVM supports a multitude of languages like Python, Ruby, JavaScript, C++, Rust, etc. besides the regular JVM languages (like Clojure, Kotlin, and Scala), and lets them interoperate with almost zero overhead. It can also run in multiple contexts including OpenJDK, Node.js, and SubstrateVM (internally used by native-image), making it a powerful polyglot runtime.

We'll be reproducing the Utah teapot example from Rosetta code in Java 11. Since we'd also be briefly leaving the JVM world to connect with native technologies, some parts would look familiar to C/C++ code. I'm not an OpenGL expert but I'll try my best to explain all the setup. All the source code can be found on GitHub.

Project structure

Atleast JDK 11 is required to run this project. The gradle-graal plugin is used to auto-download GraalVM. It is then configured accordingly:

graal {
    graalVersion("20.0.0")
    javaVersion("11")

    mainClass("in.praj.glexamples.Main")
    outputName("glExample")
    option("--no-fallback")
    option("--verbose")
    option("--no-server")
    option("-H:+ReportExceptionStackTraces")
}

We explicitly mention --no-fallback to prevent GraalVM from using a regular JVM when AOT compilation fails. We also need to specify dependencies on GraalVM and SubstrateVM APIs:

dependencies {
    val graalVer = graal.graalVersion.get()

    compileOnly("org.graalvm.sdk", "graal-sdk", graalVer)
    compileOnly("org.graalvm.nativeimage", "svm", graalVer)
}

Like regular Gradle projects, the sources are present inside src/main/java/. The GLUT and GL classes provide access to functions from libglut and libGL, respectively. The Main class represents our actual OpenGL program. Now let's take a deep dive.

App Context

First, we specify the required native libraries in a Directives inner class and use it to setup a context for our application. This notifies SubstrateVM of our requirements:

@CContext(Main.Directives.class)
public class Main {
    public static final class Directives implements CContext.Directives {
        @Override
        public List<String> getHeaderFiles() {
            return Collections.singletonList("<GL/glut.h>");
        }

        @Override
        public List<String> getLibraries() {
            return Arrays.asList("GL", "glut");
        }
    }

    // ...
}

Essentially, getHeaderFiles returns the list of all headers we'd have mentioned after #include if it were written in C/C++, and getLibraries returns the list of libraries that would have been passed to your C/C++ compiler.

Bindings

There's a rich set of API to provide support for raw C/C++ data structures. Most important interface to note is org.graalvm.word.WordBase. It's the base type used to represent native data types like pointers and structs, and its descendants are not regular Java objects (descendants of java.lang.Object). Let's take a quick look at parts of <GL/glut.h>, in an over-simplified manner:

#define  GLUT_SINGLE  0x0000
void  glutInit( int* pargc, char** argv );
void  glutInitDisplayMode( unsigned int displayMode );
void  glutDisplayFunc( void (* callback)( void ) );

And here's what the corresponding Java bindings look like:

@CContext(Main.Directives.class)
class GLUT {
    @CConstant("GLUT_SINGLE")
    static native int SINGLE();

    @CFunction("glutInit")
    static native void init(CIntPointer argc, CCharPointerPointer argv);

    @CFunction("glutInitDisplayMode")
    static native void initDisplayMode(int displayMode);

    @CFunction("glutDisplayFunc")
    static native void displayFunc(Callback callback);

    interface Callback extends CFunctionPointer {
        @InvokeCFunctionPointer void invoke();
    }

    // ...
}

There's a boilerplate indeed, but it's much cleaner than JNI. By default, the annotations take the function name as an argument and find the corresponding declaration from the headers. So you have to pass in the names explicitly if you want to use something different for your bindings. Primitive Java types can be used to represent similar C/C++ types, but you'll need to use GraalVM's custom types for handling pointers.The Callback interface is used to represent the signature of a function that needs to be passed into GLUT.displayFunc using a function pointer. After a while, you'll see how we create and pass those function pointers.

Real Program

Inside our main method we have the actual app code. First, we need to pass in the CLI arguments to our GLUT.init call, and then configure the window system:

try (var argv = CTypeConversion.toCStrings(args)) {
    var argc = StackValue.get(CIntPointer.class);
    argc.write(args.length);
    GLUT.init(argc, argv.get());
}

GLUT.initDisplayMode(GLUT.SINGLE() | GLUT.RGB() | GLUT.DEPTH());
GLUT.initWindowPosition(15, 15);
GLUT.initWindowSize(400, 400);
try (var title = CTypeConversion.toCString("Utah Teapot - GraalVM")) {
    GLUT.createWindow(title.get());
}

The first parameter to GLUT.init is a pointer to the number of CLI arguments, and we create this on our stack using StackValue class. The second parameter is the actual list of CLI arguments, which is represented by an array of Strings in Java and by an array of char* in C/C++. Fortunately, there's a utility class called CTypeConversion for such scenarios. CTypeConversion.toCStrings returns a safe holder for the char** value representing the array, which can be used inside a try-with-resources block. Once the holder is closed, the pointer inside must not be used. We similarly set up GLUT.createWindow.

Next, we set up the lighting and materials that affect our scene:

GL.clearColor(0.5f, 0.5f, 0.5f, 0f);
GL.shadeModel(GL.SMOOTH());
try (var white = PinnedObject.create(new float[] {1f, 1f, 1f, 0f});
     var shine = PinnedObject.create(new float[] {70f})) {
    GL.lightfv(GL.LIGHT0(), GL.AMBIENT(), white.addressOfArrayElement(0));
    GL.lightfv(GL.LIGHT0(), GL.DIFFUSE(), white.addressOfArrayElement(0));
    GL.materialfv(GL.FRONT(), GL.SHININESS(), shine.addressOfArrayElement(0));
}

GL.enable(GL.LIGHTING());
GL.enable(GL.LIGHT0());
GL.enable(GL.DEPTH_TEST());

The third parameter to GL.lightfv and GL.materialfv requires a float array. Now, Java arrays cannot be directly passed here because they don't descend from WordBase. So we create a PinnedObject to represent this array and pass the pointer to its first element using addressOfArrayElement(0). Pinning the array is important as it preserves the value of that pointer by preventing the garbage collector from moving it. After the pinned object is closed, the garbage collector is free to remove it.

We end our main method by setting up display and idle callbacks, and running the GLUT main loop:

GLUT.displayFunc(displayCallback.getFunctionPointer());
GLUT.idleFunc(idleCallback.getFunctionPointer());
GLUT.mainLoop();

The display function is used to draw the teapot, while the idle function updates its angle of rotation. Both are called each frame by the GLUT main loop. Right now, creating pointer to a function with specific signature is a bit tricky. This will likely become much simpler when issue 730 is resolved. Let's take a look at how the display callback is implemented here:

@CEntryPoint
@CEntryPointOptions(prologue = CEntryPointSetup.EnterCreateIsolatePrologue.class,
                    epilogue = CEntryPointSetup.LeaveTearDownIsolateEpilogue.class)
private static void display() {
    GL.clear(GL.COLOR_BUFFER_BIT() | GL.DEPTH_BUFFER_BIT());

    GL.pushMatrix();
    GL.rotatef(rotation.get().read(), 0f, 1f, 1f);
    try (var mat = PinnedObject.create(new float[] {1, 0, 0, 0})) {
        GL.materialfv(GL.FRONT(), GL.DIFFUSE(), mat.addressOfArrayElement(0));
    }
    GLUT.wireTeapot(0.5);
    GL.popMatrix();

    GL.flush();
}

private static final CEntryPointLiteral<GLUT.Callback> displayCallback =
        CEntryPointLiteral.create(Main.class, "display");

The display function is annotated as a @CEntryPoint to allow calling it from the native side. The @CEntryPointOptions is used to configure the handling of Isolates. These are disjoint heaps that can be used to strictly separate groups of objects in memory. I won't go deeper into the topic, but you can see Christian Wimmer's post for an idea. Each time such an entry-point is called, we need to take care of the isolate it will be executed in. We simply create an isolate upon entering the function and destroy it upon returning. Finally, we create a CEntryPointLiteral to hold the pointer to display and cast it as a GLUT.Callback.

Now we need to implement the idle function to update the angle of rotation. This angle is stored as an global variable whose pointer is accessible to both display and idle functions.

private static final CGlobalData<CFloatPointer> rotation = 
    CGlobalDataFactory.createBytes(() -> 4);

@CEntryPoint
@CEntryPointOptions(prologue = CEntryPointSetup.EnterCreateIsolatePrologue.class,
                    epilogue = CEntryPointSetup.LeaveTearDownIsolateEpilogue.class)
private static void idle() {
    var rotPtr = rotation.get();
    rotPtr.write(0.1f + rotPtr.read());
    GLUT.postRedisplay();
}

private static final CEntryPointLiteral<GLUT.Callback> idleCallback =
        CEntryPointLiteral.create(Main.class, "idle");

Any static final value is stored in a special location called the Image Heap and this is accessible to any isolate. We allocate 4 bytes (size of float) and create a pointer to this in our image heap using CGlobalDataFactory. This global variable can be accessed inside idle function to update the angle. We create a pointer to idle in the same way as before.

Results

To build the program, use the following command in the root project directory:

./gradlew nativeImage

It takes a while for building. After completion, the executable can be found inside build/graal/. It should weigh around 6mb, and be ready for action:

Demo

Hope you find this post interesting. If you see any mistakes in the process or you'd like to suggest an improvement, feel free to comment below. GraalVM is an incredible technology in the Java ecosystem, and it deserves a good place in native app development. 💎