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.
curl -s "https://get.sdkman.io" | bash
)sdk install jextract
)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
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.
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
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.
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(
.allocateFrom("Example Renderer Primitives"),
arena.allocateFrom("1.0"),
arena.allocateFrom("com.example.renderer-primitives")
arena);
if (!SDL_Init(SDL_INIT_VIDEO())) {
System.err.println(
"Couldn't initialize SDL: "
+ SDL_GetError().getString(0));
return;
}
= arena.allocate(C_POINTER);
var windowPtr = arena.allocate(C_POINTER);
var rendererPtr if (!SDL_CreateWindowAndRenderer(
.allocateFrom("examples/renderer/clear"),
arena640,
480,
0,
,
windowPtr
rendererPtr)) {
System.err.println(
"Couldn't create window/renderer: "
+ SDL_GetError().getString(0));
return;
}
= windowPtr.get(C_POINTER, 0);
var window = rendererPtr.get(C_POINTER, 0);
var renderer try {
int numberOfPoints = 500;
= SDL_FPoint.allocateArray(numberOfPoints, arena);
var points for (int i = 0; i < numberOfPoints; i++) {
= SDL_FPoint.asSlice(points, i);
var point .x(
SDL_FPoint,
point(SDL_randf() * 440.0f) + 100.0f
);
.y(
SDL_FPoint,
point(SDL_randf() * 280.0f) + 100.0f
);
}
= SDL_Event.allocate(arena);
var event = SDL_FRect.allocate(arena);
var rect
:
programwhile (true) {
while (SDL_PollEvent(event)) {
= SDL_Event.type(event);
var type 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 */
.x(rect, 100);
SDL_FRect.y(rect, 100);
SDL_FRect.w(rect, 440);
SDL_FRect.h(rect, 280);
SDL_FRect
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 */
.x(
SDL_FRect,
rect.x(rect) + 30
SDL_FRect);
.y(
SDL_FRect,
rect.y(rect) + 30
SDL_FRect);
.w(
SDL_FRect,
rect.w(rect) - 60
SDL_FRect);
.h(
SDL_FRect,
rect.h(rect) - 60
SDL_FRect);
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.
So while this is easy from top to bottom, there are some interesting properties you should be aware of.
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.
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.
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.
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.
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.