diff --git a/build.gradle b/build.gradle index a6cb65f..74bb5db 100644 --- a/build.gradle +++ b/build.gradle @@ -94,6 +94,8 @@ dependencies { // and the committed binaries ship as-is. def gpuVulkanShaderSrc = file('src/main/shaders/gpuvulkan') def gpuVulkanResourceOut = file('src/main/resources/com/gpuvulkan') +def gpuVulkanNativeSrc = file('src/main/native/rlmtl.m') +def gpuVulkanNativeOut = new File(gpuVulkanResourceOut, 'librlmtl.dylib') def glslangValidator = providers.environmentVariable('GLSLANG').getOrElse('glslangValidator') def toolOnPath = { tool -> @@ -167,8 +169,45 @@ def checkGpuVulkanShadersFresh = tasks.register('checkGpuVulkanShadersFresh') { } } +def compileMacOSMetalHelper = tasks.register('compileMacOSMetalHelper') { + group = 'build' + description = 'Regenerate the committed macOS CAMetalLayer/JAWT helper (needs clang, macOS)' + + inputs.file(gpuVulkanNativeSrc) + outputs.file(gpuVulkanNativeOut) + onlyIf { + System.getProperty('os.name', '').toLowerCase().contains('mac') && toolOnPath('clang') + } + + doLast { + gpuVulkanResourceOut.mkdirs() + def javaHome = System.getenv('JAVA_HOME') ?: System.getProperty('java.home') + exec { + commandLine 'clang', + '-fno-objc-arc', + '-shared', + '-dynamiclib', + '-mmacosx-version-min=11.0', + '-arch', 'arm64', + '-arch', 'x86_64', + '-framework', 'Cocoa', + '-framework', 'QuartzCore', + '-framework', 'Metal', + '-I', "${javaHome}/include", + '-I', "${javaHome}/include/darwin", + '-Wl,-undefined,dynamic_lookup', + gpuVulkanNativeSrc.absolutePath, + '-o', gpuVulkanNativeOut.absolutePath + } + exec { + commandLine 'codesign', '--force', '--sign', '-', gpuVulkanNativeOut.absolutePath + } + } +} + processResources { dependsOn compileGpuVulkanShaders + dependsOn compileMacOSMetalHelper dependsOn checkGpuVulkanShadersFresh from(projectDir) { include 'runelite-plugin.properties' diff --git a/src/main/java/com/gpuvulkan/GpuVulkanPlugin.java b/src/main/java/com/gpuvulkan/GpuVulkanPlugin.java index 45b28c3..5c24c4e 100644 --- a/src/main/java/com/gpuvulkan/GpuVulkanPlugin.java +++ b/src/main/java/com/gpuvulkan/GpuVulkanPlugin.java @@ -169,10 +169,6 @@ public void hotkeyPressed() @Override protected void startUp() { - if (isMacOS()) - { - return; - } log.info("Starting GPU (Vulkan)"); shuttingDown = false; keyManager.registerKeyListener(inFlightClipHotkeyListener); @@ -220,12 +216,6 @@ protected void startUp() }); } - private static boolean isMacOS() - { - String os = System.getProperty("os.name", "").toLowerCase(); - return os.contains("mac") || os.contains("darwin"); - } - private static boolean isVulkanLoaderAvailable() { try diff --git a/src/main/java/com/gpuvulkan/platform/MacOSMetalHelper.java b/src/main/java/com/gpuvulkan/platform/MacOSMetalHelper.java index e991064..64d14f6 100644 --- a/src/main/java/com/gpuvulkan/platform/MacOSMetalHelper.java +++ b/src/main/java/com/gpuvulkan/platform/MacOSMetalHelper.java @@ -1,32 +1,203 @@ /* * Copyright (c) 2026, Dennis de Vulder * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ package com.gpuvulkan; import java.awt.Canvas; +import java.awt.GraphicsConfiguration; +import java.awt.geom.AffineTransform; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import lombok.extern.slf4j.Slf4j; /** - * Plugin Hub build does not ship macOS native presentation support. + * macOS-only native bridge (rlmtl.m / librlmtl.dylib) that attaches a + * CAMetalLayer to the AWT Canvas via JAWT. Pure-Java JAWT bindings don't + * work on macOS. */ +@Slf4j final class MacOSMetalHelper { + private static volatile boolean loaded; + private static volatile double layerScale = 1.0; + private MacOSMetalHelper() {} - static void detachMetalLayer() {} + private static synchronized void ensureLoaded() + { + if (loaded) + { + return; + } + // Force-load libjawt first: the helper links JAWT symbols with + // dynamic_lookup, and without it the first call SIGSEGVs at PC=0. + try + { + System.loadLibrary("jawt"); + } + catch (UnsatisfiedLinkError e) + { + log.debug("System.loadLibrary(\"jawt\") said: {}", e.toString()); + } + + String resourcePath = "/com/gpuvulkan/librlmtl.dylib"; + try (InputStream in = MacOSMetalHelper.class.getResourceAsStream(resourcePath)) + { + if (in == null) + { + throw new RuntimeException("librlmtl.dylib not on classpath at " + + resourcePath + " — rebuild the jar on macOS with ./gradlew shadowJar " + + "so compileMacOSMetalHelper can package the native helper."); + } + File temp = File.createTempFile("librlmtl", ".dylib"); + temp.deleteOnExit(); + Files.copy(in, temp.toPath(), StandardCopyOption.REPLACE_EXISTING); + System.load(temp.getAbsolutePath()); + loaded = true; + log.info("Loaded librlmtl.dylib from {}", temp.getAbsolutePath()); + } + catch (IOException e) + { + throw new RuntimeException("Failed to extract/load librlmtl.dylib", e); + } + } + + private static native long nAttachMetalLayer(Canvas canvas, boolean vsync, + int initialWidthPoints, int initialHeightPoints, double scale); + private static native void nDetachMetalLayer(); + private static native void nResizeMetalLayer(int widthPoints, int heightPoints, double scale); + private static native long[] nNextDrawable(); + private static native void nPresentDrawable(long drawable, long mtlQueue); + private static native void nRetainObject(long ptr); + private static native void nReleaseObject(long ptr); + + static long attachMetalLayer(Canvas canvas, boolean vsync) + { + ensureLoaded(); + // Pass the Canvas size: LWAWT defers layout, and an unsized layer + // doesn't render until the first resize. + int w = Math.max(canvas.getWidth(), 1); + int h = Math.max(canvas.getHeight(), 1); + layerScale = canvasScale(canvas); + long ptr = nAttachMetalLayer(canvas, vsync, w, h, layerScale); + if (ptr == 0L) + { + throw new RuntimeException("nAttachMetalLayer returned NULL — " + + "JAWT_GetAWT rejected every version, or the canvas was " + + "not in a JAWT-lockable state"); + } + return ptr; + } + + static void detachMetalLayer() + { + if (loaded) + { + nDetachMetalLayer(); + } + } + + static void resizeMetalLayer(Canvas canvas) + { + long token = ResizeTrace.start("metal.resizeCanvas", + canvas == null ? "canvas=null" : canvas.getWidth() + "x" + canvas.getHeight()); + layerScale = canvasScale(canvas); + try + { + resizeMetalLayerSize(canvas.getWidth(), canvas.getHeight()); + } + finally + { + ResizeTrace.end(token, "metal.resizeCanvas"); + } + } - static void resizeMetalLayer(Canvas canvas) {} + static void resizeMetalLayerSize(int widthPoints, int heightPoints) + { + if (loaded) + { + int width = Math.max(widthPoints, 1); + int height = Math.max(heightPoints, 1); + long start = System.nanoTime(); + nResizeMetalLayer(width, height, layerScale); + ResizeTrace.slow("metal.resizeLayer", System.nanoTime() - start, + width + "x" + height + " scale=" + layerScale); + } + } - static void resizeMetalLayerSize(int width, int height) {} + private static double canvasScale(Canvas canvas) + { + if (canvas == null) + { + return 1.0; + } + GraphicsConfiguration graphicsConfiguration = canvas.getGraphicsConfiguration(); + if (graphicsConfiguration == null) + { + return 1.0; + } + AffineTransform transform = graphicsConfiguration.getDefaultTransform(); + return Math.max(transform.getScaleX(), transform.getScaleY()); + } + /** Returns [drawable, MTLTexture, width, height] or null on timeout. The drawable + * MUST reach {@link #presentDrawable} even on failure paths, or nextDrawable stalls. */ static long[] nextDrawable() { - return null; + ensureLoaded(); + long start = System.nanoTime(); + long[] drawable = nNextDrawable(); + long elapsed = System.nanoTime() - start; + ResizeTrace.slow("metal.nextDrawable", elapsed, + drawable == null ? "null" : drawable[2] + "x" + drawable[3]); + return drawable; } - static void presentDrawable(long drawable, long mtlQueue) {} + /** Schedules [drawable present] on mtlQueue and drops nextDrawable's retain. + * Must run AFTER the render's vkQueueSubmit on the same queue — Metal's + * in-queue ordering is what sequences the present behind the render. */ + static void presentDrawable(long drawable, long mtlQueue) + { + long start = System.nanoTime(); + nPresentDrawable(drawable, mtlQueue); + ResizeTrace.slow("metal.presentDrawable", System.nanoTime() - start, + "drawable=0x" + Long.toHexString(drawable)); + } - static void retainObject(long ptr) {} + /** {@code [obj retain]} on an arbitrary Objective-C handle (typically + * an {@code MTLTexture}). Pair with {@link #releaseObject}. */ + static void retainObject(long ptr) + { + nRetainObject(ptr); + } - static void releaseObject(long ptr) {} + /** {@code [obj release]}; pair with {@link #retainObject}. */ + static void releaseObject(long ptr) + { + nReleaseObject(ptr); + } } diff --git a/src/main/java/com/gpuvulkan/platform/MacOSPlatformSurface.java b/src/main/java/com/gpuvulkan/platform/MacOSPlatformSurface.java index 9ddee81..bc907e5 100644 --- a/src/main/java/com/gpuvulkan/platform/MacOSPlatformSurface.java +++ b/src/main/java/com/gpuvulkan/platform/MacOSPlatformSurface.java @@ -1,28 +1,106 @@ /* * Copyright (c) 2026, Dennis de Vulder * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ package com.gpuvulkan; import java.awt.Canvas; +import java.nio.LongBuffer; +import lombok.extern.slf4j.Slf4j; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.EXTMetalSurface; +import org.lwjgl.vulkan.KHRPortabilityEnumeration; +import org.lwjgl.vulkan.KHRSurface; +import org.lwjgl.vulkan.VkMetalSurfaceCreateInfoEXT; + +import static org.lwjgl.system.MemoryStack.stackPush; +import static org.lwjgl.vulkan.VK13.VK_SUCCESS; /** - * macOS is not shipped in the Plugin Hub build because it needs native layer - * integration that Plugin Hub policy does not allow. + * macOS surface via {@code VK_EXT_metal_surface}: {@link MacOSMetalHelper} + * attaches the CAMetalLayer, vkCreateMetalSurfaceEXT wraps the pointer. */ +@Slf4j final class MacOSPlatformSurface implements PlatformSurface { - MacOSPlatformSurface(boolean vsync) {} + private final boolean vsync; + + MacOSPlatformSurface(boolean vsync) + { + this.vsync = vsync; + } + @Override public String[] requiredInstanceExtensions() { - return new String[0]; + return new String[] + { + KHRSurface.VK_KHR_SURFACE_EXTENSION_NAME, + EXTMetalSurface.VK_EXT_METAL_SURFACE_EXTENSION_NAME, + KHRPortabilityEnumeration.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME, + }; } @Override public long createSurface(VulkanInstance instance, Canvas canvas) { - throw new UnsupportedOperationException("GPU (Vulkan) macOS support is not available in this build"); + // Hold the AWT tree lock across JAWT — the peer can mutate between + // lock and GetDrawingSurfaceInfo and crash in jni_GetObjectField. + long caMetalLayer; + synchronized (canvas.getTreeLock()) + { + if (!canvas.isValid()) + { + throw new RuntimeException("Canvas not valid at JAWT-attach time"); + } + caMetalLayer = MacOSMetalHelper.attachMetalLayer(canvas, vsync); + } + log.info("Attached CAMetalLayer 0x{}", Long.toHexString(caMetalLayer)); + + // Synthetic COMPONENT_RESIZED so AWT lays out the CALayer now. + // AWT-only dispatch — setting CALayer.bounds directly trips MoltenVK. + javax.swing.SwingUtilities.invokeLater(() -> + { + canvas.dispatchEvent(new java.awt.event.ComponentEvent( + canvas, java.awt.event.ComponentEvent.COMPONENT_RESIZED)); + }); + + try (MemoryStack stack = stackPush()) + { + VkMetalSurfaceCreateInfoEXT info = VkMetalSurfaceCreateInfoEXT.calloc(stack) + .sType$Default(); + // LWJGL 3.3.6's pLayer(PointerBuffer) writes the buffer's address, + // not the contained pointer — write the field offset directly. + MemoryUtil.memPutAddress( + info.address() + VkMetalSurfaceCreateInfoEXT.PLAYER, + caMetalLayer); + + LongBuffer pSurface = stack.mallocLong(1); + Vk.check("vkCreateMetalSurfaceEXT", EXTMetalSurface.vkCreateMetalSurfaceEXT( + instance.handle(), info, null, pSurface)); + return pSurface.get(0); + } } } diff --git a/src/main/java/com/gpuvulkan/platform/PlatformSurface.java b/src/main/java/com/gpuvulkan/platform/PlatformSurface.java index 536c21a..8e507fb 100644 --- a/src/main/java/com/gpuvulkan/platform/PlatformSurface.java +++ b/src/main/java/com/gpuvulkan/platform/PlatformSurface.java @@ -40,7 +40,8 @@ interface PlatformSurface * ownership; destroys via {@code vkDestroySurfaceKHR}. */ long createSurface(VulkanInstance instance, Canvas canvas); - /** Platforms control vsync via swapchain present modes. */ + /** {@code vsync} is macOS-only (CAMetalLayer.displaySyncEnabled); other + * platforms control vsync via swapchain present modes. */ static PlatformSurface current(boolean vsync) { String os = System.getProperty("os.name", "").toLowerCase(); @@ -52,6 +53,10 @@ static PlatformSurface current(boolean vsync) { return new Win32PlatformSurface(); } + if (os.contains("mac") || os.contains("darwin")) + { + return new MacOSPlatformSurface(vsync); + } throw new UnsupportedOperationException( "GPU (Vulkan) plugin: unsupported OS \"" + System.getProperty("os.name") + "\""); } diff --git a/src/main/native/rlmtl.m b/src/main/native/rlmtl.m new file mode 100644 index 0000000..97338e9 --- /dev/null +++ b/src/main/native/rlmtl.m @@ -0,0 +1,461 @@ +/* + * rlmtl — minimal native bridge that attaches a CAMetalLayer to the AWT + * canvas's region on macOS so MoltenVK has a valid surface to render into. + * + * Approach: walk JAWT to reach the canvas's CALayer + the NSWindow's + * contentView (AWTView, the single NSView holding all of RuneLite's UI on + * macOS JDK). Assign a CAMetalLayer through JAWT_SurfaceLayers so AWT owns + * the layer placement. Drive AppKit's compose tick with a main-run-loop + * NSTimer + [CATransaction flush] so MoltenVK's async drawable presents + * actually reach the screen. + * + * Build (vkdev does this automatically): + * clang -fno-objc-arc -shared -dynamiclib \ + * -framework Cocoa -framework QuartzCore -framework Metal \ + * -I "$JAVA_HOME/include" -I "$JAVA_HOME/include/darwin" \ + * -Wl,-undefined,dynamic_lookup \ + * rlmtl.m -o librlmtl.dylib + */ + +#import +#import +#import +#import +#import + +static CAMetalLayer* gMetalLayer = nil; +static NSTimer* gFlushTimer = nil; + +/* JAWT_SurfaceLayers integration: the protocol has a `layer` SETTER — + * the application assigns its CALayer; AWT then incorporates that layer + * into the Canvas's CALayer hierarchy and handles positioning + resize + * automatically. This is the documented pattern (same one rlawt uses for + * OpenGL). Reading `layer` before setting returns nil, which is what + * tripped the earlier "use surfaceLayers.layer" attempt. + * + * With this approach our CAMetalLayer is geometrically scoped to the AWT + * Canvas widget's bounds — anything outside the Canvas (RuneLite plugin + * panels rendered as Swing siblings, the title bar) is not covered. We + * don't need a parent-layer reference to track resizes; AWT does it. The + * display link only needs to keep CAMetalLayer.drawableSize in sync with + * the layer's bounds (CAMetalLayer doesn't auto-update drawableSize), and + * to flush CATransaction so MoltenVK's async-presented drawables actually + * reach the compositor each refresh. */ +@interface DisplayLinkTicker : NSObject +- (void)displayLinkTick:(NSTimer*)timer; +@end + +@implementation DisplayLinkTicker +- (void)displayLinkTick:(NSTimer*)timer +{ + CAMetalLayer* mtl = gMetalLayer; + if (mtl) { + /* Keep drawableSize in sync with the layer's actual on-screen + * size. AWT moves/resizes our layer (we set it via + * surfaceLayers.layer = mtl), so layer.bounds reflects the + * current AWT Canvas size in points. Multiply by contentsScale + * for the pixel-resolution backing texture. */ + CGFloat scale = mtl.contentsScale; + if (scale <= 0) scale = 1; + CGSize wanted = CGSizeMake( + mtl.bounds.size.width * scale, + mtl.bounds.size.height * scale); + if (wanted.width > 0 && wanted.height > 0 + && !CGSizeEqualToSize(wanted, mtl.drawableSize)) + { + [CATransaction begin]; + [CATransaction setDisableActions: YES]; + mtl.drawableSize = wanted; + [CATransaction commit]; + } + } + /* Force any pending implicit CATransaction (from MoltenVK's + * [drawable present] earlier this frame) to commit so the layer + * actually shows the latest drawable on this refresh. */ + [CATransaction flush]; +} +@end + +static DisplayLinkTicker* gTicker = nil; + +static void rlmtlFlushCoreAnimationAsync(void) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + [CATransaction flush]; + }); +} + +static void rlmtlStopDisplayLink(void) +{ + if (gFlushTimer) { + [gFlushTimer invalidate]; + [gFlushTimer release]; + gFlushTimer = nil; + } +} + +static void rlmtlDetachMetalLayer(void) +{ + rlmtlStopDisplayLink(); + CAMetalLayer* layer = gMetalLayer; + gMetalLayer = nil; + DisplayLinkTicker* ticker = gTicker; + gTicker = nil; + if (!layer && !ticker) { + return; + } + void (^cleanup)(void) = ^{ + if (layer) { + /* AWT may still hold a reference via surfaceLayers.layer. + * removeFromSuperlayer detaches us from whatever AWT-managed + * parent layer we ended up under; our explicit -release drops + * the retain we held in nAttachMetalLayer. AWT's retain (set + * via the property assignment) is dropped when AWT tears down + * the surfaceLayers object — usually on canvas dispose. */ + [layer removeFromSuperlayer]; + [layer release]; + } + if (ticker) { + [ticker release]; + } + }; + if ([NSThread isMainThread]) { + cleanup(); + } else { + dispatch_sync(dispatch_get_main_queue(), cleanup); + } +} + +JNIEXPORT void JNICALL +Java_com_gpuvulkan_MacOSMetalHelper_nDetachMetalLayer( + JNIEnv* env, jclass cls) +{ + rlmtlDetachMetalLayer(); +} + +JNIEXPORT void JNICALL +Java_com_gpuvulkan_MacOSMetalHelper_nResizeMetalLayer( + JNIEnv* env, jclass cls, jint widthPoints, jint heightPoints, jdouble scaleHint) +{ + CAMetalLayer* layer = gMetalLayer; + if (!layer) { + return; + } + int w = widthPoints > 0 ? widthPoints : 1; + int h = heightPoints > 0 ? heightPoints : 1; + void (^resize)(void) = ^{ + CGFloat scale = scaleHint > 0 ? (CGFloat) scaleHint : layer.contentsScale; + if (scale <= 0) { + scale = [[NSScreen mainScreen] backingScaleFactor]; + if (scale <= 0) scale = 1; + } + layer.contentsScale = scale; + + [CATransaction begin]; + [CATransaction setDisableActions: YES]; + layer.contentsScale = scale; + CGSize bounds = layer.bounds.size; + CGFloat drawableWidth = bounds.width > 0 ? bounds.width : (CGFloat) w; + CGFloat drawableHeight = bounds.height > 0 ? bounds.height : (CGFloat) h; + layer.drawableSize = CGSizeMake(drawableWidth * scale, drawableHeight * scale); + [CATransaction commit]; + [CATransaction flush]; + }; + if ([NSThread isMainThread]) { + resize(); + } else { + dispatch_async(dispatch_get_main_queue(), resize); + } +} + +/* + * Retain/release an arbitrary Objective-C object pointer (used for keeping + * MTLTexture handles alive while MetalDrawableSet caches VkImages wrapping + * them — see MetalDrawableSet.java for why). + * + * The CAMetalDrawable's MTLTexture is owned by the drawable. Once the + * drawable's retain count drops to zero (typically after the present + * MTLCommandBuffer completes asynchronously), the texture is released and + * any VkImage we built around it goes dangling. CAMetalLayer's drawable + * pool may then hand back a drawable whose `texture` pointer reuses the + * freed slot — our cache lookup by pointer thinks it's a hit, returns the + * stale VkImage, and MoltenVK crashes inside command encoding the next + * time it dereferences the dead MTLTexture. + * + * Workaround: pin the MTLTexture with our own retain for as long as we + * keep the VkImage cached. Drop the retain when the cache entry is + * evicted (canvas resize) or the renderer is closed. + */ +JNIEXPORT void JNICALL +Java_com_gpuvulkan_MacOSMetalHelper_nRetainObject( + JNIEnv* env, jclass cls, jlong ptr) +{ + id obj = (id)(uintptr_t) ptr; + if (obj) [obj retain]; +} + +JNIEXPORT void JNICALL +Java_com_gpuvulkan_MacOSMetalHelper_nReleaseObject( + JNIEnv* env, jclass cls, jlong ptr) +{ + id obj = (id)(uintptr_t) ptr; + if (obj) [obj release]; +} + +/* + * Custom present path — bypasses MoltenVK's vkQueuePresentKHR entirely. + * + * The idea: MoltenVK's swapchain + present is one of the few code paths we + * can't see into. Sync validation is silent on the symptom, the geometry + * renders correctly, but a "one layer covering/uncovering" flicker persists + * tied to zoom level. The likeliest remaining suspect is the timing of the + * internal [drawable present] call MoltenVK schedules in response to + * vkQueuePresentKHR — its async present queue can hand a drawable to the + * compositor at a point that doesn't agree with where our render commands + * land in the Metal command stream. + * + * By acquiring the CAMetalDrawable directly from our CAMetalLayer, importing + * its MTLTexture as a VkImage on the Java side (via VK_EXT_metal_objects), + * rendering into that VkImage, and then presenting it via our OWN tiny + * MTLCommandBuffer ordered after the render submit, we get full ordering + * control. No async present queue between us and the compositor. + * + * Returns the drawable + its texture handles packed into a Java long[]: + * [0] = (uintptr_t)id (retained — caller must + * eventually release via Present) + * [1] = (uintptr_t)id + * [2] = (jlong)drawable.texture.width (pixels) + * [3] = (jlong)drawable.texture.height (pixels) + * Returns NULL if the layer is unavailable or nextDrawable blocks past the + * 1-second internal timeout. + */ +JNIEXPORT jlongArray JNICALL +Java_com_gpuvulkan_MacOSMetalHelper_nNextDrawable( + JNIEnv* env, jclass cls) +{ + CAMetalLayer* layer = gMetalLayer; + if (!layer) { + return NULL; + } + + /* @autoreleasepool is MANDATORY here. The JVM's JNI threads don't have + * a top-level autorelease pool, so every autoreleased Objective-C object + * that AppKit/Metal create during -[CAMetalLayer nextDrawable] (and the + * deeper compositor calls it makes) would leak forever — including big + * IOSurfaces and MoltenVK-internal Metal state — until MoltenVK trips + * on its own debris ~50 frames in and crashes inside its compositor + * code. Wrap every JNI entry that calls into AppKit/Metal in a pool. */ + __block jlongArray result = NULL; + @autoreleasepool { + /* nextDrawable can block waiting for a drawable to become available + * (CAMetalLayer.maximumDrawableCount defaults to 3 on macOS). Call + * on the calling JNI thread (the RuneLite render thread). Not main + * thread — we don't want to stall the AppKit event loop. */ + id drawable = [[layer nextDrawable] retain]; + if (!drawable) { + return NULL; + } + + id tex = drawable.texture; + if (!tex) { + [drawable release]; + return NULL; + } + + jlong values[4]; + values[0] = (jlong)(uintptr_t) drawable; + values[1] = (jlong)(uintptr_t) tex; + values[2] = (jlong) tex.width; + values[3] = (jlong) tex.height; + + result = (*env)->NewLongArray(env, 4); + if (!result) { + [drawable release]; + return NULL; + } + (*env)->SetLongArrayRegion(env, result, 0, 4, values); + } + return result; +} + +/* + * Schedules [drawable present] on a tiny MTLCommandBuffer built from the + * supplied MTLCommandQueue, then releases the retain we took in + * nNextDrawable. The Vulkan render submit must have committed (and ideally + * completed; the caller waits on its fence/semaphore) BEFORE this call — + * otherwise we'd present a half-rendered drawable. + */ +JNIEXPORT void JNICALL +Java_com_gpuvulkan_MacOSMetalHelper_nPresentDrawable( + JNIEnv* env, jclass cls, jlong drawableHandle, jlong mtlQueueHandle) +{ + id drawable = (id) (uintptr_t) drawableHandle; + id queue = (id) (uintptr_t) mtlQueueHandle; + if (!drawable || !queue) { + if (drawable) [drawable release]; + return; + } + + /* @autoreleasepool is MANDATORY (see comment in nNextDrawable). The + * MTLCommandBuffer returned by -[MTLCommandQueue commandBuffer] is + * autoreleased, as are the assorted Metal objects MoltenVK and the + * compositor create during the present path. Without a pool every + * frame these accumulate and either OOM or trip MoltenVK's internal + * resource tracking — manifesting as a SIGSEGV inside libMoltenVK + * a few dozen frames in. */ + @autoreleasepool { + id cmd = [queue commandBuffer]; + [cmd presentDrawable: drawable]; + [cmd addCompletedHandler:^(id completed) { + (void) completed; + rlmtlFlushCoreAnimationAsync(); + }]; + [cmd commit]; + /* Do NOT wait — Metal handles the present asynchronously on the + * compositor side. Ordering with our Vulkan render is guaranteed + * by both submits running on the same MTLCommandQueue. */ + [drawable release]; + } +} + +JNIEXPORT jlong JNICALL +Java_com_gpuvulkan_MacOSMetalHelper_nAttachMetalLayer( + JNIEnv* env, jclass cls, jobject canvas, jboolean vsync, + jint initialWidthPoints, jint initialHeightPoints, jdouble scaleHint) +{ + rlmtlDetachMetalLayer(); + + JAWT awt; + const jint versions[] = { + JAWT_VERSION_9 | JAWT_MACOSX_USE_CALAYER, + JAWT_VERSION_1_7 | JAWT_MACOSX_USE_CALAYER, + JAWT_VERSION_1_4 | JAWT_MACOSX_USE_CALAYER, + }; + jboolean got = JNI_FALSE; + for (int i = 0; i < 3; i++) { + awt.version = versions[i]; + if (JAWT_GetAWT(env, &awt)) { + got = JNI_TRUE; + break; + } + } + if (!got) { + return 0; + } + + JAWT_DrawingSurface* ds = awt.GetDrawingSurface(env, canvas); + if (!ds) { + return 0; + } + + jint lock = ds->Lock(ds); + if ((lock & JAWT_LOCK_ERROR) != 0) { + awt.FreeDrawingSurface(ds); + return 0; + } + + /* Retain surfaceLayers under the JAWT lock, then release the lock + * before AppKit work. surfaceLayers is the JAWT bridge object whose + * `layer` property we'll assign our CAMetalLayer to on the main + * thread. */ + id surfaceLayers = nil; + JAWT_DrawingSurfaceInfo* dsi = ds->GetDrawingSurfaceInfo(ds); + if (dsi) { + id sl = + (id) dsi->platformInfo; + if (sl) { + surfaceLayers = (id) [(NSObject*) sl retain]; + } + ds->FreeDrawingSurfaceInfo(dsi); + } + ds->Unlock(ds); + awt.FreeDrawingSurface(ds); + + if (!surfaceLayers) { + return 0; + } + + __block CAMetalLayer* metalLayer = nil; + __block CGFloat scale = scaleHint > 0 ? (CGFloat) scaleHint : 1; + dispatch_sync(dispatch_get_main_queue(), ^{ + if (scale <= 0) { + scale = [[NSScreen mainScreen] backingScaleFactor]; + if (scale <= 0) scale = 1; + } + + metalLayer = [[CAMetalLayer alloc] init]; + id device = MTLCreateSystemDefaultDevice(); + if (device) { + metalLayer.device = device; + [device release]; + } + metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm; + metalLayer.allowsNextDrawableTimeout = YES; + metalLayer.contentsScale = scale; + metalLayer.anchorPoint = CGPointZero; + metalLayer.position = CGPointZero; + metalLayer.frame = CGRectMake(0, 0, + initialWidthPoints > 0 ? initialWidthPoints : 1, + initialHeightPoints > 0 ? initialHeightPoints : 1); + /* drawableSize MUST start small here because vkCreateSwapchainKHR + * later reads surfaceCapabilities.currentExtent, which MoltenVK + * derives from drawableSize. A full-size value here makes + * MoltenVK's internal swapchain claim drawables from the same + * 3-deep pool we acquire from directly — they collide on the + * first scene render and SIGSEGV inside libMoltenVK at a fixed + * offset. 1×1 keeps the swapchain a tiny no-op (we never use + * it on macOS anyway; our custom-present path bypasses it). */ + metalLayer.drawableSize = CGSizeMake(1, 1); + /* displaySyncEnabled hardcoded to NO. The vsync param is currently + * ignored on macOS — see the comment below. The compositor still + * presents at most one drawable per display refresh, so visible + * FPS is capped at refresh rate (120 Hz on ProMotion) regardless. + * + * Why we don't honor vsync=YES yet: setting displaySyncEnabled=YES + * combined with our custom-present path (where the [drawable + * present] runs on a separate MTLCommandBuffer from the Vulkan + * render submit) reliably crashes MoltenVK on the first scene + * render at a fixed offset (libMoltenVK+0x47a78). Reverting to + * NO is the known-good configuration. If the user really wants a + * true vsync mode (battery-saver, no tearing), the right + * approach is probably to route present back through + * vkQueuePresentKHR conditionally — that's a bigger change, + * deferred. */ + (void) vsync; + metalLayer.displaySyncEnabled = NO; + surfaceLayers.layer = metalLayer; + }); + (void) initialWidthPoints; + (void) initialHeightPoints; + + /* AWT retains the metal layer via its property setter (the setter is + * declared `retain`). Our retain on surfaceLayers was only to keep + * the bridge object alive across the dispatch_sync — drop it now. */ + [(NSObject*) surfaceLayers release]; + + if (!metalLayer) { + return 0; + } + + NSLog(@"rlmtl: attached CAMetalLayer %p via JAWT_SurfaceLayers @ %.1fx scale", + metalLayer, scale); + + gMetalLayer = metalLayer; + + /* AppKit doesn't tick a CAMetalLayer's compose loop on its own when + * MoltenVK presents drawables asynchronously — without the periodic + * flush in the tick handler, drawables get queued but never displayed. + * NSTimer keeps this bridge compatible with pre-Sonoma macOS; exact + * display cadence is not required because Vulkan still drives frames. */ + dispatch_sync(dispatch_get_main_queue(), ^{ + gTicker = [[DisplayLinkTicker alloc] init]; + gFlushTimer = [[NSTimer timerWithTimeInterval: (1.0 / 120.0) + target: gTicker + selector: @selector(displayLinkTick:) + userInfo: nil + repeats: YES] retain]; + [[NSRunLoop mainRunLoop] addTimer: gFlushTimer forMode: NSRunLoopCommonModes]; + }); + + return (jlong)(uintptr_t) metalLayer; +} diff --git a/src/main/resources/com/gpuvulkan/librlmtl.dylib b/src/main/resources/com/gpuvulkan/librlmtl.dylib new file mode 100755 index 0000000..d5ed4b7 Binary files /dev/null and b/src/main/resources/com/gpuvulkan/librlmtl.dylib differ