How to use SDL3 from Java

by: Ethan McCue

Using native code libraries from Java is both easier than its ever been and still a little bit frustrating.

To understand that duality I wrote this pretty basic tutorial on how to use the newly-ish released SDL3 from Java.

This should be useful both for those invested in the Java ecosystem and those who have a more practical desire to use SDL3 for their own projects. While all I am going to do will be focused on Java, feel free to generalize to Clojure, Kotlin, Scala, or flix1.

Prerequisites

Tutorial

0. Make a Hello World project

src/
    Main.java
public class Main {
    public static void main(String[] args) {
        System.out.println("Hello, world");
    }
}

As part of this, make a Justfile with a recipe to run the project.

help:
    just --list
    
run:
    java src/Main.java

1. Clone and Build SDL

SDL is one of those dependencies you still get from source. There are platform specific ways to get this and other native libraries, but those are all nightmares in their own right.

You can find the build instructions for your platform here.

help:
    just --list

# Clone and Build SDL    
sdl:
    rm -rf SDL
    git clone https://github.com/libsdl-org/SDL
    cd SDL && mkdir build
    cd SDL/build && cmake -DCMAKE_BUILD_TYPE=Release ..
    cd SDL/build && cmake --build . --config Release --parallel
    cd SDL/build && sudo cmake --install . --config Release
    
run:
    java src/Main.java

Whether you want to keep SDL as a git submodule, clone it fresh every time, or something else is up to you.

On my machine (M1 Mac) running the build outputs some warnings which leads to a non-zero exit code. While annoying, it does manage to finish the build so whatever. So long as you don't see errors you should be fine.

2. Generate Java Bindings

To do this we will use jextract.

jextract \
      --include-dir SDL/include \
      --dump-includes includes.txt \
      SDL/include/SDL3/SDL.h

This will create a file with the command-line flags needed to include every symbol. This is useful so you can trim down the functions Java code will be generated for. Basically just go through this file and remove everything that doesn't start with SDL_ or similar.

Then, using that list of symbols to include, generate Java code.

As part of this you should use the --use-system-load-library flag. This will generate the code such that it will pull libsdl3 from the directly configurable java.library.path.

jextract \
      --include-dir SDL/include \
      --output src \
      --target-package bindings.sdl \
      --library SDL3 \
      --use-system-load-library \
      @includes.txt \
      SDL/include/SDL3/SDL.h

3. Update Run Configuration

In order to call into native code you need to pass a flag to enable native access. This is because, in general, calling arbitrary C code can crash or otherwise bork the JVM.

Native access permissions are given per-module. By default (unless you make a module-info.java) your code will be on the unnamed module, so we will use ALL-UNNAMED.

java --enable-native-access=ALL-UNNAMED src/Main.java

A known quirk of using a library like SDL on Mac is that you need to also pass -XstartOnFirstThread. On non-mac platforms I think you can leave this off.

java \
    -XstartOnFirstThread \
    --enable-native-access=ALL-UNNAMED \
    src/Main.java

And then we need to pass the path of our build SDL shared library.

java \
    -XstartOnFirstThread \
    --enable-native-access=ALL-UNNAMED \
    -Djava.library.path=SDL/build \
    src/Main.java

If you are unfamiliar with -Djava.library.path - isn't that crazy? Consequence of build tools only caring about --class-path I think.

4. Make some calls to SDL

The following is translated from one of the SDL examples. Note that while the C example its based on has some callbacks, in Java you need to manually implement those lifecycle bits.

Also note the uses of try/finally. One big difference between C and Java is that Java has exceptions. If you want cleanup code to always run (such as SDL_DestroyWindow) you need to account for exceptions.

import bindings.sdl.SDL_Event;
import bindings.sdl.SDL_FPoint;
import bindings.sdl.SDL_FRect;

import java.lang.foreign.Arena;

import static bindings.sdl.SDL_h.*;

