Calling ObjectiveC code from the Java Virtual Machine
But... why?
I recently went back to using a MacBook Pro, after a rather unsatisfying journey into the world of Windows notebooks. This gave me additional motivation to invest some time into getting the scenery rendering framework I am maintaining to properly work on macOS. scenery uses Vulkan for rendering, and already a while ago, I had tried to use MoltenVK in order to get it going on macOS.
Back in ye olden days, MoltenVK had a few limitations, with most of them actually coming from Metal. Array images were not supported for instance, but scenery required those. I shelved the project for the time being. Then last year, with being back on macOS, this gave new motivation to get scenery going with MoltenVK. And it turned out to be quite easy. Not a lot needed to be changed, and most of the stuff that took longer was related to the (back then relatively) new Apple Silicon ARM architecture. Headless rendering worked basically out of the box, but getting GLFW to play nice with Swing on macOS turned out to be quite impossible. As one of scenery's clients is sciview, a 3D viewer for the Fiji bioimage analysis ecosystem, where macOS is quite popular, this was a bit of an issue.
Fortunately, Kai Burjack, SWinxy, and more amazing people from the lwjgl projects had already worked on getting MoltenVK to render to an AWT/Swing Canvas within the lwjgl3-awt project. Back then, there was no support yet for Apple Silicon, and creating the native surfaces for both OpenGL and Vulkan relied on a little binary that needed to be built in addition, making e.g. CI builds a bit of a pain. As I really needed Apple Silicon support, and wanted to get rid of the binary, I started looking around how the ObjectiveC code in that little blob could maybe be replaced.
Calling ObjectiveC code with JNI and libffi
As lwjgl already provides nice facilities to use both JNI and libffi, the following code is quite brief. This would otherwise be longer, but I hope the principles nevertheless are understandable. Now, the code responsible for creating a native surface in lwjgl3-awt is the following:
try (MemoryStack stack = MemoryStack.stackPush()) {
SharedLibrary metalKit = MacOSXLibrary.create("/System/Library/Frameworks/MetalKit.framework");
SharedLibrary metal = MacOSXLibrary.create("/System/Library/Frameworks/Metal.framework");
long objc_msgSend = ObjCRuntime.getLibrary().getFunctionAddress("objc_msgSend");
metalKit.getFunctionAddress("MTKView"); // loads the MTKView class or something (required, somehow)
// id<MTLDevice> device = MTLCreateSystemDefaultDevice();
long device = JNI.invokeP(metal.getFunctionAddress("MTLCreateSystemDefaultDevice"));
PointerBuffer argumentTypes = BufferUtils.createPointerBuffer(7) // 4 arguments, one of them an array of 4 doubles
.put(0, LibFFI.ffi_type_pointer) // MTKView*
.put(1, LibFFI.ffi_type_pointer) // initWithFrame:
.put(2, LibFFI.ffi_type_double) // CGRect
.put(3, LibFFI.ffi_type_double) // CGRect
.put(4, LibFFI.ffi_type_double) // CGRect
.put(5, LibFFI.ffi_type_double) // CGRect
.put(6, LibFFI.ffi_type_pointer); // device*
// Prepare the call interface
FFICIF cif = FFICIF.malloc(stack);
int status = LibFFI.ffi_prep_cif(cif, LibFFI.FFI_DEFAULT_ABI, LibFFI.ffi_type_pointer, argumentTypes);
if (status != LibFFI.FFI_OK) {
throw new IllegalStateException("ffi_prep_cif failed: " + status);
}
// An array of pointers that point to the actual argument values.
PointerBuffer arguments = stack.mallocPointer(7);
// Storage for the actual argument values.
ByteBuffer values = stack.malloc(
Pointer.POINTER_SIZE + // MTKView*
Pointer.POINTER_SIZE + // initWithFrame*
Double.BYTES * 4 + // CGRect (4 doubles)
Pointer.POINTER_SIZE // device*
);
// MTKView *view = [MTKView alloc];
long mtkView = JNI.invokePPP(
ObjCRuntime.objc_getClass("MTKView"),
ObjCRuntime.sel_getUid("alloc"),
objc_msgSend);
// Set up the argument buffers by inserting pointers
// MTKView*
arguments.put(MemoryUtil.memAddress(values));
PointerBuffer.put(values, mtkView);
// initWithFrame*
arguments.put(MemoryUtil.memAddress(values));
PointerBuffer.put(values, ObjCRuntime.sel_getUid("initWithFrame:"));
// frame
arguments.put(MemoryUtil.memAddress(values));
values.putDouble(x);
arguments.put(MemoryUtil.memAddress(values));
values.putDouble(y);
arguments.put(MemoryUtil.memAddress(values));
values.putDouble(width);
arguments.put(MemoryUtil.memAddress(values));
values.putDouble(height);
// device*
arguments.put(MemoryUtil.memAddress(values));
values.putLong(device);
arguments.flip();
values.flip();
// [view initWithFrame:rect device:device];
// Returns itself, we just need to know if it's NULL
LongBuffer pMTKView = stack.mallocLong(1);
LibFFI.ffi_call(cif, objc_msgSend, MemoryUtil.memByteBuffer(pMTKView), arguments);
if (pMTKView.get(0) == MemoryUtil.NULL) {
throw new IllegalStateException("[MTKView initWithFrame:device:] returned null.");
}
// layer = view.layer;
long layer = JNI.invokePPP(mtkView,
ObjCRuntime.sel_getUid("layer"),
objc_msgSend);
// set layer on JAWTSurfaceLayers object
// surfaceLayers.layer = layer;
JNI.invokePPPV(platformInfo,
ObjCRuntime.sel_getUid("setLayer:"),
layer,
objc_msgSend);
// return layer;
return layer;
}
Let's walk through this step-by-step:
SharedLibrary metalKit = MacOSXLibrary.create("/System/Library/Frameworks/MetalKit.framework");
SharedLibrary metal = MacOSXLibrary.create("/System/Library/Frameworks/Metal.framework");
This uses the both the SharedLibrary and MacOSXLibrary class from lwjgl to create a handle for both frameworks. These handles can then be used to query for function addresses:
long objc_msgSend = ObjCRuntime.getLibrary().getFunctionAddress("objc_msgSend");
metalKit.getFunctionAddress("MTKView");
Both of these are important: The first one, objc_msgSend
, we will use downstream to send around ObjectiveC messages. This is basically the calling convention for ObjectiveC functions. The second call then loads the MTKView
class from metalKit. This is important to be able to send ObjectiveC messages (aka, call functions) from that class.
long device = JNI.invokeP(metal.getFunctionAddress("MTLCreateSystemDefaultDevice"));
This call creates a handle to the system's default Metal device. JNI.invokeP
is one of the convenience functions from lwjgl, and will invoke the function whose address is given to it.
Now comes the part that actually gave me some grief. In order to get a native Metal surface, the basic call that needs to be done is [[MTKView alloc] initWithFrame:CGRect]
– this is ObjectiveC speak for two calls actually:
- Calling
alloc
onMTKView
, and then - Calling
initWithFrame
on the resulting object, withCGRect
being the data structure that holds the frame's dimensions.
I was not able to realise this with JNI functions in lwjgl alone, which is why this is done via libffi. The simple reason is that ObjectiveC expects the CGRect to be passed by value, and not by reference. With some additional work with JNI, aka, implementing additional functions in lwjgl to take the additional arguments, this would also be possible with JNI, but the libffi route does not require additional native code. I first use libffi here to prepare the memory structure for the call:
// 4 arguments, one of them an array of 4 doubles
PointerBuffer argumentTypes = BufferUtils.createPointerBuffer(7)
.put(0, LibFFI.ffi_type_pointer) // MTKView*
.put(1, LibFFI.ffi_type_pointer) // initWithFrame:
.put(2, LibFFI.ffi_type_double) // CGRect
.put(3, LibFFI.ffi_type_double) // CGRect
.put(4, LibFFI.ffi_type_double) // CGRect
.put(5, LibFFI.ffi_type_double) // CGRect
.put(6, LibFFI.ffi_type_pointer); // device*
Next, I create two buffers: One to hold pointers to the arguments, and one to hold the actual values. The arguments
buffer is the one that then actually gets handed to libffi. It contains the memory addresses of the values. Of course, those could also be calculated by hand, however, I found this more readable.
// An array of pointers that point to the actual argument values.
PointerBuffer arguments = stack.mallocPointer(7);
// Storage for the actual argument values.
ByteBuffer values = stack.malloc(
Pointer.POINTER_SIZE + // MTKView*
Pointer.POINTER_SIZE + // initWithFrame*
Double.BYTES * 4 + // CGRect (4 doubles)
Pointer.POINTER_SIZE // device*
);
Then, we can perform the actual MTKView
allocation. Here, the objc_msgSend
mentioned above comes into play. What's happening behind the scenes is that objc_msgSend
is called, with the class MTKView
and the UID of the alloc
function as parameters.
// MTKView *view = [MTKView alloc];
long mtkView = JNI.invokePPP(
ObjCRuntime.objc_getClass("MTKView"),
ObjCRuntime.sel_getUid("alloc"),
objc_msgSend);
We can now set up the data in the buffers for the call to initWithFrame
:
// MTKView*
arguments.put(MemoryUtil.memAddress(values));
PointerBuffer.put(values, mtkView);
// initWithFrame*
arguments.put(MemoryUtil.memAddress(values));
PointerBuffer.put(values, ObjCRuntime.sel_getUid("initWithFrame:"));
// frame
arguments.put(MemoryUtil.memAddress(values));
values.putDouble(x);
arguments.put(MemoryUtil.memAddress(values));
values.putDouble(y);
arguments.put(MemoryUtil.memAddress(values));
values.putDouble(width);
arguments.put(MemoryUtil.memAddress(values));
values.putDouble(height);
// device*
arguments.put(MemoryUtil.memAddress(values));
values.putLong(device);
arguments.flip();
values.flip();
Finally, we can call initWithFrame
, with the arguments we have prepared:
// [view initWithFrame:rect device:device];
// Returns itself, we just need to know if it's NULL
LongBuffer pMTKView = stack.mallocLong(1);
LibFFI.ffi_call(cif, objc_msgSend, MemoryUtil.memByteBuffer(pMTKView), arguments);
if (pMTKView.get(0) == MemoryUtil.NULL) {
throw new IllegalStateException("[MTKView initWithFrame:device:] returned null.");
}
As stated in the comment above, the function will return NULL
if something went wrong, otherwise we'll get the handle to the newly-created frame.
We can then finish up by getting the layer of the MTKView
frame, which is what AWT actually needs:
// layer = view.layer;
long layer = JNI.invokePPP(mtkView,
ObjCRuntime.sel_getUid("layer"),
objc_msgSend);
// set layer on JAWTSurfaceLayers object
JNI.invokePPPV(platformInfo,
ObjCRuntime.sel_getUid("setLayer:"),
layer,
objc_msgSend);
I hope this was both useful and understandable – maybe it comes in handy for someone who also wants or needs to deal with ObjectiveC data structures and functions from within the JVM 👍