From 3918124153b0434a5ae78161bd44984e57ae2e3a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 10 Apr 2026 14:28:52 +0200 Subject: [PATCH] WIP native osx backend --- rendercanvas/MetalIOSurfaceHelper.m | 90 +++++ rendercanvas/_native_osx.py | 387 +++++++++++++++++++++ rendercanvas/libMetalIOSurfaceHelper.dylib | Bin 0 -> 85792 bytes 3 files changed, 477 insertions(+) create mode 100644 rendercanvas/MetalIOSurfaceHelper.m create mode 100644 rendercanvas/_native_osx.py create mode 100755 rendercanvas/libMetalIOSurfaceHelper.dylib diff --git a/rendercanvas/MetalIOSurfaceHelper.m b/rendercanvas/MetalIOSurfaceHelper.m new file mode 100644 index 00000000..684b7931 --- /dev/null +++ b/rendercanvas/MetalIOSurfaceHelper.m @@ -0,0 +1,90 @@ +/* + +clang -dynamiclib -fobjc-arc \ + -framework Foundation -framework Metal -framework IOSurface \ + -arch x86_64 -arch arm64 \ + -mmacosx-version-min=10.13 \ + -o libMetalIOSurfaceHelper.dylib MetalIOSurfaceHelper.m + + maybe add '-framework MetalKit' too + +*/ +#import +#import +#import + +@interface MetalIOSurfaceHelper : NSObject +@property (nonatomic, readonly) id device; +@property (nonatomic, readonly) id texture; + +- (instancetype)initWithWidth:(NSUInteger)width + height:(NSUInteger)height; + +- (void *)baseAddress; +- (NSUInteger)bytesPerRow; +@end + + +@implementation MetalIOSurfaceHelper { + IOSurfaceRef _surf; +} + +- (instancetype)initWithWidth:(NSUInteger)width + height:(NSUInteger)height +{ + if ((self = [super init])) { + // Create Metal device + _device = MTLCreateSystemDefaultDevice(); + if (!_device) { + NSLog(@"❌ Failed to create Metal device"); + return nil; + } + + // Create IOSurface properties + NSDictionary *props = @{ + (id)kIOSurfaceWidth: @(width), + (id)kIOSurfaceHeight: @(height), + (id)kIOSurfaceBytesPerElement: @(4), + (id)kIOSurfacePixelFormat: @(0x42475241) // 'BGRA' + }; + + _surf = IOSurfaceCreate((__bridge CFDictionaryRef)props); + if (!_surf) { + NSLog(@"❌ Failed to create IOSurface"); + return nil; + } + + // Create texture from IOSurface + MTLTextureDescriptor *desc = + [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm + width:width + height:height + mipmapped:NO]; + desc.storageMode = MTLStorageModeShared; + + _texture = [_device newTextureWithDescriptor:desc iosurface:_surf plane:0]; + if (!_texture) { + NSLog(@"❌ Failed to create MTLTexture from IOSurface"); + CFRelease(_surf); + return nil; + } + } + return self; +} + +- (void *)baseAddress { + return IOSurfaceGetBaseAddress(_surf); +} + +- (NSUInteger)bytesPerRow { + return IOSurfaceGetBytesPerRow(_surf); +} + +- (void)dealloc { + if (_surf) { + CFRelease(_surf); + _surf = NULL; + } +} + +@end \ No newline at end of file diff --git a/rendercanvas/_native_osx.py b/rendercanvas/_native_osx.py new file mode 100644 index 00000000..811901ba --- /dev/null +++ b/rendercanvas/_native_osx.py @@ -0,0 +1,387 @@ +""" + +This uses rubicon to load objc classes, mainly for Cocoa (MacOS's +windowing API). For rendering to bitmap we follow the super-fast +approach of creating an IOSurface that is wrapped in a Metal texture. +On Apple silicon, the memory for that texture is in RAM, so we can write +directly to the texture, no copies. This approach is used by e.g. video +viewers. + +However, because Python (via Rubicon) cannot pass or create pure C-level +IOSurfaceRef pointers, which are required by Metal’s +newTextureWithDescriptor:iosurface:plane; Rubicon can only work with +actual Objective-C objects. + +Therefore this code relies on a mirco objc libary that is shipped along +in rendercanvas. This dylib handles the C-level IOSurface creation and +wraps it in a proper MTLTexture that Python can safely use. +""" + +# ruff: noqa - for now + +import os +import time +import ctypes + +import numpy as np # TODO: no numpy? +from rubicon.objc import ObjCClass, objc_method, ObjCInstance +from rubicon.objc.runtime import load_library +from rubicon.objc.types import NSRect, NSPoint, NSSize + +from .base import BaseCanvasGroup, BaseRenderCanvas +from .asyncio import loop + +load_library("AppKit") +# load_framework( +# ctypes.util.find_library("MetalKit"), +# framework_name="MetalKit" +# ) + +__all__ = ["RenderCanvas", "CocoaRenderCanvas", "loop"] + + +NSApplication = ObjCClass("NSApplication") +NSWindow = ObjCClass("NSWindow") +NSObject = ObjCClass("NSObject") + + +# Application and window +app = NSApplication.sharedApplication + +app.setActivationPolicy_(0) # NSApplicationActivationPolicyRegular +app.activateIgnoringOtherApps_(True) + +SHADER = """ +#include +using namespace metal; + +struct VertexOut { + float4 position [[position]]; + float2 texcoord; +}; + +vertex VertexOut vertex_main(uint vertexID [[vertex_id]]) { + float2 pos[3] = { + float2(-1.0, -1.0), + float2( 3.0, -1.0), + float2(-1.0, 3.0) + }; + VertexOut out; + out.position = float4(pos[vertexID], 0.0, 1.0); + out.texcoord = (pos[vertexID] * float2(1.0, -1.0) + 1.0) * 0.5; + return out; +} + +fragment float4 fragment_main(VertexOut in [[stage_in]], + texture2d tex [[texture(0)]], + sampler samp [[sampler(0)]]) { + constexpr sampler linearSampler(address::clamp_to_edge, filter::linear); + float4 color = tex.sample(linearSampler, in.texcoord); + return color; +} +""" + + +class MetalRenderer(NSObject): + @objc_method + def initWithDevice_(self, device): # -> ctypes.c_void_p: + self.init() + # self = ObjCInstance(send_message(self, "init")) + if self is None: + return None + self.device = device + self.queue = device.newCommandQueue() + + self.texture = None + + # --- Metal shader code --- + + options = {} + error_placeholder = None # ctypes.c_void_p() + library = device.newLibraryWithSource_options_error_( + SHADER, None, error_placeholder + ) + if not library: + print("Shader compile failed:", error_placeholder) + return self + + vertex_func = library.newFunctionWithName_("vertex_main") + frag_func = library.newFunctionWithName_("fragment_main") + + desc = ObjCClass("MTLRenderPipelineDescriptor").alloc().init() + desc.vertexFunction = vertex_func + desc.fragmentFunction = frag_func + desc.colorAttachments.objectAtIndexedSubscript_( + 0 + ).pixelFormat = 80 # BGRA8Unorm + + self.pipeline = device.newRenderPipelineStateWithDescriptor_error_( + desc, error_placeholder + ) + if not self.pipeline: + print("Pipeline creation failed:", error_placeholder) + return self + + @objc_method + def setTexture_(self, texture): + self.texture = texture + + @objc_method + def drawInMTKView_(self, view): + drawable = view.currentDrawable + if drawable is None: + return + + passdesc = ObjCClass("MTLRenderPassDescriptor").renderPassDescriptor() + passdesc.colorAttachments.objectAtIndexedSubscript_( + 0 + ).texture = drawable.texture + passdesc.colorAttachments.objectAtIndexedSubscript_(0).loadAction = 2 # Clear + passdesc.colorAttachments.objectAtIndexedSubscript_(0).storeAction = 1 # Store + passdesc.colorAttachments.objectAtIndexedSubscript_( + 0 + ).clearColor = view.clearColor + + cmd_buf = self.queue.commandBuffer() + enc = cmd_buf.renderCommandEncoderWithDescriptor_(passdesc) + + enc.setRenderPipelineState_(self.pipeline) + enc.setFragmentTexture_atIndex_(self.texture, 0) + + enc.setRenderPipelineState_(self.pipeline) + enc.drawPrimitives_vertexStart_vertexCount_(3, 0, 3) + enc.endEncoding() + cmd_buf.presentDrawable_(drawable) + cmd_buf.commit() + # cmd_buf.waitUntilCompleted() + + @objc_method + def mtkView_drawableSizeWillChange_(self, view, newSize): + # Update if needed + # print("resize", newSize) + pass + + +class CocoaCanvasGroup(BaseCanvasGroup): + pass + + +class CocoaRenderCanvas(BaseRenderCanvas): + """A native canvas for OSX using Cocoa.""" + + _rc_canvas_group = CocoaCanvasGroup(loop) + + _helper_dylib = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Define window style + NSWindowStyleMaskTitled = 1 << 0 + NSBackingStoreBuffered = 2 + NSTitledWindowMask = 1 << 0 + NSClosableWindowMask = 1 << 1 + NSMiniaturizableWindowMask = 1 << 2 + NSResizableWindowMask = 1 << 3 + style_mask = ( + NSTitledWindowMask + | NSClosableWindowMask + | NSMiniaturizableWindowMask + | NSResizableWindowMask + ) + + rect = NSRect(NSPoint(100, 100), NSSize(100, 100)) + self._window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( + rect, style_mask, NSBackingStoreBuffered, False + ) + self._window.makeKeyAndOrderFront_(None) # focus + self._keep_notified_of_resizes() + + # Start out with no bitmap present enabled. Will do that jit when needed. + self._texture = None + self._renderer = None + + self._final_canvas_init() + + def _keep_notified_of_resizes(self): + def update_size(): + pixel_ratio = self._window.screen.backingScaleFactor + size = self._window.frame.size + pwidth = int(size.width * pixel_ratio) + pheight = int(size.height * pixel_ratio) + print("new size", pwidth, pheight) + self._size_info.set_physical_size(pwidth, pheight, pixel_ratio) + + class WindowDelegate(NSObject): + @objc_method + def windowDidResize_(self, notification): + update_size() + + @objc_method + def windowDidChangeBackingProperties_(self, notification): + update_size() + + self._delegate1 = WindowDelegate.alloc().init() + self._delegate2 = self._window.setDelegate_(self._delegate1) + update_size() + + def _setup_for_bitmap_present(self): + # Create the helper first, because it also creates the device + self._create_surface_texture_array(1, 1) + + # # Create more components + self._create_renderer() + self._create_mtk_view() + + # TODO: move the _create_renderer, _create_mtk_view, and maybe _create_surface_texture_array to functions or a helper class + # -> keep bitmap/metal logic more separate + + def _create_renderer(self): + # Instantiate the renderer and set as delegate + # renderer = MetalRenderer.alloc().init() + self._renderer = MetalRenderer.alloc().initWithDevice_(self._device) + + def _create_mtk_view(self): + # Create MTKView + MTKView = ObjCClass("MTKView") + mtk_view = MTKView.alloc().initWithFrame_device_( + self._window.contentView.bounds, self._device + ) + # Ensure we can write into the view's texture (not framebuffer-only) if we want to upload into it + try: + mtk_view.setFramebufferOnly_(False) + except Exception: + pass # Not all setups require this call; ignore if not present + + # TODO: use RGBA + # TODO: support yuv420p or something + # Choose pixel format. We'll assume BGRA8Unorm for Metal. + mtk_view.setColorPixelFormat_(80) # MTLPixelFormatBGRA8Unorm + + self._window.setContentView_(mtk_view) + mtk_view.setDelegate_(self._renderer) + + # ?? vsync? + # mtk_view.enableSetNeedsDisplay = False + # mtk_view.preferredFramesPerSecond = 60 + + self._mtkView = mtk_view + + def _create_surface_texture_array(self, width, height): + print("creating new texture") + if CocoaRenderCanvas._helper_dylib is None: + # Load our helper dylib to make its objc class available to rubicon. + CocoaRenderCanvas._helper_dylib = ctypes.CDLL( + os.path.abspath( + os.path.join(__file__, "..", "libMetalIOSurfaceHelper.dylib") + ) + ) + + # Init our little helper helper + MetalIOSurfaceHelper = ObjCClass("MetalIOSurfaceHelper") + self._helper = MetalIOSurfaceHelper.alloc().initWithWidth_height_(width, height) + self._texture = self._helper.texture + self._device = self._helper.device + + # Access CPU memory + base_addr = self._helper.baseAddress() + bytes_per_row = self._helper.bytesPerRow() + + # Map array onto the shared memory + total_bytes = bytes_per_row * height + array_type = ctypes.c_uint8 * total_bytes + pixel_buf = array_type.from_address(base_addr.value) + self._texture_array = np.frombuffer( + pixel_buf, dtype=np.uint8, count=total_bytes + ) + self._texture_array.shape = height, -1 + self._texture_array = self._texture_array[:, : width * 4] + self._texture_array.shape = height, width, 4 + + if self._renderer is not None: + self._renderer.setTexture(self._texture) + + def _rc_gui_poll(self): + for mode in ("kCFRunLoopDefaultMode", "NSEventTrackingRunLoopMode"): + # Drain events (non-blocking). If we don't drain events, the animation becomes jaggy when e.g. the mouse moves. + # TODO: this seems to work, but lets check what happens here + while True: + event = app.nextEventMatchingMask_untilDate_inMode_dequeue_( + 0xFFFFFFFFFFFFFFFF, # all events + None, # don't wait + mode, + True, + ) + if event: + app.sendEvent_(event) + else: + break + + def _rc_get_present_info(self, present_methods): + # Select method + the_method = present_methods[0] + + # Apply + if the_method == "screen": + return { + "method": "screen", + "platform": "cocoa", + "window": self._window.ptr.value, + } + elif the_method == "bitmap": + return { + "method": "bitmap", + "formats": ["rgba-u8"], + } + else: + return None # raises error + + def _rc_request_draw(self): + # For this backend there's no need to wait (a direct call is allowed). + self._time_to_draw() + + def _rc_request_paint(self): + # Schedule a new paint (a direct call is not allowed). + loop = self._rc_canvas_group.get_loop() + loop.call_soon(self._paint) + + def _rc_force_paint(self): + self._paint() + app.updateWindows() # does not work? + + def _paint(self): + self._time_to_paint() + # app.updateWindows() # I also want to update one + + def _rc_present_bitmap(self, *, data, format, **kwargs): + if not self._texture: + self._setup_for_bitmap_present() + if data.shape[:2] != self._texture_array.shape[:2]: + self._create_surface_texture_array(data.shape[1], data.shape[0]) + + self._texture_array[:] = data + # print("present bitmap", data.shape) + # self._window.contentView.setNeedsDisplay_(True) + # self._mtkView.setNeedsDisplay_(True) + + def _rc_set_logical_size(self, width, height): + frame = self._window.frame + frame.size.width = width + frame.size.height = height + self._window.setFrame_display_animate_(frame, True, False) + + def _rc_close(self): + pass + + def _rc_get_closed(self): + return False + + def _rc_set_title(self, title): + self._window.setTitle_(title) + + def _rc_set_cursor(self, cursor): + pass + + +# Make available under a common name +RenderCanvas = CocoaRenderCanvas diff --git a/rendercanvas/libMetalIOSurfaceHelper.dylib b/rendercanvas/libMetalIOSurfaceHelper.dylib new file mode 100755 index 0000000000000000000000000000000000000000..cc7457fb75f73510891272556676663ac1d51be1 GIT binary patch literal 85792 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 Ir$+I=0LRyN4gdfE literal 0 HcmV?d00001