public class Main {
    public static void main(String[] args) {
        try (var arena = Arena.ofConfined()) {
            SDL_SetAppMetadata(
                    arena.allocateFrom("Example Renderer Primitives"),
                    arena.allocateFrom("1.0"),
                    arena.allocateFrom("com.example.renderer-primitives")
            );

            if (!SDL_Init(SDL_INIT_VIDEO())) {
                System.err.println(
                        "Couldn't initialize SDL: "
                                + SDL_GetError().getString(0));
                return;
            }


            var windowPtr = arena.allocate(C_POINTER);
            var rendererPtr = arena.allocate(C_POINTER);
            if (!SDL_CreateWindowAndRenderer(
                    arena.allocateFrom("examples/renderer/clear"),
                    640,
                    480,
                    0,
                    windowPtr,
                    rendererPtr
            )) {
                System.err.println(
                        "Couldn't create window/renderer: "
                                + SDL_GetError().getString(0));
                return;
            }

            var window = windowPtr.get(C_POINTER, 0);
            var renderer = rendererPtr.get(C_POINTER, 0);
            try {

                int numberOfPoints = 500;
                var points = SDL_FPoint.allocateArray(numberOfPoints, arena);
                for (int i = 0; i < numberOfPoints; i++) {
                    var point = SDL_FPoint.asSlice(points, i);
                    SDL_FPoint.x(
                            point,
                            (SDL_randf() * 440.0f) + 100.0f
                    );
                    SDL_FPoint.y(
                            point,
                            (SDL_randf() * 280.0f) + 100.0f
                    );
                }

                var event = SDL_Event.allocate(arena);
                var rect = SDL_FRect.allocate(arena);

                program:
                while (true) {
                    while (SDL_PollEvent(event)) {
                        var type = SDL_Event.type(event);
                        if (type == SDL_EVENT_QUIT()) {
                            System.err.println("Quitting");
                            break program;
                        }
                    }

                    /* as you can see from this, rendering draws over whatever was drawn before it. */
                    SDL_SetRenderDrawColor(
                            renderer,
                            (byte) 33, (byte) 33, (byte) 33, (byte) SDL_ALPHA_OPAQUE()
                    );  /* dark gray, full alpha */
                    SDL_RenderClear(renderer);  /* start with a blank canvas. */

                    /* draw a filled rectangle in the middle of the canvas. */
                    SDL_SetRenderDrawColor(
                            renderer,
                            (byte) 0, (byte) 0, (byte) 255, (byte) SDL_ALPHA_OPAQUE()
                    );  /* blue, full alpha */
                    SDL_FRect.x(rect, 100);
                    SDL_FRect.y(rect, 100);
                    SDL_FRect.w(rect, 440);
                    SDL_FRect.h(rect, 280);

                    SDL_RenderFillRect(renderer, rect);

                    /* draw some points across the canvas. */
                    SDL_SetRenderDrawColor(
                            renderer,
                            (byte) 255, (byte) 0, (byte) 0, (byte) SDL_ALPHA_OPAQUE()
                    );  /* red, full alpha */
                    SDL_RenderPoints(renderer, points, numberOfPoints);

                    /* draw a unfilled rectangle in-set a little bit. */
                    SDL_SetRenderDrawColor(
                            renderer,
                            (byte) 0, (byte) 255, (byte) 0, (byte) SDL_ALPHA_OPAQUE()
                    );  /* green, full alpha */
                    SDL_FRect.x(
                            rect,
                            SDL_FRect.x(rect) + 30
                    );
                    SDL_FRect.y(
                            rect,
                            SDL_FRect.y(rect) + 30
                    );
                    SDL_FRect.w(
                            rect,
                            SDL_FRect.w(rect) - 60
                    );
                    SDL_FRect.h(
                            rect,
                            SDL_FRect.h(rect) - 60
                    );
                    SDL_RenderRect(renderer, rect);

                    /* draw two lines in an X across the whole canvas. */
                    SDL_SetRenderDrawColor(
                            renderer,
                            (byte) 255, (byte) 255, (byte) 0, (byte) SDL_ALPHA_OPAQUE()
                    );  /* yellow, full alpha */
                    SDL_RenderLine(renderer, 0, 0, 640, 480);
                    SDL_RenderLine(renderer, 0, 480, 640, 0);

                    SDL_RenderPresent(renderer);  /* put it all on the screen! */
                }

            } finally {
                SDL_DestroyRenderer(renderer);
                SDL_DestroyWindow(window);
                SDL_Quit();
            }
        }
    }
}

Run the code with the flags discussed above. You should see a window pop up with a rectangle and some dots.

Annoyances

So while this is easy from top to bottom, there are some interesting properties you should be aware of.

1. SDL_h doesn't actually have all the functions.

I assume because of limits on class size, with a library the size of SDL the jextract-generated binding code is split over multiple files. SDL_h, SDL_h_1, SDL_h_2, etc. This isn't an issue normally since you can just add more static imports, but it can be an issue for binary compatibility. If you end up directly accessing the static properties of SDL_h_2 you might be in for a bad surprise if those symbols end up in a different class file when you next update.

It's not a problem when the generated binding code is just part of your build, but it is an issue if you wanted to make a stable sdl artifact to share with other people.

2. The generated Java code is per-platform.

Java doesn't actually have a C api - it has a "foreign function and memory" api. This means that the descriptions of native memory layouts include platform specific padding. jextract uses clang to figure out what the memory layouts for structs are and dumps those as part of its generated code.

This means that if you want to use jextract generated code across different platforms you need to either make distinct artifacts per-platform or handle things dynamically at runtime.

An easy way to get access to different platforms (which you should think of as target triples - (operating system, architecture, libc)) is via GitHub actions. Exercise for the reader on how to integrate that, though I have one example.

3. It would be work to share this

Distributing a library which uses C code over the usual Java library channels like Maven Central can be annoying. The standard build tools (maven, gradle, etc.) do not provide an easy way to set java.library.path or get dependencies that should go there. What most people have historically done is to embed one or multiple shared libraries in a jar and extract them at runtime.2

This is some bunk, but kinda the lowest common denominator approach. You can read some of that nonsense here.

The binary compatibility issues alluded to above would also be something to consider.

All that is to say - don't go just publishing libraries for every C library you want bindings for. At least at the moment there are some caveats and the ecosystem isn't super ready for it. If you are going to do that, provide a layer on top of the auto-generated jextract code. Have some value that is worth the time investment.

4. You need to care about memory lifetimes

In the example code there is only one Arena and it lives for the entire program. If you have a need to allocate memory for a shorter timespan you'll need to make at least one allocator. While this is better than directly dealing with malloc/free it is still more responsibility than you usually have in Java code.

It can be tempting to build APIs that use foreign memory the same as if they did not, but unless you have a really clear seam behind which to hide the memory shenanigans it is probably going to backfire.

Conclusion

If you want to make a game or game engine, this should be a good start. You can pivot to more C oriented SDL tutorials and translate the calls needed to open windows, render graphics, etc.

If you want to do something similar for another native library, this should serve as a decent starting point.

You can find the code for this demo here.

1: Sidenote, but I think flix could kill Scala as the JVM language for Haskell-likin-types. Other than implicits + some basic type level stuff: Java has already taken or will take most of what made Scala interesting. "Stratified Negation," "Lattice Semantics," and "Associated Effects" intimidate me in a way I haven't felt in a while.

2: There is one method of distribution that doesn't have these problems: .jmod. JMods have a special place for shared libraries and will merge them in to a JDK it's linked with. I'm investigating the possibilities of that on the side.


<- Index