From a22815e51eba99d77fc9643b81435bdf1c4fb123 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 11 Nov 2025 14:34:57 +0100 Subject: [PATCH 1/6] Use buffer mapping instead of queue.read_texture --- rendercanvas/MetalIOSurfaceHelper.m | 88 +++++ rendercanvas/_native_osx.py | 392 +++++++++++++++++++++ rendercanvas/contexts/wgpucontext.py | 105 +++++- rendercanvas/libMetalIOSurfaceHelper.dylib | Bin 0 -> 85792 bytes rendercanvas/stub.py | 2 +- 5 files changed, 572 insertions(+), 15 deletions(-) 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 0000000..00daa40 --- /dev/null +++ b/rendercanvas/MetalIOSurfaceHelper.m @@ -0,0 +1,88 @@ +/* + +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 + +*/ +#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 0000000..df74392 --- /dev/null +++ b/rendercanvas/_native_osx.py @@ -0,0 +1,392 @@ +""" + +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 .base import BaseCanvasGroup, BaseRenderCanvas +from .asyncio import loop + + +__all__ = ["RenderCanvas", "CocoaRenderCanvas", "loop"] + + +NSApplication = ObjCClass("NSApplication") +NSWindow = ObjCClass("NSWindow") +NSObject = ObjCClass("NSObject") + + +# Application and window +app = NSApplication.sharedApplication + + +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, present_method=None, **kwargs): + super().__init__(*args, **kwargs) + self._is_minimized = False + self._present_method = present_method + + # 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 = (100, 100), (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._set_size_info(pwidth, pheight, pixel_ratio) + + class WindowDelegate(NSObject): + @objc_method + def windowDidResize_(self, notification): + update_size() + + @objc_method + def windowDidChangeBackingProperties_(self, notification): + update_size() + + delegate = WindowDelegate.alloc().init() + self._window.setDelegate_(delegate) + 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 _paint(self): + self._draw_frame_and_present() + # app.updateWindows() # I also want to update one + + def _rc_get_present_methods(self): + methods = { + "bitmap": {"formats": ["rgba-u8"]}, + "screen": {"platform": "cocoa", "window": self._window.ptr.value}, + } + if self._present_method: + methods = { + key: val for key, val in methods.items() if key == self._present_method + } + return methods + + def _rc_request_draw(self): + if not self._is_minimized: + loop = self._rc_canvas_group.get_loop() + loop.call_soon(self._paint) + + def _rc_force_draw(self): + self._paint() + + 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 + + +if __name__ == "__main__": + win = Window() + + frame_index = 0 + while True: + frame_index += 1 + # Drain events (non-blocking) + event = app.nextEventMatchingMask_untilDate_inMode_dequeue_( + 0xFFFFFFFFFFFFFFFF, # all events + None, # don't wait + "kCFRunLoopDefaultMode", + True, + ) + if event: + app.sendEvent_(event) + + update_texture(frame_index) + + app.updateWindows() + + # your own update / render logic here + # (Metal drawInMTKView_ will get called by MTKView’s internal timer) + time.sleep(1 / 120) # e.g. 120 Hz pacing diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index a27b02f..0739f4a 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -1,3 +1,4 @@ +import time from typing import Sequence from .basecontext import BaseContext @@ -289,7 +290,12 @@ def _rc_present(self) -> None: self._drop_texture() return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} + _copy_buffer = None, 0 + _extra_stride = -1 + def _get_bitmap(self): + import wgpu + texture = self._texture device = texture._device @@ -309,19 +315,91 @@ def _get_bitmap(self): f"Image present unsupported texture format bitdepth {format}." ) - data = device.queue.read_texture( - { - "texture": texture, - "mip_level": 0, - "origin": (0, 0, 0), - }, - { - "offset": 0, - "bytes_per_row": bytes_per_pixel * size[0], - "rows_per_image": size[1], - }, - size, - ) + source = { + "texture": texture, + "mip_level": 0, + "origin": (0, 0, 0), + } + + ori_stride = bytes_per_pixel * size[0] + extra_stride = (256 - ori_stride % 256) % 256 + full_stride = ori_stride + extra_stride + + data_length = full_stride * size[1] * size[2] + + # Create temporary buffer + copy_buffer, time_since_size_ok = self._copy_buffer + if copy_buffer is None: + pass # No buffer + elif copy_buffer.size < data_length: + copy_buffer = None # Buffer too small + elif copy_buffer.size < data_length * 4: + self._copy_buffer = copy_buffer, time.perf_counter() # Bufer size ok + elif time.perf_counter() - time_since_size_ok > 5.0: + copy_buffer = None # Too large too long + if copy_buffer is None: + buffer_size = data_length + buffer_size += (4096 - buffer_size % 4096) % 4096 + buf_usage = wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ + copy_buffer = device._create_buffer( + "copy-buffer", buffer_size, buf_usage, False + ) + self._copy_buffer = copy_buffer, time.perf_counter() + + destination = { + "buffer": copy_buffer, + "offset": 0, + "bytes_per_row": full_stride, # or WGPU_COPY_STRIDE_UNDEFINED ? + "rows_per_image": size[1], + } + + # Copy data to temp buffer + encoder = device.create_command_encoder() + encoder.copy_texture_to_buffer(source, destination, size) + command_buffer = encoder.finish() + device.queue.submit([command_buffer]) + + awaitable = copy_buffer.map_async("READ_NOSYNC", 0, data_length) + + # Download from mappable buffer + # Because we use `copy=False``, we *must* copy the data. + if copy_buffer.map_state == "pending": + awaitable.sync_wait() + mapped_data = copy_buffer.read_mapped(copy=False) + + data_length2 = ori_stride * size[1] * size[2] + + if extra_stride is not self._extra_stride: + self._extra_stride = extra_stride + print("extra stride", extra_stride) + + # Copy the data + if extra_stride: + # Copy per row + data = memoryview(bytearray(data_length2)).cast(mapped_data.format) + i_start = 0 + for i in range(size[1] * size[2]): + row = mapped_data[i * full_stride : i * full_stride + ori_stride] + data[i_start : i_start + ori_stride] = row + i_start += ori_stride + else: + # Copy as a whole + data = memoryview(bytearray(mapped_data)).cast(mapped_data.format) + + # Alternative copy solution using Numpy. + # I expected this to be faster, but does not really seem to be. Seems not worth it + # since we technically don't depend on Numpy. Leaving here for reference. + # import numpy as np + # mapped_data = np.asarray(mapped_data)[:data_length] + # data = np.empty(data_length2, dtype=mapped_data.dtype) + # mapped_data.shape = -1, full_stride + # data.shape = -1, ori_stride + # data[:] = mapped_data[:, :ori_stride] + # data.shape = -1 + # data = memoryview(data) + + # Since we use read_mapped(copy=False), we must unmap it *after* we've copied the data. + copy_buffer.unmap() # Derive struct dtype from wgpu texture format memoryview_type = "B" @@ -339,7 +417,6 @@ def _get_bitmap(self): # Represent as memory object to avoid numpy dependency # Equivalent: np.frombuffer(data, np.uint8).reshape(size[1], size[0], nchannels) - return data.cast(memoryview_type, (size[1], size[0], nchannels)) def _rc_close(self): diff --git a/rendercanvas/libMetalIOSurfaceHelper.dylib b/rendercanvas/libMetalIOSurfaceHelper.dylib new file mode 100755 index 0000000000000000000000000000000000000000..73f6a4a50e114621fe3c301ea4558bc99eaad9a3 GIT binary patch literal 85792 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 Date: Tue, 11 Nov 2025 15:24:56 +0100 Subject: [PATCH 2/6] bitmap async by lagging one frame --- rendercanvas/contexts/bitmapcontext.py | 2 +- rendercanvas/contexts/wgpucontext.py | 35 ++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 868d67f..2c19953 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -34,7 +34,7 @@ def set_bitmap(self, bitmap): """Set the rendered bitmap image. Call this in the draw event. The bitmap must be an object that can be - conveted to a memoryview, like a numpy array. It must represent a 2D + converted to a memoryview, like a numpy array. It must represent a 2D image in either grayscale or rgba format, with uint8 values """ diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 0739f4a..d9a7360 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -286,14 +286,19 @@ def _rc_present(self) -> None: if not self._texture: return {"method": "skip"} - bitmap = self._get_bitmap() + # TODO: in some cases, like offscreen backend, we don't want to skip the first frame! + bitmap = self._get_bitmap_stage2() + self._get_bitmap_stage1() self._drop_texture() + if bitmap is None: + return {"method": "skip"} return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} _copy_buffer = None, 0 _extra_stride = -1 + _pending_bitmap_info = None - def _get_bitmap(self): + def _get_bitmap_stage1(self): import wgpu texture = self._texture @@ -361,6 +366,32 @@ def _get_bitmap(self): awaitable = copy_buffer.map_async("READ_NOSYNC", 0, data_length) + self._pending_bitmap_info = ( + awaitable, + copy_buffer, + size, + ori_stride, + extra_stride, + full_stride, + format, + nchannels, + ) + + def _get_bitmap_stage2(self): + if self._pending_bitmap_info is None: + return None + + ( + awaitable, + copy_buffer, + size, + ori_stride, + extra_stride, + full_stride, + format, + nchannels, + ) = self._pending_bitmap_info + # Download from mappable buffer # Because we use `copy=False``, we *must* copy the data. if copy_buffer.map_state == "pending": From e8456fe028adc064d9af5fc46d5b4f062c017414 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 12 Nov 2025 10:48:02 +0100 Subject: [PATCH 3/6] delete files I accidentally added --- rendercanvas/MetalIOSurfaceHelper.m | 88 ----- rendercanvas/_native_osx.py | 392 --------------------- rendercanvas/libMetalIOSurfaceHelper.dylib | Bin 85792 -> 0 bytes 3 files changed, 480 deletions(-) delete mode 100644 rendercanvas/MetalIOSurfaceHelper.m delete mode 100644 rendercanvas/_native_osx.py delete mode 100755 rendercanvas/libMetalIOSurfaceHelper.dylib diff --git a/rendercanvas/MetalIOSurfaceHelper.m b/rendercanvas/MetalIOSurfaceHelper.m deleted file mode 100644 index 00daa40..0000000 --- a/rendercanvas/MetalIOSurfaceHelper.m +++ /dev/null @@ -1,88 +0,0 @@ -/* - -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 - -*/ -#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 deleted file mode 100644 index df74392..0000000 --- a/rendercanvas/_native_osx.py +++ /dev/null @@ -1,392 +0,0 @@ -""" - -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 .base import BaseCanvasGroup, BaseRenderCanvas -from .asyncio import loop - - -__all__ = ["RenderCanvas", "CocoaRenderCanvas", "loop"] - - -NSApplication = ObjCClass("NSApplication") -NSWindow = ObjCClass("NSWindow") -NSObject = ObjCClass("NSObject") - - -# Application and window -app = NSApplication.sharedApplication - - -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, present_method=None, **kwargs): - super().__init__(*args, **kwargs) - self._is_minimized = False - self._present_method = present_method - - # 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 = (100, 100), (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._set_size_info(pwidth, pheight, pixel_ratio) - - class WindowDelegate(NSObject): - @objc_method - def windowDidResize_(self, notification): - update_size() - - @objc_method - def windowDidChangeBackingProperties_(self, notification): - update_size() - - delegate = WindowDelegate.alloc().init() - self._window.setDelegate_(delegate) - 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 _paint(self): - self._draw_frame_and_present() - # app.updateWindows() # I also want to update one - - def _rc_get_present_methods(self): - methods = { - "bitmap": {"formats": ["rgba-u8"]}, - "screen": {"platform": "cocoa", "window": self._window.ptr.value}, - } - if self._present_method: - methods = { - key: val for key, val in methods.items() if key == self._present_method - } - return methods - - def _rc_request_draw(self): - if not self._is_minimized: - loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._paint) - - def _rc_force_draw(self): - self._paint() - - 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 - - -if __name__ == "__main__": - win = Window() - - frame_index = 0 - while True: - frame_index += 1 - # Drain events (non-blocking) - event = app.nextEventMatchingMask_untilDate_inMode_dequeue_( - 0xFFFFFFFFFFFFFFFF, # all events - None, # don't wait - "kCFRunLoopDefaultMode", - True, - ) - if event: - app.sendEvent_(event) - - update_texture(frame_index) - - app.updateWindows() - - # your own update / render logic here - # (Metal drawInMTKView_ will get called by MTKView’s internal timer) - time.sleep(1 / 120) # e.g. 120 Hz pacing diff --git a/rendercanvas/libMetalIOSurfaceHelper.dylib b/rendercanvas/libMetalIOSurfaceHelper.dylib deleted file mode 100755 index 73f6a4a50e114621fe3c301ea4558bc99eaad9a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85792 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 Date: Fri, 14 Nov 2025 11:22:07 +0100 Subject: [PATCH 4/6] add note --- rendercanvas/contexts/wgpucontext.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 7a9a798..6d3876f 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -371,6 +371,8 @@ def _get_bitmap_stage1(self): command_buffer = encoder.finish() device.queue.submit([command_buffer]) + # Note: the buffer.map_async() method by default also does a flush, to hide a bug in wgpu-core (https://github.com/gfx-rs/wgpu/issues/5173). + # That bug does not affect this use-case, so we use a special (undocumented :/) map-mode to prevent wgpu-py from doing its sync thing. awaitable = copy_buffer.map_async("READ_NOSYNC", 0, data_length) self._pending_bitmap_info = ( From cccdb425708b8dc7735d3832ed4259e4ce76370f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 14 Nov 2025 15:48:50 +0100 Subject: [PATCH 5/6] Refactor to implement basic ring buffer --- rendercanvas/contexts/wgpucontext.py | 249 +++++++++++++++------------ 1 file changed, 140 insertions(+), 109 deletions(-) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 6d3876f..8968125 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -90,6 +90,10 @@ def configure( elif not isinstance(usage, int): raise TypeError("Texture usage must be str or int") + # Store usage flags, now that we have the wgpu namespace + self._our_texture_usage = wgpu.TextureUsage.COPY_SRC + self._our_buffer_usage = wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ + # Build config dict config = { "device": device, @@ -100,7 +104,6 @@ def configure( # "tone_mapping": tone_mapping, "alpha_mode": alpha_mode, } - # Let subclass finnish the configuration, then store the config self._configure(config) self._config = config @@ -190,6 +193,22 @@ def __init__(self, present_info: dict): # The last used texture self._texture = None + # A ring-buffer to download the rendered images to the CPU/RAM. The + # image is first copied from the texture to an available copy-buffer. + # This is very fast (which is why we don't have a ring of textures). + # Mapping the buffers to RAM takes time, and we want to wait for this + # asynchronously. + # + # I feel that using just one buffer is sufficient. Adding more costs + # memory, and does not necessarily improve the FPS. It can actually + # strain the GPU more, because it would be busy mapping multiple buffers + # at once. I leave the ring-mechanism in-place for now, so we can + # experiment with it. + self._downloaders = [None] # Put as many None's as you want buffers + + # Extra vars for the downloading + self._pending_bitmap_info = None + def _get_capabilities(self): """Get dict of capabilities and cache the result.""" @@ -272,8 +291,6 @@ def _get_current_texture(self): # Right now we return the existing texture, so user can retrieve it in different render passes that write to the same frame. if self._texture is None: - import wgpu - width, height = self.physical_size width, height = max(width, 1), max(height, 1) @@ -284,7 +301,7 @@ def _get_current_texture(self): label="present", size=(width, height, 1), format=self._config["format"], - usage=self._config["usage"] | wgpu.TextureUsage.COPY_SRC, + usage=self._config["usage"] | self._our_texture_usage, ) return self._texture @@ -294,26 +311,60 @@ def _rc_present(self) -> None: return {"method": "skip"} # TODO: in some cases, like offscreen backend, we don't want to skip the first frame! - bitmap = self._get_bitmap_stage2() - self._get_bitmap_stage1() + + # Get bitmap from oldest downloader + bitmap = None + downloader = self._downloaders.pop(0) + try: + if downloader is not None: + bitmap = downloader.get_bitmap() + finally: + self._downloaders.append(downloader) + + # Select new downloader + downloader = self._downloaders[-1] + if downloader is None: + downloader = self._downloaders[-1] = ImageDownloader( + self._config["device"], self._our_buffer_usage + ) + downloader.initiate_download(self._texture) + self._drop_texture() if bitmap is None: return {"method": "skip"} - return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} + else: + return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} - _copy_buffer = None, 0 - _extra_stride = -1 - _pending_bitmap_info = None + def _rc_close(self): + self._drop_texture() - def _get_bitmap_stage1(self): - import wgpu - texture = self._texture - device = texture._device +class ImageDownloader: + """A helper class that wraps a copy-buffer to async-download an image from a texture.""" + + def __init__(self, device, buffer_usage): + self._device = device + self._buffer_usage = buffer_usage + self._buffer = None + self._time = 0 + + def initiate_download(self, texture): + # TODO: assert not waiting + + self._parse_texture_metadata(texture) + nbytes = self._padded_stride * self._texture_size[1] + self._ensure_size(nbytes) + self._copy_texture(texture) + + # Note: the buffer.map_async() method by default also does a flush, to hide a bug in wgpu-core (https://github.com/gfx-rs/wgpu/issues/5173). + # That bug does not affect this use-case, so we use a special (undocumented :/) map-mode to prevent wgpu-py from doing its sync thing. + self._awaitable = self._buffer.map_async("READ_NOSYNC", 0, nbytes) + def _parse_texture_metadata(self, texture): size = texture.size format = texture.format nchannels = 4 # we expect rgba or bgra + if not format.startswith(("rgba", "bgra")): raise RuntimeError(f"Image present unsupported texture format {format}.") if "8" in format: @@ -327,101 +378,94 @@ def _get_bitmap_stage1(self): f"Image present unsupported texture format bitdepth {format}." ) - source = { - "texture": texture, - "mip_level": 0, - "origin": (0, 0, 0), - } + memoryview_type = "B" + if "float" in format: + memoryview_type = "e" if "16" in format else "f" + else: + if "32" in format: + memoryview_type = "I" + elif "16" in format: + memoryview_type = "H" + else: + memoryview_type = "B" + if "sint" in format: + memoryview_type = memoryview_type.lower() - ori_stride = bytes_per_pixel * size[0] - extra_stride = (256 - ori_stride % 256) % 256 - full_stride = ori_stride + extra_stride + plain_stride = bytes_per_pixel * size[0] + extra_stride = (256 - plain_stride % 256) % 256 + padded_stride = plain_stride + extra_stride - data_length = full_stride * size[1] * size[2] + self._memoryview_type = memoryview_type + self._nchannels = nchannels + self._plain_stride = plain_stride + self._padded_stride = padded_stride + self._texture_size = size - # Create temporary buffer - copy_buffer, time_since_size_ok = self._copy_buffer - if copy_buffer is None: + def _ensure_size(self, required_size): + # Get buffer and decide whether we can still use it + buffer = self._buffer + if buffer is None: pass # No buffer - elif copy_buffer.size < data_length: - copy_buffer = None # Buffer too small - elif copy_buffer.size < data_length * 4: - self._copy_buffer = copy_buffer, time.perf_counter() # Bufer size ok - elif time.perf_counter() - time_since_size_ok > 5.0: - copy_buffer = None # Too large too long - if copy_buffer is None: - buffer_size = data_length + elif required_size > buffer.size: + buffer = None # Buffer too small + elif required_size < 0.25 * buffer.size: + buffer = None # Buffer too large + elif required_size > 0.75 * buffer.size: + self._time = time.perf_counter() # Size is fine + elif time.perf_counter() - self._time > 5.0: + buffer = None # Too large too long + + # Create a new buffer if we need one + if buffer is None: + buffer_size = required_size buffer_size += (4096 - buffer_size % 4096) % 4096 - buf_usage = wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ - copy_buffer = device._create_buffer( - "copy-buffer", buffer_size, buf_usage, False + self._buffer = self._device.create_buffer( + label="copy-buffer", size=buffer_size, usage=self._buffer_usage ) - self._copy_buffer = copy_buffer, time.perf_counter() + + def _copy_texture(self, texture): + source = { + "texture": texture, + "mip_level": 0, + "origin": (0, 0, 0), + } destination = { - "buffer": copy_buffer, + "buffer": self._buffer, "offset": 0, - "bytes_per_row": full_stride, # or WGPU_COPY_STRIDE_UNDEFINED ? - "rows_per_image": size[1], + "bytes_per_row": self._padded_stride, + "rows_per_image": self._texture_size[1], } # Copy data to temp buffer - encoder = device.create_command_encoder() - encoder.copy_texture_to_buffer(source, destination, size) + encoder = self._device.create_command_encoder() + encoder.copy_texture_to_buffer(source, destination, texture.size) command_buffer = encoder.finish() - device.queue.submit([command_buffer]) + self._device.queue.submit([command_buffer]) - # Note: the buffer.map_async() method by default also does a flush, to hide a bug in wgpu-core (https://github.com/gfx-rs/wgpu/issues/5173). - # That bug does not affect this use-case, so we use a special (undocumented :/) map-mode to prevent wgpu-py from doing its sync thing. - awaitable = copy_buffer.map_async("READ_NOSYNC", 0, data_length) - - self._pending_bitmap_info = ( - awaitable, - copy_buffer, - size, - ori_stride, - extra_stride, - full_stride, - format, - nchannels, - ) - - def _get_bitmap_stage2(self): - if self._pending_bitmap_info is None: - return None - - ( - awaitable, - copy_buffer, - size, - ori_stride, - extra_stride, - full_stride, - format, - nchannels, - ) = self._pending_bitmap_info + def get_bitmap(self): + memoryview_type = self._memoryview_type + plain_stride = self._plain_stride + padded_stride = self._padded_stride + + nbytes = plain_stride * self._texture_size[1] + plain_shape = (self._texture_size[1], self._texture_size[0], self._nchannels) # Download from mappable buffer # Because we use `copy=False``, we *must* copy the data. - if copy_buffer.map_state == "pending": - awaitable.sync_wait() - mapped_data = copy_buffer.read_mapped(copy=False) - - data_length2 = ori_stride * size[1] * size[2] - - if extra_stride is not self._extra_stride: - self._extra_stride = extra_stride - print("extra stride", extra_stride) + if self._buffer.map_state == "pending": + self._awaitable.sync_wait() + mapped_data = self._buffer.read_mapped(copy=False) # Copy the data - if extra_stride: + if padded_stride > plain_stride: # Copy per row - data = memoryview(bytearray(data_length2)).cast(mapped_data.format) + data = memoryview(bytearray(nbytes)).cast(mapped_data.format) i_start = 0 - for i in range(size[1] * size[2]): - row = mapped_data[i * full_stride : i * full_stride + ori_stride] - data[i_start : i_start + ori_stride] = row - i_start += ori_stride + for i in range(self._texture_size[1]): + row = mapped_data[i * padded_stride : i * padded_stride + plain_stride] + data[i_start : i_start + plain_stride] = row + i_start += plain_stride else: # Copy as a whole data = memoryview(bytearray(mapped_data)).cast(mapped_data.format) @@ -431,33 +475,20 @@ def _get_bitmap_stage2(self): # since we technically don't depend on Numpy. Leaving here for reference. # import numpy as np # mapped_data = np.asarray(mapped_data)[:data_length] - # data = np.empty(data_length2, dtype=mapped_data.dtype) - # mapped_data.shape = -1, full_stride - # data.shape = -1, ori_stride - # data[:] = mapped_data[:, :ori_stride] + # data = np.empty(nbytes, dtype=mapped_data.dtype) + # mapped_data.shape = -1, padded_stride + # data.shape = -1, plain_stride + # data[:] = mapped_data[:, :plain_stride] # data.shape = -1 # data = memoryview(data) # Since we use read_mapped(copy=False), we must unmap it *after* we've copied the data. - copy_buffer.unmap() + self._buffer.unmap() # Derive struct dtype from wgpu texture format - memoryview_type = "B" - if "float" in format: - memoryview_type = "e" if "16" in format else "f" - else: - if "32" in format: - memoryview_type = "I" - elif "16" in format: - memoryview_type = "H" - else: - memoryview_type = "B" - if "sint" in format: - memoryview_type = memoryview_type.lower() # Represent as memory object to avoid numpy dependency - # Equivalent: np.frombuffer(data, np.uint8).reshape(size[1], size[0], nchannels) - return data.cast(memoryview_type, (size[1], size[0], nchannels)) + # Equivalent: np.frombuffer(data, np.uint8).reshape(plain_shape) + data = data.cast(memoryview_type, plain_shape) - def _rc_close(self): - self._drop_texture() + return data From 7fafc89924a2371ad7d4e8818f0fa892ba4c6d07 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 14 Nov 2025 15:58:16 +0100 Subject: [PATCH 6/6] polishing a bit --- rendercanvas/contexts/wgpucontext.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 8968125..745172f 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -90,10 +90,6 @@ def configure( elif not isinstance(usage, int): raise TypeError("Texture usage must be str or int") - # Store usage flags, now that we have the wgpu namespace - self._our_texture_usage = wgpu.TextureUsage.COPY_SRC - self._our_buffer_usage = wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ - # Build config dict config = { "device": device, @@ -206,14 +202,15 @@ def __init__(self, present_info: dict): # experiment with it. self._downloaders = [None] # Put as many None's as you want buffers - # Extra vars for the downloading - self._pending_bitmap_info = None - def _get_capabilities(self): """Get dict of capabilities and cache the result.""" import wgpu + # Store usage flags now that we have the wgpu namespace + self._our_texture_usage = wgpu.TextureUsage.COPY_SRC + self._our_buffer_usage = wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ + capabilities = {} # Query format capabilities from the info provided by the canvas @@ -280,8 +277,14 @@ def _configure(self, config: dict): f"Configure: unsupported alpha-mode: {alpha_mode} not in {cap_alpha_modes}" ) + # (re)create downloaders + self._downloaders[:] = [ + ImageDownloader(config["device"], self._our_buffer_usage) + ] + def _unconfigure(self) -> None: self._drop_texture() + self._downloaders[:] = [None for _ in self._downloaders] def _get_current_texture(self): # When the texture is active right now, we could either: @@ -316,17 +319,12 @@ def _rc_present(self) -> None: bitmap = None downloader = self._downloaders.pop(0) try: - if downloader is not None: - bitmap = downloader.get_bitmap() + bitmap = downloader.get_bitmap() finally: self._downloaders.append(downloader) # Select new downloader downloader = self._downloaders[-1] - if downloader is None: - downloader = self._downloaders[-1] = ImageDownloader( - self._config["device"], self._our_buffer_usage - ) downloader.initiate_download(self._texture) self._drop_texture() @@ -444,6 +442,9 @@ def _copy_texture(self, texture): self._device.queue.submit([command_buffer]) def get_bitmap(self): + if self._buffer is None: # todo: more explicit state tracking + return None + memoryview_type = self._memoryview_type plain_stride = self._plain_stride padded_stride = self._padded_stride